Initial commit: MetonaEditor v0.1.0
This commit is contained in:
+945
@@ -0,0 +1,945 @@
|
||||
/**
|
||||
* MetonaEditor Core - 编辑器核心
|
||||
* @module core
|
||||
* @version 0.1.0
|
||||
* @description MarkdownEditor 类实现:DOM 构建、事件、渲染、历史栈、选区操作、模式切换
|
||||
*/
|
||||
|
||||
import { generateId, escapeHTML, isBrowser } from './utils.js';
|
||||
import { DEFAULTS, ICONS, THEMES } from './constants.js';
|
||||
import { injectStyles } from './styles.js';
|
||||
import { t as i18nT, getCurrentLocale, getLocaleDirection } from './i18n.js';
|
||||
import { parseMarkdown } from './parser.js';
|
||||
import { presetPlugins } from './plugins.js';
|
||||
import { getTheme } from './themes.js';
|
||||
|
||||
const MODES = ['edit', 'split', 'preview'];
|
||||
|
||||
/**
|
||||
* 解析主题名称为实际主题(auto -> light/dark)
|
||||
*/
|
||||
const resolveThemeName = (theme) => {
|
||||
if (theme && theme !== 'auto') return theme;
|
||||
return getTheme('auto');
|
||||
};
|
||||
|
||||
/**
|
||||
* MarkdownEditor 编辑器类
|
||||
*/
|
||||
class MarkdownEditor {
|
||||
/**
|
||||
* 静态事件钩子(全局,所有实例共享,供插件使用)
|
||||
*/
|
||||
static _hooks = new Map();
|
||||
|
||||
static on(name, fn) {
|
||||
if (!this._hooks.has(name)) this._hooks.set(name, []);
|
||||
this._hooks.get(name).push(fn);
|
||||
return () => this.off(name, fn);
|
||||
}
|
||||
|
||||
static off(name, fn) {
|
||||
const list = this._hooks.get(name);
|
||||
if (list) this._hooks.set(name, list.filter((f) => f !== fn));
|
||||
}
|
||||
|
||||
static trigger(name, instance) {
|
||||
const list = this._hooks.get(name);
|
||||
if (list) list.forEach((fn) => {
|
||||
try { fn(instance); } catch (e) { console.error(`MeEditor hook "${name}" error:`, e); }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|HTMLElement} container - 容器选择器或元素
|
||||
* @param {Object} options - 配置项(见 DEFAULTS)
|
||||
*/
|
||||
constructor(container, options = {}) {
|
||||
if (!isBrowser()) {
|
||||
this._destroyed = true;
|
||||
this._value = options.value || '';
|
||||
return this;
|
||||
}
|
||||
|
||||
this.id = options.id || generateId();
|
||||
this.container = typeof container === 'string'
|
||||
? document.querySelector(container)
|
||||
: container;
|
||||
|
||||
if (!this.container) {
|
||||
console.error('MeEditor: container not found:', container);
|
||||
this._destroyed = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
this.config = { ...DEFAULTS, ...options };
|
||||
if (options.style && typeof options.style === 'object') {
|
||||
this.config.style = { ...options.style };
|
||||
}
|
||||
|
||||
this._value = this.config.value || '';
|
||||
this._mode = MODES.includes(this.config.mode) ? this.config.mode : 'split';
|
||||
this._history = [];
|
||||
this._historyIndex = -1;
|
||||
this._cleanups = [];
|
||||
this._plugins = [];
|
||||
this._listeners = {};
|
||||
this._renderRaf = null;
|
||||
this._historyTimer = null;
|
||||
this._fullscreen = false;
|
||||
this._destroyed = false;
|
||||
|
||||
// 渲染函数:自定义覆盖内置解析器
|
||||
this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown;
|
||||
this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null;
|
||||
|
||||
MarkdownEditor.trigger('beforeCreate', this);
|
||||
|
||||
injectStyles();
|
||||
this._buildDOM();
|
||||
this._buildToolbar();
|
||||
this._bindEvents();
|
||||
|
||||
// 初始内容
|
||||
this.textarea.value = this._value;
|
||||
this._pushHistory();
|
||||
this._render();
|
||||
this._updateWordCount();
|
||||
|
||||
// 安装配置中的插件
|
||||
if (Array.isArray(this.config.plugins)) {
|
||||
this.config.plugins.forEach((p) => this.use(p));
|
||||
}
|
||||
|
||||
if (this.config.autofocus) this.focus();
|
||||
|
||||
if (typeof this.config.onCreate === 'function') {
|
||||
try { this.config.onCreate(this); } catch (e) { console.error('onCreate error:', e); }
|
||||
}
|
||||
|
||||
MarkdownEditor.trigger('afterCreate', this);
|
||||
}
|
||||
|
||||
// ============ DOM 构建 ============
|
||||
|
||||
_buildDOM() {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = `me-wrapper me-theme-${resolveThemeName(this.config.theme)}`;
|
||||
wrapper.dataset.id = this.id;
|
||||
wrapper.setAttribute('role', 'application');
|
||||
wrapper.setAttribute('aria-label', i18nT('edit'));
|
||||
|
||||
if (this.config.className) {
|
||||
this.config.className.split(/\s+/).filter(Boolean).forEach((c) => wrapper.classList.add(c));
|
||||
}
|
||||
|
||||
// 高度
|
||||
const h = this.config.height;
|
||||
wrapper.style.height = typeof h === 'number' ? `${h}px` : (h || '400px');
|
||||
|
||||
// 自定义样式
|
||||
if (this.config.style && typeof this.config.style === 'object') {
|
||||
Object.entries(this.config.style).forEach(([k, v]) => { wrapper.style[k] = v; });
|
||||
}
|
||||
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'me-toolbar';
|
||||
toolbar.setAttribute('role', 'toolbar');
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = `me-body me-mode-${this._mode}`;
|
||||
|
||||
const editorPane = document.createElement('div');
|
||||
editorPane.className = 'me-editor-pane';
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.className = 'me-textarea';
|
||||
textarea.spellcheck = !!this.config.spellcheck;
|
||||
textarea.placeholder = this.config.placeholder || i18nT('placeholder');
|
||||
textarea.setAttribute('aria-label', i18nT('edit'));
|
||||
editorPane.appendChild(textarea);
|
||||
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'me-divider';
|
||||
divider.setAttribute('role', 'separator');
|
||||
|
||||
const previewPane = document.createElement('div');
|
||||
previewPane.className = 'me-preview-pane';
|
||||
previewPane.setAttribute('role', 'region');
|
||||
previewPane.setAttribute('aria-label', i18nT('preview'));
|
||||
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 'me-preview';
|
||||
previewPane.appendChild(preview);
|
||||
|
||||
body.appendChild(editorPane);
|
||||
body.appendChild(divider);
|
||||
body.appendChild(previewPane);
|
||||
|
||||
wrapper.appendChild(toolbar);
|
||||
wrapper.appendChild(body);
|
||||
|
||||
// 状态栏
|
||||
let statusbar = null;
|
||||
if (this.config.wordCount) {
|
||||
statusbar = document.createElement('div');
|
||||
statusbar.className = 'me-statusbar';
|
||||
wrapper.appendChild(statusbar);
|
||||
}
|
||||
|
||||
this.container.appendChild(wrapper);
|
||||
|
||||
this.el = wrapper;
|
||||
this.toolbarEl = toolbar;
|
||||
this.bodyEl = body;
|
||||
this.editorPane = editorPane;
|
||||
this.previewPane = previewPane;
|
||||
this.dividerEl = divider;
|
||||
this.textarea = textarea;
|
||||
this.previewEl = preview;
|
||||
this.statusEl = statusbar;
|
||||
}
|
||||
|
||||
_buildToolbar() {
|
||||
const tools = this.config.toolbar;
|
||||
if (!tools || !Array.isArray(tools) || tools.length === 0) return;
|
||||
|
||||
const modeActions = [];
|
||||
tools.forEach((item) => {
|
||||
if (item === '|') {
|
||||
const sep = document.createElement('span');
|
||||
sep.className = 'me-toolbar-sep';
|
||||
this.toolbarEl.appendChild(sep);
|
||||
return;
|
||||
}
|
||||
// 模式与全屏按钮归入右侧组
|
||||
if (item === 'edit' || item === 'split' || item === 'preview' || item === 'fullscreen') {
|
||||
modeActions.push(item);
|
||||
return;
|
||||
}
|
||||
this.toolbarEl.appendChild(this._createBtn(item));
|
||||
});
|
||||
|
||||
if (modeActions.length) {
|
||||
const group = document.createElement('div');
|
||||
group.className = 'me-toolbar-group';
|
||||
modeActions.forEach((item) => {
|
||||
const btn = this._createBtn(item);
|
||||
if (item === 'edit' || item === 'split' || item === 'preview') {
|
||||
btn.dataset.mode = item;
|
||||
if (item === this._mode) btn.classList.add('me-active');
|
||||
}
|
||||
group.appendChild(btn);
|
||||
});
|
||||
this.toolbarEl.appendChild(group);
|
||||
}
|
||||
}
|
||||
|
||||
_createBtn(item) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = `me-btn me-btn-${item}`;
|
||||
btn.dataset.action = item;
|
||||
const label = i18nT(item) || item;
|
||||
btn.title = label;
|
||||
btn.setAttribute('aria-label', label);
|
||||
btn.innerHTML = ICONS[item] || `<span>${escapeHTML(item)}</span>`;
|
||||
return btn;
|
||||
}
|
||||
|
||||
// ============ 事件绑定 ============
|
||||
|
||||
_bindEvents() {
|
||||
const ta = this.textarea;
|
||||
|
||||
const onInput = () => {
|
||||
this._value = ta.value;
|
||||
this._scheduleRender();
|
||||
this._scheduleHistory();
|
||||
this._updateWordCount();
|
||||
this._emit('input', this._value);
|
||||
this._emit('change', this._value);
|
||||
if (typeof this.config.onInput === 'function') {
|
||||
try { this.config.onInput(this._value, this); } catch (e) { console.error(e); }
|
||||
}
|
||||
if (typeof this.config.onChange === 'function') {
|
||||
try { this.config.onChange(this._value, this); } catch (e) { console.error(e); }
|
||||
}
|
||||
};
|
||||
ta.addEventListener('input', onInput);
|
||||
|
||||
const onKeydown = (e) => this._handleKeydown(e);
|
||||
ta.addEventListener('keydown', onKeydown);
|
||||
|
||||
const onScroll = () => {
|
||||
if (!this.config.syncScroll || this._mode !== 'split') return;
|
||||
const max = ta.scrollHeight - ta.clientHeight;
|
||||
const ratio = max > 0 ? ta.scrollTop / max : 0;
|
||||
const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight;
|
||||
this.previewPane.scrollTop = ratio * pmax;
|
||||
};
|
||||
ta.addEventListener('scroll', onScroll);
|
||||
|
||||
const onFocus = () => {
|
||||
this._emit('focus', this);
|
||||
if (typeof this.config.onFocus === 'function') {
|
||||
try { this.config.onFocus(this); } catch (e) { console.error(e); }
|
||||
}
|
||||
};
|
||||
const onBlur = () => {
|
||||
this._emit('blur', this);
|
||||
if (typeof this.config.onBlur === 'function') {
|
||||
try { this.config.onBlur(this); } catch (e) { console.error(e); }
|
||||
}
|
||||
};
|
||||
ta.addEventListener('focus', onFocus);
|
||||
ta.addEventListener('blur', onBlur);
|
||||
|
||||
// 工具栏点击
|
||||
const onToolbarClick = (e) => {
|
||||
const btn = e.target.closest('.me-btn');
|
||||
if (!btn) return;
|
||||
const mode = btn.dataset.mode;
|
||||
if (mode) {
|
||||
this.setMode(mode);
|
||||
return;
|
||||
}
|
||||
const action = btn.dataset.action;
|
||||
if (action) this.exec(action);
|
||||
};
|
||||
this.toolbarEl.addEventListener('click', onToolbarClick);
|
||||
|
||||
// 分隔条拖拽(分屏模式)
|
||||
const onDividerDown = (e) => this._bindDividerDrag(e);
|
||||
this.dividerEl.addEventListener('pointerdown', onDividerDown);
|
||||
|
||||
this._cleanups.push(() => {
|
||||
ta.removeEventListener('input', onInput);
|
||||
ta.removeEventListener('keydown', onKeydown);
|
||||
ta.removeEventListener('scroll', onScroll);
|
||||
ta.removeEventListener('focus', onFocus);
|
||||
ta.removeEventListener('blur', onBlur);
|
||||
this.toolbarEl.removeEventListener('click', onToolbarClick);
|
||||
this.dividerEl.removeEventListener('pointerdown', onDividerDown);
|
||||
});
|
||||
}
|
||||
|
||||
_handleKeydown(e) {
|
||||
// Tab 缩进
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
this._handleTab(e.shiftKey);
|
||||
return;
|
||||
}
|
||||
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (!mod) return;
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
const shortcuts = {
|
||||
b: 'bold', i: 'italic', k: 'link', u: 'underline',
|
||||
e: 'code', '1': 'h1', '2': 'h2', '3': 'h3',
|
||||
q: 'quote',
|
||||
};
|
||||
|
||||
if (key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.undo();
|
||||
} else if ((key === 'z' && e.shiftKey) || key === 'y') {
|
||||
e.preventDefault();
|
||||
this.redo();
|
||||
} else if (key === 's') {
|
||||
e.preventDefault();
|
||||
this._emit('save', this._value);
|
||||
if (typeof this.config.onSave === 'function') {
|
||||
try { this.config.onSave(this._value, this); } catch (err) { console.error(err); }
|
||||
}
|
||||
} else if (shortcuts[key]) {
|
||||
e.preventDefault();
|
||||
this.exec(shortcuts[key]);
|
||||
}
|
||||
}
|
||||
|
||||
_handleTab(shift) {
|
||||
const ta = this.textarea;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const tabSize = this.config.tabSize || 0;
|
||||
const tabStr = tabSize > 0 ? ' '.repeat(tabSize) : '\t';
|
||||
|
||||
if (start === end) {
|
||||
// 无选区:插入缩进
|
||||
ta.value = ta.value.slice(0, start) + tabStr + ta.value.slice(end);
|
||||
ta.selectionStart = ta.selectionEnd = start + tabStr.length;
|
||||
} else {
|
||||
// 有选区:整块缩进/反缩进
|
||||
const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1;
|
||||
const block = ta.value.slice(lineStart, end);
|
||||
const lines = block.split('\n');
|
||||
let newBlock;
|
||||
if (shift) {
|
||||
newBlock = lines.map((l) => l.replace(/^(\t| {1,4})/, '')).join('\n');
|
||||
} else {
|
||||
newBlock = lines.map((l) => tabStr + l).join('\n');
|
||||
}
|
||||
ta.value = ta.value.slice(0, lineStart) + newBlock + ta.value.slice(end);
|
||||
ta.selectionStart = lineStart;
|
||||
ta.selectionEnd = lineStart + newBlock.length;
|
||||
}
|
||||
|
||||
this._value = ta.value;
|
||||
this._pushHistory();
|
||||
this._render();
|
||||
this._emit('change', this._value);
|
||||
}
|
||||
|
||||
_bindDividerDrag(e) {
|
||||
if (this._mode !== 'split') return;
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const editorWidth = this.editorPane.getBoundingClientRect().width;
|
||||
const totalWidth = this.bodyEl.getBoundingClientRect().width;
|
||||
const divider = this.dividerEl;
|
||||
|
||||
const onMove = (ev) => {
|
||||
const dx = ev.clientX - startX;
|
||||
let newEditorWidth = editorWidth + dx;
|
||||
const min = 80;
|
||||
const max = totalWidth - 80 - divider.offsetWidth;
|
||||
newEditorWidth = Math.max(min, Math.min(max, newEditorWidth));
|
||||
const pct = (newEditorWidth / totalWidth) * 100;
|
||||
this.editorPane.style.flex = `0 0 ${pct}%`;
|
||||
this.previewPane.style.flex = `1 1 ${100 - pct}%`;
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
};
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
}
|
||||
|
||||
// ============ 渲染 ============
|
||||
|
||||
_scheduleRender() {
|
||||
if (this._renderRaf) return;
|
||||
this._renderRaf = requestAnimationFrame(() => {
|
||||
this._renderRaf = null;
|
||||
this._render();
|
||||
});
|
||||
}
|
||||
|
||||
_render() {
|
||||
if (this._mode === 'edit') return;
|
||||
MarkdownEditor.trigger('beforeRender', this);
|
||||
|
||||
const env = {
|
||||
highlight: this._highlightFn,
|
||||
locale: getCurrentLocale(),
|
||||
};
|
||||
|
||||
let html;
|
||||
try {
|
||||
html = this._renderFn(this._value, env);
|
||||
} catch (err) {
|
||||
console.error('MeEditor render error:', err);
|
||||
html = `<p style="color:#ef4444">${i18nT('renderError')}: ${escapeHTML(err.message)}</p>`;
|
||||
}
|
||||
|
||||
if (typeof this.config.sanitize === 'function') {
|
||||
try { html = this.config.sanitize(html); } catch (err) { console.error('sanitize error:', err); }
|
||||
}
|
||||
|
||||
this.previewEl.innerHTML = html;
|
||||
MarkdownEditor.trigger('afterRender', this);
|
||||
}
|
||||
|
||||
// ============ 历史栈 ============
|
||||
|
||||
_scheduleHistory() {
|
||||
if (this._historyTimer) clearTimeout(this._historyTimer);
|
||||
this._historyTimer = setTimeout(() => this._pushHistory(), 400);
|
||||
}
|
||||
|
||||
_pushHistory() {
|
||||
const cur = this._history[this._historyIndex];
|
||||
if (cur === this._value) return;
|
||||
this._history = this._history.slice(0, this._historyIndex + 1);
|
||||
this._history.push(this._value);
|
||||
const limit = this.config.historyLimit > 0 ? this.config.historyLimit : 100;
|
||||
while (this._history.length > limit) this._history.shift();
|
||||
this._historyIndex = this._history.length - 1;
|
||||
}
|
||||
|
||||
undo() {
|
||||
if (this._historyIndex <= 0) return this;
|
||||
this._historyIndex--;
|
||||
this._applyHistory();
|
||||
return this;
|
||||
}
|
||||
|
||||
redo() {
|
||||
if (this._historyIndex >= this._history.length - 1) return this;
|
||||
this._historyIndex++;
|
||||
this._applyHistory();
|
||||
return this;
|
||||
}
|
||||
|
||||
_applyHistory() {
|
||||
this._value = this._history[this._historyIndex];
|
||||
this.textarea.value = this._value;
|
||||
this._render();
|
||||
this._updateWordCount();
|
||||
this._emit('change', this._value);
|
||||
if (typeof this.config.onChange === 'function') {
|
||||
try { this.config.onChange(this._value, this); } catch (e) { console.error(e); }
|
||||
}
|
||||
}
|
||||
|
||||
canUndo() { return this._historyIndex > 0; }
|
||||
canRedo() { return this._historyIndex < this._history.length - 1; }
|
||||
|
||||
// ============ 命令执行 ============
|
||||
|
||||
exec(action, ...args) {
|
||||
const actions = {
|
||||
bold: () => this._wrapSelection('**', '**'),
|
||||
italic: () => this._wrapSelection('*', '*'),
|
||||
strikethrough: () => this._wrapSelection('~~', '~~'),
|
||||
underline: () => this._wrapSelection('<u>', '</u>'),
|
||||
code: () => this._wrapSelection('`', '`'),
|
||||
h1: () => this._toggleLinePrefix('# '),
|
||||
h2: () => this._toggleLinePrefix('## '),
|
||||
h3: () => this._toggleLinePrefix('### '),
|
||||
quote: () => this._toggleLinePrefix('> '),
|
||||
ul: () => this._toggleLinePrefix('- '),
|
||||
ol: () => this._toggleLinePrefix('1. '),
|
||||
indent: () => this._handleTab(false),
|
||||
outdent: () => this._handleTab(true),
|
||||
hr: () => this._insertBlock('\n---\n'),
|
||||
link: () => this._insertLink(),
|
||||
image: () => this._insertImage(),
|
||||
table: () => this._insertTable(),
|
||||
undo: () => this.undo(),
|
||||
redo: () => this.redo(),
|
||||
edit: () => this.setMode('edit'),
|
||||
split: () => this.setMode('split'),
|
||||
preview: () => this.setMode('preview'),
|
||||
fullscreen: () => this.toggleFullscreen(),
|
||||
};
|
||||
const fn = actions[action];
|
||||
if (fn) fn.apply(this, args);
|
||||
return this;
|
||||
}
|
||||
|
||||
_wrapSelection(before, after) {
|
||||
const ta = this.textarea;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const selected = ta.value.slice(start, end);
|
||||
const text = selected || (i18nT('placeholder') || 'text');
|
||||
const inserted = before + text + after;
|
||||
|
||||
ta.value = ta.value.slice(0, start) + inserted + ta.value.slice(end);
|
||||
ta.focus();
|
||||
|
||||
if (selected) {
|
||||
ta.selectionStart = start + before.length;
|
||||
ta.selectionEnd = start + before.length + text.length;
|
||||
} else {
|
||||
ta.selectionStart = ta.selectionEnd = start + before.length;
|
||||
}
|
||||
|
||||
this._value = ta.value;
|
||||
this._pushHistory();
|
||||
this._render();
|
||||
this._updateWordCount();
|
||||
this._emit('change', this._value);
|
||||
}
|
||||
|
||||
_toggleLinePrefix(prefix) {
|
||||
const ta = this.textarea;
|
||||
const start = ta.selectionStart;
|
||||
const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1;
|
||||
const lineEndPos = ta.value.indexOf('\n', start);
|
||||
const lineEnd = lineEndPos === -1 ? ta.value.length : lineEndPos;
|
||||
const line = ta.value.slice(lineStart, lineEnd);
|
||||
|
||||
const existingMatch = line.match(/^(#{1,6}\s*|>\s*|[-*+]\s*|\d+\.\s*)/);
|
||||
let newLine;
|
||||
if (existingMatch && existingMatch[0] === prefix) {
|
||||
newLine = line.slice(prefix.length);
|
||||
} else if (existingMatch) {
|
||||
newLine = prefix + line.slice(existingMatch[0].length);
|
||||
} else {
|
||||
newLine = prefix + line;
|
||||
}
|
||||
|
||||
ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd);
|
||||
ta.focus();
|
||||
ta.selectionStart = ta.selectionEnd = lineStart + newLine.length;
|
||||
|
||||
this._value = ta.value;
|
||||
this._pushHistory();
|
||||
this._render();
|
||||
this._updateWordCount();
|
||||
this._emit('change', this._value);
|
||||
}
|
||||
|
||||
_insertBlock(text) {
|
||||
const ta = this.textarea;
|
||||
const start = ta.selectionStart;
|
||||
const before = ta.value.slice(0, start);
|
||||
const needNL = before && !before.endsWith('\n');
|
||||
const insert = (needNL ? '\n' : '') + text;
|
||||
|
||||
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(ta.selectionEnd);
|
||||
ta.focus();
|
||||
const pos = start + insert.length;
|
||||
ta.selectionStart = ta.selectionEnd = pos;
|
||||
|
||||
this._value = ta.value;
|
||||
this._pushHistory();
|
||||
this._render();
|
||||
this._updateWordCount();
|
||||
this._emit('change', this._value);
|
||||
}
|
||||
|
||||
_insertLink() {
|
||||
const ta = this.textarea;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const sel = ta.value.slice(start, end) || i18nT('link') || 'link';
|
||||
const url = 'https://';
|
||||
const insert = `[${sel}](${url})`;
|
||||
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end);
|
||||
ta.focus();
|
||||
// 选中 url 便于替换
|
||||
const urlStart = start + sel.length + 3;
|
||||
ta.selectionStart = urlStart;
|
||||
ta.selectionEnd = urlStart + url.length;
|
||||
this._value = ta.value;
|
||||
this._pushHistory();
|
||||
this._render();
|
||||
this._emit('change', this._value);
|
||||
}
|
||||
|
||||
_insertImage() {
|
||||
const ta = this.textarea;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const sel = ta.value.slice(start, end) || i18nT('image') || 'image';
|
||||
const url = 'https://';
|
||||
const insert = ``;
|
||||
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end);
|
||||
ta.focus();
|
||||
const urlStart = start + sel.length + 4;
|
||||
ta.selectionStart = urlStart;
|
||||
ta.selectionEnd = urlStart + url.length;
|
||||
this._value = ta.value;
|
||||
this._pushHistory();
|
||||
this._render();
|
||||
this._emit('change', this._value);
|
||||
}
|
||||
|
||||
_insertTable(rows = 3, cols = 3) {
|
||||
const header = Array.from({ length: cols }, (_, i) => `${i18nT('tableCols') || '列'}${i + 1}`).join(' | ');
|
||||
const sep = Array.from({ length: cols }, () => '---').join(' | ');
|
||||
let md = `| ${header} |\n| ${sep} |\n`;
|
||||
for (let r = 1; r < rows; r++) {
|
||||
const row = Array.from({ length: cols }, () => ' ').join(' | ');
|
||||
md += `| ${row} |\n`;
|
||||
}
|
||||
this._insertBlock('\n' + md);
|
||||
}
|
||||
|
||||
// ============ 模式与全屏 ============
|
||||
|
||||
setMode(mode) {
|
||||
if (!MODES.includes(mode) || mode === this._mode) return this;
|
||||
this._mode = mode;
|
||||
this.bodyEl.className = `me-body me-mode-${mode}`;
|
||||
this._updateModeButtons();
|
||||
if (mode !== 'edit') this._render();
|
||||
this._emit('modeChange', mode);
|
||||
if (typeof this.config.onModeChange === 'function') {
|
||||
try { this.config.onModeChange(mode, this); } catch (e) { console.error(e); }
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
getMode() { return this._mode; }
|
||||
|
||||
_updateModeButtons() {
|
||||
this.toolbarEl.querySelectorAll('.me-btn[data-mode]').forEach((b) => {
|
||||
b.classList.toggle('me-active', b.dataset.mode === this._mode);
|
||||
});
|
||||
}
|
||||
|
||||
toggleFullscreen() {
|
||||
this._fullscreen = !this._fullscreen;
|
||||
this.el.classList.toggle('me-fullscreen', this._fullscreen);
|
||||
// 更新全屏按钮图标/标题
|
||||
const fsBtn = this.toolbarEl.querySelector('.me-btn-fullscreen');
|
||||
if (fsBtn) {
|
||||
fsBtn.title = this._fullscreen ? (i18nT('fullscreenExit') || '退出全屏') : (i18nT('fullscreen') || '全屏');
|
||||
}
|
||||
this._emit('fullscreen', this._fullscreen);
|
||||
if (typeof this.config.onFullscreen === 'function') {
|
||||
try { this.config.onFullscreen(this._fullscreen, this); } catch (e) { console.error(e); }
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
isFullscreen() { return this._fullscreen; }
|
||||
|
||||
exitFullscreen() {
|
||||
if (this._fullscreen) this.toggleFullscreen();
|
||||
return this;
|
||||
}
|
||||
|
||||
// ============ 字数统计 ============
|
||||
|
||||
_updateWordCount() {
|
||||
if (!this.config.wordCount || !this.statusEl) return;
|
||||
const text = this._value || '';
|
||||
const chars = text.length;
|
||||
// 中文字符数 + 英文单词数
|
||||
const cnChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
|
||||
const enWords = (text.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length;
|
||||
const words = cnChars + enWords;
|
||||
const lines = text ? text.split('\n').length : 0;
|
||||
const readingMin = Math.max(1, Math.ceil(words / 300));
|
||||
|
||||
const parts = [
|
||||
`${i18nT('characters')}: ${chars}`,
|
||||
`${i18nT('words')}: ${words}`,
|
||||
`${i18nT('lines')}: ${lines}`,
|
||||
`${i18nT('readingTime')}: ${readingMin} ${i18nT('minutes')}`,
|
||||
];
|
||||
this.statusEl.textContent = parts.join(' · ');
|
||||
}
|
||||
|
||||
getStats() {
|
||||
const text = this._value || '';
|
||||
const cnChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
|
||||
const enWords = (text.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length;
|
||||
return {
|
||||
characters: text.length,
|
||||
words: cnChars + enWords,
|
||||
chineseChars: cnChars,
|
||||
englishWords: enWords,
|
||||
lines: text ? text.split('\n').length : 0,
|
||||
readingTime: Math.max(1, Math.ceil((cnChars + enWords) / 300)),
|
||||
};
|
||||
}
|
||||
|
||||
// ============ 公共 API ============
|
||||
|
||||
getValue() { return this._value; }
|
||||
|
||||
setValue(md, opts = {}) {
|
||||
this._value = md || '';
|
||||
this.textarea.value = this._value;
|
||||
if (!opts.silent) this._pushHistory();
|
||||
this._render();
|
||||
this._updateWordCount();
|
||||
if (!opts.silent) {
|
||||
this._emit('change', this._value);
|
||||
if (typeof this.config.onChange === 'function') {
|
||||
try { this.config.onChange(this._value, this); } catch (e) { console.error(e); }
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
getHTML() {
|
||||
const env = { highlight: this._highlightFn, locale: getCurrentLocale() };
|
||||
let html = this._renderFn(this._value, env);
|
||||
if (typeof this.config.sanitize === 'function') {
|
||||
try { html = this.config.sanitize(html); } catch (e) { console.error(e); }
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
insert(text, opts = {}) {
|
||||
const ta = this.textarea;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const replace = opts.replace || false;
|
||||
ta.value = ta.value.slice(0, replace ? start : start) + text + ta.value.slice(replace ? end : end);
|
||||
ta.focus();
|
||||
const pos = start + text.length;
|
||||
ta.selectionStart = ta.selectionEnd = pos;
|
||||
this._value = ta.value;
|
||||
this._pushHistory();
|
||||
this._render();
|
||||
this._updateWordCount();
|
||||
this._emit('change', this._value);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在选区两侧包裹文本
|
||||
*/
|
||||
wrap(before, after) {
|
||||
this._wrapSelection(before, after || before);
|
||||
return this;
|
||||
}
|
||||
|
||||
focus() { this.textarea.focus(); return this; }
|
||||
blur() { this.textarea.blur(); return this; }
|
||||
|
||||
enable() {
|
||||
this.textarea.disabled = false;
|
||||
this.el.classList.remove('me-disabled');
|
||||
return this;
|
||||
}
|
||||
|
||||
disable() {
|
||||
this.textarea.disabled = true;
|
||||
this.el.classList.add('me-disabled');
|
||||
return this;
|
||||
}
|
||||
|
||||
isDisabled() { return this.textarea.disabled; }
|
||||
|
||||
/**
|
||||
* 注册实例事件监听
|
||||
* 支持事件:change/input/focus/blur/save/modeChange/fullscreen/beforeCreate/afterCreate/beforeRender/afterRender/destroy
|
||||
*/
|
||||
on(name, fn) {
|
||||
if (!this._listeners[name]) this._listeners[name] = [];
|
||||
this._listeners[name].push(fn);
|
||||
return () => this.off(name, fn);
|
||||
}
|
||||
|
||||
off(name, fn) {
|
||||
const list = this._listeners[name];
|
||||
if (list) this._listeners[name] = list.filter((f) => f !== fn);
|
||||
return this;
|
||||
}
|
||||
|
||||
_emit(name, ...args) {
|
||||
const list = this._listeners[name];
|
||||
if (list) list.forEach((fn) => {
|
||||
try { fn(...args, this); } catch (e) { console.error(`MeEditor event "${name}" error:`, e); }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装插件
|
||||
* @param {string|Object} plugin - 预设插件名或插件对象
|
||||
* @param {Object} options - 插件配置
|
||||
*/
|
||||
use(plugin, options = {}) {
|
||||
let p = plugin;
|
||||
if (typeof plugin === 'string') {
|
||||
p = presetPlugins[plugin];
|
||||
if (!p) {
|
||||
console.warn(`MeEditor: preset plugin "${plugin}" not found`);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
if (!p || typeof p !== 'object') {
|
||||
console.warn('MeEditor: invalid plugin');
|
||||
return this;
|
||||
}
|
||||
const merged = { ...p, ...options };
|
||||
if (typeof merged.install === 'function') {
|
||||
try {
|
||||
merged.install(this);
|
||||
} catch (e) {
|
||||
console.error(`MeEditor: plugin "${merged.name || 'custom'}" install error:`, e);
|
||||
}
|
||||
}
|
||||
this._plugins.push(merged);
|
||||
return this;
|
||||
}
|
||||
|
||||
getPlugins() { return [...this._plugins]; }
|
||||
|
||||
/**
|
||||
* 向工具栏追加自定义按钮
|
||||
*/
|
||||
addToolbarButton(config) {
|
||||
if (!config || !config.action) return this;
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = `me-btn me-btn-custom-${config.action}`;
|
||||
btn.dataset.action = config.action;
|
||||
btn.title = config.title || config.action;
|
||||
btn.setAttribute('aria-label', config.title || config.action);
|
||||
btn.innerHTML = config.icon || `<span>${escapeHTML(config.text || config.action)}</span>`;
|
||||
if (typeof config.onClick === 'function') {
|
||||
btn.addEventListener('click', () => config.onClick(this));
|
||||
} else if (config.action) {
|
||||
// 注册到 exec 动作
|
||||
this._customActions = this._customActions || {};
|
||||
this._customActions[config.action] = config.handler;
|
||||
}
|
||||
this.toolbarEl.insertBefore(btn, this.toolbarEl.querySelector('.me-toolbar-group') || null);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁编辑器
|
||||
*/
|
||||
destroy() {
|
||||
if (this._destroyed) return;
|
||||
MarkdownEditor.trigger('beforeDestroy', this);
|
||||
|
||||
if (this._renderRaf) cancelAnimationFrame(this._renderRaf);
|
||||
if (this._historyTimer) clearTimeout(this._historyTimer);
|
||||
|
||||
this._cleanups.forEach((fn) => { try { fn(); } catch (_) {} });
|
||||
this._cleanups = [];
|
||||
|
||||
// 销毁插件
|
||||
this._plugins.forEach((p) => {
|
||||
if (typeof p.destroy === 'function') {
|
||||
try { p.destroy(this); } catch (_) {}
|
||||
}
|
||||
});
|
||||
this._plugins = [];
|
||||
|
||||
if (this.el && this.el.parentNode) {
|
||||
this.el.parentNode.removeChild(this.el);
|
||||
}
|
||||
this.el = null;
|
||||
this.textarea = null;
|
||||
this.previewEl = null;
|
||||
|
||||
this._destroyed = true;
|
||||
|
||||
if (typeof this.config.onDestroy === 'function') {
|
||||
try { this.config.onDestroy(this); } catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
// 先触发 destroy 事件,再清空监听器(否则 destroy 监听器永远收不到事件)
|
||||
this._emit('destroy');
|
||||
this._listeners = {};
|
||||
MarkdownEditor.trigger('afterDestroy', this);
|
||||
}
|
||||
|
||||
isDestroyed() { return this._destroyed; }
|
||||
|
||||
/**
|
||||
* 获取编辑器状态
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
id: this.id,
|
||||
mode: this._mode,
|
||||
theme: this.config.theme,
|
||||
locale: getCurrentLocale(),
|
||||
fullscreen: this._fullscreen,
|
||||
disabled: this.textarea ? this.textarea.disabled : false,
|
||||
destroyed: this._destroyed,
|
||||
plugins: this._plugins.map((p) => p.name || 'custom'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { MarkdownEditor as Editor, MarkdownEditor };
|
||||
export default MarkdownEditor;
|
||||
Reference in New Issue
Block a user