Files
MetonaEditor/src/core.ts
T
thzxx a0bf5f3032
CI / test (18.x) (push) Canceled after 0s
CI / test (20.x) (push) Canceled after 0s
CI / test (22.x) (push) Canceled after 0s
CI / test (24.x) (push) Canceled after 0s
fix: 浮动工具栏坐标修正 — 转为 editorPane 局部坐标,紧贴选中文字
2026-07-25 15:20:14 +08:00

1179 lines
66 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';
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;
_selectionTimer!: ReturnType<typeof setInterval> | null;
_floatingEnabled!: boolean;
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._floatingToolbar = null; this._selectionTimer = 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 (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;
// 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', epFlex);
} catch (_) {}
}
_restoreDividerPosition(): void {
try {
const saved = localStorage.getItem('metona-editor-divider');
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); });
}
_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); }
_trackOutlineScroll(): void {
if (!this.config.outline || !this.previewPane) return;
let ticking = false;
const onScroll = () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
ticking = false;
if (!this.el) return;
const panel = this.el.querySelector('.me-outline');
if (!panel) return;
const headings = this.previewEl.querySelectorAll('h1, h2, h3, h4, h5, h6');
let activeId = '';
const scrollTop = this.previewPane.scrollTop + 80; // offset for better UX
headings.forEach((h) => {
if ((h as HTMLElement).offsetTop <= scrollTop) {
activeId = h.id;
}
});
panel.querySelectorAll('a').forEach((a) => {
a.classList.toggle('me-outline-active', a.getAttribute('href') === '#' + activeId);
});
});
};
this.previewPane.addEventListener('scroll', onScroll, { passive: true });
this._cleanups.push(() => this.previewPane.removeEventListener('scroll', onScroll));
}
_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(),
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;
}
_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); }
_formatTable(): void {
const ta = this.textarea;
const start = ta.selectionStart;
// Find table boundaries around cursor
const before = ta.value.substring(0, start);
const after = ta.value.substring(start);
const blockStart = before.lastIndexOf('\n\n');
const blockEnd = after.indexOf('\n\n');
const tableStart = blockStart === -1 ? 0 : blockStart + 2;
const tableEnd = blockEnd === -1 ? ta.value.length : start + blockEnd;
const tableText = ta.value.substring(tableStart, tableEnd);
const lines = tableText.split('\n').filter((l) => l.includes('|'));
if (lines.length < 2) return; // need at least header + separator
// Parse columns
const splitRow = (r: string) => r.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/);
const allCells = lines.map(splitRow);
const colCount = Math.max(...allCells.map((c) => c.length));
// Calculate max width per column
const colWidths: number[] = Array(colCount).fill(3);
allCells.forEach((cells) => {
cells.forEach((cell, ci) => {
colWidths[ci] = Math.max(colWidths[ci], cell.trim().length);
});
});
// Rebuild table
const pad = (s: string, w: number) => { const padLen = w - s.length; return s + ' '.repeat(Math.max(0, padLen)); };
const formatted = allCells.map((cells) => {
const padded = [];
for (let ci = 0; ci < colCount; ci++) {
padded.push(pad((cells[ci] || '').trim(), colWidths[ci]));
}
return '| ' + padded.join(' | ') + ' |';
});
// Replace in value
ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd);
this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value);
}
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; }
_initFloatingToolbar(): void {
if (typeof document === 'undefined') return;
// Inject floating toolbar CSS once globally
if (!document.getElementById('me-float-style')) {
const style = document.createElement('style');
style.id = 'me-float-style';
style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;transform:translateY(4px);transition:opacity .15s,transform .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}`;
document.head.appendChild(style);
}
if (!this._floatingEnabled) return;
const ta = this.textarea;
const show = () => {
if (this._destroyed || this.config.readOnly || !this._floatingEnabled) return;
const start = ta.selectionStart; const end = ta.selectionEnd;
if (start === end) { this._hideFloatingToolbar(); return; }
if (!this._floatingToolbar || !this._floatingToolbar.parentNode) this._buildFloatingToolbar();
// Position the toolbar near the selection (toolbar is absolute inside editorPane)
const taRect = ta.getBoundingClientRect();
const paneRect = this.editorPane.getBoundingClientRect();
const textBefore = ta.value.substring(0, start);
const lines = textBefore.split('\n');
const currentLine = lines.length - 1;
const lineHeight = parseInt(getComputedStyle(ta).lineHeight, 10) || 22;
const paddingTop = parseInt(getComputedStyle(ta).paddingTop, 10) || 16;
const scrollOffset = ta.scrollTop;
// Line top relative to viewport
const lineViewportTop = taRect.top + paddingTop + currentLine * lineHeight - scrollOffset;
// Convert to editorPane-local coordinates
const lineLocalTop = lineViewportTop - paneRect.top;
const toolbarHeight = 36;
const gap = 4;
// Try above first
let top = lineLocalTop - toolbarHeight - gap;
// If not enough room above, place below the selection
if (top < gap) {
top = lineLocalTop + lineHeight + gap;
}
// Horizontal: center near selection, in pane-local coords
const selMidX = taRect.left + (ta.selectionEnd - ta.selectionStart) / 2 * (taRect.width / Math.max(1, ta.value.length || 1));
let left = selMidX - paneRect.left - 80; // ~half toolbar width (5*28 + gaps)
left = Math.max(4, Math.min(left, paneRect.width - 200));
this._floatingToolbar!.style.top = top + 'px';
this._floatingToolbar!.style.left = left + 'px';
this._floatingToolbar!.classList.add('me-visible');
};
const hide = () => { this._hideFloatingToolbar(); };
let lastSelStart = ta.selectionStart;
let lastSelEnd = ta.selectionEnd;
this._selectionTimer = setInterval(() => {
if (this._destroyed || !document.body.contains(ta) || !this._floatingEnabled) return;
const s = ta.selectionStart; const e = ta.selectionEnd;
if (s !== lastSelStart || e !== lastSelEnd) {
lastSelStart = s; lastSelEnd = e;
const text = this._value.slice(s, e);
this._emit('selectionChange', { start: s, end: e, text });
}
}, 500) as any;
this._cleanups.push(() => { if (this._selectionTimer) { clearInterval(this._selectionTimer); this._selectionTimer = null; } });
ta.addEventListener('mouseup', () => { if (this._floatingEnabled) setTimeout(show, 0); });
ta.addEventListener('keyup', () => {
if (!this._floatingEnabled) return;
if (ta.selectionStart !== ta.selectionEnd) setTimeout(show, 0);
else setTimeout(hide, 0);
this._emit('cursorMove', this.getCursorPosition());
});
ta.addEventListener('blur', () => setTimeout(hide, 300));
ta.addEventListener('click', () => { if (ta.selectionStart === ta.selectionEnd) setTimeout(hide, 0); });
}
toggleFloatingToolbar(): this {
this._floatingEnabled = !this._floatingEnabled;
if (this._floatingEnabled) {
// Re-enable: restart the interval
if (!this._selectionTimer) {
const ta = this.textarea;
this._selectionTimer = setInterval(() => {
if (this._destroyed || !this._floatingEnabled) return;
const s = ta.selectionStart; const e = ta.selectionEnd;
if (s !== e) { /* will show via keyup/mouseup */ }
}, 500) as any;
this._cleanups.push(() => { if (this._selectionTimer) { clearInterval(this._selectionTimer); this._selectionTimer = null; } });
}
} else {
// Disable: clear interval and hide toolbar
if (this._selectionTimer) { clearInterval(this._selectionTimer); this._selectionTimer = null; }
if (this._floatingToolbar) {
if (this._floatingToolbar.parentNode) this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
this._floatingToolbar = null;
}
}
return this;
}
isFloatingToolbar(): boolean { return this._floatingEnabled; }
_buildFloatingToolbar(): void {
const bar = document.createElement('div');
bar.className = 'me-float-toolbar';
const actions = ['bold', 'italic', 'code', 'link', 'strikethrough'];
actions.forEach((action) => {
const btn = this._createBtn(action);
btn.addEventListener('mousedown', (e) => {
e.preventDefault(); e.stopPropagation();
this.exec(action);
// Keep selection after exec
setTimeout(() => this.textarea.focus(), 0);
});
bar.appendChild(btn);
});
this.editorPane.appendChild(bar);
this._floatingToolbar = bar;
}
_hideFloatingToolbar(): void {
if (this._floatingToolbar) {
this._floatingToolbar.classList.remove('me-visible');
}
}
_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._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._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];
}
getValue(): string { return this._destroyed ? '' : this._value; }
setValue(md: string, opts: { silent?: boolean } = {}): this {
if (this._destroyed) return this;
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;
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, 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; }
registerContextMenu(items: any[] = []): this { this._contextMenuItems = items; return this; }
_bindContextMenu(): void {
if (!this.el) return;
const onContextMenu = (e: MouseEvent) => {
const existing = document.querySelector('.me-context-menu');
if (existing) existing.remove();
this._showContextMenu(e);
};
this.el.addEventListener('contextmenu', onContextMenu);
this._cleanups.push(() => this.el.removeEventListener('contextmenu', onContextMenu));
}
_showContextMenu(e: MouseEvent): void {
e.preventDefault();
const ta = this.textarea;
const hasSelection = ta && ta.selectionStart !== ta.selectionEnd;
const defaultItems: Array<{ label?: string; action?: string; shortcut?: string; sep?: boolean; disabled?: boolean; onClick?: () => void }> = [
{ label: i18nT('undo') || 'Undo', action: 'undo', shortcut: 'Ctrl+Z', disabled: !this.canUndo() },
{ label: i18nT('redo') || 'Redo', action: 'redo', shortcut: 'Ctrl+Y', disabled: !this.canRedo() },
{ sep: true },
{ label: i18nT('cut') || 'Cut', action: 'cut', shortcut: 'Ctrl+X', disabled: !hasSelection },
{ label: i18nT('copy') || 'Copy', action: 'copy', shortcut: 'Ctrl+C', disabled: !hasSelection },
{ label: i18nT('paste') || 'Paste', action: 'paste', shortcut: 'Ctrl+V', disabled: !!this.config.readOnly },
{ label: i18nT('selectAll') || 'Select All', action: 'selectAll', shortcut: 'Ctrl+A' },
];
const items = [...defaultItems];
if (this._contextMenuItems.length) {
items.push({ sep: true });
this._contextMenuItems.forEach((item) => items.push(item));
}
const menu = document.createElement('div');
menu.className = 'me-context-menu';
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
// Adjust if off-screen
requestAnimationFrame(() => {
const rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth) menu.style.left = (e.clientX - rect.width) + 'px';
if (rect.bottom > window.innerHeight) menu.style.top = (e.clientY - rect.height) + 'px';
});
items.forEach((item) => {
if ((item as any).sep) { const sep = document.createElement('div'); sep.className = 'me-context-menu-sep'; menu.appendChild(sep); return; }
const el = document.createElement('div');
el.className = 'me-context-menu-item';
if (item.disabled) el.classList.add('me-disabled');
el.innerHTML = `<span>${item.label}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${item.shortcut}</span>` : ''}`;
el.addEventListener('click', (ev) => {
ev.stopPropagation();
if (item.disabled) return;
if (item.onClick) { item.onClick(); }
else if (item.action) this._execContextAction(item.action);
this._hideContextMenu();
});
menu.appendChild(el);
});
document.body.appendChild(menu);
const close = (ev: Event) => {
if (!menu.contains(ev.target as Node)) { this._hideContextMenu(); }
};
document.addEventListener('click', close, { once: true });
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') this._hideContextMenu(); }, { once: true });
}
_hideContextMenu(): void {
const menu = document.querySelector('.me-context-menu');
if (menu) menu.remove();
}
_execContextAction(action: string): void {
const ta = this.textarea;
if (!ta) return;
switch (action) {
case 'undo': this.undo(); break;
case 'redo': this.redo(); break;
case 'cut': document.execCommand('cut'); break;
case 'copy': document.execCommand('copy'); break;
case 'paste': document.execCommand('paste'); break;
case 'selectAll': ta.focus(); ta.select(); break;
default: this.exec(action); break;
}
}
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);
if (this._selectionTimer) { clearInterval(this._selectionTimer); this._selectionTimer = null; }
this._cleanups.forEach((fn) => { try { fn(); } catch (_) {} }); this._cleanups = [];
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; }
}
export { MarkdownEditor as Editor };
export default MarkdownEditor;