mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
Merge pull request #34698 from overleaf/mj-context-menu-tracked-changes
[web] Add tracked changes options to context menu GitOrigin-RevId: b49198c6c2c63063d96ace69f10b617ac819bb04
This commit is contained in:
committed by
Copybot
parent
a53b164b45
commit
e0cfb07f60
+4
-80
@@ -1,91 +1,15 @@
|
||||
import { FC, useCallback, useMemo } from 'react'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import {
|
||||
useCodeMirrorStateContext,
|
||||
useCodeMirrorViewContext,
|
||||
} from '@/features/source-editor/components/codemirror-context'
|
||||
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||
import { useModalsContext } from '@/features/ide-react/context/modals-context'
|
||||
import {
|
||||
useRangesActionsContext,
|
||||
useRangesContext,
|
||||
} from '@/features/review-panel/context/ranges-context'
|
||||
import { numberOfChangesInSelection } from '@/features/review-panel/utils/changes-in-selection'
|
||||
import { isInsertOperation } from '@/utils/operations'
|
||||
import { captureException } from '@/infrastructure/error-reporter'
|
||||
import { useTrackedChangesActions } from '@/features/review-panel/hooks/use-tracked-changes-actions'
|
||||
|
||||
const TrackedChangesActions: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const view = useCodeMirrorViewContext()
|
||||
const state = useCodeMirrorStateContext()
|
||||
const permissions = usePermissionsContext()
|
||||
const ranges = useRangesContext()
|
||||
const { acceptChanges, rejectChanges } = useRangesActionsContext()
|
||||
const { showGenericConfirmModal } = useModalsContext()
|
||||
|
||||
const changesInSelection = useMemo(() => {
|
||||
return (ranges?.changes ?? []).filter(({ op }) => {
|
||||
const opFrom = op.p
|
||||
const opLength = isInsertOperation(op) ? op.i.length : 0
|
||||
const opTo = opFrom + opLength
|
||||
const selection = state.selection.main
|
||||
return opFrom >= selection.from && opTo <= selection.to
|
||||
})
|
||||
}, [ranges, state.selection.main])
|
||||
|
||||
const acceptChangesHandler = useCallback(() => {
|
||||
const nChanges = numberOfChangesInSelection(
|
||||
ranges,
|
||||
view.state.selection.main
|
||||
)
|
||||
showGenericConfirmModal({
|
||||
message: t('confirm_accept_selected_changes', { count: nChanges }),
|
||||
title: t('accept_selected_changes'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await acceptChanges(...changesInSelection)
|
||||
} catch (err: any) {
|
||||
captureException(err)
|
||||
}
|
||||
},
|
||||
primaryVariant: 'danger',
|
||||
})
|
||||
}, [
|
||||
acceptChanges,
|
||||
changesInSelection,
|
||||
ranges,
|
||||
showGenericConfirmModal,
|
||||
view,
|
||||
t,
|
||||
])
|
||||
|
||||
const rejectChangesHandler = useCallback(() => {
|
||||
const nChanges = numberOfChangesInSelection(
|
||||
ranges,
|
||||
view.state.selection.main
|
||||
)
|
||||
showGenericConfirmModal({
|
||||
message: t('confirm_reject_selected_changes', { count: nChanges }),
|
||||
title: t('reject_selected_changes'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await rejectChanges(...changesInSelection)
|
||||
} catch (err: any) {
|
||||
captureException(err)
|
||||
}
|
||||
},
|
||||
primaryVariant: 'danger',
|
||||
})
|
||||
}, [
|
||||
showGenericConfirmModal,
|
||||
t,
|
||||
ranges,
|
||||
view,
|
||||
rejectChanges,
|
||||
changesInSelection,
|
||||
])
|
||||
const { changesInSelection, acceptChangesHandler, rejectChangesHandler } =
|
||||
useTrackedChangesActions()
|
||||
|
||||
if (!permissions.comment || changesInSelection.length === 0) {
|
||||
return null
|
||||
|
||||
@@ -101,7 +101,9 @@ const buildRangesFromHistoryOT = (currentDocument: DocumentContainer) => {
|
||||
)
|
||||
}
|
||||
|
||||
const RangesActionsContext = createContext<RangesActions | undefined>(undefined)
|
||||
export const RangesActionsContext = createContext<RangesActions | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
export const RangesProvider: FC<React.PropsWithChildren> = ({ children }) => {
|
||||
const view = useCodeMirrorViewContext()
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useCallback, useContext, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
useCodeMirrorStateContext,
|
||||
useCodeMirrorViewContext,
|
||||
} from '@/features/source-editor/components/codemirror-context'
|
||||
import { useModalsContext } from '@/features/ide-react/context/modals-context'
|
||||
import {
|
||||
RangesActionsContext,
|
||||
useRangesContext,
|
||||
} from '@/features/review-panel/context/ranges-context'
|
||||
import { numberOfChangesInSelection } from '@/features/review-panel/utils/changes-in-selection'
|
||||
import { isInsertOperation } from '@/utils/operations'
|
||||
import { captureException } from '@/infrastructure/error-reporter'
|
||||
|
||||
export const useTrackedChangesActions = () => {
|
||||
const { t } = useTranslation()
|
||||
const view = useCodeMirrorViewContext()
|
||||
const state = useCodeMirrorStateContext()
|
||||
const ranges = useRangesContext()
|
||||
const rangesActions = useContext(RangesActionsContext)
|
||||
const { showGenericConfirmModal } = useModalsContext()
|
||||
|
||||
const changesInSelection = useMemo(() => {
|
||||
return (ranges?.changes ?? []).filter(({ op }) => {
|
||||
const opFrom = op.p
|
||||
const opLength = isInsertOperation(op) ? op.i.length : 0
|
||||
const opTo = opFrom + opLength
|
||||
const selection = state.selection.main
|
||||
return opFrom >= selection.from && opTo <= selection.to
|
||||
})
|
||||
}, [ranges, state.selection.main])
|
||||
|
||||
const acceptChangesHandler = useCallback(() => {
|
||||
if (!rangesActions) {
|
||||
return
|
||||
}
|
||||
const nChanges = numberOfChangesInSelection(
|
||||
ranges,
|
||||
view.state.selection.main
|
||||
)
|
||||
showGenericConfirmModal({
|
||||
message: t('confirm_accept_selected_changes', { count: nChanges }),
|
||||
title: t('accept_selected_changes'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await rangesActions.acceptChanges(...changesInSelection)
|
||||
} catch (err: any) {
|
||||
captureException(err)
|
||||
}
|
||||
},
|
||||
primaryVariant: 'danger',
|
||||
})
|
||||
}, [
|
||||
rangesActions,
|
||||
changesInSelection,
|
||||
ranges,
|
||||
showGenericConfirmModal,
|
||||
view,
|
||||
t,
|
||||
])
|
||||
|
||||
const rejectChangesHandler = useCallback(() => {
|
||||
if (!rangesActions) {
|
||||
return
|
||||
}
|
||||
const nChanges = numberOfChangesInSelection(
|
||||
ranges,
|
||||
view.state.selection.main
|
||||
)
|
||||
showGenericConfirmModal({
|
||||
message: t('confirm_reject_selected_changes', { count: nChanges }),
|
||||
title: t('reject_selected_changes'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await rangesActions.rejectChanges(...changesInSelection)
|
||||
} catch (err: any) {
|
||||
captureException(err)
|
||||
}
|
||||
},
|
||||
primaryVariant: 'danger',
|
||||
})
|
||||
}, [
|
||||
showGenericConfirmModal,
|
||||
t,
|
||||
ranges,
|
||||
view,
|
||||
rangesActions,
|
||||
changesInSelection,
|
||||
])
|
||||
|
||||
return { changesInSelection, acceptChangesHandler, rejectChangesHandler }
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from '../utils/context-menu-analytics'
|
||||
import { isCursorOnEmptyLine } from '../utils/is-cursor-on-empty-line'
|
||||
import { selectAll } from '@codemirror/commands'
|
||||
import { useTrackedChangesActions } from '@/features/review-panel/hooks/use-tracked-changes-actions'
|
||||
|
||||
export const useContextMenuItems = () => {
|
||||
const view = useCodeMirrorViewContext()
|
||||
@@ -55,6 +56,8 @@ export const useContextMenuItems = () => {
|
||||
const { setUpgradeTrackChangesModal } = useEditorContext()
|
||||
const trackingChangesMode = useTrackingChangesMode()
|
||||
const isReview = trackingChangesMode === 'review'
|
||||
const { changesInSelection, acceptChangesHandler, rejectChangesHandler } =
|
||||
useTrackedChangesActions()
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
view.dispatch({ effects: closeContextMenuEffect.of(null) })
|
||||
@@ -173,6 +176,16 @@ export const useContextMenuItems = () => {
|
||||
return true
|
||||
})
|
||||
|
||||
const handleAcceptChanges = wrapForContextMenu('accept-changes', () => {
|
||||
acceptChangesHandler()
|
||||
return true
|
||||
})
|
||||
|
||||
const handleRejectChanges = wrapForContextMenu('reject-changes', () => {
|
||||
rejectChangesHandler()
|
||||
return true
|
||||
})
|
||||
|
||||
// Sync-to-PDF is special: it needs to wait for async completion before closing
|
||||
const handleSyncToPdf = useCallback(() => {
|
||||
// Switch to split view only when in editor-only mode with non-detached PDF
|
||||
@@ -264,6 +277,20 @@ export const useContextMenuItems = () => {
|
||||
show: permissions.comment,
|
||||
shortcut: getShortcut('insert-comment'),
|
||||
},
|
||||
{
|
||||
label: t('accept_selected_changes'),
|
||||
handler: handleAcceptChanges,
|
||||
disabled: false,
|
||||
show: permissions.comment && changesInSelection.length > 0,
|
||||
shortcut: undefined,
|
||||
},
|
||||
{
|
||||
label: t('reject_selected_changes'),
|
||||
handler: handleRejectChanges,
|
||||
disabled: false,
|
||||
show: permissions.comment && changesInSelection.length > 0,
|
||||
shortcut: undefined,
|
||||
},
|
||||
].filter(item => item.show),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export type ContextMenuItemSegmentation =
|
||||
| 'suggest-edits'
|
||||
| 'back-to-editing'
|
||||
| 'comment'
|
||||
| 'accept-changes'
|
||||
| 'reject-changes'
|
||||
|
||||
export type ContextMenuAnalyticsEvents = {
|
||||
'menu-expand': {
|
||||
|
||||
+135
@@ -3,9 +3,11 @@ import {
|
||||
EditorProviders,
|
||||
makeEditorPropertiesProvider,
|
||||
makeProjectProvider,
|
||||
USER_ID,
|
||||
} from '../../../helpers/editor-providers'
|
||||
import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
|
||||
import { TestContainer } from '../helpers/test-container'
|
||||
import { docId } from '../helpers/mock-doc'
|
||||
import { FC } from 'react'
|
||||
import { PermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||
import { Permissions } from '@/features/ide-react/types/permissions'
|
||||
@@ -1324,4 +1326,137 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
cy.findByRole('menu').should('be.visible')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tracked-change actions', function () {
|
||||
function mountEditorWithChanges() {
|
||||
cy.intercept('POST', `/project/*/doc/${docId}/changes/accept`, {}).as(
|
||||
'acceptChange'
|
||||
)
|
||||
|
||||
const changes = [
|
||||
{
|
||||
metadata: {
|
||||
user_id: USER_ID,
|
||||
ts: new Date('2025-01-01T00:00:00.000Z'),
|
||||
},
|
||||
id: 'inserted-op-id',
|
||||
op: { p: 166, t: 'inserted-op-id', i: 'introduction' },
|
||||
},
|
||||
{
|
||||
metadata: {
|
||||
user_id: USER_ID,
|
||||
ts: new Date('2025-01-01T01:00:00.000Z'),
|
||||
},
|
||||
id: 'deleted-op-id',
|
||||
op: { p: 110, t: 'deleted-op-id', d: 'beautiful ' },
|
||||
},
|
||||
]
|
||||
const getChanges = cy.stub().as('getChanges').returns([])
|
||||
const removeChangeIds = cy.stub().as('removeChangeIds')
|
||||
|
||||
const scope = mockScope(undefined, {
|
||||
docOptions: {
|
||||
rangesOptions: { changes, getChanges, removeChangeIds },
|
||||
},
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders
|
||||
scope={scope}
|
||||
features={{ trackChangesVisible: true }}
|
||||
>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
}
|
||||
|
||||
// Select a deletion and an insertion so the bulk-action items appear, then
|
||||
// right-click inside the selection (which preserves it).
|
||||
function openMenuOverChanges() {
|
||||
cy.findByText('\\maketitle').type(
|
||||
'{home}{shift}' + '{downArrow}'.repeat(10),
|
||||
{ scrollBehavior: false }
|
||||
)
|
||||
cy.findByText('\\maketitle').rightclick({ scrollBehavior: false })
|
||||
}
|
||||
|
||||
it('shows accept and reject items when the selection covers changes', function () {
|
||||
mountEditorWithChanges()
|
||||
openMenuOverChanges()
|
||||
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: 'Accept selected changes' }).should(
|
||||
'be.visible'
|
||||
)
|
||||
cy.findByRole('menuitem', { name: 'Reject selected changes' }).should(
|
||||
'be.visible'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('hides accept and reject items when the selection covers no changes', function () {
|
||||
mountEditorWithChanges()
|
||||
|
||||
cy.get('.cm-line').eq(2).rightclick({ scrollBehavior: false })
|
||||
|
||||
cy.findByRole('menu').should('be.visible')
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: 'Accept selected changes' }).should(
|
||||
'not.exist'
|
||||
)
|
||||
cy.findByRole('menuitem', { name: 'Reject selected changes' }).should(
|
||||
'not.exist'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts the selected changes', function () {
|
||||
mountEditorWithChanges()
|
||||
openMenuOverChanges()
|
||||
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: 'Accept selected changes' }).click({
|
||||
scrollBehavior: false,
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.findByText(
|
||||
'Are you sure you want to accept the selected 2 changes?'
|
||||
).should('exist')
|
||||
cy.findByRole('button', { name: 'OK' }).click({ scrollBehavior: false })
|
||||
})
|
||||
|
||||
cy.wait('@acceptChange')
|
||||
cy.get('@removeChangeIds').should('have.been.calledWith', [
|
||||
'inserted-op-id',
|
||||
'deleted-op-id',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects the selected changes', function () {
|
||||
mountEditorWithChanges()
|
||||
openMenuOverChanges()
|
||||
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: 'Reject selected changes' }).click({
|
||||
scrollBehavior: false,
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.findByText(
|
||||
'Are you sure you want to reject the selected 2 changes?'
|
||||
).should('exist')
|
||||
cy.findByRole('button', { name: 'OK' }).click({ scrollBehavior: false })
|
||||
})
|
||||
|
||||
cy.get('@getChanges').should('have.been.calledWith', [
|
||||
'inserted-op-id',
|
||||
'deleted-op-id',
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user