670 lines
24 KiB
JavaScript
670 lines
24 KiB
JavaScript
/**
|
||
* 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(`\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, '&').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;
|