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

278 lines
8.2 KiB
TypeScript

import {
CSSProperties,
FC,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import ReactDOM from 'react-dom'
import MaterialIcon from '@/shared/components/material-icon'
import { useTranslation } from 'react-i18next'
import {
useCodeMirrorStateContext,
useCodeMirrorViewContext,
} from '@/features/source-editor/components/codemirror-context'
import {
buildAddNewCommentRangeEffect,
reviewTooltipStateField,
} from '@/features/source-editor/extensions/review-tooltip'
import { EditorView, getTooltip } from '@codemirror/view'
import useViewerPermissions from '@/shared/hooks/use-viewer-permissions'
import usePreviousValue from '@/shared/hooks/use-previous-value'
import { useLayoutContext } from '@/shared/context/layout-context'
import { useReviewPanelViewActionsContext } from '../context/review-panel-view-context'
import {
useRangesActionsContext,
useRangesContext,
} from '../context/ranges-context'
import { isInsertOperation } from '@/utils/operations'
import { isCursorNearViewportEdge } from '@/features/source-editor/utils/is-cursor-near-edge'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import { useModalsContext } from '@/features/ide-react/context/modals-context'
import { numberOfChangesInSelection } from '../utils/changes-in-selection'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import classNames from 'classnames'
import useEventListener from '@/shared/hooks/use-event-listener'
import getMeta from '@/utils/meta'
import { useRailContext } from '@/features/ide-redesign/contexts/rail-context'
import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
const isReviewerRoleEnabled = getMeta('ol-isReviewerRoleEnabled')
const TRACK_CHANGES_ON_WIDGET_HEIGHT = 25
const EDIT_MODE_SWITCH_WIDGET_HEIGHT = 40
const CM_LINE_RIGHT_PADDING = isReviewerRoleEnabled ? 8 : 2
const TOOLTIP_SHOW_DELAY = 120
const ReviewTooltipMenu: FC = () => {
const state = useCodeMirrorStateContext()
const view = useCodeMirrorViewContext()
const isViewer = useViewerPermissions()
const [show, setShow] = useState(true)
const { setView } = useReviewPanelViewActionsContext()
const { setReviewPanelOpen } = useLayoutContext()
const { openTab: openRailTab } = useRailContext()
const newEditor = useIsNewEditorEnabled()
const tooltipState = state.field(reviewTooltipStateField, false)?.tooltip
const previousTooltipState = usePreviousValue(tooltipState)
useEffect(() => {
if (tooltipState !== null && previousTooltipState === null) {
setShow(true)
}
}, [tooltipState, previousTooltipState])
const addComment = useCallback(() => {
const { main } = view.state.selection
if (main.empty) {
return
}
if (newEditor) {
openRailTab('review-panel')
} else {
setReviewPanelOpen(true)
}
setView('cur_file')
const effects = isCursorNearViewportEdge(view, main.anchor)
? [
buildAddNewCommentRangeEffect(main),
EditorView.scrollIntoView(main.anchor, { y: 'center' }),
]
: [buildAddNewCommentRangeEffect(main)]
view.dispatch({ effects })
setShow(false)
}, [setReviewPanelOpen, setView, setShow, view, openRailTab, newEditor])
useEventListener('add-new-review-comment', addComment)
if (isViewer || !show || !tooltipState) {
return null
}
const tooltipView = getTooltip(view, tooltipState)
if (!tooltipView) {
return null
}
return ReactDOM.createPortal(
<ReviewTooltipMenuContent onAddComment={addComment} />,
tooltipView.dom
)
}
const ReviewTooltipMenuContent: FC<{ onAddComment: () => void }> = ({
onAddComment,
}) => {
const { t } = useTranslation()
const view = useCodeMirrorViewContext()
const state = useCodeMirrorStateContext()
const { reviewPanelOpen } = useLayoutContext()
const ranges = useRangesContext()
const { acceptChanges, rejectChanges } = useRangesActionsContext()
const { showGenericConfirmModal } = useModalsContext()
const { wantTrackChanges } = useEditorManagerContext()
const [tooltipStyle, setTooltipStyle] = useState<CSSProperties | undefined>()
const [visible, setVisible] = useState(false)
const changeIdsInSelection = useMemo(() => {
return (ranges?.changes ?? [])
.filter(({ op }) => {
const opFrom = op.p
const opLength = isInsertOperation(op) ? op.i.length : 0
const opTo = opFrom + opLength
const selection = state.selection.main
return opFrom >= selection.from && opTo <= selection.to
})
.map(({ id }) => id)
}, [ranges, state.selection.main])
const acceptChangesHandler = useCallback(() => {
const nChanges = numberOfChangesInSelection(
ranges,
view.state.selection.main
)
showGenericConfirmModal({
message: t('confirm_accept_selected_changes', { count: nChanges }),
title: t('accept_selected_changes'),
onConfirm: () => {
acceptChanges(...changeIdsInSelection)
},
primaryVariant: 'danger',
})
}, [
acceptChanges,
changeIdsInSelection,
ranges,
showGenericConfirmModal,
view,
t,
])
const rejectChangesHandler = useCallback(() => {
const nChanges = numberOfChangesInSelection(
ranges,
view.state.selection.main
)
showGenericConfirmModal({
message: t('confirm_reject_selected_changes', { count: nChanges }),
title: t('reject_selected_changes'),
onConfirm: () => {
rejectChanges(...changeIdsInSelection)
},
primaryVariant: 'danger',
})
}, [
showGenericConfirmModal,
t,
ranges,
view,
rejectChanges,
changeIdsInSelection,
])
const showChangesButtons = changeIdsInSelection.length > 0
useEffect(() => {
view.requestMeasure({
key: 'review-tooltip-outside-viewport',
read(view) {
const cursorCoords = view.coordsAtPos(view.state.selection.main.head)
if (!cursorCoords) {
return
}
const scrollDomRect = view.scrollDOM.getBoundingClientRect()
const contentDomRect = view.contentDOM.getBoundingClientRect()
const editorRightPos = contentDomRect.right - CM_LINE_RIGHT_PADDING
if (
cursorCoords.top > scrollDomRect.top &&
cursorCoords.top < scrollDomRect.bottom
) {
return
}
let widgetOffset = 0
if (isReviewerRoleEnabled) {
widgetOffset = EDIT_MODE_SWITCH_WIDGET_HEIGHT
} else if (wantTrackChanges && !reviewPanelOpen) {
widgetOffset = TRACK_CHANGES_ON_WIDGET_HEIGHT
}
return {
position: 'fixed' as const,
top: scrollDomRect.top + widgetOffset,
right: window.innerWidth - editorRightPos,
}
},
write(res) {
setTooltipStyle(res)
},
})
}, [view, reviewPanelOpen, wantTrackChanges])
useEffect(() => {
setVisible(false)
const timeout = setTimeout(() => {
setVisible(true)
}, TOOLTIP_SHOW_DELAY)
return () => {
clearTimeout(timeout)
}
}, [])
return (
<div
className={classNames('review-tooltip-menu', {
'review-tooltip-menu-visible': visible,
})}
style={tooltipStyle}
>
<button
className="review-tooltip-menu-button review-tooltip-add-comment-button"
onClick={onAddComment}
>
<MaterialIcon type="chat" />
{t('add_comment')}
</button>
{showChangesButtons && (
<>
<div className="review-tooltip-menu-divider" />
<OLTooltip
id="accept-all-changes"
description={t('accept_selected_changes')}
>
<button
className="review-tooltip-menu-button"
onClick={acceptChangesHandler}
>
<MaterialIcon type="check" />
</button>
</OLTooltip>
<OLTooltip
id="reject-all-changes"
description={t('reject_selected_changes')}
>
<button
className="review-tooltip-menu-button"
onClick={rejectChangesHandler}
>
<MaterialIcon type="clear" />
</button>
</OLTooltip>
</>
)}
</div>
)
}
export default ReviewTooltipMenu