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