CKEditor 5 摸爬滚打(五)—— 图片的插入与编辑
这篇文章将以插入图片为例,介绍如何在 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 的官方插件源码,看有没有新的思路
演示项目源码:https://github.com/wisewrong/bolg-demo-app/tree/main/ck5-demo-app