Report an issue

指南编辑器外部的评论

评论功能 API,连同 Context,使您可以与您的应用程序创建更深入的集成。其中一种集成是在非编辑器表单字段上启用评论。

在本指南中,您将了解如何将此功能添加到您的应用程序。此外,所有连接到表单的用户都将在在场列表中可见。

# 开始之前

我们强烈建议您阅读 上下文和协作功能 指南,然后再继续。

出于本指南的目的,将使用 CKEditor 云服务和 实时协作评论。但是,评论功能 API 也可以以类似的方式与 独立评论 一起使用。

# 准备上下文

作为本指南的补充,我们提供了一个 随时可用的示例.

您可以将其用作示例或作为您自己的集成的起点。

目标是在非编辑器表单字段上启用评论,因此我们需要使用上下文来初始化评论功能而不使用编辑器。

首先,您需要准备 Context 配置。您可以参考 上下文和协作功能 指南以获取更深入的解释。

import { CloudServices } from 'ckeditor5';
import { CommentsRepository, NarrowSidebar, WideSidebar, CloudServicesCommentsAdapter, PresenceList } from 'ckeditor5-premium-features';

// The context's configuration.
const contextConfig = {
    // Plugins specific for the context:
    plugins: [
        CloudServices,
        CommentsRepository,
        NarrowSidebar,
        PresenceList,
        WideSidebar,
        CloudServicesCommentsAdapter,
    ],

    // Sidebar and presence list's shared locations:
    sidebar: {
        container: document.querySelector( '#editor-annotations' )
    },
    presenceList: {
        container: document.querySelector( '#editor-presence' )
    },

    comments: {
        editorConfig: {}
    },

    // Real-time features configuration:
    // NOTE: PROVIDE CORRECT VALUES HERE.
    cloudServices: {
        tokenUrl: 'https://example.com/cs-token-endpoint',
        uploadUrl: 'https://your-organization-id.cke-cs.com/easyimage/upload/',
        webSocketUrl: 'your-organization-id.cke-cs.com/ws/'
    },

    collaboration: {
        channelId: 'your-channel-id'
    }
};

# 准备 HTML 结构

当上下文配置准备就绪后,就是时候准备一个具有示例表单、在场列表和侧边栏的 HTML 结构了。

<div id="editor-presence"></div>

<div id="container">
    <div class="form">
        <div class="form-field" id="field-1" tabindex="-1">
            <label>Field 1:</label>
            <input name="field-1" type="text" value="Input 1">
            <button class="add-comment">+</button>
        </div>
        <div class="form-field" id="field-2" tabindex="-1">
            <label>Field 2:</label>
            <input name="field-2" type="text" value="Input 2">
            <button class="add-comment">+</button>
        </div>
        <div class="form-field" id="field-3" tabindex="-1">
            <label>Field 3:</label>
            <input name="field-3" type="text" value="Input 3">
            <button class="add-comment">+</button>
        </div>
        <div class="form-field" id="field-4" tabindex="-1">
            <label>Field 4:</label>
            <select name="field-4">
                <option>Option 1</option>
                <option>Option 2</option>
                <option>Option 3</option>
            </select>
            <button class="add-comment">+</button>
        </div>
        <div class="form-field" id="field-5" tabindex="-1">
            <label>Field 5:</label>
            <select name="field-5">
                <option>Option 1</option>
                <option>Option 2</option>
                <option>Option 3</option>
            </select>
            <button class="add-comment">+</button>
        </div>
        <div class="form-field" id="field-6" tabindex="-1">
            <label>Field 6:</label>
            <select name="field-6">
                <option>Option 1</option>
                <option>Option 2</option>
                <option>Option 3</option>
            </select>
            <button class="add-comment">+</button>
        </div>
    </div>

    <div id="editor-annotations"></div>
</div>

该表单包含多个字段,如上所示。每个字段都有一个按钮,允许创建附加到该字段的评论。每个字段都分配了一个唯一的 ID。此外,tabindex="-1" 属性被添加以使其能够聚焦 DOM 元素(并将其添加到 焦点跟踪器)。

