Talk is cheap. Show me your code

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

posted @ 2021-04-12 10:59  Wise.Wrong  阅读(6016)  评论(9编辑  收藏  举报