Files
MetonaEditor/src/plugins.js
T

670 lines
24 KiB
JavaScript
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 — 插件系统 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 = `<!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>` : ''}\n</head>\n<body>\n${body}\n</body>\n</html>`;
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 = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${(t('searchPlaceholder') || '查找内容')}" value="${escapeAttr(selected)}"/><button type="button" class="me-search-prev" title="${(t('findPrev') || '上一个')}">↑</button><button type="button" class="me-search-next" title="${(t('findNext') || '下一个')}">↓</button><span class="me-search-count"></span><button type="button" class="me-search-close" title="${(t('close') || '关闭')}" aria-label="close">×</button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${(t('replacePlaceholder') || '替换为')}"/><button type="button" class="me-search-replace-one">${(t('replace') || '替换')}</button><button type="button" class="me-search-replace-all">${(t('replaceAll') || '全部')}</button></div>`;
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 += `<tr><td>${combo}</td><td>${desc}</td></tr>`;
});
const overlay = document.createElement('div');
overlay.className = 'me-shortcut-overlay';
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ 快捷键</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
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, '&amp;').replace(/"/g, '&quot;')
.replace(/</g, '&lt;').replace(/>/g, '&gt;');
// ============ 插件工具集 ============
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;