Contribute to this guide

guide在块级小部件中使用 React 组件

在本教程中,您将学习如何实现一个编辑器插件,它在 CKEditor 5 小部件生态系统中利用了 React 库的功能。您将构建一个“产品预览”功能,该功能在编辑器中渲染一个实际的 React 组件,以显示有关产品的有用信息。

稍后,您将使用“产品预览”功能构建一个简单的 React 应用程序,该应用程序在可用产品列表旁边显示一个编辑器。该应用程序将允许用户通过单击列表中的产品将其插入编辑器内容。

如果您想在深入研究之前查看本教程的最终产品,请查看 演示

# 开始之前

在您开始之前,您应该了解一些事情。

  • 既然您在这里,您可能至少对 React 是什么以及它是如何工作的有一些基本了解。但您可能不知道的是,CKEditor 5 有一个官方的 用于 React 的富文本编辑器组件,它将是本教程中使用的关键功能之一。学习如何在您的项目中使用它是一个良好的起点。
  • 在本教程中,您将实现一个块级编辑器小部件,这本身就可能让您头疼。建议您至少浏览一下 实现块级小部件 教程,以了解编辑器小部件、它们的 API 和可能的用例。
  • 您将参考 CKEditor 5 架构 部分的各个部分。虽然阅读它们不是完成本教程的必要条件,但建议您在某个时间点阅读这些指南,以便更好地了解本教程中使用的机制。

如果您想为 React 组件触发的事件使用自己的事件处理程序,您必须将其用一个带有 data-cke-ignore-events 属性的容器包装起来,以将其从编辑器的默认处理程序中排除。有关更多详细信息,请参阅 从默认处理程序中排除 DOM 事件

# 让我们开始

最简单的入门方法是使用以下命令获取入门项目。

npx -y degit ckeditor/ckeditor5-tutorials-examples/react-widget/starter-files react-widget
cd react-widget

npm install
npm run dev

这将创建一个名为 react-widget 的新目录,其中包含必要的文件。npm install 命令将安装所有依赖项,而 npm run dev 将启动开发服务器。

带有某些基本插件的编辑器是在 main.js 文件中创建的。

打开终端中显示的 URL。如果一切顺利,您应该在 Web 浏览器中看到一个“Hello world”应用程序,它可能并不多,但这是一个良好的开端。

Screenshot of the “Hello world” application in web browser.

# 应用程序结构

没有什么比一个好的“Hello world!”更能温暖开发人员的心了,但您可能同意您创建的应用程序并不是最实用的,现在是改变它的时候了。在接下来的部分中,您将创建一些 React 组件和 CKEditor 5 类,为应用程序带来一些真正的逻辑。

为了保持项目中的一些顺序,您将把 CKEditor 类 放入 /ckeditor 目录,并将 React 组件 放入 /react 目录。 图像 将位于 /public 目录中。当您完成本教程时,项目的结构应如下所示。

├── public
│    ├── fields.jpg
│    ├── malta.jpg
│    ├── tajmahal.jpg
│    └── umbrellas.jpg
├── src
│    ├── ckeditor
│    │   ├── insertproductpreviewcommand.js
│    │   └── productpreviewediting.js
│    ├── react
│    │   ├── productlist.jsx
│    │   └── productpreview.jsx
│    ├── app.jsx
│    ├── main.jsx
│    └── styles.css
├── index.html
├── package.json
└── node_modules

# CKEditor 类

创建支持编辑器内容中产品预览小部件的 CKEditor 侧逻辑。

本指南假设您熟悉 实现块级小部件 指南,该指南解释了数据结构和小部件背后的基本概念。如有疑问,请参阅该指南以获取更多信息。

# 编辑插件

ProductPreviewEditing 插件在编辑器 模型 中定义了 productPreview 元素,并指定了将其转换为编辑和数据 视图 的方式。

