• tinymce富文本编辑器做评论区


    今天分享一下tinymce富文本编辑器做评论区的全过程。

    一、介绍

    1.最终效果

    在这里插入图片描述

    2.功能介绍

    1. 自定义toolbar的功能区
    2. 展示、收起评论区
    3. 选择文字然后添加评论
    4. 取消添加评论
    5. 点击左侧富文本内容【有评论的元素】右侧评论区滚动到对应的评论
    6. 删除评论
    7. 编辑评论、回复评论、标记解决评论、艾特人这些属于基本的需求了,本文不做分享

    3.主要项目包版本介绍:

    "react": "^16.13.1",
    "@tinymce/tinymce-react": "^3.14.0",
    
    • 1
    • 2

    二、每个功能的实现

    1.自定义toolbar的功能区

    ①对应的样式以及意义

    在这里插入图片描述
    首先加了一个自定义的icon - 追加评论用的
    然后加了一个自定义的文字 - 显示隐藏评论区用的

    ②对应的代码实现【忽略了一切非实现该功能的代码】

    // 你换成你自己想要追加图片的地址
    import addCommentIcon from '@assets/imgs/Comment/add-comment.png';
    
    <Editor
      init={{
      	// 在toolbar中追加配置 addCommentButton showCommentArea ,这俩个都是我们在setup里面注册的按钮【其他加粗、字体大小那些在这里忽略了】
        toolbar: 'addCommentButton showCommentArea',
        setup: (editor) => {
          // 追加自定义icon - addComment
          editor.ui.registry.addIcon(
            'addComment',
            ``,
          );
          // 在toolbar中追加ICON - 添加评论的按钮 - customCommentButton
          editor.ui.registry.addButton('addCommentButton', {
            type: 'contextformbutton',
            icon: 'addComment', // 使用自定义的icon
            onAction: () => {},
          });
    
          // 在toolbar中追加按钮 - 控制评论区的显示与否
          editor.ui.registry.addButton('showCommentArea', {
            type: 'contextformbutton',
            text: 'Show/Hide comment',
            onAction: () => {},
          });
        },
      }}
    />
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29

    2.展示、收起评论区

    ①对应的样式以及意义

    点击Show/Hide comment控制右侧评论区的显示隐藏【始终显示的话占用空间】
    在这里插入图片描述

    ②对应的代码实现【忽略了一切非实现该功能的代码】

    首先自己做一个评论区的区域

    下方代码说明:
    设置id是为了控制展示、收起【在tinymce的setup中获取dom元素、在那里去react的state会有问题】
    style控制评论区的显示隐藏【笔者这里使用display会触发回流,你可以使用其他的隐藏元素的方法,比如改成定位移出可视区等等】
    Card笔者用的是antd的组件,你可以自己搞一个样式【是否要loading可选】。
    commentList是从后端获取到的comment列表
    Comment是自己做的渲染的每一项的组件【在后续会有这个组件,这里先不写】

    // 是否展示评论区
    const [commentAreaIsShow, setCommentAreaIsShow] = useState(false);
    
    <div
      id="rich-editor-comment-wrapper"
      data-show={JSON.stringify(commentAreaIsShow)}
      style={{ display: commentAreaIsShow ? 'block' : 'none' }}
    >
      <Card bodyStyle={{ height: '60vh', overflowY: 'auto' }}>
        {commentsLoading && <ContainerLoading />}
        {/* view comment */}
        {commentList.map((item) => (
          <Comment key={item.commentId} {...item} {...commentPublicParams} />
        ))}
      </Card>
    </div>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    然后我们改一下刚才注册的tinymce的setup里面的showCommentArea的onAction

    特殊说明:在tinymce的setup里面取不到最新的state,想取得需要重新渲染编辑区,会导致富文本区域闪烁,所以这里通过获取dom的自定义属性获取值取反进行更改。

    // 控制评论区的显示与否
    editor.ui.registry.addButton('showCommentArea', {
      type: 'contextformbutton',
      text: 'Show/Hide comment',
      onAction: () => {
        const commentArea = document.getElementById(
          'rich-editor-comment-wrapper',
        );
        const show = JSON.parse(commentArea.getAttribute('data-show'));
        setCommentAreaIsShow(!show);
      },
    });
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    3.选择文字然后添加评论

    ①对应的样式以及意义

    选中文字 -> 点击add Comment的那个icon然后就会看到右侧评论区加了一个添加评论项
    在这里插入图片描述

    ②对应的代码实现【忽略了一切非实现该功能的代码】

    定义一个addItemInfo、如果有的话那就显示添加comment的组件。

    // 当前add的信息
    const [addItemInfo, setAddItemInfo] = useState({});
    
    // 在刚才的评论区的div里面加一个判断,如果有addItemInfo的话就显示新增评论的Comment元素。
    // addComment固定显示在第一个
    <Card bodyStyle={{ height: '60vh', overflowY: 'auto' }}>
      {commentsLoading && <ContainerLoading />}
      {/* 添加comment - 单独放在最上面 */}
      {addItemInfo?.id && (
        <Comment
          {...addItemInfo}
          setAddItemInfo={setAddItemInfo}
          {...commentPublicParams}
        />
      )}
      {/* view comment */}
      {commentList.map((item) => (
        <Comment key={item.commentId} {...item} {...commentPublicParams} />
      ))}
    </Card>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    改一下刚才tinymce的setup里面的命令

    // 追加自定义命令
    editor.addCommand('addComment', (ui, v) => {
      // 获取选中的内容
      const selectionText = editor.selection.getContent();
      if (selectionText) {
        const uuid = uuid4NoDash(); // 你可以自己用其他方法生成一个uuid
        // 把刚才选中的内容替换成新的内容:加了一个下划线标识,然后加了一个id
        editor.insertContent(`
        ${uuid}"
          style="border-bottom: 1px solid orangered;"
        >
          ${editor.selection.getContent({ format: 'text' })}
        `);
        // 添加comment,id和text是后续调后端接口存数据库用的,type是给comment组件用的
        setAddItemInfo({ id: uuid, type: 'add', text: selectionText });
        // 这里把评论区固定展示出来
        setCommentAreaIsShow(true);
      } else {
      	// 这里用的antd的message,如果没选择文案的话这边抛个提示
        message.warn('Please select a sentence to add a comment.');
      }
    });
    
    // 添加评论的按钮
    editor.ui.registry.addButton('customCommentButton', {
      type: 'contextformbutton',
      icon: 'addComment',
      onAction: () => {
        editor.editorManager.execCommand('addComment');
      },
    });
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32

    Comment组件代码详见下方全部代码当中的公共代码

    4.取消添加评论

    在AddComment里面的cancel按钮的点击事件进行如下处理,下方代码可在全部代码->公共组件Comment中找到
    在这里插入图片描述

    5.点击左侧富文本内容【有评论的元素】右侧评论区滚动到对应的评论

    // 删除已有的高亮的comment标记样式
    function removeMarkComment() {
      // 将已有的高亮样式删除
      const Ele = document.getElementsByClassName('current_selected_comment')[0];
      if (Ele) {
        Ele.classList.remove('current_selected_comment');
      }
    }
    
    <Editor
      onSelectionChange={(event, editor) => {
        const currentEle = editor.selection.getNode();
        const currentEleId = currentEle.getAttribute('id');
        const targetEle = document.getElementById(
          `comment_item_${currentEleId}`,
        );
        removeMarkComment();
        if (targetEle) {
          // 滚动到对应评论区的位置
          targetEle.scrollIntoView({ behavior: 'smooth' });
          // 追加类名,高亮对应区域
          targetEle.classList.add('current_selected_comment');
        }
      }}
    />
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    6.删除评论\编辑评论\回复评论\标记评论

    这四个属于具体业务逻辑,与富文本编辑器添加comment其实是没太大关系的,可自行在下发代码中查看对应的逻辑

    三、完整的代码

    1.公共代码

    ①Comment组件

    /**
     * @note
     * 评论组件【添加、编辑、展示】
     * @param {
     *  type     : 'add' | 'edit' | 'view' - 添加、编辑、查看
     *  id       : string     -   每个评论的唯一id,id是前端生成的uuid
     *  content  : string     -   评论的内容
     *  date     : string     -   评论的日期
     *  user     : string     -   评论人
     *  replyList: [params]   -   回复列表
     * }
     * @author Di Wu
     * @returns ReactComponent
     */
    import React, { useState } from 'react';
    import { connect } from 'react-redux';
    import { Checkbox, Badge } from 'antd';
    import { cloneDeep } from 'lodash';
    import { Button } from 'appkit-react';
    import { PWCInput } from '@components';
    import { ConfirmModal } from '@components/ConfirmModal';
    import { formatDate } from '@utils';
    import { API } from '@service';
    import {
      changeCurrentToNewMode,
      excludeTypeIsEqualToReply,
      handleAddReply,
      cancelReply,
    } from '../../dataProcessor';
    import './index.scss';
    
    import EditCommentIcon from '@assets/imgs/Comment/edit-comment.png';
    import DeleteCommentIcon from '@assets/imgs/Comment/delete-comment.png';
    
    // comment的header部分
    function CommentHeader({
      user,
      date,
      type,
      email,
      loginUserInfo = {},
      channel,
      commentId,
      projectId,
      setCommentsLoading,
      commentList,
      setCommentList,
      getAgendaCommentData,
    }) {
      // 是否展示删除确认的modal
      const [showModal, setShowModal] = useState(false);
    
      return (
        <div className="comment-item-header">
          <div>
            <Badge color="#B23F02" />
            <span className="user-name">{user}</span>
            <span className="date">{formatDate(date)}</span>
          </div>
          {/* 用这条内容的email和当前登录人的email判断是否一致,一致才展示编辑还有删除按钮 */}
          {type === 'view' && loginUserInfo.email === email && (
            <div>
              <img
                src={EditCommentIcon}
                style={{ marginRight: 12 }}
                onClick={() => {
                  // 点击edit的时候把所有的reply删除掉
                  const commentListV2 = excludeTypeIsEqualToReply({ commentList });
                  // 将comment变成编辑模式
                  const newCommentList = changeCurrentToNewMode({
                    commentList: commentListV2,
                    commentId,
                    newType: 'edit',
                  });
                  setCommentList(newCommentList);
                }}
              />
              <img src={DeleteCommentIcon} onClick={() => setShowModal(true)} />
            </div>
          )}
          {/* 删除确认的modal */}
          <ConfirmModal
            handleClickOk={async () => {
              setShowModal(false);
              setCommentsLoading(true);
              // 调删除接口
              const res = await API.deleteAgendaComment({});
              // 重新获取comment数据
              getAgendaCommentData();
              setCommentsLoading(false);
            }}
            handleClickCancel={() => setShowModal(false)}
            modalVisible={showModal}
            type="WARNING"
            width={580}
            // Do you want to delete this comment ?
            content={<p>Do you want to delete this comment thread?</p>}
            cancelText="CANCEL"
            okText="DELETE"
          />
        </div>
      );
    }
    
    // 新增一个reply的输入框
    function ReplyComment(props) {
      const {
        commentList,
        setCommentList,
        commentId,
        getAgendaCommentData,
        projectId,
        userInfo,
        component,
        identifier,
        channel,
        setCommentsLoading,
      } = props;
      const [currentEditVal, setCurrentEditVal] = useState('');
      return (
        <>
          <div className="comment-content">
            <PWCInput
              value={currentEditVal}
              onChange={(e) => setCurrentEditVal(e.target.value)}
            />
          </div>
          <div className="comment-foother">
            <div> </div>
            <div className="btn-group">
              <Button
                className="cancel-btn"
                kind="secondary"
                onClick={() => {
                  // 删除这个reply
                  const newCommentList = cancelReply({ commentList, commentId });
                  setCommentList(newCommentList);
                }}
              >
                Cancel
              </Button>
              <Button
                className="create"
                kind="primary"
                onClick={async () => {
                  console.log(
                    'currentEditVal: ',
                    commentId,
                    '--',
                    currentEditVal,
                    props,
                  );
                  try {
                    setCommentsLoading(true);
                    // 调用reply接口,然后成功之后reload
                    const res = await API.createAgendaComment({});
                    getAgendaCommentData();
                    setCommentsLoading(false);
                  } catch (err) {
                    console.log('!!!!', err);
                  }
                }}
              >
                Comment
              </Button>
            </div>
          </div>
        </>
      );
    }
    
    // comment的reply部分的渲染
    function renderReplyList({ replyList, ...props }) {
      const renderReplyItemObj = ({ item }) =>
        ({
          view: (
            <>
              <CommentHeader {...props} {...item} />
              <div className="comment-content">{item.content}</div>
            </>
          ),
          edit: <EditComment noPedding needFoother={false} {...props} {...item} />,
          reply: <ReplyComment {...props} {...item} />,
        }[item.type]);
      return replyList?.map?.((item) => (
        <div key={item.commentId} className="reply-item-box">
          {renderReplyItemObj({ item })}
        </div>
      ));
    }
    
    // comment的foother部分
    function CommentFoother(props) {
      const {
        commentList,
        setCommentList,
        showReply,
        commentId,
        projectId,
        userInfo,
        channel,
        identifier,
        getAgendaCommentData,
        setCommentsLoading,
      } = props;
      return (
        <div className="comment-foother">
          <div>
            <Checkbox
              checked={false}
              onChange={async () => {
                try {
                  setCommentsLoading(true);
                  // 调用resolve接口,然后成功之后reload
                  const res = await API.editAgendaComment({});
                  getAgendaCommentData();
                  setCommentsLoading(false);
                } catch (err) {
                  console.log('!!!!', err);
                }
              }}
            />{' '}
            Mark as resolved
          </div>
          {/* 判断是否显示,如果这一组的最后一个的type==='reply'那就不显示 */}
          {showReply && (
            <span
              className="reply-btn"
              onClick={() => {
                // 点击edit的时候把所有的reply删除掉
                const commentListV2 = excludeTypeIsEqualToReply({ commentList });
                // 给这组comment加一个reply
                const newCommentList = handleAddReply({
                  commentId,
                  commentList: commentListV2,
                });
                setCommentList(newCommentList);
              }}
            >
              Reply
            </span>
          )}
        </div>
      );
    }
    
    // 新增comment
    function AddComment({
      id,
      user,
      commentList,
      setCommentList,
      setAddItemInfo,
      setCommentsLoading,
      ...props
    }) {
      const {
        userInfo: loginUserInfo,
        projectId,
        text,
        getAgendaCommentData,
      } = props;
      const date = formatDate(new Date());
      const [currentEditVal, setCurrentEditVal] = useState('');
      return (
        <div className="comment-item-box">
          <CommentHeader user={user} date={date} type="add" />
          <div className="comment-content">
            <PWCInput
              value={currentEditVal}
              onChange={(e) => setCurrentEditVal(e.target.value)}
            />
          </div>
          <div className="comment-foother">
            <div> </div>
            <div className="btn-group">
              <Button
                className="cancel-btn"
                kind="secondary"
                onClick={() => {
                  const dom = document
                    .getElementsByTagName('iframe')?.[0]
                    ?.contentWindow?.document?.getElementById?.(id);
                  if (dom) {
                    dom.removeAttribute('id');
                    dom.removeAttribute('style');
                    dom.removeAttribute('data-mce-style');
                    setAddItemInfo({});
                  } else {
                    // catch error
                    console.log('系统出现了未知错误');
                  }
                }}
              >
                Cancel
              </Button>
              <Button
                className="create"
                kind="primary"
                onClick={async () => {
                  // TODO:调后端接口,然后从新刷 comment 区域,或者根据后端的返回的值去做set
                  setCommentsLoading(true);
                  const res = await API.createAgendaComment({});
                  setCurrentEditVal('');
                  setAddItemInfo({});
                  // 重新获取数据
                  await getAgendaCommentData();
                  setCommentsLoading(false);
                }}
              >
                Comment
              </Button>
            </div>
          </div>
        </div>
      );
    }
    // 查看comment
    function ViewComment({ content, ...props }) {
      const { replyList } = props;
      const publicParams = {
        type: 'view',
        loginUserInfo: props.userInfo,
      };
      return (
        <div className="comment-item-box">
          <CommentHeader {...props} {...publicParams} />
          <div className="comment-content">{content}</div>
          {renderReplyList({
            ...props,
            ...publicParams,
          })}
          <CommentFoother
            {...props}
            showReply={replyList?.[replyList.length - 1]?.type !== 'reply'}
          />
        </div>
      );
    }
    // 编辑comment
    function EditComment(props) {
      const {
        noPedding,
        needFoother = true,
        replyList,
        content,
        user,
        commentList,
        commentId,
        userInfo,
        setCommentList,
        projectId,
        identifier,
        channel,
        setCommentsLoading,
        getAgendaCommentData,
      } = props;
      const publicParams = {
        type: 'view',
        loginUserInfo: userInfo,
      };
      const date = formatDate(new Date());
      const [currentEditVal, setCurrentEditVal] = useState(content);
      return (
        <div className="comment-item-box" style={noPedding ? { padding: 0 } : {}}>
          <CommentHeader user={user} date={date} {...props} />
          <div className="comment-content">
            <PWCInput
              value={currentEditVal}
              onChange={(e) => setCurrentEditVal(e.target.value)}
            />
          </div>
          <div className="comment-foother">
            <div> </div>
            <div className="btn-group">
              <Button
                className="cancel-btn"
                kind="secondary"
                onClick={() => {
                  // 将comment变回查看模式
                  const newCommentList = changeCurrentToNewMode({
                    commentList,
                    commentId,
                    newType: 'view',
                  });
                  setCommentList(newCommentList);
                }}
              >
                Cancel
              </Button>
              <Button
                className="create"
                kind="primary"
                onClick={async () => {
                  console.log(
                    'currentEditVal: ',
                    commentId,
                    '--',
                    currentEditVal,
                    props,
                  );
                  try {
                    setCommentsLoading(true);
                    // 调用edit接口,然后成功之后reload
                    const res = await API.editAgendaComment({});
                    getAgendaCommentData();
                    setCommentsLoading(false);
                  } catch (err) {
                    console.log('!!!!', err);
                  }
                }}
              >
                Comment
              </Button>
            </div>
          </div>
          {renderReplyList({
            ...props,
            ...publicParams,
          })}
          {needFoother && (
            <CommentFoother
              {...props}
              showReply={replyList?.[replyList.length - 1]?.type !== 'reply'}
            />
          )}
        </div>
      );
    }
    
    function Comment(props) {
      const { type, id } = props;
      const returnDomByMode = {
        add: <AddComment key={id} {...props} user={props?.userInfo?.name || ''} />,
        edit: <EditComment {...props} />,
        view: <ViewComment {...props} />,
      };
      return (
        <div
          id={`comment_item_${id}`}
          style={{ marginBottom: 12 }}
          onClick={() => {
            const Ele = document.getElementsByClassName(
              'current_selected_comment',
            )[0];
            if (Ele) {
              Ele.classList.remove('current_selected_comment');
            }
          }}
        >
          {returnDomByMode[type]}
        </div>
      );
    }
    // 获取redux当中的登录用户的信息
    const mapStateToProps = ({ login, common }) => ({
      userInfo: login.userInfo,
    });
    export default connect(mapStateToProps, () => ({}))(React.memo(Comment));
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    • 150
    • 151
    • 152
    • 153
    • 154
    • 155
    • 156
    • 157
    • 158
    • 159
    • 160
    • 161
    • 162
    • 163
    • 164
    • 165
    • 166
    • 167
    • 168
    • 169
    • 170
    • 171
    • 172
    • 173
    • 174
    • 175
    • 176
    • 177
    • 178
    • 179
    • 180
    • 181
    • 182
    • 183
    • 184
    • 185
    • 186
    • 187
    • 188
    • 189
    • 190
    • 191
    • 192
    • 193
    • 194
    • 195
    • 196
    • 197
    • 198
    • 199
    • 200
    • 201
    • 202
    • 203
    • 204
    • 205
    • 206
    • 207
    • 208
    • 209
    • 210
    • 211
    • 212
    • 213
    • 214
    • 215
    • 216
    • 217
    • 218
    • 219
    • 220
    • 221
    • 222
    • 223
    • 224
    • 225
    • 226
    • 227
    • 228
    • 229
    • 230
    • 231
    • 232
    • 233
    • 234
    • 235
    • 236
    • 237
    • 238
    • 239
    • 240
    • 241
    • 242
    • 243
    • 244
    • 245
    • 246
    • 247
    • 248
    • 249
    • 250
    • 251
    • 252
    • 253
    • 254
    • 255
    • 256
    • 257
    • 258
    • 259
    • 260
    • 261
    • 262
    • 263
    • 264
    • 265
    • 266
    • 267
    • 268
    • 269
    • 270
    • 271
    • 272
    • 273
    • 274
    • 275
    • 276
    • 277
    • 278
    • 279
    • 280
    • 281
    • 282
    • 283
    • 284
    • 285
    • 286
    • 287
    • 288
    • 289
    • 290
    • 291
    • 292
    • 293
    • 294
    • 295
    • 296
    • 297
    • 298
    • 299
    • 300
    • 301
    • 302
    • 303
    • 304
    • 305
    • 306
    • 307
    • 308
    • 309
    • 310
    • 311
    • 312
    • 313
    • 314
    • 315
    • 316
    • 317
    • 318
    • 319
    • 320
    • 321
    • 322
    • 323
    • 324
    • 325
    • 326
    • 327
    • 328
    • 329
    • 330
    • 331
    • 332
    • 333
    • 334
    • 335
    • 336
    • 337
    • 338
    • 339
    • 340
    • 341
    • 342
    • 343
    • 344
    • 345
    • 346
    • 347
    • 348
    • 349
    • 350
    • 351
    • 352
    • 353
    • 354
    • 355
    • 356
    • 357
    • 358
    • 359
    • 360
    • 361
    • 362
    • 363
    • 364
    • 365
    • 366
    • 367
    • 368
    • 369
    • 370
    • 371
    • 372
    • 373
    • 374
    • 375
    • 376
    • 377
    • 378
    • 379
    • 380
    • 381
    • 382
    • 383
    • 384
    • 385
    • 386
    • 387
    • 388
    • 389
    • 390
    • 391
    • 392
    • 393
    • 394
    • 395
    • 396
    • 397
    • 398
    • 399
    • 400
    • 401
    • 402
    • 403
    • 404
    • 405
    • 406
    • 407
    • 408
    • 409
    • 410
    • 411
    • 412
    • 413
    • 414
    • 415
    • 416
    • 417
    • 418
    • 419
    • 420
    • 421
    • 422
    • 423
    • 424
    • 425
    • 426
    • 427
    • 428
    • 429
    • 430
    • 431
    • 432
    • 433
    • 434
    • 435
    • 436
    • 437
    • 438
    • 439
    • 440
    • 441
    • 442
    • 443
    • 444
    • 445
    • 446
    • 447
    • 448
    • 449
    • 450
    • 451
    • 452
    • 453
    • 454
    • 455
    • 456
    • 457
    • 458
    • 459

    ②数据处理dataProcessor

    import { cloneDeep } from 'lodash';
    // 将后端返回的comment变成前端想要的格式
    function returnNewObj(obj) {
      return {
        type: 'view',
        ...obj,
        id: obj.identifier,
        content: obj.comment,
        date: obj.updatedAt, // 'Oct 14, 2022 04:15 PM'
        user: obj.name,
        commentId: obj.id,
      };
    }
    // 由于这边后端格式不是很理想,所以固有此function
    export function handleCommentData(commonServicesData = []) {
      const resData = [];
      const groupObj = {}; // 将每个channel的进行分组
      // 把所有的resolved的filter掉
      const data = commonServicesData.filter((item) => {
        // 筛选的时候直接把每个分组找出来
        // 需要满足条件:没有被resolved掉 + id等于分组id +
        if (
          !item.channelStatus &&
          item.id === item.channel &&
          !groupObj[item.channel]
        ) {
          groupObj[item.channel] = item;
        }
        return !item.channelStatus;
      });
    
      // 追加reply list
      data.forEach((item) => {
        // 因为上面分组已经都找到了,那么如果没有对应的话就代表是reply
        if (!groupObj[item.id]) {
          if (!groupObj[item.channel].replyList) {
            groupObj[item.channel].replyList = [];
          }
          groupObj[item.channel].replyList.push(returnNewObj(item));
        }
      });
      Object.values(groupObj).forEach((item) => {
        resData.push(returnNewObj(item));
      });
    
      // reply list排序
      resData.forEach(item => {
        if (item.replyList) {
          item.replyList = item.replyList.sort(
            (a, b) => +new Date(a.createdAt) - +new Date(b.createdAt),
          )
        }
      })
      return resData.sort(
        (a, b) => +new Date(b.createdAt) - +new Date(a.createdAt),
      );
    }
    
    // 找到对应的comment,然后改变他的mode
    export function changeCurrentToNewMode({
      commentList = [],
      commentId,
      newType,
    }) {
      const newCommentList = cloneDeep(commentList);
      newCommentList.forEach((item) => {
        if (item.commentId === commentId) {
          item.type = newType;
        } else {
          item.type = 'view'; // 目前只允许同时编辑一个,所以这里将其他的都变成view模式
        }
        if (item.replyList) {
          item.replyList.forEach((ite) => {
            if (ite.commentId === commentId) {
              ite.type = newType;
            } else {
              ite.type = 'view'; // 目前只允许同时编辑一个,所以这里将其他的都变成view模式
            }
          });
        }
      });
      return newCommentList;
    }
    
    // 找到所有的type为reply的,然后filter掉
    export function excludeTypeIsEqualToReply({ commentList = [] }) {
      const newCommentList = cloneDeep(commentList);
      newCommentList.forEach((item) => {
        if (item.replyList) {
          item.replyList = item.replyList.filter((ite) => ite.type !== 'reply');
        }
      });
      return newCommentList;
    }
    
    // 添加reply
    export function handleAddReply({ commentList = [], commentId }) {
      const newCommentList = cloneDeep(commentList);
      newCommentList.forEach((item) => {
        if (item.commentId === commentId) {
          if (!item.replyList) {
            item.replyList = [];
          }
          item.replyList.push({
            type: 'reply',
            commentId,
          });
        }
      });
      return newCommentList;
    }
    
    // 取消reply
    export function cancelReply({ commentList = [], commentId }) {
      const newCommentList = cloneDeep(commentList);
      newCommentList.forEach((item) => {
        if (item.commentId === commentId) {
          item.replyList = item.replyList.slice(0, item.replyList.length - 1);
        }
      });
      return newCommentList;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122

    2.富文本编辑器代码

    import React, { useState } from 'react';
    import { message } from 'antd';
    import classNames from 'classnames';
    import { Editor } from '@tinymce/tinymce-react';
    import { uuid4NoDash } from '@utils/commonFunc';
    import { Card, ContainerLoading } from '@components';
    import { Comment } from './components/index';
    import { API } from '@service';
    import './textEditor.scss';
    import addCommentIcon from '@assets/imgs/Comment/add-comment.png';
    
    function TextEditor({
      changedContent = {},
      isReadOnly,
      setChangedContent,
      commentsLoading,
      setCommentsLoading,
      commentList,
      setCommentList,
      projectId,
      getAgendaCommentData,
    }) {
      // 是否展示评论区
      const [commentAreaIsShow, setCommentAreaIsShow] = useState(false);
      // 当前add的信息
      const [addItemInfo, setAddItemInfo] = useState({});
    
      // 删除已有的高亮的comment标记样式
      function removeMarkComment() {
        // 将已有的高亮样式删除
        const Ele = document.getElementsByClassName('current_selected_comment')[0];
        if (Ele) {
          Ele.classList.remove('current_selected_comment');
        }
      }
    
      // 添加comment
      function addComment({ id, text }) {
        setAddItemInfo({ id, type: 'add', text });
      }
    
      // Comment组件的公共参数
      const commentPublicParams = {
        setCommentsLoading,
        setCommentList,
        commentList,
        projectId,
        getAgendaCommentData,
      };
      return (
        <div
          className={classNames('editor-container', {
            'ready-only-style': isReadOnly,
            'write-style': !isReadOnly,
          })}
        >
          <Editor
            apiKey="自己去申请"
            value={changedContent}
            disabled={isReadOnly}
            init={{
              height: 400,
              menubar: false,
              skin: window.matchMedia('(prefers-color-scheme: dark)').matches
                ? 'oxide-dark'
                : 'oxide',
              plugins: [
                'autolink lists image charmap print preview anchor tinycomments',
                'searchreplace visualblocks code fullscreen',
                'insertdatetime media table paste code help tabfocus spellchecker',
              ],
              paste_data_images: true,
              toolbar_sticky: true,
              toolbar:
                'formatselect | fontsizeselect | bold italic alignleft aligncenter alignright alignjustify | numlist bullist | insertfile image | forecolor backcolor | customCommentButton showCommentArea',
              content_style: `body {font-family:Helvetica,Arial,sans-serif; font-size:14px;color:#dbdbdb; overflow-y: hidden;}`,
              setup: (editor) => {
                // 追加自定义icon
                editor.ui.registry.addIcon(
                  'addComment',
                  ``,
                );
    
                // 追加自定义命令
                editor.addCommand('addComment', (ui, v) => {
                  const selectionText = editor.selection.getContent();
                  if (selectionText) {
                    const uuid = uuid4NoDash();
                    editor.insertContent(`
                    ${uuid}"
                      style="border-bottom: 1px solid orangered;"
                    >
                      ${editor.selection.getContent({ format: 'text' })}
                    `);
                    addComment({ id: uuid, text: selectionText });
                    setCommentAreaIsShow(true);
                  } else {
                    message.warn('Please select a sentence to add a comment.');
                  }
                });
    
                // 添加评论的按钮
                editor.ui.registry.addButton('customCommentButton', {
                  type: 'contextformbutton',
                  icon: 'addComment',
                  onAction: () => {
                    editor.editorManager.execCommand('addComment');
                  },
                });
    
                // 控制评论区的显示与否
                editor.ui.registry.addButton('showCommentArea', {
                  type: 'contextformbutton',
                  text: 'Show/Hide comment',
                  onAction: () => {
                    const commentArea = document.getElementById(
                      'rich-editor-comment-wrapper',
                    );
                    const show = JSON.parse(commentArea.getAttribute('data-show'));
                    setCommentAreaIsShow(!show);
                  },
                });
              },
            }}
            onSelectionChange={(event, editor) => {
              const currentEle = editor.selection.getNode();
              const currentEleId = currentEle.getAttribute('id');
              const targetEle = document.getElementById(
                `comment_item_${currentEleId}`,
              );
              if (targetEle) {
                removeMarkComment();
                // 滚动到对应评论区的位置
                targetEle.scrollIntoView({ behavior: 'smooth' });
                // 追加类名,高亮对应区域
                targetEle.classList.add('current_selected_comment');
              } else {
                removeMarkComment();
              }
            }}
            onEditorChange={onEditorChange}
          />
          <div
            id="rich-editor-comment-wrapper"
            data-show={JSON.stringify(commentAreaIsShow)}
            style={{ display: commentAreaIsShow ? 'block' : 'none' }}
          >
            <Card bodyStyle={{ height: '60vh', overflowY: 'auto' }}>
              {commentsLoading && <ContainerLoading />}
              {/* 添加comment */}
              {addItemInfo?.id && (
                <Comment
                  {...addItemInfo}
                  setAddItemInfo={setAddItemInfo}
                  {...commentPublicParams}
                />
              )}
              {/* view comment */}
              {commentList.map((item) => (
                <Comment key={item.commentId} {...item} {...commentPublicParams} />
              ))}
            </Card>
          </div>
        </div>
      );
    }
    
    export default TextEditor;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    • 150
    • 151
    • 152
    • 153
    • 154
    • 155
    • 156
    • 157
    • 158
    • 159
    • 160
    • 161
    • 162
    • 163
    • 164
    • 165
    • 166
    • 167
    • 168
    • 169

    四、结语

    分享不易,望大家一键三连作为笔者持续分享的动力~

  • 相关阅读:
    leetcode刷题笔记——使用双指针处理链表问题
    计算机毕业设计Java高校教师个人主页网站设计与实现(源码+系统+mysql数据库+lw文档)
    ROS1云课→02系统架构
    #力扣:2769. 找出最大的可达成数字@FDDLC
    Linux/Ubuntu环境搭建(二):创建添加新磁盘、搭建Samba服务器
    MyBatis-plus 分页功能实现
    软件测试中如何测试算法?
    【算法】顺序表力扣OJ
    发布Python包到pypi
    Canal+Kafka实现MySQL与Redis数据同步(一)
  • 原文地址:https://blog.csdn.net/weixin_43606158/article/details/127965067