diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js
index e650e8f7d9..4cd93528c6 100644
--- a/services/web/config/settings.defaults.js
+++ b/services/web/config/settings.defaults.js
@@ -1098,6 +1098,7 @@ module.exports = {
referenceSearchSetting: [],
settingsModalEditorTabSections: [],
settingsModalSpellcheckSections: [],
+ editorFloatingMenuActions: [],
errorLogsComponents: [],
referenceIndices: [],
railEntries: [],
diff --git a/services/web/frontend/js/features/editor-floating-menu/components/add-comment-action.tsx b/services/web/frontend/js/features/editor-floating-menu/components/add-comment-action.tsx
new file mode 100644
index 0000000000..d98746f973
--- /dev/null
+++ b/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 (
+
+ )
+}
+
+export default AddCommentAction
diff --git a/services/web/frontend/js/features/editor-floating-menu/components/add-comment-command.tsx b/services/web/frontend/js/features/editor-floating-menu/components/add-comment-command.tsx
new file mode 100644
index 0000000000..042e53c842
--- /dev/null
+++ b/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
diff --git a/services/web/frontend/js/features/editor-floating-menu/components/tracked-changes-actions.tsx b/services/web/frontend/js/features/editor-floating-menu/components/tracked-changes-actions.tsx
new file mode 100644
index 0000000000..2251a4164c
--- /dev/null
+++ b/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 (
+ <>
+
+
+
+ >
+ )
+}
+
+export default TrackedChangesActions
diff --git a/services/web/frontend/js/features/editor-floating-menu/editor-floating-menu.tsx b/services/web/frontend/js/features/editor-floating-menu/editor-floating-menu.tsx
new file mode 100644
index 0000000000..33c505c9bb
--- /dev/null
+++ b/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(, tooltipView.dom)
+}
+
+const EditorFloatingMenuContent = memo(function EditorFloatingMenuContent() {
+ const view = useCodeMirrorViewContext()
+ const { reviewPanelOpen } = useLayoutContext()
+ const { wantTrackChanges } = useEditorPropertiesContext()
+ const [visible, setVisible] = useState(false)
+ const menuRef = useRef(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 (
+
+
+
+ {editorFloatingMenuActions.map(
+ ({ import: { default: Component }, path }) => (
+
+
+
+
+ )
+ )}
+
+ )
+})
+
+export default EditorFloatingMenu
diff --git a/services/web/frontend/js/features/review-panel/components/review-tooltip-menu.tsx b/services/web/frontend/js/features/review-panel/components/review-tooltip-menu.tsx
index 34ee1cf725..475c3e09d9 100644
--- a/services/web/frontend/js/features/review-panel/components/review-tooltip-menu.tsx
+++ b/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 TOOLTIP_SHOW_DELAY = 120
+// TODO remove when `writefull-toolbar-migration` fully rolled out
const ReviewTooltipMenu: FC = () => {
const state = useCodeMirrorStateContext()
const view = useCodeMirrorViewContext()
diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx
index 828b299880..7191fd642c 100644
--- a/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx
+++ b/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 ReviewPanelTabsHeaderPortal from '@/features/review-panel/components/review-panel-tabs-header-portal'
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 {
CodeMirrorStateContext,
CodeMirrorViewContext,
@@ -93,6 +95,9 @@ function CodeMirrorEditorComponents({
}: CodeMirrorEditorComponentsProps) {
useToolbarMenuBarEditorCommands()
const { features } = useProjectContext()
+ const writefullToolbarMigrationEnabled = useFeatureFlag(
+ 'writefull-toolbar-migration'
+ )
return (
@@ -104,7 +109,15 @@ function CodeMirrorEditorComponents({
- {features.trackChangesVisible && }
+ {features.trackChangesVisible &&
+ (writefullToolbarMigrationEnabled ? (
+ <>
+
+
+ >
+ ) : (
+
+ ))}
{features.trackChangesVisible && }
{features.trackChangesVisible && }
{features.trackChangesVisible && }
diff --git a/services/web/frontend/stylesheets/components/dropdown-menu.scss b/services/web/frontend/stylesheets/components/dropdown-menu.scss
index c5ba8d387e..725ddf7561 100644
--- a/services/web/frontend/stylesheets/components/dropdown-menu.scss
+++ b/services/web/frontend/stylesheets/components/dropdown-menu.scss
@@ -41,7 +41,8 @@
.project-ds-nav-page,
// Codemirror tooltips are rendered outside of the main app container
.cm-tooltip .dropdown-menu,
- .cm-tooltip .dropdown {
+ .cm-tooltip .dropdown,
+ .cm-tooltip .editor-floating-menu {
@include dark-dropdown-menu;
}
}
diff --git a/services/web/frontend/stylesheets/pages/all.scss b/services/web/frontend/stylesheets/pages/all.scss
index d7570c4ad2..ef0db13379 100644
--- a/services/web/frontend/stylesheets/pages/all.scss
+++ b/services/web/frontend/stylesheets/pages/all.scss
@@ -32,6 +32,7 @@
@import 'editor/tabs';
@import 'editor/tags-input';
@import 'editor/review-panel';
+@import 'editor/editor-floating-menu';
@import 'editor/table-generator-column-width-modal';
@import 'editor/math-preview';
@import 'editor/references-search';
diff --git a/services/web/frontend/stylesheets/pages/editor/editor-floating-menu.scss b/services/web/frontend/stylesheets/pages/editor/editor-floating-menu.scss
new file mode 100644
index 0000000000..8595b67a19
--- /dev/null
+++ b/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 `` 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);
+ }
+}
diff --git a/services/web/test/frontend/features/editor-floating-menu/editor-floating-menu.spec.tsx b/services/web/test/frontend/features/editor-floating-menu/editor-floating-menu.spec.tsx
new file mode 100644
index 0000000000..4b08dbeff0
--- /dev/null
+++ b/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('', 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(
+
+
+
+
+
+ )
+
+ // 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(
+
+
+
+
+
+ )
+
+ // 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')
+ })
+ })
+})