mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #13573 from overleaf/ii-review-panel-migration-comment-entry
[web] Create comment entries GitOrigin-RevId: 7f3fbe672d18d57a0f5e683e5456ea79ed295e2d
This commit is contained in:
parent
13ea6b1860
commit
9e8be31bdf
12 changed files with 579 additions and 12 deletions
|
@ -58,6 +58,7 @@
|
||||||
"are_you_affiliated_with_an_institution": "",
|
"are_you_affiliated_with_an_institution": "",
|
||||||
"are_you_getting_an_undefined_control_sequence_error": "",
|
"are_you_getting_an_undefined_control_sequence_error": "",
|
||||||
"are_you_still_at": "",
|
"are_you_still_at": "",
|
||||||
|
"are_you_sure": "",
|
||||||
"ascending": "",
|
"ascending": "",
|
||||||
"ask_proj_owner_to_upgrade_for_full_history": "",
|
"ask_proj_owner_to_upgrade_for_full_history": "",
|
||||||
"ask_proj_owner_to_upgrade_for_longer_compiles": "",
|
"ask_proj_owner_to_upgrade_for_longer_compiles": "",
|
||||||
|
@ -425,6 +426,7 @@
|
||||||
"history_view_a11y_description": "",
|
"history_view_a11y_description": "",
|
||||||
"history_view_all": "",
|
"history_view_all": "",
|
||||||
"history_view_labels": "",
|
"history_view_labels": "",
|
||||||
|
"hit_enter_to_reply": "",
|
||||||
"hotkey_add_a_comment": "",
|
"hotkey_add_a_comment": "",
|
||||||
"hotkey_autocomplete_menu": "",
|
"hotkey_autocomplete_menu": "",
|
||||||
"hotkey_beginning_of_document": "",
|
"hotkey_beginning_of_document": "",
|
||||||
|
@ -621,6 +623,7 @@
|
||||||
"new_to_latex_look_at": "",
|
"new_to_latex_look_at": "",
|
||||||
"newsletter": "",
|
"newsletter": "",
|
||||||
"next_payment_of_x_collectected_on_y": "",
|
"next_payment_of_x_collectected_on_y": "",
|
||||||
|
"no_comments": "",
|
||||||
"no_existing_password": "",
|
"no_existing_password": "",
|
||||||
"no_folder": "",
|
"no_folder": "",
|
||||||
"no_image_files_found": "",
|
"no_image_files_found": "",
|
||||||
|
@ -797,11 +800,13 @@
|
||||||
"replace_from_computer": "",
|
"replace_from_computer": "",
|
||||||
"replace_from_project_files": "",
|
"replace_from_project_files": "",
|
||||||
"replace_from_url": "",
|
"replace_from_url": "",
|
||||||
|
"reply": "",
|
||||||
"repository_name": "",
|
"repository_name": "",
|
||||||
"republish": "",
|
"republish": "",
|
||||||
"resend": "",
|
"resend": "",
|
||||||
"resend_confirmation_email": "",
|
"resend_confirmation_email": "",
|
||||||
"resending_confirmation_email": "",
|
"resending_confirmation_email": "",
|
||||||
|
"resolve": "",
|
||||||
"resolved_comments": "",
|
"resolved_comments": "",
|
||||||
"restore_file": "",
|
"restore_file": "",
|
||||||
"restoring": "",
|
"restoring": "",
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useMemo } from 'react'
|
||||||
import ChangeEntry from './entries/change-entry'
|
import ChangeEntry from './entries/change-entry'
|
||||||
import AggregateChangeEntry from './entries/aggregate-change-entry'
|
import AggregateChangeEntry from './entries/aggregate-change-entry'
|
||||||
import CommentEntry from './entries/comment-entry'
|
import CommentEntry from './entries/comment-entry'
|
||||||
|
@ -5,9 +6,12 @@ import AddCommentEntry from './entries/add-comment-entry'
|
||||||
import BulkActionsEntry from './entries/bulk-actions-entry'
|
import BulkActionsEntry from './entries/bulk-actions-entry'
|
||||||
import { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context'
|
import { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context'
|
||||||
import useCodeMirrorContentHeight from '../../hooks/use-codemirror-content-height'
|
import useCodeMirrorContentHeight from '../../hooks/use-codemirror-content-height'
|
||||||
|
import { ReviewPanelEntry } from '../../../../../../types/review-panel/entry'
|
||||||
|
import { ThreadId } from '../../../../../../types/review-panel/review-panel'
|
||||||
|
|
||||||
function CurrentFileContainer() {
|
function CurrentFileContainer() {
|
||||||
const { entries, openDocId, permissions } = useReviewPanelValueContext()
|
const { commentThreads, entries, openDocId, permissions, loadingThreads } =
|
||||||
|
useReviewPanelValueContext()
|
||||||
const contentHeight = useCodeMirrorContentHeight()
|
const contentHeight = useCodeMirrorContentHeight()
|
||||||
|
|
||||||
console.log('Review panel got content height', contentHeight)
|
console.log('Review panel got content height', contentHeight)
|
||||||
|
@ -15,6 +19,12 @@ function CurrentFileContainer() {
|
||||||
const currentDocEntries =
|
const currentDocEntries =
|
||||||
openDocId && openDocId in entries ? entries[openDocId] : undefined
|
openDocId && openDocId in entries ? entries[openDocId] : undefined
|
||||||
|
|
||||||
|
const objectEntries = useMemo(() => {
|
||||||
|
return Object.entries(currentDocEntries || {}) as Array<
|
||||||
|
[ThreadId, ReviewPanelEntry]
|
||||||
|
>
|
||||||
|
}, [currentDocEntries])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id="review-panel-current-file"
|
id="review-panel-current-file"
|
||||||
|
@ -26,8 +36,8 @@ function CurrentFileContainer() {
|
||||||
className="rp-entry-list-inner"
|
className="rp-entry-list-inner"
|
||||||
style={{ height: `${contentHeight}px` }}
|
style={{ height: `${contentHeight}px` }}
|
||||||
>
|
>
|
||||||
{currentDocEntries &&
|
{openDocId &&
|
||||||
Object.entries(currentDocEntries).map(([id, entry]) => {
|
objectEntries.map(([id, entry]) => {
|
||||||
if (!entry.visible) {
|
if (!entry.visible) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
@ -40,8 +50,16 @@ function CurrentFileContainer() {
|
||||||
return <AggregateChangeEntry key={id} />
|
return <AggregateChangeEntry key={id} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.type === 'comment') {
|
if (entry.type === 'comment' && !loadingThreads) {
|
||||||
return <CommentEntry key={id} />
|
return (
|
||||||
|
<CommentEntry
|
||||||
|
key={id}
|
||||||
|
docId={openDocId}
|
||||||
|
entry={entry}
|
||||||
|
entryId={id}
|
||||||
|
threads={commentThreads}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.type === 'add-comment' && permissions.comment) {
|
if (entry.type === 'add-comment' && permissions.comment) {
|
||||||
|
|
|
@ -1,7 +1,195 @@
|
||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import EntryContainer from './entry-container'
|
import EntryContainer from './entry-container'
|
||||||
|
import Comment from './comment'
|
||||||
|
import EntryActions from './entry-actions'
|
||||||
|
import AutoExpandingTextArea, {
|
||||||
|
resetHeight,
|
||||||
|
} from '../../../../../shared/components/auto-expanding-text-area'
|
||||||
|
import Icon from '../../../../../shared/components/icon'
|
||||||
|
import {
|
||||||
|
useReviewPanelUpdaterFnsContext,
|
||||||
|
useReviewPanelValueContext,
|
||||||
|
} from '../../../context/review-panel/review-panel-context'
|
||||||
|
import classnames from 'classnames'
|
||||||
|
import { ReviewPanelCommentEntry } from '../../../../../../../types/review-panel/entry'
|
||||||
|
import {
|
||||||
|
DocId,
|
||||||
|
ReviewPanelCommentThreads,
|
||||||
|
ThreadId,
|
||||||
|
} from '../../../../../../../types/review-panel/review-panel'
|
||||||
|
|
||||||
function CommentEntry() {
|
type CommentEntryProps = {
|
||||||
return <EntryContainer>Comment entry</EntryContainer>
|
docId: DocId
|
||||||
|
entry: ReviewPanelCommentEntry
|
||||||
|
entryId: ThreadId
|
||||||
|
threads: ReviewPanelCommentThreads
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommentEntry({ docId, entry, entryId, threads }: CommentEntryProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const {
|
||||||
|
permissions,
|
||||||
|
gotoEntry,
|
||||||
|
toggleReviewPanel,
|
||||||
|
resolveComment,
|
||||||
|
submitReply,
|
||||||
|
handleLayoutChange,
|
||||||
|
} = useReviewPanelValueContext()
|
||||||
|
const { setEntryHover } = useReviewPanelUpdaterFnsContext()
|
||||||
|
|
||||||
|
const [replyContent, setReplyContent] = useState('')
|
||||||
|
const [animating, setAnimating] = useState(false)
|
||||||
|
const entryDivRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const thread =
|
||||||
|
entry.thread_id in threads ? threads[entry.thread_id] : undefined
|
||||||
|
|
||||||
|
const handleEntryClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const target = e.target as Element
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
'rp-entry',
|
||||||
|
'rp-comment-loaded',
|
||||||
|
'rp-comment-content',
|
||||||
|
'rp-comment-reply',
|
||||||
|
'rp-entry-metadata',
|
||||||
|
].some(className => [...target.classList].includes(className))
|
||||||
|
) {
|
||||||
|
gotoEntry(docId, entry.offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAnimateAndCallOnResolve = () => {
|
||||||
|
setAnimating(true)
|
||||||
|
|
||||||
|
if (entryDivRef.current) {
|
||||||
|
entryDivRef.current.style.top = '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
resolveComment(docId, entryId)
|
||||||
|
}, 350)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCommentReplyKeyPress = (
|
||||||
|
e: React.KeyboardEvent<HTMLTextAreaElement>
|
||||||
|
) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (replyContent.length) {
|
||||||
|
;(e.target as HTMLTextAreaElement).blur()
|
||||||
|
submitReply(entry, replyContent)
|
||||||
|
setReplyContent('')
|
||||||
|
resetHeight(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOnReply = () => {
|
||||||
|
if (replyContent.length) {
|
||||||
|
submitReply(entry, replyContent)
|
||||||
|
setReplyContent('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!thread) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EntryContainer>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||||
|
<div
|
||||||
|
className={classnames('rp-comment-wrapper', {
|
||||||
|
'rp-comment-wrapper-resolving': animating,
|
||||||
|
})}
|
||||||
|
onMouseEnter={() => setEntryHover(true)}
|
||||||
|
onMouseLeave={() => setEntryHover(false)}
|
||||||
|
onClick={handleEntryClick}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rp-entry-callout rp-entry-callout-comment"
|
||||||
|
style={{
|
||||||
|
top: entry.screenPos
|
||||||
|
? entry.screenPos.y + entry.screenPos.height - 1 + 'px'
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||||
|
<div
|
||||||
|
className={classnames('rp-entry-indicator', {
|
||||||
|
'rp-entry-indicator-focused': entry.focused,
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
top: entry.screenPos ? `${entry.screenPos.y}px` : undefined,
|
||||||
|
}}
|
||||||
|
onClick={toggleReviewPanel}
|
||||||
|
>
|
||||||
|
<Icon type="comment" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={classnames('rp-entry', 'rp-entry-comment', {
|
||||||
|
'rp-entry-focused': entry.focused,
|
||||||
|
'rp-entry-comment-resolving': animating,
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
top: entry.screenPos ? `${entry.screenPos.y}px` : undefined,
|
||||||
|
visibility: entry.visible ? 'visible' : 'hidden',
|
||||||
|
}}
|
||||||
|
ref={entryDivRef}
|
||||||
|
>
|
||||||
|
{!thread.submitting && (!thread || thread.messages.length === 0) && (
|
||||||
|
<div className="rp-loading">{t('no_comments')}</div>
|
||||||
|
)}
|
||||||
|
<div className="rp-comment-loaded">
|
||||||
|
{thread.messages.map(comment => (
|
||||||
|
<Comment
|
||||||
|
key={comment.id}
|
||||||
|
thread={thread}
|
||||||
|
threadId={entry.thread_id}
|
||||||
|
comment={comment}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{thread.submitting && (
|
||||||
|
<div className="rp-loading">
|
||||||
|
<Icon type="spinner" spin />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{permissions.comment && (
|
||||||
|
<div className="rp-comment-reply">
|
||||||
|
<AutoExpandingTextArea
|
||||||
|
className="rp-comment-input"
|
||||||
|
onChange={e => setReplyContent(e.target.value)}
|
||||||
|
onKeyPress={handleCommentReplyKeyPress}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
onResize={handleLayoutChange}
|
||||||
|
placeholder={t('hit_enter_to_reply')}
|
||||||
|
value={replyContent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<EntryActions>
|
||||||
|
{permissions.comment && permissions.write && (
|
||||||
|
<EntryActions.Button onClick={handleAnimateAndCallOnResolve}>
|
||||||
|
<Icon type="inbox" /> {t('resolve')}
|
||||||
|
</EntryActions.Button>
|
||||||
|
)}
|
||||||
|
{permissions.comment && (
|
||||||
|
<EntryActions.Button
|
||||||
|
onClick={handleOnReply}
|
||||||
|
disabled={!replyContent.length}
|
||||||
|
>
|
||||||
|
<Icon type="reply" /> {t('reply')}
|
||||||
|
</EntryActions.Button>
|
||||||
|
)}
|
||||||
|
</EntryActions>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EntryContainer>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CommentEntry
|
export default CommentEntry
|
||||||
|
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import AutoExpandingTextArea from '../../../../../shared/components/auto-expanding-text-area'
|
||||||
|
import { formatTime } from '../../../../utils/format-date'
|
||||||
|
import { useReviewPanelValueContext } from '../../../context/review-panel/review-panel-context'
|
||||||
|
import {
|
||||||
|
ReviewPanelCommentThread,
|
||||||
|
ReviewPanelCommentThreadMessage,
|
||||||
|
ThreadId,
|
||||||
|
} from '../../../../../../../types/review-panel/review-panel'
|
||||||
|
|
||||||
|
type CommentProps = {
|
||||||
|
thread: ReviewPanelCommentThread
|
||||||
|
threadId: ThreadId
|
||||||
|
comment: ReviewPanelCommentThreadMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
function Comment({ thread, threadId, comment }: CommentProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { deleteComment, handleLayoutChange, saveEdit } =
|
||||||
|
useReviewPanelValueContext()
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
|
||||||
|
const handleConfirmDelete = () => {
|
||||||
|
setDeleting(true)
|
||||||
|
handleLayoutChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDoDelete = () => {
|
||||||
|
setDeleting(false)
|
||||||
|
deleteComment(threadId, comment.id)
|
||||||
|
handleLayoutChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelDelete = () => {
|
||||||
|
setDeleting(false)
|
||||||
|
handleLayoutChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStartEditing = () => {
|
||||||
|
setEditing(true)
|
||||||
|
handleLayoutChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveEditOnEnter = (
|
||||||
|
e: React.KeyboardEvent<HTMLTextAreaElement>
|
||||||
|
) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSaveEdit(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveEdit = (
|
||||||
|
e:
|
||||||
|
| React.FocusEvent<HTMLTextAreaElement>
|
||||||
|
| React.KeyboardEvent<HTMLTextAreaElement>
|
||||||
|
) => {
|
||||||
|
setEditing(false)
|
||||||
|
saveEdit(threadId, comment.id, (e.target as HTMLTextAreaElement).value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rp-comment">
|
||||||
|
<p className="rp-comment-content">
|
||||||
|
{editing ? (
|
||||||
|
<AutoExpandingTextArea
|
||||||
|
className="rp-comment-input"
|
||||||
|
onKeyPress={handleSaveEditOnEnter}
|
||||||
|
onBlur={handleSaveEdit}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
onResize={handleLayoutChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="rp-entry-user"
|
||||||
|
style={{ color: `hsl(${comment.user.hue}, 70%, 40%` }}
|
||||||
|
>
|
||||||
|
{comment.user.name}:
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{comment.content}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{!editing && (
|
||||||
|
<div className="rp-entry-metadata">
|
||||||
|
{!deleting && formatTime(comment.timestamp, 'MMM d, y h:mm a')}
|
||||||
|
{comment.user.isSelf && !deleting && (
|
||||||
|
<span className="rp-comment-actions">
|
||||||
|
•
|
||||||
|
<button onClick={handleStartEditing}>{t('edit')}</button>
|
||||||
|
{thread.messages.length > 1 && (
|
||||||
|
<>
|
||||||
|
•
|
||||||
|
<button onClick={handleConfirmDelete}>{t('delete')}</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{comment.user.isSelf && deleting && (
|
||||||
|
<span className="rp-confim-delete">
|
||||||
|
{t('are_you_sure')} •
|
||||||
|
<button type="button" onClick={handleDoDelete}>
|
||||||
|
{t('delete')}
|
||||||
|
</button>
|
||||||
|
•
|
||||||
|
<button onClick={handleCancelDelete}>{t('cancel')}</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Comment
|
|
@ -0,0 +1,19 @@
|
||||||
|
import classnames from 'classnames'
|
||||||
|
|
||||||
|
function EntryActions({
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return <div className={classnames('rp-entry-actions', className)} {...rest} />
|
||||||
|
}
|
||||||
|
|
||||||
|
EntryActions.Button = function EntryActionsButton({
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: React.ComponentPropsWithoutRef<'button'>) {
|
||||||
|
return (
|
||||||
|
<button className={classnames('rp-entry-button', className)} {...rest} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EntryActions
|
|
@ -1,20 +1,30 @@
|
||||||
import { useMemo, useCallback } from 'react'
|
import { useState, useMemo, useCallback } from 'react'
|
||||||
import useScopeValue from '../../../../../shared/hooks/use-scope-value'
|
import useScopeValue from '../../../../../shared/hooks/use-scope-value'
|
||||||
|
import useScopeEventEmitter from '../../../../../shared/hooks/use-scope-event-emitter'
|
||||||
import { ReviewPanelState } from '../types/review-panel-state'
|
import { ReviewPanelState } from '../types/review-panel-state'
|
||||||
import { sendMB } from '../../../../../infrastructure/event-tracking'
|
import { sendMB } from '../../../../../infrastructure/event-tracking'
|
||||||
import * as ReviewPanel from '../types/review-panel-state'
|
import * as ReviewPanel from '../types/review-panel-state'
|
||||||
import { SubView } from '../../../../../../../types/review-panel/review-panel'
|
import { SubView } from '../../../../../../../types/review-panel/review-panel'
|
||||||
|
import { ReviewPanelCommentEntry } from '../../../../../../../types/review-panel/entry'
|
||||||
|
|
||||||
function useAngularReviewPanelState(): ReviewPanelState {
|
function useAngularReviewPanelState(): ReviewPanelState {
|
||||||
|
const emitLayoutChange = useScopeEventEmitter('review-panel:layout', false)
|
||||||
|
|
||||||
const [subView, setSubView] = useScopeValue<ReviewPanel.Value<'subView'>>(
|
const [subView, setSubView] = useScopeValue<ReviewPanel.Value<'subView'>>(
|
||||||
'reviewPanel.subView'
|
'reviewPanel.subView'
|
||||||
)
|
)
|
||||||
const [collapsed, setCollapsed] = useScopeValue<
|
const [collapsed, setCollapsed] = useScopeValue<
|
||||||
ReviewPanel.Value<'collapsed'>
|
ReviewPanel.Value<'collapsed'>
|
||||||
>('reviewPanel.overview.docsCollapsedState')
|
>('reviewPanel.overview.docsCollapsedState')
|
||||||
|
const [commentThreads] = useScopeValue<ReviewPanel.Value<'commentThreads'>>(
|
||||||
|
'reviewPanel.commentThreads',
|
||||||
|
true
|
||||||
|
)
|
||||||
const [entries] = useScopeValue<ReviewPanel.Value<'entries'>>(
|
const [entries] = useScopeValue<ReviewPanel.Value<'entries'>>(
|
||||||
'reviewPanel.entries'
|
'reviewPanel.entries'
|
||||||
)
|
)
|
||||||
|
const [loadingThreads] =
|
||||||
|
useScopeValue<ReviewPanel.Value<'loadingThreads'>>('loadingThreads')
|
||||||
|
|
||||||
const [permissions] =
|
const [permissions] =
|
||||||
useScopeValue<ReviewPanel.Value<'permissions'>>('permissions')
|
useScopeValue<ReviewPanel.Value<'permissions'>>('permissions')
|
||||||
|
@ -50,6 +60,14 @@ function useAngularReviewPanelState(): ReviewPanelState {
|
||||||
const [trackChangesForGuestsAvailable] = useScopeValue<
|
const [trackChangesForGuestsAvailable] = useScopeValue<
|
||||||
ReviewPanel.Value<'trackChangesForGuestsAvailable'>
|
ReviewPanel.Value<'trackChangesForGuestsAvailable'>
|
||||||
>('reviewPanel.trackChangesForGuestsAvailable')
|
>('reviewPanel.trackChangesForGuestsAvailable')
|
||||||
|
const [resolveComment] =
|
||||||
|
useScopeValue<ReviewPanel.Value<'resolveComment'>>('resolveComment')
|
||||||
|
const [deleteComment] =
|
||||||
|
useScopeValue<ReviewPanel.Value<'deleteComment'>>('deleteComment')
|
||||||
|
const [gotoEntry] = useScopeValue<ReviewPanel.Value<'gotoEntry'>>('gotoEntry')
|
||||||
|
const [saveEdit] = useScopeValue<ReviewPanel.Value<'saveEdit'>>('saveEdit')
|
||||||
|
const [submitReplyAngular] =
|
||||||
|
useScopeValue<(entry: ReviewPanelCommentEntry) => void>('submitReply')
|
||||||
|
|
||||||
const [formattedProjectMembers] = useScopeValue<
|
const [formattedProjectMembers] = useScopeValue<
|
||||||
ReviewPanel.Value<'formattedProjectMembers'>
|
ReviewPanel.Value<'formattedProjectMembers'>
|
||||||
|
@ -66,12 +84,36 @@ function useAngularReviewPanelState(): ReviewPanelState {
|
||||||
[setSubView]
|
[setSubView]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleLayoutChange = useCallback(() => {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
emitLayoutChange()
|
||||||
|
})
|
||||||
|
}, [emitLayoutChange])
|
||||||
|
|
||||||
|
const submitReply = useCallback(
|
||||||
|
(entry: ReviewPanelCommentEntry, replyContent: string) => {
|
||||||
|
submitReplyAngular({ ...entry, replyContent })
|
||||||
|
},
|
||||||
|
[submitReplyAngular]
|
||||||
|
)
|
||||||
|
|
||||||
|
const [entryHover, setEntryHover] = useState(false)
|
||||||
|
|
||||||
const values = useMemo<ReviewPanelState['values']>(
|
const values = useMemo<ReviewPanelState['values']>(
|
||||||
() => ({
|
() => ({
|
||||||
collapsed,
|
collapsed,
|
||||||
|
commentThreads,
|
||||||
|
deleteComment,
|
||||||
entries,
|
entries,
|
||||||
|
entryHover,
|
||||||
|
gotoEntry,
|
||||||
|
handleLayoutChange,
|
||||||
|
loadingThreads,
|
||||||
permissions,
|
permissions,
|
||||||
|
resolveComment,
|
||||||
|
saveEdit,
|
||||||
shouldCollapse,
|
shouldCollapse,
|
||||||
|
submitReply,
|
||||||
subView,
|
subView,
|
||||||
wantTrackChanges,
|
wantTrackChanges,
|
||||||
openDocId,
|
openDocId,
|
||||||
|
@ -87,9 +129,18 @@ function useAngularReviewPanelState(): ReviewPanelState {
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
collapsed,
|
collapsed,
|
||||||
|
commentThreads,
|
||||||
|
deleteComment,
|
||||||
entries,
|
entries,
|
||||||
|
entryHover,
|
||||||
|
gotoEntry,
|
||||||
|
handleLayoutChange,
|
||||||
|
loadingThreads,
|
||||||
permissions,
|
permissions,
|
||||||
|
resolveComment,
|
||||||
|
saveEdit,
|
||||||
shouldCollapse,
|
shouldCollapse,
|
||||||
|
submitReply,
|
||||||
subView,
|
subView,
|
||||||
wantTrackChanges,
|
wantTrackChanges,
|
||||||
openDocId,
|
openDocId,
|
||||||
|
@ -108,10 +159,11 @@ function useAngularReviewPanelState(): ReviewPanelState {
|
||||||
const updaterFns = useMemo<ReviewPanelState['updaterFns']>(
|
const updaterFns = useMemo<ReviewPanelState['updaterFns']>(
|
||||||
() => ({
|
() => ({
|
||||||
handleSetSubview,
|
handleSetSubview,
|
||||||
|
setEntryHover,
|
||||||
setCollapsed,
|
setCollapsed,
|
||||||
setShouldCollapse,
|
setShouldCollapse,
|
||||||
}),
|
}),
|
||||||
[handleSetSubview, setCollapsed, setShouldCollapse]
|
[handleSetSubview, setCollapsed, setEntryHover, setShouldCollapse]
|
||||||
)
|
)
|
||||||
|
|
||||||
return { values, updaterFns }
|
return { values, updaterFns }
|
||||||
|
|
|
@ -1,16 +1,33 @@
|
||||||
import {
|
import {
|
||||||
|
CommentId,
|
||||||
DocId,
|
DocId,
|
||||||
|
ReviewPanelCommentThreads,
|
||||||
ReviewPanelEntries,
|
ReviewPanelEntries,
|
||||||
ReviewPanelPermissions,
|
ReviewPanelPermissions,
|
||||||
SubView,
|
SubView,
|
||||||
|
ThreadId,
|
||||||
} from '../../../../../../../types/review-panel/review-panel'
|
} from '../../../../../../../types/review-panel/review-panel'
|
||||||
|
import { ReviewPanelCommentEntry } from '../../../../../../../types/review-panel/entry'
|
||||||
|
|
||||||
export interface ReviewPanelState {
|
export interface ReviewPanelState {
|
||||||
values: {
|
values: {
|
||||||
collapsed: Record<string, boolean>
|
collapsed: Record<string, boolean>
|
||||||
|
commentThreads: ReviewPanelCommentThreads
|
||||||
|
deleteComment: (threadId: ThreadId, commentId: CommentId) => void
|
||||||
entries: ReviewPanelEntries
|
entries: ReviewPanelEntries
|
||||||
|
entryHover: boolean
|
||||||
|
gotoEntry: (docId: DocId, entryOffset: number) => void
|
||||||
|
handleLayoutChange: () => void
|
||||||
|
loadingThreads: boolean
|
||||||
permissions: ReviewPanelPermissions
|
permissions: ReviewPanelPermissions
|
||||||
|
resolveComment: (docId: DocId, entryId: ThreadId) => void
|
||||||
|
saveEdit: (
|
||||||
|
threadId: ThreadId,
|
||||||
|
commentId: CommentId,
|
||||||
|
content: string
|
||||||
|
) => void
|
||||||
shouldCollapse: boolean
|
shouldCollapse: boolean
|
||||||
|
submitReply: (entry: ReviewPanelCommentEntry, replyContent: string) => void
|
||||||
subView: SubView
|
subView: SubView
|
||||||
wantTrackChanges: boolean
|
wantTrackChanges: boolean
|
||||||
openDocId: DocId | null
|
openDocId: DocId | null
|
||||||
|
@ -32,6 +49,7 @@ export interface ReviewPanelState {
|
||||||
}
|
}
|
||||||
updaterFns: {
|
updaterFns: {
|
||||||
handleSetSubview: (subView: SubView) => void
|
handleSetSubview: (subView: SubView) => void
|
||||||
|
setEntryHover: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
setCollapsed: React.Dispatch<
|
setCollapsed: React.Dispatch<
|
||||||
React.SetStateAction<ReviewPanelState['values']['collapsed']>
|
React.SetStateAction<ReviewPanelState['values']['collapsed']>
|
||||||
>
|
>
|
||||||
|
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { callFnsInSequence } from '../../utils/functions'
|
||||||
|
import { MergeAndOverride } from '../../../../types/utils'
|
||||||
|
|
||||||
|
export const resetHeight = (
|
||||||
|
e:
|
||||||
|
| React.ChangeEvent<HTMLTextAreaElement>
|
||||||
|
| React.KeyboardEvent<HTMLTextAreaElement>
|
||||||
|
) => {
|
||||||
|
const el = e.target as HTMLTextAreaElement
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
const curHeight = el.offsetHeight
|
||||||
|
const fitHeight = el.scrollHeight
|
||||||
|
// clear height if text area is empty
|
||||||
|
if (!el.value.length) {
|
||||||
|
el.style.removeProperty('height')
|
||||||
|
}
|
||||||
|
// otherwise expand to fit text
|
||||||
|
else if (fitHeight > curHeight) {
|
||||||
|
el.style.height = `${fitHeight}px`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type AutoExpandingTextAreaProps = MergeAndOverride<
|
||||||
|
React.ComponentProps<'textarea'>,
|
||||||
|
{
|
||||||
|
onResize?: () => void
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
|
function AutoExpandingTextArea({
|
||||||
|
onChange,
|
||||||
|
onResize,
|
||||||
|
...rest
|
||||||
|
}: AutoExpandingTextAreaProps) {
|
||||||
|
const ref = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current || !onResize || !('ResizeObserver' in window)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let isFirstResize = true
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
// Ignore the resize that is triggered when the element is first
|
||||||
|
// inserted into the DOM
|
||||||
|
if (isFirstResize) {
|
||||||
|
isFirstResize = false
|
||||||
|
} else {
|
||||||
|
onResize()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
resizeObserver.observe(ref.current)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect()
|
||||||
|
}
|
||||||
|
}, [onResize])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
onChange={callFnsInSequence(onChange, resetHeight)}
|
||||||
|
{...rest}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AutoExpandingTextArea
|
|
@ -1294,4 +1294,18 @@ button when (@is-overleaf-light = true) {
|
||||||
height: 0;
|
height: 0;
|
||||||
transition: height 150ms;
|
transition: height 150ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rp-entry-metadata {
|
||||||
|
button {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
color: @ol-blue;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -166,4 +166,27 @@ describe('<ReviewPanel />', function () {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('comment entries', function () {
|
||||||
|
// eslint-disable-next-line mocha/no-skipped-tests
|
||||||
|
it.skip('shows threads and comments', function () {})
|
||||||
|
|
||||||
|
// eslint-disable-next-line mocha/no-skipped-tests
|
||||||
|
it.skip('edits comment', function () {})
|
||||||
|
|
||||||
|
// eslint-disable-next-line mocha/no-skipped-tests
|
||||||
|
it.skip('deletes comment', function () {})
|
||||||
|
|
||||||
|
// eslint-disable-next-line mocha/no-skipped-tests
|
||||||
|
it.skip('cancels comment editing', function () {})
|
||||||
|
|
||||||
|
// eslint-disable-next-line mocha/no-skipped-tests
|
||||||
|
it.skip('cancels comment deletion', function () {})
|
||||||
|
|
||||||
|
// eslint-disable-next-line mocha/no-skipped-tests
|
||||||
|
it.skip('adds new comment (replies) to a thread', function () {})
|
||||||
|
|
||||||
|
// eslint-disable-next-line mocha/no-skipped-tests
|
||||||
|
it.skip('resolves comment', function () {})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { ThreadId } from './review-panel'
|
||||||
|
|
||||||
interface ReviewPanelEntryScreenPos {
|
interface ReviewPanelEntryScreenPos {
|
||||||
y: number
|
y: number
|
||||||
height: number
|
height: number
|
||||||
|
@ -8,14 +10,15 @@ interface ReviewPanelBaseEntry {
|
||||||
visible: boolean
|
visible: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReviewPanelCommentEntry extends ReviewPanelBaseEntry {
|
export interface ReviewPanelCommentEntry extends ReviewPanelBaseEntry {
|
||||||
type: 'comment'
|
type: 'comment'
|
||||||
content: string
|
content: string
|
||||||
entry_ids: string[]
|
entry_ids: string[]
|
||||||
focused: boolean
|
focused: boolean
|
||||||
offset: number
|
offset: number
|
||||||
screenPos: ReviewPanelEntryScreenPos
|
screenPos: ReviewPanelEntryScreenPos
|
||||||
thread_id: string
|
thread_id: ThreadId
|
||||||
|
replyContent?: string // angular specific
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReviewPanelInsertEntry extends ReviewPanelBaseEntry {
|
interface ReviewPanelInsertEntry extends ReviewPanelBaseEntry {
|
||||||
|
|
|
@ -10,7 +10,42 @@ export interface ReviewPanelPermissions {
|
||||||
comment: boolean
|
comment: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReviewPanelDocEntries = Record<string, ReviewPanelEntry>
|
export type ThreadId = Brand<string, 'ThreadId'>
|
||||||
|
type ReviewPanelDocEntries = Record<ThreadId, ReviewPanelEntry>
|
||||||
|
|
||||||
export type DocId = Brand<string, 'DocId'>
|
export type DocId = Brand<string, 'DocId'>
|
||||||
export type ReviewPanelEntries = Record<DocId, ReviewPanelDocEntries>
|
export type ReviewPanelEntries = Record<DocId, ReviewPanelDocEntries>
|
||||||
|
|
||||||
|
type UserId = Brand<string, 'UserId'>
|
||||||
|
|
||||||
|
interface ReviewPanelUser {
|
||||||
|
avatar_text: string
|
||||||
|
email: string
|
||||||
|
hue: number
|
||||||
|
id: UserId
|
||||||
|
isSelf: boolean
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommentId = Brand<string, 'CommentId'>
|
||||||
|
export interface ReviewPanelCommentThreadMessage {
|
||||||
|
content: string
|
||||||
|
id: CommentId
|
||||||
|
timestamp: number
|
||||||
|
user: ReviewPanelUser
|
||||||
|
user_id: UserId
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewPanelCommentThread {
|
||||||
|
messages: Array<ReviewPanelCommentThreadMessage>
|
||||||
|
// resolved: boolean
|
||||||
|
// resolved_at: number
|
||||||
|
// resolved_by_user_id: string
|
||||||
|
// resolved_by_user: ReviewPanelUser
|
||||||
|
submitting?: boolean // angular specific (to be made into a local state)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReviewPanelCommentThreads = Record<
|
||||||
|
ThreadId,
|
||||||
|
ReviewPanelCommentThread
|
||||||
|
>
|
||||||
|
|
Loading…
Reference in a new issue