MediumEditor 自定义事件(Custom Events)完全指南:订阅、触发与扩展你的富文本编辑器
2026/9/21 2:18:24 网站建设 项目流程

MediumEditor 自定义事件(Custom Events)完全指南:订阅、触发与扩展你的富文本编辑器

【免费下载链接】medium-editorMedium.com WYSIWYG editor clone. Uses contenteditable API to implement a rich text solution.项目地址: https://gitcode.com/gh_mirrors/me/medium-editor

导读

MediumEditor 是一个基于contenteditableAPI 实现的所见即所得富文本编辑器(克隆自 Medium.com 的编辑体验),其核心交互能力并不只是工具栏按钮,而是一套围绕「可编辑区域生命周期」设计的自定义事件系统。本指南基于仓库根目录的 CUSTOM-EVENTS.md(v5.0.0),完整讲解 MediumEditor 暴露的三大类自定义事件(核心自定义事件、工具栏自定义事件、原生事件代理事件),以及subscribe/unsubscribe/trigger三个 API 的用法、参数与底层实现。读完本文,你将掌握:如何在编辑器失焦/聚焦、内容变化、工具栏显隐等时机精确挂载业务逻辑,如何监听键盘、剪贴板等原生交互,甚至如何通过trigger手动触发自己的业务事件,并理解这些事件在 src/js/events.js 中是如何被原生浏览器事件驱动起来的。

一、自定义事件系统概览

MediumEditor 为方便 Web 应用与编辑器集成,暴露了大量自定义事件。你可以对这些事件挂载(attach)与移除(detach)监听器,也可以手动触发任意自定义事件——包括你自己定义的事件。

需要特别注意两点设计约定:

  1. 监听器按订阅顺序依次触发triggerCustomEvent的实现正是用forEach顺序遍历this.customEvents[name]数组(见 src/js/events.js),因此先订阅的监听器先执行
  2. 内置功能先于你的监听器完成。MediumEditor 内部的大部分功能(工具栏状态更新、锚点处理、粘贴清理等)也是通过自定义事件驱动的,由于监听器按订阅顺序执行,可以认为内置功能在你的监听器被调用之前已经完成

如果你需要覆盖编辑器的内置行为,文档建议不要依赖事件去"抢跑",而是用你自己的 自定义扩展(custom extension) 覆盖内置扩展。

事件系统的实体是MediumEditor.Events类(见 src/js/events.js),它维护了四个核心数据结构:

  • this.events:已挂载的 DOM 原生事件列表(用于统一清理);
  • this.disabledEvents:被临时禁用的自定义事件标记;
  • this.customEvents:自定义事件名到监听器数组的映射;
  • this.listeners:内部原生事件监听器是否已建立的标记。

二、API 方法:订阅、退订与手动触发

事件交互的公开入口是MediumEditor实例上的三个方法(见 src/js/core.js),它们只是对Events内部方法的薄封装,并支持链式调用(返回this):

subscribe: function (event, listener) { this.events.attachCustomEvent(event, listener); return this; }, unsubscribe: function (event, listener) { this.events.detachCustomEvent(event, listener); return this; }, trigger: function (name, data, editable) { this.events.triggerCustomEvent(name, data, editable); return this; }

1.MediumEditor.subscribe(name, listener)

为指定自定义事件名挂载一个监听器。

参数

  1. nameString):要监听的事件名,见下文三类内置事件列表;也可以是任意自定义事件名。
  2. listener(data, editable)function):事件被触发时调用的回调函数。

监听器参数

  1. dataEvent|object):
    • 对于大多数自定义事件,这是触发该自定义事件的浏览器原生Event对象
    • 对于部分自定义事件(如addElementremoveElement),这是一个包含事件描述信息的对象
  2. editableHTMLElement):该自定义事件对应的contenteditable容器元素引用。当一个编辑器实例包含多个元素,或页面存在多个编辑器实例时,这个参数尤其有用。例如blur触发时,该参数就是即将失去焦点的那个<div contenteditable=true></div>元素。
var editor = new MediumEditor('.editable'); editor.subscribe('blur', function (event, editable) { console.log('编辑器失去焦点,对应元素为:', editable); });

2.MediumEditor.unsubscribe(name, listener)

为指定自定义事件名移除一个已挂载的监听器。

参数

  1. nameString):要移除监听器的事件名。
  2. listenerfunction):要移除的监听器引用。注意:必须是按引用匹配(by-reference),不能传一份拷贝的函数(例如内联匿名函数无法被退订,除非保存其引用)。

