mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-06-05 07:09:02 +02:00
Remove 'review-panel-redesign' split test and old code (#24235)
GitOrigin-RevId: 1f3d4a9a51429591a82391a9bee3cfdf226bc9c8
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
-1654
File diff suppressed because it is too large
Load Diff
-32
@@ -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
|
||||
-8
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -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
-1
@@ -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'
|
||||
|
||||
+4
-4
@@ -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'
|
||||
+8
-3
@@ -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] ?? {
|
||||
|
||||
|
Before Width: | Height: | Size: 762 KiB After Width: | Height: | Size: 762 KiB |
@@ -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 }) => (
|
||||
|
||||
-5
@@ -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
|
||||
-53
@@ -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)
|
||||
-109
@@ -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" />
|
||||
|
||||
{t('accept_all')} ({nChanges})
|
||||
</BulkActions.Button>
|
||||
<BulkActions.Button onClick={handleShowBulkRejectDialog}>
|
||||
<MaterialIcon type="close" />
|
||||
|
||||
{t('reject_all')} ({nChanges})
|
||||
</BulkActions.Button>
|
||||
</>
|
||||
)}
|
||||
{hasTrackChangesFeature &&
|
||||
permissions.comment &&
|
||||
!isRestrictedTokenMember &&
|
||||
currentDocEntries?.['add-comment'] && (
|
||||
<AddCommentButton onClick={handleAddNewCommentClick}>
|
||||
<MaterialIcon type="mode_comment" />
|
||||
|
||||
{t('add_comment')}
|
||||
</AddCommentButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
show={show}
|
||||
setShow={setShow}
|
||||
isAccept={isAccept}
|
||||
nChanges={nChanges}
|
||||
onConfirm={handleConfirmDialog}
|
||||
/>
|
||||
</>,
|
||||
view.scrollDOM
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(EditorWidgets)
|
||||
-32
@@ -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
|
||||
-165
@@ -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" />
|
||||
|
||||
{t('cancel')}
|
||||
</EntryActions.Button>
|
||||
<EntryActions.Button
|
||||
onClick={handleSubmitNewComment}
|
||||
disabled={isSubmitting || !content.length}
|
||||
>
|
||||
<MaterialIcon type="mode_comment" />
|
||||
|
||||
{t('comment')}
|
||||
</EntryActions.Button>
|
||||
</EntryActions>
|
||||
</>
|
||||
) : (
|
||||
<AddCommentButton onClick={handleStartNewComment}>
|
||||
<MaterialIcon type="mode_comment" />
|
||||
|
||||
{t('add_comment')}
|
||||
</AddCommentButton>
|
||||
)}
|
||||
</div>
|
||||
</EntryContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddCommentEntry
|
||||
-159
@@ -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')}
|
||||
<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')}
|
||||
<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">
|
||||
•
|
||||
<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" />
|
||||
{t('reject')}
|
||||
</EntryActions.Button>
|
||||
<EntryActions.Button onClick={() => acceptChanges(entryIds)}>
|
||||
<MaterialIcon type="check" />
|
||||
{t('accept')}
|
||||
</EntryActions.Button>
|
||||
</EntryActions>
|
||||
)}
|
||||
</div>
|
||||
</EntryContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(
|
||||
AggregateChangeEntry,
|
||||
comparePropsWithShallowArrayCompare('entryIds')
|
||||
)
|
||||
-55
@@ -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" />
|
||||
{t('reject_all')} ({nChanges})
|
||||
</BulkActions.Button>
|
||||
<BulkActions.Button onClick={handleShowBulkAcceptDialog}>
|
||||
<MaterialIcon type="check" />
|
||||
{t('accept_all')} ({nChanges})
|
||||
</BulkActions.Button>
|
||||
</BulkActions>
|
||||
</>
|
||||
)}
|
||||
</EntryContainer>
|
||||
<Modal
|
||||
show={show}
|
||||
setShow={setShow}
|
||||
isAccept={isAccept}
|
||||
nChanges={nChanges}
|
||||
onConfirm={handleConfirmDialog}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BulkActionsEntry
|
||||
-24
@@ -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
|
||||
-91
@@ -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
|
||||
-161
@@ -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')}
|
||||
<ins className="rp-content-highlight">
|
||||
{contentToDisplay}
|
||||
</ins>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('tracked_change_deleted')}
|
||||
<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">
|
||||
•
|
||||
<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" />
|
||||
{t('reject')}
|
||||
</EntryActions.Button>
|
||||
<EntryActions.Button onClick={() => acceptChanges(entryIds)}>
|
||||
<MaterialIcon type="check" />
|
||||
{t('accept')}
|
||||
</EntryActions.Button>
|
||||
</EntryActions>
|
||||
)}
|
||||
</div>
|
||||
</EntryContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(
|
||||
ChangeEntry,
|
||||
comparePropsWithShallowArrayCompare('entryIds')
|
||||
)
|
||||
-185
@@ -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" />
|
||||
{t('resolve')}
|
||||
</EntryActions.Button>
|
||||
)}
|
||||
{permissions.comment && (
|
||||
<EntryActions.Button
|
||||
onClick={handleOnReply}
|
||||
disabled={!replyContent.length}
|
||||
>
|
||||
<MaterialIcon type="reply" />
|
||||
{t('reply')}
|
||||
</EntryActions.Button>
|
||||
)}
|
||||
</EntryActions>
|
||||
</div>
|
||||
</div>
|
||||
</EntryContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CommentEntry)
|
||||
-121
@@ -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>
|
||||
|
||||
{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">
|
||||
•
|
||||
<button onClick={handleStartEditing}>{t('edit')}</button>
|
||||
{thread.messages.length > 1 && (
|
||||
<>
|
||||
•
|
||||
<button onClick={handleConfirmDelete}>{t('delete')}</button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{comment.user.isSelf && deleting && (
|
||||
<span className="rp-confim-delete">
|
||||
{t('are_you_sure')} •
|
||||
<button type="button" onClick={handleDoDelete}>
|
||||
{t('delete')}
|
||||
</button>
|
||||
•
|
||||
<button onClick={handleCancelDelete}>{t('cancel')}</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Comment)
|
||||
-19
@@ -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
|
||||
-7
@@ -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
|
||||
-42
@@ -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
|
||||
-30
@@ -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
|
||||
-96
@@ -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
|
||||
-127
@@ -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')}
|
||||
|
||||
<span className="rp-resolved-comment-context-file">
|
||||
{thread.docName}
|
||||
</span>
|
||||
<p className="rp-resolved-comment-context-quote">
|
||||
<span>{content}</span>
|
||||
</p>
|
||||
{needsCollapsing && (
|
||||
<>
|
||||
|
||||
<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}:
|
||||
</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}:
|
||||
</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)
|
||||
-33
@@ -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)
|
||||
}
|
||||
}
|
||||
-69
@@ -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
|
||||
-43
@@ -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)
|
||||
-68
@@ -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 && (
|
||||
<>
|
||||
|
||||
<span className="rp-overview-file-num-entries">
|
||||
({entryCount})
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!docCollapsed && (
|
||||
<OverviewFileEntries docId={docId} docEntries={docEntries} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OverviewFile
|
||||
-502
@@ -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
|
||||
-37
@@ -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)
|
||||
-19
@@ -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>
|
||||
)
|
||||
}
|
||||
-18
@@ -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
|
||||
-133
@@ -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
|
||||
-40
@@ -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
|
||||
-82
@@ -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)
|
||||
-30
@@ -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
|
||||
-130
@@ -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
|
||||
-18
@@ -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']
|
||||
}
|
||||
-31
@@ -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
|
||||
}
|
||||
}
|
||||
+7
-10
@@ -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')}
|
||||
|
||||
-45
@@ -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
|
||||
}
|
||||
-105
@@ -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
@@ -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 you’ve 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> We’ve 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)
|
||||
|
||||
|
||||
-81
@@ -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
|
||||
})
|
||||
})
|
||||
+11
-13
@@ -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 () {
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
>
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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)[]
|
||||
|
||||
Reference in New Issue
Block a user