context-menu.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  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),
  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(pos: number): Tooltip {
  56. return {
  57. pos,
  58. above: false,
  59. strictSide: false,
  60. arrow: false,
  61. create: createTooltipView,
  62. }
  63. }
  64. const createTooltipView = (): TooltipView => {
  65. const dom = document.createElement('div')
  66. dom.className = 'editor-context-menu-container'
  67. return { dom, overlap: true, offset: { x: 0, y: 0 } }
  68. }
  69. function isPositionInsideSelection(pos: number, from: number, to: number) {
  70. return from !== to && pos >= from && pos <= to
  71. }
  72. function isPositionInsideAnyRangeOrCursor(view: EditorView, pos: number) {
  73. for (const range of view.state.selection.ranges) {
  74. // If it's a cursor, treat a right-click anywhere on the same line as "inside".
  75. // This avoids collapsing multi-cursor selections when right-clicking on blank lines
  76. // or to the right of the caret.
  77. if (range.from === range.to) {
  78. const clickedLine = view.state.doc.lineAt(pos)
  79. const cursorLine = view.state.doc.lineAt(range.from)
  80. if (clickedLine.number === cursorLine.number) {
  81. return true
  82. }
  83. continue
  84. }
  85. if (isPositionInsideSelection(pos, range.from, range.to)) {
  86. return true
  87. }
  88. }
  89. return false
  90. }
  91. function selectEntireLine(
  92. view: EditorView,
  93. pos: number
  94. ): EditorSelection | null {
  95. if (pos === null) {
  96. return null
  97. }
  98. const line = view.state.doc.lineAt(pos)
  99. return EditorSelection.single(line.from, line.to)
  100. }
  101. function closeContextMenu(view: EditorView): void {
  102. const menuState = view.state.field(contextMenuStateField, false)
  103. if (menuState?.tooltip) {
  104. view.dispatch({ effects: closeContextMenuEffect.of(null) })
  105. }
  106. }
  107. function openContextMenuAtPosition(
  108. view: EditorView,
  109. pos: number,
  110. selection: EditorSelection | TransactionSpec['selection'],
  111. clientX: number,
  112. clientY: number
  113. ): void {
  114. view.dispatch({
  115. selection,
  116. effects: openContextMenuEffect.of({
  117. pos,
  118. x: clientX,
  119. y: clientY,
  120. }),
  121. })
  122. }
  123. function isClickOnGutter(target: HTMLElement): boolean {
  124. return !!target.closest('.cm-gutters')
  125. }
  126. // Gutter context menu plugin
  127. const gutterContextMenuPlugin = (): Extension =>
  128. EditorView.updateListener.of(update => {
  129. if (!update.view.dom.parentElement) {
  130. return
  131. }
  132. const gutters = update.view.dom.parentElement.querySelector('.cm-gutters')
  133. // Attach listener only once per editor instance
  134. if (!gutters || gutters.hasAttribute('data-context-menu-attached')) {
  135. return
  136. }
  137. gutters.setAttribute('data-context-menu-attached', 'true')
  138. gutters.addEventListener('contextmenu', (event: Event) => {
  139. const mouseEvent = event as MouseEvent
  140. event.preventDefault()
  141. const pos = update.view.posAtCoords({
  142. x: mouseEvent.clientX,
  143. y: mouseEvent.clientY,
  144. })
  145. if (pos === null) {
  146. return
  147. }
  148. const selection = selectEntireLine(update.view, pos)
  149. if (selection) {
  150. openContextMenuAtPosition(
  151. update.view,
  152. pos,
  153. selection,
  154. mouseEvent.clientX,
  155. mouseEvent.clientY
  156. )
  157. }
  158. })
  159. })
  160. // Editor view context menu handlers
  161. const editorContextMenuHandlers = (): Extension =>
  162. EditorView.domEventHandlers({
  163. contextmenu(event: MouseEvent, view: EditorView) {
  164. event.preventDefault()
  165. const pos = view.posAtCoords({ x: event.clientX, y: event.clientY })
  166. if (pos === null) {
  167. return false
  168. }
  169. const clickedInsideSelection = isPositionInsideAnyRangeOrCursor(view, pos)
  170. // Set cursor to clicked position if outside selection
  171. let selection: TransactionSpec['selection'] = { anchor: pos }
  172. if (clickedInsideSelection) {
  173. // Keep current selection if inside selection
  174. // so actions apply to the existing selection
  175. selection = view.state.selection
  176. }
  177. openContextMenuAtPosition(
  178. view,
  179. pos,
  180. selection,
  181. event.clientX,
  182. event.clientY
  183. )
  184. return true
  185. },
  186. mousedown(event: MouseEvent, view: EditorView) {
  187. const target = event.target as HTMLElement
  188. const isGutter = isClickOnGutter(target)
  189. const isRightClick = event.button === 2 || event.ctrlKey
  190. // Close menu on any click except right-click on non-gutter
  191. if (!isRightClick || isGutter) {
  192. closeContextMenu(view)
  193. }
  194. // Prevent default on right-click to preserve selection
  195. if (isRightClick) {
  196. event.preventDefault()
  197. return true
  198. }
  199. return false
  200. },
  201. })
  202. // High-priority keymap to handle Escape before default handlers
  203. const contextMenuKeymap = (): Extension =>
  204. Prec.high(
  205. keymap.of([
  206. {
  207. key: 'Escape',
  208. run: view => {
  209. const menuState = view.state.field(contextMenuStateField, false)
  210. if (menuState?.tooltip) {
  211. closeContextMenu(view)
  212. return true
  213. }
  214. return false
  215. },
  216. },
  217. ])
  218. )
  219. export const contextMenu = (enabled: boolean): Extension =>
  220. enabled
  221. ? [
  222. contextMenuContainerTheme,
  223. contextMenuStateField,
  224. gutterContextMenuPlugin(),
  225. editorContextMenuHandlers(),
  226. contextMenuKeymap(),
  227. ]
  228. : []
  229. const contextMenuContainerTheme = EditorView.baseTheme({
  230. '.editor-context-menu-container.cm-tooltip': {
  231. backgroundColor: 'transparent',
  232. border: 'none',
  233. zIndex: 100,
  234. },
  235. })