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...


免责声明!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系本站邮箱yoyou2525@163.com删除。



 
粤ICP备18138465号  © 2018-2025 CODEPRJ.COM