2025-04-24 13:11:28 +08:00

217 lines
6.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 { useLayoutContext } from '@/shared/context/layout-context'
import { EditorSelection } from '@codemirror/state'
import MaterialIcon from '@/shared/components/material-icon'
import { OFFSET_FOR_ENTRIES_ABOVE } from '../utils/position-items'
export const ReviewPanelEntry: FC<{
position: number
op: AnyOperation
docId: string
top?: number
className?: string
selectLineOnFocus?: boolean
hoverRanges?: boolean
disabled?: boolean
onEnterEntryIndicator?: () => void
onLeaveEntryIndicator?: () => void
entryIndicator?: 'comment' | 'edit'
}> = ({
children,
position,
top,
op,
className,
selectLineOnFocus = true,
docId,
hoverRanges = true,
disabled,
onEnterEntryIndicator,
onLeaveEntryIndicator,
entryIndicator,
}) => {
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 { setReviewPanelOpen } = useLayoutContext()
const highlighted = isSelectionWithinOp(op, state.selection.main)
const entryRef = useRef<HTMLDivElement>(null)
const mousePressedRef = useRef(false)
const openReviewPanel = useCallback(() => {
setReviewPanelOpen(true)
}, [setReviewPanelOpen])
const selectEntry = useCallback(
event => {
setFocused(true)
if (event.target instanceof HTMLTextAreaElement) {
const entryBottom =
(entryRef.current?.offsetTop || 0) +
(entryRef.current?.offsetHeight || 0)
if (entryBottom > OFFSET_FOR_ENTRIES_ABOVE) {
// if the entry textarea 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(() => {
view.dispatch({
selection: EditorSelection.cursor(position),
})
// 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,
]
)
// 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 && (
<div
className="review-panel-entry-indicator"
onMouseEnter={onEnterEntryIndicator}
onMouseLeave={onLeaveEntryIndicator}
onMouseDown={openReviewPanel} // Using onMouseDown rather than onClick to guarantee that it fires before onFocus
role="button"
tabIndex={0}
>
<MaterialIcon
type={entryIndicator}
className="review-panel-entry-icon"
/>
</div>
)}
{children}
</div>
)
}