Files
MetonaEditor/src/core.js
T
thzxx 4e63870180 release: v0.1.12 — accessibility, Zen mode, scroll sync v2, shortcutHelp plugin
feat(a11y): toolbar keyboard navigation (Arrow keys, Home, End)
feat(a11y): aria-live region for screen reader announcements on mode change
feat(a11y): enhance focus-visible style (outline-offset: 2px, hide on mouse focus)

feat(core): Zen mode (editor.toggleZen() / exec('zen'))
- Toggle with Ctrl+Shift+Z shortcut concept
- Auto-hide toolbar and statusbar, mouse to top edge reveals toolbar
- Centered editor pane with max-width constraint

feat(core): heading-aware scroll sync v2
- Sync preview scroll to nearest heading before cursor position
- Falls back to proportional sync when no headings found

feat(core): Word wrap toggle (editor.toggleWordWrap() / setWordWrap(bool))

feat(plugins): shortcutHelp preset plugin
- Press '?' to open keyboard shortcuts overlay panel
- Lists built-in + user-registered shortcuts
- Click overlay or press Escape to close

style: Zen mode CSS, sr-only class, focus-visible refinement

test: 3 new tests for Zen mode, WordWrap, shortcutHelp (535 total)

chore: bump version 0.1.11 → 0.1.12 across all files
2026-07-24 16:10:51 +08:00