详细了解 CKEditor 5 的 编辑引擎架构

  • 在 **数据视图** 中,productPreview 表示为一个空的 <section class="product" data-id="..."></section> 元素,带有一个 data-id 属性,将其与特定产品相关联。然后,可以通过使用 data-id 检索新的预览,在前端使用数据库中保存产品的语义表示。由于它不包含任何格式或样式,因此数据表示永远不会过时,即使应用程序的布局或样式在将来发生变化。
  • 另一方面,在 **编辑视图** 中,产品预览是一个 块级小部件,它充当一个自包含的内容,用户可以将其作为一个整体进行插入、复制和粘贴,但他们无法更改其内部结构。在小部件内部,有一个带有 .product__react-wrapper 类的 UIElement,它托管一个 React <ProductPreview> 组件。每次模型元素被向上转换时,在 编辑器配置editor.config.products.productRenderer)中指定的渲染函数都会在 UIElement 中安装一个 React 组件。

我们建议使用官方的 CKEditor 5 检查器 进行开发和调试。它将为您提供有关编辑器状态的大量有用信息,例如内部数据结构、选择、命令等等。

产品预览数据表示的差异在以下表格中总结。

数据结构 表示
模型
<productPreview id="..." />
编辑视图
<section class="product" data-id="...">
    <div class="product__react-wrapper">
        <ProductPreview /> // React component
    </div>
</section>
数据视图(编辑器输出)
<section class="product" data-id="..."></section>

以下是 ProductPreviewEditing 编辑器插件的完整源代码。

// ckeditor/productpreviewediting.js

import { Plugin, Widget, toWidget } from 'ckeditor5';

import InsertProductPreviewCommand from './insertproductpreviewcommand';

export default class ProductPreviewEditing extends Plugin {
    static get requires() {
        return [ Widget ];
    }

    init() {
        this._defineSchema();
        this._defineConverters();

        this.editor.commands.add( 'insertProduct', new InsertProductPreviewCommand( this.editor ) );
    }

    _defineSchema() {
        const schema = this.editor.model.schema;

        schema.register( 'productPreview', {
            // Behaves like a self-contained object (e.g. an image).
            isObject: true,

            // Allow in places where other blocks are allowed (e.g. directly in the root).
            allowWhere: '$block',

            // Each product preview has an ID. A unique ID tells the application which
            // product it represents and makes it possible to render it inside a widget.
            allowAttributes: [ 'id' ]
        } );
    }

    _defineConverters() {
        const editor = this.editor;
        const conversion = editor.conversion;
        const renderProduct = editor.config.get( 'products' ).productRenderer;

        // <productPreview> converters ((data) view → model)
        conversion.for( 'upcast' ).elementToElement( {
            view: {
                name: 'section',
                classes: 'product'
            },
            model: ( viewElement, { writer: modelWriter } ) => {
                // Read the "data-id" attribute from the view and set it as the "id" in the model.
                return modelWriter.createElement( 'productPreview', {
                    id: parseInt( viewElement.getAttribute( 'data-id' ) )
                } );
            }
        } );

        // <productPreview> converters (model → data view)
        conversion.for( 'dataDowncast' ).elementToElement( {
            model: 'productPreview',
            view: ( modelElement, { writer: viewWriter } ) => {
                // In the data view, the model <productPreview> corresponds to:
                //
                // <section class="product" data-id="..."></section>
                return viewWriter.createEmptyElement( 'section', {
                    class: 'product',
                    'data-id': modelElement.getAttribute( 'id' )
                } );
            }
        } );

        // <productPreview> converters (model → editing view)
        conversion.for( 'editingDowncast' ).elementToElement( {
            model: 'productPreview',
            view: ( modelElement, { writer: viewWriter } ) => {
                // In the editing view, the model <productPreview> corresponds to:
                //
                // <section class="product" data-id="...">
                //     <div class="product__react-wrapper">
                //         <ProductPreview /> (React component)
                //     </div>
                // </section>
                const id = modelElement.getAttribute( 'id' );

                // The outermost <section class="product" data-id="..."></section> element.
                const section = viewWriter.createContainerElement( 'section', {
                    class: 'product',
                    'data-id': id
                } );

                // The inner <div class="product__react-wrapper"></div> element.
                // This element will host a React <ProductPreview /> component.
                const reactWrapper = viewWriter.createRawElement( 'div', {
                    class: 'product__react-wrapper'
                }, function( domElement ) {
                    // This the place where React renders the actual product preview hosted
                    // by a UIElement in the view. You are using a function (renderer) passed as
                    // editor.config.products#productRenderer.
                    renderProduct( id, domElement );
                } );

                viewWriter.insert( viewWriter.createPositionAt( section, 0 ), reactWrapper );

                return toWidget( section, viewWriter, { label: 'product preview widget' } );
            }
        } );
    }
}