重要注意点

  • 调用 destroy() 销毁 MediumEditor 实例时,会自动移除所有自定义事件监听器。这在 src/js/events.js 的destroy()中得到验证:它会依次执行detachAllDOMEvents()detachAllCustomEvents()(将customEvents重置为空对象)、detachExecCommand()。对应测试见 spec/events.spec.js("should not be called after destroying editor")。
var onBlur = function (event, editable) { // 业务逻辑 }; editor.subscribe('blur', onBlur); // 稍后移除 editor.unsubscribe('blur', onBlur);

3.MediumEditor.trigger(name, data, editable)

手动触发一个自定义事件——包括内置事件和自定义事件

参数

  1. nameString):要触发的事件名。
  2. dataEvent|object):要传给该事件所有监听器的原生事件对象或自定义数据对象。
  3. editableHTMLElement):要传给所有监听器的<div contenteditable=true></div>元素。

典型用法:手动触发自己的业务事件。事件系统对"非内置事件名"一视同仁——attachCustomEvent不校验事件名是否为内置事件(见 src/js/events.js),因此你可以用任意名称做应用级事件总线。这一点被 spec/events.spec.js 明确测试:

var tempData = { temp: 'data' }; editor.subscribe('myIncredibleEvent', spy); editor.trigger('myIncredibleEvent', tempData, editor.elements[0]); // expect(spy).toHaveBeenCalledWith(tempData, editor.elements[0]);

补充:事件的临时禁用机制。除了公开三方法,Events内部还提供了disableCustomEvent(event)enableCustomEvent(event)(见 src/js/events.js),用于在程序化修改内容期间临时屏蔽事件(例如createLink内部会临时禁用editableInput再手动触发,见 src/js/core.js)。triggerCustomEvent只有在事件未被禁用时才会通知监听器。这一机制同样有对应测试(spec/events.spec.js)。

三、核心自定义事件(Custom Events)

这些事件是 MediumEditor 独有的,每个事件背后可能对应一个或多个原生事件。下面是文档定义的六个核心事件。

addElement

触发时机:编辑器实例化之后,有元素被加入编辑器时触发。该事件在元素已经过编辑器初始化并加入内部elements数组之后触发(源码见 src/js/core.js 附近)。

特殊行为:如果被加入的元素是一个<textarea>,传给监听器的将是 MediumEditor 为它创建的<div contenteditable=true>元素,而非根<textarea>

监听器参数

  1. dataobject):
    • target:被加入编辑器的元素;
    • currentTarget:被加入编辑器的元素。
  2. editableHTMLElement):被加入编辑器的元素。
editor.subscribe('addElement', function (data, editable) { console.log('新增元素:', data.target, editable); });

blur

触发时机:编辑器内某个contenteditable元素将焦点丢失给"非编辑器维护元素"(即不是工具栏 Toolbar、锚点预览 Anchor Preview 等)时触发。

文档给出了一个非常具体的行为示例,帮助理解"什么算真正的 blur":

  1. 用户在编辑器元素内选中文本,工具栏出现;
  2. 用户点击工具栏按钮——技术上焦点可能已从编辑元素移开,但因为用户正在与工具栏交互,blur不会被触发
  3. 用户悬停链接,出现锚点预览(anchor-preview);
  4. 用户点击链接进行编辑,工具栏现在显示一个编辑 URL 的文本框——焦点已进入 URL 输入框,但同样因为它在工具栏内部,blur不会被触发
  5. 用户点击页面其他部分,工具栏隐藏,焦点离开contenteditable
  6. 此时blur才被触发

底层实现blurfocusexternalInteraction都依赖对document.bodymousedownclickfocus捕获阶段监听(见 src/js/events.js),再由updateFocus统一判定焦点归属:只有当焦点从编辑元素移向外部元素时才触发blur(src/js/events.js)。isElementDescendantOfExtension(src/js/events.js)正是用来判断目标元素是否属于某个扩展(如工具栏)——属于扩展则不视为失焦。

editableInput

触发时机contenteditable的内容发生变化时触发,覆盖按键输入、工具栏操作、以及任何其他改变元素内 HTML 的用户交互。

浏览器差异与实现原理:这是最复杂的事件之一,其实现直接体现在 src/js/events.js:

  • 对于非 IE 浏览器editableInput只是原生input事件的代理版本;
  • Internet Explorer 从未支持contenteditable上的input事件,而 Edge 对contenteditableinput支持也不稳定(可能在未来 Edge 版本中修复),因此对这些浏览器,editableInput通过以下组合来模拟:
    1. 元素上的原生keypress事件;
    2. document 上的原生selectionchange事件;
    3. 监控document.execCommand()调用。

