实现块状小部件
在本教程中,您将学习如何实现更复杂的 CKEditor 5 插件。
您将构建一个“简单框”功能,允许用户将带有标题和正文字段的自定义框插入文档中。您将使用小部件实用程序并使用模型-视图转换来正确设置此功能的行为。稍后,您将创建一个 UI,允许使用工具栏按钮将新的简单框插入文档中。
如果您想在深入研究之前查看本教程的最终产品,请查看演示。
# 开始之前
本教程将参考您在学习过程中遇到的CKEditor 5 架构部分的各个部分。虽然阅读这些部分不是完成本教程的必要条件,但建议您在某些时候阅读这些指南,以更好地理解本教程中使用的机制。
如果您想为小部件触发的事件使用自己的事件处理程序,您必须将其包装在具有data-cke-ignore-events
属性的容器中,以将其从编辑器的默认处理程序中排除。有关更多详细信息,请参阅从默认处理程序中排除 DOM 事件。
# 让我们开始
最简单的入门方法是使用以下命令获取入门项目。
npx -y degit ckeditor/ckeditor5-tutorials-examples/block-widget/starter-files block-widget
cd block-widget
npm install
npm run dev
这将在名为block-widget
的新目录中创建必要的文件。npm install
命令将安装所有依赖项,npm run dev
将启动开发服务器。
带有基本插件的编辑器是在main.js
文件中创建的。
打开终端中显示的 URL。如果一切正常,您应该在浏览器中看到一个像这样的 CKEditor 5 实例
# 插件结构
编辑器启动并运行后,您可以开始实现插件。您可以将整个插件代码保存在单个文件中,但是建议将它的“编辑”和“UI”层拆分,并创建一个加载两者的主插件。这样,您可以确保更好地分离关注点,并允许重新组合功能(例如,选择现有功能的编辑部分,但编写自己的 UI)。所有官方 CKEditor 5 插件都遵循这种模式。
此外,您将命令、按钮和其他“自包含”组件的代码拆分到单独的文件中。为了避免将这些文件与项目的main.js
文件混淆,请创建以下目录结构
├── main.js
├── index.html
├── node_modules
├── package.json
├── simplebox
│ ├── simplebox.js
│ ├── simpleboxediting.js
│ └── simpleboxui.js
└─ ...
现在定义 3 个插件。
首先是主(粘合)插件。它的作用只是加载“编辑”和“UI”部分。
// simplebox/simplebox.js
import SimpleBoxEditing from './simpleboxediting';
import SimpleBoxUI from './simpleboxui';
import { Plugin } from 'ckeditor5';
export default class SimpleBox extends Plugin {
static get requires() {
return [ SimpleBoxEditing, SimpleBoxUI ];
}
}
现在,剩下的两个插件
// simplebox/simpleboxui.js
import { Plugin } from 'ckeditor5';
export default class SimpleBoxUI extends Plugin {
init() {
console.log( 'SimpleBoxUI#init() got called' );
}
}
// simplebox/simpleboxediting.js
import { Plugin } from 'ckeditor5';
export default class SimpleBoxEditing extends Plugin {
init() {
console.log( 'SimpleBoxEditing#init() got called' );
}
}
最后,您需要在main.js
文件中加载SimpleBox
插件
// main.js
import SimpleBox from './simplebox/simplebox'; // ADDED
ClassicEditor
.create( document.querySelector( '#editor' ), {
plugins: [
Essentials, Paragraph, Heading, List, Bold, Italic,
SimpleBox // ADDED
],
toolbar: [ 'heading', 'bold', 'italic', 'numberedList', 'bulletedList' ]
} );
您的页面将刷新,您应该看到SimpleBoxEditing
和SmpleBoxUI
插件已加载
# 模型和视图层
CKEditor 5 实现了一个 MVC 架构,它的自定义数据模型虽然仍然是树结构,但并没有与 DOM 一对一映射。您可以将模型视为编辑器内容的语义表示,而 DOM 是其可能的表示之一。
了解有关编辑引擎架构的更多信息。
由于您的简单框功能旨在成为一个带有标题和描述字段的框,因此请像这样定义它的模型表示
<simpleBox>
<simpleBoxTitle></simpleBoxTitle>
<simpleBoxDescription></simpleBoxDescription>
</simpleBox>
# 定义模式
您需要从定义模型的模式开始。您需要定义 3 个元素及其类型,以及允许的父/子元素。
了解有关模式的更多信息。
使用此定义更新SimpleBoxEditing
插件。
// simplebox/simpleboxediting.js
import { Plugin } from 'ckeditor5';
export default class SimpleBoxEditing extends Plugin {
init() {
console.log( 'SimpleBoxEditing#init() got called' );
this._defineSchema(); // ADDED
}
_defineSchema() { // ADDED
const schema = this.editor.model.schema;
schema.register( 'simpleBox', {
// Behaves like a self-contained block object (e.g. a block image)
// allowed in places where other blocks are allowed (e.g. directly in the root).
inheritAllFrom: '$blockObject'
} );
schema.register( 'simpleBoxTitle', {
// Cannot be split or left by the caret.
isLimit: true,
allowIn: 'simpleBox',
// Allow content which is allowed in blocks (i.e. text with attributes).
allowContentOf: '$block'
} );
schema.register( 'simpleBoxDescription', {
// Cannot be split or left by the caret.
isLimit: true,
allowIn: 'simpleBox',
// Allow content which is allowed in the root (e.g. paragraphs).
allowContentOf: '$root'
} );
}
}
定义模式目前不会对编辑器有任何影响。这是一个信息,插件和编辑器引擎可以使用它来了解如何处理按下Enter键、单击元素、键入文本、插入图像等操作的行为。
为了使简单框插件开始执行任何操作,您需要定义模型-视图转换器。现在就做吧!
# 定义转换器
转换器告诉编辑器如何将视图转换为模型(例如,加载数据到编辑器或处理粘贴内容)以及如何将模型渲染到视图(用于编辑目的,或者检索编辑器数据时)。
了解有关编辑器中的转换的更多信息。
现在您需要考虑如何将<simpleBox>
元素及其子元素渲染到 DOM(用户将看到的内容)和数据中。CKEditor 5 允许将模型转换为不同的结构以供编辑,并转换为不同的结构以存储为“数据”或在复制粘贴内容时与其他应用程序交换。但是,为了简单起见,现在在两个管道中都使用相同的表示。
您要实现的视图中的结构
<section class="simple-box">
<h1 class="simple-box-title"></h1>
<div class="simple-box-description"></div>
</section>
使用conversion.elementToElement()
方法定义所有转换器。
您需要为 3 个模型元素定义转换器。使用此代码更新SimpleBoxEditing
插件
// simplebox/simpleboxediting.js
import { Plugin } from 'ckeditor5';
export default class SimpleBoxEditing extends Plugin {
init() {
console.log( 'SimpleBoxEditing#init() got called' );
this._defineSchema();
this._defineConverters(); // ADDED
}
_defineSchema() {
// Previously registered schema.
// ...
}
_defineConverters() { // ADDED
const conversion = this.editor.conversion;
conversion.elementToElement( {
model: 'simpleBox',
view: {
name: 'section',
classes: 'simple-box'
}
} );
conversion.elementToElement( {
model: 'simpleBoxTitle',
view: {
name: 'h1',
classes: 'simple-box-title'
}
} );
conversion.elementToElement( {
model: 'simpleBoxDescription',
view: {
name: 'div',
classes: 'simple-box-description'
}
} );
}
}
有了转换器后,您可以尝试查看简单框的实际效果。您还没有定义将新简单框插入文档的方法,因此请通过编辑器数据加载它。为此,您需要修改index.html
文件
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>CKEditor 5 Framework – Implementing a simple widget</title>
<style>
.simple-box {
padding: 10px;
margin: 1em 0;
background: rgba( 0, 0, 0, 0.1 );
border: solid 1px hsl(0, 0%, 77%);
border-radius: 2px;
}
.simple-box-title, .simple-box-description {
padding: 10px;
margin: 0;
background: #FFF;
border: solid 1px hsl(0, 0%, 77%);
}
.simple-box-title {
margin-bottom: 10px;
}
</style>
</head>
<body>
<div id="editor">
<p>This is a simple box:</p>
<section class="simple-box">
<h1 class="simple-box-title">Box title</h1>
<div class="simple-box-description">
<p>The description goes here.</p>
<ul>
<li>It can contain lists,</li>
<li>and other block elements like headings.</li>
</ul>
</div>
</section>
</div>
<script src="dist/bundle.js"></script>
</body>
</html>
瞧!这是您的第一个简单框实例
# 模型中有什么?
您添加到index.html
文件中的 HTML 是您的编辑器数据。这是editor.getData()
将返回的内容。此外,目前,这也是 CKEditor 5 引擎在可编辑区域中渲染的 DOM 结构
但是,模型中有什么?
要了解这一点,请使用官方的CKEditor 5 检查器。在安装后,您需要在main.js
文件中加载它
// main.js
import SimpleBox from './simplebox/simplebox';
import CKEditorInspector from '@ckeditor/ckeditor5-inspector'; // ADDED
ClassicEditor
.create( document.querySelector( '#editor' ), {
plugins: [
Essentials, Paragraph, Heading, List, Bold, Italic,
SimpleBox
],
toolbar: [ 'heading', 'bold', 'italic', 'numberedList', 'bulletedList' ]
} )
.then( editor => {
console.log( 'Editor was initialized', editor );
CKEditorInspector.attach( { 'editor': editor } );
window.editor = editor;
} );
刷新页面后,您将看到检查器
您将看到以下 HTML 类字符串
<paragraph>[]This is a simple box:</paragraph>
<simpleBox>
<simpleBoxTitle>Box title</simpleBoxTitle>
<simpleBoxDescription>
<paragraph>The description goes here.</paragraph>
<listItem listIndent="0" listType="bulleted">It can contain lists,</listItem>
<listItem listIndent="0" listType="bulleted">and other block elements like headings.</listItem>
</simpleBoxDescription>
</simpleBox>
如您所见,此结构与 HTML 输入/输出完全不同。如果您仔细观察,您还会注意到第一段中的[]
字符 - 这是选择位置。
使用编辑器功能(粗体、斜体、标题、列表、选择)进行一些操作,以查看模型结构如何变化。
您还可以使用一些有用的助手,如getData()
和setData()
来了解有关编辑器模型状态的更多信息,或在测试中编写断言。
# 将简单框转换为小部件之前的行为
现在该检查简单框是否按您期望的方式运行了。您可以观察到以下情况
- 您可以在标题中键入文本。按下Enter不会拆分它,Backspace不会完全删除它。这是因为它在模式中被标记为
isLimit
元素。 - 您无法在标题中应用列表,也无法将其转换为标题(除了
<h1 class="simple-box-title">
,它已经是这样了)。这是因为它只允许其他块元素(如段落)中允许的内容。但是,您可以在标题中应用斜体(因为斜体在其他块中是允许的)。 - 描述的行为与标题类似,但它允许更多内容在其中 - 列表和其他标题。
- 如果您尝试选择整个简单框实例并按下Delete,它将作为一个整体被删除。复制粘贴它时也是如此。这是因为它在模式中被标记为
isObject
元素。 - 您无法通过单击轻松地选择整个简单框实例。此外,当您将鼠标悬停在它上面时,光标指针不会改变。换句话说,它看起来有点“死”了。这是因为您还没有定义视图行为。
到目前为止,非常棒,对吧?只需少量代码,您就可以定义简单框插件的行为,该插件可以维护这些元素的完整性。引擎确保用户不会破坏这些实例。
看看您还能改进什么。
# 将简单框转换为小部件
在 CKEditor 5 中,小部件系统主要由引擎处理。其中一些包含在 (@ckeditor/ckeditor5-widget
) 包中,而其他一些则必须由 CKEditor 5 框架提供的其他实用程序处理。
因此,CKEditor 5 的实现对扩展和重新组合是开放的。您可以选择您想要的行为(就像您在本教程中通过定义模式所做的那样),并跳过其他行为或自己实现它们。
您定义的转换器将模型<simpleBox*>
元素转换为视图中的普通 ContainerElement
(以及在向上转换期间返回)。
您想稍微更改此行为,以便在编辑视图中创建的结构使用 toWidget()
和 toWidgetEditable()
实用程序进行增强。但是,您不想影响数据视图。因此,您需要分别为编辑和数据向下转换定义转换器。
如果您发现向下转换和向上转换的概念令人困惑,请阅读 转换简介。
现在是时候重新审视您之前定义的 _defineConverters()
方法了。您将使用 elementToElement()
向上转换助手 和 elementToElement()
向下转换助手,而不是双向 elementToElement()
转换器助手。
此外,您需要确保 Widget
插件已加载。如果您省略它,视图中的元素将具有所有类(如 ck-widget
),但不会加载任何“行为”(例如,单击小部件不会选择它)。
// simplebox/simpleboxediting.js
// ADDED 2 imports.
import { Plugin, Widget, toWidget, toWidgetEditable } from 'ckeditor5';
export default class SimpleBoxEditing extends Plugin {
static get requires() { // ADDED
return [ Widget ];
}
init() {
console.log( 'SimpleBoxEditing#init() got called' );
this._defineSchema();
this._defineConverters();
}
_defineSchema() {
// Previously registered schema.
// ...
}
_defineConverters() { // MODIFIED
const conversion = this.editor.conversion;
// <simpleBox> converters.
conversion.for( 'upcast' ).elementToElement( {
model: 'simpleBox',
view: {
name: 'section',
classes: 'simple-box'
}
} );
conversion.for( 'dataDowncast' ).elementToElement( {
model: 'simpleBox',
view: {
name: 'section',
classes: 'simple-box'
}
} );
conversion.for( 'editingDowncast' ).elementToElement( {
model: 'simpleBox',
view: ( modelElement, { writer: viewWriter } ) => {
const section = viewWriter.createContainerElement( 'section', { class: 'simple-box' } );
return toWidget( section, viewWriter, { label: 'simple box widget' } );
}
} );
// <simpleBoxTitle> converters.
conversion.for( 'upcast' ).elementToElement( {
model: 'simpleBoxTitle',
view: {
name: 'h1',
classes: 'simple-box-title'
}
} );
conversion.for( 'dataDowncast' ).elementToElement( {
model: 'simpleBoxTitle',
view: {
name: 'h1',
classes: 'simple-box-title'
}
} );
conversion.for( 'editingDowncast' ).elementToElement( {
model: 'simpleBoxTitle',
view: ( modelElement, { writer: viewWriter } ) => {
// Note: You use a more specialized createEditableElement() method here.
const h1 = viewWriter.createEditableElement( 'h1', { class: 'simple-box-title' } );
return toWidgetEditable( h1, viewWriter );
}
} );
// <simpleBoxDescription> converters.
conversion.for( 'upcast' ).elementToElement( {
model: 'simpleBoxDescription',
view: {
name: 'div',
classes: 'simple-box-description'
}
} );
conversion.for( 'dataDowncast' ).elementToElement( {
model: 'simpleBoxDescription',
view: {
name: 'div',
classes: 'simple-box-description'
}
} );
conversion.for( 'editingDowncast' ).elementToElement( {
model: 'simpleBoxDescription',
view: ( modelElement, { writer: viewWriter } ) => {
// Note: You use a more specialized createEditableElement() method here.
const div = viewWriter.createEditableElement( 'div', { class: 'simple-box-description' } );
return toWidgetEditable( div, viewWriter );
}
} );
}
}
如您所见,代码变得更加冗长且更长。这是因为您使用了较低级别的转换器。我们计划在将来提供更多方便的小部件转换实用程序。阅读更多信息(和 👍) 此票证。
# 将简单框转换为小部件后的行为
现在,您应该看到您的简单框插件发生了什么变化。
您应该观察到
<section>
、<h1>
和<div>
元素具有contentEditable
属性(以及一些类)。此属性告诉浏览器元素是否被视为可编辑的。将元素通过toWidget()
传递将使其内容不可编辑。相反,通过toWidgetEditable()
传递它将使其内容再次可编辑。- 您现在可以单击小部件(灰色区域)来选择它。一旦选中,它就更容易进行复制粘贴。
- 小部件及其嵌套的可编辑区域对悬停、选择和焦点(轮廓)做出反应。
换句话说,简单框实例变得更具响应性。
此外,如果您调用 editor.getData()
,您将获得与将简单框转换为小部件之前相同的 HTML。这是由于仅在 editingDowncast
管道中使用 toWidget()
和 toNestedEditable()
。
现在,您只需要从模型和视图层中获得这些。就可编辑性和数据输入/输出而言,它完全可以工作。现在找到一种方法将新的简单框插入文档!
# 创建命令
一个 命令 是一个动作和一个状态的组合。您可以通过它们公开的命令与大多数编辑器功能进行交互。这不仅允许执行这些功能(例如,使文本片段变为粗体),还可以检查此操作是否可以在选择的当前位置执行,以及观察其他状态属性(例如,当前选定的文本是否已变为粗体)。
对于简单框,情况很简单
- 您需要一个“插入新简单框”动作。
- 您需要一个“您可以在此处(在当前选择位置)插入新的简单框”检查。
在 simplebox/
目录中创建一个名为 insertsimpleboxcommand.js
的新文件。您将使用 model.insertObject()
方法,该方法将能够(例如,如果您尝试在段落的中间插入一个简单框,则拆分段落(这在模式中不允许)。
// simplebox/insertsimpleboxcommand.js
import { Command } from 'ckeditor5';
export default class InsertSimpleBoxCommand extends Command {
execute() {
this.editor.model.change( writer => {
// Insert <simpleBox>*</simpleBox> at the current selection position
// in a way that will result in creating a valid model structure.
this.editor.model.insertObject( createSimpleBox( writer ) );
} );
}
refresh() {
const model = this.editor.model;
const selection = model.document.selection;
const allowedIn = model.schema.findAllowedParent( selection.getFirstPosition(), 'simpleBox' );
this.isEnabled = allowedIn !== null;
}
}
function createSimpleBox( writer ) {
const simpleBox = writer.createElement( 'simpleBox' );
const simpleBoxTitle = writer.createElement( 'simpleBoxTitle' );
const simpleBoxDescription = writer.createElement( 'simpleBoxDescription' );
writer.append( simpleBoxTitle, simpleBox );
writer.append( simpleBoxDescription, simpleBox );
// There must be at least one paragraph for the description to be editable.
// See https://github.com/ckeditor/ckeditor5/issues/1464.
writer.appendElement( 'paragraph', simpleBoxDescription );
return simpleBox;
}
导入命令并在 SimpleBoxEditing
插件中注册它
// simplebox/simpleboxediting.js
import { Plugin, Widget, toWidget, toWidgetEditable } from 'ckeditor5';
import InsertSimpleBoxCommand from './insertsimpleboxcommand'; // ADDED
export default class SimpleBoxEditing extends Plugin {
static get requires() {
return [ Widget ];
}
init() {
console.log( 'SimpleBoxEditing#init() got called' );
this._defineSchema();
this._defineConverters();
// ADDED
this.editor.commands.add( 'insertSimpleBox', new InsertSimpleBoxCommand( this.editor ) );
}
_defineSchema() {
// Previously registered schema.
// ...
}
_defineConverters() {
// Previously defined converters.
// ...
}
}
您现在可以执行此命令以插入一个新的简单框。调用
editor.execute( 'insertSimpleBox' );
它应该导致
您也可以尝试检查 isEnabled
属性值(或只是在 CKEditor 5 检查器中检查它)
console.log( editor.commands.get( 'insertSimpleBox' ).isEnabled );
它始终为 true
,除非选择在一个地方 - 在另一个简单框的标题中。您还可以观察到,当选择在该位置时执行命令不会产生任何影响。
在您继续前进之前,再更改一件事 - 也不允许 simpleBox
在 simpleBoxDescription
中。这可以通过 定义自定义子级检查 来完成
// simplebox/simpleboxediting.js
// Previously imported packages.
// ...
export default class SimpleBoxEditing extends Plugin {
static get requires() {
return [ Widget ];
}
init() {
console.log( 'SimpleBoxEditing#init() got called' );
this._defineSchema();
this._defineConverters();
this.editor.commands.add( 'insertSimpleBox', new InsertSimpleBoxCommand( this.editor ) );
}
_defineSchema() {
const schema = this.editor.model.schema;
schema.register( 'simpleBox', {
// Behaves like a self-contained block object (e.g. a block image)
// allowed in places where other blocks are allowed (e.g. directly in the root).
inheritAllFrom: '$blockObject'
} );
schema.register( 'simpleBoxTitle', {
// Cannot be split or left by the caret.
isLimit: true,
allowIn: 'simpleBox',
// Allow content which is allowed in blocks (i.e. text with attributes).
allowContentOf: '$block'
} );
schema.register( 'simpleBoxDescription', {
// Cannot be split or left by the caret.
isLimit: true,
allowIn: 'simpleBox',
// Allow content which is allowed in the root (e.g. paragraphs).
allowContentOf: '$root'
} );
// ADDED
schema.addChildCheck( ( context, childDefinition ) => {
if ( context.endsWith( 'simpleBoxDescription' ) && childDefinition.name == 'simpleBox' ) {
return false;
}
} );
}
_defineConverters() {
// Previously defined converters.
// ...
}
}
现在,当选择在另一个简单框实例的描述中时,命令也应该被禁用。
# 创建按钮
现在该允许编辑器用户将小部件插入内容中了。最好的方法是通过工具栏中的 UI 按钮。您可以使用 ButtonView
类(由 CKEditor 5 的 UI 框架 提供)快速创建一个。
按钮应该在单击时执行 命令,如果小部件无法插入选择的某个特定位置(如模式中所定义),则按钮将变为非活动状态。
看看它在实践中的样子,并扩展之前 创建的 SimpleBoxUI
插件
// simplebox/simpleboxui.js
import { ButtonView, Plugin } from 'ckeditor5';
export default class SimpleBoxUI extends Plugin {
init() {
console.log( 'SimpleBoxUI#init() got called' );
const editor = this.editor;
const t = editor.t;
// The "simpleBox" button must be registered among the UI components of the editor
// to be displayed in the toolbar.
editor.ui.componentFactory.add( 'simpleBox', locale => {
// The state of the button will be bound to the widget command.
const command = editor.commands.get( 'insertSimpleBox' );
// The button will be an instance of ButtonView.
const buttonView = new ButtonView( locale );
buttonView.set( {
// The t() function helps localize the editor. All strings enclosed in t() can be
// translated and change when the language of the editor changes.
label: t( 'Simple Box' ),
withText: true,
tooltip: true
} );
// Bind the state of the button to the command.
buttonView.bind( 'isOn', 'isEnabled' ).to( command, 'value', 'isEnabled' );
// Execute the command when the button is clicked (executed).
this.listenTo( buttonView, 'execute', () => editor.execute( 'insertSimpleBox' ) );
return buttonView;
} );
}
}
您需要做的最后一件事是告诉编辑器在工具栏中显示按钮。为此,您需要稍微修改运行编辑器实例的代码,并将按钮包含在 工具栏配置 中
ClassicEditor
.create( document.querySelector( '#editor' ), {
plugins: [ Essentials, Paragraph, Heading, List, Bold, Italic, SimpleBox ],
// Insert the "simpleBox" button into the editor toolbar.
toolbar: [ 'heading', 'bold', 'italic', 'numberedList', 'bulletedList', 'simpleBox' ]
} )
.then( editor => {
// This code runs after the editor initialization.
// ...
} )
.catch( error => {
// Error handling if something goes wrong during initialization.
// ...
} );
刷新网页并自己尝试一下
# 演示
您可以在下面的编辑器中看到块小部件的实现。如果您想开发自己的块小部件,您还可以查看本教程的完整 源代码。
这是一个简单框
框标题
描述在这里。
- 它可以包含列表,
- 以及其他块级元素,如标题。
# 最终解决方案
如果您在教程中的任何地方迷路了,或者想直接获得解决方案,则有一个包含 最终项目 的存储库。
npx -y degit ckeditor/ckeditor5-tutorials-examples/block-widget/final-project final-project
cd final-project
npm install
npm run dev
我们每天都在努力使我们的文档保持完整。您是否发现过时信息?是否缺少什么东西?请通过我们的 问题跟踪器 报告。
随着 42.0.0 版的发布,我们重新编写了我们的许多文档以反映新的导入路径和功能。感谢您的反馈,帮助我们确保文档的准确性和完整性。