- 前端
- UI组件
【免费下载链接】medium-editor
Medium.com WYSIWYG editor clone. Uses contenteditable API to implement a rich text solution.
本篇技术指南以 Medium Editor 官方扩展文档为核心,系统讲解其扩展体系的三层抽象:通用Extension、与工具栏建立契约的Button Extension、以及负责收集用户输入的Form Extension。你将掌握扩展的注册方式、全部生命周期方法与辅助工具、按钮状态的判定机制,并通过两个官方 Walkthrough(禁用右键菜单扩展与高亮按钮)获得可直接落地的实战代码。全文结合 src/js/extensions/ 目录下的真实源码与 demo/ 示例页面,确保每个结论都有实现依据。
扩展体系总览:三层抽象
Medium Editor 的所有自定义能力都建立在同一个入口上:通过extensions选项传入扩展实例。官方文档将其划分为三个层次:
- Extension(通用扩展):任意自定义动作或命令,可替换同名内置按钮,也可为编辑器增加全新功能;
- Button(按钮扩展):与工具栏有明确契约的一类扩展,负责在工具栏渲染可点击元素、在点击时对选中文本执行动作、并根据用户选区更新自身外观;
- Form Extension(表单扩展):Button 的子类,在工具栏内收集用户输入(如锚点 URL、字号),继承 Button 的全部生命周期方法,并额外提供表单显示/隐藏等公共能力。
三者之间是严格的继承关系,源码中可见:Button = MediumEditor.Extension.extend(...)(见 src/js/extensions/button.js),而FormExtension = MediumEditor.extensions.button.extend(...)(见 src/js/extensions/form.js)。
内置扩展:扩展体系本身就是编辑器的骨架
官方文档特别强调:整个 Medium Editor 工具栏本身就是一个扩展。以下内置功能全部以扩展形式实现:
| 内置扩展 | 源码文件 | 功能 |
|---|---|---|
| Toolbar | src/js/extensions/toolbar.js | 承载所有按钮的整个工具栏 |
| Auto-Link | src/js/extensions/auto-link.js | 自动检测 URL 并将其转换为<a>锚点 |
| Anchor Preview | src/js/extensions/anchor-preview.js | 悬停链接时显示 href 提示气泡 |
| File Dragging | src/js/extensions/file-dragging.js | 允许将文件拖拽进编辑器(旧版 image-dragging 已移至 src/js/extensions/deprecated/image-dragging.js) |
| Keyboard Commands | src/js/extensions/keyboard-commands.js | 将键盘快捷键映射到各类命令 |
| Placeholder | src/js/extensions/placeholder.js | 编辑器为空时显示占位文本 |
| Paste | src/js/extensions/paste.js | 过滤并处理粘贴进编辑器的内容 |
| Anchor(表单) | src/js/extensions/anchor.js | 通过工具栏表单收集 URL 并创建/解除链接 |
| FontSize(表单,beta) | src/js/extensions/fontsize.js | 通过工具栏表单修改选中文字的字号 |
从源码结构看,凡是需要与工具栏交互的内置功能都被设计为扩展,这使得核心编辑器保持精简,而功能全部可插拔、可替换。
什么是 Button:与工具栏的契约
Button 是特定类型的 Extension,它与工具栏之间存在明确的契约。只要扩展实现了getButton()方法,且其名称出现在toolbar.buttons选项中,工具栏就会将其视为按钮扩展。这一契约赋予自定义按钮三类能力:
- 在工具栏中渲染一个元素(可点击的按钮/链接);
- 点击时对编辑器文本执行动作(如 bold、italic、blockquote);
- 根据用户选区更新元素外观(选区已是粗体时"激活",否则"未激活")。
内置按钮只是不同配置的 Button 扩展
官方文档指出,所有内置按钮都是带有不同配置的 Button 扩展。这些配置集中定义在 src/js/defaults/buttons.js,共 25 个内置按钮:bold、italic、underline、strikethrough、subscript、superscript、image、quote、pre、orderedlist、unorderedlist、indent、outdent、justifyLeft、justifyCenter、justifyRight、justifyFull、h1~h6、removeFormat、html。
以bold为例,其完整配置如下(见 src/js/defaults/buttons.js):
'bold': { name: 'bold', action: 'bold', aria: 'bold', tagNames: ['b', 'strong'], style: { prop: 'font-weight', value: '700|bold' }, useQueryState: true, contentDefault: '<b>B</b>', contentFA: '<i class="fa fa-bold"></i>' }这里的name就是工具栏配置中的按钮标识。工具栏在创建时遍历toolbar.buttons数组,对每个名称调用base.addBuiltInExtension(buttonName, buttonOpts)获取扩展实例,再调用其getButton()方法取回 DOM 元素追加到工具栏(见 src/js/extensions/toolbar.js)。
什么是 Form Extension:在工具栏内收集输入
Form Extension 是特殊的 Button Extension,它从 Button 继承全部生命周期方法,同时获得与编辑器交互的附加方法,用于在工具栏中渲染表单控件。
两个内置表单扩展:
- Anchor Button(src/js/extensions/anchor.js):点击后弹出 URL 输入框(含可选复选框),将选中文本转为链接;若选区本身已是链接,点击则解除链接(执行
unlink)。其源码handleClick中通过getClosestTag(..., 'a')判断选区是否已在链接内,见 src/js/extensions/anchor.js。 - FontSize Button(beta)(src/js/extensions/fontsize.js):点击后显示字号滑块(range 输入,范围 1~7),修改选中文本的字号;源码中
createForm()构建了滑块、保存与关闭按钮,见 src/js/extensions/fontsize.js。
Form Extension 的公共能力(见 src/js/extensions/form.js):
formSaveLabel/formCloseLabel:表单保存/关闭按钮的默认文本(✓与×);activeClass:表单显示时附加的类名,默认medium-editor-toolbar-form-active;hasForm:设为true时,工具栏创建时会调用getForm()并将返回的表单追加到工具栏容器内;getForm():返回将被追加到工具栏的表单 DOM 元素(默认隐藏);isDisplayed()/showForm()/hideForm():表单显隐控制;showToolbarDefaultActions()/hideToolbarDefaultActions():隐藏表单时恢复/隐藏工具栏默认按钮组;setToolbarPosition():根据工具栏内容与选区位置更新工具栏尺寸和位置。
Extension 接口:核心生命周期方法
以下方法是 Medium Editor 在内部与扩展交互时会调用/使用的契约。它们的默认实现与文档注释均可在 src/js/extension.js 中找到。
name(string)
扩展的唯一标识,用于MediumEditor.getExtensionByName(name)获取扩展实例。若未定义,Medium Editor 会将其设置为extensions选项中传入时的键名。
var MyExtension = MediumEditor.Extension.extend({ name: 'myextension' }); var myExt = new MyExtension(); var editor = new MediumEditor('.editor', { extensions: { 'myextension': myExt } }); editor.getExtensionByName('myextension') === myExt; // trueinit()
在 Medium Editor 初始化期间被调用。调用时.base属性(当前 MediumEditor 实例引用)已被设置,所有辅助方法也已就绪。源码中其默认实现为空函数(见 src/js/extension.js)。
checkState(node)
若实现该方法,每当编辑器与工具栏状态更新后,它会被调用一次或多次。状态更新时编辑器执行以下流程:
- 找到包含当前选区的父节点;
- 对该节点调用每个扩展的
checkState(node); - 取上一个节点的父节点;
- 重复步骤 2、3,直到移出父级 contenteditable。
参数:node(Node)——选区变化时,位于选区祖先链上、当前被检查的节点。
官方示例:根据选区是否位于带自定义data-edited属性的元素内,为编辑器元素添加/移除 CSS 类:
var EditedExtension = MediumEditor.Extension.extend({ name: 'edited', checkState: function (node) { // checkState 在一次选区变化中会被多次调用, // 因此只在找到属性时才保存值 if (!this.foundAttribute && node.getAttribute('data-edited')) { this.foundAttribute = true; } // 向上遍历到容器元素时,说明祖先链遍历完毕, // 此时可以添加/移除 css 类 if (MediumEditor.util.isMediumEditorElement(node)) { if (this.foundAttribute) { node.classList.add('edited-text'); } else { node.classList.remove('edited-text'); } // 确保该属性不会被持久化到下一次选区更新 delete this.foundAttribute; } } }); var editedExt = new EditedExtension(); var editor = new MediumEditor('.editor', { extensions: { 'edited': editedExt } });destroy()
在 Medium Editor 被销毁(调用MediumEditor.destroy())时被调用,用于移除创建的 HTML、自定义事件处理器或执行其他清理任务。
queryCommandState()
在编辑器/工具栏状态更新时对每个扩展调用一次。若返回非null值,扩展将不再参与 DOM 祖先链的爬升检查;若返回true且扩展定义了setActive(),Medium Editor 会调用setActive()。
返回:boolean或null。内置按钮扩展的默认实现是:仅当useQueryState为true时调用document.queryCommandState(action),否则返回null(见 src/js/extensions/button.js)。
getInteractionElements()
若扩展渲染了用户可交互的元素,应实现此方法并返回根元素或包含所有根元素的数组。Medium Editor 在交互时调用它判断用户点击是否发生在编辑器之外:这些元素会被用来检查点击目标是否为扩展元素的子孙节点,从而把对扩展元素的交互也视为对编辑器的交互,避免误触发blur。工具栏扩展正是通过返回整个工具栏元素来保证点击工具栏不会导致失焦(见 src/js/extensions/toolbar.js)。
isActive()
返回按钮是否已被设为"激活"。若返回true,该扩展/按钮将跳过激活状态检查;若返回false,isAlreadyApplied()仍会随祖先链爬升被逐一调用。返回:boolean。
isAlreadyApplied(node)
与checkState()类似,在状态变化后随 DOM 爬升被反复调用,用于判断扩展是否已应用于当前节点。
注意:若已实现checkState(),此方法不会被调用;若queryCommandState()已实现且返回非null,此方法也不会被调用。返回:boolean。
setActive()/setInactive()
setActive():当 Medium Editor 确认扩展当前已启用时调用(目前仅在状态更新且queryCommandState()或isAlreadyApplied(node)返回true时触发);setInactive():当扩展未应用于当前选区时调用,目前每次编辑器/工具栏状态变化开始时都会调用。之后 Medium Editor 会尝试通过checkState()或queryCommandState()、isAlreadyApplied(node)、isActive()、setActive()的组合来更新扩展状态。
工具栏在状态更新时的真实调用链可在 src/js/extensions/toolbar.js 中看到:setToolbarButtonStates()先对所有扩展调用setInactive(),随后checkActiveButtons()优先使用queryCommandState(),无法使用浏览器原生查询的扩展则加入manualStateChecks,沿选区父节点逐级向上调用isAlreadyApplied()。
Extension Helpers:内置辅助工具
以下辅助属性/方法由 Medium Editor 在初始化时设置,或直接路由到 MediumEditor 实例。
| Helper | 类型 | 说明 |
|---|---|---|
base | MediumEditor | 当前 MediumEditor 实例引用,如this.base.saveSelection()保存选区 |
window | Window | 内容窗口引用,对应contentWindow选项,如this.window.innerWidth |
document | Document | 所属文档引用,对应ownerDocument选项,如this.document.createElement('button') |
getEditorElements() | 方法 | 返回本实例监视的元素数组(Array<HTMLElement>),底层即this.base.elements |
getEditorId() | 方法 | 返回本 MediumEditor 实例的唯一数字标识,底层即this.base.id |
getEditorOption(option) | 方法 | 返回初始化 MediumEditor 时使用的某个选项值,底层即this.base.options[option] |
文档中各 Helper 的源码级实现均可在 src/js/extension.js 中找到。
getEditorElements()的官方示例——Placeholder 扩展的destroy()方法,为所有编辑元素移除占位属性:
MediumEditor.extensions.placeholder = MediumEditor.Extension.extend({ // ... destroy: function () { this.getEditorElements().forEach(function (el) { if (el.getAttribute('data-placeholder') === this.text) { el.removeAttribute('data-placeholder'); } }, this); }, // ... });getEditorOption()的官方示例——Anchor 扩展根据buttonLabels选项决定表单保存按钮的显示(fontawesome图标或默认文本):
MediumEditor.extensions.anchor = MediumEditor.extensions.form.extend({ // ... getTemplate: function () { var template = [ '<input type="text" class="medium-editor-toolbar-input" placeholder="', this.placeholderText, '">' ]; template.push( '<a href="#" class="medium-editor-toolbar-save">', this.getEditorOption('buttonLabels') === 'fontawesome' ? '<i class="fa fa-check"></i>' : this.formSaveLabel, '</a>' ); // ... }, // ... });Extension Proxy Methods:直通 MediumEditor 实例
以下方法是对既有 MediumEditor 函数的直接代理调用。它们的实现方式是在Extension.prototype上为每个方法名生成转发函数(见 src/js/extension.js):
['execAction', 'on', 'off', 'subscribe', 'trigger'].forEach(function (helper) { Extension.prototype[helper] = function () { return this.base[helper].apply(this.base, arguments); }; });| 方法 | 对应 MediumEditor 方法 | 典型用途 |
|---|---|---|
execAction(action, opts) | MediumEditor.execAction() | 执行命令,如this.execAction('bold') |
on(target, event, listener, useCapture) | MediumEditor.on() | 绑定 DOM 事件,销毁时自动解绑 |
off(target, event, listener, useCapture) | MediumEditor.off() | 解绑 DOM 事件 |
subscribe(name, listener) | MediumEditor.subscribe() | 订阅自定义事件 |
trigger(name, data, editable) | MediumEditor.trigger() | 触发自定义事件 |
官方示例——Anchor Preview 扩展用on()为链接绑定mouseout(见 src/js/extensions/anchor-preview.js):
handleEditableMouseover: function (event) { // ... this.instanceHandleAnchorMouseout = this.handleAnchorMouseout.bind(this); this.on(this.anchorToPreview, 'mouseout', this.instanceHandleAnchorMouseout); // ... }官方示例——Keyboard Commands 扩展在init()中订阅editableKeydown自定义事件(见 src/js/extensions/keyboard-commands.js):
init: function () { MediumEditor.Extension.prototype.init.apply(this, arguments); this.subscribe('editableKeydown', this.handleKeydown.bind(this)); // ... }官方示例——Toolbar 扩展隐藏工具栏时触发hideToolbar自定义事件:
hideToolbar: function () { if (this.isDisplayed()) { this.getToolbarElement().classList.remove('medium-editor-toolbar-active'); this.trigger('hideToolbar', {}, this.base.getFocusedElement()); } }Button 接口与配置项详解
getButton()
唯一将扩展定义为Button Extension的方法。只要扩展名出现在toolbar.buttons选项中且实现了getButton(),工具栏就会按toolbar.buttons中指定的顺序依次调用各按钮的getButton(),并将返回的HTMLElement追加到工具栏(见 src/js/extensions/toolbar.js)。
Button 配置项(可复用/可覆写)
以下属性均来自内置按钮扩展实现MediumEditor.extensions.button(src/js/extensions/button.js),自定义按钮可直接继承并覆写:
| 属性 | 类型 | 说明 |
|---|---|---|
action | string | 点击时传给MediumEditor.execAction()的动作参数,同时写入按钮的data-action属性 |
aria | string | 同时作为按钮的aria-label与title属性值 |
tagNames | Array | 表示按钮已应用的元素标签名数组,命中则按钮显示"激活";useQueryState为true时不生效 |
style | Object | 表示按钮已应用的 CSS 属性与值对:prop为属性名,value为属性值(多值用\|分隔);useQueryState为true时不生效 |
useQueryState | boolean | 是否用document.queryCommandState()判断动作是否已应用(如queryCommandState('bold')) |
contentDefault | string | 按钮默认 innerHTML |
contentFA | string | buttonLabels选项为'fontawesome'时使用的 innerHTML |
classList | Array | 要添加到按钮的类名数组 |
attrs | Object | 要添加到按钮的自定义属性键值对 |
handleClick(event) | function | 按钮点击事件监听器,默认实现调用this.execAction(action) |
源码中createButton()的完整构建逻辑见 src/js/extensions/button.js:它会添加medium-editor-action与medium-editor-action-{name}类、写入data-action、设置 title/aria-label、合并自定义classList与attrs,并根据buttonLabels选择contentFA或contentDefault。
各配置项的官方示例——定义名为custom-button-extension的按钮扩展:
var CustomButtonExtension = MediumEditor.extensions.button.extend({ name: 'custom-button-extension', action: 'bold', // 点击执行 execAction('bold') aria: 'bold text', // aria-label 与 title 均为 'bold text' useQueryState: false, // 不使用浏览器原生状态查询 tagNames: ['b', 'strong'], // 选区位于 <b>/<strong> 内时按钮激活 style: { // 或 font-weight 为 700/bold 时按钮激活 prop: 'font-weight', value: '700|bold' }, contentDefault: '<b>H</b>', // 默认内容 contentFA: '<i class="fa fa-paint-brush"></i>', // fontawesome 内容 classList: ['custom-button', 'custom-extension'], // 追加类名 attrs: { 'data-is-custom': 'true' }, // 追加自定义属性 handleClick: function (event) { var action = prompt('Please enter an action', 'bold'); if (action) { this.execAction(action); } } });实战一:构建通用扩展(DisableContextMenuExtension)
官方 Walkthrough(src/js/extensions/WALKTHROUGH-EXTENSION.md)演示了如何构建一个禁用右键菜单的扩展,完整示例位于 demo/extension-example.html,可在浏览器中通过file://[Medium Editor Source Root]/demo/extension-example.html加载体验。
1. 定义扩展
调用MediumEditor.Extension.extend()并传入要覆写的方法/属性:
var DisableContextMenuExtension = MediumEditor.Extension.extend({ name: 'disable-context-menu' }); var editor = new MediumEditor('.editable', { extensions: { 'disable-context-menu': new DisableContextMenuExtension() } });2. 绑定 contextmenu 事件
实现init()方法(每个扩展在 Medium Editor 初始化时都会被调用),遍历所有编辑元素绑定contextmenu:
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) { } });这里用到了三个 Helper:getEditorElements()获取编辑器维护的所有元素数组;base引用 MediumEditor 实例;base.on()保证事件处理器在编辑器销毁时自动解绑。文档还特别提示:on()也有直通代理方法,可直接写作this.on(element, 'contextmenu', ...)。
3. 添加功能
阻止默认行为即可禁用右键菜单:
handleContextmenu: function (event) { event.preventDefault(); }4. 结合自定义事件实现开关
需求升级:用户按 ESCAPE 时,对特定元素切换禁用/启用。实现要点:
- 监听每个元素上的
keydown,可复用内置的editableKeydown自定义事件,利用事件监听器的第二个参数(当前活动的编辑元素)切换data-allow-context-menu属性; contextmenu触发时,仅当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) { // 用户按 ESCAPE 时切换>var HighlighterButton = MediumEditor.Extension.extend({ name: 'highlighter' }); var editor = new MediumEditor('.editable', { toolbar: { buttons: ['bold', 'italic', 'underline', 'highlighter'] }, extensions: { 'highlighter': new HighlighterButton() } });注意:要让工具栏寻找并添加按钮,扩展名必须出现在toolbar.buttons数组中。
2. 创建并显示按钮
实现init()创建按钮元素、getButton()作为按钮访问器。工具栏会在扩展创建完成后遍历toolbar.buttons,对每个名称取出扩展并检查是否实现了getButton(),若实现则将返回的元素追加到工具栏:
var HighlighterButton = MediumEditor.Extension.extend({ name: 'highlighter', init: function () { this.button = this.document.createElement('button'); this.button.classList.add('medium-editor-action'); this.button.innerHTML = '<b>H</b>'; }, getButton: function () { return this.button; } });运行后选中文本,工具栏会出现 Bold、Italic、Underline 与自定义 Highlighter 四个按钮。
3. 美化外观(fontawesome 图标 + 提示)
var HighlighterButton = MediumEditor.Extension.extend({ name: 'highlighter', init: function () { this.button = this.document.createElement('button'); this.button.classList.add('medium-editor-action'); this.button.innerHTML = '<i class="fa fa-paint-brush"></i>'; this.button.title = 'Highlight'; }, getButton: function () { return this.button; } }); var editor = new MediumEditor('.editable', { toolbar: { buttons: ['bold', 'italic', 'underline', 'highlighter'] }, buttonLabels: 'fontawesome', // 为其他按钮启用 font-awesome 图标 extensions: { 'highlighter': new HighlighterButton() } });4. 处理点击:借助 rangy 实现高亮
使用开源库 rangy 的 CSS Class Applier 模块包裹选区。init()中创建 Class Applier(生成带highlight类的<mark>元素),通过this.on()绑定点击事件,点击时调用toggleSelection():
rangy.init(); var HighlighterButton = MediumEditor.Extension.extend({ name: 'highlighter', init: function () { this.classApplier = rangy.createClassApplier('highlight', { elementTagName: 'mark', normalize: true }); this.button = this.document.createElement('button'); this.button.classList.add('medium-editor-action'); this.button.innerHTML = '<i class="fa fa-paint-brush"></i>'; this.button.title = 'Highlight'; this.on(this.button, 'click', this.handleClick.bind(this)); }, getButton: function () { return this.button; }, handleClick: function (event) { this.classApplier.toggleSelection(); // 通知编辑器 html 可能已变化,保证外部依赖(如 <textarea> 同步)感知 this.base.checkContentChanged(); } });两个关键点:
toggleSelection()的便利之处在于它会自动取消包裹——再次点击同一选中区域时<mark>元素会被移除;this.base.checkContentChanged()直接调用核心编辑器通知内容变化。当传入<textarea>作为编辑元素时,编辑器依赖editableInput事件保持 textarea 与生成的<div>同步。
5. 响应选区:激活/未激活状态
实现 4 个扩展方法,让按钮外观随选区变化:
isAlreadyApplied: function (node) { return node.nodeName.toLowerCase() === 'mark'; }, isActive: function () { return this.button.classList.contains('medium-editor-button-active'); }, setInactive: function () { this.button.classList.remove('medium-editor-button-active'); }, setActive: function () { this.button.classList.add('medium-editor-button-active'); }isAlreadyApplied(node):随选区父节点祖先链逐级调用,任一节点是<mark>即返回true(选区已高亮);isActive():返回按钮当前是否已激活(检查medium-editor-button-active类);setActive()/setInactive():添加/移除激活类。
6. 复用内置按钮代码(推荐)
大量内置按钮逻辑是重复的,因此更优做法是继承MediumEditor.extensions.button(实现见 src/js/extensions/button.js),代码量大幅缩减:
rangy.init(); var HighlighterButton = MediumEditor.extensions.button.extend({ name: 'highlighter', tagNames: ['mark'], // isAlreadyApplied() 命中这些 nodeName 时按钮激活 contentDefault: '<b>H</b>', // 按钮默认 innerHTML contentFA: '<i class="fa fa-paint-brush"></i>', // fontawesome 模式下的 innerHTML aria: 'Highlight', // 同时作为 aria-label 与 title action: 'highlight', // 用作按钮的>赞- 前端
- UI组件
【免费下载链接】medium-editor
Medium.com WYSIWYG editor clone. Uses contenteditable API to implement a rich text solution.
相关推荐
KubeSphere frontend-forge 扩展全生命周期管理:InstallPlan 与 Extension 操作权威指南
KubeSphere frontend forge 扩展全生命周期管理:InstallPlan 与 Extension 操作权威指南 导读 frontend f
云原生容器编排后端微服务多集群DevOps可观测性AI 技能OHIF Viewers 扩展系统完全指南:从 Extension 骨架、插件注册到模块与生命周期钩子
OHIF Viewers 扩展系统完全指南:从 Extension 骨架、插件注册到模块与生命周期钩子 OHIF v3 将扩展系统重新设计为「扩展(Extens
医疗健康前端音视频Elementor Editor V2 包体系深度指南:微前端架构、`init()` 生命周期与扩展实战
Elementor Editor V2 包体系深度指南:微前端架构、 init 生命周期与扩展实战 Elementor 的 Editor V2 是一套以 Rea
CMS前端后端低代码