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

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

GitOrigin-RevId: 0455283e47511e6e770d389c5cc6994018766f74
This commit is contained in:
Malik Glossop
2026-04-20 15:56:32 +02:00
committed by Copybot
parent 1110172597
commit 99f1551e4b
7 changed files with 244 additions and 7 deletions
@@ -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
@@ -61,6 +61,11 @@ function FileTreeItemInner({
}, [isSelected, itemRef])
function handleContextMenu(ev: React.MouseEvent<HTMLDivElement>) {
if (ev.shiftKey) {
setContextMenuCoords(null)
return
}
ev.preventDefault()
setContextMenuCoords({
@@ -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
}
@@ -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,
@@ -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 = [
{
@@ -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()
@@ -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)