context-menu.ts 11 KB

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