然后,像下面一样设置 HTML 样式。

#editor-presence {
    width: 679px;
    margin: 0 auto;
}

#container {
    display: flex;
    position: relative;
    width: 679px;
    margin: 0 auto;
}

#editor-annotations {
    width: 300px;
}

.form-field {
    padding: 8px 10px;
    margin-bottom: 20px;
    outline: none;
    margin-right: 20px;
    border: 1px solid #DDDDDD;
    border-radius: 3px;
}

.form-field.has-comment {
    background: hsl(55, 98%, 83%);
}

.form-field.active {
    background: hsl(55, 98%, 68%);
}

.form-field label {
    display: inline-block;
    width: 100px;
}

.form-field input, .form-field select {
    width: 200px;
    margin: 0px;
    padding: 0px 8px;
    height: 29px;
    background: #FFFFFF;
    border: 1px solid #DDDDDD;
    border-radius: 3px;
    box-sizing: border-box;
}

.form-field button {
    width: 29px;
    margin: 0px;
    height: 29px;
    background: #EEEEEE;
    border: 1px solid #DDDDDD;
    border-radius: 3px;
    vertical-align: top;
}

# 在表单字段上实现评论

现在,是时候将评论与我们的自定义 UI 集成在一起了。集成将满足以下要求

  1. 可以向任何表单字段添加评论线程。
  2. 评论应该实时发送、接收和处理。
  3. 非编辑器表单字段上只能有一个评论线程。
  4. 单击按钮会创建一个评论线程或激活现有线程。
  5. 应该有一个可见的指示,表明在给定字段上存在评论线程。

# 创建上下文

首先,使用前面定义的 contextConfig 创建一个上下文实例。

import { Context } from 'ckeditor5';

Context.create( contextConfig ).then( context => {
    const commentsRepository = context.plugins.get( 'CommentsRepository' );
    const annotations = context.plugins.get( 'Annotations' );
    const channelId = context.config.get( 'collaboration.channelId' );

    // ...
} );

# 添加评论线程

要创建一个附加到表单字段的新评论线程,请使用 CommentsRepository#openNewCommentThread().

Context.create( contextConfig ).then( context => {

    // ...

    document.querySelectorAll( '.form-field button' ).forEach( button => {
        const field = button.parentNode;

        button.addEventListener( 'click', () => {
            // Thread ID must be unique.
            // Use field ID + current date time to generate a unique thread ID.
            const threadId = field.id + ':' + new Date().getTime();

            commentsRepository.openNewCommentThread( {
                channelId,
                threadId,
                target: () => getAnnotationTarget( field, threadId ),
                // `context` is additional information about what the comment was made on.
                // It can be left empty but it also can be set to a custom message.
                // The value is used when the comment is displayed in comments archive.
                context: {
                    type: 'text',
                    value: getCustomContextMessage( field )
                },
                // `isResolvable` indicates whether the comment thread can become resolved.
                // Set this flag to `false` to disable the possibility of resolving given comment thread.
                // You will still be able to remove the comment thread.
                isResolvable: true
            } );
        } );
    } );

    function getCustomContextMessage( field ) {
        // This function should return the custom context value for given form field.
        // It will depend on your application.
        // Below, we assume HTML structure from this sample.
        return field.previousSibling.innerText + ' ' + field.value;
    }
} );

# 处理新的评论线程

定义一个回调,该回调将处理添加到评论存储库中的评论线程 - 包括本地用户创建的线程和来自远程用户的传入线程。为此,请使用 CommentsRepository#addCommentThread 事件.

请注意,事件名称包含上下文通道 ID。只会处理“添加到上下文”的评论。

