context-menu.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. import {
  2. EditorView,
  3. showTooltip,
  4. Tooltip,
  5. TooltipView,
  6. keymap,
  7. ViewPlugin,
  8. } from '@codemirror/view'
  9. import {
  10. Extension,
  11. StateField,
  12. StateEffect,
  13. TransactionSpec,
  14. EditorSelection,
  15. Prec,
  16. Annotation,
  17. } from '@codemirror/state'
  18. import { closeAllContextMenusEffect } from '../utils/close-all-context-menus-effect'
  19. import { isContextMenuMouseEvent } from '../utils/context-menu-mouse-event'
  20. export const openContextMenuEffect = StateEffect.define<{
  21. pos: number
  22. x: number
  23. y: number
  24. }>()
  25. export const closeContextMenuEffect = StateEffect.define()
  26. export const openContextMenuAnnotation = Annotation.define<boolean>()
  27. const isTouchOnlyInput =
  28. typeof window.matchMedia === 'function' &&
  29. window.matchMedia('(pointer: coarse)').matches &&
  30. window.matchMedia('(hover: none)').matches
  31. type ContextMenuState = {
  32. tooltip: Tooltip | null
  33. mousePosition: { x: number; y: number } | null
  34. }
  35. export const contextMenuStateField = StateField.define<ContextMenuState>({
  36. create() {
  37. return { tooltip: null, mousePosition: null }
  38. },
  39. update(field, tr) {
  40. let next = field
  41. // Process effects in order but let open win if present in the same transaction
  42. for (const effect of tr.effects) {
  43. if (
  44. effect.is(closeContextMenuEffect) ||
  45. effect.is(closeAllContextMenusEffect)
  46. ) {
  47. next = { tooltip: null, mousePosition: null }
  48. }
  49. if (effect.is(openContextMenuEffect)) {
  50. const { pos, x, y } = effect.value
  51. return {
  52. tooltip: buildContextMenuTooltip(pos, { x, y }),
  53. mousePosition: { x, y },
  54. }
  55. }
  56. }
  57. // If effects changed the state, return early so doc-change fallback doesn’t override it
  58. if (next !== field) {
  59. return next
  60. }
  61. // Close menu on document changes
  62. if (tr.docChanged && field.tooltip) {
  63. return { tooltip: null, mousePosition: null }
  64. }
  65. return field
  66. },
  67. // Connect state field to tooltip system
  68. provide: field => [
  69. showTooltip.compute([field], state => state.field(field).tooltip),
  70. ],
  71. })
  72. function buildContextMenuTooltip(
  73. pos: number,
  74. mousePosition: { x: number; y: number }
  75. ): Tooltip {
  76. return {
  77. pos,
  78. above: false,
  79. strictSide: false,
  80. arrow: false,
  81. create: () => createTooltipView(mousePosition),
  82. }
  83. }
  84. const createTooltipView = (mousePosition: {
  85. x: number
  86. y: number
  87. }): TooltipView => {
  88. const dom = document.createElement('div')
  89. dom.className = 'editor-context-menu-container'
  90. // Watch for size changes and reposition accordingly
  91. const resizeObserver = new ResizeObserver(() => {
  92. requestAnimationFrame(() => positionMenu(dom, mousePosition))
  93. })
  94. resizeObserver.observe(dom)
  95. return {
  96. dom,
  97. overlap: true,
  98. offset: { x: 0, y: 0 },
  99. destroy() {
  100. resizeObserver.disconnect()
  101. },
  102. }
  103. }
  104. function positionMenu(
  105. dom: HTMLElement,
  106. mousePosition: { x: number; y: number }
  107. ) {
  108. const bounds = dom.getBoundingClientRect()
  109. // Wait for menu to render
  110. if (bounds.width === 0 || bounds.height === 0) {
  111. return
  112. }
  113. const viewportWidth = window.innerWidth
  114. const viewportHeight = window.innerHeight
  115. const y = mousePosition.y
  116. // Adjust horizontal position if menu would overflow right edge
  117. let left = mousePosition.x
  118. if (mousePosition.x + bounds.width > viewportWidth) {
  119. left = viewportWidth - bounds.width
  120. }
  121. dom.style.setProperty('--context-menu-left', `${left}px`)
  122. const spaceBelow = viewportHeight - y
  123. let top = y
  124. if (bounds.height > spaceBelow) {
  125. // Show above if menu won't fit below
  126. top = y - bounds.height
  127. }
  128. dom.style.setProperty('--context-menu-top', `${top}px`)
  129. }
  130. function isPositionInsideSelection(pos: number, from: number, to: number) {
  131. return from !== to && pos >= from && pos <= to
  132. }
  133. function isPositionInsideAnyRangeOrCursor(view: EditorView, pos: number) {
  134. for (const range of view.state.selection.ranges) {
  135. // If it's a cursor, treat a right-click anywhere on the same line as "inside".
  136. // This avoids collapsing multi-cursor selections when right-clicking on blank lines
  137. // or to the right of the caret.
  138. if (range.from === range.to) {
  139. const clickedLine = view.state.doc.lineAt(pos)
  140. const cursorLine = view.state.doc.lineAt(range.from)
  141. if (clickedLine.number === cursorLine.number) {
  142. return true
  143. }
  144. continue
  145. }
  146. if (isPositionInsideSelection(pos, range.from, range.to)) {
  147. return true
  148. }
  149. }
  150. return false
  151. }
  152. function selectEntireLine(
  153. view: EditorView,
  154. pos: number
  155. ): EditorSelection | null {
  156. if (pos === null) {
  157. return null
  158. }
  159. const line = view.state.doc.lineAt(pos)
  160. return EditorSelection.single(line.from, line.to)
  161. }
  162. function closeContextMenu(view: EditorView): void {
  163. const menuState = view.state.field(contextMenuStateField, false)
  164. if (menuState?.tooltip) {
  165. view.dispatch({ effects: closeContextMenuEffect.of(null) })
  166. }
  167. }
  168. function openContextMenuAtPosition(
  169. view: EditorView,
  170. pos: number,
  171. selection: EditorSelection | TransactionSpec['selection'],
  172. clientX: number,
  173. clientY: number
  174. ): void {
  175. view.dispatch({
  176. selection,
  177. effects: [
  178. closeAllContextMenusEffect.of(null),
  179. openContextMenuEffect.of({
  180. pos,
  181. x: clientX,
  182. y: clientY,
  183. }),
  184. ],
  185. annotations: [openContextMenuAnnotation.of(true)],
  186. })
  187. }
  188. function openContextMenuAtSelection(view: EditorView): boolean {
  189. const { main } = view.state.selection
  190. const pos = main.head
  191. const coords = view.coordsAtPos(pos)
  192. if (!coords) {
  193. return false
  194. }
  195. // Keep the current selection; actions should apply to it
  196. const selection = view.state.selection
  197. openContextMenuAtPosition(view, pos, selection, coords.left, coords.top)
  198. return true
  199. }
  200. function isClickOnGutter(target: HTMLElement): boolean {
  201. return !!target.closest('.cm-gutters')
  202. }
  203. // Gutter context menu plugin
  204. const gutterContextMenuPlugin = (): Extension =>
  205. EditorView.updateListener.of(update => {
  206. if (isTouchOnlyInput || !update.view.dom.parentElement) {
  207. return
  208. }
  209. const gutters = update.view.dom.parentElement.querySelector('.cm-gutters')
  210. // Attach listener only once per editor instance
  211. if (!gutters || gutters.hasAttribute('data-context-menu-attached')) {
  212. return
  213. }
  214. gutters.setAttribute('data-context-menu-attached', 'true')
  215. gutters.addEventListener('contextmenu', (event: Event) => {
  216. const mouseEvent = event as MouseEvent
  217. const pos = update.view.posAtCoords({
  218. x: mouseEvent.clientX,
  219. y: mouseEvent.clientY,
  220. })
  221. if (pos === null) {
  222. return
  223. }
  224. event.preventDefault()
  225. const selection = selectEntireLine(update.view, pos)
  226. if (selection) {
  227. openContextMenuAtPosition(
  228. update.view,
  229. pos,
  230. selection,
  231. mouseEvent.clientX,
  232. mouseEvent.clientY
  233. )
  234. }
  235. })
  236. })
  237. // Handle right-click on ol-cm-filler (empty line widget)
  238. // domEventHandlers doesn't fire for contenteditable="false" elements, so we use a direct DOM listener
  239. const emptyLineFillerContextMenuPlugin = (): Extension => {
  240. if (isTouchOnlyInput) {
  241. return []
  242. }
  243. return ViewPlugin.define(view => {
  244. const contentDOM = view.contentDOM
  245. const handleContextMenu = (event: Event) => {
  246. const mouseEvent = event as MouseEvent
  247. const target = mouseEvent.target as HTMLElement
  248. // Only handle ol-cm-filler elements
  249. if (!target.classList.contains('ol-cm-filler')) {
  250. return
  251. }
  252. event.preventDefault()
  253. event.stopPropagation()
  254. // Re-dispatch on contentDOM so CodeMirror's domEventHandlers picks it up
  255. const customEvent = new MouseEvent('contextmenu', {
  256. bubbles: true,
  257. cancelable: true,
  258. clientX: mouseEvent.clientX,
  259. clientY: mouseEvent.clientY,
  260. })
  261. contentDOM.dispatchEvent(customEvent)
  262. }
  263. contentDOM.addEventListener('contextmenu', handleContextMenu)
  264. return {
  265. destroy() {
  266. contentDOM.removeEventListener('contextmenu', handleContextMenu)
  267. },
  268. }
  269. })
  270. }
  271. // Editor view context menu handlers
  272. const editorContextMenuHandlers = (): Extension =>
  273. EditorView.domEventHandlers({
  274. contextmenu(event: MouseEvent, view: EditorView) {
  275. if (isTouchOnlyInput) {
  276. return false
  277. }
  278. const pos = view.posAtCoords({ x: event.clientX, y: event.clientY })
  279. if (pos === null) {
  280. return false
  281. }
  282. event.preventDefault()
  283. const clickedInsideSelection = isPositionInsideAnyRangeOrCursor(view, pos)
  284. // Set cursor to clicked position if outside selection
  285. let selection: TransactionSpec['selection'] = { anchor: pos }
  286. if (clickedInsideSelection) {
  287. // Keep current selection if inside selection
  288. // so actions apply to the existing selection
  289. selection = view.state.selection
  290. }
  291. openContextMenuAtPosition(
  292. view,
  293. pos,
  294. selection,
  295. event.clientX,
  296. event.clientY
  297. )
  298. return true
  299. },
  300. mousedown(event: MouseEvent, view: EditorView) {
  301. const target = event.target as HTMLElement
  302. const isGutter = isClickOnGutter(target)
  303. const isRightClick = isContextMenuMouseEvent(event)
  304. // Close menu on any click except right-click on non-gutter
  305. if (!isRightClick || isGutter) {
  306. closeContextMenu(view)
  307. }
  308. // Prevent default on right-click to preserve selection
  309. // But not on touch devices - they need native selection behavior
  310. if (isRightClick && !isTouchOnlyInput) {
  311. event.preventDefault()
  312. return true
  313. }
  314. return false
  315. },
  316. })
  317. // High-priority keymap to handle Escape before default handlers
  318. const contextMenuKeymap = (): Extension =>
  319. Prec.high(
  320. keymap.of([
  321. {
  322. key: 'Escape',
  323. run: view => {
  324. const menuState = view.state.field(contextMenuStateField, false)
  325. if (menuState?.tooltip) {
  326. closeContextMenu(view)
  327. return true
  328. }
  329. return false
  330. },
  331. },
  332. {
  333. key: 'Shift-F10',
  334. // Accessibility standard shortcut to open context menu
  335. run: view => openContextMenuAtSelection(view),
  336. },
  337. ])
  338. )
  339. export const contextMenu = (enabled: boolean): Extension =>
  340. enabled
  341. ? [
  342. contextMenuContainerTheme,
  343. contextMenuStateField,
  344. gutterContextMenuPlugin(),
  345. emptyLineFillerContextMenuPlugin(),
  346. editorContextMenuHandlers(),
  347. contextMenuKeymap(),
  348. ]
  349. : []
  350. const contextMenuContainerTheme = EditorView.baseTheme({
  351. '.editor-context-menu-container.cm-tooltip': {
  352. backgroundColor: 'transparent',
  353. border: 'none',
  354. zIndex: 100,
  355. position: 'fixed !important',
  356. top: 'var(--context-menu-top) !important',
  357. left: 'var(--context-menu-left) !important',
  358. },
  359. })