前面的几篇文章已经介绍了 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...