Talk is cheap. Show me your code

CKEditor 5 摸爬滚打(四)—— 开发带有弹窗表单的超链接插件

前面的几篇文章已经介绍了 CKEditor5 插件的构成,并开发了一个加粗插件

这篇文章会用一个超链接插件的例子,来介绍怎么在 CKEditor5 中开发带有弹窗表单的插件

 

 

一、设计转换器 Conversion

开发 CKEditor5 的插件有两个必须步骤:

1. 设计好 View、Model 以及转换规则 conversion;

2. 创建只含基本逻辑的 command.js 和 toolbar-ui.js

而对于超链接插件,View 肯定是一个 <a> 标签:

<!-- View -->
<a herf="${url}" target="_blank">${链接名称}</a>

和加粗插件类似,<a> 标签中的文本可以编辑,所以对应的 Model 也应该继承自 $text,然后通过自定义属性进行转换

<!-- Model -->
<paragraph>
  <$text linkHref="url">超链接</$text>
</paragraph>

所以 Schema 的注册可以这么来:

_defineSchema() {
  const schema = this.editor.model.schema;
  // SCHEMA_NAME__LINK -> 'linkHref'
  schema.extend("$text", {
    allowAttributes: SCHEMA_NAME__LINK
  });
}

然后完善一下 conversion,editing.js 就完成了:

// editing.js

import Plugin from "@ckeditor/ckeditor5-core/src/plugin";
import inlineHighlight from '@ckeditor/ckeditor5-typing/src/utils/inlinehighlight';
import LinkCommand from "./command";
import {
  SCHEMA_NAME__LINK,
  COMMAND_NAME__LINK,
} from "./constant";

const HIGHLIGHT_CLASS = 'ck-link_selected';

export default class LinkEditing extends Plugin {
  static get pluginName() {
    return "LinkEditing";
  }

  init() {
    const editor = this.editor;

    this._defineSchema();
    this._defineConverters();

    // COMMAND_NAME__LINK -> 'link'
    editor.commands.add(COMMAND_NAME__LINK, new LinkCommand(editor));

    // 当光标位于 link 中间,追加 class,用于高亮当前超链接
    inlineHighlight(editor, SCHEMA_NAME__LINK, "a", HIGHLIGHT_CLASS);
  }

  _defineSchema() {
    const schema = this.editor.model.schema;
    schema.extend("$text", {
      // SCHEMA_NAME__LINK -> 'linkHref'
      allowAttributes: SCHEMA_NAME__LINK,
    });
  }

  _defineConverters() {
    const conversion = this.editor.conversion;

    conversion.for("downcast").attributeToElement({
      model: SCHEMA_NAME__LINK,
      // attributeToElement 方法中,如果 view 是一个函数,其第一个参数是对应的属性值,在这里就是超链接的 url
      // 实际项目中需要校验 url 的真实性,这里就省略掉了
      view: createLinkElement,
    });

    conversion.for("upcast").elementToAttribute({
      view: {
        name: "a",
        attributes: {
          href: true,
        },
      },
      model: {
        key: SCHEMA_NAME__LINK,
        value: (viewElement) => viewElement.getAttribute("href"),
      },
    });
  }
}

function createLinkElement(href, { writer }) {
  return writer.createAttributeElement("a", { href });
}

 

 

二、基础的 Command 和 ToolbarUI

先来完成简单的 command.js

// command.js 基础版

import Command from "@ckeditor/ckeditor5-core/src/command";
import { SCHEMA_NAME__LINK } from "./constant";

export default class LinkCommand extends Command {
  refresh() {
    const model = this.editor.model;
    const doc = model.document;

    // 将链接关联到到 value
    this.value = doc.selection.getAttribute(SCHEMA_NAME__LINK);
    // 根据 editing.js 中定义的 schema 规则来维护按钮的禁用/启用状态
    this.isEnabled = model.schema.checkAttributeInSelection(doc.selection, SCHEMA_NAME__LINK);
  }

  execute(href) {
    console.log('LinkCommand Executed', href);
  }
}

整个超链接插件的交互过程是:选中文本 -> 点击工具栏按钮 -> 打开弹窗 -> 输入连接 -> 点击确定

所以工具栏按钮的点击事件,并没有直接触发 command,而是打开弹窗。最终是弹窗的确定按钮触发 command

基于这个逻辑,可以完成基础的 toolbar-ui.js

// toolbar-ui.js

import Plugin from "@ckeditor/ckeditor5-core/src/plugin";
import ButtonView from "@ckeditor/ckeditor5-ui/src/button/buttonview";
import linkIcon from "@ckeditor/ckeditor5-link/theme/icons/link.svg";
import {
  COMMAND_NAME__LINK,
  TOOLBAR_NAME__LINK,
  TOOLBAR_LABEL__LINK,
} from "./constant";

