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

Merge pull request #32754 from overleaf/mg-native-context-shortcut

Allow Shift+right-click to use native browser context menu

GitOrigin-RevId: 0455283e47511e6e770d389c5cc6994018766f74
Malik Glossop 3 месяцев назад
Родитель
Сommit
99f1551e4b

+ 12 - 1
services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx

@@ -65,15 +65,26 @@ function FileTreeContextMenu() {
     keyboardInputRef.current = false
   }, [])
 
+  const handleShiftContextMenu = useCallback(
+    (event: MouseEvent) => {
+      if (event.shiftKey) {
+        setContextMenuCoords(null)
+      }
+    },
+    [setContextMenuCoords]
+  )
+
   useEffect(() => {
     document.addEventListener('keydown', handleKeyDown)
     document.addEventListener('mousedown', handleMouseDown)
+    document.addEventListener('contextmenu', handleShiftContextMenu)
 
     return () => {
       document.removeEventListener('keydown', handleKeyDown)
       document.removeEventListener('mousedown', handleMouseDown)
+      document.removeEventListener('contextmenu', handleShiftContextMenu)
     }
-  }, [handleKeyDown, handleMouseDown])
+  }, [handleKeyDown, handleMouseDown, handleShiftContextMenu])
 
   if (!contextMenuCoords || fileTreeReadOnly) return null
 

+ 5 - 0
services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.tsx

