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

Merge pull request #34512 from overleaf/mj-floating-toolbar-option

[web] Add setting for floating toolbar menu

GitOrigin-RevId: bd28f1f287f2011535c7fd530f1217f01e0cfbbc
Mathias Jakobsen 1 месяц назад
Родитель
Сommit
5f79c2dd10

+ 1 - 0
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,

+ 3 - 0
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 }
   }

+ 1 - 0
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,

+ 1 - 0
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": "",

+ 7 - 0
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<React.PropsWithChildren> = ({
     setNonBlinkingCursor,
     darkModePdf,
     setDarkModePdf,
+    floatingMenu,
+    setFloatingMenu,
     zotero,
     setZotero,
     mendeley,
@@ -158,6 +161,8 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
       setNonBlinkingCursor,
       darkModePdf,
       setDarkModePdf,
+      floatingMenu,
+      setFloatingMenu,
       zotero,
       setZotero,
       mendeley,
@@ -210,6 +215,8 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
       setNonBlinkingCursor,
       darkModePdf,
       setDarkModePdf,
+      floatingMenu,
+      setFloatingMenu,
       zotero,
       setZotero,
       mendeley,

+ 10 - 0
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,

+ 18 - 0
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 (
+    <ToggleSetting
+      id="floatingMenu"
+      label={t('show_quick_actions_on_text_selection')}
+      description=""
+      checked={floatingMenu}
+      onChange={setFloatingMenu}
+    />
+  )
+}

+ 10 - 1
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<React.PropsWithChildren> = ({
 }) => {
   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<React.PropsWithChildren> = ({
                 component: <ReferenceSearchSetting />,
                 hidden: !ReferenceSearchSetting,
               },
+              {
+                key: 'floating-menu',
+                component: <FloatingMenuSetting />,
+                hidden: !hasToolbarMigration && floatingMenu,
+              },
             ],
           },
           {
@@ -290,6 +297,8 @@ export const SettingsModalProvider: FC<React.PropsWithChildren> = ({
       isOverleaf,
       editorTabExtraSections,
       spellcheckExtraSections,
+      hasToolbarMigration,
+      floatingMenu,
     ]
   )
 

+ 6 - 1
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({
 
       <MathPreviewTooltip />
       <EditorContextMenu />
-      {features.trackChangesVisible &&
+      {floatingMenu &&
+        features.trackChangesVisible &&
         (writefullToolbarMigrationEnabled ? (
           <>
             <AddCommentCommand />

+ 4 - 1
services/web/frontend/js/features/source-editor/extensions/index.ts

@@ -159,7 +159,10 @@ export const createExtensions = (options: Record<string, any>): 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(),

+ 25 - 1
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) {

+ 12 - 0
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(() => {

+ 1 - 0
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: [],

+ 1 - 0
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",

+ 85 - 1
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 (
+    <button
+      onClick={() =>
+        setUserSettings(settings => ({
+          ...settings,
+          floatingMenu: !settings.floatingMenu,
+        }))
+      }
+    >
+      Toggle floating menu
+    </button>
+  )
+}
 
 describe('<EditorFloatingMenu />', 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('<EditorFloatingMenu />', function () {
 
     cy.mount(
       <TestContainer>
-        <EditorProviders scope={scope} features={{ trackChangesVisible }}>
+        <EditorProviders
+          scope={scope}
+          features={{ trackChangesVisible }}
+          userSettings={{ floatingMenu }}
+        >
           <CodeMirrorEditor />
+          {withSettingsToggle && <FloatingMenuToggle />}
         </EditorProviders>
       </TestContainer>
     )
@@ -129,6 +155,64 @@ describe('<EditorFloatingMenu />', 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()

+ 51 - 0
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('<FloatingMenuSetting />', function () {
+  afterEach(function () {
+    fetchMock.removeRoutes().clearHistory()
+  })
+
+  it('can toggle', async function () {
+    render(
+      <EditorProviders>
+        <SettingsModalProvider>
+          <FloatingMenuSetting />
+        </SettingsModalProvider>
+      </EditorProviders>
+    )
+
+    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
+  })
+})

+ 1 - 0
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