这篇文章将以插入图片为例,介绍如何在 CKEditor5 中插入块级元素,以及在块级元素上添加工具栏
最终的效果如下:
一、定义 Schema 和 Conversion
和之前的加粗插件、超链接插件不同,图片在编辑器中是以块级元素呈现的
所以在定义 Schema 的时候需要设置 isObject 以及 isBlock,从而得到这样的 Schema:
_defineSchema() { const schema = this.editor.model.schema; // SCHEMA_NAME__IMAGE --> "image"
schema.register(SCHEMA_NAME__IMAGE, { isObject: true, isBlock: true, allowWhere: "$block", allowAttributes: ["src", "title"], }); }
然后在定义转换器 Conversion 的时候,需要使用 toWidget 将图片元素包装起来,所以得区分 editingDowncast 与 dataDowncast:
_defineConverters() { const conversion = this.editor.conversion; // SCHEMA_NAME__IMAGE --> "image"
conversion.for("editingDowncast").elementToElement({ model: SCHEMA_NAME__IMAGE, view: (element, { writer }) => { const widgetElement = createImageViewElement(element, writer); // 添加自定义属性,以判断是否为 Image Model // CUSTOM_PROPERTY__IMAGE --> "is-image"
writer.setCustomProperty(CUSTOM_PROPERTY__IMAGE, true, widgetElement); return toWidget(widgetElement, writer); }, }); conversion.for("dataDowncast").elementToElement({ model: SCHEMA_NAME__IMAGE, view: (element, { writer }) => createImageViewElement(element, writer), }); conversion.for("upcast").elementToElement({ view: { name: "figure", classes: IMAGE_CLASS, }, model: createImageModel, }); }
// 先忽略创建 Model 和 View 的具体方法 createImageModel、createImageViewElement
这里的 editingDowncast 使用了 toWidget,给编辑器里的图片元素添加了一个不可编辑的父元素,这样保证了整个图片元素被视为一个整体
而在 dataDowncast 里面没有使用 toWidget,最终导出的结果就不会有额外的元素
从目前的设计来看,最终 View 和 Model 的转换结果是这样的:
<!-- Model -->
<image src="$url" title="$title"></image>
<!-- View -->
<figure>
<img src="$url" title="$title">
</figure>
需要注意的是,由于使用了 toWidget,所以需要在 requires 中添加 Widget:
// editing.js
import Plugin from "@ckeditor/ckeditor5-core/src/plugin"; import Widget from '@ckeditor/ckeditor5-widget/src/widget'; export default class ImageEditing extends Plugin { static get requires() { return [Widget]; } static get pluginName() { return "ImageEditing"; } // ...
}
二、创建 Model 和 View
上面的 Conversion 只是列举了上行和下行的转换逻辑,接下来完善具体创建 Model 和 View 的方法
首先是根据 Model 创建图片 View:
// 根据 Model 创建图片 View
export function createImageViewElement(element, writer) {
// 使用 createContainerElement 创建容器元素
const figure = writer.createContainerElement("figure", { class: IMAGE_CLASS, }); // 使用 createEmptyElement 创建 img 标签,并设置属性
const imageElement = writer.createEmptyElement("img"); ["src", "title"].map((k) => { writer.setAttribute(k, element.getAttribute(k), imageElement); }); // 将 img 作为子节点插入到 figure
writer.insert(writer.createPositionAt(figure, 0), imageElement); return figure; }
然后是根据 View 创建图片 Model,通过 upcast 转换器能够获取到这样的 View:
<!-- View -->
<figure>
<img src="$url" title="$title">
</figure>
然后通过操作 DOM 的方法获取到 <img> 上的 src 和 title,并作为属性传给创建的 Schema:
// 根据 View 创建图片 Model
export function createImageModel(view, { writer }) { const params = {}; const imageInner = view.getChild(0); ["src", "title"].map((k) => { params[k] = imageInner.getAttribute(k); }); return writer.createElement(SCHEMA_NAME__IMAGE, params); }
三、添加自定义配置
对于图片元素,在实际应用场景中很可能需要添加一些自定义配置,比如自定义 class
CKEditor 5 提供了 EditorConfig 用来添加用户的自定义配置
首先在 editing.js 的构造函数 constructor 中声明一个默认值,并通过 get 方法获取:
constructor(editor) { super(editor); // 配置 IMAGE_CONFIG 的缺省值 // IMAGE_CONFIG --> "IMAGE_CONFIG"
editor.config.define(IMAGE_CONFIG, {}); // 通过 get 方法获取实际传入的配置
this.imageConfig = editor.config.get(IMAGE_CONFIG); }
然后在使用 create 创建 editor 的时候,传入对应的配置项,就能在 this.imageConfig 中获取到用户的配置信息了
四、插入图片
上一篇文章《CKEditor 5 摸爬滚打(四)—— 开发带有弹窗表单的超链接插件》已经介绍了弹窗表单的开发
这里就不再细讲 toolbar-ui.js、image-form.js 的详细代码,只提一下 command.js 中关于图片的插入
// command.js
import Command from "@ckeditor/ckeditor5-core/src/command"; import { insertImage } from "./util"; export default class LinkCommand extends Command { refresh() { const model = this.editor.model; const selectedContent = model.getSelectedContent(model.document.selection); this.isEnabled = selectedContent.isEmpty; } execute(data) { const model = this.editor.model; insertImage(model, data); } }
触发命令的时候会将图片元素的参数 { src, title } 传过来,然后通过 insertImage 方法插入图片
export function insertImage(model, attributes = {}) { if (!attributes || !attributes.src) { return; } model.change((writer) => { const imageElement = writer.createElement(SCHEMA_NAME__IMAGE, attributes); // 使用 findOptimalInsertionPosition 方法来获取最佳位置 // 如果某个选择位于段落的中间,则将返回该段落之前的位置,不拆分当前段落 // 如果选择位于段落的末尾,则将返回该段落之后的位置
const insertAtSelection = findOptimalInsertionPosition( model.document.selection, model ); model.insertContent(imageElement, insertAtSelection); }); }
和之前介绍的插件的区别在于,对于编辑器中的块级元素,如果直接使用 model.insertContent 插入元素,会截断当前行的内容
而 CK5 提供的工具方法 findOptimalInsertionPosition 可以返回一个合适的位置,用于插入块级元素
五、编辑图片
CKEditor 5 为 Widget 提供了悬浮工具栏的构造函数 WidgetToolbarRepository
通过这个组件可以在 Widget 上创建一个悬浮工具栏,但工具栏上的工具按钮需要另外定义
// ./widget-toolbar/toolbar.js
import Plugin from "@ckeditor/ckeditor5-core/src/plugin"; import WidgetToolbarRepository from "@ckeditor/ckeditor5-widget/src/widgettoolbarrepository"; import { getSelectedImageWidget } from '../util'; import ImageEdit from "./edit/main"; import { WIDGET_TOOLBAR_NAME__IMAGE, } from "../constant"; export default class ImageWidgetToolbar extends Plugin { static get requires() { return [WidgetToolbarRepository, ImageEdit]; } static get pluginName() { return "ImageToolbar"; } afterInit() { const editor = this.editor; const widgetToolbarRepository = editor.plugins.get(WidgetToolbarRepository); // WIDGET_TOOLBAR_NAME__IMAGE --> "ck-image-toolbar"
widgetToolbarRepository.register(WIDGET_TOOLBAR_NAME__IMAGE, { ariaLabel: "图片工具栏", items: [ImageEdit.pluginName], getRelatedElement: getSelectedImageWidget, }); } }
这是工具栏的入口文件 toolbar.js,需要在 plugin-image 组件的入口文件 main.js 中作为 requires 引入
在通过 register 注册工具栏的时候,第二个参数是工具栏配置项,其中的 items 是一个由工具名组成的数组,类似于创建编辑器时的 toolbar 配置项
需要注意的是 getRelatedElement,用来判断是否选中的对应的 widget 元素,换句话说就是判断是否需要显示工具栏
export function getSelectedImageWidget(selection) { const viewElement = selection.getSelectedElement(); if (viewElement && isImageWidget(viewElement)) { return viewElement; } return null; } export function isImageWidget(viewElement) { return ( !!viewElement && viewElement.getCustomProperty(CUSTOM_PROPERTY__IMAGE) && isWidget(viewElement) ); }
另外 toolbar.js 中引入了一个 ImageEdit 工具,也就是“编辑图片”的功能主体
这个 ImageEdit 和普通的插件并无二致,也需要 editing.js、command.js、toolbar-ui.js
也就是说,图片编辑这个功能其实本身也是一个插件,只是这个插件的按钮图标没有放到编辑器的工具栏,而是在图片元素的悬浮工具栏上展示
这里的 toolbar-ui.js 就不再介绍,和其他插件的 toolbar-ui.js 一样,只是定义了工具栏按钮的样式
先说一下 command.js 的基本逻辑
// ./widget-toolbar/edit/command.js
import Command from "@ckeditor/ckeditor5-core/src/command"; import { COMMAND_NAME__IMAGE } from "../../constant"; import ImageForm from "../../form/image-form"; export default class ImageEditCommand extends Command { constructor(editor) { super(editor); } refresh() { const element = this.editor.model.document.selection.getSelectedElement(); this.isEnabled = !!element && element.is("element", COMMAND_NAME__IMAGE); } execute() { const model = this.editor.model; const viewElement = model.document.selection.getSelectedElement(); const attributes = viewElement.getAttributes(); // 获取当前图片的参数
const initialValue = [...attributes].reduce( (obj, [key, value]) => ((obj[key] = value), obj), {} ); // 打开弹窗,编辑图片信息
this.$form = new ImageForm({ initialValue, onSubmit: this._handleEditImage.bind(this), }); } }
然后对于修改图片这个核心功能 _handleEditImage 有两种思路:
1. 删除原有图片,在原位置重新插入一个新的图片
2. 监听属性的修改,在属性改变后更新视图
这两种思路的区别在于:
方案一(删除后插入)比较暴力,相对来说性能较差,但很实用,也不容易出错
方案二(修改属性)需要对每一个有可能更改的属性进行监听,如果可修改的属性较多,反而不如方案一
在此基础上,接下来就介绍这两种方案的具体实现:
方案一、删除后插入新图片
Model 的 writer 提供了 remove 方法,可以删除一个 ModelElement 或者 Rang
上面“插入图片”小节中封装了一个 insertImage 方法,可以直接调用
所以最终的 _handleEditImage 就很简单:
_handleEditImage(data) { const model = this.editor.model; const imageElement = model.document.selection.getSelectedElement(); model.change((writer) => { writer.remove(imageElement); insertImage(model, data) }); }
最后只需要完善 ./widget-toolbar/edit/editing.js,编辑功能就完成了
// editing.js
import Plugin from "@ckeditor/ckeditor5-core/src/plugin"; import ImageEditCommand from "./command"; import { COMMAND_NAME__IMAGE_EDIT } from "../../constant"; export default class ImageEditEditing extends Plugin { init() { const editor = this.editor; const command = new ImageEditCommand(editor); editor.commands.add(COMMAND_NAME__IMAGE_EDIT, command); } }
方案二、属性修改后更新视图
这种方案的 _handleEditImage 只需要修改对应的属性:
_handleEditImage(data) { const model = this.editor.model; const imageElement = model.document.selection.getSelectedElement(); model.change((writer) => { ["src", "title"].forEach(key => { writer.setAttribute(key, data[key], imageElement); }) }); }
但在 editing.js 中需要通过 downcastDispatcher 监听对应的属性
// editing.js
import Plugin from "@ckeditor/ckeditor5-core/src/plugin"; import ImageEditCommand from "./command"; import { COMMAND_NAME__IMAGE_EDIT } from "../../constant"; export default class ImageEditEditing extends Plugin { init() { const editor = this.editor; const data = editor.data; const editing = editor.editing; // 监听 src 和 title 属性的变更,需要从 editing 和 data 中获取 downcastDispatcher
editing.downcastDispatcher.on( "attribute:src:image", modelToViewConverter("src") ); data.downcastDispatcher.on( "attribute:src:image", modelToViewConverter("src") ); editing.downcastDispatcher.on( "attribute:title:image", modelToViewConverter("title") ); data.downcastDispatcher.on( "attribute:title:image", modelToViewConverter("title") ); const command = new ImageEditCommand(editor); editor.commands.add(COMMAND_NAME__IMAGE_EDIT, command); } } function modelToViewConverter(attr) { return (evt, data, conversionApi) => { // CK5 会将属性的更改状态保存为 consumable,用于校验该变化是否已经完成
if (!conversionApi.consumable.consume(data.item, evt.name)) { return; } const viewElement = conversionApi.mapper.toViewElement(data.item); const viewWriter = conversionApi.writer; const imageInner = viewElement.getChild(0); // 修改视图中对应的属性
viewWriter.setAttribute( attr, data.attributeNewValue, imageInner ); // 阻止事件冒泡
evt.stop(); }; }
到此为止的五篇《CKEditor 5 摸爬滚打》 介绍了 CK5 中常见的开发方式,已经能开发大部分的编辑器组件
但整个 CK5 的架构太过繁琐,还有很多工具函数和细节没有涉及到
如果在开发的过程中仍然存在问题,建议多挖一挖官方文档,或者结合 CKEditor 5 的官方插件源码,看有没有新的思路