Contribute to this guide

guide提高可访问性和添加命令

您已经完成了缩写插件教程的最后部分。在本部分中,我们将改进插件的可访问性。我们还将处理一个命令,它将另外从用户的选择中获取文本,并将其插入到我们的表单中。等等!

我们从 第二部分 结束的地方开始,因此请确保您已经完成了它,或者使用以下命令获取本部分的入门文件。

npx -y degit ckeditor/ckeditor5-tutorials-examples/abbreviation-plugin/part-2 abbreviation-plugin
cd abbreviation-plugin

npm install
npm run dev

如果您想在深入了解之前查看本教程的最终产品,请查看 实时演示

# 提高可访问性

首先,我们使插件对依赖键盘导航的用户可访问。我们希望确保按下 TabShift + Tab 将在表单视图中移动焦点,并且按下 Esc 将关闭它。

要提高插件的可访问性,了解 CKEditor 5 框架中的按键和焦点管理机制非常重要。我们建议您 阅读基础知识,或 深入了解焦点跟踪

我们有一些现成的选项可以帮助我们——KeystrokeHandlerFocusTrackerFocusCycler 辅助类。

# 添加按键处理程序和焦点跟踪器

我们首先将 KeystrokeHandlerFocusTracker 类导入到我们的表单视图中,并在 constructor() 中创建它们的实例。

现在,在 render() 方法中,我们将 childViews 视图集合中的每个元素添加到 focusTracker 中。在那里,我们还可以开始监听来自渲染的视图元素的按键。

最后,让我们添加 destroy() 方法,并销毁焦点跟踪器和按键处理程序。它将确保当用户关闭编辑器时,我们的助手也“死亡”,防止任何内存泄漏。

// abbreviation/abbreviationview.js

import {
    View,
    LabeledFieldView,
    createLabeledInputText,
    ButtonView,
    submitHandler,
    icons,
    FocusTracker,		// ADDED
    KeystrokeHandler,	// ADDED
} from 'ckeditor5';

export default class FormView extends View {
    constructor( locale ) {
        // View class constructor invoke.
        // ...

        this.focusTracker = new FocusTracker();
        this.keystrokes = new KeystrokeHandler();

        // Code prepared in the previous part.
        // ...
    }

    render() {
        // View.render() invocation and adding a submit handler.
        // ...

        this.childViews._items.forEach( view => {
            // Register the view in the focus tracker.
            this.focusTracker.add( view.element );
        } );

        // Start listening for the keystrokes coming from #element.
        this.keystrokes.listenTo( this.element );
    }

    destroy() {
        super.destroy();

        this.focusTracker.destroy();
        this.keystrokes.destroy();
    }

    // Previously declared helper methods.
    // ...
}

# 添加焦点循环器

FocusCycler 将允许用户在表单视图的所有子元素中导航,并在它们之间循环。检查我们的表单视图中的导航是如何工作的——我们可以使用 Tab 从第一个输入字段移动到第二个,但随后焦点将离开表单,以及编辑器本身。让我们解决这个问题。

我们导入 FocusCycler 类,并在表单视图 constructor() 中创建它的实例。我们需要传入一个包含可聚焦元素(即我们的 childViews 集合)、焦点跟踪器、按键处理程序以及与不同按键相关的操作的对象。

// abbreviation/abbreviationview.js

import {
    View,
    LabeledFieldView,
    createLabeledInputText,
    ButtonView,
    submitHandler,
    FocusTracker,
    KeystrokeHandler,
    icons,
    FocusCycler		// ADDED
} from 'ckeditor5';

export default class FormView extends View {
    constructor( locale ) {

        // Previous code from the constructor.
        // ...

        this._focusCycler = new FocusCycler( {
            focusables: this.childViews,
            focusTracker: this.focusTracker,
            keystrokeHandler: this.keystrokes,
            actions: {
                // Navigate form fields backwards using the Shift + Tab keystroke.
                focusPrevious: 'shift + tab',

                // Navigate form fields forwards using the Tab key.
                focusNext: 'tab'
            }
        } );

        this.setTemplate( {
            // Setting the form template.
            // ...
        } );
    }

    // Previously declared methods.
    // ...
}

现在,我们可以在 _createFromView() 函数中添加 Esc 按钮处理程序,它将隐藏 UI 并触发表单上的 cancel

// abbreviation/abbreviationui.js

// Previously imported packages.
// ...

export default class AbbreviationUI extends Plugin {
    // Previously declared methods.
    // ...

