Files
overleaf-cep/services/web/frontend/js/features/source-editor/extensions/context-menu.ts
Malik Glossop daa7b52ef1 Merge pull request #31572 from overleaf/mg-context-menu-non-editable
Show context menu for empty line filler elements

GitOrigin-RevId: 9becb697e61130a623a9a00bccae015b927760a2
2026-02-19 09:05:59 +00:00

413 lines
10 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,
} from '@codemirror/state'
import { closeAllContextMenusEffect } from '../utils/close-all-context-menus-effect'
export const openContextMenuEffect = StateEffect.define<{
pos: number
x: number
y: number
}>()
export const closeContextMenuEffect = StateEffect.define()
const isTouchOnlyInput =
typeof window.matchMedia === 'function' &&
window.matchMedia('(pointer: coarse)').matches &&
window.matchMedia('(hover: none)').matches
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, treat a right-click anywhere on the same line as "inside".
// This avoids collapsing multi-cursor selections when right-clicking on blank lines
// or to the right of the caret.
if (range.from === range.to) {
const clickedLine = view.state.doc.lineAt(pos)
const cursorLine = view.state.doc.lineAt(range.from)
if (clickedLine.number === cursorLine.number) {
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,
}),
],
})
}
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 (isTouchOnlyInput || !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
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 => {
if (isTouchOnlyInput) {
return []
}
return 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
}
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,
})
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 (isTouchOnlyInput) {
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 = event.button === 2 || event.ctrlKey
// 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 on touch devices - they need native selection behavior
if (isRightClick && !isTouchOnlyInput) {
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
? [
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',
},
})