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 { import {
useCodeMirrorStateContext, useCodeMirrorStateContext,
useCodeMirrorViewContext, useCodeMirrorViewContext,
@ -6,55 +6,158 @@ import {
import { EditorSelection } from '@codemirror/state' import { EditorSelection } from '@codemirror/state'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useThreadsActionsContext } from '../context/threads-context' 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 { t } = useTranslation()
const view = useCodeMirrorViewContext() const view = useCodeMirrorViewContext()
// eslint-disable-next-line no-unused-vars const state = useCodeMirrorStateContext()
const _state = useCodeMirrorStateContext()
const { addComment } = useThreadsActionsContext() const { addComment } = useThreadsActionsContext()
const [error, setError] = useState<Error>() const [error, setError] = useState<Error>()
const [showForm, setShowForm] = useState(false) const [submitting, setSubmitting] = useState(false)
const handleSubmit = useCallback<FormEventHandler>( const handleClose = useCallback(() => {
event => { view.dispatch({
event.preventDefault() 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 content = view.state.sliceDoc(from, to)
const formData = new FormData(event.target as HTMLFormElement) addComment(from, content, message)
const message = formData.get('message') as string .catch(setError)
.finally(() => setSubmitting(false))
addComment(from, content, message).catch(setError)
view.dispatch({ view.dispatch({
selection: EditorSelection.cursor(view.state.selection.main.anchor), selection: EditorSelection.cursor(view.state.selection.main.anchor),
}) })
handleClose()
}, },
[addComment, view] [addComment, view, handleClose, from, to]
) )
const handleElement = useCallback((element: HTMLElement | null) => { const { handleChange, handleKeyPress, content } =
if (element) { useSubmittableTextInput(submitForm)
element.dispatchEvent(new Event('review-panel:position'))
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) { const handleElement = useCallback(
return <button onClick={() => setShowForm(true)}>{t('add_comment')}</button> (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 ( return (
<ReviewPanelEntry
top={top}
position={from}
op={{
p: from,
c: state.sliceDoc(from, to),
t: value.spec.id as ThreadId,
}}
selectLineOnFocus={false}
>
<form <form
className="review-panel-entry-content"
onBlur={handleBlur}
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="review-panel-add-comment-form"
ref={handleElement} ref={handleElement}
> >
{/* eslint-disable-next-line jsx-a11y/no-autofocus */} <AutoExpandingTextArea
<textarea name="message" rows={3} autoFocus /> name="message"
<button type="submit">{t('comment')}</button> 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>} {error && <div>{error.message}</div>}
</form> </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 { Change, CommentOperation } from '../../../../../types/change'
import { ReviewPanelMessage } from './review-panel-message' import { ReviewPanelMessage } from './review-panel-message'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -9,6 +9,7 @@ import {
import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area' import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
import ReviewPanelResolvedMessage from './review-panel-resolved-message' import ReviewPanelResolvedMessage from './review-panel-resolved-message'
import { ReviewPanelResolvedCommentThread } from '../../../../../types/review-panel/comment-thread' import { ReviewPanelResolvedCommentThread } from '../../../../../types/review-panel/comment-thread'
import useSubmittableTextInput from '../hooks/use-submittable-text-input'
export const ReviewPanelCommentContent = memo<{ export const ReviewPanelCommentContent = memo<{
comment: Change<CommentOperation> comment: Change<CommentOperation>
@ -17,11 +18,11 @@ export const ReviewPanelCommentContent = memo<{
const { t } = useTranslation() const { t } = useTranslation()
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<Error>() const [error, setError] = useState<Error>()
const [content, setContent] = useState('')
const threads = useThreadsContext() const threads = useThreadsContext()
const { resolveThread, addMessage } = useThreadsActionsContext() const { resolveThread, addMessage } = useThreadsActionsContext()
const handleSubmitReply = useCallback(() => { const handleSubmitReply = useCallback(
(content: string, setContent: Dispatch<SetStateAction<string>>) => {
setSubmitting(true) setSubmitting(true)
addMessage(comment.op.t, content) addMessage(comment.op.t, content)
.then(() => { .then(() => {
@ -33,16 +34,12 @@ export const ReviewPanelCommentContent = memo<{
.finally(() => { .finally(() => {
setSubmitting(false) setSubmitting(false)
}) })
}, [addMessage, comment.op.t, content]) },
[addMessage, comment.op.t]
)
const handleCommentReplyKeyPress = ( const { handleChange, handleKeyPress, content } =
e: React.KeyboardEvent<HTMLTextAreaElement> useSubmittableTextInput(handleSubmitReply)
) => {
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault()
handleSubmitReply()
}
}
const thread = threads?.[comment.op.t] const thread = threads?.[comment.op.t]
if (!thread) { if (!thread) {
@ -82,8 +79,8 @@ export const ReviewPanelCommentContent = memo<{
<AutoExpandingTextArea <AutoExpandingTextArea
name="content" name="content"
className="review-panel-comment-input" className="review-panel-comment-input"
onChange={e => setContent(e.target.value)} onChange={handleChange}
onKeyDown={handleCommentReplyKeyPress} onKeyDown={handleKeyPress}
placeholder={t('reply')} placeholder={t('reply')}
value={content} value={content}
disabled={submitting} disabled={submitting}

View file

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

View file

@ -14,7 +14,8 @@ export const ReviewPanelEntry: FC<{
op: AnyOperation op: AnyOperation
top?: number top?: number
className?: string className?: string
}> = ({ children, position, top, op, className }) => { selectLineOnFocus?: boolean
}> = ({ children, position, top, op, className, selectLineOnFocus }) => {
const state = useCodeMirrorStateContext() const state = useCodeMirrorStateContext()
const view = useCodeMirrorViewContext() const view = useCodeMirrorViewContext()
const [focused, setFocused] = useState(false) const [focused, setFocused] = useState(false)
@ -25,13 +26,15 @@ export const ReviewPanelEntry: FC<{
setTimeout(() => { setTimeout(() => {
// without setTimeout, error "EditorView.update are not allowed while an update is in progress" can occur // 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 // 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({ view.dispatch({
selection: EditorSelection.cursor(position), selection: EditorSelection.cursor(position),
effects: EditorView.scrollIntoView(position, { y: 'center' }), effects: EditorView.scrollIntoView(position, { y: 'center' }),
}) })
}
}, 0) }, 0)
setFocused(true) setFocused(true)
}, [view, position]) }, [view, position, selectLineOnFocus])
return ( return (
<div <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 { CodemirrorOutline } from './codemirror-outline'
import { CodeMirrorCommandTooltip } from './codemirror-command-tooltip' import { CodeMirrorCommandTooltip } from './codemirror-command-tooltip'
import { dispatchTimer } from '../../../infrastructure/cm6-performance' import { dispatchTimer } from '../../../infrastructure/cm6-performance'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro' import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import { FigureModal } from './figure-modal/figure-modal' import { FigureModal } from './figure-modal/figure-modal'
import { ReviewPanelProviders } from '@/features/review-panel-new/context/review-panel-providers' import { ReviewPanelProviders } from '@/features/review-panel-new/context/review-panel-providers'
import { ReviewPanelMigration } from '@/features/source-editor/components/review-panel/review-panel-migration' 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( const sourceEditorComponents = importOverleafModules(
'sourceEditorComponents' 'sourceEditorComponents'
@ -37,6 +38,8 @@ function CodeMirrorEditor() {
const isMounted = useIsMounted() const isMounted = useIsMounted()
const newReviewPanel = useFeatureFlag('review-panel-redesign')
// create the view using the initial state and intercept transactions // create the view using the initial state and intercept transactions
const viewRef = useRef<EditorView | null>(null) const viewRef = useRef<EditorView | null>(null)
if (viewRef.current === null) { if (viewRef.current === null) {
@ -75,7 +78,9 @@ function CodeMirrorEditor() {
)} )}
<CodeMirrorCommandTooltip /> <CodeMirrorCommandTooltip />
{newReviewPanel && <AddCommentTooltip />}
<ReviewPanelMigration /> <ReviewPanelMigration />
{sourceEditorComponents.map( {sourceEditorComponents.map(
({ import: { default: Component }, path }) => ( ({ import: { default: Component }, path }) => (
<Component key={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 { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { ranges } from './ranges' import { ranges } from './ranges'
import { trackDetachedComments } from './track-detached-comments' import { trackDetachedComments } from './track-detached-comments'
import { addComment } from './add-comment'
const moduleExtensions: Array<() => Extension> = importOverleafModules( const moduleExtensions: Array<() => Extension> = importOverleafModules(
'sourceEditorExtensions' 'sourceEditorExtensions'
@ -133,6 +134,7 @@ export const createExtensions = (options: Record<string, any>): Extension[] => [
trackDetachedComments(options.currentDoc), trackDetachedComments(options.currentDoc),
visual(options.visual), visual(options.visual),
mathPreview(options.settings.mathPreview), mathPreview(options.settings.mathPreview),
addComment(),
toolbarPanel(), toolbarPanel(),
verticalOverflow(), verticalOverflow(),
highlightActiveLine(options.visual.visual), highlightActiveLine(options.visual.visual),

View file

@ -16,6 +16,7 @@ import { DetachRole } from './detach-context'
import { debugConsole } from '@/utils/debugging' import { debugConsole } from '@/utils/debugging'
import { BinaryFile } from '@/features/file-view/types/binary-file' import { BinaryFile } from '@/features/file-view/types/binary-file'
import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter' import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter'
import useEventListener from '../hooks/use-event-listener'
export type IdeLayout = 'sideBySide' | 'flat' export type IdeLayout = 'sideBySide' | 'flat'
export type IdeView = 'editor' | 'file' | 'pdf' | 'history' export type IdeView = 'editor' | 'file' | 'pdf' | 'history'
@ -165,6 +166,17 @@ export const LayoutProvider: FC = ({ children }) => {
changeLayout, 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>( const value = useMemo<LayoutContextValue>(
() => ({ () => ({
reattach, reattach,

View file

@ -349,12 +349,6 @@
max-height: 400px; max-height: 400px;
} }
.review-panel-add-comment-form {
display: flex;
flex-direction: column;
width: var(--review-panel-width);
}
.review-panel-empty-state { .review-panel-empty-state {
position: absolute; position: absolute;
top: 0; top: 0;
@ -453,6 +447,33 @@
display: flex; 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-subview-overview {
&.review-panel-container { &.review-panel-container {
overflow-y: hidden; 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;
}