    _createFormView() {
        // Form view initialization.
        // ...

        // Close the panel on esc key press when the form has focus.
        formView.keystrokes.set( 'Esc', ( data, cancel ) => {
            this._hideUI();
            cancel();
        } );

        return formView;
    }

    // Previously declared helper methods.
    // ...
}

我们已经完成了为仅使用键盘的用户改进可访问性。通过在表单中按下 TabShift + TabEsc 来自行尝试。

# 改进 UI 功能

当用户选择一个范围(一个字母、一个词或整个文档片段)并按下缩写按钮时,他们可能希望其选定的文本自动出现在缩写输入字段中。让我们将此功能添加到我们的表单中。

由于我们将使用文档中的用户选择,因此了解它在编辑器的模型中到底意味着什么非常重要。阅读我们关于 位置、范围和选择 的介绍,以扩展您在这方面的知识。

要将用户选择中的文本显示在表单字段中,我们需要首先获取并连接从选定范围中的所有文本。如果用户选择了一些段落、一个标题和一个图像,我们需要遍历所有节点,并且只使用包含文本的节点。

让我们在单独的 /utils.js 文件中创建一个辅助 getRangeText() 函数。它将使用其 getItems() 方法获取范围中的所有项目。然后,它将连接来自 texttextProxy 节点的所有文本,并跳过所有其他节点。

// abbreviation/utils.js

// A helper function that retrieves and concatenates all text within the model range.
export default function getRangeText( range ) {
    return Array.from( range.getItems() ).reduce( ( rangeText, node ) => {
        if ( !( node.is( 'text' ) || node.is( 'textProxy' ) ) ) {
            return rangeText;
        }

        return rangeText + node.data;
    }, '' );
}

现在,在 AbbreviationUI 中,我们可以调整 _showUI() 方法以将选定文本显示在缩写输入字段中。我们导入 getRangeText 并将选择中的第一个范围(使用 getFirstRange() 方法)作为参数传入。

我们还将在选择未折叠时禁用输入字段,因为如果选择跨越多个段落,则很难更改缩写的文本。

// abbreviation/abbreviationui.js

// Previously imported packages.
// ...

import getRangeText from './utils.js';

export default class AbbreviationUI extends Plugin {
    // Previously declared methods.
    // ...

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

        this._balloon.add( {
            view: this.formView,
            position: this._getBalloonPositionData()
        } );

        // Disable the input when the selection is not collapsed.
        this.formView.abbrInputView.isEnabled = selection.getFirstRange().isCollapsed;

        const selectedText = getRangeText( selection.getFirstRange() );
        this.formView.abbrInputView.fieldView.value = selectedText;
        this.formView.titleInputView.fieldView.value = '';

        this.formView.focus();
    }

    // Previously declared helper methods.
    // ...
}

由于我们在某些情况下禁用了第一个输入字段,让我们相应地更新表单视图中的 focus() 方法。

// abbreviation/abbreviationview.js

// Previously imported packages.
// ...

export default class FormView extends View {
    // Previously declared constructor and other methods.
    // ...

    focus() {
        // If the abbreviation text field is enabled, focus it.
        if ( this.abbrInputView.isEnabled ) {
            this.abbrInputView.focus();
        }
        // Focus the abbreviation title field if the former is disabled.
        else {
            this.titleInputView.focus();
        }
    }

    // Previously declared helper methods.
    // ...
}

我们的新功能现在应该可以工作了,自己尝试一下!它不识别选定的文本是否已经是缩写,因此如果您选择“WYSIWYG”,则完整的标题尚未出现在标题输入字段中。我们将在接下来的步骤中更改它。

# 添加命令

我们的插件完成了我们想要它做的事情,那么为什么通过添加命令来使事情复杂化呢?嗯,命令不仅执行操作,而且还会在对模型应用任何更改时自动做出反应。

CKEditor 5 中的命令是操作和状态的组合。无论何时对模型进行任何更改,命令的状态都会刷新。我们强烈建议您 阅读有关命令的信息,然后再继续。

当用户在编辑器中进行选择时,命令将自动检查该选择中是否包含缩写。它还将确保只有在当前模型选择(例如,不在图像上)可以设置“缩写”属性的情况下,命令才被启用。

# 创建命令

让我们从创建命令开始,并将现有的操作逻辑移动到那里。

/abbreviationcommand.js 文件中,我们导入 Command 类并创建它的实例。

我们将从简单地将我们之前为 submit 创建的操作移动到 _createFormView() 方法中开始,将标题和缩写文本传递到命令的 execute() 方法中。

// abbreviation/abbreviationcommand.js

import { Command } from 'ckeditor5';

