MediumEditor 扩展开发实战:从零构建一个 DisableContextMenu 自定义扩展
2026/9/21 19:29:10 网站建设 项目流程
  • 前端
  • UI组件

【免费下载链接】medium-editor

Medium.com WYSIWYG editor clone. Uses contenteditable API to implement a rich text solution.

项目地址:https://gitcode.com/gh_mirrors/me/medium-editor
点击查看免费下载

导读

本文围绕 MediumEditor(一个基于contenteditableAPI 实现的 WYSIWYG 富文本编辑器)的扩展机制展开,以官方 Walkthrough 文档(src/js/extensions/WALKTHROUGH-EXTENSION.md)为核心骨架,完整演示如何通过MediumEditor.Extension.extend()从零构建一个"禁用右键上下文菜单"的自定义扩展,并逐步引入init()生命周期、编辑器元素遍历、DOM 事件绑定与自定义事件订阅等关键能力。读完本文,你将掌握 MediumEditor 扩展的定义、注册、初始化、事件处理与清理的完整流程,并能独立编写属于自己的编辑器扩展。


一、扩展机制速览:为什么需要扩展

MediumEditor 的所有高级功能几乎都以"扩展(Extension)"的形式实现。查看 src/js/extensions/README.md 可以看到,工具栏(toolbar)、自动链接检测(auto-link)、链接预览气泡(anchor-preview)、图片拖拽(image-dragging)、键盘快捷键(keyboard-commands)、占位符(placeholder)、粘贴过滤(paste)等内置能力,全部是扩展。扩展可以通过extensions选项传入编辑器,也可以与内置扩展同名来替换默认实现。

这意味着:想要为编辑器添加自定义行为,正确的姿势不是去改核心源码,而是写一个扩展挂载进去。本文要构建的DisableContextMenuExtension就是最小而完整的范例。


二、准备:可运行的 Demo 与前置条件

Walkthrough 文档对应的完整示例代码位于仓库的 demo/extension-example.html。该页面加载了dist/css/medium-editor.cssdist/css/themes/default.cssdist/js/medium-editor.js,并在页面内直接定义了DisableContextMenuExtension扩展。

仓库是只读的,你只需在浏览器中打开该文件即可交互验证效果。文档给出的方式为:file://[Medium Editor Source Root]/demo/extension-example.html

打开页面后,在.editable区域内右键,会发现浏览器默认的上下文菜单不再弹出;按 ESC 键可以切换某个编辑元素上是否允许弹出右键菜单(详见下文第四步)。这份示例中最终形态的扩展代码如下(与官方文档最终版一致):

