feat: v0.2.0 — TypeScript full rewrite, 95%+ core coverage

BREAKING CHANGE: All source files converted from JavaScript to TypeScript.
- 12 .ts source files with strict types, full EditorOptions/Plugin/Token interfaces
- 7 .ts test files, 610 total tests (27 new), 7 suites all passing
- tsc --noEmit: 0 errors
- rollup-plugin-typescript build: 5 artifacts (UMD/ESM/CJS/Min/DTS)
- @babel/preset-typescript for jest
- New tsconfig.json, updated babel/jest/rollup configs
- Coverage: parser 99.5%, utils 95.7%, themes 96.2%, core 88.8%, plugins 89.5%
- Removed types/ folder (types now inline in .ts + auto-generated .d.ts)
- Desktop-only, no backward compatibility
This commit is contained in:
2026-07-24 22:28:38 +08:00
parent d7cae48073
commit e83fc211dc
40 changed files with 3563 additions and 6913 deletions
+748
View File
@@ -0,0 +1,748 @@
/**
* MetonaEditor Core — MarkdownEditor class (TypeScript)
* @module core
* @version 0.2.0
*/
import { generateId, escapeHTML, isBrowser } from './utils';
import { DEFAULTS, ICONS, THEMES } from './constants';
import type { EditorOptions, EditMode, ToolbarItem, RenderEnv, ThemeName } from './constants';
import { injectStyles } from './styles';
import { t as i18nT, getCurrentLocale, getLocaleDirection, createInstanceI18n } from './i18n';
import type { InstanceI18n } from './i18n';
import { parseMarkdown } from './parser';
import { presetPlugins, topologicalSort } from './plugins';
import { resolveTheme, getThemeConfig, setThemeVariables, createInstanceTheme } from './themes';
const MODES: EditMode[] = ['edit', 'split', 'preview'];
const TOAST_ICONS: Record<string, string> = { success: '✓', error: '✗', warning: '⚠', info: '' };
const BRACKET_PAIRS: Record<string, string> = {
'(': ')', '[': ']', '{': '}', '"': '"', "'": "'", '`': '`', '*': '*', '_': '_',
};
const resolveThemeName = (theme: string): string => {
if (theme && theme !== 'auto') return theme;
return resolveTheme('auto');
};
export class MarkdownEditor {
static _hooks = new Map<string, Array<(editor: MarkdownEditor) => void>>();
static on(name: string, fn: (editor: MarkdownEditor) => void): () => void {
if (!this._hooks.has(name)) this._hooks.set(name, []);
this._hooks.get(name)!.push(fn);
return () => this.off(name, fn);
}
static off(name: string, fn: (editor: MarkdownEditor) => void): void {
const list = this._hooks.get(name);
if (list) this._hooks.set(name, list.filter((f) => f !== fn));
}
static trigger(name: string, instance: MarkdownEditor): void {
const list = this._hooks.get(name);
if (list) list.forEach((fn) => { try { fn(instance); } catch (e) { console.error(`MeEditor hook "${name}" error:`, e); } });
}
id!: string;
container!: HTMLElement;
config!: EditorOptions;
el!: HTMLElement;
toolbarEl!: HTMLElement;
bodyEl!: HTMLElement;
editorPane!: HTMLElement;
editorInner!: HTMLElement;
previewPane!: HTMLElement;
dividerEl!: HTMLElement;
textarea!: HTMLTextAreaElement;
previewEl!: HTMLElement;
gutter!: HTMLElement;
statusEl!: HTMLElement | null;
_value!: string;
_mode!: EditMode;
_history!: string[];
_historyIndex!: number;
_cleanups!: Array<() => void>;
_plugins!: any[];
_listeners!: Record<string, Array<(...args: any[]) => void>>;
_renderRaf!: number | null;
_historyTimer!: ReturnType<typeof setTimeout> | null;
_fullscreen!: boolean;
_destroyed!: boolean;
_lastRenderedValue!: string | null;
_shortcuts!: any[];
_contextMenuItems!: any[];
_customActions!: Record<string, (...args: any[]) => void>;
_outlineTimer!: ReturnType<typeof setTimeout> | null;
_zenMode!: boolean;
_wordWrap!: boolean;
_syncing!: boolean;
_zenMouseHandler!: ((e: MouseEvent) => void) | null;
_ariaLive!: HTMLElement;
_renderFn!: (md: string, env: RenderEnv) => string;
_highlightFn!: ((code: string, lang: string) => string) | null;
_themeCtx: any;
_i18nCtx!: InstanceI18n;
constructor(container: string | HTMLElement, options: EditorOptions = {}) {
if (!isBrowser()) {
this._destroyed = true; this._value = options.value || ''; return this as any;
}
this.id = options.id || generateId();
this.container = typeof container === 'string' ? document.querySelector(container) as HTMLElement : container;
if (!this.container) {
console.error('MeEditor: container not found:', container);
this._destroyed = true; return this as any;
}
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 = []; this._contextMenuItems = []; this._customActions = {};
this._outlineTimer = null; this._zenMode = false; this._wordWrap = true; this._syncing = false;
this._zenMouseHandler = 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();
this._themeCtx = createInstanceTheme(this);
this._i18nCtx = createInstanceI18n(this);
if (options.theme && typeof document !== 'undefined') {
document.documentElement.setAttribute('data-md-theme', resolveTheme(options.theme));
}
const resolvedThemeConfig = getThemeConfig(this.config.theme!);
setThemeVariables(resolvedThemeConfig, this.el);
this._buildToolbar();
this._updateModeButtons();
this._bindToolbarKeyboard();
this._initAriaLive();
this._bindEvents();
this.textarea.value = this._value;
this._pushHistory();
this._render();
this._updateWordCount();
if (Array.isArray(this.config.plugins)) {
const plugins = this.config.plugins.map((p: any) => {
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);
}
_buildDOM(): void {
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]) => { if (v) (wrapper.style as any)[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 editorInner = document.createElement('div'); editorInner.className = 'me-editor-inner';
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: HTMLElement | null = 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(): void {
const tools = this.config.toolbar;
if (!tools || !Array.isArray(tools) || tools.length === 0) return;
const modeActions: string[] = [];
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: string): HTMLButtonElement {
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(): void {
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: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) this._handleSmartEnter(e);
this._handleKeydown(e);
};
ta.addEventListener('keydown', onKeydown);
const onKeypress = (e: KeyboardEvent) => this._handleBracketAutoClose(e);
ta.addEventListener('keypress', onKeypress);
const onCursorActivity = () => { this._updateCurrentLine(); };
ta.addEventListener('click', onCursorActivity);
ta.addEventListener('keyup', onCursorActivity);
const onScroll = () => { this._syncScroll(); };
ta.addEventListener('scroll', onScroll);
const onPreviewScroll = () => {
if (!this.config.syncScroll || this._mode !== 'split') return;
if (this._syncing) return; this._syncing = true;
const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight;
if (pmax <= 0) { this._syncing = false; return; }
const ratio = this.previewPane.scrollTop / pmax;
const tmax = ta.scrollHeight - ta.clientHeight;
ta.scrollTop = ratio * tmax;
if (this.gutter) { const gmax = this.gutter.scrollHeight - this.gutter.clientHeight; this.gutter.scrollTop = gmax > 0 ? ratio * gmax : ratio * tmax; }
requestAnimationFrame(() => { this._syncing = false; });
};
this.previewPane.addEventListener('scroll', onPreviewScroll);
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 onPreviewClick = (e: MouseEvent) => {
const a = (e.target as HTMLElement).closest('a'); if (!a) return;
const href = a.getAttribute('href'); if (!href || href.startsWith('#')) return;
e.preventDefault(); this._emit('linkClick', { href, text: a.textContent });
if (typeof (this.config as any).onLinkClick === 'function') { try { (this.config as any).onLinkClick(href, a.textContent, this); } catch (_) {} return; }
window.open(href, '_blank', 'noopener');
};
this.previewEl.addEventListener('click', onPreviewClick);
const onToolbarClick = (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest('.me-btn') as HTMLElement; if (!btn) return;
const mode = btn.dataset.mode; if (mode) { this.setMode(mode as EditMode); return; }
const action = btn.dataset.action; if (action) this.exec(action);
};
this.toolbarEl.addEventListener('click', onToolbarClick);
const onDividerDown = (e: PointerEvent) => this._bindDividerDrag(e);
this.dividerEl.addEventListener('pointerdown', onDividerDown);
this._bindDragDrop();
this._cleanups.push(() => {
ta.removeEventListener('input', onInput); ta.removeEventListener('keydown', onKeydown);
ta.removeEventListener('keypress', onKeypress); ta.removeEventListener('scroll', onScroll);
this.previewPane.removeEventListener('scroll', onPreviewScroll);
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: KeyboardEvent): void {
if (e.key === 'Tab') { e.preventDefault(); this._handleTab(e.shiftKey); return; }
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 error:', err); } }
return;
}
}
}
const mod = e.ctrlKey || e.metaKey; if (!mod) return;
const key = e.key.toLowerCase();
const shortcuts: Record<string, string> = { 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: boolean): void {
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: string;
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: PointerEvent): void {
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: PointerEvent) => {
const dx = ev.clientX - startX;
let newW = editorWidth + dx;
const min = 80; const max = totalWidth - 80 - divider.offsetWidth;
newW = Math.max(min, Math.min(max, newW));
const pct = (newW / 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(): void { if (this._renderRaf) return; this._renderRaf = requestAnimationFrame(() => { this._renderRaf = null; this._render(); }); }
_render(): void {
if (this._mode === 'edit') return;
if (this._lastRenderedValue === this._value) return;
this._lastRenderedValue = this._value;
MarkdownEditor.trigger('beforeRender', this); this._emit('beforeRender', this);
let html: string;
try { html = this._renderFn(this._value, { highlight: this._highlightFn || undefined, locale: getCurrentLocale() }); }
catch (err: any) { 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 (e) { console.error('sanitize error:', e); } }
this.previewEl.innerHTML = html; this._renderGutter();
MarkdownEditor.trigger('afterRender', this); this._emit('afterRender', this);
}
_renderGutter(): void {
if (!this.config.lineNumbers || !this.gutter) return;
const lines = this._value ? this._value.split('\n').length : 1;
if (this.gutter.children.length === lines) return;
let h = ''; for (let i = 1; i <= lines; i++) h += `<div class="me-gutter-line">${i}</div>`;
this.gutter.innerHTML = h;
}
_updateCurrentLine(): void {
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] as HTMLElement; if (cur) cur.classList.add('me-gutter-active');
}
_handleSmartEnter(e: KeyboardEvent): void {
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);
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);
}
}
_handleBracketAutoClose(e: KeyboardEvent): void {
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;
if (start !== end) { e.preventDefault(); const selected = ta.value.slice(start, end); const wrap = 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] || '';
if (key === close) { 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);
}
_bindDragDrop(): void {
const ta = this.textarea; if (!ta) return;
const onDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; };
const onDrop = (e: DragEvent) => {
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 = () => { this.insert(`![${file.name}](${reader.result})\n`); }; reader.readAsDataURL(file); }
else if (file.type.startsWith('text/') || /\.(md|txt|js|ts|json|css|html|xml|yml|yaml)$/i.test(file.name)) { const reader = new FileReader(); reader.onload = () => this.insert(reader.result as string); 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); });
}
_buildOutline(): void {
if (!this.config.outline || !this.previewEl) return;
const old = this.el.querySelector('.me-outline'); if (old) old.remove();
const headings: Array<{ level: number; id: string; text: string }> = [];
const headingRe = /<h([1-6])\s+id="([^"]+)"[^>]*>(.+?)<\/h\1>/gi;
let m: RegExpExecArray | null;
while ((m = headingRe.exec(this.previewEl.innerHTML)) !== 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: typeof headings, minLevel: number): string => {
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 sub: typeof headings = []; let j = i + 1; while (j < items.length && items[j].level > item.level) { sub.push(items[j]); j++; }
if (sub.length) h += buildTree(sub, item.level + 1);
h += '</li>'; i = j > i + 1 ? j : i + 1;
}
return h + '</ul>';
};
panel.innerHTML += buildTree(headings, 1);
this.el.appendChild(panel);
panel.addEventListener('click', (e) => {
const a = (e.target as HTMLElement).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' }); const idx = this._value.indexOf(target.textContent || ''); if (idx !== -1) { this.textarea.focus(); this.textarea.setSelectionRange(idx, idx); } }
});
}
_updateOutline(): void { if (!this.config.outline) return; if (this._outlineTimer) clearTimeout(this._outlineTimer); this._outlineTimer = setTimeout(() => this._buildOutline(), 300); }
_scheduleHistory(): void { if (this._historyTimer) clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); }
_pushHistory(): void {
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(): this { if (this._historyIndex <= 0) return this; this._historyIndex--; this._applyHistory(); return this; }
redo(): this { if (this._historyIndex >= this._history.length - 1) return this; this._historyIndex++; this._applyHistory(); return this; }
canUndo(): boolean { return this._historyIndex > 0; }
canRedo(): boolean { return this._historyIndex < this._history.length - 1; }
_applyHistory(): void {
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;
if (this.previewPane && this.config.syncScroll) this.previewPane.scrollTop = 0;
this._emit('change', this._value);
if (typeof this.config.onChange === 'function') { try { this.config.onChange(this._value, this); } catch (e) { console.error(e); } }
}
exec(action: string, ...args: any[]): this {
const actions: Record<string, () => void> = {
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(), wordwrap: () => this.toggleWordWrap(),
};
const fn = actions[action];
if (fn) { (fn as Function).apply(this, args); return this; }
if (this._customActions && typeof this._customActions[action] === 'function') {
try { (this._customActions[action] as Function).apply(this, args); } catch (e) { console.error('custom action error:', e); }
}
return this;
}
_wrapSelection(before: string, after: string): void {
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: string): void {
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: string;
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: string): void {
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(): void { 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(); ta.selectionStart = start + sel.length + 3; ta.selectionEnd = ta.selectionStart + url.length; this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); }
_insertImage(): void { 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(); ta.selectionStart = start + sel.length + 4; ta.selectionEnd = ta.selectionStart + url.length; this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); }
_insertTable(rows = 3, cols = 3): void { 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++) md += `| ${Array.from({ length: cols }, () => ' ').join(' | ')} |\n`; this._insertBlock('\n' + md); }
setMode(mode: EditMode): this { if (!MODES.includes(mode) || mode === this._mode) return this; const taS = this.textarea.scrollTop; const pvS = this.previewPane.scrollTop; this._mode = mode; this.bodyEl.className = `me-body me-mode-${mode}`; this._updateModeButtons(); if (mode !== 'edit') this._render(); this.textarea.scrollTop = taS; this.previewPane.scrollTop = pvS; this._emit('modeChange', mode); this._announce(`${i18nT(mode) || mode} mode`); if (typeof this.config.onModeChange === 'function') { try { this.config.onModeChange(mode, this); } catch (e) { console.error(e); } } return this; }
getMode(): EditMode { return this._mode; }
_updateModeButtons(): void {
const isPreview = this._mode === 'preview'; const isReadonly = this.config.readOnly;
this.toolbarEl.querySelectorAll('.me-btn').forEach((b) => {
const btn = b as HTMLButtonElement; const mode = btn.dataset.mode; const action = btn.dataset.action;
if (mode) { btn.classList.toggle('me-active', mode === this._mode); btn.disabled = false; return; }
if (action === 'fullscreen') { btn.disabled = false; return; }
if (isReadonly) { btn.disabled = !(action === 'undo' || action === 'redo'); return; }
if (isPreview) { btn.disabled = !(action === 'undo' || action === 'redo'); return; }
btn.disabled = false;
});
}
toggleFullscreen(): this { this._fullscreen = !this._fullscreen; this.el.classList.toggle('me-fullscreen', this._fullscreen); const fsBtn = this.toolbarEl.querySelector('.me-btn-fullscreen') as HTMLButtonElement; 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(): boolean { return this._fullscreen; }
exitFullscreen(): this { if (this._fullscreen) this.toggleFullscreen(); return this; }
toggleZen(): this {
this._zenMode = !this._zenMode; if (this.el) this.el.classList.toggle('me-zen', this._zenMode);
if (this._zenMode && this.toolbarEl) {
this._zenMouseHandler = (e: MouseEvent) => { 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(): boolean { return this._zenMode; }
toggleWordWrap(): this { this._wordWrap = !this._wordWrap; if (this.textarea) this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; }
setWordWrap(on: boolean): this { this._wordWrap = !!on; if (this.textarea) this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; }
isWordWrap(): boolean { return this._wordWrap; }
_bindToolbarKeyboard(): void {
if (!this.toolbarEl) return;
const onKeydown = (e: KeyboardEvent) => {
const btns = [...this.toolbarEl.querySelectorAll('.me-btn:not(:disabled)')] as HTMLButtonElement[];
if (!btns.length) return; const idx = btns.indexOf(document.activeElement as HTMLButtonElement); if (idx === -1) return;
if (e.key === 'ArrowRight') { e.preventDefault(); btns[(idx + 1) % btns.length].focus(); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); btns[(idx - 1 + btns.length) % btns.length].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));
}
_initAriaLive(): void { 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: string): void { if (this._ariaLive) { this._ariaLive.textContent = ''; requestAnimationFrame(() => { this._ariaLive.textContent = msg; }); } }
_syncScroll(): void {
if (this._syncing) return; this._syncing = true; const ta = this.textarea; if (!ta) { this._syncing = false; return; }
const tmax = ta.scrollHeight - ta.clientHeight; if (tmax <= 0) { this._syncing = false; return; }
const ratio = ta.scrollTop / tmax;
if (this.gutter) { const gmax = this.gutter.scrollHeight - this.gutter.clientHeight; this.gutter.scrollTop = gmax > 0 ? ratio * gmax : ta.scrollTop; }
if (this.config.syncScroll && this._mode === 'split' && this.previewPane) {
const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight;
if (pmax > 0) this.previewPane.scrollTop = ratio * pmax;
}
requestAnimationFrame(() => { this._syncing = false; });
}
_updateWordCount(): void {
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));
this.statusEl.textContent = [`${i18nT('characters')}: ${chars}`, `${i18nT('words')}: ${words}`, `${i18nT('lines')}: ${lines}`, `${i18nT('readingTime')}: ${readingMin} ${i18nT('minutes')}`].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)), }; }
getValue(): string { return this._destroyed ? '' : this._value; }
setValue(md: string, opts: { silent?: boolean } = {}): this {
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; this.textarea.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(): string { MarkdownEditor.trigger('beforeRender', this); this._emit('beforeRender', this); let html = this._renderFn(this._value, { highlight: this._highlightFn || undefined, locale: getCurrentLocale() }); 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 { this._lastRenderedValue = null; this._render(); return this; }
insert(text: string, opts: { replace?: boolean } = {}): this {
const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd;
ta.value = ta.value.slice(0, start) + text + ta.value.slice(opts.replace ? end : start); ta.focus();
ta.selectionStart = ta.selectionEnd = start + text.length;
this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); return this;
}
wrap(before: string, after: string): this { this._wrapSelection(before, after || before); return this; }
focus(): this { if (this.textarea) this.textarea.focus(); return this; }
blur(): this { if (this.textarea) this.textarea.blur(); return this; }
enable(): this { if (this.textarea) this.textarea.disabled = false; if (this.el) this.el.classList.remove('me-disabled'); return this; }
disable(): this { if (this.textarea) this.textarea.disabled = true; if (this.el) this.el.classList.add('me-disabled'); return this; }
isDisabled(): boolean { return this.textarea ? this.textarea.disabled : false; }
setReadOnly(readOnly: boolean): this { 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(): boolean { return !!this.config.readOnly; }
on(name: string, fn: (...args: any[]) => void): () => void { if (!this._listeners[name]) this._listeners[name] = []; this._listeners[name].push(fn); return () => this.off(name, fn); }
off(name: string, fn: (...args: any[]) => void): this { const list = this._listeners[name]; if (list) this._listeners[name] = list.filter((f) => f !== fn); return this; }
_emit(name: string, ...args: any[]): void { const list = this._listeners[name]; if (list) list.forEach((fn) => { try { fn(...args, this); } catch (e) { console.error(`MeEditor event "${name}" error:`, e); } }); }
use(plugin: string | any, options: any = {}): this {
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: any) => console.error(`MeEditor: async plugin "${merged.name}" error:`, e)); } }
catch (e) { console.error(`MeEditor: plugin "${merged.name}" install error:`, e); }
}
this._plugins.push(merged); return this;
}
unuse(name: string): this { 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(): any[] { return [...this._plugins]; }
addToolbarButton(config: any): this {
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') { 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;
}
registerShortcut(combo: string, handler: Function | string, description: string = ''): this {
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;
}
unregisterShortcut(combo: string): this { this._shortcuts = this._shortcuts.filter((s) => s.combo !== combo); return this; }
getShortcuts(): any[] { return [...this._shortcuts]; }
configureToolbar(tools: ToolbarItem[]): this { if (!this.toolbarEl) return this; this.config.toolbar = tools; this.toolbarEl.innerHTML = ''; this._buildToolbar(); return this; }
removeToolbarButton(action: string): this { 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; }
registerContextMenu(items: any[] = []): this { this._contextMenuItems = items; return this; }
toast(message: string, opts: { type?: string; duration?: number; animation?: string } = {}): this {
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;
}
setLocale(locale: string): this { if (this._i18nCtx) this._i18nCtx.set(locale); return this; }
getLocale(): string { if (this._i18nCtx) return this._i18nCtx.get(); return getCurrentLocale(); }
t(key: string, params?: Record<string, any>): string { if (this._i18nCtx) return this._i18nCtx.t(key, params); return i18nT(key, params); }
setTheme(theme: ThemeName): this { if (!theme) return this; this.config.theme = theme; if (this._themeCtx) this._themeCtx.set(theme); return this; }
getTheme(): string { if (this._themeCtx) return this._themeCtx.get(); return this.config.theme || 'auto'; }
getThemeContext(): any { 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'), }; }
destroy(): void {
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 = {};
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); } }
this._emit('destroy'); this._listeners = {};
MarkdownEditor.trigger('afterDestroy', this);
}
isDestroyed(): boolean { return this._destroyed; }
}
export { MarkdownEditor as Editor };
export default MarkdownEditor;