Initial commit: MetonaEditor v0.1.0

This commit is contained in:
2026-07-23 16:23:07 +08:00
commit 22e867eda8
32 changed files with 17621 additions and 0 deletions
+945
View File
@@ -0,0 +1,945 @@
/**
* MetonaEditor Core - 编辑器核心
* @module core
* @version 0.1.0
* @description MarkdownEditor 类实现:DOM 构建、事件、渲染、历史栈、选区操作、模式切换
*/
import { generateId, escapeHTML, isBrowser } from './utils.js';
import { DEFAULTS, ICONS, THEMES } from './constants.js';
import { injectStyles } from './styles.js';
import { t as i18nT, getCurrentLocale, getLocaleDirection } from './i18n.js';
import { parseMarkdown } from './parser.js';
import { presetPlugins } from './plugins.js';
import { getTheme } from './themes.js';
const MODES = ['edit', 'split', 'preview'];
/**
* 解析主题名称为实际主题(auto -> light/dark
*/
const resolveThemeName = (theme) => {
if (theme && theme !== 'auto') return theme;
return getTheme('auto');
};
/**
* MarkdownEditor 编辑器类
*/
class MarkdownEditor {
/**
* 静态事件钩子(全局,所有实例共享,供插件使用)
*/
static _hooks = new Map();
static on(name, fn) {
if (!this._hooks.has(name)) this._hooks.set(name, []);
this._hooks.get(name).push(fn);
return () => this.off(name, fn);
}
static off(name, fn) {
const list = this._hooks.get(name);
if (list) this._hooks.set(name, list.filter((f) => f !== fn));
}
static trigger(name, instance) {
const list = this._hooks.get(name);
if (list) list.forEach((fn) => {
try { fn(instance); } catch (e) { console.error(`MeEditor hook "${name}" error:`, e); }
});
}
/**
* @param {string|HTMLElement} container - 容器选择器或元素
* @param {Object} options - 配置项(见 DEFAULTS
*/
constructor(container, options = {}) {
if (!isBrowser()) {
this._destroyed = true;
this._value = options.value || '';
return this;
}
this.id = options.id || generateId();
this.container = typeof container === 'string'
? document.querySelector(container)
: container;
if (!this.container) {
console.error('MeEditor: container not found:', container);
this._destroyed = true;
return this;
}
this.config = { ...DEFAULTS, ...options };
if (options.style && typeof options.style === 'object') {
this.config.style = { ...options.style };
}
this._value = this.config.value || '';
this._mode = MODES.includes(this.config.mode) ? this.config.mode : 'split';
this._history = [];
this._historyIndex = -1;
this._cleanups = [];
this._plugins = [];
this._listeners = {};
this._renderRaf = null;
this._historyTimer = null;
this._fullscreen = false;
this._destroyed = false;
// 渲染函数:自定义覆盖内置解析器
this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown;
this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null;
MarkdownEditor.trigger('beforeCreate', this);
injectStyles();
this._buildDOM();
this._buildToolbar();
this._bindEvents();
// 初始内容
this.textarea.value = this._value;
this._pushHistory();
this._render();
this._updateWordCount();
// 安装配置中的插件
if (Array.isArray(this.config.plugins)) {
this.config.plugins.forEach((p) => this.use(p));
}
if (this.config.autofocus) this.focus();
if (typeof this.config.onCreate === 'function') {
try { this.config.onCreate(this); } catch (e) { console.error('onCreate error:', e); }
}
MarkdownEditor.trigger('afterCreate', this);
}
// ============ DOM 构建 ============
_buildDOM() {
const wrapper = document.createElement('div');
wrapper.className = `me-wrapper me-theme-${resolveThemeName(this.config.theme)}`;
wrapper.dataset.id = this.id;
wrapper.setAttribute('role', 'application');
wrapper.setAttribute('aria-label', i18nT('edit'));
if (this.config.className) {
this.config.className.split(/\s+/).filter(Boolean).forEach((c) => wrapper.classList.add(c));
}
// 高度
const h = this.config.height;
wrapper.style.height = typeof h === 'number' ? `${h}px` : (h || '400px');
// 自定义样式
if (this.config.style && typeof this.config.style === 'object') {
Object.entries(this.config.style).forEach(([k, v]) => { wrapper.style[k] = v; });
}
const toolbar = document.createElement('div');
toolbar.className = 'me-toolbar';
toolbar.setAttribute('role', 'toolbar');
const body = document.createElement('div');
body.className = `me-body me-mode-${this._mode}`;
const editorPane = document.createElement('div');
editorPane.className = 'me-editor-pane';
const textarea = document.createElement('textarea');
textarea.className = 'me-textarea';
textarea.spellcheck = !!this.config.spellcheck;
textarea.placeholder = this.config.placeholder || i18nT('placeholder');
textarea.setAttribute('aria-label', i18nT('edit'));
editorPane.appendChild(textarea);
const divider = document.createElement('div');
divider.className = 'me-divider';
divider.setAttribute('role', 'separator');
const previewPane = document.createElement('div');
previewPane.className = 'me-preview-pane';
previewPane.setAttribute('role', 'region');
previewPane.setAttribute('aria-label', i18nT('preview'));
const preview = document.createElement('div');
preview.className = 'me-preview';
previewPane.appendChild(preview);
body.appendChild(editorPane);
body.appendChild(divider);
body.appendChild(previewPane);
wrapper.appendChild(toolbar);
wrapper.appendChild(body);
// 状态栏
let statusbar = null;
if (this.config.wordCount) {
statusbar = document.createElement('div');
statusbar.className = 'me-statusbar';
wrapper.appendChild(statusbar);
}
this.container.appendChild(wrapper);
this.el = wrapper;
this.toolbarEl = toolbar;
this.bodyEl = body;
this.editorPane = editorPane;
this.previewPane = previewPane;
this.dividerEl = divider;
this.textarea = textarea;
this.previewEl = preview;
this.statusEl = statusbar;
}
_buildToolbar() {
const tools = this.config.toolbar;
if (!tools || !Array.isArray(tools) || tools.length === 0) return;
const modeActions = [];
tools.forEach((item) => {
if (item === '|') {
const sep = document.createElement('span');
sep.className = 'me-toolbar-sep';
this.toolbarEl.appendChild(sep);
return;
}
// 模式与全屏按钮归入右侧组
if (item === 'edit' || item === 'split' || item === 'preview' || item === 'fullscreen') {
modeActions.push(item);
return;
}
this.toolbarEl.appendChild(this._createBtn(item));
});
if (modeActions.length) {
const group = document.createElement('div');
group.className = 'me-toolbar-group';
modeActions.forEach((item) => {
const btn = this._createBtn(item);
if (item === 'edit' || item === 'split' || item === 'preview') {
btn.dataset.mode = item;
if (item === this._mode) btn.classList.add('me-active');
}
group.appendChild(btn);
});
this.toolbarEl.appendChild(group);
}
}
_createBtn(item) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `me-btn me-btn-${item}`;
btn.dataset.action = item;
const label = i18nT(item) || item;
btn.title = label;
btn.setAttribute('aria-label', label);
btn.innerHTML = ICONS[item] || `<span>${escapeHTML(item)}</span>`;
return btn;
}
// ============ 事件绑定 ============
_bindEvents() {
const ta = this.textarea;
const onInput = () => {
this._value = ta.value;
this._scheduleRender();
this._scheduleHistory();
this._updateWordCount();
this._emit('input', this._value);
this._emit('change', this._value);
if (typeof this.config.onInput === 'function') {
try { this.config.onInput(this._value, this); } catch (e) { console.error(e); }
}
if (typeof this.config.onChange === 'function') {
try { this.config.onChange(this._value, this); } catch (e) { console.error(e); }
}
};
ta.addEventListener('input', onInput);
const onKeydown = (e) => this._handleKeydown(e);
ta.addEventListener('keydown', onKeydown);
const onScroll = () => {
if (!this.config.syncScroll || this._mode !== 'split') return;
const max = ta.scrollHeight - ta.clientHeight;
const ratio = max > 0 ? ta.scrollTop / max : 0;
const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight;
this.previewPane.scrollTop = ratio * pmax;
};
ta.addEventListener('scroll', onScroll);
const onFocus = () => {
this._emit('focus', this);
if (typeof this.config.onFocus === 'function') {
try { this.config.onFocus(this); } catch (e) { console.error(e); }
}
};
const onBlur = () => {
this._emit('blur', this);
if (typeof this.config.onBlur === 'function') {
try { this.config.onBlur(this); } catch (e) { console.error(e); }
}
};
ta.addEventListener('focus', onFocus);
ta.addEventListener('blur', onBlur);
// 工具栏点击
const onToolbarClick = (e) => {
const btn = e.target.closest('.me-btn');
if (!btn) return;
const mode = btn.dataset.mode;
if (mode) {
this.setMode(mode);
return;
}
const action = btn.dataset.action;
if (action) this.exec(action);
};
this.toolbarEl.addEventListener('click', onToolbarClick);
// 分隔条拖拽(分屏模式)
const onDividerDown = (e) => this._bindDividerDrag(e);
this.dividerEl.addEventListener('pointerdown', onDividerDown);
this._cleanups.push(() => {
ta.removeEventListener('input', onInput);
ta.removeEventListener('keydown', onKeydown);
ta.removeEventListener('scroll', onScroll);
ta.removeEventListener('focus', onFocus);
ta.removeEventListener('blur', onBlur);
this.toolbarEl.removeEventListener('click', onToolbarClick);
this.dividerEl.removeEventListener('pointerdown', onDividerDown);
});
}
_handleKeydown(e) {
// Tab 缩进
if (e.key === 'Tab') {
e.preventDefault();
this._handleTab(e.shiftKey);
return;
}
const mod = e.ctrlKey || e.metaKey;
if (!mod) return;
const key = e.key.toLowerCase();
const shortcuts = {
b: 'bold', i: 'italic', k: 'link', u: 'underline',
e: 'code', '1': 'h1', '2': 'h2', '3': 'h3',
q: 'quote',
};
if (key === 'z' && !e.shiftKey) {
e.preventDefault();
this.undo();
} else if ((key === 'z' && e.shiftKey) || key === 'y') {
e.preventDefault();
this.redo();
} else if (key === 's') {
e.preventDefault();
this._emit('save', this._value);
if (typeof this.config.onSave === 'function') {
try { this.config.onSave(this._value, this); } catch (err) { console.error(err); }
}
} else if (shortcuts[key]) {
e.preventDefault();
this.exec(shortcuts[key]);
}
}
_handleTab(shift) {
const ta = this.textarea;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const tabSize = this.config.tabSize || 0;
const tabStr = tabSize > 0 ? ' '.repeat(tabSize) : '\t';
if (start === end) {
// 无选区:插入缩进
ta.value = ta.value.slice(0, start) + tabStr + ta.value.slice(end);
ta.selectionStart = ta.selectionEnd = start + tabStr.length;
} else {
// 有选区:整块缩进/反缩进
const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1;
const block = ta.value.slice(lineStart, end);
const lines = block.split('\n');
let newBlock;
if (shift) {
newBlock = lines.map((l) => l.replace(/^(\t| {1,4})/, '')).join('\n');
} else {
newBlock = lines.map((l) => tabStr + l).join('\n');
}
ta.value = ta.value.slice(0, lineStart) + newBlock + ta.value.slice(end);
ta.selectionStart = lineStart;
ta.selectionEnd = lineStart + newBlock.length;
}
this._value = ta.value;
this._pushHistory();
this._render();
this._emit('change', this._value);
}
_bindDividerDrag(e) {
if (this._mode !== 'split') return;
e.preventDefault();
const startX = e.clientX;
const editorWidth = this.editorPane.getBoundingClientRect().width;
const totalWidth = this.bodyEl.getBoundingClientRect().width;
const divider = this.dividerEl;
const onMove = (ev) => {
const dx = ev.clientX - startX;
let newEditorWidth = editorWidth + dx;
const min = 80;
const max = totalWidth - 80 - divider.offsetWidth;
newEditorWidth = Math.max(min, Math.min(max, newEditorWidth));
const pct = (newEditorWidth / totalWidth) * 100;
this.editorPane.style.flex = `0 0 ${pct}%`;
this.previewPane.style.flex = `1 1 ${100 - pct}%`;
};
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
}
// ============ 渲染 ============
_scheduleRender() {
if (this._renderRaf) return;
this._renderRaf = requestAnimationFrame(() => {
this._renderRaf = null;
this._render();
});
}
_render() {
if (this._mode === 'edit') return;
MarkdownEditor.trigger('beforeRender', this);
const env = {
highlight: this._highlightFn,
locale: getCurrentLocale(),
};
let html;
try {
html = this._renderFn(this._value, env);
} catch (err) {
console.error('MeEditor render error:', err);
html = `<p style="color:#ef4444">${i18nT('renderError')}: ${escapeHTML(err.message)}</p>`;
}
if (typeof this.config.sanitize === 'function') {
try { html = this.config.sanitize(html); } catch (err) { console.error('sanitize error:', err); }
}
this.previewEl.innerHTML = html;
MarkdownEditor.trigger('afterRender', this);
}
// ============ 历史栈 ============
_scheduleHistory() {
if (this._historyTimer) clearTimeout(this._historyTimer);
this._historyTimer = setTimeout(() => this._pushHistory(), 400);
}
_pushHistory() {
const cur = this._history[this._historyIndex];
if (cur === this._value) return;
this._history = this._history.slice(0, this._historyIndex + 1);
this._history.push(this._value);
const limit = this.config.historyLimit > 0 ? this.config.historyLimit : 100;
while (this._history.length > limit) this._history.shift();
this._historyIndex = this._history.length - 1;
}
undo() {
if (this._historyIndex <= 0) return this;
this._historyIndex--;
this._applyHistory();
return this;
}
redo() {
if (this._historyIndex >= this._history.length - 1) return this;
this._historyIndex++;
this._applyHistory();
return this;
}
_applyHistory() {
this._value = this._history[this._historyIndex];
this.textarea.value = this._value;
this._render();
this._updateWordCount();
this._emit('change', this._value);
if (typeof this.config.onChange === 'function') {
try { this.config.onChange(this._value, this); } catch (e) { console.error(e); }
}
}
canUndo() { return this._historyIndex > 0; }
canRedo() { return this._historyIndex < this._history.length - 1; }
// ============ 命令执行 ============
exec(action, ...args) {
const actions = {
bold: () => this._wrapSelection('**', '**'),
italic: () => this._wrapSelection('*', '*'),
strikethrough: () => this._wrapSelection('~~', '~~'),
underline: () => this._wrapSelection('<u>', '</u>'),
code: () => this._wrapSelection('`', '`'),
h1: () => this._toggleLinePrefix('# '),
h2: () => this._toggleLinePrefix('## '),
h3: () => this._toggleLinePrefix('### '),
quote: () => this._toggleLinePrefix('> '),
ul: () => this._toggleLinePrefix('- '),
ol: () => this._toggleLinePrefix('1. '),
indent: () => this._handleTab(false),
outdent: () => this._handleTab(true),
hr: () => this._insertBlock('\n---\n'),
link: () => this._insertLink(),
image: () => this._insertImage(),
table: () => this._insertTable(),
undo: () => this.undo(),
redo: () => this.redo(),
edit: () => this.setMode('edit'),
split: () => this.setMode('split'),
preview: () => this.setMode('preview'),
fullscreen: () => this.toggleFullscreen(),
};
const fn = actions[action];
if (fn) fn.apply(this, args);
return this;
}
_wrapSelection(before, after) {
const ta = this.textarea;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const selected = ta.value.slice(start, end);
const text = selected || (i18nT('placeholder') || 'text');
const inserted = before + text + after;
ta.value = ta.value.slice(0, start) + inserted + ta.value.slice(end);
ta.focus();
if (selected) {
ta.selectionStart = start + before.length;
ta.selectionEnd = start + before.length + text.length;
} else {
ta.selectionStart = ta.selectionEnd = start + before.length;
}
this._value = ta.value;
this._pushHistory();
this._render();
this._updateWordCount();
this._emit('change', this._value);
}
_toggleLinePrefix(prefix) {
const ta = this.textarea;
const start = ta.selectionStart;
const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1;
const lineEndPos = ta.value.indexOf('\n', start);
const lineEnd = lineEndPos === -1 ? ta.value.length : lineEndPos;
const line = ta.value.slice(lineStart, lineEnd);
const existingMatch = line.match(/^(#{1,6}\s*|>\s*|[-*+]\s*|\d+\.\s*)/);
let newLine;
if (existingMatch && existingMatch[0] === prefix) {
newLine = line.slice(prefix.length);
} else if (existingMatch) {
newLine = prefix + line.slice(existingMatch[0].length);
} else {
newLine = prefix + line;
}
ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd);
ta.focus();
ta.selectionStart = ta.selectionEnd = lineStart + newLine.length;
this._value = ta.value;
this._pushHistory();
this._render();
this._updateWordCount();
this._emit('change', this._value);
}
_insertBlock(text) {
const ta = this.textarea;
const start = ta.selectionStart;
const before = ta.value.slice(0, start);
const needNL = before && !before.endsWith('\n');
const insert = (needNL ? '\n' : '') + text;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(ta.selectionEnd);
ta.focus();
const pos = start + insert.length;
ta.selectionStart = ta.selectionEnd = pos;
this._value = ta.value;
this._pushHistory();
this._render();
this._updateWordCount();
this._emit('change', this._value);
}
_insertLink() {
const ta = this.textarea;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const sel = ta.value.slice(start, end) || i18nT('link') || 'link';
const url = 'https://';
const insert = `[${sel}](${url})`;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end);
ta.focus();
// 选中 url 便于替换
const urlStart = start + sel.length + 3;
ta.selectionStart = urlStart;
ta.selectionEnd = urlStart + url.length;
this._value = ta.value;
this._pushHistory();
this._render();
this._emit('change', this._value);
}
_insertImage() {
const ta = this.textarea;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const sel = ta.value.slice(start, end) || i18nT('image') || 'image';
const url = 'https://';
const insert = `![${sel}](${url})`;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end);
ta.focus();
const urlStart = start + sel.length + 4;
ta.selectionStart = urlStart;
ta.selectionEnd = urlStart + url.length;
this._value = ta.value;
this._pushHistory();
this._render();
this._emit('change', this._value);
}
_insertTable(rows = 3, cols = 3) {
const header = Array.from({ length: cols }, (_, i) => `${i18nT('tableCols') || '列'}${i + 1}`).join(' | ');
const sep = Array.from({ length: cols }, () => '---').join(' | ');
let md = `| ${header} |\n| ${sep} |\n`;
for (let r = 1; r < rows; r++) {
const row = Array.from({ length: cols }, () => ' ').join(' | ');
md += `| ${row} |\n`;
}
this._insertBlock('\n' + md);
}
// ============ 模式与全屏 ============
setMode(mode) {
if (!MODES.includes(mode) || mode === this._mode) return this;
this._mode = mode;
this.bodyEl.className = `me-body me-mode-${mode}`;
this._updateModeButtons();
if (mode !== 'edit') this._render();
this._emit('modeChange', mode);
if (typeof this.config.onModeChange === 'function') {
try { this.config.onModeChange(mode, this); } catch (e) { console.error(e); }
}
return this;
}
getMode() { return this._mode; }
_updateModeButtons() {
this.toolbarEl.querySelectorAll('.me-btn[data-mode]').forEach((b) => {
b.classList.toggle('me-active', b.dataset.mode === this._mode);
});
}
toggleFullscreen() {
this._fullscreen = !this._fullscreen;
this.el.classList.toggle('me-fullscreen', this._fullscreen);
// 更新全屏按钮图标/标题
const fsBtn = this.toolbarEl.querySelector('.me-btn-fullscreen');
if (fsBtn) {
fsBtn.title = this._fullscreen ? (i18nT('fullscreenExit') || '退出全屏') : (i18nT('fullscreen') || '全屏');
}
this._emit('fullscreen', this._fullscreen);
if (typeof this.config.onFullscreen === 'function') {
try { this.config.onFullscreen(this._fullscreen, this); } catch (e) { console.error(e); }
}
return this;
}
isFullscreen() { return this._fullscreen; }
exitFullscreen() {
if (this._fullscreen) this.toggleFullscreen();
return this;
}
// ============ 字数统计 ============
_updateWordCount() {
if (!this.config.wordCount || !this.statusEl) return;
const text = this._value || '';
const chars = text.length;
// 中文字符数 + 英文单词数
const cnChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
const enWords = (text.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length;
const words = cnChars + enWords;
const lines = text ? text.split('\n').length : 0;
const readingMin = Math.max(1, Math.ceil(words / 300));
const parts = [
`${i18nT('characters')}: ${chars}`,
`${i18nT('words')}: ${words}`,
`${i18nT('lines')}: ${lines}`,
`${i18nT('readingTime')}: ${readingMin} ${i18nT('minutes')}`,
];
this.statusEl.textContent = parts.join(' · ');
}
getStats() {
const text = this._value || '';
const cnChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
const enWords = (text.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length;
return {
characters: text.length,
words: cnChars + enWords,
chineseChars: cnChars,
englishWords: enWords,
lines: text ? text.split('\n').length : 0,
readingTime: Math.max(1, Math.ceil((cnChars + enWords) / 300)),
};
}
// ============ 公共 API ============
getValue() { return this._value; }
setValue(md, opts = {}) {
this._value = md || '';
this.textarea.value = this._value;
if (!opts.silent) this._pushHistory();
this._render();
this._updateWordCount();
if (!opts.silent) {
this._emit('change', this._value);
if (typeof this.config.onChange === 'function') {
try { this.config.onChange(this._value, this); } catch (e) { console.error(e); }
}
}
return this;
}
getHTML() {
const env = { highlight: this._highlightFn, locale: getCurrentLocale() };
let html = this._renderFn(this._value, env);
if (typeof this.config.sanitize === 'function') {
try { html = this.config.sanitize(html); } catch (e) { console.error(e); }
}
return html;
}
insert(text, opts = {}) {
const ta = this.textarea;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const replace = opts.replace || false;
ta.value = ta.value.slice(0, replace ? start : start) + text + ta.value.slice(replace ? end : end);
ta.focus();
const pos = start + text.length;
ta.selectionStart = ta.selectionEnd = pos;
this._value = ta.value;
this._pushHistory();
this._render();
this._updateWordCount();
this._emit('change', this._value);
return this;
}
/**
* 在选区两侧包裹文本
*/
wrap(before, after) {
this._wrapSelection(before, after || before);
return this;
}
focus() { this.textarea.focus(); return this; }
blur() { this.textarea.blur(); return this; }
enable() {
this.textarea.disabled = false;
this.el.classList.remove('me-disabled');
return this;
}
disable() {
this.textarea.disabled = true;
this.el.classList.add('me-disabled');
return this;
}
isDisabled() { return this.textarea.disabled; }
/**
* 注册实例事件监听
* 支持事件:change/input/focus/blur/save/modeChange/fullscreen/beforeCreate/afterCreate/beforeRender/afterRender/destroy
*/
on(name, fn) {
if (!this._listeners[name]) this._listeners[name] = [];
this._listeners[name].push(fn);
return () => this.off(name, fn);
}
off(name, fn) {
const list = this._listeners[name];
if (list) this._listeners[name] = list.filter((f) => f !== fn);
return this;
}
_emit(name, ...args) {
const list = this._listeners[name];
if (list) list.forEach((fn) => {
try { fn(...args, this); } catch (e) { console.error(`MeEditor event "${name}" error:`, e); }
});
}
/**
* 安装插件
* @param {string|Object} plugin - 预设插件名或插件对象
* @param {Object} options - 插件配置
*/
use(plugin, options = {}) {
let p = plugin;
if (typeof plugin === 'string') {
p = presetPlugins[plugin];
if (!p) {
console.warn(`MeEditor: preset plugin "${plugin}" not found`);
return this;
}
}
if (!p || typeof p !== 'object') {
console.warn('MeEditor: invalid plugin');
return this;
}
const merged = { ...p, ...options };
if (typeof merged.install === 'function') {
try {
merged.install(this);
} catch (e) {
console.error(`MeEditor: plugin "${merged.name || 'custom'}" install error:`, e);
}
}
this._plugins.push(merged);
return this;
}
getPlugins() { return [...this._plugins]; }
/**
* 向工具栏追加自定义按钮
*/
addToolbarButton(config) {
if (!config || !config.action) return this;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `me-btn me-btn-custom-${config.action}`;
btn.dataset.action = config.action;
btn.title = config.title || config.action;
btn.setAttribute('aria-label', config.title || config.action);
btn.innerHTML = config.icon || `<span>${escapeHTML(config.text || config.action)}</span>`;
if (typeof config.onClick === 'function') {
btn.addEventListener('click', () => config.onClick(this));
} else if (config.action) {
// 注册到 exec 动作
this._customActions = this._customActions || {};
this._customActions[config.action] = config.handler;
}
this.toolbarEl.insertBefore(btn, this.toolbarEl.querySelector('.me-toolbar-group') || null);
return this;
}
/**
* 销毁编辑器
*/
destroy() {
if (this._destroyed) return;
MarkdownEditor.trigger('beforeDestroy', this);
if (this._renderRaf) cancelAnimationFrame(this._renderRaf);
if (this._historyTimer) clearTimeout(this._historyTimer);
this._cleanups.forEach((fn) => { try { fn(); } catch (_) {} });
this._cleanups = [];
// 销毁插件
this._plugins.forEach((p) => {
if (typeof p.destroy === 'function') {
try { p.destroy(this); } catch (_) {}
}
});
this._plugins = [];
if (this.el && this.el.parentNode) {
this.el.parentNode.removeChild(this.el);
}
this.el = null;
this.textarea = null;
this.previewEl = null;
this._destroyed = true;
if (typeof this.config.onDestroy === 'function') {
try { this.config.onDestroy(this); } catch (e) { console.error(e); }
}
// 先触发 destroy 事件,再清空监听器(否则 destroy 监听器永远收不到事件)
this._emit('destroy');
this._listeners = {};
MarkdownEditor.trigger('afterDestroy', this);
}
isDestroyed() { return this._destroyed; }
/**
* 获取编辑器状态
*/
getStatus() {
return {
id: this.id,
mode: this._mode,
theme: this.config.theme,
locale: getCurrentLocale(),
fullscreen: this._fullscreen,
disabled: this.textarea ? this.textarea.disabled : false,
destroyed: this._destroyed,
plugins: this._plugins.map((p) => p.name || 'custom'),
};
}
}
export { MarkdownEditor as Editor, MarkdownEditor };
export default MarkdownEditor;