Explorar el Código

Implement a floating "Add comment" button for the redesigned review panel (#19891)

* Implement floating Add comment button

* Fix comment typo

* Remove unused imports

* Make tooltip always appear above cursor

Co-authored-by: Domagoj Kriskovic <dom.kriskovic@overleaf.com>

* Refactor how new comment form is positioned

* Add missing file

* Create new map when rendering positions

* Use codemirror state to manage ranges and allow for mutliple in-progress comments

* Memoise sorting

* Create new ranges map each time it is changed

* Add back mutation observer

* Only allow single tooltip

* Fix typo

* Convert state field to store a single tooltip

* Make add comment tooltip content a react component

* Refactor to remove usages of !important

* Use RangeSet to keep track of new comment ranges

* Fix logic broken in rebase

* Map ranges through document changes

* Add decorations for in-progress comments

* Use set-review-panel-open rather than an editor event to open review panel

* Implement new designs for add comment form

* Add padding to textarea

* Fix bug where comment was being submitted for incorrect range

* Add missing key to ReviewPanelAddComment

* Store new comment ranges as a DecorationSet

* Small refactor to how ReviewPanelAddCommens are rendered

* Make op prop to ReviewPanelEntry required

* Add handling for disabling of add comemnt form buttons

* Move viewer check inside AddCommentTooltip

* Ensure that add comment button doesn't reshow when collaborators edit the document

* Remove unneeded op check in ReviewPanelEntry

* Update services/web/frontend/js/features/review-panel-new/components/review-panel-add-comment.tsx

Co-authored-by: Domagoj Kriskovic <dom.kriskovic@overleaf.com>

---------

Co-authored-by: Domagoj Kriskovic <dom.kriskovic@overleaf.com>
GitOrigin-RevId: 3110845f6a557310f3bf72014689e2f2ab53e966
David hace 1 año
padre
commit
a323f3af75

+ 76 - 0
services/web/frontend/js/features/review-panel-new/components/add-comment-tooltip.tsx

@@ -0,0 +1,76 @@
+import { Dispatch, FC, SetStateAction, useEffect, 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-editor'
+import {
+  addCommentStateField,
+  buildAddNewCommentRangeEffect,
+} from '@/features/source-editor/extensions/add-comment'
+import { getTooltip } from '@codemirror/view'
+import useViewerPermissions from '@/shared/hooks/use-viewer-permissions'
+import usePreviousValue from '@/shared/hooks/use-previous-value'
+
+const AddCommentTooltip: FC = () => {
+  const state = useCodeMirrorStateContext()
+  const view = useCodeMirrorViewContext()
+  const isViewer = useViewerPermissions()
+  const [show, setShow] = useState(true)
+
+  const tooltipState = state.field(addCommentStateField, false)?.tooltip
+  const previousTooltipState = usePreviousValue(tooltipState)
+
+  useEffect(() => {
+    if (tooltipState !== null && previousTooltipState === null) {
+      setShow(true)
+    }
+  }, [tooltipState, previousTooltipState])
+
+  if (isViewer || !show || !tooltipState) {
+    return null
+  }
+
+  const tooltipView = getTooltip(view, tooltipState)
+
+  if (!tooltipView) {
+    return null
+  }
+
+  return ReactDOM.createPortal(
+    <AddCommentTooltipContent setShow={setShow} />,
+    tooltipView.dom
+  )
+}
+
+const AddCommentTooltipContent: FC<{
+  setShow: Dispatch<SetStateAction<boolean>>
+}> = ({ setShow }) => {
+  const { t } = useTranslation()
+  const view = useCodeMirrorViewContext()
+  const state = useCodeMirrorStateContext()
+
+  const handleClick = () => {
+    window.dispatchEvent(
+      new CustomEvent<{ isOpen: boolean }>('set-review-panel-open', {
+        detail: { isOpen: true },
+      })
+    )
+
+    view.dispatch({
+      effects: buildAddNewCommentRangeEffect(state.selection.main),
+    })
+    setShow(false)
+  }
+
+  return (
+    <button className="review-panel-add-comment-tooltip" onClick={handleClick}>
+      <MaterialIcon type="chat" />
+      {t('add_comment')}
+    </button>
+  )
+}
+
+export default AddCommentTooltip

+ 132 - 29
services/web/frontend/js/features/review-panel-new/components/review-panel-add-comment.tsx

@@ -1,4 +1,4 @@
-import { FC, FormEventHandler, useCallback, useState } from 'react'
+import { FC, FormEventHandler, useCallback, useState, useRef } from 'react'
 import {
   useCodeMirrorStateContext,
   useCodeMirrorViewContext,
@@ -6,55 +6,158 @@ import {
 import { EditorSelection } from '@codemirror/state'
 import { useTranslation } from 'react-i18next'
 import { useThreadsActionsContext } from '../context/threads-context'
+import { removeNewCommentRangeEffect } from '@/features/source-editor/extensions/add-comment'
+import useSubmittableTextInput from '../hooks/use-submittable-text-input'
+import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
+import { Button } from 'react-bootstrap'
+import { ReviewPanelEntry } from './review-panel-entry'
+import { ThreadId } from '../../../../../types/review-panel/review-panel'
+import { Decoration } from '@codemirror/view'
 
-export const ReviewPanelAddComment: FC = () => {
+export const ReviewPanelAddComment: FC<{
+  from: number
+  to: number
+  value: Decoration
+  top: number | undefined
+}> = ({ from, to, value, top }) => {
   const { t } = useTranslation()
   const view = useCodeMirrorViewContext()
-  // eslint-disable-next-line no-unused-vars
-  const _state = useCodeMirrorStateContext()
+  const state = useCodeMirrorStateContext()
   const { addComment } = useThreadsActionsContext()
   const [error, setError] = useState<Error>()
-  const [showForm, setShowForm] = useState(false)
+  const [submitting, setSubmitting] = useState(false)
 
-  const handleSubmit = useCallback<FormEventHandler>(
-    event => {
-      event.preventDefault()
+  const handleClose = useCallback(() => {
+    view.dispatch({
+      effects: removeNewCommentRangeEffect.of(value),
+    })
+  }, [view, value])
 
-      const { from, to } = view.state.selection.main
-      const content = view.state.sliceDoc(from, to)
+  const submitForm = useCallback(
+    message => {
+      setSubmitting(true)
 
-      const formData = new FormData(event.target as HTMLFormElement)
-      const message = formData.get('message') as string
+      const content = view.state.sliceDoc(from, to)
 
-      addComment(from, content, message).catch(setError)
+      addComment(from, content, message)
+        .catch(setError)
+        .finally(() => setSubmitting(false))
 
       view.dispatch({
         selection: EditorSelection.cursor(view.state.selection.main.anchor),
       })
+
+      handleClose()
     },
-    [addComment, view]
+    [addComment, view, handleClose, from, to]
   )
 
-  const handleElement = useCallback((element: HTMLElement | null) => {
-    if (element) {
-      element.dispatchEvent(new Event('review-panel:position'))
+  const { handleChange, handleKeyPress, content } =
+    useSubmittableTextInput(submitForm)
+
+  const handleBlur = useCallback(() => {
+    if (content === '') {
+      handleClose()
+    }
+  }, [content, handleClose])
+
+  const handleSubmit = useCallback<FormEventHandler>(
+    event => {
+      event.preventDefault()
+      submitForm(content)
+    },
+    [submitForm, content]
+  )
+
+  // We only ever want to focus the element once
+  const hasBeenFocused = useRef(false)
+
+  // Auto-focus the textarea once the element has been correctly positioned.
+  // We cannot use the autofocus attribute as we need to wait until the parent element
+  // has been positioned (with the "top" attribute) to avoid scrolling to the initial
+  // position of the element
+  const observerCallback = useCallback(mutationList => {
+    if (hasBeenFocused.current) {
+      return
+    }
+
+    for (const mutation of mutationList) {
+      if (mutation.target.style.top) {
+        const textArea = mutation.target.getElementsByTagName('textarea')[0]
+        if (textArea) {
+          textArea.focus()
+          hasBeenFocused.current = true
+        }
+      }
     }
   }, [])
 
-  if (!showForm) {
-    return <button onClick={() => setShowForm(true)}>{t('add_comment')}</button>
-  }
+  const handleElement = useCallback(
+    (element: HTMLElement | null) => {
+      if (element) {
+        element.dispatchEvent(new Event('review-panel:position'))
+
+        const observer = new MutationObserver(observerCallback)
+        const entryWrapper = element.closest('.review-panel-entry')
+        if (entryWrapper) {
+          observer.observe(entryWrapper, {
+            attributes: true,
+            attributeFilter: ['style'],
+          })
+          return () => observer.disconnect()
+        }
+      }
+    },
+    [observerCallback]
+  )
 
   return (
-    <form
-      onSubmit={handleSubmit}
-      className="review-panel-add-comment-form"
-      ref={handleElement}
+    <ReviewPanelEntry
+      top={top}
+      position={from}
+      op={{
+        p: from,
+        c: state.sliceDoc(from, to),
+        t: value.spec.id as ThreadId,
+      }}
+      selectLineOnFocus={false}
     >
-      {/* eslint-disable-next-line jsx-a11y/no-autofocus */}
-      <textarea name="message" rows={3} autoFocus />
-      <button type="submit">{t('comment')}</button>
-      {error && <div>{error.message}</div>}
-    </form>
+      <form
+        className="review-panel-entry-content"
+        onBlur={handleBlur}
+        onSubmit={handleSubmit}
+        ref={handleElement}
+      >
+        <AutoExpandingTextArea
+          name="message"
+          className="review-panel-add-comment-textarea"
+          onChange={handleChange}
+          onKeyDown={handleKeyPress}
+          placeholder={t('add_your_comment_here')}
+          value={content}
+          disabled={submitting}
+        />
+        <div className="review-panel-add-comment-buttons">
+          <Button
+            bsSize="sm"
+            bsStyle={null}
+            className="review-panel-add-comment-cancel-button"
+            disabled={submitting}
+            onClick={handleClose}
+          >
+            {t('cancel')}
+          </Button>
+          <Button
+            type="submit"
+            bsSize="sm"
+            className="btn-primary"
+            disabled={content === '' || submitting}
+          >
+            {t('comment')}
+          </Button>
+        </div>
+        {error && <div>{error.message}</div>}
+      </form>
+    </ReviewPanelEntry>
   )
 }

+ 22 - 25
services/web/frontend/js/features/review-panel-new/components/review-panel-comment-content.tsx

@@ -1,4 +1,4 @@
-import { memo, useCallback, useState } from 'react'
+import { Dispatch, memo, SetStateAction, useCallback, useState } from 'react'
 import { Change, CommentOperation } from '../../../../../types/change'
 import { ReviewPanelMessage } from './review-panel-message'
 import { useTranslation } from 'react-i18next'
@@ -9,6 +9,7 @@ import {
 import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
 import ReviewPanelResolvedMessage from './review-panel-resolved-message'
 import { ReviewPanelResolvedCommentThread } from '../../../../../types/review-panel/comment-thread'
+import useSubmittableTextInput from '../hooks/use-submittable-text-input'
 
 export const ReviewPanelCommentContent = memo<{
   comment: Change<CommentOperation>
@@ -17,32 +18,28 @@ export const ReviewPanelCommentContent = memo<{
   const { t } = useTranslation()
   const [submitting, setSubmitting] = useState(false)
   const [error, setError] = useState<Error>()
-  const [content, setContent] = useState('')
   const threads = useThreadsContext()
   const { resolveThread, addMessage } = useThreadsActionsContext()
 
-  const handleSubmitReply = useCallback(() => {
-    setSubmitting(true)
-    addMessage(comment.op.t, content)
-      .then(() => {
-        setContent('')
-      })
-      .catch(error => {
-        setError(error)
-      })
-      .finally(() => {
-        setSubmitting(false)
-      })
-  }, [addMessage, comment.op.t, content])
+  const handleSubmitReply = useCallback(
+    (content: string, setContent: Dispatch<SetStateAction<string>>) => {
+      setSubmitting(true)
+      addMessage(comment.op.t, content)
+        .then(() => {
+          setContent('')
+        })
+        .catch(error => {
+          setError(error)
+        })
+        .finally(() => {
+          setSubmitting(false)
+        })
+    },
+    [addMessage, comment.op.t]
+  )
 
-  const handleCommentReplyKeyPress = (
-    e: React.KeyboardEvent<HTMLTextAreaElement>
-  ) => {
-    if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
-      e.preventDefault()
-      handleSubmitReply()
-    }
-  }
+  const { handleChange, handleKeyPress, content } =
+    useSubmittableTextInput(handleSubmitReply)
 
   const thread = threads?.[comment.op.t]
   if (!thread) {
@@ -82,8 +79,8 @@ export const ReviewPanelCommentContent = memo<{
         <AutoExpandingTextArea
           name="content"
           className="review-panel-comment-input"
-          onChange={e => setContent(e.target.value)}
-          onKeyDown={handleCommentReplyKeyPress}
+          onChange={handleChange}
+          onKeyDown={handleKeyPress}
           placeholder={t('reply')}
           value={content}
           disabled={submitting}

+ 65 - 31
services/web/frontend/js/features/review-panel-new/components/review-panel-current-file.tsx

@@ -3,9 +3,9 @@ import {
   memo,
   useCallback,
   useEffect,
-  useMemo,
   useRef,
   useState,
+  useMemo,
 } from 'react'
 import { ReviewPanelAddComment } from './review-panel-add-comment'
 import { ReviewPanelChange } from './review-panel-change'
@@ -23,12 +23,12 @@ import {
 import { useRangesContext } from '../context/ranges-context'
 import { useThreadsContext } from '../context/threads-context'
 import { isDeleteChange, isInsertChange } from '@/utils/operations'
-import Icon from '@/shared/components/icon'
 import { positionItems } from '../utils/position-items'
 import { canAggregate } from '../utils/can-aggregate'
 import ReviewPanelEmptyState from './review-panel-empty-state'
 import useEventListener from '@/shared/hooks/use-event-listener'
 import { hasActiveRange } from '@/features/review-panel-new/utils/has-active-range'
+import { addCommentStateField } from '@/features/source-editor/extensions/add-comment'
 
 type AggregatedRanges = {
   changes: Change<EditOperation>[]
@@ -44,14 +44,6 @@ const ReviewPanelCurrentFile: FC = () => {
 
   const [aggregatedRanges, setAggregatedRanges] = useState<AggregatedRanges>()
 
-  const selectionCoords = useMemo(
-    () =>
-      state.selection.main.empty
-        ? null
-        : view.coordsAtPos(state.selection.main.head),
-    [view, state]
-  )
-
   const containerRef = useRef<HTMLDivElement | null>(null)
   const previousFocusedItem = useRef(0)
 
@@ -149,6 +141,8 @@ const ReviewPanelCurrentFile: FC = () => {
 
   const positionsRef = useRef<Map<string, number>>(new Map())
 
+  const addCommentRanges = state.field(addCommentStateField).ranges
+
   useEffect(() => {
     if (aggregatedRanges) {
       view.requestMeasure({
@@ -157,29 +151,45 @@ const ReviewPanelCurrentFile: FC = () => {
           const contentRect = view.contentDOM.getBoundingClientRect()
           const docLength = view.state.doc.length
 
-          const screenPosition = (change: Change): number | undefined => {
-            const pos = Math.min(change.op.p, docLength) // TODO: needed?
+          const screenPosition = (position: number): number | undefined => {
+            const pos = Math.min(position, docLength) // TODO: needed?
             const coords = view.coordsAtPos(pos)
 
             return coords ? Math.round(coords.top - contentRect.top) : undefined
           }
 
           for (const change of aggregatedRanges.changes) {
-            const position = screenPosition(change)
+            const position = screenPosition(change.op.p)
             if (position) {
               positionsRef.current.set(change.id, position)
             }
           }
 
           for (const comment of aggregatedRanges.comments) {
-            const position = screenPosition(comment)
+            const position = screenPosition(comment.op.p)
             if (position) {
               positionsRef.current.set(comment.id, position)
             }
           }
+
+          const cursor = addCommentRanges.iter()
+
+          while (cursor.value) {
+            const { from } = cursor
+            const position = screenPosition(from)
+
+            if (position) {
+              positionsRef.current.set(
+                `new-comment-${cursor.value.spec.id}`,
+                position
+              )
+            }
+
+            cursor.next()
+          }
         },
         write() {
-          setPositions(positionsRef.current)
+          setPositions(new Map(positionsRef.current))
           window.setTimeout(() => {
             containerRef.current?.dispatchEvent(
               new Event('review-panel:position')
@@ -188,34 +198,58 @@ const ReviewPanelCurrentFile: FC = () => {
         },
       })
     }
-  }, [view, aggregatedRanges])
+  }, [view, aggregatedRanges, addCommentRanges])
 
   const showEmptyState = useMemo(
     () => hasActiveRange(ranges, threads) === false,
     [ranges, threads]
   )
 
+  const addCommentEntries = useMemo(() => {
+    const cursor = addCommentRanges.iter()
+
+    const entries = []
+
+    while (cursor.value) {
+      const id = `new-comment-${cursor.value.spec.id}`
+      if (!positions.has(id)) {
+        cursor.next()
+        continue
+      }
+
+      const { from, to } = cursor
+
+      entries.push({
+        id,
+        from,
+        to,
+        value: cursor.value,
+        top: positions.get(id),
+      })
+
+      cursor.next()
+    }
+    return entries
+  }, [addCommentRanges, positions])
+
   if (!aggregatedRanges) {
     return null
   }
 
   return (
     <div ref={handleContainer}>
-      {selectionCoords && (
-        <div
-          className="review-panel-entry review-panel-entry-action"
-          style={{ position: 'absolute' }}
-          data-top={selectionCoords.top + view.scrollDOM.scrollTop - 70}
-          data-pos={state.selection.main.head}
-        >
-          <div className="review-panel-entry-indicator">
-            <Icon type="pencil" fw />
-          </div>
-          <div className="review-panel-entry-content">
-            <ReviewPanelAddComment />
-          </div>
-        </div>
-      )}
+      {addCommentEntries.map(entry => {
+        const { id, from, to, value, top } = entry
+        return (
+          <ReviewPanelAddComment
+            key={id}
+            from={from}
+            to={to}
+            value={value}
+            top={top}
+          />
+        )
+      })}
 
       {showEmptyState && <ReviewPanelEmptyState />}
 

+ 9 - 6
services/web/frontend/js/features/review-panel-new/components/review-panel-entry.tsx

@@ -14,7 +14,8 @@ export const ReviewPanelEntry: FC<{
   op: AnyOperation
   top?: number
   className?: string
-}> = ({ children, position, top, op, className }) => {
+  selectLineOnFocus?: boolean
+}> = ({ children, position, top, op, className, selectLineOnFocus }) => {
   const state = useCodeMirrorStateContext()
   const view = useCodeMirrorViewContext()
   const [focused, setFocused] = useState(false)
@@ -25,13 +26,15 @@ export const ReviewPanelEntry: FC<{
     setTimeout(() => {
       // without setTimeout, error "EditorView.update are not allowed while an update is in progress" can occur
       // this can be avoided by using onClick rather than onFocus but it will then not pick up <Tab> or <Shift+Tab> events for focusing entries
-      view.dispatch({
-        selection: EditorSelection.cursor(position),
-        effects: EditorView.scrollIntoView(position, { y: 'center' }),
-      })
+      if (selectLineOnFocus) {
+        view.dispatch({
+          selection: EditorSelection.cursor(position),
+          effects: EditorView.scrollIntoView(position, { y: 'center' }),
+        })
+      }
     }, 0)
     setFocused(true)
-  }, [view, position])
+  }, [view, position, selectLineOnFocus])
 
   return (
     <div

+ 29 - 0
services/web/frontend/js/features/review-panel-new/hooks/use-submittable-text-input.ts

@@ -0,0 +1,29 @@
+import { useCallback, useState, Dispatch, SetStateAction } from 'react'
+
+export default function useSubmittableTextInput(
+  handleSubmit: (
+    content: string,
+    setContent: Dispatch<SetStateAction<string>>
+  ) => void
+) {
+  const [content, setContent] = useState('')
+
+  const handleKeyPress = useCallback(
+    (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+      if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
+        e.preventDefault()
+        handleSubmit(content, setContent)
+      }
+    },
+    [content, handleSubmit]
+  )
+
+  const handleChange = useCallback(
+    (e: React.ChangeEvent<HTMLTextAreaElement>) => {
+      setContent(e.target.value)
+    },
+    []
+  )
+
+  return { handleChange, handleKeyPress, content }
+}

+ 6 - 1
services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx

@@ -15,11 +15,12 @@ import { CodeMirrorToolbar } from './codemirror-toolbar'
 import { CodemirrorOutline } from './codemirror-outline'
 import { CodeMirrorCommandTooltip } from './codemirror-command-tooltip'
 import { dispatchTimer } from '../../../infrastructure/cm6-performance'
-
 import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
 import { FigureModal } from './figure-modal/figure-modal'
 import { ReviewPanelProviders } from '@/features/review-panel-new/context/review-panel-providers'
 import { ReviewPanelMigration } from '@/features/source-editor/components/review-panel/review-panel-migration'
+import AddCommentTooltip from '@/features/review-panel-new/components/add-comment-tooltip'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
 
 const sourceEditorComponents = importOverleafModules(
   'sourceEditorComponents'
@@ -37,6 +38,8 @@ function CodeMirrorEditor() {
 
   const isMounted = useIsMounted()
 
+  const newReviewPanel = useFeatureFlag('review-panel-redesign')
+
   // create the view using the initial state and intercept transactions
   const viewRef = useRef<EditorView | null>(null)
   if (viewRef.current === null) {
@@ -75,7 +78,9 @@ function CodeMirrorEditor() {
           )}
           <CodeMirrorCommandTooltip />
 
+          {newReviewPanel && <AddCommentTooltip />}
           <ReviewPanelMigration />
+
           {sourceEditorComponents.map(
             ({ import: { default: Component }, path }) => (
               <Component key={path} />

+ 134 - 0
services/web/frontend/js/features/source-editor/extensions/add-comment.ts

@@ -0,0 +1,134 @@
+import {
+  Decoration,
+  DecorationSet,
+  EditorView,
+  showTooltip,
+  Tooltip,
+} from '@codemirror/view'
+import {
+  EditorState,
+  Extension,
+  StateField,
+  StateEffect,
+  Range,
+  SelectionRange,
+} from '@codemirror/state'
+import { isSplitTestEnabled } from '@/utils/splitTestUtils'
+import { v4 as uuid } from 'uuid'
+
+export const addNewCommentRangeEffect = StateEffect.define<Range<Decoration>>()
+
+export const removeNewCommentRangeEffect = StateEffect.define<Decoration>()
+
+export const buildAddNewCommentRangeEffect = (range: SelectionRange) => {
+  return addNewCommentRangeEffect.of(
+    Decoration.mark({
+      tagName: 'span',
+      class: `ol-cm-change ol-cm-change-c`,
+      opType: 'c',
+      id: uuid(),
+    }).range(range.from, range.to)
+  )
+}
+
+export const addComment = (): Extension => {
+  if (!isSplitTestEnabled('review-panel-redesign')) {
+    return []
+  }
+
+  return [addCommentTheme, addCommentStateField]
+}
+
+export const addCommentStateField = StateField.define<{
+  tooltip: Tooltip | null
+  ranges: DecorationSet
+}>({
+  create() {
+    return { tooltip: null, ranges: Decoration.none }
+  },
+
+  update(field, tr) {
+    let { tooltip, ranges } = field
+
+    ranges = ranges.map(tr.changes)
+
+    for (const effect of tr.effects) {
+      if (effect.is(removeNewCommentRangeEffect)) {
+        const rangeToRemove = effect.value
+        ranges = ranges.update({
+          // eslint-disable-next-line no-unused-vars
+          filter: (from, to, value) => {
+            return value.spec.id !== rangeToRemove.spec.id
+          },
+        })
+      }
+
+      if (effect.is(addNewCommentRangeEffect)) {
+        const rangeToAdd = effect.value
+        ranges = ranges.update({
+          add: [rangeToAdd],
+        })
+      }
+    }
+
+    if (tr.docChanged || tr.selection) {
+      tooltip = buildTooltip(tr.state)
+    }
+
+    return { tooltip, ranges }
+  },
+
+  provide: field => [
+    EditorView.decorations.from(field, field => field.ranges),
+    showTooltip.compute([field], state => state.field(field).tooltip),
+  ],
+})
+
+function buildTooltip(state: EditorState): Tooltip | null {
+  const range = state.selection.main
+  if (range.empty) {
+    return null
+  }
+
+  return {
+    pos: range.assoc < 0 ? range.to : range.from,
+    above: true,
+    strictSide: true,
+    arrow: false,
+    create() {
+      const dom = document.createElement('div')
+      dom.className = 'review-panel-add-comment-tooltip-container'
+      return { dom, overlap: true, offset: { x: 0, y: 8 } }
+    },
+  }
+}
+
+/**
+ * Styles for the tooltip
+ */
+const addCommentTheme = EditorView.baseTheme({
+  '.review-panel-add-comment-tooltip-container.cm-tooltip': {
+    backgroundColor: 'transparent',
+    border: 'none',
+  },
+
+  '&light': {
+    '& .review-panel-add-comment-tooltip': {
+      backgroundColor: 'white',
+      border: '1px solid #e7e9ee',
+      '&:hover': {
+        backgroundColor: '#e7e9ee',
+      },
+    },
+  },
+
+  '&dark': {
+    '& .review-panel-add-comment-tooltip': {
+      backgroundColor: '#1b222c',
+      border: '1px solid #2f3a4c',
+      '&:hover': {
+        backgroundColor: '#2f3a4c',
+      },
+    },
+  },
+})

+ 2 - 0
services/web/frontend/js/features/source-editor/extensions/index.ts

@@ -52,6 +52,7 @@ import { mathPreview } from './math-preview'
 import { isSplitTestEnabled } from '@/utils/splitTestUtils'
 import { ranges } from './ranges'
 import { trackDetachedComments } from './track-detached-comments'
+import { addComment } from './add-comment'
 
 const moduleExtensions: Array<() => Extension> = importOverleafModules(
   'sourceEditorExtensions'
@@ -133,6 +134,7 @@ export const createExtensions = (options: Record<string, any>): Extension[] => [
   trackDetachedComments(options.currentDoc),
   visual(options.visual),
   mathPreview(options.settings.mathPreview),
+  addComment(),
   toolbarPanel(),
   verticalOverflow(),
   highlightActiveLine(options.visual.visual),

+ 12 - 0
services/web/frontend/js/shared/context/layout-context.tsx

@@ -16,6 +16,7 @@ import { DetachRole } from './detach-context'
 import { debugConsole } from '@/utils/debugging'
 import { BinaryFile } from '@/features/file-view/types/binary-file'
 import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter'
+import useEventListener from '../hooks/use-event-listener'
 
 export type IdeLayout = 'sideBySide' | 'flat'
 export type IdeView = 'editor' | 'file' | 'pdf' | 'history'
@@ -165,6 +166,17 @@ export const LayoutProvider: FC = ({ children }) => {
     changeLayout,
   ])
 
+  const handleSetReviewPanelOpenEvent = useCallback(
+    (e: Event) => {
+      const event = e as CustomEvent<{ isOpen: boolean }>
+      const { isOpen } = event.detail
+      setReviewPanelOpen(isOpen)
+    },
+    [setReviewPanelOpen]
+  )
+
+  useEventListener('set-review-panel-open', handleSetReviewPanelOpenEvent)
+
   const value = useMemo<LayoutContextValue>(
     () => ({
       reattach,

+ 36 - 6
services/web/frontend/stylesheets/app/editor/review-panel-new.less

@@ -349,12 +349,6 @@
     max-height: 400px;
   }
 
-  .review-panel-add-comment-form {
-    display: flex;
-    flex-direction: column;
-    width: var(--review-panel-width);
-  }
-
   .review-panel-empty-state {
     position: absolute;
     top: 0;
@@ -453,6 +447,33 @@
     display: flex;
   }
 
+  .review-panel-add-comment-textarea {
+    padding: 2px 6px;
+    resize: vertical;
+    min-height: 44px;
+  }
+
+  .review-panel-add-comment {
+    position: absolute;
+    z-index: 2;
+  }
+
+  .review-panel-add-comment-buttons {
+    display: flex;
+    justify-content: flex-end;
+    gap: 8px;
+  }
+
+  .review-panel-add-comment-cancel-button {
+    background-color: transparent;
+
+    &:hover,
+    &:focus {
+      background-color: @neutral-20;
+      color: @content-primary;
+    }
+  }
+
   &.review-panel-subview-overview {
     &.review-panel-container {
       overflow-y: hidden;
@@ -510,3 +531,12 @@
     }
   }
 }
+
+.review-panel-add-comment-tooltip {
+  height: 24px;
+  border-radius: 12px;
+  padding: 2px 12px;
+  display: flex;
+  align-items: center;
+  gap: 2px;
+}