1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-12 21:30:39 +00:00

Add draft comment button (#2536)

This commit is contained in:
MichaelAn
2018-11-23 10:19:36 +08:00
committed by Daniel Pan
parent 2cf8f1b46e
commit aab50057db
5 changed files with 303 additions and 54 deletions

View File

@@ -4,8 +4,8 @@ import { processor } from '../../utils/seafile-markdown2html';
import { Button, Dropdown, DropdownToggle, DropdownMenu, DropdownItem, Tooltip } from 'reactstrap'; import { Button, Dropdown, DropdownToggle, DropdownMenu, DropdownItem, Tooltip } from 'reactstrap';
import { seafileAPI } from '../../utils/seafile-api'; import { seafileAPI } from '../../utils/seafile-api';
import { reviewID, gettext } from '../../utils/constants'; import { reviewID, gettext } from '../../utils/constants';
import moment from 'moment';
import Loading from '../../components/loading.js'; import Loading from '../../components/loading.js';
import reviewComment from '../../models/review-comment.js';
import '../../css/review-comments.css'; import '../../css/review-comments.css';
@@ -13,7 +13,11 @@ const commentPropTypes = {
getCommentsNumber: PropTypes.func.isRequired, getCommentsNumber: PropTypes.func.isRequired,
inResizing: PropTypes.bool.isRequired, inResizing: PropTypes.bool.isRequired,
toggleCommentList: PropTypes.func.isRequired, toggleCommentList: PropTypes.func.isRequired,
commentsNumber: PropTypes.number.isRequired commentsNumber: PropTypes.number.isRequired,
selectedText: PropTypes.string,
newIndex: PropTypes.number,
oldIndex: PropTypes.number,
scrollToQuote: PropTypes.func.isRequired
}; };
class ReviewComments extends React.Component { class ReviewComments extends React.Component {
@@ -22,11 +26,12 @@ class ReviewComments extends React.Component {
super(props); super(props);
this.state = { this.state = {
commentsList: [], commentsList: [],
userAvatar: `${window.location.host}media/avatars/default.png`, userAvatar: '',
inResizing: false, inResizing: false,
commentFooterHeight: 30, commentFooterHeight: 30,
showResolvedComment: false, showResolvedComment: false,
openResolvedTooltip: false, openResolvedTooltip: false,
comment: '',
}; };
this.accountInfo = {}; this.accountInfo = {};
} }
@@ -34,8 +39,13 @@ class ReviewComments extends React.Component {
listComments = (scroll) => { listComments = (scroll) => {
seafileAPI.listReviewComments(reviewID).then((response) => { seafileAPI.listReviewComments(reviewID).then((response) => {
response.data.comments.reverse(); response.data.comments.reverse();
let commentList = [];
response.data.comments.forEach(item => {
let commentItem = new reviewComment(item);
commentList.push(commentItem);
});
this.setState({ this.setState({
commentsList: response.data.comments commentsList: commentList
}); });
if (scroll) { if (scroll) {
this.refs.commentsList.scrollTo(0, 10000); this.refs.commentsList.scrollTo(0, 10000);
@@ -59,13 +69,29 @@ class ReviewComments extends React.Component {
} }
submitComment = () => { submitComment = () => {
let comment = this.refs.commentTextarea.value; let comment = this.state.comment.trim();
if (comment.trim().length > 0) { if (comment.length > 0) {
seafileAPI.addReviewComment(reviewID, comment.trim()).then((res) => { if (this.props.selectedText.length > 0) {
let detail = {
selectedText: this.props.selectedText.slice(0, 10),
newIndex: this.props.newIndex,
oldIndex: this.props.oldIndex
};
let detailJSON = JSON.stringify(detail);
seafileAPI.addReviewComment(reviewID, comment, detailJSON).then((response) => {
this.listComments(true); this.listComments(true);
this.props.getCommentsNumber(); this.props.getCommentsNumber();
}); });
this.refs.commentTextarea.value = ''; }
else {
seafileAPI.addReviewComment(reviewID, comment).then((response) => {
this.listComments(true);
this.props.getCommentsNumber();
});
}
this.setState({
comment: ''
});
} }
} }
@@ -131,11 +157,37 @@ class ReviewComments extends React.Component {
}); });
}; };
setQuoteText = (text) => {
if (text.length > 0) {
let comment = '> ' + text;
this.setState({
comment: comment
})
}
}
scrollToQuote = (newIndex, oldIndex, selectedText) => {
this.props.scrollToQuote(newIndex, oldIndex, selectedText);
this.setState({
comment: ''
});
}
componentWillMount() { componentWillMount() {
this.getUserAvatar(); this.getUserAvatar();
this.listComments(); this.listComments();
} }
componentDidMount() {
this.setQuoteText(this.props.selectedText);
}
componentWillReceiveProps(nextProps) {
if (this.props.selectedText !== nextProps.selectedText) {
this.setQuoteText(nextProps.selectedText);
}
}
render() { render() {
const onResizeMove = this.state.inResizing ? this.onResizeMouseMove : null; const onResizeMove = this.state.inResizing ? this.onResizeMouseMove : null;
return ( return (
@@ -169,19 +221,11 @@ class ReviewComments extends React.Component {
{ this.state.commentsList.length > 0 && { this.state.commentsList.length > 0 &&
<ul className={'seafile-comment-list'} ref='commentsList'> <ul className={'seafile-comment-list'} ref='commentsList'>
{ (this.state.commentsList.length > 0 && this.props.commentsNumber > 0) && { (this.state.commentsList.length > 0 && this.props.commentsNumber > 0) &&
this.state.commentsList.map((item, index = 0, arr) => { this.state.commentsList.map((item, index) => {
let oldTime = (new Date(item.created_at)).getTime();
let time = moment(oldTime).format('YYYY-MM-DD HH:mm');
return ( return (
<CommentItem id={item.id} time={time} headUrl={item.avatar_url} <CommentItem item={item} showResolvedComment={this.state.showResolvedComment}
comment={item.comment} name={item.user_name} resolveComment={this.resolveComment} accountInfo={this.accountInfo} key={index}
user_email={item.user_email} key={index} resolved={item.resolved} scrollToQuote={this.scrollToQuote} deleteComment={this.deleteComment}/>
deleteComment={this.deleteComment}
resolveComment={this.resolveComment}
commentsList={this.state.commentsList}
accountInfo={this.accountInfo}
showResolvedComment={this.state.showResolvedComment}
/>
); );
}) })
} }
@@ -194,8 +238,8 @@ class ReviewComments extends React.Component {
<img className="avatar" src={this.state.userAvatar} alt="avatar"/> <img className="avatar" src={this.state.userAvatar} alt="avatar"/>
</div> </div>
<div className="seafile-add-comment"> <div className="seafile-add-comment">
<textarea className="add-comment-input" ref="commentTextarea" <textarea className="add-comment-input" value={this.state.comment}
placeholder={gettext('Add a comment.')} placeholder={gettext('Add a comment.')} onChange={this.handleCommentChange}
clos="100" rows="3" warp="virtual"></textarea> clos="100" rows="3" warp="virtual"></textarea>
<Button className="submit-comment" color="success" <Button className="submit-comment" color="success"
size="sm" onClick={this.submitComment}> size="sm" onClick={this.submitComment}>
@@ -209,19 +253,13 @@ class ReviewComments extends React.Component {
ReviewComments.propTypes = commentPropTypes; ReviewComments.propTypes = commentPropTypes;
const commentItemPropTypes = { const commentItemPropTypes = {
comment: PropTypes.string.isRequired, item: PropTypes.object.isRequired,
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
time: PropTypes.string.isRequired,
user_email: PropTypes.string.isRequired,
deleteComment: PropTypes.func.isRequired, deleteComment: PropTypes.func.isRequired,
resolveComment: PropTypes.func.isRequired, resolveComment: PropTypes.func.isRequired,
accountInfo: PropTypes.object.isRequired, accountInfo: PropTypes.object.isRequired,
headUrl: PropTypes.string.isRequired, showResolvedComment: PropTypes.bool.isRequired,
resolved: PropTypes.bool.isRequired, scrollToQuote: PropTypes.func.isRequired
showResolvedComment: PropTypes.bool.isRequired
}; };
class CommentItem extends React.Component { class CommentItem extends React.Component {
@@ -251,44 +289,55 @@ class CommentItem extends React.Component {
); );
} }
scrollToQuote = () => {
this.props.scrollToQuote(this.props.item.newIndex, this.props.item.oldIndex,
this.props.item.selectedText);
}
componentWillMount() { componentWillMount() {
this.convertComment(this.props.comment); this.convertComment(this.props.item.comment);
} }
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
this.convertComment(nextProps.comment); this.convertComment(nextProps.item.comment);
} }
render() { render() {
if (this.props.resolved && !this.props.showResolvedComment) { if (this.props.item.resolved && !this.props.showResolvedComment) {
return null; return null;
} }
return ( return (
<li className={this.props.resolved ? 'seafile-comment-item seafile-comment-item-resolved' <li className={this.props.item.resolved ? 'seafile-comment-item seafile-comment-item-resolved'
: 'seafile-comment-item'} id={this.props.id}> : 'seafile-comment-item'} id={this.props.item.id}>
<div className="seafile-comment-info"> <div className="seafile-comment-info">
<img className="avatar" src={this.props.headUrl} alt="avatar"/> <img className="avatar" src={this.props.item.avatarUrl} alt=""/>
<div className="reviewer-info"> <div className="reviewer-info">
<div className="reviewer-name">{this.props.name}</div> <div className="reviewer-name">{this.props.item.name}</div>
<div className="review-time">{this.props.time}</div> <div className="review-time">{this.props.item.time}</div>
</div> </div>
{ !this.props.resolved && { !this.props.item.resolved &&
<Dropdown isOpen={this.state.dropdownOpen} size="sm" <Dropdown isOpen={this.state.dropdownOpen} size="sm"
className="seafile-comment-dropdown" toggle={this.toggleDropDownMenu}> className="seafile-comment-dropdown" toggle={this.toggleDropDownMenu}>
<DropdownToggle className="seafile-comment-dropdown-btn"> <DropdownToggle className="seafile-comment-dropdown-btn">
<i className="fas fa-ellipsis-v"></i> <i className="fas fa-ellipsis-v"></i>
</DropdownToggle> </DropdownToggle>
<DropdownMenu> <DropdownMenu>
{ (this.props.user_email === this.props.accountInfo.email) && { (this.props.item.userEmail === this.props.accountInfo.email) &&
<DropdownItem onClick={this.props.deleteComment} <DropdownItem onClick={this.props.deleteComment}
className="delete-comment" id={this.props.id}>{gettext('Delete')}</DropdownItem>} className="delete-comment" id={this.props.item.id}>{gettext('Delete')}</DropdownItem>}
<DropdownItem onClick={this.props.resolveComment} <DropdownItem onClick={this.props.resolveComment}
className="seafile-comment-resolved" id={this.props.id}>{gettext('Mark as resolved')}</DropdownItem> className="seafile-comment-resolved" id={this.props.item.id}>{gettext('Mark as resolved')}</DropdownItem>
</DropdownMenu> </DropdownMenu>
</Dropdown> </Dropdown>
} }
</div> </div>
<div className="seafile-comment-content" dangerouslySetInnerHTML={{ __html: this.state.html }}></div> { this.props.item.newIndex ?
<div className="seafile-comment-content" onClick={this.scrollToQuote}
dangerouslySetInnerHTML={{ __html: this.state.html }}></div>
:
<div className="seafile-comment-content"
dangerouslySetInnerHTML={{ __html: this.state.html }}></div>
}
</li> </li>
); );
} }

View File

@@ -119,6 +119,25 @@
line-height: 2rem; line-height: 2rem;
} }
.markdown-viewer-render-content {
position: relative;
}
.review-comment-btn {
position: absolute;
top: 0;
right: 5000px;
border: 1px solid rgba(0, 40, 100, 0.12);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
border-radius: 3px;
background-color: #fff;
padding: 5px;
}
.review-comment-btn:hover {
cursor: pointer;
background-color: #eee;
}
@media (max-width: 992px) { @media (max-width: 992px) {
.main .cur-view-container .cur-view-content-commenton { .main .cur-view-container .cur-view-content-commenton {
width: 20% !important; width: 20% !important;

View File

@@ -47,7 +47,7 @@
text-align: center; text-align: center;
} }
.seafile-comment-item { .seafile-comment-item {
overflow-y: auto; overflow-y: hidden;
padding: 15px 10px; padding: 15px 10px;
overflow-y: hidden; overflow-y: hidden;
} }

View File

@@ -3,7 +3,9 @@ import ReactDOM from 'react-dom';
/* eslint-disable */ /* eslint-disable */
import Prism from 'prismjs'; import Prism from 'prismjs';
/* eslint-enable */ /* eslint-enable */
import { siteRoot, gettext, draftID, reviewID, draftOriginFilePath, draftFilePath, draftOriginRepoID, draftFileName, opStatus, publishFileVersion, originFileVersion, author, authorAvatar } from './utils/constants'; import { siteRoot, gettext, reviewID, draftOriginFilePath, draftFilePath, draftOriginRepoID,
draftFileName, opStatus, publishFileVersion, originFileVersion, author, authorAvatar
} from './utils/constants';
import { seafileAPI } from './utils/seafile-api'; import { seafileAPI } from './utils/seafile-api';
import axios from 'axios'; import axios from 'axios';
import DiffViewer from '@seafile/seafile-editor/dist/viewer/diff-viewer'; import DiffViewer from '@seafile/seafile-editor/dist/viewer/diff-viewer';
@@ -12,6 +14,7 @@ import Toast from './components/toast';
import ReviewComments from './components/review-list-view/review-comments'; import ReviewComments from './components/review-list-view/review-comments';
import { Tooltip } from 'reactstrap'; import { Tooltip } from 'reactstrap';
import AddReviewerDialog from './components/dialog/add-reviewer-dialog.js'; import AddReviewerDialog from './components/dialog/add-reviewer-dialog.js';
import { findRange } from '@seafile/slate-react';
import 'seafile-ui'; import 'seafile-ui';
import './assets/css/fa-solid.css'; import './assets/css/fa-solid.css';
@@ -41,6 +44,9 @@ class DraftReview extends React.Component {
showReviewerDialog: false, showReviewerDialog: false,
reviewers: [], reviewers: [],
}; };
this.selectedText = '';
this.newIndex = null;
this.oldIndex = null;
} }
componentDidMount() { componentDidMount() {
@@ -74,6 +80,11 @@ class DraftReview extends React.Component {
}); });
})); }));
} }
document.addEventListener('selectionchange', this.setBtnPosition);
}
componentWillUnmount() {
document.removeEventListener('selectionchange', this.setBtnPosition);
} }
onCloseReview = () => { onCloseReview = () => {
@@ -171,6 +182,138 @@ class DraftReview extends React.Component {
}); });
} }
setBtnPosition = (e) => {
const nativeSelection = window.getSelection();
if (!nativeSelection.rangeCount) {
return;
}
if (nativeSelection.isCollapsed === false) {
const nativeRange = nativeSelection.getRangeAt(0);
let range = findRange(nativeRange, this.refs.diffViewer.value);
if (!range) {
return;
}
let rect = nativeRange.getBoundingClientRect();
// fix Safari bug
if (navigator.userAgent.indexOf('Chrome') < 0 && navigator.userAgent.indexOf('Safari') > 0) {
if (nativeRange.collapsed && rect.top == 0 && rect.height == 0) {
if (nativeRange.startOffset == 0) {
nativeRange.setEnd(nativeRange.endContainer, 1);
} else {
nativeRange.setStart(nativeRange.startContainer, nativeRange.startOffset - 1);
}
rect = nativeRange.getBoundingClientRect();
if (rect.top == 0 && rect.height == 0) {
if (nativeRange.getClientRects().length) {
rect = nativeRange.getClientRects()[0];
}
}
}
}
let style = this.refs.commentbtn.style;
style.top = `${rect.top - 113 + this.refs.viewContent.scrollTop}px`;
style.right = `${this.refs.viewContent.clientWidth - rect.x - 70}px`;
return range;
}
else {
let style = this.refs.commentbtn.style;
style.top = '0px';
style.right = '5000px';
}
}
addComment = (e) => {
e.stopPropagation();
let range = this.setBtnPosition();
if (!range) {
return;
}
const { document } = this.refs.diffViewer.value;
let { anchor, focus } = range;
const anchorText = document.getNode(anchor.key);
const focusText = document.getNode(focus.key);
const anchorInline = document.getClosestInline(anchor.key);
const focusInline = document.getClosestInline(focus.key);
const focusBlock = document.getClosestBlock(focus.key);
const anchorBlock = document.getClosestBlock(anchor.key);
if (anchorBlock && anchor.offset == 0 && focusBlock && focus.offset != 0) {
range = range.setFocus(focus.setOffset(0));
}
// COMPAT: If the selection is at the end of a non-void inline node, and
// there is a node after it, put it in the node after instead. This
// standardizes the behavior, since it's indistinguishable to the user.
if (anchorInline && anchor.offset == anchorText.text.length) {
const block = document.getClosestBlock(anchor.key);
const nextText = block.getNextText(anchor.key);
if (nextText) {
range = range.moveAnchorTo(nextText.key, 0);
}
}
if (focusInline && focus.offset == focusText.text.length) {
const block = document.getClosestBlock(focus.key);
const nextText = block.getNextText(focus.key);
if (nextText) {
range = range.moveFocusTo(nextText.key, 0);
}
}
let blockPath = document.createSelection(range).anchor.path.slice(0, 1);
let node = document.getNode(blockPath);
this.selectedText = window.getSelection().toString().trim();
this.newIndex = node.data.get('new_index');
this.oldIndex = node.data.get('old_index');
this.setState({
isShowComments: true
});
}
findScrollContainer = (element, window) => {
let parent = element.parentNode;
const OVERFLOWS = ['auto', 'overlay', 'scroll'];
let scroller;
while (!scroller) {
if (!parent.parentNode) break;
const style = window.getComputedStyle(parent);
const { overflowY } = style;
if (OVERFLOWS.includes(overflowY)) {
scroller = parent;
break;
}
parent = parent.parentNode;
}
if (!scroller) {
return window.document.body;
}
return scroller;
}
scrollToQuote = (newIndex, oldIndex, selectedText) => {
const nodes = this.refs.diffViewer.value.document.nodes;
let key;
nodes.map((node) => {
if (node.data.get('old_index') == oldIndex && node.data.get('new_index') == newIndex) {
key = node.key;
}
});
if (typeof(key) !== 'string') {
nodes.map((node) => {
if (node.text.indexOf(selectedText) > 0) {
key = node.key;
}
});
}
if (typeof(key) === 'string') {
const win = window;
const element = win.document.querySelector(`[data-key="${key}"]`);
const scroller = this.findScrollContainer(element, win);
const isWindow = scroller == win.document.body || scroller == win.document.documentElement;
if (isWindow) {
win.scrollTo(0, element.offsetTop);
} else {
scroller.scrollTop = element.offsetTop;
}
}
}
componentWillMount() { componentWillMount() {
this.getCommentsNumber(); this.getCommentsNumber();
this.listReviewers(); this.listReviewers();
@@ -239,18 +382,28 @@ class DraftReview extends React.Component {
<div className="cur-view-container content-container" <div className="cur-view-container content-container"
onMouseMove={onResizeMove} onMouseUp={this.onResizeMouseUp} ref="comment"> onMouseMove={onResizeMove} onMouseUp={this.onResizeMouseUp} ref="comment">
<div style={{width:(100-this.state.commentWidth)+'%'}} <div style={{width:(100-this.state.commentWidth)+'%'}}
className={!this.state.isShowComments ? 'cur-view-content' : 'cur-view-content cur-view-content-commenton'} > className={!this.state.isShowComments ? 'cur-view-content' : 'cur-view-content cur-view-content-commenton'} ref="viewContent">
{this.state.isLoading ? {this.state.isLoading ?
<div className="markdown-viewer-render-content article"> <div className="markdown-viewer-render-content article">
<Loading /> <Loading />
</div> </div>
: :
<div className="markdown-viewer-render-content article"> <div className="markdown-viewer-render-content article" ref="mainPanel">
{this.state.isShowDiff ? {this.state.isShowDiff ?
<DiffViewer newMarkdownContent={this.state.draftContent} oldMarkdownContent={this.state.draftOriginContent} /> <DiffViewer
newMarkdownContent={this.state.draftContent}
oldMarkdownContent={this.state.draftOriginContent}
ref="diffViewer"
/>
: :
<DiffViewer newMarkdownContent={this.state.draftContent} oldMarkdownContent={this.state.draftContent} /> <DiffViewer
newMarkdownContent={this.state.draftContent}
oldMarkdownContent={this.state.draftContent}
ref="diffViewer"
/>
} }
<i className="fa fa-comments review-comment-btn"
ref="commentbtn" onMouseDown={this.addComment}></i>
</div> </div>
} }
</div> </div>
@@ -259,9 +412,13 @@ class DraftReview extends React.Component {
<div className="seafile-comment-resize" onMouseDown={this.onResizeMouseDown}></div> <div className="seafile-comment-resize" onMouseDown={this.onResizeMouseDown}></div>
<ReviewComments <ReviewComments
toggleCommentList={this.toggleCommentList} toggleCommentList={this.toggleCommentList}
commentsNumber={this.state.commentsNumber} scrollToQuote={this.scrollToQuote}
getCommentsNumber={this.getCommentsNumber} getCommentsNumber={this.getCommentsNumber}
commentsNumber={this.state.commentsNumber}
inResizing={this.state.inResizing} inResizing={this.state.inResizing}
selectedText={this.selectedText}
newIndex={this.newIndex}
oldIndex={this.oldIndex}
/> />
</div> </div>
} }

View File

@@ -0,0 +1,24 @@
import moment from 'moment';
class reviewComment {
constructor(item) {
let oldTime = (new Date(item.created_at)).getTime();
this.time = moment(oldTime).format('YYYY-MM-DD HH:mm');
this.id = item.id;
this.avatarUrl = item.avatar_url;
this.comment = item.comment;
this.name = item.user_name;
this.userEmail = item.user_email;
this.resolved = item.resolved;
if (item.detail) {
let detail = JSON.parse(item.detail);
this.newIndex = detail.newIndex;
this.oldIndex = detail.oldIndex;
this.selectedText = detail.selectedText;
}
}
}
export default reviewComment;