Contribute to this guide

指南从外部数据源获取数据

在本教程中,您将学习如何实现一个小部件,该小部件从外部数据源获取数据并在设定的时间间隔内更新所有自己的实例。

您将构建一个“外部数据获取”功能,允许用户插入一个预定义的小部件,该小部件将显示当前的比特币汇率,并且它将使用设定的时间间隔从预定义的外部数据源获取数据进行更新。您将使用小部件实用程序和转换来定义此功能的行为。稍后,您将使用 UI 库 创建一个 ButtonView,允许插入外部数据源小部件的新实例。您还将学习如何根据编辑器 API 更新小部件数据。

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

# 开始之前

本指南假设您熟悉在 实现块小部件实现内联小部件 教程中介绍的小部件概念。本教程还引用了有关 CKEditor 5 架构 的各种概念。

# 引导项目

整体项目结构将类似于“实现内联小部件”教程的 引导项目 部分中所述的结构。

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

npx -y degit ckeditor/ckeditor5-tutorials-examples/data-from-external-source/starter-files data-from-external-source
cd data-from-external-source

npm install
npm run dev

这将在名为 data-from-external-source 的新目录中创建必要的文件。npm install 命令将安装所有依赖项,npm run dev 将启动开发服务器。

包含一些基本插件的编辑器是在 main.js 文件中创建的。

首先,让我们定义 ExternalDataWidget 插件。项目结构应如下所示

├── main.js
├── index.html
├── node_modules
├── package.json
├── external-data-widget
│   ├── externaldatawidget.js
│   ├── externaldatawidgetcommand.js
│   ├── externaldatawidgetediting.js
│   ├── externaldatawidgetui.js
│   └── theme
│       └── externaldatawidget.css
└── ...

您可以看到,外部数据小部件功能遵循已建立的插件结构:主(粘合)插件 (external-data-widget/externaldatawidget.js)、“编辑” (external-data-widget/externaldatawidgetediting.js) 和“UI” (external-data-widget/externaldatawidgetui.js) 部分。

主(粘合)插件

// external-data-widget/externaldatawidget.js

import { Plugin } from 'ckeditor5';

import ExternalDataWidgetEditing from './externaldatawidgetediting';
import ExternalDataWidgetUI from './externaldatawidgetui';

export default class ExternalDataWidget extends Plugin {
    static get requires() {
        return [ ExternalDataWidgetEditing, ExternalDataWidgetUI ];
    }
}

UI 部分(目前为空)

// external-data-widget/externaldatawidgetui.js

import { Plugin } from 'ckeditor5';

export default class ExternalDataWidgetUI extends Plugin {
    init() {
        console.log( 'ExternalDataWidgetUI#init() got called' );
    }
}

以及编辑部分(目前为空)

// external-data-widget/externaldatawidgetediting.js

import { Plugin } from 'ckeditor5';

export default class ExternalDataWidgetEditing extends Plugin {
    init() {
        console.log( 'ExternalDataWidgetEditing#init() got called' );
    }
}

最后,您需要在 main.js 文件中加载 ExternalDataWidget 插件

// main.js

import ExternalDataWidget from './external-data-widget/externaldatawidget';

ClassicEditor
    .create( document.querySelector( '#editor' ), {
        plugins: [ Essentials, Paragraph, Heading, List, Bold, Italic, ExternalDataWidget ],
        toolbar: [ 'heading', 'bold', 'italic', 'numberedList', 'bulletedList', '|', 'undo', 'redo' ]
    } );

在此阶段,您可以运行项目并在浏览器中打开它以验证它是否正在正确加载。

# 模型和视图层

外部数据小部件功能将 定义为内联(类似文本)元素,因此它将插入到允许文本的其他编辑器块中,例如 <paragraph>。外部数据小部件还将具有一个 data-resource-url 属性。这意味着外部数据小部件的模型表示将如下所示

<paragraph>
    External value: <externalElement data-resource-url="RESOURCE_URL"></externalElement>.
</paragraph>

上面介绍的语法由我们的调试工具(例如 CKEditor 5 检查器)使用,这在开发新的富文本编辑器功能时特别有用。

