Files
MetonaEditor/src/core.js
T
thzxx f8b9f4a761 release: v0.1.4 — parser enhancement, new syntax, performance optimization
feat(parser): comprehensive Markdown parser enhancement
- Add nested list support (multi-level unordered/ordered, mixed nesting)
- Add Setext headings (=== / ---)
- Add indented code blocks (4-space indent)
- Add HTML comment passthrough (<!-- -->)
- Add entity reference protection (&amp; &#169;)
- Add ordered list start attribute
- Add link title single-quote support
- Add LaTeX math formulas ($inline$ / $$block$$)
- Add footnote support ([^1] ref + [^1]: definition)
- Add definition lists (term\n: definition)
- Extend emoji map from 85 to 150+ common emojis
- Fix backtick code block matching (CommonMark spec)
- Fix italic regex character truncation (lookbehind assertions)
- Fix superscript/subscript/strikethrough ambiguity
- Fix blockquote prefix handling (>text without space)
- Enhance safeUrl filtering (additional protocol checks)
- Enhance slugify (Unicode NFKC normalization)

perf(parser): single-pass inline scanning, render cache, pre-compiled regex
- Replace 14-step chained regex with unified single-pass scanInline()
- Add cachedRenderInline() with FIFO LRU eviction (300-entry cap)
- Pre-compile 15+ static regex constants for block boundary detection
- Skip redundant style='text-align:left' on default-aligned table cells

style(parser): add CSS for footnotes, definition lists, math formulas, sup/sub

test(parser): 44 new test cases covering all v0.1.4 syntax additions (469 total)

chore: bump version 0.1.3 → 0.1.4 across all source files, docs, site pages
2026-07-24 09:35:51 +08:00

1004 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* MetonaEditor Core - 编辑器核心
* @module core
* @version 0.1.4
* @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, applyTheme, saveTheme, getThemeConfig, setThemeVariables } 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 = { ...DEFAULTS.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._lastRenderedValue = null;
// 渲染函数:自定义覆盖内置解析器
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();
// 主题同步:用户显式传 theme 时覆盖全局 CSS 变量并持久化,否则尊重 localStorage
if (options.theme) {
applyTheme(this.config.theme);
saveTheme(this.config.theme);
}
// 将 CSS 变量限定到当前实例的 wrapper 元素,避免污染全局样式
const resolvedThemeConfig = getThemeConfig(this.config.theme);
setThemeVariables(resolvedThemeConfig, this.el);
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.config.readOnly) 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)}`;
if (this.config.readOnly) wrapper.classList.add('me-readonly');
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.readOnly = !!this.config.readOnly;
textarea.style.tabSize = String(this.config.tabSize || 2);
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');
if (typeof this.config.onFocus === 'function') {
try { this.config.onFocus(this); } catch (e) { console.error(e); }
}
};
const onBlur = () => {
this._emit('blur');
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;
if (totalWidth <= 0) return;
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;
// 内容未变化时跳过解析(主题切换等场景触发重渲染但内容不变)
if (this._lastRenderedValue === this._value) return;
this._lastRenderedValue = this._value;
MarkdownEditor.trigger('beforeRender', this);
this._emit('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);
this._emit('afterRender', this);
}
// ============ 历史栈 ============
_scheduleHistory() {
if (this._historyTimer) clearTimeout(this._historyTimer);
this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 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; }
// 检查自定义动作(通过 addToolbarButton 注册)
if (this._customActions && typeof this._customActions[action] === 'function') {
try { this._customActions[action].apply(this, args); } catch (e) { console.error('custom action error:', e); }
}
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 || '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;
// 保存当前滚动位置
const taScroll = this.textarea ? this.textarea.scrollTop : 0;
const pvScroll = this.previewPane ? this.previewPane.scrollTop : 0;
this._mode = mode;
this.bodyEl.className = `me-body me-mode-${mode}`;
this._updateModeButtons();
if (mode !== 'edit') this._render();
// 恢复滚动位置
if (this.textarea) this.textarea.scrollTop = taScroll;
if (this.previewPane) this.previewPane.scrollTop = pvScroll;
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() {
MarkdownEditor.trigger('beforeRender', this);
this._emit('beforeRender', this);
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); }
}
MarkdownEditor.trigger('afterRender', this);
this._emit('afterRender', this);
return html;
}
/**
* 强制刷新预览(内容未变但需重渲染时使用,如主题切换)
*/
refresh() {
this._lastRenderedValue = null;
this._render();
return this;
}
insert(text, opts = {}) {
const ta = this.textarea;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const replace = opts.replace === true;
// replace=true: 替换选区内容;replace=false: 在光标处插入,保留选区
ta.value = ta.value.slice(0, start) + text + ta.value.slice(replace ? end : start);
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() { if (this.textarea) this.textarea.focus(); return this; }
blur() { if (this.textarea) this.textarea.blur(); return this; }
enable() {
if (this.textarea) this.textarea.disabled = false;
if (this.el) this.el.classList.remove('me-disabled');
return this;
}
disable() {
if (this.textarea) this.textarea.disabled = true;
if (this.el) this.el.classList.add('me-disabled');
return this;
}
isDisabled() { return this.textarea ? this.textarea.disabled : false; }
/**
* 设置只读模式
*/
setReadOnly(readOnly) {
this.config.readOnly = !!readOnly;
if (this.textarea) this.textarea.readOnly = !!readOnly;
if (this.el) this.el.classList.toggle('me-readonly', !!readOnly);
return this;
}
isReadOnly() { return this.config.readOnly; }
/**
* 注册实例事件监听
* 支持事件: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.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') {
// 直接绑定 click,不设置 data-action 避免工具栏委托重复触发
btn.addEventListener('click', (e) => { e.stopPropagation(); config.onClick(this); });
} else if (config.action) {
btn.dataset.action = config.action;
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,
readOnly: this.config.readOnly || false,
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;