/** * 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 = { success: '✓', error: '✗', warning: '⚠', info: 'ℹ' }; const BRACKET_PAIRS: Record = { '(': ')', '[': ']', '{': '}', '"': '"', "'": "'", '`': '`', '*': '*', '_': '_', }; const resolveThemeName = (theme: string): string => { if (theme && theme !== 'auto') return theme; return resolveTheme('auto'); }; export class MarkdownEditor { static _hooks = new Map 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 void>>; _renderRaf!: number | null; _historyTimer!: ReturnType | null; _fullscreen!: boolean; _destroyed!: boolean; _lastRenderedValue!: string | null; _shortcuts!: any[]; _contextMenuItems!: any[]; _customActions!: Record void>; _outlineTimer!: ReturnType | 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 | 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] || `${escapeHTML(item)}`; 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 = { 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 = `

${i18nT('renderError')}: ${escapeHTML(err.message)}

`; } 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 += `
${i}
`; 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>/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 = `
${i18nT('outline') || '大纲'}
`; const buildTree = (items: typeof headings, minLevel: number): string => { let h = '
    '; let i = 0; while (i < items.length) { const item = items[i]; if (item.level < minLevel) break; h += `
  • ${escapeHTML(item.text)}`; 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 += '
  • '; i = j > i + 1 ? j : i + 1; } return h + '
'; }; 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 void> = { bold: () => this._wrapSelection('**', '**'), italic: () => this._wrapSelection('*', '*'), strikethrough: () => this._wrapSelection('~~', '~~'), underline: () => this._wrapSelection('', ''), 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 || `${escapeHTML(config.text || config.action)}`; 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 = `${item.label}${item.shortcut ? `${item.shortcut}` : ''}`; 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 = `${TOAST_ICONS[type] || ''}${escapeHTML(String(message))}`; 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 { 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;