diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx index a00e890396..072fbc1087 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx +++ b/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 diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.tsx index 64cf9a6192..b8c4dd56ab 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.tsx +++ b/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) { + if (ev.shiftKey) { + setContextMenuCoords(null) + return + } + ev.preventDefault() setContextMenuCoords({ diff --git a/services/web/frontend/js/features/source-editor/extensions/context-menu.ts b/services/web/frontend/js/features/source-editor/extensions/context-menu.ts index d8b9d24399..ce2561007a 100644 --- a/services/web/frontend/js/features/source-editor/extensions/context-menu.ts +++ b/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 } diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.tsx b/services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.tsx index edb42dea40..a5f9df4bd9 100644 --- a/services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.tsx +++ b/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, diff --git a/services/web/test/frontend/features/file-tree/flows/context-menu.spec.tsx b/services/web/test/frontend/features/file-tree/flows/context-menu.spec.tsx index 1e6b0d27a1..b76f980b1f 100644 --- a/services/web/test/frontend/features/file-tree/flows/context-menu.spec.tsx +++ b/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( + + + + ) + + 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( + + + + ) + + 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 = [ { diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx index 5ee615b4b3..63480aee0e 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx +++ b/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( + + + + + + ) + + 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( + + + + + + ) + + 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() diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-spellchecker-client.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-spellchecker-client.spec.tsx index 3677884a0c..03e94921ea 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-spellchecker-client.spec.tsx +++ b/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( + + + + + + ) + + 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)