# 命令

InsertProductPreviewCommandproductPreview 元素插入当前选择位置的模型中。它由应用程序侧边栏中的 <ProductPreview> React 组件执行,以将小部件插入编辑器内容中。

实现块级小部件 指南中详细了解小部件命令。您可以在有关 主应用程序组件 的部分中看到此命令的实际操作。

// ckeditor/insertproductpreviewcommand.js

import { Command } from 'ckeditor5';

export default class InsertProductPreviewCommand extends Command {
    execute( id ) {
        this.editor.model.change( writer => {
            // Insert <productPreview id="...">*</productPreview> at the current selection position
            // in a way which will result in creating a valid model structure.
            this.editor.model.insertContent( writer.createElement( 'productPreview', { id } ) );
        } );
    }

    refresh() {
        const model = this.editor.model;
        const selection = model.document.selection;
        const allowedIn = model.schema.findAllowedParent( selection.getFirstPosition(), 'productPreview' );

        this.isEnabled = allowedIn !== null;
    }
}

# React 组件

现在是定义渲染实际布局的应用程序的 React 侧的时候了。

  • <ProductList> 组件显示一堆 <ProductPreview> 子项,并允许用户单击它们以将其插入编辑器中。
  • <ProductPreview> 组件表示具有名称、价格标签和背景图像的单个产品。
  • <App> 组件将所有内容粘合在一起。

# 产品列表

<ProductList> React 组件渲染 <ProductPreview> 的实例。单击时,预览会执行 “prop” 中传递的回调,该回调通过执行 'insertProduct' 编辑器命令将自己的副本插入编辑器内容中。该列表显示在 应用程序 的侧边栏中。

// react/productlist.js

import ProductPreview from './productpreview';

export default function ProductList( props ) {
    return (
        <div className='app__product-list'>
            <h3>Products</h3>
            <ul>
                {props.products.map( ( product ) => {
                    return (
                        <li key={ product.id }>
                            <ProductPreview
                                id={ product.id }
                                onClick={ props.onClick }
                                { ...product }
                            />
                        </li>
                    );
                })}
            </ul>
            <p><b>Tip</b>: Clicking the product will add it to the editor.</p>
        </div>
    );
}

# 产品预览

产品的实际预览,包括其名称、价格和图像。<ProductPreview> 组件的实例填充 <ProductList> 和内容中的 编辑器小部件

单击侧边栏中的预览将执行 'insertProduct' 编辑器命令,并将相同的预览插入编辑器内容中。

// react/productpreview.js

export default function ProductPreview( props ) {
    return (
        <div
            className='product-preview'
            style={ {
                '--product-image': `url(${ props.image })`
            } }
        >
            <button
                className='product-preview__add'
                onClick={ () => props.onClick( props.id ) }
                title='Add to the offer'
            >
                <span>+</span>
            </button>
            <span className='product-preview__name'>{ props.name }</span>
            <span className='product-preview__price'>from { props.price }</span>
        </div>
    );
}

# 主应用程序组件

