/** * MetonaEditor Plugins — 插件系统 v2 * @module plugins * @version 0.1.13 * @description 插件管理器 + 预设插件 + 依赖/生命周期/异步/卸载 * * v0.1.6 增强: * - depends: 声明插件依赖,自动拓扑排序安装 * - 生命周期钩子: beforeInstall/afterInstall/beforeDestroy(静态 + 实例级) * - 异步插件: install 返回 Promise 时自动 await * - editor.unuse(name): 卸载单个已安装插件 * - pluginUtils.validateConfig(): schema 配置校验 * - priority: 数字越大越先安装 */ import { t } from './i18n.js'; // ============ 插件管理器 ============ class PluginManager { constructor() { this.plugins = new Map(); } register(name, plugin) { 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, { name, version: plugin.version || '0.0.0', description: plugin.description || '', depends: plugin.depends || [], priority: plugin.priority || 0, ...plugin, enabled: true, }); return this; } unregister(name) { this.plugins.delete(name); return this; } get(name) { return this.plugins.get(name) || null; } has(name) { return this.plugins.has(name); } getAll() { return Array.from(this.plugins.values()); } getNames() { return Array.from(this.plugins.keys()); } enable(name) { const p = this.plugins.get(name); if (p) p.enabled = true; return this; } disable(name) { const p = this.plugins.get(name); if (p) p.enabled = false; return this; } isEnabled(name) { const p = this.plugins.get(name); return p ? p.enabled : false; } destroy() { this.plugins.clear(); } } const defaultPluginManager = new PluginManager(); // ============ 依赖拓扑排序 ============ /** * 对插件列表按依赖关系拓扑排序(Kahn 算法) * @param {Object[]} plugins - 插件对象数组 * @returns {Object[]} 排序后的插件数组 */ const topologicalSort = (plugins) => { const map = new Map(); plugins.forEach((p) => map.set(p.name, p)); const inDegree = new Map(); const adj = new Map(); 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 = []; inDegree.forEach((deg, name) => { if (deg === 0) queue.push(name); }); const sorted = []; while (queue.length) { // 同层级按 priority 降序 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 in plugins, falling back to original order'); return plugins; } return sorted; }; // ============ 配置校验 ============ /** * 校验插件配置是否符合 schema * @param {Object} schema - { key: { type, required, default, enum, validator } } * @param {Object} config - 用户传入的配置 * @returns {{ valid: boolean, errors: string[], patched: Object }} */ const validateConfig = (schema = {}, config = {}) => { const errors = []; const patched = { ...config }; Object.entries(schema).forEach(([key, rule]) => { const val = config[key]; // required if (rule.required && (val === undefined || val === null)) { errors.push(`"${key}" is required`); return; } // default if (val === undefined && rule.default !== undefined) { patched[key] = rule.default; return; } // type 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}`); } } // enum if (val !== undefined && rule.enum && !rule.enum.includes(val)) { errors.push(`"${key}" must be one of [${rule.enum.join(', ')}]`); } // custom validator if (val !== undefined && typeof rule.validator === 'function') { try { const result = rule.validator(val); if (result !== true) errors.push(`"${key}": ${result}`); } catch (e) { errors.push(`"${key}": ${e.message}`); } } }); return { valid: errors.length === 0, errors, patched }; }; // ============ 预设插件 ============ /** * 自动保存插件 */ const autoSavePlugin = { name: 'autoSave', version: '0.1.0', description: '自动保存到 localStorage,支持草稿恢复', priority: 100, depends: [], _timer: null, _onInput: null, _onBlur: null, _onSave: null, install(editor) { if (!editor || typeof editor.getValue !== 'function') return; const key = this.key || ('me-draft-' + (editor.id || '')); const save = () => { if (this._timer) { clearTimeout(this._timer); this._timer = null; } try { const value = editor.getValue(); localStorage.setItem(key, value); if (typeof editor._emit === 'function') { editor._emit('autosave', { key, value }); } } catch (e) { console.warn('MeEditor autoSave: save failed', e); } }; this._save = save; this._onInput = () => { if (this._timer) clearTimeout(this._timer); this._timer = setTimeout(save, this.delay || 1000); }; this._onBlur = save; this._onSave = save; editor.on('change', this._onInput); editor.on('blur', this._onBlur); editor.on('save', this._onSave); editor.restoreDraft = () => { try { const v = localStorage.getItem(key); if (v != null && typeof editor.setValue === 'function') editor.setValue(v); return v; } catch (e) { return null; } }; editor.clearDraft = () => { try { localStorage.removeItem(key); } catch (_) {} return editor; }; editor.getDraftKey = () => key; }, destroy(editor) { if (this._timer) { clearTimeout(this._timer); this._timer = null; } if (editor && typeof editor.off === 'function') { if (this._onInput) editor.off('change', this._onInput); if (this._onBlur) editor.off('blur', this._onBlur); if (this._onSave) editor.off('save', this._onSave); } }, // 配置 schema configSchema: { key: { type: 'string' }, delay: { type: 'number', default: 1000 }, }, }; /** * 导出工具插件 */ const exportToolPlugin = { name: 'exportTool', version: '0.1.0', description: '导出 Markdown / HTML 文件', priority: 50, install(editor) { if (!editor || typeof editor.getValue !== 'function') return; const download = (filename, content, mime) => { 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) => String(n).padStart(2, '0'); return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`; }; editor.exportMarkdown = (filename) => { download(filename || `metona-${stamp()}.md`, editor.getValue(), 'text/markdown'); return editor; }; editor.exportHTML = (filename, opts = {}) => { const title = opts.title || 'Document'; const css = opts.css || ''; const body = typeof editor.getHTML === 'function' ? editor.getHTML() : ''; const html = `\n\n\n\n\n${title}\n${css ? `` : ''}\n\n\n${body}\n\n`; download(filename || `metona-${stamp()}.html`, html, 'text/html'); return editor; }; }, destroy() {}, }; /** * 查找替换插件 */ const searchReplacePlugin = { name: 'searchReplace', version: '0.1.0', description: '查找替换(Ctrl+F / Ctrl+H)', priority: 80, _onKeydown: null, _panel: null, _cleanup: null, install(editor) { if (!editor || !editor.textarea || typeof document === 'undefined') return; this._injectStyle(); this._onKeydown = (e) => { const mod = e.ctrlKey || e.metaKey; if (!mod) return; const k = e.key.toLowerCase(); if (k === 'f') { e.preventDefault(); this._open(editor); } else if (k === 'h') { e.preventDefault(); this._open(editor, true); } else if (k === 'escape' && this._panel) { this._close(editor); } }; editor.textarea.addEventListener('keydown', this._onKeydown); }, _injectStyle() { if (document.getElementById('me-search-style')) return; 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); }, _open(editor, showReplace) { if (this._panel) { this._panel.dataset.replace = showReplace ? '1' : '0'; this._updateReplaceVisible(); const inp = this._panel.querySelector('.me-search-find'); 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 = `
`; editor.el.appendChild(panel); this._panel = panel; this._updateReplaceVisible(); const findInput = panel.querySelector('.me-search-find'); const replaceInput = panel.querySelector('.me-search-replace'); const countEl = panel.querySelector('.me-search-count'); const findAll = () => { const text = editor.textarea.value; const q = findInput.value; if (!q) { countEl.textContent = ''; return []; } const idxs = []; let from = 0; while (true) { const idx = text.indexOf(q, from); if (idx === -1) break; idxs.push(idx); from = idx + q.length; } countEl.textContent = idxs.length ? `${idxs.length}` : '0'; return idxs; }; const selectAt = (idx) => { const q = findInput.value; editor.textarea.focus(); editor.textarea.setSelectionRange(idx, idx + q.length); const lineHeight = parseFloat(getComputedStyle(editor.textarea).lineHeight) || 20; const lineNum = editor.textarea.value.substring(0, idx).split('\n').length - 1; editor.textarea.scrollTop = Math.max(0, lineNum * lineHeight - editor.textarea.clientHeight / 2); }; let lastIdxs = []; const findNext = () => { lastIdxs = findAll(); if (!lastIdxs.length) return; const cur = editor.textarea.selectionEnd; let next = lastIdxs.find((i) => i >= cur); if (next == null) next = lastIdxs[0]; selectAt(next); }; 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); }; const replaceOne = () => { const q = findInput.value; const r = replaceInput.value; if (!q) return; const ta = editor.textarea; const start = ta.selectionStart, end = ta.selectionEnd; if (ta.value.substring(start, end) === q) { ta.value = ta.value.substring(0, start) + r + ta.value.substring(end); ta.setSelectionRange(start, start + 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 = findInput.value, r = replaceInput.value; if (!q) return; const ta = editor.textarea; const before = ta.value; const after = before.split(q).join(r); if (before === after) return; ta.value = after; 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(); }; findInput.addEventListener('input', () => { lastIdxs = findAll(); }); findInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? findPrev() : findNext(); } if (e.key === 'Escape') { e.preventDefault(); this._close(editor); } }); replaceInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); replaceOne(); } if (e.key === 'Escape') { e.preventDefault(); this._close(editor); } }); panel.querySelector('.me-search-next').addEventListener('click', findNext); panel.querySelector('.me-search-prev').addEventListener('click', findPrev); panel.querySelector('.me-search-close').addEventListener('click', () => this._close(editor)); panel.querySelector('.me-search-replace-one').addEventListener('click', replaceOne); panel.querySelector('.me-search-replace-all').addEventListener('click', replaceAll); if (selected) findAll(); findInput.focus(); findInput.select(); this._cleanup = () => { if (panel.parentNode) panel.parentNode.removeChild(panel); }; }, _updateReplaceVisible() { if (!this._panel) return; const show = this._panel.dataset.replace === '1'; const row = this._panel.querySelector('.me-search-replace-row'); if (row) row.style.display = show ? 'flex' : 'none'; }, _close(editor) { if (this._cleanup) { this._cleanup(); this._cleanup = null; } this._panel = null; if (editor && editor.textarea) editor.textarea.focus(); }, destroy(editor) { this._close(editor); if (this._onKeydown && editor && editor.textarea) { editor.textarea.removeEventListener('keydown', this._onKeydown); } this._onKeydown = null; }, }; /** * 图片粘贴插件 */ const imagePastePlugin = { name: 'imagePaste', version: '0.1.0', description: '粘贴图片自动转为 base64 内联', priority: 60, _onPaste: null, install(editor) { if (!editor || !editor.textarea || typeof document === 'undefined') return; this._onPaste = (e) => { const items = e.clipboardData && e.clipboardData.items; if (!items) return; for (const item of items) { if (item.type && item.type.startsWith('image/')) { e.preventDefault(); const reader = new FileReader(); reader.onload = () => { const dataUri = reader.result; const name = `image-${Date.now().toString(36)}.png`; editor.insert(`![${name}](${dataUri})\n`); }; reader.readAsDataURL(item.getAsFile()); break; } } }; editor.textarea.addEventListener('paste', this._onPaste); }, destroy(editor) { if (this._onPaste && editor && editor.textarea) { editor.textarea.removeEventListener('paste', this._onPaste); } this._onPaste = null; }, }; /** * 快捷键帮助插件(v0.1.12) * 按 ? 弹出快捷键列表面板 */ const shortcutHelpPlugin = { name: 'shortcutHelp', version: '0.1.0', description: '按 ? 查看快捷键列表', priority: 200, _onKeydown: null, _panel: null, install(editor) { if (!editor || !editor.textarea || typeof document === 'undefined') return; this._injectStyle(); this._onKeydown = (e) => { // ? 键(Shift+/),且不在输入框中时 if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); this._open(editor); } if (e.key === 'Escape' && this._panel) { this._close(); } }; editor.textarea.addEventListener('keydown', this._onKeydown); }, _injectStyle() { if (document.getElementById('me-shortcut-style')) return; const style = document.createElement('style'); style.id = 'me-shortcut-style'; style.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 td:last-child{color:var(--md-text)}.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(style); }, _open(editor) { if (this._panel) { this._close(); return; } const builtin = [ ['Ctrl+B', '粗体'], ['Ctrl+I', '斜体'], ['Ctrl+U', '下划线'], ['Ctrl+K', '链接'], ['Ctrl+E', '行内代码'], ['Ctrl+1/2/3', '标题 H1/H2/H3'], ['Ctrl+Q', '引用'], ['Ctrl+Z', '撤销'], ['Ctrl+Y / Ctrl+Shift+Z', '重做'], ['Ctrl+S', '保存'], ['Ctrl+F', '查找'], ['Ctrl+H', '替换'], ['Tab', '缩进'], ['Shift+Tab', '反缩进'], ['Ctrl+Shift+T', 'Zen 模式演示快捷键'], ['?', '显示/隐藏快捷键帮助'], ]; const custom = (editor.getShortcuts && editor.getShortcuts()) || []; const customRows = custom.map((s) => [s.combo, s.description || s.combo]); const allRows = [...builtin, ...customRows]; let rows = ''; allRows.forEach(([combo, desc]) => { rows += `${combo}${desc}`; }); const overlay = document.createElement('div'); overlay.className = 'me-shortcut-overlay'; overlay.innerHTML = `