Context.create( contextConfig ).then( context => {

    // ...

    // This `Map` is used to store all open threads for a given field.
    // An open thread is a non-resolved, non-removed thread.
    // Keys are field IDs, and values are arrays with all opened threads on this field.
    // Since it is possible to create multiple comment threads on the same field, this `Map`
    // is used to check if a given field has an open thread.
    const commentThreadsForField = new Map();

    commentsRepository.on( 'addCommentThread:' + channelId, ( evt, data ) => {
        handleNewCommentThread( data.threadId );
    }, { priority: 'low' } );

    function handleNewCommentThread( threadId ) {
        // Get the thread instance and the related DOM element using the thread ID.
        // Note that thread ID format is "fieldId:time".
        const thread = commentsRepository.getCommentThread( threadId );
        const field = document.getElementById( threadId.split( ':' )[ 0 ] );

        // If the thread is not attached yet, attach it.
        // This is the difference between local and remote comments.
        // Locally created comments are attached in the `openNewCommentThread()` call.
        // Remotely created comments need to be attached when they are received.
        if ( !thread.isAttached ) {
            thread.attachTo( () => thread.isResolved ? null : field );
        }

        // Add a CSS class to the field to show that it has a comment.
        field.classList.add( 'has-comment' );

        // Get all open threads for given field.
        const openThreads = commentThreadsForField.get( field.id ) || [];

        // When an annotation is created or reopened we need to bound its focus manager with the field.
        // Thanks to that, the annotation will be focused whenever the field is focused as well.
        // However, this can be done only for one annotation, so we do it only if there are no open
        // annotations for a given field.
        if ( !openThreads.length ) {
            const threadView = commentsRepository._threadToController.get( thread ).view;
            const annotation = annotations.collection.getByInnerView( threadView );

            annotation.focusableElements.add( field );
        }

        // Add new thread to open threads list.
        openThreads.push( thread );

        commentThreadsForField.set( field.id, openThreads );
    }
} );

当上下文初始化时,可能已经存在一些由远程用户创建并在编辑器初始化时加载的评论线程。这些评论也需要处理。

for ( const thread of commentsRepository.getCommentThreads( { channelId } ) ) {
    // Ignore threads that have been already resolved.
    if ( !thread.isResolved ) {
        handleNewCommentThread(thread.id);
    }
}

# 处理删除的评论线程

您还应该处理删除评论线程。为此,请使用 CommentsRepository#removeCommentThread 事件。同样,请注意事件名称。

Context.create( contextConfig ).then( context => {

    // ...

    commentsRepository.on( 'removeCommentThread:' + channelId, ( evt, data ) => {
        handleRemovedCommentThread( data.threadId );
    }, { priority: 'low' } );

    function handleRemovedCommentThread( threadId ) {
        // Note that thread ID format is "fieldId:time".
        const field = document.getElementById( threadId.split( ':' )[ 0 ] );
        const openThreads = commentThreadsForField.get( field.id );
        const threadIndex = openThreads.findIndex( openThread => openThread.id === threadId );

        // Remove this comment thread from the list of open comment threads for given field.
        openThreads.splice( threadIndex, 1 );

        // In `handleNewCommentThread` we bound the first comment thread annotation focus manager with the field.
        // If we are removing that comment thread, we need to handle field focus as well.
        // After removing or resolving the first thread you should field focus to the next thread's annotation.
        if ( threadIndex === 0 ) {
            const thread = commentsRepository.getCommentThread( threadId );
            const threadController = commentsRepository._threadToController.get( thread );

            // Remove the old binding between removed annotation and field.
            if ( threadController ) {
                const threadView = threadController.view;
                const annotation = annotations.collection.getByInnerView( threadView );

                annotation.focusableElements.remove( field );
            }

            const newActiveThread = commentThreadsForField[ 0 ];

            // If there other open threads, bind another annotation to the field.
            if ( newActiveThread ) {
                const newThreadView = commentsRepository._threadToController.get( newActiveThread ).view;
                const newAnnotation = annotations.collection.getByInnerView( newThreadView );

                newAnnotation.focusableElements.add( field );
            }
        }

        // If there are no more active threads the CSS classes should be removed.
        if ( openThreads.length === 0 ) {
            field.classList.remove( 'has-comment', 'active' );
        }

        commentThreadsForField.set( field.id, openThreads );
    }
} );

# 处理已解决/重新打开的评论线程

处理评论线程的解决对于保持您的 UI 最新至关重要。要管理此操作,请使用 CommentsRepository#resolveCommentThread 事件 以及当线程再次打开时 CommentsRepository#reopenCommentThread 事件。与前一点一样,请注意事件名称。

