CKEditor 5 自定义 UI 实战:用 Bootstrap 构建完全独立的编辑器界面
2026/9/17 8:27:05 网站建设 项目流程

CKEditor 5 自定义 UI 实战:用 Bootstrap 构建完全独立的编辑器界面

【免费下载链接】ckeditor5Powerful rich text editor framework with a modular architecture, modern integrations, and features like collaborative editing.项目地址: https://gitcode.com/GitHub_Trending/ck/ckeditor5

CKEditor 5 的 UI 层与编辑引擎在设计上是彻底解耦的,你可以完全抛开官方内置的主题与工具栏,在现有页面中用 Bootstrap(或其他任意 UI 框架)自行搭建一整套工具栏、按钮与下拉菜单,只把"编辑"这件事交给 CKEditor 5 的引擎。本文以仓库中packages/ckeditor5-ui/docs/examples/custom-ui.md所展示的 Bootstrap 自定义 UI 示例为主线,结合配套的完整源码与框架层实现,逐行讲解如何从零构建一个使用纯 Bootstrap 组件、却具备加粗、斜体、下划线、撤销/重做与多级标题等真实编辑能力的编辑器,读完你将掌握自定义 Editor 子类、实现EditorUI接口、绑定命令状态到任意 DOM 控件的完整方法。

示例概览:UI 是 Bootstrap 的,编辑是 CKEditor 5 的

示例的核心思路一句话概括:界面骨架全部用 Bootstrap 4 的btn-toolbarbtn-groupdropdown等组件在 HTML 中写好,CKEditor 5 只负责提供可编辑区域与文档模型(model/view)引擎。最终渲染出的编辑器包含:

  • 一个 "Headings" 下拉菜单(Bootstrap dropdown),可切换段落与三级标题;
  • B / I / U 三个基础样式按钮(Bold、Italic、Underline);
  • 撤销 / 重做两个方向箭头按钮;
  • 一个可编辑内容区域(WYSIWYG editable)。

整个 UI 的源码可在 bootstrap-ui.html(界面结构与样式)与 bootstrap-ui.js(编辑器与 UI 逻辑)中查看,完整的图文分步教程位于 external-ui.md。

需要特别说明的是:这种"外置 UI"方案对功能集的选择是有意识的裁剪——例如导入BoldEditing而非BoldBold插件会同时加载默认的加粗按钮 UI 与编辑特性,而BoldEditing只提供引擎层的加粗能力,不带任何界面,这正是把界面完全让渡给 Bootstrap 的前提。

第一步:导入最小化的功能集

在 bootstrap-ui.js 中,示例从ckeditor5统一入口导入所需模块,可分成四类:

创建编辑器的基类与工具

