From 5f79c2dd10fae57d33696e64d5e6550969bd9684 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Thu, 18 Jun 2026 08:59:31 +0100 Subject: [PATCH] Merge pull request #34512 from overleaf/mj-floating-toolbar-option [web] Add setting for floating toolbar menu GitOrigin-RevId: bd28f1f287f2011535c7fd530f1217f01e0cfbbc --- .../Features/Project/UserSettingsHelper.mjs | 1 + .../app/src/Features/User/UserController.mjs | 3 + services/web/app/src/models/User.mjs | 1 + .../web/frontend/extracted-translations.json | 1 + .../context/project-settings-context.tsx | 7 ++ .../hooks/use-user-wide-settings.tsx | 10 +++ .../editor-settings/floating-menu-setting.tsx | 18 ++++ .../context/settings-modal-context.tsx | 11 ++- .../components/codemirror-editor.tsx | 7 +- .../source-editor/extensions/index.ts | 5 +- .../extensions/review-tooltip.ts | 26 +++++- .../hooks/use-codemirror-scope.ts | 12 +++ .../shared/context/user-settings-context.tsx | 1 + services/web/locales/en.json | 1 + .../editor-floating-menu.spec.tsx | 86 ++++++++++++++++++- .../settings/floating-menu-setting.test.tsx | 51 +++++++++++ services/web/types/user-settings.ts | 1 + 17 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 services/web/frontend/js/features/settings/components/editor-settings/floating-menu-setting.tsx create mode 100644 services/web/test/frontend/features/settings-modal/settings/floating-menu-setting.test.tsx diff --git a/services/web/app/src/Features/Project/UserSettingsHelper.mjs b/services/web/app/src/Features/Project/UserSettingsHelper.mjs index cad1e6e709..bb6ba50c4c 100644 --- a/services/web/app/src/Features/Project/UserSettingsHelper.mjs +++ b/services/web/app/src/Features/Project/UserSettingsHelper.mjs @@ -34,6 +34,7 @@ async function buildUserSettings(_req, _res, user) { nonBlinkingCursor: user.ace.nonBlinkingCursor ?? false, referencesSearchMode: user.ace.referencesSearchMode, darkModePdf: user.ace.darkModePdf ?? false, + floatingMenu: user.ace.floatingMenu ?? true, zotero: user.ace.zotero, mendeley: user.ace.mendeley, papers: user.ace.papers, diff --git a/services/web/app/src/Features/User/UserController.mjs b/services/web/app/src/Features/User/UserController.mjs index 782f9d18e2..389d4b9c5c 100644 --- a/services/web/app/src/Features/User/UserController.mjs +++ b/services/web/app/src/Features/User/UserController.mjs @@ -438,6 +438,9 @@ async function updateUserSettings(req, res, next) { if (body.darkModePdf != null) { user.ace.darkModePdf = Boolean(body.darkModePdf) } + if (body.floatingMenu != null) { + user.ace.floatingMenu = Boolean(body.floatingMenu) + } if (body.zotero != null) { user.ace.zotero = { ...user.ace.zotero, ...body.zotero } } diff --git a/services/web/app/src/models/User.mjs b/services/web/app/src/models/User.mjs index cf7cc8951c..f11c48f049 100644 --- a/services/web/app/src/models/User.mjs +++ b/services/web/app/src/models/User.mjs @@ -110,6 +110,7 @@ export const UserSchema = new Schema( nonBlinkingCursor: { type: Boolean, default: false }, referencesSearchMode: { type: String, default: 'advanced' }, // 'advanced' or 'simple' darkModePdf: { type: Boolean, default: false }, + floatingMenu: { type: Boolean, default: true }, zotero: refProviderSettingsSchema, mendeley: refProviderSettingsSchema, papers: refProviderSettingsSchema, diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 5cfc4e9331..835465447f 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -1836,6 +1836,7 @@ "show_local_file_contents": "", "show_more": "", "show_outline": "", + "show_quick_actions_on_text_selection": "", "show_version_history": "", "show_x_more_projects": "", "show_x_more_projects_plural": "", diff --git a/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx b/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx index 6483cc7c4d..c9b9016a2f 100644 --- a/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx +++ b/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx @@ -38,6 +38,7 @@ type ProjectSettingsSetterContextValue = { nonBlinkingCursor: UserSettings['nonBlinkingCursor'] ) => void setDarkModePdf: (darkModePdf: UserSettings['darkModePdf']) => void + setFloatingMenu: (floatingMenu: UserSettings['floatingMenu']) => void setZotero: (zotero: UserSettings['zotero']) => void setMendeley: (mendeley: UserSettings['mendeley']) => void setPapers: (papers: UserSettings['papers']) => void @@ -102,6 +103,8 @@ export const ProjectSettingsProvider: FC = ({ setNonBlinkingCursor, darkModePdf, setDarkModePdf, + floatingMenu, + setFloatingMenu, zotero, setZotero, mendeley, @@ -158,6 +161,8 @@ export const ProjectSettingsProvider: FC = ({ setNonBlinkingCursor, darkModePdf, setDarkModePdf, + floatingMenu, + setFloatingMenu, zotero, setZotero, mendeley, @@ -210,6 +215,8 @@ export const ProjectSettingsProvider: FC = ({ setNonBlinkingCursor, darkModePdf, setDarkModePdf, + floatingMenu, + setFloatingMenu, zotero, setZotero, mendeley, diff --git a/services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx b/services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx index 92658eb580..5829aa4b05 100644 --- a/services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx +++ b/services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx @@ -27,6 +27,7 @@ export default function useUserWideSettings() { editorTabs, nonBlinkingCursor, darkModePdf, + floatingMenu, zotero, mendeley, papers, @@ -152,6 +153,13 @@ export default function useUserWideSettings() { [saveUserSettings] ) + const setFloatingMenu = useCallback( + (floatingMenu: UserSettings['floatingMenu']) => { + saveUserSettings('floatingMenu', floatingMenu) + }, + [saveUserSettings] + ) + const setZotero = useCallback( (zotero: UserSettings['zotero']) => { saveUserSettings('zotero', { ...zotero, migrated: true }) @@ -210,6 +218,8 @@ export default function useUserWideSettings() { setNonBlinkingCursor, darkModePdf, setDarkModePdf, + floatingMenu, + setFloatingMenu, zotero, setZotero, mendeley, diff --git a/services/web/frontend/js/features/settings/components/editor-settings/floating-menu-setting.tsx b/services/web/frontend/js/features/settings/components/editor-settings/floating-menu-setting.tsx new file mode 100644 index 0000000000..f924dfbf88 --- /dev/null +++ b/services/web/frontend/js/features/settings/components/editor-settings/floating-menu-setting.tsx @@ -0,0 +1,18 @@ +import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context' +import ToggleSetting from '../toggle-setting' +import { useTranslation } from 'react-i18next' + +export default function FloatingMenuSetting() { + const { floatingMenu, setFloatingMenu } = useProjectSettingsContext() + const { t } = useTranslation() + + return ( + + ) +} diff --git a/services/web/frontend/js/features/settings/context/settings-modal-context.tsx b/services/web/frontend/js/features/settings/context/settings-modal-context.tsx index 062b9688e4..feb3378ac9 100644 --- a/services/web/frontend/js/features/settings/context/settings-modal-context.tsx +++ b/services/web/frontend/js/features/settings/context/settings-modal-context.tsx @@ -37,6 +37,7 @@ import type { SettingsSectionHook, } from '@/features/settings/context/types' import EditorTabsSetting from '../components/editor-settings/editor-tabs-setting' +import FloatingMenuSetting from '../components/editor-settings/floating-menu-setting' const [referenceSearchSettingModule] = importOverleafModules( 'referenceSearchSetting' @@ -75,13 +76,14 @@ export const SettingsModalProvider: FC = ({ }) => { const { t } = useTranslation() const { isOverleaf } = getMeta('ol-ExposedSettings') - const { overallTheme } = useProjectSettingsContext() + const { overallTheme, floatingMenu } = useProjectSettingsContext() // TODO ide-redesign-cleanup: Rename this field and move it directly into this context const { leftMenuShown, setLeftMenuShown } = useLayoutContext() const hasEmailNotifications = useFeatureFlag('email-notifications') const hasEditorTabs = useFeatureFlag('editor-tabs') + const hasToolbarMigration = useFeatureFlag('writefull-toolbar-migration') const editorTabExtraSections = useSlotSections(editorTabExtraSectionHooks) const spellcheckExtraSections = useSlotSections(spellcheckExtraSectionHooks) @@ -135,6 +137,11 @@ export const SettingsModalProvider: FC = ({ component: , hidden: !ReferenceSearchSetting, }, + { + key: 'floating-menu', + component: , + hidden: !hasToolbarMigration && floatingMenu, + }, ], }, { @@ -290,6 +297,8 @@ export const SettingsModalProvider: FC = ({ isOverleaf, editorTabExtraSections, spellcheckExtraSections, + hasToolbarMigration, + floatingMenu, ] ) 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 7191fd642c..40956224ee 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 @@ -28,6 +28,7 @@ import { useFeatureFlag } from '@/shared/context/split-test-context' import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context' import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context' import UpgradeTrackChangesModal from '@/features/review-panel/components/upgrade-track-changes-modal' +import { useUserSettingsContext } from '@/shared/context/user-settings-context' // TODO: remove this when definitely no longer used export * from './codemirror-context' @@ -95,6 +96,9 @@ function CodeMirrorEditorComponents({ }: CodeMirrorEditorComponentsProps) { useToolbarMenuBarEditorCommands() const { features } = useProjectContext() + const { + userSettings: { floatingMenu }, + } = useUserSettingsContext() const writefullToolbarMigrationEnabled = useFeatureFlag( 'writefull-toolbar-migration' ) @@ -109,7 +113,8 @@ function CodeMirrorEditorComponents({ - {features.trackChangesVisible && + {floatingMenu && + features.trackChangesVisible && (writefullToolbarMigrationEnabled ? ( <> diff --git a/services/web/frontend/js/features/source-editor/extensions/index.ts b/services/web/frontend/js/features/source-editor/extensions/index.ts index e373591f66..6435fd00c5 100644 --- a/services/web/frontend/js/features/source-editor/extensions/index.ts +++ b/services/web/frontend/js/features/source-editor/extensions/index.ts @@ -159,7 +159,10 @@ export const createExtensions = (options: Record): Extension[] => [ trackDetachedComments(options.currentDoc), visual(options.docName, options.visual), mathPreview(options.settings.mathPreview), - reviewTooltip(options.editorContextMenuEnabled), + reviewTooltip( + options.settings.floatingMenu, + options.editorContextMenuEnabled + ), contextMenu(options.editorContextMenuEnabled), toolbarPanel(), breadcrumbPanel(), diff --git a/services/web/frontend/js/features/source-editor/extensions/review-tooltip.ts b/services/web/frontend/js/features/source-editor/extensions/review-tooltip.ts index e69573ddac..2a91dfb2f5 100644 --- a/services/web/frontend/js/features/source-editor/extensions/review-tooltip.ts +++ b/services/web/frontend/js/features/source-editor/extensions/review-tooltip.ts @@ -14,6 +14,8 @@ import { SelectionRange, EditorState, Transaction, + Compartment, + TransactionSpec, } from '@codemirror/state' import { v4 as uuid } from 'uuid' import { isContextMenuMouseEvent } from '../utils/context-menu-mouse-event' @@ -52,7 +54,29 @@ export const buildAddNewCommentRangeEffect = (range: SelectionRange) => { ) } -export const reviewTooltip = (editorContextMenuEnabled = false): Extension => { +const reviewTooltipCompartment = new Compartment() + +export const reviewTooltip = ( + enabled: boolean, + editorContextMenuEnabled: boolean = false +): Extension => { + return reviewTooltipCompartment.of( + enabled ? reviewTooltipEnabled(editorContextMenuEnabled) : [] + ) +} + +export const setReviewTooltip = ( + enabled: boolean, + editorContextMenuEnabled: boolean +): TransactionSpec => ({ + effects: reviewTooltipCompartment.reconfigure( + enabled ? reviewTooltipEnabled(editorContextMenuEnabled) : [] + ), +}) + +export const reviewTooltipEnabled = ( + editorContextMenuEnabled = false +): Extension => { let mouseUpListener: null | (() => void) = null const disableMouseUpListener = () => { if (mouseUpListener) { diff --git a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts index daf7fd43e2..451efdb65b 100644 --- a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts +++ b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts @@ -65,6 +65,7 @@ import { useActiveEditorTheme } from '@/shared/hooks/use-active-editor-theme' import { useFeatureFlag } from '@/shared/context/split-test-context' import { isCmVisualEditorAvailable } from '../utils/visual-editor' import { setEditorTabs } from '../extensions/tabs-listener' +import { setReviewTooltip } from '../extensions/review-tooltip' function useCodeMirrorScope(view: EditorView) { const { fileTreeData } = useFileTreeData() @@ -93,6 +94,7 @@ function useCodeMirrorScope(view: EditorView) { editorTabs, nonBlinkingCursor, referencesSearchMode, + floatingMenu, } = userSettings const activeOverallTheme = useActiveOverallTheme() const editorTheme = useActiveEditorTheme() @@ -165,6 +167,7 @@ function useCodeMirrorScope(view: EditorView) { editorTabs, nonBlinkingCursor, referencesSearchMode, + floatingMenu, }) const currentDocRef = useRef({ @@ -481,6 +484,15 @@ function useCodeMirrorScope(view: EditorView) { }) }, [view, editorTabs]) + useEffect(() => { + settingsRef.current.floatingMenu = floatingMenu + window.setTimeout(() => { + view.dispatch( + setReviewTooltip(floatingMenu, editorContextMenuEnabledRef.current) + ) + }) + }, [view, floatingMenu]) + useEffect(() => { settingsRef.current.nonBlinkingCursor = nonBlinkingCursor window.setTimeout(() => { diff --git a/services/web/frontend/js/shared/context/user-settings-context.tsx b/services/web/frontend/js/shared/context/user-settings-context.tsx index 979516824a..4ca25d2f12 100644 --- a/services/web/frontend/js/shared/context/user-settings-context.tsx +++ b/services/web/frontend/js/shared/context/user-settings-context.tsx @@ -34,6 +34,7 @@ export const defaultSettings: UserSettings = { breadcrumbs: true, nonBlinkingCursor: false, darkModePdf: false, + floatingMenu: true, zotero: { enabled: true, groups: [], diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 1e70d952e4..e1a9175bab 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -2392,6 +2392,7 @@ "show_local_file_contents": "Show Local File Contents", "show_more": "show more", "show_outline": "Show File outline", + "show_quick_actions_on_text_selection": "Show quick actions on text selection", "show_version_history": "Show version history", "show_x_more_projects": "Show __count__ more project", "show_x_more_projects_plural": "Show __count__ more projects", 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 index 4b08dbeff0..58afd47870 100644 --- 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 @@ -3,14 +3,35 @@ 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' +import { useUserSettingsContext } from '@/shared/context/user-settings-context' + +function FloatingMenuToggle() { + const { setUserSettings } = useUserSettingsContext() + return ( + + ) +} describe('', function () { function mountEditor({ migrationEnabled, trackChangesVisible = true, + floatingMenu = true, + withSettingsToggle = false, }: { migrationEnabled: boolean trackChangesVisible?: boolean + floatingMenu?: boolean + withSettingsToggle?: boolean }) { window.metaAttributesCache.set('ol-preventCompileOnLoad', true) window.metaAttributesCache.set('ol-splitTestVariants', { @@ -23,8 +44,13 @@ describe('', function () { cy.mount( - + + {withSettingsToggle && } ) @@ -129,6 +155,64 @@ describe('', function () { }) }) + describe('floating menu setting', function () { + it('does not show the unified floating menu when the migration split test is enabled', function () { + mountEditor({ migrationEnabled: true, floatingMenu: false }) + + cy.get('.editor-floating-menu').should('not.exist') + }) + + it('does not show the legacy review tooltip when the migration split test is disabled', function () { + mountEditor({ migrationEnabled: false, floatingMenu: false }) + + cy.get('.review-tooltip-menu').should('not.exist') + }) + + it('removes the menu and its tooltip when the setting is turned off', function () { + mountEditor({ + migrationEnabled: true, + floatingMenu: true, + withSettingsToggle: true, + }) + + cy.get('.review-tooltip-menu-container').should('exist') + cy.get('.editor-floating-menu').should('exist') + + // force: the visible menu can overlap this test-only toggle button + cy.findByRole('button', { name: 'Toggle floating menu' }).click({ + scrollBehavior: false, + force: true, + }) + + cy.get('.editor-floating-menu').should('not.exist') + cy.get('.review-tooltip-menu-container').should('not.exist') + }) + + it('restores the menu when the setting is turned back on', function () { + mountEditor({ + migrationEnabled: true, + floatingMenu: false, + withSettingsToggle: true, + }) + + cy.get('.editor-floating-menu').should('not.exist') + + cy.findByRole('button', { name: 'Toggle floating menu' }).click({ + scrollBehavior: false, + force: true, + }) + + // delay: the extension reconfigures on a deferred dispatch, so spaced-out + // keystrokes ensure a selection event lands after it to rebuild the menu + cy.findByText('contentLine 12').type( + '{home}{shift}' + '{rightArrow}'.repeat(6), + { scrollBehavior: false, delay: 20 } + ) + + cy.get('.editor-floating-menu').should('exist') + }) + }) + describe('bulk tracked-change actions', function () { beforeEach(function () { mountEditorWithChanges() diff --git a/services/web/test/frontend/features/settings-modal/settings/floating-menu-setting.test.tsx b/services/web/test/frontend/features/settings-modal/settings/floating-menu-setting.test.tsx new file mode 100644 index 0000000000..11c9a53521 --- /dev/null +++ b/services/web/test/frontend/features/settings-modal/settings/floating-menu-setting.test.tsx @@ -0,0 +1,51 @@ +import { screen, render } from '@testing-library/react' +import { expect } from 'chai' +import fetchMock from 'fetch-mock' +import { EditorProviders } from '../../../helpers/editor-providers' +import { SettingsModalProvider } from '@/features/settings/context/settings-modal-context' +import FloatingMenuSetting from '@/features/settings/components/editor-settings/floating-menu-setting' + +describe('', function () { + afterEach(function () { + fetchMock.removeRoutes().clearHistory() + }) + + it('can toggle', async function () { + render( + + + + + + ) + + const saveSettingsMock = fetchMock.post( + `express:/user/settings`, + { + status: 200, + }, + { delay: 0 } + ) + + const toggle = screen.getByLabelText('Show quick actions on text selection') + const startingCheckedValue = (toggle as HTMLInputElement).checked + + // Toggle the checkbox + toggle.click() + expect((toggle as HTMLInputElement).checked).to.equal(!startingCheckedValue) + expect( + saveSettingsMock.callHistory.called(`/user/settings`, { + body: { floatingMenu: !startingCheckedValue }, + }) + ).to.be.true + + // Toggle back to original value + toggle.click() + expect((toggle as HTMLInputElement).checked).to.equal(startingCheckedValue) + expect( + saveSettingsMock.callHistory.called(`/user/settings`, { + body: { floatingMenu: startingCheckedValue }, + }) + ).to.be.true + }) +}) diff --git a/services/web/types/user-settings.ts b/services/web/types/user-settings.ts index 9021a5d7ec..4d2a9920a2 100644 --- a/services/web/types/user-settings.ts +++ b/services/web/types/user-settings.ts @@ -30,6 +30,7 @@ export type UserSettings = { editorTabs: boolean nonBlinkingCursor: boolean darkModePdf: boolean + floatingMenu: boolean zotero: RefProviderSettings mendeley: RefProviderSettings papers: RefProviderSettings