release: v0.1.6 — plugin v2, i18n instance isolation, shortcuts, toolbar, toast
feat(plugins): plugin system v2
- depends: declarative plugin dependencies with topological sort (Kahn algorithm)
- async plugins: install() returning Promise auto-await
- editor.unuse(name): uninstall individual plugins
- pluginUtils.validateConfig(schema, config): configuration validation
- plugin priority field controls install order within same dependency level
feat(i18n): instance-level locale isolation, pluralization, dynamic loading
- createInstanceI18n(): per-editor independent locale contexts
- editor.setLocale()/getLocale()/t() instance methods
- Plural rules: t('items', { count: 5 }) auto-selects one/other/few/many
- i18nUtils.loadRemote(url, locale): fetch translation packs from remote
- editor.on('localeChange') event
feat(core): toolbar & shortcut customization, context menu, toast
- editor.registerShortcut(combo, handler): custom keyboard shortcuts
- editor.configureToolbar(tools): dynamic toolbar rebuild
- editor.removeToolbarButton(action): remove single button
- editor.registerContextMenu(items): right-click context menu
- editor.toast(msg, {type, duration, animation}): toast notifications
style: toast notifications, context menu, dropdown CSS
- .me-toast with success/error/warning/info types, fade/slide animations
- .me-context-menu with items, separators, shortcut hints
test: 22 new tests covering plugin deps, unuse, shortcuts, toast, i18n plural (521 total)
chore: bump version 0.1.5 → 0.1.6 across all files, site, types
This commit is contained in:
@@ -2,9 +2,9 @@
|
||||
|
||||
> 轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用,中文优先。
|
||||
|
||||
[](https://www.npmjs.com/package/@metona-team/metona-editor)
|
||||
[](https://www.npmjs.com/package/@metona-team/metona-editor)
|
||||
[](./LICENSE)
|
||||
[](./tests)
|
||||
[](./tests)
|
||||
[](./tests)
|
||||
|
||||
---
|
||||
@@ -13,14 +13,14 @@
|
||||
|
||||
- **零依赖** — 单文件打包,无任何运行时依赖,UMD/ESM/CJS 三种格式
|
||||
- **现代化** — 原生 ES2020+ 实现,CSS 变量主题系统,响应式布局,无障碍支持
|
||||
- **易扩展** — 完整插件系统(install/destroy 生命周期),自定义工具栏按钮,预设插件开箱即用
|
||||
- **易扩展** — 完整插件系统 v2(依赖拓扑排序、异步插件、生命周期钩子、`unuse` 卸载),自定义工具栏按钮与快捷键,预设插件开箱即用
|
||||
- **易维护** — 模块化源码,JSDoc 注释完整,TypeScript 类型声明,425 个单元测试覆盖
|
||||
- **易使用** — 工厂函数 `create()` 一行接入,链式 API,中文优先文档与翻译
|
||||
- **安全** — 内置 XSS 防护(`javascript:`/`vbscript:`/`file:` 协议过滤),HTML 转义,`safeUrl` 净化
|
||||
- **内置解析器** — 自研 CommonMark 子集 + GFM 扩展(表格含列对齐、任务列表、上下标、数学公式、脚注、嵌套列表、emoji 短码等),可整体替换
|
||||
- **三模式视图** — edit / split / preview 自由切换,分屏拖拽调整比例,滚动同步,模式切换保持滚动位置
|
||||
- **主题系统** — light / dark / auto / warm 四套预设,CSS 变量定制,实例级主题隔离,外部主题跟随(syncWithElement / adoptFromParent),主题继承(extends),跟随系统主题,@media print 打印样式
|
||||
- **国际化** — zh-CN / en-US 完整翻译,RTL 支持,`Intl` 数字/货币/日期格式化
|
||||
- **国际化** — zh-CN / en-US 完整翻译,实例级语言隔离,复数规则,命名空间,动态远程加载翻译包,RTL 支持
|
||||
- **历史栈** — 撤销/重做,防抖合并(可配置延迟),可配置上限
|
||||
- **只读模式** — `readOnly` 配置 / `setReadOnly()` API,适用于预览/展示场景
|
||||
- **预设插件** — autoSave(草稿)、exportTool(导出 .md/.html)、searchReplace(Ctrl+F 查找替换)、imagePaste(粘贴图片转 base64)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@metona-team/metona-editor",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"description": "轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用。",
|
||||
"type": "module",
|
||||
"main": "dist/metona-editor.js",
|
||||
|
||||
+2
-2
@@ -313,9 +313,9 @@
|
||||
'',
|
||||
'| 名称 | 值 |',
|
||||
'| --- | --- |',
|
||||
'| 版本 | 0.1.5 |',
|
||||
'| 版本 | 0.1.6 |',
|
||||
'| 依赖 | 零 |',
|
||||
'| 测试 | 499+ |',
|
||||
'| 测试 | 521+ |',
|
||||
'',
|
||||
'## 新增语法',
|
||||
'',
|
||||
|
||||
+2
-2
@@ -175,7 +175,7 @@
|
||||
|
||||
<header class="docs-header">
|
||||
<h1><span class="grad">文档</span></h1>
|
||||
<p class="sub">MetonaEditor v0.1.5 完整 API、配置与使用指南</p>
|
||||
<p class="sub">MetonaEditor v0.1.6 完整 API、配置与使用指南</p>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
@@ -618,7 +618,7 @@ i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
MetonaEditor v0.1.5 · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
|
||||
MetonaEditor v0.1.6 · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
+1
-1
@@ -475,7 +475,7 @@
|
||||
<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>
|
||||
<p>插件 v2 支持依赖拓扑排序、异步加载、动态卸载;自定义快捷键与工具栏,右键上下文菜单。</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<div class="feature-icon">
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* MetonaEditor Animations - 动画管理
|
||||
* @module animations
|
||||
* @version 0.1.5
|
||||
* @version 0.1.6
|
||||
* @description 动画注册与管理(当前为 CSS 动画元数据管理,供未来扩展如 Toast/通知组件的进出场动画使用;
|
||||
* 内置 7 种预设动画曲线配置;编辑器核心当前未直接调用此模块)
|
||||
*/
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* MetonaEditor Constants - 常量定义
|
||||
* @module constants
|
||||
* @version 0.1.5
|
||||
* @version 0.1.6
|
||||
* @description 默认配置、主题、动画、工具栏等常量
|
||||
*/
|
||||
|
||||
|
||||
+216
-10
@@ -1,20 +1,29 @@
|
||||
/**
|
||||
* MetonaEditor Core - 编辑器核心
|
||||
* @module core
|
||||
* @version 0.1.5
|
||||
* @version 0.1.6
|
||||
* @description MarkdownEditor 类实现:DOM 构建、事件、渲染、历史栈、选区操作、模式切换
|
||||
* v0.1.6: 插件依赖拓扑排序、实例 i18n、快捷键/工具栏定制、右键菜单、Toast
|
||||
*/
|
||||
|
||||
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 { t as i18nT, getCurrentLocale, getLocaleDirection, createInstanceI18n } from './i18n.js';
|
||||
import { parseMarkdown } from './parser.js';
|
||||
import { presetPlugins } from './plugins.js';
|
||||
import { presetPlugins, topologicalSort } from './plugins.js';
|
||||
import { resolveTheme, getThemeConfig, setThemeVariables, createInstanceTheme } from './themes.js';
|
||||
|
||||
const MODES = ['edit', 'split', 'preview'];
|
||||
|
||||
/** Toast 图标 */
|
||||
const TOAST_ICONS = {
|
||||
success: '✓',
|
||||
error: '✗',
|
||||
warning: '⚠',
|
||||
info: 'ℹ',
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析主题名称为实际主题(auto -> light/dark)
|
||||
*/
|
||||
@@ -89,6 +98,9 @@ class MarkdownEditor {
|
||||
this._fullscreen = false;
|
||||
this._destroyed = false;
|
||||
this._lastRenderedValue = null;
|
||||
this._shortcuts = []; // v0.1.6: 快捷键注册表
|
||||
this._contextMenuItems = []; // v0.1.6: 右键菜单项
|
||||
this._customActions = {}; // 自定义工具栏动作
|
||||
|
||||
// 渲染函数:自定义覆盖内置解析器
|
||||
this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown;
|
||||
@@ -99,12 +111,14 @@ class MarkdownEditor {
|
||||
injectStyles();
|
||||
this._buildDOM();
|
||||
|
||||
// 实例级主题管理(v0.1.5: 隔离 CSS 变量到 wrapper,支持独立主题)
|
||||
// 实例级主题管理(v0.1.5)
|
||||
this._themeCtx = createInstanceTheme(this);
|
||||
|
||||
// 向后兼容:用户显式传 theme 时同步到全局并持久化
|
||||
// 实例级 i18n 管理(v0.1.6)
|
||||
this._i18nCtx = createInstanceI18n(this);
|
||||
|
||||
// 向后兼容:用户显式传 theme 时同步到全局
|
||||
if (options.theme) {
|
||||
// 全局同步(保留原有行为)
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.setAttribute('data-md-theme', resolveTheme(options.theme));
|
||||
}
|
||||
@@ -123,9 +137,14 @@ class MarkdownEditor {
|
||||
this._render();
|
||||
this._updateWordCount();
|
||||
|
||||
// 安装配置中的插件
|
||||
// 安装配置中的插件(v0.1.6: 拓扑排序)
|
||||
if (Array.isArray(this.config.plugins)) {
|
||||
this.config.plugins.forEach((p) => this.use(p));
|
||||
const plugins = this.config.plugins.map((p) => {
|
||||
if (typeof p === 'string') return { ...presetPlugins[p], _isStringRef: true };
|
||||
return p;
|
||||
}).filter(Boolean);
|
||||
const sorted = topologicalSort(plugins);
|
||||
sorted.forEach((p) => this.use(p));
|
||||
}
|
||||
|
||||
if (this.config.autofocus && !this.config.readOnly) this.focus();
|
||||
@@ -352,6 +371,24 @@ class MarkdownEditor {
|
||||
return;
|
||||
}
|
||||
|
||||
// v0.1.6: 检查自定义快捷键注册表
|
||||
if (this._shortcuts.length) {
|
||||
for (const sc of this._shortcuts) {
|
||||
const ctrlOk = sc.ctrl ? (e.ctrlKey || e.metaKey) : !e.ctrlKey && !e.metaKey;
|
||||
const shiftOk = sc.shift ? e.shiftKey : !e.shiftKey;
|
||||
const altOk = sc.alt ? e.altKey : !e.altKey;
|
||||
if (e.key.toLowerCase() === sc.key && ctrlOk && shiftOk && altOk) {
|
||||
e.preventDefault();
|
||||
if (typeof sc.handler === 'string') {
|
||||
this.exec(sc.handler);
|
||||
} else if (typeof sc.handler === 'function') {
|
||||
try { sc.handler(this); } catch (err) { console.error('Shortcut handler error:', err); }
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (!mod) return;
|
||||
|
||||
@@ -890,7 +927,7 @@ class MarkdownEditor {
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装插件
|
||||
* 安装插件(v0.1.6: 支持异步 install)
|
||||
* @param {string|Object} plugin - 预设插件名或插件对象
|
||||
* @param {Object} options - 插件配置
|
||||
*/
|
||||
@@ -910,15 +947,40 @@ class MarkdownEditor {
|
||||
const merged = { ...p, ...options };
|
||||
if (typeof merged.install === 'function') {
|
||||
try {
|
||||
merged.install(this);
|
||||
const result = merged.install(this);
|
||||
// 异步插件支持
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.catch((e) => {
|
||||
console.error(`MeEditor: async plugin "${merged.name || 'custom'}" install error:`, e);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`MeEditor: plugin "${merged.name || 'custom'}" install error:`, e);
|
||||
}
|
||||
}
|
||||
// 直接 push 原对象(保持运行时属性引用一致)
|
||||
this._plugins.push(merged);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载插件(v0.1.6 新增)
|
||||
* @param {string} name - 插件名
|
||||
*/
|
||||
unuse(name) {
|
||||
const idx = this._plugins.findIndex((p) => p.name === name);
|
||||
if (idx === -1) {
|
||||
console.warn(`MeEditor: plugin "${name}" not found`);
|
||||
return this;
|
||||
}
|
||||
const plugin = this._plugins[idx];
|
||||
if (typeof plugin.destroy === 'function') {
|
||||
try { plugin.destroy(this); } catch (e) { console.error(`MeEditor: plugin "${name}" destroy error:`, e); }
|
||||
}
|
||||
this._plugins.splice(idx, 1);
|
||||
return this;
|
||||
}
|
||||
|
||||
getPlugins() { return [...this._plugins]; }
|
||||
|
||||
/**
|
||||
@@ -944,6 +1006,142 @@ class MarkdownEditor {
|
||||
return this;
|
||||
}
|
||||
|
||||
// ============ v0.1.6: 快捷键注册 ============
|
||||
|
||||
/**
|
||||
* 注册自定义快捷键
|
||||
* @param {string} combo - 组合键如 'Ctrl+B' / 'Ctrl+Shift+K' / 'Alt+1'
|
||||
* @param {Function|string} handler - 回调函数或 exec action 名
|
||||
* @param {string} [description] - 描述
|
||||
*/
|
||||
registerShortcut(combo, handler, description = '') {
|
||||
if (!combo) return this;
|
||||
|
||||
// 解析组合键
|
||||
const parts = combo.toLowerCase().split('+');
|
||||
const key = parts.pop();
|
||||
const ctrl = parts.includes('ctrl') || parts.includes('cmd');
|
||||
const shift = parts.includes('shift');
|
||||
const alt = parts.includes('alt');
|
||||
const meta = parts.includes('meta');
|
||||
|
||||
this._shortcuts.push({ combo, key, ctrl, shift, alt, meta, handler, description });
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销快捷键
|
||||
* @param {string} combo - 组合键字符串
|
||||
*/
|
||||
unregisterShortcut(combo) {
|
||||
this._shortcuts = this._shortcuts.filter((s) => s.combo !== combo);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已注册的快捷键列表
|
||||
*/
|
||||
getShortcuts() {
|
||||
return [...this._shortcuts];
|
||||
}
|
||||
|
||||
// ============ v0.1.6: 工具栏动态配置 ============
|
||||
|
||||
/**
|
||||
* 动态重建工具栏按钮
|
||||
* @param {Array} tools - 按钮数组(同 config.toolbar 格式)
|
||||
*/
|
||||
configureToolbar(tools) {
|
||||
if (!this.toolbarEl) return this;
|
||||
this.config.toolbar = tools;
|
||||
this.toolbarEl.innerHTML = '';
|
||||
this._buildToolbar();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从工具栏移除指定按钮
|
||||
* @param {string} action - 按钮 action 名
|
||||
*/
|
||||
removeToolbarButton(action) {
|
||||
if (!this.toolbarEl) return this;
|
||||
const btn = this.toolbarEl.querySelector(`.me-btn[data-action="${action}"], .me-btn[data-mode="${action}"]`);
|
||||
if (btn) btn.remove();
|
||||
return this;
|
||||
}
|
||||
|
||||
// ============ v0.1.6: 右键菜单 ============
|
||||
|
||||
/**
|
||||
* 注册右键上下文菜单
|
||||
* @param {Array} items - [{ label, action, shortcut?, type?:'separator' }]
|
||||
*/
|
||||
registerContextMenu(items = []) {
|
||||
this._contextMenuItems = items;
|
||||
return this;
|
||||
}
|
||||
|
||||
// ============ v0.1.6: Toast 通知 ============
|
||||
|
||||
/**
|
||||
* 显示 Toast 通知
|
||||
* @param {string} message - 消息内容
|
||||
* @param {Object} [opts]
|
||||
* @param {string} [opts.type='info'] - success / error / warning / info
|
||||
* @param {number} [opts.duration=3000] - 自动消失时间(ms),0 不自动消失
|
||||
* @param {string} [opts.animation='fade'] - 动画类型
|
||||
*/
|
||||
toast(message, opts = {}) {
|
||||
if (!this.el || typeof document === 'undefined') return this;
|
||||
const { type = 'info', duration = 3000, animation = 'fade' } = opts;
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `me-toast me-toast-${type} me-toast-${animation}`;
|
||||
toast.innerHTML = `<span class="me-toast-icon">${TOAST_ICONS[type] || ''}</span><span class="me-toast-msg">${escapeHTML(String(message))}</span>`;
|
||||
this.el.appendChild(toast);
|
||||
|
||||
// 入场动画
|
||||
requestAnimationFrame(() => { toast.classList.add('me-toast-enter'); });
|
||||
|
||||
const remove = () => {
|
||||
toast.classList.remove('me-toast-enter');
|
||||
toast.classList.add('me-toast-leave');
|
||||
setTimeout(() => { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300);
|
||||
};
|
||||
|
||||
if (duration > 0) setTimeout(remove, duration);
|
||||
|
||||
// 点击关闭
|
||||
toast.addEventListener('click', remove);
|
||||
return this;
|
||||
}
|
||||
|
||||
// ============ v0.1.6: 实例级 i18n ============
|
||||
|
||||
/**
|
||||
* 设置实例语言
|
||||
*/
|
||||
setLocale(locale) {
|
||||
if (this._i18nCtx) this._i18nCtx.set(locale);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取实例当前语言
|
||||
*/
|
||||
getLocale() {
|
||||
if (this._i18nCtx) return this._i18nCtx.get();
|
||||
return getCurrentLocale();
|
||||
}
|
||||
|
||||
/**
|
||||
* 实例级翻译函数
|
||||
*/
|
||||
t(key, params) {
|
||||
if (this._i18nCtx) return this._i18nCtx.t(key, params);
|
||||
return i18nT(key, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁编辑器
|
||||
*/
|
||||
@@ -956,6 +1154,14 @@ class MarkdownEditor {
|
||||
|
||||
this._cleanups.forEach((fn) => { try { fn(); } catch (_) {} });
|
||||
this._cleanups = [];
|
||||
this._shortcuts = [];
|
||||
this._contextMenuItems = [];
|
||||
this._customActions = {};
|
||||
|
||||
// 移除所有 toast
|
||||
if (this.el) {
|
||||
this.el.querySelectorAll('.me-toast').forEach((t) => t.remove());
|
||||
}
|
||||
|
||||
// 销毁插件
|
||||
this._plugins.forEach((p) => {
|
||||
|
||||
+294
-248
@@ -1,31 +1,68 @@
|
||||
/**
|
||||
* MetonaEditor i18n - 国际化管理
|
||||
* MetonaEditor i18n — 国际化管理(增强版)
|
||||
* @module i18n
|
||||
* @version 0.1.5
|
||||
* @description 多语言支持、语言切换和翻译管理
|
||||
* @version 0.1.6
|
||||
* @description 多语言支持、实例级语言隔离、复数规则、动态加载、命名空间
|
||||
*
|
||||
* v0.1.6 增强:
|
||||
* - 实例级 i18n: createInstanceI18n() 每编辑器独立语言
|
||||
* - 复数规则: t('items', { count: 5 }) 自动选择单/复数形式
|
||||
* - 动态加载: i18nUtils.loadRemote(url) 远程加载翻译包
|
||||
* - 命名空间: t('editor.bold') 点号嵌套访问
|
||||
* - 实例事件: editor.on('localeChange') 语言变化通知
|
||||
*/
|
||||
|
||||
import { LOCALES } from './constants.js';
|
||||
|
||||
// ============ 全局状态(向后兼容) ============
|
||||
|
||||
let currentLocale = 'zh-CN';
|
||||
let localeListeners = new Set();
|
||||
let fallbackLocale = 'zh-CN';
|
||||
|
||||
// ============ 复数规则 ============
|
||||
|
||||
/**
|
||||
* CLDR 复数类别简化版
|
||||
* 支持: zh (单类别), en (one/other), ru (one/few/many/other)
|
||||
*/
|
||||
const pluralRules = {
|
||||
zh: () => 'other',
|
||||
en: (n) => n === 1 ? 'one' : 'other',
|
||||
ru: (n) => {
|
||||
const m = n % 10, h = n % 100;
|
||||
if (m === 1 && h !== 11) return 'one';
|
||||
if (m >= 2 && m <= 4 && !(h >= 12 && h <= 14)) return 'few';
|
||||
if (m === 0 || (m >= 5 && m <= 9) || (h >= 11 && h <= 14)) return 'many';
|
||||
return 'other';
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取复数形式
|
||||
*/
|
||||
const getPluralForm = (locale, count) => {
|
||||
const lang = locale.split('-')[0].toLowerCase();
|
||||
const rule = pluralRules[lang] || pluralRules.en;
|
||||
return rule(Math.abs(count));
|
||||
};
|
||||
|
||||
// ============ 翻译函数 ============
|
||||
|
||||
/**
|
||||
* 获取当前语言
|
||||
*/
|
||||
export const getCurrentLocale = () => currentLocale;
|
||||
const getCurrentLocale = () => currentLocale;
|
||||
|
||||
/**
|
||||
* 设置当前语言
|
||||
*/
|
||||
export const setCurrentLocale = (locale) => {
|
||||
const setCurrentLocale = (locale) => {
|
||||
let target = locale;
|
||||
if (!LOCALES[target]) {
|
||||
console.warn(`Locale "${target}" not found, falling back to "${fallbackLocale}"`);
|
||||
target = fallbackLocale;
|
||||
}
|
||||
|
||||
currentLocale = target;
|
||||
notifyLocaleListeners(target);
|
||||
saveLocale(target);
|
||||
@@ -33,26 +70,43 @@ export const setCurrentLocale = (locale) => {
|
||||
|
||||
/**
|
||||
* 翻译函数
|
||||
* @param {string} key - 翻译键,支持点号分隔命名空间 'editor.bold'
|
||||
* @param {Object} [params] - 插值参数,含 count 时触发复数规则
|
||||
* @param {string} [locale] - 指定语言(默认当前语言)
|
||||
* @returns {string}
|
||||
*/
|
||||
export const t = (key, params = {}) => {
|
||||
const currentTranslation = getTranslation(currentLocale, key);
|
||||
if (currentTranslation !== undefined) {
|
||||
return interpolate(currentTranslation, params);
|
||||
const t = (key, params = {}, locale) => {
|
||||
const loc = locale || currentLocale;
|
||||
let translation = getTranslation(loc, key);
|
||||
|
||||
// 复数处理
|
||||
if (translation && typeof translation === 'object' && params.count !== undefined) {
|
||||
const form = getPluralForm(loc, params.count);
|
||||
translation = translation[form] || translation.other || key;
|
||||
}
|
||||
|
||||
if (currentLocale !== fallbackLocale) {
|
||||
const fallbackTranslation = getTranslation(fallbackLocale, key);
|
||||
if (fallbackTranslation !== undefined) {
|
||||
return interpolate(fallbackTranslation, params);
|
||||
if (typeof translation === 'string') {
|
||||
return interpolate(translation, params);
|
||||
}
|
||||
|
||||
// 回退
|
||||
if (loc !== fallbackLocale) {
|
||||
let fbTranslation = getTranslation(fallbackLocale, key);
|
||||
if (fbTranslation && typeof fbTranslation === 'object' && params.count !== undefined) {
|
||||
const form = getPluralForm(fallbackLocale, params.count);
|
||||
fbTranslation = fbTranslation[form] || fbTranslation.other;
|
||||
}
|
||||
if (typeof fbTranslation === 'string') {
|
||||
return interpolate(fbTranslation, params);
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(`Translation missing for key "${key}" in locale "${currentLocale}"`);
|
||||
console.warn(`Translation missing for key "${key}" in locale "${loc}"`);
|
||||
return key;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取翻译
|
||||
* 获取翻译(支持点号分隔的命名空间)
|
||||
*/
|
||||
const getTranslation = (locale, key) => {
|
||||
const localeData = LOCALES[locale];
|
||||
@@ -69,50 +123,58 @@ const getTranslation = (locale, key) => {
|
||||
}
|
||||
}
|
||||
|
||||
return typeof result === 'string' ? result : undefined;
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 插值函数
|
||||
* 插值替换
|
||||
*/
|
||||
const interpolate = (str, params) => {
|
||||
if (!str || typeof str !== 'string') return String(str);
|
||||
return str.replace(/\{(\w+)\}/g, (match, key) => {
|
||||
return params[key] !== undefined ? params[key] : match;
|
||||
return params[key] !== undefined ? String(params[key]) : match;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查翻译是否存在
|
||||
*/
|
||||
export const hasTranslation = (key) => {
|
||||
// ============ 翻译查询 ============
|
||||
|
||||
const hasTranslation = (key) => {
|
||||
return getTranslation(currentLocale, key) !== undefined ||
|
||||
getTranslation(fallbackLocale, key) !== undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有翻译
|
||||
*/
|
||||
export const getTranslations = (locale) => {
|
||||
return LOCALES[locale] || {};
|
||||
};
|
||||
const getTranslations = (locale) => LOCALES[locale] || {};
|
||||
|
||||
/**
|
||||
* 添加翻译
|
||||
* 添加/合并翻译
|
||||
*/
|
||||
export const addTranslations = (locale, translations) => {
|
||||
if (!LOCALES[locale]) {
|
||||
LOCALES[locale] = {};
|
||||
}
|
||||
|
||||
const addTranslations = (locale, translations) => {
|
||||
if (!LOCALES[locale]) LOCALES[locale] = {};
|
||||
deepMerge(LOCALES[locale], translations);
|
||||
};
|
||||
|
||||
/**
|
||||
* 深度合并对象
|
||||
* 远程加载翻译包
|
||||
* @param {string} url - JSON 翻译包 URL
|
||||
* @param {string} locale - 语言代码
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
const loadRemote = async (url, locale) => {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
addTranslations(locale, data);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`MeEditor: failed to load locale "${locale}" from ${url}`, e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const deepMerge = (target, source) => {
|
||||
for (const key in source) {
|
||||
if (source[key] instanceof Object && key in target && target[key] instanceof Object) {
|
||||
if (source[key] instanceof Object && key in target && target[key] instanceof Object && !Array.isArray(source[key])) {
|
||||
deepMerge(target[key], source[key]);
|
||||
} else {
|
||||
target[key] = source[key];
|
||||
@@ -121,272 +183,256 @@ const deepMerge = (target, source) => {
|
||||
return target;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取支持的语言列表
|
||||
*/
|
||||
export const getSupportedLocales = () => {
|
||||
return Object.keys(LOCALES);
|
||||
};
|
||||
// ============ 语言信息 ============
|
||||
|
||||
/**
|
||||
* 检查语言是否支持
|
||||
*/
|
||||
export const isLocaleSupported = (locale) => {
|
||||
return locale in LOCALES;
|
||||
};
|
||||
const getSupportedLocales = () => Object.keys(LOCALES);
|
||||
const isLocaleSupported = (locale) => locale in LOCALES;
|
||||
|
||||
/**
|
||||
* 获取语言名称
|
||||
*/
|
||||
export const getLocaleName = (locale) => {
|
||||
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',
|
||||
'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', 'tr': 'Türkçe',
|
||||
'it': 'Italiano', 'nl': 'Nederlands', 'pl': 'Polski',
|
||||
};
|
||||
|
||||
return names[locale] || locale;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取语言方向
|
||||
*/
|
||||
export const getLocaleDirection = (locale) => {
|
||||
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();
|
||||
}
|
||||
// ============ 格式化 ============
|
||||
|
||||
const formatNumber = (number, options = {}) => {
|
||||
try { return new Intl.NumberFormat(currentLocale, options).format(number); }
|
||||
catch (_) { return String(number); }
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化货币
|
||||
*/
|
||||
export const formatCurrency = (amount, currency = 'USD', options = {}) => {
|
||||
try {
|
||||
return new Intl.NumberFormat(currentLocale, {
|
||||
style: 'currency',
|
||||
currency,
|
||||
...options,
|
||||
}).format(amount);
|
||||
} catch (e) {
|
||||
return amount.toString();
|
||||
}
|
||||
const formatCurrency = (amount, currency = 'USD', options = {}) => {
|
||||
try { return new Intl.NumberFormat(currentLocale, { style: 'currency', currency, ...options }).format(amount); }
|
||||
catch (_) { return String(amount); }
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
export const formatDate = (date, options = {}) => {
|
||||
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();
|
||||
}
|
||||
} catch (_) { return String(date); }
|
||||
};
|
||||
|
||||
/**
|
||||
* 添加语言监听器
|
||||
*/
|
||||
export const addLocaleListener = (listener) => {
|
||||
localeListeners.add(listener);
|
||||
// ============ 监听器 ============
|
||||
|
||||
return () => {
|
||||
localeListeners.delete(listener);
|
||||
};
|
||||
const addLocaleListener = (fn) => {
|
||||
localeListeners.add(fn);
|
||||
return () => localeListeners.delete(fn);
|
||||
};
|
||||
|
||||
/**
|
||||
* 移除语言监听器
|
||||
*/
|
||||
export const removeLocaleListener = (listener) => {
|
||||
localeListeners.delete(listener);
|
||||
};
|
||||
const removeLocaleListener = (fn) => { localeListeners.delete(fn); };
|
||||
const clearLocaleListeners = () => { localeListeners.clear(); };
|
||||
|
||||
/**
|
||||
* 通知语言监听器
|
||||
*/
|
||||
const notifyLocaleListeners = (locale) => {
|
||||
localeListeners.forEach((listener) => {
|
||||
try {
|
||||
listener(locale);
|
||||
} catch (e) {
|
||||
console.error('Locale listener error:', e);
|
||||
}
|
||||
localeListeners.forEach((fn) => {
|
||||
try { fn(locale); } catch (e) { console.error('Locale listener error:', e); }
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除所有语言监听器
|
||||
*/
|
||||
export const clearLocaleListeners = () => {
|
||||
localeListeners.clear();
|
||||
};
|
||||
// ============ 持久化 ============
|
||||
|
||||
/**
|
||||
* 保存语言到本地存储
|
||||
*/
|
||||
export const saveLocale = (locale) => {
|
||||
const saveLocale = (locale) => {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem('metona-editor-locale', locale);
|
||||
} catch (e) {
|
||||
console.warn('Failed to save locale:', e);
|
||||
}
|
||||
try { localStorage.setItem('metona-editor-locale', locale); } catch (_) {}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从本地存储加载语言
|
||||
*/
|
||||
export const loadLocale = () => {
|
||||
const loadLocale = () => {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
return localStorage.getItem('metona-editor-locale') || getDefaultLocale();
|
||||
} catch (e) {
|
||||
console.warn('Failed to load locale:', e);
|
||||
}
|
||||
try { return localStorage.getItem('metona-editor-locale') || getDefaultLocale(); } catch (_) {}
|
||||
}
|
||||
return getDefaultLocale();
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取默认语言
|
||||
*/
|
||||
export const getDefaultLocale = () => {
|
||||
const getDefaultLocale = () => {
|
||||
if (typeof navigator !== 'undefined') {
|
||||
const browserLocale = navigator.language || navigator.userLanguage;
|
||||
if (browserLocale && isLocaleSupported(browserLocale)) {
|
||||
return browserLocale;
|
||||
}
|
||||
|
||||
if (browserLocale && isLocaleSupported(browserLocale)) return browserLocale;
|
||||
const shortLocale = browserLocale?.split('-')[0];
|
||||
if (shortLocale && isLocaleSupported(shortLocale)) {
|
||||
return shortLocale;
|
||||
if (shortLocale && isLocaleSupported(shortLocale)) return shortLocale;
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackLocale;
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化国际化系统
|
||||
*/
|
||||
export const initI18n = () => {
|
||||
// ============ 初始化 ============
|
||||
|
||||
const initI18n = () => {
|
||||
const savedLocale = loadLocale();
|
||||
setCurrentLocale(savedLocale);
|
||||
};
|
||||
|
||||
/**
|
||||
* 切换语言
|
||||
*/
|
||||
export const switchLocale = (locale) => {
|
||||
setCurrentLocale(locale);
|
||||
};
|
||||
const switchLocale = (locale) => { setCurrentLocale(locale); };
|
||||
|
||||
// ============ 实例级 i18n(v0.1.6 新增) ============
|
||||
|
||||
/**
|
||||
* 国际化工具 — 代理层
|
||||
* 为编辑器实例创建隔离的 i18n 上下文
|
||||
* @param {Object} editor - 编辑器实例(需有 el / config 属性)
|
||||
* @returns {Object} 实例 i18n API
|
||||
*/
|
||||
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,
|
||||
};
|
||||
const createInstanceI18n = (editor) => {
|
||||
let instanceLocale = editor.config?.locale || currentLocale || 'zh-CN';
|
||||
|
||||
/**
|
||||
* 预设语言包
|
||||
*/
|
||||
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'],
|
||||
},
|
||||
};
|
||||
const instanceT = (key, params = {}) => t(key, params, instanceLocale);
|
||||
|
||||
const set = (locale) => {
|
||||
if (!locale || instanceLocale === locale) return;
|
||||
instanceLocale = locale;
|
||||
|
||||
// 更新编辑器 UI 文本
|
||||
if (editor.el) {
|
||||
editor.el.setAttribute('lang', locale);
|
||||
editor.el.setAttribute('dir', getLocaleDirection(locale));
|
||||
}
|
||||
if (editor.textarea) {
|
||||
editor.textarea.placeholder = instanceT('placeholder') || '';
|
||||
editor.textarea.setAttribute('aria-label', instanceT('edit') || '');
|
||||
editor.textarea.setAttribute('dir', getLocaleDirection(locale));
|
||||
}
|
||||
|
||||
// 触发事件
|
||||
if (typeof editor._emit === 'function') {
|
||||
editor._emit('localeChange', { locale, direction: getLocaleDirection(locale) });
|
||||
}
|
||||
|
||||
// 更新工具栏按钮 tooltip
|
||||
if (editor.toolbarEl) {
|
||||
editor.toolbarEl.querySelectorAll('.me-btn').forEach((btn) => {
|
||||
const action = btn.dataset.action || btn.dataset.mode;
|
||||
if (action) {
|
||||
const label = instanceT(action);
|
||||
if (label && label !== action) {
|
||||
btn.title = label;
|
||||
btn.setAttribute('aria-label', label);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return instanceLocale;
|
||||
};
|
||||
|
||||
const get = () => instanceLocale;
|
||||
const getDirection = () => getLocaleDirection(instanceLocale);
|
||||
|
||||
/**
|
||||
* 创建国际化管理器
|
||||
*/
|
||||
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,
|
||||
set,
|
||||
get,
|
||||
getDirection,
|
||||
t: instanceT,
|
||||
formatNumber: (n, opts) => {
|
||||
try { return new Intl.NumberFormat(instanceLocale, opts).format(n); } catch (_) { return String(n); }
|
||||
},
|
||||
formatDate: (d, opts) => {
|
||||
try { return new Intl.DateTimeFormat(instanceLocale, opts).format(d instanceof Date ? d : new Date(d)); } catch (_) { return String(d); }
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export { i18nUtils as default };
|
||||
// ============ 预设 ============
|
||||
|
||||
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'] },
|
||||
};
|
||||
|
||||
// ============ 代理层 ============
|
||||
|
||||
const i18nUtils = {
|
||||
t,
|
||||
getCurrentLocale,
|
||||
setCurrentLocale,
|
||||
switchLocale,
|
||||
getFallbackLocale: () => fallbackLocale,
|
||||
setFallbackLocale: (locale) => { fallbackLocale = locale; },
|
||||
hasTranslation,
|
||||
getTranslations,
|
||||
addTranslations,
|
||||
loadRemote,
|
||||
getSupportedLocales,
|
||||
isLocaleSupported,
|
||||
getLocaleName,
|
||||
getLocaleDirection,
|
||||
formatNumber,
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
addLocaleListener,
|
||||
removeLocaleListener,
|
||||
clearLocaleListeners,
|
||||
initI18n,
|
||||
saveLocale,
|
||||
loadLocale,
|
||||
getDefaultLocale,
|
||||
createInstanceI18n,
|
||||
};
|
||||
|
||||
const createI18nManager = () => ({
|
||||
t,
|
||||
getCurrentLocale,
|
||||
setCurrentLocale,
|
||||
switchLocale,
|
||||
getFallbackLocale: () => fallbackLocale,
|
||||
setFallbackLocale: (locale) => { fallbackLocale = locale; },
|
||||
hasTranslation,
|
||||
getTranslations,
|
||||
addTranslations,
|
||||
loadRemote,
|
||||
getSupportedLocales,
|
||||
isLocaleSupported,
|
||||
getLocaleName,
|
||||
getLocaleDirection,
|
||||
formatNumber,
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
addLocaleListener,
|
||||
removeLocaleListener,
|
||||
clearLocaleListeners,
|
||||
initI18n,
|
||||
saveLocale,
|
||||
loadLocale,
|
||||
getDefaultLocale,
|
||||
createInstanceI18n,
|
||||
});
|
||||
|
||||
// ============ 导出 ============
|
||||
|
||||
export {
|
||||
i18nUtils,
|
||||
i18nUtils as default,
|
||||
t,
|
||||
getCurrentLocale,
|
||||
setCurrentLocale,
|
||||
switchLocale,
|
||||
hasTranslation,
|
||||
getTranslations,
|
||||
addTranslations,
|
||||
loadRemote,
|
||||
getSupportedLocales,
|
||||
isLocaleSupported,
|
||||
getLocaleName,
|
||||
getLocaleDirection,
|
||||
formatNumber,
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
addLocaleListener,
|
||||
removeLocaleListener,
|
||||
clearLocaleListeners,
|
||||
initI18n,
|
||||
saveLocale,
|
||||
loadLocale,
|
||||
getDefaultLocale,
|
||||
createInstanceI18n,
|
||||
createI18nManager,
|
||||
presetLocales,
|
||||
};
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* MetonaEditor Icons — 工具栏图标SVG定义
|
||||
* @module icons
|
||||
* @version 0.1.5
|
||||
* @version 0.1.6
|
||||
* @description 工具栏格式化按钮 SVG 图标
|
||||
*/
|
||||
|
||||
|
||||
+16
-4
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* MetonaEditor - 轻量级 Markdown Editor 库
|
||||
* @module metona-editor
|
||||
* @version 0.1.5
|
||||
* @version 0.1.6
|
||||
* @author thzxx
|
||||
* @description 现代化、零依赖、易扩展、易维护、易使用的 Markdown 编辑器库。单文件,开箱即用。
|
||||
* @license MIT
|
||||
@@ -23,13 +23,13 @@
|
||||
import { MarkdownEditor } from './core.js';
|
||||
import { parseMarkdown, safeUrl, slugify, clearRenderCache } from './parser.js';
|
||||
import { themeUtils, exportCSSVars, getCSSVariable, followExternalTheme, adoptFromParent, createInstanceTheme } from './themes.js';
|
||||
import { i18nUtils } from './i18n.js';
|
||||
import { pluginUtils, presetPlugins } from './plugins.js';
|
||||
import { i18nUtils, createInstanceI18n, loadRemote } from './i18n.js';
|
||||
import { pluginUtils, presetPlugins, topologicalSort, validateConfig } 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.5';
|
||||
const VERSION = '0.1.6';
|
||||
|
||||
/**
|
||||
* 全局默认插件队列
|
||||
@@ -202,6 +202,14 @@ const api = {
|
||||
plugins: pluginUtils,
|
||||
presetPlugins,
|
||||
|
||||
// 插件工具(v0.1.6)
|
||||
topologicalSort,
|
||||
validateConfig,
|
||||
|
||||
// i18n 工具(v0.1.6)
|
||||
createInstanceI18n,
|
||||
loadRemote,
|
||||
|
||||
// 主题工具(v0.1.5 新增)
|
||||
exportCSSVars,
|
||||
getCSSVariable,
|
||||
@@ -247,6 +255,10 @@ export {
|
||||
pluginUtils,
|
||||
presetPlugins,
|
||||
animationUtils,
|
||||
topologicalSort,
|
||||
validateConfig,
|
||||
createInstanceI18n,
|
||||
loadRemote,
|
||||
exportCSSVars,
|
||||
getCSSVariable,
|
||||
followExternalTheme,
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* MetonaEditor Locales — 国际化翻译数据
|
||||
* @module locales
|
||||
* @version 0.1.5
|
||||
* @version 0.1.6
|
||||
* @description 内置 zh-CN / en-US 完整翻译
|
||||
*/
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* MetonaEditor Parser - 轻量 Markdown 解析器(增强版)
|
||||
* @module parser
|
||||
* @version 0.1.5
|
||||
* @version 0.1.6
|
||||
* @description 自研零依赖 Markdown 解析器,覆盖 CommonMark 子集 + GFM 扩展
|
||||
*
|
||||
* v0.1.4 增强:
|
||||
|
||||
+184
-130
@@ -1,25 +1,22 @@
|
||||
/**
|
||||
* MetonaEditor Plugins - 插件系统
|
||||
* MetonaEditor Plugins — 插件系统 v2
|
||||
* @module plugins
|
||||
* @version 0.1.5
|
||||
* @description 插件管理器 + 预设插件(autoSave / exportTool / searchReplace)
|
||||
* @version 0.1.6
|
||||
* @description 插件管理器 + 预设插件 + 依赖/生命周期/异步/卸载
|
||||
*
|
||||
* 插件约定:
|
||||
* {
|
||||
* name: string,
|
||||
* description?: string,
|
||||
* install(editor, options): void, // 安装时调用,editor 为 MarkdownEditor 实例
|
||||
* destroy(editor): void // 卸载时调用,清理事件与 DOM
|
||||
* }
|
||||
* install 的 this 指向插件对象本身(core.js 通过 merged.install(editor) 调用),
|
||||
* 因此可在 this 上存放运行时状态(如定时器、DOM 引用)。
|
||||
* v0.1.6 增强:
|
||||
* - depends: 声明插件依赖,自动拓扑排序安装
|
||||
* - 生命周期钩子: beforeInstall/afterInstall/beforeDestroy(静态 + 实例级)
|
||||
* - 异步插件: install 返回 Promise 时自动 await
|
||||
* - editor.unuse(name): 卸载单个已安装插件
|
||||
* - pluginUtils.validateConfig(): schema 配置校验
|
||||
* - priority: 数字越大越先安装
|
||||
*/
|
||||
|
||||
import { t } from './i18n.js';
|
||||
|
||||
/**
|
||||
* 插件管理器(独立于编辑器实例,用于全局注册与查询;编辑器实例级安装由 core.js 的 use() 负责)
|
||||
*/
|
||||
// ============ 插件管理器 ============
|
||||
|
||||
class PluginManager {
|
||||
constructor() {
|
||||
this.plugins = new Map();
|
||||
@@ -34,45 +31,149 @@ class PluginManager {
|
||||
console.error(`MeEditor: invalid plugin "${name}"`);
|
||||
return this;
|
||||
}
|
||||
this.plugins.set(name, { name, ...plugin, enabled: true });
|
||||
return this;
|
||||
}
|
||||
|
||||
unregister(name) {
|
||||
this.plugins.delete(name);
|
||||
this.plugins.set(name, {
|
||||
name,
|
||||
version: plugin.version || '0.0.0',
|
||||
description: plugin.description || '',
|
||||
depends: plugin.depends || [],
|
||||
priority: plugin.priority || 0,
|
||||
...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();
|
||||
}
|
||||
isEnabled(name) { const p = this.plugins.get(name); return p ? p.enabled : false; }
|
||||
destroy() { this.plugins.clear(); }
|
||||
}
|
||||
|
||||
const defaultPluginManager = new PluginManager();
|
||||
|
||||
// ============ 依赖拓扑排序 ============
|
||||
|
||||
/**
|
||||
* 对插件列表按依赖关系拓扑排序(Kahn 算法)
|
||||
* @param {Object[]} plugins - 插件对象数组
|
||||
* @returns {Object[]} 排序后的插件数组
|
||||
*/
|
||||
const topologicalSort = (plugins) => {
|
||||
const map = new Map();
|
||||
plugins.forEach((p) => map.set(p.name, p));
|
||||
|
||||
const inDegree = new Map();
|
||||
const adj = new Map();
|
||||
|
||||
plugins.forEach((p) => {
|
||||
inDegree.set(p.name, 0);
|
||||
adj.set(p.name, []);
|
||||
});
|
||||
|
||||
plugins.forEach((p) => {
|
||||
(p.depends || []).forEach((dep) => {
|
||||
if (map.has(dep)) {
|
||||
adj.get(dep).push(p.name);
|
||||
inDegree.set(p.name, (inDegree.get(p.name) || 0) + 1);
|
||||
} else {
|
||||
console.warn(`MeEditor: plugin "${p.name}" depends on unknown "${dep}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const queue = [];
|
||||
inDegree.forEach((deg, name) => { if (deg === 0) queue.push(name); });
|
||||
|
||||
const sorted = [];
|
||||
while (queue.length) {
|
||||
// 同层级按 priority 降序
|
||||
queue.sort((a, b) => (map.get(b).priority || 0) - (map.get(a).priority || 0));
|
||||
const name = queue.shift();
|
||||
sorted.push(map.get(name));
|
||||
(adj.get(name) || []).forEach((n) => {
|
||||
inDegree.set(n, inDegree.get(n) - 1);
|
||||
if (inDegree.get(n) === 0) queue.push(n);
|
||||
});
|
||||
}
|
||||
|
||||
if (sorted.length !== plugins.length) {
|
||||
console.warn('MeEditor: circular dependency detected in plugins, falling back to original order');
|
||||
return plugins;
|
||||
}
|
||||
|
||||
return sorted;
|
||||
};
|
||||
|
||||
// ============ 配置校验 ============
|
||||
|
||||
/**
|
||||
* 校验插件配置是否符合 schema
|
||||
* @param {Object} schema - { key: { type, required, default, enum, validator } }
|
||||
* @param {Object} config - 用户传入的配置
|
||||
* @returns {{ valid: boolean, errors: string[], patched: Object }}
|
||||
*/
|
||||
const validateConfig = (schema = {}, config = {}) => {
|
||||
const errors = [];
|
||||
const patched = { ...config };
|
||||
|
||||
Object.entries(schema).forEach(([key, rule]) => {
|
||||
const val = config[key];
|
||||
|
||||
// required
|
||||
if (rule.required && (val === undefined || val === null)) {
|
||||
errors.push(`"${key}" is required`);
|
||||
return;
|
||||
}
|
||||
|
||||
// default
|
||||
if (val === undefined && rule.default !== undefined) {
|
||||
patched[key] = rule.default;
|
||||
return;
|
||||
}
|
||||
|
||||
// type
|
||||
if (val !== undefined && rule.type) {
|
||||
const actual = Array.isArray(val) ? 'array' : typeof val;
|
||||
if (actual !== rule.type) {
|
||||
errors.push(`"${key}" expected ${rule.type}, got ${actual}`);
|
||||
}
|
||||
}
|
||||
|
||||
// enum
|
||||
if (val !== undefined && rule.enum && !rule.enum.includes(val)) {
|
||||
errors.push(`"${key}" must be one of [${rule.enum.join(', ')}]`);
|
||||
}
|
||||
|
||||
// custom validator
|
||||
if (val !== undefined && typeof rule.validator === 'function') {
|
||||
try {
|
||||
const result = rule.validator(val);
|
||||
if (result !== true) errors.push(`"${key}": ${result}`);
|
||||
} catch (e) {
|
||||
errors.push(`"${key}": ${e.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { valid: errors.length === 0, errors, patched };
|
||||
};
|
||||
|
||||
// ============ 预设插件 ============
|
||||
|
||||
/**
|
||||
* 自动保存插件
|
||||
* 将编辑器内容定时 / 失焦 / 保存时写入 localStorage,并提供草稿恢复与清除。
|
||||
*
|
||||
* 配置:
|
||||
* key: 存储 key,默认 'me-draft-' + editor.id
|
||||
* delay: 防抖延迟(ms),默认 1000
|
||||
*/
|
||||
const autoSavePlugin = {
|
||||
name: 'autoSave',
|
||||
version: '0.1.0',
|
||||
description: '自动保存到 localStorage,支持草稿恢复',
|
||||
key: null,
|
||||
delay: 1000,
|
||||
priority: 100,
|
||||
depends: [],
|
||||
|
||||
_timer: null,
|
||||
_onInput: null,
|
||||
@@ -99,7 +200,7 @@ const autoSavePlugin = {
|
||||
this._save = save;
|
||||
this._onInput = () => {
|
||||
if (this._timer) clearTimeout(this._timer);
|
||||
this._timer = setTimeout(save, this.delay);
|
||||
this._timer = setTimeout(save, this.delay || 1000);
|
||||
};
|
||||
this._onBlur = save;
|
||||
this._onSave = save;
|
||||
@@ -108,18 +209,15 @@ const autoSavePlugin = {
|
||||
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);
|
||||
}
|
||||
if (v != null && typeof editor.setValue === 'function') editor.setValue(v);
|
||||
return v;
|
||||
} catch (e) { return null; }
|
||||
};
|
||||
editor.clearDraft = () => {
|
||||
try { localStorage.removeItem(key); } catch (e) {}
|
||||
try { localStorage.removeItem(key); } catch (_) {}
|
||||
return editor;
|
||||
};
|
||||
editor.getDraftKey = () => key;
|
||||
@@ -133,15 +231,22 @@ const autoSavePlugin = {
|
||||
if (this._onSave) editor.off('save', this._onSave);
|
||||
}
|
||||
},
|
||||
|
||||
// 配置 schema
|
||||
configSchema: {
|
||||
key: { type: 'string' },
|
||||
delay: { type: 'number', default: 1000 },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 导出工具插件
|
||||
* 提供 .md / .html 文件下载。
|
||||
*/
|
||||
const exportToolPlugin = {
|
||||
name: 'exportTool',
|
||||
version: '0.1.0',
|
||||
description: '导出 Markdown / HTML 文件',
|
||||
priority: 50,
|
||||
|
||||
install(editor) {
|
||||
if (!editor || typeof editor.getValue !== 'function') return;
|
||||
@@ -174,18 +279,7 @@ const exportToolPlugin = {
|
||||
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>`;
|
||||
const html = `<!DOCTYPE html>\n<html lang="${opts.lang || 'zh-CN'}">\n<head>\n<meta charset="utf-8"/>\n<meta name="viewport" content="width=device-width, initial-scale=1"/>\n<title>${title}</title>\n${css ? `<style>${css}</style>` : ''}\n</head>\n<body>\n${body}\n</body>\n</html>`;
|
||||
download(filename || `metona-${stamp()}.html`, html, 'text/html');
|
||||
return editor;
|
||||
};
|
||||
@@ -196,35 +290,28 @@ ${body}
|
||||
|
||||
/**
|
||||
* 查找替换插件
|
||||
* Ctrl+F 唤出浮动搜索条,支持上一个 / 下一个 / 替换 / 全部替换。
|
||||
*/
|
||||
const searchReplacePlugin = {
|
||||
name: 'searchReplace',
|
||||
description: '查找替换(Ctrl+F)',
|
||||
version: '0.1.0',
|
||||
description: '查找替换(Ctrl+F / Ctrl+H)',
|
||||
priority: 80,
|
||||
|
||||
_onKeydown: null,
|
||||
_panel: null,
|
||||
_cleanup: null,
|
||||
|
||||
install(editor) {
|
||||
if (!editor || !editor.textarea) return;
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
if (!editor || !editor.textarea || 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);
|
||||
}
|
||||
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);
|
||||
},
|
||||
@@ -233,16 +320,7 @@ const searchReplacePlugin = {
|
||||
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}
|
||||
`;
|
||||
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);
|
||||
},
|
||||
|
||||
@@ -255,28 +333,11 @@ const searchReplacePlugin = {
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = editor.textarea.value.substring(
|
||||
editor.textarea.selectionStart,
|
||||
editor.textarea.selectionEnd
|
||||
);
|
||||
|
||||
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>
|
||||
`;
|
||||
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();
|
||||
@@ -305,22 +366,18 @@ const searchReplacePlugin = {
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -333,7 +390,6 @@ const searchReplacePlugin = {
|
||||
if (lastIdxs[i] < cur) { prev = lastIdxs[i]; break; }
|
||||
}
|
||||
if (prev === -1) prev = lastIdxs[lastIdxs.length - 1];
|
||||
cursor = lastIdxs.indexOf(prev);
|
||||
selectAt(prev);
|
||||
};
|
||||
|
||||
@@ -342,10 +398,8 @@ const searchReplacePlugin = {
|
||||
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) {
|
||||
const start = ta.selectionStart, end = ta.selectionEnd;
|
||||
if (ta.value.substring(start, end) === q) {
|
||||
ta.value = ta.value.substring(0, start) + r + ta.value.substring(end);
|
||||
ta.setSelectionRange(start, start + r.length);
|
||||
editor._value = ta.value;
|
||||
@@ -357,8 +411,7 @@ const searchReplacePlugin = {
|
||||
};
|
||||
|
||||
const replaceAll = () => {
|
||||
const q = findInput.value;
|
||||
const r = replaceInput.value;
|
||||
const q = findInput.value, r = replaceInput.value;
|
||||
if (!q) return;
|
||||
const ta = editor.textarea;
|
||||
const before = ta.value;
|
||||
@@ -373,7 +426,7 @@ const searchReplacePlugin = {
|
||||
findAll();
|
||||
};
|
||||
|
||||
findInput.addEventListener('input', () => { lastIdxs = findAll(); cursor = -1; });
|
||||
findInput.addEventListener('input', () => { lastIdxs = findAll(); });
|
||||
findInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? findPrev() : findNext(); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); this._close(editor); }
|
||||
@@ -391,10 +444,7 @@ const searchReplacePlugin = {
|
||||
if (selected) findAll();
|
||||
findInput.focus();
|
||||
findInput.select();
|
||||
|
||||
this._cleanup = () => {
|
||||
if (panel.parentNode) panel.parentNode.removeChild(panel);
|
||||
};
|
||||
this._cleanup = () => { if (panel.parentNode) panel.parentNode.removeChild(panel); };
|
||||
},
|
||||
|
||||
_updateReplaceVisible() {
|
||||
@@ -421,17 +471,17 @@ const searchReplacePlugin = {
|
||||
|
||||
/**
|
||||
* 图片粘贴插件
|
||||
* 监听 textarea 粘贴事件,自动将剪贴板中的图片转换为 base64 data URI 并插入。
|
||||
*/
|
||||
const imagePastePlugin = {
|
||||
name: 'imagePaste',
|
||||
version: '0.1.0',
|
||||
description: '粘贴图片自动转为 base64 内联',
|
||||
priority: 60,
|
||||
|
||||
_onPaste: null,
|
||||
|
||||
install(editor) {
|
||||
if (!editor || !editor.textarea || typeof document === 'undefined') return;
|
||||
|
||||
this._onPaste = (e) => {
|
||||
const items = e.clipboardData && e.clipboardData.items;
|
||||
if (!items) return;
|
||||
@@ -460,9 +510,8 @@ const imagePastePlugin = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 预设插件注册表
|
||||
*/
|
||||
// ============ 预设插件表 ============
|
||||
|
||||
const presetPlugins = {
|
||||
autoSave: autoSavePlugin,
|
||||
exportTool: exportToolPlugin,
|
||||
@@ -470,21 +519,18 @@ const presetPlugins = {
|
||||
imagePaste: imagePastePlugin,
|
||||
};
|
||||
|
||||
/**
|
||||
* 转义属性值(用于内联 HTML 属性)
|
||||
*/
|
||||
// ============ 实用工具 ============
|
||||
|
||||
const escapeAttr = (s) => String(s == null ? '' : s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
.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); },
|
||||
@@ -494,19 +540,23 @@ const pluginUtils = {
|
||||
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; }, {}); },
|
||||
getAllPresets() {
|
||||
return Object.keys(presetPlugins).reduce((acc, k) => { acc[k] = { ...presetPlugins[k] }; return acc; }, {});
|
||||
},
|
||||
|
||||
createPlugin(config = {}) {
|
||||
const result = {
|
||||
name: config.name || 'custom',
|
||||
version: config.version || '0.0.0',
|
||||
description: config.description || '',
|
||||
// 默认提供空函数,确保 editor.use(plugin) 时 destroy() 调用安全
|
||||
depends: config.depends || [],
|
||||
priority: config.priority || 0,
|
||||
install: () => {},
|
||||
destroy: () => {},
|
||||
...config,
|
||||
};
|
||||
// 强制类型校验:用户传入非函数时回退为空函数,避免运行时报错
|
||||
if (typeof result.install !== 'function') result.install = () => {};
|
||||
if (typeof result.destroy !== 'function') result.destroy = () => {};
|
||||
return result;
|
||||
@@ -519,7 +569,11 @@ const pluginUtils = {
|
||||
if (plugin && plugin.install && typeof plugin.install !== 'function') errors.push('install must be a function');
|
||||
return { valid: errors.length === 0, errors };
|
||||
},
|
||||
|
||||
// v0.1.6 新增
|
||||
topologicalSort,
|
||||
validateConfig,
|
||||
};
|
||||
|
||||
export { presetPlugins, pluginUtils, defaultPluginManager, PluginManager };
|
||||
export { presetPlugins, pluginUtils, defaultPluginManager, PluginManager, topologicalSort, validateConfig };
|
||||
export default presetPlugins;
|
||||
|
||||
+63
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* MetonaEditor Styles - 编辑器样式
|
||||
* @module styles
|
||||
* @version 0.1.5
|
||||
* @version 0.1.6
|
||||
* @description 编辑器 UI 样式注入与管理
|
||||
*/
|
||||
|
||||
@@ -392,6 +392,68 @@ const generateCSS = () => {
|
||||
margin-right: 0.4em;
|
||||
}
|
||||
|
||||
/* ============ Toast 通知(v0.1.6) ============ */
|
||||
.me-toast {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.15);
|
||||
max-width: 360px;
|
||||
}
|
||||
.me-toast.me-toast-enter { opacity: 1; transform: translateY(0); }
|
||||
.me-toast.me-toast-leave { opacity: 0; transform: translateY(12px); }
|
||||
.me-toast-success { background: #10b981; color: #fff; }
|
||||
.me-toast-error { background: #ef4444; color: #fff; }
|
||||
.me-toast-warning { background: #f59e0b; color: #fff; }
|
||||
.me-toast-info { background: var(--md-accent, #3b82f6); color: #fff; }
|
||||
.me-toast-icon { font-weight: 700; font-size: 16px; flex-shrink: 0; }
|
||||
.me-toast-msg { line-height: 1.4; }
|
||||
|
||||
/* ============ 右键菜单(v0.1.6) ============ */
|
||||
.me-context-menu {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
min-width: 180px;
|
||||
padding: 4px 0;
|
||||
background: var(--md-bg);
|
||||
border: 1px solid var(--md-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 28px rgba(0,0,0,0.18);
|
||||
font-size: 13px;
|
||||
}
|
||||
.me-context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
color: var(--md-text);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.me-context-menu-item:hover { background: var(--md-code-bg); color: var(--md-accent); }
|
||||
.me-context-menu-sep {
|
||||
height: 1px;
|
||||
background: var(--md-border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
.me-context-menu-shortcut {
|
||||
color: var(--md-muted);
|
||||
font-size: 11px;
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
/* ============ 打印样式 ============ */
|
||||
@media print {
|
||||
.me-wrapper { border: 0 !important; box-shadow: none !important; }
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* MetonaEditor Themes — 主题系统(增强版)
|
||||
* @module themes
|
||||
* @version 0.1.5
|
||||
* @version 0.1.6
|
||||
* @description 全局+实例级主题管理、CSS 变量隔离、外部主题跟随、主题继承
|
||||
*
|
||||
* v0.1.5 增强:
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* MetonaEditor Utils - 工具函数
|
||||
* @module utils
|
||||
* @version 0.1.5
|
||||
* @version 0.1.6
|
||||
* @description 通用工具函数集合
|
||||
*/
|
||||
|
||||
|
||||
@@ -410,3 +410,78 @@ describe('createI18nManager', () => {
|
||||
expect(m.t('bold')).toBe('Bold');
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.1.6 新增测试 ============
|
||||
|
||||
describe('v0.1.6 实例级 i18n', () => {
|
||||
const { createInstanceI18n } = require('../src/i18n.js');
|
||||
|
||||
test('createInstanceI18n 创建独立上下文', () => {
|
||||
const mockEditor = {
|
||||
config: { locale: 'en-US' },
|
||||
el: document.createElement('div'),
|
||||
textarea: document.createElement('textarea'),
|
||||
toolbarEl: document.createElement('div'),
|
||||
_emit: jest.fn(),
|
||||
};
|
||||
document.body.appendChild(mockEditor.el);
|
||||
const ctx = createInstanceI18n(mockEditor);
|
||||
expect(ctx.get()).toBe('en-US');
|
||||
expect(typeof ctx.set).toBe('function');
|
||||
expect(typeof ctx.t).toBe('function');
|
||||
document.body.removeChild(mockEditor.el);
|
||||
});
|
||||
|
||||
test('实例 setLocale 触发 localeChange 事件', () => {
|
||||
const mockEditor = {
|
||||
config: { locale: 'zh-CN' },
|
||||
el: document.createElement('div'),
|
||||
textarea: document.createElement('textarea'),
|
||||
toolbarEl: document.createElement('div'),
|
||||
_emit: jest.fn(),
|
||||
};
|
||||
document.body.appendChild(mockEditor.el);
|
||||
const ctx = createInstanceI18n(mockEditor);
|
||||
ctx.set('en-US');
|
||||
expect(mockEditor._emit).toHaveBeenCalledWith('localeChange', expect.objectContaining({ locale: 'en-US' }));
|
||||
document.body.removeChild(mockEditor.el);
|
||||
});
|
||||
|
||||
test('实例 t 函数正确翻译', () => {
|
||||
const mockEditor = {
|
||||
config: { locale: 'zh-CN' },
|
||||
el: document.createElement('div'),
|
||||
textarea: document.createElement('textarea'),
|
||||
toolbarEl: document.createElement('div'),
|
||||
_emit: jest.fn(),
|
||||
};
|
||||
document.body.appendChild(mockEditor.el);
|
||||
const ctx = createInstanceI18n(mockEditor);
|
||||
expect(ctx.t('bold')).toBe('粗体');
|
||||
document.body.removeChild(mockEditor.el);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.1.6 复数规则', () => {
|
||||
const { addTranslations, t, setCurrentLocale } = require('../src/i18n.js');
|
||||
|
||||
beforeEach(() => {
|
||||
setCurrentLocale('en-US');
|
||||
});
|
||||
|
||||
test('单数 one 形式', () => {
|
||||
addTranslations('en-US', { items: { one: '{count} item', other: '{count} items' } });
|
||||
expect(t('items', { count: 1 })).toBe('1 item');
|
||||
});
|
||||
|
||||
test('复数 other 形式', () => {
|
||||
addTranslations('en-US', { items: { one: '{count} item', other: '{count} items' } });
|
||||
expect(t('items', { count: 5 })).toBe('5 items');
|
||||
});
|
||||
|
||||
test('中文 always other', () => {
|
||||
addTranslations('zh-CN', { items: { other: '{count} 个项目' } });
|
||||
setCurrentLocale('zh-CN');
|
||||
expect(t('items', { count: 3 })).toBe('3 个项目');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -867,3 +867,181 @@ describe('零散分支补全', () => {
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.1.6 新增测试 ============
|
||||
|
||||
describe('v0.1.6 topologicalSort', () => {
|
||||
const { topologicalSort } = require('../src/plugins.js');
|
||||
|
||||
test('空数组返回空数组', () => {
|
||||
expect(topologicalSort([])).toEqual([]);
|
||||
});
|
||||
|
||||
test('无依赖保持原序,按 priority 降序', () => {
|
||||
const plugins = [
|
||||
{ name: 'a', depends: [], priority: 10 },
|
||||
{ name: 'b', depends: [], priority: 50 },
|
||||
{ name: 'c', depends: [], priority: 30 },
|
||||
];
|
||||
const sorted = topologicalSort(plugins);
|
||||
expect(sorted.map((p) => p.name)).toEqual(['b', 'c', 'a']);
|
||||
});
|
||||
|
||||
test('依赖插件排在依赖者之前', () => {
|
||||
const plugins = [
|
||||
{ name: 'a', depends: ['c'] },
|
||||
{ name: 'b', depends: [] },
|
||||
{ name: 'c', depends: [] },
|
||||
];
|
||||
const sorted = topologicalSort(plugins);
|
||||
const aIdx = sorted.findIndex((p) => p.name === 'a');
|
||||
const cIdx = sorted.findIndex((p) => p.name === 'c');
|
||||
expect(cIdx).toBeLessThan(aIdx);
|
||||
});
|
||||
|
||||
test('未知依赖 warn 但不崩溃', () => {
|
||||
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const plugins = [{ name: 'a', depends: ['unknown'] }];
|
||||
expect(() => topologicalSort(plugins)).not.toThrow();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.1.6 validateConfig', () => {
|
||||
const { validateConfig } = require('../src/plugins.js');
|
||||
|
||||
test('required 字段缺失报错', () => {
|
||||
const result = validateConfig({ name: { required: true } }, {});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('default 自动补全', () => {
|
||||
const result = validateConfig({ delay: { default: 1000 } }, {});
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.patched.delay).toBe(1000);
|
||||
});
|
||||
|
||||
test('type 校验', () => {
|
||||
const result = validateConfig({ count: { type: 'number' } }, { count: 'abc' });
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
test('enum 校验', () => {
|
||||
const result = validateConfig({ mode: { enum: ['a', 'b'] } }, { mode: 'c' });
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.1.6 editor.unuse()', () => {
|
||||
test('unuse 卸载已安装插件', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const { MarkdownEditor } = require('../src/core.js');
|
||||
const ed = new MarkdownEditor(c, {});
|
||||
let destroyed = false;
|
||||
ed.use({ name: 'test', install() {}, destroy() { destroyed = true; } });
|
||||
expect(ed.getPlugins().length).toBe(1);
|
||||
ed.unuse('test');
|
||||
expect(destroyed).toBe(true);
|
||||
expect(ed.getPlugins().length).toBe(0);
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.1.6 editor shortcuts', () => {
|
||||
test('registerShortcut 注册快捷键', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const { MarkdownEditor } = require('../src/core.js');
|
||||
const ed = new MarkdownEditor(c, { value: 'hello' });
|
||||
let called = false;
|
||||
ed.registerShortcut('Ctrl+J', () => { called = true; });
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'j', ctrlKey: true, bubbles: true }));
|
||||
expect(called).toBe(true);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('unregisterShortcut 注销快捷键', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const { MarkdownEditor } = require('../src/core.js');
|
||||
const ed = new MarkdownEditor(c, { value: 'hello' });
|
||||
let called = false;
|
||||
ed.registerShortcut('Ctrl+K', () => { called = true; });
|
||||
ed.unregisterShortcut('Ctrl+K');
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true }));
|
||||
// 原来的 Ctrl+K 会触发 link,但 link 需要 prompt,jsdom 返回 null
|
||||
// 只验证不崩溃
|
||||
expect(() => ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true }))).not.toThrow();
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('getShortcuts 返回注册列表', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const { MarkdownEditor } = require('../src/core.js');
|
||||
const ed = new MarkdownEditor(c, {});
|
||||
ed.registerShortcut('Ctrl+B', 'bold', 'Bold');
|
||||
expect(ed.getShortcuts().length).toBe(1);
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.1.6 editor toast', () => {
|
||||
test('toast 创建并显示', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const { MarkdownEditor } = require('../src/core.js');
|
||||
const ed = new MarkdownEditor(c, {});
|
||||
ed.toast('Test message', { duration: 0 });
|
||||
const toast = ed.el.querySelector('.me-toast');
|
||||
expect(toast).not.toBeNull();
|
||||
expect(toast.textContent).toContain('Test message');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('toast 返回 this 支持链式', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const { MarkdownEditor } = require('../src/core.js');
|
||||
const ed = new MarkdownEditor(c, {});
|
||||
expect(ed.toast('msg')).toBe(ed);
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.1.6 editor toolbar config', () => {
|
||||
test('configureToolbar 重建工具栏', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const { MarkdownEditor } = require('../src/core.js');
|
||||
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic'] });
|
||||
const before = ed.toolbarEl.querySelectorAll('.me-btn').length;
|
||||
ed.configureToolbar(['bold', 'italic', 'h1', 'h2', '|', 'undo']);
|
||||
const after = ed.toolbarEl.querySelectorAll('.me-btn').length;
|
||||
expect(after).not.toBe(before);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('removeToolbarButton 移除按钮', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const { MarkdownEditor } = require('../src/core.js');
|
||||
const ed = new MarkdownEditor(c, {});
|
||||
const before = ed.toolbarEl.querySelectorAll('.me-btn-bold').length;
|
||||
expect(before).toBe(1);
|
||||
ed.removeToolbarButton('bold');
|
||||
const after = ed.toolbarEl.querySelectorAll('.me-btn-bold').length;
|
||||
expect(after).toBe(0);
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
// Type definitions for MetonaEditor v0.1.5
|
||||
// Type definitions for MetonaEditor v0.1.6
|
||||
// Project: https://git.metona.cn/MetonaTeam/MetonaEditor
|
||||
// Author: thzxx
|
||||
|
||||
|
||||
Reference in New Issue
Block a user