ソースを参照

Merge pull request #34698 from overleaf/mj-context-menu-tracked-changes

[web] Add tracked changes options to context menu

GitOrigin-RevId: b49198c6c2c63063d96ace69f10b617ac819bb04
Mathias Jakobsen 3 ヶ月 前
親
コミット
e0cfb07f60

+ 4 - 80
services/web/frontend/js/features/editor-floating-menu/components/tracked-changes-actions.tsx

@@ -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

+ 3 - 1
services/web/frontend/js/features/review-panel/context/ranges-context.tsx

@@ -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()

+ 93 - 0
services/web/frontend/js/features/review-panel/hooks/use-tracked-changes-actions.ts

@@ -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 }
+}

+ 27 - 0
services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx

@@ -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),
   }
 }

+ 2 - 0
services/web/frontend/js/features/source-editor/utils/context-menu-analytics.ts

@@ -13,6 +13,8 @@ export type ContextMenuItemSegmentation =
   | 'suggest-edits'
   | 'back-to-editing'
   | 'comment'
+  | 'accept-changes'
+  | 'reject-changes'
 
 export type ContextMenuAnalyticsEvents = {
   'menu-expand': {

+ 135 - 0
services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx

@@ -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',
+      ])
+    })
+  })
 })