mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #13720 from overleaf/td-review-panel-entry-pos
React review panel entry positioning GitOrigin-RevId: c22617b1d3243b7d54b093426358aeb291421b9e
This commit is contained in:
parent
b24d209453
commit
38c673d057
23 changed files with 717 additions and 167 deletions
|
@ -1,4 +1,4 @@
|
|||
import { useMemo } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import Container from './container'
|
||||
import Toolbar from './toolbar/toolbar'
|
||||
import Nav from './nav'
|
||||
|
@ -8,13 +8,21 @@ import AggregateChangeEntry from './entries/aggregate-change-entry'
|
|||
import CommentEntry from './entries/comment-entry'
|
||||
import AddCommentEntry from './entries/add-comment-entry'
|
||||
import BulkActionsEntry from './entries/bulk-actions-entry/bulk-actions-entry'
|
||||
import PositionedEntries from './positioned-entries'
|
||||
import {
|
||||
useReviewPanelUpdaterFnsContext,
|
||||
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'
|
||||
import {
|
||||
ReviewPanelDocEntries,
|
||||
ThreadId,
|
||||
} from '../../../../../../types/review-panel/review-panel'
|
||||
|
||||
const isEntryAThreadId = (
|
||||
entry: keyof ReviewPanelDocEntries
|
||||
): entry is ThreadId => entry !== 'add-comment' && entry !== 'bulk-actions'
|
||||
|
||||
function CurrentFileContainer() {
|
||||
const {
|
||||
|
@ -36,10 +44,18 @@ function CurrentFileContainer() {
|
|||
|
||||
const objectEntries = useMemo(() => {
|
||||
return Object.entries(currentDocEntries || {}) as Array<
|
||||
[ThreadId, ReviewPanelEntry]
|
||||
[keyof ReviewPanelDocEntries, ReviewPanelEntry]
|
||||
>
|
||||
}, [currentDocEntries])
|
||||
|
||||
const onMouseEnter = useCallback(() => {
|
||||
setEntryHover(true)
|
||||
}, [setEntryHover])
|
||||
|
||||
const onMouseLeave = useCallback(() => {
|
||||
setEntryHover(false)
|
||||
}, [setEntryHover])
|
||||
|
||||
return (
|
||||
<Container classNames={{ 'rp-collapsed-displaying-entry': entryHover }}>
|
||||
<div className="review-panel-tools">
|
||||
|
@ -53,9 +69,9 @@ function CurrentFileContainer() {
|
|||
tabIndex={0}
|
||||
aria-labelledby="review-panel-tab-current-file"
|
||||
>
|
||||
<div
|
||||
className="rp-entry-list-inner"
|
||||
style={{ height: `${contentHeight}px` }}
|
||||
<PositionedEntries
|
||||
entries={objectEntries}
|
||||
contentHeight={contentHeight}
|
||||
>
|
||||
{openDocId &&
|
||||
objectEntries.map(([id, entry]) => {
|
||||
|
@ -63,37 +79,46 @@ function CurrentFileContainer() {
|
|||
return null
|
||||
}
|
||||
|
||||
if (entry.type === 'insert' || entry.type === 'delete') {
|
||||
if (
|
||||
isEntryAThreadId(id) &&
|
||||
(entry.type === 'insert' || entry.type === 'delete')
|
||||
) {
|
||||
return (
|
||||
<ChangeEntry
|
||||
key={id}
|
||||
docId={openDocId}
|
||||
entry={entry}
|
||||
entryId={id}
|
||||
permissions={permissions}
|
||||
user={users[entry.metadata.user_id]}
|
||||
onMouseEnter={setEntryHover.bind(null, true)}
|
||||
onMouseLeave={setEntryHover.bind(null, false)}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onIndicatorClick={toggleReviewPanel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (entry.type === 'aggregate-change') {
|
||||
if (isEntryAThreadId(id) && entry.type === 'aggregate-change') {
|
||||
return (
|
||||
<AggregateChangeEntry
|
||||
key={id}
|
||||
docId={openDocId}
|
||||
entry={entry}
|
||||
entryId={id}
|
||||
permissions={permissions}
|
||||
user={users[entry.metadata.user_id]}
|
||||
onMouseEnter={setEntryHover.bind(null, true)}
|
||||
onMouseLeave={setEntryHover.bind(null, false)}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onIndicatorClick={toggleReviewPanel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (entry.type === 'comment' && !loadingThreads) {
|
||||
if (
|
||||
isEntryAThreadId(id) &&
|
||||
entry.type === 'comment' &&
|
||||
!loadingThreads
|
||||
) {
|
||||
return (
|
||||
<CommentEntry
|
||||
key={id}
|
||||
|
@ -102,15 +127,15 @@ function CurrentFileContainer() {
|
|||
entryId={id}
|
||||
permissions={permissions}
|
||||
threads={commentThreads}
|
||||
onMouseEnter={setEntryHover.bind(null, true)}
|
||||
onMouseLeave={setEntryHover.bind(null, false)}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onIndicatorClick={toggleReviewPanel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (entry.type === 'add-comment' && permissions.comment) {
|
||||
return <AddCommentEntry key={id} entry={entry} />
|
||||
return <AddCommentEntry key={id} entryId={entry.type} />
|
||||
}
|
||||
|
||||
if (entry.type === 'bulk-actions') {
|
||||
|
@ -118,6 +143,7 @@ function CurrentFileContainer() {
|
|||
<BulkActionsEntry
|
||||
key={id}
|
||||
entry={entry}
|
||||
entryId={entry.type}
|
||||
nChanges={nChanges}
|
||||
/>
|
||||
)
|
||||
|
@ -125,7 +151,7 @@ function CurrentFileContainer() {
|
|||
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
</PositionedEntries>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
|
|
|
@ -12,8 +12,6 @@ import { useCodeMirrorViewContext } from '../../codemirror-editor'
|
|||
import Modal, { useBulkActionsModal } from '../entries/bulk-actions-entry/modal'
|
||||
import getMeta from '../../../../../utils/meta'
|
||||
import useScopeValue from '../../../../../shared/hooks/use-scope-value'
|
||||
import { MergeAndOverride } from '../../../../../../../types/utils'
|
||||
import { ReviewPanelBulkActionsEntry } from '../../../../../../../types/review-panel/entry'
|
||||
|
||||
function EditorWidgets() {
|
||||
const { t } = useTranslation()
|
||||
|
@ -32,30 +30,13 @@ function EditorWidgets() {
|
|||
)
|
||||
const view = useCodeMirrorViewContext()
|
||||
|
||||
type UseReviewPanelValueContextReturnType = ReturnType<
|
||||
typeof useReviewPanelValueContext
|
||||
>
|
||||
const {
|
||||
entries,
|
||||
openDocId,
|
||||
nVisibleSelectedChanges: nChanges,
|
||||
wantTrackChanges,
|
||||
permissions,
|
||||
// Remapping entries as they may contain `add-comment` and `bulk-actions` props along with DocIds
|
||||
// Ideally the `add-comment` and `bulk-actions` objects should not be within the entries object
|
||||
// as the doc data, but this is what currently angular returns.
|
||||
} = useReviewPanelValueContext() as MergeAndOverride<
|
||||
UseReviewPanelValueContextReturnType,
|
||||
{
|
||||
entries: {
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
[Entry in UseReviewPanelValueContextReturnType['entries'] as keyof Entry]: Entry & {
|
||||
'add-comment': ReviewPanelBulkActionsEntry
|
||||
'bulk-actions': ReviewPanelBulkActionsEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
>
|
||||
} = useReviewPanelValueContext()
|
||||
|
||||
const hasTrackChangesFeature = getMeta('ol-hasTrackChangesFeature')
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import EntryContainer from './entry-container'
|
||||
import EntryCallout from './entry-callout'
|
||||
import EntryActions from './entry-actions'
|
||||
|
@ -14,34 +14,41 @@ import classnames from 'classnames'
|
|||
import { ReviewPanelAddCommentEntry } from '../../../../../../../types/review-panel/entry'
|
||||
|
||||
type AddCommentEntryProps = {
|
||||
entry: ReviewPanelAddCommentEntry
|
||||
entryId: ReviewPanelAddCommentEntry['type']
|
||||
}
|
||||
|
||||
function AddCommentEntry({ entry }: AddCommentEntryProps) {
|
||||
function AddCommentEntry({ entryId }: AddCommentEntryProps) {
|
||||
const { t } = useTranslation()
|
||||
const { isAddingComment, submitNewComment, handleLayoutChange } =
|
||||
useReviewPanelValueContext()
|
||||
const { setIsAddingComment } = useReviewPanelUpdaterFnsContext()
|
||||
const { isAddingComment, submitNewComment } = useReviewPanelValueContext()
|
||||
const { setIsAddingComment, handleLayoutChange } =
|
||||
useReviewPanelUpdaterFnsContext()
|
||||
|
||||
const [content, setContent] = useState('')
|
||||
|
||||
const handleStartNewComment = () => {
|
||||
setIsAddingComment(true)
|
||||
handleLayoutChange()
|
||||
window.setTimeout(handleLayoutChange, 0)
|
||||
}
|
||||
|
||||
const handleSubmitNewComment = () => {
|
||||
submitNewComment(content)
|
||||
setIsAddingComment(false)
|
||||
setContent('')
|
||||
window.setTimeout(handleLayoutChange, 0)
|
||||
}
|
||||
|
||||
const handleCancelNewComment = () => {
|
||||
setIsAddingComment(false)
|
||||
setContent('')
|
||||
handleLayoutChange()
|
||||
window.setTimeout(handleLayoutChange, 0)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setIsAddingComment(false)
|
||||
}
|
||||
}, [setIsAddingComment])
|
||||
|
||||
const handleCommentKeyPress = (
|
||||
e: React.KeyboardEvent<HTMLTextAreaElement>
|
||||
) => {
|
||||
|
@ -62,16 +69,12 @@ function AddCommentEntry({ entry }: AddCommentEntryProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<EntryContainer>
|
||||
<EntryContainer id={entryId}>
|
||||
<EntryCallout className="rp-entry-callout-add-comment" />
|
||||
<div
|
||||
className={classnames('rp-entry', 'rp-entry-add-comment', {
|
||||
'rp-entry-adding-comment': isAddingComment,
|
||||
})}
|
||||
style={{
|
||||
top: entry.screenPos.y + 'px',
|
||||
visibility: entry.visible ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{isAddingComment ? (
|
||||
<>
|
||||
|
|
|
@ -4,19 +4,24 @@ import EntryContainer from './entry-container'
|
|||
import EntryCallout from './entry-callout'
|
||||
import EntryActions from './entry-actions'
|
||||
import Icon from '../../../../../shared/components/icon'
|
||||
import { useReviewPanelValueContext } from '../../../context/review-panel/review-panel-context'
|
||||
import {
|
||||
useReviewPanelUpdaterFnsContext,
|
||||
useReviewPanelValueContext,
|
||||
} from '../../../context/review-panel/review-panel-context'
|
||||
import { formatTime } from '../../../../utils/format-date'
|
||||
import classnames from 'classnames'
|
||||
import { ReviewPanelAggregateChangeEntry } from '../../../../../../../types/review-panel/entry'
|
||||
import {
|
||||
ReviewPanelPermissions,
|
||||
ReviewPanelUser,
|
||||
ThreadId,
|
||||
} from '../../../../../../../types/review-panel/review-panel'
|
||||
import { DocId } from '../../../../../../../types/project-settings'
|
||||
|
||||
type AggregateChangeEntryProps = {
|
||||
docId: DocId
|
||||
entry: ReviewPanelAggregateChangeEntry
|
||||
entryId: ThreadId
|
||||
permissions: ReviewPanelPermissions
|
||||
user: ReviewPanelUser | undefined
|
||||
contentLimit?: number
|
||||
|
@ -28,6 +33,7 @@ type AggregateChangeEntryProps = {
|
|||
function AggregateChangeEntry({
|
||||
docId,
|
||||
entry,
|
||||
entryId,
|
||||
permissions,
|
||||
user,
|
||||
contentLimit = 17,
|
||||
|
@ -36,8 +42,9 @@ function AggregateChangeEntry({
|
|||
onIndicatorClick,
|
||||
}: AggregateChangeEntryProps) {
|
||||
const { t } = useTranslation()
|
||||
const { acceptChanges, rejectChanges, handleLayoutChange, gotoEntry } =
|
||||
const { acceptChanges, rejectChanges, gotoEntry } =
|
||||
useReviewPanelValueContext()
|
||||
const { handleLayoutChange } = useReviewPanelUpdaterFnsContext()
|
||||
const [isDeletionCollapsed, setIsDeletionCollapsed] = useState(true)
|
||||
const [isInsertionCollapsed, setIsInsertionCollapsed] = useState(true)
|
||||
|
||||
|
@ -82,26 +89,17 @@ function AggregateChangeEntry({
|
|||
|
||||
return (
|
||||
<EntryContainer
|
||||
id={entryId}
|
||||
onClick={handleEntryClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<EntryCallout
|
||||
className="rp-entry-callout-aggregate"
|
||||
style={{
|
||||
top: entry.screenPos
|
||||
? entry.screenPos.y + entry.screenPos.height - 1 + 'px'
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
<EntryCallout className="rp-entry-callout-aggregate" />
|
||||
{/* 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={onIndicatorClick}
|
||||
>
|
||||
<Icon type="pencil" />
|
||||
|
@ -110,10 +108,6 @@ function AggregateChangeEntry({
|
|||
className={classnames('rp-entry', 'rp-entry-aggregate', {
|
||||
'rp-entry-focused': entry.focused,
|
||||
})}
|
||||
style={{
|
||||
top: entry.screenPos ? entry.screenPos.y + 'px' : undefined,
|
||||
visibility: entry.visible ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
<div className="rp-entry-body">
|
||||
<div className="rp-entry-action-icon">
|
||||
|
|
|
@ -8,10 +8,11 @@ import { ReviewPanelBulkActionsEntry } from '../../../../../../../../types/revie
|
|||
|
||||
type BulkActionsEntryProps = {
|
||||
entry: ReviewPanelBulkActionsEntry
|
||||
entryId: ReviewPanelBulkActionsEntry['type']
|
||||
nChanges: number
|
||||
}
|
||||
|
||||
function BulkActionsEntry({ entry, nChanges }: BulkActionsEntryProps) {
|
||||
function BulkActionsEntry({ entry, entryId, nChanges }: BulkActionsEntryProps) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
show,
|
||||
|
@ -24,7 +25,7 @@ function BulkActionsEntry({ entry, nChanges }: BulkActionsEntryProps) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<EntryContainer>
|
||||
<EntryContainer id={entryId}>
|
||||
{nChanges > 1 && (
|
||||
<>
|
||||
<EntryCallout
|
||||
|
|
|
@ -4,7 +4,10 @@ import EntryContainer from './entry-container'
|
|||
import EntryCallout from './entry-callout'
|
||||
import EntryActions from './entry-actions'
|
||||
import Icon from '../../../../../shared/components/icon'
|
||||
import { useReviewPanelValueContext } from '../../../context/review-panel/review-panel-context'
|
||||
import {
|
||||
useReviewPanelUpdaterFnsContext,
|
||||
useReviewPanelValueContext,
|
||||
} from '../../../context/review-panel/review-panel-context'
|
||||
import { formatTime } from '../../../../utils/format-date'
|
||||
import classnames from 'classnames'
|
||||
import {
|
||||
|
@ -14,12 +17,14 @@ import {
|
|||
import {
|
||||
ReviewPanelPermissions,
|
||||
ReviewPanelUser,
|
||||
ThreadId,
|
||||
} from '../../../../../../../types/review-panel/review-panel'
|
||||
import { DocId } from '../../../../../../../types/project-settings'
|
||||
|
||||
type ChangeEntryProps = {
|
||||
docId: DocId
|
||||
entry: ReviewPanelInsertEntry | ReviewPanelDeleteEntry
|
||||
entryId: ThreadId
|
||||
permissions: ReviewPanelPermissions
|
||||
user: ReviewPanelUser | undefined
|
||||
contentLimit?: number
|
||||
|
@ -31,6 +36,7 @@ type ChangeEntryProps = {
|
|||
function ChangeEntry({
|
||||
docId,
|
||||
entry,
|
||||
entryId,
|
||||
permissions,
|
||||
user,
|
||||
contentLimit = 40,
|
||||
|
@ -39,8 +45,9 @@ function ChangeEntry({
|
|||
onIndicatorClick,
|
||||
}: ChangeEntryProps) {
|
||||
const { t } = useTranslation()
|
||||
const { acceptChanges, rejectChanges, handleLayoutChange, gotoEntry } =
|
||||
const { acceptChanges, rejectChanges, gotoEntry } =
|
||||
useReviewPanelValueContext()
|
||||
const { handleLayoutChange } = useReviewPanelUpdaterFnsContext()
|
||||
const [isCollapsed, setIsCollapsed] = useState(true)
|
||||
|
||||
const content = isCollapsed
|
||||
|
@ -75,23 +82,14 @@ function ChangeEntry({
|
|||
onClick={handleEntryClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
id={entryId}
|
||||
>
|
||||
<EntryCallout
|
||||
className={`rp-entry-callout-${entry.type}`}
|
||||
style={{
|
||||
top: entry.screenPos
|
||||
? entry.screenPos.y + entry.screenPos.height - 1 + 'px'
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
<EntryCallout className={`rp-entry-callout-${entry.type}`} />
|
||||
{/* 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={onIndicatorClick}
|
||||
>
|
||||
{entry.type === 'insert' ? (
|
||||
|
@ -104,10 +102,6 @@ function ChangeEntry({
|
|||
className={classnames('rp-entry', `rp-entry-${entry.type}`, {
|
||||
'rp-entry-focused': entry.focused,
|
||||
})}
|
||||
style={{
|
||||
top: entry.screenPos ? entry.screenPos.y + 'px' : undefined,
|
||||
visibility: entry.visible ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
<div className="rp-entry-body">
|
||||
<div className="rp-entry-action-icon">
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useRef } from 'react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import EntryContainer from './entry-container'
|
||||
import EntryCallout from './entry-callout'
|
||||
|
@ -8,7 +8,10 @@ import AutoExpandingTextArea, {
|
|||
resetHeight,
|
||||
} from '../../../../../shared/components/auto-expanding-text-area'
|
||||
import Icon from '../../../../../shared/components/icon'
|
||||
import { useReviewPanelValueContext } from '../../../context/review-panel/review-panel-context'
|
||||
import {
|
||||
useReviewPanelUpdaterFnsContext,
|
||||
useReviewPanelValueContext,
|
||||
} from '../../../context/review-panel/review-panel-context'
|
||||
import classnames from 'classnames'
|
||||
import { ReviewPanelCommentEntry } from '../../../../../../../types/review-panel/entry'
|
||||
import {
|
||||
|
@ -40,8 +43,9 @@ function CommentEntry({
|
|||
onIndicatorClick,
|
||||
}: CommentEntryProps) {
|
||||
const { t } = useTranslation()
|
||||
const { gotoEntry, resolveComment, submitReply, handleLayoutChange } =
|
||||
const { gotoEntry, resolveComment, submitReply } =
|
||||
useReviewPanelValueContext()
|
||||
const { handleLayoutChange } = useReviewPanelUpdaterFnsContext()
|
||||
const [replyContent, setReplyContent] = useState('')
|
||||
const [animating, setAnimating] = useState(false)
|
||||
const [resolved, setResolved] = useState(false)
|
||||
|
@ -103,12 +107,24 @@ function CommentEntry({
|
|||
}
|
||||
}
|
||||
|
||||
const submitting = Boolean(thread?.submitting)
|
||||
|
||||
// Update the layout when loading finishes
|
||||
useEffect(() => {
|
||||
if (!submitting) {
|
||||
// Ensure everything is rendered in the DOM before updating the layout.
|
||||
// Having to use a timeout seems less than ideal.
|
||||
window.setTimeout(handleLayoutChange, 0)
|
||||
}
|
||||
}, [submitting, handleLayoutChange])
|
||||
|
||||
if (!thread || resolved) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<EntryContainer
|
||||
id={entryId}
|
||||
onClick={handleEntryClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
|
@ -119,22 +135,12 @@ function CommentEntry({
|
|||
'rp-comment-wrapper-resolving': animating,
|
||||
})}
|
||||
>
|
||||
<EntryCallout
|
||||
className="rp-entry-callout-comment"
|
||||
style={{
|
||||
top: entry.screenPos
|
||||
? entry.screenPos.y + entry.screenPos.height - 1 + 'px'
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
<EntryCallout className="rp-entry-callout-comment" />
|
||||
{/* 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={onIndicatorClick}
|
||||
>
|
||||
<Icon type="comment" />
|
||||
|
@ -144,13 +150,9 @@ function CommentEntry({
|
|||
'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) && (
|
||||
{!submitting && (!thread || thread.messages.length === 0) && (
|
||||
<div className="rp-loading">{t('no_comments')}</div>
|
||||
)}
|
||||
<div className="rp-comment-loaded">
|
||||
|
@ -163,7 +165,7 @@ function CommentEntry({
|
|||
/>
|
||||
))}
|
||||
</div>
|
||||
{thread.submitting && (
|
||||
{submitting && (
|
||||
<div className="rp-loading">
|
||||
<Icon type="spinner" spin />
|
||||
</div>
|
||||
|
|
|
@ -2,7 +2,10 @@ 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 {
|
||||
useReviewPanelUpdaterFnsContext,
|
||||
useReviewPanelValueContext,
|
||||
} from '../../../context/review-panel/review-panel-context'
|
||||
import { ReviewPanelCommentThread } from '../../../../../../../types/review-panel/comment-thread'
|
||||
import {
|
||||
ReviewPanelCommentThreadMessage,
|
||||
|
@ -17,8 +20,8 @@ type CommentProps = {
|
|||
|
||||
function Comment({ thread, threadId, comment }: CommentProps) {
|
||||
const { t } = useTranslation()
|
||||
const { deleteComment, handleLayoutChange, saveEdit } =
|
||||
useReviewPanelValueContext()
|
||||
const { deleteComment, saveEdit } = useReviewPanelValueContext()
|
||||
const { handleLayoutChange } = useReviewPanelUpdaterFnsContext()
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
|
||||
|
|
|
@ -1,7 +1,17 @@
|
|||
import classnames from 'classnames'
|
||||
|
||||
function EntryContainer({ className, ...rest }: React.ComponentProps<'div'>) {
|
||||
return <div className={classnames('rp-entry-wrapper', className)} {...rest} />
|
||||
function EntryContainer({
|
||||
id,
|
||||
className,
|
||||
...rest
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={classnames('rp-entry-wrapper', className)}
|
||||
data-entry-id={id}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default EntryContainer
|
||||
|
|
|
@ -6,14 +6,28 @@ import {
|
|||
useReviewPanelUpdaterFnsContext,
|
||||
} from '../../context/review-panel/review-panel-context'
|
||||
import { isCurrentFileView, isOverviewView } from '../../utils/sub-view'
|
||||
import { useCallback } from 'react'
|
||||
import { useResizeObserver } from '../../../../shared/hooks/use-resize-observer'
|
||||
|
||||
function Nav() {
|
||||
const { t } = useTranslation()
|
||||
const { subView } = useReviewPanelValueContext()
|
||||
const { handleSetSubview } = useReviewPanelUpdaterFnsContext()
|
||||
const { handleSetSubview, setNavHeight } = useReviewPanelUpdaterFnsContext()
|
||||
const handleResize = useCallback(
|
||||
el => {
|
||||
// Use requestAnimationFrame to prevent errors like "ResizeObserver loop
|
||||
// completed with undelivered notifications" that occur if onResize does
|
||||
// something complicated. The cost of this is that onResize lags one frame
|
||||
// behind, but it's unlikely to matter.
|
||||
const height = el.offsetHeight
|
||||
window.requestAnimationFrame(() => setNavHeight(height))
|
||||
},
|
||||
[setNavHeight]
|
||||
)
|
||||
const resizeRef = useResizeObserver(handleResize)
|
||||
|
||||
return (
|
||||
<div className="rp-nav" role="tablist">
|
||||
<div ref={resizeRef} className="rp-nav" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
id="review-panel-tab-current-file"
|
||||
|
|
|
@ -82,6 +82,7 @@ function OverviewFile({ docId, docPath }: OverviewFileProps) {
|
|||
key={id}
|
||||
docId={docId}
|
||||
entry={entry}
|
||||
entryId={id}
|
||||
permissions={permissions}
|
||||
user={users[entry.metadata.user_id]}
|
||||
/>
|
||||
|
@ -94,6 +95,7 @@ function OverviewFile({ docId, docPath }: OverviewFileProps) {
|
|||
key={id}
|
||||
docId={docId}
|
||||
entry={entry}
|
||||
entryId={id}
|
||||
permissions={permissions}
|
||||
user={users[entry.metadata.user_id]}
|
||||
/>
|
||||
|
|
|
@ -0,0 +1,409 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { useLayoutContext } from '../../../../shared/context/layout-context'
|
||||
import {
|
||||
ReviewPanelEntry,
|
||||
ReviewPanelEntryScreenPos,
|
||||
} from '../../../../../../types/review-panel/entry'
|
||||
import useScopeValue from '../../../../shared/hooks/use-scope-value'
|
||||
import { debugConsole } from '../../../../utils/debugging'
|
||||
import { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context'
|
||||
import useEventListener from '../../../../shared/hooks/use-event-listener'
|
||||
import { ReviewPanelDocEntries } from '../../../../../../types/review-panel/review-panel'
|
||||
import { dispatchReviewPanelLayout } from '../../extensions/changes/change-manager'
|
||||
|
||||
type EntryView = {
|
||||
wrapper: HTMLElement
|
||||
indicator: HTMLElement | null
|
||||
box: HTMLElement
|
||||
callout: HTMLElement
|
||||
layout: HTMLElement
|
||||
height: number
|
||||
previousEntryTop: number | null
|
||||
previousCalloutTop: number | null
|
||||
entry: ReviewPanelEntry
|
||||
visible: boolean
|
||||
positions?: {
|
||||
entryTop: number
|
||||
callout: { top: number; height: number; inverted: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
function css(el: HTMLElement, props: React.CSSProperties) {
|
||||
Object.assign(el.style, props)
|
||||
}
|
||||
|
||||
function applyEntryVisibility(entryView: EntryView) {
|
||||
const visible = !!entryView.entry.screenPos
|
||||
entryView.wrapper.classList.toggle('rp-entry-hidden', !visible)
|
||||
return visible
|
||||
}
|
||||
|
||||
function calculateCalloutPosition(
|
||||
screenPos: ReviewPanelEntryScreenPos,
|
||||
entryTop: number,
|
||||
lineHeight: number
|
||||
) {
|
||||
const height = screenPos.height ?? lineHeight
|
||||
const originalTop = screenPos.y
|
||||
const inverted = entryTop <= originalTop
|
||||
return {
|
||||
top: inverted ? entryTop + height : originalTop + height - 1,
|
||||
height: Math.abs(entryTop - originalTop),
|
||||
inverted,
|
||||
}
|
||||
}
|
||||
|
||||
const calculateEntryViewPositions = (
|
||||
entryViews: EntryView[],
|
||||
lineHeight: number,
|
||||
calculateTop: (originalTop: number, height: number) => number
|
||||
) => {
|
||||
for (const entryView of entryViews) {
|
||||
const entryVisible = applyEntryVisibility(entryView)
|
||||
if (entryVisible) {
|
||||
const entryTop = calculateTop(
|
||||
entryView.entry.screenPos.y,
|
||||
entryView.height
|
||||
)
|
||||
const callout = calculateCalloutPosition(
|
||||
entryView.entry.screenPos,
|
||||
entryTop,
|
||||
lineHeight
|
||||
)
|
||||
entryView.positions = {
|
||||
entryTop,
|
||||
callout,
|
||||
}
|
||||
}
|
||||
debugConsole.log('ENTRY', { entry: entryView.entry, top })
|
||||
}
|
||||
}
|
||||
|
||||
type PositionedEntriesProps = {
|
||||
entries: Array<[keyof ReviewPanelDocEntries, ReviewPanelEntry]>
|
||||
contentHeight: number
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function PositionedEntries({
|
||||
entries,
|
||||
contentHeight,
|
||||
children,
|
||||
}: PositionedEntriesProps) {
|
||||
const { navHeight, toolbarHeight } = useReviewPanelValueContext()
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const { reviewPanelOpen } = useLayoutContext()
|
||||
const [lineHeight] = useScopeValue<number>(
|
||||
'reviewPanel.rendererData.lineHeight'
|
||||
)
|
||||
const animationTimerRef = useRef<number | null>(null)
|
||||
const previousFocusedEntryIndexRef = useRef(0)
|
||||
|
||||
const layout = () => {
|
||||
const container = containerRef.current
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
const padding = reviewPanelOpen ? 8 : 4
|
||||
const toolbarPaddedHeight = reviewPanelOpen ? toolbarHeight + 6 : 0
|
||||
const navPaddedHeight = reviewPanelOpen ? navHeight + 4 : 0
|
||||
|
||||
// Create a list of entry views, typing together DOM elements and model.
|
||||
// No measuring or style change is done at this point.
|
||||
const entryViews: EntryView[] = []
|
||||
|
||||
// TODO: Look into tying the entry to the DOM element without going via a DOM data attribute
|
||||
for (const wrapper of container.querySelectorAll<HTMLElement>(
|
||||
'.rp-entry-wrapper'
|
||||
)) {
|
||||
const entryId = wrapper.dataset.entryId
|
||||
if (!entryId) {
|
||||
throw new Error('Could not find an entry ID')
|
||||
}
|
||||
|
||||
const entry = entries.find(value => value[0] === entryId)?.[1]
|
||||
if (!entry) {
|
||||
throw new Error(`Could not find an entry for ID ${entryId}`)
|
||||
}
|
||||
|
||||
const indicator = wrapper.querySelector<HTMLElement>(
|
||||
'.rp-entry-indicator'
|
||||
)
|
||||
const box = wrapper.querySelector<HTMLElement>('.rp-entry')
|
||||
const callout = wrapper.querySelector<HTMLElement>('.rp-entry-callout')
|
||||
const layoutElement = reviewPanelOpen ? box : indicator
|
||||
|
||||
if (box && callout && layoutElement) {
|
||||
const previousEntryTopData = box.dataset.previousEntryTop
|
||||
const previousCalloutTopData = callout.dataset.previousEntryTop
|
||||
entryViews.push({
|
||||
wrapper,
|
||||
indicator,
|
||||
box,
|
||||
callout,
|
||||
layout: layoutElement,
|
||||
visible: !!entry.screenPos,
|
||||
height: 0,
|
||||
previousEntryTop:
|
||||
previousEntryTopData !== undefined
|
||||
? parseInt(previousEntryTopData)
|
||||
: null,
|
||||
previousCalloutTop:
|
||||
previousCalloutTopData !== undefined
|
||||
? parseInt(previousCalloutTopData)
|
||||
: null,
|
||||
entry,
|
||||
})
|
||||
} else {
|
||||
debugConsole.log(
|
||||
'Entry wrapper is missing indicator, box or callout, so ignoring',
|
||||
wrapper
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (entryViews.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
entryViews.sort((a, b) => a.entry.offset - b.entry.offset)
|
||||
|
||||
// Do the DOM interaction in three phases:
|
||||
//
|
||||
// - Apply the `display` property to all elements whose visibility has
|
||||
// changed. This needs to happen first in order to measure heights.
|
||||
// - Measure the height of each entry
|
||||
// - Move each entry without animation to their original position
|
||||
// relative to the editor content
|
||||
// - In the next animation frame, re-enable animation and position each
|
||||
// entry
|
||||
//
|
||||
// The idea is to batch DOM reads and writes to avoid layout thrashing. In
|
||||
// this case, the best we can do is a write phase, a read phase then a
|
||||
// final write phase.
|
||||
// See https://web.dev/avoid-large-complex-layouts-and-layout-thrashing/
|
||||
|
||||
// First, update visibility for each entry that needs it
|
||||
for (const entryView of entryViews) {
|
||||
entryView.wrapper.classList.toggle('rp-entry-hidden', !entryView.visible)
|
||||
}
|
||||
|
||||
// Next, measure the height of each entry
|
||||
for (const entryView of entryViews) {
|
||||
if (entryView.visible) {
|
||||
entryView.height = entryView.layout.offsetHeight
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate positions for all visible entries
|
||||
let focusedEntryIndex = entryViews.findIndex(view => view.entry.focused)
|
||||
if (focusedEntryIndex === -1) {
|
||||
focusedEntryIndex = Math.min(
|
||||
previousFocusedEntryIndexRef.current,
|
||||
entryViews.length - 1
|
||||
)
|
||||
}
|
||||
previousFocusedEntryIndexRef.current = focusedEntryIndex
|
||||
|
||||
const focusedEntryView = entryViews[focusedEntryIndex]
|
||||
if (!focusedEntryView.entry.screenPos) {
|
||||
return
|
||||
}
|
||||
|
||||
// If the focused entry has no screenPos, we can't position other
|
||||
// entryViews relative to it, so we position all other entryViews as
|
||||
// though the focused entry is at the top and the rest follow it
|
||||
const entryViewsAfter = focusedEntryView.visible
|
||||
? entryViews.slice(focusedEntryIndex + 1)
|
||||
: [...entryViews]
|
||||
const entryViewsBefore = focusedEntryView.visible
|
||||
? entryViews.slice(0, focusedEntryIndex).reverse() // Work through backwards, starting with the one just above
|
||||
: []
|
||||
|
||||
debugConsole.log('focusedEntryIndex', focusedEntryIndex)
|
||||
|
||||
let lastEntryBottom = 0
|
||||
let firstEntryTop = 0
|
||||
|
||||
// Put the focused entry as close to where it wants to be as possible
|
||||
if (focusedEntryView.visible) {
|
||||
const focusedEntryScreenPos = focusedEntryView.entry.screenPos
|
||||
const entryTop = Math.max(focusedEntryScreenPos.y, toolbarPaddedHeight)
|
||||
const callout = calculateCalloutPosition(
|
||||
focusedEntryScreenPos,
|
||||
entryTop,
|
||||
lineHeight
|
||||
)
|
||||
focusedEntryView.positions = {
|
||||
entryTop,
|
||||
callout,
|
||||
}
|
||||
lastEntryBottom = entryTop + focusedEntryView.height
|
||||
firstEntryTop = entryTop
|
||||
}
|
||||
|
||||
// Calculate positions for entries that are below the focused entry
|
||||
calculateEntryViewPositions(
|
||||
entryViewsAfter,
|
||||
lineHeight,
|
||||
(originalTop: number, height: number) => {
|
||||
const top = Math.max(originalTop, lastEntryBottom + padding)
|
||||
lastEntryBottom = top + height
|
||||
return top
|
||||
}
|
||||
)
|
||||
|
||||
// Calculate positions for entries that are above the focused entry
|
||||
calculateEntryViewPositions(
|
||||
entryViewsBefore,
|
||||
lineHeight,
|
||||
(originalTop: number, height: number) => {
|
||||
const originalBottom = originalTop + height
|
||||
const bottom = Math.min(originalBottom, firstEntryTop - padding)
|
||||
const top = bottom - height
|
||||
firstEntryTop = top
|
||||
return top
|
||||
}
|
||||
)
|
||||
|
||||
// Calculate the new top overflow
|
||||
const overflowTop = Math.max(0, toolbarPaddedHeight - firstEntryTop)
|
||||
|
||||
// Position everything where it was before, taking into account the new top
|
||||
// overflow
|
||||
const moveEntriesToInitialPosition = () => {
|
||||
// Prevent CSS animation of position for this phase
|
||||
container.classList.add('no-animate')
|
||||
for (const entryView of entryViews) {
|
||||
const { callout: calloutEl, positions } = entryView
|
||||
if (positions) {
|
||||
const { entryTop, callout } = positions
|
||||
|
||||
// Position the main wrapper in its original position, if it had
|
||||
// one, or its new position otherwise
|
||||
const entryTopInitial =
|
||||
entryView.previousEntryTop === null
|
||||
? entryTop
|
||||
: entryView.previousEntryTop
|
||||
|
||||
css(entryView.box, {
|
||||
top: entryTopInitial + overflowTop + 'px',
|
||||
// The entry element is invisible by default, to avoid flickering
|
||||
// when positioning for the first time. Here we make sure it becomes
|
||||
// visible after having a "top" value.
|
||||
visibility: 'visible',
|
||||
})
|
||||
|
||||
if (entryView.indicator) {
|
||||
entryView.indicator.style.top = entryTopInitial + overflowTop + 'px'
|
||||
}
|
||||
|
||||
entryView.box.dataset.previousEntryTop = entryTopInitial + ''
|
||||
|
||||
// Position the callout element in its original position, if it had
|
||||
// one, or its new position otherwise
|
||||
calloutEl.classList.toggle(
|
||||
'rp-entry-callout-inverted',
|
||||
callout.inverted
|
||||
)
|
||||
const calloutTopInitial =
|
||||
entryView.previousCalloutTop === null
|
||||
? callout.top
|
||||
: entryView.previousCalloutTop
|
||||
|
||||
css(calloutEl, {
|
||||
top: calloutTopInitial + overflowTop + 'px',
|
||||
height: callout.height + 'px',
|
||||
})
|
||||
|
||||
entryView.box.dataset.previousCalloutTop = calloutTopInitial + ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const moveToFinalPositions = () => {
|
||||
// Re-enable CSS animation of position for this phase
|
||||
container.classList.remove('no-animate')
|
||||
|
||||
for (const entryView of entryViews) {
|
||||
const { callout: calloutEl, positions } = entryView
|
||||
if (positions) {
|
||||
const { entryTop, callout } = positions
|
||||
|
||||
// Position the main wrapper, if it's moved
|
||||
if (entryView.previousEntryTop !== entryTop) {
|
||||
entryView.box.style.top = entryTop + overflowTop + 'px'
|
||||
}
|
||||
entryView.box.dataset.previousEntryTop = entryTop + ''
|
||||
|
||||
if (entryView.indicator) {
|
||||
entryView.indicator.style.top = entryTop + overflowTop + 'px'
|
||||
}
|
||||
|
||||
// Position the callout element
|
||||
if (entryView.previousCalloutTop !== callout.top) {
|
||||
calloutEl.style.top = callout.top + overflowTop + 'px'
|
||||
entryView.callout.dataset.previousCalloutTop = callout.top + ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
animationTimerRef.current = null
|
||||
}
|
||||
|
||||
moveEntriesToInitialPosition()
|
||||
|
||||
// Inform the editor of the new top overflow
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('review-panel:event', {
|
||||
detail: {
|
||||
type: 'sizes',
|
||||
payload: {
|
||||
overflowTop,
|
||||
height: lastEntryBottom + navPaddedHeight,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
// Schedule the final, animated move
|
||||
animationTimerRef.current = window.setTimeout(moveToFinalPositions, 60)
|
||||
}
|
||||
|
||||
useEventListener('review-panel:layout', () => {
|
||||
if (animationTimerRef.current) {
|
||||
window.clearTimeout(animationTimerRef.current)
|
||||
animationTimerRef.current = null
|
||||
}
|
||||
layout()
|
||||
})
|
||||
|
||||
// Cancel scheduled move on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (animationTimerRef.current) {
|
||||
window.clearTimeout(animationTimerRef.current)
|
||||
animationTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Layout on first render. This is necessary to ensure layout happens when
|
||||
// switching from overview to current file view
|
||||
useEffect(() => {
|
||||
dispatchReviewPanelLayout()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="rp-entry-list-react"
|
||||
style={{ height: `${contentHeight}px` }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PositionedEntries
|
|
@ -11,12 +11,13 @@ import {
|
|||
} from '../../../../../../../types/review-panel/review-panel'
|
||||
import { ReviewPanelResolvedCommentThread } from '../../../../../../../types/review-panel/comment-thread'
|
||||
import { DocId } from '../../../../../../../types/project-settings'
|
||||
import { ReviewPanelEntry } from '../../../../../../../types/review-panel/entry'
|
||||
|
||||
export interface FilteredResolvedComments
|
||||
extends ReviewPanelResolvedCommentThread {
|
||||
content: string
|
||||
threadId: ThreadId
|
||||
entryId: string
|
||||
entryId: ThreadId
|
||||
docId: DocId
|
||||
docName: string | null
|
||||
}
|
||||
|
@ -56,7 +57,9 @@ function ResolvedCommentsDropdown() {
|
|||
for (const [docId, docEntries] of Object.entries(resolvedComments) as Array<
|
||||
[DocId, ReviewPanelDocEntries]
|
||||
>) {
|
||||
for (const [entryId, entry] of Object.entries(docEntries)) {
|
||||
for (const [entryId, entry] of Object.entries(docEntries) as Array<
|
||||
[ThreadId, ReviewPanelEntry]
|
||||
>) {
|
||||
if (entry.type === 'comment') {
|
||||
const threadId = entry.thread_id
|
||||
const thread =
|
||||
|
|
|
@ -1,9 +1,26 @@
|
|||
import ResolvedCommentsDropdown from './resolved-comments-dropdown'
|
||||
import ToggleMenu from './toggle-menu'
|
||||
import { useReviewPanelUpdaterFnsContext } from '../../../context/review-panel/review-panel-context'
|
||||
import { useCallback } from 'react'
|
||||
import { useResizeObserver } from '../../../../../shared/hooks/use-resize-observer'
|
||||
|
||||
function Toolbar() {
|
||||
const { setToolbarHeight } = useReviewPanelUpdaterFnsContext()
|
||||
const handleResize = useCallback(
|
||||
el => {
|
||||
// Use requestAnimationFrame to prevent errors like "ResizeObserver loop
|
||||
// completed with undelivered notifications" that occur if onResize does
|
||||
// something complicated. The cost of this is that onResize lags one frame
|
||||
// behind, but it's unlikely to matter.
|
||||
const height = el.offsetHeight
|
||||
window.requestAnimationFrame(() => setToolbarHeight(height))
|
||||
},
|
||||
[setToolbarHeight]
|
||||
)
|
||||
const resizeRef = useResizeObserver(handleResize)
|
||||
|
||||
return (
|
||||
<div className="review-panel-toolbar">
|
||||
<div ref={resizeRef} className="review-panel-toolbar">
|
||||
<ResolvedCommentsDropdown />
|
||||
<ToggleMenu />
|
||||
</div>
|
||||
|
|
|
@ -1,15 +1,13 @@
|
|||
import { useState, useMemo, useCallback } from 'react'
|
||||
import useScopeValue from '../../../../../shared/hooks/use-scope-value'
|
||||
import useScopeEventEmitter from '../../../../../shared/hooks/use-scope-event-emitter'
|
||||
import { sendMB } from '../../../../../infrastructure/event-tracking'
|
||||
import { ReviewPanelState } from '../types/review-panel-state'
|
||||
import * as ReviewPanel from '../types/review-panel-state'
|
||||
import { SubView } from '../../../../../../../types/review-panel/review-panel'
|
||||
import { ReviewPanelCommentEntry } from '../../../../../../../types/review-panel/entry'
|
||||
import { dispatchReviewPanelLayout as handleLayoutChange } from '../../../extensions/changes/change-manager'
|
||||
|
||||
function useAngularReviewPanelState(): ReviewPanelState {
|
||||
const emitLayoutChange = useScopeEventEmitter('review-panel:layout', false)
|
||||
|
||||
const [subView, setSubView] = useScopeValue<ReviewPanel.Value<'subView'>>(
|
||||
'reviewPanel.subView'
|
||||
)
|
||||
|
@ -113,12 +111,6 @@ function useAngularReviewPanelState(): ReviewPanelState {
|
|||
[setSubView]
|
||||
)
|
||||
|
||||
const handleLayoutChange = useCallback(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
emitLayoutChange()
|
||||
})
|
||||
}, [emitLayoutChange])
|
||||
|
||||
const submitReply = useCallback(
|
||||
(entry: ReviewPanelCommentEntry, replyContent: string) => {
|
||||
submitReplyAngular({ ...entry, replyContent })
|
||||
|
@ -128,6 +120,8 @@ function useAngularReviewPanelState(): ReviewPanelState {
|
|||
|
||||
const [entryHover, setEntryHover] = useState(false)
|
||||
const [isAddingComment, setIsAddingComment] = useState(false)
|
||||
const [navHeight, setNavHeight] = useState(0)
|
||||
const [toolbarHeight, setToolbarHeight] = useState(0)
|
||||
|
||||
const values = useMemo<ReviewPanelState['values']>(
|
||||
() => ({
|
||||
|
@ -139,7 +133,6 @@ function useAngularReviewPanelState(): ReviewPanelState {
|
|||
entryHover,
|
||||
isAddingComment,
|
||||
gotoEntry,
|
||||
handleLayoutChange,
|
||||
loadingThreads,
|
||||
nVisibleSelectedChanges,
|
||||
permissions,
|
||||
|
@ -148,6 +141,8 @@ function useAngularReviewPanelState(): ReviewPanelState {
|
|||
resolvedComments,
|
||||
saveEdit,
|
||||
shouldCollapse,
|
||||
navHeight,
|
||||
toolbarHeight,
|
||||
submitReply,
|
||||
subView,
|
||||
wantTrackChanges,
|
||||
|
@ -180,7 +175,6 @@ function useAngularReviewPanelState(): ReviewPanelState {
|
|||
entryHover,
|
||||
isAddingComment,
|
||||
gotoEntry,
|
||||
handleLayoutChange,
|
||||
loadingThreads,
|
||||
nVisibleSelectedChanges,
|
||||
permissions,
|
||||
|
@ -189,6 +183,8 @@ function useAngularReviewPanelState(): ReviewPanelState {
|
|||
resolvedComments,
|
||||
saveEdit,
|
||||
shouldCollapse,
|
||||
navHeight,
|
||||
toolbarHeight,
|
||||
submitReply,
|
||||
subView,
|
||||
wantTrackChanges,
|
||||
|
@ -217,10 +213,13 @@ function useAngularReviewPanelState(): ReviewPanelState {
|
|||
const updaterFns = useMemo<ReviewPanelState['updaterFns']>(
|
||||
() => ({
|
||||
handleSetSubview,
|
||||
handleLayoutChange,
|
||||
setEntryHover,
|
||||
setCollapsed,
|
||||
setShouldCollapse,
|
||||
setIsAddingComment,
|
||||
setNavHeight,
|
||||
setToolbarHeight,
|
||||
}),
|
||||
[
|
||||
handleSetSubview,
|
||||
|
@ -228,6 +227,8 @@ function useAngularReviewPanelState(): ReviewPanelState {
|
|||
setEntryHover,
|
||||
setShouldCollapse,
|
||||
setIsAddingComment,
|
||||
setNavHeight,
|
||||
setToolbarHeight,
|
||||
]
|
||||
)
|
||||
|
||||
|
|
|
@ -24,7 +24,6 @@ export interface ReviewPanelState {
|
|||
entryHover: boolean
|
||||
isAddingComment: boolean
|
||||
gotoEntry: (docId: DocId, entryOffset: number) => void
|
||||
handleLayoutChange: () => void
|
||||
loadingThreads: boolean
|
||||
nVisibleSelectedChanges: number
|
||||
permissions: ReviewPanelPermissions
|
||||
|
@ -37,6 +36,8 @@ export interface ReviewPanelState {
|
|||
content: string
|
||||
) => void
|
||||
shouldCollapse: boolean
|
||||
navHeight: number
|
||||
toolbarHeight: number
|
||||
submitReply: (entry: ReviewPanelCommentEntry, replyContent: string) => void
|
||||
subView: SubView
|
||||
wantTrackChanges: boolean
|
||||
|
@ -68,6 +69,7 @@ export interface ReviewPanelState {
|
|||
}
|
||||
updaterFns: {
|
||||
handleSetSubview: (subView: SubView) => void
|
||||
handleLayoutChange: () => void
|
||||
setEntryHover: React.Dispatch<React.SetStateAction<Value<'entryHover'>>>
|
||||
setIsAddingComment: React.Dispatch<
|
||||
React.SetStateAction<Value<'isAddingComment'>>
|
||||
|
@ -76,6 +78,10 @@ export interface ReviewPanelState {
|
|||
setShouldCollapse: React.Dispatch<
|
||||
React.SetStateAction<Value<'shouldCollapse'>>
|
||||
>
|
||||
setNavHeight: React.Dispatch<React.SetStateAction<Value<'navHeight'>>>
|
||||
setToolbarHeight: React.Dispatch<
|
||||
React.SetStateAction<Value<'toolbarHeight'>>
|
||||
>
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-use-before-define */
|
||||
|
|
|
@ -25,6 +25,15 @@ export const dispatchEditorEvent = (type: string, payload?: unknown) => {
|
|||
}, 0)
|
||||
}
|
||||
|
||||
export const dispatchReviewPanelLayout = () => {
|
||||
window.dispatchEvent(new CustomEvent('review-panel:layout'))
|
||||
}
|
||||
|
||||
const scheduleDispatchReviewPanelLayout = debounce(
|
||||
dispatchReviewPanelLayout,
|
||||
50
|
||||
)
|
||||
|
||||
export type ChangeManager = {
|
||||
initialize: () => void
|
||||
handleUpdate: (update: ViewUpdate) => void
|
||||
|
@ -66,6 +75,10 @@ export const createChangeManager = (
|
|||
const y = Math.round(coords.top - contentRect.top - editorPaddingTop)
|
||||
const height = Math.round(coords.bottom - coords.top)
|
||||
|
||||
if (!entry.screenPos) {
|
||||
visibilityChanged = true
|
||||
}
|
||||
|
||||
entry.screenPos = { y, height, editorPaddingTop }
|
||||
}
|
||||
|
||||
|
@ -284,6 +297,12 @@ export const createChangeManager = (
|
|||
if (changed) {
|
||||
dispatchEditorEvent('track-changes:visibility_changed')
|
||||
}
|
||||
dispatchReviewPanelLayout()
|
||||
// Ensure the layout is updated again once the review panel entries
|
||||
// have updated in the React review panel. The use of a timeout is bad
|
||||
// but the timings are a bit of a mess and will be improved when the
|
||||
// review panel state is migrated away from Angular
|
||||
scheduleDispatchReviewPanelLayout()
|
||||
break
|
||||
}
|
||||
|
||||
|
@ -348,7 +367,6 @@ export const createChangeManager = (
|
|||
})
|
||||
)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
|
@ -128,17 +128,17 @@ export default App.controller(
|
|||
})
|
||||
|
||||
$scope.$on('layout:pdf:linked', (event, state) =>
|
||||
$scope.$broadcast('review-panel:layout')
|
||||
ide.$scope.$broadcast('review-panel:layout')
|
||||
)
|
||||
|
||||
$scope.$on('layout:pdf:resize', (event, state) => {
|
||||
ide.$scope.reviewPanel.layoutToLeft =
|
||||
state.east?.size < 220 || state.east?.initClosed
|
||||
$scope.$broadcast('review-panel:layout', false)
|
||||
ide.$scope.$broadcast('review-panel:layout', false)
|
||||
})
|
||||
|
||||
$scope.$on('expandable-text-area:resize', event =>
|
||||
$timeout(() => $scope.$broadcast('review-panel:layout'))
|
||||
$timeout(() => ide.$scope.$broadcast('review-panel:layout'))
|
||||
)
|
||||
|
||||
$scope.$on('review-panel:sizes', (e, sizes) => {
|
||||
|
@ -206,7 +206,7 @@ export default App.controller(
|
|||
delete thread.submitting
|
||||
thread.messages.push(formatComment(comment))
|
||||
$scope.$apply()
|
||||
return $timeout(() => $scope.$broadcast('review-panel:layout'))
|
||||
return $timeout(() => ide.$scope.$broadcast('review-panel:layout'))
|
||||
})
|
||||
|
||||
ide.socket.on('accept-changes', function (doc_id, change_ids) {
|
||||
|
@ -321,7 +321,7 @@ export default App.controller(
|
|||
}
|
||||
return $timeout(function () {
|
||||
$scope.$broadcast('review-panel:toggle')
|
||||
return $scope.$broadcast('review-panel:layout', false)
|
||||
return ide.$scope.$broadcast('review-panel:layout', false)
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -559,12 +559,12 @@ export default App.controller(
|
|||
$scope.$broadcast('review-panel:recalculate-screen-positions')
|
||||
dispatchReviewPanelEvent('recalculate-screen-positions', entries)
|
||||
|
||||
return $scope.$broadcast('review-panel:layout')
|
||||
return ide.$scope.$broadcast('review-panel:layout')
|
||||
}
|
||||
})
|
||||
|
||||
$scope.$on('editor:track-changes:visibility_changed', () =>
|
||||
$timeout(() => $scope.$broadcast('review-panel:layout', false))
|
||||
$timeout(() => ide.$scope.$broadcast('review-panel:layout', false))
|
||||
)
|
||||
|
||||
$scope.$on(
|
||||
|
@ -576,21 +576,42 @@ export default App.controller(
|
|||
ide.$scope.reviewPanel.selectedEntryIds = []
|
||||
// Count of user-visible changes, i.e. an aggregated change will count as one.
|
||||
ide.$scope.reviewPanel.nVisibleSelectedChanges = 0
|
||||
delete entries['add-comment']
|
||||
delete entries['bulk-actions']
|
||||
|
||||
const offset = selection_offset_start
|
||||
const length = selection_offset_end - selection_offset_start
|
||||
|
||||
// Recreate the add comment and bulk actions entries only when
|
||||
// necessary. This is to avoid the UI thinking that these entries have
|
||||
// changed and getting into an infinite loop.
|
||||
if (selection) {
|
||||
const existingAddComment = entries['add-comment']
|
||||
if (
|
||||
!existingAddComment ||
|
||||
existingAddComment.offset !== offset ||
|
||||
existingAddComment.length !== length
|
||||
) {
|
||||
entries['add-comment'] = {
|
||||
type: 'add-comment',
|
||||
offset: selection_offset_start,
|
||||
length: selection_offset_end - selection_offset_start,
|
||||
offset,
|
||||
length,
|
||||
}
|
||||
}
|
||||
const existingBulkActions = entries['bulk-actions']
|
||||
if (
|
||||
!existingBulkActions ||
|
||||
existingBulkActions.offset !== offset ||
|
||||
existingBulkActions.length !== length
|
||||
) {
|
||||
entries['bulk-actions'] = {
|
||||
type: 'bulk-actions',
|
||||
offset: selection_offset_start,
|
||||
length: selection_offset_end - selection_offset_start,
|
||||
offset,
|
||||
length,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
delete entries['add-comment']
|
||||
delete entries['bulk-actions']
|
||||
}
|
||||
|
||||
for (const id in entries) {
|
||||
const entry = entries[id]
|
||||
|
@ -640,7 +661,11 @@ export default App.controller(
|
|||
|
||||
dispatchReviewPanelEvent('recalculate-screen-positions', entries)
|
||||
|
||||
return $scope.$broadcast('review-panel:layout')
|
||||
// Ensure that watchers, such as the React-based review panel component,
|
||||
// are informed of the changes to entries
|
||||
ide.$scope.$apply()
|
||||
|
||||
return ide.$scope.$broadcast('review-panel:layout')
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -757,7 +782,7 @@ export default App.controller(
|
|||
$scope.toggleReviewPanel()
|
||||
}
|
||||
return $timeout(function () {
|
||||
$scope.$broadcast('review-panel:layout')
|
||||
ide.$scope.$broadcast('review-panel:layout')
|
||||
return $scope.$broadcast('comment:start_adding')
|
||||
})
|
||||
}
|
||||
|
@ -765,7 +790,7 @@ export default App.controller(
|
|||
$scope.startNewComment = function () {
|
||||
$scope.$broadcast('comment:select_line')
|
||||
dispatchReviewPanelEvent('comment:select_line')
|
||||
return $timeout(() => $scope.$broadcast('review-panel:layout'))
|
||||
return $timeout(() => ide.$scope.$broadcast('review-panel:layout'))
|
||||
}
|
||||
|
||||
ide.$scope.submitNewComment = function (content) {
|
||||
|
@ -800,16 +825,16 @@ export default App.controller(
|
|||
)
|
||||
// TODO: unused?
|
||||
$scope.$broadcast('editor:clearSelection')
|
||||
$timeout(() => $scope.$broadcast('review-panel:layout'))
|
||||
$timeout(() => ide.$scope.$broadcast('review-panel:layout'))
|
||||
eventTracking.sendMB('rp-new-comment', { size: content.length })
|
||||
}
|
||||
|
||||
$scope.cancelNewComment = entry =>
|
||||
$timeout(() => $scope.$broadcast('review-panel:layout'))
|
||||
$timeout(() => ide.$scope.$broadcast('review-panel:layout'))
|
||||
|
||||
$scope.startReply = function (entry) {
|
||||
entry.replying = true
|
||||
return $timeout(() => $scope.$broadcast('review-panel:layout'))
|
||||
return $timeout(() => ide.$scope.$broadcast('review-panel:layout'))
|
||||
}
|
||||
|
||||
ide.$scope.submitReply = function (entry, entry_id) {
|
||||
|
@ -839,14 +864,14 @@ export default App.controller(
|
|||
thread.submitting = true
|
||||
entry.replyContent = ''
|
||||
entry.replying = false
|
||||
$timeout(() => $scope.$broadcast('review-panel:layout'))
|
||||
$timeout(() => ide.$scope.$broadcast('review-panel:layout'))
|
||||
eventTracking.sendMB('rp-comment-reply', trackingMetadata)
|
||||
}
|
||||
|
||||
$scope.cancelReply = function (entry) {
|
||||
entry.replying = false
|
||||
entry.replyContent = ''
|
||||
return $scope.$broadcast('review-panel:layout')
|
||||
return ide.$scope.$broadcast('review-panel:layout')
|
||||
}
|
||||
|
||||
ide.$scope.resolveComment = function (doc_id, entry_id) {
|
||||
|
@ -947,7 +972,7 @@ export default App.controller(
|
|||
_csrf: window.csrfToken,
|
||||
}
|
||||
)
|
||||
return $timeout(() => $scope.$broadcast('review-panel:layout'))
|
||||
return $timeout(() => ide.$scope.$broadcast('review-panel:layout'))
|
||||
}
|
||||
|
||||
ide.$scope.deleteComment = function (thread_id, comment_id) {
|
||||
|
@ -959,7 +984,7 @@ export default App.controller(
|
|||
'X-CSRF-Token': window.csrfToken,
|
||||
},
|
||||
})
|
||||
return $timeout(() => $scope.$broadcast('review-panel:layout'))
|
||||
return $timeout(() => ide.$scope.$broadcast('review-panel:layout'))
|
||||
}
|
||||
|
||||
$scope.setSubView = function (subView) {
|
||||
|
@ -1244,7 +1269,7 @@ export default App.controller(
|
|||
}
|
||||
ide.$scope.reviewPanel.commentThreads = threads
|
||||
dispatchReviewPanelEvent('loaded_threads')
|
||||
return $timeout(() => $scope.$broadcast('review-panel:layout'))
|
||||
return $timeout(() => ide.$scope.$broadcast('review-panel:layout'))
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -1310,7 +1335,7 @@ export default App.controller(
|
|||
switch (type) {
|
||||
case 'line-height': {
|
||||
ide.$scope.reviewPanel.rendererData.lineHeight = payload
|
||||
$scope.$broadcast('review-panel:layout')
|
||||
ide.$scope.$broadcast('review-panel:layout')
|
||||
break
|
||||
}
|
||||
|
||||
|
|
|
@ -50,7 +50,11 @@ function AutoExpandingTextArea({
|
|||
if (isFirstResize) {
|
||||
isFirstResize = false
|
||||
} else {
|
||||
onResize()
|
||||
// Prevent errors like "ResizeObserver loop completed with undelivered
|
||||
// notifications" that occur if onResize does something complicated.
|
||||
// The cost of this is that onResize lags one frame behind, but it's
|
||||
// unlikely to matter.
|
||||
window.requestAnimationFrame(onResize)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
7
services/web/frontend/js/utils/debugging.ts
Normal file
7
services/web/frontend/js/utils/debugging.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
type DebugConsole = { log(...data: any[]): void }
|
||||
|
||||
export const debugging =
|
||||
new URLSearchParams(window.location.search).get('debug') === 'true'
|
||||
export const debugConsole: DebugConsole = debugging
|
||||
? console
|
||||
: { log: () => {} }
|
|
@ -27,6 +27,8 @@
|
|||
@rp-toolbar-height: 32px;
|
||||
|
||||
@rp-entry-animation-speed: 0.3s;
|
||||
// Move a little faster in React to compensate for the delay before moving the vertical position
|
||||
@rp-entry-animation-speed-react: 0.2s;
|
||||
|
||||
.rp-button() {
|
||||
display: block; // IE doesn't do flex with inline items.
|
||||
|
@ -65,6 +67,8 @@
|
|||
}
|
||||
|
||||
#review-panel {
|
||||
--rp-animation-speed: @rp-entry-animation-speed;
|
||||
|
||||
display: block;
|
||||
|
||||
.rp-size-expanded & {
|
||||
|
@ -248,10 +252,10 @@
|
|||
border-radius: 3px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: top @rp-entry-animation-speed, left 0.1s, right 0.1s;
|
||||
transition: top var(--rp-animation-speed), left 0.1s, right 0.1s;
|
||||
|
||||
.no-animate & {
|
||||
transition: none;
|
||||
transition: left 0.1s, right 0.1s;
|
||||
}
|
||||
|
||||
&-focused {
|
||||
|
@ -372,10 +376,10 @@
|
|||
border-left: solid @rp-entry-ribbon-width transparent;
|
||||
border-radius: 3px;
|
||||
background-color: #fff;
|
||||
transition: top @rp-entry-animation-speed, left 0.1s, right 0.1s;
|
||||
transition: top var(--rp-animation-speed), left 0.1s, right 0.1s;
|
||||
|
||||
.no-animate & {
|
||||
transition: none;
|
||||
transition: left 0.1s, right 0.1s;
|
||||
}
|
||||
|
||||
&-insert,
|
||||
|
@ -640,7 +644,7 @@
|
|||
}
|
||||
|
||||
.rp-entry-callout {
|
||||
transition: top @rp-entry-animation-speed, height @rp-entry-animation-speed;
|
||||
transition: top var(--rp-animation-speed), height var(--rp-animation-speed);
|
||||
|
||||
.rp-state-current-file & {
|
||||
position: absolute;
|
||||
|
@ -1193,6 +1197,8 @@ button when (@is-overleaf-light = true) {
|
|||
|
||||
// CM6-specific review panel rules
|
||||
.ol-cm-review-panel {
|
||||
--rp-animation-speed: @rp-entry-animation-speed-react;
|
||||
|
||||
position: relative;
|
||||
z-index: 6;
|
||||
display: block;
|
||||
|
@ -1254,6 +1260,17 @@ button when (@is-overleaf-light = true) {
|
|||
bottom: 0;
|
||||
}
|
||||
|
||||
.rp-entry-list-react {
|
||||
position: relative;
|
||||
|
||||
.rp-entry-list-react-inner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.rp-state-current-file & {
|
||||
.review-panel-tools {
|
||||
display: flex;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { ThreadId, UserId } from './review-panel'
|
||||
|
||||
interface ReviewPanelEntryScreenPos {
|
||||
export interface ReviewPanelEntryScreenPos {
|
||||
y: number
|
||||
height: number
|
||||
editorPaddingTop: number
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
import { Brand } from '../helpers/brand'
|
||||
import { DocId } from '../project-settings'
|
||||
import { ReviewPanelEntry } from './entry'
|
||||
import {
|
||||
ReviewPanelAddCommentEntry,
|
||||
ReviewPanelBulkActionsEntry,
|
||||
ReviewPanelEntry,
|
||||
} from './entry'
|
||||
import { ReviewPanelCommentThread } from './comment-thread'
|
||||
|
||||
export type SubView = 'cur_file' | 'overview'
|
||||
|
@ -13,7 +17,15 @@ export interface ReviewPanelPermissions {
|
|||
}
|
||||
|
||||
export type ThreadId = Brand<string, 'ThreadId'>
|
||||
export type ReviewPanelDocEntries = Record<ThreadId, ReviewPanelEntry>
|
||||
// Entries may contain `add-comment` and `bulk-actions` props along with DocIds
|
||||
// Ideally the `add-comment` and `bulk-actions` objects should not be within the entries object
|
||||
// as the doc data, but this is what currently angular returns.
|
||||
export type ReviewPanelDocEntries = Record<
|
||||
| ThreadId
|
||||
| ReviewPanelAddCommentEntry['type']
|
||||
| ReviewPanelBulkActionsEntry['type'],
|
||||
ReviewPanelEntry
|
||||
>
|
||||
|
||||
export type ReviewPanelEntries = Record<DocId, ReviewPanelDocEntries>
|
||||
|
||||
|
@ -27,6 +39,7 @@ export interface ReviewPanelUser {
|
|||
isSelf: boolean
|
||||
name: string
|
||||
}
|
||||
|
||||
export type ReviewPanelUsers = Record<UserId, ReviewPanelUser>
|
||||
|
||||
export type CommentId = Brand<string, 'CommentId'>
|
||||
|
|
Loading…
Reference in a new issue