mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
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:
parent
8736bee460
commit
2304536844
55 changed files with 2970 additions and 211 deletions
|
@ -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": "",
|
||||
|
|
|
@ -67,7 +67,7 @@ const ChatPane = React.memo(function ChatPane() {
|
|||
throw error
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
if (!user?.id) {
|
||||
return null
|
||||
}
|
||||
if (!chatOpenedOnce) {
|
||||
|
|
|
@ -225,7 +225,7 @@ function AllHistoryList() {
|
|||
showDivider={showDivider}
|
||||
setSelection={setSelection}
|
||||
selectionState={selectionState}
|
||||
currentUserId={currentUserId}
|
||||
currentUserId={currentUserId!}
|
||||
selectable={selectable}
|
||||
projectId={projectId}
|
||||
setActiveDropdownItem={setActiveDropdownItem}
|
||||
|
|
|
@ -33,7 +33,7 @@ function LabelsList() {
|
|||
key={version}
|
||||
labels={labels}
|
||||
version={version}
|
||||
currentUserId={currentUserId}
|
||||
currentUserId={currentUserId!}
|
||||
projectId={projectId}
|
||||
selectionState={selectionState}
|
||||
selectable={selectionState !== 'selected'}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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'
|
|
@ -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')}:
|
||||
<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')}:
|
||||
<del className="review-panel-content-highlight">
|
||||
{change.op.d}
|
||||
</del>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
ReviewPanelChange.displayName = 'ReviewPanelChange'
|
|
@ -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)
|
|
@ -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'
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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)
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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)
|
|
@ -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)
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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)
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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 }
|
||||
}
|
|
@ -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'
|
||||
}
|
|
@ -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
|
|
@ -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)
|
||||
}
|
|
@ -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
|
|
@ -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 }
|
||||
)
|
|
@ -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,6 +62,7 @@ function CodeMirrorEditor() {
|
|||
return (
|
||||
<CodeMirrorStateContext.Provider value={state}>
|
||||
<CodeMirrorViewContextProvider value={viewRef.current}>
|
||||
<ReviewPanelProviders>
|
||||
<CodemirrorOutline />
|
||||
<CodeMirrorView />
|
||||
<FigureModal />
|
||||
|
@ -73,12 +75,13 @@ function CodeMirrorEditor() {
|
|||
)}
|
||||
<CodeMirrorCommandTooltip />
|
||||
|
||||
{shouldShowReviewPanel && <ReviewPanel />}
|
||||
{shouldShowReviewPanel && <ReviewPanelMigration />}
|
||||
{sourceEditorComponents.map(
|
||||
({ import: { default: Component }, path }) => (
|
||||
<Component key={path} />
|
||||
)
|
||||
)}
|
||||
</ReviewPanelProviders>
|
||||
</CodeMirrorViewContextProvider>
|
||||
</CodeMirrorStateContext.Provider>
|
||||
)
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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: '' }
|
||||
|
|
|
@ -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,7 +148,8 @@ export const trackChanges = (
|
|||
cutCommentsState,
|
||||
|
||||
// initialize/destroy the change manager, and handle any updates
|
||||
ViewPlugin.define(() => {
|
||||
changeManager
|
||||
? ViewPlugin.define(() => {
|
||||
changeManager.initialize()
|
||||
|
||||
return {
|
||||
|
@ -131,7 +160,8 @@ export const trackChanges = (
|
|||
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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
window.setTimeout(() => {
|
||||
view.dispatch(setMetadata(metadataRef.current))
|
||||
})
|
||||
}, [view, metadata])
|
||||
|
||||
// listen to project reference keys updates
|
||||
useEffect(() => {
|
||||
metadataRef.current.referenceKeys = referenceKeys
|
||||
window.setTimeout(() => {
|
||||
view.dispatch(setMetadata(metadataRef.current))
|
||||
})
|
||||
}, [view, referenceKeys])
|
||||
|
||||
// listen to project root folder updates
|
||||
useEffect(() => {
|
||||
if (fileTreeData) {
|
||||
metadataRef.current.fileTreeData = fileTreeData
|
||||
window.setTimeout(() => {
|
||||
view.dispatch(setMetadata(metadataRef.current))
|
||||
})
|
||||
}
|
||||
}, [view, fileTreeData])
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1134,44 +1134,40 @@ button when (@is-overleaf-light = true) {
|
|||
}
|
||||
}
|
||||
|
||||
.rp-empty-state {
|
||||
.review-panel-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.review-panel-mini {
|
||||
width: 22px !important;
|
||||
overflow: visible !important;
|
||||
|
||||
.rp-entry {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.rp-entry-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
left: -6px;
|
||||
right: 0;
|
||||
|
||||
.rp-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;
|
||||
}
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.rp-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;
|
||||
.rp-entry-content {
|
||||
display: none;
|
||||
background: white;
|
||||
border: 1px solid @rp-border-grey;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.material-symbols {
|
||||
font-size: 32px;
|
||||
.rp-entry:hover {
|
||||
.rp-entry-content {
|
||||
display: initial;
|
||||
position: absolute;
|
||||
left: -206px;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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": "You’re 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",
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -26,7 +26,7 @@ export type Features = {
|
|||
}
|
||||
|
||||
export type User = {
|
||||
id: UserId
|
||||
id: UserId | null
|
||||
isAdmin?: boolean
|
||||
email: string
|
||||
allowedFreeTrial?: boolean
|
||||
|
|
Loading…
Reference in a new issue