今天分享一下tinymce富文本编辑器做评论区的全过程。
"react": "^16.13.1",
"@tinymce/tinymce-react": "^3.14.0",
首先加了一个自定义的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: () => {},});},}}
/>
点击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);},
});
选中文字 -> 点击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组件代码详见下方全部代码当中的公共代码
在AddComment里面的cancel按钮的点击事件进行如下处理,下方代码可在全部代码->公共组件Comment中找到
// 删除已有的高亮的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');}}}
/>
这四个属于具体业务逻辑,与富文本编辑器添加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]}
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;
}
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} />))}
分享不易,望大家一键三连作为笔者持续分享的动力~