Просмотр исходного кода

Merge pull request #34357 from overleaf/mg-combine-floating-menus

Add combined floating menu for comment, tracked changes, writefull

GitOrigin-RevId: 4de5b9d3b1717fc76394f7c0edfe4d7230394c8d
Malik Glossop 1 месяц назад
Родитель
Сommit
8ef463f8b0

+ 1 - 0
services/web/config/settings.defaults.js

@@ -1098,6 +1098,7 @@ module.exports = {
     referenceSearchSetting: [],
     referenceSearchSetting: [],
     settingsModalEditorTabSections: [],
     settingsModalEditorTabSections: [],
     settingsModalSpellcheckSections: [],
     settingsModalSpellcheckSections: [],
+    editorFloatingMenuActions: [],
     errorLogsComponents: [],
     errorLogsComponents: [],
     referenceIndices: [],
     referenceIndices: [],
     railEntries: [],
     railEntries: [],

+ 38 - 0
services/web/frontend/js/features/editor-floating-menu/components/add-comment-action.tsx

@@ -0,0 +1,38 @@
+import { FC, useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import MaterialIcon from '@/shared/components/material-icon'
+import OLTooltip from '@/shared/components/ol/ol-tooltip'
+import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
+import { sendMB } from '@/infrastructure/event-tracking'
+
+const AddCommentAction: FC = () => {
+  const { t } = useTranslation()
+  const permissions = usePermissionsContext()
+
+  const handleClick = useCallback(() => {
+    sendMB('add-comment', { location: 'tooltip' })
+    window.dispatchEvent(new Event('add-new-review-comment'))
+  }, [])
+
+  if (!permissions.comment) {
+    return null
+  }
+
+  return (
+    <OLTooltip
+      id="editor-floating-menu-add-comment"
+      description={t('add_comment')}
+      overlayProps={{ placement: 'right' }}
+    >
+      <button
+        className="editor-floating-menu-button"
+        onClick={handleClick}
+        aria-label={t('add_comment')}
+      >
+        <MaterialIcon type="chat" />
+      </button>
+    </OLTooltip>
+  )
+}
+
+export default AddCommentAction

+ 61 - 0
services/web/frontend/js/features/editor-floating-menu/components/add-comment-command.tsx

@@ -0,0 +1,61 @@
+import { useCallback } from 'react'
+import { EditorSelection } from '@codemirror/state'
+import { EditorView } from '@codemirror/view'
+import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-context'
+import { buildAddNewCommentRangeEffect } from '@/features/source-editor/extensions/review-tooltip'
+import { selectHighlightedOrNearestToken } from '@/features/source-editor/utils/select-highlighted-or-nearest-token'
+import { isCursorNearViewportEdge } from '@/features/source-editor/utils/is-cursor-near-edge'
+import useEventListener from '@/shared/hooks/use-event-listener'
+import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
+import { useReviewPanelViewActionsContext } from '@/features/review-panel/context/review-panel-view-context'
+import useReviewPanelLayout from '@/features/review-panel/hooks/use-review-panel-layout'
+
+// Headless owner of the "add comment" editor command. It is mounted whenever
+// commenting is possible, independently of the floating menu's visibility, so
+// the keyboard shortcut, toolbar command and floating menu button
+// (which all dispatch `add-new-review-comment`) work even when the menu is hidden
+const AddCommentCommand = () => {
+  const view = useCodeMirrorViewContext()
+  const permissions = usePermissionsContext()
+  const { setView } = useReviewPanelViewActionsContext()
+  const { openReviewPanel } = useReviewPanelLayout()
+
+  const addComment = useCallback(() => {
+    if (!permissions.comment) {
+      return
+    }
+
+    let { main } = view.state.selection
+
+    if (main.empty) {
+      const tokenRange = selectHighlightedOrNearestToken(view.state)
+      if (!tokenRange) {
+        return
+      }
+      main = EditorSelection.range(tokenRange.from, tokenRange.to)
+    }
+
+    openReviewPanel()
+    setView('cur_file')
+
+    const effects = isCursorNearViewportEdge(view, main.anchor)
+      ? [
+          buildAddNewCommentRangeEffect(main),
+          EditorView.scrollIntoView(main.anchor, { y: 'center' }),
+        ]
+      : [buildAddNewCommentRangeEffect(main)]
+
+    // Dispatching a new selection clears the review tooltip state, which
+    // dismisses the floating menu — no need to toggle menu state from here.
+    view.dispatch({
+      selection: { anchor: main.anchor, head: main.head },
+      effects,
+    })
+  }, [view, permissions.comment, openReviewPanel, setView])
+
+  useEventListener('add-new-review-comment', addComment)
+
+  return null
+}
+
+export default AddCommentCommand

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

@@ -0,0 +1,127 @@
+import { FC, useCallback, useMemo } 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'
+
+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,
+  ])
+
+  if (!permissions.comment || changesInSelection.length === 0) {
+    return null
+  }
+
+  return (
+    <>
+      <div className="editor-floating-menu-divider" />
+      <OLTooltip
+        id="editor-floating-menu-accept-changes"
+        description={t('accept_selected_changes')}
+        overlayProps={{ placement: 'right' }}
+      >
+        <button
+          className="editor-floating-menu-button"
+          onClick={acceptChangesHandler}
+          aria-label={t('accept_selected_changes')}
+        >
+          <MaterialIcon type="check" />
+        </button>
+      </OLTooltip>
+      <OLTooltip
+        id="editor-floating-menu-reject-changes"
+        description={t('reject_selected_changes')}
+        overlayProps={{ placement: 'right' }}
+      >
+        <button
+          className="editor-floating-menu-button"
+          onClick={rejectChangesHandler}
+          aria-label={t('reject_selected_changes')}
+        >
+          <MaterialIcon type="clear" />
+        </button>
+      </OLTooltip>
+    </>
+  )
+}
+
+export default TrackedChangesActions