export default class AbbreviationCommand extends Command {
    execute( { title, abbr } ) {
        const model = this.editor.model;

        model.change( writer => {
            model.insertContent(
                writer.createText( abbr, { abbreviation: title } )
            );
        } );

    }
}

现在,让我们初始化 AbbreviationCommand,通过将其添加到编辑器命令列表中的 AbbreviationEditing 中。我们还将传递一个名称,我们将使用它来调用我们的命令。

// abreviation/abbreviationediting.js

import { Plugin } from 'ckeditor5';
import AbbreviationCommand from './abbreviationcommand';	// ADDED

export default class AbbreviationEditing extends Plugin {
    init() {
        this._defineSchema();
        this._defineConverters();

        this.editor.commands.add(
            'addAbbreviation', new AbbreviationCommand( this.editor )
        );
    }

    // Previously declared methods.
    // ...
}

我们现在可以用新的命令替换 submit 上调用的操作,将其传入编辑器的 execute() 方法中,以及缩写和标题值。

// abbreviation/abbreviationui.js

// Previously imported packages.
// ...

export default class AbbreviationUI extends Plugin {
    // Previously declared methods.
    // ...

    _createFormView() {
        const editor = this.editor;
        const formView = new FormView( editor.locale );

        // Execute the command after clicking the "Save" button.
        this.listenTo( formView, 'submit', () => {
            const value = {
                abbr: formView.abbrInputView.fieldView.element.value,
                title: formView.titleInputView.fieldView.element.value
            };
            editor.execute( 'addAbbreviation', value );

            this._hideUI();
        } );

        // Handle clicking outside the balloon and on the "Cancel" button.
        // ...
    }

    // Previously declared helper methods.
    // ...
}

命令现在应该可以工作了,按下 Submit 按钮应该与之前具有相同的效果。我们现在可以探索一些额外的功能。您现在可以在 CKEditor 5 检查器中查看它。

Screenshot of the CKEditor 5 inspector showing the <code>addAbbreviation</code> command.

# 刷新状态

由于命令的 refresh() 方法,我们可以观察命令的状态和值,不仅是在用户按下按钮时,而是在编辑器中进行任何更改时。我们将使用它来检查用户的选择是否已经具有缩写模型属性。

在我们这样做之前,我们可能想检查命令是否可以在给定的选择上使用。如果用户选择了一个图像,则命令应该被禁用。让我们使用其 checkAttributeInSelection() 方法来检查我们的 abbreviation 属性是否在模式中被允许。

// abbreviation/abbreviationcommand.js

import { Command } from 'ckeditor5';

export default class AbbreviationCommand extends Command {
    refresh() {
        const model = this.editor.model;
        const selection = model.document.selection;

        // The command is enabled when the "abbreviation" attribute
        // can be set on the current model selection.
        this.isEnabled = model.schema.checkAttributeInSelection(
            selection, 'abbreviation'
        );
    }

    execute( { title, abbr } ) {
        // The code runs after command execution.
        // ...
    }
}

我们现在可以检查选定范围是否折叠。如果是,我们将检查光标是否在缩写中,并获取包含它的整个范围。我们可以使用 findAttributeRange 辅助函数轻松地做到这一点。我们需要将选择的首个位置、我们的属性名称和值以及模型传递给它。

然后,我们更改命令的值。我们将使用我们的 getRangeText 辅助函数获取缩写文本。我们还添加一个范围值,我们将在执行命令时使用它。

// abbreviation/abbreviationcommand.js

import { 
    Command,
    findAttributeRange						// ADDED
} from 'ckeditor5';
import getRangeText from './utils.js';		// ADDED

export default class AbbreviationCommand extends Command {
    refresh() {
        const model = this.editor.model;
        const selection = model.document.selection;
        const firstRange = selection.getFirstRange();

        // When the selection is collapsed, the command has a value
        // if the caret is in an abbreviation.
        if ( firstRange.isCollapsed ) {
            if ( selection.hasAttribute( 'abbreviation' ) ) {
                const attributeValue = selection.getAttribute( 'abbreviation' );

                // Find the entire range containing the abbreviation
                // under the caret position.
                const abbreviationRange = findAttributeRange(
                    selection.getFirstPosition(), 'abbreviation', attributeValue, model
                );

                this.value = {
                    abbr: getRangeText( abbreviationRange ),
                    title: attributeValue,
                    range: abbreviationRange
                };
            } else {
                this.value = null;
            }
        }

        // The code that enables the command.
        // ...
    }

    execute( { title, abbr } ) {
        // The code runs after command execution.
        // ...
    }
}

