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

Add combined floating menu for comment, tracked changes, writefull

GitOrigin-RevId: 4de5b9d3b1717fc76394f7c0edfe4d7230394c8d
This commit is contained in:
Malik Glossop
2026-06-17 08:06:44 +00:00
committed by Copybot
parent 9fae908602
commit 8ef463f8b0
11 changed files with 685 additions and 2 deletions
+1
View File
@@ -1098,6 +1098,7 @@ module.exports = {
referenceSearchSetting: [],
settingsModalEditorTabSections: [],
settingsModalSpellcheckSections: [],
editorFloatingMenuActions: [],
errorLogsComponents: [],
referenceIndices: [],
railEntries: [],
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
@@ -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 (
<ReviewPanelProviders>
<CodemirrorOutline />
@@ -104,7 +109,15 @@ function CodeMirrorEditorComponents({
<MathPreviewTooltip />
<EditorContextMenu />
{features.trackChangesVisible && <ReviewTooltipMenu />}
{features.trackChangesVisible &&
(writefullToolbarMigrationEnabled ? (
<>
<AddCommentCommand />
<EditorFloatingMenu />
</>
) : (
<ReviewTooltipMenu />
))}
{features.trackChangesVisible && <ReviewPanelTabsHeaderPortal />}
{features.trackChangesVisible && <ReviewPanelRoot />}
{features.trackChangesVisible && <UpgradeTrackChangesModal />}
@@ -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;
}
}
@@ -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';
@@ -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);
}
}
@@ -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')
})
})
})