Merge pull request #30168 from overleaf/mg-context-menu

Add context menu (right click) to editor

GitOrigin-RevId: f2e567b51b04170ba1a46b0ab4659f3481b05bfe
This commit is contained in:
Malik Glossop
2026-01-15 14:18:58 +00:00
committed by Copybot
parent 5e38efb6b2
commit f087d125c1
21 changed files with 1722 additions and 35 deletions
@@ -0,0 +1,249 @@
import {
EditorView,
showTooltip,
Tooltip,
TooltipView,
keymap,
} from '@codemirror/view'
import {
Extension,
StateField,
StateEffect,
TransactionSpec,
EditorSelection,
Prec,
} from '@codemirror/state'
export const openContextMenuEffect = StateEffect.define<{
pos: number
x: number
y: number
}>()
export const closeContextMenuEffect = StateEffect.define()
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) {
// Process state effects to open/close menu
for (const effect of tr.effects) {
if (effect.is(openContextMenuEffect)) {
const { pos, x, y } = effect.value
return {
tooltip: buildContextMenuTooltip(pos),
mousePosition: { x, y },
}
}
if (effect.is(closeContextMenuEffect)) {
return { tooltip: null, mousePosition: null }
}
}
// 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): Tooltip {
return {
pos,
above: false,
strictSide: false,
arrow: false,
create: createTooltipView,
}
}
const createTooltipView = (): TooltipView => {
const dom = document.createElement('div')
dom.className = 'editor-context-menu-container'
return { dom, overlap: true, offset: { x: 0, y: 0 } }
}
function isPositionInsideSelection(pos: number, from: number, to: number) {
return from !== to && pos >= from && pos <= to
}
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: openContextMenuEffect.of({
pos,
x: clientX,
y: clientY,
}),
})
}
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
event.preventDefault()
const pos = update.view.posAtCoords({
x: mouseEvent.clientX,
y: mouseEvent.clientY,
})
if (pos === null) {
return
}
const selection = selectEntireLine(update.view, pos)
if (selection) {
openContextMenuAtPosition(
update.view,
pos,
selection,
mouseEvent.clientX,
mouseEvent.clientY
)
}
})
})
// Editor view context menu handlers
const editorContextMenuHandlers = (): Extension =>
EditorView.domEventHandlers({
contextmenu(event: MouseEvent, view: EditorView) {
event.preventDefault()
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY })
if (pos === null) {
return false
}
const { from, to } = view.state.selection.main
const clickedInsideSelection = isPositionInsideSelection(pos, from, to)
// 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
if (isRightClick) {
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
},
},
])
)
export const contextMenu = (enabled: boolean): Extension =>
enabled
? [
contextMenuContainerTheme,
contextMenuStateField,
gutterContextMenuPlugin(),
editorContextMenuHandlers(),
contextMenuKeymap(),
]
: []
const contextMenuContainerTheme = EditorView.baseTheme({
'.editor-context-menu-container.cm-tooltip': {
backgroundColor: 'transparent',
border: 'none',
zIndex: 100,
},
})
@@ -8,6 +8,10 @@ import { EditorView } from '@codemirror/view'
import { addEffectListener, removeEffectListener } from './effect-listeners'
import { setMetadataEffect } from './language'
import { debugConsole } from '@/utils/debugging'
import {
dispatchFigureModalPasteEvent,
isAllowedImageType,
} from '../utils/paste-image'
type NestedReadonly<T> = {
readonly [P in keyof T]: NestedReadonly<T[P]>
@@ -160,18 +164,6 @@ export function waitForFileTreeUpdate(view: EditorView) {
}
}
const ALLOWED_MIME_TYPES = new Set([
'image/jpeg',
'image/png',
'application/pdf',
])
export type PastedImageData = {
name: string
type: string
data: Blob
}
export const figureModalPasteHandler = (): Extension => {
return EditorView.domEventHandlers({
drop: evt => {
@@ -179,18 +171,14 @@ export const figureModalPasteHandler = (): Extension => {
return
}
const file = evt.dataTransfer.files[0]
if (!ALLOWED_MIME_TYPES.has(file.type)) {
if (!isAllowedImageType(file.type)) {
return
}
window.dispatchEvent(
new CustomEvent<PastedImageData>('figure-modal:paste-image', {
detail: {
name: file.name,
type: file.type,
data: file,
},
})
)
dispatchFigureModalPasteEvent({
name: file.name,
type: file.type,
data: file,
})
},
paste: evt => {
if (!evt.clipboardData || evt.clipboardData.files.length === 0) {
@@ -200,18 +188,14 @@ export const figureModalPasteHandler = (): Extension => {
return // allow pasted text to be handled even if there's also a file on the clipboard
}
const file = evt.clipboardData.files[0]
if (!ALLOWED_MIME_TYPES.has(file.type)) {
if (!isAllowedImageType(file.type)) {
return
}
window.dispatchEvent(
new CustomEvent<PastedImageData>('figure-modal:paste-image', {
detail: {
name: file.name,
type: file.type,
data: file,
},
})
)
dispatchFigureModalPasteEvent({
name: file.name,
type: file.type,
data: file,
})
},
})
}
@@ -55,6 +55,7 @@ import { trackDetachedComments } from './track-detached-comments'
import { reviewTooltip } from './review-tooltip'
import { tooltipsReposition } from './tooltips-reposition'
import { selectionListener } from '@/features/source-editor/extensions/selection-listener'
import { contextMenu } from './context-menu'
const moduleExtensions: Array<(options: Record<string, any>) => Extension> =
importOverleafModules('sourceEditorExtensions').map(
@@ -156,6 +157,7 @@ export const createExtensions = (options: Record<string, any>): Extension[] => [
visual(options.visual),
mathPreview(options.settings.mathPreview),
reviewTooltip(),
contextMenu(options.editorContextMenuEnabled),
toolbarPanel(),
breadcrumbPanel(),
verticalOverflow(),
@@ -166,3 +166,14 @@ export const toggleSearch: Command = view => {
export const addComment = () => {
window.dispatchEvent(new Event('add-new-review-comment'))
}
export const deleteSelection: Command = view => {
const { from, to } = view.state.selection.main
if (from === to) return false
view.dispatch({
changes: { from, to, insert: '' },
selection: { anchor: from },
})
return true
}