Remove 'review-panel-redesign' split test and old code (#24235)

GitOrigin-RevId: 1f3d4a9a51429591a82391a9bee3cfdf226bc9c8
This commit is contained in:
Alf Eaton
2025-03-19 09:58:53 +00:00
committed by Copybot
parent deccf11b82
commit 689a3c103b
82 changed files with 140 additions and 7196 deletions
@@ -345,7 +345,6 @@ const _ProjectController = {
'pdf-caching-prefetching',
'revert-file',
'revert-project',
'review-panel-redesign',
!anonymous && 'ro-mirror-on-client',
'track-pdf-download',
!anonymous && 'writefull-oauth-promotion',
+2 -2
View File
@@ -1,6 +1,6 @@
import 'cypress-plugin-tab'
import { resetMeta } from './ct/window' // needs to be before i18n
import '@/i18n'
import localesPromise from '@/i18n'
import './shared/commands'
import './shared/exceptions'
import './ct/commands'
@@ -8,5 +8,5 @@ import './ct/codemirror'
import '../../test/frontend/helpers/bootstrap-5'
beforeEach(function () {
resetMeta()
cy.wrap(localesPromise).then(resetMeta)
})
@@ -32,8 +32,6 @@
"about_to_leave_projects": "",
"about_to_trash_projects": "",
"about_writefull": "",
"accept": "",
"accept_all": "",
"accept_and_continue": "",
"accept_change": "",
"accept_change_error_description": "",
@@ -147,7 +145,6 @@
"are_you_affiliated_with_an_institution": "",
"are_you_getting_an_undefined_control_sequence_error": "",
"are_you_still_at": "",
"are_you_sure": "",
"are_you_sure_you_want_to_cancel_add_on": "",
"as_email": "",
"ask_proj_owner_to_unlink_from_current_github": "",
@@ -185,8 +182,6 @@
"blocked_filename": "",
"blog": "",
"browser": "",
"bulk_accept_confirm": "",
"bulk_reject_confirm": "",
"by_subscribing_you_agree_to_our_terms_of_service": "",
"can_add_tracked_changes_and_comments": "",
"can_edit": "",
@@ -273,7 +268,6 @@
"column_width_is_custom_click_to_resize": "",
"column_width_is_x_click_to_resize": "",
"comment": "",
"comment_submit_error": "",
"commit": "",
"common": "",
"common_causes_of_compile_timeouts_include": "",
@@ -522,7 +516,6 @@
"error_opening_document_detail": "",
"error_performing_request": "",
"error_processing_file": "",
"error_submitting_comment": "",
"example_project": "",
"existing_plan_active_until_term_end": "",
"expand": "",
@@ -724,7 +717,6 @@
"history_view_a11y_description": "",
"history_view_all": "",
"history_view_labels": "",
"hit_enter_to_reply": "",
"home": "",
"hotkey_add_a_comment": "",
"hotkey_autocomplete_menu": "",
@@ -980,7 +972,6 @@
"managed_users_terms": "",
"managers_management": "",
"managing_your_subscription": "",
"mark_as_resolved": "",
"marked_as_resolved": "",
"math_display": "",
"math_inline": "",
@@ -1051,7 +1042,6 @@
"no_actions": "",
"no_borders": "",
"no_caption": "",
"no_comments": "",
"no_comments_or_suggestions": "",
"no_existing_password": "",
"no_folder": "",
@@ -1071,7 +1061,6 @@
"no_preview_available": "",
"no_projects": "",
"no_resolved_comments": "",
"no_resolved_threads": "",
"no_search_results": "",
"no_selection_select_file": "",
"no_symbols_found": "",
@@ -1284,7 +1273,6 @@
"push_sharelatex_changes_to_github": "",
"push_to_github_pull_to_overleaf": "",
"quoted_text": "",
"quoted_text_in": "",
"raw_logs": "",
"raw_logs_description": "",
"react_history_tutorial_content": "",
@@ -1331,8 +1319,6 @@
"refresh_page_after_starting_free_trial": "",
"refreshing": "",
"regards": "",
"reject": "",
"reject_all": "",
"reject_change": "",
"reject_selected_changes": "",
"relink_your_account": "",
@@ -1377,7 +1363,6 @@
"resending_confirmation_code": "",
"resending_confirmation_email": "",
"resize": "",
"resolve": "",
"resolve_comment": "",
"resolve_comment_error_message": "",
"resolve_comment_error_title": "",
@@ -1514,7 +1499,6 @@
"shared_with_you": "",
"sharelatex_beta_program": "",
"shortcut_to_open_advanced_reference_search": "",
"show_all": "",
"show_all_projects": "",
"show_document_preamble": "",
"show_file_tree": "",
@@ -1672,9 +1656,6 @@
"take_survey": "",
"tc_everyone": "",
"tc_guests": "",
"tc_switch_everyone_tip": "",
"tc_switch_guests_tip": "",
"tc_switch_user_tip": "",
"tell_the_project_owner_and_ask_them_to_upgrade": "",
"template": "",
"template_description": "",
@@ -9,7 +9,6 @@ import React, {
} from 'react'
import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
import populateLayoutScope from '@/features/ide-react/scope-adapters/layout-context-adapter'
import populateReviewPanelScope from '@/features/ide-react/scope-adapters/review-panel-context-adapter'
import { IdeProvider } from '@/shared/context/ide-context'
import {
createIdeEventEmitter,
@@ -73,10 +72,8 @@ export function createReactScopeValueStore(projectId: string) {
populateLayoutScope(scopeStore)
populateProjectScope(scopeStore)
populatePdfScope(scopeStore)
populateReviewPanelScope(scopeStore)
scopeStore.allowNonExistentPath('hasLintingError')
scopeStore.allowNonExistentPath('loadingThreads')
return scopeStore
}
@@ -1,32 +0,0 @@
import { useState, useEffect } from 'react'
function useLayoutToLeft(querySelector: string) {
const [layoutToLeft, setLayoutToLeft] = useState(false)
useEffect(() => {
if (!('ResizeObserver' in window)) return
const target = document.querySelector(querySelector)
if (!target) return
const handleResize = () => {
const docWidth = document.documentElement.clientWidth
const { right: rightEdge } = target.getBoundingClientRect()
setLayoutToLeft(docWidth - rightEdge < 225)
}
handleResize()
const observer = new ResizeObserver(handleResize)
observer.observe(target)
return () => {
observer.disconnect()
}
}, [querySelector])
return layoutToLeft
}
export default useLayoutToLeft
@@ -1,8 +0,0 @@
import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
export default function populateReviewPanelScope(store: ReactScopeValueStore) {
store.set('loadingThreads', true)
store.set('users', {})
store.set('usingNewReviewPanel', isSplitTestEnabled('review-panel-redesign'))
}
@@ -20,7 +20,6 @@ const ReviewPanelHeader: FC = () => {
<PanelHeading
title={t('review')}
handleClose={() => setReviewPanelOpen(false)}
splitTestName="review-panel-redesign"
>
{isReviewerRoleEnabled && <ReviewPanelResolvedThreadsButton />}
</PanelHeading>
@@ -0,0 +1,12 @@
import React, { FC, lazy, Suspense } from 'react'
import LoadingSpinner from '@/shared/components/loading-spinner'
const ReviewPanelContainer = lazy(() => import('./review-panel-container'))
export const ReviewPanelNew: FC = () => {
return (
<Suspense fallback={<LoadingSpinner delay={500} />}>
<ReviewPanelContainer />
</Suspense>
)
}
@@ -3,7 +3,7 @@ import { Trans } from 'react-i18next'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import MaterialIcon from '@/shared/components/material-icon'
import { useProjectContext } from '@/shared/context/project-context'
import UpgradeTrackChangesModal from '@/features/source-editor/components/review-panel/upgrade-track-changes-modal'
import UpgradeTrackChangesModal from '@/features/review-panel-new/components/upgrade-track-changes-modal'
import { send, sendMB } from '@/infrastructure/event-tracking'
const sendAnalytics = () => {
@@ -1,5 +1,5 @@
import { FC } from 'react'
import TrackChangesToggle from '@/features/source-editor/components/review-panel/toolbar/track-changes-toggle'
import TrackChangesToggle from '@/features/review-panel-new/components/track-changes-toggle'
import { useProjectContext } from '@/shared/context/project-context'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { useTranslation } from 'react-i18next'
@@ -1,8 +1,8 @@
import { useTranslation } from 'react-i18next'
import { useProjectContext } from '../../../../shared/context/project-context'
import { useUserContext } from '../../../../shared/context/user-context'
import teaserVideo from '../../images/teaser-track-changes.mp4'
import teaserImage from '../../images/teaser-track-changes.gif'
import { useProjectContext } from '@/shared/context/project-context'
import { useUserContext } from '@/shared/context/user-context'
import teaserVideo from '../images/teaser-track-changes.mp4'
import teaserImage from '../images/teaser-track-changes.gif'
import { startFreeTrial, upgradePlan } from '@/main/account-upgrade'
import { memo } from 'react'
import { useFeatureFlag } from '@/shared/context/split-test-context'
@@ -10,6 +10,8 @@ import { getJSON } from '@/infrastructure/fetch-json'
import { useProjectContext } from '@/shared/context/project-context'
import { UserId } from '../../../../../types/user'
import { useEditorContext } from '@/shared/context/editor-context'
import { debugConsole } from '@/utils/debugging'
import { captureException } from '@/infrastructure/error-reporter'
export type ChangesUser = {
id: UserId
@@ -35,9 +37,12 @@ export const ChangesUsersProvider: FC = ({ children }) => {
return
}
getJSON<ChangesUser[]>(`/project/${projectId}/changes/users`).then(data =>
setChangesUsers(new Map(data.map(item => [item.id, item])))
)
getJSON<ChangesUser[]>(`/project/${projectId}/changes/users`)
.then(data => setChangesUsers(new Map(data.map(item => [item.id, item]))))
.catch(error => {
debugConsole.error(error)
captureException(error)
})
}, [projectId, isRestrictedTokenMember])
// add the project owner and members to the changes users data
@@ -3,14 +3,9 @@ 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'
import { ReviewPanelViewProvider } from './review-panel-view-context'
export const ReviewPanelProviders: FC = ({ children }) => {
if (!isSplitTestEnabled('review-panel-redesign')) {
return <>{children}</>
}
return (
<ReviewPanelViewProvider>
<ChangesUsersProvider>
@@ -10,18 +10,20 @@ import {
import { useProjectContext } from '@/shared/context/project-context'
import {
CommentId,
ReviewPanelCommentThreadMessage,
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 { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import { useEditorContext } from '@/shared/context/editor-context'
import { debugConsole } from '@/utils/debugging'
import { captureException } from '@/infrastructure/error-reporter'
export type Threads = Record<ThreadId, ReviewPanelCommentThread>
@@ -64,12 +66,15 @@ export const ThreadsProvider: FC = ({ children }) => {
getJSON(`/project/${projectId}/threads`, {
signal: abortController.signal,
}).then(data => {
setData(data)
})
// .catch(error => {
// setError(error)
// })
.then(data => {
setData(data)
})
.catch(error => {
debugConsole.error(error)
captureException(error)
// setError(error)
})
}, [projectId, isRestrictedTokenMember])
const { socket } = useConnectionContext()
@@ -78,7 +83,10 @@ export const ThreadsProvider: FC = ({ children }) => {
socket,
'new-comment',
useCallback(
(threadId: ThreadId, comment: ReviewPanelCommentThreadMessageApi) => {
(
threadId: ThreadId,
comment: ReviewPanelCommentThreadMessage & { timestamp: number }
) => {
setData(value => {
if (value) {
const { submitting, ...thread } = value[threadId] ?? {
@@ -10,9 +10,8 @@ import { CodeMirrorCommandTooltip } from './codemirror-command-tooltip'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import { FigureModal } from './figure-modal/figure-modal'
import { ReviewPanelProviders } from '@/features/review-panel-new/context/review-panel-providers'
import { ReviewPanelMigration } from '@/features/source-editor/components/review-panel/review-panel-migration'
import { ReviewPanelNew } from '@/features/review-panel-new/components/review-panel-new'
import ReviewTooltipMenu from '@/features/review-panel-new/components/review-tooltip-menu'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import {
CodeMirrorStateContext,
CodeMirrorViewContext,
@@ -40,7 +39,6 @@ function CodeMirrorEditor() {
const isMounted = useIsMounted()
const newReviewPanel = useFeatureFlag('review-panel-redesign')
const newEditor = useIsNewEditorEnabled()
// create the view using the initial state and intercept transactions
@@ -79,8 +77,8 @@ function CodeMirrorEditor() {
<CodeMirrorCommandTooltip />
<MathPreviewTooltip />
{newReviewPanel && <ReviewTooltipMenu />}
<ReviewPanelMigration />
<ReviewTooltipMenu />
<ReviewPanelNew />
{sourceEditorComponents.map(
({ import: { default: Component }, path }) => (
@@ -1,5 +0,0 @@
function AddCommentButton(props: React.ComponentPropsWithoutRef<'button'>) {
return <button className="rp-add-comment-btn" {...props} />
}
export default AddCommentButton
@@ -1,20 +0,0 @@
import classnames from 'classnames'
type ContainerProps = {
children?: React.ReactNode
className?: string
}
function Container({ children, className, ...rest }: ContainerProps) {
return (
<div
className={classnames('review-panel', className)}
{...rest}
data-testid="review-panel"
>
{children}
</div>
)
}
export default Container
@@ -1,53 +0,0 @@
import { memo, useMemo } from 'react'
import Container from './container'
import Toolbar from './toolbar/toolbar'
import Nav from './nav'
import Toggler from './toggler'
import PositionedEntries from './positioned-entries'
import { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context'
import useCodeMirrorContentHeight from '../../hooks/use-codemirror-content-height'
import { ReviewPanelEntry } from '../../../../../../types/review-panel/entry'
import { ReviewPanelDocEntries } from '../../../../../../types/review-panel/review-panel'
import Entry from './entry'
function CurrentFileContainer() {
const { entries, openDocId } = useReviewPanelValueContext()
const contentHeight = useCodeMirrorContentHeight()
const currentDocEntries =
openDocId && openDocId in entries ? entries[openDocId] : undefined
const objectEntries = useMemo(() => {
return Object.entries(currentDocEntries || {}) as Array<
[keyof ReviewPanelDocEntries, ReviewPanelEntry]
>
}, [currentDocEntries])
return (
<Container className="rp-current-file-container">
<div className="review-panel-tools">
<Toolbar />
<Nav />
</div>
<Toggler />
<div
id="review-panel-current-file"
role="tabpanel"
tabIndex={0}
aria-labelledby="review-panel-tab-current-file"
>
<PositionedEntries
entries={objectEntries}
contentHeight={contentHeight}
>
{openDocId &&
objectEntries.map(([id, entry]) => {
return <Entry key={id} id={id} entry={entry} />
})}
</PositionedEntries>
</div>
</Container>
)
}
export default memo(CurrentFileContainer)
@@ -1,109 +0,0 @@
import ReactDOM from 'react-dom'
import { useTranslation } from 'react-i18next'
import ToggleWidget from './toggle-widget'
import BulkActions from '../entries/bulk-actions-entry/bulk-actions'
import AddCommentButton from '../add-comment-button'
import {
useReviewPanelUpdaterFnsContext,
useReviewPanelValueContext,
} from '../../../context/review-panel/review-panel-context'
import { useEditorContext } from '@/shared/context/editor-context'
import { useCodeMirrorViewContext } from '../../codemirror-context'
import Modal, { useBulkActionsModal } from '../entries/bulk-actions-entry/modal'
import getMeta from '../../../../../utils/meta'
import useScopeEventListener from '@/shared/hooks/use-scope-event-listener'
import { memo, useCallback } from 'react'
import { useLayoutContext } from '@/shared/context/layout-context'
import classnames from 'classnames'
import MaterialIcon from '@/shared/components/material-icon'
function EditorWidgets() {
const { t } = useTranslation()
const {
show,
setShow,
isAccept,
handleShowBulkAcceptDialog,
handleShowBulkRejectDialog,
handleConfirmDialog,
} = useBulkActionsModal()
const { setIsAddingComment } = useReviewPanelUpdaterFnsContext()
const { toggleReviewPanel } = useReviewPanelUpdaterFnsContext()
const view = useCodeMirrorViewContext()
const { reviewPanelOpen } = useLayoutContext()
const { isRestrictedTokenMember } = useEditorContext()
const {
entries,
openDocId,
nVisibleSelectedChanges: nChanges,
wantTrackChanges,
permissions,
} = useReviewPanelValueContext()
const hasTrackChangesFeature = getMeta('ol-hasTrackChangesFeature')
const currentDocEntries =
openDocId && openDocId in entries ? entries[openDocId] : undefined
const handleAddNewCommentClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
setIsAddingComment(true)
toggleReviewPanel()
}
useScopeEventListener(
'comment:start_adding',
useCallback(() => {
setIsAddingComment(true)
}, [setIsAddingComment])
)
return ReactDOM.createPortal(
<>
<div
className={classnames('rp-in-editor-widgets', {
hidden: reviewPanelOpen,
})}
>
<div className="rp-in-editor-widgets-inner">
{wantTrackChanges && <ToggleWidget />}
{nChanges > 1 && permissions.write && (
<>
<BulkActions.Button onClick={handleShowBulkAcceptDialog}>
<MaterialIcon type="check" />
&nbsp;
{t('accept_all')} ({nChanges})
</BulkActions.Button>
<BulkActions.Button onClick={handleShowBulkRejectDialog}>
<MaterialIcon type="close" />
&nbsp;
{t('reject_all')} ({nChanges})
</BulkActions.Button>
</>
)}
{hasTrackChangesFeature &&
permissions.comment &&
!isRestrictedTokenMember &&
currentDocEntries?.['add-comment'] && (
<AddCommentButton onClick={handleAddNewCommentClick}>
<MaterialIcon type="mode_comment" />
&nbsp;
{t('add_comment')}
</AddCommentButton>
)}
</div>
</div>
<Modal
show={show}
setShow={setShow}
isAccept={isAccept}
nChanges={nChanges}
onConfirm={handleConfirmDialog}
/>
</>,
view.scrollDOM
)
}
export default memo(EditorWidgets)
@@ -1,32 +0,0 @@
import { Trans } from 'react-i18next'
import { useReviewPanelUpdaterFnsContext } from '../../../context/review-panel/review-panel-context'
import { useCodeMirrorStateContext } from '../../codemirror-context'
import { EditorView } from '@codemirror/view'
import classnames from 'classnames'
import { memo } from 'react'
function ToggleWidget() {
const { toggleReviewPanel } = useReviewPanelUpdaterFnsContext()
const state = useCodeMirrorStateContext()
const darkTheme = state.facet(EditorView.darkTheme)
return (
<button
className={classnames('rp-track-changes-indicator', {
'rp-track-changes-indicator-on-dark': darkTheme,
})}
onClick={toggleReviewPanel}
>
<TrackChangesOn />
</button>
)
}
const TrackChangesOn = memo(() => {
return (
<Trans i18nKey="track_changes_is_on" components={{ strong: <strong /> }} />
)
})
TrackChangesOn.displayName = 'TrackChangesOn'
export default ToggleWidget
@@ -1,165 +0,0 @@
import { useTranslation } from 'react-i18next'
import { useEffect, useRef, useState } from 'react'
import EntryContainer from './entry-container'
import EntryCallout from './entry-callout'
import EntryActions from './entry-actions'
import AutoExpandingTextArea from '../../../../../shared/components/auto-expanding-text-area'
import AddCommentButton from '../add-comment-button'
import {
useReviewPanelUpdaterFnsContext,
useReviewPanelValueContext,
} from '../../../context/review-panel/review-panel-context'
import classnames from 'classnames'
import MaterialIcon from '@/shared/components/material-icon'
import LoadingSpinner from '@/shared/components/loading-spinner'
function AddCommentEntry() {
const { t } = useTranslation()
const { isAddingComment, unsavedComment } = useReviewPanelValueContext()
const {
setIsAddingComment,
submitNewComment,
handleLayoutChange,
setUnsavedComment,
} = useReviewPanelUpdaterFnsContext()
const [content, setContent] = useState(unsavedComment)
const [isSubmitting, setIsSubmitting] = useState(false)
const handleStartNewComment = () => {
setIsAddingComment(true)
handleLayoutChange({ async: true })
}
const handleSubmitNewComment = () => {
setIsSubmitting(true)
try {
submitNewComment(content)
setIsSubmitting(false)
setIsAddingComment(false)
setContent('')
} catch (err) {
setIsSubmitting(false)
}
handleLayoutChange({ async: true })
}
const handleCancelNewComment = () => {
setIsAddingComment(false)
setContent('')
handleLayoutChange({ async: true })
}
useEffect(() => {
return () => {
setIsAddingComment(false)
}
}, [setIsAddingComment])
const unsavedCommentRef = useRef(unsavedComment)
// Keep unsaved comment ref up to date for use when the component unmounts
useEffect(() => {
unsavedCommentRef.current = content
}, [content])
// Store the unsaved comment in the context on unmount
useEffect(() => {
return () => {
setUnsavedComment(unsavedCommentRef.current)
}
}, [setUnsavedComment])
const handleCommentKeyPress = (
e: React.KeyboardEvent<HTMLTextAreaElement>
) => {
const target = e.target as HTMLTextAreaElement
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault()
if (content.length) {
handleSubmitNewComment()
}
}
if (['PageDown', 'PageUp'].includes(e.key)) {
if (target.closest('textarea')) {
e.preventDefault()
}
}
}
const handleCommentAutoFocus = (textarea: HTMLTextAreaElement) => {
// Sometimes the comment textarea is scrolled out of view once focussed,
// so this checks for that and scrolls it into view if necessary. It
// seems we sometimes need to allow time for the dust to settle after
// focussing the textarea before scrolling.
window.setTimeout(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.intersectionRatio < 1) {
textarea.scrollIntoView({ block: 'center' })
}
observer.disconnect()
})
observer.observe(textarea)
}, 500)
}
return (
<EntryContainer id="add-comment">
<EntryCallout className="rp-entry-callout-add-comment" />
<div
className={classnames('rp-entry', 'rp-entry-add-comment', {
'rp-entry-adding-comment': isAddingComment,
})}
>
{isAddingComment ? (
<>
<div className="rp-new-comment">
{isSubmitting ? (
<LoadingSpinner className="d-flex justify-content-center" />
) : (
<AutoExpandingTextArea
className="rp-comment-input"
onChange={e => setContent(e.target.value)}
onKeyPress={handleCommentKeyPress}
onResize={handleLayoutChange}
onAutoFocus={handleCommentAutoFocus}
placeholder={t('add_your_comment_here')}
value={content}
autoFocus // eslint-disable-line jsx-a11y/no-autofocus
/>
)}
</div>
<EntryActions>
<EntryActions.Button
className="rp-entry-button-cancel"
onClick={handleCancelNewComment}
>
<MaterialIcon type="close" />
&nbsp;
{t('cancel')}
</EntryActions.Button>
<EntryActions.Button
onClick={handleSubmitNewComment}
disabled={isSubmitting || !content.length}
>
<MaterialIcon type="mode_comment" />
&nbsp;
{t('comment')}
</EntryActions.Button>
</EntryActions>
</>
) : (
<AddCommentButton onClick={handleStartNewComment}>
<MaterialIcon type="mode_comment" />
&nbsp;
{t('add_comment')}
</AddCommentButton>
)}
</div>
</EntryContainer>
)
}
export default AddCommentEntry
@@ -1,159 +0,0 @@
import { useTranslation } from 'react-i18next'
import { memo, useState } from 'react'
import EntryContainer from './entry-container'
import EntryCallout from './entry-callout'
import EntryActions from './entry-actions'
import { useReviewPanelUpdaterFnsContext } from '../../../context/review-panel/review-panel-context'
import { formatTime } from '../../../../utils/format-date'
import classnames from 'classnames'
import comparePropsWithShallowArrayCompare from '../utils/compare-props-with-shallow-array-compare'
import { BaseChangeEntryProps } from '../types/base-change-entry-props'
import useIndicatorHover from '../hooks/use-indicator-hover'
import EntryIndicator from './entry-indicator'
import { useEntryClick } from '@/features/source-editor/components/review-panel/hooks/use-entry-click'
import MaterialIcon from '@/shared/components/material-icon'
interface AggregateChangeEntryProps extends BaseChangeEntryProps {
replacedContent: string
}
function AggregateChangeEntry({
docId,
entryId,
permissions,
user,
content,
replacedContent,
offset,
focused,
entryIds,
timestamp,
contentLimit = 17,
}: AggregateChangeEntryProps) {
const { t } = useTranslation()
const { acceptChanges, rejectChanges, handleLayoutChange } =
useReviewPanelUpdaterFnsContext()
const [isDeletionCollapsed, setIsDeletionCollapsed] = useState(true)
const [isInsertionCollapsed, setIsInsertionCollapsed] = useState(true)
const {
hoverCoords,
indicatorRef,
endHover,
handleIndicatorMouseEnter,
handleIndicatorClick,
} = useIndicatorHover()
const deletionNeedsCollapsing = replacedContent.length > contentLimit
const insertionNeedsCollapsing = content.length > contentLimit
const deletionContent = isDeletionCollapsed
? replacedContent.substring(0, contentLimit)
: replacedContent
const insertionContent = isInsertionCollapsed
? content.substring(0, contentLimit)
: content
const handleEntryClick = useEntryClick(docId, offset, endHover)
const handleDeletionToggleCollapse = () => {
setIsDeletionCollapsed(value => !value)
handleLayoutChange()
}
const handleInsertionToggleCollapse = () => {
setIsInsertionCollapsed(value => !value)
handleLayoutChange()
}
return (
<EntryContainer
id={entryId}
hoverCoords={hoverCoords}
onClick={handleEntryClick}
onMouseLeave={endHover}
>
<EntryCallout className="rp-entry-callout-aggregate" />
<EntryIndicator
ref={indicatorRef}
focused={focused}
onMouseEnter={handleIndicatorMouseEnter}
onClick={handleIndicatorClick}
>
<MaterialIcon type="edit" />
</EntryIndicator>
<div
className={classnames('rp-entry', 'rp-entry-aggregate', {
'rp-entry-focused': focused,
})}
>
<div className="rp-entry-body">
<div className="rp-entry-action-icon">
<MaterialIcon type="edit" />
</div>
<div className="rp-entry-details">
<div className="rp-entry-description">
{t('aggregate_changed')}&nbsp;
<del className="rp-content-highlight">{deletionContent}</del>
{deletionNeedsCollapsing && (
<button
className="rp-collapse-toggle"
onClick={handleDeletionToggleCollapse}
>
{isDeletionCollapsed
? `… (${t('show_all')})`
: ` (${t('show_less')})`}
</button>
)}{' '}
{t('aggregate_to')}&nbsp;
<ins className="rp-content-highlight">{insertionContent}</ins>
{insertionNeedsCollapsing && (
<button
className="rp-collapse-toggle"
onClick={handleInsertionToggleCollapse}
>
{isInsertionCollapsed
? `… (${t('show_all')})`
: ` (${t('show_less')})`}
</button>
)}
</div>
<div className="rp-entry-metadata">
<span className="rp-entry-metadata-element">
{formatTime(timestamp, 'MMM D, Y h:mm A')}
</span>
{user && (
<span className="rp-entry-metadata-element">
&nbsp;&bull;&nbsp;
<span
className="rp-entry-user"
style={{ color: `hsl(${user.hue}, 70%, 40%)` }}
>
{user.name ?? t('anonymous')}
</span>
</span>
)}
</div>
</div>
</div>
{permissions.write && (
<EntryActions>
<EntryActions.Button onClick={() => rejectChanges(entryIds)}>
<MaterialIcon type="close" />
&nbsp;{t('reject')}
</EntryActions.Button>
<EntryActions.Button onClick={() => acceptChanges(entryIds)}>
<MaterialIcon type="check" />
&nbsp;{t('accept')}
</EntryActions.Button>
</EntryActions>
)}
</div>
</EntryContainer>
)
}
export default memo(
AggregateChangeEntry,
comparePropsWithShallowArrayCompare('entryIds')
)
@@ -1,55 +0,0 @@
import { useTranslation } from 'react-i18next'
import EntryContainer from '../entry-container'
import EntryCallout from '../entry-callout'
import BulkActions from './bulk-actions'
import Modal, { useBulkActionsModal } from './modal'
import { ReviewPanelBulkActionsEntry } from '../../../../../../../../types/review-panel/entry'
import MaterialIcon from '@/shared/components/material-icon'
type BulkActionsEntryProps = {
entryId: ReviewPanelBulkActionsEntry['type']
nChanges: number
}
function BulkActionsEntry({ entryId, nChanges }: BulkActionsEntryProps) {
const { t } = useTranslation()
const {
show,
setShow,
isAccept,
handleShowBulkAcceptDialog,
handleShowBulkRejectDialog,
handleConfirmDialog,
} = useBulkActionsModal()
return (
<>
<EntryContainer id={entryId}>
{nChanges > 1 && (
<>
<EntryCallout className="rp-entry-callout-bulk-actions" />
<BulkActions className="rp-entry">
<BulkActions.Button onClick={handleShowBulkRejectDialog}>
<MaterialIcon type="close" />
&nbsp;{t('reject_all')} ({nChanges})
</BulkActions.Button>
<BulkActions.Button onClick={handleShowBulkAcceptDialog}>
<MaterialIcon type="check" />
&nbsp;{t('accept_all')} ({nChanges})
</BulkActions.Button>
</BulkActions>
</>
)}
</EntryContainer>
<Modal
show={show}
setShow={setShow}
isAccept={isAccept}
nChanges={nChanges}
onConfirm={handleConfirmDialog}
/>
</>
)
}
export default BulkActionsEntry
@@ -1,24 +0,0 @@
import classnames from 'classnames'
function BulkActions({
className,
...rest
}: React.ComponentPropsWithoutRef<'div'>) {
return (
<div className={classnames('rp-entry-bulk-actions', className)} {...rest} />
)
}
BulkActions.Button = function BulkActionsButton({
className,
...rest
}: React.ComponentPropsWithoutRef<'button'>) {
return (
<button
className={classnames('rp-bulk-actions-btn', className)}
{...rest}
/>
)
}
export default BulkActions
@@ -1,91 +0,0 @@
import { useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useReviewPanelUpdaterFnsContext } from '../../../../context/review-panel/review-panel-context'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
type BulkActionsModalProps = {
show: boolean
setShow: React.Dispatch<React.SetStateAction<boolean>>
isAccept: boolean
nChanges: number
onConfirm: () => void
}
function Modal({
show,
setShow,
isAccept,
nChanges,
onConfirm,
}: BulkActionsModalProps) {
const { t } = useTranslation()
return (
<OLModal show={show} onHide={() => setShow(false)}>
<OLModalHeader closeButton>
<OLModalTitle>
{isAccept ? t('accept_all') : t('reject_all')}
</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p>
{isAccept
? t('bulk_accept_confirm', { nChanges })
: t('bulk_reject_confirm', { nChanges })}
</p>
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={() => setShow(false)}>
{t('cancel')}
</OLButton>
<OLButton variant="primary" onClick={onConfirm}>
{t('ok')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}
export function useBulkActionsModal() {
const [show, setShow] = useState(false)
const [isAccept, setIsAccept] = useState(false)
const { bulkAcceptActions, bulkRejectActions } =
useReviewPanelUpdaterFnsContext()
const handleShowBulkAcceptDialog = useCallback(() => {
setIsAccept(true)
setShow(true)
}, [])
const handleShowBulkRejectDialog = useCallback(() => {
setIsAccept(false)
setShow(true)
}, [])
const handleConfirmDialog = useCallback(() => {
if (isAccept) {
bulkAcceptActions()
} else {
bulkRejectActions()
}
setShow(false)
}, [bulkAcceptActions, bulkRejectActions, isAccept])
return {
show,
setShow,
isAccept,
handleShowBulkAcceptDialog,
handleShowBulkRejectDialog,
handleConfirmDialog,
}
}
export default Modal
@@ -1,161 +0,0 @@
import { useTranslation } from 'react-i18next'
import { memo, useState } from 'react'
import EntryContainer from './entry-container'
import EntryCallout from './entry-callout'
import EntryActions from './entry-actions'
import { useReviewPanelUpdaterFnsContext } from '../../../context/review-panel/review-panel-context'
import { formatTime } from '../../../../utils/format-date'
import classnames from 'classnames'
import { ReviewPanelChangeEntry } from '../../../../../../../types/review-panel/entry'
import { BaseChangeEntryProps } from '../types/base-change-entry-props'
import comparePropsWithShallowArrayCompare from '../utils/compare-props-with-shallow-array-compare'
import useIndicatorHover from '../hooks/use-indicator-hover'
import EntryIndicator from './entry-indicator'
import { useEntryClick } from '@/features/source-editor/components/review-panel/hooks/use-entry-click'
import MaterialIcon from '@/shared/components/material-icon'
interface ChangeEntryProps extends BaseChangeEntryProps {
type: ReviewPanelChangeEntry['type']
}
function ChangeEntry({
docId,
entryId,
permissions,
user,
content,
offset,
type,
focused,
entryIds,
timestamp,
contentLimit = 40,
}: ChangeEntryProps) {
const { t } = useTranslation()
const { handleLayoutChange, acceptChanges, rejectChanges } =
useReviewPanelUpdaterFnsContext()
const [isCollapsed, setIsCollapsed] = useState(true)
const {
hoverCoords,
indicatorRef,
endHover,
handleIndicatorMouseEnter,
handleIndicatorClick,
} = useIndicatorHover()
const contentToDisplay = isCollapsed
? content.substring(0, contentLimit)
: content
const needsCollapsing = content.length > contentLimit
const isInsert = type === 'insert'
const handleEntryClick = useEntryClick(docId, offset, endHover)
const handleToggleCollapse = () => {
setIsCollapsed(value => !value)
handleLayoutChange()
}
return (
<EntryContainer
id={entryId}
hoverCoords={hoverCoords}
onClick={handleEntryClick}
onMouseLeave={endHover}
>
<EntryCallout className={`rp-entry-callout-${type}`} />
<EntryIndicator
ref={indicatorRef}
focused={focused}
onMouseEnter={handleIndicatorMouseEnter}
onClick={handleIndicatorClick}
>
{isInsert ? (
<MaterialIcon type="edit" />
) : (
<i className="rp-icon-delete" />
)}
</EntryIndicator>
<div
className={classnames('rp-entry', `rp-entry-${type}`, {
'rp-entry-focused': focused,
})}
>
<div className="rp-entry-body">
<div className="rp-entry-action-icon">
{isInsert ? (
<MaterialIcon type="edit" />
) : (
<i className="rp-icon-delete" />
)}
</div>
<div className="rp-entry-details">
<div className="rp-entry-description">
<span>
{isInsert ? (
<>
{t('tracked_change_added')}&nbsp;
<ins className="rp-content-highlight">
{contentToDisplay}
</ins>
</>
) : (
<>
{t('tracked_change_deleted')}&nbsp;
<del className="rp-content-highlight">
{contentToDisplay}
</del>
</>
)}
{needsCollapsing && (
<button
className="rp-collapse-toggle"
onClick={handleToggleCollapse}
>
{isCollapsed
? `… (${t('show_all')})`
: ` (${t('show_less')})`}
</button>
)}
</span>
</div>
<div className="rp-entry-metadata">
<span className="rp-entry-metadata-element">
{formatTime(timestamp, 'MMM D, Y h:mm A')}
</span>
{user && (
<span className="rp-entry-metadata-element">
&nbsp;&bull;&nbsp;
<span
className="rp-entry-user"
style={{ color: `hsl(${user.hue}, 70%, 40%)` }}
>
{user.name ?? t('anonymous')}
</span>
</span>
)}
</div>
</div>
</div>
{permissions.write && (
<EntryActions>
<EntryActions.Button onClick={() => rejectChanges(entryIds)}>
<MaterialIcon type="close" />
&nbsp;{t('reject')}
</EntryActions.Button>
<EntryActions.Button onClick={() => acceptChanges(entryIds)}>
<MaterialIcon type="check" />
&nbsp;{t('accept')}
</EntryActions.Button>
</EntryActions>
)}
</div>
</EntryContainer>
)
}
export default memo(
ChangeEntry,
comparePropsWithShallowArrayCompare('entryIds')
)
@@ -1,185 +0,0 @@
import { useState, useRef, useEffect, memo } from 'react'
import { useTranslation } from 'react-i18next'
import EntryContainer from './entry-container'
import EntryCallout from './entry-callout'
import EntryActions from './entry-actions'
import Comment from './comment'
import AutoExpandingTextArea from '../../../../../shared/components/auto-expanding-text-area'
import { useReviewPanelUpdaterFnsContext } from '../../../context/review-panel/review-panel-context'
import classnames from 'classnames'
import { ThreadId } from '../../../../../../../types/review-panel/review-panel'
import { Permissions } from '@/features/ide-react/types/permissions'
import { DocId } from '../../../../../../../types/project-settings'
import { ReviewPanelCommentThread } from '../../../../../../../types/review-panel/comment-thread'
import { ReviewPanelCommentEntry } from '../../../../../../../types/review-panel/entry'
import useIndicatorHover from '../hooks/use-indicator-hover'
import EntryIndicator from './entry-indicator'
import { useEntryClick } from '@/features/source-editor/components/review-panel/hooks/use-entry-click'
import MaterialIcon from '@/shared/components/material-icon'
import LoadingSpinner from '@/shared/components/loading-spinner'
type CommentEntryProps = {
docId: DocId
entryId: ThreadId
thread: ReviewPanelCommentThread | undefined
threadId: ReviewPanelCommentEntry['thread_id']
permissions: Permissions
} & Pick<ReviewPanelCommentEntry, 'offset' | 'focused'>
function CommentEntry({
docId,
entryId,
thread,
threadId,
offset,
focused,
permissions,
}: CommentEntryProps) {
const { t } = useTranslation()
const { resolveComment, submitReply, handleLayoutChange } =
useReviewPanelUpdaterFnsContext()
const [replyContent, setReplyContent] = useState('')
const [animating, setAnimating] = useState(false)
const [resolved, setResolved] = useState(false)
const entryDivRef = useRef<HTMLDivElement | null>(null)
const {
hoverCoords,
indicatorRef,
endHover,
handleIndicatorMouseEnter,
handleIndicatorClick,
} = useIndicatorHover()
const handleEntryClick = useEntryClick(docId, offset)
const handleAnimateAndCallOnResolve = () => {
setAnimating(true)
if (entryDivRef.current) {
entryDivRef.current.style.top = '0'
}
setTimeout(() => {
setAnimating(false)
setResolved(true)
resolveComment(docId, entryId)
}, 350)
}
const handleCommentReplyKeyPress = (
e: React.KeyboardEvent<HTMLTextAreaElement>
) => {
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault()
if (replyContent.length) {
;(e.target as HTMLTextAreaElement).blur()
submitReply(threadId, replyContent)
setReplyContent('')
}
}
}
const handleOnReply = () => {
if (replyContent.length) {
submitReply(threadId, replyContent)
setReplyContent('')
}
}
const submitting = Boolean(thread?.submitting)
// Update the layout when loading finishes
useEffect(() => {
if (!submitting) {
// Ensure everything is rendered in the DOM before updating the layout.
handleLayoutChange({ async: true })
}
}, [submitting, handleLayoutChange])
if (!thread || resolved) {
return null
}
return (
<EntryContainer
id={entryId}
hoverCoords={hoverCoords}
onClick={handleEntryClick}
onMouseLeave={endHover}
>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
className={classnames('rp-comment-wrapper', {
'rp-comment-wrapper-resolving': animating,
})}
>
<EntryCallout className="rp-entry-callout-comment" />
<EntryIndicator
ref={indicatorRef}
focused={focused}
onMouseEnter={handleIndicatorMouseEnter}
onClick={handleIndicatorClick}
>
<MaterialIcon type="mode_comment" />
</EntryIndicator>
<div
className={classnames('rp-entry', 'rp-entry-comment', {
'rp-entry-focused': focused,
'rp-entry-comment-resolving': animating,
})}
ref={entryDivRef}
>
{!submitting && (!thread || thread.messages.length === 0) && (
<div className="text-center p-1">{t('no_comments')}</div>
)}
<div className="rp-comment-loaded">
{thread.messages.map(comment => (
<Comment
key={comment.id}
thread={thread}
threadId={threadId}
comment={comment}
/>
))}
</div>
{submitting && (
<LoadingSpinner className="d-flex justify-content-center" />
)}
{permissions.comment && (
<div className="rp-comment-reply">
<AutoExpandingTextArea
className="rp-comment-input"
onChange={e => setReplyContent(e.target.value)}
onKeyPress={handleCommentReplyKeyPress}
onClick={e => e.stopPropagation()}
onResize={handleLayoutChange}
placeholder={t('hit_enter_to_reply')}
value={replyContent}
/>
</div>
)}
<EntryActions>
{permissions.comment && permissions.write && (
<EntryActions.Button onClick={handleAnimateAndCallOnResolve}>
<MaterialIcon type="inbox" />
&nbsp;{t('resolve')}
</EntryActions.Button>
)}
{permissions.comment && (
<EntryActions.Button
onClick={handleOnReply}
disabled={!replyContent.length}
>
<MaterialIcon type="reply" />
&nbsp;{t('reply')}
</EntryActions.Button>
)}
</EntryActions>
</div>
</div>
</EntryContainer>
)
}
export default memo(CommentEntry)
@@ -1,121 +0,0 @@
import { useTranslation } from 'react-i18next'
import { memo, useState } from 'react'
import AutoExpandingTextArea from '../../../../../shared/components/auto-expanding-text-area'
import { formatTime } from '../../../../utils/format-date'
import { useReviewPanelUpdaterFnsContext } from '../../../context/review-panel/review-panel-context'
import { ReviewPanelCommentThread } from '../../../../../../../types/review-panel/comment-thread'
import {
ReviewPanelCommentThreadMessage,
ThreadId,
} from '../../../../../../../types/review-panel/review-panel'
type CommentProps = {
thread: ReviewPanelCommentThread
threadId: ThreadId
comment: ReviewPanelCommentThreadMessage
}
function Comment({ thread, threadId, comment }: CommentProps) {
const { t } = useTranslation()
const { handleLayoutChange, deleteComment, saveEdit } =
useReviewPanelUpdaterFnsContext()
const [deleting, setDeleting] = useState(false)
const [editing, setEditing] = useState(false)
const handleConfirmDelete = () => {
setDeleting(true)
handleLayoutChange()
}
const handleDoDelete = () => {
setDeleting(false)
deleteComment(threadId, comment.id)
handleLayoutChange()
}
const handleCancelDelete = () => {
setDeleting(false)
handleLayoutChange()
}
const handleStartEditing = () => {
setEditing(true)
handleLayoutChange()
}
const handleSaveEditOnEnter = (
e: React.KeyboardEvent<HTMLTextAreaElement>
) => {
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault()
handleSaveEdit(e)
}
}
const handleSaveEdit = (
e:
| React.FocusEvent<HTMLTextAreaElement>
| React.KeyboardEvent<HTMLTextAreaElement>
) => {
setEditing(false)
saveEdit(threadId, comment.id, (e.target as HTMLTextAreaElement).value)
}
return (
<div className="rp-comment">
<p className="rp-comment-content">
{editing ? (
<AutoExpandingTextArea
className="rp-comment-input"
defaultValue={comment.content}
onKeyPress={handleSaveEditOnEnter}
onBlur={handleSaveEdit}
onClick={e => e.stopPropagation()}
onResize={handleLayoutChange}
autoFocus // eslint-disable-line jsx-a11y/no-autofocus
/>
) : (
<>
<span
className="rp-entry-user"
style={{ color: `hsl(${comment.user.hue}, 70%, 40%` }}
>
{comment.user.name}:
</span>
&nbsp;
{comment.content}
</>
)}
</p>
{!editing && (
<div className="rp-entry-metadata">
{!deleting && formatTime(comment.timestamp, 'MMM D, Y h:mm A')}
{comment.user.isSelf && !deleting && (
<span className="rp-comment-actions">
&nbsp;&bull;&nbsp;
<button onClick={handleStartEditing}>{t('edit')}</button>
{thread.messages.length > 1 && (
<>
&nbsp;&bull;&nbsp;
<button onClick={handleConfirmDelete}>{t('delete')}</button>
</>
)}
</span>
)}
{comment.user.isSelf && deleting && (
<span className="rp-confim-delete">
{t('are_you_sure')}&nbsp;&bull;&nbsp;
<button type="button" onClick={handleDoDelete}>
{t('delete')}
</button>
&nbsp;&bull;&nbsp;
<button onClick={handleCancelDelete}>{t('cancel')}</button>
</span>
)}
</div>
)}
</div>
)
}
export default memo(Comment)
@@ -1,19 +0,0 @@
import classnames from 'classnames'
function EntryActions({
className,
...rest
}: React.ComponentPropsWithoutRef<'div'>) {
return <div className={classnames('rp-entry-actions', className)} {...rest} />
}
EntryActions.Button = function EntryActionsButton({
className,
...rest
}: React.ComponentPropsWithoutRef<'button'>) {
return (
<button className={classnames('rp-entry-button', className)} {...rest} />
)
}
export default EntryActions
@@ -1,7 +0,0 @@
import classnames from 'classnames'
function EntryCallout({ className, ...rest }: React.ComponentProps<'div'>) {
return <div className={classnames('rp-entry-callout', className)} {...rest} />
}
export default EntryCallout
@@ -1,42 +0,0 @@
import classNames from 'classnames'
import { createPortal } from 'react-dom'
import { useReviewPanelValueContext } from '@/features/source-editor/context/review-panel/review-panel-context'
import { Coordinates } from '../hooks/use-indicator-hover'
function EntryContainer({
id,
className,
hoverCoords,
...rest
}: React.ComponentProps<'div'> & {
hoverCoords?: Coordinates | null
}) {
const { layoutToLeft } = useReviewPanelValueContext()
const container = (
<div
className={classNames('rp-entry-wrapper', className)}
data-entry-id={id}
{...rest}
/>
)
if (hoverCoords) {
// Render in a floating positioned container
return createPortal(
<div
className={classNames('rp-floating-entry', {
'rp-floating-entry-layout-left': layoutToLeft,
})}
style={{ left: hoverCoords.x + 'px', top: hoverCoords.y + 'px' }}
>
{container}
</div>,
document.body
)
} else {
return container
}
}
export default EntryContainer
@@ -1,30 +0,0 @@
import classnames from 'classnames'
import { forwardRef } from 'react'
type EntryIndicatorProps = {
focused: boolean
onMouseEnter: () => void
onClick: () => void
children: React.ReactNode
}
const EntryIndicator = forwardRef<HTMLDivElement, EntryIndicatorProps>(
({ focused, onMouseEnter, onClick, children }, ref) => {
return (
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
<div
ref={ref}
className={classnames('rp-entry-indicator', {
'rp-entry-indicator-focused': focused,
})}
onClick={onClick}
onMouseEnter={onMouseEnter}
>
{children}
</div>
)
}
)
EntryIndicator.displayName = 'EntryIndicator'
export default EntryIndicator
@@ -1,96 +0,0 @@
import { useMemo } from 'react'
import ChangeEntry from '@/features/source-editor/components/review-panel/entries/change-entry'
import AggregateChangeEntry from '@/features/source-editor/components/review-panel/entries/aggregate-change-entry'
import CommentEntry from '@/features/source-editor/components/review-panel/entries/comment-entry'
import { useReviewPanelValueContext } from '@/features/source-editor/context/review-panel/review-panel-context'
import {
ReviewPanelDocEntries,
ThreadId,
} from '../../../../../../../types/review-panel/review-panel'
import { ReviewPanelEntry } from '../../../../../../../types/review-panel/entry'
import { DocId } from '../../../../../../../types/project-settings'
type OverviewFileEntriesProps = {
docId: DocId
docEntries: ReviewPanelDocEntries
}
function OverviewFileEntries({ docId, docEntries }: OverviewFileEntriesProps) {
const { commentThreads, permissions, users } = useReviewPanelValueContext()
const objectEntries = useMemo(() => {
const entries = Object.entries(docEntries) as Array<
[ThreadId, ReviewPanelEntry]
>
const orderedEntries = entries.sort(([, entryA], [, entryB]) => {
return entryA.offset - entryB.offset
})
return orderedEntries
}, [docEntries])
return (
<div className="rp-overview-file-entries">
{objectEntries.map(([id, entry]) => {
if (entry.type === 'insert' || entry.type === 'delete') {
return (
<ChangeEntry
key={id}
docId={docId}
entryId={id}
permissions={permissions}
user={users[entry.metadata.user_id]}
content={entry.content}
offset={entry.offset}
type={entry.type}
focused={entry.focused}
entryIds={entry.entry_ids}
timestamp={entry.metadata.ts}
/>
)
}
if (entry.type === 'aggregate-change') {
return (
<AggregateChangeEntry
key={id}
docId={docId}
entryId={id}
permissions={permissions}
user={users[entry.metadata.user_id]}
content={entry.content}
replacedContent={entry.metadata.replaced_content}
offset={entry.offset}
focused={entry.focused}
entryIds={entry.entry_ids}
timestamp={entry.metadata.ts}
/>
)
}
if (entry.type === 'comment') {
const thread = commentThreads[entry.thread_id]
if (!thread?.resolved) {
return (
<CommentEntry
key={id}
docId={docId}
threadId={entry.thread_id}
thread={thread}
entryId={id}
offset={entry.offset}
focused={entry.focused}
permissions={permissions}
/>
)
}
}
return null
})}
</div>
)
}
export default OverviewFileEntries
@@ -1,127 +0,0 @@
import { useTranslation } from 'react-i18next'
import { memo, useState } from 'react'
import Linkify from 'react-linkify'
import { formatTime } from '../../../../utils/format-date'
import { useReviewPanelUpdaterFnsContext } from '../../../context/review-panel/review-panel-context'
import { FilteredResolvedComments } from '../toolbar/resolved-comments-dropdown'
import { Permissions } from '@/features/ide-react/types/permissions'
function LinkDecorator(
decoratedHref: string,
decoratedText: string,
key: number
) {
return (
<a target="blank" rel="noreferrer noopener" href={decoratedHref} key={key}>
{decoratedText}
</a>
)
}
type ResolvedCommentEntryProps = {
thread: FilteredResolvedComments
permissions: Permissions
contentLimit?: number
}
function ResolvedCommentEntry({
thread,
permissions,
contentLimit = 40,
}: ResolvedCommentEntryProps) {
const { t } = useTranslation()
const { unresolveComment, deleteThread } = useReviewPanelUpdaterFnsContext()
const [isCollapsed, setIsCollapsed] = useState(false)
const needsCollapsing = thread.content.length > contentLimit
const content = isCollapsed
? thread.content.substring(0, contentLimit)
: thread.content
const handleUnresolve = () => {
unresolveComment(thread.docId, thread.threadId)
}
const handleDelete = () => {
deleteThread(thread.docId, thread.threadId)
}
return (
<div className="rp-resolved-comment">
<div>
<div className="rp-resolved-comment-context">
{t('quoted_text_in')}
&nbsp;
<span className="rp-resolved-comment-context-file">
{thread.docName}
</span>
<p className="rp-resolved-comment-context-quote">
<span>{content}</span>
</p>
{needsCollapsing && (
<>
&nbsp;
<button
className="rp-collapse-toggle"
onClick={() => setIsCollapsed(value => !value)}
>
{isCollapsed ? `… (${t('show_all')})` : ` (${t('show_less')})`}
</button>
</>
)}
</div>
{thread.messages.map((comment, index) => {
const showUser =
index === 0 ||
comment.user.id !== thread.messages[index - 1].user.id
return (
<div className="rp-comment" key={comment.id}>
<p className="rp-comment-content">
{showUser && (
<span
className="rp-entry-user"
style={{ color: `hsl(${comment.user.hue}, 70%, 40%)` }}
>
{comment.user.name}:&nbsp;
</span>
)}
<Linkify componentDecorator={LinkDecorator}>
{comment.content}
</Linkify>
</p>
<div className="rp-entry-metadata">
{formatTime(comment.timestamp, 'MMM D, Y h:mm A')}
</div>
</div>
)
})}
<div className="rp-comment rp-comment-resolver">
<p className="rp-comment-resolver-content">
<span
className="rp-entry-user"
style={{ color: `hsl(${thread.resolved_by_user.hue}, 70%, 40%)` }}
>
{thread.resolved_by_user.name}:&nbsp;
</span>
{t('mark_as_resolved')}.
</p>
<div className="rp-entry-metadata">
{formatTime(thread.resolved_at, 'MMM D, Y h:mm A')}
</div>
</div>
</div>
{permissions.comment && permissions.write && (
<div className="rp-entry-actions">
<button className="rp-entry-button" onClick={handleUnresolve}>
{t('reopen')}
</button>
<button className="rp-entry-button" onClick={handleDelete}>
{t('delete')}
</button>
</div>
)}
</div>
)
}
export default memo(ResolvedCommentEntry)
@@ -1,109 +0,0 @@
import { memo } from 'react'
import ChangeEntry from './entries/change-entry'
import AggregateChangeEntry from './entries/aggregate-change-entry'
import CommentEntry from './entries/comment-entry'
import AddCommentEntry from './entries/add-comment-entry'
import BulkActionsEntry from './entries/bulk-actions-entry/bulk-actions-entry'
import {
ReviewPanelDocEntries,
ThreadId,
} from '../../../../../../types/review-panel/review-panel'
import { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context'
import { useEditorContext } from '../../../../shared/context/editor-context'
type Props = {
entry: ReviewPanelDocEntries[keyof ReviewPanelDocEntries]
id: ThreadId | 'add-comment' | 'bulk-actions'
}
const isEntryAThreadId = (
entry: keyof ReviewPanelDocEntries
): entry is ThreadId => entry !== 'add-comment' && entry !== 'bulk-actions'
function Entry({ entry, id }: Props) {
const {
commentThreads,
openDocId,
permissions,
loadingThreads,
users,
nVisibleSelectedChanges: nChanges,
} = useReviewPanelValueContext()
const { isRestrictedTokenMember } = useEditorContext()
if (!entry.visible || !openDocId) {
return null
}
if (
isEntryAThreadId(id) &&
(entry.type === 'insert' || entry.type === 'delete')
) {
return (
<ChangeEntry
key={id}
docId={openDocId}
entryId={id}
permissions={permissions}
user={users[entry.metadata.user_id]}
content={entry.content}
offset={entry.offset}
type={entry.type}
focused={entry.focused}
entryIds={entry.entry_ids}
timestamp={entry.metadata.ts}
/>
)
}
if (isEntryAThreadId(id) && entry.type === 'aggregate-change') {
return (
<AggregateChangeEntry
key={id}
docId={openDocId}
entryId={id}
permissions={permissions}
user={users[entry.metadata.user_id]}
content={entry.content}
replacedContent={entry.metadata.replaced_content}
offset={entry.offset}
focused={entry.focused}
entryIds={entry.entry_ids}
timestamp={entry.metadata.ts}
/>
)
}
if (isEntryAThreadId(id) && entry.type === 'comment' && !loadingThreads) {
return (
<CommentEntry
key={id}
docId={openDocId}
threadId={entry.thread_id}
thread={commentThreads[entry.thread_id]}
entryId={id}
offset={entry.offset}
focused={entry.focused}
permissions={permissions}
/>
)
}
if (
entry.type === 'add-comment' &&
permissions.comment &&
!isRestrictedTokenMember
) {
return <AddCommentEntry key={id} />
}
if (entry.type === 'bulk-actions' && permissions.write) {
return (
<BulkActionsEntry key={id} entryId={entry.type} nChanges={nChanges} />
)
}
return null
}
export default memo(Entry)
@@ -1,33 +0,0 @@
import { useReviewPanelUpdaterFnsContext } from '@/features/source-editor/context/review-panel/review-panel-context'
import { DocId } from '../../../../../../../types/project-settings'
export function useEntryClick(
docId: DocId,
offset: number,
cb?: (e: React.MouseEvent<HTMLDivElement>) => void
) {
const { gotoEntry } = useReviewPanelUpdaterFnsContext()
return (e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as Element
// Ignore clicks inside interactive elements
if (!target.closest('textarea, button, a')) {
// If the user was making a selection within the entry rather than
// clicking it, ignore the click. Do this by checking whether there is a
// selection that intersects with the target, in which case we assume
// the user was making a selection
const selection = window.getSelection()
if (
!selection ||
selection.isCollapsed ||
selection.rangeCount === 0 ||
!selection.getRangeAt(0).intersectsNode(target)
) {
gotoEntry(docId, offset)
}
}
cb?.(e)
}
}
@@ -1,69 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import { useReviewPanelUpdaterFnsContext } from '../../../context/review-panel/review-panel-context'
import { useLayoutContext } from '../../../../../shared/context/layout-context'
import EntryIndicator from '../entries/entry-indicator'
export type Coordinates = {
x: number
y: number
}
function useIndicatorHover() {
const [hoverCoords, setHoverCoords] = useState<Coordinates | null>(null)
const { toggleReviewPanel } = useReviewPanelUpdaterFnsContext()
const { reviewPanelOpen } = useLayoutContext()
const { setLayoutSuspended, handleLayoutChange } =
useReviewPanelUpdaterFnsContext()
const indicatorRef = useRef<React.ElementRef<typeof EntryIndicator> | null>(
null
)
const endHover = useCallback(() => {
if (!reviewPanelOpen) {
// Use flushSync to ensure that React renders immediately. This is
// necessary to ensure that the subsequent layout update acts on the
// updated DOM.
flushSync(() => {
setHoverCoords(null)
setLayoutSuspended(false)
})
handleLayoutChange({ force: true })
}
}, [handleLayoutChange, reviewPanelOpen, setLayoutSuspended])
const handleIndicatorMouseEnter = () => {
const rect = indicatorRef.current?.getBoundingClientRect()
setHoverCoords({
x: rect?.left || 0,
y: rect?.top || 0,
})
setLayoutSuspended(true)
}
const handleIndicatorClick = () => {
setHoverCoords(null)
setLayoutSuspended(false)
toggleReviewPanel()
}
useEffect(() => {
if (hoverCoords) {
window.addEventListener('editor:scroll', endHover)
return () => {
window.removeEventListener('editor:scroll', endHover)
}
}
}, [hoverCoords, endHover])
return {
hoverCoords,
indicatorRef,
endHover,
handleIndicatorMouseEnter,
handleIndicatorClick,
}
}
export default useIndicatorHover
@@ -1,65 +0,0 @@
import { useTranslation } from 'react-i18next'
import classnames from 'classnames'
import {
useReviewPanelValueContext,
useReviewPanelUpdaterFnsContext,
} from '../../context/review-panel/review-panel-context'
import { isCurrentFileView, isOverviewView } from '../../utils/sub-view'
import { useCallback } from 'react'
import { useResizeObserver } from '../../../../shared/hooks/use-resize-observer'
import MaterialIcon from '@/shared/components/material-icon'
function Nav() {
const { t } = useTranslation()
const { subView } = useReviewPanelValueContext()
const { handleSetSubview, setNavHeight } = useReviewPanelUpdaterFnsContext()
const handleResize = useCallback(
el => {
// Use requestAnimationFrame to prevent errors like "ResizeObserver loop
// completed with undelivered notifications" that occur if onResize does
// something complicated. The cost of this is that onResize lags one frame
// behind, but it's unlikely to matter.
const height = el.offsetHeight
window.requestAnimationFrame(() => setNavHeight(height))
},
[setNavHeight]
)
const { elementRef } = useResizeObserver(handleResize)
return (
<div ref={elementRef} className="rp-nav" role="tablist">
<button
type="button"
id="review-panel-tab-current-file"
role="tab"
aria-selected={isCurrentFileView(subView)}
aria-controls="review-panel-current-file"
tabIndex={isCurrentFileView(subView) ? 0 : -1}
className={classnames('rp-nav-item', {
'rp-nav-item-active': isCurrentFileView(subView),
})}
onClick={() => handleSetSubview('cur_file')}
>
<MaterialIcon type="description" className="align-middle" />
<span className="rp-nav-label">{t('current_file')}</span>
</button>
<button
type="button"
id="review-panel-tab-overview"
role="tab"
aria-selected={isOverviewView(subView)}
aria-controls="review-panel-overview"
tabIndex={isOverviewView(subView) ? 0 : -1}
className={classnames('rp-nav-item', {
'rp-nav-item-active': isOverviewView(subView),
})}
onClick={() => handleSetSubview('overview')}
>
<MaterialIcon type="list" className="align-middle" />
<span className="rp-nav-label">{t('overview')}</span>
</button>
</div>
)
}
export default Nav
@@ -1,43 +0,0 @@
import Container from './container'
import Toggler from './toggler'
import Toolbar from './toolbar/toolbar'
import Nav from './nav'
import OverviewFile from './overview-file'
import { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { memo } from 'react'
import LoadingSpinner from '@/shared/components/loading-spinner'
function OverviewContainer() {
const { isOverviewLoading } = useReviewPanelValueContext()
const { docs } = useFileTreeData()
return (
<Container>
<Toggler />
<Toolbar />
<div
className="rp-entry-list"
id="review-panel-overview"
role="tabpanel"
tabIndex={0}
aria-labelledby="review-panel-tab-overview"
>
{isOverviewLoading ? (
<LoadingSpinner className="d-flex justify-content-center my-2" />
) : (
docs?.map(doc => (
<OverviewFile
key={doc.doc.id}
docId={doc.doc.id}
docPath={doc.path}
/>
))
)}
</div>
<Nav />
</Container>
)
}
export default memo(OverviewContainer)
@@ -1,68 +0,0 @@
import { useMemo } from 'react'
import {
useReviewPanelUpdaterFnsContext,
useReviewPanelValueContext,
} from '../../context/review-panel/review-panel-context'
import classnames from 'classnames'
import { ReviewPanelDocEntries } from '../../../../../../types/review-panel/review-panel'
import { MainDocument } from '../../../../../../types/project-settings'
import OverviewFileEntries from '@/features/source-editor/components/review-panel/entries/overview-file-entries'
import MaterialIcon from '@/shared/components/material-icon'
type OverviewFileProps = {
docId: MainDocument['doc']['id']
docPath: MainDocument['path']
}
function OverviewFile({ docId, docPath }: OverviewFileProps) {
const { entries, collapsed } = useReviewPanelValueContext()
const { setCollapsed } = useReviewPanelUpdaterFnsContext()
const docCollapsed = collapsed[docId]
const docEntries = useMemo(() => {
return docId in entries ? entries[docId] : ({} as ReviewPanelDocEntries)
}, [docId, entries])
const entryCount = useMemo(() => {
return Object.keys(docEntries).filter(
key => key !== 'add-comment' && key !== 'bulk-actions'
).length
}, [docEntries])
const handleToggleCollapsed = () => {
setCollapsed({ ...collapsed, [docId]: !docCollapsed })
}
return (
<div className="rp-overview-file">
{entryCount > 0 && (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className="rp-overview-file-header"
onClick={handleToggleCollapsed}
>
<span
className={classnames('rp-overview-file-header-collapse', {
'rp-overview-file-header-collapse-on': docCollapsed,
})}
>
<MaterialIcon type="expand_more" />
</span>
{docPath}
{docCollapsed && (
<>
&nbsp;
<span className="rp-overview-file-num-entries">
({entryCount})
</span>
</>
)}
</div>
)}
{!docCollapsed && (
<OverviewFileEntries docId={docId} docEntries={docEntries} />
)}
</div>
)
}
export default OverviewFile
@@ -1,502 +0,0 @@
import { useEffect, useLayoutEffect, useRef, useCallback } from 'react'
import { useLayoutContext } from '../../../../shared/context/layout-context'
import {
ReviewPanelEntry,
ReviewPanelEntryScreenPos,
} from '../../../../../../types/review-panel/entry'
import { debugConsole } from '../../../../utils/debugging'
import { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context'
import { ReviewPanelDocEntries } from '../../../../../../types/review-panel/review-panel'
import { dispatchReviewPanelLayout } from '../../extensions/changes/change-manager'
import { isEqual } from 'lodash'
type Positions = {
entryTop: number
callout: { top: number; height: number; inverted: boolean }
inViewport: boolean
}
type EntryView = {
entryId: keyof ReviewPanelDocEntries
wrapper: HTMLElement
indicator: HTMLElement | null
box: HTMLElement
callout: HTMLElement
layout: HTMLElement
height: number
entry: ReviewPanelEntry
hasScreenPos: boolean
positions?: Positions
previousPositions?: Positions
}
type EntryPositions = Pick<EntryView, 'entryId' | 'positions'>
type PositionedEntriesProps = {
entries: Array<[keyof ReviewPanelDocEntries, ReviewPanelEntry]>
contentHeight: number
children: React.ReactNode
}
const initialLayoutInfo = {
focusedEntryIndex: 0,
overflowTop: 0,
height: 0,
positions: [] as EntryPositions[],
}
function css(el: HTMLElement, props: React.CSSProperties) {
Object.assign(el.style, props)
}
function calculateCalloutPosition(
screenPos: ReviewPanelEntryScreenPos,
entryTop: number,
lineHeight: number
) {
const height = screenPos.height ?? lineHeight
const originalTop = screenPos.y
const inverted = entryTop <= originalTop
return {
top: inverted ? entryTop + height : originalTop + height - 1,
height: Math.abs(entryTop - originalTop),
inverted,
}
}
function positionsEqual(
entryPos1: EntryPositions[],
entryPos2: EntryPositions[]
) {
return isEqual(entryPos1, entryPos2)
}
function updateEntryPositions(
entryView: EntryView,
entryTop: number,
lineHeight: number
) {
const callout = calculateCalloutPosition(
entryView.entry.screenPos,
entryTop,
lineHeight
)
entryView.positions = {
entryTop,
callout,
inViewport: entryView.entry.inViewport,
}
}
function calculateEntryViewPositions(
entryViews: EntryView[],
lineHeight: number,
calculateTop: (originalTop: number, height: number) => number
) {
for (const entryView of entryViews) {
if (entryView.hasScreenPos) {
const entryTop = calculateTop(
entryView.entry.screenPos.y,
entryView.height
)
updateEntryPositions(entryView, entryTop, lineHeight)
}
}
}
function hideOrShowEntries(entryViews: EntryView[]) {
for (const entryView of entryViews) {
// Completely hide any entry that has no screen position
entryView.wrapper.classList.toggle(
'rp-entry-hidden',
!entryView.hasScreenPos
)
}
}
function applyEntryTop(entryView: EntryView, top: number) {
entryView.box.style.top = top + 'px'
if (entryView.indicator) {
entryView.indicator.style.top = top + 'px'
}
}
function applyEntryVisibility(entryView: EntryView) {
// The entry element is invisible by default, to avoid flickering when
// positioning for the first time. Here we make sure it becomes visible after
// acquiring a screen position.
if (entryView.entry.inViewport) {
entryView.box.style.visibility = 'visible'
entryView.callout.style.visibility = 'visible'
}
}
// Position everything where it was before, taking into account the new top
// overflow
function moveEntriesToInitialPosition(
entryViews: EntryView[],
overflowTop: number
) {
for (const entryView of entryViews) {
const { callout: calloutEl, positions } = entryView
if (positions) {
const { entryTop, callout } = positions
// Position the main wrapper in its original position, if it had
// one, or its new position otherwise
const entryTopInitial = entryView.previousPositions
? entryView.previousPositions.entryTop
: entryTop
applyEntryVisibility(entryView)
applyEntryTop(entryView, entryTopInitial + overflowTop)
// Position the callout element in its original position, if it had
// one, or its new position otherwise
calloutEl.classList.toggle('rp-entry-callout-inverted', callout.inverted)
const calloutTopInitial = entryView.previousPositions
? entryView.previousPositions.callout.top
: callout.top
css(calloutEl, {
top: calloutTopInitial + overflowTop + 'px',
height: callout.height + 'px',
})
}
}
}
function moveEntriesToFinalPositions(
entryViews: EntryView[],
overflowTop: number,
shouldApplyVisibility: boolean
) {
for (const entryView of entryViews) {
const { callout: calloutEl, positions } = entryView
if (positions) {
const { entryTop, callout } = positions
if (shouldApplyVisibility) {
applyEntryVisibility(entryView)
}
// Position the main wrapper, if it's moved
if (entryView.previousPositions?.entryTop !== entryTop) {
entryView.box.style.top = entryTop + overflowTop + 'px'
}
if (entryView.indicator) {
entryView.indicator.style.top = entryTop + overflowTop + 'px'
}
// Position the callout element
if (entryView.previousPositions?.callout.top !== callout.top) {
calloutEl.style.top = callout.top + overflowTop + 'px'
}
}
}
}
function PositionedEntries({
entries,
contentHeight,
children,
}: PositionedEntriesProps) {
const { navHeight, toolbarHeight, lineHeight, layoutSuspended } =
useReviewPanelValueContext()
const containerRef = useRef<HTMLDivElement | null>(null)
const { reviewPanelOpen } = useLayoutContext()
const previousLayoutInfoRef = useRef(initialLayoutInfo)
const resetLayout = () => {
previousLayoutInfoRef.current = initialLayoutInfo
}
const layout = useCallback(
(animate = true) => {
const container = containerRef.current
if (!container) {
return
}
const padding = reviewPanelOpen ? 8 : 4
const toolbarPaddedHeight = reviewPanelOpen ? toolbarHeight + 6 : 0
const navPaddedHeight = reviewPanelOpen ? navHeight + 4 : 0
// Create a list of entry views, typing together DOM elements and model.
// No measuring or style change is done at this point.
const entryViews: EntryView[] = []
// TODO: Look into tying the entry to the DOM element without going via a DOM data attribute
for (const wrapper of container.querySelectorAll<HTMLElement>(
'.rp-entry-wrapper'
)) {
const entryId = wrapper.dataset.entryId as
| EntryView['entryId']
| undefined
if (!entryId) {
throw new Error('Could not find an entry ID')
}
const entry = entries.find(value => value[0] === entryId)?.[1]
if (!entry) {
throw new Error(`Could not find an entry for ID ${entryId}`)
}
const indicator = wrapper.querySelector<HTMLElement>(
'.rp-entry-indicator'
)
const box = wrapper.querySelector<HTMLElement>('.rp-entry')
const callout = wrapper.querySelector<HTMLElement>('.rp-entry-callout')
const layoutElement = reviewPanelOpen ? box : indicator
if (box && callout && layoutElement) {
const previousPositions =
previousLayoutInfoRef.current?.positions.find(
pos => pos.entryId === entryId
)?.positions
const hasScreenPos = Boolean(entry.screenPos)
entryViews.push({
entryId,
wrapper,
indicator,
box,
callout,
layout: layoutElement,
hasScreenPos,
height: 0,
entry,
previousPositions,
})
} else {
debugConsole.log(
'Entry wrapper is missing indicator, box or callout, so ignoring',
wrapper
)
}
}
if (entryViews.length === 0) {
resetLayout()
return
}
entryViews.sort((a, b) => a.entry.offset - b.entry.offset)
// Do the DOM interaction in three phases:
//
// - Apply the `display` property to all elements whose visibility has
// changed. This needs to happen first in order to measure heights.
// - Measure the height of each entry
// - Move each entry without animation to their original position
// relative to the editor content
// - Re-enable animation and position each entry
//
// The idea is to batch DOM reads and writes to avoid layout thrashing. In
// this case, the best we can do is a write phase, a read phase then a
// final write phase.
// See https://web.dev/avoid-large-complex-layouts-and-layout-thrashing/
// First, update display for each entry that needs it
hideOrShowEntries(entryViews)
// Next, measure the height of each entry
for (const entryView of entryViews) {
if (entryView.hasScreenPos) {
entryView.height = entryView.layout.offsetHeight
}
}
// Calculate positions for all positioned entries, starting by calculating
// which entry to put in its desired position and anchor everything else
// around. If there is an explicitly focused entry, use that.
let focusedEntryIndex = entryViews.findIndex(view => view.entry.focused)
if (focusedEntryIndex === -1) {
// There is no explicitly focused entry, so use the focused entry from the
// previous layout. This will be the first entry in the list if there was
// no previous layout.
focusedEntryIndex = Math.min(
previousLayoutInfoRef.current.focusedEntryIndex,
entryViews.length - 1
)
// If the entry from the previous layout has no screen position, fall back
// to the first entry in the list that does.
if (!entryViews[focusedEntryIndex].hasScreenPos) {
focusedEntryIndex = entryViews.findIndex(view => view.hasScreenPos)
}
}
// If there is no entry with a screen position, bail out
if (focusedEntryIndex === -1) {
return
}
const focusedEntryView = entryViews[focusedEntryIndex]
// If the focused entry has no screenPos, we can't position other
// entryViews relative to it, so we position all other entryViews as
// though the focused entry is at the top and the rest follow it
const entryViewsAfter = focusedEntryView.hasScreenPos
? entryViews.slice(focusedEntryIndex + 1)
: [...entryViews]
const entryViewsBefore = focusedEntryView.hasScreenPos
? entryViews.slice(0, focusedEntryIndex).reverse() // Work through backwards, starting with the one just above
: []
debugConsole.log('focusedEntryIndex', focusedEntryIndex)
let lastEntryBottom = 0
let firstEntryTop = 0
// Put the focused entry as close as possible to where it wants to be
if (focusedEntryView.hasScreenPos) {
const focusedEntryScreenPos = focusedEntryView.entry.screenPos
const entryTop = Math.max(focusedEntryScreenPos.y, toolbarPaddedHeight)
updateEntryPositions(focusedEntryView, entryTop, lineHeight)
lastEntryBottom = entryTop + focusedEntryView.height
firstEntryTop = entryTop
}
// Calculate positions for entries that are below the focused entry
calculateEntryViewPositions(
entryViewsAfter,
lineHeight,
(originalTop: number, height: number) => {
const top = Math.max(originalTop, lastEntryBottom + padding)
lastEntryBottom = top + height
return top
}
)
// Calculate positions for entries that are above the focused entry
calculateEntryViewPositions(
entryViewsBefore,
lineHeight,
(originalTop: number, height: number) => {
const originalBottom = originalTop + height
const bottom = Math.min(originalBottom, firstEntryTop - padding)
const top = bottom - height
firstEntryTop = top
return top
}
)
// Calculate the new top overflow
const overflowTop = Math.max(0, toolbarPaddedHeight - firstEntryTop)
// Check whether the positions of any entry have changed since the last
// layout
const positions = entryViews.map(
(entryView): EntryPositions => ({
entryId: entryView.entryId,
positions: entryView.positions,
})
)
const positionsChanged = !positionsEqual(
previousLayoutInfoRef.current.positions,
positions
)
// Check whether the top overflow or review panel height have changed
const overflowTopChanged =
overflowTop !== previousLayoutInfoRef.current.overflowTop
const height = lastEntryBottom + navPaddedHeight
const heightChanged = height !== previousLayoutInfoRef.current.height
const isMoveRequired = positionsChanged || overflowTopChanged
// Move entries into their initial positions, if animating, avoiding
// animation until the final animated move
if (animate && isMoveRequired) {
container.classList.add('no-animate')
moveEntriesToInitialPosition(entryViews, overflowTop)
}
// Inform the editor of the new top overflow and/or height if either has
// changed
if (overflowTopChanged || heightChanged) {
window.dispatchEvent(
new CustomEvent('review-panel:event', {
detail: {
type: 'sizes',
payload: {
overflowTop,
height,
},
},
})
)
}
// Do the final move
if (isMoveRequired) {
if (animate) {
container.classList.remove('no-animate')
moveEntriesToFinalPositions(entryViews, overflowTop, false)
} else {
container.classList.add('no-animate')
moveEntriesToFinalPositions(entryViews, overflowTop, true)
// Force reflow now to ensure that entries are moved without animation
// eslint-disable-next-line no-void
void container.offsetHeight
container.classList.remove('no-animate')
}
}
previousLayoutInfoRef.current = {
positions,
focusedEntryIndex,
height,
overflowTop,
}
},
[entries, lineHeight, navHeight, reviewPanelOpen, toolbarHeight]
)
useLayoutEffect(() => {
const callback = (event: Event) => {
const e = event as CustomEvent
if (!layoutSuspended) {
// Clear previous positions if forcing a layout
if (e.detail.force) {
previousLayoutInfoRef.current = initialLayoutInfo
}
layout(e.detail.animate)
}
}
window.addEventListener('review-panel:layout', callback)
return () => {
window.removeEventListener('review-panel:layout', callback)
}
}, [layoutSuspended, layout])
// Layout on first render. This is necessary to ensure layout happens when
// switching from overview to current file view
useEffect(() => {
dispatchReviewPanelLayout()
}, [])
// Ensure a full layout is performed after opening or closing the review panel
useEffect(() => {
previousLayoutInfoRef.current = initialLayoutInfo
}, [reviewPanelOpen])
return (
<div
ref={containerRef}
className="rp-entry-list-react"
style={{ height: `${contentHeight}px` }}
>
{children}
</div>
)
}
export default PositionedEntries
@@ -1,37 +0,0 @@
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)
@@ -1,19 +0,0 @@
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,18 +0,0 @@
import ReactDOM from 'react-dom'
import { useCodeMirrorViewContext } from '../codemirror-context'
import { ReviewPanelProvider } from '../../context/review-panel/review-panel-context'
import { memo } from 'react'
import ReviewPanelContent from '@/features/source-editor/components/review-panel/review-panel-content'
function ReviewPanel() {
const view = useCodeMirrorViewContext()
return ReactDOM.createPortal(
<ReviewPanelProvider>
<ReviewPanelContent />
</ReviewPanelProvider>,
view.scrollDOM
)
}
export default memo(ReviewPanel)
@@ -1,29 +0,0 @@
import { useTranslation } from 'react-i18next'
import { useReviewPanelUpdaterFnsContext } from '../../context/review-panel/review-panel-context'
import MaterialIcon from '@/shared/components/material-icon'
function Toggler() {
const { t } = useTranslation()
const { toggleReviewPanel } = useReviewPanelUpdaterFnsContext()
const handleTogglerClick = (event: React.MouseEvent<HTMLButtonElement>) => {
const target = event.target as HTMLButtonElement
target.blur()
toggleReviewPanel()
}
return (
<button
type="button"
className="review-panel-toggler"
onClick={handleTogglerClick}
>
<span className="sr-only">{t('hotkey_toggle_review_panel')}</span>
<span className="review-panel-toggler-icon">
<MaterialIcon type="chevron_left" />
</span>
</button>
)
}
export default Toggler
@@ -1,133 +0,0 @@
import { useTranslation } from 'react-i18next'
import { useState, useMemo, useCallback } from 'react'
import ResolvedCommentsScroller from './resolved-comments-scroller'
import classnames from 'classnames'
import {
useReviewPanelUpdaterFnsContext,
useReviewPanelValueContext,
} from '../../../context/review-panel/review-panel-context'
import {
ReviewPanelDocEntries,
ThreadId,
} from '../../../../../../../types/review-panel/review-panel'
import { ReviewPanelResolvedCommentThread } from '../../../../../../../types/review-panel/comment-thread'
import { DocId } from '../../../../../../../types/project-settings'
import { ReviewPanelEntry } from '../../../../../../../types/review-panel/entry'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import LoadingSpinner from '@/shared/components/loading-spinner'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import MaterialIcon from '@/shared/components/material-icon'
export interface FilteredResolvedComments
extends ReviewPanelResolvedCommentThread {
content: string
threadId: ThreadId
entryId: ThreadId
docId: DocId
docName: string | null
}
function ResolvedCommentsDropdown() {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const { commentThreads, resolvedComments, permissions } =
useReviewPanelValueContext()
const { docs } = useFileTreeData()
const { refreshResolvedCommentsDropdown } = useReviewPanelUpdaterFnsContext()
const handleResolvedCommentsClick = () => {
setIsOpen(isOpen => {
if (!isOpen) {
setIsLoading(true)
refreshResolvedCommentsDropdown().finally(() => setIsLoading(false))
}
return !isOpen
})
}
const getDocNameById = useCallback(
(docId: DocId) => {
return docs?.find(doc => doc.doc.id === docId)?.doc.name || null
},
[docs]
)
const filteredResolvedComments = useMemo(() => {
const comments: FilteredResolvedComments[] = []
for (const [docId, docEntries] of Object.entries(resolvedComments) as Array<
[DocId, ReviewPanelDocEntries]
>) {
for (const [entryId, entry] of Object.entries(docEntries) as Array<
[ThreadId, ReviewPanelEntry]
>) {
if (entry.type === 'comment') {
const threadId = entry.thread_id
const thread =
threadId in commentThreads
? commentThreads[entry.thread_id]
: undefined
if (thread?.resolved) {
comments.push({
...thread,
content: entry.content,
threadId,
entryId,
docId,
docName: getDocNameById(docId),
})
}
}
}
}
return comments
}, [commentThreads, getDocNameById, resolvedComments])
return (
<div className="resolved-comments">
<div
aria-hidden="true"
className={classnames('resolved-comments-backdrop', {
'resolved-comments-backdrop-visible': isOpen,
})}
onClick={() => setIsOpen(false)}
/>
<OLTooltip
id="resolved-comments-toggle"
description={t('resolved_comments')}
overlayProps={{ container: document.body, placement: 'bottom' }}
>
<button
className="resolved-comments-toggle"
onClick={handleResolvedCommentsClick}
aria-label={t('resolved_comments')}
>
<MaterialIcon type="inbox" />
</button>
</OLTooltip>
<div
className={classnames('resolved-comments-dropdown', {
'resolved-comments-dropdown-open': isOpen,
})}
>
{isLoading ? (
<LoadingSpinner className="d-flex justify-content-center my-2" />
) : isOpen ? (
<ResolvedCommentsScroller
resolvedComments={filteredResolvedComments}
permissions={permissions}
/>
) : null}
</div>
</div>
)
}
export default ResolvedCommentsDropdown
@@ -1,40 +0,0 @@
import { useTranslation } from 'react-i18next'
import { useMemo } from 'react'
import ResolvedCommentEntry from '../entries/resolved-comment-entry'
import { FilteredResolvedComments } from './resolved-comments-dropdown'
import { Permissions } from '@/features/ide-react/types/permissions'
type ResolvedCommentsScrollerProps = {
resolvedComments: FilteredResolvedComments[]
permissions: Permissions
}
function ResolvedCommentsScroller({
resolvedComments,
permissions,
}: ResolvedCommentsScrollerProps) {
const { t } = useTranslation()
const sortedResolvedComments = useMemo(() => {
return [...resolvedComments].sort((a, b) => {
return Date.parse(b.resolved_at) - Date.parse(a.resolved_at)
})
}, [resolvedComments])
return (
<div className="resolved-comments-scroller">
{sortedResolvedComments.map(comment => (
<ResolvedCommentEntry
key={comment.entryId}
thread={comment}
permissions={permissions}
/>
))}
{!resolvedComments.length && (
<div className="text-center p-1">{t('no_resolved_threads')}</div>
)}
</div>
)
}
export default ResolvedCommentsScroller
@@ -1,82 +0,0 @@
import { memo, useState } from 'react'
import { Trans } from 'react-i18next'
import TrackChangesMenu from '@/features/source-editor/components/review-panel/toolbar/track-changes-menu'
import UpgradeTrackChangesModal from '../upgrade-track-changes-modal'
import { useProjectContext } from '../../../../../shared/context/project-context'
import {
useReviewPanelUpdaterFnsContext,
useReviewPanelValueContext,
} from '../../../context/review-panel/review-panel-context'
import { send, sendMB } from '../../../../../infrastructure/event-tracking'
import classnames from 'classnames'
import MaterialIcon from '@/shared/components/material-icon'
const sendAnalytics = () => {
send('subscription-funnel', 'editor-click-feature', 'real-time-track-changes')
sendMB('paywall-prompt', {
'paywall-type': 'track-changes',
})
}
function ToggleMenu() {
const project = useProjectContext()
const { setShouldCollapse } = useReviewPanelUpdaterFnsContext()
const { wantTrackChanges, shouldCollapse } = useReviewPanelValueContext()
const [showModal, setShowModal] = useState(false)
const handleToggleFullTCStateCollapse = () => {
if (project.features.trackChanges) {
setShouldCollapse(value => !value)
} else {
sendAnalytics()
setShowModal(true)
}
}
return (
<>
<span className="review-panel-toolbar-label">
{wantTrackChanges && (
<span className="review-panel-toolbar-icon-on">
<span className="track-changes-indicator-circle" />
</span>
)}
<button
className="review-panel-toolbar-collapse-button"
onClick={handleToggleFullTCStateCollapse}
>
<span>
{wantTrackChanges ? <TrackChangesOn /> : <TrackChangesOff />}
</span>
<span
className={classnames('rp-tc-state-collapse', {
'rp-tc-state-collapse-on': shouldCollapse,
})}
>
<MaterialIcon type="expand_more" />
</span>
</button>
</span>
{!shouldCollapse && <TrackChangesMenu />}
<UpgradeTrackChangesModal show={showModal} setShow={setShowModal} />
</>
)
}
const TrackChangesOn = memo(() => {
return (
<Trans i18nKey="track_changes_is_on" components={{ strong: <strong /> }} />
)
})
TrackChangesOn.displayName = 'TrackChangesOn'
const TrackChangesOff = memo(() => (
<Trans i18nKey="track_changes_is_off" components={{ strong: <strong /> }} />
))
TrackChangesOff.displayName = 'TrackChangesOff'
export default memo(ToggleMenu)
@@ -1,30 +0,0 @@
import ResolvedCommentsDropdown from './resolved-comments-dropdown'
import ToggleMenu from './toggle-menu'
import { useReviewPanelUpdaterFnsContext } from '../../../context/review-panel/review-panel-context'
import { useCallback } from 'react'
import { useResizeObserver } from '../../../../../shared/hooks/use-resize-observer'
function Toolbar() {
const { setToolbarHeight } = useReviewPanelUpdaterFnsContext()
const handleResize = useCallback(
el => {
// Use requestAnimationFrame to prevent errors like "ResizeObserver loop
// completed with undelivered notifications" that occur if onResize does
// something complicated. The cost of this is that onResize lags one frame
// behind, but it's unlikely to matter.
const height = el.offsetHeight
window.requestAnimationFrame(() => setToolbarHeight(height))
},
[setToolbarHeight]
)
const { elementRef } = useResizeObserver(handleResize)
return (
<div ref={elementRef} className="review-panel-toolbar">
<ResolvedCommentsDropdown />
<ToggleMenu />
</div>
)
}
export default Toolbar
@@ -1,130 +0,0 @@
import { useTranslation } from 'react-i18next'
import TrackChangesToggle from '@/features/source-editor/components/review-panel/toolbar/track-changes-toggle'
import {
useReviewPanelUpdaterFnsContext,
useReviewPanelValueContext,
} from '@/features/source-editor/context/review-panel/review-panel-context'
import { useProjectContext } from '@/shared/context/project-context'
import classnames from 'classnames'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
function TrackChangesMenu() {
const { t } = useTranslation()
const project = useProjectContext()
const {
toggleTrackChangesForEveryone,
toggleTrackChangesForUser,
toggleTrackChangesForGuests,
} = useReviewPanelUpdaterFnsContext()
const {
permissions,
trackChangesState,
trackChangesOnForEveryone,
trackChangesOnForGuests,
trackChangesForGuestsAvailable,
formattedProjectMembers,
} = useReviewPanelValueContext()
return (
<ul className="rp-tc-state" data-testid="review-panel-track-changes-menu">
<li className="rp-tc-state-item rp-tc-state-item-everyone">
<OLTooltip
description={t('tc_switch_everyone_tip')}
id="track-changes-switch-everyone"
overlayProps={{
container: document.body,
placement: 'left',
delay: 1000,
}}
>
<span className="rp-tc-state-item-name">{t('tc_everyone')}</span>
</OLTooltip>
<TrackChangesToggle
id="track-changes-everyone"
description={t('track_changes_for_everyone')}
handleToggle={() =>
toggleTrackChangesForEveryone(!trackChangesOnForEveryone)
}
value={trackChangesOnForEveryone}
disabled={!project.features.trackChanges || !permissions.write}
/>
</li>
{Object.values(formattedProjectMembers).map(member => (
<li className="rp-tc-state-item" key={member.id}>
<OLTooltip
description={t('tc_switch_user_tip')}
id="track-changes-switch-user"
overlayProps={{
container: document.body,
placement: 'left',
delay: 1000,
}}
>
<span
className={classnames('rp-tc-state-item-name', {
'rp-tc-state-item-name-disabled': trackChangesOnForEveryone,
})}
>
{member.name}
</span>
</OLTooltip>
<TrackChangesToggle
id={`track-changes-user-toggle-${member.id}`}
description={t('track_changes_for_x', { name: member.name })}
handleToggle={() =>
toggleTrackChangesForUser(
!trackChangesState[member.id]?.value,
member.id
)
}
value={Boolean(trackChangesState[member.id]?.value)}
disabled={
trackChangesOnForEveryone ||
!project.features.trackChanges ||
!permissions.write
}
/>
</li>
))}
<li className="rp-tc-state-separator" />
<li className="rp-tc-state-item">
<OLTooltip
description={t('tc_switch_guests_tip')}
id="track-changes-switch-guests"
overlayProps={{
container: document.body,
placement: 'left',
delay: 1000,
}}
>
<span
className={classnames('rp-tc-state-item-name', {
'rp-tc-state-item-name-disabled': trackChangesOnForEveryone,
})}
>
{t('tc_guests')}
</span>
</OLTooltip>
<TrackChangesToggle
id="track-changes-guests-toggle"
description="Track changes for guests"
handleToggle={() =>
toggleTrackChangesForGuests(!trackChangesOnForGuests)
}
value={trackChangesOnForGuests}
disabled={
trackChangesOnForEveryone ||
!project.features.trackChanges ||
!permissions.write ||
!trackChangesForGuestsAvailable
}
/>
</li>
</ul>
)
}
export default TrackChangesMenu
@@ -1,18 +0,0 @@
import { ReviewPanelChangeEntry } from '../../../../../../../types/review-panel/entry'
import { DocId } from '../../../../../../../types/project-settings'
import {
ReviewPanelUser,
ThreadId,
} from '../../../../../../../types/review-panel/review-panel'
import { Permissions } from '@/features/ide-react/types/permissions'
export interface BaseChangeEntryProps
extends Pick<ReviewPanelChangeEntry, 'content' | 'offset' | 'focused'> {
docId: DocId
entryId: ThreadId
permissions: Permissions
user: ReviewPanelUser | undefined
timestamp: ReviewPanelChangeEntry['metadata']['ts']
contentLimit?: number
entryIds: ReviewPanelChangeEntry['entry_ids']
}
@@ -1,31 +0,0 @@
const shallowEqual = (arr1: unknown[], arr2: unknown[]) =>
arr1.length === arr2.length && !arr1.some((val, index) => val !== arr2[index])
// Compares props for a component, but comparing the specified props using
// shallow array comparison rather than identity
export default function comparePropsWithShallowArrayCompare<
T extends Record<string, unknown>,
>(...args: Array<keyof T>) {
return (prevProps: T, nextProps: T) => {
for (const k in prevProps) {
const prev = prevProps[k]
const next = nextProps[k]
if (Object.is(prev, next)) {
continue
}
if (!args.includes(k)) {
return false
}
if (
!Array.isArray(prev) ||
!Array.isArray(next) ||
!shallowEqual(prev, next)
) {
return false
}
}
return true
}
}
@@ -11,7 +11,6 @@ import { useTranslation } from 'react-i18next'
import { MathDropdown } from './math-dropdown'
import { TableInserterDropdown } from './table-inserter-dropdown'
import { withinFormattingCommand } from '@/features/source-editor/utils/tree-operations/formatting'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { isMac } from '@/shared/utils/os'
export const ToolbarItems: FC<{
@@ -123,15 +122,13 @@ export const ToolbarItems: FC<{
command={commands.wrapInHref}
icon="add_link"
/>
{isSplitTestEnabled('review-panel-redesign') && (
<ToolbarButton
id="toolbar-add-comment"
label={t('add_comment')}
disabled={state.selection.main.empty}
command={commands.addComment}
icon="add_comment"
/>
)}
<ToolbarButton
id="toolbar-add-comment"
label={t('add_comment')}
disabled={state.selection.main.empty}
command={commands.addComment}
icon="add_comment"
/>
<ToolbarButton
id="toolbar-ref"
label={t('toolbar_insert_cross_reference')}
@@ -1,45 +0,0 @@
import { createContext, useContext } from 'react'
import type { ReviewPanelState } from './types/review-panel-state'
import useReviewPanelState from '@/features/ide-react/context/review-panel/hooks/use-review-panel-state'
export const ReviewPanelValueContext = createContext<
ReviewPanelState['values'] | undefined
>(undefined)
export const ReviewPanelUpdaterFnsContext = createContext<
ReviewPanelState['updaterFns'] | undefined
>(undefined)
export const ReviewPanelProvider: React.FC = ({ children }) => {
const { values, updaterFns } = useReviewPanelState()
return (
<ReviewPanelValueContext.Provider value={values}>
<ReviewPanelUpdaterFnsContext.Provider value={updaterFns}>
{children}
</ReviewPanelUpdaterFnsContext.Provider>
</ReviewPanelValueContext.Provider>
)
}
export function useReviewPanelValueContext() {
const context = useContext(ReviewPanelValueContext)
if (!context) {
throw new Error(
'ReviewPanelValueContext is only available inside ReviewPanelProvider'
)
}
return context
}
export function useReviewPanelUpdaterFnsContext() {
const context = useContext(ReviewPanelUpdaterFnsContext)
if (!context) {
throw new Error(
'ReviewPanelUpdaterFnsContext is only available inside ReviewPanelProvider'
)
}
return context
}
@@ -1,105 +0,0 @@
import {
CommentId,
ReviewPanelCommentThreads,
ReviewPanelDocEntries,
ReviewPanelEntries,
ReviewPanelUsers,
SubView,
ThreadId,
} from '../../../../../../../types/review-panel/review-panel'
import { Permissions } from '@/features/ide-react/types/permissions'
import { DocId } from '../../../../../../../types/project-settings'
import { dispatchReviewPanelLayout } from '../../../extensions/changes/change-manager'
import { UserId } from '../../../../../../../types/user'
export interface ReviewPanelState {
values: {
collapsed: Record<DocId, boolean>
commentThreads: ReviewPanelCommentThreads
entries: ReviewPanelEntries
isAddingComment: boolean
loadingThreads: boolean
nVisibleSelectedChanges: number
permissions: Permissions
users: ReviewPanelUsers
resolvedComments: ReviewPanelEntries
shouldCollapse: boolean
navHeight: number
toolbarHeight: number
subView: SubView
wantTrackChanges: boolean
isOverviewLoading: boolean
openDocId: DocId | null
lineHeight: number
trackChangesState:
| Record<UserId, { value: boolean; syncState: 'synced' | 'pending' }>
| Record<UserId, undefined>
trackChangesOnForEveryone: boolean
trackChangesOnForGuests: boolean
trackChangesForGuestsAvailable: boolean
formattedProjectMembers: Record<
string,
{
id: UserId
name: string
}
>
layoutSuspended: boolean
unsavedComment: string
layoutToLeft: boolean
}
updaterFns: {
handleSetSubview: (subView: SubView) => void
handleLayoutChange: (
...args: Parameters<typeof dispatchReviewPanelLayout>
) => void
gotoEntry: (docId: DocId, entryOffset: number) => void
resolveComment: (docId: DocId, entryId: ThreadId) => void
deleteComment: (threadId: ThreadId, commentId: CommentId) => void
submitReply: (threadId: ThreadId, replyContent: string) => void
acceptChanges: (entryIds: ThreadId[]) => void
rejectChanges: (entryIds: ThreadId[]) => void
toggleTrackChangesForEveryone: (onForEveryone: boolean) => void
toggleTrackChangesForUser: (onForUser: boolean, userId: UserId) => void
toggleTrackChangesForGuests: (onForGuests: boolean) => void
toggleReviewPanel: () => void
bulkAcceptActions: () => void
bulkRejectActions: () => void
saveEdit: (
threadId: ThreadId,
commentId: CommentId,
content: string
) => void
unresolveComment: (docId: DocId, threadId: ThreadId) => void
deleteThread: (docId: DocId, threadId: ThreadId) => void
refreshResolvedCommentsDropdown: () => Promise<
void | ReviewPanelDocEntries[]
>
submitNewComment: (content: string) => void
setIsAddingComment: React.Dispatch<
React.SetStateAction<Value<'isAddingComment'>>
>
setCollapsed: React.Dispatch<React.SetStateAction<Value<'collapsed'>>>
setShouldCollapse: React.Dispatch<
React.SetStateAction<Value<'shouldCollapse'>>
>
setNavHeight: React.Dispatch<React.SetStateAction<Value<'navHeight'>>>
setToolbarHeight: React.Dispatch<
React.SetStateAction<Value<'toolbarHeight'>>
>
setLayoutSuspended: React.Dispatch<
React.SetStateAction<Value<'layoutSuspended'>>
>
setUnsavedComment: React.Dispatch<
React.SetStateAction<Value<'unsavedComment'>>
>
}
}
// Getter for values
export type Value<T extends keyof ReviewPanelState['values']> =
ReviewPanelState['values'][T]
// Getter for stable functions
export type UpdaterFn<T extends keyof ReviewPanelState['updaterFns']> =
ReviewPanelState['updaterFns'][T]
@@ -1,616 +0,0 @@
import { trackChangesAnnotation } from '../realtime'
import { clearChangeMarkers, buildChangeMarkers } from '../track-changes'
import {
setVerticalOverflow,
editorVerticalTopPadding,
updateChangesTopPadding,
updateSetsVerticalOverflow,
} from '../vertical-overflow'
import {
EditorSelection,
EditorState,
StateEffect,
TransactionSpec,
} from '@codemirror/state'
import { EditorView, ViewUpdate } from '@codemirror/view'
import { fullHeightCoordsAtPos } from '../../utils/layer'
import { debounce } from 'lodash'
import { Change, EditOperation } from '../../../../../../types/change'
import { ThreadId } from '../../../../../../types/review-panel/review-panel'
import { isDeleteOperation, isInsertOperation } from '@/utils/operations'
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
import { updateHasEffect } from '@/features/source-editor/utils/effects'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
// With less than this number of entries, don't bother culling to avoid
// little UI jumps when scrolling.
const CULL_AFTER = Infinity // Note: was 100 but couldn't scroll to see items outside the viewport
export const dispatchEditorEvent = (type: string, payload?: unknown) => {
window.setTimeout(() => {
window.dispatchEvent(
new CustomEvent('editor:event', {
detail: { type, payload },
})
)
}, 0)
}
const dispatchReviewPanelLayoutImmediately = ({
force = false,
animate = true,
} = {}) => {
window.dispatchEvent(
new CustomEvent('review-panel:layout', { detail: { force, animate } })
)
}
const scheduleDispatchReviewPanelLayout = debounce(
dispatchReviewPanelLayoutImmediately,
10
)
/**
* @param force If true, forces the entries to be repositioned
* @param animate
* @param async If true, calls are briefly delayed and debounced
*/
export const dispatchReviewPanelLayout = ({
force = false,
animate = true,
async = false,
} = {}) => {
if (async) {
scheduleDispatchReviewPanelLayout({ force, animate })
} else {
dispatchReviewPanelLayoutImmediately({ force, animate })
}
}
export type ChangeManager = {
initialize: () => void
handleUpdate: (update: ViewUpdate) => void
destroy: () => void
}
export type UpdateType =
| 'edit'
| 'selectionChange'
| 'geometryChange'
| 'viewportChange'
| 'acceptChanges'
| 'rejectChanges'
| 'trackedChangesChange'
| 'topPaddingChange'
export const createChangeManager = (
view: EditorView,
currentDoc: DocumentContainer
): ChangeManager | undefined => {
if (isSplitTestEnabled('review-panel-redesign')) {
return undefined
}
/**
* Calculate the screen coordinates of each entry (change or comment),
* for use in the review panel.
*
* Returns a boolean indicating whether the visibility of any entry has changed
*/
const recalculateScreenPositions = ({
entries,
updateType,
}: {
entries?: Record<string, any>
updateType: UpdateType
}) => {
const contentRect = view.contentDOM.getBoundingClientRect()
const { doc } = view.state
const items = Object.values(entries || {})
const allVisible = items.length <= CULL_AFTER
let visibilityChanged = false
const docLength = doc.length
const editorPaddingTop = editorVerticalTopPadding(view)
for (const entry of items) {
// TODO: clamp to max row and column, account for folding?
const coords = fullHeightCoordsAtPos(
view,
Math.min(entry.offset, docLength) // avoid exception for comments at end of document when deleting text
)
if (coords) {
const y = Math.round(coords.top - contentRect.top - editorPaddingTop)
const height = Math.round(coords.bottom - coords.top)
if (!entry.screenPos) {
visibilityChanged = true
}
entry.screenPos = { y, height, editorPaddingTop }
entry.inViewport = true
} else {
entry.inViewport = false
}
if (allVisible) {
if (!entry.visible) {
visibilityChanged = true
}
entry.visible = true
}
}
if (!allVisible) {
const { from, to } = view.viewport
for (const entry of items) {
const previouslyVisible = entry.visible
entry.visible = entry.offset >= from && entry.offset <= to
if (previouslyVisible !== entry.visible) {
visibilityChanged = true
}
}
}
return { visibilityChanged, updateType }
}
/**
* Add a comment (thread) to the ShareJS doc when it's created
*/
const addComment = (offset: number, length: number, threadId: ThreadId) => {
currentDoc.submitOp({
c: view.state.doc.sliceString(offset, offset + length),
p: offset,
t: threadId,
})
}
/**
* Remove a comment (thread) from the range tracker when it's deleted
*/
const removeComment = (commentId: string) => {
currentDoc.ranges!.removeCommentId(commentId)
}
/**
* Remove tracked changes from the range tracker when they're accepted
*/
const acceptChanges = (changeIds: string[]) => {
currentDoc.ranges!.removeChangeIds(changeIds)
}
/**
* Remove tracked changes from the range tracker when they're rejected,
* and restore the original content
*/
const rejectChanges = (changeIds: string[]) => {
const changes = currentDoc.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 = view.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')],
}
}
/**
* If the current selection is empty, select the whole line.
*
* Used when adding a comment with no selected range, e.g. with a keyboard shortcut.
*/
const selectCurrentLine = () => {
if (view.state.selection.main.empty) {
const line = view.state.doc.lineAt(view.state.selection.main.from)
view.dispatch({
selection: {
anchor: line.from,
head: line.to === view.state.doc.length ? line.to : line.to + 1,
},
})
}
}
/**
* Collapse the current selection to a single point (after inserting a comment)
*/
const collapseSelection = () => {
view.dispatch({
selection: EditorSelection.cursor(view.state.selection.main.head),
})
}
/**
* Listen for events dispatched from the (Angular) review panel.
*
* These are combined into a single listener, avoiding the need to add and remove event listeners individually.
*/
const reviewPanelEventListener = (event: Event) => {
const { type, payload } = (
event as CustomEvent<{ type: string; payload: any }>
).detail
switch (type) {
// receive review panel scroll events
case 'scroll': {
view.scrollDOM.scrollBy(0, payload)
break
}
case 'overview-closed': {
window.setTimeout(() => {
dispatchScrollEvent()
}, 0)
break
}
case 'recalculate-screen-positions': {
const { visibilityChanged, updateType } =
recalculateScreenPositions(payload)
if (visibilityChanged) {
dispatchEditorEvent('track-changes:visibility_changed')
}
// Ensure the layout is updated once the review panel entries have
// updated in the React review panel. The use of a timeout is bad but
// the timings are a bit of a mess and will be improved when the review
// panel state is migrated away from Angular. Entries are not animated
// into position when scrolling, or when the editor geometry changes
// (e.g. because the window has been resized), or when the top padding
// is adjusted
const nonAnimatingUpdateTypes: UpdateType[] = [
'viewportChange',
'geometryChange',
'topPaddingChange',
]
const animate = !nonAnimatingUpdateTypes.includes(updateType)
dispatchReviewPanelLayout({
async: true,
animate,
force: false, // updateType === 'geometryChange',
})
break
}
case 'changes:accept': {
acceptChanges(payload)
view.dispatch(buildChangeMarkers())
broadcastChange()
// Dispatch a focus:changed event to force the Angular controller to
// reassemble the list of entries without bulk actions
scheduleDispatchFocusChanged(view.state, 'acceptChanges')
break
}
case 'changes:reject': {
view.dispatch(rejectChanges(payload))
broadcastChange()
// Dispatch a focus:changed event to force the Angular controller to
// reassemble the list of entries without bulk actions
setTimeout(() => {
// Delay the execution to make sure it runs after `broadcastChange`
scheduleDispatchFocusChanged(view.state, 'rejectChanges')
}, 30)
break
}
case 'comment:select_line': {
selectCurrentLine()
broadcastChange()
break
}
case 'comment:add': {
addComment(payload.offset, payload.length, payload.threadId)
collapseSelection()
broadcastChange()
break
}
case 'comment:remove': {
removeComment(payload)
view.dispatch(buildChangeMarkers())
broadcastChange()
break
}
case 'comment:resolve_threads':
case 'comment:unresolve_thread': {
view.dispatch(buildChangeMarkers())
broadcastChange()
break
}
case 'loaded_threads': {
view.dispatch(buildChangeMarkers())
broadcastChange()
break
}
case 'sizes': {
const editorFullContentHeight = view.contentDOM.clientHeight
// the content height and top overflow of the review panel
const { height, overflowTop } = payload
// the difference between the review panel height and the editor content height
const heightDiff = height + overflowTop - editorFullContentHeight
// the height of the block added at the top of the editor to match the review panel
const topPadding = editorVerticalTopPadding(view)
const bottomPadding = view.documentPadding.bottom
const contentHeight =
editorFullContentHeight - (topPadding + bottomPadding)
const newBottomPadding = height - contentHeight
if (overflowTop !== topPadding || heightDiff !== 0) {
view.dispatch(
setVerticalOverflow({
top: overflowTop,
bottom: newBottomPadding,
})
)
}
break
}
}
}
const broadcastChange = debounce(() => {
dispatchEditorEvent('track-changes:changed')
}, 50)
/**
* When the editor content, focus, size, viewport or selection changes,
* tell the review panel to update.
*
* @param state object
* @param updateType UpdateType
*/
const dispatchFocusChangedImmediately = (
state: EditorState,
updateType: UpdateType
) => {
// TODO: multiple selections?
const { from, to, empty } = state.selection.main
dispatchEditorEvent('focus:changed', {
from,
to,
empty,
updateType,
})
}
const scheduleDispatchFocusChanged = debounce(
dispatchFocusChangedImmediately,
50
)
/**
* When the editor is scrolled, tell the review panel so it can scroll in sync.
*/
const dispatchScrollEvent = () => {
window.dispatchEvent(
new CustomEvent('editor:scroll', {
detail: {
height: view.scrollDOM.scrollHeight,
scrollTop: view.scrollDOM.scrollTop,
paddingTop: editorVerticalTopPadding(view),
},
})
)
}
/**
* Add event listeners to the ShareJS doc so that change markers are rebuilt when the tracked changes are updated.
*
* Also add event listeners to the editor scroll DOM and window.
*/
const addListeners = () => {
// NOTE: the namespace "cm6" is needed so the listeners can be removed individually
currentDoc.on('ranges:dirty.cm6', () => {
// TODO: use currentDoc.ranges.getDirtyState and only update those which have changed?
window.setTimeout(() => {
view.dispatch(buildChangeMarkers())
broadcastChange()
}, 0)
})
// called on joinDoc
currentDoc.on('ranges:clear.cm6', () => {
window.setTimeout(() => {
view.dispatch(clearChangeMarkers())
broadcastChange()
}, 0)
})
// called on joinDoc
currentDoc.on('ranges:redraw.cm6', () => {
window.setTimeout(() => {
view.dispatch(buildChangeMarkers())
broadcastChange()
}, 0)
})
// sync review panel scroll with editor scroll
view.scrollDOM.addEventListener('scroll', dispatchScrollEvent)
// listen for events from the review panel controller
window.addEventListener('review-panel:event', reviewPanelEventListener)
}
/**
* Remove event listeners
*/
const removeListeners = () => {
currentDoc.off('ranges:clear.cm6')
currentDoc.off('ranges:dirty.cm6')
currentDoc.off('ranges:redraw.cm6')
view.scrollDOM.removeEventListener('scroll', dispatchScrollEvent)
window.removeEventListener('review-panel:event', reviewPanelEventListener)
}
let ignoreGeometryChangesUntil = 0
return {
initialize() {
addListeners()
broadcastChange()
},
handleUpdate(update: ViewUpdate) {
const changesTopPadding = updateChangesTopPadding(update)
const {
geometryChanged,
viewportChanged,
docChanged,
focusChanged,
selectionSet,
} = update
const setsVerticalOverflow = updateSetsVerticalOverflow(update)
const ignoringGeometryChanges = Date.now() < ignoreGeometryChangesUntil
if (geometryChanged && !docChanged && !ignoringGeometryChanges) {
broadcastChange()
}
if (
!setsVerticalOverflow &&
(geometryChanged || viewportChanged) &&
ignoringGeometryChanges
) {
// Ignore a change to the editor geometry or viewport that occurs immediately after
// an update to the vertical padding because otherwise it triggers
// another update to the padding and so on ad infinitum. This is not an
// ideal way to handle this but I couldn't see another way.
return
}
if (changesTopPadding) {
scheduleDispatchFocusChanged(update.state, 'topPaddingChange')
} else if (docChanged) {
scheduleDispatchFocusChanged(update.state, 'edit')
} else if (focusChanged || selectionSet) {
scheduleDispatchFocusChanged(update.state, 'selectionChange')
} else if (viewportChanged && !geometryChanged) {
// It's better to respond immediately to a viewport change, which
// happens when scrolling, and have previously unpositioned entries
// appear immediately rather than risk a delay due to debouncing
dispatchFocusChangedImmediately(update.state, 'viewportChange')
} else if (geometryChanged) {
scheduleDispatchFocusChanged(update.state, 'geometryChange')
}
// Wait until after updating the review panel layout before starting the
// interval during which to ignore geometry update
if (setsVerticalOverflow) {
ignoreGeometryChangesUntil = Date.now() + 50
}
},
destroy() {
removeListeners()
},
}
}
const reviewPanelToggledEffect = StateEffect.define()
export const updateHasReviewPanelToggledEffect = updateHasEffect(
reviewPanelToggledEffect
)
export const reviewPanelToggled = (): TransactionSpec => ({
effects: reviewPanelToggledEffect.of(null),
})
@@ -3,7 +3,6 @@ import { EditorView, layer } from '@codemirror/view'
import { rectangleMarkerForRange } from '../utils/layer'
import { updateHasMouseDownEffect } from './visual/selection'
import browser from './browser'
import { updateHasReviewPanelToggledEffect } from './changes/change-manager'
/**
* The built-in extension which draws the cursor and selection(s) in layers,
@@ -96,8 +95,7 @@ const selectionLayer = layer({
update.docChanged ||
update.selectionSet ||
update.viewportChanged ||
updateHasMouseDownEffect(update) ||
updateHasReviewPanelToggledEffect(update)
updateHasMouseDownEffect(update)
)
},
class: 'cm-selectionLayer',
@@ -23,7 +23,6 @@ import { autoPair } from './auto-pair'
import { phrases } from './phrases'
import { spelling } from './spelling'
import { symbolPalette } from './symbol-palette'
import { trackChanges } from './track-changes'
import { search } from './search'
import { filterCharacters } from './filter-characters'
import { keybindings } from './keybindings'
@@ -50,7 +49,6 @@ import { geometryChangeEvent } from './geometry-change-event'
import { docName } from './doc-name'
import { fileTreeItemDrop } from './file-tree-item-drop'
import { mathPreview } from './math-preview'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { ranges } from './ranges'
import { trackDetachedComments } from './track-detached-comments'
import { reviewTooltip } from './review-tooltip'
@@ -144,9 +142,7 @@ export const createExtensions = (options: Record<string, any>): Extension[] => [
// NOTE: `emptyLineFiller` needs to be before `trackChanges`,
// so the decorations are added in the correct order.
emptyLineFiller(),
isSplitTestEnabled('review-panel-redesign')
? ranges()
: trackChanges(options.currentDoc, options.changeManager),
ranges(),
trackDetachedComments(options.currentDoc),
visual(options.visual),
mathPreview(options.settings.mathPreview),
@@ -15,7 +15,6 @@ import {
EditorState,
Transaction,
} from '@codemirror/state'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { v4 as uuid } from 'uuid'
export const addNewCommentRangeEffect = StateEffect.define<Range<Decoration>>()
@@ -57,10 +56,6 @@ export const buildAddNewCommentRangeEffect = (range: SelectionRange) => {
}
export const reviewTooltip = (): Extension => {
if (!isSplitTestEnabled('review-panel-redesign')) {
return []
}
let mouseUpListener: null | (() => void) = null
const disableMouseUpListener = () => {
if (mouseUpListener) {
@@ -1,4 +1,4 @@
import { EditorView, keymap } from '@codemirror/view'
import { keymap } from '@codemirror/view'
import { Prec } from '@codemirror/state'
import { indentMore } from '../commands/indent'
import {
@@ -18,39 +18,24 @@ import {
import { changeCase, duplicateSelection } from '../commands/ranges'
import { selectNextOccurrence, selectPrevOccurrence } from '../commands/select'
import { cloneSelectionVertically } from '../commands/cursor'
import { dispatchEditorEvent } from './changes/change-manager'
import {
deleteToVisualLineEnd,
deleteToVisualLineStart,
} from './visual-line-selection'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { emitShortcutEvent } from '@/features/source-editor/extensions/toolbar/utils/analytics'
const toggleReviewPanel = () => {
if (isSplitTestEnabled('review-panel-redesign')) {
window.dispatchEvent(new Event('ui.toggle-review-panel'))
} else {
dispatchEditorEvent('toggle-review-panel')
}
window.dispatchEvent(new Event('ui.toggle-review-panel'))
return true
}
const addNewCommentFromKbdShortcut = (view: EditorView) => {
if (isSplitTestEnabled('review-panel-redesign')) {
window.dispatchEvent(new Event('add-new-review-comment'))
} else {
dispatchEditorEvent('add-new-comment')
}
const addNewCommentFromKbdShortcut = () => {
window.dispatchEvent(new Event('add-new-review-comment'))
return true
}
const toggleTrackChangesFromKbdShortcut = () => {
if (isSplitTestEnabled('review-panel-redesign')) {
window.dispatchEvent(new Event('toggle-track-changes'))
} else {
dispatchEditorEvent('toggle-track-changes')
}
window.dispatchEvent(new Event('toggle-track-changes'))
return true
}
@@ -1,284 +0,0 @@
import { StateEffect } from '@codemirror/state'
import {
Decoration,
type DecorationSet,
EditorView,
type PluginValue,
ViewPlugin,
WidgetType,
} from '@codemirror/view'
import { Change, DeleteOperation } from '../../../../../types/change'
import { ChangeManager } from './changes/change-manager'
import { debugConsole } from '@/utils/debugging'
import { isCommentOperation, isDeleteOperation } from '@/utils/operations'
import {
DocumentContainer,
RangesTrackerWithResolvedThreadIds,
} from '@/features/ide-react/editor/document-container'
const clearChangesEffect = StateEffect.define()
const buildChangesEffect = StateEffect.define()
type Options = {
currentDoc: DocumentContainer
loadingThreads: boolean
}
/**
* A custom extension that initialises the change manager, passes any updates to it,
* and produces decorations for tracked changes and comments.
*/
export const trackChanges = (
{ currentDoc, loadingThreads }: Options,
changeManager: ChangeManager
) => {
return [
// initialize/destroy the change manager, and handle any updates
ViewPlugin.define(() => {
changeManager.initialize()
return {
update: update => {
changeManager.handleUpdate(update)
},
destroy: () => {
changeManager.destroy()
},
}
}),
// draw change decorations
ViewPlugin.define<
PluginValue & {
decorations: DecorationSet
}
>(
() => {
return {
decorations: loadingThreads
? Decoration.none
: buildChangeDecorations(currentDoc),
update(update) {
for (const transaction of update.transactions) {
this.decorations = this.decorations.map(transaction.changes)
for (const effect of transaction.effects) {
if (effect.is(clearChangesEffect)) {
this.decorations = Decoration.none
} else if (effect.is(buildChangesEffect)) {
this.decorations = buildChangeDecorations(currentDoc)
}
}
}
},
}
},
{
decorations: value => value.decorations,
}
),
// styles for change decorations
trackChangesTheme,
]
}
export const clearChangeMarkers = () => {
return {
effects: clearChangesEffect.of(null),
}
}
export const buildChangeMarkers = () => {
return {
effects: buildChangesEffect.of(null),
}
}
const buildChangeDecorations = (currentDoc: DocumentContainer) => {
if (!currentDoc.ranges) {
return Decoration.none
}
const changes = [...currentDoc.ranges.changes, ...currentDoc.ranges.comments]
const decorations = []
for (const change of changes) {
try {
decorations.push(...createChangeRange(change, currentDoc))
} catch (error) {
// ignore invalid changes
debugConsole.debug('invalid change position', error)
}
}
return Decoration.set(decorations, true)
}
class ChangeDeletedWidget extends WidgetType {
constructor(public change: Change<DeleteOperation>) {
super()
}
toDOM() {
const widget = document.createElement('span')
widget.classList.add('ol-cm-change')
widget.classList.add('ol-cm-change-d')
return widget
}
eq() {
return true
}
}
class ChangeCalloutWidget extends WidgetType {
constructor(
public change: Change,
public opType: string
) {
super()
}
toDOM() {
const widget = document.createElement('span')
widget.className = 'ol-cm-change-callout'
widget.classList.add(`ol-cm-change-callout-${this.opType}`)
const inner = document.createElement('span')
inner.classList.add('ol-cm-change-callout-inner')
widget.appendChild(inner)
return widget
}
eq(widget: ChangeCalloutWidget) {
return widget.opType === this.opType
}
updateDOM(element: HTMLElement) {
element.className = 'ol-cm-change-callout'
element.classList.add(`ol-cm-change-callout-${this.opType}`)
return true
}
}
const createChangeRange = (change: Change, currentDoc: DocumentContainer) => {
const { id, metadata, op } = change
const from = op.p
// TODO: find valid positions?
if (isDeleteOperation(op)) {
const opType = 'd'
const changeWidget = Decoration.widget({
widget: new ChangeDeletedWidget(change as Change<DeleteOperation>),
side: 1,
opType,
id,
metadata,
})
const calloutWidget = Decoration.widget({
widget: new ChangeCalloutWidget(change, opType),
side: 1,
opType,
id,
metadata,
})
return [calloutWidget.range(from, from), changeWidget.range(from, from)]
}
const _isCommentOperation = isCommentOperation(op)
if (
_isCommentOperation &&
(currentDoc.ranges as RangesTrackerWithResolvedThreadIds)
.resolvedThreadIds![op.t]
) {
return []
}
const opType = _isCommentOperation ? 'c' : 'i'
const changedText = _isCommentOperation ? op.c : op.i
const to = from + changedText.length
// Mark decorations must not be empty
if (from === to) {
return []
}
const changeMark = Decoration.mark({
tagName: 'span',
class: `ol-cm-change ol-cm-change-${opType}`,
opType,
id,
metadata,
})
const calloutWidget = Decoration.widget({
widget: new ChangeCalloutWidget(change, opType),
opType,
id,
metadata,
})
return [calloutWidget.range(from, from), changeMark.range(from, to)]
}
const trackChangesTheme = EditorView.baseTheme({
'.cm-line': {
overflowX: 'hidden', // needed so the callout elements don't overflow (requires line wrapping to be on)
},
'&light .ol-cm-change-i': {
backgroundColor: '#2c8e304d',
},
'&dark .ol-cm-change-i': {
backgroundColor: 'rgba(37, 107, 41, 0.15)',
},
'&light .ol-cm-change-c': {
backgroundColor: '#f3b1114d',
},
'&dark .ol-cm-change-c': {
backgroundColor: 'rgba(194, 93, 11, 0.15)',
},
'.ol-cm-change': {
padding: 'var(--half-leading, 0) 0',
},
'.ol-cm-change-d': {
borderLeft: '2px dotted #c5060b',
marginLeft: '-1px',
},
'.ol-cm-change-callout': {
position: 'relative',
pointerEvents: 'none',
padding: 'var(--half-leading, 0) 0',
},
'.ol-cm-change-callout-inner': {
display: 'inline-block',
position: 'absolute',
left: 0,
bottom: 0,
width: '100vw',
borderBottom: '1px dashed black',
},
// disable callout line in Firefox
'@supports (-moz-appearance:none)': {
'.ol-cm-change-callout-inner': {
display: 'none',
},
},
'.ol-cm-change-callout-i .ol-cm-change-callout-inner': {
borderColor: '#2c8e30',
},
'.ol-cm-change-callout-c .ol-cm-change-callout-inner': {
borderColor: '#f3b111',
},
'.ol-cm-change-callout-d .ol-cm-change-callout-inner': {
borderColor: '#c5060b',
},
})
@@ -14,8 +14,6 @@ import {
} from './changes/comments'
import { invertedEffects } from '@codemirror/commands'
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { buildChangeMarkers } from './track-changes'
const restoreDetachedCommentsEffect = StateEffect.define<RangeSet<any>>({
map: (value, mapping) => {
@@ -90,11 +88,6 @@ export const trackDetachedComments = ({
if (effect.is(restoreDetachedCommentsEffect)) {
// send the comments to the ShareJS doc
restoreDetachedComments(currentDoc, transaction, effect.value)
if (isSplitTestEnabled('review-panel-redesign')) {
// return a transaction spec to rebuild the change markers
return buildChangeMarkers()
}
}
}
return null
@@ -30,11 +30,6 @@ import { setAutoComplete } from '../extensions/auto-complete'
import { usePhrases } from './use-phrases'
import { setPhrases } from '../extensions/phrases'
import { setSpellCheckLanguage } from '../extensions/spelling'
import {
createChangeManager,
dispatchEditorEvent,
reviewPanelToggled,
} from '../extensions/changes/change-manager'
import { setKeybindings } from '../extensions/keybindings'
import { Highlight } from '../../../../../types/highlight'
import { EditorView } from '@codemirror/view'
@@ -46,7 +41,6 @@ import { setDocName } from '@/features/source-editor/extensions/doc-name'
import { isValidTeXFile } from '@/main/is-valid-tex-file'
import { captureException } from '@/infrastructure/error-reporter'
import grammarlyExtensionPresent from '@/shared/utils/grammarly'
import { useLayoutContext } from '@/shared/context/layout-context'
import { debugConsole } from '@/utils/debugging'
import { useMetadataContext } from '@/features/ide-react/context/metadata-context'
import { useUserContext } from '@/shared/context/user-context'
@@ -57,7 +51,6 @@ import { updateRanges } from '@/features/source-editor/extensions/ranges'
import { useThreadsContext } from '@/features/review-panel-new/context/threads-context'
import { useHunspell } from '@/features/source-editor/hooks/use-hunspell'
import { Permissions } from '@/features/ide-react/types/permissions'
import { lineHeights } from '@/shared/utils/styles'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import { useOnlineUsersContext } from '@/features/ide-react/context/online-users-context'
@@ -71,13 +64,10 @@ function useCodeMirrorScope(view: EditorView) {
const { logEntryAnnotations, editedSinceCompileStarted, compiling } =
useCompileContext()
const { reviewPanelOpen, miniReviewPanelVisible } = useLayoutContext()
const { currentDocument, openDocName, trackChanges } =
useEditorManagerContext()
const metadata = useMetadataContext()
const [loadingThreads] = useScopeValue<boolean>('loadingThreads')
const { id: userId } = useUserContext()
const { userSettings } = useUserSettingsContext()
const {
@@ -166,7 +156,6 @@ function useCodeMirrorScope(view: EditorView) {
const currentDocRef = useRef({
currentDocument,
trackChanges,
loadingThreads,
})
useEffect(() => {
@@ -185,10 +174,6 @@ function useCodeMirrorScope(view: EditorView) {
const docNameRef = useRef(openDocName)
useEffect(() => {
currentDocRef.current.loadingThreads = loadingThreads
}, [view, loadingThreads])
useEffect(() => {
currentDocRef.current.trackChanges = trackChanges
@@ -201,12 +186,6 @@ function useCodeMirrorScope(view: EditorView) {
}
}, [userId, currentDocument, trackChanges])
useEffect(() => {
if (lineHeight && fontSize) {
dispatchEditorEvent('line-height', lineHeights[lineHeight] * fontSize)
}
}, [lineHeight, fontSize])
const spellingRef = useRef({
spellCheckLanguage,
hunspellManager,
@@ -323,7 +302,6 @@ function useCodeMirrorScope(view: EditorView) {
spelling: spellingRef.current,
visual: visualRef.current,
projectFeatures: projectFeaturesRef.current,
changeManager: createChangeManager(view, currentDocument),
handleError,
handleException,
}),
@@ -568,12 +546,6 @@ function useCodeMirrorScope(view: EditorView) {
view.focus()
}, [view])
)
useEffect(() => {
window.setTimeout(() => {
view.dispatch(reviewPanelToggled())
})
}, [reviewPanelOpen, miniReviewPanelVisible, view])
}
export default useCodeMirrorScope
@@ -55,13 +55,6 @@ const settings = {
syntaxValidation: false,
}
const reviewPanel = {
resolvedComments: {},
formattedProjectMembers: {},
overview: { docsCollapsedState: { 'story-doc': false } },
entries: {},
}
const permissions = {
write: true,
}
@@ -101,7 +94,6 @@ export const Latex = (args: any, { globals: { theme } }: any) => {
overallTheme: theme === 'default-' ? '' : theme,
},
permissions,
reviewPanel,
})
useMeta({
@@ -122,7 +114,6 @@ export const Markdown = (args: any, { globals: { theme } }: any) => {
overallTheme: theme === 'default-' ? '' : theme,
},
permissions,
reviewPanel,
})
return <SourceEditor />
@@ -140,7 +131,6 @@ export const Visual = (args: any, { globals: { theme } }: any) => {
overallTheme: theme === 'default-' ? '' : theme,
},
permissions,
reviewPanel,
})
useMeta({
'ol-showSymbolPalette': true,
@@ -162,7 +152,6 @@ export const Bibtex = (args: any, { globals: { theme } }: any) => {
overallTheme: theme === 'default-' ? '' : theme,
},
permissions,
reviewPanel,
})
return <SourceEditor />
@@ -21,7 +21,6 @@
@import 'editor/file-tree';
@import 'editor/file-view';
@import 'editor/figure-modal';
@import 'editor/review-panel';
@import 'editor/chat';
@import 'editor/toast';
@import 'editor/history';
@@ -1,5 +1,16 @@
@use 'sass:color';
$rp-border-grey: #d9d9d9;
$rp-type-blue: #6b7797;
$rp-type-darkgrey: #3f3f3f;
:root {
--rp-base-font-size: var(--font-size-01);
--rp-border-grey: #{$rp-border-grey};
--rp-type-blue: #{$rp-type-blue};
--rp-type-darkgrey: #{$rp-type-darkgrey};
}
.review-panel-new {
&.review-panel-container {
height: 100%;
@@ -182,11 +193,39 @@
// TODO: Update this when we move the track changes menu to the new design
.rp-tc-state {
position: absolute;
top: 100%;
left: 0;
right: 0;
overflow: hidden;
list-style: none;
padding: 0 var(--spacing-03);
margin: 0;
border-bottom: 1px solid var(--rp-border-grey);
text-align: left;
background-color: var(--white);
max-height: calc(
100vh - var(--review-panel-top) - var(--review-panel-header-height)
);
overflow-y: auto;
.rp-tc-state-item {
display: flex;
align-items: center;
padding: var(--spacing-02) 0;
&:last-of-type {
padding-bottom: var(--spacing-03);
}
}
.rp-tc-state-item-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-grow: 1;
font-weight: 600;
}
}
.review-panel-tools {
@@ -216,7 +255,7 @@
&:hover,
&:focus {
text-decoration: none;
color: $rp-type-blue;
color: var(--rp-type-blue);
}
}
@@ -703,7 +742,7 @@
display: block;
padding: 5px 10px;
background-color: rgb(240 240 240 / 90%);
color: $rp-type-blue;
color: var(--rp-type-blue);
text-align: center;
border-bottom-left-radius: 3px;
white-space: nowrap;
@@ -724,7 +763,7 @@
outline: 0;
text-decoration: none;
background-color: rgb(240 240 240 / 100%);
color: $rp-type-blue;
color: var(--rp-type-blue);
}
}
File diff suppressed because it is too large Load Diff
-16
View File
@@ -37,7 +37,6 @@
"about_writefull": "About Writefull",
"abstract": "Abstract",
"accept": "Accept",
"accept_all": "Accept all",
"accept_and_continue": "Accept and continue",
"accept_change": "Accept change",
"accept_change_error_description": "There was an error accepting a track change. Please try again in a few moments.",
@@ -181,7 +180,6 @@
"are_you_affiliated_with_an_institution": "Are you affiliated with an institution?",
"are_you_getting_an_undefined_control_sequence_error": "Are you getting an Undefined Control Sequence error? If you are, make sure youve loaded the graphicx package—<0>\\usepackage{graphicx}</0>—in the preamble (first section of code) in your document. <1>Learn more</1>",
"are_you_still_at": "Are you still at <0>__institutionName__</0>?",
"are_you_sure": "Are you sure?",
"are_you_sure_you_want_to_cancel_add_on": "Are you sure you want to cancel the <strong>__addOnName__</strong> add-on?",
"article": "Article",
"articles": "Articles",
@@ -241,8 +239,6 @@
"brl_discount_offer_plans_page_banner": "__flag__ <b>Great news!</b> Weve applied a 50% discount to premium plans on this page for our users in Brazil. Check out the new lower prices.",
"browser": "Browser",
"built_in": "Built-In",
"bulk_accept_confirm": "Are you sure you want to accept the selected __nChanges__ changes?",
"bulk_reject_confirm": "Are you sure you want to reject the selected __nChanges__ changes?",
"buy_now_no_exclamation_mark": "Buy now",
"by": "by",
"by_joining_labs": "By joining Labs, you agree to receive occasional emails and updates from Overleaf—for example, to request your feedback. You also agree to our <0>terms of service</0> and <1>privacy notice</1>.",
@@ -353,7 +349,6 @@
"column_width_is_custom_click_to_resize": "Column width is custom. Click to resize",
"column_width_is_x_click_to_resize": "Column width is __width__. Click to resize",
"comment": "Comment",
"comment_submit_error": "Sorry, there was a problem submitting your comment",
"commit": "Commit",
"common": "Common",
"common_causes_of_compile_timeouts_include": "Common causes of compile timeouts include",
@@ -673,7 +668,6 @@
"error_opening_document_detail": "Sorry, something went wrong opening this document. Please try again.",
"error_performing_request": "An error has occurred while performing your request.",
"error_processing_file": "Sorry, something went wrong processing this file. Please try again.",
"error_submitting_comment": "Error submitting comment",
"es": "Spanish",
"estimated_number_of_overleaf_users": "Estimated number of __appName__ users",
"every": "per",
@@ -946,7 +940,6 @@
"history_view_a11y_description": "Show all of the project history or only labelled versions.",
"history_view_all": "All history",
"history_view_labels": "Labels",
"hit_enter_to_reply": "Hit Enter to reply",
"home": "Home",
"hotkey_add_a_comment": "Add a comment",
"hotkey_autocomplete_menu": "Autocomplete Menu",
@@ -1293,7 +1286,6 @@
"managers_management": "Managers management",
"managing_your_subscription": "Managing your subscription",
"march": "March",
"mark_as_resolved": "Mark as resolved",
"marked_as_resolved": "Marked as resolved",
"math_display": "Math Display",
"math_inline": "Math Inline",
@@ -1388,7 +1380,6 @@
"no_articles_matching_your_tags": "There are no articles matching your tags",
"no_borders": "No borders",
"no_caption": "No caption",
"no_comments": "No comments",
"no_comments_or_suggestions": "No comments or suggestions",
"no_existing_password": "Please use the password reset form to set your password",
"no_featured_templates": "No featured templates",
@@ -1412,7 +1403,6 @@
"no_preview_available": "Sorry, no preview is available.",
"no_projects": "No projects",
"no_resolved_comments": "No resolved comments",
"no_resolved_threads": "No resolved threads",
"no_search_results": "No Search Results",
"no_selection_select_file": "Currently, no file is selected. Please select a file from the file tree.",
"no_symbols_found": "No symbols found",
@@ -1700,7 +1690,6 @@
"push_sharelatex_changes_to_github": "Push __appName__ changes to GitHub",
"push_to_github_pull_to_overleaf": "Push to GitHub, pull to __appName__",
"quoted_text": "Quoted text",
"quoted_text_in": "Quoted text in",
"raw_logs": "Raw logs",
"raw_logs_description": "Raw logs from the LaTeX compiler",
"react_history_tutorial_content": "To compare a range of versions, use the <0></0> on the versions you want at the start and end of the range. To add a label or to download a version use the options in the three-dot menu. <1>Learn more about using Overleaf History.</1>",
@@ -1765,7 +1754,6 @@
"registering": "Registering",
"registration_error": "Registration error",
"reject": "Reject",
"reject_all": "Reject all",
"reject_change": "Reject change",
"reject_selected_changes": "Reject selected changes",
"related_tags": "Related Tags",
@@ -1984,7 +1972,6 @@
"shared_with_you": "Shared with you",
"sharelatex_beta_program": "__appName__ Beta Program",
"shortcut_to_open_advanced_reference_search": "(<strong>__ctrlSpace__</strong> or <strong>__altSpace__</strong>)",
"show_all": "show all",
"show_all_projects": "Show all projects",
"show_document_preamble": "Show document preamble",
"show_file_tree": "Show file tree",
@@ -2171,9 +2158,6 @@
"take_survey": "Take survey",
"tc_everyone": "Everyone",
"tc_guests": "Guests",
"tc_switch_everyone_tip": "Toggle track-changes for everyone",
"tc_switch_guests_tip": "Toggle track-changes for all link-sharing guests",
"tc_switch_user_tip": "Toggle track-changes for this user",
"tell_the_project_owner_and_ask_them_to_upgrade": "<0>Tell the project owner</0> and ask them to upgrade their Overleaf plan if you need more compile time.",
"template": "Template",
"template_approved_by_publisher": "This template has been approved by the publisher",
@@ -3,7 +3,9 @@ import { EditorProviders } from '../../helpers/editor-providers'
import { mockScope } from '../source-editor/helpers/mock-scope'
import { TestContainer } from '../source-editor/helpers/test-container'
describe('<ReviewPanel />', function () {
// TODO: update tests and re-enable once reviewer role is active
// eslint-disable-next-line mocha/no-skipped-tests
describe.skip('<ReviewPanel />', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
@@ -1,81 +0,0 @@
import { expect } from 'chai'
import comparePropsWithShallowArrayCompare from '../../../../../frontend/js/features/source-editor/components/review-panel/utils/compare-props-with-shallow-array-compare'
describe('comparePropsWithShallowArrayCompare', function () {
it('is true with all equal non-array props', function () {
type NoArrayProps = { prop1: string; prop2: number }
const props1: NoArrayProps = { prop1: 'wombat', prop2: 1 }
const props2: NoArrayProps = { prop1: 'wombat', prop2: 1 }
expect(comparePropsWithShallowArrayCompare()(props1, props2)).to.be.true
})
it('is false with non-equal non-array props', function () {
type NoArrayProps = { prop1: string; prop2: number }
const props1: NoArrayProps = { prop1: 'wombat', prop2: 1 }
const props2: NoArrayProps = { prop1: 'squirrel', prop2: 1 }
expect(comparePropsWithShallowArrayCompare()(props1, props2)).to.be.false
})
it('is false with similar but not specified array prop', function () {
type ArrayProps = { prop1: string; prop2: number[] }
const props1: ArrayProps = { prop1: 'wombat', prop2: [1] }
const props2: ArrayProps = { prop1: 'wombat', prop2: [1] }
expect(comparePropsWithShallowArrayCompare()(props1, props2)).to.be.false
})
it('is true with similar and specified array prop', function () {
type ArrayProps = { prop1: string; prop2: number[] }
const props1: ArrayProps = { prop1: 'wombat', prop2: [1] }
const props2: ArrayProps = { prop1: 'wombat', prop2: [1] }
expect(
comparePropsWithShallowArrayCompare<ArrayProps>('prop2')(props1, props2)
).to.be.true
})
it('is false with non-similar and specified array prop', function () {
type ArrayProps = { prop1: string; prop2: number[] }
const props1: ArrayProps = { prop1: 'wombat', prop2: [1] }
const props2: ArrayProps = { prop1: 'wombat', prop2: [2] }
expect(
comparePropsWithShallowArrayCompare<ArrayProps>('prop2')(props1, props2)
).to.be.false
})
it('is false with multiple similar array props with not all specified', function () {
type MultipleArrayProps = { prop1: number[]; prop2: number[] }
const props1: MultipleArrayProps = { prop1: [1], prop2: [2] }
const props2: MultipleArrayProps = { prop1: [1], prop2: [2] }
expect(
comparePropsWithShallowArrayCompare<MultipleArrayProps>('prop1')(
props1,
props2
)
).to.be.false
})
it('is true with multiple similar array props with all specified', function () {
type MultipleArrayProps = { prop1: number[]; prop2: number[] }
const props1: MultipleArrayProps = { prop1: [1], prop2: [2] }
const props2: MultipleArrayProps = { prop1: [1], prop2: [2] }
expect(
comparePropsWithShallowArrayCompare<MultipleArrayProps>('prop1', 'prop2')(
props1,
props2
)
).to.be.true
})
})
@@ -128,19 +128,18 @@ describe('<CodeMirrorEditor/> command tooltip in Visual mode', function () {
// assert the focused command is undecorated
cy.get('@content-line').should('have.text', '\\include{foo}')
cy.window().then(win => {
cy.stub(win, 'dispatchEvent').as('dispatch-event')
})
cy.contains('figures/foo.tex').click()
cy.get('@content-line').should('have.text', '\\include{figures/foo}')
cy.get('@content-line').type('{leftArrow}')
// open the target
// assert the unfocused command has a menu
cy.findByRole('menu').should('have.length', 1)
cy.findByRole('button', { name: 'Edit file' }).click()
cy.get('@dispatch-event').should('have.been.calledOnce')
cy.findByText('Edit file')
// assert the unfocused command is decorated
cy.get('@content-line').type('{downArrow}')
cy.findByRole('menu').should('have.length', 0)
cy.get('@content-line').should('have.text', '\\include{foo}')
cy.get('@content-line').should('have.text', '\\include{figures/foo}')
})
it('shows a tooltip for \\input', function () {
@@ -153,19 +152,18 @@ describe('<CodeMirrorEditor/> command tooltip in Visual mode', function () {
// assert the focused command is undecorated
cy.get('@content-line').should('have.text', '\\input{foo}')
cy.window().then(win => {
cy.stub(win, 'dispatchEvent').as('dispatch-event')
})
cy.contains('figures/foo.tex').click()
cy.get('@content-line').should('have.text', '\\input{figures/foo}')
cy.get('@content-line').type('{leftArrow}')
// open the target
cy.findByRole('menu').should('have.length', 1)
cy.findByRole('button', { name: 'Edit file' }).click()
cy.get('@dispatch-event').should('have.been.calledOnce')
cy.findByRole('button', { name: 'Edit file' })
// assert the unfocused command is decorated
cy.get('@content-line').type('{downArrow}')
cy.findByRole('menu').should('have.length', 0)
cy.get('@content-line').should('have.text', '\\input{foo}')
cy.get('@content-line').should('have.text', '\\input{figures/foo}')
})
it('shows a tooltip for \\ref', function () {
@@ -79,11 +79,26 @@ export const mockDoc = (content = defaultContent) => {
getIdSeed: () => '123',
setIdSeed: () => {},
getTrackedDeletesLength: () => 0,
getDirtyState: () => ({
comment: {
moved: {},
removed: {},
added: {},
},
change: {
moved: {},
removed: {},
added: {},
},
}),
resetDirtyState: () => {},
},
setTrackChangesIdSeeds: () => {},
getTrackingChanges: () => true,
setTrackingChanges: () => {},
getInflightOp: () => null,
getPendingOp: () => null,
hasBufferedOps: () => false,
leaveAndCleanUpPromise: () => false,
}
}
@@ -35,7 +35,12 @@ export const mockScope = (content?: string) => {
{
_id: figuresFolderId,
name: 'figures',
docs: [],
docs: [
{
_id: 'fake-nested-doc-id',
name: 'foo.tex',
},
],
folders: [],
fileRefs: [
{
@@ -65,15 +70,8 @@ export const mockScope = (content?: string) => {
trackedWrite: true,
write: true,
},
reviewPanel: {
subView: 'cur_file',
formattedProjectMembers: {},
fullTCStateCollapsed: true,
entries: {},
resolvedComments: {},
},
ui: {
reviewPanelOpen: true,
reviewPanelOpen: false,
},
toggleReviewPanel: cy.stub(),
toggleTrackChangesForEveryone: cy.stub(),
@@ -5,10 +5,10 @@ describe('i18n', function () {
it('translates a plain string', function () {
const Test = () => {
const { t } = useTranslation()
return <div>{t('accept')}</div>
return <div>{t('accept_change')}</div>
}
cy.mount(<Test />)
cy.findByText('Accept')
cy.findByText('Accept change')
})
it('uses defaultValues', function () {
-14
View File
@@ -1,14 +0,0 @@
import { ReviewPanelCommentThreadMessage, ThreadId } from './review-panel'
import { MergeAndOverride } from '../utils'
export type ReviewPanelCommentThreadMessageApi = MergeAndOverride<
ReviewPanelCommentThreadMessage,
{ timestamp: number }
>
export type ReviewPanelCommentThreadsApi = Record<
ThreadId,
{
messages: ReviewPanelCommentThreadMessageApi[]
}
>
-78
View File
@@ -1,78 +0,0 @@
import { ThreadId } from './review-panel'
import { UserId } from '../user'
export interface ReviewPanelEntryScreenPos {
y: number
height: number
editorPaddingTop: number
}
interface ReviewPanelBaseEntry {
focused: boolean
offset: number
screenPos: ReviewPanelEntryScreenPos
visible: boolean
inViewport: boolean
}
interface ReviewPanelInsertOrDeleteEntry {
content: string
entry_ids: ThreadId[]
metadata: {
ts: Date
user_id: UserId
}
}
export interface ReviewPanelInsertEntry
extends ReviewPanelBaseEntry,
ReviewPanelInsertOrDeleteEntry {
type: 'insert'
}
export interface ReviewPanelDeleteEntry
extends ReviewPanelBaseEntry,
ReviewPanelInsertOrDeleteEntry {
type: 'delete'
}
export type ReviewPanelChangeEntry =
| ReviewPanelInsertEntry
| ReviewPanelDeleteEntry
export interface ReviewPanelCommentEntry extends ReviewPanelBaseEntry {
type: 'comment'
content: string
entry_ids: ThreadId[]
thread_id: ThreadId
replyContent?: string // angular specific
}
export interface ReviewPanelAggregateChangeEntry extends ReviewPanelBaseEntry {
type: 'aggregate-change'
content: string
entry_ids: ThreadId[]
metadata: {
replaced_content: string
ts: Date
user_id: UserId
}
}
export interface ReviewPanelAddCommentEntry extends ReviewPanelBaseEntry {
type: 'add-comment'
length: number
}
export interface ReviewPanelBulkActionsEntry extends ReviewPanelBaseEntry {
type: 'bulk-actions'
length: number
}
export type ReviewPanelEntry =
| ReviewPanelCommentEntry
| ReviewPanelInsertEntry
| ReviewPanelDeleteEntry
| ReviewPanelAggregateChangeEntry
| ReviewPanelAddCommentEntry
| ReviewPanelBulkActionsEntry
@@ -1,27 +1,9 @@
import { Brand } from '../helpers/brand'
import { DocId } from '../project-settings'
import { UserId } from '../user'
import {
ReviewPanelAddCommentEntry,
ReviewPanelBulkActionsEntry,
ReviewPanelEntry,
} from './entry'
import { ReviewPanelCommentThread } from './comment-thread'
export type SubView = 'cur_file' | 'overview'
export type ThreadId = Brand<string, 'ThreadId'>
// Entries may contain `add-comment` and `bulk-actions` props along with DocIds
// Ideally the `add-comment` and `bulk-actions` objects should not be within the entries object
// as the doc data, but this is what currently angular returns.
export type ReviewPanelDocEntries = Record<
| ThreadId
| ReviewPanelAddCommentEntry['type']
| ReviewPanelBulkActionsEntry['type'],
ReviewPanelEntry
>
export type ReviewPanelEntries = Record<DocId, ReviewPanelDocEntries>
export interface ReviewPanelUser {
avatar_text: string
@@ -32,8 +14,6 @@ export interface ReviewPanelUser {
name: string
}
export type ReviewPanelUsers = Record<UserId, ReviewPanelUser>
export type CommentId = Brand<string, 'CommentId'>
export interface ReviewPanelCommentThreadMessage {
@@ -43,8 +23,3 @@ export interface ReviewPanelCommentThreadMessage {
user: ReviewPanelUser
user_id: UserId
}
export type ReviewPanelCommentThreads = Record<
ThreadId,
ReviewPanelCommentThread
>
-2
View File
@@ -19,6 +19,4 @@ export type DeepPartial<T> = Partial<{ [P in keyof T]: DeepPartial<T[P]> }>
export type MergeAndOverride<Parent, Own> = Own & Omit<Parent, keyof Own>
export type Entries<T extends object> = [keyof T, T[keyof T]][]
export type Keys<T extends object> = (keyof T)[]