- 前端
- UI组件
【免费下载链接】medium-editor
Medium.com WYSIWYG editor clone. Uses contenteditable API to implement a rich text solution.
导读
本文围绕 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.css、dist/css/themes/default.css与dist/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.contentWindowdocument:来自options.ownerDocumentbase:指向当前 MediumEditor 实例
随后调用扩展的init()方法,最后在扩展没有name时补上注册 key。这解释了为什么在扩展的init()里可以直接使用this.base、this.window、this.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 中on、off、subscribe、trigger、execAction的批量代理定义)。
因此,后续步骤中的代码都统一使用更简洁的this.on(element, 'contextmenu', ...)写法。
init()被调用时发生了什么
从 src/js/core.js 可以看出,init()被调用之前,base、window、document等属性已经注入完成,所有代理助手方法也已就位,所以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触发时,只有在该属性不存在的情况下才阻止默认菜单。
要实现这个交互,需要两块拼图:
- 监听
keydown事件:借助内置自定义事件editableKeydown(见 CUSTOM-EVENTS.md#editablekeydown)。它是原生keydown事件在编辑元素上的代理封装,回调签名为listener(data, editable),其中editable是当前触发事件的 contenteditable 元素——这正是我们需要的"当前编辑元素"。 - 按属性条件阻止:在
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()、base、on()、subscribe(),但掌握完整工具箱能让你写出更强大的扩展。以下是 src/js/extension.js 与 src/js/extensions/README.md 中定义的完整清单:
助手属性(初始化时由编辑器注入)
| 属性 | 含义 | 对应选项 |
|---|---|---|
base | MediumEditor 实例引用 | 自动注入 |
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 将execAction、on、off、subscribe、trigger这五个方法名逐一挂到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 自定义扩展的四个关键阶段:
- 定义:
MediumEditor.Extension.extend({ name: ... })创建扩展类,extensions选项注册实例; - 初始化:实现
init(),借助注入的base/window/document与助手方法getEditorElements()遍历编辑器元素; - 功能:通过
this.on()绑定原生 DOM 事件(contextmenu),并利用event.preventDefault()拦截默认行为; - 联动:通过
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.
相关推荐
JupyterLab扩展开发实战:从零构建自定义插件
JupyterLab扩展开发实战:从零构建自定义插件 本文深入探讨JupyterLab扩展开发的完整流程,从架构设计理念到实际开发部署。首先解析JupyterL
前端后端数据科学开发工具Umi插件开发实战:从零构建自定义功能扩展
Umi插件开发实战:从零构建自定义功能扩展 本文全面介绍了Umi插件开发的全过程,从基础环境配置、插件API与生命周期钩子解析,到preset umi预设插件集
前端Web框架CLI构建工具Faker扩展开发实战:从零开始创建一个自定义数据生成器
Faker扩展开发实战:从零开始创建一个自定义数据生成器 Faker是一款强大的PHP库,专为生成逼真的假数据而设计。本指南将带你从零开始,创建一个自定义的Fa
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考