• 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,  // 是否允许在点击遮罩时关闭弹窗
             '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,
           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 || "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,
           "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...

  • 相关阅读:
    文字列をバイトで切る
    把SmartForm转换成PDF
    相对布局和网格布局
    帧布局和表格布局
    计算器布局
    课堂总结和练习
    Android UI组件
    2层导航
    导航
    课堂总结
  • 原文地址:https://www.cnblogs.com/wisewrong/p/14506005.html
Copyright © 2020-2023  润新知