Add review panel context providers and components (#19490)

* Tidy up review panel components

* Add ReviewPanel providers

* [web] new design for review panel track change (#19544)

* [web] new design for review panel track change

* fixed mini view

* mini icon style change

* fix icon size

* format date

* useRangesUserContext hook

* remove useRangesUserContext hook

* using full class names

* fix action icons hover

* change wording for tooltips

* added ReviewPanelChangeUser component

* Update header in new review panel

* Extract ReviewPanelTrackChangesMenuButton as a separate component

* Remove wrapper div

* Replace h2 with div for review panel label

* Rename ReviewPanelTools to ReviewPanelHeader

* Rename trackChangesExpanded -> trackChangesMenuExpanded

* Dont break memoisation of ReviewPanelTrackChangesMenuButton

* Fix the width of the track changes arrow icon

* Update how prop types are declared

* Remove new empty state from old review panel

* Add empty state to new review panel

* Add project members and owner to ChangesUsers context (#19624)

---------

Co-authored-by: Alf Eaton <alf.eaton@overleaf.com>

* Redesign comment entry in review panel (#19678)

* Redesign comment entry in review panel

* ReviewPanelCommentOptions component

* remove unused prop

* Tidying

* Add conditional import

* Optional changeManager

* Add more split test compatibility

* More split test compatibility

* Fixes

* Improve overview scrolling

* Fix overview scrolling

* Fix & simplify track changes toggle

* Fix overview scrolling

* Fix current file container

* ExpandableContent component for messages in review panel (#19738)

* ExpandableContent component for messages in review panel

* remove isExpanded dependancy

* Delete comment option for new review panel (#19772)

* Delete comment option for new review panel

* dont show thread warning if there are no replies

* fix hasReplies issue

* Implement initial collapsing overview files

* Fix positioning of overview panel

* Small styling changes

* Add count of unresolved comments and tracked chanegs

* More style adjustments

* Move review-panel-overview styles into css file

* Remove unused var

---------

Co-authored-by: Domagoj Kriskovic <dom.kriskovic@overleaf.com>
Co-authored-by: David Powell <david.powell@overleaf.com>
Co-authored-by: David <33458145+davidmcpowell@users.noreply.github.com>
GitOrigin-RevId: e67463443d541f88445a86eed5e2b6ec6040f9c7
This commit is contained in:
Alf Eaton 2024-08-12 10:50:54 +01:00 committed by Copybot
parent 8736bee460
commit 2304536844
55 changed files with 2970 additions and 211 deletions

View file

@ -20,6 +20,7 @@
"accept": "",
"accept_all": "",
"accept_and_continue": "",
"accept_change": "",
"accept_invitation": "",
"accept_or_reject_each_changes_individually": "",
"accept_terms_and_conditions": "",
@ -281,6 +282,10 @@
"delete_authentication_token": "",
"delete_authentication_token_info": "",
"delete_certificate": "",
"delete_comment": "",
"delete_comment_message": "",
"delete_comment_thread": "",
"delete_comment_thread_message": "",
"delete_figure": "",
"delete_projects": "",
"delete_row_or_column": "",
@ -822,6 +827,7 @@
"more": "",
"more_actions": "",
"more_info": "",
"more_options": "",
"more_options_for_border_settings_coming_soon": "",
"my_library": "",
"n_items": "",
@ -1080,6 +1086,7 @@
"regards": "",
"reject": "",
"reject_all": "",
"reject_change": "",
"relink_your_account": "",
"reload_editor": "",
"remind_before_trial_ends": "",
@ -1118,6 +1125,7 @@
"resending_confirmation_email": "",
"resize": "",
"resolve": "",
"resolve_comment": "",
"resolved_comments": "",
"restore": "",
"restore_file": "",
@ -1242,6 +1250,7 @@
"show_in_pdf": "",
"show_less": "",
"show_local_file_contents": "",
"show_more": "",
"show_outline": "",
"show_x_more_projects": "",
"showing_1_result": "",
@ -1479,6 +1488,7 @@
"track_any_change_in_real_time": "",
"track_changes": "",
"track_changes_for_everyone": "",
"track_changes_for_guests": "",
"track_changes_for_x": "",
"track_changes_is_off": "",
"track_changes_is_on": "",

View file

@ -67,7 +67,7 @@ const ChatPane = React.memo(function ChatPane() {
throw error
}
if (!user) {
if (!user?.id) {
return null
}
if (!chatOpenedOnce) {

View file

@ -225,7 +225,7 @@ function AllHistoryList() {
showDivider={showDivider}
setSelection={setSelection}
selectionState={selectionState}
currentUserId={currentUserId}
currentUserId={currentUserId!}
selectable={selectable}
projectId={projectId}
setActiveDropdownItem={setActiveDropdownItem}

View file

@ -33,7 +33,7 @@ function LabelsList() {
key={version}
labels={labels}
version={version}
currentUserId={currentUserId}
currentUserId={currentUserId!}
projectId={projectId}
selectionState={selectionState}
selectable={selectionState !== 'selected'}

View file

@ -282,7 +282,7 @@ function useReviewPanelState(): ReviewPanel.ReviewPanelState {
const tempUsers = {} as ReviewPanel.Value<'users'>
// Always include ourself, since if we submit an op, we might need to display info
// about it locally before it has been flushed through the server
if (user) {
if (user?.id) {
tempUsers[user.id] = formatUser(user)
}
@ -724,7 +724,7 @@ function useReviewPanelState(): ReviewPanel.ReviewPanelState {
)
const applyTrackChangesStateToClient = useCallback(
(state: boolean | ReviewPanel.Value<'trackChangesState'>) => {
(state: boolean | Record<UserId | '__guests__', boolean>) => {
if (typeof state === 'boolean') {
setEveryoneTCState(state)
setGuestsTCState(state)
@ -1189,8 +1189,8 @@ function useReviewPanelState(): ReviewPanel.ReviewPanelState {
// listen for events from the CodeMirror 6 track changes extension
useEffect(() => {
const toggleTrackChangesFromKbdShortcut = () => {
if (trackChangesVisible && trackChanges) {
const userId: UserId = user.id
const userId = user.id
if (trackChangesVisible && trackChanges && userId) {
const state = trackChangesState[userId]
if (state) {
toggleTrackChangesForUser(!state.value, userId)

View file

@ -1,19 +0,0 @@
import useReviewPanelState from '@/features/ide-react/context/review-panel/hooks/use-review-panel-state'
import {
ReviewPanelUpdaterFnsContext,
ReviewPanelValueContext,
} from '@/features/source-editor/context/review-panel/review-panel-context'
const ReviewPanelReactIdeProvider: React.FC = ({ children }) => {
const { values, updaterFns } = useReviewPanelState()
return (
<ReviewPanelValueContext.Provider value={values}>
<ReviewPanelUpdaterFnsContext.Provider value={updaterFns}>
{children}
</ReviewPanelUpdaterFnsContext.Provider>
</ReviewPanelValueContext.Provider>
)
}
export default ReviewPanelReactIdeProvider

View file

@ -0,0 +1,61 @@
import { FC, FormEventHandler, useCallback, useState } from 'react'
import {
useCodeMirrorStateContext,
useCodeMirrorViewContext,
} from '@/features/source-editor/components/codemirror-editor'
import { EditorSelection } from '@codemirror/state'
import { PANEL_WIDTH } from './review-panel'
import { useTranslation } from 'react-i18next'
import { useThreadsActionsContext } from '../context/threads-context'
export const ReviewPanelAddComment: FC = () => {
const { t } = useTranslation()
const view = useCodeMirrorViewContext()
// eslint-disable-next-line no-unused-vars
const _state = useCodeMirrorStateContext()
const { addComment } = useThreadsActionsContext()
const [error, setError] = useState<Error>()
const [showForm, setShowForm] = useState(false)
const handleSubmit = useCallback<FormEventHandler>(
event => {
event.preventDefault()
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)
view.dispatch({
selection: EditorSelection.cursor(view.state.selection.main.anchor),
})
},
[addComment, view]
)
const handleElement = useCallback((element: HTMLElement | null) => {
if (element) {
element.dispatchEvent(new Event('review-panel:position'))
}
}, [])
if (!showForm) {
return <button onClick={() => setShowForm(true)}>{t('add_comment')}</button>
}
return (
<form
onSubmit={handleSubmit}
style={{ display: 'flex', flexDirection: 'column', width: PANEL_WIDTH }}
ref={handleElement}
>
{/* 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>
)
}

View file

@ -0,0 +1,16 @@
import { memo, useMemo } from 'react'
import { useChangesUsersContext } from '../context/changes-users-context'
import { buildName } from '../utils/build-name'
import { Change } from '../../../../../types/change'
export const ReviewPanelChangeUser = memo<{ change: Change }>(({ change }) => {
const changesUsers = useChangesUsersContext()
const userId = change.metadata?.user_id
const userName = useMemo(
() => buildName(userId ? changesUsers?.get(userId) : undefined),
[changesUsers, userId]
)
return <span>{userName}</span>
})
ReviewPanelChangeUser.displayName = 'ReviewPanelChangeUser'

View file

@ -0,0 +1,172 @@
import { memo } from 'react'
import { useRangesActionsContext } from '../context/ranges-context'
import {
Change,
DeleteOperation,
EditOperation,
} from '../../../../../types/change'
import { useTranslation } from 'react-i18next'
import classnames from 'classnames'
import { useCodeMirrorStateContext } from '@/features/source-editor/components/codemirror-editor'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { isFocused } from '../utils/is-focused'
import { Button } from 'react-bootstrap'
import Tooltip from '@/shared/components/tooltip'
import MaterialIcon from '@/shared/components/material-icon'
import { formatTimeBasedOnYear } from '@/features/utils/format-date'
import { useChangesUsersContext } from '../context/changes-users-context'
import { ReviewPanelChangeUser } from './review-panel-change-user'
export const ReviewPanelChange = memo<{
change: Change<EditOperation>
aggregate?: Change<DeleteOperation>
top?: number
}>(({ change, aggregate, top }) => {
const state = useCodeMirrorStateContext()
const { t } = useTranslation()
const { acceptChanges, rejectChanges } = useRangesActionsContext()
const permissions = usePermissionsContext()
const changesUsers = useChangesUsersContext()
if (!changesUsers) {
// if users are not loaded yet, do not show "Unknown" user
return null
}
const focused = isFocused(change.op, state.selection.main)
return (
<div
className={classnames('review-panel-entry', 'review-panel-entry-change', {
'review-panel-entry-focused': focused,
'review-panel-entry-insert': 'i' in change.op,
'review-panel-entry-delete': 'd' in change.op,
// TODO: aggregate
})}
data-top={top}
data-pos={change.op.p}
style={{
position: top === undefined ? 'relative' : 'absolute',
visibility: top === undefined ? 'visible' : 'hidden',
transition: 'top .3s, left .1s, right .1s',
}}
>
<div className="review-panel-entry-indicator">
<MaterialIcon type="edit" className="review-panel-entry-icon" />
</div>
<div className="review-panel-entry-content">
<div className="review-panel-entry-header">
<div>
<div className="review-panel-entry-user">
<ReviewPanelChangeUser change={change} />
</div>
<div className="review-panel-entry-time">
{formatTimeBasedOnYear(change.metadata?.ts)}
</div>
</div>
{permissions.write && (
<div className="review-panel-entry-actions">
<Tooltip
id="accept-change"
overlayProps={{ placement: 'bottom' }}
description={t('accept_change')}
>
<Button
onClick={() =>
aggregate
? acceptChanges(change.id, aggregate.id)
: acceptChanges(change.id)
}
bsStyle={null}
>
<MaterialIcon
type="check"
className="review-panel-entry-actions-icon"
accessibilityLabel={t('accept_change')}
/>
</Button>
</Tooltip>
<Tooltip
id="reject-change"
description={t('reject_change')}
overlayProps={{ placement: 'bottom' }}
>
<Button
bsStyle={null}
onClick={() =>
aggregate
? rejectChanges(change.id, aggregate.id)
: rejectChanges(change.id)
}
>
<MaterialIcon
className="review-panel-entry-actions-icon"
accessibilityLabel={t('reject_change')}
type="close"
/>
</Button>
</Tooltip>
</div>
)}
</div>
<div className="review-panel-change-body">
{'i' in change.op && (
<>
{aggregate ? (
<MaterialIcon
className="review-panel-entry-icon review-panel-entry-icon-changed"
type="edit"
/>
) : (
<MaterialIcon
className="review-panel-entry-icon review-panel-entry-icon-accept"
type="add_circle"
/>
)}
{aggregate ? (
<span>
{t('aggregate_changed')}:{' '}
<del className="review-panel-content-highlight">
{aggregate.op.d}
</del>{' '}
{t('aggregate_to')}{' '}
<ins className="review-panel-content-highlight">
{change.op.i}
</ins>
</span>
) : (
<span>
{t('tracked_change_added')}:&nbsp;
<ins className="review-panel-content-highlight">
{change.op.i}
</ins>
</span>
)}
</>
)}
{'d' in change.op && (
<>
<MaterialIcon
className="review-panel-entry-icon review-panel-entry-icon-reject"
type="delete"
/>
<span>
{t('tracked_change_deleted')}:&nbsp;
<del className="review-panel-content-highlight">
{change.op.d}
</del>
</span>
</>
)}
</div>
</div>
</div>
)
})
ReviewPanelChange.displayName = 'ReviewPanelChange'

View file

@ -0,0 +1,30 @@
import ControlledDropdown from '@/shared/components/controlled-dropdown'
import MaterialIcon from '@/shared/components/material-icon'
import { FC, memo } from 'react'
import { Dropdown, MenuItem } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
const ReviewPanelCommentOptions: FC<{
onEdit: () => void
onDelete: () => void
}> = ({ onEdit, onDelete }) => {
const { t } = useTranslation()
return (
<ControlledDropdown id="review-panel-comment-options" pullRight>
<Dropdown.Toggle noCaret bsSize="small" bsStyle={null}>
<MaterialIcon
type="more_vert"
className="review-panel-entry-actions-icon"
accessibilityLabel={t('more_options')}
/>
</Dropdown.Toggle>
<Dropdown.Menu>
<MenuItem onClick={onEdit}>{t('edit')}</MenuItem>
<MenuItem onClick={onDelete}>{t('delete')}</MenuItem>
</Dropdown.Menu>
</ControlledDropdown>
)
}
export default memo(ReviewPanelCommentOptions)

View file

@ -0,0 +1,115 @@
import { memo, useCallback, useState } from 'react'
import { Change, CommentOperation } from '../../../../../types/change'
import { ReviewPanelMessage } from './review-panel-message'
import { useTranslation } from 'react-i18next'
import {
useThreadsActionsContext,
useThreadsContext,
} from '../context/threads-context'
import { useCodeMirrorStateContext } from '@/features/source-editor/components/codemirror-editor'
import classnames from 'classnames'
import { isFocused } from '../utils/is-focused'
import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
import MaterialIcon from '@/shared/components/material-icon'
export const ReviewPanelComment = memo<{
comment: Change<CommentOperation>
top?: number
}>(({ comment, top }) => {
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 state = useCodeMirrorStateContext()
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 handleCommentReplyKeyPress = (
e: React.KeyboardEvent<HTMLTextAreaElement>
) => {
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault()
handleSubmitReply()
}
}
const thread = threads?.[comment.op.t]
if (!thread || thread.resolved) {
return null
}
const focused = isFocused(comment.op, state.selection.main)
return (
<div
className={classnames(
'review-panel-entry',
'review-panel-entry-comment',
{
'review-panel-entry-loaded': !!threads?.[comment.op.t],
'review-panel-entry-focused': focused,
}
)}
data-top={top}
data-pos={comment.op.p}
style={{
position: top === undefined ? 'relative' : 'absolute',
visibility: top === undefined ? 'visible' : 'hidden',
transition: 'top .3s, left .1s, right .1s',
}}
>
<div className="review-panel-entry-indicator">
<MaterialIcon type="edit" className="review-panel-entry-icon" />
</div>
<div className="review-panel-entry-content">
{thread.messages.map((message, i) => {
const isReply = i !== 0
return (
<div key={message.id} className="review-panel-comment-wrapper">
{isReply && (
<div className="review-panel-comment-reply-divider" />
)}
<ReviewPanelMessage
message={message}
threadId={comment.op.t}
isReply={isReply}
hasReplies={!isReply && thread.messages.length > 1}
onResolve={() => resolveThread(comment.op.t)}
/>
</div>
)
})}
<AutoExpandingTextArea
name="content"
className="review-panel-comment-input"
onChange={e => setContent(e.target.value)}
onKeyDown={handleCommentReplyKeyPress}
placeholder={t('reply')}
value={content}
disabled={submitting}
/>
{error && <div>{error.message}</div>}
</div>
</div>
)
})
ReviewPanelComment.displayName = 'ReviewPanelComment'

View file

@ -0,0 +1,22 @@
import ReactDOM from 'react-dom'
import { useCodeMirrorViewContext } from '../../source-editor/components/codemirror-editor'
import { memo } from 'react'
import ReviewPanel from './review-panel'
import { useLayoutContext } from '@/shared/context/layout-context'
import { useRangesContext } from '../context/ranges-context'
function ReviewPanelContainer() {
const view = useCodeMirrorViewContext()
const ranges = useRangesContext()
const { reviewPanelOpen } = useLayoutContext()
const mini = !reviewPanelOpen && !!ranges?.total
if (!view || (!reviewPanelOpen && !mini)) {
return null
}
return ReactDOM.createPortal(<ReviewPanel mini={mini} />, view.scrollDOM)
}
export default memo(ReviewPanelContainer)

View file

@ -0,0 +1,257 @@
import {
FC,
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { ReviewPanelAddComment } from './review-panel-add-comment'
import { ReviewPanelChange } from './review-panel-change'
import { ReviewPanelComment } from './review-panel-comment'
import {
Change,
CommentOperation,
DeleteOperation,
EditOperation,
} from '../../../../../types/change'
import {
editorOverflowPadding,
editorVerticalTopPadding,
} from '@/features/source-editor/extensions/vertical-overflow'
import {
useCodeMirrorStateContext,
useCodeMirrorViewContext,
} from '@/features/source-editor/components/codemirror-editor'
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 { isInViewport } from '../utils/is-in-viewport'
import ReviewPanelEmptyState from './review-panel-empty-state'
type Positions = Map<string, number>
type Aggregates = Map<string, Change<DeleteOperation>>
type RangesWithPositions = {
changes: Change<EditOperation>[]
comments: Change<CommentOperation>[]
positions: Positions
aggregates: Aggregates
}
const ReviewPanelCurrentFile: FC = () => {
const view = useCodeMirrorViewContext()
const ranges = useRangesContext()
const threads = useThreadsContext()
const state = useCodeMirrorStateContext()
const [rangesWithPositions, setRangesWithPositions] =
useState<RangesWithPositions>()
const contentRect = view.contentDOM.getBoundingClientRect()
const editorPaddingTop = editorVerticalTopPadding(view)
const topDiff = contentRect.top - editorPaddingTop
const docLength = state.doc.length
const screenPosition = useCallback(
(change: Change): number | undefined => {
const pos = Math.min(change.op.p, docLength)
const coords = view.coordsAtPos(pos)
return coords ? Math.round(coords.top - topDiff) : undefined
},
[docLength, topDiff, view]
)
const selectionCoords = useMemo(
() =>
state.selection.main.empty
? null
: view.coordsAtPos(state.selection.main.head),
[view, state]
)
const containerRef = useRef<HTMLDivElement | null>(null)
const ignoreNextUpdateRef = useRef(false)
const previousFocusedItem = useRef(0)
const updatePositions = useCallback(() => {
if (ignoreNextUpdateRef.current) {
ignoreNextUpdateRef.current = false
return
}
if (containerRef.current) {
const extents = positionItems(
containerRef.current,
view.scrollDOM as HTMLDivElement,
previousFocusedItem.current
)
if (extents) {
previousFocusedItem.current = extents.focusedItemIndex
window.setTimeout(() => {
const top = extents.min < 0 ? -extents.min : 0
const bottom =
extents.max > contentRect.bottom
? extents.max - contentRect.bottom
: 0
const currentPadding = editorOverflowPadding(view)
if (
currentPadding?.top !== top ||
currentPadding?.bottom !== bottom
) {
// ignoreNextUpdateRef.current = true
// view.dispatch(setVerticalOverflow({ top, bottom }))
}
})
}
}
}, [contentRect.bottom, view])
useEffect(() => {
const timer = window.setTimeout(() => {
updatePositions()
}, 100)
return () => {
window.clearTimeout(timer)
}
}, [state, updatePositions, view.viewport.from, view.viewport.to])
useEffect(() => {
const element = containerRef.current
if (element) {
element.addEventListener('review-panel:position', updatePositions)
// view.scrollDOM.addEventListener('scroll', positionListener)
return () => {
element.removeEventListener('review-panel:position', updatePositions)
// view.scrollDOM.removeEventListener('scroll', positionListener)
}
}
}, [view, updatePositions])
useEffect(() => {
if (ranges) {
view.requestMeasure({
key: 'review-panel-position',
read(view): RangesWithPositions {
const isVisible = isInViewport(view)
const output: RangesWithPositions = {
positions: new Map(),
aggregates: new Map(),
changes: [],
comments: [],
}
let precedingChange: Change<EditOperation> | null = null
for (const change of ranges.changes) {
if (isVisible(change)) {
if (
precedingChange &&
isInsertChange(precedingChange) &&
isDeleteChange(change) &&
canAggregate(change, precedingChange)
) {
output.aggregates.set(precedingChange.id, change)
} else {
output.changes.push(change)
const position = screenPosition(change)
if (position) {
output.positions.set(change.id, position)
}
}
}
precedingChange = change
}
if (threads) {
for (const comment of ranges.comments) {
if (isVisible(comment)) {
output.comments.push(comment)
if (!threads[comment.op.t]?.resolved) {
const position = screenPosition(comment)
if (position) {
output.positions.set(comment.id, position)
}
}
}
}
}
return output
},
write(positionedRanges) {
setRangesWithPositions(positionedRanges)
window.setTimeout(() => {
containerRef.current?.dispatchEvent(
new Event('review-panel:position')
)
})
},
})
}
}, [view, threads, ranges, screenPosition, containerRef])
if (!rangesWithPositions) {
return null
}
const showEmptyState =
threads &&
rangesWithPositions.changes.length === 0 &&
rangesWithPositions.comments.length === 0
return (
<div ref={containerRef}>
{selectionCoords && (
<div
className="review-panel-entry"
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>
)}
{showEmptyState && <ReviewPanelEmptyState />}
{rangesWithPositions.changes.map(change => (
<ReviewPanelChange
key={change.id}
change={change}
top={rangesWithPositions.positions.get(change.id)}
aggregate={rangesWithPositions.aggregates.get(change.id)}
/>
))}
{rangesWithPositions.comments.map(comment => (
<ReviewPanelComment
key={comment.id}
comment={comment}
top={rangesWithPositions.positions.get(comment.id)}
/>
))}
</div>
)
}
export default memo(ReviewPanelCurrentFile)

View file

@ -0,0 +1,32 @@
import AccessibleModal from '@/shared/components/accessible-modal'
import { FC, memo } from 'react'
import { Button, Modal } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
const ReviewPanelDeleteCommentModal: FC<{
onHide: () => void
onDelete: () => void
title: string
message: string
}> = ({ onHide, onDelete, title, message }) => {
const { t } = useTranslation()
return (
<AccessibleModal show onHide={onHide}>
<Modal.Header>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body>{message}</Modal.Body>
<Modal.Footer>
<Button bsStyle={null} className="btn-secondary" onClick={onHide}>
{t('cancel')}
</Button>
<Button bsStyle="danger" onClick={onDelete}>
{t('delete')}
</Button>
</Modal.Footer>
</AccessibleModal>
)
}
export default memo(ReviewPanelDeleteCommentModal)

View file

@ -1,13 +1,13 @@
import MaterialIcon from '@/shared/components/material-icon'
import { useTranslation } from 'react-i18next'
function EmptyState() {
function ReviewPanelEmptyState() {
const { t } = useTranslation()
return (
<div className="rp-empty-state">
<div className="rp-empty-state-inner">
<div className="rp-empty-state-comment-icon">
<div className="review-panel-empty-state">
<div className="review-panel-empty-state-inner">
<div className="review-panel-empty-state-comment-icon">
<MaterialIcon type="question_answer" />
</div>
<p>
@ -19,4 +19,4 @@ function EmptyState() {
)
}
export default EmptyState
export default ReviewPanelEmptyState

View file

@ -0,0 +1,72 @@
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import classNames from 'classnames'
import { Button } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
export const ExpandableContent: FC<{ className?: string }> = ({
children,
className,
}) => {
const { t } = useTranslation()
const contentRef = useRef<HTMLDivElement>(null)
const [isExpanded, setIsExpanded] = useState(false)
const [isOverflowing, setIsOverflowing] = useState(false)
useEffect(() => {
if (contentRef.current) {
setIsOverflowing(
contentRef.current.scrollHeight > contentRef.current.clientHeight
)
}
}, [])
const handleShowMore = useCallback(() => {
setIsExpanded(true)
contentRef.current?.dispatchEvent(
new CustomEvent('review-panel:position', { bubbles: true })
)
}, [])
const handleShowLess = useCallback(() => {
setIsExpanded(false)
contentRef.current?.dispatchEvent(
new CustomEvent('review-panel:position', { bubbles: true })
)
}, [])
return (
<div>
<div
ref={contentRef}
className={classNames(
'review-panel-content-expandable',
{
'review-panel-content-expanded': isExpanded,
},
className
)}
>
{children}
</div>
{isExpanded ? (
<Button
bsStyle="link"
className="btn-inline-link"
onClick={handleShowLess}
>
{t('show_less')}
</Button>
) : (
isOverflowing && (
<Button
bsStyle="link"
className="btn-inline-link"
onClick={handleShowMore}
>
{t('show_more')}
</Button>
)
)}
</div>
)
}

View file

@ -0,0 +1,43 @@
import { FC, memo, useState } from 'react'
import { ReviewPanelResolvedThreads } from './review-panel-resolved-threads'
import { ReviewPanelTrackChangesMenu } from './review-panel-track-changes-menu'
import ReviewPanelTrackChangesMenuButton from './review-panel-track-changes-menu-button'
import { Button } from 'react-bootstrap'
import MaterialIcon from '@/shared/components/material-icon'
import { useLayoutContext } from '@/shared/context/layout-context'
const ReviewPanelHeader: FC<{
top: number
width: number
}> = ({ top, width }) => {
const [trackChangesMenuExpanded, setTrackChangesMenuExpanded] =
useState(false)
const { setReviewPanelOpen } = useLayoutContext()
return (
<div className="review-panel-header" style={{ top, width }}>
<div className="review-panel-heading">
<div className="review-panel-label">Review</div>
<Button
bsStyle={null}
className="review-panel-close-button"
type="button"
onClick={() => setReviewPanelOpen(false)}
>
<MaterialIcon type="close" />
</Button>
</div>
<div className="review-panel-tools">
<ReviewPanelResolvedThreads />
<ReviewPanelTrackChangesMenuButton
menuExpanded={trackChangesMenuExpanded}
setMenuExpanded={setTrackChangesMenuExpanded}
/>
</div>
{trackChangesMenuExpanded && <ReviewPanelTrackChangesMenu />}
</div>
)
}
export default memo(ReviewPanelHeader)

View file

@ -0,0 +1,135 @@
import { FC, useCallback, useState } from 'react'
import {
ReviewPanelCommentThreadMessage,
ThreadId,
} from '../../../../../types/review-panel/review-panel'
import { useTranslation } from 'react-i18next'
import { useThreadsActionsContext } from '../context/threads-context'
import { formatTimeBasedOnYear } from '@/features/utils/format-date'
import Tooltip from '@/shared/components/tooltip'
import { Button } from 'react-bootstrap'
import MaterialIcon from '@/shared/components/material-icon'
import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
import { buildName } from '../utils/build-name'
import ReviewPanelCommentOptions from './review-panel-comment-options'
import { ExpandableContent } from './review-panel-expandable-content'
import ReviewPanelDeleteCommentModal from './review-panel-delete-comment-modal'
export const ReviewPanelMessage: FC<{
message: ReviewPanelCommentThreadMessage
threadId: ThreadId
hasReplies: boolean
isReply: boolean
onResolve: () => void
}> = ({ message, threadId, isReply, hasReplies, onResolve }) => {
const { t } = useTranslation()
const [editing, setEditing] = useState(false)
const [deleting, setDeleting] = useState(false)
const [error, setError] = useState<Error>()
const [content, setContent] = useState(message.content)
const { editMessage, deleteMessage } = useThreadsActionsContext()
const handleEditOption = useCallback(() => setEditing(true), [])
const showDeleteModal = useCallback(() => setDeleting(true), [])
const hideDeleteModal = useCallback(() => setDeleting(false), [])
const handleSubmit = useCallback(async () => {
await editMessage(threadId, message.id, content)
.catch(error => {
setError(error)
})
.finally(() => {
setEditing(false)
})
}, [content, editMessage, message.id, threadId])
const handleDelete = useCallback(async () => {
await deleteMessage(threadId, message.id)
.catch(error => {
setError(error)
})
.finally(() => {
setDeleting(false)
})
}, [deleteMessage, message.id, threadId])
if (editing) {
return (
<div>
<AutoExpandingTextArea
className="review-panel-comment-input"
onBlur={handleSubmit}
onChange={e => setContent(e.target.value)}
onKeyDown={e => {
if (
e.key === 'Enter' &&
!e.shiftKey &&
!e.ctrlKey &&
!e.metaKey &&
content
) {
e.preventDefault()
handleSubmit()
}
}}
value={content}
autoFocus // eslint-disable-line jsx-a11y/no-autofocus
/>
{error && <div>{error.message}</div>}
</div>
)
}
return (
<div className="review-panel-comment">
<div className="review-panel-entry-header">
<div>
<div className="review-panel-entry-user">
{buildName(message.user)}
</div>
<div className="review-panel-entry-time">
{formatTimeBasedOnYear(message.timestamp)}
</div>
</div>
<div className="review-panel-entry-actions">
{!isReply && (
<Tooltip
id="resolve-thread"
overlayProps={{ placement: 'bottom' }}
description={t('resolve_comment')}
>
<Button onClick={onResolve} bsStyle={null}>
<MaterialIcon
type="check"
className="review-panel-entry-actions-icon"
accessibilityLabel={t('resolve_comment')}
/>
</Button>
</Tooltip>
)}
<ReviewPanelCommentOptions
onEdit={handleEditOption}
onDelete={showDeleteModal}
/>
</div>
</div>
<ExpandableContent className="review-panel-comment-body">
{message.content}
</ExpandableContent>
{deleting && (
<ReviewPanelDeleteCommentModal
onHide={hideDeleteModal}
onDelete={handleDelete}
title={hasReplies ? t('delete_comment_thread') : t('delete_comment')}
message={
hasReplies
? t('delete_comment_thread_message')
: t('delete_comment_message')
}
/>
)}
</div>
)
}

View file

@ -0,0 +1,94 @@
import { FC, useMemo } from 'react'
import { MainDocument } from '../../../../../types/project-settings'
import { Ranges } from '../context/ranges-context'
import { ReviewPanelComment } from './review-panel-comment'
import { ReviewPanelChange } from './review-panel-change'
import { isDeleteChange, isInsertChange } from '@/utils/operations'
import {
Change,
DeleteOperation,
EditOperation,
} from '../../../../../types/change'
import { canAggregate } from '../utils/can-aggregate'
import { Button } from 'react-bootstrap'
import MaterialIcon from '@/shared/components/material-icon'
import useOverviewFileCollapsed from '../hooks/use-overview-file-collapsed'
import { useThreadsContext } from '../context/threads-context'
export const ReviewPanelOverviewFile: FC<{
doc: MainDocument
ranges: Ranges
}> = ({ doc, ranges }) => {
const { collapsed, toggleCollapsed } = useOverviewFileCollapsed(doc.doc.id)
const threads = useThreadsContext()
const { aggregates, changes } = useMemo(() => {
const changes: Change<EditOperation>[] = []
const aggregates: Map<string, Change<DeleteOperation>> = new Map()
let precedingChange: Change<EditOperation> | null = null
for (const change of ranges.changes) {
if (
precedingChange &&
isInsertChange(precedingChange) &&
isDeleteChange(change) &&
canAggregate(change, precedingChange)
) {
aggregates.set(precedingChange.id, change)
} else {
changes.push(change)
}
precedingChange = change
}
return { aggregates, changes }
}, [ranges])
const unresolvedComments = useMemo(() => {
return ranges.comments.filter(comment => {
const thread = threads?.[comment.op.t]
return thread && !thread.resolved
})
}, [ranges.comments, threads])
const numEntries = changes.length + unresolvedComments.length
if (numEntries === 0) {
return null
}
return (
<div>
<Button
bsClass="review-panel-overview-file-header"
bsStyle={null}
onClick={toggleCollapsed}
>
<MaterialIcon
type={collapsed ? 'keyboard_arrow_right' : 'keyboard_arrow_down'}
/>
{doc.doc.name}
<div className="review-panel-overview-file-entry-count">
{numEntries}
</div>
</Button>
{!collapsed && (
<div className="review-panel-overview-file-entries">
{changes.map(change => (
<ReviewPanelChange
key={change.id}
change={change}
aggregate={aggregates.get(change.id)}
/>
))}
{unresolvedComments.map(comment => (
<ReviewPanelComment key={comment.id} comment={comment} />
))}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,99 @@
import { FC, useEffect, useMemo, useState } from 'react'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { Ranges, useRangesContext } from '../context/ranges-context'
import { getJSON } from '@/infrastructure/fetch-json'
import { useProjectContext } from '@/shared/context/project-context'
import { useTranslation } from 'react-i18next'
import { ReviewPanelOverviewFile } from './review-panel-overview-file'
import ReviewPanelEmptyState from './review-panel-empty-state'
export const ReviewPanelOverview: FC = () => {
const { t } = useTranslation()
const { _id: projectId } = useProjectContext()
const { docs } = useFileTreeData()
const [error, setError] = useState<Error>()
const [projectRanges, setProjectRanges] = useState<Map<string, Ranges>>()
const docRanges = useRangesContext()
useEffect(() => {
getJSON<{ id: string; ranges: Ranges }[]>(`/project/${projectId}/ranges`)
.then(data =>
setProjectRanges(
new Map(
data.map(item => [
item.id,
{
docId: item.id,
changes: item.ranges.changes ?? [],
comments: item.ranges.comments ?? [],
total: 0, // TODO
},
])
)
)
)
.catch(error => setError(error))
}, [projectId])
const rangesForDocs = useMemo(() => {
if (docs && docRanges && projectRanges) {
const rangesForDocs = new Map<string, Ranges>()
for (const doc of docs) {
const ranges =
doc.doc.id === docRanges.docId
? docRanges
: projectRanges.get(doc.doc.id)
if (ranges) {
rangesForDocs.set(doc.doc.id, ranges)
}
}
return rangesForDocs
}
}, [docRanges, docs, projectRanges])
const showEmptyState = useMemo((): boolean => {
if (!rangesForDocs) {
// data isn't loaded yet
return false
}
for (const ranges of rangesForDocs.values()) {
if (ranges.changes.length > 0 || ranges.comments.length > 0) {
return false
}
}
return true
}, [rangesForDocs])
return (
<div className="review-panel-overview">
{error && <div>{t('something_went_wrong')}</div>}
{showEmptyState && <ReviewPanelEmptyState />}
{docs && rangesForDocs && (
<div>
{docs.map(doc => {
const ranges = rangesForDocs.get(doc.doc.id)
return (
ranges && (
<>
<ReviewPanelOverviewFile
key={doc.doc.id}
doc={doc}
ranges={ranges}
/>
<div className="review-panel-overfile-divider" />
</>
)
)
})}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,87 @@
import React, { FC, useMemo, useRef, useState } from 'react'
import { Overlay, Popover } from 'react-bootstrap'
import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-editor'
import {
useThreadsActionsContext,
useThreadsContext,
} from '../context/threads-context'
import { useTranslation } from 'react-i18next'
import { ThreadId } from '../../../../../types/review-panel/review-panel'
import Icon from '@/shared/components/icon'
export const ReviewPanelResolvedThreads: FC = () => {
const { t } = useTranslation()
const view = useCodeMirrorViewContext()
const threads = useThreadsContext()
const { reopenThread, deleteThread } = useThreadsActionsContext()
const [expanded, setExpanded] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
// eslint-disable-next-line react-hooks/exhaustive-deps
const resolvedThreads = useMemo(() => {
if (!threads) {
return []
}
const resolvedThreads = []
for (const [id, thread] of Object.entries(threads)) {
if (thread.resolved) {
resolvedThreads.push({ ...thread, id })
}
}
return resolvedThreads
}, [threads])
return (
<>
<button
className="resolved-comments-toggle"
ref={buttonRef}
onClick={() => setExpanded(true)}
>
<Icon type="inbox" fw />
</button>
{expanded && (
<Overlay
show
onHide={() => setExpanded(false)}
animation={false}
container={view.scrollDOM}
containerPadding={0}
placement="bottom"
rootClose
target={buttonRef.current ?? undefined}
>
<Popover id="popover-resolved-threads">
{resolvedThreads.length ? (
<div className="resolved-comments">
{resolvedThreads.map(thread => (
<div key={thread.id}>
<div>{thread.resolved_at}</div>
<div>
<button
onClick={() => reopenThread(thread.id as ThreadId)}
>
{t('reopen')}
</button>
<button
onClick={() => deleteThread(thread.id as ThreadId)}
>
{t('delete')}
</button>
</div>
</div>
))}
</div>
) : (
<div>{t('no_resolved_threads')}</div>
)}
</Popover>
</Overlay>
)}
</>
)
}

View file

@ -0,0 +1,31 @@
import { Dispatch, FC, memo, SetStateAction } from 'react'
import classnames from 'classnames'
import { SubView } from '../components/review-panel'
const ReviewPanelTabs: FC<{
subView: SubView
setSubView: Dispatch<SetStateAction<SubView>>
}> = ({ subView, setSubView }) => {
return (
<>
<button
className={classnames('rp-nav-item', {
'rp-nav-item-active': subView === 'cur_file',
})}
onClick={() => setSubView('cur_file')}
>
Current file
</button>
<button
className={classnames('rp-nav-item', {
'rp-nav-item-active': subView === 'overview',
})}
onClick={() => setSubView('overview')}
>
Overview
</button>
</>
)
}
export default memo(ReviewPanelTabs)

View file

@ -0,0 +1,34 @@
import { FC, memo } from 'react'
import { Trans } from 'react-i18next'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import Icon from '@/shared/components/icon'
const ReviewPanelTrackChangesMenuButton: FC<{
menuExpanded: boolean
setMenuExpanded: React.Dispatch<React.SetStateAction<boolean>>
}> = ({ menuExpanded, setMenuExpanded }) => {
const { wantTrackChanges } = useEditorManagerContext()
return (
<button
className="track-changes-menu-button"
onClick={() => setMenuExpanded(value => !value)}
>
{wantTrackChanges && <div className="track-changes-indicator-circle" />}
{wantTrackChanges ? (
<Trans
i18nKey="track_changes_is_on"
components={{ strong: <strong /> }}
/>
) : (
<Trans
i18nKey="track_changes_is_off"
components={{ strong: <strong /> }}
/>
)}
<Icon type={menuExpanded ? 'angle-down' : 'angle-right'} />
</button>
)
}
export default memo(ReviewPanelTrackChangesMenuButton)

View file

@ -0,0 +1,112 @@
import { FC, useCallback } from 'react'
import TrackChangesToggle from '@/features/source-editor/components/review-panel/toolbar/track-changes-toggle'
import { useProjectContext } from '@/shared/context/project-context'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { useTranslation } from 'react-i18next'
import { useTrackChangesStateContext } from '../context/track-changes-state-context'
import { postJSON } from '@/infrastructure/fetch-json'
import { useChangesUsersContext } from '../context/changes-users-context'
import { UserId } from '../../../../../types/user'
import { buildName } from '../utils/build-name'
export const ReviewPanelTrackChangesMenu: FC = () => {
const { t } = useTranslation()
const permissions = usePermissionsContext()
const project = useProjectContext()
const trackChanges = useTrackChangesStateContext()
const changesUsers = useChangesUsersContext()
const saveTrackChanges = useCallback(
body => {
postJSON(`/project/${project._id}/track_changes`, {
body,
})
},
[project._id]
)
if (trackChanges === undefined || !changesUsers) {
return null
}
const trackChangesIsObject = trackChanges !== true && trackChanges !== false
const onForEveryone = trackChanges === true
const onForGuests =
onForEveryone || (trackChangesIsObject && trackChanges.__guests__ === true)
const trackChangesValues: Record<UserId, boolean | undefined> = {}
if (trackChangesIsObject) {
for (const key of Object.keys(trackChanges)) {
if (key !== '__guests__') {
trackChangesValues[key as UserId] = trackChanges[key as UserId]
}
}
}
const canToggle = project.features.trackChanges && permissions.write
return (
<div className="rp-tc-state">
<div className="rp-tc-state-item">
<span className="rp-tc-state-item-name">{t('tc_everyone')}</span>
<TrackChangesToggle
id="track-changes-everyone"
description={t('track_changes_for_everyone')}
handleToggle={() =>
saveTrackChanges(onForEveryone ? { on_for: {} } : { on: true })
}
value={onForEveryone}
disabled={!canToggle}
/>
</div>
{[project.owner, ...project.members].map(member => {
const user = changesUsers.get(member._id) ?? member
const name = buildName(user)
const value =
trackChanges === true || trackChangesValues[member._id] === true
return (
<div key={member._id} className="rp-tc-state-item">
<span className="rp-tc-state-item-name">{name}</span>
<TrackChangesToggle
id={`track-changes-${member._id}`}
description={t('track_changes_for_x', { name })}
handleToggle={() => {
saveTrackChanges({
on_for: {
...trackChangesValues,
[member._id]: !value,
},
on_for_guests: onForGuests,
})
}}
value={value}
disabled={!canToggle || onForEveryone}
/>
</div>
)
})}
<div className="rp-tc-state-item">
<span className="rp-tc-state-item-name">{t('tc_guests')}</span>
<TrackChangesToggle
id="track-changes-guests"
description={t('track_changes_for_guests')}
handleToggle={() =>
saveTrackChanges({
on_for: trackChangesValues,
on_for_guests: !onForGuests,
})
}
value={onForGuests}
disabled={!canToggle || onForEveryone}
/>
</div>
</div>
)
}

View file

@ -0,0 +1,74 @@
import { FC, memo, useState } from 'react'
import {
useCodeMirrorStateContext,
useCodeMirrorViewContext,
} from '@/features/source-editor/components/codemirror-editor'
import ReviewPanelTabs from './review-panel-tabs'
import ReviewPanelHeader from './review-panel-header'
import ReviewPanelCurrentFile from './review-panel-current-file'
import { ReviewPanelOverview } from './review-panel-overview'
import classnames from 'classnames'
export type SubView = 'cur_file' | 'overview'
export const PANEL_WIDTH = 230
export const PANEL_MINI_WIDTH = 20
const ReviewPanel: FC<{ mini: boolean }> = ({ mini }) => {
const view = useCodeMirrorViewContext()
// eslint-disable-next-line no-unused-vars
const _state = useCodeMirrorStateContext() // needs to update on editor state changes
const [subView, setSubView] = useState<SubView>('cur_file')
const contentRect = view.contentDOM.getBoundingClientRect()
const scrollRect = view.scrollDOM.getBoundingClientRect()
return (
<div
className="review-panel-container"
style={{
overflowY: subView === 'overview' ? 'hidden' : undefined,
position: subView === 'overview' ? 'sticky' : 'relative',
top: subView === 'overview' ? 0 : undefined,
}}
>
<div
className={classnames('review-panel-new', {
'review-panel-mini': mini,
})}
style={{
flexShrink: 0,
minHeight: subView === 'cur_file' ? contentRect.height : 'auto',
height: subView === 'overview' ? '100%' : undefined,
overflow: subView === 'overview' ? 'hidden' : undefined,
width: mini ? PANEL_MINI_WIDTH : PANEL_WIDTH,
}}
>
{!mini && (
<ReviewPanelHeader top={scrollRect.top - 40} width={PANEL_WIDTH} />
)}
{subView === 'cur_file' && <ReviewPanelCurrentFile />}
{subView === 'overview' && <ReviewPanelOverview />}
<div
className="review-panel-footer"
style={{
position: 'fixed',
top: scrollRect.bottom - 66,
zIndex: 1,
background: '#fafafa',
borderTop: 'solid 1px #d9d9d9',
width: PANEL_WIDTH,
display: mini ? 'none' : 'flex',
}}
>
<ReviewPanelTabs subView={subView} setSubView={setSubView} />
</div>
</div>
</div>
)
}
export default memo(ReviewPanel)

View file

@ -0,0 +1,56 @@
import {
createContext,
FC,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { getJSON } from '@/infrastructure/fetch-json'
import { useProjectContext } from '@/shared/context/project-context'
import { UserId } from '../../../../../types/user'
type ChangesUser = {
id: UserId
email: string
first_name?: string
last_name?: string
}
export type ChangesUsers = Map<UserId, ChangesUser>
export const ChangesUsersContext = createContext<ChangesUsers | undefined>(
undefined
)
export const ChangesUsersProvider: FC = ({ children }) => {
const { _id: projectId, members, owner } = useProjectContext()
const [changesUsers, setChangesUsers] = useState<ChangesUsers>()
useEffect(() => {
getJSON<ChangesUser[]>(`/project/${projectId}/changes/users`).then(data =>
setChangesUsers(new Map(data.map(item => [item.id, item])))
)
}, [projectId])
// add the project owner and members to the changes users data
const value = useMemo(() => {
const value: ChangesUsers = new Map(changesUsers)
value.set(owner._id, { ...owner, id: owner._id })
for (const member of members) {
value.set(member._id, { ...member, id: member._id })
}
return value
}, [members, owner, changesUsers])
return (
<ChangesUsersContext.Provider value={value}>
{children}
</ChangesUsersContext.Provider>
)
}
export const useChangesUsersContext = () => {
return useContext(ChangesUsersContext)
}

View file

@ -0,0 +1,141 @@
import {
createContext,
FC,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import useScopeValue from '@/shared/hooks/use-scope-value'
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
import {
Change,
CommentOperation,
EditOperation,
} from '../../../../../types/change'
import RangesTracker from '@overleaf/ranges-tracker'
import { rejectChanges } from '@/features/source-editor/extensions/track-changes'
import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-editor'
export type Ranges = {
docId: string
total: number
changes: Change<EditOperation>[]
comments: Change<CommentOperation>[]
}
export const RangesContext = createContext<Ranges | undefined>(undefined)
type RangesActions = {
acceptChanges: (...ids: string[]) => void
rejectChanges: (...ids: string[]) => void
}
const buildRanges = (currentDoc: DocumentContainer | null) => {
if (currentDoc?.ranges) {
return {
...currentDoc.ranges,
docId: currentDoc.doc_id,
total:
currentDoc.ranges.changes.length + currentDoc.ranges.comments.length,
}
}
}
const RangesActionsContext = createContext<RangesActions | undefined>(undefined)
export const RangesProvider: FC = ({ children }) => {
const view = useCodeMirrorViewContext()
const [currentDoc] = useScopeValue<DocumentContainer | null>(
'editor.sharejs_doc'
)
const [ranges, setRanges] = useState<Ranges | undefined>(() =>
buildRanges(currentDoc)
)
// rebuild the ranges when the current doc changes
useEffect(() => {
setRanges(buildRanges(currentDoc))
}, [currentDoc])
useEffect(() => {
if (currentDoc) {
const listener = () => {
setRanges(buildRanges(currentDoc))
}
// currentDoc.on('ranges:clear.cm6', listener)
currentDoc.on('ranges:redraw.cm6', listener)
currentDoc.on('ranges:dirty.cm6', listener)
return () => {
// currentDoc.off('ranges:clear.cm6')
currentDoc.off('ranges:redraw.cm6')
currentDoc.off('ranges:dirty.cm6')
}
}
}, [currentDoc])
// TODO: move this into DocumentContainer?
useEffect(() => {
if (currentDoc) {
const regenerateTrackChangesId = (doc: DocumentContainer) => {
if (doc.ranges) {
const inflight = doc.ranges.getIdSeed()
const pending = RangesTracker.generateIdSeed()
doc.ranges.setIdSeed(pending)
doc.setTrackChangesIdSeeds({ pending, inflight })
}
}
currentDoc.on('flipped_pending_to_inflight', () =>
regenerateTrackChangesId(currentDoc)
)
regenerateTrackChangesId(currentDoc)
return () => {
currentDoc.off('flipped_pending_to_inflight')
}
}
}, [currentDoc])
const actions = useMemo(
() => ({
acceptChanges(...ids: string[]) {
if (currentDoc?.ranges) {
currentDoc.ranges.removeChangeIds(ids)
setRanges(buildRanges(currentDoc))
}
},
rejectChanges(...ids: string[]) {
if (currentDoc?.ranges) {
view.dispatch(rejectChanges(view.state, currentDoc.ranges, ids))
}
},
}),
[currentDoc, view]
)
return (
<RangesActionsContext.Provider value={actions}>
<RangesContext.Provider value={ranges}>{children}</RangesContext.Provider>
</RangesActionsContext.Provider>
)
}
export const useRangesContext = () => {
return useContext(RangesContext)
}
export const useRangesActionsContext = () => {
const context = useContext(RangesActionsContext)
if (!context) {
throw new Error(
'useRangesActionsContext is only available inside RangesProvider'
)
}
return context
}

View file

@ -0,0 +1,22 @@
import { FC } from 'react'
import { RangesProvider } from './ranges-context'
import { ChangesUsersProvider } from './changes-users-context'
import { TrackChangesStateProvider } from './track-changes-state-context'
import { ThreadsProvider } from './threads-context'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
export const ReviewPanelProviders: FC = ({ children }) => {
if (!isSplitTestEnabled('review-panel-redesign')) {
return <>{children}</>
}
return (
<ChangesUsersProvider>
<TrackChangesStateProvider>
<ThreadsProvider>
<RangesProvider>{children}</RangesProvider>
</ThreadsProvider>
</TrackChangesStateProvider>
</ChangesUsersProvider>
)
}

View file

@ -0,0 +1,291 @@
import {
createContext,
FC,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { useProjectContext } from '@/shared/context/project-context'
import {
CommentId,
ThreadId,
} from '../../../../../types/review-panel/review-panel'
import { ReviewPanelCommentThread } from '../../../../../types/review-panel/comment-thread'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
import { ReviewPanelCommentThreadMessageApi } from '../../../../../types/review-panel/api'
import { UserId } from '../../../../../types/user'
import { deleteJSON, getJSON, postJSON } from '@/infrastructure/fetch-json'
import RangesTracker from '@overleaf/ranges-tracker'
import { CommentOperation } from '../../../../../types/change'
import useScopeValue from '@/shared/hooks/use-scope-value'
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
export type Threads = Record<ThreadId, ReviewPanelCommentThread>
export const ThreadsContext = createContext<Threads | undefined>(undefined)
type ThreadsActions = {
addComment: (pos: number, text: string, content: string) => Promise<void>
resolveThread: (threadId: ThreadId) => Promise<void>
reopenThread: (threadId: ThreadId) => Promise<void>
deleteThread: (threadId: ThreadId) => Promise<void>
addMessage: (threadId: ThreadId, content: string) => Promise<void>
editMessage: (
threadId: ThreadId,
commentId: CommentId,
content: string
) => Promise<void>
deleteMessage: (threadId: ThreadId, commentId: CommentId) => Promise<void>
}
const ThreadsActionsContext = createContext<ThreadsActions | undefined>(
undefined
)
export const ThreadsProvider: FC = ({ children }) => {
const { _id: projectId } = useProjectContext()
const [currentDoc] = useScopeValue<DocumentContainer | null>(
'editor.sharejs_doc'
)
// const [error, setError] = useState<Error>()
const [data, setData] = useState<Threads>()
// load the initial threads data
useEffect(() => {
const abortController = new AbortController()
getJSON(`/project/${projectId}/threads`, {
signal: abortController.signal,
}).then(data => {
setData(data)
})
// .catch(error => {
// setError(error)
// })
}, [projectId])
const { socket } = useConnectionContext()
useSocketListener(
socket,
'new-comment',
useCallback(
(threadId: ThreadId, comment: ReviewPanelCommentThreadMessageApi) => {
setData(value => {
if (value) {
const { submitting, ...thread } = value[threadId] ?? {
messages: [],
}
return {
...value,
[threadId]: {
...thread,
messages: [
...thread.messages,
{
...comment,
user: comment.user, // TODO
timestamp: new Date(comment.timestamp),
},
],
},
}
}
})
},
[]
)
)
useSocketListener(
socket,
'edit-message',
useCallback((threadId: ThreadId, commentId: CommentId, content: string) => {
setData(value => {
if (value) {
const thread = value[threadId] ?? { messages: [] }
return {
...value,
[threadId]: {
...thread,
messages: thread.messages.map(message =>
message.id === commentId ? { ...message, content } : message
),
},
}
}
})
}, [])
)
useSocketListener(
socket,
'delete-message',
useCallback((threadId: ThreadId, commentId: CommentId) => {
setData(value => {
if (value) {
const thread = value[threadId] ?? { messages: [] }
return {
...value,
[threadId]: {
...thread,
messages: thread.messages.filter(
message => message.id !== commentId
),
},
}
}
})
}, [])
)
useSocketListener(
socket,
'resolve-thread',
useCallback(
(
threadId: ThreadId,
user: { email: string; first_name: string; id: UserId }
) => {
setData(value => {
if (value) {
const thread = value[threadId] ?? { messages: [] }
return {
...value,
[threadId]: {
...thread,
resolved: true,
resolved_by_user: user, // TODO
resolved_at: new Date().toISOString(),
},
}
}
})
},
[]
)
)
useSocketListener(
socket,
'reopen-thread',
useCallback((threadId: ThreadId) => {
setData(value => {
if (value) {
const thread = value[threadId] ?? { messages: [] }
return {
...value,
[threadId]: {
...thread,
resolved: undefined,
resolved_by_user: undefined,
resolved_at: undefined,
},
}
}
})
}, [])
)
useSocketListener(
socket,
'delete-thread',
useCallback((threadId: ThreadId) => {
setData(value => {
if (value) {
const _value = { ...value }
delete _value[threadId]
return _value
}
})
}, [])
)
const actions = useMemo(
() => ({
async addComment(pos: number, text: string, content: string) {
const threadId = RangesTracker.generateId() as ThreadId
await postJSON(`/project/${projectId}/thread/${threadId}/messages`, {
body: { content },
})
const op: CommentOperation = {
c: text,
p: pos,
t: threadId,
}
currentDoc?.submitOp(op)
},
async resolveThread(threadId: string) {
await postJSON(
`/project/${projectId}/doc/${currentDoc?.doc_id}/thread/${threadId}/resolve`
)
},
async reopenThread(threadId: string) {
await postJSON(
`/project/${projectId}/doc/${currentDoc?.doc_id}/thread/${threadId}/reopen`
)
},
async deleteThread(threadId: string) {
await deleteJSON(
`/project/${projectId}/doc/${currentDoc?.doc_id}/thread/${threadId}`
)
currentDoc?.ranges?.removeCommentId(threadId)
},
async addMessage(threadId: ThreadId, content: string) {
await postJSON(`/project/${projectId}/thread/${threadId}/messages`, {
body: { content },
})
// TODO: error_submitting_comment
},
async editMessage(
threadId: ThreadId,
commentId: CommentId,
content: string
) {
await postJSON(
`/project/${projectId}/thread/${threadId}/messages/${commentId}/edit`,
{ body: { content } }
)
},
async deleteMessage(threadId: ThreadId, commentId: CommentId) {
await deleteJSON(
`/project/${projectId}/thread/${threadId}/messages/${commentId}`
)
},
}),
[currentDoc, projectId]
)
return (
<ThreadsActionsContext.Provider value={actions}>
<ThreadsContext.Provider value={data}>{children}</ThreadsContext.Provider>
</ThreadsActionsContext.Provider>
)
}
export const useThreadsContext = () => {
return useContext(ThreadsContext)
}
export const useThreadsActionsContext = () => {
const context = useContext(ThreadsActionsContext)
if (!context) {
throw new Error(
'useThreadsActionsContext is only available inside ThreadsProvider'
)
}
return context
}

View file

@ -0,0 +1,43 @@
import { UserId } from '../../../../../types/user'
import { createContext, FC, useContext, useEffect, useState } from 'react'
import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import { useProjectContext } from '@/shared/context/project-context'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import { useUserContext } from '@/shared/context/user-context'
export type TrackChangesState = boolean | Record<UserId | '__guests__', boolean>
export const TrackChangesStateContext = createContext<
TrackChangesState | undefined
>(undefined)
export const TrackChangesStateProvider: FC = ({ children }) => {
const { socket } = useConnectionContext()
const project = useProjectContext()
const user = useUserContext()
const { setWantTrackChanges } = useEditorManagerContext()
// TODO: update project.trackChangesState instead?
const [value, setValue] = useState<TrackChangesState>(
project.trackChangesState ?? false
)
useSocketListener(socket, 'toggle-track-changes', setValue)
useEffect(() => {
setWantTrackChanges(
value === true || (value !== false && value[user.id ?? '__guests__'])
)
}, [setWantTrackChanges, value, user.id])
return (
<TrackChangesStateContext.Provider value={value}>
{children}
</TrackChangesStateContext.Provider>
)
}
export const useTrackChangesStateContext = () => {
return useContext(TrackChangesStateContext)
}

View file

@ -0,0 +1,22 @@
import { useCallback } from 'react'
import { DocId } from '../../../../../types/project-settings'
import { useProjectContext } from '../../../shared/context/project-context'
import usePersistedState from '../../../shared/hooks/use-persisted-state'
export default function useOverviewFileCollapsed(docId: DocId) {
const { _id: projectId } = useProjectContext()
const [collapsedDocs, setCollapsedDocs] = usePersistedState<
Record<DocId, boolean>
>(`docs_collapsed_state:${projectId}`, {}, false, true)
const toggleCollapsed = useCallback(() => {
setCollapsedDocs((collapsedDocs: Record<DocId, boolean>) => {
return {
...collapsedDocs,
[docId]: !collapsedDocs[docId],
}
})
}, [docId, setCollapsedDocs])
return { collapsed: collapsedDocs[docId], toggleCollapsed }
}

View file

@ -0,0 +1,15 @@
export const buildName = (user?: {
first_name?: string
last_name?: string
email?: string
}) => {
const name = [user?.first_name, user?.last_name].filter(Boolean).join(' ')
if (name) {
return name
}
if (user?.email) {
return user.email.split('@')[0]
}
return 'Unknown'
}

View file

@ -0,0 +1,15 @@
import {
Change,
DeleteOperation,
InsertOperation,
} from '../../../../../types/change'
export const canAggregate = (
deletion: Change<DeleteOperation>,
insertion: Change<InsertOperation>
) =>
deletion.metadata?.user_id &&
// same user
deletion.metadata?.user_id === insertion.metadata?.user_id &&
// same position
deletion.op.p === insertion.op.p + insertion.op.i.length

View file

@ -0,0 +1,7 @@
import { AnyOperation } from '../../../../../types/change'
import { SelectionRange } from '@codemirror/state'
import { visibleTextLength } from '@/utils/operations'
export const isFocused = (op: AnyOperation, range: SelectionRange): boolean => {
return range.to >= op.p && range.from <= op.p + visibleTextLength(op)
}

View file

@ -0,0 +1,7 @@
import { EditorView } from '@codemirror/view'
import { Change } from '../../../../../types/change'
export const isInViewport =
(view: EditorView) =>
(change: Change): boolean =>
change.op.p >= view.viewport.from && change.op.p <= view.viewport.to

View file

@ -0,0 +1,77 @@
import { debounce } from 'lodash'
export const positionItems = debounce(
(
element: HTMLDivElement,
containerElement: HTMLDivElement,
previousFocusedItemIndex: number
) => {
const scrollRect = containerElement.getBoundingClientRect()
const items = Array.from(
element.querySelectorAll<HTMLDivElement>('.review-panel-entry')
)
items.sort((a, b) => Number(a.dataset.pos) - Number(b.dataset.pos))
if (!items.length) {
return
}
let focusedItemIndex = items.findIndex(item =>
item.classList.contains('review-panel-entry-focused')
)
if (focusedItemIndex === -1) {
focusedItemIndex = previousFocusedItemIndex
}
// TODO: editorPadding?
const topDiff = scrollRect.top - 80
const focusedItem = items[focusedItemIndex]
if (!focusedItem) {
return
}
const focusedItemTop = Number(focusedItem.dataset.top)
focusedItem.style.top = `${focusedItemTop + topDiff}px`
focusedItem.style.visibility = 'visible'
const focusedItemRect = focusedItem.getBoundingClientRect()
// above the focused item
let topLimit = focusedItemTop
for (let i = focusedItemIndex - 1; i >= 0; i--) {
const item = items[i]
const rect = item.getBoundingClientRect()
let top = Number(item.dataset.top)
const bottom = top + rect.height
if (bottom > topLimit) {
top = topLimit - rect.height - 10
}
item.style.top = `${top + topDiff}px`
item.style.visibility = 'visible'
topLimit = top
}
// below the focused item
let bottomLimit = focusedItemTop + focusedItemRect.height
for (let i = focusedItemIndex + 1; i < items.length; i++) {
const item = items[i]
const rect = item.getBoundingClientRect()
let top = Number(item.dataset.top)
if (top < bottomLimit) {
top = bottomLimit + 10
}
item.style.top = `${top + topDiff}px`
item.style.visibility = 'visible'
bottomLimit = top + rect.height
}
return {
focusedItemIndex,
min: topLimit,
max: bottomLimit,
}
},
100,
{ leading: false, trailing: true, maxWait: 1000 }
)

View file

@ -18,8 +18,9 @@ import { dispatchTimer } from '../../../infrastructure/cm6-performance'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import { FigureModal } from './figure-modal/figure-modal'
import ReviewPanel from './review-panel/review-panel'
import useViewerPermissions from '@/shared/hooks/use-viewer-permissions'
import { ReviewPanelProviders } from '@/features/review-panel-new/context/review-panel-providers'
import { ReviewPanelMigration } from '@/features/source-editor/components/review-panel/review-panel-migration'
const sourceEditorComponents = importOverleafModules(
'sourceEditorComponents'
@ -61,24 +62,26 @@ function CodeMirrorEditor() {
return (
<CodeMirrorStateContext.Provider value={state}>
<CodeMirrorViewContextProvider value={viewRef.current}>
<CodemirrorOutline />
<CodeMirrorView />
<FigureModal />
<CodeMirrorSearch />
<CodeMirrorToolbar />
{sourceEditorToolbarComponents.map(
({ import: { default: Component }, path }) => (
<Component key={path} />
)
)}
<CodeMirrorCommandTooltip />
<ReviewPanelProviders>
<CodemirrorOutline />
<CodeMirrorView />
<FigureModal />
<CodeMirrorSearch />
<CodeMirrorToolbar />
{sourceEditorToolbarComponents.map(
({ import: { default: Component }, path }) => (
<Component key={path} />
)
)}
<CodeMirrorCommandTooltip />
{shouldShowReviewPanel && <ReviewPanel />}
{sourceEditorComponents.map(
({ import: { default: Component }, path }) => (
<Component key={path} />
)
)}
{shouldShowReviewPanel && <ReviewPanelMigration />}
{sourceEditorComponents.map(
({ import: { default: Component }, path }) => (
<Component key={path} />
)
)}
</ReviewPanelProviders>
</CodeMirrorViewContextProvider>
</CodeMirrorStateContext.Provider>
)

View file

@ -9,8 +9,6 @@ import useCodeMirrorContentHeight from '../../hooks/use-codemirror-content-heigh
import { ReviewPanelEntry } from '../../../../../../types/review-panel/entry'
import { ReviewPanelDocEntries } from '../../../../../../types/review-panel/review-panel'
import Entry from './entry'
import EmptyState from './empty-state'
import { useFeatureFlag } from '@/shared/context/split-test-context'
function CurrentFileContainer() {
const { entries, openDocId } = useReviewPanelValueContext()
@ -25,19 +23,10 @@ function CurrentFileContainer() {
>
}, [currentDocEntries])
const enableEmptyState = useFeatureFlag('review-panel-redesign')
const showEmptyState =
enableEmptyState &&
objectEntries.filter(
([key]) => key !== 'add-comment' && key !== 'bulk-actions'
).length === 0
return (
<Container className="rp-current-file-container">
<div className="review-panel-tools">
<Toolbar />
{showEmptyState && <EmptyState />}
<Nav />
</div>
<Toggler />

View file

@ -6,33 +6,15 @@ import Icon from '../../../../shared/components/icon'
import OverviewFile from './overview-file'
import { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { memo, useMemo } from 'react'
import EmptyState from './empty-state'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import { memo } from 'react'
function OverviewContainer() {
const { entries } = useReviewPanelValueContext()
const { isOverviewLoading } = useReviewPanelValueContext()
const { docs } = useFileTreeData()
const entryCount = useMemo(() => {
return docs
?.map(doc => {
const docEntries = entries[doc.doc.id] ?? {}
return Object.keys(docEntries).filter(
key => key !== 'add-comment' && key !== 'bulk-actions'
).length
})
.reduce((acc, curr) => acc + curr, 0)
}, [docs, entries])
const enableEmptyState = useFeatureFlag('review-panel-redesign')
return (
<Container>
<Toggler />
{enableEmptyState && entryCount === 0 && <EmptyState />}
<Toolbar />
<div
className="rp-entry-list"

View file

@ -0,0 +1,37 @@
import { FC, memo } from 'react'
import EditorWidgets from '@/features/source-editor/components/review-panel/editor-widgets/editor-widgets'
import { isCurrentFileView } from '@/features/source-editor/utils/sub-view'
import CurrentFileContainer from '@/features/source-editor/components/review-panel/current-file-container'
import OverviewContainer from '@/features/source-editor/components/review-panel/overview-container'
import { useReviewPanelValueContext } from '@/features/source-editor/context/review-panel/review-panel-context'
import { useLayoutContext } from '@/shared/context/layout-context'
import classnames from 'classnames'
const ReviewPanelContent: FC = () => {
const { subView, loadingThreads, layoutToLeft } = useReviewPanelValueContext()
const { reviewPanelOpen, miniReviewPanelVisible } = useLayoutContext()
const className = classnames('review-panel-wrapper', {
'rp-state-current-file': subView === 'cur_file',
'rp-state-current-file-expanded': subView === 'cur_file' && reviewPanelOpen,
'rp-state-current-file-mini': subView === 'cur_file' && !reviewPanelOpen,
'rp-state-overview': subView === 'overview',
'rp-size-mini': miniReviewPanelVisible,
'rp-size-expanded': reviewPanelOpen,
'rp-layout-left': layoutToLeft,
'rp-loading-threads': loadingThreads,
})
return (
<div className={className}>
<EditorWidgets />
{isCurrentFileView(subView) ? (
<CurrentFileContainer />
) : (
<OverviewContainer />
)}
</div>
)
}
export default memo(ReviewPanelContent)

View file

@ -0,0 +1,19 @@
import React, { FC, lazy, Suspense } from 'react'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import LoadingSpinner from '@/shared/components/loading-spinner'
const ReviewPanel = lazy(() => import('./review-panel'))
const ReviewPanelNew = lazy(
() => import('../../../review-panel-new/components/review-panel-container')
)
export const ReviewPanelMigration: FC = () => {
const newReviewPanel = useFeatureFlag('review-panel-redesign')
return (
<Suspense fallback={<LoadingSpinner delay={500} />}>
{newReviewPanel ? <ReviewPanelNew /> : <ReviewPanel />}
</Suspense>
)
}

View file

@ -1,70 +1,17 @@
import ReactDOM from 'react-dom'
import EditorWidgets from './editor-widgets/editor-widgets'
import CurrentFileContainer from './current-file-container'
import OverviewContainer from './overview-container'
import { useCodeMirrorViewContext } from '../codemirror-editor'
import { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context'
import { isCurrentFileView } from '../../utils/sub-view'
import { useLayoutContext } from '@/shared/context/layout-context'
import classnames from 'classnames'
import { lazy, memo } from 'react'
import { SubView } from '../../../../../../types/review-panel/review-panel'
type ReviewPanelViewProps = {
parentDomNode: Element
}
function ReviewPanelView({ parentDomNode }: ReviewPanelViewProps) {
return ReactDOM.createPortal(<ReviewPanelContainer />, parentDomNode)
}
const ReviewPanelContainer = memo(() => {
const { subView, loadingThreads, layoutToLeft } = useReviewPanelValueContext()
const { reviewPanelOpen, miniReviewPanelVisible } = useLayoutContext()
const className = classnames('review-panel-wrapper', {
'rp-state-current-file': subView === 'cur_file',
'rp-state-current-file-expanded': subView === 'cur_file' && reviewPanelOpen,
'rp-state-current-file-mini': subView === 'cur_file' && !reviewPanelOpen,
'rp-state-overview': subView === 'overview',
'rp-size-mini': miniReviewPanelVisible,
'rp-size-expanded': reviewPanelOpen,
'rp-layout-left': layoutToLeft,
'rp-loading-threads': loadingThreads,
})
return (
<div className={className}>
<ReviewPanelContent subView={subView} />
</div>
)
})
ReviewPanelContainer.displayName = 'ReviewPanelContainer'
const ReviewPanelContent = memo<{ subView: SubView }>(({ subView }) => (
<>
<EditorWidgets />
{isCurrentFileView(subView) ? (
<CurrentFileContainer />
) : (
<OverviewContainer />
)}
</>
))
ReviewPanelContent.displayName = 'ReviewPanelContent'
const ReviewPanelProvider = lazy(
() =>
import('@/features/ide-react/context/review-panel/review-panel-provider')
)
import { ReviewPanelProvider } from '../../context/review-panel/review-panel-context'
import { memo } from 'react'
import ReviewPanelContent from '@/features/source-editor/components/review-panel/review-panel-content'
function ReviewPanel() {
const view = useCodeMirrorViewContext()
return (
return ReactDOM.createPortal(
<ReviewPanelProvider>
<ReviewPanelView parentDomNode={view.scrollDOM} />
</ReviewPanelProvider>
<ReviewPanelContent />
</ReviewPanelProvider>,
view.scrollDOM
)
}

View file

@ -1,5 +1,6 @@
import { createContext, useContext } from 'react'
import type { ReviewPanelState } from './types/review-panel-state'
import useReviewPanelState from '@/features/ide-react/context/review-panel/hooks/use-review-panel-state'
export const ReviewPanelValueContext = createContext<
ReviewPanelState['values'] | undefined
@ -9,6 +10,18 @@ export const ReviewPanelUpdaterFnsContext = createContext<
ReviewPanelState['updaterFns'] | undefined
>(undefined)
export const ReviewPanelProvider: React.FC = ({ children }) => {
const { values, updaterFns } = useReviewPanelState()
return (
<ReviewPanelValueContext.Provider value={values}>
<ReviewPanelUpdaterFnsContext.Provider value={updaterFns}>
{children}
</ReviewPanelUpdaterFnsContext.Provider>
</ReviewPanelValueContext.Provider>
)
}
export function useReviewPanelValueContext() {
const context = useContext(ReviewPanelValueContext)

View file

@ -20,6 +20,7 @@ import { ThreadId } from '../../../../../../types/review-panel/review-panel'
import { isDeleteOperation, isInsertOperation } from '@/utils/operations'
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
import { updateHasEffect } from '@/features/source-editor/utils/effects'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
// With less than this number of entries, don't bother culling to avoid
// little UI jumps when scrolling.
@ -85,7 +86,11 @@ export type UpdateType =
export const createChangeManager = (
view: EditorView,
currentDoc: DocumentContainer
): ChangeManager => {
): ChangeManager | undefined => {
if (isSplitTestEnabled('review-panel-redesign')) {
return undefined
}
/**
* Calculate the screen coordinates of each entry (change or comment),
* for use in the review panel.
@ -261,11 +266,7 @@ export const createChangeManager = (
const text = view.state.doc.sliceString(from, to)
if (text !== content) {
throw new Error(
`Op to be removed (${JSON.stringify(
change.op
)}) does not match editor text '${text}'`
)
throw new Error(`Op to be removed does not match editor text`)
}
return { from, to, insert: '' }

View file

@ -4,6 +4,7 @@ import {
StateEffect,
StateField,
Transaction,
TransactionSpec,
} from '@codemirror/state'
import {
Decoration,
@ -21,14 +22,39 @@ import {
StoredComment,
} from './changes/comments'
import { invertedEffects } from '@codemirror/commands'
import { Change, DeleteOperation } from '../../../../../types/change'
import {
Change,
DeleteOperation,
EditOperation,
} from '../../../../../types/change'
import { ChangeManager } from './changes/change-manager'
import { debugConsole } from '@/utils/debugging'
import { isCommentOperation, isDeleteOperation } from '@/utils/operations'
import {
isCommentOperation,
isDeleteOperation,
isInsertOperation,
} from '@/utils/operations'
import {
DocumentContainer,
RangesTrackerWithResolvedThreadIds,
} from '@/features/ide-react/editor/document-container'
import { trackChangesAnnotation } from '@/features/source-editor/extensions/realtime'
import { Ranges } from '@/features/review-panel-new/context/ranges-context'
import { Threads } from '@/features/review-panel-new/context/threads-context'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
type RangesData = {
ranges: Ranges
threads: Threads
}
const updateRangesEffect = StateEffect.define<RangesData>()
export const updateRanges = (data: RangesData): TransactionSpec => {
return {
effects: updateRangesEffect.of(data),
}
}
const clearChangesEffect = StateEffect.define()
const buildChangesEffect = StateEffect.define()
@ -46,7 +72,9 @@ const restoreDetachedCommentsEffect = StateEffect.define<RangeSet<any>>({
type Options = {
currentDoc: DocumentContainer
loadingThreads: boolean
loadingThreads?: boolean
ranges?: Ranges
threads?: Threads
}
/**
@ -54,8 +82,8 @@ type Options = {
* and produces decorations for tracked changes and comments.
*/
export const trackChanges = (
{ currentDoc, loadingThreads }: Options,
changeManager: ChangeManager
{ currentDoc, loadingThreads, ranges, threads }: Options,
changeManager?: ChangeManager
) => {
// A state field that stored any comments found within the ranges of a "cut" transaction,
// to be restored when pasting matching text.
@ -120,18 +148,20 @@ export const trackChanges = (
cutCommentsState,
// initialize/destroy the change manager, and handle any updates
ViewPlugin.define(() => {
changeManager.initialize()
changeManager
? ViewPlugin.define(() => {
changeManager.initialize()
return {
update: update => {
changeManager.handleUpdate(update)
},
destroy: () => {
changeManager.destroy()
},
}
}),
return {
update: update => {
changeManager.handleUpdate(update)
},
destroy: () => {
changeManager.destroy()
},
}
})
: [],
// draw change decorations
ViewPlugin.define<
@ -140,10 +170,20 @@ export const trackChanges = (
}
>(
() => {
let decorations = Decoration.none
if (isSplitTestEnabled('review-panel-redesign')) {
if (ranges && threads) {
decorations = buildChangeDecorations(currentDoc, {
ranges,
threads,
})
}
} else if (!loadingThreads) {
decorations = buildChangeDecorations(currentDoc)
}
return {
decorations: loadingThreads
? Decoration.none
: buildChangeDecorations(currentDoc),
decorations,
update(update) {
for (const transaction of update.transactions) {
this.decorations = this.decorations.map(transaction.changes)
@ -153,6 +193,11 @@ export const trackChanges = (
this.decorations = Decoration.none
} else if (effect.is(buildChangesEffect)) {
this.decorations = buildChangeDecorations(currentDoc)
} else if (effect.is(updateRangesEffect)) {
this.decorations = buildChangeDecorations(
currentDoc,
effect.value
)
}
}
}
@ -181,18 +226,23 @@ export const buildChangeMarkers = () => {
}
}
const buildChangeDecorations = (currentDoc: DocumentContainer) => {
if (!currentDoc.ranges) {
const buildChangeDecorations = (
currentDoc: DocumentContainer,
data?: RangesData
) => {
const ranges = data ? data.ranges : currentDoc.ranges
if (!ranges) {
return Decoration.none
}
const changes = [...currentDoc.ranges.changes, ...currentDoc.ranges.comments]
const changes = [...ranges.changes, ...ranges.comments]
const decorations = []
for (const change of changes) {
try {
decorations.push(...createChangeRange(change, currentDoc))
decorations.push(...createChangeRange(change, currentDoc, data))
} catch (error) {
// ignore invalid changes
debugConsole.debug('invalid change position', error)
@ -251,7 +301,11 @@ class ChangeCalloutWidget extends WidgetType {
}
}
const createChangeRange = (change: Change, currentDoc: DocumentContainer) => {
const createChangeRange = (
change: Change,
currentDoc: DocumentContainer,
data?: RangesData
) => {
const { id, metadata, op } = change
const from = op.p
@ -289,6 +343,20 @@ const createChangeRange = (change: Change, currentDoc: DocumentContainer) => {
return []
}
if (_isCommentOperation) {
if (data) {
const thread = data.threads[op.t]
if (!thread || thread.resolved) {
return []
}
} else if (
(currentDoc.ranges as RangesTrackerWithResolvedThreadIds)
.resolvedThreadIds![op.t]
) {
return []
}
}
const opType = _isCommentOperation ? 'c' : 'i'
const changedText = _isCommentOperation ? op.c : op.i
const to = from + changedText.length
@ -316,6 +384,107 @@ const createChangeRange = (change: Change, currentDoc: DocumentContainer) => {
return [calloutWidget.range(from, from), changeMark.range(from, to)]
}
/**
* Remove tracked changes from the range tracker when they're rejected,
* and restore the original content
*/
export const rejectChanges = (
state: EditorState,
ranges: DocumentContainer['ranges'],
changeIds: string[]
) => {
const changes = ranges!.getChanges(changeIds) as Change<EditOperation>[]
if (changes.length === 0) {
return {}
}
// When doing bulk rejections, adjacent changes might interact with each other.
// Consider an insertion with an adjacent deletion (which is a common use-case, replacing words):
//
// "foo bar baz" -> "foo quux baz"
//
// The change above will be modeled with two ops, with the insertion going first:
//
// foo quux baz
// |--| -> insertion of "quux", op 1, at position 4
// | -> deletion of "bar", op 2, pushed forward by "quux" to position 8
//
// When rejecting these changes at once, if the insertion is rejected first, we get unexpected
// results. What happens is:
//
// 1) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars
// starting from position 4;
//
// "foo quux baz" -> "foo baz"
// |--| -> 4 characters to be removed
//
// 2) Rejecting the deletion adds the deleted word "bar" at position 8 (i.e. it will act as if
// the word "quuux" was still present).
//
// "foo baz" -> "foo bazbar"
// | -> deletion of "bar" is reverted by reinserting "bar" at position 8
//
// While the intended result would be "foo bar baz", what we get is:
//
// "foo bazbar" (note "bar" readded at position 8)
//
// The issue happens because of step 1. To revert the insertion of "quux", 4 characters are deleted
// from position 4. This includes the position where the deletion exists; when that position is
// cleared, the RangesTracker considers that the deletion is gone and stops tracking/updating it.
// As we still hold a reference to it, the code tries to revert it by readding the deleted text, but
// does so at the outdated position (position 8, which was valid when "quux" was present).
//
// To avoid this kind of problem, we need to make sure that reverting operations doesn't affect
// subsequent operations that come after. Reverse sorting the operations based on position will
// achieve it; in the case above, it makes sure that the the deletion is reverted first:
//
// 1) Rejecting the deletion adds the deleted word "bar" at position 8
//
// "foo quux baz" -> "foo quuxbar baz"
// | -> deletion of "bar" is reverted by
// reinserting "bar" at position 8
//
// 2) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars
// starting from position 4 and achieves the expected result:
//
// "foo quuxbar baz" -> "foo bar baz"
// |--| -> 4 characters to be removed
changes.sort((a, b) => b.op.p - a.op.p)
const changesToDispatch = changes.map(change => {
const { op } = change
if (isInsertOperation(op)) {
const from = op.p
const content = op.i
const to = from + content.length
const text = state.doc.sliceString(from, to)
if (text !== content) {
throw new Error(`Op to be removed does not match editor text`)
}
return { from, to, insert: '' }
} else if (isDeleteOperation(op)) {
return {
from: op.p,
to: op.p,
insert: op.d,
}
} else {
throw new Error(`unknown change type: ${JSON.stringify(change)}`)
}
})
return {
changes: changesToDispatch,
annotations: [trackChangesAnnotation.of('reject')],
}
}
const trackChangesTheme = EditorView.baseTheme({
'.cm-line': {
overflowX: 'hidden', // needed so the callout elements don't overflow (requires line wrapping to be on)

View file

@ -243,5 +243,9 @@ export function updateChangesTopPadding(update: ViewUpdate): boolean {
}
export function editorVerticalTopPadding(view: EditorView): number {
return view.state.field(overflowPaddingState).top
return view.state.field(overflowPaddingState, false)?.top ?? 0
}
export function editorOverflowPadding(view: EditorView) {
return view.state.field(overflowPaddingState, false)
}

View file

@ -62,6 +62,9 @@ import { useMetadataContext } from '@/features/ide-react/context/metadata-contex
import { useUserContext } from '@/shared/context/user-context'
import { useReferencesContext } from '@/features/ide-react/context/references-context'
import { setMathPreview } from '@/features/source-editor/extensions/math-preview'
import { useRangesContext } from '@/features/review-panel-new/context/ranges-context'
import { updateRanges } from '@/features/source-editor/extensions/track-changes'
import { useThreadsContext } from '@/features/review-panel-new/context/threads-context'
function useCodeMirrorScope(view: EditorView) {
const { fileTreeData } = useFileTreeData()
@ -112,6 +115,9 @@ function useCodeMirrorScope(view: EditorView) {
const { referenceKeys } = useReferencesContext()
const ranges = useRangesContext()
const threads = useThreadsContext()
// build the translation phrases
const phrases = usePhrases()
@ -162,6 +168,8 @@ function useCodeMirrorScope(view: EditorView) {
currentDoc,
trackChanges,
loadingThreads,
threads,
ranges,
})
useEffect(() => {
@ -170,6 +178,16 @@ function useCodeMirrorScope(view: EditorView) {
}
}, [view, currentDoc])
useEffect(() => {
currentDocRef.current.ranges = ranges
currentDocRef.current.threads = threads
if (ranges && threads) {
window.setTimeout(() => {
view.dispatch(updateRanges({ ranges, threads }))
})
}
}, [view, ranges, threads])
const docNameRef = useRef(docName)
useEffect(() => {
@ -225,20 +243,26 @@ function useCodeMirrorScope(view: EditorView) {
// listen to project metadata (commands, labels and package names) updates
useEffect(() => {
metadataRef.current = { ...metadataRef.current, ...metadata }
view.dispatch(setMetadata(metadataRef.current))
window.setTimeout(() => {
view.dispatch(setMetadata(metadataRef.current))
})
}, [view, metadata])
// listen to project reference keys updates
useEffect(() => {
metadataRef.current.referenceKeys = referenceKeys
view.dispatch(setMetadata(metadataRef.current))
window.setTimeout(() => {
view.dispatch(setMetadata(metadataRef.current))
})
}, [view, referenceKeys])
// listen to project root folder updates
useEffect(() => {
if (fileTreeData) {
metadataRef.current.fileTreeData = fileTreeData
view.dispatch(setMetadata(metadataRef.current))
window.setTimeout(() => {
view.dispatch(setMetadata(metadataRef.current))
})
}
}, [view, fileTreeData])

View file

@ -1,11 +1,12 @@
import { UserId } from '../../../../../types/user'
import { PublicAccessLevel } from '../../../../../types/public-access-level'
import type * as ReviewPanel from '@/features/source-editor/context/review-panel/types/review-panel-state'
export type ProjectContextMember = {
_id: UserId
privileges: 'readOnly' | 'readAndWrite'
email: string
first_name: string
last_name: string
}
export type ProjectContextValue = {
@ -32,13 +33,17 @@ export type ProjectContextValue = {
owner: {
_id: UserId
email: string
first_name: string
last_name: string
privileges: string
signUpDate: string
}
tags: {
_id: string
name: string
color?: string
}[]
trackChangesState: ReviewPanel.Value<'trackChangesState'>
trackChangesState: boolean | Record<UserId | '__guests__', boolean>
}
export type ProjectContextUpdateValue = Partial<ProjectContextValue>

View file

@ -1,6 +1,9 @@
import {
AnyOperation,
Change,
CommentOperation,
DeleteOperation,
EditOperation,
InsertOperation,
Operation,
} from '../../../types/change'
@ -11,3 +14,27 @@ export const isCommentOperation = (op: Operation): op is CommentOperation =>
'c' in op
export const isDeleteOperation = (op: Operation): op is DeleteOperation =>
'd' in op
export const isInsertChange = (
change: Change<EditOperation>
): change is Change<InsertOperation> => isInsertOperation(change.op)
export const isCommentChange = (
change: Change<CommentOperation>
): change is Change<CommentOperation> => isCommentOperation(change.op)
export const isDeleteChange = (
change: Change<EditOperation>
): change is Change<DeleteOperation> => isDeleteOperation(change.op)
export const visibleTextLength = (op: AnyOperation) => {
if (isCommentOperation(op)) {
return op.c.length
}
if (isInsertOperation(op)) {
return op.i.length
}
return 0
}

View file

@ -14,6 +14,7 @@
@import './editor/math-preview.less';
@import './editor/hotkeys.less';
@import './editor/review-panel.less';
@import './editor/review-panel-new.less';
@import './editor/publish-modal.less';
@import './editor/outline.less';
@import './editor/logs.less';

View file

@ -0,0 +1,361 @@
.review-panel-container {
height: 100%;
flex-shrink: 0;
}
.review-panel-new {
z-index: 6;
flex-shrink: 0;
background-color: @neutral-10;
border-left: solid 0 @neutral-20;
font-family: @font-family-base;
line-height: @line-height-base;
font-size: @font-size-01;
box-sizing: content-box;
.review-panel-entry {
background-color: white;
border-radius: @border-radius-base-new;
border: 1px solid @neutral-20;
padding: @spacing-04;
width: calc(100% - @spacing-04);
margin-left: @spacing-02;
z-index: 1;
}
.review-panel-entry-indicator {
display: none;
}
.review-panel-entry-content {
display: flex;
flex-direction: column;
gap: @spacing-04;
}
.review-panel-entry-focused {
margin-left: @spacing-01;
border: 2px solid @blue-50;
}
.review-panel-entry-header {
display: flex;
justify-content: space-between;
.review-panel-entry-user {
color: @blue;
font-size: 110%;
}
.review-panel-entry-time {
color: @content-secondary;
}
.review-panel-entry-actions {
display: flex;
align-items: center;
gap: @spacing-03;
.btn {
background-color: transparent;
padding: 0;
height: 24px;
width: 24px;
&:hover,
&:focus {
background-color: @neutral-20;
color: @content-primary;
}
}
.review-panel-entry-actions-icon {
padding: @spacing-01;
font-size: 20px;
}
}
}
.review-panel-change-body {
display: flex;
align-items: center;
color: @content-secondary;
gap: @spacing-02;
}
.review-panel-content-highlight {
color: @content-primary;
text-decoration: none;
}
del.review-panel-content-highlight {
text-decoration: line-through;
}
.review-panel-entry-icon {
border-radius: @border-radius-base-new;
padding: @spacing-02;
font-size: 16px;
}
.review-panel-entry-icon-accept {
background-color: @green-10;
color: @green-50;
}
.review-panel-entry-icon-reject {
background-color: @red-10;
color: @red-50;
}
.review-panel-entry-icon-changed {
background-color: @neutral-20;
color: @content-secondary;
}
.review-panel-header {
position: fixed;
top: 0;
z-index: 2;
display: flex;
flex-direction: column;
justify-content: center;
border-bottom: 1px solid @rp-border-grey;
background-color: white;
text-align: center;
}
// TODO: Update this when we move the track changes menu to the new design
.rp-tc-state {
background-color: white;
}
.review-panel-tools {
display: flex;
align-items: center;
justify-content: space-between;
padding-left: 4px;
padding-right: 12px;
flex-shrink: 0;
flex-basis: 32px;
}
.resolved-comments-toggle {
display: flex;
align-items: center;
justify-content: center;
}
.track-changes-indicator-circle {
width: 8px;
height: 8px;
border-radius: 100%;
background-color: @green-50;
}
.track-changes-menu-button {
border: none;
background: none;
padding: 0;
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
i {
width: 8px;
}
}
.review-panel-heading {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 4px;
.review-panel-label {
font-family: Lato, sans-serif;
font-size: 14px;
font-weight: bold;
margin: 0;
}
.review-panel-close-button {
display: flex;
align-items: center;
border: none;
background-color: transparent;
color: @content-primary;
padding: 2px;
&:hover,
&:focus {
background-color: @neutral-20;
}
}
}
.review-panel-comment-wrapper {
display: flex;
gap: @spacing-04;
}
.review-panel-comment {
flex-grow: 1;
}
.review-panel-comment-reply-divider {
border-left: 2px solid @yellow-20;
}
.review-panel-comment-body {
font-size: @font-size-02;
color: @content-primary;
overflow-wrap: anywhere;
}
.review-panel-content-expandable  {
display: -webkit-box;
text-overflow: ellipsis;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;
overflow: hidden;
}
.review-panel-content-expanded {
display: block;
}
.review-panel-comment-input {
width: 100%;
font-size: @rp-base-font-size;
padding: 2px @spacing-03;
border-radius: @border-radius-base-new;
border: solid 1px @neutral-60;
resize: vertical;
color: @rp-type-darkgrey;
background-color: #fff;
height: 25px;
min-height: 25px;
overflow-x: hidden;
max-height: 400px;
}
.review-panel-empty-state {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
pointer-events: none;
}
.review-panel-empty-state-inner {
position: sticky;
top: 50%;
transform: translateY(-50%);
width: 100%;
padding-left: 16px;
padding-right: 16px;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
p {
margin-bottom: 0;
text-align: center;
}
}
.review-panel-empty-state-comment-icon {
width: 80px;
height: 80px;
background-color: white;
border-radius: 100%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
.material-symbols {
font-size: 32px;
}
}
.review-panel-overview {
padding: 4px;
position: absolute;
top: 69px;
bottom: 30px;
width: 100%;
overflow: auto;
overscroll-behavior-block: none;
.review-panel-entry {
margin-left: 0;
width: 100%;
}
}
.review-panel-overview-file-header {
all: unset;
padding: 6px 8px;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
width: 100%;
}
.review-panel-overfile-divider {
border-bottom: 1px solid #e7e9ee;
margin: 2px 0;
}
.review-panel-overview-file-entries {
overflow: hidden;
padding-top: 4px;
padding-bottom: 6px;
}
.review-panel-overview-file-entry-count {
background-color: @neutral-20;
padding: 2px 4px;
margin-left: auto;
border-radius: @border-radius-base;
}
.review-panel-footer {
z-index: 2;
}
}
.review-panel-new.review-panel-mini {
width: 22px !important;
overflow: visible !important;
.review-panel-entry {
margin-left: 2px;
background-color: transparent;
border: none;
}
.review-panel-entry-indicator {
position: absolute;
left: 0;
top: 7px;
display: flex;
width: 16px;
height: 16px;
color: @content-secondary;
cursor: pointer;
}
.review-panel-entry-content {
display: none;
background: white;
border: 1px solid @rp-border-grey;
border-radius: @border-radius-base-new;
width: 200px;
padding: @spacing-02;
}
.review-panel-entry:hover {
.review-panel-entry-content {
display: initial;
position: absolute;
left: -200px;
top: 0;
}
}
}

View file

@ -1134,44 +1134,40 @@ button when (@is-overleaf-light = true) {
}
}
.rp-empty-state {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
.review-panel-indicator {
display: none;
}
.rp-empty-state-inner {
position: sticky;
top: 50%;
transform: translateY(-50%);
.review-panel-mini {
width: 22px !important;
overflow: visible !important;
width: 100%;
padding-left: 16px;
padding-right: 16px;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
p {
margin-bottom: 0;
text-align: center;
}
.rp-entry {
height: 12px;
}
.rp-empty-state-comment-icon {
width: 80px;
height: 80px;
background-color: white;
border-radius: 100%;
.rp-entry-indicator {
position: absolute;
left: -6px;
right: 0;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
width: 20px;
height: 20px;
}
.material-symbols {
font-size: 32px;
.rp-entry-content {
display: none;
background: white;
border: 1px solid @rp-border-grey;
width: 200px;
}
.rp-entry:hover {
.rp-entry-content {
display: initial;
position: absolute;
left: -206px;
top: 0;
}
}
}

View file

@ -32,6 +32,7 @@
"accept": "Accept",
"accept_all": "Accept all",
"accept_and_continue": "Accept and continue",
"accept_change": "Accept change",
"accept_invitation": "Accept invitation",
"accept_or_reject_each_changes_individually": "Accept or reject each change individually",
"accept_terms_and_conditions": "Accept terms and conditions",
@ -415,6 +416,10 @@
"delete_authentication_token": "Delete Authentication token",
"delete_authentication_token_info": "Youre about to delete a Git authentication token. If you do, it can no longer be used to authenticate your identity when performing Git operations.",
"delete_certificate": "Delete certificate",
"delete_comment": "Delete comment",
"delete_comment_message": "You cannot undo this action.",
"delete_comment_thread": "Delete comment thread",
"delete_comment_thread_message": "This will delete the whole comment thread. You cannot undo this action.",
"delete_figure": "Delete figure",
"delete_projects": "Delete Projects",
"delete_row_or_column": "Delete row or column",
@ -1212,6 +1217,7 @@
"more": "More",
"more_actions": "More actions",
"more_info": "More Info",
"more_options": "More options",
"more_options_for_border_settings_coming_soon": "More options for border settings coming soon.",
"more_project_collaborators": "<0>More</0> project <0>collaborators</0>",
"more_than_one_kind_of_snippet_was_requested": "The link to open this content on Overleaf included some invalid parameters. If this keeps happening for links on a particular site, please report this to them.",
@ -1600,6 +1606,7 @@
"registration_error": "Registration error",
"reject": "Reject",
"reject_all": "Reject all",
"reject_change": "Reject change",
"related_tags": "Related Tags",
"relink_your_account": "Re-link your account",
"reload_editor": "Reload editor",
@ -1652,6 +1659,7 @@
"reset_your_password": "Reset your password",
"resize": "Resize",
"resolve": "Resolve",
"resolve_comment": "Resolve comment",
"resolved_comments": "Resolved comments",
"restore": "Restore",
"restore_file": "Restore file",
@ -1802,6 +1810,7 @@
"show_in_pdf": "Show in PDF",
"show_less": "show less",
"show_local_file_contents": "Show Local File Contents",
"show_more": "show more",
"show_outline": "Show File outline",
"show_x_more_projects": "Show __x__ more projects",
"show_your_support": "Show your support",

View file

@ -5,7 +5,6 @@ import { mockScope } from '../helpers/mock-scope'
import { EditorProviders } from '../../../helpers/editor-providers'
import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { activeEditorLine } from '../helpers/active-editor-line'
import { User, UserId } from '../../../../../types/user'
import { TestContainer } from '../helpers/test-container'
import { FC } from 'react'
import { MetadataContext } from '@/features/ide-react/context/metadata-context'
@ -759,9 +758,9 @@ describe('autocomplete', { scrollBehavior: false }, function () {
window.metaAttributesCache.set('ol-showSymbolPalette', true)
const user = {
id: '123abd' as UserId,
id: '123abd',
email: 'testuser@example.com',
} as User
}
cy.mount(
<TestContainer>
<EditorProviders user={user} scope={scope}>

View file

@ -26,7 +26,7 @@ export type Features = {
}
export type User = {
id: UserId
id: UserId | null
isAdmin?: boolean
email: string
allowedFreeTrial?: boolean