Files
MetonaEditor/src/plugins.ts
T
thzxx e5f99aee83 fix: 复查修复 5 处问题 — URL 双重转义/zen 泄漏/高亮环境不一致等
- parser: href/src/alt 改最小属性转义(escapeAttr 会把已实体化的 &
  再转义成 &,导致链接 URL 双重转义错误);新增 URL/alt 防回归测试
- core: _pushHistory 增加 maxLength 兜底截断(exec/Tab/Smart Enter/括号
  自动闭合等程序化路径此前绕过 maxlength 限制)
- core: destroy 时清理 Zen 模式的 document mousemove 监听(此前泄漏)
- highlight: 自写环境无关转义(仅 & < >),修复 Node 环境下字符串高亮
  失效、浏览器与 SSR 输出不一致的问题
- floating-toolbar/context-menu: toggleFloatingToolbar/registerContextMenu
  恢复链式 return this(拆分时丢失)
- plugins: 补齐 3 个硬编码文案的 i18n(regex/shortcuts/imageTooLarge),
  6 种语言同步
- 测试 768 → 780
2026-08-09 09:26:20 +08:00

632 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* MetonaEditor Plugins — plugin system v2
* @module plugins
* @version 0.2.0
*/
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: EditorLike, options?: any) => void | Promise<void>;
destroy?: (editor: EditorLike) => void;
[key: string]: any;
}
export interface PluginSchema {
[key: string]: {
type?: string;
required?: boolean;
default?: any;
enum?: any[];
validator?: (val: any) => boolean | string;
};
}
// ============ Plugin Manager ============
export class PluginManager {
plugins = new Map<string, Plugin & { enabled: boolean }>();
register(name: string, plugin: Plugin): this {
if (this.plugins.has(name)) { console.warn(`MeEditor: plugin "${name}" already registered`); return this; }
if (!plugin || typeof plugin !== 'object' || (!plugin.name && !name)) { console.error(`MeEditor: invalid plugin "${name}"`); return this; }
this.plugins.set(name, {
version: plugin.version || '0.0.0', description: plugin.description || '',
depends: plugin.depends || [], priority: plugin.priority || 0,
...plugin, name, enabled: true,
});
return this;
}
unregister(name: string): this { this.plugins.delete(name); return this; }
get(name: string): Plugin | null { return this.plugins.get(name) || null; }
has(name: string): boolean { return this.plugins.has(name); }
getAll(): Plugin[] { return Array.from(this.plugins.values()); }
getNames(): string[] { return Array.from(this.plugins.keys()); }
enable(name: string): this { const p = this.plugins.get(name); if (p) p.enabled = true; return this; }
disable(name: string): this { const p = this.plugins.get(name); if (p) p.enabled = false; return this; }
isEnabled(name: string): boolean { const p = this.plugins.get(name); return p ? p.enabled : false; }
destroy(): void { this.plugins.clear(); }
}
const defaultPluginManager = new PluginManager();
// ============ Topological sort ============
export const topologicalSort = (plugins: Plugin[]): Plugin[] => {
const map = new Map<string, Plugin>();
plugins.forEach((p) => map.set(p.name, p));
const inDegree = new Map<string, number>();
const adj = new Map<string, string[]>();
plugins.forEach((p) => { inDegree.set(p.name, 0); adj.set(p.name, []); });
plugins.forEach((p) => {
(p.depends || []).forEach((dep) => {
if (map.has(dep)) { adj.get(dep)!.push(p.name); inDegree.set(p.name, (inDegree.get(p.name) || 0) + 1); }
else { console.warn(`MeEditor: plugin "${p.name}" depends on unknown "${dep}"`); }
});
});
const queue: string[] = [];
inDegree.forEach((deg, name) => { if (deg === 0) queue.push(name); });
const sorted: Plugin[] = [];
while (queue.length) {
queue.sort((a, b) => ((map.get(b)!.priority || 0) - (map.get(a)!.priority || 0)));
const name = queue.shift()!;
sorted.push(map.get(name)!);
(adj.get(name) || []).forEach((n) => { inDegree.set(n, inDegree.get(n)! - 1); if (inDegree.get(n) === 0) queue.push(n); });
}
if (sorted.length !== plugins.length) { console.warn('MeEditor: circular dependency detected, falling back to original order'); return plugins; }
return sorted;
};
// ============ Config validation ============
export const validateConfig = (schema: PluginSchema = {}, config: Record<string, any> = {}): { valid: boolean; errors: string[]; patched: Record<string, any> } => {
const errors: string[] = [];
const patched = { ...config };
for (const [key, rule] of Object.entries(schema)) {
const val = config[key];
if (rule.required && (val === undefined || val === null)) { errors.push(`"${key}" is required`); continue; }
if (val === undefined && rule.default !== undefined) { patched[key] = rule.default; continue; }
if (val !== undefined && rule.type) {
const actual = Array.isArray(val) ? 'array' : typeof val;
if (actual !== rule.type) errors.push(`"${key}" expected ${rule.type}, got ${actual}`);
}
if (val !== undefined && rule.enum && !rule.enum.includes(val)) errors.push(`"${key}" must be one of [${rule.enum.join(', ')}]`);
if (val !== undefined && typeof rule.validator === 'function') {
try { const r = rule.validator(val); if (r !== true) errors.push(`"${key}": ${r}`); } catch (e: any) { errors.push(`"${key}": ${e.message}`); }
}
}
return { valid: errors.length === 0, errors, patched };
};
// ============ Preset plugins ============
const escapeAttr = (s: any): string => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const autoSavePlugin: Plugin = {
name: 'autoSave', version: '0.1.1', description: 'Auto-save to localStorage', priority: 100,
install(editor, options?: Record<string, any>) {
if (!editor || typeof editor.getValue !== 'function') return;
const opts = options || (this as any);
const key: string = opts.key || ('me-draft-' + (editor.id || ''));
const delay: number = opts.delay || 1000;
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!() }); }
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;
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 = 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') {
if (cleanup._onInput) editor.off('change', cleanup._onInput);
if (cleanup._onBlur) editor.off('blur', cleanup._onBlur);
if (cleanup._onSave) editor.off('save', cleanup._onSave);
}
deletePluginState(editor, K_AUTOSAVE);
}
},
};
const exportToolPlugin: Plugin = {
name: 'exportTool', version: '0.1.0', description: 'Export Markdown/HTML', priority: 50,
install(editor) {
if (!editor || typeof editor.getValue !== 'function') return;
const download = (filename: string, content: string, mime: string) => {
if (typeof document === 'undefined') return;
const blob = new Blob([content], { type: mime + ';charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
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())}`; };
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() : '';
// Build minimal embedded CSS for the exported HTML
const embedCSS = opts.embedCSS !== false ? `<style>
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif;font-size:14px;line-height:1.6;color:#1f2937;max-width:860px;margin:0 auto;padding:20px}
h1,h2,h3,h4,h5,h6{margin:1.4em 0 .6em;font-weight:650;line-height:1.3}
h1{font-size:1.9em;padding-bottom:.3em;border-bottom:1px solid #e5e7eb}
h2{font-size:1.55em;padding-bottom:.3em;border-bottom:1px solid #e5e7eb}
h3{font-size:1.3em}h4{font-size:1.12em}h5{font-size:1em}h6{font-size:.9em;color:#6b7280}
p{margin:.7em 0}a{color:#3b82f6;text-decoration:none}a:hover{text-decoration:underline}
strong{font-weight:650}em{font-style:italic}del{text-decoration:line-through;opacity:.75}
ul,ol{margin:.6em 0;padding-left:1.6em}li{margin:.25em 0}
li.me-task-item{list-style:none;margin-left:-1.4em}
li.me-task-item input{margin-right:.5em;vertical-align:middle}
blockquote{margin:.8em 0;padding:.4em 1em;border-left:3px solid #3b82f6;background:#f3f4f6;border-radius:0 6px 6px 0}
hr{border:0;height:1px;background:#e5e7eb;margin:1.6em 0}
code{font-family:"SF Mono",Consolas,monospace;font-size:.88em;padding:.15em .4em;background:#f3f4f6;border-radius:4px}
pre{margin:.9em 0;padding:14px 16px;background:#f3f4f6;border-radius:8px;overflow-x:auto;border:1px solid #e5e7eb}
pre code{padding:0;background:transparent;font-size:.9em;line-height:1.6;border-radius:0}
img{max-width:100%;height:auto;border-radius:6px}
table{border-collapse:collapse;width:100%;font-size:.93em;display:block;overflow-x:auto}
th,td{border:1px solid #e5e7eb;padding:7px 12px;text-align:left}
th{background:#f3f4f6;font-weight:600}
tr:nth-child(even) td{background:#f9fafb}
mark{background:rgba(250,204,21,.3);color:inherit;padding:.1em .2em;border-radius:3px}
sup{font-size:.75em}sub{font-size:.75em}
.me-math-block{display:block;margin:1.2em 0;padding:12px 16px;background:#f3f4f6;border-radius:8px;overflow-x:auto;font-family:monospace;text-align:center}
.me-math-inline{font-family:monospace}
dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;color:#4b5563}
.me-footnote-ref a{font-size:.75em;vertical-align:super;text-decoration:none;color:#3b82f6}
.me-footnotes{margin-top:2em;border-top:1px solid #e5e7eb;padding-top:.8em;font-size:.9em;color:#6b7280}
.me-footnotes hr{display:none}
.me-footnotes ol{padding-left:1.2em}
.me-footnote-item{margin:.3em 0}
.me-footnote-backref{text-decoration:none;color:#3b82f6;margin-right:.4em}
.me-table-wrap{overflow-x:auto;margin:.9em 0}
</style>` : '';
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;
};
},
destroy() {},
};
const searchReplacePlugin: Plugin = {
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
if (!document.getElementById('me-search-style')) {
const style = document.createElement('style'); style.id = 'me-search-style';
style.textContent = `.me-search{position:absolute;top:10px;right:16px;z-index:20;display:flex;flex-direction:column;gap:8px;padding:12px;background:var(--md-bg,#1c2029);border:1px solid var(--md-border,rgba(255,255,255,0.12));border-radius:10px;box-shadow:0 12px 40px -8px rgba(0,0,0,0.4),0 0 0 1px rgba(255,255,255,0.05);font-size:13px;min-width:300px;backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);animation:meSearchIn .18s ease-out}@keyframes meSearchIn{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}.me-search-row{display:flex;gap:6px;align-items:center}.me-search-row+.me-search-row{margin-top:2px}.me-search input{flex:1;min-width:0;padding:7px 10px;border:1px solid var(--md-border,rgba(255,255,255,0.12));border-radius:6px;background:var(--md-textarea-bg,rgba(255,255,255,0.04));color:var(--md-text,#e6e8eb);font-size:13px;outline:none;transition:border-color .15s,box-shadow .15s}.me-search input::placeholder{color:var(--md-muted,#6b7280)}.me-search input:focus{border-color:var(--md-accent,#3b82f6);box-shadow:0 0 0 3px rgba(59,130,246,0.15)}.me-search .me-search-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:1px solid rgba(255,255,255,0.08);border-radius:5px;background:rgba(255,255,255,0.04);color:var(--md-muted,#9ca3af);cursor:pointer;transition:all .12s;flex-shrink:0}.me-search .me-search-btn:hover{background:var(--md-accent,#3b82f6);color:#fff;border-color:var(--md-accent,#3b82f6)}.me-search .me-search-btn:active{transform:scale(.92)}.me-search .me-search-btn.me-active{background:var(--md-accent,#3b82f6);color:#fff;border-color:var(--md-accent,#3b82f6)}.me-search .me-search-btn svg{width:14px;height:14px}.me-search .me-search-count{color:var(--md-muted,#9ca3af);font-size:11.5px;min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.me-search .me-search-close{margin-left:2px;font-size:16px;line-height:1}.me-search .me-search-close:hover{background:#ef4444;border-color:#ef4444}.me-search .me-search-replace-one,.me-search .me-search-replace-all{width:auto;padding:5px 10px;font-size:12px;font-weight:500;letter-spacing:.01em}.me-search .me-search-replace-all{background:rgba(59,130,246,0.12);border-color:rgba(59,130,246,0.3);color:var(--md-accent,#60a5fa)}.me-search .me-search-replace-all:hover{background:var(--md-accent,#3b82f6);color:#fff}`;
document.head.appendChild(style);
}
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';
const row = state._panel.querySelector('.me-search-replace-row') as HTMLElement;
if (row) row.style.display = show ? 'flex' : 'none';
};
const _close = () => {
if (state._cleanup) { state._cleanup(); state._cleanup = null; }
state._panel = null;
if (editor && editor.textarea) editor.textarea.focus();
};
const _open = (showReplace?: boolean) => {
if (state._panel) {
state._panel.dataset.replace = showReplace ? '1' : '0';
_updateReplaceVisible();
const inp = state._panel.querySelector('.me-search-find') as HTMLInputElement;
if (inp) { inp.focus(); inp.select(); }
return;
}
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-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="${t('regex')||'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._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[] = [];
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;
};
let lastIdxs: number[] = [];
const selectAt = (idx: number, len?: number) => {
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;
let next = lastIdxs.find((i: number) => i >= cur);
if (next == null) next = lastIdxs[0];
selectAt(next, matchLenAt(next) || fi.value.length);
};
const findPrev = () => {
lastIdxs = findAll(); if (!lastIdxs.length) return;
const cur = editor.textarea.selectionStart;
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, 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) {
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);
}
editor._value = ta.value; if (typeof editor._pushHistory === 'function') editor._pushHistory();
if (typeof editor._render === 'function') editor._render();
if (typeof editor._emit === 'function') editor._emit('change', editor._value);
findNext();
};
const replaceAll = () => {
const q = fi.value, r = ri.value; if (!q) return;
const ta = editor.textarea;
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();
if (typeof editor._emit === 'function') editor._emit('change', editor._value);
findAll();
};
fi.addEventListener('input', () => { lastIdxs = findAll(); });
fi.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? findPrev() : findNext(); } if (e.key === 'Escape') { e.preventDefault(); _close(); } });
ri.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); replaceOne(); } if (e.key === 'Escape') { e.preventDefault(); _close(); } });
panel.querySelector('.me-search-next')!.addEventListener('click', findNext);
panel.querySelector('.me-search-prev')!.addEventListener('click', findPrev);
panel.querySelector('.me-search-close')!.addEventListener('click', () => _close());
panel.querySelector('.me-search-replace-one')!.addEventListener('click', replaceOne);
panel.querySelector('.me-search-replace-all')!.addEventListener('click', replaceAll);
fi.focus(); fi.select();
state._cleanup = () => { if (panel.parentNode) panel.parentNode.removeChild(panel); };
};
const _onKeydown = (e: KeyboardEvent) => {
const mod = e.ctrlKey || e.metaKey;
if (!mod) return;
const k = e.key.toLowerCase();
if (k === 'f') { e.preventDefault(); _open(false); }
else if (k === 'h') { e.preventDefault(); _open(true); }
else if (k === 'escape' && state._panel) { _close(); }
};
editor.textarea.addEventListener('keydown', _onKeydown);
// Expose for tests via Symbol-keyed state (no public-property pollution)
setPluginState(editor, K_SEARCH, { state, _open, _close, _onKeydown });
},
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 imagePastePlugin: Plugin = {
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/')) {
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(t('imageTooLarge', { size: sizeKB.toFixed(0), max: maxSizeKB }) || `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);
setPluginState(editor, K_IMAGE_PASTE, _onPaste);
},
destroy(editor: EditorLike) {
const _onPaste = pluginState<(e: ClipboardEvent) => void>(editor, K_IMAGE_PASTE);
if (_onPaste && editor?.textarea) editor.textarea.removeEventListener('paste', _onPaste);
deletePluginState(editor, K_IMAGE_PASTE);
},
};
const shortcutHelpPlugin: Plugin = {
name: 'shortcutHelp', version: '0.2.0', description: 'Press ? to show shortcuts', priority: 200,
install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined') return;
if (!document.getElementById('me-shortcut-style')) {
const s = document.createElement('style'); s.id = 'me-shortcut-style';
s.textContent = `.me-shortcut-overlay{position:fixed;top:0;left:0;right:0;bottom:0;z-index:50;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center}.me-shortcut-panel{background:var(--md-bg,#fff);border-radius:12px;padding:24px;max-width:560px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:0 12px 40px rgba(0,0,0,0.3)}.me-shortcut-panel h3{font-size:16px;margin:0 0 16px;color:var(--md-text)}.me-shortcut-panel table{width:100%;border-collapse:collapse;font-size:13px}.me-shortcut-panel td{padding:6px 10px;border-bottom:1px solid var(--md-border)}.me-shortcut-panel td:first-child{font-family:var(--md-mono);font-size:12px;color:var(--md-accent);white-space:nowrap;width:40%}.me-shortcut-panel .me-shortcut-close{position:absolute;top:16px;right:20px;background:none;border:none;font-size:20px;cursor:pointer;color:var(--md-muted)}`;
document.head.appendChild(s);
}
let _panel: HTMLElement | null = null;
const _close = () => { if (_panel) { _panel.remove(); _panel = null; } };
const _open = () => {
if (_panel) { _close(); return; }
// i18n-aware shortcut labels
const builtin: [string, string][] = [
['Ctrl+B', t('bold') || 'Bold'], ['Ctrl+I', t('italic') || 'Italic'],
['Ctrl+U', t('underline') || 'Underline'], ['Ctrl+K', t('link') || 'Link'],
['Ctrl+E', t('code') || 'Code'], ['Ctrl+1/2/3', t('h1') || 'Heading'],
['Ctrl+Q', t('quote') || 'Quote'], ['Ctrl+Z', t('undo') || 'Undo'],
['Ctrl+Y', t('redo') || 'Redo'], ['Ctrl+S', t('save') || 'Save'],
['Ctrl+F', t('search') || 'Search'], ['Ctrl+H', t('replace') || 'Replace'],
['Tab', t('indent') || 'Indent'], ['Shift+Tab', t('outdent') || 'Outdent'],
['?', t('shortcuts') || 'Shortcuts'],
];
let rows = ''; builtin.forEach(([c, d]) => { rows += `<tr><td>${c}</td><td>${d}</td></tr>`; });
const overlay = document.createElement('div'); overlay.className = 'me-shortcut-overlay';
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${t('shortcuts') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
overlay.addEventListener('click', (e) => { if (e.target === overlay || (e.target as HTMLElement).classList.contains('me-shortcut-close')) _close(); });
document.body.appendChild(overlay); _panel = overlay;
};
const _onKeydown = (e: KeyboardEvent) => {
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); _open(); }
if (e.key === 'Escape' && _panel) _close();
};
editor.textarea.addEventListener('keydown', _onKeydown);
setPluginState(editor, K_SHORTCUT, { _panel, _open, _close, _onKeydown });
},
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 = state?._onKeydown;
if (_onKeydown && editor?.textarea) editor.textarea.removeEventListener('keydown', _onKeydown);
deletePluginState(editor, K_SHORTCUT);
},
};
const fileSystemPlugin: Plugin = {
name: 'fileSystem', version: '0.2.0', description: 'File System Access API', priority: 90,
install(editor) {
if (!editor || typeof editor.getValue !== 'function') return;
const hasAPI = typeof window !== 'undefined' && typeof (window as any).showOpenFilePicker === 'function';
let _fileHandle: any = 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();
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 as any).saveFile = async (opts: any = {}) => {
let handle = _fileHandle;
if (!handle || opts.saveAs) {
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 as any).saveFile({ ...opts, saveAs: true }); console.error('Write error:', e); return false; }
};
(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: 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);
},
};
// ============ Preset plugins table ============
export const presetPlugins: Record<string, Plugin> = {
autoSave: autoSavePlugin, exportTool: exportToolPlugin, searchReplace: searchReplacePlugin,
imagePaste: imagePastePlugin, shortcutHelp: shortcutHelpPlugin, fileSystem: fileSystemPlugin,
};
// ============ Plugin utils ============
export const pluginUtils = {
createManager: () => new PluginManager(),
manager: defaultPluginManager,
register: (name: string, plugin: Plugin) => defaultPluginManager.register(name, plugin),
unregister: (name: string) => defaultPluginManager.unregister(name),
get: (name: string) => defaultPluginManager.get(name),
has: (name: string) => defaultPluginManager.has(name),
getAll: () => defaultPluginManager.getAll(),
getNames: () => defaultPluginManager.getNames(),
enable: (name: string) => defaultPluginManager.enable(name),
disable: (name: string) => defaultPluginManager.disable(name),
isEnabled: (name: string) => defaultPluginManager.isEnabled(name),
getPreset: (name: string): Plugin | null => presetPlugins[name] ? { ...presetPlugins[name] } : null,
getAllPresets: (): Record<string, Plugin> => Object.keys(presetPlugins).reduce((acc, k) => { acc[k] = { ...presetPlugins[k] }; return acc; }, {} as Record<string, Plugin>),
createPlugin: (config: Partial<Plugin> = {}): Plugin => {
const result: Plugin = { name: config.name || 'custom', version: config.version || '0.0.0', description: config.description || '', depends: config.depends || [], priority: config.priority || 0, install: () => {}, destroy: () => {}, ...config };
if (typeof result.install !== 'function') result.install = () => {};
if (typeof result.destroy !== 'function') result.destroy = () => {};
return result;
},
validatePlugin: (plugin: any): { valid: boolean; errors: string[] } => {
const errors: string[] = [];
if (!plugin || typeof plugin !== 'object') errors.push('plugin must be an object');
if (plugin && !plugin.name) errors.push('plugin must have a name');
if (plugin && plugin.install && typeof plugin.install !== 'function') errors.push('install must be a function');
return { valid: errors.length === 0, errors };
},
topologicalSort, validateConfig,
};
export default presetPlugins;