判定是否真正触发基于内容缓存对比updateInput会比较元素当前innerHTML与缓存contentCache中的快照,只有内容确实变化才触发editableInput(src/js/events.js)。对应测试覆盖了"内容未变则不触发"(spec/events.spec.js)。

监控execCommand的实现尤为值得一提:attachToExecCommand会把document.execCommand包装成wrapper,保存原方法为wrapper.orig、挂载wrapper.listeners数组,每次调用原方法后通知所有监听器并附带{ command, value, args, result }信息(src/js/events.js)。当所有监听器移除后会自动unwrapExecCommand还原(src/js/events.js),测试见 spec/events.spec.js。

此外,通过setContent(html, index)程序化修改内容也会经由checkContentChangedupdateInput触发editableInput(src/js/core.js),且仅在内容真正变化时触发(spec/events.spec.js)。

externalInteraction

触发时机:用户与contenteditable元素之外、或编辑器维护的其他元素(工具栏、锚点预览等)之外的任何元素交互时触发。该事件无论现有contenteditable是否有焦点都会触发

底层实现externalInteraction作为blur/focus的前置依赖被建立(setupListener('blur')会先调用setupListener('externalInteraction'),见 src/js/events.js)。在updateFocus末尾,只要目标元素既不是焦点元素的后代、也不是任何扩展的元素,就会无条件触发externalInteraction(src/js/events.js)。

focus

触发时机:编辑器内某个contenteditable元素获得焦点时触发。如果用户与编辑器维护的元素(如工具栏)交互,由于焦点并未真正丢失,blur不会触发;相应地,focus只会在首次与contenteditable元素(或其所在编辑器)交互时触发

注意:blur触发时,监听器的editable参数是即将失去焦点的那个元素;而focus触发时,editable参数是刚获得焦点的元素(src/js/events.js)。

removeElement

触发时机:编辑器实例化之后,有元素从编辑器中被移除时触发。该事件在元素已从编辑器移除、其上挂载的事件也已全部移除之后触发(源码见 src/js/core.js 附近)。

特殊行为:如果被移除的是为<textarea>创建的对应<div>,此时该元素已经从 DOM 中移除

监听器参数

  1. dataobject):
    • target:从编辑器移除的元素;
    • currentTarget:从编辑器移除的元素。
  2. editableHTMLElement):从编辑器移除的元素。
editor.subscribe('removeElement', function (data, editable) { console.log('移除元素:', data.target, editable); });

四、工具栏自定义事件(Toolbar Custom Events)

这类事件由工具栏扩展触发,前提是工具栏扩展未被禁用(即没有在 options 中关闭 toolbar)。其触发点集中在 src/js/extensions/toolbar.js:

hideToolbar

工具栏可见且刚刚被隐藏时触发。源码中hideToolbar()先移除medium-editor-toolbar-activeclass,再执行this.trigger('hideToolbar', {}, this.base.getFocusedElement())(src/js/extensions/toolbar.js)。注意隐藏调用被延迟 1ms 执行以规避页面多编辑器并存时的 bug(src/js/extensions/toolbar.js)。

positionToolbar

每次检查当前选区、即将更新工具栏位置时触发。此时所有按钮的状态已经更新完毕,但工具栏尚未移动到正确位置。即使工具栏外观不会有任何变化,该事件也会触发(src/js/extensions/toolbar.js)。

positionedToolbar

每次检查当前选区、工具栏已显示、位置已更新时触发。与positionToolbar的区别在于:此事件触发时可见性与位置已经改变完成(而前者是在这些改变发生之前触发)。同样地,即使外观没有变化也会触发(src/js/extensions/toolbar.js)。

showToolbar

工具栏隐藏状态下刚刚被显示时触发。源码中showToolbar()先清除hideTimeout,在未显示时才添加medium-editor-toolbar-activeclass 并触发事件(src/js/extensions/toolbar.js)。

editor.subscribe('showToolbar', function () { console.log('工具栏已显示'); }); editor.subscribe('positionedToolbar', function () { console.log('工具栏已定位完成'); });

五、代理自定义事件(Proxied Custom Events)

这类事件在本实例监控的任何contenteditable元素上触发对应原生浏览器事件时被触发。它们提供两个关键价值:

  1. 单一监听器覆盖所有元素:你无需为每个元素单独绑定原生事件,只需订阅一次代理事件,即可收到所有元素的事件;
  2. 携带触发元素:触发事件的contenteditable元素会作为第二个参数传给监听器。

例如editableClick会在任一contenteditable元素上触发原生click时被触发(源码见 src/js/events.js 的setupListener与 src/js/events.js 的handleClick)。