+ 188 - 0
services/web/frontend/js/features/editor-floating-menu/editor-floating-menu.tsx

@@ -0,0 +1,188 @@
+import React, {
+  ComponentType,
+  FC,
+  memo,
+  useEffect,
+  useRef,
+  useState,
+} from 'react'
+import ReactDOM from 'react-dom'
+import classNames from 'classnames'
+import { getTooltip } from '@codemirror/view'
+import importOverleafModules from '../../../macros/import-overleaf-module.macro'
+import {
+  useCodeMirrorStateContext,
+  useCodeMirrorViewContext,
+} from '@/features/source-editor/components/codemirror-context'
+import { reviewTooltipStateField } from '@/features/source-editor/extensions/review-tooltip'
+import usePreviousValue from '@/shared/hooks/use-previous-value'
+import { useLayoutContext } from '@/shared/context/layout-context'
+import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
+import AddCommentAction from './components/add-comment-action'
+import TrackedChangesActions from './components/tracked-changes-actions'
+
+const TOOLTIP_SHOW_DELAY = 120
+
+// Each default-exports a self-gating component.
+const editorFloatingMenuActions = importOverleafModules(
+  'editorFloatingMenuActions'
+) as { import: { default: ComponentType }; path: string }[]
+
+const EditorFloatingMenu: FC = () => {
+  const state = useCodeMirrorStateContext()
+  const view = useCodeMirrorViewContext()
+  const [show, setShow] = useState(true)
+  const tooltipState = state.field(reviewTooltipStateField, false)?.tooltip
+  const previousTooltipState = usePreviousValue(tooltipState)
+
+  useEffect(() => {
+    if (tooltipState !== null && previousTooltipState === null) {
+      setShow(true)
+    }
+  }, [tooltipState, previousTooltipState])
+
+  useEffect(() => {
+    if (!show || !tooltipState) {
+      return
+    }
+    const handleMouseDown = (event: MouseEvent) => {
+      const target = event.target as Element | null
+      if (
+        !view.contentDOM.contains(target) &&
+        !target?.closest?.('.review-tooltip-menu-container') &&
+        !target?.closest?.('.modal') &&
+        !target?.closest?.('.modal-backdrop')
+      ) {
+        setShow(false)
+      }
+    }
+    document.addEventListener('mousedown', handleMouseDown)
+    return () => {
+      document.removeEventListener('mousedown', handleMouseDown)
+    }
+  }, [show, tooltipState, view])
+
+  if (!show || !tooltipState) {
+    return null
+  }
+
+  const tooltipView = getTooltip(view, tooltipState)
+
+  if (!tooltipView) {
+    return null
+  }
+
+  return ReactDOM.createPortal(<EditorFloatingMenuContent />, tooltipView.dom)
+}
+
+const EditorFloatingMenuContent = memo(function EditorFloatingMenuContent() {
+  const view = useCodeMirrorViewContext()
+  const { reviewPanelOpen } = useLayoutContext()
+  const { wantTrackChanges } = useEditorPropertiesContext()
+  const [visible, setVisible] = useState(false)
+  const menuRef = useRef<HTMLDivElement>(null)
+
+  useEffect(() => {
+    const measure = () => {
+      view.requestMeasure({
+        key: 'editor-floating-menu-position',
+        read(view) {
+          const cursorCoords = view.coordsAtPos(view.state.selection.main.head)
+          if (!cursorCoords) {
+            return
+          }
+
+          const menuHeight =
+            menuRef.current?.getBoundingClientRect().height ?? 0
+          const scrollDomRect = view.scrollDOM.getBoundingClientRect()
+          const contentDomRect = view.contentDOM.getBoundingClientRect()
+          const cursorCenterY = (cursorCoords.top + cursorCoords.bottom) / 2
+
+          if (
+            // Cursor scrolls out of view at the top
+            cursorCoords.top < scrollDomRect.top ||
+            // Cursor scrolls out of view at the bottom
+            cursorCoords.top > scrollDomRect.bottom
+          ) {
+            return { visibility: 'hidden' as const }
+          }
+
+          return {
+            position: 'fixed' as const,
+            // Align centrally
+            top: cursorCenterY - menuHeight / 2,
+            right: window.innerWidth - contentDomRect.left,
+          }
+        },
+        // Mutate the DOM directly rather than via state to avoid re-rendering
+        // on every scroll frame.
+        write(res) {
+          const el = menuRef.current
+          if (!el || !res) return
+          // Only toggle visibility when off-screen
+          if (res.visibility === 'hidden') {
+            el.style.visibility = 'hidden'
+          } else {
+            el.style.visibility = ''
+            el.style.position = res.position
+            el.style.top = `${res.top}px`
+            el.style.right = `${res.right}px`
+          }
+        },
+      })
+    }
+
+    measure()
+
+    // Re-center when the menu's own height changes (e.g. tracked-change
+    // actions appear/disappear).
+    const observer = new ResizeObserver(measure)
+    if (menuRef.current) {
+      observer.observe(menuRef.current)
+    }
+    // The scroller's box tracks the editor pane, so this catches pane/window
+    // resizes. It doesn't fire on scroll, hence the separate scroll listener.
+    observer.observe(view.scrollDOM)
+
+    // Track the cursor as the editor scrolls.
+    view.scrollDOM.addEventListener('scroll', measure)
+
+    return () => {
+      observer.disconnect()
+      view.scrollDOM.removeEventListener('scroll', measure)
+    }
+  }, [view, reviewPanelOpen, wantTrackChanges])
+
+  useEffect(() => {
+    setVisible(false)
+    const timeout = setTimeout(() => {
+      setVisible(true)
+    }, TOOLTIP_SHOW_DELAY)
+
+    return () => {
+      clearTimeout(timeout)
+    }
+  }, [])
+
+  return (
+    <div
+      ref={menuRef}
+      className={classNames('editor-floating-menu', {
+        'editor-floating-menu-visible': visible,
+      })}
+    >
+      <AddCommentAction />
+      <TrackedChangesActions />
+      {editorFloatingMenuActions.map(
+        ({ import: { default: Component }, path }) => (
+          <React.Fragment key={path}>
+            <div className="editor-floating-menu-divider" />
+            <Component />
+          </React.Fragment>
+        )
+      )}
+    </div>
+  )
+})
+
+export default EditorFloatingMenu

