注释的自定义视图
自定义视图是自定义注释最强大的方式。在这种情况下,您需要提供整个视图:模板、UI 元素以及任何必要的行为逻辑,但您仍然可以使用一些默认的构建块。
强烈建议您在继续之前熟悉注释的自定义模板指南。
以下示例基于包含协作功能的工作编辑器设置。我们强烈建议您根据评论功能集成指南准备好您的设置,然后再进行任何进一步的操作。
# 使用基本视图
提供自定义视图基于与提供自定义模板相同的解决方案。您需要为视图创建自己的类。在这种情况下,您将有兴趣扩展基本视图类
BaseCommentThreadView
– 评论线程视图的基本视图。BaseCommentView
– 评论视图的基本视图。BaseSuggestionThreadView
– 建议线程视图的基本视图。
基本视图类提供了一些核心功能,这些功能对于视图的操作是必要的,无论视图的外观或模板如何。
默认视图类也扩展了基本视图类。
# 默认视图模板
用于创建评论线程视图的默认模板如下所示: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);
# 实时演示
我们每天都在努力使我们的文档保持完整。您是否发现过时信息?是否缺少某些内容?请通过我们的 问题追踪器 报告。
随着 42.0.0 版本的发布,我们重写了大部分文档以反映新的导入路径和功能。感谢您的反馈,帮助我们确保其准确性和完整性。