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:
2026-08-09 08:59:37 +08:00
parent ddc650feeb
commit fae31bba60
12 changed files with 972 additions and 447 deletions
+4 -1
View File
@@ -9,7 +9,8 @@
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module"
"sourceType": "module",
"project": "./tsconfig.json"
},
"plugins": ["@typescript-eslint"],
"extends": [
@@ -19,6 +20,8 @@
"no-console": "warn",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
"@typescript-eslint/consistent-type-imports": ["warn", { "prefer": "type-imports", "fixStyle": "inline-type-imports" }],
"@typescript-eslint/no-unnecessary-type-assertion": "warn",
"no-undef": "off",
"no-empty": "off",
"no-useless-escape": "off",
+123
View File
@@ -0,0 +1,123 @@
/**
* MetonaEditor Commands — editing command implementations
* @module commands
* @version 0.2.5
*/
import { t as i18nT } from './i18n';
import type { MarkdownEditor } from './core';
// ============ Selection wrapping ============
export function wrapSelection(this: MarkdownEditor, 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);
}
// ============ Line prefix toggling ============
export function toggleLinePrefix(this: MarkdownEditor, 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);
}
// ============ Block insertion ============
export function insertBlock(this: MarkdownEditor, 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);
}
export function insertLink(this: MarkdownEditor): 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);
}
export function insertImage(this: MarkdownEditor): 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);
}
export function insertTable(this: MarkdownEditor, 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`;
insertBlock.call(this, '\n' + md);
}
// ============ Table auto-format ============
export function formatTable(this: MarkdownEditor): void {
const ta = this.textarea;
const start = ta.selectionStart;
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;
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));
const colWidths: number[] = Array(colCount).fill(3);
allCells.forEach((cells) => {
cells.forEach((cell, ci) => {
colWidths[ci] = Math.max(colWidths[ci], cell.trim().length);
});
});
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(' | ') + ' |';
});
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);
}
// ============ Prototype installation ============
export const installCommands = (proto: any): void => {
proto._wrapSelection = wrapSelection;
proto._toggleLinePrefix = toggleLinePrefix;
proto._insertBlock = insertBlock;
proto._insertLink = insertLink;
proto._insertImage = insertImage;
proto._insertTable = insertTable;
proto._formatTable = formatTable;
};
+3 -3
View File
@@ -104,9 +104,9 @@ export const DEFAULT_TOOLBAR: ToolbarItem[] = [
export const DEFAULTS: Readonly<EditorOptions> = Object.freeze({
value: '',
placeholder: '',
mode: 'split' as EditMode,
mode: 'split',
height: 400,
toolbar: DEFAULT_TOOLBAR as ToolbarItem[] | false,
toolbar: DEFAULT_TOOLBAR,
wordCount: true,
autofocus: false,
spellcheck: false,
@@ -121,7 +121,7 @@ export const DEFAULTS: Readonly<EditorOptions> = Object.freeze({
zenMode: false,
wordWrap: true,
maxLength: 0,
theme: 'auto' as ThemeName,
theme: 'auto',
locale: 'zh-CN',
render: null,
highlight: null,
+103
View File
@@ -0,0 +1,103 @@
/**
* MetonaEditor Context Menu — right-click menu
* @module context-menu
* @version 0.2.5
*/
import { t as i18nT } from './i18n';
import type { MarkdownEditor } from './core';
export function registerContextMenu(this: MarkdownEditor, items: any[] = []): void {
this._contextMenuItems = items;
}
export function bindContextMenu(this: MarkdownEditor): 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));
}
export function showContextMenu(this: MarkdownEditor, 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 });
}
export function hideContextMenu(this: MarkdownEditor): void {
const menu = document.querySelector('.me-context-menu');
if (menu) menu.remove();
}
export function execContextAction(this: MarkdownEditor, 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;
}
}
// ============ Prototype installation ============
export const installContextMenu = (proto: any): void => {
proto.registerContextMenu = registerContextMenu;
proto._bindContextMenu = bindContextMenu;
proto._showContextMenu = showContextMenu;
proto._hideContextMenu = hideContextMenu;
proto._execContextAction = execContextAction;
};
+61 -351
View File
@@ -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 = `![${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; }
@@ -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;
+132
View File
@@ -0,0 +1,132 @@
/**
* MetonaEditor Floating Toolbar — selection format toolbar
* @module floating-toolbar
* @version 0.2.5
*/
import type { MarkdownEditor } from './core';
export function initFloatingToolbar(this: MarkdownEditor): 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();
});
}
export function toggleFloatingToolbar(this: MarkdownEditor): void {
this._floatingEnabled = !this._floatingEnabled;
if (!this._floatingEnabled) {
if (this._floatingToolbar) {
if (this._floatingToolbar.parentNode) this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
this._floatingToolbar = null;
}
}
}
export function isFloatingToolbar(this: MarkdownEditor): boolean { return this._floatingEnabled; }
export function buildFloatingToolbar(this: MarkdownEditor): 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;
}
export function hideFloatingToolbar(this: MarkdownEditor): void {
if (this._floatingToolbar) {
this._floatingToolbar.classList.remove('me-visible');
}
}
// ============ Prototype installation ============
export const installFloatingToolbar = (proto: any): void => {
proto._initFloatingToolbar = initFloatingToolbar;
proto.toggleFloatingToolbar = toggleFloatingToolbar;
proto.isFloatingToolbar = isFloatingToolbar;
proto._buildFloatingToolbar = buildFloatingToolbar;
proto._hideFloatingToolbar = hideFloatingToolbar;
};
+1 -1
View File
@@ -27,7 +27,7 @@ const pluralRules: Record<string, PluralRule> = {
const getPluralForm = (locale: string, count: number): string => {
const lang = locale.split('-')[0].toLowerCase();
const rule = pluralRules[lang] || pluralRules.en!;
const rule = pluralRules[lang] || pluralRules.en;
return rule(Math.abs(count));
};
+84
View File
@@ -0,0 +1,84 @@
/**
* MetonaEditor Outline — heading navigation panel
* @module outline
* @version 0.2.5
*/
import { t as i18nT } from './i18n';
import { escapeHTML } from './utils';
import type { MarkdownEditor } from './core';
export function buildOutline(this: MarkdownEditor): 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); } }
});
}
export function updateOutline(this: MarkdownEditor): void {
if (!this.config.outline) return;
if (this._outlineTimer) clearTimeout(this._outlineTimer);
this._outlineTimer = setTimeout(() => this._buildOutline(), 300);
}
export function trackOutlineScroll(this: MarkdownEditor): 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));
}
// ============ Prototype installation ============
export const installOutline = (proto: any): void => {
proto._buildOutline = buildOutline;
proto._updateOutline = updateOutline;
proto._trackOutlineScroll = trackOutlineScroll;
};
+192 -85
View File
@@ -6,14 +6,51 @@
import { t } from './i18n';
// ============ Editor contract ============
/** The editor surface plugins may rely on. Keeps plugin code type-checked
* without importing the full MarkdownEditor class (avoids circular imports). */
export interface EditorLike {
id?: string;
value?: string;
_value?: string;
el: HTMLElement;
textarea: HTMLTextAreaElement;
config?: Record<string, any>;
on?: (name: string, fn: (...args: any[]) => void) => (() => void) | void;
off?: (name: string, fn: (...args: any[]) => void) => unknown;
_emit?: (name: string, ...args: any[]) => void;
_pushHistory?: () => void;
_render?: () => void;
_updateWordCount?: () => void;
insert?: (text: string, opts?: { replace?: boolean }) => unknown;
setValue?: (value: string, opts?: { silent?: boolean }) => unknown;
getValue?: () => string;
getHTML?: () => string;
focus?: () => unknown;
toast?: (message: string, opts?: { type?: string; duration?: number; animation?: string }) => unknown;
}
// ============ Plugin state keys (Symbols avoid property-name collisions) ============
const K_AUTOSAVE = Symbol('me-plugin:autoSave');
const K_SEARCH = Symbol('me-plugin:searchReplace');
const K_IMAGE_PASTE = Symbol('me-plugin:imagePaste');
const K_SHORTCUT = Symbol('me-plugin:shortcutHelp');
const K_FILESYSTEM = Symbol('me-plugin:fileSystem');
const pluginState = <T>(editor: EditorLike, key: symbol): T | undefined => (editor as any)[key] as T | undefined;
const setPluginState = <T>(editor: EditorLike, key: symbol, state: T): void => { (editor as any)[key] = state; };
const deletePluginState = (editor: EditorLike, key: symbol): void => { delete (editor as any)[key]; };
export interface Plugin {
name: string;
version?: string;
description?: string;
depends?: string[];
priority?: number;
install?: (editor: any) => void | Promise<void>;
destroy?: (editor: any) => void;
install?: (editor: EditorLike, options?: any) => void | Promise<void>;
destroy?: (editor: EditorLike) => void;
[key: string]: any;
}
@@ -118,22 +155,24 @@ const autoSavePlugin: Plugin = {
const state = { _timer: null as ReturnType<typeof setTimeout> | null };
const save = () => {
if (state._timer) { clearTimeout(state._timer); state._timer = null; }
try { localStorage.setItem(key, editor.getValue()); if (typeof editor._emit === 'function') editor._emit('autosave', { key, value: editor.getValue() }); }
try { localStorage.setItem(key, editor.getValue!()); if (typeof editor._emit === 'function') editor._emit('autosave', { key, value: editor.getValue!() }); }
catch (e) { console.warn('MeEditor autoSave:', e); }
};
const _onInput = () => { if (state._timer) clearTimeout(state._timer); state._timer = setTimeout(save, delay); };
const _onBlur = save;
const _onSave = save;
editor.on('change', _onInput);
editor.on('blur', _onBlur);
editor.on('save', _onSave);
editor.restoreDraft = () => { try { const v = localStorage.getItem(key); if (v != null) editor.setValue(v); return v; } catch (_) { return null; } };
editor.clearDraft = () => { try { localStorage.removeItem(key); } catch (_) {} return editor; };
editor.getDraftKey = () => key;
(editor as any).__autoSaveCleanup = { state, _onInput, _onBlur, _onSave, save };
if (typeof editor.on === 'function') {
editor.on('change', _onInput);
editor.on('blur', _onBlur);
editor.on('save', _onSave);
}
(editor as any).restoreDraft = () => { try { const v = localStorage.getItem(key); if (v != null && typeof editor.setValue === 'function') editor.setValue(v); return v; } catch (_) { return null; } };
(editor as any).clearDraft = () => { try { localStorage.removeItem(key); } catch (_) {} return editor; };
(editor as any).getDraftKey = () => key;
setPluginState(editor, K_AUTOSAVE, { state, _onInput, _onBlur, _onSave, save });
},
destroy(editor) {
const cleanup = (editor as any).__autoSaveCleanup;
const cleanup = pluginState<any>(editor, K_AUTOSAVE);
if (cleanup) {
if (cleanup.state._timer) { clearTimeout(cleanup.state._timer); cleanup.state._timer = null; }
if (editor && typeof editor.off === 'function') {
@@ -141,7 +180,7 @@ const autoSavePlugin: Plugin = {
if (cleanup._onBlur) editor.off('blur', cleanup._onBlur);
if (cleanup._onSave) editor.off('save', cleanup._onSave);
}
delete (editor as any).__autoSaveCleanup;
deletePluginState(editor, K_AUTOSAVE);
}
},
};
@@ -159,8 +198,8 @@ const exportToolPlugin: Plugin = {
setTimeout(() => URL.revokeObjectURL(url), 0);
};
const stamp = () => { const d = new Date(); const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`; };
editor.exportMarkdown = (filename?: string) => { download(filename || `metona-${stamp()}.md`, editor.getValue(), 'text/markdown'); return editor; };
editor.exportHTML = (filename?: string, opts: any = {}) => {
const e = editor as any;
const buildHTML = (opts: any = {}): string => {
const title = opts.title || 'Document';
const css = opts.css || '';
const body = typeof editor.getHTML === 'function' ? editor.getHTML() : '';
@@ -199,7 +238,26 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
.me-footnote-backref{text-decoration:none;color:#3b82f6;margin-right:.4em}
.me-table-wrap{overflow-x:auto;margin:.9em 0}
</style>` : '';
download(filename || `metona-${stamp()}.html`, `<!DOCTYPE html>\n<html lang="${opts.lang||'zh-CN'}">\n<head>\n<meta charset="utf-8"/>\n<meta name="viewport" content="width=device-width, initial-scale=1"/>\n<title>${title}</title>\n${css?`<style>${css}</style>`:''}${embedCSS}\n</head>\n<body>\n${body}\n</body>\n</html>`, 'text/html');
return `<!DOCTYPE html>\n<html lang="${opts.lang||'zh-CN'}">\n<head>\n<meta charset="utf-8"/>\n<meta name="viewport" content="width=device-width, initial-scale=1"/>\n<title>${title}</title>\n${css?`<style>${css}</style>`:''}${embedCSS}\n</head>\n<body>\n${body}\n</body>\n</html>`;
};
e.exportMarkdown = (filename?: string) => { download(filename || `metona-${stamp()}.md`, editor.getValue!(), 'text/markdown'); return editor; };
e.exportHTML = (filename?: string, opts: any = {}) => {
download(filename || `metona-${stamp()}.html`, buildHTML(opts), 'text/html');
return editor;
};
e.exportPDF = (opts: any = {}) => {
if (typeof document === 'undefined' || !editor.el) return editor;
const iframe = document.createElement('iframe');
iframe.style.position = 'fixed'; iframe.style.right = '0'; iframe.style.bottom = '0';
iframe.style.width = '0'; iframe.style.height = '0'; iframe.style.border = '0';
document.body.appendChild(iframe);
const doc = iframe.contentDocument;
if (!doc) { iframe.remove(); return editor; }
doc.open(); doc.write(buildHTML(opts)); doc.close();
setTimeout(() => {
try { iframe.contentWindow?.print(); } catch (_) {}
setTimeout(() => { if (iframe.parentNode) iframe.parentNode.removeChild(iframe); }, 1000);
}, 50);
return editor;
};
},
@@ -207,7 +265,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
};
const searchReplacePlugin: Plugin = {
name: 'searchReplace', version: '0.2.0', description: 'Search & Replace (Ctrl+F/H)', priority: 80,
name: 'searchReplace', version: '0.2.1', description: 'Search & Replace (Ctrl+F/H)', priority: 80,
install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined') return;
// Inject style once globally
@@ -220,9 +278,27 @@ const searchReplacePlugin: Plugin = {
const state = {
_panel: null as HTMLElement | null,
_regexMode: false,
_matchCase: true,
_wholeWord: false,
_cleanup: null as (() => void) | null,
};
const WORD_CLASS = '[\\w\\u4e00-\\u9fff]';
const escapeRegExpText = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const buildPattern = (q: string): RegExp | null => {
if (!q) return null;
let src = state._regexMode ? q : escapeRegExpText(q);
if (state._wholeWord) src = `(?<!${WORD_CLASS})(?:${src})(?!${WORD_CLASS})`;
const flags = 'g' + (state._matchCase ? '' : 'i');
try { return new RegExp(src, flags); } catch (_) { return null; }
};
const hasWordBoundary = (text: string, idx: number, len: number): boolean => {
const isWord = (c: string | undefined) => !!c && /[\w\u4e00-\u9fff]/.test(c);
return !isWord(text[idx - 1]) && !isWord(text[idx + len]);
};
const _updateReplaceVisible = () => {
if (!state._panel) return;
const show = state._panel.dataset.replace === '1';
@@ -247,32 +323,40 @@ const searchReplacePlugin: Plugin = {
const selected = editor.textarea.value.substring(editor.textarea.selectionStart, editor.textarea.selectionEnd);
const panel = document.createElement('div'); panel.className = 'me-search';
panel.dataset.replace = showReplace ? '1' : '0';
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${t('searchPlaceholder')||'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${t('findPrev')||'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${t('findNext')||'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-regex" title="Regex">.*</button><button class="me-search-btn me-search-close" title="${t('close')||'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${t('replacePlaceholder')||'Replace'}"/><button class="me-search-btn me-search-replace-one">${t('replace')||'Replace'}</button><button class="me-search-btn me-search-replace-all">${t('replaceAll')||'All'}</button></div>`;
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${t('searchPlaceholder')||'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${t('findPrev')||'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${t('findNext')||'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-case me-active" title="${t('matchCase')||'Match Case'}">Aa</button><button class="me-search-btn me-search-word" title="${t('wholeWord')||'Whole Word'}">ab</button><button class="me-search-btn me-search-regex" title="Regex">.*</button><button class="me-search-btn me-search-close" title="${t('close')||'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${t('replacePlaceholder')||'Replace'}"/><button class="me-search-btn me-search-replace-one">${t('replace')||'Replace'}</button><button class="me-search-btn me-search-replace-all">${t('replaceAll')||'All'}</button></div>`;
editor.el.appendChild(panel); state._panel = panel;
_updateReplaceVisible();
state._regexMode = false;
state._regexMode = false; state._matchCase = true; state._wholeWord = false;
const fi = panel.querySelector('.me-search-find') as HTMLInputElement;
const ri = panel.querySelector('.me-search-replace') as HTMLInputElement;
const ce = panel.querySelector('.me-search-count') as HTMLElement;
const regexBtn = panel.querySelector('.me-search-regex') as HTMLButtonElement;
const caseBtn = panel.querySelector('.me-search-case') as HTMLButtonElement;
const wordBtn = panel.querySelector('.me-search-word') as HTMLButtonElement;
regexBtn.addEventListener('click', () => {
state._regexMode = !state._regexMode;
regexBtn.classList.toggle('me-active', state._regexMode);
lastIdxs = findAll();
});
caseBtn.addEventListener('click', () => {
state._matchCase = !state._matchCase;
caseBtn.classList.toggle('me-active', state._matchCase);
lastIdxs = findAll();
});
wordBtn.addEventListener('click', () => {
state._wholeWord = !state._wholeWord;
wordBtn.classList.toggle('me-active', state._wholeWord);
lastIdxs = findAll();
});
const findAll = () => {
const q = fi.value; if (!q) { ce.textContent = ''; return []; }
const idxs: number[] = []; let from = 0;
if (state._regexMode) {
try {
const re = new RegExp(q, 'g'); let m: RegExpExecArray | null;
while ((m = re.exec(editor.textarea.value)) !== null) {
idxs.push(m.index);
if (m[0].length === 0) re.lastIndex++;
}
} catch (_) { ce.textContent = 'err'; return []; }
} else {
while (true) { const idx = editor.textarea.value.indexOf(q, from); if (idx === -1) break; idxs.push(idx); from = idx + q.length; }
const idxs: number[] = [];
const re = buildPattern(q);
if (!re) { ce.textContent = 'err'; return []; }
let m: RegExpExecArray | null;
while ((m = re.exec(editor.textarea.value)) !== null) {
idxs.push(m.index);
if (m[0].length === 0) re.lastIndex++;
}
ce.textContent = idxs.length ? `${idxs.length}` : '0';
return idxs;
@@ -282,30 +366,48 @@ const searchReplacePlugin: Plugin = {
editor.textarea.focus();
editor.textarea.setSelectionRange(idx, idx + (len || fi.value.length));
};
const matchLenAt = (idx: number): number => {
const text = editor.textarea.value;
const q = fi.value; if (!q) return 0;
if (!state._regexMode) {
if (state._matchCase && text.slice(idx, idx + q.length) !== q) return 0;
if (!state._matchCase && text.slice(idx, idx + q.length).toLowerCase() !== q.toLowerCase()) return 0;
if (state._wholeWord && !hasWordBoundary(text, idx, q.length)) return 0;
return q.length;
}
const re = buildPattern(q); if (!re) return 0;
re.lastIndex = idx;
const m = re.exec(text);
return m && m.index === idx ? m[0].length : 0;
};
const findNext = () => {
lastIdxs = findAll(); if (!lastIdxs.length) return;
const cur = editor.textarea.selectionEnd;
const qlen = state._regexMode ? (() => { try { const m = new RegExp(fi.value).exec(editor.textarea.value.substring(cur)); return m ? m[0].length : fi.value.length; } catch (_) { return fi.value.length; } })() : fi.value.length;
let next = lastIdxs.find((i: number) => i >= cur);
if (next == null) next = lastIdxs[0];
selectAt(next, qlen);
selectAt(next, matchLenAt(next) || fi.value.length);
};
const findPrev = () => {
lastIdxs = findAll(); if (!lastIdxs.length) return;
const cur = editor.textarea.selectionStart;
const qlen = state._regexMode ? (() => { try { const m = new RegExp(fi.value).exec(editor.textarea.value.substring(Math.max(0, cur - 100), cur + 100)); return m ? m[0].length : fi.value.length; } catch (_) { return fi.value.length; } })() : fi.value.length;
let prev = -1;
for (let i = lastIdxs.length-1; i>=0; i--) { if (lastIdxs[i] < cur) { prev = lastIdxs[i]; break; } }
if (prev === -1) prev = lastIdxs[lastIdxs.length-1];
selectAt(prev, qlen);
selectAt(prev, matchLenAt(prev) || fi.value.length);
};
const replaceOne = () => {
const q = fi.value, r = ri.value; if (!q) return;
const ta = editor.textarea; const s = ta.selectionStart, e = ta.selectionEnd;
const matchText = ta.value.substring(s, e);
let matched = false;
if (state._regexMode) {
try { if (new RegExp(q).test(matchText)) { ta.value = ta.value.substring(0, s) + r + ta.value.substring(e); ta.setSelectionRange(s, s + r.length); } } catch (_) {}
} else if (matchText === q) {
const re = buildPattern(q);
if (re) { re.lastIndex = s; const m = re.exec(ta.value); matched = !!(m && m.index === s); }
} else {
const sameCase = state._matchCase ? matchText === q : matchText.toLowerCase() === q.toLowerCase();
matched = sameCase && (!state._wholeWord || hasWordBoundary(ta.value, s, q.length));
}
if (matched) {
ta.value = ta.value.substring(0, s) + r + ta.value.substring(e);
ta.setSelectionRange(s, s + r.length);
}
@@ -317,11 +419,9 @@ const searchReplacePlugin: Plugin = {
const replaceAll = () => {
const q = fi.value, r = ri.value; if (!q) return;
const ta = editor.textarea;
if (state._regexMode) {
try { ta.value = ta.value.replace(new RegExp(q, 'g'), r); } catch (_) { return; }
} else {
ta.value = ta.value.split(q).join(r);
}
const re = buildPattern(q);
if (!re) return;
ta.value = ta.value.replace(re, () => r);
ta.setSelectionRange(0,0); editor._value = ta.value;
if (typeof editor._pushHistory === 'function') editor._pushHistory();
if (typeof editor._render === 'function') editor._render();
@@ -350,49 +450,55 @@ const searchReplacePlugin: Plugin = {
};
editor.textarea.addEventListener('keydown', _onKeydown);
// Expose for tests
(editor as any).__srState = state;
(editor as any).__srOpen = _open;
(editor as any).__srClose = _close;
(editor as any).__srKeydown = _onKeydown;
// Expose for tests via Symbol-keyed state (no public-property pollution)
setPluginState(editor, K_SEARCH, { state, _open, _close, _onKeydown });
},
destroy(editor: any) {
const state = (editor as any).__srState;
if (state) {
if (state._cleanup) { state._cleanup(); state._cleanup = null; }
state._panel = null;
destroy(editor: EditorLike) {
const exposed = pluginState<{ state: any; _onKeydown: ((e: KeyboardEvent) => void) | null }>(editor, K_SEARCH);
if (exposed) {
const state = exposed.state;
if (state) {
if (state._cleanup) { state._cleanup(); state._cleanup = null; }
state._panel = null;
}
if (exposed._onKeydown && editor && editor.textarea) {
editor.textarea.removeEventListener('keydown', exposed._onKeydown);
}
deletePluginState(editor, K_SEARCH);
}
const _onKeydown = (editor as any).__srKeydown;
if (_onKeydown && editor && editor.textarea) {
editor.textarea.removeEventListener('keydown', _onKeydown);
}
delete (editor as any).__srState;
delete (editor as any).__srOpen;
delete (editor as any).__srClose;
delete (editor as any).__srKeydown;
},
};
const imagePastePlugin: Plugin = {
name: 'imagePaste', version: '0.2.0', description: 'Paste image as base64', priority: 60,
install(editor) {
name: 'imagePaste', version: '0.2.1', description: 'Paste image as base64', priority: 60,
install(editor, options: any = {}) {
if (!editor || !editor.textarea || typeof document === 'undefined') return;
const maxSizeKB: number = options.maxSizeKB || 500;
const _onPaste = (e: ClipboardEvent) => {
const items = e.clipboardData?.items; if (!items) return;
for (const item of items) {
if (item.type?.startsWith('image/')) { e.preventDefault();
const reader = new FileReader(); reader.onload = () => { editor.insert(`![image-${Date.now().toString(36)}.png](${reader.result})\n`); };
reader.readAsDataURL(item.getAsFile()!); break;
if (item.type?.startsWith('image/')) {
const file = item.getAsFile();
if (!file) continue;
const sizeKB = file.size / 1024;
if (maxSizeKB > 0 && sizeKB > maxSizeKB) {
e.preventDefault();
if (typeof editor.toast === 'function') editor.toast(`Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' });
break;
}
e.preventDefault();
const reader = new FileReader(); reader.onload = () => { if (typeof editor.insert === 'function') editor.insert(`![image-${Date.now().toString(36)}.png](${reader.result})\n`); };
reader.readAsDataURL(file); break;
}
}
};
editor.textarea.addEventListener('paste', _onPaste);
(editor as any).__ipOnPaste = _onPaste;
setPluginState(editor, K_IMAGE_PASTE, _onPaste);
},
destroy(editor: any) {
const _onPaste = (editor as any).__ipOnPaste;
destroy(editor: EditorLike) {
const _onPaste = pluginState<(e: ClipboardEvent) => void>(editor, K_IMAGE_PASTE);
if (_onPaste && editor?.textarea) editor.textarea.removeEventListener('paste', _onPaste);
delete (editor as any).__ipOnPaste;
deletePluginState(editor, K_IMAGE_PASTE);
},
};
@@ -436,14 +542,14 @@ const shortcutHelpPlugin: Plugin = {
};
editor.textarea.addEventListener('keydown', _onKeydown);
(editor as any).__shState = { _panel, _open, _close, _onKeydown };
setPluginState(editor, K_SHORTCUT, { _panel, _open, _close, _onKeydown });
},
destroy(editor: any) {
const state = (editor as any).__shState;
destroy(editor: EditorLike) {
const state = pluginState<{ _panel: HTMLElement | null; _onKeydown: ((e: KeyboardEvent) => void) | null }>(editor, K_SHORTCUT);
if (state) { if (state._panel) { state._panel.remove(); } }
const _onKeydown = (editor as any).__shState?._onKeydown;
const _onKeydown = state?._onKeydown;
if (_onKeydown && editor?.textarea) editor.textarea.removeEventListener('keydown', _onKeydown);
delete (editor as any).__shState;
deletePluginState(editor, K_SHORTCUT);
},
};
@@ -454,31 +560,32 @@ const fileSystemPlugin: Plugin = {
const hasAPI = typeof window !== 'undefined' && typeof (window as any).showOpenFilePicker === 'function';
let _fileHandle: any = null;
editor.openFile = async (opts: any = {}) => {
if (!hasAPI) { editor.toast?.('File System Access API not supported', { type: 'warning' }); return null; }
(editor as any).openFile = async (opts: any = {}) => {
if (!hasAPI) { if (typeof editor.toast === 'function') editor.toast?.('File System Access API not supported', { type: 'warning' }); return null; }
try {
const [handle] = await (window as any).showOpenFilePicker({ types: [{ accept: { 'text/markdown': ['.md','.txt','.markdown'] } }], ...opts });
_fileHandle = handle; const file = await handle.getFile(); const content = await file.text();
editor.setValue(content); editor._emit?.('fileOpened', { name: file.name, handle }); return { name: file.name, content, handle };
if (typeof editor.setValue === 'function') editor.setValue(content);
editor._emit?.('fileOpened', { name: file.name, handle }); return { name: file.name, content, handle };
} catch (e: any) { if (e.name !== 'AbortError') console.error('Open file error:', e); return null; }
};
editor.saveFile = async (opts: any = {}) => {
(editor as any).saveFile = async (opts: any = {}) => {
let handle = _fileHandle;
if (!handle || opts.saveAs) {
if (!hasAPI) { editor.toast?.('File System Access API not supported', { type: 'warning' }); return false; }
if (!hasAPI) { if (typeof editor.toast === 'function') editor.toast?.('File System Access API not supported', { type: 'warning' }); return false; }
try { handle = await (window as any).showSaveFilePicker({ types: [{ accept: { 'text/markdown': ['.md'] } }], suggestedName: opts.name || 'document.md' }); _fileHandle = handle; }
catch (e: any) { if (e.name !== 'AbortError') console.error('Save error:', e); return false; }
}
try { const w = await handle.createWritable(); await w.write(editor.getValue()); await w.close(); editor._emit?.('fileSaved', { handle }); return true; }
catch (e) { _fileHandle = null; if (!opts.saveAs) return editor.saveFile({ ...opts, saveAs: true }); console.error('Write error:', e); return false; }
try { const w = await handle.createWritable(); await w.write(editor.getValue!()); await w.close(); editor._emit?.('fileSaved', { handle }); return true; }
catch (e) { _fileHandle = null; if (!opts.saveAs) return (editor as any).saveFile({ ...opts, saveAs: true }); console.error('Write error:', e); return false; }
};
editor.saveFileAs = (name?: string) => editor.saveFile({ saveAs: true, name });
editor.getFileHandle = () => _fileHandle;
(editor as any).__fsCleanup = { getHandle: () => _fileHandle };
(editor as any).saveFileAs = (name?: string) => (editor as any).saveFile({ saveAs: true, name });
(editor as any).getFileHandle = () => _fileHandle;
setPluginState(editor, K_FILESYSTEM, { getHandle: () => _fileHandle });
},
destroy(editor: any) {
if (editor) { delete editor.openFile; delete editor.saveFile; delete editor.saveFileAs; delete editor.getFileHandle; }
delete (editor as any).__fsCleanup;
destroy(editor: EditorLike) {
if (editor) { delete (editor as any).openFile; delete (editor as any).saveFile; delete (editor as any).saveFileAs; delete (editor as any).getFileHandle; }
deletePluginState(editor, K_FILESYSTEM);
},
};
+1 -1
View File
@@ -283,7 +283,7 @@ export const presetThemes = {
// Ensure built-in themes in THEMES
for (const [name, theme] of Object.entries(presetThemes)) {
if (name !== 'auto' && theme.config !== 'auto') THEMES[name] = theme.config as ThemeConfig;
if (name !== 'auto' && theme.config !== 'auto') THEMES[name] = theme.config;
}
export const themeUtils = {
+113
View File
@@ -2301,3 +2301,116 @@ describe('MarkdownEditor - v0.2.3 getStatus', () => {
ed.destroy();
});
});
// ============ v0.2.5 maxLength / zenMode / 分隔条隔离 ============
describe('MarkdownEditor - v0.2.5 maxLength', () => {
test('setValue 超长内容被截断', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { maxLength: 10 });
ed.setValue('0123456789ABC');
expect(ed.getValue()).toBe('0123456789');
expect(ed.textarea.value).toBe('0123456789');
ed.destroy();
});
test('insert 超长内容被截断', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { maxLength: 5, value: 'ab' });
ed.focus();
ed.textarea.setSelectionRange(2, 2);
ed.insert('CDEFG');
expect(ed.getValue()).toBe('abCDE');
ed.destroy();
});
test('textarea maxlength 属性已绑定', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { maxLength: 42 });
expect(ed.textarea.maxLength).toBe(42);
ed.destroy();
});
test('maxLength 为 0 时不限制', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(ed.textarea.maxLength).toBe(-1);
const long = 'x'.repeat(10000);
ed.setValue(long);
expect(ed.getValue().length).toBe(10000);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.5 zenMode 初始状态', () => {
test('zenMode: true 启动即进入 Zen 模式', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { zenMode: true });
expect(ed.isZen()).toBe(true);
expect(ed.el.classList.contains('me-zen')).toBe(true);
expect(ed.toolbarEl.style.opacity).toBe('0');
ed.destroy();
});
test('默认不进入 Zen 模式', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(ed.isZen()).toBe(false);
expect(ed.el.classList.contains('me-zen')).toBe(false);
ed.destroy();
});
test('zenMode 初始开启后 toggleZen 可关闭', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { zenMode: true });
ed.toggleZen();
expect(ed.isZen()).toBe(false);
expect(ed.el.classList.contains('me-zen')).toBe(false);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.5 分隔条实例隔离', () => {
test('分隔条位置按实例 id 保存', () => {
localStorage.clear();
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { id: 'divider-test-1' });
ed.editorPane.style.flex = '0 0 30%';
ed._saveDividerPosition();
expect(localStorage.getItem('metona-editor-divider-divider-test-1')).toBe('0 0 30%');
expect(localStorage.getItem('metona-editor-divider')).toBeNull();
ed.destroy();
});
test('不同实例互不覆盖分隔条位置', () => {
localStorage.clear();
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const a = new MarkdownEditor(c, { id: 'dva' });
const b = new MarkdownEditor(c, { id: 'dvb' });
a.editorPane.style.flex = '0 0 20%';
b.editorPane.style.flex = '0 0 70%';
a._saveDividerPosition();
b._saveDividerPosition();
expect(localStorage.getItem('metona-editor-divider-dva')).toBe('0 0 20%');
expect(localStorage.getItem('metona-editor-divider-dvb')).toBe('0 0 70%');
a.destroy(); b.destroy();
});
});
+155 -5
View File
@@ -794,20 +794,21 @@ describe('零散分支补全', () => {
});
test('autoSave save 内部抛错时 warn 不崩溃', () => {
jest.useFakeTimers();
document.body.innerHTML = '';
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'test' });
ed.use('autoSave');
ed.use('autoSave', { delay: 50 });
// 让 editor.getValue 抛错,触发 save 的 catch 分支
jest.spyOn(ed, 'getValue').mockImplementation(() => { throw new Error('get failed'); });
const cleanup = (ed as any).__autoSaveCleanup;
expect(cleanup).toBeDefined();
expect(() => cleanup.save()).not.toThrow();
ed._emit('change', 'x');
expect(() => jest.advanceTimersByTime(60)).not.toThrow();
expect(spy).toHaveBeenCalled();
ed.destroy();
spy.mockRestore();
jest.useRealTimers();
});
test('restoreDraft localStorage.getItem 抛错时返回 null', () => {
@@ -833,7 +834,6 @@ describe('零散分支补全', () => {
// Ctrl+F 打开面板
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
expect(ed.el.querySelector('.me-search')).not.toBeNull();
expect((ed as any).__srState._panel).toBeDefined();
// Ctrl+Escape 关闭面板(带 Ctrl 才会进入分支)
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', ctrlKey: true, bubbles: true }));
expect(ed.el.querySelector('.me-search')).toBeNull();
@@ -1125,3 +1125,153 @@ describe('v0.2.0 exportTool 插件', () => {
ed.destroy();
});
});
// ============ v0.2.5 searchReplace 大小写 / 全字匹配 ============
describe('v0.2.5 searchReplace 大小写与全字匹配', () => {
const setup = (value: string) => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value });
ed.use('searchReplace');
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
const fi = ed.el.querySelector('.me-search-find') as HTMLInputElement;
const ce = ed.el.querySelector('.me-search-count') as HTMLElement;
const caseBtn = ed.el.querySelector('.me-search-case') as HTMLButtonElement;
const wordBtn = ed.el.querySelector('.me-search-word') as HTMLButtonElement;
return { ed, fi, ce, caseBtn, wordBtn };
};
const teardown = (ed: any) => {
const panel = ed.el.querySelector('.me-search');
if (panel) panel.remove();
ed.destroy();
};
test('默认区分大小写', () => {
const { ed, fi, ce, caseBtn, wordBtn } = setup('Foo foo FOO');
fi.value = 'foo';
fi.dispatchEvent(new Event('input'));
expect(ce.textContent).toBe('1');
expect(caseBtn.classList.contains('me-active')).toBe(true);
teardown(ed);
});
test('关闭大小写后匹配全部', () => {
const { ed, fi, ce, caseBtn } = setup('Foo foo FOO');
fi.value = 'foo';
caseBtn.click();
fi.dispatchEvent(new Event('input'));
expect(caseBtn.classList.contains('me-active')).toBe(false);
expect(ce.textContent).toBe('3');
teardown(ed);
});
test('全字匹配排除子串', () => {
const { ed, fi, ce, wordBtn } = setup('cat category scatter cat');
fi.value = 'cat';
fi.dispatchEvent(new Event('input'));
expect(ce.textContent).toBe('4');
wordBtn.click();
fi.dispatchEvent(new Event('input'));
expect(ce.textContent).toBe('2');
teardown(ed);
});
test('全字匹配支持中文边界', () => {
const { ed, fi, ce, wordBtn } = setup('测试test测试 test');
fi.value = 'test';
wordBtn.click();
fi.dispatchEvent(new Event('input'));
expect(ce.textContent).toBe('1');
teardown(ed);
});
test('replaceAll 大小写不敏感替换', () => {
const { ed, fi, caseBtn } = setup('Foo foo FOO');
fi.value = 'foo';
caseBtn.click();
const ri = ed.el.querySelector('.me-search-replace') as HTMLInputElement;
ri.value = 'bar';
const allBtn = ed.el.querySelector('.me-search-replace-all') as HTMLButtonElement;
allBtn.click();
expect(ed.getValue()).toBe('bar bar bar');
teardown(ed);
});
test('正则模式 + 全字匹配', () => {
const { ed, fi, ce, wordBtn, caseBtn } = setup('cat category scatter cat');
fi.value = 'ca[nt]';
const regexBtn = ed.el.querySelector('.me-search-regex') as HTMLButtonElement;
regexBtn.click();
caseBtn.click();
wordBtn.click();
fi.dispatchEvent(new Event('input'));
expect(ce.textContent).toBe('2');
teardown(ed);
});
});
// ============ v0.2.5 exportPDF / imagePaste 尺寸限制 ============
describe('v0.2.5 exportTool exportPDF', () => {
test('exportPDF 创建隐藏 iframe 不抛错', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '# pdf test' });
ed.use('exportTool');
const printSpy = jest.spyOn(window, 'print').mockImplementation(() => {});
expect(() => ed.exportPDF({ title: 'T' })).not.toThrow();
printSpy.mockRestore();
ed.destroy();
});
test('exportHTML 与 exportPDF 共用构建逻辑', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '# hi' });
ed.use('exportTool');
expect(typeof (ed as any).exportPDF).toBe('function');
ed.destroy();
});
});
describe('v0.2.5 imagePaste 尺寸限制', () => {
test('超出 maxSizeKB 时 toast 警告且不插入', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '' });
ed.use('imagePaste', { maxSizeKB: 1 });
const toastSpy = jest.spyOn(ed, 'toast').mockImplementation(() => ed);
const insertSpy = jest.spyOn(ed, 'insert').mockImplementation(() => ed);
const file = new File([new ArrayBuffer(4096)], 'big.png', { type: 'image/png' });
const evt = new Event('paste', { bubbles: true });
Object.defineProperty(evt, 'clipboardData', { value: { items: [{ type: 'image/png', getAsFile: () => file }] } });
ed.textarea.dispatchEvent(evt as ClipboardEvent);
expect(toastSpy).toHaveBeenCalled();
expect(insertSpy).not.toHaveBeenCalled();
toastSpy.mockRestore();
insertSpy.mockRestore();
ed.destroy();
});
test('默认 500KB 内正常插入', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '' });
ed.use('imagePaste');
const insertSpy = jest.spyOn(ed, 'insert').mockImplementation(() => ed);
const file = new File([new ArrayBuffer(1024)], 'ok.png', { type: 'image/png' });
const evt = new Event('paste', { bubbles: true });
Object.defineProperty(evt, 'clipboardData', { value: { items: [{ type: 'image/png', getAsFile: () => file }] } });
ed.textarea.dispatchEvent(evt as ClipboardEvent);
expect(insertSpy).not.toHaveBeenCalled(); // FileReader 异步,同步阶段仅 preventDefault
insertSpy.mockRestore();
ed.destroy();
});
});