如果选择未折叠,我们将检查它是否具有 abbreviation 模型属性。如果是,我们将再次获取缩写的完整范围,并将其与用户选择进行比较。

当用户选择具有缩写属性的文本的一部分,以及没有缩写属性的一部分时,我们不希望更改命令的值。因此,我们将使用 containsRange() 方法来查看选定范围是否在缩写范围内。第二个参数使其成为 loose 检查,这意味着选定范围可以与缩写范围开始、结束或相等。

// abbreviation/abbreviationcommand.js

// Previously imported packages.
//...

export default class AbbreviationCommand extends Command {
    refresh() {
        const model = this.editor.model;
        const selection = model.document.selection;
        const firstRange = selection.getFirstRange();

        if ( firstRange.isCollapsed ) {
            // When the selection is collapsed, the command has a value
            // if the caret is in an abbreviation.
            // ...
        }
        // When the selection is not collapsed, the command has a value if the selection
        // contains a subset of a single abbreviation or an entire abbreviation.
        else {
            if ( selection.hasAttribute( 'abbreviation' ) ) {
                const attributeValue = selection.getAttribute( 'abbreviation' );

                // Find the entire range containing the abbreviation
                // under the caret position.
                const abbreviationRange = findAttributeRange(
                    selection.getFirstPosition(), 'abbreviation', attributeValue, model
                );

                if ( abbreviationRange.containsRange( firstRange, true ) ) {
                    this.value = {
                        abbr: getRangeText( firstRange ),
                        title: attributeValue,
                        range: firstRange
                    };
                } else {
                    this.value = null;
                }
            } else {
                this.value = null;
            }
        }

        // The code that enables the command.
        // ...
    }

    execute( { title, abbr } ) {
        // The code runs after command execution.
        // ...
    }
}

您可以在检查器中查看命令及其当前值。

Screenshot of the CKEditor 5 inspector showing the value of the <code>addAbbreviation</code> command.

我们现在可以检查用户按下工具栏缩写按钮时的命令值,并将缩写文本和标题值都插入到表单的输入字段中。

AbbreviationUI 中添加一个简单的 if 语句,使用命令的值或选定文本(如我们之前所做的那样)填充表单。

// abbreviation/abbreviationui.js

// Previously imported packages.
// ...

export default class AbbreviationUI extends Plugin {
    // Previously declared methods.
    // ...

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

        // Check the value of the command.
        const commandValue = this.editor.commands.get( 'addAbbreviation' ).value;

        this._balloon.add( {
            view: this.formView,
            position: this._getBalloonPositionData()
        } );

        // Disable the input when the selection is not collapsed.
        this.formView.abbrInputView.isEnabled = selection.getFirstRange().isCollapsed;

        // Fill the form using the state (value) of the command.
        if ( commandValue ) {
            this.formView.abbrInputView.fieldView.value = commandValue.abbr;
            this.formView.titleInputView.fieldView.value = commandValue.title;
        }
        // If the command has no value, put the currently selected text (not collapsed)
        // in the first field and empty the second in that case.
        else {
            const selectedText = getRangeText( selection.getFirstRange() );

            this.formView.abbrInputView.fieldView.value = selectedText;
            this.formView.titleInputView.fieldView.value = '';
        }

        this.formView.focus();
    }

    // Previously declared helper methods.
    // ...
}

# 改进 execute() 方法

现在我们应该在 execute() 方法中添加更多用例。首先,如果用户的选择没有折叠,我们只需要将缩写属性添加到他们的选择中,而不是将缩写文本插入模型中。

如果选择没有折叠,我们将使用模式的 getValidRanges() 方法收集所有允许使用 abbreviation 模式属性的范围。然后我们将使用 setAttribute() 将标题值添加到每个范围。

如果选择已折叠,我们将保留我们之前使用的 insertContent() 模式方法。然后,我们需要使用 removeSelectionAttribute 方法,以防止在用户开始输入时向缩写添加新内容。

// abbreviation/abbreviationcommand.js

// Previously imported packages.
// ...

export default class AbbreviationCommand extends Command {
    refresh() {
        // The code runs after the command refresh.
        // ...
    }

