From 8d2e1722891a47ca6426b09d273f199650ff473f Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Thu, 23 Jul 2026 21:00:09 +0800 Subject: [PATCH] =?UTF-8?q?release:=20v0.1.3=20=E2=80=94=20bug=20fixes,=20?= =?UTF-8?q?performance,=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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

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 --- README.md | 2 +- package.json | 2 +- site/demo.html | 2 +- src/animations.js | 2 +- src/constants.js | 2 +- src/core.js | 55 +++++++++++++++++++++++---------- src/i18n.js | 15 ++++----- src/icons.js | 2 +- src/index.js | 4 +-- src/locales.js | 2 +- src/parser.js | 9 +++--- src/plugins.js | 2 +- src/styles.js | 2 +- src/themes.js | 2 +- src/utils.js | 2 +- tests/core.test.js | 73 ++++++++++++++++++++++++++++++++++++++++++++ tests/parser.test.js | 5 +++ types/index.d.ts | 3 ++ 18 files changed, 146 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 0923a7b..7a7ed5d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > 轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用,中文优先。 -[![npm version](https://img.shields.io/badge/version-0.1.2-blue.svg)](https://www.npmjs.com/package/@metona-team/metona-editor) +[![npm version](https://img.shields.io/badge/version-0.1.3-blue.svg)](https://www.npmjs.com/package/@metona-team/metona-editor) [![license](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE) [![tests](https://img.shields.io/badge/tests-407%20passed-brightgreen.svg)](./tests) [![coverage](https://img.shields.io/badge/coverage-97%25-brightgreen.svg)](./tests) diff --git a/package.json b/package.json index 3c9ec19..7b6383c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metona-team/metona-editor", - "version": "0.1.2", + "version": "0.1.3", "description": "轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用。", "type": "module", "main": "dist/metona-editor.js", diff --git a/site/demo.html b/site/demo.html index ee1665b..4c6c574 100644 --- a/site/demo.html +++ b/site/demo.html @@ -305,7 +305,7 @@ '', '| 名称 | 值 |', '| --- | --- |', - '| 版本 | 0.1.2 |', + '| 版本 | 0.1.3 |', '| 依赖 | 零 |', '| 测试 | 390+ |', '', diff --git a/src/animations.js b/src/animations.js index 7dd9c66..1205e27 100644 --- a/src/animations.js +++ b/src/animations.js @@ -1,7 +1,7 @@ /** * MetonaEditor Animations - 动画管理 * @module animations - * @version 0.1.2 + * @version 0.1.3 * @description 动画注册与管理(当前为 CSS 动画元数据管理,供未来扩展如 Toast/通知组件的进出场动画使用; * 内置 7 种预设动画曲线配置;编辑器核心当前未直接调用此模块) */ diff --git a/src/constants.js b/src/constants.js index 091040c..7020dd5 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,7 +1,7 @@ /** * MetonaEditor Constants - 常量定义 * @module constants - * @version 0.1.2 + * @version 0.1.3 * @description 默认配置、主题、动画、工具栏等常量 */ diff --git a/src/core.js b/src/core.js index 656f504..c02a92a 100644 --- a/src/core.js +++ b/src/core.js @@ -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 || `${escapeHTML(config.text || config.action)}`; 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'), diff --git a/src/i18n.js b/src/i18n.js index feb307d..ab426ab 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -1,7 +1,7 @@ /** * MetonaEditor i18n - 国际化管理 * @module i18n - * @version 0.1.2 + * @version 0.1.3 * @description 多语言支持、语言切换和翻译管理 */ @@ -20,14 +20,15 @@ export const getCurrentLocale = () => currentLocale; * 设置当前语言 */ export const setCurrentLocale = (locale) => { - if (!LOCALES[locale]) { - console.warn(`Locale "${locale}" not found, falling back to "${fallbackLocale}"`); - locale = fallbackLocale; + let target = locale; + if (!LOCALES[target]) { + console.warn(`Locale "${target}" not found, falling back to "${fallbackLocale}"`); + target = fallbackLocale; } - currentLocale = locale; - notifyLocaleListeners(locale); - saveLocale(locale); + currentLocale = target; + notifyLocaleListeners(target); + saveLocale(target); }; /** diff --git a/src/icons.js b/src/icons.js index 4f74813..3895ec8 100644 --- a/src/icons.js +++ b/src/icons.js @@ -1,7 +1,7 @@ /** * MetonaEditor Icons — 工具栏图标SVG定义 * @module icons - * @version 0.1.2 + * @version 0.1.3 * @description 工具栏格式化按钮 SVG 图标 */ diff --git a/src/index.js b/src/index.js index 4bc79e5..f2e4236 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,7 @@ /** * MetonaEditor - 轻量级 Markdown Editor 库 * @module metona-editor - * @version 0.1.2 + * @version 0.1.3 * @author thzxx * @description 现代化、零依赖、易扩展、易维护、易使用的 Markdown 编辑器库。单文件,开箱即用。 * @license MIT @@ -29,7 +29,7 @@ import { animationUtils } from './animations.js'; import { DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS } from './constants.js'; // 版本信息 -const VERSION = '0.1.2'; +const VERSION = '0.1.3'; /** * 全局默认插件队列 diff --git a/src/locales.js b/src/locales.js index 95aed98..9bf5e5d 100644 --- a/src/locales.js +++ b/src/locales.js @@ -1,7 +1,7 @@ /** * MetonaEditor Locales — 国际化翻译数据 * @module locales - * @version 0.1.2 + * @version 0.1.3 * @description 内置 zh-CN / en-US 完整翻译 */ diff --git a/src/parser.js b/src/parser.js index 9641084..9d00a51 100644 --- a/src/parser.js +++ b/src/parser.js @@ -1,7 +1,7 @@ /** * MetonaEditor Parser - 轻量 Markdown 解析器 * @module parser - * @version 0.1.2 + * @version 0.1.3 * @description 自研零依赖 Markdown 解析器,覆盖 CommonMark 子集 + GFM 扩展 * * 支持语法: @@ -100,8 +100,8 @@ export const parseMarkdown = (md, env = {}) => { continue; } - // ATX 标题 - const h = line.match(/^(#{1,6})\s+(.*?)(?:\s+#{1,6})?\s*$/); + // ATX 标题(非空标题文本) + const h = line.match(/^(#{1,6})\s+(.+?)(?:\s+#{1,6})?\s*$/); if (h) { tokens.push({ type: 'heading', level: h[1].length, text: h[2] }); i++; @@ -364,12 +364,13 @@ const renderInline = (text, env) => { * 生成标题锚点 id(支持中文) */ const slugify = (text) => { - return String(text) + const slug = String(text) .toLowerCase() .replace(/[^\w\u4e00-\u9fa5\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); + return slug || 'heading'; }; export { safeUrl, slugify }; diff --git a/src/plugins.js b/src/plugins.js index 9fea20c..7c3d353 100644 --- a/src/plugins.js +++ b/src/plugins.js @@ -1,7 +1,7 @@ /** * MetonaEditor Plugins - 插件系统 * @module plugins - * @version 0.1.2 + * @version 0.1.3 * @description 插件管理器 + 预设插件(autoSave / exportTool / searchReplace) * * 插件约定: diff --git a/src/styles.js b/src/styles.js index da13cf0..58933cd 100644 --- a/src/styles.js +++ b/src/styles.js @@ -1,7 +1,7 @@ /** * MetonaEditor Styles - 编辑器样式 * @module styles - * @version 0.1.2 + * @version 0.1.3 * @description 编辑器 UI 样式注入与管理 */ diff --git a/src/themes.js b/src/themes.js index 9e3f5dc..b38bbd6 100644 --- a/src/themes.js +++ b/src/themes.js @@ -1,7 +1,7 @@ /** * MetonaEditor Themes - 主题管理 * @module themes - * @version 0.1.2 + * @version 0.1.3 * @description 主题系统、自定义主题和主题切换 */ diff --git a/src/utils.js b/src/utils.js index 7e1ca3c..0f201f5 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,7 +1,7 @@ /** * MetonaEditor Utils - 工具函数 * @module utils - * @version 0.1.2 + * @version 0.1.3 * @description 通用工具函数集合 */ diff --git a/tests/core.test.js b/tests/core.test.js index eaf3ef2..2e7864f 100644 --- a/tests/core.test.js +++ b/tests/core.test.js @@ -1278,4 +1278,77 @@ describe('MarkdownEditor - v0.1.2 readOnly 模式', () => { expect(ed.getValue()).toBe('# hello'); ed.destroy(); }); + + test('getStatus 包含 readOnly 字段', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { readOnly: true }); + expect(ed.getStatus().readOnly).toBe(true); + ed.destroy(); + }); + + test('autofocus 在 readOnly 模式下不聚焦', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { readOnly: true, autofocus: true }); + // readOnly textarea 不应被 focus(autofocus 被跳过) + expect(ed.textarea.readOnly).toBe(true); + ed.destroy(); + }); +}); + +describe('MarkdownEditor - v0.1.3 修复验证', () => { + test('exec 支持 _customActions 自定义动作', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { value: 'hello' }); + let called = false; + ed._customActions = { myAction: () => { called = true; } }; + ed.exec('myAction'); + expect(called).toBe(true); + ed.destroy(); + }); + + test('refresh 清除渲染缓存并强制重渲染', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { value: '# test', mode: 'split' }); + const first = ed.previewEl.innerHTML; + // setValue 不改变内容,正常 _render 会跳过 + ed.setValue('# test', { silent: true }); + // refresh 强制重渲染 + ed.refresh(); + // 内容一致,预览应一致 + expect(ed.previewEl.innerHTML).toBe(first); + ed.destroy(); + }); + + test('focus/blur 监听器接收正确参数', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, {}); + let focusArg; + ed.on('focus', (...args) => { focusArg = args; }); + ed.textarea.dispatchEvent(new Event('focus', { bubbles: true })); + // focus 事件只传 editor 一次 + expect(focusArg.length).toBe(1); + expect(focusArg[0]).toBe(ed); + ed.destroy(); + }); + + test('destroy 后 focus/blur 不抛错', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, {}); + ed.destroy(); + expect(() => ed.focus()).not.toThrow(); + expect(() => ed.blur()).not.toThrow(); + expect(ed.isDisabled()).toBe(false); + }); }); diff --git a/tests/parser.test.js b/tests/parser.test.js index 0ee2c95..3973651 100644 --- a/tests/parser.test.js +++ b/tests/parser.test.js @@ -350,6 +350,11 @@ describe('slugify', () => { test('去除首尾连字符', () => { expect(slugify('-hello-')).toBe('hello'); }); + + test('slugify 空/特殊字符输入返回兜底值', () => { + expect(slugify('')).toBe('heading'); + expect(slugify('###')).toBe('heading'); + }); }); describe('parseMarkdown - v0.1.2 新语法', () => { diff --git a/types/index.d.ts b/types/index.d.ts index 00966df..0ad168c 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -115,6 +115,7 @@ export interface EditorStatus { theme: ThemeName; locale: string; fullscreen: boolean; + readOnly: boolean; disabled: boolean; destroyed: boolean; plugins: string[]; @@ -162,6 +163,8 @@ export class MarkdownEditor { getValue(): string; setValue(markdown: string, opts?: { silent?: boolean }): this; getHTML(): string; + /** 强制刷新预览(内容未变时手动触发重渲染) */ + refresh(): this; insert(text: string, opts?: { replace?: boolean }): this; wrap(before: string, after?: string): this;