refactor(core): 拆分 4 模块 + 插件状态 Symbol 化 + eslint type-aware
- core.ts 从 1162 行降至 865 行:命令/浮动工具栏/右键菜单/大纲 拆至 commands.ts / floating-toolbar.ts / context-menu.ts / outline.ts (原型安装,API 与测试完全兼容) - 6 个预设插件状态改用 Symbol 键存储,定义 EditorLike 契约接口, 消灭 (editor as any).__xxx 魔法属性 - 实现 maxLength(textarea maxlength + 程序化入口截断)与 zenMode 初始状态(此前为 dead config) - 搜索面板补齐 matchCase/wholeWord(翻译键已有但未实现, 中文全字边界感知) - 分隔条 localStorage key 加入实例 id 隔离 - eslint 启用 type-aware 规则(consistent-type-imports / no-unnecessary-type-assertion),0 errors
This commit is contained in:
+61
-351
@@ -13,6 +13,10 @@ import type { InstanceI18n } from './i18n';
|
||||
import { parseMarkdown } from './parser';
|
||||
import { presetPlugins, topologicalSort } from './plugins';
|
||||
import { resolveTheme, getThemeConfig, setThemeVariables, createInstanceTheme } from './themes';
|
||||
import { installCommands } from './commands';
|
||||
import { installFloatingToolbar } from './floating-toolbar';
|
||||
import { installContextMenu } from './context-menu';
|
||||
import { installOutline } from './outline';
|
||||
|
||||
const MODES: EditMode[] = ['edit', 'split', 'preview'];
|
||||
const TOAST_ICONS: Record<string, string> = { success: '✓', error: '✗', warning: '⚠', info: 'ℹ' };
|
||||
@@ -85,15 +89,37 @@ export class MarkdownEditor {
|
||||
_floatingToolbar!: HTMLElement | null;
|
||||
_floatingEnabled!: boolean;
|
||||
|
||||
// Prototype-installed methods (see installers at bottom of file)
|
||||
_wrapSelection!: (before: string, after: string) => void;
|
||||
_toggleLinePrefix!: (prefix: string) => void;
|
||||
_insertBlock!: (text: string) => void;
|
||||
_insertLink!: () => void;
|
||||
_insertImage!: () => void;
|
||||
_insertTable!: (rows?: number, cols?: number) => void;
|
||||
_formatTable!: () => void;
|
||||
_initFloatingToolbar!: () => void;
|
||||
toggleFloatingToolbar!: () => this;
|
||||
isFloatingToolbar!: () => boolean;
|
||||
_buildFloatingToolbar!: () => void;
|
||||
_hideFloatingToolbar!: () => void;
|
||||
registerContextMenu!: (items: any[]) => this;
|
||||
_bindContextMenu!: () => void;
|
||||
_showContextMenu!: (e: MouseEvent) => void;
|
||||
_hideContextMenu!: () => void;
|
||||
_execContextAction!: (action: string) => void;
|
||||
_buildOutline!: () => void;
|
||||
_updateOutline!: () => void;
|
||||
_trackOutlineScroll!: () => void;
|
||||
|
||||
constructor(container: string | HTMLElement, options: EditorOptions = {}) {
|
||||
if (!isBrowser()) {
|
||||
this._destroyed = true; this._value = options.value || ''; return this as any;
|
||||
this._destroyed = true; this._value = options.value || ''; return this;
|
||||
}
|
||||
this.id = options.id || generateId();
|
||||
this.container = typeof container === 'string' ? document.querySelector(container) as HTMLElement : container;
|
||||
if (!this.container) {
|
||||
console.error('MeEditor: container not found:', container);
|
||||
this._destroyed = true; return this as any;
|
||||
this._destroyed = true; return this;
|
||||
}
|
||||
this.config = { ...DEFAULTS, ...options };
|
||||
if (options.style && typeof options.style === 'object') {
|
||||
@@ -105,7 +131,7 @@ export class MarkdownEditor {
|
||||
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._outlineTimer = null; this._zenMode = this.config.zenMode === true; this._wordWrap = true; this._syncing = false;
|
||||
this._zenMouseHandler = null;
|
||||
this._floatingToolbar = null;
|
||||
this._floatingEnabled = this.config.floatingToolbar !== false;
|
||||
@@ -138,6 +164,7 @@ export class MarkdownEditor {
|
||||
this._pushHistory();
|
||||
this._render();
|
||||
this._updateWordCount();
|
||||
if (this._zenMode) this._setZen(true);
|
||||
|
||||
if (Array.isArray(this.config.plugins)) {
|
||||
const plugins = this.config.plugins.map((p: any) => {
|
||||
@@ -179,6 +206,7 @@ export class MarkdownEditor {
|
||||
editorInner.appendChild(gutter);
|
||||
const textarea = document.createElement('textarea'); textarea.className = 'me-textarea';
|
||||
textarea.spellcheck = !!this.config.spellcheck; textarea.readOnly = !!this.config.readOnly;
|
||||
textarea.maxLength = this.config.maxLength && this.config.maxLength > 0 ? this.config.maxLength : -1;
|
||||
textarea.style.tabSize = String(this.config.tabSize || 2);
|
||||
textarea.placeholder = this.config.placeholder || i18nT('placeholder');
|
||||
textarea.setAttribute('aria-label', i18nT('edit'));
|
||||
@@ -371,13 +399,13 @@ export class MarkdownEditor {
|
||||
_saveDividerPosition(): void {
|
||||
try {
|
||||
const epFlex = this.editorPane.style.flex;
|
||||
if (epFlex) localStorage.setItem('metona-editor-divider', epFlex);
|
||||
if (epFlex) localStorage.setItem(`metona-editor-divider-${this.id}`, epFlex);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
_restoreDividerPosition(): void {
|
||||
try {
|
||||
const saved = localStorage.getItem('metona-editor-divider');
|
||||
const saved = localStorage.getItem(`metona-editor-divider-${this.id}`);
|
||||
if (saved && this._mode === 'split') {
|
||||
this.editorPane.style.flex = saved;
|
||||
this.previewPane.style.flex = '1 1 auto';
|
||||
@@ -481,69 +509,6 @@ export class MarkdownEditor {
|
||||
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 {
|
||||
@@ -592,82 +557,6 @@ export class MarkdownEditor {
|
||||
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 = ``; 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; }
|
||||
|
||||
@@ -687,19 +576,24 @@ export class MarkdownEditor {
|
||||
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);
|
||||
toggleZen(): this { this._setZen(!this._zenMode); return this; }
|
||||
|
||||
_setZen(on: boolean): void {
|
||||
this._zenMode = !!on; if (!this.el) return;
|
||||
this.el.classList.toggle('me-zen', this._zenMode);
|
||||
if (this._zenMode && this.toolbarEl) {
|
||||
this._zenMouseHandler = (e: MouseEvent) => { this.toolbarEl.style.opacity = e.clientY < 40 ? '1' : '0'; this.toolbarEl.style.pointerEvents = e.clientY < 40 ? 'auto' : 'none'; };
|
||||
if (!this._zenMouseHandler) {
|
||||
this._zenMouseHandler = (e: MouseEvent) => { this.toolbarEl.style.opacity = e.clientY < 40 ? '1' : '0'; this.toolbarEl.style.pointerEvents = e.clientY < 40 ? 'auto' : 'none'; };
|
||||
document.addEventListener('mousemove', this._zenMouseHandler);
|
||||
}
|
||||
this.toolbarEl.style.transition = 'opacity 0.2s'; this.toolbarEl.style.opacity = '0'; this.toolbarEl.style.pointerEvents = 'none';
|
||||
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;
|
||||
this._emit('zenChange', this._zenMode);
|
||||
}
|
||||
isZen(): boolean { return this._zenMode; }
|
||||
|
||||
@@ -707,121 +601,6 @@ export class MarkdownEditor {
|
||||
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;
|
||||
let _lastSelStart = ta.selectionStart;
|
||||
let _lastSelEnd = ta.selectionEnd;
|
||||
|
||||
const emitSelectionChange = () => {
|
||||
const s = ta.selectionStart; const e = ta.selectionEnd;
|
||||
if (s !== _lastSelStart || e !== _lastSelEnd) {
|
||||
_lastSelStart = s; _lastSelEnd = e;
|
||||
this._emit('selectionChange', { start: s, end: e, text: this._value.slice(s, e) });
|
||||
}
|
||||
};
|
||||
|
||||
const show = () => {
|
||||
if (this._destroyed || this.config.readOnly || !this._floatingEnabled) return;
|
||||
emitSelectionChange();
|
||||
const start = ta.selectionStart; const end = ta.selectionEnd;
|
||||
if (start === end) { this._hideFloatingToolbar(); return; }
|
||||
|
||||
if (!this._floatingToolbar || !this._floatingToolbar.parentNode) this._buildFloatingToolbar();
|
||||
|
||||
const taRect = ta.getBoundingClientRect();
|
||||
const paneRect = this.editorPane.getBoundingClientRect();
|
||||
const taStyle = getComputedStyle(ta);
|
||||
const lineHeight = parseFloat(taStyle.lineHeight) || 22;
|
||||
const paddingTop = parseFloat(taStyle.paddingTop) || 16;
|
||||
const paddingLeft = parseFloat(taStyle.paddingLeft) || 18;
|
||||
const fontSize = parseFloat(taStyle.fontSize) || 13.5;
|
||||
const charWidth = fontSize * 0.6;
|
||||
|
||||
const textBefore = ta.value.substring(0, start);
|
||||
const lines = textBefore.split('\n');
|
||||
const currentLine = lines.length - 1;
|
||||
const currentCol = textBefore.length - textBefore.lastIndexOf('\n') - 1;
|
||||
const midCol = currentCol + Math.floor((end - start) / 2 * ((ta.selectionDirection === 'backward') ? -1 : 1));
|
||||
|
||||
const lineViewportTop = taRect.top + paddingTop + currentLine * lineHeight - ta.scrollTop;
|
||||
const lineLocalTop = lineViewportTop - paneRect.top;
|
||||
const toolbarHeight = 36;
|
||||
const gap = 4;
|
||||
let top = lineLocalTop - toolbarHeight - gap;
|
||||
if (top < gap) top = lineLocalTop + lineHeight + gap;
|
||||
|
||||
const colX = taRect.left + paddingLeft + midCol * charWidth - ta.scrollLeft;
|
||||
let left = colX - paneRect.left - 80;
|
||||
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(); };
|
||||
|
||||
ta.addEventListener('mouseup', () => setTimeout(show, 0));
|
||||
ta.addEventListener('keyup', () => {
|
||||
emitSelectionChange();
|
||||
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', () => {
|
||||
emitSelectionChange();
|
||||
if (ta.selectionStart === ta.selectionEnd) hide();
|
||||
});
|
||||
}
|
||||
|
||||
toggleFloatingToolbar(): this {
|
||||
this._floatingEnabled = !this._floatingEnabled;
|
||||
if (!this._floatingEnabled) {
|
||||
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) => {
|
||||
@@ -921,7 +700,7 @@ export class MarkdownEditor {
|
||||
const re = new RegExp(escaped, flags);
|
||||
const matches = this._value.match(re);
|
||||
if (!matches) return 0;
|
||||
this._value = this._value.replace(re, replace);
|
||||
this._value = this._limitLength(this._value.replace(re, replace));
|
||||
this.textarea.value = this._value;
|
||||
this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
|
||||
return matches.length;
|
||||
@@ -932,7 +711,7 @@ export class MarkdownEditor {
|
||||
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._value = this._limitLength(this._value.replace(re, replace));
|
||||
this.textarea.value = this._value;
|
||||
this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
|
||||
return matches.length;
|
||||
@@ -949,9 +728,15 @@ export class MarkdownEditor {
|
||||
return lines[idx];
|
||||
}
|
||||
|
||||
_limitLength(value: string): string {
|
||||
const max = this.config.maxLength;
|
||||
return max && max > 0 ? value.slice(0, max) : value;
|
||||
}
|
||||
|
||||
getValue(): string { return this._destroyed ? '' : this._value; }
|
||||
setValue(md: string, opts: { silent?: boolean } = {}): this {
|
||||
if (this._destroyed) return this;
|
||||
md = this._limitLength(md || '');
|
||||
MarkdownEditor.trigger('beforeChange', this); this._emit('beforeChange', this._value, md);
|
||||
this._value = md || ''; this.textarea.value = this._value;
|
||||
if (!opts.silent) this._pushHistory(); this._render(); this._renderGutter(); this._updateWordCount(); this._updateOutline();
|
||||
@@ -982,8 +767,9 @@ export class MarkdownEditor {
|
||||
|
||||
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;
|
||||
const inserted = this._limitLength(ta.value.slice(0, start) + text + ta.value.slice(opts.replace ? end : start));
|
||||
ta.value = inserted; ta.focus();
|
||||
ta.selectionStart = ta.selectionEnd = Math.min(start + text.length, inserted.length);
|
||||
this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); return this;
|
||||
}
|
||||
wrap(before: string, after: string): this { this._wrapSelection(before, after || before); return this; }
|
||||
@@ -1033,89 +819,6 @@ export class MarkdownEditor {
|
||||
|
||||
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;
|
||||
@@ -1158,5 +861,12 @@ export class MarkdownEditor {
|
||||
isDestroyed(): boolean { return this._destroyed; }
|
||||
}
|
||||
|
||||
// Install split-out modules onto the prototype (keeps `this` binding and
|
||||
// preserves the existing instance API surface for consumers and tests)
|
||||
installCommands(MarkdownEditor.prototype);
|
||||
installFloatingToolbar(MarkdownEditor.prototype);
|
||||
installContextMenu(MarkdownEditor.prototype);
|
||||
installOutline(MarkdownEditor.prototype);
|
||||
|
||||
export { MarkdownEditor as Editor };
|
||||
export default MarkdownEditor;
|
||||
|
||||
Reference in New Issue
Block a user