Files
MetonaEditor/src/core.ts
T
thzxx 64aedb9546
CI / test-parser (push) Successful in 9m26s
CI / test-core (push) Successful in 9m33s
CI / test-rest (push) Successful in 9m29s
CI / verify (18.x) (push) Successful in 9m50s
CI / verify (20.x) (push) Successful in 9m52s
CI / verify (24.x) (push) Successful in 9m45s
fix(core): maxLength 默认值不再赋值 -1,修复浏览器 IndexSizeError
- textarea.maxLength 的 setter 不接受负值(-1 仅能通过不设置来
  保持),显式赋 -1 在真实浏览器抛出 IndexSizeError
- 改为仅当 maxLength > 0 时才设置属性
- 补防回归测试(mock setter 抛错 + 环境无关断言;
  jsdom 默认 maxLength 为 0,浏览器为 -1)
2026-08-09 10:22:22 +08:00

881 lines
50 KiB
TypeScript
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 — 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';
import { installCommands } from './commands';
import { installFloatingToolbar } from './floating-toolbar';
import { installContextMenu } from './context-menu';
import { installOutline } from './outline';
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;
_floatingToolbar!: HTMLElement | null;
_floatingEnabled!: boolean;
// Prototype-installed methods (see installers at bottom of file)
_wrapSelection!: (before: string, after: string) => void;
_toggleLinePrefix!: (prefix: string) => void;
_insertBlock!: (text: string) => void;
_insertLink!: () => void;
_insertImage!: () => void;
_insertTable!: (rows?: number, cols?: number) => void;
_formatTable!: () => void;
_initFloatingToolbar!: () => void;
toggleFloatingToolbar!: () => this;
isFloatingToolbar!: () => boolean;
_buildFloatingToolbar!: () => void;
_hideFloatingToolbar!: () => void;
registerContextMenu!: (items: any[]) => this;
_bindContextMenu!: () => void;
_showContextMenu!: (e: MouseEvent) => void;
_hideContextMenu!: () => void;
_execContextAction!: (action: string) => void;
_buildOutline!: () => void;
_updateOutline!: () => void;
_trackOutlineScroll!: () => void;
constructor(container: string | HTMLElement, options: EditorOptions = {}) {
if (!isBrowser()) {
this._destroyed = true; this._value = options.value || ''; return this;
}
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;
}
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 = this.config.zenMode === true; this._wordWrap = true; this._syncing = false;
this._zenMouseHandler = null;
this._floatingToolbar = null;
this._floatingEnabled = this.config.floatingToolbar !== false;
this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown;
this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null;
MarkdownEditor.trigger('beforeCreate', this);
injectStyles();
this._buildDOM();
this._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._bindContextMenu();
this._trackOutlineScroll();
if (this.config.floatingToolbar !== false) this._initFloatingToolbar();
this.textarea.value = this._value;
this._pushHistory();
this._render();
this._updateWordCount();
if (this._zenMode) this._setZen(true);
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;
if (this.config.maxLength && this.config.maxLength > 0) textarea.maxLength = this.config.maxLength;
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;
// Restore divider position if saved
this._restoreDividerPosition();
}
_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 = () => {
const oldValue = this._value;
this._value = ta.value;
MarkdownEditor.trigger('beforeChange', this); this._emit('beforeChange', oldValue, this._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); } }
this._emit('afterChange', this._value); MarkdownEditor.trigger('afterChange', this);
};
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.onLinkClick === 'function') { try { this.config.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);
this._saveDividerPosition();
};
window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp);
}
_saveDividerPosition(): void {
try {
const epFlex = this.editorPane.style.flex;
if (epFlex) localStorage.setItem(`metona-editor-divider-${this.id}`, epFlex);
} catch (_) {}
}
_restoreDividerPosition(): void {
try {
const saved = localStorage.getItem(`metona-editor-divider-${this.id}`);
if (saved && this._mode === 'split') {
this.editorPane.style.flex = saved;
this.previewPane.style.flex = '1 1 auto';
}
} catch (_) {}
}
_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;
const current = this.gutter.children.length;
if (current === lines) return;
if (current < lines) {
// Add new line numbers
let h = '';
for (let i = current + 1; i <= lines; i++) h += `<div class="me-gutter-line">${i}</div>`;
this.gutter.insertAdjacentHTML('beforeend', h);
} else {
// Remove excess line numbers
while (this.gutter.children.length > lines) {
this.gutter.removeChild(this.gutter.lastChild!);
}
}
}
_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); });
}
_scheduleHistory(): void { if (this._historyTimer) clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); }
_pushHistory(): void {
// Enforce maxLength as a last-resort guard for programmatic edit paths
// (tab/indent, smart-enter, bracket auto-close, exec commands) that write
// to the textarea directly and bypass the native maxlength attribute.
if (this.config.maxLength && this.config.maxLength > 0 && this._value.length > this.config.maxLength) {
this._value = this._value.slice(0, this.config.maxLength);
if (this.textarea) this.textarea.value = this._value;
}
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(),
formatTable: () => this._formatTable(),
};
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;
}
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._setZen(!this._zenMode); return this; }
_setZen(on: boolean): void {
this._zenMode = !!on; if (!this.el) return;
this.el.classList.toggle('me-zen', this._zenMode);
if (this._zenMode && this.toolbarEl) {
if (!this._zenMouseHandler) {
this._zenMouseHandler = (e: MouseEvent) => { this.toolbarEl.style.opacity = e.clientY < 40 ? '1' : '0'; this.toolbarEl.style.pointerEvents = e.clientY < 40 ? 'auto' : 'none'; };
document.addEventListener('mousemove', this._zenMouseHandler);
}
this.toolbarEl.style.transition = 'opacity 0.2s'; this.toolbarEl.style.opacity = '0'; this.toolbarEl.style.pointerEvents = 'none';
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);
}
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)), }; }
getSelectedText(): string {
if (!this.textarea) return '';
return this._value.slice(this.textarea.selectionStart, this.textarea.selectionEnd);
}
getCursorPosition(): { line: number; column: number } {
if (!this.textarea) return { line: 1, column: 0 };
const pos = this.textarea.selectionStart;
const before = this._value.substring(0, pos);
const line = before.split('\n').length;
const column = pos - before.lastIndexOf('\n') - 1;
return { line, column: column < 0 ? 0 : column };
}
setCursorPosition(line: number, column: number = 0): this {
if (!this.textarea) return this;
const lines = this._value.split('\n');
const clampedLine = Math.max(1, Math.min(line, lines.length));
let pos = 0;
for (let i = 0; i < clampedLine - 1; i++) pos += lines[i].length + 1;
pos += Math.min(column, lines[clampedLine - 1]?.length || 0);
this.textarea.focus();
this.textarea.setSelectionRange(pos, pos);
return this;
}
scrollToLine(line: number): this {
if (!this.textarea) return this;
const lines = this._value.split('\n');
const clampedLine = Math.max(1, Math.min(line, lines.length));
const lineHeight = 22; // approximate
this.textarea.scrollTop = (clampedLine - 1) * lineHeight;
if (this.gutter) this.gutter.scrollTop = this.textarea.scrollTop;
return this;
}
selectLine(line: number): this {
if (!this.textarea) return this;
const lines = this._value.split('\n');
const clampedLine = Math.max(1, Math.min(line, lines.length));
let startPos = 0;
for (let i = 0; i < clampedLine - 1; i++) startPos += lines[i].length + 1;
const endPos = startPos + lines[clampedLine - 1].length;
this.textarea.focus();
this.textarea.setSelectionRange(startPos, endPos);
return this;
}
selectAll(): this { if (this.textarea) { this.textarea.focus(); this.textarea.select(); } return this; }
replaceAll(search: string, replace: string, caseSensitive: boolean = true): number {
if (!search) return 0;
const flags = caseSensitive ? 'g' : 'gi';
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(escaped, flags);
const matches = this._value.match(re);
if (!matches) return 0;
this._value = this._limitLength(this._value.replace(re, replace));
this.textarea.value = this._value;
this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
return matches.length;
}
replaceAllRegex(pattern: RegExp, replace: string): number {
if (!pattern) return 0;
const re = new RegExp(pattern, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
const matches = this._value.match(re);
if (!matches) return 0;
this._value = this._limitLength(this._value.replace(re, replace));
this.textarea.value = this._value;
this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
return matches.length;
}
lineCount(): number {
return this._value ? this._value.split('\n').length : 0;
}
getLine(line: number): string {
const lines = this._value.split('\n');
const idx = line - 1;
if (idx < 0 || idx >= lines.length) return '';
return lines[idx];
}
_limitLength(value: string): string {
const max = this.config.maxLength;
return max && max > 0 ? value.slice(0, max) : value;
}
getValue(): string { return this._destroyed ? '' : this._value; }
setValue(md: string, opts: { silent?: boolean } = {}): this {
if (this._destroyed) return this;
md = this._limitLength(md || '');
MarkdownEditor.trigger('beforeChange', this); this._emit('beforeChange', this._value, md);
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); } } }
this._emit('afterChange', this._value); MarkdownEditor.trigger('afterChange', this);
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; }
copyAsMarkdown(): this {
if (typeof navigator !== 'undefined' && navigator.clipboard) {
navigator.clipboard.writeText(this._value).then(() => this._emit('copy', { type: 'markdown' })).catch(() => {});
}
return this;
}
copyAsHTML(): this {
const html = this.getHTML();
if (typeof navigator !== 'undefined' && navigator.clipboard) {
const blob = new Blob([html], { type: 'text/html' });
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) });
navigator.clipboard.write([item]).then(() => this._emit('copy', { type: 'html' })).catch(() => {});
}
return this;
}
insert(text: string, opts: { replace?: boolean } = {}): this {
const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd;
const inserted = this._limitLength(ta.value.slice(0, start) + text + ta.value.slice(opts.replace ? end : start));
ta.value = inserted; ta.focus();
ta.selectionStart = ta.selectionEnd = Math.min(start + text.length, inserted.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, options); 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; }
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 = [];
if (this._zenMouseHandler) { document.removeEventListener('mousemove', this._zenMouseHandler); this._zenMouseHandler = null; }
this._shortcuts = []; this._contextMenuItems = []; this._customActions = {};
if (this._floatingToolbar && this._floatingToolbar.parentNode) this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
this._floatingToolbar = null;
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; }
}
// Install split-out modules onto the prototype (keeps `this` binding and
// preserves the existing instance API surface for consumers and tests)
installCommands(MarkdownEditor.prototype);
installFloatingToolbar(MarkdownEditor.prototype);
installContextMenu(MarkdownEditor.prototype);
installOutline(MarkdownEditor.prototype);
export { MarkdownEditor as Editor };
export default MarkdownEditor;