release: v0.1.3 — bug fixes, performance, hardening

Bug Fixes:
- fix(core): exec() now supports _customActions registered via addToolbarButton
- fix(core): _emit('focus'/'blur') no longer passes editor argument twice
- fix(core): getHTML() now triggers beforeRender/afterRender hooks
- fix(core): _wrapSelection defaults to 'text' instead of i18n placeholder
- fix(core): addToolbarButton onClick+action no longer double-fires
- fix(core): null-safety on focus/blur/enable/disable after destroy
- fix(parser): empty headings (# ) no longer produce empty <h1> tags
- fix(parser): slugify returns 'heading' fallback for empty/special inputs
- fix(i18n): setCurrentLocale no longer mutates input parameter
- fix(core): autofocus skipped when readOnly is enabled

Performance:
- perf(core): cache last rendered value, skip parsing when content unchanged
- perf(core): add refresh() API to force re-render after theme/locale changes

Hardening:
- feat(core): tabSize dynamically applied to textarea via style.tabSize
- feat(core): getStatus() now includes readOnly field

Tests:
- 7 new test cases: customActions, refresh, focus args, destroy safety,
  getStatus readOnly, autofocus+readOnly, slugify edge cases
- Total: 425 tests passing (+7 from v0.1.2)

Types:
- EditorStatus includes readOnly field
- MarkdownEditor.refresh() added to type definitions
This commit is contained in:
2026-07-23 21:00:09 +08:00
parent 12875921eb
commit 8d2e172289
18 changed files with 146 additions and 40 deletions
+39 -16
View File
@@ -1,7 +1,7 @@
/**
* MetonaEditor Core - 编辑器核心
* @module core
* @version 0.1.2
* @version 0.1.3
* @description MarkdownEditor 类实现:DOM 构建、事件、渲染、历史栈、选区操作、模式切换
*/
@@ -88,6 +88,7 @@ class MarkdownEditor {
this._historyTimer = null;
this._fullscreen = false;
this._destroyed = false;
this._lastRenderedValue = null;
// 渲染函数:自定义覆盖内置解析器
this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown;
@@ -122,7 +123,7 @@ class MarkdownEditor {
this.config.plugins.forEach((p) => this.use(p));
}
if (this.config.autofocus) this.focus();
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); }
@@ -168,6 +169,7 @@ class MarkdownEditor {
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'));
editorPane.appendChild(textarea);
@@ -294,13 +296,13 @@ class MarkdownEditor {
ta.addEventListener('scroll', onScroll);
const onFocus = () => {
this._emit('focus', this);
this._emit('focus');
if (typeof this.config.onFocus === 'function') {
try { this.config.onFocus(this); } catch (e) { console.error(e); }
}
};
const onBlur = () => {
this._emit('blur', this);
this._emit('blur');
if (typeof this.config.onBlur === 'function') {
try { this.config.onBlur(this); } catch (e) { console.error(e); }
}
@@ -445,6 +447,9 @@ class MarkdownEditor {
_render() {
if (this._mode === 'edit') return;
// 内容未变化时跳过解析(主题切换等场景触发重渲染但内容不变)
if (this._lastRenderedValue === this._value) return;
this._lastRenderedValue = this._value;
MarkdownEditor.trigger('beforeRender', this);
this._emit('beforeRender', this);
@@ -544,7 +549,11 @@ class MarkdownEditor {
fullscreen: () => this.toggleFullscreen(),
};
const fn = actions[action];
if (fn) fn.apply(this, args);
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;
}
@@ -553,7 +562,7 @@ class MarkdownEditor {
const start = ta.selectionStart;
const end = ta.selectionEnd;
const selected = ta.value.slice(start, end);
const text = selected || (i18nT('placeholder') || 'text');
const text = selected || 'text';
const inserted = before + text + after;
ta.value = ta.value.slice(0, start) + inserted + ta.value.slice(end);
@@ -776,14 +785,27 @@ class MarkdownEditor {
}
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;
@@ -810,22 +832,22 @@ class MarkdownEditor {
return this;
}
focus() { this.textarea.focus(); return this; }
blur() { this.textarea.blur(); return this; }
focus() { if (this.textarea) this.textarea.focus(); return this; }
blur() { if (this.textarea) this.textarea.blur(); return this; }
enable() {
this.textarea.disabled = false;
this.el.classList.remove('me-disabled');
if (this.textarea) this.textarea.disabled = false;
if (this.el) this.el.classList.remove('me-disabled');
return this;
}
disable() {
this.textarea.disabled = true;
this.el.classList.add('me-disabled');
if (this.textarea) this.textarea.disabled = true;
if (this.el) this.el.classList.add('me-disabled');
return this;
}
isDisabled() { return this.textarea.disabled; }
isDisabled() { return this.textarea ? this.textarea.disabled : false; }
/**
* 设置只读模式
@@ -902,14 +924,14 @@ class MarkdownEditor {
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));
// 直接绑定 click,不设置 data-action 避免工具栏委托重复触发
btn.addEventListener('click', (e) => { e.stopPropagation(); config.onClick(this); });
} else if (config.action) {
// 注册到 exec 动作
btn.dataset.action = config.action;
this._customActions = this._customActions || {};
this._customActions[config.action] = config.handler;
}
@@ -969,6 +991,7 @@ class MarkdownEditor {
theme: this.config.theme,
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'),