上一篇文章将加粗插件的架子给搭好了,现在就来完善具体的逻辑,主要的难点在于 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...