context-menu.ts 9.3 KB

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