var DisableContextMenuExtension = MediumEditor.Extension.extend({ name: 'disable-context-menu', init: function () { this.getEditorElements().forEach(function (element) { this.on(element, 'contextmenu', this.handleContextmenu.bind(this)); }, this); this.subscribe('editableKeydown', this.handleKeydown.bind(this)); }, handleContextmenu: function (event) { if (!event.currentTarget.getAttribute('data-allow-context-menu')) { event.preventDefault(); } }, handleKeydown: function (event, editable) { // If the user hits escape, toggle the>var DisableContextMenuExtension = MediumEditor.Extension.extend({ name: 'disable-context-menu' });

这里的name是扩展的唯一标识,用于后续通过MediumEditor.getExtensionByName(name)获取扩展实例(见 API.md)。如果不显式设置name,MediumEditor 会用你传入extensions选项时使用的 key 作为扩展名——这一点在 src/js/extension.js 的原型注释中有明确说明,也在 spec/extension.spec.js 的测试中得到验证:未指定name的扩展会被自动命名为传入时的 key(如'one''two')。

extend() 的实现原理

extend()的实现位于 src/js/extension.js,其思路借鉴了 Backbone / Google Closure 的继承工具:

  • 如果你的protoProps中定义了constructor,新子类就用它作为构造函数;否则默认构造函数会调用父类构造函数,并把arguments透传过去(extension.js第 40-46 行)。
  • 通过MediumEditor.util.extend(child, parent)把静态方法(包括extend本身)复制到子类上,因此子类还能继续被extend(),支持多级继承。
  • 使用一个 Surrogate 中间函数建立原型链(Surrogate.prototype = parent.prototype),避免直接调用父构造函数,同时把protoProps浅拷贝到子类原型上(第 53-61 行)。

换句话说,extend()是一个"浅拷贝 + 原型链"的继承封装:传入的属性会覆盖原型上的同名属性,传入的方法会成为子类实例的方法。

把扩展注册进编辑器

定义好扩展类后,实例化并通过extensions选项注册:

var editor = new MediumEditor('.editable', { extensions: { 'disable-context-menu': new DisableContextMenuExtension() } });

注册的幕后流程在 src/js/core.js 的initExtensions()中:编辑器初始化时遍历options.extensions,对每个扩展调用initExtension()(core.js 第 311-331 行)。initExtension()会先注入三个默认属性:

  • window:来自options.contentWindow
  • document:来自options.ownerDocument
  • base:指向当前 MediumEditor 实例

随后调用扩展的init()方法,最后在扩展没有name时补上注册 key。这解释了为什么在扩展的init()里可以直接使用this.basethis.windowthis.document——它们是由编辑器在初始化时注入的。

源码证据:上述注入逻辑见 src/js/core.js;测试见 spec/extension.spec.js(校验base为 MediumEditor 实例)与 spec/extension.spec.js(校验window/document来自contentWindow/ownerDocument选项)。


四、第二步:接入contextmenu事件 —— 理解init()与助手方法

定义好的扩展目前只是个空壳,要让它在编辑器初始化时真正干点事,需要实现init()方法。MediumEditor 在 setup 阶段会对每一个扩展调用init(),因此它是扩展初始化的标准入口:

var DisableContextMenuExtension = MediumEditor.Extension.extend({ name: 'disable-context-menu', init: function () { this.getEditorElements().forEach(function (element) { this.base.on(element, 'contextmenu', this.handleContextmenu.bind(this)); }, this); }, handleContextmenu: function (event) { } });

这段代码用到了扩展的三个核心助手:

助手说明
this.getEditorElements()返回当前编辑器实例维护的所有elements(contenteditable 元素)数组。其实现就是一行return this.base.elements;,见 src/js/extension.js。
this.base当前 MediumEditor 实例的引用。用它可以在扩展内调用编辑器公开方法,例如this.base.saveSelection()this.base.execAction()等。
this.base.on()MediumEditor 提供的 DOM 事件绑定方法,见 API.md#ontarget-event-listener-usecapture。与原生addEventListener的区别在于:通过它注册的事件处理器会在 MediumEditor 被destroy()时自动解除,避免内存泄漏。

为什么优先用this.on()而不是this.base.on()

Walkthrough 文档特别强调了一个便利点:MediumEditor 为扩展提供了一批"代理方法(proxy methods)",它们直接转发到base实例上的同名方法。其中就包括on()。也就是说,this.on(...)this.base.on(...)完全等价(见 src/js/extension.js 中onoffsubscribetriggerexecAction的批量代理定义)。

因此,后续步骤中的代码都统一使用更简洁的this.on(element, 'contextmenu', ...)写法。

init()被调用时发生了什么

从 src/js/core.js 可以看出,init()被调用之前,basewindowdocument等属性已经注入完成,所有代理助手方法也已就位,所以init()里可以放心使用它们。这保证了扩展的初始化代码拥有与编辑器实例一致的运行环境(例如 iframe 场景下ownerDocument/contentWindow指向的是 iframe 内的 document 与 window)。


五、第三步:添加实际功能 —— 阻止默认行为

前面两步只建立了事件绑定框架,handleContextmenu还是空函数。现在补上核心逻辑:当contextmenu事件触发时调用preventDefault(),阻止浏览器默认上下文菜单出现:

var DisableContextMenuExtension = MediumEditor.Extension.extend({ name: 'disable-context-menu', init: function () { this.getEditorElements().forEach(function (element) { this.base.on(element, 'contextmenu', this.handleContextmenu.bind(this)); }, this); }, handleContextmenu: function (event) { event.preventDefault(); } });

到这里,一个"禁用右键菜单"的扩展已经可以工作了:编辑器初始化后,对每个 contenteditable 元素绑定contextmenu监听,事件发生时调用preventDefault(),浏览器不再弹出右键菜单。

需要注意的是:这里使用event.preventDefault()而非event.stopPropagation()——前者阻止默认行为但不阻断事件冒泡,不会影响页面上其他监听器;后者则会中断事件传播。在富文本编辑器场景中,避免无谓地截断事件流是良好的实践。


六、第四步:利用自定义事件实现"按 ESC 切换开关"

光能禁用右键菜单还不够有代表性。Walkthrough 文档的第四个步骤把扩展升级为一个可按需开关的功能:当用户按下 ESCAPE 时,为当前编辑元素切换一个data-allow-context-menu属性;contextmenu触发时,只有在该属性不存在的情况下才阻止默认菜单。

要实现这个交互,需要两块拼图:

  1. 监听keydown事件:借助内置自定义事件editableKeydown(见 CUSTOM-EVENTS.md#editablekeydown)。它是原生keydown事件在编辑元素上的代理封装,回调签名为listener(data, editable),其中editable是当前触发事件的 contenteditable 元素——这正是我们需要的"当前编辑元素"。
  2. 按属性条件阻止:在handleContextmenu中检查event.currentTarget是否带有data-allow-context-menu属性。

完整代码:

var DisableContextMenuExtension = MediumEditor.Extension.extend({ name: 'disable-context-menu', init: function () { this.getEditorElements().forEach(function (element) { this.on(element, 'contextmenu', this.handleContextmenu.bind(this)); }, this); this.subscribe('editableKeydown', this.handleKeydown.bind(this)); }, handleContextmenu: function (event) { if (!event.currentTarget.getAttribute('data-allow-context-menu')) { event.preventDefault(); } }, handleKeydown: function (event, editable) { // If the user hits escape, toggle the>destroy: function () { this.getEditorElements().forEach(function (element) { element.removeAttribute('data-allow-context-menu'); }, this); }

destroy()被调用的行为在 spec/extension.spec.js 有测试覆盖:编辑器destroy()后,扩展的destroy()必然被调用。


八、扩展的完整工具箱:助手方法与代理方法

Walkthrough 只用了getEditorElements()baseon()subscribe(),但掌握完整工具箱能让你写出更强大的扩展。以下是 src/js/extension.js 与 src/js/extensions/README.md 中定义的完整清单:

助手属性(初始化时由编辑器注入)

属性含义对应选项
baseMediumEditor 实例引用自动注入
window内容窗口引用contentWindow选项(OPTIONS.md)
document宿主文档引用ownerDocument选项(OPTIONS.md)

助手方法

方法返回用途
getEditorElements()Array<HTMLElement>编辑器维护的所有 contenteditable 元素
getEditorId()Number当前编辑器实例的唯一 ID
getEditorOption(option)任意读取初始化时传入的某个选项值

代理方法(直接转发到 base 实例)

方法转发目标
execAction(action, opts)MediumEditor.execAction()(API.md#execactionaction-opts)
on(target, event, listener, useCapture)MediumEditor.on()
off(target, event, listener, useCapture)MediumEditor.off()(API.md#offtarget-event-listener-usecapture)
subscribe(name, listener)MediumEditor.subscribe()(API.md#subscribename-listener)
trigger(name, data, editable)MediumEditor.trigger()(API.md#triggername-data-editable)

代理的实现方式非常直接:在 src/js/extension.js,MediumEditor 将execActiononoffsubscribetrigger这五个方法名逐一挂到Extension.prototype上,每个都是return this.base[helper].apply(this.base, arguments)的转发。这也是"在扩展内使用this.on(...)等价于this.base.on(...)"的代码级根源。

可选状态接口(面向工具栏交互)

除上述工具外,扩展接口还定义了一组可选的生命周期钩子(详见 src/js/extensions/README.md):

  • checkState(node):选区状态变化时,从选区节点沿祖先链逐级向上调用;
  • queryCommandState()/isAlreadyApplied(node)/isActive()/setActive()/setInactive():用于让扩展(尤其是按钮类扩展)感知并反映当前选区是否已应用某格式;
  • getInteractionElements():返回扩展渲染的可交互元素,使编辑器把这些元素内的点击视为"编辑器内交互"而不触发blur(spec/extension.spec.js 有专门测试验证该行为)。

如果你的扩展只是像本文示例一样做全局行为控制(拦截事件、切换属性),实现init()和可选的destroy()就足够了;若要往工具栏里加按钮,则需进一步参考 src/js/extensions/WALKTHROUGH-BUTTON.md。


九、测试与验证

仓库为扩展机制提供了完整的单元测试,见 spec/extension.spec.js,其中与本例直接相关的验证点包括:

  • 扩展的base属性被设置为 MediumEditor 实例(第 27-37 行);
  • 未显式指定name的扩展被自动命名为注册 key(第 54-71 行);
  • 扩展被注入window/document(对应contentWindow/ownerDocument选项)(第 73-110 行);
  • destroy()被编辑器destroy()调用(第 112-125 行);
  • 助手方法on/off/subscribe/execAction/trigger均正确转发到 base 实例(第 227-251 行);
  • getEditorId()/getEditorElements()/getEditorOption()返回正确的编辑器状态(第 253-286 行)。

验证方式是运行npm install后执行测试套件(项目使用 Karma + Jasmine,配置文件见 karma.conf.js 与 karma.dev.conf.js,构建任务见 Gruntfile.js)。


十、小结与进一步探索

通过DisableContextMenuExtension这个由浅入深的例子,我们完整走通了 MediumEditor 自定义扩展的四个关键阶段:

  1. 定义MediumEditor.Extension.extend({ name: ... })创建扩展类,extensions选项注册实例;
  2. 初始化:实现init(),借助注入的base/window/document与助手方法getEditorElements()遍历编辑器元素;
  3. 功能:通过this.on()绑定原生 DOM 事件(contextmenu),并利用event.preventDefault()拦截默认行为;
  4. 联动:通过this.subscribe('editableKeydown', ...)订阅自定义事件,结合MediumEditor.util.isKey()keyCode.ESCAPE实现按键驱动的开关逻辑。

这套模式可以推广到几乎所有编辑器自定义需求:拦截粘贴(editablePaste)、监听内容变化(editableInput)、感知焦点(focus/blur)、响应外部点击(externalInteraction)等自定义事件均已在 CUSTOM-EVENTS.md 中列出,可供扩展内部直接订阅。若要深入了解扩展接口的完整定义,推荐继续阅读 src/js/extensions/README.md、src/js/extensions/WALKTHROUGH-BUTTON.md 以及扩展基类源码 src/js/extension.js。

  • 前端
  • UI组件

【免费下载链接】medium-editor

Medium.com WYSIWYG editor clone. Uses contenteditable API to implement a rich text solution.

项目地址:https://gitcode.com/gh_mirrors/me/medium-editor
点击查看免费下载

相关推荐

上一篇:FastAPI-Utils 会话管理指南:使用 FastAPISessionMaker 优化 SQLAlchemy 集成
下一篇:重构效率革命:Python-Rope核心功能全解析与实战指南

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

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

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

立即咨询