+ 1 - 0
services/web/frontend/js/features/review-panel/components/review-tooltip-menu.tsx

@@ -44,6 +44,7 @@ const EDIT_MODE_SWITCH_WIDGET_HEIGHT = 40
 const CM_LINE_RIGHT_PADDING = 8
 const CM_LINE_RIGHT_PADDING = 8
 const TOOLTIP_SHOW_DELAY = 120
 const TOOLTIP_SHOW_DELAY = 120
 
 
+// TODO remove when `writefull-toolbar-migration` fully rolled out
 const ReviewTooltipMenu: FC = () => {
 const ReviewTooltipMenu: FC = () => {
   const state = useCodeMirrorStateContext()
   const state = useCodeMirrorStateContext()
   const view = useCodeMirrorViewContext()
   const view = useCodeMirrorViewContext()

+ 14 - 1
services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx

@@ -13,6 +13,8 @@ import { ReviewPanelProviders } from '@/features/review-panel/context/review-pan
 import { ReviewPanelRoot } from '@/features/review-panel/components/review-panel-root'
 import { ReviewPanelRoot } from '@/features/review-panel/components/review-panel-root'
 import ReviewPanelTabsHeaderPortal from '@/features/review-panel/components/review-panel-tabs-header-portal'
 import ReviewPanelTabsHeaderPortal from '@/features/review-panel/components/review-panel-tabs-header-portal'
 import ReviewTooltipMenu from '@/features/review-panel/components/review-tooltip-menu'
 import ReviewTooltipMenu from '@/features/review-panel/components/review-tooltip-menu'
+import EditorFloatingMenu from '@/features/editor-floating-menu/editor-floating-menu'
+import AddCommentCommand from '@/features/editor-floating-menu/components/add-comment-command'
 import {
 import {
   CodeMirrorStateContext,
   CodeMirrorStateContext,
   CodeMirrorViewContext,
   CodeMirrorViewContext,
@@ -93,6 +95,9 @@ function CodeMirrorEditorComponents({
 }: CodeMirrorEditorComponentsProps) {
 }: CodeMirrorEditorComponentsProps) {
   useToolbarMenuBarEditorCommands()
   useToolbarMenuBarEditorCommands()
   const { features } = useProjectContext()
   const { features } = useProjectContext()
+  const writefullToolbarMigrationEnabled = useFeatureFlag(
+    'writefull-toolbar-migration'
+  )
   return (
   return (
     <ReviewPanelProviders>
     <ReviewPanelProviders>
       <CodemirrorOutline />
       <CodemirrorOutline />
@@ -104,7 +109,15 @@ function CodeMirrorEditorComponents({
 
 
       <MathPreviewTooltip />
       <MathPreviewTooltip />
       <EditorContextMenu />
       <EditorContextMenu />
-      {features.trackChangesVisible && <ReviewTooltipMenu />}
+      {features.trackChangesVisible &&
+        (writefullToolbarMigrationEnabled ? (
+          <>
+            <AddCommentCommand />
+            <EditorFloatingMenu />
+          </>
+        ) : (
+          <ReviewTooltipMenu />
+        ))}
       {features.trackChangesVisible && <ReviewPanelTabsHeaderPortal />}
       {features.trackChangesVisible && <ReviewPanelTabsHeaderPortal />}
       {features.trackChangesVisible && <ReviewPanelRoot />}
       {features.trackChangesVisible && <ReviewPanelRoot />}
       {features.trackChangesVisible && <UpgradeTrackChangesModal />}
       {features.trackChangesVisible && <UpgradeTrackChangesModal />}

+ 2 - 1
services/web/frontend/stylesheets/components/dropdown-menu.scss

@@ -41,7 +41,8 @@
   .project-ds-nav-page,
   .project-ds-nav-page,
   // Codemirror tooltips are rendered outside of the main app container
   // Codemirror tooltips are rendered outside of the main app container
   .cm-tooltip .dropdown-menu,
   .cm-tooltip .dropdown-menu,
-  .cm-tooltip .dropdown {
+  .cm-tooltip .dropdown,
+  .cm-tooltip .editor-floating-menu {
     @include dark-dropdown-menu;
     @include dark-dropdown-menu;
   }
   }
 }
 }

+ 1 - 0
services/web/frontend/stylesheets/pages/all.scss

@@ -32,6 +32,7 @@
 @import 'editor/tabs';
 @import 'editor/tabs';
 @import 'editor/tags-input';
 @import 'editor/tags-input';
 @import 'editor/review-panel';
 @import 'editor/review-panel';
+@import 'editor/editor-floating-menu';
 @import 'editor/table-generator-column-width-modal';
 @import 'editor/table-generator-column-width-modal';
 @import 'editor/math-preview';
 @import 'editor/math-preview';
 @import 'editor/references-search';
 @import 'editor/references-search';

+ 64 - 0
services/web/frontend/stylesheets/pages/editor/editor-floating-menu.scss

@@ -0,0 +1,64 @@
+.editor-floating-menu {
+  display: flex;
+  flex-direction: column;
+  box-shadow: 0 2px 4px 0 #1e253029;
+  background-color: var(--dropdown-background);
+  color: var(--dropdown-text-color);
+  border: 1px solid var(--border-divider);
+  border-radius: var(--border-radius-base);
+  padding: var(--spacing-02);
+  gap: var(--spacing-02);
+  transition: opacity 0.05s ease-in;
+  opacity: 0;
+}
+
+.editor-floating-menu-visible {
+  opacity: 1;
+}
+
+// Module actions self-gate and may render nothing, so hide the pill when it
+// has no buttons rather than showing an empty box.
+.editor-floating-menu:not(:has(button)) {
+  display: none;
+}
+
+.editor-floating-menu-button {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background-color: inherit;
+  color: inherit;
+  border: none;
+  padding: var(--spacing-01);
+  border-radius: var(--border-radius-base);
+  cursor: pointer;
+
+  &:hover {
+    background-color: var(--dropdown-background-hover);
+  }
+}
+
+.editor-floating-menu-divider {
+  height: 1px;
+  background-color: var(--dropdown-border-divider);
+
+  // Hide dividers at the menu edges, or with no following button (e.g. a module
+  // action rendered nothing). `~ * button` keeps it wrapper-agnostic.
+  &:first-child,
+  &:last-child,
+  &:not(:has(~ button, ~ * button)) {
+    display: none;
+  }
+}
+
+// Flatten AppWritefullContainer's `<div class="writefull">` wrapper so the button
+// is a direct flex item of the menu.
+.editor-floating-menu .writefull {
+  display: contents;
+}
+
+@include theme('default') {
+  .cm-tooltip .editor-floating-menu {
+    border-color: var(--border-primary);
+  }
+}

+ 188 - 0
services/web/test/frontend/features/editor-floating-menu/editor-floating-menu.spec.tsx

@@ -0,0 +1,188 @@
+import CodeMirrorEditor from '../../../../frontend/js/features/source-editor/components/codemirror-editor'
+import { EditorProviders, USER_ID } from '../../helpers/editor-providers'
+import { mockScope } from '../source-editor/helpers/mock-scope'
+import { TestContainer } from '../source-editor/helpers/test-container'
+import { docId } from '../source-editor/helpers/mock-doc'
+
+describe('<EditorFloatingMenu />', function () {
+  function mountEditor({
+    migrationEnabled,
+    trackChangesVisible = true,
+  }: {
+    migrationEnabled: boolean
+    trackChangesVisible?: boolean
+  }) {
+    window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
+    window.metaAttributesCache.set('ol-splitTestVariants', {
+      'writefull-toolbar-migration': migrationEnabled ? 'enabled' : 'default',
+    })
+
+    cy.interceptEvents()
+
+    const scope = mockScope()
+
+    cy.mount(
+      <TestContainer>
+        <EditorProviders scope={scope} features={{ trackChangesVisible }}>
+          <CodeMirrorEditor />
+        </EditorProviders>
+      </TestContainer>
+    )
+
+    // Create a non-empty selection so the selection tooltip is shown.
+    cy.findByText('contentLine 12').type(
+      '{home}{shift}' + '{rightArrow}'.repeat(6),
+      {
+        scrollBehavior: false,
+      }
+    )
+  }
+
+  function mountEditorWithChanges() {
+    window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
+    window.metaAttributesCache.set('ol-splitTestVariants', {
+      'writefull-toolbar-migration': 'enabled',
+    })
+
+    cy.interceptEvents()
+    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 controls appear.
+    cy.findByText('\\maketitle').type(
+      '{home}{shift}' + '{downArrow}'.repeat(10),
+      { scrollBehavior: false }
+    )
+  }
+
+  describe('when the migration split test is enabled', function () {
+    it('shows the unified floating menu with Add comment, not the legacy tooltip', function () {
+      mountEditor({ migrationEnabled: true })
+
+      cy.get('.editor-floating-menu').within(() => {
+        cy.findByLabelText('Add comment').should('exist')
+      })
+      // Legacy tooltip is replaced.
+      cy.get('.review-tooltip-menu').should('not.exist')
+    })
+
+    it('clicking Add comment dispatches the add-new-review-comment event', function () {
+      mountEditor({ migrationEnabled: true })
+
+      cy.window().then(win => {
+        win.addEventListener(
+          'add-new-review-comment',
+          cy.stub().as('addComment')
+        )
+      })
+
+      cy.get('.editor-floating-menu').within(() => {
+        cy.findByLabelText('Add comment').click({ scrollBehavior: false })
+      })
+
+      cy.get('@addComment').should('have.been.called')
+    })
+  })
+
+  describe('when the migration split test is disabled (control)', function () {
+    it('keeps the legacy review tooltip and does not render the unified menu', function () {
+      mountEditor({ migrationEnabled: false })
+
+      cy.get('.review-tooltip-menu').should('exist')
+      cy.get('.editor-floating-menu').should('not.exist')
+    })
+  })
+
+  describe('bulk tracked-change actions', function () {
+    beforeEach(function () {
+      mountEditorWithChanges()
+      cy.findByLabelText('Accept selected changes').as(
+        'accept-selected-changes'
+      )
+      cy.findByLabelText('Reject selected changes').as(
+        'reject-selected-changes'
+      )
+    })
+
+    it('renders the accept and reject controls in the unified menu', function () {
+      cy.get('.editor-floating-menu').should('exist')
+      cy.get('@accept-selected-changes').should('exist')
+      cy.get('@reject-selected-changes').should('exist')
+    })
+
+    it('accepts the selected changes', function () {
+      cy.get('@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 () {
+      cy.get('@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',
+      ])
+    })
+
+    it('keeps the menu visible when cancelling the confirmation modal', function () {
+      cy.get('@accept-selected-changes').click({ scrollBehavior: false })
+      cy.findByRole('dialog').within(() => {
+        cy.findByRole('button', { name: 'Cancel' }).click({
+          scrollBehavior: false,
+        })
+      })
+      cy.get('.editor-floating-menu').should('exist')
+    })
+  })
+})