目前,您拥有将产品预览引入内容的 CKEditor 类、产品列表和一个准备就绪的产品组件。现在是将它们在 App 组件中粘合在一起的时候了。

您将扩展本教程前面创建的 主应用程序文件 架构,以便它在左侧渲染 官方 <CKEditor> React 组件,并在右侧渲染可用产品的列表。

查看 App 函数的完整源代码。

// app.jsx

// Imports necessary to run a React application.
import { useState } from 'react';
import { createRoot } from 'react-dom/client';
// The official <CKEditor> component for React.
import { CKEditor } from '@ckeditor/ckeditor5-react';
// The base editor class and features required to run the editor.
import {
    ClassicEditor,
    Bold,
    Italic,
    Underline,
    Essentials,
    Heading,
    Link,
    Paragraph,
    Table,
    TableToolbar
} from 'ckeditor5';
// The official CKEditor 5 instance inspector. It helps understand the editor view and model.
import CKEditorInspector from '@ckeditor/ckeditor5-inspector';
// CKEditor plugin implementing a product widget to be used in the editor content.
import ProductPreviewEditing from './ckeditor/productpreviewediting';
// React components to render the list of products and the product preview.
import ProductList from './react/productlist';
import ProductPreview from './react/productpreview';

import 'ckeditor5/ckeditor5.css';
import './styles.css';

// The React application function component. It renders the editor and the product list.
export default function App( props ) {
    // A place to store the reference to the editor instance created by the <CKEditor> component.
    // The editor instance is created asynchronously and is only available when the editor is ready.
    const [ editorRef, setEditorRef ] = useState( null );
    // The initial editor data. It is bound to the editor instance and will change as
    // the user types and modifies the content of the editor.
    const [ editorData, setEditorData ] = useState( `<h2>Check our last minute deals!</h2>

    <p>Aenean erat conubia pretium libero habitant turpis vivamus dignissim molestie, phasellus libero! Curae; consequat cubilia mattis. Litora non iaculis tincidunt.</p>
    <section class="product" data-id="2">&nbsp;</section>
    <p>Mollis gravida parturient ad maecenas euismod consectetur lacus rutrum urna eget ligula. Nisi imperdiet scelerisque natoque scelerisque cubilia nulla gravida. Eleifend malesuada pharetra est commodo venenatis aenean habitasse curae; fusce elit.</p>
    <section class="product" data-id="1">&nbsp;</section>

    <h3>Other deals</h3>
    <p>Ultricies dapibus placerat orci natoque fames commodo facilisi sollicitudin. Sed hendrerit mi dis non lacinia ipsum. Luctus fames scelerisque auctor pellentesque mi nunc mattis, amet sapien.</p>

    <figure class="table">
        <table>
            <thead>
                <tr>
                    <th>Our deal</th>
                    <th>Why this one?</th>
                </tr>
            </thead>
            <tbody>
                <tr>
                    <td>
                        <section class="product" data-id="3">&nbsp;</section>
                    </td>
                    <td>Nascetur, nullam hac nibh curabitur elementum. Est ridiculus turpis adipiscing erat maecenas habitant montes. Curabitur mauris ut luctus semper. Neque orci auctor luctus accumsan quam cursus purus condimentum dis?</td>
                </tr>
                <tr>
                    <td>
                        <section class="product" data-id="4">&nbsp;</section>
                    </td>
                    <td>Elementum condimentum convallis porttitor cubilia consectetur cum. In pretium neque accumsan pharetra. Magna in quisque dignissim praesent facilisi diam. Ad habitant ultricies at faucibus. Ultricies auctor sodales massa nisi eget sem porta?</td>
                </tr>
            </tbody>
        </table>
    </figure>` );

    return (
        // The application renders two columns:
        // * in the left one, the <CKEditor> and the textarea displaying live
        //   editor data are rendered.
        // * in the right column, a <ProductList> is rendered with available <ProductPreviews>
        //   to choose from.
        <div ref={ setEditorRef } className='app'>
            { editorRef && <>
                <div className='app__offer-editor' key='offer-editor'>
                    <CKEditor
                        editor={ ClassicEditor }
                        // The configuration of the <CKEditor> instance.
                        config={ {
                            plugins: [
                                // A set of editor features to be enabled and made available to the user.
                                Essentials, Heading, Bold, Italic, Underline,
                                Link, Paragraph, Table, TableToolbar,
                                // Your custom plugin implementing the widget is loaded here.
                                ProductPreviewEditing
                            ],
                            toolbar: [
                                'heading',
                                '|',
                                'bold', 'italic', 'underline',
                                '|',
                                'link', 'insertTable',
                                '|',
                                'undo', 'redo'
                            ],
                            table: {
                                contentToolbar: [
                                    'tableColumn',
                                    'tableRow',
                                    'mergeTableCells'
                                ]
                            },
                            // The configuration of the Products plugin. It specifies a function that will allow
                            // the editor to render a React <ProductPreview> component inside a product widget.
                            products: {
                                productRenderer: ( id, domElement ) => {
                                    const product = props.products.find( product => product.id === id );
                                    const root = createRoot( domElement );
                        
                                    root.render(
                                        <ProductPreview id={ id } { ...product } />
                                    );
                                }
                            }
                        } }
                        data={ editorData }
                        onReady={ ( editor ) => {
                            // A function executed when the editor has been initialized and is ready.
                            // It synchronizes the initial data state and saves the reference to the editor instance.
                            setEditorRef( editor );
                            // CKEditor&nbsp;5 inspector allows you to take a peek into the editor's model and view
                            // data layers. Use it to debug the application and learn more about the editor.
                            CKEditorInspector.attach( editor );
                        } }
                        onChange={ ( evt, editor ) => {
                            // A function executed when the user types or modifies the editor content.
                            // It updates the state of the application.
                            setEditorData( editor.getData() );
                        } }
                    />
                </div>
                <ProductList
                    key='product-list'
                    products={ props.products }
                    onClick={ ( id  ) => {
                        editorRef.execute( 'insertProduct', id );
                        editorRef.editing.view.focus();
                    } }
                />
            </> }
        </div>
    )
}