    execute( { abbr, title } ) {
        const model = this.editor.model;
        const selection = model.document.selection;

        model.change( writer => {
            // If selection is collapsed then update the selected abbreviation
            // or insert a new one at the place of caret.
            if ( selection.isCollapsed ) {
                model.insertContent(
                    writer.createText( abbr, { abbreviation: title } )
                );

                // Remove the "abbreviation" attribute attribute from the selection.
                writer.removeSelectionAttribute( 'abbreviation' );
            } else {
                // If the selection has non-collapsed ranges,
                // change the attribute on nodes inside those ranges
                // omitting nodes where the "abbreviation" attribute is disallowed.
                const ranges = model.schema.getValidRanges(
                    selection.getRanges(), 'abbreviation'
                );

                for ( const range of ranges ) {
                    writer.setAttribute( 'abbreviation', title, range );
                }
            }
        } );
    }
}

现在我们可以使用命令的状态来检查选择是否位于现有缩写内。如果命令的值不为 null,我们将获取整个范围并更新其文本和标题。

我们将在插入的缩写末尾创建一个位置,并在那里设置选择。insertContent() 方法返回一个范围,我们获取其结束位置以定义我们的 positionAfter

// abbreviation/abbreviationcommand.js

// Previously imported packages.
// ...

export default class AbbreviationCommand extends Command {
    refresh() {
        // The code runs after the command refresh.
        // ...
    }

    execute( { abbr, title } ) {
        const model = this.editor.model;
        const selection = model.document.selection;

        model.change( writer => {
            // If selection is collapsed then update the selected abbreviation
            // or insert a new one at the place of caret.
            if ( selection.isCollapsed ) {
                // When a collapsed selection is inside text with the "abbreviation" attribute,
                // update its text and title.
                if ( this.value ) {
                    const { end: positionAfter } = model.insertContent(
                        writer.createText( abbr, { abbreviation: title } ),
                        this.value.range
                    );

                    // Put the selection at the end of the inserted abbreviation.
                    writer.setSelection( positionAfter );
                }

                writer.removeSelectionAttribute( 'abbreviation' );
            } else {
                // When the selection has non-collapsed node ranges, change the attribute on nodes inside those ranges
                // omitting nodes where the "abbreviation" attribute is disallowed.
                // ...
            }
        } );
    }
}

如果折叠的选择不在现有缩写内,我们将在光标处插入一个带有 "abbreviation" 属性的文本节点。

用户可能会将缩写放在文本中,该文本已经具有其他一些模型属性,如 "bold" 或 "italic"。我们应该首先将它们与我们的缩写属性一起收集起来,并在将缩写插入文档时使用整个列表。我们将使用我们的 toMap 辅助函数来收集所有属性。

// abbreviation/abbreviationcommand.js

// More imports.
// ...
import { toMap } from 'ckeditor5';		// ADDED

export default class AbbreviationCommand extends Command {
    refresh() {
        // The code runs after the command refresh.
        // ...
    }

    execute( { abbr, title } ) {
        const model = this.editor.model;
        const selection = model.document.selection;

        model.change( writer => {
            if ( selection.isCollapsed ) {
                if ( this.value ) {
                    // When a collapsed selection is inside text
                    // with the "abbreviation" attribute, update texts.
                    // ...
                }
                // If the collapsed selection is not in an existing abbreviation,
                // insert a text node with the "abbreviation" attribute
                // in place of the caret.
                // If the abbreviation is empty, don't do anything.
                else if ( abbr !== '' ) {
                    const firstPosition = selection.getFirstPosition();

                    // Collect all attributes of the user selection.
                    const attributes = toMap( selection.getAttributes() );

                    // Put the new attribute to the map of attributes.
                    attributes.set( 'abbreviation', title );

                    // Inject the new text node with the abbreviation text
                    // with all selection attributes.
                    const { end: positionAfter } = model.insertContent(
                        writer.createText( abbr, attributes ), firstPosition
                    );

                    // Put the selection at the end of the inserted abbreviation.
                    writer.setSelection( positionAfter );
                }

                writer.removeSelectionAttribute( 'abbreviation' );
            } else {
                // When the selection has non-collapsed node ranges, change the attribute on nodes inside those ranges
                // omitting nodes where the "abbreviation" attribute is disallowed.
                // ...
            }
        } );
    }
}

命令现在已经完成,通过尝试所有不同的用例来检查它的工作方式 - 选择已折叠、未折叠、位于现有缩写内等。

# 演示

查看实际效果。

缩写插件

CKEditor 5 是一款现代、功能丰富、世界一流的 WYSIWYG 编辑器。

# 最终代码

如果您在任何地方迷路了,这是 插件的最终实现。您可以将来自不同文件的代码粘贴到您的项目中,或者克隆并安装整个代码,它将开箱即用。

下一步

就这样,您已经完成了本教程!您现在已经准备好创建自己的插件。如果您想继续学习,请继续学习我们更高级的教程,从 实现一个块小部件 指南开始。