context-menu.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  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 selectEntireLine(
  73. view: EditorView,
  74. pos: number
  75. ): EditorSelection | null {
  76. if (pos === null) {
  77. return null
  78. }
  79. const line = view.state.doc.lineAt(pos)
  80. return EditorSelection.single(line.from, line.to)
  81. }
  82. function closeContextMenu(view: EditorView): void {
  83. const menuState = view.state.field(contextMenuStateField, false)
  84. if (menuState?.tooltip) {
  85. view.dispatch({ effects: closeContextMenuEffect.of(null) })
  86. }
  87. }
  88. function openContextMenuAtPosition(
  89. view: EditorView,
  90. pos: number,
  91. selection: EditorSelection | TransactionSpec['selection'],
  92. clientX: number,
  93. clientY: number
  94. ): void {
  95. view.dispatch({
  96. selection,
  97. effects: openContextMenuEffect.of({
  98. pos,
  99. x: clientX,
  100. y: clientY,
  101. }),
  102. })
  103. }
  104. function isClickOnGutter(target: HTMLElement): boolean {
  105. return !!target.closest('.cm-gutters')
  106. }
  107. // Gutter context menu plugin
  108. const gutterContextMenuPlugin = (): Extension =>
  109. EditorView.updateListener.of(update => {
  110. if (!update.view.dom.parentElement) {
  111. return
  112. }
  113. const gutters = update.view.dom.parentElement.querySelector('.cm-gutters')
  114. // Attach listener only once per editor instance
  115. if (!gutters || gutters.hasAttribute('data-context-menu-attached')) {
  116. return
  117. }
  118. gutters.setAttribute('data-context-menu-attached', 'true')
  119. gutters.addEventListener('contextmenu', (event: Event) => {
  120. const mouseEvent = event as MouseEvent
  121. event.preventDefault()
  122. const pos = update.view.posAtCoords({
  123. x: mouseEvent.clientX,
  124. y: mouseEvent.clientY,
  125. })
  126. if (pos === null) {
  127. return
  128. }
  129. const selection = selectEntireLine(update.view, pos)
  130. if (selection) {
  131. openContextMenuAtPosition(
  132. update.view,
  133. pos,
  134. selection,
  135. mouseEvent.clientX,
  136. mouseEvent.clientY
  137. )
  138. }
  139. })
  140. })
  141. // Editor view context menu handlers
  142. const editorContextMenuHandlers = (): Extension =>
  143. EditorView.domEventHandlers({
  144. contextmenu(event: MouseEvent, view: EditorView) {
  145. event.preventDefault()
  146. const pos = view.posAtCoords({ x: event.clientX, y: event.clientY })
  147. if (pos === null) {
  148. return false
  149. }
  150. const { from, to } = view.state.selection.main
  151. const clickedInsideSelection = isPositionInsideSelection(pos, from, to)
  152. // Set cursor to clicked position if outside selection
  153. let selection: TransactionSpec['selection'] = { anchor: pos }
  154. if (clickedInsideSelection) {
  155. // Keep current selection if inside selection
  156. // so actions apply to the existing selection
  157. selection = view.state.selection
  158. }
  159. openContextMenuAtPosition(
  160. view,
  161. pos,
  162. selection,
  163. event.clientX,
  164. event.clientY
  165. )
  166. return true
  167. },
  168. mousedown(event: MouseEvent, view: EditorView) {
  169. const target = event.target as HTMLElement
  170. const isGutter = isClickOnGutter(target)
  171. const isRightClick = event.button === 2 || event.ctrlKey
  172. // Close menu on any click except right-click on non-gutter
  173. if (!isRightClick || isGutter) {
  174. closeContextMenu(view)
  175. }
  176. // Prevent default on right-click to preserve selection
  177. if (isRightClick) {
  178. event.preventDefault()
  179. return true
  180. }
  181. return false
  182. },
  183. })
  184. // High-priority keymap to handle Escape before default handlers
  185. const contextMenuKeymap = (): Extension =>
  186. Prec.high(
  187. keymap.of([
  188. {
  189. key: 'Escape',
  190. run: view => {
  191. const menuState = view.state.field(contextMenuStateField, false)
  192. if (menuState?.tooltip) {
  193. closeContextMenu(view)
  194. return true
  195. }
  196. return false
  197. },
  198. },
  199. ])
  200. )
  201. export const contextMenu = (enabled: boolean): Extension =>
  202. enabled
  203. ? [
  204. contextMenuContainerTheme,
  205. contextMenuStateField,
  206. gutterContextMenuPlugin(),
  207. editorContextMenuHandlers(),
  208. contextMenuKeymap(),
  209. ]
  210. : []
  211. const contextMenuContainerTheme = EditorView.baseTheme({
  212. '.editor-context-menu-container.cm-tooltip': {
  213. backgroundColor: 'transparent',
  214. border: 'none',
  215. zIndex: 100,
  216. },
  217. })