feat(core): syntax highlight editor overlay - Transparent highlight-layer behind textarea with regex-based token coloring - Supports heading, bold, italic, strikethrough, code, link, image, list, quote, hr - Scroll-synced with textarea, config.syntaxHighlight toggle feat(core): preview link click interception - Clicks on preview links intercepted, emitted as 'linkClick' event - Configurable via onLinkClick(uri, text, editor) callback - Default: opens in new tab, anchor links pass through feat(parser): Mermaid diagram rendering - ```mermaid code blocks render as <div class="me-mermaid"><pre class="mermaid"> feat(plugins): fileSystem plugin (IndexedDB persistence) - saveToFile(id) / loadFromFile(id) / deleteFile(id) / listFiles() - Uses IndexedDB 'metona-editor-fs' database feat(config): maxLength (char limit), syntaxHighlight toggle style: syntax highlight theme colors, Mermaid container chore: bump version 0.1.13 → 0.1.14 across all files
1830 lines
58 KiB
JavaScript
1830 lines
58 KiB
JavaScript
/**
|
||
* MetonaEditor Core - 编辑器核心
|
||
* @module core
|
||
* @version 0.1.14
|
||
* @description MarkdownEditor 类实现:DOM 构建、事件、渲染、历史栈、选区操作、模式切换
|
||
* v0.1.6: 插件依赖拓扑排序、实例 i18n、快捷键/工具栏定制、右键菜单、Toast
|
||
* v0.1.7: 行号装订线、自动格式化、括号自动闭合、拖放、大纲面板
|
||
* v0.1.12: 工具栏键盘导航、aria-live、Zen模式、滚动同步v2、WordWrap
|
||
* v0.1.14: 语法高亮编辑区、图片上传钩子、预览链接拦截、Mermaid、文件存储
|
||
*/
|
||
|
||
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, createInstanceI18n } from './i18n.js';
|
||
import { parseMarkdown } from './parser.js';
|
||
import { presetPlugins, topologicalSort } from './plugins.js';
|
||
import { resolveTheme, getThemeConfig, setThemeVariables, createInstanceTheme } from './themes.js';
|
||
|
||
const MODES = ['edit', 'split', 'preview'];
|
||
|
||
/** Toast 图标 */
|
||
const TOAST_ICONS = {
|
||
success: '✓',
|
||
error: '✗',
|
||
warning: '⚠',
|
||
info: 'ℹ',
|
||
};
|
||
|
||
/** 括号/引号自动闭合映射 */
|
||
const BRACKET_PAIRS = {
|
||
'(': ')', '[': ']', '{': '}',
|
||
'"': '"', "'": "'", '`': '`',
|
||
'*': '*', '_': '_',
|
||
};
|
||
|
||
/**
|
||
* 解析主题名称为实际主题(auto -> light/dark)
|
||
*/
|
||
const resolveThemeName = (theme) => {
|
||
if (theme && theme !== 'auto') return theme;
|
||
return resolveTheme('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 = { ...DEFAULTS.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._lastRenderedValue = null;
|
||
this._shortcuts = []; // v0.1.6: 快捷键注册表
|
||
this._contextMenuItems = []; // v0.1.6: 右键菜单项
|
||
this._customActions = {}; // 自定义工具栏动作
|
||
this._outlineTimer = null; // 大纲防抖计时器
|
||
this._zenMode = false; // v0.1.12: Zen 模式
|
||
this._wordWrap = true; // v0.1.12: 自动换行
|
||
this._syncing = 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();
|
||
|
||
// 实例级主题管理(v0.1.5)
|
||
this._themeCtx = createInstanceTheme(this);
|
||
|
||
// 实例级 i18n 管理(v0.1.6)
|
||
this._i18nCtx = createInstanceI18n(this);
|
||
|
||
// 向后兼容:用户显式传 theme 时同步到全局
|
||
if (options.theme) {
|
||
if (typeof document !== 'undefined') {
|
||
document.documentElement.setAttribute('data-md-theme', resolveTheme(options.theme));
|
||
}
|
||
}
|
||
|
||
// 确保 CSS 变量作用到实例 wrapper
|
||
const resolvedThemeConfig = getThemeConfig(this.config.theme);
|
||
setThemeVariables(resolvedThemeConfig, this.el);
|
||
|
||
this._buildToolbar();
|
||
this._updateModeButtons(); // 初始化按钮状态
|
||
this._bindToolbarKeyboard(); // v0.1.12: 键盘导航
|
||
this._initAriaLive(); // v0.1.12: 屏幕阅读器
|
||
this._bindEvents();
|
||
|
||
// 初始内容
|
||
this.textarea.value = this._value;
|
||
this._pushHistory();
|
||
this._render();
|
||
this._updateWordCount();
|
||
|
||
// 安装配置中的插件(v0.1.6: 拓扑排序)
|
||
if (Array.isArray(this.config.plugins)) {
|
||
const plugins = this.config.plugins.map((p) => {
|
||
if (typeof p === 'string') return { ...presetPlugins[p], _isStringRef: true };
|
||
return p;
|
||
}).filter(Boolean);
|
||
const sorted = topologicalSort(plugins);
|
||
sorted.forEach((p) => this.use(p));
|
||
}
|
||
|
||
if (this.config.autofocus && !this.config.readOnly) 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)}`;
|
||
if (this.config.readOnly) wrapper.classList.add('me-readonly');
|
||
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';
|
||
|
||
// 编辑区内层容器(行号 + textarea 水平排列)
|
||
const editorInner = document.createElement('div');
|
||
editorInner.className = 'me-editor-inner';
|
||
|
||
// 行号装订线(v0.1.7)
|
||
const gutter = document.createElement('div');
|
||
gutter.className = 'me-gutter';
|
||
if (!this.config.lineNumbers) gutter.style.display = 'none';
|
||
editorInner.appendChild(gutter);
|
||
|
||
// 语法高亮叠加层(v0.1.14)
|
||
const highlightLayer = document.createElement('pre');
|
||
highlightLayer.className = 'me-highlight-layer';
|
||
highlightLayer.setAttribute('aria-hidden', 'true');
|
||
editorInner.appendChild(highlightLayer);
|
||
|
||
const textarea = document.createElement('textarea');
|
||
textarea.className = 'me-textarea';
|
||
textarea.spellcheck = !!this.config.spellcheck;
|
||
textarea.readOnly = !!this.config.readOnly;
|
||
textarea.style.tabSize = String(this.config.tabSize || 2);
|
||
textarea.placeholder = this.config.placeholder || i18nT('placeholder');
|
||
textarea.setAttribute('aria-label', i18nT('edit'));
|
||
editorInner.appendChild(textarea);
|
||
|
||
editorPane.appendChild(editorInner);
|
||
|
||
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.editorInner = editorInner;
|
||
this.gutter = gutter;
|
||
this.highlightLayer = highlightLayer;
|
||
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._renderGutter();
|
||
this._updateOutline();
|
||
this._highlightEditor(); // v0.1.14
|
||
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) => {
|
||
// v0.1.7: 智能 Enter
|
||
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||
this._handleSmartEnter(e);
|
||
}
|
||
this._handleKeydown(e);
|
||
};
|
||
ta.addEventListener('keydown', onKeydown);
|
||
|
||
// v0.1.7: 括号/引号自动闭合
|
||
const onKeypress = (e) => this._handleBracketAutoClose(e);
|
||
ta.addEventListener('keypress', onKeypress);
|
||
|
||
// v0.1.7: 当前行高亮
|
||
const onCursorActivity = () => { this._updateCurrentLine(); };
|
||
ta.addEventListener('click', onCursorActivity);
|
||
ta.addEventListener('keyup', onCursorActivity);
|
||
|
||
const onScroll = () => {
|
||
this._syncScroll();
|
||
};
|
||
ta.addEventListener('scroll', onScroll);
|
||
|
||
// v0.1.12: 预览区滚轮反向同步到源码
|
||
const onPreviewScroll = () => {
|
||
if (!this.config.syncScroll || this._mode !== 'split') return;
|
||
if (this._syncing) return; // 防止循环触发
|
||
this._syncing = true;
|
||
const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight;
|
||
if (pmax <= 0) { this._syncing = false; return; }
|
||
const ratio = this.previewPane.scrollTop / pmax;
|
||
const tmax = ta.scrollHeight - ta.clientHeight;
|
||
ta.scrollTop = ratio * tmax;
|
||
if (this.gutter) {
|
||
const gmax = this.gutter.scrollHeight - this.gutter.clientHeight;
|
||
this.gutter.scrollTop = gmax > 0 ? ratio * gmax : ratio * tmax;
|
||
}
|
||
requestAnimationFrame(() => { this._syncing = false; });
|
||
};
|
||
this.previewPane.addEventListener('scroll', onPreviewScroll);
|
||
|
||
const onFocus = () => {
|
||
this._emit('focus');
|
||
if (typeof this.config.onFocus === 'function') {
|
||
try { this.config.onFocus(this); } catch (e) { console.error(e); }
|
||
}
|
||
};
|
||
const onBlur = () => {
|
||
this._emit('blur');
|
||
if (typeof this.config.onBlur === 'function') {
|
||
try { this.config.onBlur(this); } catch (e) { console.error(e); }
|
||
}
|
||
};
|
||
ta.addEventListener('focus', onFocus);
|
||
ta.addEventListener('blur', onBlur);
|
||
|
||
// v0.1.14: 预览区链接点击拦截
|
||
const onPreviewClick = (e) => {
|
||
const a = e.target.closest('a');
|
||
if (!a) return;
|
||
const href = a.getAttribute('href');
|
||
if (!href || href.startsWith('#')) return; // 锚点链接不拦截
|
||
e.preventDefault();
|
||
// 触发自定义事件,用户可监听处理
|
||
this._emit('linkClick', { href, text: a.textContent });
|
||
// 如果用户提供了 onLinkClick 回调
|
||
if (typeof this.config.onLinkClick === 'function') {
|
||
try { this.config.onLinkClick(href, a.textContent, this); } catch (_) {}
|
||
return;
|
||
}
|
||
// 默认:新窗口打开
|
||
window.open(href, '_blank', 'noopener');
|
||
};
|
||
this.previewEl.addEventListener('click', onPreviewClick);
|
||
|
||
// 工具栏点击
|
||
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);
|
||
|
||
// v0.1.7: 拖放支持
|
||
this._bindDragDrop();
|
||
|
||
this._cleanups.push(() => {
|
||
ta.removeEventListener('input', onInput);
|
||
ta.removeEventListener('keydown', onKeydown);
|
||
ta.removeEventListener('keypress', onKeypress);
|
||
ta.removeEventListener('scroll', onScroll);
|
||
this.previewPane.removeEventListener('scroll', onPreviewScroll);
|
||
ta.removeEventListener('click', onCursorActivity);
|
||
ta.removeEventListener('keyup', onCursorActivity);
|
||
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;
|
||
}
|
||
|
||
// v0.1.6: 检查自定义快捷键注册表
|
||
if (this._shortcuts.length) {
|
||
for (const sc of this._shortcuts) {
|
||
const ctrlOk = sc.ctrl ? (e.ctrlKey || e.metaKey) : !e.ctrlKey && !e.metaKey;
|
||
const shiftOk = sc.shift ? e.shiftKey : !e.shiftKey;
|
||
const altOk = sc.alt ? e.altKey : !e.altKey;
|
||
if (e.key.toLowerCase() === sc.key && ctrlOk && shiftOk && altOk) {
|
||
e.preventDefault();
|
||
if (typeof sc.handler === 'string') {
|
||
this.exec(sc.handler);
|
||
} else if (typeof sc.handler === 'function') {
|
||
try { sc.handler(this); } catch (err) { console.error('Shortcut handler error:', err); }
|
||
}
|
||
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;
|
||
if (totalWidth <= 0) return;
|
||
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;
|
||
// 内容未变化时跳过解析(主题切换等场景触发重渲染但内容不变)
|
||
if (this._lastRenderedValue === this._value) return;
|
||
this._lastRenderedValue = this._value;
|
||
MarkdownEditor.trigger('beforeRender', this);
|
||
this._emit('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;
|
||
this._renderGutter();
|
||
MarkdownEditor.trigger('afterRender', this);
|
||
this._emit('afterRender', this);
|
||
}
|
||
|
||
// ============ 行号渲染(v0.1.7) ============
|
||
|
||
_renderGutter() {
|
||
if (!this.config.lineNumbers || !this.gutter) return;
|
||
const lines = this._value ? this._value.split('\n').length : 1;
|
||
const cur = this.gutter.children.length;
|
||
if (cur === lines) return; // 行数未变,跳过
|
||
let html = '';
|
||
for (let i = 1; i <= lines; i++) {
|
||
html += `<div class="me-gutter-line">${i}</div>`;
|
||
}
|
||
this.gutter.innerHTML = html;
|
||
}
|
||
|
||
_updateCurrentLine() {
|
||
if (!this.gutter) return;
|
||
const pos = this.textarea.selectionStart;
|
||
const lineNum = this._value.substring(0, pos).split('\n').length;
|
||
const prev = this.gutter.querySelector('.me-gutter-active');
|
||
if (prev) prev.classList.remove('me-gutter-active');
|
||
const cur = this.gutter.children[lineNum - 1];
|
||
if (cur) cur.classList.add('me-gutter-active');
|
||
}
|
||
|
||
// ============ 自动格式化(v0.1.7) ============
|
||
|
||
/**
|
||
* 智能 Enter:在列表/引用行尾自动延续标记
|
||
*/
|
||
_handleSmartEnter(e) {
|
||
const ta = this.textarea;
|
||
const start = ta.selectionStart;
|
||
const val = ta.value;
|
||
const lineStart = val.lastIndexOf('\n', start - 1) + 1;
|
||
const line = val.slice(lineStart, start);
|
||
|
||
// 列表项延续:匹配 "- " / "* " / "+ " / "1. " / "2. " 等
|
||
const listMatch = line.match(/^(\s*)([-*+]|\d+\.)\s(.*)/);
|
||
if (listMatch) {
|
||
const indent = listMatch[1];
|
||
const marker = listMatch[2];
|
||
const content = listMatch[3];
|
||
// 空列表项 → 结束列表(移除标记)
|
||
if (!content.trim()) {
|
||
e.preventDefault();
|
||
ta.value = val.slice(0, lineStart) + '\n' + val.slice(start);
|
||
ta.selectionStart = ta.selectionEnd = lineStart;
|
||
this._value = ta.value;
|
||
this._pushHistory();
|
||
this._renderGutter();
|
||
this._emit('change', this._value);
|
||
return;
|
||
}
|
||
// 有序列表自动递增
|
||
let nextMarker = marker;
|
||
if (/^\d+\.$/.test(marker)) {
|
||
const num = parseInt(marker, 10);
|
||
if (!isNaN(num)) nextMarker = (num + 1) + '.';
|
||
}
|
||
e.preventDefault();
|
||
const insert = '\n' + indent + nextMarker + ' ';
|
||
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
|
||
ta.selectionStart = ta.selectionEnd = start + insert.length;
|
||
this._value = ta.value;
|
||
this._pushHistory();
|
||
this._renderGutter();
|
||
this._emit('change', this._value);
|
||
return;
|
||
}
|
||
|
||
// 引用块延续
|
||
const quoteMatch = line.match(/^(\s*>+\s?)(.*)/);
|
||
if (quoteMatch) {
|
||
const prefix = quoteMatch[1];
|
||
const content = quoteMatch[2];
|
||
if (!content.trim()) {
|
||
e.preventDefault();
|
||
ta.value = val.slice(0, lineStart) + '\n' + val.slice(start);
|
||
ta.selectionStart = ta.selectionEnd = lineStart;
|
||
this._value = ta.value;
|
||
this._pushHistory();
|
||
this._renderGutter();
|
||
this._emit('change', this._value);
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
const insert = '\n' + prefix;
|
||
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
|
||
ta.selectionStart = ta.selectionEnd = start + insert.length;
|
||
this._value = ta.value;
|
||
this._pushHistory();
|
||
this._renderGutter();
|
||
this._emit('change', this._value);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// ============ 括号/引号自动闭合(v0.1.7) ============
|
||
|
||
_handleBracketAutoClose(e) {
|
||
if (!this.config.autoBrackets) return;
|
||
const ta = this.textarea;
|
||
const key = e.key;
|
||
const close = BRACKET_PAIRS[key];
|
||
if (!close) return;
|
||
|
||
const start = ta.selectionStart;
|
||
const end = ta.selectionEnd;
|
||
const hasSelection = start !== end;
|
||
|
||
// 有选区:包裹选中文本
|
||
if (hasSelection) {
|
||
e.preventDefault();
|
||
const selected = ta.value.slice(start, end);
|
||
const wrap = key === '*' || key === '_' ? key + selected + close : key + selected + close;
|
||
ta.value = ta.value.slice(0, start) + wrap + ta.value.slice(end);
|
||
ta.selectionStart = start + 1;
|
||
ta.selectionEnd = start + 1 + selected.length;
|
||
this._value = ta.value;
|
||
this._pushHistory();
|
||
this._emit('change', this._value);
|
||
return;
|
||
}
|
||
|
||
// 无选区:判断是否应插入闭合对
|
||
const nextChar = ta.value[start] || '';
|
||
const isQuotePair = key === close; // 引号自己闭合自己
|
||
|
||
if (isQuotePair) {
|
||
// 引号:下一个字符是同种引号 → 跳过而非插入
|
||
if (nextChar === close) {
|
||
e.preventDefault();
|
||
ta.selectionStart = ta.selectionEnd = start + 1;
|
||
return;
|
||
}
|
||
// 光标在单词中间 → 不自动闭合
|
||
if (/\w/.test(nextChar)) return;
|
||
}
|
||
|
||
// 括号:下一个字符非空白/换行/闭合括号 → 可能不需要闭合
|
||
// 简单策略:始终闭合
|
||
e.preventDefault();
|
||
const pair = key + close;
|
||
ta.value = ta.value.slice(0, start) + pair + ta.value.slice(end);
|
||
ta.selectionStart = ta.selectionEnd = start + 1;
|
||
this._value = ta.value;
|
||
this._pushHistory();
|
||
this._emit('change', this._value);
|
||
}
|
||
|
||
// ============ 拖放支持(v0.1.7) ============
|
||
|
||
_bindDragDrop() {
|
||
const ta = this.textarea;
|
||
if (!ta) return;
|
||
|
||
const onDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; };
|
||
const onDrop = (e) => {
|
||
e.preventDefault();
|
||
const files = e.dataTransfer.files;
|
||
if (files && files.length) {
|
||
Array.from(files).forEach((file) => {
|
||
if (file.type.startsWith('image/')) {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const name = file.name || `image-${Date.now().toString(36)}.png`;
|
||
this.insert(`\n`);
|
||
};
|
||
reader.readAsDataURL(file);
|
||
} else if (file.type.startsWith('text/') || file.name.match(/\.(md|txt|js|ts|json|css|html|xml|yml|yaml)$/i)) {
|
||
const reader = new FileReader();
|
||
reader.onload = () => this.insert(reader.result);
|
||
reader.readAsText(file);
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
// 拖入外部文本
|
||
const text = e.dataTransfer.getData('text/plain');
|
||
if (text) {
|
||
this.insert(text);
|
||
}
|
||
};
|
||
|
||
ta.addEventListener('dragover', onDragOver);
|
||
ta.addEventListener('drop', onDrop);
|
||
this._cleanups.push(() => {
|
||
ta.removeEventListener('dragover', onDragOver);
|
||
ta.removeEventListener('drop', onDrop);
|
||
});
|
||
}
|
||
|
||
// ============ 大纲面板(v0.1.7) ============
|
||
|
||
_buildOutline() {
|
||
if (!this.config.outline || !this.previewEl) return;
|
||
// 移除旧面板
|
||
const old = this.el.querySelector('.me-outline');
|
||
if (old) old.remove();
|
||
|
||
const headings = [];
|
||
const headingRe = /<h([1-6])\s+id="([^"]+)"[^>]*>(.+?)<\/h\1>/gi;
|
||
const html = this.previewEl.innerHTML;
|
||
let m;
|
||
while ((m = headingRe.exec(html)) !== null) {
|
||
headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, '') });
|
||
}
|
||
if (!headings.length) return;
|
||
|
||
const panel = document.createElement('div');
|
||
panel.className = 'me-outline';
|
||
panel.innerHTML = '<div class="me-outline-title">' + (i18nT('outline') || '大纲') + '</div>';
|
||
|
||
const buildTree = (items, minLevel) => {
|
||
let h = '<ul>';
|
||
let i = 0;
|
||
while (i < items.length) {
|
||
const item = items[i];
|
||
if (item.level < minLevel) break;
|
||
h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.id}">${escapeHTML(item.text)}</a>`;
|
||
// 收集子项
|
||
const subItems = [];
|
||
let j = i + 1;
|
||
while (j < items.length && items[j].level > item.level) {
|
||
subItems.push(items[j]);
|
||
j++;
|
||
}
|
||
if (subItems.length) {
|
||
h += buildTree(subItems, item.level + 1);
|
||
}
|
||
h += '</li>';
|
||
i = j > i + 1 ? j : i + 1;
|
||
}
|
||
h += '</ul>';
|
||
return h;
|
||
};
|
||
|
||
panel.innerHTML += buildTree(headings, 1);
|
||
this.el.appendChild(panel);
|
||
|
||
// 点击跳转
|
||
panel.addEventListener('click', (e) => {
|
||
const a = e.target.closest('a');
|
||
if (!a) return;
|
||
e.preventDefault();
|
||
const id = a.getAttribute('href').slice(1);
|
||
const target = this.previewEl.querySelector('#' + CSS.escape(id));
|
||
if (target) {
|
||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
// 同步滚动 textarea
|
||
const headingText = target.textContent;
|
||
const idx = this._value.indexOf(headingText);
|
||
if (idx !== -1) {
|
||
this.textarea.focus();
|
||
this.textarea.setSelectionRange(idx, idx);
|
||
const lineNum = this._value.substring(0, idx).split('\n').length - 1;
|
||
this.textarea.scrollTop = lineNum * 20;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
_updateOutline() {
|
||
if (!this.config.outline) return;
|
||
// 防抖重建
|
||
clearTimeout(this._outlineTimer);
|
||
this._outlineTimer = setTimeout(() => this._buildOutline(), 300);
|
||
}
|
||
|
||
_scheduleHistory() {
|
||
if (this._historyTimer) clearTimeout(this._historyTimer);
|
||
this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 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._renderGutter();
|
||
this._updateWordCount();
|
||
// 重置滚动
|
||
if (this.gutter) this.gutter.scrollTop = this.textarea.scrollTop;
|
||
if (this.previewPane && this.config.syncScroll) this.previewPane.scrollTop = 0;
|
||
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(),
|
||
zen: () => this.toggleZen(), // v0.1.12
|
||
wordwrap: () => this.toggleWordWrap(), // v0.1.12
|
||
};
|
||
const fn = actions[action];
|
||
if (fn) { fn.apply(this, args); return this; }
|
||
// 检查自定义动作(通过 addToolbarButton 注册)
|
||
if (this._customActions && typeof this._customActions[action] === 'function') {
|
||
try { this._customActions[action].apply(this, args); } catch (e) { console.error('custom action error:', e); }
|
||
}
|
||
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 || '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;
|
||
// 保存当前滚动位置
|
||
const taScroll = this.textarea ? this.textarea.scrollTop : 0;
|
||
const pvScroll = this.previewPane ? this.previewPane.scrollTop : 0;
|
||
this._mode = mode;
|
||
this.bodyEl.className = `me-body me-mode-${mode}`;
|
||
this._updateModeButtons();
|
||
if (mode !== 'edit') this._render();
|
||
// 恢复滚动位置
|
||
if (this.textarea) this.textarea.scrollTop = taScroll;
|
||
if (this.previewPane) this.previewPane.scrollTop = pvScroll;
|
||
this._emit('modeChange', mode);
|
||
this._announce(`${i18nT(mode) || mode} ${i18nT('mode') || 'mode'}`); // v0.1.12
|
||
if (typeof this.config.onModeChange === 'function') {
|
||
try { this.config.onModeChange(mode, this); } catch (e) { console.error(e); }
|
||
}
|
||
return this;
|
||
}
|
||
|
||
getMode() { return this._mode; }
|
||
|
||
_updateModeButtons() {
|
||
const isPreview = this._mode === 'preview';
|
||
const isReadonly = this.config.readOnly;
|
||
|
||
this.toolbarEl.querySelectorAll('.me-btn').forEach((b) => {
|
||
const mode = b.dataset.mode;
|
||
const action = b.dataset.action;
|
||
|
||
// 模式切换按钮始终可用,高亮当前模式
|
||
if (mode) {
|
||
b.classList.toggle('me-active', mode === this._mode);
|
||
b.disabled = false;
|
||
return;
|
||
}
|
||
|
||
// fullscreen 始终可用
|
||
if (action === 'fullscreen') {
|
||
b.disabled = false;
|
||
return;
|
||
}
|
||
|
||
// readonly 模式:禁用编辑操作按钮,保留 undo/redo
|
||
if (isReadonly) {
|
||
b.disabled = !(action === 'undo' || action === 'redo');
|
||
return;
|
||
}
|
||
|
||
// preview 模式:禁用编辑操作按钮
|
||
if (isPreview) {
|
||
b.disabled = !(action === 'undo' || action === 'redo');
|
||
return;
|
||
}
|
||
|
||
b.disabled = false;
|
||
});
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// ============ Zen 模式(v0.1.12) ============
|
||
|
||
toggleZen() {
|
||
this._zenMode = !this._zenMode;
|
||
if (this.el) this.el.classList.toggle('me-zen', this._zenMode);
|
||
|
||
// 鼠标移到顶部时显示工具栏
|
||
if (this._zenMode && this.toolbarEl) {
|
||
this._zenMouseHandler = (e) => {
|
||
this.toolbarEl.style.opacity = e.clientY < 40 ? '1' : '0';
|
||
this.toolbarEl.style.pointerEvents = e.clientY < 40 ? 'auto' : 'none';
|
||
};
|
||
this.toolbarEl.style.transition = 'opacity 0.2s';
|
||
this.toolbarEl.style.opacity = '0';
|
||
this.toolbarEl.style.pointerEvents = 'none';
|
||
document.addEventListener('mousemove', this._zenMouseHandler);
|
||
// 隐藏状态栏
|
||
if (this.statusEl) this.statusEl.style.display = 'none';
|
||
} else {
|
||
if (this._zenMouseHandler) {
|
||
document.removeEventListener('mousemove', this._zenMouseHandler);
|
||
this._zenMouseHandler = null;
|
||
}
|
||
if (this.toolbarEl) {
|
||
this.toolbarEl.style.opacity = '';
|
||
this.toolbarEl.style.pointerEvents = '';
|
||
this.toolbarEl.style.transition = '';
|
||
}
|
||
if (this.statusEl) this.statusEl.style.display = '';
|
||
}
|
||
|
||
this._emit('zenChange', this._zenMode);
|
||
return this;
|
||
}
|
||
|
||
isZen() { return this._zenMode; }
|
||
|
||
// ============ Word Wrap(v0.1.12) ============
|
||
|
||
toggleWordWrap() {
|
||
this._wordWrap = !this._wordWrap;
|
||
if (this.textarea) {
|
||
this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre';
|
||
}
|
||
return this;
|
||
}
|
||
|
||
setWordWrap(on) {
|
||
this._wordWrap = !!on;
|
||
if (this.textarea) {
|
||
this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre';
|
||
}
|
||
return this;
|
||
}
|
||
|
||
isWordWrap() { return this._wordWrap; }
|
||
|
||
// ============ 工具栏键盘导航(v0.1.12 无障碍) ============
|
||
|
||
_bindToolbarKeyboard() {
|
||
if (!this.toolbarEl) return;
|
||
|
||
const onKeydown = (e) => {
|
||
const btns = [...this.toolbarEl.querySelectorAll('.me-btn:not(:disabled)')];
|
||
if (!btns.length) return;
|
||
const idx = btns.indexOf(document.activeElement);
|
||
if (idx === -1) return;
|
||
|
||
if (e.key === 'ArrowRight') {
|
||
e.preventDefault();
|
||
const next = (idx + 1) % btns.length;
|
||
btns[next].focus();
|
||
} else if (e.key === 'ArrowLeft') {
|
||
e.preventDefault();
|
||
const prev = (idx - 1 + btns.length) % btns.length;
|
||
btns[prev].focus();
|
||
} else if (e.key === 'Home') {
|
||
e.preventDefault();
|
||
btns[0].focus();
|
||
} else if (e.key === 'End') {
|
||
e.preventDefault();
|
||
btns[btns.length - 1].focus();
|
||
}
|
||
};
|
||
|
||
this.toolbarEl.addEventListener('keydown', onKeydown);
|
||
this._cleanups.push(() => this.toolbarEl.removeEventListener('keydown', onKeydown));
|
||
}
|
||
|
||
// ============ aria-live 区域(v0.1.12 无障碍) ============
|
||
|
||
_initAriaLive() {
|
||
if (!this.el) return;
|
||
const live = document.createElement('div');
|
||
live.className = 'me-sr-only';
|
||
live.setAttribute('aria-live', 'polite');
|
||
live.setAttribute('aria-atomic', 'true');
|
||
this.el.appendChild(live);
|
||
this._ariaLive = live;
|
||
}
|
||
|
||
_announce(msg) {
|
||
if (this._ariaLive) {
|
||
this._ariaLive.textContent = '';
|
||
requestAnimationFrame(() => { this._ariaLive.textContent = msg; });
|
||
}
|
||
}
|
||
|
||
// ============ 滚动同步(v0.1.12 重写 — 比例同步 + 双向联动) ============
|
||
|
||
/**
|
||
* 源码滚动 → 同步行号 + 预览
|
||
* 使用比例而非直接 scrollTop,兼容不同内容高度
|
||
*/
|
||
_syncScroll() {
|
||
if (this._syncing) return;
|
||
this._syncing = true;
|
||
const ta = this.textarea;
|
||
if (!ta) { this._syncing = false; return; }
|
||
|
||
const tmax = ta.scrollHeight - ta.clientHeight;
|
||
if (tmax <= 0) { this._syncing = false; return; }
|
||
const ratio = ta.scrollTop / tmax;
|
||
|
||
// 同步行号(比例方式,不受内容高度差异影响)
|
||
if (this.gutter) {
|
||
const gmax = this.gutter.scrollHeight - this.gutter.clientHeight;
|
||
this.gutter.scrollTop = gmax > 0 ? ratio * gmax : ta.scrollTop;
|
||
}
|
||
|
||
// 同步预览(比例方式)
|
||
if (this.config.syncScroll && this._mode === 'split' && this.previewPane) {
|
||
const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight;
|
||
if (pmax > 0) this.previewPane.scrollTop = ratio * pmax;
|
||
}
|
||
|
||
requestAnimationFrame(() => { this._syncing = false; });
|
||
}
|
||
|
||
// ============ 语法高亮编辑区(v0.1.14) ============
|
||
|
||
_highlightEditor() {
|
||
if (!this.highlightLayer) return;
|
||
const text = this._value || '';
|
||
if (!text) { this.highlightLayer.innerHTML = ''; return; }
|
||
|
||
// 轻量级 tokenizer:正则匹配所有语法模式,包裹为 span
|
||
let html = escapeHTML(text);
|
||
|
||
// 标题
|
||
html = html.replace(/^(#{1,6})\s+(.+)$/gm, '<span class="mh-heading">$&</span>');
|
||
// 粗体
|
||
html = html.replace(/(\*\*|__)(.+?)\1/g, '<span class="mh-bold">$&</span>');
|
||
// 斜体
|
||
html = html.replace(/(?<!\*)\*(?!\*)(.+?)\*(?!\*)/g, '<span class="mh-italic">$&</span>');
|
||
// 删除线
|
||
html = html.replace(/~~(.+?)~~/g, '<span class="mh-strike">$&</span>');
|
||
// 行内代码
|
||
html = html.replace(/`([^`]+)`/g, '<span class="mh-code">$&</span>');
|
||
// 链接
|
||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<span class="mh-link">$&</span>');
|
||
// 图片
|
||
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<span class="mh-image">$&</span>');
|
||
// 列表标记
|
||
html = html.replace(/^(\s*[-*+]|\d+\.)\s/gm, '<span class="mh-list">$&</span>');
|
||
// 引用
|
||
html = html.replace(/^(>)\s?/gm, '<span class="mh-quote">$&</span>');
|
||
// 水平线
|
||
html = html.replace(/^([-*_]{3,})\s*$/gm, '<span class="mh-hr">$&</span>');
|
||
|
||
this.highlightLayer.innerHTML = html + '\n';
|
||
// 同步滚动
|
||
if (this.textarea) this.highlightLayer.scrollTop = this.textarea.scrollTop;
|
||
}
|
||
|
||
_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._destroyed ? '' : this._value; }
|
||
|
||
setValue(md, opts = {}) {
|
||
if (this._destroyed) return this;
|
||
this._value = md || '';
|
||
this.textarea.value = this._value;
|
||
if (!opts.silent) this._pushHistory();
|
||
this._render();
|
||
this._renderGutter();
|
||
this._updateWordCount();
|
||
this._updateOutline();
|
||
// 重置滚动位置到顶部
|
||
if (this.gutter) this.gutter.scrollTop = 0;
|
||
this.textarea.scrollTop = 0;
|
||
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() {
|
||
MarkdownEditor.trigger('beforeRender', this);
|
||
this._emit('beforeRender', this);
|
||
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); }
|
||
}
|
||
MarkdownEditor.trigger('afterRender', this);
|
||
this._emit('afterRender', this);
|
||
return html;
|
||
}
|
||
|
||
/**
|
||
* 强制刷新预览(内容未变但需重渲染时使用,如主题切换)
|
||
*/
|
||
refresh() {
|
||
this._lastRenderedValue = null;
|
||
this._render();
|
||
return this;
|
||
}
|
||
|
||
insert(text, opts = {}) {
|
||
const ta = this.textarea;
|
||
const start = ta.selectionStart;
|
||
const end = ta.selectionEnd;
|
||
const replace = opts.replace === true;
|
||
// replace=true: 替换选区内容;replace=false: 在光标处插入,保留选区
|
||
ta.value = ta.value.slice(0, start) + text + ta.value.slice(replace ? end : start);
|
||
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() { if (this.textarea) this.textarea.focus(); return this; }
|
||
blur() { if (this.textarea) this.textarea.blur(); return this; }
|
||
|
||
enable() {
|
||
if (this.textarea) this.textarea.disabled = false;
|
||
if (this.el) this.el.classList.remove('me-disabled');
|
||
return this;
|
||
}
|
||
|
||
disable() {
|
||
if (this.textarea) this.textarea.disabled = true;
|
||
if (this.el) this.el.classList.add('me-disabled');
|
||
return this;
|
||
}
|
||
|
||
isDisabled() { return this.textarea ? this.textarea.disabled : false; }
|
||
|
||
/**
|
||
* 设置只读模式
|
||
*/
|
||
setReadOnly(readOnly) {
|
||
this.config.readOnly = !!readOnly;
|
||
if (this.textarea) this.textarea.readOnly = !!readOnly;
|
||
if (this.el) this.el.classList.toggle('me-readonly', !!readOnly);
|
||
this._updateModeButtons();
|
||
return this;
|
||
}
|
||
|
||
isReadOnly() { return this.config.readOnly; }
|
||
|
||
/**
|
||
* 注册实例事件监听
|
||
* 支持事件: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); }
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 安装插件(v0.1.6: 支持异步 install)
|
||
* @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 {
|
||
const result = merged.install(this);
|
||
// 异步插件支持
|
||
if (result && typeof result.then === 'function') {
|
||
result.catch((e) => {
|
||
console.error(`MeEditor: async plugin "${merged.name || 'custom'}" install error:`, e);
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.error(`MeEditor: plugin "${merged.name || 'custom'}" install error:`, e);
|
||
}
|
||
}
|
||
// 直接 push 原对象(保持运行时属性引用一致)
|
||
this._plugins.push(merged);
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* 卸载插件(v0.1.6 新增)
|
||
* @param {string} name - 插件名
|
||
*/
|
||
unuse(name) {
|
||
const idx = this._plugins.findIndex((p) => p.name === name);
|
||
if (idx === -1) {
|
||
console.warn(`MeEditor: plugin "${name}" not found`);
|
||
return this;
|
||
}
|
||
const plugin = this._plugins[idx];
|
||
if (typeof plugin.destroy === 'function') {
|
||
try { plugin.destroy(this); } catch (e) { console.error(`MeEditor: plugin "${name}" destroy error:`, e); }
|
||
}
|
||
this._plugins.splice(idx, 1);
|
||
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.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') {
|
||
// 直接绑定 click,不设置 data-action 避免工具栏委托重复触发
|
||
btn.addEventListener('click', (e) => { e.stopPropagation(); config.onClick(this); });
|
||
} else if (config.action) {
|
||
btn.dataset.action = config.action;
|
||
this._customActions = this._customActions || {};
|
||
this._customActions[config.action] = config.handler;
|
||
}
|
||
this.toolbarEl.insertBefore(btn, this.toolbarEl.querySelector('.me-toolbar-group') || null);
|
||
return this;
|
||
}
|
||
|
||
// ============ v0.1.6: 快捷键注册 ============
|
||
|
||
/**
|
||
* 注册自定义快捷键
|
||
* @param {string} combo - 组合键如 'Ctrl+B' / 'Ctrl+Shift+K' / 'Alt+1'
|
||
* @param {Function|string} handler - 回调函数或 exec action 名
|
||
* @param {string} [description] - 描述
|
||
*/
|
||
registerShortcut(combo, handler, description = '') {
|
||
if (!combo) return this;
|
||
|
||
// 解析组合键
|
||
const parts = combo.toLowerCase().split('+');
|
||
const key = parts.pop();
|
||
const ctrl = parts.includes('ctrl') || parts.includes('cmd');
|
||
const shift = parts.includes('shift');
|
||
const alt = parts.includes('alt');
|
||
const meta = parts.includes('meta');
|
||
|
||
this._shortcuts.push({ combo, key, ctrl, shift, alt, meta, handler, description });
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* 注销快捷键
|
||
* @param {string} combo - 组合键字符串
|
||
*/
|
||
unregisterShortcut(combo) {
|
||
this._shortcuts = this._shortcuts.filter((s) => s.combo !== combo);
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* 获取已注册的快捷键列表
|
||
*/
|
||
getShortcuts() {
|
||
return [...this._shortcuts];
|
||
}
|
||
|
||
// ============ v0.1.6: 工具栏动态配置 ============
|
||
|
||
/**
|
||
* 动态重建工具栏按钮
|
||
* @param {Array} tools - 按钮数组(同 config.toolbar 格式)
|
||
*/
|
||
configureToolbar(tools) {
|
||
if (!this.toolbarEl) return this;
|
||
this.config.toolbar = tools;
|
||
this.toolbarEl.innerHTML = '';
|
||
this._buildToolbar();
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* 从工具栏移除指定按钮
|
||
* @param {string} action - 按钮 action 名
|
||
*/
|
||
removeToolbarButton(action) {
|
||
if (!this.toolbarEl) return this;
|
||
const btn = this.toolbarEl.querySelector(`.me-btn[data-action="${action}"], .me-btn[data-mode="${action}"]`);
|
||
if (btn) btn.remove();
|
||
return this;
|
||
}
|
||
|
||
// ============ v0.1.6: 右键菜单 ============
|
||
|
||
/**
|
||
* 注册右键上下文菜单
|
||
* @param {Array} items - [{ label, action, shortcut?, type?:'separator' }]
|
||
*/
|
||
registerContextMenu(items = []) {
|
||
this._contextMenuItems = items;
|
||
return this;
|
||
}
|
||
|
||
// ============ v0.1.6: Toast 通知 ============
|
||
|
||
/**
|
||
* 显示 Toast 通知
|
||
* @param {string} message - 消息内容
|
||
* @param {Object} [opts]
|
||
* @param {string} [opts.type='info'] - success / error / warning / info
|
||
* @param {number} [opts.duration=3000] - 自动消失时间(ms),0 不自动消失
|
||
* @param {string} [opts.animation='fade'] - 动画类型
|
||
*/
|
||
toast(message, opts = {}) {
|
||
if (!this.el || typeof document === 'undefined') return this;
|
||
const { type = 'info', duration = 3000, animation = 'fade' } = opts;
|
||
|
||
const toast = document.createElement('div');
|
||
toast.className = `me-toast me-toast-${type} me-toast-${animation}`;
|
||
toast.innerHTML = `<span class="me-toast-icon">${TOAST_ICONS[type] || ''}</span><span class="me-toast-msg">${escapeHTML(String(message))}</span>`;
|
||
this.el.appendChild(toast);
|
||
|
||
// 入场动画
|
||
requestAnimationFrame(() => { toast.classList.add('me-toast-enter'); });
|
||
|
||
const remove = () => {
|
||
toast.classList.remove('me-toast-enter');
|
||
toast.classList.add('me-toast-leave');
|
||
setTimeout(() => { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300);
|
||
};
|
||
|
||
if (duration > 0) setTimeout(remove, duration);
|
||
|
||
// 点击关闭
|
||
toast.addEventListener('click', remove);
|
||
return this;
|
||
}
|
||
|
||
// ============ v0.1.6: 实例级 i18n ============
|
||
|
||
/**
|
||
* 设置实例语言
|
||
*/
|
||
setLocale(locale) {
|
||
if (this._i18nCtx) this._i18nCtx.set(locale);
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* 获取实例当前语言
|
||
*/
|
||
getLocale() {
|
||
if (this._i18nCtx) return this._i18nCtx.get();
|
||
return getCurrentLocale();
|
||
}
|
||
|
||
/**
|
||
* 实例级翻译函数
|
||
*/
|
||
t(key, params) {
|
||
if (this._i18nCtx) return this._i18nCtx.t(key, params);
|
||
return i18nT(key, params);
|
||
}
|
||
|
||
/**
|
||
* 销毁编辑器
|
||
*/
|
||
destroy() {
|
||
if (this._destroyed) return;
|
||
MarkdownEditor.trigger('beforeDestroy', this);
|
||
|
||
if (this._renderRaf) cancelAnimationFrame(this._renderRaf);
|
||
if (this._historyTimer) clearTimeout(this._historyTimer);
|
||
if (this._outlineTimer) clearTimeout(this._outlineTimer);
|
||
|
||
this._cleanups.forEach((fn) => { try { fn(); } catch (_) {} });
|
||
this._cleanups = [];
|
||
this._shortcuts = [];
|
||
this._contextMenuItems = [];
|
||
this._customActions = {};
|
||
|
||
// 移除所有 toast
|
||
if (this.el) {
|
||
this.el.querySelectorAll('.me-toast').forEach((t) => t.remove());
|
||
}
|
||
|
||
// 销毁插件
|
||
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; }
|
||
|
||
/**
|
||
* 设置实例主题(v0.1.5: 实例隔离,不影响其他编辑器)
|
||
* @param {string} theme - 主题名
|
||
* @returns {MarkdownEditor}
|
||
*/
|
||
setTheme(theme) {
|
||
if (!theme) return this;
|
||
this.config.theme = theme;
|
||
if (this._themeCtx) {
|
||
this._themeCtx.set(theme);
|
||
}
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* 获取实例当前主题名
|
||
* @returns {string}
|
||
*/
|
||
getTheme() {
|
||
if (this._themeCtx) return this._themeCtx.get();
|
||
return this.config.theme || 'auto';
|
||
}
|
||
|
||
/**
|
||
* 获取实例主题上下文(高级 API)
|
||
* 提供 syncWithElement / adopt / watch 等外部跟随能力
|
||
* @returns {Object|null}
|
||
*/
|
||
getThemeContext() {
|
||
return this._themeCtx || null;
|
||
}
|
||
|
||
/**
|
||
* 获取编辑器状态
|
||
*/
|
||
getStatus() {
|
||
return {
|
||
id: this.id,
|
||
mode: this._mode,
|
||
theme: this.getTheme(),
|
||
locale: getCurrentLocale(),
|
||
fullscreen: this._fullscreen,
|
||
readOnly: this.config.readOnly || false,
|
||
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;
|