解决后,评论线程将从侧边栏中删除,但是,您仍然可以从 Annotations#collection 中获取注释并将其渲染在自定义评论存档 UI 中。

Context.create( contextConfig ).then( context => {

    // ...

    commentsRepository.on( 'resolveCommentThread:' + channelId, ( evt, { threadId } ) => {
        handleRemovedCommentThread( threadId );
    }, { priority: 'low' } );

    commentsRepository.on( 'reopenCommentThread:' + channelId, ( evt, { threadId } ) => {
        handleNewCommentThread( threadId );
    }, { priority: 'low' } );
} );

# 突出显示活动表单字段

为了使 UI 更具响应性,突出显示与活动评论对应的表单字段是一个好主意。要添加此改进,请向 CommentsRepository#activeCommentThread 可观察属性添加一个监听器。

Context.create( contextConfig ).then( context => {

    // ...

    commentsRepository.on( 'change:activeCommentThread', ( evt, propName, activeThread ) => {
        // When an active comment thread changes, remove the 'active' class from all the fields.
        document.querySelectorAll( '.form-field.active' )
            .forEach( el => el.classList.remove( 'active' ) );

        // If `activeThread` is not null, highlight the corresponding form field.
        // Handle only comments added to the context channel ID.
        if ( activeThread && activeThread.channelId == channelId ) {
            const field = document.getElementById( activeThread.id.split( ':' )[ 0 ] );

            field.classList.add( 'active' );
        }
    } );
} );

# 全部实现

以下是你最终的解决方案。

这是 main.js 文件的内容

import { CloudServices, Context } from 'ckeditor5';
import { CommentsRepository, NarrowSidebar, WideSidebar, CloudServicesCommentsAdapter, PresenceList } from 'ckeditor5-premium-features';

import 'ckeditor5/ckeditor5.css';
import 'ckeditor5-premium-features/ckeditor5-premium-features.css';

// The context's configuration.
const contextConfig = {
    // Plugins specific for the context:
    plugins: [
        CloudServices,
        CommentsRepository,
        NarrowSidebar,
        PresenceList,
        WideSidebar,
        CloudServicesCommentsAdapter,
    ],

    // Sidebar and presence list's shared locations:
    sidebar: {
        container: document.querySelector( '#editor-annotations' )
    },
    presenceList: {
        container: document.querySelector( '#editor-presence' )
    },

    comments: {
        editorConfig: {}
    },

    // Real-time features configuration:
    // NOTE: PROVIDE CORRECT VALUES HERE.
    cloudServices: {
        tokenUrl: 'https://example.com/cs-token-endpoint',
        uploadUrl: 'https://your-organization-id.cke-cs.com/easyimage/upload/',
        webSocketUrl: 'your-organization-id.cke-cs.com/ws/'
    },

    collaboration: {
        channelId: 'your-channel-id'
    }
};

