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": "",
|
||||||
"accept_all": "",
|
"accept_all": "",
|
||||||
"accept_and_continue": "",
|
"accept_and_continue": "",
|
||||||
|
"accept_change": "",
|
||||||
"accept_invitation": "",
|
"accept_invitation": "",
|
||||||
"accept_or_reject_each_changes_individually": "",
|
"accept_or_reject_each_changes_individually": "",
|
||||||
"accept_terms_and_conditions": "",
|
"accept_terms_and_conditions": "",
|
||||||
|
@ -281,6 +282,10 @@
|
||||||
"delete_authentication_token": "",
|
"delete_authentication_token": "",
|
||||||
"delete_authentication_token_info": "",
|
"delete_authentication_token_info": "",
|
||||||
"delete_certificate": "",
|
"delete_certificate": "",
|
||||||
|
"delete_comment": "",
|
||||||
|
"delete_comment_message": "",
|
||||||
|
"delete_comment_thread": "",
|
||||||
|
"delete_comment_thread_message": "",
|
||||||
"delete_figure": "",
|
"delete_figure": "",
|
||||||
"delete_projects": "",
|
"delete_projects": "",
|
||||||
"delete_row_or_column": "",
|
"delete_row_or_column": "",
|
||||||
|
@ -822,6 +827,7 @@
|
||||||
"more": "",
|
"more": "",
|
||||||
"more_actions": "",
|
"more_actions": "",
|
||||||
"more_info": "",
|
"more_info": "",
|
||||||
|
"more_options": "",
|
||||||
"more_options_for_border_settings_coming_soon": "",
|
"more_options_for_border_settings_coming_soon": "",
|
||||||
"my_library": "",
|
"my_library": "",
|
||||||
"n_items": "",
|
"n_items": "",
|
||||||
|
@ -1080,6 +1086,7 @@
|
||||||
"regards": "",
|
"regards": "",
|
||||||
"reject": "",
|
"reject": "",
|
||||||
"reject_all": "",
|
"reject_all": "",
|
||||||
|
"reject_change": "",
|
||||||
"relink_your_account": "",
|
"relink_your_account": "",
|
||||||
"reload_editor": "",
|
"reload_editor": "",
|
||||||
"remind_before_trial_ends": "",
|
"remind_before_trial_ends": "",
|
||||||
|
@ -1118,6 +1125,7 @@
|
||||||
"resending_confirmation_email": "",
|
"resending_confirmation_email": "",
|
||||||
"resize": "",
|
"resize": "",
|
||||||
"resolve": "",
|
"resolve": "",
|
||||||
|
"resolve_comment": "",
|
||||||
"resolved_comments": "",
|
"resolved_comments": "",
|
||||||
"restore": "",
|
"restore": "",
|
||||||
"restore_file": "",
|
"restore_file": "",
|
||||||
|
@ -1242,6 +1250,7 @@
|
||||||
"show_in_pdf": "",
|
"show_in_pdf": "",
|
||||||
"show_less": "",
|
"show_less": "",
|
||||||
"show_local_file_contents": "",
|
"show_local_file_contents": "",
|
||||||
|
"show_more": "",
|
||||||
"show_outline": "",
|
"show_outline": "",
|
||||||
"show_x_more_projects": "",
|
"show_x_more_projects": "",
|
||||||
"showing_1_result": "",
|
"showing_1_result": "",
|
||||||
|
@ -1479,6 +1488,7 @@
|
||||||
"track_any_change_in_real_time": "",
|
"track_any_change_in_real_time": "",
|
||||||
"track_changes": "",
|
"track_changes": "",
|
||||||
"track_changes_for_everyone": "",
|
"track_changes_for_everyone": "",
|
||||||
|
"track_changes_for_guests": "",
|
||||||
"track_changes_for_x": "",
|
"track_changes_for_x": "",
|
||||||
"track_changes_is_off": "",
|
"track_changes_is_off": "",
|
||||||
"track_changes_is_on": "",
|
"track_changes_is_on": "",
|
||||||
|
|
|
@ -67,7 +67,7 @@ const ChatPane = React.memo(function ChatPane() {
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user?.id) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
if (!chatOpenedOnce) {
|
if (!chatOpenedOnce) {
|
||||||
|
|
|
@ -225,7 +225,7 @@ function AllHistoryList() {
|
||||||
showDivider={showDivider}
|
showDivider={showDivider}
|
||||||
setSelection={setSelection}
|
setSelection={setSelection}
|
||||||
selectionState={selectionState}
|
selectionState={selectionState}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId!}
|
||||||
selectable={selectable}
|
selectable={selectable}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
setActiveDropdownItem={setActiveDropdownItem}
|
setActiveDropdownItem={setActiveDropdownItem}
|
||||||
|
|
|
@ -33,7 +33,7 @@ function LabelsList() {
|
||||||
key={version}
|
key={version}
|
||||||
labels={labels}
|
labels={labels}
|
||||||
version={version}
|
version={version}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId!}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
selectionState={selectionState}
|
selectionState={selectionState}
|
||||||
selectable={selectionState !== 'selected'}
|
selectable={selectionState !== 'selected'}
|
||||||
|
|
|
@ -282,7 +282,7 @@ function useReviewPanelState(): ReviewPanel.ReviewPanelState {
|
||||||
const tempUsers = {} as ReviewPanel.Value<'users'>
|
const tempUsers = {} as ReviewPanel.Value<'users'>
|
||||||
// Always include ourself, since if we submit an op, we might need to display info
|
// 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
|
// about it locally before it has been flushed through the server
|
||||||
if (user) {
|
if (user?.id) {
|
||||||
tempUsers[user.id] = formatUser(user)
|
tempUsers[user.id] = formatUser(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -724,7 +724,7 @@ function useReviewPanelState(): ReviewPanel.ReviewPanelState {
|
||||||
)
|
)
|
||||||
|
|
||||||
const applyTrackChangesStateToClient = useCallback(
|
const applyTrackChangesStateToClient = useCallback(
|
||||||
(state: boolean | ReviewPanel.Value<'trackChangesState'>) => {
|
(state: boolean | Record<UserId | '__guests__', boolean>) => {
|
||||||
if (typeof state === 'boolean') {
|
if (typeof state === 'boolean') {
|
||||||
setEveryoneTCState(state)
|
setEveryoneTCState(state)
|
||||||
setGuestsTCState(state)
|
setGuestsTCState(state)
|
||||||
|
@ -1189,8 +1189,8 @@ function useReviewPanelState(): ReviewPanel.ReviewPanelState {
|
||||||
// listen for events from the CodeMirror 6 track changes extension
|
// listen for events from the CodeMirror 6 track changes extension
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const toggleTrackChangesFromKbdShortcut = () => {
|
const toggleTrackChangesFromKbdShortcut = () => {
|
||||||
if (trackChangesVisible && trackChanges) {
|
const userId = user.id
|
||||||
const userId: UserId = user.id
|
if (trackChangesVisible && trackChanges && userId) {
|
||||||
const state = trackChangesState[userId]
|
const state = trackChangesState[userId]
|
||||||
if (state) {
|
if (state) {
|
||||||
toggleTrackChangesForUser(!state.value, userId)
|
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 MaterialIcon from '@/shared/components/material-icon'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
function EmptyState() {
|
function ReviewPanelEmptyState() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rp-empty-state">
|
<div className="review-panel-empty-state">
|
||||||
<div className="rp-empty-state-inner">
|
<div className="review-panel-empty-state-inner">
|
||||||
<div className="rp-empty-state-comment-icon">
|
<div className="review-panel-empty-state-comment-icon">
|
||||||
<MaterialIcon type="question_answer" />
|
<MaterialIcon type="question_answer" />
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<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 importOverleafModules from '../../../../macros/import-overleaf-module.macro'
|
||||||
import { FigureModal } from './figure-modal/figure-modal'
|
import { FigureModal } from './figure-modal/figure-modal'
|
||||||
import ReviewPanel from './review-panel/review-panel'
|
|
||||||
import useViewerPermissions from '@/shared/hooks/use-viewer-permissions'
|
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(
|
const sourceEditorComponents = importOverleafModules(
|
||||||
'sourceEditorComponents'
|
'sourceEditorComponents'
|
||||||
|
@ -61,24 +62,26 @@ function CodeMirrorEditor() {
|
||||||
return (
|
return (
|
||||||
<CodeMirrorStateContext.Provider value={state}>
|
<CodeMirrorStateContext.Provider value={state}>
|
||||||
<CodeMirrorViewContextProvider value={viewRef.current}>
|
<CodeMirrorViewContextProvider value={viewRef.current}>
|
||||||
<CodemirrorOutline />
|
<ReviewPanelProviders>
|
||||||
<CodeMirrorView />
|
<CodemirrorOutline />
|
||||||
<FigureModal />
|
<CodeMirrorView />
|
||||||
<CodeMirrorSearch />
|
<FigureModal />
|
||||||
<CodeMirrorToolbar />
|
<CodeMirrorSearch />
|
||||||
{sourceEditorToolbarComponents.map(
|
<CodeMirrorToolbar />
|
||||||
({ import: { default: Component }, path }) => (
|
{sourceEditorToolbarComponents.map(
|
||||||
<Component key={path} />
|
({ import: { default: Component }, path }) => (
|
||||||
)
|
<Component key={path} />
|
||||||
)}
|
)
|
||||||
<CodeMirrorCommandTooltip />
|
)}
|
||||||
|
<CodeMirrorCommandTooltip />
|
||||||
|
|
||||||
{shouldShowReviewPanel && <ReviewPanel />}
|
{shouldShowReviewPanel && <ReviewPanelMigration />}
|
||||||
{sourceEditorComponents.map(
|
{sourceEditorComponents.map(
|
||||||
({ import: { default: Component }, path }) => (
|
({ import: { default: Component }, path }) => (
|
||||||
<Component key={path} />
|
<Component key={path} />
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
</ReviewPanelProviders>
|
||||||
</CodeMirrorViewContextProvider>
|
</CodeMirrorViewContextProvider>
|
||||||
</CodeMirrorStateContext.Provider>
|
</CodeMirrorStateContext.Provider>
|
||||||
)
|
)
|
||||||
|
|
|
@ -9,8 +9,6 @@ import useCodeMirrorContentHeight from '../../hooks/use-codemirror-content-heigh
|
||||||
import { ReviewPanelEntry } from '../../../../../../types/review-panel/entry'
|
import { ReviewPanelEntry } from '../../../../../../types/review-panel/entry'
|
||||||
import { ReviewPanelDocEntries } from '../../../../../../types/review-panel/review-panel'
|
import { ReviewPanelDocEntries } from '../../../../../../types/review-panel/review-panel'
|
||||||
import Entry from './entry'
|
import Entry from './entry'
|
||||||
import EmptyState from './empty-state'
|
|
||||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
|
||||||
|
|
||||||
function CurrentFileContainer() {
|
function CurrentFileContainer() {
|
||||||
const { entries, openDocId } = useReviewPanelValueContext()
|
const { entries, openDocId } = useReviewPanelValueContext()
|
||||||
|
@ -25,19 +23,10 @@ function CurrentFileContainer() {
|
||||||
>
|
>
|
||||||
}, [currentDocEntries])
|
}, [currentDocEntries])
|
||||||
|
|
||||||
const enableEmptyState = useFeatureFlag('review-panel-redesign')
|
|
||||||
|
|
||||||
const showEmptyState =
|
|
||||||
enableEmptyState &&
|
|
||||||
objectEntries.filter(
|
|
||||||
([key]) => key !== 'add-comment' && key !== 'bulk-actions'
|
|
||||||
).length === 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="rp-current-file-container">
|
<Container className="rp-current-file-container">
|
||||||
<div className="review-panel-tools">
|
<div className="review-panel-tools">
|
||||||
<Toolbar />
|
<Toolbar />
|
||||||
{showEmptyState && <EmptyState />}
|
|
||||||
<Nav />
|
<Nav />
|
||||||
</div>
|
</div>
|
||||||
<Toggler />
|
<Toggler />
|
||||||
|
|
|
@ -6,33 +6,15 @@ import Icon from '../../../../shared/components/icon'
|
||||||
import OverviewFile from './overview-file'
|
import OverviewFile from './overview-file'
|
||||||
import { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context'
|
import { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context'
|
||||||
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||||
import { memo, useMemo } from 'react'
|
import { memo } from 'react'
|
||||||
import EmptyState from './empty-state'
|
|
||||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
|
||||||
|
|
||||||
function OverviewContainer() {
|
function OverviewContainer() {
|
||||||
const { entries } = useReviewPanelValueContext()
|
|
||||||
|
|
||||||
const { isOverviewLoading } = useReviewPanelValueContext()
|
const { isOverviewLoading } = useReviewPanelValueContext()
|
||||||
const { docs } = useFileTreeData()
|
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 (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Toggler />
|
<Toggler />
|
||||||
{enableEmptyState && entryCount === 0 && <EmptyState />}
|
|
||||||
<Toolbar />
|
<Toolbar />
|
||||||
<div
|
<div
|
||||||
className="rp-entry-list"
|
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 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 { useCodeMirrorViewContext } from '../codemirror-editor'
|
||||||
import { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context'
|
import { ReviewPanelProvider } from '../../context/review-panel/review-panel-context'
|
||||||
import { isCurrentFileView } from '../../utils/sub-view'
|
import { memo } from 'react'
|
||||||
import { useLayoutContext } from '@/shared/context/layout-context'
|
import ReviewPanelContent from '@/features/source-editor/components/review-panel/review-panel-content'
|
||||||
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')
|
|
||||||
)
|
|
||||||
|
|
||||||
function ReviewPanel() {
|
function ReviewPanel() {
|
||||||
const view = useCodeMirrorViewContext()
|
const view = useCodeMirrorViewContext()
|
||||||
|
|
||||||
return (
|
return ReactDOM.createPortal(
|
||||||
<ReviewPanelProvider>
|
<ReviewPanelProvider>
|
||||||
<ReviewPanelView parentDomNode={view.scrollDOM} />
|
<ReviewPanelContent />
|
||||||
</ReviewPanelProvider>
|
</ReviewPanelProvider>,
|
||||||
|
view.scrollDOM
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { createContext, useContext } from 'react'
|
import { createContext, useContext } from 'react'
|
||||||
import type { ReviewPanelState } from './types/review-panel-state'
|
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<
|
export const ReviewPanelValueContext = createContext<
|
||||||
ReviewPanelState['values'] | undefined
|
ReviewPanelState['values'] | undefined
|
||||||
|
@ -9,6 +10,18 @@ export const ReviewPanelUpdaterFnsContext = createContext<
|
||||||
ReviewPanelState['updaterFns'] | undefined
|
ReviewPanelState['updaterFns'] | undefined
|
||||||
>(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() {
|
export function useReviewPanelValueContext() {
|
||||||
const context = useContext(ReviewPanelValueContext)
|
const context = useContext(ReviewPanelValueContext)
|
||||||
|
|
||||||
|
|
|
@ -20,6 +20,7 @@ import { ThreadId } from '../../../../../../types/review-panel/review-panel'
|
||||||
import { isDeleteOperation, isInsertOperation } from '@/utils/operations'
|
import { isDeleteOperation, isInsertOperation } from '@/utils/operations'
|
||||||
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
|
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
|
||||||
import { updateHasEffect } from '@/features/source-editor/utils/effects'
|
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
|
// With less than this number of entries, don't bother culling to avoid
|
||||||
// little UI jumps when scrolling.
|
// little UI jumps when scrolling.
|
||||||
|
@ -85,7 +86,11 @@ export type UpdateType =
|
||||||
export const createChangeManager = (
|
export const createChangeManager = (
|
||||||
view: EditorView,
|
view: EditorView,
|
||||||
currentDoc: DocumentContainer
|
currentDoc: DocumentContainer
|
||||||
): ChangeManager => {
|
): ChangeManager | undefined => {
|
||||||
|
if (isSplitTestEnabled('review-panel-redesign')) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate the screen coordinates of each entry (change or comment),
|
* Calculate the screen coordinates of each entry (change or comment),
|
||||||
* for use in the review panel.
|
* for use in the review panel.
|
||||||
|
@ -261,11 +266,7 @@ export const createChangeManager = (
|
||||||
const text = view.state.doc.sliceString(from, to)
|
const text = view.state.doc.sliceString(from, to)
|
||||||
|
|
||||||
if (text !== content) {
|
if (text !== content) {
|
||||||
throw new Error(
|
throw new Error(`Op to be removed does not match editor text`)
|
||||||
`Op to be removed (${JSON.stringify(
|
|
||||||
change.op
|
|
||||||
)}) does not match editor text '${text}'`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { from, to, insert: '' }
|
return { from, to, insert: '' }
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {
|
||||||
StateEffect,
|
StateEffect,
|
||||||
StateField,
|
StateField,
|
||||||
Transaction,
|
Transaction,
|
||||||
|
TransactionSpec,
|
||||||
} from '@codemirror/state'
|
} from '@codemirror/state'
|
||||||
import {
|
import {
|
||||||
Decoration,
|
Decoration,
|
||||||
|
@ -21,14 +22,39 @@ import {
|
||||||
StoredComment,
|
StoredComment,
|
||||||
} from './changes/comments'
|
} from './changes/comments'
|
||||||
import { invertedEffects } from '@codemirror/commands'
|
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 { ChangeManager } from './changes/change-manager'
|
||||||
import { debugConsole } from '@/utils/debugging'
|
import { debugConsole } from '@/utils/debugging'
|
||||||
import { isCommentOperation, isDeleteOperation } from '@/utils/operations'
|
import {
|
||||||
|
isCommentOperation,
|
||||||
|
isDeleteOperation,
|
||||||
|
isInsertOperation,
|
||||||
|
} from '@/utils/operations'
|
||||||
import {
|
import {
|
||||||
DocumentContainer,
|
DocumentContainer,
|
||||||
RangesTrackerWithResolvedThreadIds,
|
RangesTrackerWithResolvedThreadIds,
|
||||||
} from '@/features/ide-react/editor/document-container'
|
} 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 clearChangesEffect = StateEffect.define()
|
||||||
const buildChangesEffect = StateEffect.define()
|
const buildChangesEffect = StateEffect.define()
|
||||||
|
@ -46,7 +72,9 @@ const restoreDetachedCommentsEffect = StateEffect.define<RangeSet<any>>({
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
currentDoc: DocumentContainer
|
currentDoc: DocumentContainer
|
||||||
loadingThreads: boolean
|
loadingThreads?: boolean
|
||||||
|
ranges?: Ranges
|
||||||
|
threads?: Threads
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -54,8 +82,8 @@ type Options = {
|
||||||
* and produces decorations for tracked changes and comments.
|
* and produces decorations for tracked changes and comments.
|
||||||
*/
|
*/
|
||||||
export const trackChanges = (
|
export const trackChanges = (
|
||||||
{ currentDoc, loadingThreads }: Options,
|
{ currentDoc, loadingThreads, ranges, threads }: Options,
|
||||||
changeManager: ChangeManager
|
changeManager?: ChangeManager
|
||||||
) => {
|
) => {
|
||||||
// A state field that stored any comments found within the ranges of a "cut" transaction,
|
// A state field that stored any comments found within the ranges of a "cut" transaction,
|
||||||
// to be restored when pasting matching text.
|
// to be restored when pasting matching text.
|
||||||
|
@ -120,18 +148,20 @@ export const trackChanges = (
|
||||||
cutCommentsState,
|
cutCommentsState,
|
||||||
|
|
||||||
// initialize/destroy the change manager, and handle any updates
|
// initialize/destroy the change manager, and handle any updates
|
||||||
ViewPlugin.define(() => {
|
changeManager
|
||||||
changeManager.initialize()
|
? ViewPlugin.define(() => {
|
||||||
|
changeManager.initialize()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
update: update => {
|
update: update => {
|
||||||
changeManager.handleUpdate(update)
|
changeManager.handleUpdate(update)
|
||||||
},
|
},
|
||||||
destroy: () => {
|
destroy: () => {
|
||||||
changeManager.destroy()
|
changeManager.destroy()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
|
: [],
|
||||||
|
|
||||||
// draw change decorations
|
// draw change decorations
|
||||||
ViewPlugin.define<
|
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 {
|
return {
|
||||||
decorations: loadingThreads
|
decorations,
|
||||||
? Decoration.none
|
|
||||||
: buildChangeDecorations(currentDoc),
|
|
||||||
update(update) {
|
update(update) {
|
||||||
for (const transaction of update.transactions) {
|
for (const transaction of update.transactions) {
|
||||||
this.decorations = this.decorations.map(transaction.changes)
|
this.decorations = this.decorations.map(transaction.changes)
|
||||||
|
@ -153,6 +193,11 @@ export const trackChanges = (
|
||||||
this.decorations = Decoration.none
|
this.decorations = Decoration.none
|
||||||
} else if (effect.is(buildChangesEffect)) {
|
} else if (effect.is(buildChangesEffect)) {
|
||||||
this.decorations = buildChangeDecorations(currentDoc)
|
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) => {
|
const buildChangeDecorations = (
|
||||||
if (!currentDoc.ranges) {
|
currentDoc: DocumentContainer,
|
||||||
|
data?: RangesData
|
||||||
|
) => {
|
||||||
|
const ranges = data ? data.ranges : currentDoc.ranges
|
||||||
|
|
||||||
|
if (!ranges) {
|
||||||
return Decoration.none
|
return Decoration.none
|
||||||
}
|
}
|
||||||
|
|
||||||
const changes = [...currentDoc.ranges.changes, ...currentDoc.ranges.comments]
|
const changes = [...ranges.changes, ...ranges.comments]
|
||||||
|
|
||||||
const decorations = []
|
const decorations = []
|
||||||
|
|
||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
try {
|
try {
|
||||||
decorations.push(...createChangeRange(change, currentDoc))
|
decorations.push(...createChangeRange(change, currentDoc, data))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// ignore invalid changes
|
// ignore invalid changes
|
||||||
debugConsole.debug('invalid change position', error)
|
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 { id, metadata, op } = change
|
||||||
|
|
||||||
const from = op.p
|
const from = op.p
|
||||||
|
@ -289,6 +343,20 @@ const createChangeRange = (change: Change, currentDoc: DocumentContainer) => {
|
||||||
return []
|
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 opType = _isCommentOperation ? 'c' : 'i'
|
||||||
const changedText = _isCommentOperation ? op.c : op.i
|
const changedText = _isCommentOperation ? op.c : op.i
|
||||||
const to = from + changedText.length
|
const to = from + changedText.length
|
||||||
|
@ -316,6 +384,107 @@ const createChangeRange = (change: Change, currentDoc: DocumentContainer) => {
|
||||||
return [calloutWidget.range(from, from), changeMark.range(from, to)]
|
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({
|
const trackChangesTheme = EditorView.baseTheme({
|
||||||
'.cm-line': {
|
'.cm-line': {
|
||||||
overflowX: 'hidden', // needed so the callout elements don't overflow (requires line wrapping to be on)
|
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 {
|
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 { useUserContext } from '@/shared/context/user-context'
|
||||||
import { useReferencesContext } from '@/features/ide-react/context/references-context'
|
import { useReferencesContext } from '@/features/ide-react/context/references-context'
|
||||||
import { setMathPreview } from '@/features/source-editor/extensions/math-preview'
|
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) {
|
function useCodeMirrorScope(view: EditorView) {
|
||||||
const { fileTreeData } = useFileTreeData()
|
const { fileTreeData } = useFileTreeData()
|
||||||
|
@ -112,6 +115,9 @@ function useCodeMirrorScope(view: EditorView) {
|
||||||
|
|
||||||
const { referenceKeys } = useReferencesContext()
|
const { referenceKeys } = useReferencesContext()
|
||||||
|
|
||||||
|
const ranges = useRangesContext()
|
||||||
|
const threads = useThreadsContext()
|
||||||
|
|
||||||
// build the translation phrases
|
// build the translation phrases
|
||||||
const phrases = usePhrases()
|
const phrases = usePhrases()
|
||||||
|
|
||||||
|
@ -162,6 +168,8 @@ function useCodeMirrorScope(view: EditorView) {
|
||||||
currentDoc,
|
currentDoc,
|
||||||
trackChanges,
|
trackChanges,
|
||||||
loadingThreads,
|
loadingThreads,
|
||||||
|
threads,
|
||||||
|
ranges,
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -170,6 +178,16 @@ function useCodeMirrorScope(view: EditorView) {
|
||||||
}
|
}
|
||||||
}, [view, currentDoc])
|
}, [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)
|
const docNameRef = useRef(docName)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -225,20 +243,26 @@ function useCodeMirrorScope(view: EditorView) {
|
||||||
// listen to project metadata (commands, labels and package names) updates
|
// listen to project metadata (commands, labels and package names) updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
metadataRef.current = { ...metadataRef.current, ...metadata }
|
metadataRef.current = { ...metadataRef.current, ...metadata }
|
||||||
view.dispatch(setMetadata(metadataRef.current))
|
window.setTimeout(() => {
|
||||||
|
view.dispatch(setMetadata(metadataRef.current))
|
||||||
|
})
|
||||||
}, [view, metadata])
|
}, [view, metadata])
|
||||||
|
|
||||||
// listen to project reference keys updates
|
// listen to project reference keys updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
metadataRef.current.referenceKeys = referenceKeys
|
metadataRef.current.referenceKeys = referenceKeys
|
||||||
view.dispatch(setMetadata(metadataRef.current))
|
window.setTimeout(() => {
|
||||||
|
view.dispatch(setMetadata(metadataRef.current))
|
||||||
|
})
|
||||||
}, [view, referenceKeys])
|
}, [view, referenceKeys])
|
||||||
|
|
||||||
// listen to project root folder updates
|
// listen to project root folder updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fileTreeData) {
|
if (fileTreeData) {
|
||||||
metadataRef.current.fileTreeData = fileTreeData
|
metadataRef.current.fileTreeData = fileTreeData
|
||||||
view.dispatch(setMetadata(metadataRef.current))
|
window.setTimeout(() => {
|
||||||
|
view.dispatch(setMetadata(metadataRef.current))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}, [view, fileTreeData])
|
}, [view, fileTreeData])
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import { UserId } from '../../../../../types/user'
|
import { UserId } from '../../../../../types/user'
|
||||||
import { PublicAccessLevel } from '../../../../../types/public-access-level'
|
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 = {
|
export type ProjectContextMember = {
|
||||||
_id: UserId
|
_id: UserId
|
||||||
privileges: 'readOnly' | 'readAndWrite'
|
privileges: 'readOnly' | 'readAndWrite'
|
||||||
email: string
|
email: string
|
||||||
|
first_name: string
|
||||||
|
last_name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProjectContextValue = {
|
export type ProjectContextValue = {
|
||||||
|
@ -32,13 +33,17 @@ export type ProjectContextValue = {
|
||||||
owner: {
|
owner: {
|
||||||
_id: UserId
|
_id: UserId
|
||||||
email: string
|
email: string
|
||||||
|
first_name: string
|
||||||
|
last_name: string
|
||||||
|
privileges: string
|
||||||
|
signUpDate: string
|
||||||
}
|
}
|
||||||
tags: {
|
tags: {
|
||||||
_id: string
|
_id: string
|
||||||
name: string
|
name: string
|
||||||
color?: string
|
color?: string
|
||||||
}[]
|
}[]
|
||||||
trackChangesState: ReviewPanel.Value<'trackChangesState'>
|
trackChangesState: boolean | Record<UserId | '__guests__', boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProjectContextUpdateValue = Partial<ProjectContextValue>
|
export type ProjectContextUpdateValue = Partial<ProjectContextValue>
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
import {
|
import {
|
||||||
|
AnyOperation,
|
||||||
|
Change,
|
||||||
CommentOperation,
|
CommentOperation,
|
||||||
DeleteOperation,
|
DeleteOperation,
|
||||||
|
EditOperation,
|
||||||
InsertOperation,
|
InsertOperation,
|
||||||
Operation,
|
Operation,
|
||||||
} from '../../../types/change'
|
} from '../../../types/change'
|
||||||
|
@ -11,3 +14,27 @@ export const isCommentOperation = (op: Operation): op is CommentOperation =>
|
||||||
'c' in op
|
'c' in op
|
||||||
export const isDeleteOperation = (op: Operation): op is DeleteOperation =>
|
export const isDeleteOperation = (op: Operation): op is DeleteOperation =>
|
||||||
'd' in op
|
'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/math-preview.less';
|
||||||
@import './editor/hotkeys.less';
|
@import './editor/hotkeys.less';
|
||||||
@import './editor/review-panel.less';
|
@import './editor/review-panel.less';
|
||||||
|
@import './editor/review-panel-new.less';
|
||||||
@import './editor/publish-modal.less';
|
@import './editor/publish-modal.less';
|
||||||
@import './editor/outline.less';
|
@import './editor/outline.less';
|
||||||
@import './editor/logs.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 {
|
||||||
position: absolute;
|
display: none;
|
||||||
top: 0;
|
}
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
|
|
||||||
.rp-empty-state-inner {
|
.review-panel-mini {
|
||||||
position: sticky;
|
width: 22px !important;
|
||||||
top: 50%;
|
overflow: visible !important;
|
||||||
transform: translateY(-50%);
|
|
||||||
|
|
||||||
width: 100%;
|
.rp-entry {
|
||||||
padding-left: 16px;
|
height: 12px;
|
||||||
padding-right: 16px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin-bottom: 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rp-empty-state-comment-icon {
|
.rp-entry-indicator {
|
||||||
width: 80px;
|
position: absolute;
|
||||||
height: 80px;
|
left: -6px;
|
||||||
background-color: white;
|
right: 0;
|
||||||
border-radius: 100%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
width: 20px;
|
||||||
justify-content: center;
|
height: 20px;
|
||||||
margin-bottom: 16px;
|
}
|
||||||
|
|
||||||
.material-symbols {
|
.rp-entry-content {
|
||||||
font-size: 32px;
|
display: none;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid @rp-border-grey;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-entry:hover {
|
||||||
|
.rp-entry-content {
|
||||||
|
display: initial;
|
||||||
|
position: absolute;
|
||||||
|
left: -206px;
|
||||||
|
top: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,6 +32,7 @@
|
||||||
"accept": "Accept",
|
"accept": "Accept",
|
||||||
"accept_all": "Accept all",
|
"accept_all": "Accept all",
|
||||||
"accept_and_continue": "Accept and continue",
|
"accept_and_continue": "Accept and continue",
|
||||||
|
"accept_change": "Accept change",
|
||||||
"accept_invitation": "Accept invitation",
|
"accept_invitation": "Accept invitation",
|
||||||
"accept_or_reject_each_changes_individually": "Accept or reject each change individually",
|
"accept_or_reject_each_changes_individually": "Accept or reject each change individually",
|
||||||
"accept_terms_and_conditions": "Accept terms and conditions",
|
"accept_terms_and_conditions": "Accept terms and conditions",
|
||||||
|
@ -415,6 +416,10 @@
|
||||||
"delete_authentication_token": "Delete Authentication token",
|
"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_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_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_figure": "Delete figure",
|
||||||
"delete_projects": "Delete Projects",
|
"delete_projects": "Delete Projects",
|
||||||
"delete_row_or_column": "Delete row or column",
|
"delete_row_or_column": "Delete row or column",
|
||||||
|
@ -1212,6 +1217,7 @@
|
||||||
"more": "More",
|
"more": "More",
|
||||||
"more_actions": "More actions",
|
"more_actions": "More actions",
|
||||||
"more_info": "More Info",
|
"more_info": "More Info",
|
||||||
|
"more_options": "More options",
|
||||||
"more_options_for_border_settings_coming_soon": "More options for border settings coming soon.",
|
"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_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.",
|
"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",
|
"registration_error": "Registration error",
|
||||||
"reject": "Reject",
|
"reject": "Reject",
|
||||||
"reject_all": "Reject all",
|
"reject_all": "Reject all",
|
||||||
|
"reject_change": "Reject change",
|
||||||
"related_tags": "Related Tags",
|
"related_tags": "Related Tags",
|
||||||
"relink_your_account": "Re-link your account",
|
"relink_your_account": "Re-link your account",
|
||||||
"reload_editor": "Reload editor",
|
"reload_editor": "Reload editor",
|
||||||
|
@ -1652,6 +1659,7 @@
|
||||||
"reset_your_password": "Reset your password",
|
"reset_your_password": "Reset your password",
|
||||||
"resize": "Resize",
|
"resize": "Resize",
|
||||||
"resolve": "Resolve",
|
"resolve": "Resolve",
|
||||||
|
"resolve_comment": "Resolve comment",
|
||||||
"resolved_comments": "Resolved comments",
|
"resolved_comments": "Resolved comments",
|
||||||
"restore": "Restore",
|
"restore": "Restore",
|
||||||
"restore_file": "Restore file",
|
"restore_file": "Restore file",
|
||||||
|
@ -1802,6 +1810,7 @@
|
||||||
"show_in_pdf": "Show in PDF",
|
"show_in_pdf": "Show in PDF",
|
||||||
"show_less": "show less",
|
"show_less": "show less",
|
||||||
"show_local_file_contents": "Show Local File Contents",
|
"show_local_file_contents": "Show Local File Contents",
|
||||||
|
"show_more": "show more",
|
||||||
"show_outline": "Show File outline",
|
"show_outline": "Show File outline",
|
||||||
"show_x_more_projects": "Show __x__ more projects",
|
"show_x_more_projects": "Show __x__ more projects",
|
||||||
"show_your_support": "Show your support",
|
"show_your_support": "Show your support",
|
||||||
|
|
|
@ -5,7 +5,6 @@ import { mockScope } from '../helpers/mock-scope'
|
||||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||||
import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
|
import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
|
||||||
import { activeEditorLine } from '../helpers/active-editor-line'
|
import { activeEditorLine } from '../helpers/active-editor-line'
|
||||||
import { User, UserId } from '../../../../../types/user'
|
|
||||||
import { TestContainer } from '../helpers/test-container'
|
import { TestContainer } from '../helpers/test-container'
|
||||||
import { FC } from 'react'
|
import { FC } from 'react'
|
||||||
import { MetadataContext } from '@/features/ide-react/context/metadata-context'
|
import { MetadataContext } from '@/features/ide-react/context/metadata-context'
|
||||||
|
@ -759,9 +758,9 @@ describe('autocomplete', { scrollBehavior: false }, function () {
|
||||||
|
|
||||||
window.metaAttributesCache.set('ol-showSymbolPalette', true)
|
window.metaAttributesCache.set('ol-showSymbolPalette', true)
|
||||||
const user = {
|
const user = {
|
||||||
id: '123abd' as UserId,
|
id: '123abd',
|
||||||
email: 'testuser@example.com',
|
email: 'testuser@example.com',
|
||||||
} as User
|
}
|
||||||
cy.mount(
|
cy.mount(
|
||||||
<TestContainer>
|
<TestContainer>
|
||||||
<EditorProviders user={user} scope={scope}>
|
<EditorProviders user={user} scope={scope}>
|
||||||
|
|
|
@ -26,7 +26,7 @@ export type Features = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
id: UserId
|
id: UserId | null
|
||||||
isAdmin?: boolean
|
isAdmin?: boolean
|
||||||
email: string
|
email: string
|
||||||
allowedFreeTrial?: boolean
|
allowedFreeTrial?: boolean
|
||||||
|
|
Loading…
Reference in a new issue