context-menu.ts 8.9 KB

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