Merge pull request #20617 from overleaf/dk-review-indicator-hover

Improve "mini" view popovers for new review panel

GitOrigin-RevId: ff5fc0af70bd9660d5cc17437b25824ef4c9a704
This commit is contained in:
David 2024-10-02 12:58:46 +01:00 committed by Copybot
parent 46198cd780
commit 56d396a6cf
6 changed files with 204 additions and 136 deletions

View file

@ -23,146 +23,172 @@ export const ReviewPanelChange = memo<{
editable?: boolean editable?: boolean
docId: string docId: string
hoverRanges?: boolean hoverRanges?: boolean
}>(({ change, aggregate, top, docId, hoverRanges, editable = true }) => { hovered?: boolean
const { t } = useTranslation() onEnter?: () => void
const { acceptChanges, rejectChanges } = useRangesActionsContext() onLeave?: () => void
const permissions = usePermissionsContext() }>(
const changesUsers = useChangesUsersContext() ({
change,
aggregate,
top,
docId,
hoverRanges,
editable = true,
hovered,
onEnter,
onLeave,
}) => {
const { t } = useTranslation()
const { acceptChanges, rejectChanges } = useRangesActionsContext()
const permissions = usePermissionsContext()
const changesUsers = useChangesUsersContext()
if (!changesUsers) { if (!changesUsers) {
// if users are not loaded yet, do not show "Unknown" user // if users are not loaded yet, do not show "Unknown" user
return null return null
} }
return ( return (
<ReviewPanelEntry <ReviewPanelEntry
className={classnames('review-panel-entry-change', { className={classnames('review-panel-entry-change', {
'review-panel-entry-insert': 'i' in change.op, 'review-panel-entry-insert': 'i' in change.op,
'review-panel-entry-delete': 'd' in change.op, 'review-panel-entry-delete': 'd' in change.op,
// TODO: aggregate 'review-panel-entry-hover': hovered,
})} // TODO: aggregate
top={top} })}
op={change.op} top={top}
position={change.op.p} op={change.op}
docId={docId} position={change.op.p}
hoverRanges={hoverRanges} docId={docId}
> hoverRanges={hoverRanges}
<div className="review-panel-entry-indicator"> >
<MaterialIcon type="edit" className="review-panel-entry-icon" /> <div
</div> className="review-panel-entry-indicator"
onMouseEnter={onEnter}
onMouseLeave={onLeave}
>
<MaterialIcon type="edit" className="review-panel-entry-icon" />
</div>
<div className="review-panel-entry-content"> <div
<div className="review-panel-entry-header"> className="review-panel-entry-content"
<div> onMouseEnter={onEnter}
<div className="review-panel-entry-user"> onMouseLeave={onLeave}
<ReviewPanelChangeUser change={change} /> >
</div> <div className="review-panel-entry-header">
<div className="review-panel-entry-time"> <div>
{formatTimeBasedOnYear(change.metadata?.ts)} <div className="review-panel-entry-user">
<ReviewPanelChangeUser change={change} />
</div>
<div className="review-panel-entry-time">
{formatTimeBasedOnYear(change.metadata?.ts)}
</div>
</div> </div>
{editable && permissions.write && (
<div className="review-panel-entry-actions">
<Tooltip
id="accept-change"
overlayProps={{ placement: 'bottom' }}
description={t('accept_change')}
tooltipProps={{ className: 'review-panel-tooltip' }}
>
<Button
onClick={() =>
aggregate
? acceptChanges(change.id, aggregate.id)
: acceptChanges(change.id)
}
bsStyle={null}
>
<MaterialIcon
type="check"
className="review-panel-entry-actions-icon"
accessibilityLabel={t('accept_change')}
/>
</Button>
</Tooltip>
<Tooltip
id="reject-change"
description={t('reject_change')}
overlayProps={{ placement: 'bottom' }}
tooltipProps={{ className: 'review-panel-tooltip' }}
>
<Button
bsStyle={null}
onClick={() =>
aggregate
? rejectChanges(change.id, aggregate.id)
: rejectChanges(change.id)
}
>
<MaterialIcon
className="review-panel-entry-actions-icon"
accessibilityLabel={t('reject_change')}
type="close"
/>
</Button>
</Tooltip>
</div>
)}
</div> </div>
{editable && permissions.write && (
<div className="review-panel-entry-actions"> <div className="review-panel-change-body">
<Tooltip {'i' in change.op && (
id="accept-change" <>
overlayProps={{ placement: 'bottom' }} {aggregate ? (
description={t('accept_change')}
>
<Button
onClick={() =>
aggregate
? acceptChanges(change.id, aggregate.id)
: acceptChanges(change.id)
}
bsStyle={null}
>
<MaterialIcon <MaterialIcon
type="check" className="review-panel-entry-icon review-panel-entry-icon-changed"
className="review-panel-entry-actions-icon" type="edit"
accessibilityLabel={t('accept_change')}
/> />
</Button> ) : (
</Tooltip>
<Tooltip
id="reject-change"
description={t('reject_change')}
overlayProps={{ placement: 'bottom' }}
>
<Button
bsStyle={null}
onClick={() =>
aggregate
? rejectChanges(change.id, aggregate.id)
: rejectChanges(change.id)
}
>
<MaterialIcon <MaterialIcon
className="review-panel-entry-actions-icon" className="review-panel-entry-icon review-panel-entry-icon-accept"
accessibilityLabel={t('reject_change')} type="add_circle"
type="close"
/> />
</Button> )}
</Tooltip>
</div>
)}
</div>
<div className="review-panel-change-body"> {aggregate ? (
{'i' in change.op && ( <span>
<> {t('aggregate_changed')}:{' '}
{aggregate ? ( <del className="review-panel-content-highlight">
<MaterialIcon {aggregate.op.d}
className="review-panel-entry-icon review-panel-entry-icon-changed" </del>{' '}
type="edit" {t('aggregate_to')}{' '}
/> <ins className="review-panel-content-highlight">
) : ( {change.op.i}
<MaterialIcon </ins>
className="review-panel-entry-icon review-panel-entry-icon-accept" </span>
type="add_circle" ) : (
/> <span>
)} {t('tracked_change_added')}:&nbsp;
<ins className="review-panel-content-highlight">
{change.op.i}
</ins>
</span>
)}
</>
)}
{'d' in change.op && (
<>
<MaterialIcon
className="review-panel-entry-icon review-panel-entry-icon-reject"
type="delete"
/>
{aggregate ? (
<span> <span>
{t('aggregate_changed')}:{' '} {t('tracked_change_deleted')}:&nbsp;
<del className="review-panel-content-highlight"> <del className="review-panel-content-highlight">
{aggregate.op.d} {change.op.d}
</del>{' '} </del>
{t('aggregate_to')}{' '}
<ins className="review-panel-content-highlight">
{change.op.i}
</ins>
</span> </span>
) : ( </>
<span> )}
{t('tracked_change_added')}:&nbsp; </div>
<ins className="review-panel-content-highlight">
{change.op.i}
</ins>
</span>
)}
</>
)}
{'d' in change.op && (
<>
<MaterialIcon
className="review-panel-entry-icon review-panel-entry-icon-reject"
type="delete"
/>
<span>
{t('tracked_change_deleted')}:&nbsp;
<del className="review-panel-content-highlight">
{change.op.d}
</del>
</span>
</>
)}
</div> </div>
</div> </ReviewPanelEntry>
</ReviewPanelEntry> )
) }
}) )
ReviewPanelChange.displayName = 'ReviewPanelChange' ReviewPanelChange.displayName = 'ReviewPanelChange'

