mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
* [web] open a comment from a URL * [web] add "View comment" button to digest email * [web] open a tracked change from a URL * [web] add "View suggestion" button to digest email * [web] open the review panel from an open=review-panel URL * [web] drop the digest email CTA and collapse the deep links to t=review * [web] add an Open project button to the section overflow row and plain-text view links * [web] validate the comment and change deep-link ids as object ids * [web] collapse the deep-link resolvers into a single DeepLink component * fix: scrolling into view when below the fold * fix: address claude review * fix: style adjustments * fix: updating types to accomodate accepted/rejected changes --------- Co-authored-by: Jimmy Domagala-Tang <jimmy.domagala-tang@overleaf.com> GitOrigin-RevId: dab535591a93e1c3849a6b2a0dcc3e3934354385
281 lines
8.8 KiB
TypeScript
281 lines
8.8 KiB
TypeScript
import { FC, useCallback, useEffect, useRef, useState } from 'react'
|
|
import { AnyOperation } from '../../../../../types/change'
|
|
import {
|
|
useCodeMirrorStateContext,
|
|
useCodeMirrorViewContext,
|
|
} from '@/features/source-editor/components/codemirror-context'
|
|
import { isSelectionWithinOp } from '../utils/is-selection-within-op'
|
|
import classNames from 'classnames'
|
|
import {
|
|
clearHighlightRanges,
|
|
highlightRanges,
|
|
} from '@/features/source-editor/extensions/ranges'
|
|
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
|
|
import { EditorSelection } from '@codemirror/state'
|
|
import { OFFSET_FOR_ENTRIES_ABOVE } from '../utils/position-items'
|
|
import useReviewPanelLayout from '../hooks/use-review-panel-layout'
|
|
import { EntryIndicator } from './review-panel-entry-indicator'
|
|
import { EditorView } from '@codemirror/view'
|
|
|
|
// how many frames to wait for the panel to position and show an entry. The
|
|
// panel repositions on a 100ms debounce and animates the move over 300ms, so
|
|
// 60 frames is roughly a second at 60fps: long enough to outlast both, short
|
|
// enough that a stuck entry stops polling instead of spinning forever.
|
|
const MAX_FOCUS_FRAMES = 60
|
|
|
|
export const ReviewPanelEntry: FC<
|
|
React.PropsWithChildren<{
|
|
position: number
|
|
op: AnyOperation
|
|
docId: string
|
|
top?: number
|
|
className?: string
|
|
selectLineOnFocus?: boolean
|
|
hoverRanges?: boolean
|
|
disabled?: boolean
|
|
handleEnter?: () => void
|
|
handleLeave?: () => void
|
|
entryIndicator?: 'comment' | 'edit'
|
|
autoSelect?: boolean
|
|
onAutoSelected?: () => void
|
|
}>
|
|
> = ({
|
|
children,
|
|
position,
|
|
top,
|
|
op,
|
|
className,
|
|
selectLineOnFocus = true,
|
|
docId,
|
|
hoverRanges = true,
|
|
disabled,
|
|
handleEnter,
|
|
handleLeave,
|
|
entryIndicator,
|
|
autoSelect,
|
|
onAutoSelected,
|
|
}) => {
|
|
const state = useCodeMirrorStateContext()
|
|
const view = useCodeMirrorViewContext()
|
|
const { openDocWithId, getCurrentDocumentId } = useEditorManagerContext()
|
|
const [selected, setSelected] = useState(false)
|
|
const [focused, setFocused] = useState(false)
|
|
const [textareaFocused, setTextareaFocused] = useState(false)
|
|
const { openReviewPanel } = useReviewPanelLayout()
|
|
const highlighted = isSelectionWithinOp(op, state.selection.main)
|
|
const entryRef = useRef<HTMLDivElement>(null)
|
|
const mousePressedRef = useRef(false)
|
|
|
|
const selectEntry = useCallback(
|
|
(event?: React.FocusEvent | React.MouseEvent) => {
|
|
setFocused(true)
|
|
|
|
// The add-comment input is a CodeMirror editor (contenteditable div)
|
|
// rather than a textarea, so also treat focus landing inside it as a
|
|
// text-input focus.
|
|
const target = event?.target
|
|
const isTextInput =
|
|
target instanceof HTMLTextAreaElement ||
|
|
(target instanceof HTMLElement &&
|
|
target.closest('.review-panel-add-comment-editor') !== null)
|
|
|
|
if (isTextInput) {
|
|
const entryBottom =
|
|
(entryRef.current?.offsetTop || 0) +
|
|
(entryRef.current?.offsetHeight || 0)
|
|
|
|
if (entryBottom > OFFSET_FOR_ENTRIES_ABOVE) {
|
|
// if the entry input is visible, no need to select the entry
|
|
// so that it doesn't scroll out of view as user types
|
|
setTextareaFocused(true)
|
|
return
|
|
}
|
|
}
|
|
|
|
if (mousePressedRef.current) {
|
|
return
|
|
}
|
|
|
|
setSelected(true)
|
|
|
|
if (!selectLineOnFocus) {
|
|
return
|
|
}
|
|
|
|
if (getCurrentDocumentId() !== docId) {
|
|
openDocWithId(docId, { gotoOffset: position, keepCurrentView: true })
|
|
} else {
|
|
setTimeout(() => {
|
|
const selection = EditorSelection.cursor(position)
|
|
|
|
// if the position is outside the viewport, so could be estimated,
|
|
// use EditorView.scrollIntoView (accurate, not smooth)
|
|
if (position < view.viewport.from || view.viewport.to < position) {
|
|
view.dispatch({
|
|
selection,
|
|
effects: EditorView.scrollIntoView(selection, { y: 'center' }),
|
|
})
|
|
return
|
|
}
|
|
|
|
// if the position is inside the viewport, so accurate,
|
|
// use scrollDOM.scrollTo (smooth)
|
|
|
|
view.dispatch({ selection })
|
|
|
|
// scroll to line (centered)
|
|
const blockInfo = view.lineBlockAt(position)
|
|
const coordsAtPos = view.coordsAtPos(position)
|
|
const coordsAtLineStart = view.coordsAtPos(blockInfo.from)
|
|
let wrappedLineOffset = 0
|
|
if (coordsAtPos !== null && coordsAtLineStart !== null) {
|
|
wrappedLineOffset = coordsAtPos.top - coordsAtLineStart.top
|
|
}
|
|
|
|
const editorHeight = view.scrollDOM.getBoundingClientRect().height
|
|
view.scrollDOM.scrollTo({
|
|
top:
|
|
blockInfo.top -
|
|
editorHeight / 2 +
|
|
view.defaultLineHeight +
|
|
wrappedLineOffset,
|
|
behavior: 'smooth',
|
|
})
|
|
})
|
|
}
|
|
},
|
|
[
|
|
getCurrentDocumentId,
|
|
docId,
|
|
selectLineOnFocus,
|
|
view,
|
|
position,
|
|
openDocWithId,
|
|
]
|
|
)
|
|
|
|
const autoSelectDoneRef = useRef(false)
|
|
const focusFrameRef = useRef(0)
|
|
|
|
useEffect(() => {
|
|
if (!autoSelect || autoSelectDoneRef.current) {
|
|
return
|
|
}
|
|
|
|
autoSelectDoneRef.current = true
|
|
selectEntry()
|
|
onAutoSelected?.()
|
|
|
|
// the panel positions entries asynchronously and keeps them hidden until
|
|
// then, and focus() does nothing on a hidden element, so wait for the entry
|
|
// to become visible
|
|
let attempts = 0
|
|
|
|
const focusWhenVisible = () => {
|
|
const entry = entryRef.current
|
|
|
|
if (!entry) {
|
|
return
|
|
}
|
|
|
|
if (entry.style.visibility === 'hidden' && attempts < MAX_FOCUS_FRAMES) {
|
|
attempts++
|
|
focusFrameRef.current = window.requestAnimationFrame(focusWhenVisible)
|
|
return
|
|
}
|
|
|
|
entry.focus({ preventScroll: true })
|
|
}
|
|
|
|
focusFrameRef.current = window.requestAnimationFrame(focusWhenVisible)
|
|
}, [autoSelect, onAutoSelected, selectEntry])
|
|
|
|
useEffect(() => {
|
|
return () => window.cancelAnimationFrame(focusFrameRef.current)
|
|
}, [])
|
|
|
|
// Clear op highlight on dismount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (hoverRanges) {
|
|
setTimeout(() => {
|
|
view.dispatch(clearHighlightRanges(op))
|
|
})
|
|
}
|
|
}
|
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
return (
|
|
<div
|
|
ref={entryRef}
|
|
onMouseDown={() => {
|
|
mousePressedRef.current = true
|
|
}}
|
|
onMouseUp={event => {
|
|
mousePressedRef.current = false
|
|
const isTextSelected = Boolean(window.getSelection()?.toString())
|
|
if (!isTextSelected && !selected) {
|
|
selectEntry(event)
|
|
}
|
|
}}
|
|
onFocus={selectEntry}
|
|
onBlur={() => {
|
|
mousePressedRef.current = false
|
|
setSelected(false)
|
|
setFocused(false)
|
|
setTextareaFocused(false)
|
|
}}
|
|
onMouseEnter={() => {
|
|
if (hoverRanges) {
|
|
view.dispatch(highlightRanges(op))
|
|
}
|
|
}}
|
|
onMouseLeave={() => {
|
|
if (hoverRanges) {
|
|
view.dispatch(clearHighlightRanges(op))
|
|
}
|
|
}}
|
|
role="button"
|
|
tabIndex={position + 1}
|
|
className={classNames(
|
|
'review-panel-entry',
|
|
{
|
|
// 'selected' is used to manually select an entry
|
|
// useful if the range is within range and you want to show the one outside the viewport
|
|
// it is not enough to just check isSelectionWithinOp for that
|
|
'review-panel-entry-selected': selected,
|
|
// 'focused' is set even when an entry was clicked but not selected (like clicking on a menu option)
|
|
// used to set z-index above other entries (since entries are not ordered the same way visually and in the DOM)
|
|
'review-panel-entry-focused': focused,
|
|
// 'highlighted' is set if the selection is within op but that doesn't necessarily mean it should be selected
|
|
// multiple entries can be highlighted at the same time
|
|
'review-panel-entry-highlighted': highlighted,
|
|
// 'textarea-focused' only changes entry styling (border, shadow etc)
|
|
// it doesnt change selected entry because that moves the cursor
|
|
// and repositions entries which can cause textarea to be scrolled out of view
|
|
'review-panel-entry-textarea-focused': textareaFocused,
|
|
'review-panel-entry-disabled': disabled,
|
|
},
|
|
className
|
|
)}
|
|
data-top={top}
|
|
data-pos={position}
|
|
style={{
|
|
position: top === undefined ? 'relative' : 'absolute',
|
|
visibility: top === undefined ? 'visible' : 'hidden',
|
|
transition: 'top .3s, left .1s, right .1s',
|
|
}}
|
|
>
|
|
{entryIndicator && (
|
|
<EntryIndicator
|
|
type={entryIndicator}
|
|
handleMouseEnter={handleEnter}
|
|
handleMouseLeave={handleLeave}
|
|
handleMouseDown={openReviewPanel}
|
|
/>
|
|
)}
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|