# 定义模式

此小部件的模式定义与 内联小部件 教程中的定义几乎相同,唯一的区别是 allowAttributes。在我们的例子中,我们要允许 'data-resource-url' 属性。
与其将所有属性传递给配置对象,我们可以使用 泛型项 来继承已预定义的选项。

您还可以利用此机会导入主题文件 (theme/externaldatawidget.css)。

// external-data-widget/externaldatawidgetediting.js

import { Plugin } from 'ckeditor5';

import './theme/externaldatawidget.css';                                           // ADDED

export default class ExternalDataWidgetEditing extends Plugin {
    init() {
        console.log( 'ExternalDataWidgetEditing#init() got called' );

        this._defineSchema();                                                  // ADDED
    }

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

        schema.register( 'externalElement', {
            // Inheriting all from the generic item
            inheritAllFrom: '$inlineObject',

            // The external data widget can have many attributes
            allowAttributes: [ 'data-resource-url' ]
        } );
    }
}

定义好模式后,您现在可以定义模型-视图转换器。

# 定义转换器

转换器的 HTML 结构(数据输出)将是一个 <span>,其中包含一个 data-resource-url 属性,其值为外部资源 URL。

<span data-resource-url="RESOURCE_URL"></span>
  • 向上转换。此视图到模型转换器将查找具有 data-resource-url 属性的 <span>,并将创建具有相同 data-resource-url 属性的模型 <externalElement> 元素,并相应地设置。
  • 向下转换。模型到视图的转换对于“编辑”和“数据”管道将略有不同,因为“编辑向下转换”管道将使用小部件实用程序来启用编辑视图中的小部件特定行为。在这两个管道中,元素将使用相同的结构呈现。
// external-data-widget/externaldatawidgetediting.js

// ADDED 2 imports
import { Plugin, Widget, toWidget } from 'ckeditor5';

import './theme/externaldatawidget.css';

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

    init() {
        console.log( 'ExternalDataWidgetEditing#init() got called' );

        this._defineSchema();
        this._defineConverters();                                              // ADDED
    }

    _defineSchema() {                                                          // ADDED
        // Previously registered schema.
        // ...
    }

    _defineConverters() {                                                      // ADDED
        const editor = this.editor;

        editor.conversion.for( 'upcast' ).elementToElement( {
            view: {
                name: 'span',
                attributes: [ 'data-resource-url' ]
            },
            model: ( viewElement, { writer } ) => {
                const externalUrl = viewElement.getAttribute( 'data-resource-url' );

                return writer.createElement( 'externalElement', {
                    'data-resource-url': externalUrl
                } );
            }
        } );

        editor.conversion.for( 'dataDowncast' ).elementToElement( {
            model: 'externalElement',
            view: ( modelElement, { writer } ) => {
                return writer.createEmptyElement( 'span', {
                    'data-resource-url': modelElement.getAttribute( 'data-resource-url' )
                } );
            }
        } );

        editor.conversion.for( 'editingDowncast' ).elementToElement( {
            model: 'externalElement',
            view: ( modelElement, { writer } ) => {
                const externalDataPreviewElement = writer.createRawElement( 'span', null, function( domElement ) {
                    // For now show some static text
                    domElement.textContent = 'Data placeholder';
                } );

                const externalWidgetContainer = writer.createContainerElement( 'span', null, externalDataPreviewElement );

                return toWidget( externalWidgetContainer, writer, {
                    label: 'External widget'
                } );
            }
        } );
    }
}

# 功能样式

如您所见,编辑部分导入 ./theme/externaldatawidget.css CSS 文件,该文件描述了小部件的外观以及新值到达时小部件的动画方式

/* external-data-widget/theme/externaldatawidget.css */

.external-data-widget {
    border: 2px solid rgb(242, 169, 0);
}

.external-data-widget-bounce {
    animation: external-data-widget-bounce-animation 1.5s 1;
}

@keyframes external-data-widget-bounce-animation {
    0% {
        box-shadow: 0px 0px 0px 0px rgba(242, 169, 0, 1);
    }

    100% {
        box-shadow: 0px 0px 0px 10px rgba(242, 169, 0, 0);
    }
}

