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