export default class LinkToolbarUI extends Plugin {
  init() {
    this._createToolbarButton();
  }

  _createToolbarButton() {
    const editor = this.editor;
    // COMMAND_NAME__LINK -> 'link'
    const linkCommand = editor.commands.get(COMMAND_NAME__LINK);

    // TOOLBAR_NAME__LINK -> 'ck-link'
    editor.ui.componentFactory.add(TOOLBAR_NAME__LINK, (locale) => {
      const view = new ButtonView(locale);
      view.set({
        // TOOLBAR_LABEL__LINK -> '超链接'
        label: TOOLBAR_LABEL__LINK,
        tooltip: true,
        icon: linkIcon,
        class: "toolbar_button_link",
      });

      view.bind("isEnabled").to(linkCommand, "isEnabled");
      // 根据 command 的 value 来控制按钮的高亮状态
      view.bind("isOn").to(linkCommand, "value", (value) => !!value);

      this.listenTo(view, "execute", () => {
        // 点击按钮的时候打开弹窗
        this._openDialog(linkCommand.value);
      });
      return view;
    });
  }

  // value 为已设置的超链接,作为初始值传给弹窗表单
  _openDialog(value) {
    // 在弹窗中触发命令
    this.editor.execute(COMMAND_NAME__LINK);
  }
}

准备就绪,接下来就是重头戏:开发弹窗表单组件

 

 

三、开发弹窗组件

CKEditor 提供了一套定义视图的规则 TemplateDefinition,可以从 View 继承然后按照相应的格式开发视图

这种方式就像是手动定义一个 DOM 树结构,类似于 Vue 的 render 方法,或者使用 js 而不是 jsx 的 React 视图模板

在熟悉了规则之后还是能很顺手的完成视图开发,但很难算得上高效

直到我忽然意识到:弹窗视图是在编辑器视图之外的,也就是说弹窗不需要转成 Model。

既然如此,那可不可以使用原生 JS 开发的插件呢?答案是可以的

 

所以我最终使用 JS 开发了一个弹窗组件 /packages/UI/dialog/dialog.js,这里就不细讲了,贴一下代码:

// dialog.js

import { domParser } from "../util";
import "./dialog.less";

// 用于批量绑定/解绑事件
const EventMaps = {
  'closeButton': {
    selector: '.dialog-button_close',
    handleName: 'close',
  },
  'cancelButton': {
    selector: '.dialog-button_cancel',
    handleName: 'close',
  },
  'submitButton': {
    selector: '.dialog-button_submit',
    handleName: '_handleSubmit',
  },
  'mask': {
    selector: '.dialog-mask',
    handleName: 'close',
    verifier: 'maskEvent'
  }
}

export default class Dialog {
  constructor(props) {
    Object.assign(
      this,
      {
        container: "body", // querySelector 可接收的参数
        content: {}, // 内容对象 { title, body, classes }
        afterClose: () => {},
        beforeClose: () => {},
        onSubmit: () => {},
        maskEvent: true,  // 是否允许在点击遮罩时关闭弹窗
        width: '60%', // 弹窗宽度,需携带单位
      },
      props || {}
    );

    this.$container = document.querySelector(this.container);
    this.render();
  }

  render() {
    let config = {};
    if (typeof this.content === 'object') {
      config = this.content;
    } else {
      config.body = this.content;
    }

    this.$pop = domParser(template({
      ...config,
      width: this.width
    }));
    this.$container.appendChild(this.$pop);
    this._bind();
  }

  close() {
    typeof this.beforeClose === "function" && this.beforeClose();
    this.$pop.style.display = "none";
    this.destroy();
    typeof this.afterClose === "function" && this.afterClose();
  }

  destroy() {
    this._unbind();
    this.$pop && this.$pop.remove();
  }

  _bind() {
    for (const key in EventMaps) {
      const item = EventMaps[key];
      // 当存在检验器,且校验器为 falsy 时,不监听事件
      if (item.verifier && !this[item.verifier]) {
        continue;
      }
      this[key] = this.$pop.querySelector(item.selector);
      this[key].addEventListener("click", this[item.handleName].bind(this));
    }
  }

  _unbind() {
    for (const key in EventMaps) {
      const item = EventMaps[key];
      try {
        this[key] && this[key].removeEventListener("click", this[item.handleName].bind(this));
      } catch(err) {
        console.error('Dialog Unbind Error: ', err);
      }
    }
  }

  _handleSubmit() {
    typeof this.onSubmit === "function" && this.onSubmit();
    this.close();
  }
}

