Contribute to this guide

指南提升可访问性并添加命令

您已经完成了缩写插件教程的最后部分。在本部分中,我们将改进插件的可访问性。我们还将创建一个命令,该命令将额外获取用户选择的文本,并将其插入到我们的表单中。以及更多内容!

我们从 第二部分 结束的地方继续,所以请确保您完成了它,或者获取我们的 本部分的入门文件

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

# 提升可访问性

首先,我们让插件对依赖键盘导航的用户更易访问。我们希望确保按下 TabShift + Tab 将焦点移动到表单视图中的各个位置,按下 esc 将关闭表单视图。

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

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

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

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

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

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

// abbreviation/abbreviationview.js

import {
    View,
    LabeledFieldView,
    createLabeledInputText,
    ButtonView,
    submitHandler,
} from '@ckeditor/ckeditor5-ui';
import { FocusTracker, KeystrokeHandler } from '@ckeditor/ckeditor5-utils'; // ADDED
import { icons } from '@ckeditor/ckeditor5-core';

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,
    FocusCycler																// ADDED
} from '@ckeditor/ckeditor5-ui';
import { FocusTracker, KeystrokeHandler } from '@ckeditor/ckeditor5-utils';
import { icons } from '@ckeditor/ckeditor5-core';

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 类并创建它的实例。

我们将首先简单地将我们之前在 _createFormView() 方法中为 submit 创建的动作移动到那里,并将标题和缩写文本传递到命令的 execute() 方法中。

// abbreviation/abbreviationcommand.js

import Command from '@ckeditor/ckeditor5-core/src/command';

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 '@ckeditor/ckeditor5-core/src/plugin';
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 ‘addAbbreviation’ command.

# 刷新状态

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

在我们执行此操作之前,我们可能需要检查是否可以在给定的选择上使用命令。如果用户选择了图像,则命令应被禁用。让我们使用其 checkAttributeInSelection() 方法检查我们的 abbreviation 属性是否在模式中允许使用。

// abbreviation/abbreviationcommand.js

import Command from '@ckeditor/ckeditor5-core/src/command';

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 from '@ckeditor/ckeditor5-core/src/command';
import findAttributeRange from '@ckeditor/ckeditor5-typing/src/utils/findattributerange'; 	// ADDED
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 ‘addAbbreviation’ 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() 方法中引入更多情况。首先,如果用户选择未折叠,我们只需要将缩写属性添加到他们的选择中,而不是将缩写文本插入到模型中。

如果选区未折叠,我们将使用 schema 的 `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 '@ckeditor/ckeditor5-utils';					// 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` 编辑器。

# 最终代码

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

下一步?

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