Report an issue

指南注释的自定义视图

自定义视图是自定义注释最强大的方式。在这种情况下,您需要提供整个视图:模板、UI 元素以及任何必要的行为逻辑,但您仍然可以使用一些默认的构建块。

强烈建议您在继续之前熟悉注释的自定义模板指南。

以下示例基于包含协作功能的工作编辑器设置。我们强烈建议您根据评论功能集成指南准备好您的设置,然后再进行任何进一步的操作。

# 使用基本视图

提供自定义视图基于与提供自定义模板相同的解决方案。您需要为视图创建自己的类。在这种情况下,您将有兴趣扩展基本视图类

基本视图类提供了一些核心功能,这些功能对于视图的操作是必要的,无论视图的外观或模板如何。

默认视图类也扩展了基本视图类。

# 默认视图模板

用于创建评论线程视图的默认模板如下所示:CommentThreadView#getTemplate().

用于创建建议线程视图的默认模板如下所示:SuggestionThreadView#getTemplate().

# 创建和启用自定义视图

提醒一下,以下代码段显示了如何为 CKEditor 5 协作功能启用自定义视图

// main.js
// ...

import { BaseCommentThreadView } from 'ckeditor5-premium-features';

class CustomCommentThreadView extends BaseCommentThreadView {
    // CustomCommentThreadView implementation.
    // ...
}

// ...

const editorConfig = {
    // ...

    comments: {
        CommentThreadView: CustomCommentThreadView
    },

    // ...
};

ClassicEditor.create(document.querySelector('#editor'), editorConfig);

在自定义视图构造函数中需要执行的唯一强制性操作是设置模板

class CustomCommentThreadView extends BaseCommentThreadView {
    constructor( ...args ) {
        super( ...args );

        this.setTemplate( {
            // Template definition here.
            // ...
        } );
    }
}

您有责任构造模板以及视图所需的全部 UI 元素。

# 读取数据并与模板绑定

您的视图将传递一个模型对象,该对象在 _model 属性下可用。应使用它来设置(或绑定到)视图属性的初始数据。

您视图的某些属性可能用于控制视图模板。例如,您可以绑定视图属性,以便当它设置为 true 时,模板主元素将接收一个额外的 CSS 类。为了将视图属性与模板绑定,属性需要是可观察的。当然,您可以将现有的可观察属性与您的模板绑定。

您可以绑定两个可观察属性,以便一个属性的值将取决于另一个属性。您也可以直接监听可观察属性的变化.

const bind = this.bindTemplate;

// Set an observable property.
this.set( 'isImportant', false );

// More code.
// ...

this.setTemplate( {
    tag: 'div',

    attributes: {
        class: [
            // Bind the new observable property with the template.
            bind.if( 'isImportant', 'ck-comment--important' ),
            // Bind an existing observable property with the template.
            bind.if( 'isDirty', 'ck-comment--unsaved' )
        ]
    }
} );

# 执行操作

视图需要与系统的其他部分进行通信。当用户执行操作时,需要执行某些操作,例如:应删除评论。这种通信是通过触发事件(带有适当的数据)来实现的。请参阅以下示例

this.removeButton = new ButtonView();
this.removeButton.on( 'execute', () => this.fire( 'removeCommentThread' ) );

给定视图类可以触发的所有事件列表在BaseCommentThreadView 类的 API 文档中提供。

# 示例:评论线程操作下拉菜单

在本示例中,您将创建一个自定义评论线程视图,其中操作按钮(编辑、删除)将移动到下拉 UI 元素中。下拉菜单将添加到一个新元素中,并放置在线程 UI 的上方。

# 使用新模板创建自定义线程视图

首先,为您的自定义解决方案创建一个基础。

// main.js
// ...

import { BaseCommentThreadView } from 'ckeditor5-premium-features';

