tinymce富文本编辑器做评论区
创始人
2024-03-03 12:27:25
0

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

文章目录

  • 一、介绍
    • 1.最终效果
    • 2.功能介绍
    • 3.主要项目包版本介绍:
  • 二、每个功能的实现
    • 1.自定义toolbar的功能区
      • ①对应的样式以及意义
      • ②对应的代码实现【忽略了一切非实现该功能的代码】
    • 2.展示、收起评论区
      • ①对应的样式以及意义
      • ②对应的代码实现【忽略了一切非实现该功能的代码】
    • 3.选择文字然后添加评论
      • ①对应的样式以及意义
      • ②对应的代码实现【忽略了一切非实现该功能的代码】
    • 4.取消添加评论
    • 5.点击左侧富文本内容【有评论的元素】右侧评论区滚动到对应的评论
    • 6.删除评论\编辑评论\回复评论\标记评论
  • 三、完整的代码
    • 1.公共代码
      • ①Comment组件
      • ②数据处理dataProcessor
    • 2.富文本编辑器代码
  • 四、结语

一、介绍

1.最终效果

在这里插入图片描述

2.功能介绍

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

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

"react": "^16.13.1",
"@tinymce/tinymce-react": "^3.14.0",

二、每个功能的实现

1.自定义toolbar的功能区

①对应的样式以及意义

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

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

// 你换成你自己想要追加图片的地址
import addCommentIcon from '@assets/imgs/Comment/add-comment.png';{// 在toolbar中追加配置 addCommentButton showCommentArea ,这俩个都是我们在setup里面注册的按钮【其他加粗、字体大小那些在这里忽略了】toolbar: 'addCommentButton showCommentArea',setup: (editor) => {// 追加自定义icon - addCommenteditor.ui.registry.addIcon('addComment',`addCommentIcon}' />`,);// 在toolbar中追加ICON - 添加评论的按钮 - customCommentButtoneditor.ui.registry.addButton('addCommentButton', {type: 'contextformbutton',icon: 'addComment', // 使用自定义的icononAction: () => {},});// 在toolbar中追加按钮 - 控制评论区的显示与否editor.ui.registry.addButton('showCommentArea', {type: 'contextformbutton',text: 'Show/Hide comment',onAction: () => {},});},}}
/>

2.展示、收起评论区

①对应的样式以及意义

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

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

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

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

// 是否展示评论区
const [commentAreaIsShow, setCommentAreaIsShow] = useState(false);JSON.stringify(commentAreaIsShow)}style={{ display: commentAreaIsShow ? 'block' : 'none' }}
>{ height: '60vh', overflowY: 'auto' }}>{commentsLoading && }{/* view comment */}{commentList.map((item) => (item.commentId} {...item} {...commentPublicParams} />))}

然后我们改一下刚才注册的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);},
});

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

①对应的样式以及意义

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

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

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

// 当前add的信息
const [addItemInfo, setAddItemInfo] = useState({});// 在刚才的评论区的div里面加一个判断,如果有addItemInfo的话就显示新增评论的Comment元素。
// addComment固定显示在第一个
{ height: '60vh', overflowY: 'auto' }}>{commentsLoading && }{/* 添加comment - 单独放在最上面 */}{addItemInfo?.id && (...addItemInfo}setAddItemInfo={setAddItemInfo}{...commentPublicParams}/>)}{/* view comment */}{commentList.map((item) => (item.commentId} {...item} {...commentPublicParams} />))}

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