import { Editor, // 编辑器基类,提供模型、命令、插件系统等基础 API EditorUI, // UI 层基类,自定义 UI 必须继承它 EditorUIView, // UI 视图基类,聚合可编辑视图与各类视图元素 InlineEditableUIView, // 行内可编辑视图,负责渲染编辑区域 ElementReplacer, // 工具类:替换 DOM 元素且可恢复 CKEditorError // 错误类,用于抛出带 code 的 CKEditor 错误 } from 'ckeditor5';

扩展编辑器 API 的辅助函数

import { ElementApiMixin, // 为编辑器混入 updateSourceElement/getData 等元素相关 API attachToForm, // 编辑器位于 <form> 内时,提交时自动同步内容到源元素 getDataFromElement // 从源 HTML 元素中读取初始数据 } from 'ckeditor5';

每个编辑器都必备的基础特性

import { Clipboard, // 剪贴板(复制/粘贴/拖放) Enter, // 回车换行 Paragraph, // 段落块 Typing, // 输入处理 UndoEditing // 撤销/重做(仅引擎层) } from 'ckeditor5';

与编辑内容相关的特性(只取 Editing 层)

import { BoldEditing, ItalicEditing, UnderlineEditing, HeadingEditing, } from 'ckeditor5';

注意这里刻意使用了各特性的*Editing变体(BoldEditingItalicEditingUnderlineEditingHeadingEditingUndoEditing),它们只提供引擎层的编辑能力而不附带默认 UI,这正是复用现有 Bootstrap 界面的关键——如果不小心导入了带 UI 的完整插件(如Bold),官方默认的按钮样式会一并加载,破坏"完全自定义"的初衷。

第二步:定义 BootstrapEditor —— 继承 Editor 基类

自定义 UI 的第一步是定义一个自己的编辑器类,继承Editor(并通过ElementApiMixin混入元素相关 API)。完整实现见 bootstrap-ui.js:

export default class BootstrapEditor extends ElementApiMixin( Editor ) { constructor( config ) { super( config ); // 本示例约定:可编辑区域的宿主元素放在 config.root.element 中。 const sourceElement = config.root?.element; if ( !sourceElement ) { throw new CKEditorError( 'bootstrap-editor-missing-root-element', null ); } // 记住编辑器创建时所依附的源元素。 this.sourceElement = sourceElement; // 在模型文档树中创建 "main" 根节点。 this.model.document.createRoot(); // 设置编辑器的 UI 层(自定义的 BootstrapEditorUI)。 this.ui = new BootstrapEditorUI( this ); // 若源元素是表单内的 textarea,提交表单时自动把内容写回该元素。 attachToForm( this ); } destroy() { // 销毁时先把 editor#getData() 的输出写回源元素…… this.updateSourceElement(); // ……再销毁 UI。 this.ui.destroy(); return super.destroy(); } static create( config ) { return new Promise( resolve => { const editor = new this( config ); const replacementElement = editor.sourceElement; resolve( editor.initPlugins() // 1. 初始化插件 .then( () => editor.ui.init( replacementElement ) ) // 2. 先初始化 UI .then( () => editor.data.init( getDataFromElement( replacementElement ) ) ) // 3. 载入初始数据 .then( () => editor.fire( 'ready' ) ) // 4. 触发 ready 事件 .then( () => editor ) ); } ); } }

几个值得注意的设计点:

  • config.root.element是唯一入口:示例约定从配置中读取可编辑宿主元素,若缺失直接抛出带 code 的CKEditorError,方便排查。这种自定义配置约定对任何"外置 UI"方案都适用。
  • initPlugins()之后才初始化 UI:创建流程的顺序很重要——先让插件系统就绪(命令都注册进editor.commands),UI 才能在init()阶段通过editor.commands.get( name )拿到命令。
  • fire( 'ready' )通知外部:编辑器完全就绪后触发ready事件,调用方据此拿到实例。

第三步:用 HTML + CSS 搭建 Bootstrap 工具栏

编辑器类就绪后,它只是一块"裸的可编辑区域",需要给它配上真正的界面。在页面中引入 Bootstrap 4(CSS 与 JS)与 jQuery 后,按照 bootstrap-ui.html 定义界面骨架:

<!-- 编辑器最外层容器。 --> <div class="ck-editor"> <!-- 工具栏。 --> <div class="btn-toolbar" role="toolbar" aria-label="Editor toolbar"> <!-- 标题下拉菜单。 --> <div class="btn-group mr-2" role="group" aria-label="Headings"> <div class="dropdown" id="heading"> <button class="btn btn-primary btn-sm dropdown-toggle" type="button" >class BootstrapEditorUI extends EditorUI { constructor( editor ) { super( editor ); // 用于把 editor#element 替换为 editor.editable#element,且可随时还原。 this._elementReplacer = new ElementReplacer(); // 全局 UI 视图,聚合各类 Bootstrap DOM 元素。 const view = this._view = new EditorUIView( editor.locale ); // 编辑器最外层 DOM 元素。 view.element = $( '.ck-editor' ); // 可编辑视图,将在 DOM 中替换数据容器。 view.editable = new InlineEditableUIView( editor.locale, editor.editing.view ); // 下拉菜单与切换按钮的引用(供 _setupBootstrapHeadingDropdown 使用)。 view.dropdownMenu = view.element.find( '.dropdown-menu' ); view.dropdownToggle = view.element.find( '.dropdown-toggle' ); // 工具栏按钮的引用(供 _setupBootstrapToolbarButtons 使用)。 view.toolbarButtons = {}; [ 'bold', 'italic', 'underline', 'undo', 'redo' ].forEach( name => { view.toolbarButtons[ name ] = view.element.find( `#${ name }` ); } ); } // 所有 EditorUI 子类都应暴露 view 实例,方便其他 UI 类访问。 get view() { return this._view; } // ... }

按 editoruiview.ts 的源码说明,EditorUIView是"编辑器主视图的基类",它维护了一个bodyBodyCollection)集合,用来承载脱离主 DOM 结构的浮动元素(如面板、气泡)。自定义 UI 里EditorUIView的作用是把可编辑视图与外部 DOM 元素聚合到同一个视图体系中,便于统一渲染与销毁。

init():让引擎与 UI 相遇

init( replacementElement )是整个绑定过程的枢纽(bootstrap-ui.js):

init( replacementElement ) { const editor = this.editor; const view = this.view; const editingView = editor.editing.view; // 渲染 UI 视图,为浮动面板等脱离主 DOM 的元素预留位置。 this._view.render(); // 在编辑层创建编辑根节点,与 constructor() 中创建的文档根节点对应。 const editingRoot = editingView.document.getRoot(); // 可编辑视图与编辑根节点必须同名。 view.editable.name = editingRoot.rootName; // 先把可编辑组件渲染进 DOM。 view.editable.render(); const editableElement = view.editable.element; // 注册可编辑元素,之后可通过 getEditableElement() 获取。 this.setEditableElement( view.editable.name, editableElement ); // 让可编辑元素跟随全局焦点追踪器(focus tracker)。 this.focusTracker.add( editableElement ); view.editable.bind( 'isFocused' ).to( this.focusTracker ); // 把 DOM 可编辑元素绑定到编辑视图 —— 引擎与 UI 在此交汇。 editingView.attachDomRoot( editableElement ); // 激活外部 Bootstrap 工具栏。 this._setupBootstrapToolbarButtons(); this._setupBootstrapHeadingDropdown(); // 用可编辑元素替换原数据容器。 this._elementReplacer.replace( replacementElement, editableElement ); // 通知外界:UI 已就绪。 this.fire( 'ready' ); }

其中editingView.attachDomRoot( editableElement )是最关键的一行——它把 DOM 中的可编辑元素注册为编辑视图的根节点,使输入事件、选区变化、渲染管线全部挂接上去,即"引擎在此与 UI 相遇"。

destroy():对称的清理

destroy() { super.destroy(); // 恢复被替换的原始 editor#element。 this._elementReplacer.restore(); // 销毁视图。 this._view.editable.destroy(); this._view.destroy(); }

ElementReplacer的语义在 elementreplacer.ts 中有清晰注释:它"以不删除原元素的方式隐藏已有元素或将其替换为新元素"。replace()会把原元素display: none,并把新元素插入其相邻位置;restore()则恢复原元素显示并移除替换元素——这正是销毁编辑器后页面 DOM 能完全还原的底层保证。

第五步:把工具栏按钮绑定到编辑器命令

"几乎每个编辑器特性都定义了自己的命令",例如HeadingCommandUndoCommand。命令可以随时执行:

editor.execute( 'undo' );

命令还带有可观察属性valueisEnabled,它们反映编辑器当前的真实状态,是自定义 UI 与编辑器交互的入口。在 external-ui.md 中给出了监听命令状态变化的示例:

const command = editor.commands.get( 'undo' ); command.on( 'change:isEnabled', ( evt, name, isEnabled ) => { if ( isEnabled ) { console.log( 'Whoa, you can undo some stuff now.' ); } else { console.log( 'There is nothing to undo in the editor.' ); } } );

基于这个机制,_setupBootstrapToolbarButtons()把 B / I / U / 撤销 / 重做五个按钮统一绑定(bootstrap-ui.js):

_setupBootstrapToolbarButtons() { const editor = this.editor; for ( const name in this.view.toolbarButtons ) { // 用按钮 DOM 的 id 找到对应命令。 const command = editor.commands.get( name ); const button = this.view.toolbarButtons[ name ]; // 点击按钮执行命令…… button.click( () => editor.execute( name ) ); // ……但 mousedown 要阻止默认行为,避免焦点被按钮抢走而打断编辑。 button.mousedown( evt => evt.preventDefault() ); const onValueChange = () => { button.toggleClass( 'active', command.value ); }; const onIsEnabledChange = () => { button.attr( 'disabled', () => !command.isEnabled ); }; // 命令可被禁用(如编辑器进入只读模式),按钮需同步反映。 command.on( 'change:isEnabled', onIsEnabledChange ); onIsEnabledChange(); // Bold / Italic / Underline 有 value 属性:当选区位于命令所创建的元素内时, // value 变为真值,按钮应高亮。undo / redo 没有 value,跳过。 if ( !new Set( [ 'undo', 'redo' ] ).has( name ) ) { command.on( 'change:value', onValueChange ); onValueChange(); } } }

这段代码体现了自定义 UI 的完整闭环:点击 →editor.execute()执行命令 → 文档变化 → 命令的value/isEnabled变化 → 通过change:事件回调更新按钮 CSS 类button.mousedown( evt => evt.preventDefault() )是一个易被忽略但极其重要的细节:它防止按钮抢走焦点,保证用户连续操作时编辑光标不丢失。

第六步:把标题下拉菜单绑定到 heading 命令

下拉菜单比普通按钮复杂:需要动态填充菜单项、点击执行对应命令、并让按钮文字与菜单项高亮跟随选区的标题状态。完整实现见 bootstrap-ui.js:

_setupBootstrapHeadingDropdown() { const editor = this.editor; const dropdownMenu = this.view.dropdownMenu; const dropdownToggle = this.view.dropdownToggle; // 取出 heading 与 paragraph 两个命令。 const headingCommand = editor.commands.get( 'heading' ); const paragraphCommand = editor.commands.get( 'paragraph' ); // 依据配置中的 heading.options 生成每个菜单项。 editor.config.get( 'heading.options' ).map( option => { // paragraph 与 heading 的取值方式不同,需要区分。 const isParagraph = option.model === 'paragraph'; // 创建菜单项 DOM。 const menuItem = $( `<a href="#" class="dropdown-item heading-item_${ option.model }">` + `${ option.title }` + '</a>' ); // 点击菜单项执行命令并把焦点还给编辑视图。 menuItem.click( () => { const commandName = isParagraph ? 'paragraph' : 'heading'; const commandValue = isParagraph ? undefined : { value: option.model }; editor.execute( commandName, commandValue ); editor.editing.view.focus(); } ); dropdownMenu.append( menuItem ); const command = isParagraph ? paragraphCommand : headingCommand; // 让菜单项与下拉按钮文字跟随命令状态。 const onValueChange = isParagraph ? onValueChangeParagraph : onValueChangeHeading; command.on( 'change:value', onValueChange ); onValueChange(); command.on( 'change:isEnabled', onIsEnabledChange ); onIsEnabledChange(); function onValueChangeHeading() { const isActive = !isParagraph && command.value === option.model; if ( isActive ) { dropdownToggle.children( ':first' ).text( option.title ); } menuItem.toggleClass( 'active', isActive ); } function onValueChangeParagraph() { if ( command.value ) { dropdownToggle.children( ':first' ).text( option.title ); } menuItem.toggleClass( 'active', command.value ); } function onIsEnabledChange() { dropdownToggle.attr( 'disabled', () => !command.isEnabled ); } } ); }

值得注意的细节:

  • 菜单项不是写死在 HTML 里,而是通过editor.config.get( 'heading.options' )从配置动态生成。这意味着修改heading.options配置(如增加四级标题)时,UI 会随之自动扩展,无需改动界面代码;
  • paragraph是特例:它对应paragraph命令,执行时不带{ value }参数,value本身为布尔值;而heading命令执行时需要{ value: option.model },命令的value保存当前标题级别;
  • 每次点击菜单项后显式调用editor.editing.view.focus(),把焦点还给编辑器,保持编辑流程不间断。

第七步:运行编辑器

最后,通过BootstrapEditor.create()启动编辑器(bootstrap-ui.js):

BootstrapEditor .create( { root: { element: $( '#editor' ).get( 0 ) // 可编辑宿主元素 }, plugins: [ Clipboard, Enter, Typing, Paragraph, Image, BoldEditing, ItalicEditing, UnderlineEditing, HeadingEditing, UndoEditing ] } ) .then( editor => { window.editor = editor; // 示例额外支持通过 postMessage 切换只读模式。 const readOnlyLock = Symbol( 'read-only-lock' ); let isReadOnly = false; window.addEventListener( 'message', event => { if ( event.data === 'toggle' ) { if ( isReadOnly ) { editor.disableReadOnlyMode( readOnlyLock ); } else { editor.enableReadOnlyMode( readOnlyLock ); } isReadOnly = !isReadOnly; editor.editing.view.focus(); } } ); } ) .catch( err => { console.error( err.stack ); } );

两个要点:

  1. root.element必须指向 HTML 中#editor容器,这是编辑器与既有 Bootstrap 界面建立关联的唯一契约;初始内容由getDataFromElement( replacementElement )从该元素读取,示例中即 "Custom UI" 那段带<h2><b>的 HTML。
  2. 示例额外演示了只读模式enableReadOnlyMode( readOnlyLock )/disableReadOnlyMode( readOnlyLock )配合一个Symbol作为锁标识,可由任意代码切换只读状态,配合 CSS 中.ck-editor__editable.ck-read-only的淡化样式呈现视觉反馈。只读模式下命令的isEnabled变为false,前面绑定的按钮与下拉菜单禁用逻辑会自动生效——这正是命令状态驱动的 UI 带来的"免费"联动。

源码佐证:这套方案依赖的框架层支撑

本文的方案并非黑魔法,而是建立在EditorUIEditorUIViewElementReplacer等框架层抽象之上:

  • editorui.ts 中定义EditorUI为"成功引导任何编辑器 UI 所需的最小接口":它提供focusTracker(统一管理焦点状态)、componentFactory(插件注册 UI 组件的工厂)、tooltipManager等基础设施,并声明抽象属性view与生命周期方法init()/destroy()。任何自定义 UI 类继承它并实现这些成员,即可接入整套框架生态;
  • editoruiview.ts 定义"编辑器主视图基类",其body: BodyCollection用于承载浮动面板等脱离主 DOM 的元素;BootstrapEditorUI正是用它聚合 Bootstrap DOM 与可编辑视图;
  • elementreplacer.ts 实现"隐藏/替换 DOM 元素且不破坏原 DOM"的语义,配套测试位于 elementreplacer.js,BootstrapEditorUIinit()/destroy()中借助它完成数据容器与可编辑元素的平滑互换与还原。

小结

从 custom-ui.md 的示例出发,一套完整的外置 UI 方案可以总结为四条要点:

  1. 只导入*Editing特性,不带默认 UI,把界面完全交给第三方框架;
  2. 继承EditorEditorUI,前者承载引擎与插件系统,后者承载 UI 生命周期(init/destroy/ready);
  3. 命令是 UI 与编辑器之间的唯一契约editor.execute( name )驱动动作,change:valuechange:isEnabled事件驱动界面状态同步;
  4. attachDomRoot()让引擎与 DOM 可编辑元素交汇ElementReplacer保证 DOM 可安全替换与还原。

掌握了这套模式,你可以把同样的思路迁移到 Vue、React、Angular 或任何自定义组件库:界面由你的框架渲染,编辑能力由 CKEditor 5 提供,两者通过命令与事件解耦。若需要更深入地扩展(例如接入官方组件工厂ComponentFactory与浮动面板体系),可以继续研读 external-ui.md 以及 editorui.ts 的完整 API 注释。

【免费下载链接】ckeditor5Powerful rich text editor framework with a modular architecture, modern integrations, and features like collaborative editing.项目地址: https://gitcode.com/GitHub_Trending/ck/ckeditor5

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

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

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

立即咨询