完整清单如下:

自定义事件名对应原生事件触发条件说明
editableClickclick每个元素的原生 click 事件
editableBlurblur每个元素的原生 blur 事件
editableKeypresskeypress每个元素的原生 keypress 事件
editableKeyupkeyup每个元素的原生 keyup 事件
editableKeydownkeydown每个元素的原生 keydown 事件
editableKeydownEnterkeydown仅当按键为ENTER(keycode 13)时触发
editableKeydownTabkeydown仅当按键为TAB(keycode 9)时触发
editableKeydownDeletekeydown仅当按键为DELETE(keycode 46)时触发
editableKeydownSpacekeydown仅当按键为SPACE(keycode 32)时触发
editableMouseovermouseover每个元素的原生 mouseover 事件
editableDragdrag每个元素的原生 drag 事件(实际监听dragoverdragleave
editableDropdrop每个元素的原生 drop 事件
editablePastepaste每个元素的原生 paste 事件

按键类代理事件的派生逻辑(见 src/js/events.js 的handleKeydown):

  • 每次keydown都会先触发editableKeydown
  • 随后按MediumEditor.util.isKeykeyCode常量依次判定:SPACEeditableKeydownSpaceENTER(keycode 13)或Ctrl + MeditableKeydownEnterTABeditableKeydownTabDELETE(keycode 46)或BACKSPACEeditableKeydownDelete

懒加载(lazy setup)机制:事件监听并非一次性全部挂载,而是订阅哪个才建立哪个setupListener会在首次订阅某事件时绑定对应原生监听器,并置位this.listeners[name]防止重复绑定(src/js/events.js)。例如订阅editableKeydownSpace会先自动建立editableKeydown的监听(因为前者由后者派生),editableDrag则同时监听dragoverdragleave两个原生事件。

editor.subscribe('editableKeydownEnter', function (event, editable) { console.log('在可编辑区域内按下了回车键'); }); editor.subscribe('editablePaste', function (event, editable) { console.log('检测到粘贴操作,剪贴板内容:', event.clipboardData && event.clipboardData.getData('text')); });

六、实战:用自定义事件驱动业务逻辑

结合以上 API 与事件,一个典型的业务集成模式如下——把 MediumEditor 实例当作一个小型事件总线,监听生命周期与内容变化:

var editor = new MediumEditor('.editable', { toolbar: { buttons: ['bold', 'italic', 'underline', 'anchor'] } }); // 1. 监听内容变化(实时保存草稿) editor.subscribe('editableInput', function (event, editable) { autosave(editable.innerHTML); // 自行实现的防抖保存 }); // 2. 监听焦点状态,联动页面 UI editor.subscribe('focus', function (event, editable) { document.body.classList.add('editing'); }); editor.subscribe('blur', function (event, editable) { document.body.classList.remove('editing'); }); // 3. 监听工具栏显示,上报统计 editor.subscribe('showToolbar', function () { track('toolbar-shown'); }); // 4. 应用自己的业务事件 editor.subscribe('quote:inserted', function (data, editable) { editor.pasteHTML('<blockquote>' + data.text + '</blockquote>'); }); // 5. 手动触发自己的事件 editor.trigger('quote:inserted', { text: 'MediumEditor 支持自定义事件' }, editor.elements[0]); // 6. 页面卸载时清理(destroy 会自动移除全部自定义事件监听) // editor.destroy();

使用要点总结:

  • 多实例/多元素场景下,监听器第二个参数editable是区分事件来源的唯一可靠手段;
  • 退订必须传原函数引用,匿名函数无法退订;
  • destroy()会自动清理所有自定义事件监听与 DOM 事件,无需手动逐个unsubscribe
  • 内置功能先于你的监听器执行,如需覆盖内置行为应覆写扩展而非依赖事件顺序。

结语

MediumEditor 的自定义事件系统将contenteditable的零散原生事件(键盘、鼠标、剪贴板、选区变化、甚至execCommand调用)统一收敛为语义清晰的高级事件,并以"订阅—触发"模型暴露给业务层。理解 CUSTOM-EVENTS.md 中定义的三类事件及其在 src/js/events.js 中的实现,是深度集成、二次开发与性能排查的关键;对应的事件行为均有 spec/events.spec.js 中的测试用例兜底验证,可作为行为契约参考。更多编辑器整体 API 请参阅 API.md,工具栏等扩展的完整配置见 OPTIONS.md。

【免费下载链接】medium-editorMedium.com WYSIWYG editor clone. Uses contenteditable API to implement a rich text solution.项目地址: https://gitcode.com/gh_mirrors/me/medium-editor

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询