// 追加自定义命令
editor.addCommand('addComment', (ui, v) => {// 获取选中的内容const selectionText = editor.selection.getContent();if (selectionText) {const uuid = uuid4NoDash(); // 你可以自己用其他方法生成一个uuid// 把刚才选中的内容替换成新的内容:加了一个下划线标识,然后加了一个ideditor.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');},
});

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');}
}(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');}}}
/>

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,
}) {// 是否展示删除确认的modalconst [showModal, setShowModal] = useState(false);return (
{user}{formatDate(date)}
{/* 用这条内容的email和当前登录人的email判断是否一致,一致才展示编辑还有删除按钮 */}{type === 'view' && loginUserInfo.email === email && (
EditCommentIcon}style={{ marginRight: 12 }}onClick={() => {// 点击edit的时候把所有的reply删除掉const commentListV2 = excludeTypeIsEqualToReply({ commentList });// 将comment变成编辑模式const newCommentList = changeCurrentToNewMode({commentList: commentListV2,commentId,newType: 'edit',});setCommentList(newCommentList);}}/>DeleteCommentIcon} onClick={() => setShowModal(true)} />
)}{/* 删除确认的modal */}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={

Do you want to delete this comment thread?

}cancelText="CANCEL"okText="DELETE"/>
); }// 新增一个reply的输入框 function ReplyComment(props) {const {commentList,setCommentList,commentId,getAgendaCommentData,projectId,userInfo,component,identifier,channel,setCommentsLoading,} = props;const [currentEditVal, setCurrentEditVal] = useState('');return (<>
currentEditVal}onChange={(e) => setCurrentEditVal(e.target.value)}/>
() => {// 删除这个replyconst newCommentList = cancelReply({ commentList, commentId });setCommentList(newCommentList);}}>Cancelasync () => {console.log('currentEditVal: ',commentId,'--',currentEditVal,props,);try {setCommentsLoading(true);// 调用reply接口,然后成功之后reloadconst res = await API.createAgendaComment({});getAgendaCommentData();setCommentsLoading(false);} catch (err) {console.log('!!!!', err);}}}>Comment
); }// comment的reply部分的渲染 function renderReplyList({ replyList, ...props }) {const renderReplyItemObj = ({ item }) =>({view: (<>...props} {...item} />
{item.content}
),edit: false} {...props} {...item} />,reply: ...props} {...item} />,}[item.type]);return replyList?.map?.((item) => (
item.commentId} className="reply-item-box">{renderReplyItemObj({ item })}
)); }// comment的foother部分 function CommentFoother(props) {const {commentList,setCommentList,showReply,commentId,projectId,userInfo,channel,identifier,getAgendaCommentData,setCommentsLoading,} = props;return (
false}onChange={async () => {try {setCommentsLoading(true);// 调用resolve接口,然后成功之后reloadconst res = await API.editAgendaComment({});getAgendaCommentData();setCommentsLoading(false);} catch (err) {console.log('!!!!', err);}}}/>{' '}Mark as resolved
{/* 判断是否显示,如果这一组的最后一个的type==='reply'那就不显示 */}{showReply && (() => {// 点击edit的时候把所有的reply删除掉const commentListV2 = excludeTypeIsEqualToReply({ commentList });// 给这组comment加一个replyconst newCommentList = handleAddReply({commentId,commentList: commentListV2,});setCommentList(newCommentList);}}>Reply)}
); }// 新增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 (
user} date={date} type="add" />
currentEditVal}onChange={(e) => setCurrentEditVal(e.target.value)}/>
() => {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 errorconsole.log('系统出现了未知错误');}}}>Cancelasync () => {// TODO:调后端接口,然后从新刷 comment 区域,或者根据后端的返回的值去做setsetCommentsLoading(true);const res = await API.createAgendaComment({});setCurrentEditVal('');setAddItemInfo({});// 重新获取数据await getAgendaCommentData();setCommentsLoading(false);}}>Comment
); } // 查看comment function ViewComment({ content, ...props }) {const { replyList } = props;const publicParams = {type: 'view',loginUserInfo: props.userInfo,};return (
...props} {...publicParams} />
{content}
{renderReplyList({...props,...publicParams,})}...props}showReply={replyList?.[replyList.length - 1]?.type !== 'reply'}/>
); } // 编辑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 (
noPedding ? { padding: 0 } : {}}>user} date={date} {...props} />
currentEditVal}onChange={(e) => setCurrentEditVal(e.target.value)}/>
() => {// 将comment变回查看模式const newCommentList = changeCurrentToNewMode({commentList,commentId,newType: 'view',});setCommentList(newCommentList);}}>Cancelasync () => {console.log('currentEditVal: ',commentId,'--',currentEditVal,props,);try {setCommentsLoading(true);// 调用edit接口,然后成功之后reloadconst res = await API.editAgendaComment({});getAgendaCommentData();setCommentsLoading(false);} catch (err) {console.log('!!!!', err);}}}>Comment
{renderReplyList({...props,...publicParams,})}{needFoother && (...props}showReply={replyList?.[replyList.length - 1]?.type !== 'reply'}/>)}
); }function Comment(props) {const { type, id } = props;const returnDomByMode = {add: id} {...props} user={props?.userInfo?.name || ''} />,edit: ...props} />,view: ...props} />,};return (`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]}
); } // 获取redux当中的登录用户的信息 const mapStateToProps = ({ login, common }) => ({userInfo: login.userInfo, }); export default connect(mapStateToProps, () => ({}))(React.memo(Comment));

②数据处理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 listdata.forEach((item) => {// 因为上面分组已经都找到了,那么如果没有对应的话就代表是replyif (!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;
}

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');}}// 添加commentfunction addComment({ id, text }) {setAddItemInfo({ id, type: 'add', text });}// Comment组件的公共参数const commentPublicParams = {setCommentsLoading,setCommentList,commentList,projectId,getAgendaCommentData,};return (classNames('editor-container', {'ready-only-style': isReadOnly,'write-style': !isReadOnly,})}>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) => {// 追加自定义iconeditor.ui.registry.addIcon('addComment',`addCommentIcon}' />`,);// 追加自定义命令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}/>JSON.stringify(commentAreaIsShow)}style={{ display: commentAreaIsShow ? 'block' : 'none' }}>{ height: '60vh', overflowY: 'auto' }}>{commentsLoading && }{/* 添加comment */}{addItemInfo?.id && (...addItemInfo}setAddItemInfo={setAddItemInfo}{...commentPublicParams}/>)}{/* view comment */}{commentList.map((item) => (item.commentId} {...item} {...commentPublicParams} />))}
); }export default TextEditor;