JavaScript 代码已准备就绪,但要运行应用程序,您需要指定几个产品定义。在挂载 <App> 组件时执行此操作。

// main.jsx

import ReactDOM from 'react-dom/client';
import App from './app';

// Render the <App> in the <div class="root"></div> element found in the DOM.
ReactDOM.createRoot( document.getElementById( 'root' ) ).render(
    <App 
        // Feeding the application with predefined products.
        // In a real-life application, this sort of data would be loaded
        // from a database. To keep this tutorial simple, a few
        // hard–coded product definitions will be used.
        products={ [
            {
                id: 1,
                name: 'Colors of summer in Poland',
                price: '$1500',
                image: 'fields.jpg'
            },
            {
                id: 2,
                name: 'Mediterranean sun on Malta',
                price: '$1899',
                image: 'malta.jpg'
            },
            {
                id: 3,
                name: 'Tastes of Asia',
                price: '$2599',
                image: 'umbrellas.jpg'
            },
            {
                id: 4,
                name: 'Exotic India',
                price: '$2200',
                image: 'tajmahal.jpg'
            }
        ] }
    />
)

每个产品都带有自己的图片(例如 malta.jpg),应将其存储在 public/ 目录中,以便与 CSS background-image 正确加载。有关样式的更多信息,请参阅下一部分

# 样式和资源

应用程序需要一些样式才能看起来美观。您将把它们放在 src/styles.css 文件中,该文件导入到您的 app.jsx 文件中。

/* src/styles.css */

/* --- General application styles --------------------------------------------------- */

.app {
    display: flex;
    flex-direction: row;
    justify-content: center;
    font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
    margin: 0 auto;
}

