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:
+39
-16
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user