mirror of
https://github.com/overleaf/overleaf.git
synced 2024-12-26 20:34:02 +00:00
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:
parent
9cf94e57d3
commit
a323f3af75
11 changed files with 523 additions and 98 deletions
|
@ -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
|
|
@ -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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 />}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
}
|
|
@ -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} />
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
|
@ -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),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue