context-menu.ts 8.5 KB

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