@@ -61,6 +61,11 @@ function FileTreeItemInner({
   }, [isSelected, itemRef])
 
   function handleContextMenu(ev: React.MouseEvent<HTMLDivElement>) {
+    if (ev.shiftKey) {
+      setContextMenuCoords(null)
+      return
+    }
+
     ev.preventDefault()
 
     setContextMenuCoords({

+ 19 - 2
services/web/frontend/js/features/source-editor/extensions/context-menu.ts

@@ -246,6 +246,11 @@ const gutterContextMenuPlugin = (): Extension =>
     gutters.addEventListener('contextmenu', (event: Event) => {
       const mouseEvent = event as MouseEvent
 
+      if (mouseEvent.shiftKey) {
+        update.view.dispatch({ effects: closeAllContextMenusEffect.of(null) })
+        return
+      }
+
       const pos = update.view.posAtCoords({
         x: mouseEvent.clientX,
         y: mouseEvent.clientY,
@@ -284,6 +289,11 @@ const emptyLineFillerContextMenuPlugin = (): Extension =>
         return
       }
 
+      if (mouseEvent.shiftKey) {
+        view.dispatch({ effects: closeAllContextMenusEffect.of(null) })
+        return
+      }
+
       event.preventDefault()
       event.stopPropagation()
 
@@ -293,6 +303,7 @@ const emptyLineFillerContextMenuPlugin = (): Extension =>
         cancelable: true,
         clientX: mouseEvent.clientX,
         clientY: mouseEvent.clientY,
+        shiftKey: mouseEvent.shiftKey,
       })
       contentDOM.dispatchEvent(customEvent)
     }
@@ -310,6 +321,11 @@ const emptyLineFillerContextMenuPlugin = (): Extension =>
 const editorContextMenuHandlers = (): Extension =>
   EditorView.domEventHandlers({
     contextmenu(event: MouseEvent, view: EditorView) {
+      if (event.shiftKey) {
+        view.dispatch({ effects: closeAllContextMenusEffect.of(null) })
+        return false
+      }
+
       const pos = view.posAtCoords({ x: event.clientX, y: event.clientY })
       if (pos === null) {
         return false
@@ -347,8 +363,9 @@ const editorContextMenuHandlers = (): Extension =>
         closeContextMenu(view)
       }
 
-      // Prevent default on right-click to preserve selection
-      if (isRightClick) {
+      // Prevent default on right-click to preserve selection,
+      // but not when Shift is held (native context menu shortcut)
+      if (isRightClick && !event.shiftKey) {
         event.preventDefault()
         return true
       }

+ 5 - 0
services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.tsx

@@ -45,6 +45,11 @@ const handleClickEvent = (event: MouseEvent, view: EditorView) => {
  * and show a menu of suggestions
  */
 const handleContextMenuEvent = (event: MouseEvent, view: EditorView) => {
+  if (event.shiftKey) {
+    view.dispatch({ effects: closeAllContextMenusEffect.of(null) })
+    return
+  }
+
   const position = view.posAtCoords(
     {
       x: event.pageX,

+ 80 - 0
services/web/test/frontend/features/file-tree/flows/context-menu.spec.tsx

@@ -41,6 +41,86 @@ describe('FileTree Context Menu Flow', function () {
     cy.findByRole('menu')
   })
 
+  it('should not open on Shift+right-click', function () {
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [{ _id: '456def', name: 'main.tex' }],
+        folders: [],
+        fileRefs: [],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders
+        rootFolder={rootFolder as any}
+        projectId="123abc"
+        rootDocId="456def"
+      >
+        <FileTreeRoot
+          refProviders={{}}
+          setRefProviderEnabled={cy.stub()}
+          setStartedFreeTrial={cy.stub()}
+          onSelect={cy.stub()}
+          onInit={cy.stub()}
+          isConnected
+        />
+      </EditorProviders>
+    )
+
+    cy.findByRole('menu').should('not.exist')
+    cy.findByRole('treeitem', { name: 'main.tex' }).trigger('contextmenu', {
+      button: 2,
+      shiftKey: true,
+      bubbles: true,
+      cancelable: true,
+      force: true,
+    })
+    cy.findByRole('menu').should('not.exist')
+  })
+
+  it('should close an already-open menu on Shift+right-click', function () {
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [{ _id: '456def', name: 'main.tex' }],
+        folders: [],
+        fileRefs: [],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders
+        rootFolder={rootFolder as any}
+        projectId="123abc"
+        rootDocId="456def"
+      >
+        <FileTreeRoot
+          refProviders={{}}
+          setRefProviderEnabled={cy.stub()}
+          setStartedFreeTrial={cy.stub()}
+          onSelect={cy.stub()}
+          onInit={cy.stub()}
+          isConnected
+        />
+      </EditorProviders>
+    )
+
+    cy.findByRole('treeitem', { name: 'main.tex' }).trigger('contextmenu')
+    cy.findByRole('menu').should('exist')
+
+    cy.findByRole('treeitem', { name: 'main.tex' }).trigger('contextmenu', {
+      button: 2,
+      shiftKey: true,
+      bubbles: true,
+      cancelable: true,
+      force: true,
+    })
+    cy.findByRole('menu').should('not.exist')
+  })
+
   it('closes when a new selection is started', function () {
     const rootFolder = [
       {

+ 49 - 0
services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx

@@ -119,6 +119,55 @@ describe('editor context menu', { scrollBehavior: false }, function () {
     cy.findByRole('menu').should('not.exist')
   })
 
+  it('should not open on Shift+right-click', function () {
+    const scope = mockScope()
+
+    cy.mount(
+      <TestContainer>
+        <EditorProviders scope={scope}>
+          <CodeMirrorEditor />
+        </EditorProviders>
+      </TestContainer>
+    )
+
+    cy.findByRole('menu').should('not.exist')
+
+    cy.get('.cm-line').eq(10).trigger('contextmenu', {
+      button: 2,
+      shiftKey: true,
+      bubbles: true,
+      cancelable: true,
+      force: true,
+    })
+
+    cy.findByRole('menu').should('not.exist')
+  })
+
+  it('should close an already-open menu on Shift+right-click', function () {
+    const scope = mockScope()
+
+    cy.mount(
+      <TestContainer>
+        <EditorProviders scope={scope}>
+          <CodeMirrorEditor />
+        </EditorProviders>
+      </TestContainer>
+    )
+
+    cy.get('.cm-line').eq(10).rightclick()
+    cy.findByRole('menu').should('be.visible')
+
+    cy.get('.cm-line').eq(5).trigger('contextmenu', {
+      button: 2,
+      shiftKey: true,
+      bubbles: true,
+      cancelable: true,
+      force: true,
+    })
+
+    cy.findByRole('menu').should('not.exist')
+  })
+
   it('should open on Shift+F10', { retries: 1 }, function () {
     const scope = mockScope()
 

+ 74 - 4
services/web/test/frontend/features/source-editor/components/codemirror-editor-spellchecker-client.spec.tsx

@@ -95,10 +95,7 @@ const suggestions = {
   sv: ['medecin', 'medicin'],
 }
 
-forEach(Object.keys(suggestions)).describe(
-  'Spell check in client (%s)',
-  (spellCheckLanguage: keyof typeof suggestions) => {
-    const content = `
+const spellCheckerContent = `
 \\documentclass{}
 
 \\title{}
@@ -114,6 +111,79 @@ forEach(Object.keys(suggestions)).describe(
 
 \\end{document}`
 
+describe('Spell check context menu — Shift+right-click', function () {
+  const spellCheckLanguage = 'en_GB'
+  const [misspelled] = suggestions[spellCheckLanguage]
+
+  beforeEach(function () {
+    cy.window().then(win => {
+      win.metaAttributesCache.set('ol-preventCompileOnLoad', true)
+      win.metaAttributesCache.set('ol-learnedWords', ['baz'])
+      win.metaAttributesCache.set(
+        'ol-dictionariesRoot',
+        `js/dictionaries/${PackageVersions.version.dictionaries}/`
+      )
+      win.metaAttributesCache.set('ol-baseAssetPath', '/__cypress/src/')
+      win.metaAttributesCache.set('ol-languages', languages)
+    })
+
+    cy.interceptEvents()
+
+    const scope = mockScope(spellCheckerContent)
+    const project = mockProject({ spellCheckLanguage })
+
+    cy.mount(
+      <TestContainer>
+        <EditorProviders
+          scope={scope}
+          providers={{ ProjectProvider: makeProjectProvider(project) }}
+        >
+          <CodeMirrorEditor />
+        </EditorProviders>
+      </TestContainer>
+    )
+
+    cy.get('.cm-line').eq(13).as('line')
+    cy.get('@line').click()
+    cy.get('@line').type(misspelled)
+    cy.get('@line')
+      .find('.ol-cm-spelling-error', { timeout: 10000 })
+      .should('have.length', 1)
+  })
+
+  it('should not open spelling menu on Shift+right-click', function () {
+    cy.get('@line').find('.ol-cm-spelling-error').trigger('contextmenu', {
+      button: 2,
+      shiftKey: true,
+      bubbles: true,
+      cancelable: true,
+      force: true,
+    })
+
+    cy.get('.ol-cm-spelling-context-menu-tooltip').should('not.exist')
+  })
+
+  it('should close an already-open spelling menu on Shift+right-click', function () {
+    cy.get('@line').find('.ol-cm-spelling-error').rightclick()
+    cy.get('.ol-cm-spelling-context-menu-tooltip').should('be.visible')
+
+    cy.get('@line').find('.ol-cm-spelling-error').trigger('contextmenu', {
+      button: 2,
+      shiftKey: true,
+      bubbles: true,
+      cancelable: true,
+      force: true,
+    })
+
+    cy.get('.ol-cm-spelling-context-menu-tooltip').should('not.exist')
+  })
+})
+
+forEach(Object.keys(suggestions)).describe(
+  'Spell check in client (%s)',
+  (spellCheckLanguage: keyof typeof suggestions) => {
+    const content = spellCheckerContent
+
     beforeEach(function () {
       cy.window().then(win => {
         win.metaAttributesCache.set('ol-preventCompileOnLoad', true)