class CustomCommentThreadView extends BaseCommentThreadView {
    constructor( ...args ) {
        super( ...args );

        const bind = this.bindTemplate;

        // This template definition is partially based on the default comment thread view.
        this.setTemplate( {
            tag: 'div',

            attributes: {
                class: [
                    'ck',
                    'ck-thread',
                    'ck-reset_all-excluded',
                    'ck-rounded-corners',
                    bind.if( 'isActive', 'ck-thread--active' )
                ],
                // Needed for the native DOM Tab key navigation.
                tabindex: 0,
                role: 'listitem',
                'aria-label': bind.to( 'ariaLabel' ),
                'aria-describedby': this.ariaDescriptionView.id
            },

            children: [
                // Adding the top bar element that will hold the dropdown.
                {
                    tag: 'div',
                    attributes: {
                        class: 'ck-thread-top-bar'
                    },
                    children: [
                        this._createActionsDropdown()
                    ]
                },
                // The rest of the view is as in the default view.
                {
                    tag: 'div',
                    attributes: {
                        class: 'ck-thread__container'
                    },
                    children: [
                        this.commentsListView,
                        this.commentThreadInputView
                    ]
                }
            ]
        } );
    }

    _createActionsDropdown() {
        // _createActionsDropdown() implementation.
        // ...
    }
}

然后,您需要创建一个下拉 UI 元素并用项目填充它。

// main.js
// ...

import { ViewModel, addListToDropdown, createDropdown, Collection } from 'ckeditor5';

// ...

class CustomCommentThreadView extends BaseCommentThreadView {
    // ...

    _createActionsDropdown() {
        const dropdownView = createDropdown( this.locale );

        dropdownView.buttonView.set( {
            label: 'Actions',
            withText: true
        } );

        const items = new Collection();

        const editButtonModel = new ViewModel( {
            withText: true,
            label: 'Edit',
            action: 'edit'
        } );

        items.add( {
            type: 'button',
            model: editButtonModel
        } );

        const resolveButtonModel = new ViewModel( {
            withText: true,
            label: 'Resolve',
            action: 'resolve'
        } );

        items.add( {
            type: 'button',
            model: resolveButtonModel
        } );

        const reopenButtonModel = new ViewModel( {
            withText: true,
            label: 'Reopen',
            action: 'reopen'
        } );

        items.add( {
            type: 'button',
            model: reopenButtonModel
        } );

        const removeButtonModel = new ViewModel( {
            withText: true,
            label: 'Delete',
            action: 'delete'
        } );

        items.add( {
            type: 'button',
            model: removeButtonModel
        } );

        addListToDropdown( dropdownView, items );

        dropdownView.on( 'execute', evt => {
            // Callback on execute.
            // ...
        } );

        // Enable tab key navigation for the dropdown.
        this.focusables.add( dropdownView, 0 );

        return dropdownView;
    }

    // ...
}

请注意,如果当前本地用户不是线程的作者,则下拉菜单应该不可见。

由于评论线程中的第一个评论代表整个线程,因此您可以基于第一个评论的属性来构建它。

如果线程中没有评论,则意味着这是一个新线程,因此本地用户是作者。

// main.js
// ...

import { BaseCommentThreadView } from 'ckeditor5-premium-features';

class CustomCommentThreadView extends BaseCommentThreadView {
    constructor( ...args ) {
        super( ...args );

        const bind = this.bindTemplate;

        // The template definition is partially based on the default comment thread view.
        const templateDefinition = {
            tag: 'div',

            attributes: {
                class: [
                    'ck',
                    'ck-thread',
                    'ck-reset_all-excluded',
                    'ck-rounded-corners',
                    bind.if( 'isActive', 'ck-thread--active' )
                ],
                // Needed for the native DOM Tab key navigation.
                tabindex: 0,
                role: 'listitem',
                'aria-label': bind.to( 'ariaLabel' ),
                'aria-describedby': this.ariaDescriptionView.id
            },

            children: [
                {
                    tag: 'div',
                    attributes: {
                        class: 'ck-thread__container'
                    },
                    children: [
                        this.commentsListView,
                        this.commentThreadInputView
                    ]
                }
            ]
        };

        const isNewThread = this.length == 0;
        const isAuthor = isNewThread || this._localUser == this._model.comments.get( 0 ).author;

        // Add the actions dropdown only if the local user is the author of the comment thread.
        if ( isAuthor ) {
            templateDefinition.children.unshift(
                {
                    tag: 'div',
                    attributes: {
                        class: 'ck-thread-top-bar'
                    },

                    children: [
                        this._createActionsDropdown()
                    ]
                }
            );
        }

        this.setTemplate( templateDefinition );
    }

