| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370 |
- import {
- EditorView,
- showTooltip,
- Tooltip,
- TooltipView,
- keymap,
- } 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 doesn’t 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 (!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 (isTouchOnlyInput) {
- 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
- )
- }
- })
- })
- // 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(),
- 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',
- },
- })
|