Context.create( contextConfig ).then( context => {
    const commentsRepository = context.plugins.get( 'CommentsRepository' );
    const annotations = context.plugins.get( 'Annotations' );

    // This `Map` is used to store all open threads for a given field.
    // An open thread is a non-resolved, non-removed thread.
    // Keys are field IDs and values are arrays with all opened threads on this field.
    // Since it is possible to create multiple comment threads on the same field, this `Map`
    // is used to check if a given field has an open thread.
    const commentThreadsForField = new Map();

    for ( const thread of commentsRepository.getCommentThreads( { channelId } ) ) {
        // Ignore threads that have been already resolved.
        if ( !thread.isResolved ) {
            handleNewCommentThread(thread.id);
        }
    }

    commentsRepository.on( 'addCommentThread:' + channelId, ( evt, data ) => {
        handleNewCommentThread( data.threadId );
    }, { priority: 'low' } );

    commentsRepository.on( 'resolveCommentThread:' + channelId, ( evt, { threadId } ) => {
        handleRemovedCommentThread( threadId );
    }, { priority: 'low' } );

    commentsRepository.on( 'reopenCommentThread:' + channelId, ( evt, { threadId } ) => {
        handleNewCommentThread( threadId );
    }, { priority: 'low' } );

    commentsRepository.on( 'removeCommentThread:' + channelId, ( evt, data ) => {
        handleRemovedCommentThread( data.threadId );
    }, { priority: 'low' } );

    document.querySelectorAll( '.form-field button' ).forEach( button => {
        const field = button.parentNode;

        button.addEventListener( 'click', () => {
            // Thread ID must be unique.
            // Use field ID + current date time to generate a unique thread ID.
            const threadId = field.id + ':' + new Date().getTime();

            commentsRepository.openNewCommentThread( {
                channelId,
                threadId,
                target: () => getAnnotationTarget( field, threadId ),
                // `context` is additional information about what the comment was made on.
                // It can be left empty but it also can be set to a custom message.
                // The value is used when the comment is displayed in comments archive.
                context: {
                    type: 'text',
                    value: getCustomContextMessage( field )
                },
                // `isResolvable` indicates whether the comment thread can become resolved.
                // Set this flag to `false` to disable the possibility of resolving given comment thread.
                // You will still be able to remove the comment thread.
                isResolvable: true
            } );
        } );
    } );

    commentsRepository.on( 'change:activeCommentThread', ( evt, propName, activeThread ) => {
        // When an active comment thread changes, remove the 'active' class from all the fields.
        document.querySelectorAll( '.form-field.active' )
                .forEach( el => el.classList.remove( 'active' ) );

        // If `activeThread` is not null, highlight the corresponding form field.
        // Handle only comments added to the context channel ID.
        if ( activeThread && activeThread.channelId == channelId ) {
            const field = document.getElementById( activeThread.id.split( ':' )[ 0 ] );

            field.classList.add( 'active' );
        }
    } );

    function getCustomContextMessage( field ) {
        // This function should return the custom context value for given form field.
        // It will depend on your application.
        // Below, we assume HTML structure from this sample.
        return field.previousSibling.innerText + ' ' + field.value;
    }

    function handleNewCommentThread( threadId ) {
        // Get the thread instance and the related DOM element using the thread ID.
        // Note that thread ID format is "fieldId:time".
        const thread = commentsRepository.getCommentThread( threadId );
        const field = document.getElementById( threadId.split( ':' )[ 0 ] );

        // If the thread is not attached yet, attach it.
        // This is the difference between local and remote comments.
        // Locally created comments are attached in the `openNewCommentThread()` call.
        // Remotely created comments need to be attached when they are received.
        if ( !thread.isAttached ) {
            thread.attachTo( () => thread.isResolved ? null : field );
        }

        // Add a CSS class to the field to show that it has a comment.
        field.classList.add( 'has-comment' );

        // Get all open threads for given field.
        const openThreads = commentThreadsForField.get( field.id ) || [];

        // When an annotation is created or reopened we need to bound its focus manager with the field.
        // Thanks to that, the annotation will be focused whenever the field is focused as well.
        // However, this can be done only for one annotation, so we do it only if there are no open
        // annotations for given field.
        if ( !openThreads.length ) {
            const threadView = commentsRepository._threadToController.get( thread ).view;
            const annotation = annotations.collection.getByInnerView( threadView );

            annotation.focusableElements.add( field );
        }

        // Add new thread to open threads list.
        openThreads.push( thread );

        commentThreadsForField.set( field.id, openThreads );
    }

    function getAnnotationTarget( target, threadId ) {
        const thread = commentsRepository.getCommentThread( threadId );

        return thread.isResolved ? null : target;
    }

    function handleRemovedCommentThread( threadId ) {
        // Note that thread ID format is "fieldId:time".
        const field = document.getElementById( threadId.split( ':' )[ 0 ] );
        const openThreads = commentThreadsForField.get( field.id );
        const threadIndex = openThreads.findIndex( openThread => openThread.id === threadId );

        // Remove this comment thread from the list of open comment threads for given field.
        openThreads.splice( threadIndex, 1 );

        // In `handleNewCommentThread` we bound the first comment thread annotation focus manager with the field.
        // If we are removing that comment thread, we need to handle field focus as well.
        // After removing or resolving the first thread you should field focus to the next thread's annotation.
        if ( threadIndex === 0 ) {
            const thread = commentsRepository.getCommentThread( threadId );
            const threadController = commentsRepository._threadToController.get( thread );

            // Remove the old binding between removed annotation and field.
            if ( threadController ) {
                const threadView = threadController.view;
                const annotation = annotations.collection.getByInnerView( threadView );

                annotation.focusableElements.remove( field );
            }

            const newActiveThread = openThreads[ 0 ];

            // If there other open threads, bind another annotation to the field.
            if ( newActiveThread ) {
                const newThreadView = commentsRepository._threadToController.get( newActiveThread ).view;
                const newAnnotation = annotations.collection.getByInnerView( newThreadView );

                newAnnotation.focusableElements.add( field );
            }
        }

        // If there are no more active threads the CSS classes should be removed.
        if ( openThreads.length === 0 ) {
            field.classList.remove( 'has-comment', 'active' );
        }

        commentThreadsForField.set( field.id, openThreads );
    }
} );

