review-tooltip-menu.tsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. import {
  2. CSSProperties,
  3. FC,
  4. memo,
  5. useCallback,
  6. useEffect,
  7. useMemo,
  8. useState,
  9. } from 'react'
  10. import ReactDOM from 'react-dom'
  11. import MaterialIcon from '@/shared/components/material-icon'
  12. import { useTranslation } from 'react-i18next'
  13. import {
  14. useCodeMirrorStateContext,
  15. useCodeMirrorViewContext,
  16. } from '@/features/source-editor/components/codemirror-context'
  17. import {
  18. buildAddNewCommentRangeEffect,
  19. reviewTooltipStateField,
  20. } from '@/features/source-editor/extensions/review-tooltip'
  21. import { selectHighlightedOrNearestToken } from '@/features/source-editor/utils/select-highlighted-or-nearest-token'
  22. import { EditorSelection } from '@codemirror/state'
  23. import { EditorView, getTooltip } from '@codemirror/view'
  24. import usePreviousValue from '@/shared/hooks/use-previous-value'
  25. import { useLayoutContext } from '@/shared/context/layout-context'
  26. import { useReviewPanelViewActionsContext } from '../context/review-panel-view-context'
  27. import {
  28. useRangesActionsContext,
  29. useRangesContext,
  30. } from '../context/ranges-context'
  31. import { isInsertOperation } from '@/utils/operations'
  32. import { isCursorNearViewportEdge } from '@/features/source-editor/utils/is-cursor-near-edge'
  33. import OLTooltip from '@/shared/components/ol/ol-tooltip'
  34. import { useModalsContext } from '@/features/ide-react/context/modals-context'
  35. import { numberOfChangesInSelection } from '../utils/changes-in-selection'
  36. import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
  37. import classNames from 'classnames'
  38. import useEventListener from '@/shared/hooks/use-event-listener'
  39. import useReviewPanelLayout from '../hooks/use-review-panel-layout'
  40. import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
  41. import { sendMB } from '@/infrastructure/event-tracking'
  42. const EDIT_MODE_SWITCH_WIDGET_HEIGHT = 40
  43. const CM_LINE_RIGHT_PADDING = 8
  44. const TOOLTIP_SHOW_DELAY = 120
  45. // TODO remove when `writefull-toolbar-migration` fully rolled out
  46. const ReviewTooltipMenu: FC = () => {
  47. const state = useCodeMirrorStateContext()
  48. const view = useCodeMirrorViewContext()
  49. const permissions = usePermissionsContext()
  50. const [show, setShow] = useState(true)
  51. const { setView } = useReviewPanelViewActionsContext()
  52. const { openReviewPanel } = useReviewPanelLayout()
  53. const tooltipState = state.field(reviewTooltipStateField, false)?.tooltip
  54. const previousTooltipState = usePreviousValue(tooltipState)
  55. useEffect(() => {
  56. if (tooltipState !== null && previousTooltipState === null) {
  57. setShow(true)
  58. }
  59. }, [tooltipState, previousTooltipState])
  60. useEffect(() => {
  61. if (!show || !tooltipState || !permissions.comment) {
  62. return
  63. }
  64. const handleMouseDown = (event: MouseEvent) => {
  65. const target = event.target as Element | null
  66. if (
  67. !view.contentDOM.contains(target) &&
  68. !target?.closest?.('.review-tooltip-menu-container') &&
  69. !target?.closest?.('.modal') &&
  70. !target?.closest?.('.modal-backdrop')
  71. ) {
  72. setShow(false)
  73. }
  74. }
  75. document.addEventListener('mousedown', handleMouseDown)
  76. return () => {
  77. document.removeEventListener('mousedown', handleMouseDown)
  78. }
  79. }, [show, tooltipState, permissions.comment, view])
  80. const addComment = useCallback(() => {
  81. if (!permissions.comment) {
  82. return
  83. }
  84. let { main } = view.state.selection
  85. if (main.empty) {
  86. const tokenRange = selectHighlightedOrNearestToken(view.state)
  87. if (!tokenRange) {
  88. return
  89. }
  90. main = EditorSelection.range(tokenRange.from, tokenRange.to)
  91. }
  92. openReviewPanel()
  93. setView('cur_file')
  94. const effects = isCursorNearViewportEdge(view, main.anchor)
  95. ? [
  96. buildAddNewCommentRangeEffect(main),
  97. EditorView.scrollIntoView(main.anchor, { y: 'center' }),
  98. ]
  99. : [buildAddNewCommentRangeEffect(main)]
  100. view.dispatch({
  101. selection: { anchor: main.anchor, head: main.head },
  102. effects,
  103. })
  104. setShow(false)
  105. }, [view, permissions.comment, openReviewPanel, setView])
  106. useEventListener('add-new-review-comment', addComment)
  107. if (!permissions.comment || !show || !tooltipState) {
  108. return null
  109. }
  110. const tooltipView = getTooltip(view, tooltipState)
  111. if (!tooltipView) {
  112. return null
  113. }
  114. return ReactDOM.createPortal(
  115. <ReviewTooltipMenuContent onAddComment={addComment} />,
  116. tooltipView.dom
  117. )
  118. }
  119. const ReviewTooltipMenuContent = memo<{ onAddComment: () => void }>(
  120. function ReviewTooltipMenuContent({ onAddComment }) {
  121. const { t } = useTranslation()
  122. const view = useCodeMirrorViewContext()
  123. const state = useCodeMirrorStateContext()
  124. const { reviewPanelOpen } = useLayoutContext()
  125. const ranges = useRangesContext()
  126. const { acceptChanges, rejectChanges } = useRangesActionsContext()
  127. const { showGenericConfirmModal } = useModalsContext()
  128. const { wantTrackChanges } = useEditorPropertiesContext()
  129. const [tooltipStyle, setTooltipStyle] = useState<
  130. CSSProperties | undefined
  131. >()
  132. const [visible, setVisible] = useState(false)
  133. const handleAddCommentClick = useCallback(() => {
  134. sendMB('add-comment', {
  135. location: 'tooltip',
  136. })
  137. onAddComment()
  138. }, [onAddComment])
  139. const changesInSelection = useMemo(() => {
  140. return (ranges?.changes ?? []).filter(({ op }) => {
  141. const opFrom = op.p
  142. const opLength = isInsertOperation(op) ? op.i.length : 0
  143. const opTo = opFrom + opLength
  144. const selection = state.selection.main
  145. return opFrom >= selection.from && opTo <= selection.to
  146. })
  147. }, [ranges, state.selection.main])
  148. const acceptChangesHandler = useCallback(() => {
  149. const nChanges = numberOfChangesInSelection(
  150. ranges,
  151. view.state.selection.main
  152. )
  153. showGenericConfirmModal({
  154. message: t('confirm_accept_selected_changes', { count: nChanges }),
  155. title: t('accept_selected_changes'),
  156. onConfirm: async () => {
  157. await acceptChanges(...changesInSelection)
  158. },
  159. primaryVariant: 'danger',
  160. })
  161. }, [
  162. acceptChanges,
  163. changesInSelection,
  164. ranges,
  165. showGenericConfirmModal,
  166. view,
  167. t,
  168. ])
  169. const rejectChangesHandler = useCallback(() => {
  170. const nChanges = numberOfChangesInSelection(
  171. ranges,
  172. view.state.selection.main
  173. )
  174. showGenericConfirmModal({
  175. message: t('confirm_reject_selected_changes', { count: nChanges }),
  176. title: t('reject_selected_changes'),
  177. onConfirm: async () => {
  178. await rejectChanges(...changesInSelection)
  179. },
  180. primaryVariant: 'danger',
  181. })
  182. }, [
  183. showGenericConfirmModal,
  184. t,
  185. ranges,
  186. view,
  187. rejectChanges,
  188. changesInSelection,
  189. ])
  190. const showChangesButtons = changesInSelection.length > 0
  191. useEffect(() => {
  192. view.requestMeasure({
  193. key: 'review-tooltip-outside-viewport',
  194. read(view) {
  195. const cursorCoords = view.coordsAtPos(view.state.selection.main.head)
  196. if (!cursorCoords) {
  197. return
  198. }
  199. const scrollDomRect = view.scrollDOM.getBoundingClientRect()
  200. const contentDomRect = view.contentDOM.getBoundingClientRect()
  201. const editorRightPos = contentDomRect.right - CM_LINE_RIGHT_PADDING
  202. if (
  203. cursorCoords.top > scrollDomRect.top &&
  204. cursorCoords.top < scrollDomRect.bottom
  205. ) {
  206. return
  207. }
  208. return {
  209. position: 'fixed' as const,
  210. top: scrollDomRect.top + EDIT_MODE_SWITCH_WIDGET_HEIGHT,
  211. right: window.innerWidth - editorRightPos,
  212. }
  213. },
  214. write(res) {
  215. setTooltipStyle(res)
  216. },
  217. })
  218. }, [view, reviewPanelOpen, wantTrackChanges])
  219. useEffect(() => {
  220. setVisible(false)
  221. const timeout = setTimeout(() => {
  222. setVisible(true)
  223. }, TOOLTIP_SHOW_DELAY)
  224. return () => {
  225. clearTimeout(timeout)
  226. }
  227. }, [])
  228. return (
  229. <div
  230. className={classNames('review-tooltip-menu', {
  231. 'review-tooltip-menu-visible': visible,
  232. })}
  233. style={tooltipStyle}
  234. >
  235. <button
  236. className="review-tooltip-menu-button review-tooltip-add-comment-button"
  237. onClick={handleAddCommentClick}
  238. aria-label={t('add_comment')}
  239. >
  240. <MaterialIcon type="chat" />
  241. {t('add_comment')}
  242. </button>
  243. {showChangesButtons && (
  244. <>
  245. <div className="review-tooltip-menu-divider" />
  246. <OLTooltip
  247. id="accept-all-changes"
  248. description={t('accept_selected_changes')}
  249. >
  250. <button
  251. className="review-tooltip-menu-button"
  252. onClick={acceptChangesHandler}
  253. aria-label={t('accept_selected_changes')}
  254. >
  255. <MaterialIcon type="check" />
  256. </button>
  257. </OLTooltip>
  258. <OLTooltip
  259. id="reject-all-changes"
  260. description={t('reject_selected_changes')}
  261. >
  262. <button
  263. className="review-tooltip-menu-button"
  264. onClick={rejectChangesHandler}
  265. aria-label={t('reject_selected_changes')}
  266. >
  267. <MaterialIcon type="clear" />
  268. </button>
  269. </OLTooltip>
  270. </>
  271. )}
  272. </div>
  273. )
  274. }
  275. )
  276. export default ReviewTooltipMenu