Talk is cheap. Show me your code

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

最终完整的 eiditing.js 如下:
// 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...

 

posted @ 2021-03-09 11:20  Wise.Wrong  阅读(3214)  评论(1编辑  收藏  举报