CKEditor 5 摸爬滚打(三)—— 自定义一个简单的加粗插件(下)
上一篇文章将加粗插件的架子给搭好了,现在就来完善具体的逻辑,主要的难点在于 model 和转换器 conversion
一、创建一个 Schema
在 CKEditor 5 中,编辑器实现了自己的一套运行时的编辑内容,即 model,可以打开调试器 CKEditorInspector 查看
然后编辑器引擎通过转换器 conversion 将 model 渲染成我们熟悉的 HTML
就拿最基础的段落插件 Paragraph 来说,它最终渲染出来的是一个 <p> 标签,但在 model 中的体现是 <paragraph>,而这 <paragraph> 就是一个 Schema
CKEditor 5 有三种基本的通用 Schema:$root,$block 和 $text,分别指代根节点、块元素、普通文本。
对于加粗插件,我们也需要先在 editing.js 中注册一个 Schema:
// 注册 schema
_defineSchema() {
const schema = this.editor.model.schema;
schema.register(SCHEMA_NAME__BOLD, {
isInline: true, // 是否为行内元素
isObject: true, // 是否为一个整体
allowWhere: "$text", // 允许在哪个 schema 插入
allowAttributes: ["value"], // 允许携带哪些属性
});
}
这里的 schema.register() 方法接收的第一个参数是模型名称,类型为字符串,也放到 constant.js 中单独维护
// constant.js
export const SCHEMA_NAME__BOLD = 'bold';
第二个参数是具体的配置项,完整的配置项可以参考官网 SchemaItemDefinition,常用的属性有:
1. allowIn: String | Array<String> 可以作为哪些 schema 的子节点;
2. allowWhere: String | Array<String> 从其他 schema 继承 allowIn;
3. allowAttributes: String | Array<String> 允许携带哪些属性;
4. isLimit: 设置为 true 时,元素内的所有操作只会修改内容,不会影响元素本身。也就是说该元素无法使用回车键拆分,无法在元素内部使用删除键删除该元素(如果把整个 Molde 理解为一张网页,Limit Element 就相当于 iframe);
5. isObject: 是否为一个完整对象,通常结合 Widget 使用(完整对象会被整体选中,无法使用回车拆分,无法直接编辑文本);
6. isBlock: 是否为块元素,类似 HTML 中的块元素;
7. isInline: 是否为行内元素。但对于 <a> <strong> 这些需要即时编辑的行内标签,在编辑器中以文本属性来区分,所以 isInline 只用于独立的元素,即 isObject 应设置为 true;
这里为了介绍 Schema,我使用了 isInline 来开发加粗插件,最终的呈现的效果会和平时使用的加粗功能有所区别,但不影响最终提交的数据
二、定义转换器 Conversion
对于上面定义的 Schema,我期望的 Model 是这样的:
<paragraph>
hello
<bold value="world"></bold>
</paragraph>
然后通过转换器渲染为:
<p>hello <strong>world</strong></p>
转换器分为单向转换器和双向转换器,常用的单向转换器,具体分为两类:
1. Upcast: 将 HTML 转换为 Model
2. Downcast: 将 Model 转换为 HTML,可细分为编辑时的转换 editingDowncast 和导出数据时的转换 dataDowncast
// 定义转换器
const conversion = this.editor.conversion;
conversion.for("editingDowncast").elementToElement();
conversion.for("dataDowncast").elementToElement();
conversion.for("upcast").elementToElement();
通过 this.editor.conversion.for() 来定义对应类型的转换器,详情参考官网 Conversion
不同类型的转换器,可配置的转换规则并不相同
首先是 downcast,它的可用转换方法有: elementToElement()、attributeToElement()、attributeToAttribute()、markerToElement()、markerToHighlight()
这些转换方法被称为 Helper,除了这些自带的 Helper 之外,还可以使用 add() 自定义 Helper,详情查看 DowncastDispatcher
就目前来说,先掌握基本的 elementToElement 就行,这个 Helper 需要接收一个对象参数,用来配置具体的转换规则,主要是 model 和 view:
conversion.for("dataDowncast").elementToElement({
model: SCHEMA_NAME__BOLD,
view: (modelElement, conversionApi) =>
createDowncastElement(modelElement, conversionApi),
});
downcast 的功能就是将 model -> view(HTML),所以这里的 model 配置为上面定义的 Schema 的名称
而 view 可以接收一个 function,最终返回一个由 CKEditor 定义的 DOM 元素
这个 function 提供两个参数,第一个是 modelElement,也就是被转换的 model,第二个是工具方法集合 DowncastConversionApi
在 DowncastConversionApi 中有一个最常用的工具 writer,这个工具非常重要!非常重要!非常重要!
整个 CKEditor 中有很多 writer,它们之间有很多同名甚至功能相同的 API,但也有些区别,在使用的时候一定要清楚当前使用的是哪个 writer
而 DowncastConversionApi 提供的是 DowncastWriter,我们可以通过这个工具开发需要渲染的 DOM 结构
function createDowncastElement(modelElement, writer) {
const element = writer.createContainerElement("strong");
const value = modelElement.getAttribute("value");
const innerText = writer.createText(value);
writer.insert(writer.createPositionAt(element, 0), innerText);
return element;
}
这里使用了 DowncastWriter.createContainerElement() 创建 <strong> 标签,然后通过 createText 创建普通文本,最后通过 writer.insert 将文本节点插入到 <strong> 中
上面是 dataDowncast 的转换,但第一节的内容有提到,isInline 需要和 isObject 结合,也就是在编辑时 bold 会作为一个整体,所以在 editingDowncast 中需要用到 Widget
conversion.for("editingDowncast").elementToElement({
model: SCHEMA_NAME__BOLD,
view: (modelElement, { writer }) => {
const element = createDowncastElement(modelElement, writer);
return toWidget(element, writer);
},
});
downcast 的基本用法就是这样,上面的代码可以作为参考,后面会贴出完整的 eidting.js 代码
对于 upcast,可用的转换方法 Helper 有:elementToElement()、attributeToElement()、attributeToAttribute()、elementToMarker()
它的功能是 view -> model,而为了防止“一个 view 对应多个 model 的情况”出现,view 通常会是一个对象:
conversion.for("upcast").elementToElement({
view: {
name: "strong",
},
model: (view, { writer }) => {
return writer.createElement(SCHEMA_NAME__BOLD, { value: "wise" });
},
});
对于 view 除了标签名 name 以外,还可以配置 classes、attributes、styles
upcast 的 model 也是一个 function,第二个参数是 UpcastConversionApi,提供了 UpcastWriter 用来创建 model
这里只需要使用 createElement 创建对应的 Schema,并传入相应的属性即可
最终的 editing.js 如下:
// editing.js
import Plugin from "@ckeditor/ckeditor5-core/src/plugin";
import { toWidget } from "@ckeditor/ckeditor5-widget/src/utils";
import Widget from "@ckeditor/ckeditor5-widget/src/widget";
import BoldCommand from "./command";
import { COMMAND_NAME__BOLD, SCHEMA_NAME__BOLD } from "./constant";
export default class BoldEditing extends Plugin {
static get requires() {
return [Widget];
}
static get pluginName() {
return "BoldEditing";
}
init() {
const editor = this.editor;
this._defineSchema();
this._defineConverters();
// 注册一个 BoldCommand 命令
editor.commands.add(COMMAND_NAME__BOLD, new BoldCommand(editor));
}
// 注册 schema
_defineSchema() {
const schema = this.editor.model.schema;
schema.register(SCHEMA_NAME__BOLD, {
isInline: true,
isObject: true,
allowWhere: "$text",
allowAttributes: ["value"],
});
}
// 定义转换器
_defineConverters() {
const conversion = this.editor.conversion;
// 将 model 渲染为 HTML
conversion.for("editingDowncast").elementToElement({
model: SCHEMA_NAME__BOLD,
view: (modelElement, { writer }) => {
const element = createDowncastElement(modelElement, writer);
return toWidget(element, writer);
},
});
conversion.for("dataDowncast").elementToElement({
model: SCHEMA_NAME__BOLD,
view: (modelElement, { writer }) =>
createDowncastElement(modelElement, writer),
});
// 将 HTML 渲染为 model
conversion.for("upcast").elementToElement({
view: {
name: "strong",
},
model: (view, { writer }) => {
return writer.createElement(SCHEMA_NAME__BOLD, { value: "wise" });
},
});
}
}
function createDowncastElement(modelElement, writer) {
const element = writer.createContainerElement("strong");
const value = modelElement.getAttribute("value");
const innerText = writer.createText(value);
writer.insert(writer.createPositionAt(element, 0), innerText);
return element;
}
三、触发命令 Command
插件的转换逻辑已经写好了,接下来回到 command.js,完善触发命令的 execute 逻辑
其实有了 conversion 之后,只要在触发命令的时候,创建对应的 Schema 即可:
execute() {
const model = this.editor.model;
model.change((writer) => {
const element = writer.createElement(SCHEMA_NAME__BOLD, {
value: this._getSelectionText(),
});
model.insertContent(element);
writer.setSelection(element, "on");
});
}
model.change() 是调整 model 的主要途径,插件对内容的修改几乎都要使用这个方法
change 提供的参数是 ModelWriter,希望不要和上面的 UpcastWriter 和 DowncastWriter 搞混淆了
对于 command.js 来说,除了 execute() 之外,还需要在 refresh() 定义规则来即时调整 isEnabled 和 value,这里暂时略过
// command.js
import Command from "@ckeditor/ckeditor5-core/src/command";
import { SCHEMA_NAME__BOLD } from "./constant";
export default class BoldCommand extends Command {
refresh() {
this.isEnabled = true;
}
execute() {
const model = this.editor.model;
model.change((writer) => {
const element = writer.createElement(SCHEMA_NAME__BOLD, {
value: this._getSelectionText(),
});
model.insertContent(element);
writer.setSelection(element, "on");
});
}
_getSelectionText() {
const model = this.editor.model;
const selection = model.document.selection;
let str = "";
for (const range of selection.getRanges()) {
for (const item of range.getItems()) {
str += item.data;
}
}
return str;
}
}
到这里插件的功能就已经完成了,接下来回到项目的 example 目录加以验证
四、编辑器取值与设置初始值
在编辑器 packages/my-editor/src/index.js 中,通过 ClassicEditor.create 创建编辑器之后,可以在 then() 中接收到编辑器实例 editor
editor 提供了 getData() 方法来获取编辑器数据。可以在 example/index.js 中添加一个提交按钮,调用 getData() 来查看结果
function _bind($editor) {
const submitBtn = document.getElementById("submit");
submitBtn.onclick = function () {
const val = $editor.editor && $editor.editor.getData();
console.log("editorGetValue", val);
};
};
在使用 ClassicEditor.create 创建编辑器的时候,可以传入富文本 initialData 作为编辑器的初始值
将带有 <strong> 标签的富文本作为初始值,如果能正常渲染,则说明 upcast 也能正常工作
五、真正的加粗插件
上面的加粗插件为了介绍基本的 Model,不得已采用了 isInlie + isObject 的方式,将加粗插件复杂化
在 CKEditor 5 中最好通过文本属性的方式来开发加粗插件,所以对于 Schema 需要从 $text 继承:
editor.model.schema.extend( '$text', { allowAttributes:'bold' } );
这样就能在 $text 上添加 bold 属性,然后设置转换器,将带有 bold 的 $text 转换为 <strong>
转换逻辑很简单,就不需要分别使用 downcast 和 upcast 了
conversion.attributeToElement({
model: SCHEMA_NAME__BOLD,
view: "strong",
upcastAlso: [ "b" ],
});
这种没有指定 downcast 和 upcast 的转换器就是双向转换器
这里使用的是 attributeToElement,所以这里的 model 并不是完整的 Schema,而是 Schema 上携带的属性
upcastAlso 是对双向转换器的扩展,其配置的视图元素会被转换为 model
// editing.js
import Plugin from "@ckeditor/ckeditor5-core/src/plugin";
import BoldCommand from "./command";
import { COMMAND_NAME__BOLD, SCHEMA_NAME__BOLD } from "./constant";
export default class BoldEditing extends Plugin {
static get pluginName() {
return "BoldEditing";
}
init() {
const editor = this.editor;
this._defineSchema();
this._defineConverters();
// 注册一个 BoldCommand 命令
editor.commands.add(COMMAND_NAME__BOLD, new BoldCommand(editor));
}
// 注册 schema
_defineSchema() {
const schema = this.editor.model.schema;
schema.extend("$text", { allowAttributes: SCHEMA_NAME__BOLD });
}
// 定义转换器
_defineConverters() {
const conversion = this.editor.conversion;
conversion.attributeToElement({
model: SCHEMA_NAME__BOLD,
view: "strong",
upcastAlso: ["b"],
});
}
}
然后 command.js 也不需要创建 Schema,而是对选中的 $text 添加 bold 属性
writer.setSelectionAttribute('bold', true);
writer.setAttribute('bold', true, range);
另外还可以完善一下 command 的 value 和 isEnabled,以控制加粗按钮在工具栏上的高亮/禁用状态
refresh() {
const model = this.editor.model;
const selection = model.document.selection;
this.value = selection.hasAttribute('bold');
this.isEnabled = model.schema.checkAttributeInSelection(selection, 'bold');
}
最终完整的 command.js 如下:
// command.js
import Command from "@ckeditor/ckeditor5-core/src/command";
import { SCHEMA_NAME__BOLD } from "./constant";
export default class BoldCommand extends Command {
constructor(editor) {
super(editor);
this.attributeKey = SCHEMA_NAME__BOLD;
}
refresh() {
const model = this.editor.model;
const selection = model.document.selection;
// 如果选中的文本含有 bold 属性,设置 value 为 true,
// 由于已在 toolbar-ui 中关联,当 value 为 true 时会高亮工具栏按钮
this.value = this._getValueFromFirstAllowedNode();
// 校验选中的 Schema 是否允许 bold 属性,若不允许则禁用按钮
this.isEnabled = model.schema.checkAttributeInSelection(
selection,
this.attributeKey
);
}
execute() {
const model = this.editor.model;
const selection = model.document.selection;
const value = !this.value;
// 对选中文本设置 bold 属性
model.change((writer) => {
if (selection.isCollapsed) {
if (value) {
writer.setSelectionAttribute(this.attributeKey, true);
} else {
writer.removeSelectionAttribute(this.attributeKey);
}
} else {
const ranges = model.schema.getValidRanges(
selection.getRanges(),
this.attributeKey
);
for (const range of ranges) {
if (value) {
writer.setAttribute(this.attributeKey, value, range);
} else {
writer.removeAttribute(this.attributeKey, range);
}
}
}
});
}
_getValueFromFirstAllowedNode() {
const model = this.editor.model;
const schema = model.schema;
const selection = model.document.selection;
// 选区的锚点和焦点是否位于同一位置
if (selection.isCollapsed) {
return selection.hasAttribute(this.attributeKey);
}
for (const range of selection.getRanges()) {
for (const item of range.getItems()) {
if (schema.checkAttribute(item, this.attributeKey)) {
return item.hasAttribute(this.attributeKey);
}
}
}
return false;
}
}
了解了加粗插件的写法,就熟悉了 CKEditor 5 的基本玩法,但如果想开发一个完全自定义的插件,仍然需要努力
比如插入超链接,选中文本后点需要通过一个表单来输入连接,这个表单应该如何开发?
又比如插入图片,如果需要在插入之前做一些编辑(比如裁剪图片、添加图片描述),甚至在插入图片后还支持编辑,这就更加复杂
后面会先用超链接的例子来演示如何开发表单,to be continue...