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 的官方插件源碼,看有沒有新的思路

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章