context-menu.ts 10 KB

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