function template(config) {
  const { classes, title, body, width } = config || {};
  const cls =
    typeof classes === "string"
      ? classes
      : Array.isArray(classes)
      ? classes.join(" ")
      : "";

  return `
    <div class="dialog">
      <div class="dialog-main ${cls}" style="width:${width || "60%"};">
        <div class="dialog-header">
          <span class="dialog-title">${title || ""}</span>
          <span class="dialog-header-action">
            <button class="dialog-button dialog-button_close button-icon">X</button>
          </span>
        </div>
        <div class="dialog-content">
          ${body || ""}
        </div>
        <div class="dialog-footer">
          <button class="dialog-button dialog-button_cancel">取消</button>
          <button class="dialog-button button-primary dialog-button_submit">确认</button>
        </div>
      </div>
      <div class="dialog-mask"></div>
    </div>
  `;
}
// util.js

export const domParser = (template) => {
  return new window.DOMParser().parseFromString(
    template,
    'text/html'
  ).body.firstChild
}
// dialog.less

.dialog{
  position: fixed;
  left: 0;
  top:0;
  right:0;
  bottom:0;
  background: transparent;
  z-index: 1000;
  overflow: auto;

  &-mask {
    position: absolute;
    left: 0;
    top:0;
    right:0;
    bottom:0;
    background: rgba(0,0,0,0.4);
  }

  &-main {
    background: white;
    position: absolute;
    left: 50%;
    top: 20%;
    box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.14);
    transform: translateX(-50%);
    animation-duration: .3s;
    animation-fill-mode: both;
    animation-name: popBoxZoomIn;
    z-index: 1;
  }

  &-header {
    padding: 16px 20px 8px;

    &-action {
      .dialog-button_close {
        background: transparent;
        border: none;
        outline: none;
        padding: 0;
        color: #909399;
        float: right;
        font-size: 16px;
        line-height: 24px;
        &:hover {
          background-color: transparent;
        }
      }
    }
  }

  &-content{
    position: relative;
    padding: 20px;
    color: #606266;
    font-size: 14px;
    word-break: break-all;
  }

  &-footer {
    padding: 10px 20px 16px;
    text-align: right;
    box-sizing: border-box;
  }

  &-button {
    display: inline-block;
    line-height: 1;
    white-space: nowrap;
    cursor: pointer;
    background: #fff;
    border: 1px solid #dcdfe6;
    color: #606266;
    text-align: center;
    box-sizing: border-box;
    outline: none;
    margin: 0;
    transition: .1s;
    font-weight: 500;
    padding: 10px 16px;
    font-size: 14px;
    border-radius: 4px;

    &:hover {
      background-color: #efefef;
    }

    & + .dialog-button {
      margin-left: 10px;
    }

    &.button-primary {
      color: #fff;
      background-color: #3c9ef3;
      border-color: #3c9ef3;

      &:hover {
        border-color: rgba(#3c9ef3, .7);
        background-color: rgba(#3c9ef3, .7);
      }
    }
  }
}


@keyframes popBoxZoomIn {
  from {
    opacity: 0;
    transform: scale3d(.7, .7, .7);
  }

  50% {
    opacity: 1;
  }
}

这个 dialog.js 只是提供了弹窗,接下来还需要开发超链接的表单组件 /packages/plugin-link/form/link-form.js,嵌入到 dialog 中:

// link-form.js

import Dialog from "../../UI/dialog/dialog";
import "./link-form.less";

export default class LinkForm {
  constructor(props) {
    Object.assign(
      this,
      {
        value: undefined, // 初始值
        onSubmit: () => {},
      },
      props || {}
    );

    this.render();
  }

  render() {
    const content = template(this.value);
    this.$form = new Dialog({
      content,
      width: "420px",
      onSubmit: this._submit.bind(this),
    });

    const dialog = this.$form.$pop;
    this.$input = dialog.querySelector(`input[name=linkValue]`);
    this.$cleanButton = dialog.querySelector(".link-form-button");

    this._bind();
  }

  destroy() {
    this._unbind();
  }

  _bind() {
    this.$cleanButton.addEventListener("click", this._handleCleanup.bind(this));
  }

  _unbind() {
    try {
      this.$cleanButton.removeEventListener(
        "click",
        this._handleCleanup.bind(this)
      );
    } catch (e) {
      console.error("LinkForm Unbind Error: ", e);
    }
  }

  _submit() {
    if (typeof this.onSubmit !== "function") {
      return;
    }

    return this.onSubmit(this.$input.value);
  }

  _handleCleanup() {
    this.$input.value = "";
  }
}

function template(initialValue) {
  const body = `
    <div class="link-form">
      <input
        placeholder="插入链接为空时取消超链接"
        type="text"
        class="link-form-input"
        name="linkValue"
        value="${initialValue || ""}"
      />
      <span title="清空" class="link-form-button">X</span>
    </div>
  `;

  return {
    classes: "link-form-dialog",
    title: "插入超链接",
    body,
  };
}
// .link-form.less

.link-form {
  line-height: normal;
  display: inline-table;
  width: 100%;
  border-collapse: separate;
  border-spacing: 0;

  &-input {
    vertical-align: middle;
    display: table-cell;
    background-color: #fff;
    background-image: none;
    border-radius: 4px;
    border-top-right-radius: 0;
    border-bottom-right-radius: 0;
    border: 1px solid #dcdfe6;
    box-sizing: border-box;
    color: #606266;
    font-size: inherit;
    height: 40px;
    line-height: 40px;
    outline: none;
    padding: 0 15px;
    transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
    width: 100%;

    &:focus {
      outline: none;
      border-color: #409eff;
    }
  }

  &-button {
    background-color: #f5f7fa;
    color: #909399;
    vertical-align: middle;
    display: table-cell;
    position: relative;
    border: 1px solid #dcdfe6;
    border-radius: 4px;
    border-top-left-radius: 0;
    border-bottom-left-radius: 0;
    border-left: 0;
    padding: 0 20px;
    width: 1px;
    white-space: nowrap;
    cursor: pointer;

    &:hover {
      background-color: #e9ebef;
    }
  }

  ::-webkit-input-placeholder {
    color: #c4c6ca;
    font-weight: 300;
  }
}

然后只要在工具栏图标的点击事件中创建 LinkForm 实例就能打开弹窗

// toolbar-ui.js

import LinkForm from "./form/link-form";

export default class LinkToolbarUI extends Plugin {
  // ...
  _openDialog(value) {
    new LinkForm({
      value,
      onSubmit: (href) => {
        this.editor.execute(COMMAND_NAME__LINK, href);
      },
    });
  }
  // ...
}

 

 

四、插入超链接

万事俱备,就差完善 command.js 中的具体逻辑了

和之前的加粗插件类似,只需要向文本 $text 添加属性 linkHref 即可

但超链接有一个需要注意的问题在于:当光标位于超链接上,却并没有选中整个超链接,这种情况应该如何处理

CKEditor5 提供的工具函数 findAttributeRange 可以解决这个问题

这个函数可以根据给定的 position 和 attribute 来获取完整的 selection

所以最终的 command.js 是这样的:

// command.js

import Command from "@ckeditor/ckeditor5-core/src/command";
import findAttributeRange from "@ckeditor/ckeditor5-typing/src/utils/findattributerange";
import { SCHEMA_NAME__LINK } from "./constant";

export default class LinkCommand extends Command {
  refresh() {
    const model = this.editor.model;
    const doc = model.document;

    // 将链接关联到到 value
    this.value = doc.selection.getAttribute(SCHEMA_NAME__LINK);
    // 根据 editing.js 中定义的 schema 规则来维护按钮的禁用/启用状态
    this.isEnabled = model.schema.checkAttributeInSelection(
      doc.selection,
      SCHEMA_NAME__LINK
    );
  }

  execute(href) {
    const model = this.editor.model;
    const selection = model.document.selection;

    model.change((writer) => {
      // 选区的锚点和焦点是否位于同一位置
      if (selection.isCollapsed) {
        const position = selection.getFirstPosition();

        // 光标位于 link 中间
        if (selection.hasAttribute(SCHEMA_NAME__LINK)) {
          const range = findAttributeRange(
            position,
            SCHEMA_NAME__LINK,
            selection.getAttribute(SCHEMA_NAME__LINK),
            model
          );
          this._handleLink(writer, href, range)
        }
      } else {
        const ranges = model.schema.getValidRanges(
          selection.getRanges(),
          SCHEMA_NAME__LINK
        );
        for (const range of ranges) {
          this._handleLink(writer, href, range)
        }
      }
    });
  }

  _handleLink(writer, href, range) {
    if (href) {
      writer.setAttribute(SCHEMA_NAME__LINK, href, range);
    } else {
      writer.removeAttribute(SCHEMA_NAME__LINK, range);
    }
  }
}

最后来完成入口文件 main.js,超链接插件就完成了

// main.js

import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import ToolbarUI from './toolbar-ui';
import Editing from './editing';
import { TOOLBAR_NAME__LINK } from './constant';

export default class Link extends Plugin {
  static get requires() {
    return [ Editing, ToolbarUI ];
  }
  static get pluginName() {
   return TOOLBAR_NAME__LINK;
  }
}

 

超链接插件这个例子,主要是介绍 CKEditor 5 中高效开发弹窗表单的一个思路

像弹窗这种独立于 Model 之外的组件,可以直接使用原生 JS 进行开发

掌握这个窍门之后,开发 CKEditor 5 的插件会便捷许多

下一篇博客将用一个图片插件来介绍 CKEditor 5 中如何插入块级元素,以及块级元素的工具栏,to be continue...

posted @ 2021-04-02 09:39  Wise.Wrong  阅读(86)  评论(0编辑  收藏