.app h2 {
    font-size: 1.3em;
}

.app textarea {
    width: 100%;
    height: 150px;
    font-family: 'Courier New', Courier, monospace;
    box-sizing: border-box;
    font-size: 14px;
}

/* --- Product offer editor styles ----------------------------------------------------- */

.app .app__offer-editor {
    flex: 1 1 auto;
    max-width: 800px;
}

/* --- Generic product preview styles --------------------------------------------------- */

.app .product-preview {
    background-repeat: no-repeat;
    background-position: center;
    background-image: var(--product-image);
    background-size: cover;
    height: 150px;
    position: relative;
    overflow: hidden;
    box-shadow: 1px 1px 3px hsla(0, 0%, 0%, .3);
    min-width: 160px;
}

.app .product-preview .product-preview__name {
    padding: 10px;
    background: hsl(0, 0%, 100%);
    font-weight: bold;
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
}

.app .product-preview .product-preview__price {
    position: absolute;
    top: 0;
    right: 0;
    display: block;
    background: hsl(346, 100%, 56%);
    padding: 6px 10px;
    min-width: 50px;
    text-align: center;
    color: hsl(0, 0%, 100%);
    text-transform: uppercase;
    font-size: .8em;
}

.app .product-preview .product-preview__add {
    display: none;
}

/* --- Product list styles --------------------------------------------------- */

.app .app__product-list {
    margin-left: 20px;
    padding: 20px;
    min-width: 400px;
    border-left: 1px solid hsl(0, 0%, 87%);
}

.app .app__product-list h2 {
    margin-top: 10px;
}

.app .app__product-list ul {
    display: grid;
    grid-template-columns: 1fr 1fr;
    grid-gap: 10px;
    margin-top: 10px;
    list-style-type: none;
    margin: 0;
    padding: 0;
}

.app .app__product-list .product-preview {
    opacity: .7;
}

.app .app__product-list .product-preview:hover {
    opacity: 1;
}

.app .app__product-list .product-preview:hover .product-preview__add {
    display: block;
}

.app .app__product-list .product-preview .product-preview__add {
    display: none;
    position: absolute;
    width: 40px;
    height: 40px;
    top: 45%;
    left: 50%;

    border: 0;
    padding: 0;
    cursor: pointer;
    font-weight: bold;
    text-align: center;
    border-radius: 100px;
    background: hsl(0, 0%, 100%);
    transform: translate(-50%, -50%);
    box-shadow: 2px 2px 2px hsla(0, 0%, 0%, .3);
}

.app .app__product-list .product-preview .product-preview__add span {
    font-size: 25px;
    vertical-align: middle;
    color: hsl(0, 0%, 24%);
    line-height: 40px;
    display: inline-block;
}

.app .app__product-list .product-preview .product-preview__name {
    font-size: 10px;
}

.app .app__product-list .product-preview .product-preview__price {
    font-size: 10px;
}

/* --- In-editor product widget styles --------------------------------------------------- */

.app .ck-content .product {
    margin: 1em;
    animation: slideUp 0.3s ease;
}

@keyframes slideUp {
    0% {
        opacity: 0;
        transform: translateY(1em);
    }
    100% {
        opacity: 1;
        transform: translateY(0);
    }
}

产品预览 (.product-preview 类) 使用 background-image: var(--product-image) 设置其背景。这意味着所有图像都必须存储在 public/ 目录中才能正确加载。

# 演示

您可以在下面看到整个应用程序的运行情况。单击侧边栏中的产品将它们添加到编辑器中。您还可以查看此教程的完整源代码,如果您想进一步扩展它或将其用作应用程序的基础。

# 最终解决方案

如果您在教程中的任何地方都迷路了,或者想直接查看解决方案,这里有一个包含最终项目的存储库。

npx -y degit ckeditor/ckeditor5-tutorials-examples/react-widget/final-project final-project
cd final-project

npm install
npm run dev