⌨️ 快捷键

${rows}
`; overlay.addEventListener('click', (e) => { if (e.target === overlay || e.target.classList.contains('me-shortcut-close')) { this._close(); } }); document.body.appendChild(overlay); this._panel = overlay; }, _close() { if (this._panel) { this._panel.remove(); this._panel = null; } }, destroy(editor) { this._close(); if (this._onKeydown && editor && editor.textarea) { editor.textarea.removeEventListener('keydown', this._onKeydown); } this._onKeydown = null; }, }; // ============ 预设插件表 ============ const presetPlugins = { autoSave: autoSavePlugin, exportTool: exportToolPlugin, searchReplace: searchReplacePlugin, imagePaste: imagePastePlugin, shortcutHelp: shortcutHelpPlugin, }; // ============ 实用工具 ============ const escapeAttr = (s) => String(s == null ? '' : s) .replace(/&/g, '&').replace(/"/g, '"') .replace(//g, '>'); // ============ 插件工具集 ============ const pluginUtils = { createManager() { return new PluginManager(); }, manager: defaultPluginManager, register(name, plugin) { return defaultPluginManager.register(name, plugin); }, unregister(name) { return defaultPluginManager.unregister(name); }, get(name) { return defaultPluginManager.get(name); }, has(name) { return defaultPluginManager.has(name); }, getAll() { return defaultPluginManager.getAll(); }, getNames() { return defaultPluginManager.getNames(); }, enable(name) { return defaultPluginManager.enable(name); }, disable(name) { return defaultPluginManager.disable(name); }, isEnabled(name) { return defaultPluginManager.isEnabled(name); }, getPreset(name) { return presetPlugins[name] ? { ...presetPlugins[name] } : null; }, getAllPresets() { return Object.keys(presetPlugins).reduce((acc, k) => { acc[k] = { ...presetPlugins[k] }; return acc; }, {}); }, createPlugin(config = {}) { const result = { 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) { const errors = []; 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 }; }, // v0.1.6 新增 topologicalSort, validateConfig, }; export { presetPlugins, pluginUtils, defaultPluginManager, PluginManager, topologicalSort, validateConfig }; export default presetPlugins;