四、结语

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

相关内容

热门资讯

AWSECS:访问外部网络时出... 如果您在AWS ECS中部署了应用程序,并且该应用程序需要访问外部网络,但是无法正常访问,可能是因为...
AWSElasticBeans... 在Dockerfile中手动配置nginx反向代理。例如,在Dockerfile中添加以下代码:FR...
银河麒麟V10SP1高级服务器... 银河麒麟高级服务器操作系统简介: 银河麒麟高级服务器操作系统V10是针对企业级关键业务...
北信源内网安全管理卸载 北信源内网安全管理是一款网络安全管理软件,主要用于保护内网安全。在日常使用过程中,卸载该软件是一种常...
AWR报告解读 WORKLOAD REPOSITORY PDB report (PDB snapshots) AW...
AWS管理控制台菜单和权限 要在AWS管理控制台中创建菜单和权限,您可以使用AWS Identity and Access Ma...
​ToDesk 远程工具安装及... 目录 前言 ToDesk 优势 ToDesk 下载安装 ToDesk 功能展示 文件传输 设备链接 ...
群晖外网访问终极解决方法:IP... 写在前面的话 受够了群晖的quickconnet的小水管了,急需一个新的解决方法&#x...
不能访问光猫的的管理页面 光猫是现代家庭宽带网络的重要组成部分,它可以提供高速稳定的网络连接。但是,有时候我们会遇到不能访问光...
Azure构建流程(Power... 这可能是由于配置错误导致的问题。请检查构建流程任务中的“发布构建制品”步骤,确保正确配置了“Arti...