# 命令

外部数据小部件功能的 命令 将在选择处插入一个 <externalElement> 元素(如果模式允许),并将选择设置在插入的小部件上。

// external-data-widget/externaldatawidgetcommand.js

import { Command } from 'ckeditor5';

// example external data source url
const RESOURCE_URL = 'https://api2.binance.com/api/v3/ticker/24hr?symbol=BTCUSDT';

class ExternalDataWidgetCommand extends Command {
    execute() {
        const editor = this.editor;
        const selection = editor.model.document.selection;

        editor.model.change( writer => {
            // Create an <externalElement> element with the "data-resource-url" attribute
            // (and all the selection attributes)...
            const externalWidget = writer.createElement(
                'externalElement', {
                    ...Object.fromEntries( selection.getAttributes() ),
                    'data-resource-url': RESOURCE_URL
                }
            );

            // ... insert it into the document and put the selection on the inserted element.
            editor.model.insertObject( externalWidget, null, null, {
                setSelection: 'on'
            } );
        } );
    }

    refresh() {
        const model = this.editor.model;
        const selection = model.document.selection;

        const isAllowed = model.schema.checkChild( selection.focus.parent, 'externalElement' );

        this.isEnabled = isAllowed;
    }
}

导入新创建的命令并将其添加到编辑器命令中

// external-data-widget/externaldatawidgetediting.js

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

import ExternalDataWidgetCommand from './externaldatawidgetcommand';                   // ADDED
import './theme/externaldatawidget.css';

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

    init() {
        console.log( 'ExternalDataWidgetEditing#init() got called' );

        this._defineSchema();
        this._defineConverters();

        // ADDED
        this.editor.commands.add( 'external', new ExternalDataWidgetCommand( this.editor ) );
    }

    _defineSchema() {
        // Previously registered schema.
        // ...
    }

    _defineConverters() {
        // Previously defined converters.
        // ...
    }
}

# 创建 UI

UI 部分提供一个 ButtonView,用户可以单击该按钮将外部数据小部件插入编辑器。

注册和配置工具栏按钮,如下所示。按钮的图标可以在官方 比特币宣传图形 中找到。将 SVG 文件放在 ./theme 目录中,并在 UI 插件旁边导入它,以便按钮可以使用它。

// external-data-widget/externaldatawidgetui.js

import { Plugin, ButtonView } from 'ckeditor5';

import BitcoinLogoIcon from './theme/bitcoin-logo.svg';

class ExternalDataWidgetUI extends Plugin {
    init() {
        const editor = this.editor;
        const externalWidgetCommand = editor.commands.get( 'external' );

        // The "external" button must be registered among the UI components of the editor
        // to be displayed in the toolbar.
        editor.ui.componentFactory.add( 'external', locale => {
            const button = new ButtonView( locale );

            button.set( {
                label: 'Bitcoin rate',
                tooltip: true,
                withText: false,
                icon: BitcoinLogoIcon
            } );

            // Disable the external data widget button when the command is disabled.
            button.bind( 'isEnabled' ).to( externalWidgetCommand );

            // Execute the command when the button is clicked (executed).
            button.on( 'execute', () => {
                editor.execute( 'external' );
                // Set focus on the editor content
                editor.editing.view.focus();
            } );

            return button;
        } );
    }
}

ButtonView 添加到工具栏

// main.js

import {
    ClassicEditor,
    Bold,
    Italic,
    Essentials,
    Heading,
    List,
    Paragraph
} from 'ckeditor5';

import ExternalDataWidgetCommand from './externaldatawidgetcommand';

ClassicEditor
    .create( document.querySelector( '#editor' ), {
        plugins: [ Essentials, Paragraph, Heading, List, Bold, Italic, ExternalDataWidget ],

        // Insert the "external" button into the editor toolbar.
        toolbar: [ 'heading', 'bold', 'italic', 'numberedList', 'bulletedList', '|', 'external', '|', 'undo', 'redo' ]
    } )
    .then( editor => {
        console.log( 'Editor was initialized', editor );

        // Expose for playing in the console.
        window.editor = editor;
    } )
    .catch( error => {
        console.error( error.stack );
    } );

