Initial commit: MetonaEditor v0.1.0
This commit is contained in:
+483
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* MetonaEditor Plugins - 插件系统
|
||||
* @module plugins
|
||||
* @version 0.1.0
|
||||
* @description 插件管理器 + 预设插件(autoSave / exportTool / searchReplace)
|
||||
*
|
||||
* 插件约定:
|
||||
* {
|
||||
* name: string,
|
||||
* description?: string,
|
||||
* install(editor, options): void, // 安装时调用,editor 为 MarkdownEditor 实例
|
||||
* destroy(editor): void // 卸载时调用,清理事件与 DOM
|
||||
* }
|
||||
* install 的 this 指向插件对象本身(core.js 通过 merged.install(editor) 调用),
|
||||
* 因此可在 this 上存放运行时状态(如定时器、DOM 引用)。
|
||||
*/
|
||||
|
||||
import { t } from './i18n.js';
|
||||
|
||||
/**
|
||||
* 插件管理器(独立于编辑器实例,用于全局注册与查询;编辑器实例级安装由 core.js 的 use() 负责)
|
||||
*/
|
||||
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, ...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();
|
||||
|
||||
// ============ 预设插件 ============
|
||||
|
||||
/**
|
||||
* 自动保存插件
|
||||
* 将编辑器内容定时 / 失焦 / 保存时写入 localStorage,并提供草稿恢复与清除。
|
||||
*
|
||||
* 配置:
|
||||
* key: 存储 key,默认 'me-draft-' + editor.id
|
||||
* delay: 防抖延迟(ms),默认 1000
|
||||
*/
|
||||
const autoSavePlugin = {
|
||||
name: 'autoSave',
|
||||
description: '自动保存到 localStorage,支持草稿恢复',
|
||||
key: null,
|
||||
delay: 1000,
|
||||
|
||||
_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);
|
||||
};
|
||||
this._onBlur = save;
|
||||
this._onSave = save;
|
||||
|
||||
editor.on('change', this._onInput);
|
||||
editor.on('blur', this._onBlur);
|
||||
editor.on('save', this._onSave);
|
||||
|
||||
// 暴露草稿 API
|
||||
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 (e) {}
|
||||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 导出工具插件
|
||||
* 提供 .md / .html 文件下载。
|
||||
*/
|
||||
const exportToolPlugin = {
|
||||
name: 'exportTool',
|
||||
description: '导出 Markdown / HTML 文件',
|
||||
|
||||
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>
|
||||
<html lang="${opts.lang || 'zh-CN'}">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>${title}</title>
|
||||
${css ? `<style>${css}</style>` : ''}
|
||||
</head>
|
||||
<body>
|
||||
${body}
|
||||
</body>
|
||||
</html>`;
|
||||
download(filename || `metona-${stamp()}.html`, html, 'text/html');
|
||||
return editor;
|
||||
};
|
||||
},
|
||||
|
||||
destroy() {},
|
||||
};
|
||||
|
||||
/**
|
||||
* 查找替换插件
|
||||
* Ctrl+F 唤出浮动搜索条,支持上一个 / 下一个 / 替换 / 全部替换。
|
||||
*/
|
||||
const searchReplacePlugin = {
|
||||
name: 'searchReplace',
|
||||
description: '查找替换(Ctrl+F)',
|
||||
|
||||
_onKeydown: null,
|
||||
_panel: null,
|
||||
_cleanup: null,
|
||||
|
||||
install(editor) {
|
||||
if (!editor || !editor.textarea) return;
|
||||
if (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 = [];
|
||||
let cursor = -1;
|
||||
|
||||
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];
|
||||
cursor = lastIdxs.indexOf(next);
|
||||
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];
|
||||
cursor = lastIdxs.indexOf(prev);
|
||||
selectAt(prev);
|
||||
};
|
||||
|
||||
const replaceOne = () => {
|
||||
const q = findInput.value;
|
||||
const r = replaceInput.value;
|
||||
if (!q) return;
|
||||
const ta = editor.textarea;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const cur = ta.value.substring(start, end);
|
||||
if (cur === 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;
|
||||
const 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(); cursor = -1; });
|
||||
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 presetPlugins = {
|
||||
autoSave: autoSavePlugin,
|
||||
exportTool: exportToolPlugin,
|
||||
searchReplace: searchReplacePlugin,
|
||||
};
|
||||
|
||||
/**
|
||||
* 转义属性值(用于内联 HTML 属性)
|
||||
*/
|
||||
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',
|
||||
description: config.description || '',
|
||||
// 默认提供空函数,确保 editor.use(plugin) 时 destroy() 调用安全
|
||||
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 };
|
||||
},
|
||||
};
|
||||
|
||||
export { presetPlugins, pluginUtils, defaultPluginManager, PluginManager };
|
||||
export default presetPlugins;
|
||||
Reference in New Issue
Block a user