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:
Mathias Jakobsen
2026-06-23 08:05:33 +00:00
committed by Copybot
parent a53b164b45
commit e0cfb07f60
6 changed files with 264 additions and 81 deletions
@@ -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': {
@@ -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',
])
})
})
})