# 处理外部数据源

在本教程中,我们将使用一个提供当前比特币汇率(以美元计)的外部 API。此端点不需要任何 API 密钥,并且可以免费使用,但它有一些 限制

'https://api2.binance.com/api/v3/ticker/24hr?symbol=BTCUSDT'

数据将每 10 秒获取一次。每个小部件实例都将在同一时间更新。要实现这一点,我们需要修改 ExternalDataWidgetEditing 类。

class ExternalDataWidgetEditing extends Plugin {
    //
    constructor( editor ) {
        // The default constructor calls the parent constructor
        super( editor );
        // Property that keep the interval id
        this.intervalId = this._intervalFetch();
        // Last fetched value
        this.externalDataValue = '';
    }

    static get requires() {
        return [ Widget ];
    }

    // This method will help us to clear the interval
    destroy() {
        clearInterval( this.intervalId );
    }

    init() {
        this._defineSchema();
        this._defineConverters();
        // Initial execute function to fetch and update the data
        this._updateWidgetData();

        this.editor.commands.add( 'external', new ExternalDataWidgetCommand( this.editor ) );
    }

    // Interval function
    _intervalFetch() {
        return setInterval( () => this._updateWidgetData(), 10000 ); // set time interval to 10s
    }

    // Fetch data and update all widget instances
    async _updateWidgetData( externalUrl = RESOURCE_URL ) {
        try {
            const response = await fetch( externalUrl );
            const data = await response.json();
            const updateTime = new Date( data.closeTime );

            // Example parsed data: $17098.35 - 09/11/2022, 18:04:18
            const parsedData = '$' + Number( data.lastPrice ).toFixed( 2 ) + ' - ' + updateTime.toLocaleString();

            // Update property with last fetched and parsed data
            this.externalDataValue = parsedData;

            const rootElement = this.editor.model.document.getRoot();

            // Iterate over whole editor content, search for external data widget instances
            // and trigger `recovertItem` function
            for ( const { item } of this.editor.model.createRangeIn( rootElement ) ) {
                if ( item.is( 'element', 'externalElement' ) ) {
                    this.editor.editing.reconvertItem( item );
                }
            }
        } catch ( error ) {
            console.error( error );
        }
    }

    _defineSchema() {
        // Previously registered schema.
        // ...
    }

    _defineConverters() {
        // Previously defined upcast and data downcast converters.
        // ...

        editor.conversion.for( 'editingDowncast' ).elementToElement( {
            model: 'externalElement',
            view: ( modelElement, { writer } ) => {
                const externalValueToShow = this.externalDataValue;

                const externalDataPreviewElement = writer.createRawElement( 'span', null, function( domElement ) {
                    // CSS class responsible for the appearance of the widget
                    domElement.classList.add( 'external-data-widget' );
                    // When the value is not present (initial run) show a placeholder
                    domElement.textContent = externalValueToShow || 'Fetching data...';

                    // If a new value arrives, add a CSS animation effect to show that data were updated
                    if ( externalValueToShow ) {
                        domElement.classList.add( 'external-data-widget-bounce' );
                        // Remove the animation class when it ends
                        setTimeout( () => domElement.classList.remove( 'external-data-widget-bounce' ), 1100 );
                    }
                } );

                const externalWidgetContainer = writer.createContainerElement( 'span', null, externalDataPreviewElement );

                return toWidget( externalWidgetContainer, writer, {
                    label: 'External widget'
                } );
            }
        } );
    }
}

编辑器内容遍历可能是一个具有挑战性的过程。当内容相对较少时,呈现的方法就足够了。否则,WeakMap 将是一个更好的选择。

# 演示

您可以在下面的编辑器中查看外部数据小部件实现的实际效果。

从 Binance 获取的当前汇率(每 10 秒更新一次)

比特币汇率: 

# 最终解决方案

如果您在教程的任何步骤中迷路了,或者想直接进入解决方案,那么有一个包含 最终项目 的存储库可用。

npx -y degit ckeditor/ckeditor5-tutorials-examples/data-from-external-source/final-project final-project
cd final-project

npm install
npm run dev