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)监听器,也可以手动触发任意自定义事件——包括你自己定义的事件。
需要特别注意两点设计约定:
- 监听器按订阅顺序依次触发。
triggerCustomEvent的实现正是用forEach顺序遍历this.customEvents[name]数组(见 src/js/events.js),因此先订阅的监听器先执行。 - 内置功能先于你的监听器完成。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)
为指定自定义事件名挂载一个监听器。
参数
name(String):要监听的事件名,见下文三类内置事件列表;也可以是任意自定义事件名。listener(data, editable)(function):事件被触发时调用的回调函数。
监听器参数
data(Event|object):- 对于大多数自定义事件,这是触发该自定义事件的浏览器原生
Event对象; - 对于部分自定义事件(如
addElement、removeElement),这是一个包含事件描述信息的对象。
- 对于大多数自定义事件,这是触发该自定义事件的浏览器原生
editable(HTMLElement):该自定义事件对应的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)
为指定自定义事件名移除一个已挂载的监听器。
参数
name(String):要移除监听器的事件名。listener(function):要移除的监听器引用。注意:必须是按引用匹配(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)
手动触发一个自定义事件——包括内置事件和自定义事件。
参数
name(String):要触发的事件名。data(Event|object):要传给该事件所有监听器的原生事件对象或自定义数据对象。editable(HTMLElement):要传给所有监听器的<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>。
监听器参数
data(object):target:被加入编辑器的元素;currentTarget:被加入编辑器的元素。
editable(HTMLElement):被加入编辑器的元素。
editor.subscribe('addElement', function (data, editable) { console.log('新增元素:', data.target, editable); });blur
触发时机:编辑器内某个contenteditable元素将焦点丢失给"非编辑器维护元素"(即不是工具栏 Toolbar、锚点预览 Anchor Preview 等)时触发。
文档给出了一个非常具体的行为示例,帮助理解"什么算真正的 blur":
- 用户在编辑器元素内选中文本,工具栏出现;
- 用户点击工具栏按钮——技术上焦点可能已从编辑元素移开,但因为用户正在与工具栏交互,
blur不会被触发; - 用户悬停链接,出现锚点预览(anchor-preview);
- 用户点击链接进行编辑,工具栏现在显示一个编辑 URL 的文本框——焦点已进入 URL 输入框,但同样因为它在工具栏内部,
blur不会被触发; - 用户点击页面其他部分,工具栏隐藏,焦点离开
contenteditable; - 此时
blur才被触发。
底层实现:blur与focus、externalInteraction都依赖对document.body的mousedown、click、focus捕获阶段监听(见 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 对contenteditable的input支持也不稳定(可能在未来 Edge 版本中修复),因此对这些浏览器,editableInput通过以下组合来模拟:- 元素上的原生
keypress事件; - document 上的原生
selectionchange事件; - 监控
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)程序化修改内容也会经由checkContentChanged→updateInput触发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 中移除。
监听器参数
data(object):target:从编辑器移除的元素;currentTarget:从编辑器移除的元素。
editable(HTMLElement):从编辑器移除的元素。
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元素上触发对应原生浏览器事件时被触发。它们提供两个关键价值:
- 单一监听器覆盖所有元素:你无需为每个元素单独绑定原生事件,只需订阅一次代理事件,即可收到所有元素的事件;
- 携带触发元素:触发事件的
contenteditable元素会作为第二个参数传给监听器。
例如editableClick会在任一contenteditable元素上触发原生click时被触发(源码见 src/js/events.js 的setupListener与 src/js/events.js 的handleClick)。
完整清单如下:
| 自定义事件名 | 对应原生事件 | 触发条件说明 |
|---|---|---|
editableClick | click | 每个元素的原生 click 事件 |
editableBlur | blur | 每个元素的原生 blur 事件 |
editableKeypress | keypress | 每个元素的原生 keypress 事件 |
editableKeyup | keyup | 每个元素的原生 keyup 事件 |
editableKeydown | keydown | 每个元素的原生 keydown 事件 |
editableKeydownEnter | keydown | 仅当按键为ENTER(keycode 13)时触发 |
editableKeydownTab | keydown | 仅当按键为TAB(keycode 9)时触发 |
editableKeydownDelete | keydown | 仅当按键为DELETE(keycode 46)时触发 |
editableKeydownSpace | keydown | 仅当按键为SPACE(keycode 32)时触发 |
editableMouseover | mouseover | 每个元素的原生 mouseover 事件 |
editableDrag | drag | 每个元素的原生 drag 事件(实际监听dragover与dragleave) |
editableDrop | drop | 每个元素的原生 drop 事件 |
editablePaste | paste | 每个元素的原生 paste 事件 |
按键类代理事件的派生逻辑(见 src/js/events.js 的handleKeydown):
- 每次
keydown都会先触发editableKeydown; - 随后按
MediumEditor.util.isKey与keyCode常量依次判定:SPACE→editableKeydownSpace;ENTER(keycode 13)或Ctrl + M→editableKeydownEnter;TAB→editableKeydownTab;DELETE(keycode 46)或BACKSPACE→editableKeydownDelete。
懒加载(lazy setup)机制:事件监听并非一次性全部挂载,而是订阅哪个才建立哪个。setupListener会在首次订阅某事件时绑定对应原生监听器,并置位this.listeners[name]防止重复绑定(src/js/events.js)。例如订阅editableKeydownSpace会先自动建立editableKeydown的监听(因为前者由后者派生),editableDrag则同时监听dragover与dragleave两个原生事件。
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),仅供参考