index.html 文件的 HTML 结构和样式

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>CKEditor5 Collaboration – Hello World!</title>

    <style type="text/css">
        #editor-presence {
            width: 679px;
            margin: 0 auto;
        }

        #container {
            display: flex;
            position: relative;
            width: 679px;
            margin: 0 auto;
        }

        #editor-annotations {
            width: 300px;
        }

        .form-field {
            padding: 8px 10px;
            margin-bottom: 20px;
            outline: none;
            margin-right: 20px;
            border: 1px solid #DDDDDD;
            border-radius: 3px;
        }

        .form-field.has-comment {
            background: hsl(55, 98%, 83%);
        }

        .form-field.active {
            background: hsl(55, 98%, 68%);
        }

        .form-field label {
            display: inline-block;
            width: 100px;
        }

        .form-field input, .form-field select {
            width: 200px;
            margin: 0px;
            padding: 0px 8px;
            height: 29px;
            background: #FFFFFF;
            border: 1px solid #DDDDDD;
            border-radius: 3px;
            box-sizing: border-box;
        }

        .form-field button {
            width: 29px;
            margin: 0px;
            height: 29px;
            background: #EEEEEE;
            border: 1px solid #DDDDDD;
            border-radius: 3px;
            vertical-align: top;
        }
    </style>
</head>

<body>
    <div id="editor-presence"></div>

    <div id="container">
        <div class="form">
            <div class="form-field" id="field-1" tabindex="-1">
                <label>Field 1:</label>
                <input name="field-1" type="text" value="Input 1">
                <button class="add-comment">+</button>
            </div>
            <div class="form-field" id="field-2" tabindex="-1">
                <label>Field 2:</label>
                <input name="field-2" type="text" value="Input 2">
                <button class="add-comment">+</button>
            </div>
            <div class="form-field" id="field-3" tabindex="-1">
                <label>Field 3:</label>
                <input name="field-3" type="text" value="Input 3">
                <button class="add-comment">+</button>
            </div>
            <div class="form-field" id="field-4" tabindex="-1">
                <label>Field 4:</label>
                <select name="field-4">
                    <option>Option 1</option>
                    <option>Option 2</option>
                    <option>Option 3</option>
                </select>
                <button class="add-comment">+</button>
            </div>
            <div class="form-field" id="field-5" tabindex="-1">
                <label>Field 5:</label>
                <select name="field-5">
                    <option>Option 1</option>
                    <option>Option 2</option>
                    <option>Option 3</option>
                </select>
                <button class="add-comment">+</button>
            </div>
            <div class="form-field" id="field-6" tabindex="-1">
                <label>Field 6:</label>
                <select name="field-6">
                    <option>Option 1</option>
                    <option>Option 2</option>
                    <option>Option 3</option>
                </select>
                <button class="add-comment">+</button>
            </div>
        </div>

        <div id="editor-annotations"></div>
    </div>

    <script src="main.js"></script>
</body>
</html>

# 演示

与您的同事分享此页面的完整 URL,以便实时协作!

单击“加号”按钮添加评论。