    // ...
}

关于禁用 UI,如果评论线程处于只读模式,则下拉菜单中的操作应该被禁用。此外,如果线程中没有评论,则编辑按钮应该隐藏。此外,应根据评论线程解决状态显示解决/重新打开按钮。

// main.js
// ...

import { BaseCommentThreadView } from 'ckeditor5-premium-features';

class CustomCommentThreadView extends BaseCommentThreadView {
    // ...

    _createActionsDropdown() {
        // ...

        const editButtonModel = new ViewModel( {
            withText: true,
            label: 'Edit',
            action: 'edit'
        } );

        // The button should be enabled when the read-only mode is off.
        // So, `isEnabled` should be a negative of `isReadOnly`.
        editButtonModel.bind( 'isEnabled' )
            .to( this._model, 'isReadOnly', isReadOnly => !isReadOnly );

        // Hide the button if the thread has no comments yet.
        editButtonModel.bind( 'isVisible' )
            .to( this, 'length', length => length > 0 );

        items.add( {
            type: 'button',
            model: editButtonModel
        } );

        const resolveButtonModel = new ViewModel( {
            withText: true,
            label: 'Resolve',
            action: 'resolve'
        } );

        // Hide the button if the thread is resolved or cannot be resolved.
        resolveButtonModel.bind( 'isVisible' )
            .to( this._model, 'isResolved', this._model, 'isResolvable',
                ( isResolved, isResolvable ) => !isResolved && isResolvable );

        items.add( {
            type: 'button',
            model: resolveButtonModel
        } );

        const reopenButtonModel = new ViewModel( {
            withText: true,
            label: 'Reopen',
            action: 'reopen'
        } );

        // Hide the button if the thread is not resolved or cannot be resolved.
        reopenButtonModel.bind( 'isVisible' )
            .to( this._model, 'isResolved', this._model, 'isResolvable',
                ( isResolved, isResolvable ) => isResolved && isResolvable );

        items.add( {
            type: 'button',
            model: reopenButtonModel
        } );

        const removeButtonModel = new ViewModel( {
            withText: true,
            label: 'Delete',
            action: 'delete'
        } );

        removeButtonModel.bind( 'isEnabled' )
            .to( this._model, 'isReadOnly', isReadOnly => !isReadOnly );

        items.add( {
            type: 'button',
            model: removeButtonModel
        } );
    }
}

// ...

最后,新 UI 元素需要一些样式。

/* style.css */

/* ... */

.ck-thread-top-bar {
    padding: 2px 4px 3px 4px;
    background: #404040;
    text-align: right;
}

.ck-thread-top-bar .ck.ck-dropdown {
    font-size: 14px;
    width: 100px;
}

.ck-thread-top-bar .ck.ck-dropdown .ck-button.ck-dropdown__button {
    color: #000000;
    background: #EEEEEE;
}

# 将按钮与操作链接

编辑按钮应该将第一个评论转为编辑模式。

dropdownView.on( 'execute', evt => {
    const action = evt.source.action;

    if ( action == 'edit' ) {
        this.commentsListView.commentViews.get( 0 ).switchToEditMode();
    }

    // More actions.
    // ...
} );

删除按钮应该删除评论线程。

如前所述,您的视图应该触发事件以与系统的其他部分进行通信。

dropdownView.on( 'execute', evt => {
    const action = evt.source.action;

    if ( action == 'edit' ) {
        this.commentsListView.commentViews.get( 0 ).switchToEditMode();
    }

    if ( action == 'delete' ) {
        this.fire( 'removeCommentThread' );
    }

    if ( action == 'resolve' ) {
        this.fire( 'resolveCommentThread' );
    }

    if ( action == 'reopen' ) {
        this.fire( 'reopenCommentThread' );
    }

    if ( action == 'resolve' ) {
        this.fire( 'resolveCommentThread' );
    }

    if ( action == 'reopen' ) {
        this.fire( 'reopenCommentThread' );
    }
} );

