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
This commit is contained in:
David 2024-09-16 09:40:59 +01:00 committed by Copybot
parent 9cf94e57d3
commit a323f3af75
11 changed files with 523 additions and 98 deletions

View file

@ -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

View file

@ -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 submitForm = useCallback(
message => {
setSubmitting(true)
const { from, to } = view.state.selection.main
const content = view.state.sliceDoc(from, to)
const formData = new FormData(event.target as HTMLFormElement)
const message = formData.get('message') as string
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 (
<ReviewPanelEntry
top={top}
position={from}
op={{
p: from,
c: state.sliceDoc(from, to),
t: value.spec.id as ThreadId,
}}
selectLineOnFocus={false}
>
<form
className="review-panel-entry-content"
onBlur={handleBlur}
onSubmit={handleSubmit}
className="review-panel-add-comment-form"
ref={handleElement}
>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<textarea name="message" rows={3} autoFocus />
<button type="submit">{t('comment')}</button>
<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>
)
}

View file

@ -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,11 +18,11 @@ 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(() => {
const handleSubmitReply = useCallback(
(content: string, setContent: Dispatch<SetStateAction<string>>) => {
setSubmitting(true)
addMessage(comment.op.t, content)
.then(() => {
@ -33,16 +34,12 @@ export const ReviewPanelCommentContent = memo<{
.finally(() => {
setSubmitting(false)
})
}, [addMessage, comment.op.t, content])
},
[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}

View file

@ -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 />}

View file

@ -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
if (selectLineOnFocus) {
view.dispatch({
selection: EditorSelection.cursor(position),
effects: EditorView.scrollIntoView(position, { y: 'center' }),
})
}
}, 0)
setFocused(true)
}, [view, position])
}, [view, position, selectLineOnFocus])
return (
<div

View file

@ -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 }
}

View file

@ -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} />

View file

@ -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',
},
},
},
})

View file

@ -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),

View file

@ -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,

View file

@ -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;
}