View file

@ -14,7 +14,9 @@ import useSubmittableTextInput from '../hooks/use-submittable-text-input'
export const ReviewPanelCommentContent = memo<{ export const ReviewPanelCommentContent = memo<{
comment: Change<CommentOperation> comment: Change<CommentOperation>
isResolved: boolean isResolved: boolean
}>(({ comment, isResolved }) => { onLeave?: () => void
onEnter?: () => void
}>(({ comment, isResolved, onLeave, onEnter }) => {
const { t } = useTranslation() const { t } = useTranslation()
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<Error>() const [error, setError] = useState<Error>()
@ -47,7 +49,11 @@ export const ReviewPanelCommentContent = memo<{
} }
return ( return (
<div className="review-panel-entry-content"> <div
className="review-panel-entry-content"
onMouseEnter={onEnter}
onMouseLeave={onLeave}
>
{thread.messages.map((message, i) => { {thread.messages.map((message, i) => {
const isReply = i !== 0 const isReply = i !== 0

View file

@ -11,7 +11,10 @@ export const ReviewPanelComment = memo<{
docId: string docId: string
top?: number top?: number
hoverRanges?: boolean hoverRanges?: boolean
}>(({ comment, top, docId, hoverRanges }) => { onEnter?: () => void
onLeave?: () => void
hovered?: boolean
}>(({ comment, top, hovered, onEnter, onLeave, docId, hoverRanges }) => {
const threads = useThreadsContext() const threads = useThreadsContext()
const thread = threads?.[comment.op.t] const thread = threads?.[comment.op.t]
@ -23,6 +26,7 @@ export const ReviewPanelComment = memo<{
<ReviewPanelEntry <ReviewPanelEntry
className={classnames('review-panel-entry-comment', { className={classnames('review-panel-entry-comment', {
'review-panel-entry-loaded': !!threads?.[comment.op.t], 'review-panel-entry-loaded': !!threads?.[comment.op.t],
'review-panel-entry-hover': hovered,
})} })}
docId={docId} docId={docId}
top={top} top={top}
@ -30,10 +34,19 @@ export const ReviewPanelComment = memo<{
position={comment.op.p} position={comment.op.p}
hoverRanges={hoverRanges} hoverRanges={hoverRanges}
> >
<div className="review-panel-entry-indicator"> <div
className="review-panel-entry-indicator"
onMouseEnter={onEnter}
onMouseLeave={onLeave}
>
<MaterialIcon type="comment" className="review-panel-entry-icon" /> <MaterialIcon type="comment" className="review-panel-entry-icon" />
</div> </div>
<ReviewPanelCommentContent comment={comment} isResolved={false} /> <ReviewPanelCommentContent
comment={comment}
isResolved={false}
onLeave={onLeave}
onEnter={onEnter}
/>
</ReviewPanelEntry> </ReviewPanelEntry>
) )
}) })

View file

@ -43,6 +43,20 @@ const ReviewPanelCurrentFile: FC = () => {
const ranges = useRangesContext() const ranges = useRangesContext()
const threads = useThreadsContext() const threads = useThreadsContext()
const state = useCodeMirrorStateContext() const state = useCodeMirrorStateContext()
const [hoveredEntry, setHoveredEntry] = useState<string | null>(null)
const hoverTimeout = useRef<number>(0)
const handleEntryEnter = useCallback((id: string) => {
clearTimeout(hoverTimeout.current)
setHoveredEntry(id)
}, [])
const handleEntryLeave = useCallback((id: string) => {
clearTimeout(hoverTimeout.current)
hoverTimeout.current = window.setTimeout(() => {
setHoveredEntry(null)
}, 100)
}, [])
const [aggregatedRanges, setAggregatedRanges] = useState<AggregatedRanges>() const [aggregatedRanges, setAggregatedRanges] = useState<AggregatedRanges>()
@ -298,6 +312,9 @@ const ReviewPanelCurrentFile: FC = () => {
change={change} change={change}
top={positions.get(change.id)} top={positions.get(change.id)}
aggregate={aggregatedRanges.aggregates.get(change.id)} aggregate={aggregatedRanges.aggregates.get(change.id)}
hovered={hoveredEntry === change.id}
onEnter={() => handleEntryEnter(change.id)}
onLeave={() => handleEntryLeave(change.id)}
/> />
) )
)} )}
@ -310,6 +327,9 @@ const ReviewPanelCurrentFile: FC = () => {
key={comment.id} key={comment.id}
comment={comment} comment={comment}
top={positions.get(comment.id)} top={positions.get(comment.id)}
hovered={hoveredEntry === comment.id}
onEnter={() => handleEntryEnter(comment.id)}
onLeave={() => handleEntryLeave(comment.id)}
/> />
) )
)} )}

View file

@ -106,6 +106,7 @@ export const ReviewPanelMessage: FC<{
id="resolve-thread" id="resolve-thread"
overlayProps={{ placement: 'bottom' }} overlayProps={{ placement: 'bottom' }}
description={t('resolve_comment')} description={t('resolve_comment')}
tooltipProps={{ className: 'review-panel-tooltip' }}
> >
<Button onClick={onResolve} bsStyle={null}> <Button onClick={onResolve} bsStyle={null}>
<MaterialIcon <MaterialIcon

View file

@ -554,15 +554,14 @@
margin-left: 0; margin-left: 0;
background-color: transparent; background-color: transparent;
border: none; border: none;
width: 100%;
} }
.review-panel-entry-indicator { .review-panel-entry-indicator {
position: absolute; position: absolute;
left: 0; left: 0;
top: 7px; top: 0;
display: flex; display: flex;
width: 16px;
height: 16px;
color: @content-secondary; color: @content-secondary;
cursor: pointer; cursor: pointer;
} }
@ -576,7 +575,7 @@
padding: @spacing-02; padding: @spacing-02;
} }
.review-panel-entry:hover { .review-panel-entry-hover {
.review-panel-entry-content { .review-panel-entry-content {
display: initial; display: initial;
position: absolute; position: absolute;
@ -599,3 +598,6 @@
align-items: center; align-items: center;
gap: 2px; gap: 2px;
} }
.review-panel-tooltip {
pointer-events: none; // this is to prevent mouseLeave event from firing when hovering over the tooltip
}