context-menu.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. import {
  2. EditorView,
  3. showTooltip,
  4. Tooltip,
  5. TooltipView,
  6. keymap,
  7. ViewPlugin,
  8. } from '@codemirror/view'
  9. import {
  10. Extension,
  11. StateField,
  12. StateEffect,
  13. TransactionSpec,
  14. EditorSelection,
  15. Prec,
  16. Annotation,
  17. } from '@codemirror/state'
  18. import { closeAllContextMenusEffect } from '../utils/close-all-context-menus-effect'
  19. import { isContextMenuMouseEvent } from '../utils/context-menu-mouse-event'
  20. import { isMobileDevice } from '../utils/isMobileDevice'
  21. const isMobile = isMobileDevice()
  22. export const openContextMenuEffect = StateEffect.define<{
  23. pos: number
  24. x: number
  25. y: number
  26. }>()
  27. export const closeContextMenuEffect = StateEffect.define()
  28. export const openContextMenuAnnotation = Annotation.define<boolean>()
  29. type ContextMenuState = {
  30. tooltip: Tooltip | null
  31. mousePosition: { x: number; y: number } | null
  32. }
  33. export const contextMenuStateField = StateField.define<ContextMenuState>({
  34. create() {
  35. return { tooltip: null, mousePosition: null }
  36. },
  37. update(field, tr) {
  38. let next = field
  39. // Process effects in order but let open win if present in the same transaction
  40. for (const effect of tr.effects) {
  41. if (
  42. effect.is(closeContextMenuEffect) ||
  43. effect.is(closeAllContextMenusEffect)
  44. ) {
  45. next = { tooltip: null, mousePosition: null }
  46. }
  47. if (effect.is(openContextMenuEffect)) {
  48. const { pos, x, y } = effect.value
  49. return {
  50. tooltip: buildContextMenuTooltip(pos, { x, y }),
  51. mousePosition: { x, y },
  52. }
  53. }
  54. }
  55. // If effects changed the state, return early so doc-change fallback doesn’t override it
  56. if (next !== field) {
  57. return next
  58. }
  59. // Close menu on document changes
  60. if (tr.docChanged && field.tooltip) {
  61. return { tooltip: null, mousePosition: null }
  62. }
  63. return field
  64. },
  65. // Connect state field to tooltip system
  66. provide: field => [
  67. showTooltip.compute([field], state => state.field(field).tooltip),
  68. ],
  69. })
  70. function buildContextMenuTooltip(
  71. pos: number,
  72. mousePosition: { x: number; y: number }
  73. ): Tooltip {
  74. return {
  75. pos,
  76. above: false,
  77. strictSide: false,
  78. arrow: false,
  79. create: () => createTooltipView(mousePosition),
  80. }
  81. }
  82. const createTooltipView = (mousePosition: {
  83. x: number
  84. y: number
  85. }): TooltipView => {
  86. const dom = document.createElement('div')
  87. dom.className = 'editor-context-menu-container'
  88. // Watch for size changes and reposition accordingly
  89. const resizeObserver = new ResizeObserver(() => {
  90. requestAnimationFrame(() => positionMenu(dom, mousePosition))
  91. })
  92. resizeObserver.observe(dom)
  93. return {
  94. dom,
  95. overlap: true,
  96. offset: { x: 0, y: 0 },
  97. destroy() {
  98. resizeObserver.disconnect()
  99. },
  100. }
  101. }
  102. function positionMenu(
  103. dom: HTMLElement,
  104. mousePosition: { x: number; y: number }
  105. ) {
  106. const bounds = dom.getBoundingClientRect()
  107. // Wait for menu to render
  108. if (bounds.width === 0 || bounds.height === 0) {
  109. return
  110. }
  111. const viewportWidth = window.innerWidth
  112. const viewportHeight = window.innerHeight
  113. const y = mousePosition.y
  114. // Adjust horizontal position if menu would overflow right edge
  115. let left = mousePosition.x
  116. if (mousePosition.x + bounds.width > viewportWidth) {
  117. left = viewportWidth - bounds.width
  118. }
  119. dom.style.setProperty('--context-menu-left', `${left}px`)
  120. const spaceBelow = viewportHeight - y
  121. let top = y
  122. if (bounds.height > spaceBelow) {
  123. // Show above if menu won't fit below
  124. top = y - bounds.height
  125. }
  126. dom.style.setProperty('--context-menu-top', `${top}px`)
  127. }
  128. function isPositionInsideSelection(pos: number, from: number, to: number) {
  129. return from !== to && pos >= from && pos <= to
  130. }
  131. function isPositionInsideAnyRangeOrCursor(view: EditorView, pos: number) {
  132. for (const range of view.state.selection.ranges) {
  133. // If it's a cursor (not a selection), only treat it as "inside" when
  134. // right-clicking exactly on the cursor position. This allows cursor
  135. // movement when clicking elsewhere on the same line.
  136. if (range.from === range.to) {
  137. if (pos === range.from) {
  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. annotations: [openContextMenuAnnotation.of(true)],
  182. })
  183. }
  184. function openContextMenuAtSelection(view: EditorView): boolean {
  185. const { main } = view.state.selection
  186. const pos = main.head
  187. const coords = view.coordsAtPos(pos)
  188. if (!coords) {
  189. return false
  190. }
  191. // Keep the current selection; actions should apply to it
  192. const selection = view.state.selection
  193. openContextMenuAtPosition(view, pos, selection, coords.left, coords.top)
  194. return true
  195. }
  196. function isClickOnGutter(target: HTMLElement): boolean {
  197. return !!target.closest('.cm-gutters')
  198. }
  199. // Gutter context menu plugin
  200. const gutterContextMenuPlugin = (): Extension =>
  201. EditorView.updateListener.of(update => {
  202. if (!update.view.dom.parentElement) {
  203. return
  204. }
  205. const gutters = update.view.dom.parentElement.querySelector('.cm-gutters')
  206. // Attach listener only once per editor instance
  207. if (!gutters || gutters.hasAttribute('data-context-menu-attached')) {
  208. return
  209. }
  210. gutters.setAttribute('data-context-menu-attached', 'true')
  211. gutters.addEventListener('contextmenu', (event: Event) => {
  212. const mouseEvent = event as MouseEvent
  213. const pos = update.view.posAtCoords({
  214. x: mouseEvent.clientX,
  215. y: mouseEvent.clientY,
  216. })
  217. if (pos === null) {
  218. return
  219. }
  220. event.preventDefault()
  221. const selection = selectEntireLine(update.view, pos)
  222. if (selection) {
  223. openContextMenuAtPosition(
  224. update.view,
  225. pos,
  226. selection,
  227. mouseEvent.clientX,
  228. mouseEvent.clientY
  229. )
  230. }
  231. })
  232. })
  233. // Handle right-click on ol-cm-filler (empty line widget)
  234. // domEventHandlers doesn't fire for contenteditable="false" elements, so we use a direct DOM listener
  235. const emptyLineFillerContextMenuPlugin = (): Extension =>
  236. ViewPlugin.define(view => {
  237. const contentDOM = view.contentDOM
  238. const handleContextMenu = (event: Event) => {
  239. const mouseEvent = event as MouseEvent
  240. const target = mouseEvent.target as HTMLElement
  241. // Only handle ol-cm-filler elements
  242. if (!target.classList.contains('ol-cm-filler')) {
  243. return
  244. }
  245. event.preventDefault()
  246. event.stopPropagation()
  247. // Re-dispatch on contentDOM so CodeMirror's domEventHandlers picks it up
  248. const customEvent = new MouseEvent('contextmenu', {
  249. bubbles: true,
  250. cancelable: true,
  251. clientX: mouseEvent.clientX,
  252. clientY: mouseEvent.clientY,
  253. })
  254. contentDOM.dispatchEvent(customEvent)
  255. }
  256. contentDOM.addEventListener('contextmenu', handleContextMenu)
  257. return {
  258. destroy() {
  259. contentDOM.removeEventListener('contextmenu', handleContextMenu)
  260. },
  261. }
  262. })
  263. // Editor view context menu handlers
  264. const editorContextMenuHandlers = (): Extension =>
  265. EditorView.domEventHandlers({
  266. contextmenu(event: MouseEvent, view: EditorView) {
  267. const pos = view.posAtCoords({ x: event.clientX, y: event.clientY })
  268. if (pos === null) {
  269. return false
  270. }
  271. event.preventDefault()
  272. const clickedInsideSelection = isPositionInsideAnyRangeOrCursor(view, pos)
  273. // Set cursor to clicked position if outside selection
  274. let selection: TransactionSpec['selection'] = { anchor: pos }
  275. if (clickedInsideSelection) {
  276. // Keep current selection if inside selection
  277. // so actions apply to the existing selection
  278. selection = view.state.selection
  279. }
  280. openContextMenuAtPosition(
  281. view,
  282. pos,
  283. selection,
  284. event.clientX,
  285. event.clientY
  286. )
  287. return true
  288. },
  289. mousedown(event: MouseEvent, view: EditorView) {
  290. const target = event.target as HTMLElement
  291. const isGutter = isClickOnGutter(target)
  292. const isRightClick = isContextMenuMouseEvent(event)
  293. // Close menu on any click except right-click on non-gutter
  294. if (!isRightClick || isGutter) {
  295. closeContextMenu(view)
  296. }
  297. // Prevent default on right-click to preserve selection
  298. if (isRightClick) {
  299. event.preventDefault()
  300. return true
  301. }
  302. return false
  303. },
  304. })
  305. // High-priority keymap to handle Escape before default handlers
  306. const contextMenuKeymap = (): Extension =>
  307. Prec.high(
  308. keymap.of([
  309. {
  310. key: 'Escape',
  311. run: view => {
  312. const menuState = view.state.field(contextMenuStateField, false)
  313. if (menuState?.tooltip) {
  314. closeContextMenu(view)
  315. return true
  316. }
  317. return false
  318. },
  319. },
  320. {
  321. key: 'Shift-F10',
  322. // Accessibility standard shortcut to open context menu
  323. run: view => openContextMenuAtSelection(view),
  324. },
  325. ])
  326. )
  327. export const contextMenu = (enabled: boolean): Extension =>
  328. enabled && !isMobile
  329. ? [
  330. contextMenuContainerTheme,
  331. contextMenuStateField,
  332. gutterContextMenuPlugin(),
  333. emptyLineFillerContextMenuPlugin(),
  334. editorContextMenuHandlers(),
  335. contextMenuKeymap(),
  336. ]
  337. : []
  338. const contextMenuContainerTheme = EditorView.baseTheme({
  339. '.editor-context-menu-container.cm-tooltip': {
  340. backgroundColor: 'transparent',
  341. border: 'none',
  342. zIndex: 100,
  343. position: 'fixed !important',
  344. top: 'var(--context-menu-top) !important',
  345. left: 'var(--context-menu-left) !important',
  346. },
  347. })