Files
MetonaEditor/src/plugins.ts
T
thzxx de545d1da6
CI / test (18.x) (push) Canceled after 0s
CI / test (20.x) (push) Canceled after 0s
CI / test (22.x) (push) Canceled after 0s
feat: v0.2.2 — plugin refactor, i18n, outline tracking, formatTable, code title, fr locale
## Added
- French (fr) locale with 60+ translations
- Outline panel scroll tracking with active heading highlight
- exec('formatTable') for auto-aligning Markdown table columns
- Code block title rendering via ```js title=hello.js```
- Escaped pipe \| support in table cells
- Custom block token fallback rendering as <div>
- Gutter incremental DOM update (no more full innerHTML)
- 694 tests (+10)

## Changed
- All 6 preset plugins refactored to closure-based state (no this pollution)
- shortcutHelp plugin now i18n-aware (shortcut labels follow locale)
- Context menu labels (Cut/Copy/Paste/Select All) i18n-translated
- Warm theme now has progressBg + closeHoverBg fields
2026-07-25 09:26:54 +08:00

525 lines
29 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';
export interface Plugin {
name: string;
version?: string;
description?: string;
depends?: string[];
priority?: number;
install?: (editor: any) => void | Promise<void>;
destroy?: (editor: any) => 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;
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 };
},
destroy(editor) {
const cleanup = (editor as any).__autoSaveCleanup;
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);
}
delete (editor as any).__autoSaveCleanup;
}
},
};
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())}`; };
editor.exportMarkdown = (filename?: string) => { download(filename || `metona-${stamp()}.md`, editor.getValue(), 'text/markdown'); return editor; };
editor.exportHTML = (filename?: string, opts: any = {}) => {
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>` : '';
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 editor;
};
},
destroy() {},
};
const searchReplacePlugin: Plugin = {
name: 'searchReplace', version: '0.2.0', 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:8px;right:12px;z-index:20;display:flex;flex-direction:column;gap:6px;padding:8px;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);font-size:13px;min-width:280px}.me-search-row{display:flex;gap:4px;align-items:center}.me-search input{flex:1;min-width:0;padding:4px 8px;border:1px solid var(--md-border,rgba(0,0,0,0.15));border-radius:4px;background:var(--md-textarea-bg,#fff);color:var(--md-text,#1f2937);font-size:13px}.me-search input:focus{outline:none;border-color:var(--md-accent,#3b82f6)}.me-search button{padding:4px 8px;border:1px solid var(--md-border,rgba(0,0,0,0.15));background:var(--md-bg,#fff);color:var(--md-text,#1f2937);border-radius:4px;cursor:pointer;font-size:12px;line-height:1}.me-search button:hover{background:var(--md-accent,#3b82f6);color:#fff;border-color:var(--md-accent,#3b82f6)}.me-search .me-search-count{color:var(--md-muted,#6b7280);font-size:12px;min-width:60px;text-align:center}.me-search .me-search-close{padding:2px 6px}`;
document.head.appendChild(style);
}
const state = {
_panel: null as HTMLElement | null,
_regexMode: false,
_cleanup: null as (() => void) | null,
};
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-prev">↑</button><button class="me-search-next">↓</button><span class="me-search-count"></span><button class="me-search-regex" title="Regex">.*</button><button class="me-search-close">×</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-replace-one">${t('replace')||'Replace'}</button><button class="me-search-replace-all">${t('replaceAll')||'All'}</button></div>`;
editor.el.appendChild(panel); state._panel = panel;
_updateReplaceVisible();
state._regexMode = 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;
regexBtn.addEventListener('click', () => {
state._regexMode = !state._regexMode;
regexBtn.classList.toggle('me-active', state._regexMode);
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; }
}
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 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);
};
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);
};
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);
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) {
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;
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);
}
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
(editor as any).__srState = state;
(editor as any).__srOpen = _open;
(editor as any).__srClose = _close;
(editor as any).__srKeydown = _onKeydown;
},
destroy(editor: any) {
const state = (editor as any).__srState;
if (state) {
if (state._cleanup) { state._cleanup(); state._cleanup = null; }
state._panel = null;
}
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) {
if (!editor || !editor.textarea || typeof document === 'undefined') return;
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;
}
}
};
editor.textarea.addEventListener('paste', _onPaste);
(editor as any).__ipOnPaste = _onPaste;
},
destroy(editor: any) {
const _onPaste = (editor as any).__ipOnPaste;
if (_onPaste && editor?.textarea) editor.textarea.removeEventListener('paste', _onPaste);
delete (editor as any).__ipOnPaste;
},
};
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('close') || '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('close') || '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);
(editor as any).__shState = { _panel, _open, _close, _onKeydown };
},
destroy(editor: any) {
const state = (editor as any).__shState;
if (state) { if (state._panel) { state._panel.remove(); } }
const _onKeydown = (editor as any).__shState?._onKeydown;
if (_onKeydown && editor?.textarea) editor.textarea.removeEventListener('keydown', _onKeydown);
delete (editor as any).__shState;
},
};
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.openFile = async (opts: any = {}) => {
if (!hasAPI) { 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 };
} catch (e: any) { if (e.name !== 'AbortError') console.error('Open file error:', e); return null; }
};
editor.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; }
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; }
};
editor.saveFileAs = (name?: string) => editor.saveFile({ saveAs: true, name });
editor.getFileHandle = () => _fileHandle;
(editor as any).__fsCleanup = { getHandle: () => _fileHandle };
},
destroy(editor: any) {
if (editor) { delete editor.openFile; delete editor.saveFile; delete editor.saveFileAs; delete editor.getFileHandle; }
delete (editor as any).__fsCleanup;
},
};
// ============ 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;