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_getting_an_undefined_control_sequence_error": "",
|
||||
"are_you_still_at": "",
|
||||
"are_you_sure": "",
|
||||
"ascending": "",
|
||||
"ask_proj_owner_to_upgrade_for_full_history": "",
|
||||
"ask_proj_owner_to_upgrade_for_longer_compiles": "",
|
||||
|
@ -425,6 +426,7 @@
|
|||
"history_view_a11y_description": "",
|
||||
"history_view_all": "",
|
||||
"history_view_labels": "",
|
||||
"hit_enter_to_reply": "",
|
||||
"hotkey_add_a_comment": "",
|
||||
"hotkey_autocomplete_menu": "",
|
||||
"hotkey_beginning_of_document": "",
|
||||
|
@ -621,6 +623,7 @@
|
|||
"new_to_latex_look_at": "",
|
||||
"newsletter": "",
|
||||
"next_payment_of_x_collectected_on_y": "",
|
||||
"no_comments": "",
|
||||
"no_existing_password": "",
|
||||
"no_folder": "",
|
||||
"no_image_files_found": "",
|
||||
|
@ -797,11 +800,13 @@
|
|||
"replace_from_computer": "",
|
||||
"replace_from_project_files": "",
|
||||
"replace_from_url": "",
|
||||
"reply": "",
|
||||
"repository_name": "",
|
||||
"republish": "",
|
||||
"resend": "",
|
||||
"resend_confirmation_email": "",
|
||||
"resending_confirmation_email": "",
|
||||
"resolve": "",
|
||||
"resolved_comments": "",
|
||||
"restore_file": "",
|
||||
"restoring": "",
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { useMemo } from 'react'
|
||||
import ChangeEntry from './entries/change-entry'
|
||||
import AggregateChangeEntry from './entries/aggregate-change-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 { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context'
|
||||
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() {
|
||||
const { entries, openDocId, permissions } = useReviewPanelValueContext()
|
||||
const { commentThreads, entries, openDocId, permissions, loadingThreads } =
|
||||
useReviewPanelValueContext()
|
||||
const contentHeight = useCodeMirrorContentHeight()
|
||||
|
||||
console.log('Review panel got content height', contentHeight)
|
||||
|
@ -15,6 +19,12 @@ function CurrentFileContainer() {
|
|||
const currentDocEntries =
|
||||
openDocId && openDocId in entries ? entries[openDocId] : undefined
|
||||
|
||||
const objectEntries = useMemo(() => {
|
||||
return Object.entries(currentDocEntries || {}) as Array<
|
||||
[ThreadId, ReviewPanelEntry]
|
||||
>
|
||||
}, [currentDocEntries])
|
||||
|
||||
return (
|
||||
<div
|
||||
id="review-panel-current-file"
|
||||
|
@ -26,8 +36,8 @@ function CurrentFileContainer() {
|
|||
className="rp-entry-list-inner"
|
||||
style={{ height: `${contentHeight}px` }}
|
||||
>
|
||||
{currentDocEntries &&
|
||||
Object.entries(currentDocEntries).map(([id, entry]) => {
|
||||
{openDocId &&
|
||||
objectEntries.map(([id, entry]) => {
|
||||
if (!entry.visible) {
|
||||
return null
|
||||
}
|
||||
|
@ -40,8 +50,16 @@ function CurrentFileContainer() {
|
|||
return <AggregateChangeEntry key={id} />
|
||||
}
|
||||
|
||||
if (entry.type === 'comment') {
|
||||
return <CommentEntry key={id} />
|
||||
if (entry.type === 'comment' && !loadingThreads) {
|
||||
return (
|
||||
<CommentEntry
|
||||
key={id}
|
||||
docId={openDocId}
|
||||
entry={entry}
|
||||
entryId={id}
|
||||
threads={commentThreads}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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 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() {
|
||||
return <EntryContainer>Comment entry</EntryContainer>
|
||||
type CommentEntryProps = {
|
||||
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
|
||||
|
|
|
@ -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 useScopeEventEmitter from '../../../../../shared/hooks/use-scope-event-emitter'
|
||||
import { ReviewPanelState } from '../types/review-panel-state'
|
||||
import { sendMB } from '../../../../../infrastructure/event-tracking'
|
||||
import * as ReviewPanel from '../types/review-panel-state'
|
||||
import { SubView } from '../../../../../../../types/review-panel/review-panel'
|
||||
import { ReviewPanelCommentEntry } from '../../../../../../../types/review-panel/entry'
|
||||
|
||||
function useAngularReviewPanelState(): ReviewPanelState {
|
||||
const emitLayoutChange = useScopeEventEmitter('review-panel:layout', false)
|
||||
|
||||
const [subView, setSubView] = useScopeValue<ReviewPanel.Value<'subView'>>(
|
||||
'reviewPanel.subView'
|
||||
)
|
||||
const [collapsed, setCollapsed] = useScopeValue<
|
||||
ReviewPanel.Value<'collapsed'>
|
||||
>('reviewPanel.overview.docsCollapsedState')
|
||||
const [commentThreads] = useScopeValue<ReviewPanel.Value<'commentThreads'>>(
|
||||
'reviewPanel.commentThreads',
|
||||
true
|
||||
)
|
||||
const [entries] = useScopeValue<ReviewPanel.Value<'entries'>>(
|
||||
'reviewPanel.entries'
|
||||
)
|
||||
const [loadingThreads] =
|
||||
useScopeValue<ReviewPanel.Value<'loadingThreads'>>('loadingThreads')
|
||||
|
||||
const [permissions] =
|
||||
useScopeValue<ReviewPanel.Value<'permissions'>>('permissions')
|
||||
|
@ -50,6 +60,14 @@ function useAngularReviewPanelState(): ReviewPanelState {
|
|||
const [trackChangesForGuestsAvailable] = useScopeValue<
|
||||
ReviewPanel.Value<'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<
|
||||
ReviewPanel.Value<'formattedProjectMembers'>
|
||||
|
@ -66,12 +84,36 @@ function useAngularReviewPanelState(): ReviewPanelState {
|
|||
[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']>(
|
||||
() => ({
|
||||
collapsed,
|
||||
commentThreads,
|
||||
deleteComment,
|
||||
entries,
|
||||
entryHover,
|
||||
gotoEntry,
|
||||
handleLayoutChange,
|
||||
loadingThreads,
|
||||
permissions,
|
||||
resolveComment,
|
||||
saveEdit,
|
||||
shouldCollapse,
|
||||
submitReply,
|
||||
subView,
|
||||
wantTrackChanges,
|
||||
openDocId,
|
||||
|
@ -87,9 +129,18 @@ function useAngularReviewPanelState(): ReviewPanelState {
|
|||
}),
|
||||
[
|
||||
collapsed,
|
||||
commentThreads,
|
||||
deleteComment,
|
||||
entries,
|
||||
entryHover,
|
||||
gotoEntry,
|
||||
handleLayoutChange,
|
||||
loadingThreads,
|
||||
permissions,
|
||||
resolveComment,
|
||||
saveEdit,
|
||||
shouldCollapse,
|
||||
submitReply,
|
||||
subView,
|
||||
wantTrackChanges,
|
||||
openDocId,
|
||||
|
@ -108,10 +159,11 @@ function useAngularReviewPanelState(): ReviewPanelState {
|
|||
const updaterFns = useMemo<ReviewPanelState['updaterFns']>(
|
||||
() => ({
|
||||
handleSetSubview,
|
||||
setEntryHover,
|
||||
setCollapsed,
|
||||
setShouldCollapse,
|
||||
}),
|
||||
[handleSetSubview, setCollapsed, setShouldCollapse]
|
||||
[handleSetSubview, setCollapsed, setEntryHover, setShouldCollapse]
|
||||
)
|
||||
|
||||
return { values, updaterFns }
|
||||
|
|
|
@ -1,16 +1,33 @@
|
|||
import {
|
||||
CommentId,
|
||||
DocId,
|
||||
ReviewPanelCommentThreads,
|
||||
ReviewPanelEntries,
|
||||
ReviewPanelPermissions,
|
||||
SubView,
|
||||
ThreadId,
|
||||
} from '../../../../../../../types/review-panel/review-panel'
|
||||
import { ReviewPanelCommentEntry } from '../../../../../../../types/review-panel/entry'
|
||||
|
||||
export interface ReviewPanelState {
|
||||
values: {
|
||||
collapsed: Record<string, boolean>
|
||||
commentThreads: ReviewPanelCommentThreads
|
||||
deleteComment: (threadId: ThreadId, commentId: CommentId) => void
|
||||
entries: ReviewPanelEntries
|
||||
entryHover: boolean
|
||||
gotoEntry: (docId: DocId, entryOffset: number) => void
|
||||
handleLayoutChange: () => void
|
||||
loadingThreads: boolean
|
||||
permissions: ReviewPanelPermissions
|
||||
resolveComment: (docId: DocId, entryId: ThreadId) => void
|
||||
saveEdit: (
|
||||
threadId: ThreadId,
|
||||
commentId: CommentId,
|
||||
content: string
|
||||
) => void
|
||||
shouldCollapse: boolean
|
||||
submitReply: (entry: ReviewPanelCommentEntry, replyContent: string) => void
|
||||
subView: SubView
|
||||
wantTrackChanges: boolean
|
||||
openDocId: DocId | null
|
||||
|
@ -32,6 +49,7 @@ export interface ReviewPanelState {
|
|||
}
|
||||
updaterFns: {
|
||||
handleSetSubview: (subView: SubView) => void
|
||||
setEntryHover: React.Dispatch<React.SetStateAction<boolean>>
|
||||
setCollapsed: React.Dispatch<
|
||||
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;
|
||||
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 {
|
||||
y: number
|
||||
height: number
|
||||
|
@ -8,14 +10,15 @@ interface ReviewPanelBaseEntry {
|
|||
visible: boolean
|
||||
}
|
||||
|
||||
interface ReviewPanelCommentEntry extends ReviewPanelBaseEntry {
|
||||
export interface ReviewPanelCommentEntry extends ReviewPanelBaseEntry {
|
||||
type: 'comment'
|
||||
content: string
|
||||
entry_ids: string[]
|
||||
focused: boolean
|
||||
offset: number
|
||||
screenPos: ReviewPanelEntryScreenPos
|
||||
thread_id: string
|
||||
thread_id: ThreadId
|
||||
replyContent?: string // angular specific
|
||||
}
|
||||
|
||||
interface ReviewPanelInsertEntry extends ReviewPanelBaseEntry {
|
||||
|
|
|
@ -10,7 +10,42 @@ export interface ReviewPanelPermissions {
|
|||
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 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