1757 lines
55 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.12
* @description MarkdownEditor 类实现:DOM 构建、事件、渲染、历史栈、选区操作、模式切换
* v0.1.6: 插件依赖拓扑排序、实例 i18n、快捷键/工具栏定制、右键菜单、Toast
* v0.1.7: 行号装订线、自动格式化、括号自动闭合、拖放、大纲面板
* v0.1.12: 工具栏键盘导航、aria-live、Zen模式、滚动同步v2、WordWrap
*/
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, createInstanceI18n } from './i18n.js';
import { parseMarkdown } from './parser.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: '',
};
/** 括号/引号自动闭合映射 */
const BRACKET_PAIRS = {
'(': ')', '[': ']', '{': '}',
'"': '"', "'": "'", '`': '`',
'*': '*', '_': '_',
};
/**
* 解析主题名称为实际主题(auto -> light/dark
*/
const resolveThemeName = (theme) => {
if (theme && theme !== 'auto') return theme;
return resolveTheme('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._shortcuts = []; // v0.1.6: 快捷键注册表
this._contextMenuItems = []; // v0.1.6: 右键菜单项
this._customActions = {}; // 自定义工具栏动作
this._outlineTimer = null; // 大纲防抖计时器
this._zenMode = false; // v0.1.12: Zen 模式
this._wordWrap = true; // v0.1.12: 自动换行
// 渲染函数:自定义覆盖内置解析器
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();
// 实例级主题管理(v0.1.5
this._themeCtx = createInstanceTheme(this);
// 实例级 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));
}
}
// 确保 CSS 变量作用到实例 wrapper
const resolvedThemeConfig = getThemeConfig(this.config.theme);
setThemeVariables(resolvedThemeConfig, this.el);
this._buildToolbar();
this._updateModeButtons(); // 初始化按钮状态
this._bindToolbarKeyboard(); // v0.1.12: 键盘导航
this._initAriaLive(); // v0.1.12: 屏幕阅读器
this._bindEvents();
// 初始内容
this.textarea.value = this._value;
this._pushHistory();
this._render();
this._updateWordCount();
// 安装配置中的插件(v0.1.6: 拓扑排序)
if (Array.isArray(this.config.plugins)) {
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();
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';
// 编辑区内层容器(行号 + textarea 水平排列)
const editorInner = document.createElement('div');
editorInner.className = 'me-editor-inner';
// 行号装订线(v0.1.7
const gutter = document.createElement('div');
gutter.className = 'me-gutter';
if (!this.config.lineNumbers) gutter.style.display = 'none';
editorInner.appendChild(gutter);
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'));
editorInner.appendChild(textarea);
editorPane.appendChild(editorInner);
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.editorInner = editorInner;
this.gutter = gutter;
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._renderGutter();
this._updateOutline();
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) => {
// v0.1.7: 智能 Enter
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
this._handleSmartEnter(e);
}
this._handleKeydown(e);
};
ta.addEventListener('keydown', onKeydown);
// v0.1.7: 括号/引号自动闭合
const onKeypress = (e) => this._handleBracketAutoClose(e);
ta.addEventListener('keypress', onKeypress);
// v0.1.7: 当前行高亮
const onCursorActivity = () => { this._updateCurrentLine(); };
ta.addEventListener('click', onCursorActivity);
ta.addEventListener('keyup', onCursorActivity);
const onScroll = () => {
if (this.gutter) this.gutter.scrollTop = ta.scrollTop;
// v0.1.12: 标题感知同步
if (this.config.syncScroll && this._mode === 'split') {
this._syncScrollByHeading();
}
};
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);
// v0.1.7: 拖放支持
this._bindDragDrop();
this._cleanups.push(() => {
ta.removeEventListener('input', onInput);
ta.removeEventListener('keydown', onKeydown);
ta.removeEventListener('keypress', onKeypress);
ta.removeEventListener('scroll', onScroll);
ta.removeEventListener('click', onCursorActivity);
ta.removeEventListener('keyup', onCursorActivity);
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;
}
// 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;
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;
this._renderGutter();
MarkdownEditor.trigger('afterRender', this);
this._emit('afterRender', this);
}
// ============ 行号渲染(v0.1.7 ============
_renderGutter() {
if (!this.config.lineNumbers || !this.gutter) return;
const lines = this._value ? this._value.split('\n').length : 1;
const cur = this.gutter.children.length;
if (cur === lines) return; // 行数未变,跳过
let html = '';
for (let i = 1; i <= lines; i++) {
html += `<div class="me-gutter-line">${i}</div>`;
}
this.gutter.innerHTML = html;
}
_updateCurrentLine() {
if (!this.gutter) return;
const pos = this.textarea.selectionStart;
const lineNum = this._value.substring(0, pos).split('\n').length;
const prev = this.gutter.querySelector('.me-gutter-active');
if (prev) prev.classList.remove('me-gutter-active');
const cur = this.gutter.children[lineNum - 1];
if (cur) cur.classList.add('me-gutter-active');
}
// ============ 自动格式化(v0.1.7 ============
/**
* 智能 Enter:在列表/引用行尾自动延续标记
*/
_handleSmartEnter(e) {
const ta = this.textarea;
const start = ta.selectionStart;
const val = ta.value;
const lineStart = val.lastIndexOf('\n', start - 1) + 1;
const line = val.slice(lineStart, start);
// 列表项延续:匹配 "- " / "* " / "+ " / "1. " / "2. " 等
const listMatch = line.match(/^(\s*)([-*+]|\d+\.)\s(.*)/);
if (listMatch) {
const indent = listMatch[1];
const marker = listMatch[2];
const content = listMatch[3];
// 空列表项 → 结束列表(移除标记)
if (!content.trim()) {
e.preventDefault();
ta.value = val.slice(0, lineStart) + '\n' + val.slice(start);
ta.selectionStart = ta.selectionEnd = lineStart;
this._value = ta.value;
this._pushHistory();
this._renderGutter();
this._emit('change', this._value);
return;
}
// 有序列表自动递增
let nextMarker = marker;
if (/^\d+\.$/.test(marker)) {
const num = parseInt(marker, 10);
if (!isNaN(num)) nextMarker = (num + 1) + '.';
}
e.preventDefault();
const insert = '\n' + indent + nextMarker + ' ';
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + insert.length;
this._value = ta.value;
this._pushHistory();
this._renderGutter();
this._emit('change', this._value);
return;
}
// 引用块延续
const quoteMatch = line.match(/^(\s*>+\s?)(.*)/);
if (quoteMatch) {
const prefix = quoteMatch[1];
const content = quoteMatch[2];
if (!content.trim()) {
e.preventDefault();
ta.value = val.slice(0, lineStart) + '\n' + val.slice(start);
ta.selectionStart = ta.selectionEnd = lineStart;
this._value = ta.value;
this._pushHistory();
this._renderGutter();
this._emit('change', this._value);
return;
}
e.preventDefault();
const insert = '\n' + prefix;
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + insert.length;
this._value = ta.value;
this._pushHistory();
this._renderGutter();
this._emit('change', this._value);
return;
}
}
// ============ 括号/引号自动闭合(v0.1.7 ============
_handleBracketAutoClose(e) {
if (!this.config.autoBrackets) return;
const ta = this.textarea;
const key = e.key;
const close = BRACKET_PAIRS[key];
if (!close) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const hasSelection = start !== end;
// 有选区:包裹选中文本
if (hasSelection) {
e.preventDefault();
const selected = ta.value.slice(start, end);
const wrap = key === '*' || key === '_' ? key + selected + close : key + selected + close;
ta.value = ta.value.slice(0, start) + wrap + ta.value.slice(end);
ta.selectionStart = start + 1;
ta.selectionEnd = start + 1 + selected.length;
this._value = ta.value;
this._pushHistory();
this._emit('change', this._value);
return;
}
// 无选区:判断是否应插入闭合对
const nextChar = ta.value[start] || '';
const isQuotePair = key === close; // 引号自己闭合自己
if (isQuotePair) {
// 引号:下一个字符是同种引号 → 跳过而非插入
if (nextChar === close) {
e.preventDefault();
ta.selectionStart = ta.selectionEnd = start + 1;
return;
}
// 光标在单词中间 → 不自动闭合
if (/\w/.test(nextChar)) return;
}
// 括号:下一个字符非空白/换行/闭合括号 → 可能不需要闭合
// 简单策略:始终闭合
e.preventDefault();
const pair = key + close;
ta.value = ta.value.slice(0, start) + pair + ta.value.slice(end);
ta.selectionStart = ta.selectionEnd = start + 1;
this._value = ta.value;
this._pushHistory();
this._emit('change', this._value);
}
// ============ 拖放支持(v0.1.7 ============
_bindDragDrop() {
const ta = this.textarea;
if (!ta) return;
const onDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; };
const onDrop = (e) => {
e.preventDefault();
const files = e.dataTransfer.files;
if (files && files.length) {
Array.from(files).forEach((file) => {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = () => {
const name = file.name || `image-${Date.now().toString(36)}.png`;
this.insert(`![${name}](${reader.result})\n`);
};
reader.readAsDataURL(file);
} else if (file.type.startsWith('text/') || file.name.match(/\.(md|txt|js|ts|json|css|html|xml|yml|yaml)$/i)) {
const reader = new FileReader();
reader.onload = () => this.insert(reader.result);
reader.readAsText(file);
}
});
return;
}
// 拖入外部文本
const text = e.dataTransfer.getData('text/plain');
if (text) {
this.insert(text);
}
};
ta.addEventListener('dragover', onDragOver);
ta.addEventListener('drop', onDrop);
this._cleanups.push(() => {
ta.removeEventListener('dragover', onDragOver);
ta.removeEventListener('drop', onDrop);
});
}
// ============ 大纲面板(v0.1.7 ============
_buildOutline() {
if (!this.config.outline || !this.previewEl) return;
// 移除旧面板
const old = this.el.querySelector('.me-outline');
if (old) old.remove();
const headings = [];
const headingRe = /<h([1-6])\s+id="([^"]+)"[^>]*>(.+?)<\/h\1>/gi;
const html = this.previewEl.innerHTML;
let m;
while ((m = headingRe.exec(html)) !== null) {
headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, '') });
}
if (!headings.length) return;
const panel = document.createElement('div');
panel.className = 'me-outline';
panel.innerHTML = '<div class="me-outline-title">' + (i18nT('outline') || '大纲') + '</div>';
const buildTree = (items, minLevel) => {
let h = '<ul>';
let i = 0;
while (i < items.length) {
const item = items[i];
if (item.level < minLevel) break;
h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.id}">${escapeHTML(item.text)}</a>`;
// 收集子项
const subItems = [];
let j = i + 1;
while (j < items.length && items[j].level > item.level) {
subItems.push(items[j]);
j++;
}
if (subItems.length) {
h += buildTree(subItems, item.level + 1);
}
h += '</li>';
i = j > i + 1 ? j : i + 1;
}
h += '</ul>';
return h;
};
panel.innerHTML += buildTree(headings, 1);
this.el.appendChild(panel);
// 点击跳转
panel.addEventListener('click', (e) => {
const a = e.target.closest('a');
if (!a) return;
e.preventDefault();
const id = a.getAttribute('href').slice(1);
const target = this.previewEl.querySelector('#' + CSS.escape(id));
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
// 同步滚动 textarea
const headingText = target.textContent;
const idx = this._value.indexOf(headingText);
if (idx !== -1) {
this.textarea.focus();
this.textarea.setSelectionRange(idx, idx);
const lineNum = this._value.substring(0, idx).split('\n').length - 1;
this.textarea.scrollTop = lineNum * 20;
}
}
});
}
_updateOutline() {
if (!this.config.outline) return;
// 防抖重建
clearTimeout(this._outlineTimer);
this._outlineTimer = setTimeout(() => this._buildOutline(), 300);
}
_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._renderGutter();
this._updateWordCount();
// 同步行号滚动位置
if (this.gutter) this.gutter.scrollTop = this.textarea.scrollTop;
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(),
zen: () => this.toggleZen(), // v0.1.12
wordwrap: () => this.toggleWordWrap(), // v0.1.12
};
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);
this._announce(`${i18nT(mode) || mode} ${i18nT('mode') || 'mode'}`); // v0.1.12
if (typeof this.config.onModeChange === 'function') {
try { this.config.onModeChange(mode, this); } catch (e) { console.error(e); }
}
return this;
}
getMode() { return this._mode; }
_updateModeButtons() {
const isPreview = this._mode === 'preview';
const isReadonly = this.config.readOnly;
this.toolbarEl.querySelectorAll('.me-btn').forEach((b) => {
const mode = b.dataset.mode;
const action = b.dataset.action;
// 模式切换按钮始终可用,高亮当前模式
if (mode) {
b.classList.toggle('me-active', mode === this._mode);
b.disabled = false;
return;
}
// fullscreen 始终可用
if (action === 'fullscreen') {
b.disabled = false;
return;
}
// readonly 模式:禁用编辑操作按钮,保留 undo/redo
if (isReadonly) {
b.disabled = !(action === 'undo' || action === 'redo');
return;
}
// preview 模式:禁用编辑操作按钮
if (isPreview) {
b.disabled = !(action === 'undo' || action === 'redo');
return;
}
b.disabled = false;
});
}
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;
}
// ============ Zen 模式(v0.1.12 ============
toggleZen() {
this._zenMode = !this._zenMode;
if (this.el) this.el.classList.toggle('me-zen', this._zenMode);
// 鼠标移到顶部时显示工具栏
if (this._zenMode && this.toolbarEl) {
this._zenMouseHandler = (e) => {
this.toolbarEl.style.opacity = e.clientY < 40 ? '1' : '0';
this.toolbarEl.style.pointerEvents = e.clientY < 40 ? 'auto' : 'none';
};
this.toolbarEl.style.transition = 'opacity 0.2s';
this.toolbarEl.style.opacity = '0';
this.toolbarEl.style.pointerEvents = 'none';
document.addEventListener('mousemove', this._zenMouseHandler);
// 隐藏状态栏
if (this.statusEl) this.statusEl.style.display = 'none';
} else {
if (this._zenMouseHandler) {
document.removeEventListener('mousemove', this._zenMouseHandler);
this._zenMouseHandler = null;
}
if (this.toolbarEl) {
this.toolbarEl.style.opacity = '';
this.toolbarEl.style.pointerEvents = '';
this.toolbarEl.style.transition = '';
}
if (this.statusEl) this.statusEl.style.display = '';
}
this._emit('zenChange', this._zenMode);
return this;
}
isZen() { return this._zenMode; }
// ============ Word Wrapv0.1.12 ============
toggleWordWrap() {
this._wordWrap = !this._wordWrap;
if (this.textarea) {
this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre';
}
return this;
}
setWordWrap(on) {
this._wordWrap = !!on;
if (this.textarea) {
this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre';
}
return this;
}
isWordWrap() { return this._wordWrap; }
// ============ 工具栏键盘导航(v0.1.12 无障碍) ============
_bindToolbarKeyboard() {
if (!this.toolbarEl) return;
const onKeydown = (e) => {
const btns = [...this.toolbarEl.querySelectorAll('.me-btn:not(:disabled)')];
if (!btns.length) return;
const idx = btns.indexOf(document.activeElement);
if (idx === -1) return;
if (e.key === 'ArrowRight') {
e.preventDefault();
const next = (idx + 1) % btns.length;
btns[next].focus();
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
const prev = (idx - 1 + btns.length) % btns.length;
btns[prev].focus();
} else if (e.key === 'Home') {
e.preventDefault();
btns[0].focus();
} else if (e.key === 'End') {
e.preventDefault();
btns[btns.length - 1].focus();
}
};
this.toolbarEl.addEventListener('keydown', onKeydown);
this._cleanups.push(() => this.toolbarEl.removeEventListener('keydown', onKeydown));
}
// ============ aria-live 区域(v0.1.12 无障碍) ============
_initAriaLive() {
if (!this.el) return;
const live = document.createElement('div');
live.className = 'me-sr-only';
live.setAttribute('aria-live', 'polite');
live.setAttribute('aria-atomic', 'true');
this.el.appendChild(live);
this._ariaLive = live;
}
_announce(msg) {
if (this._ariaLive) {
this._ariaLive.textContent = '';
requestAnimationFrame(() => { this._ariaLive.textContent = msg; });
}
}
// ============ 滚动同步 v2 — 标题感知(v0.1.12 ============
_syncScrollByHeading() {
if (!this.config.syncScroll || this._mode !== 'split') return;
const ta = this.textarea;
if (!ta) return;
// 找到光标所在位置最近的标题
const pos = ta.selectionStart;
const before = this._value.substring(0, pos);
const headings = [...before.matchAll(/^(#{1,6})\s+(.+)$/gm)];
if (!headings.length) {
// 无标题,回退到比例同步
const max = ta.scrollHeight - ta.clientHeight;
if (max <= 0) return;
const ratio = ta.scrollTop / max;
const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight;
this.previewPane.scrollTop = ratio * pmax;
return;
}
// 取最后一个标题(光标之前最近的标题)
const lastH = headings[headings.length - 1];
const headingText = lastH[2].trim();
// 在预览区查找对应标题元素
const previewEls = this.previewEl.querySelectorAll('h1, h2, h3, h4, h5, h6');
for (const el of previewEls) {
if (el.textContent.trim() === headingText) {
el.scrollIntoView({ block: 'start', behavior: 'instant' });
return;
}
}
// fallback
const max = ta.scrollHeight - ta.clientHeight;
if (max <= 0) return;
this.previewPane.scrollTop = (ta.scrollTop / max) * (this.previewPane.scrollHeight - this.previewPane.clientHeight);
}
_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._destroyed ? '' : this._value; }
setValue(md, opts = {}) {
if (this._destroyed) return this;
this._value = md || '';
this.textarea.value = this._value;
if (!opts.silent) this._pushHistory();
this._render();
this._renderGutter();
this._updateWordCount();
this._updateOutline();
// 同步行号滚动
if (this.gutter) this.gutter.scrollTop = 0;
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);
this._updateModeButtons();
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); }
});
}
/**
* 安装插件(v0.1.6: 支持异步 install
* @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 {
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]; }
/**
* 向工具栏追加自定义按钮
*/
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;
}
// ============ 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);
}
/**
* 销毁编辑器
*/
destroy() {
if (this._destroyed) return;
MarkdownEditor.trigger('beforeDestroy', this);
if (this._renderRaf) cancelAnimationFrame(this._renderRaf);
if (this._historyTimer) clearTimeout(this._historyTimer);
if (this._outlineTimer) clearTimeout(this._outlineTimer);
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) => {
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; }
/**
* 设置实例主题(v0.1.5: 实例隔离,不影响其他编辑器)
* @param {string} theme - 主题名
* @returns {MarkdownEditor}
*/
setTheme(theme) {
if (!theme) return this;
this.config.theme = theme;
if (this._themeCtx) {
this._themeCtx.set(theme);
}
return this;
}
/**
* 获取实例当前主题名
* @returns {string}
*/
getTheme() {
if (this._themeCtx) return this._themeCtx.get();
return this.config.theme || 'auto';
}
/**
* 获取实例主题上下文(高级 API)
* 提供 syncWithElement / adopt / watch 等外部跟随能力
* @returns {Object|null}
*/
getThemeContext() {
return this._themeCtx || null;
}
/**
* 获取编辑器状态
*/
getStatus() {
return {
id: this.id,
mode: this._mode,
theme: this.getTheme(),
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;