- parser: href/src/alt 改最小属性转义(escapeAttr 会把已实体化的 & 再转义成 &amp;,导致链接 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
632 lines
36 KiB
TypeScript
632 lines
36 KiB
TypeScript
/**
|
||
* 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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||
|
||
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(`\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;
|