context-menu.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  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, treat a right-click anywhere on the same line as "inside".
  134. // This avoids collapsing multi-cursor selections when right-clicking on blank lines
  135. // or to the right of the caret.
  136. if (range.from === range.to) {
  137. const clickedLine = view.state.doc.lineAt(pos)
  138. const cursorLine = view.state.doc.lineAt(range.from)
  139. if (clickedLine.number === cursorLine.number) {
  140. return true
  141. }
  142. continue
  143. }
  144. if (isPositionInsideSelection(pos, range.from, range.to)) {
  145. return true
  146. }
  147. }
  148. return false
  149. }
  150. function selectEntireLine(
  151. view: EditorView,
  152. pos: number
  153. ): EditorSelection | null {
  154. if (pos === null) {
  155. return null
  156. }
  157. const line = view.state.doc.lineAt(pos)
  158. return EditorSelection.single(line.from, line.to)
  159. }
  160. function closeContextMenu(view: EditorView): void {
  161. const menuState = view.state.field(contextMenuStateField, false)
  162. if (menuState?.tooltip) {
  163. view.dispatch({ effects: closeContextMenuEffect.of(null) })
  164. }
  165. }
  166. function openContextMenuAtPosition(
  167. view: EditorView,
  168. pos: number,
  169. selection: EditorSelection | TransactionSpec['selection'],
  170. clientX: number,
  171. clientY: number
  172. ): void {
  173. view.dispatch({
  174. selection,
  175. effects: [
  176. closeAllContextMenusEffect.of(null),
  177. openContextMenuEffect.of({
  178. pos,
  179. x: clientX,
  180. y: clientY,
  181. }),
  182. ],
  183. annotations: [openContextMenuAnnotation.of(true)],
  184. })
  185. }
  186. function openContextMenuAtSelection(view: EditorView): boolean {
  187. const { main } = view.state.selection
  188. const pos = main.head
  189. const coords = view.coordsAtPos(pos)
  190. if (!coords) {
  191. return false
  192. }
  193. // Keep the current selection; actions should apply to it
  194. const selection = view.state.selection
  195. openContextMenuAtPosition(view, pos, selection, coords.left, coords.top)
  196. return true
  197. }
  198. function isClickOnGutter(target: HTMLElement): boolean {
  199. return !!target.closest('.cm-gutters')
  200. }
  201. // Gutter context menu plugin
  202. const gutterContextMenuPlugin = (): Extension =>
  203. EditorView.updateListener.of(update => {
  204. if (!update.view.dom.parentElement) {
  205. return
  206. }
  207. const gutters = update.view.dom.parentElement.querySelector('.cm-gutters')
  208. // Attach listener only once per editor instance
  209. if (!gutters || gutters.hasAttribute('data-context-menu-attached')) {
  210. return
  211. }
  212. gutters.setAttribute('data-context-menu-attached', 'true')
  213. gutters.addEventListener('contextmenu', (event: Event) => {
  214. const mouseEvent = event as MouseEvent
  215. const pos = update.view.posAtCoords({
  216. x: mouseEvent.clientX,
  217. y: mouseEvent.clientY,
  218. })
  219. if (pos === null) {
  220. return
  221. }
  222. event.preventDefault()
  223. const selection = selectEntireLine(update.view, pos)
  224. if (selection) {
  225. openContextMenuAtPosition(
  226. update.view,
  227. pos,
  228. selection,
  229. mouseEvent.clientX,
  230. mouseEvent.clientY
  231. )
  232. }
  233. })
  234. })
  235. // Handle right-click on ol-cm-filler (empty line widget)
  236. // domEventHandlers doesn't fire for contenteditable="false" elements, so we use a direct DOM listener
  237. const emptyLineFillerContextMenuPlugin = (): Extension =>
  238. ViewPlugin.define(view => {
  239. const contentDOM = view.contentDOM
  240. const handleContextMenu = (event: Event) => {
  241. const mouseEvent = event as MouseEvent
  242. const target = mouseEvent.target as HTMLElement
  243. // Only handle ol-cm-filler elements
  244. if (!target.classList.contains('ol-cm-filler')) {
  245. return
  246. }
  247. event.preventDefault()
  248. event.stopPropagation()
  249. // Re-dispatch on contentDOM so CodeMirror's domEventHandlers picks it up
  250. const customEvent = new MouseEvent('contextmenu', {
  251. bubbles: true,
  252. cancelable: true,
  253. clientX: mouseEvent.clientX,
  254. clientY: mouseEvent.clientY,
  255. })
  256. contentDOM.dispatchEvent(customEvent)
  257. }
  258. contentDOM.addEventListener('contextmenu', handleContextMenu)
  259. return {
  260. destroy() {
  261. contentDOM.removeEventListener('contextmenu', handleContextMenu)
  262. },
  263. }
  264. })
  265. // Editor view context menu handlers
  266. const editorContextMenuHandlers = (): Extension =>
  267. EditorView.domEventHandlers({
  268. contextmenu(event: MouseEvent, view: EditorView) {
  269. const pos = view.posAtCoords({ x: event.clientX, y: event.clientY })
  270. if (pos === null) {
  271. return false
  272. }
  273. event.preventDefault()
  274. const clickedInsideSelection = isPositionInsideAnyRangeOrCursor(view, pos)
  275. // Set cursor to clicked position if outside selection
  276. let selection: TransactionSpec['selection'] = { anchor: pos }
  277. if (clickedInsideSelection) {
  278. // Keep current selection if inside selection
  279. // so actions apply to the existing selection
  280. selection = view.state.selection
  281. }
  282. openContextMenuAtPosition(
  283. view,
  284. pos,
  285. selection,
  286. event.clientX,
  287. event.clientY
  288. )
  289. return true
  290. },
  291. mousedown(event: MouseEvent, view: EditorView) {
  292. const target = event.target as HTMLElement
  293. const isGutter = isClickOnGutter(target)
  294. const isRightClick = isContextMenuMouseEvent(event)
  295. // Close menu on any click except right-click on non-gutter
  296. if (!isRightClick || isGutter) {
  297. closeContextMenu(view)
  298. }
  299. // Prevent default on right-click to preserve selection
  300. if (isRightClick) {
  301. event.preventDefault()
  302. return true
  303. }
  304. return false
  305. },
  306. })
  307. // High-priority keymap to handle Escape before default handlers
  308. const contextMenuKeymap = (): Extension =>
  309. Prec.high(
  310. keymap.of([
  311. {
  312. key: 'Escape',
  313. run: view => {
  314. const menuState = view.state.field(contextMenuStateField, false)
  315. if (menuState?.tooltip) {
  316. closeContextMenu(view)
  317. return true
  318. }
  319. return false
  320. },
  321. },
  322. {
  323. key: 'Shift-F10',
  324. // Accessibility standard shortcut to open context menu
  325. run: view => openContextMenuAtSelection(view),
  326. },
  327. ])
  328. )
  329. export const contextMenu = (enabled: boolean): Extension =>
  330. enabled && !isMobile
  331. ? [
  332. contextMenuContainerTheme,
  333. contextMenuStateField,
  334. gutterContextMenuPlugin(),
  335. emptyLineFillerContextMenuPlugin(),
  336. editorContextMenuHandlers(),
  337. contextMenuKeymap(),
  338. ]
  339. : []
  340. const contextMenuContainerTheme = EditorView.baseTheme({
  341. '.editor-context-menu-container.cm-tooltip': {
  342. backgroundColor: 'transparent',
  343. border: 'none',
  344. zIndex: 100,
  345. position: 'fixed !important',
  346. top: 'var(--context-menu-top) !important',
  347. left: 'var(--context-menu-left) !important',
  348. },
  349. })