Files
overleaf-cep/services/web/frontend/js/features/source-editor/extensions/context-menu.ts
Malik Glossop 99f1551e4b Merge pull request #32754 from overleaf/mg-native-context-shortcut
Allow Shift+right-click to use native browser context menu

GitOrigin-RevId: 0455283e47511e6e770d389c5cc6994018766f74
2026-04-21 08:06:19 +00:00

421 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
EditorView,
showTooltip,
Tooltip,
TooltipView,
keymap,
ViewPlugin,
} from '@codemirror/view'
import {
Extension,
StateField,
StateEffect,
TransactionSpec,
EditorSelection,
Prec,
Annotation,
} from '@codemirror/state'
import { closeAllContextMenusEffect } from '../utils/close-all-context-menus-effect'
import { isContextMenuMouseEvent } from '../utils/context-menu-mouse-event'
import { isMobileDevice } from '../utils/isMobileDevice'
const isMobile = isMobileDevice()
export const openContextMenuEffect = StateEffect.define<{
pos: number
x: number
y: number
}>()
export const closeContextMenuEffect = StateEffect.define()
export const openContextMenuAnnotation = Annotation.define<boolean>()
type ContextMenuState = {
tooltip: Tooltip | null
mousePosition: { x: number; y: number } | null
}
export const contextMenuStateField = StateField.define<ContextMenuState>({
create() {
return { tooltip: null, mousePosition: null }
},
update(field, tr) {
let next = field
// Process effects in order but let open win if present in the same transaction
for (const effect of tr.effects) {
if (
effect.is(closeContextMenuEffect) ||
effect.is(closeAllContextMenusEffect)
) {
next = { tooltip: null, mousePosition: null }
}
if (effect.is(openContextMenuEffect)) {
const { pos, x, y } = effect.value
return {
tooltip: buildContextMenuTooltip(pos, { x, y }),
mousePosition: { x, y },
}
}
}
// If effects changed the state, return early so doc-change fallback doesnt override it
if (next !== field) {
return next
}
// Close menu on document changes
if (tr.docChanged && field.tooltip) {
return { tooltip: null, mousePosition: null }
}
return field
},
// Connect state field to tooltip system
provide: field => [
showTooltip.compute([field], state => state.field(field).tooltip),
],
})
function buildContextMenuTooltip(
pos: number,
mousePosition: { x: number; y: number }
): Tooltip {
return {
pos,
above: false,
strictSide: false,
arrow: false,
create: () => createTooltipView(mousePosition),
}
}
const createTooltipView = (mousePosition: {
x: number
y: number
}): TooltipView => {
const dom = document.createElement('div')
dom.className = 'editor-context-menu-container'
// Watch for size changes and reposition accordingly
const resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => positionMenu(dom, mousePosition))
})
resizeObserver.observe(dom)
return {
dom,
overlap: true,
offset: { x: 0, y: 0 },
destroy() {
resizeObserver.disconnect()
},
}
}
function positionMenu(
dom: HTMLElement,
mousePosition: { x: number; y: number }
) {
const bounds = dom.getBoundingClientRect()
// Wait for menu to render
if (bounds.width === 0 || bounds.height === 0) {
return
}
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const y = mousePosition.y
// Adjust horizontal position if menu would overflow right edge
let left = mousePosition.x
if (mousePosition.x + bounds.width > viewportWidth) {
left = viewportWidth - bounds.width
}
dom.style.setProperty('--context-menu-left', `${left}px`)
const spaceBelow = viewportHeight - y
let top = y
if (bounds.height > spaceBelow) {
// Show above if menu won't fit below
top = y - bounds.height
}
dom.style.setProperty('--context-menu-top', `${top}px`)
}
function isPositionInsideSelection(pos: number, from: number, to: number) {
return from !== to && pos >= from && pos <= to
}
function isPositionInsideAnyRangeOrCursor(view: EditorView, pos: number) {
for (const range of view.state.selection.ranges) {
// If it's a cursor (not a selection), only treat it as "inside" when
// right-clicking exactly on the cursor position. This allows cursor
// movement when clicking elsewhere on the same line.
if (range.from === range.to) {
if (pos === range.from) {
return true
}
continue
}
if (isPositionInsideSelection(pos, range.from, range.to)) {
return true
}
}
return false
}
function selectEntireLine(
view: EditorView,
pos: number
): EditorSelection | null {
if (pos === null) {
return null
}
const line = view.state.doc.lineAt(pos)
return EditorSelection.single(line.from, line.to)
}
function closeContextMenu(view: EditorView): void {
const menuState = view.state.field(contextMenuStateField, false)
if (menuState?.tooltip) {
view.dispatch({ effects: closeContextMenuEffect.of(null) })
}
}
function openContextMenuAtPosition(
view: EditorView,
pos: number,
selection: EditorSelection | TransactionSpec['selection'],
clientX: number,
clientY: number
): void {
view.dispatch({
selection,
effects: [
closeAllContextMenusEffect.of(null),
openContextMenuEffect.of({
pos,
x: clientX,
y: clientY,
}),
],
annotations: [openContextMenuAnnotation.of(true)],
})
}
function openContextMenuAtSelection(view: EditorView): boolean {
const { main } = view.state.selection
const pos = main.head
const coords = view.coordsAtPos(pos)
if (!coords) {
return false
}
// Keep the current selection; actions should apply to it
const selection = view.state.selection
openContextMenuAtPosition(view, pos, selection, coords.left, coords.top)
return true
}
function isClickOnGutter(target: HTMLElement): boolean {
return !!target.closest('.cm-gutters')
}
// Gutter context menu plugin
const gutterContextMenuPlugin = (): Extension =>
EditorView.updateListener.of(update => {
if (!update.view.dom.parentElement) {
return
}
const gutters = update.view.dom.parentElement.querySelector('.cm-gutters')
// Attach listener only once per editor instance
if (!gutters || gutters.hasAttribute('data-context-menu-attached')) {
return
}
gutters.setAttribute('data-context-menu-attached', 'true')
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,
})
if (pos === null) {
return
}
event.preventDefault()
const selection = selectEntireLine(update.view, pos)
if (selection) {
openContextMenuAtPosition(
update.view,
pos,
selection,
mouseEvent.clientX,
mouseEvent.clientY
)
}
})
})
// Handle right-click on ol-cm-filler (empty line widget)
// domEventHandlers doesn't fire for contenteditable="false" elements, so we use a direct DOM listener
const emptyLineFillerContextMenuPlugin = (): Extension =>
ViewPlugin.define(view => {
const contentDOM = view.contentDOM
const handleContextMenu = (event: Event) => {
const mouseEvent = event as MouseEvent
const target = mouseEvent.target as HTMLElement
// Only handle ol-cm-filler elements
if (!target.classList.contains('ol-cm-filler')) {
return
}
if (mouseEvent.shiftKey) {
view.dispatch({ effects: closeAllContextMenusEffect.of(null) })
return
}
event.preventDefault()
event.stopPropagation()
// Re-dispatch on contentDOM so CodeMirror's domEventHandlers picks it up
const customEvent = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
clientX: mouseEvent.clientX,
clientY: mouseEvent.clientY,
shiftKey: mouseEvent.shiftKey,
})
contentDOM.dispatchEvent(customEvent)
}
contentDOM.addEventListener('contextmenu', handleContextMenu)
return {
destroy() {
contentDOM.removeEventListener('contextmenu', handleContextMenu)
},
}
})
// Editor view context menu handlers
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
}
event.preventDefault()
const clickedInsideSelection = isPositionInsideAnyRangeOrCursor(view, pos)
// Set cursor to clicked position if outside selection
let selection: TransactionSpec['selection'] = { anchor: pos }
if (clickedInsideSelection) {
// Keep current selection if inside selection
// so actions apply to the existing selection
selection = view.state.selection
}
openContextMenuAtPosition(
view,
pos,
selection,
event.clientX,
event.clientY
)
return true
},
mousedown(event: MouseEvent, view: EditorView) {
const target = event.target as HTMLElement
const isGutter = isClickOnGutter(target)
const isRightClick = isContextMenuMouseEvent(event)
// Close menu on any click except right-click on non-gutter
if (!isRightClick || isGutter) {
closeContextMenu(view)
}
// 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
}
return false
},
})
// High-priority keymap to handle Escape before default handlers
const contextMenuKeymap = (): Extension =>
Prec.high(
keymap.of([
{
key: 'Escape',
run: view => {
const menuState = view.state.field(contextMenuStateField, false)
if (menuState?.tooltip) {
closeContextMenu(view)
return true
}
return false
},
},
{
key: 'Shift-F10',
// Accessibility standard shortcut to open context menu
run: view => openContextMenuAtSelection(view),
},
])
)
export const contextMenu = (enabled: boolean): Extension =>
enabled && !isMobile
? [
contextMenuContainerTheme,
contextMenuStateField,
gutterContextMenuPlugin(),
emptyLineFillerContextMenuPlugin(),
editorContextMenuHandlers(),
contextMenuKeymap(),
]
: []
const contextMenuContainerTheme = EditorView.baseTheme({
'.editor-context-menu-container.cm-tooltip': {
backgroundColor: 'transparent',
border: 'none',
zIndex: 100,
position: 'fixed !important',
top: 'var(--context-menu-top) !important',
left: 'var(--context-menu-left) !important',
},
})