# 更改第一个评论视图

您的新自定义评论线程视图已准备就绪。

对于评论视图,您将使用默认的评论视图。但是,有一件事您需要注意。由于您将评论线程控件移动到单独的下拉菜单中,因此您应该从第一个评论视图中隐藏这些按钮。

此修改将添加到自定义评论线程视图中。它不应该在自定义评论视图中完成,因为这会影响建议线程中的评论。

第一个评论视图可以从 commentsListView 属性获取。如果还没有评论,您可以监听该属性,并在添加第一个评论视图时应用自定义行为。

// main.js
// ...

import { BaseCommentThreadView } from 'ckeditor5-premium-features';

class CustomCommentThreadView extends BaseCommentThreadView {
    constructor( ...args ) {
        // More code.
        // ...

        if ( this.length > 0 ) {
            // If there is a comment when the thread is created, apply custom behavior to it.
            this._modifyFirstCommentView();
        } else {
            // If there are no comments (an empty thread was created by the user),
            // listen to `this.commentsListView` and wait for the first comment to be added.
            this.listenTo( this.commentsListView.commentViews, 'add', evt => {
                // And apply the custom behavior when it is added.
                this._modifyFirstCommentView();

                evt.off();
            } );
        }
    }

    // More code.
    // ...

    _modifyFirstCommentView() {
        // Get the first comment.
        const commentView = this.commentsListView.commentViews.get( 0 );

        // By default, the comment button is bound to the model state
        // and the buttons are visible only if the current local user is the author.
        // You need to remove this binding and make buttons for the first
        // comment always invisible.
        commentView.removeButton.unbind( 'isVisible' );
        commentView.removeButton.isVisible = false;

        commentView.editButton.unbind( 'isVisible' );
        commentView.editButton.isVisible = false;
    }
}

// ...

# 最终解决方案

以下是创建的组件的最终代码。

/* style.css */

/* ... */

.ck-thread-top-bar {
    padding: 2px 4px 3px 4px;
    background: #404040;
    text-align: right;
}

.ck-thread-top-bar .ck.ck-dropdown {
    font-size: 14px;
    width: 100px;
}

.ck-thread-top-bar .ck.ck-dropdown .ck-button.ck-dropdown__button {
    color: #000000;
    background: #EEEEEE;
}
// main.js

// ...

import { ViewModel, addListToDropdown, createDropdown, Collection, Bold, Italic } from 'ckeditor5';
import { BaseCommentThreadView } from 'ckeditor5-premium-features';

class CustomCommentThreadView extends BaseCommentThreadView {
    constructor( ...args ) {
        super( ...args );

        const bind = this.bindTemplate;

        // The template definition is partially based on the default comment thread view.
        const templateDefinition = {
            tag: 'div',

            attributes: {
                class: [
                    'ck',
                    'ck-thread',
                    'ck-reset_all-excluded',
                    'ck-rounded-corners',
                    bind.if( 'isActive', 'ck-thread--active' )
                ],
                // Needed for the native DOM Tab key navigation.
                tabindex: 0,
                role: 'listitem',
                'aria-label': bind.to( 'ariaLabel' ),
                'aria-describedby': this.ariaDescriptionView.id
            },

            children: [
                {
                    tag: 'div',
                    attributes: {
                        class: 'ck-thread__container'
                    },
                    children: [
                        this.commentsListView,
                        this.commentThreadInputView
                    ]
                }
            ]
        };

        const isNewThread = this.length == 0;
        const isAuthor = isNewThread || this._localUser == this._model.comments.get( 0 ).author;

        // Add the actions dropdown only if the local user is the author of the comment thread.
        if ( isAuthor ) {
            templateDefinition.children.unshift(
                {
                    tag: 'div',
                    attributes: {
                        class: 'ck-thread-top-bar'
                    },

                    children: [
                        this._createActionsDropdown()
                    ]
                }
            );
        }

        this.setTemplate( templateDefinition );

        if ( this.length > 0 ) {
            // If there is a comment when the thread is created, apply custom behavior to it.
            this._modifyFirstCommentView();
        } else {
            // If there are no comments (an empty thread was created by a user),
            // listen to `this.commentsListView` and wait for the first comment to be added.
            this.listenTo( this.commentsListView.commentViews, 'add', evt => {
                // And apply the custom behavior when it is added.
                this._modifyFirstCommentView();

                evt.off();
            } );
        }
    }

    _createActionsDropdown() {
        const dropdownView = createDropdown( this.locale );

        dropdownView.buttonView.set( {
            label: 'Actions',
            withText: true
        } );

        const items = new Collection();

        const editButtonModel = new ViewModel( {
            withText: true,
            label: 'Edit',
            action: 'edit'
        } );

        // The button should be enabled when the read-only mode is off.
        // So, `isEnabled` should be a negative of `isReadOnly`.
        editButtonModel.bind( 'isEnabled' )
            .to( this._model, 'isReadOnly', isReadOnly => !isReadOnly );

        // Hide the button if the thread has no comments yet.
        editButtonModel.bind( 'isVisible' )
            .to( this, 'length', length => length > 0 );

        items.add( {
            type: 'button',
            model: editButtonModel
        } );

        const resolveButtonModel = new ViewModel( {
            withText: true,
            label: 'Resolve',
            action: 'resolve'
        } );

        // Hide the button if the thread is resolved or cannot be resolved.
        resolveButtonModel.bind( 'isVisible' )
            .to( this._model, 'isResolved', this._model, 'isResolvable',
                ( isResolved, isResolvable ) => !isResolved && isResolvable );

        items.add( {
            type: 'button',
            model: resolveButtonModel
        } );

        const reopenButtonModel = new ViewModel( {
            withText: true,
            label: 'Reopen',
            action: 'reopen'
        } );

        // Hide the button if the thread is not resolved or cannot be resolved.
        reopenButtonModel.bind( 'isVisible' )
            .to( this._model, 'isResolved', this._model, 'isResolvable',
                ( isResolved, isResolvable ) => isResolved && isResolvable );

        items.add( {
            type: 'button',
            model: reopenButtonModel
        } );


        const removeButtonModel = new ViewModel( {
            withText: true,
            label: 'Delete',
            action: 'delete'
        } );

        removeButtonModel.bind( 'isEnabled' )
            .to( this._model, 'isReadOnly', isReadOnly => !isReadOnly );

        items.add( {
            type: 'button',
            model: removeButtonModel
        } );

        addListToDropdown( dropdownView, items );

        dropdownView.on( 'execute', evt => {
            const action = evt.source.action;

            if ( action == 'edit' ) {
                this.commentsListView.commentViews.get( 0 ).switchToEditMode();
            }

            if ( action == 'delete' ) {
                this.fire( 'removeCommentThread' );
            }
        } );

        // Enable tab key navigation for the dropdown.
        this.focusables.add( dropdownView, 0 );

        return dropdownView;
    }

    _modifyFirstCommentView() {
        // Get the first comment.
        const commentView = this.commentsListView.commentViews.get( 0 );

        // By default, the comment button is bound to the model state
        // and the buttons are visible only if the current local user is the author.
        // You need to remove this binding and make buttons for the first
        // comment always invisible.
        commentView.removeButton.unbind( 'isVisible' );
        commentView.removeButton.isVisible = false;

        commentView.editButton.unbind( 'isVisible' );
        commentView.editButton.isVisible = false;
    }
}

// ...

const editorConfig = {
    // ...

    comments: {
        CommentThreadView: CustomCommentThreadView,

        editorConfig: {
            extraPlugins: [ Bold, Italic ]
        }
    },

    // ...
};

ClassicEditor.create(document.querySelector('#editor'), editorConfig);

# 实时演示