context-menu.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  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. if (mouseEvent.shiftKey) {
  214. update.view.dispatch({ effects: closeAllContextMenusEffect.of(null) })
  215. return
  216. }
  217. const pos = update.view.posAtCoords({
  218. x: mouseEvent.clientX,
  219. y: mouseEvent.clientY,
  220. })
  221. if (pos === null) {
  222. return
  223. }
  224. event.preventDefault()
  225. const selection = selectEntireLine(update.view, pos)
  226. if (selection) {
  227. openContextMenuAtPosition(
  228. update.view,
  229. pos,
  230. selection,
  231. mouseEvent.clientX,
  232. mouseEvent.clientY
  233. )
  234. }
  235. })
  236. })
  237. // Handle right-click on ol-cm-filler (empty line widget)
  238. // domEventHandlers doesn't fire for contenteditable="false" elements, so we use a direct DOM listener
  239. const emptyLineFillerContextMenuPlugin = (): Extension =>
  240. ViewPlugin.define(view => {
  241. const contentDOM = view.contentDOM
  242. const handleContextMenu = (event: Event) => {
  243. const mouseEvent = event as MouseEvent
  244. const target = mouseEvent.target as HTMLElement
  245. // Only handle ol-cm-filler elements
  246. if (!target.classList.contains('ol-cm-filler')) {
  247. return
  248. }
  249. if (mouseEvent.shiftKey) {
  250. view.dispatch({ effects: closeAllContextMenusEffect.of(null) })
  251. return
  252. }
  253. event.preventDefault()
  254. event.stopPropagation()
  255. // Re-dispatch on contentDOM so CodeMirror's domEventHandlers picks it up
  256. const customEvent = new MouseEvent('contextmenu', {
  257. bubbles: true,
  258. cancelable: true,
  259. clientX: mouseEvent.clientX,
  260. clientY: mouseEvent.clientY,
  261. shiftKey: mouseEvent.shiftKey,
  262. })
  263. contentDOM.dispatchEvent(customEvent)
  264. }
  265. contentDOM.addEventListener('contextmenu', handleContextMenu)
  266. return {
  267. destroy() {
  268. contentDOM.removeEventListener('contextmenu', handleContextMenu)
  269. },
  270. }
  271. })
  272. // Editor view context menu handlers
  273. const editorContextMenuHandlers = (): Extension =>
  274. EditorView.domEventHandlers({
  275. contextmenu(event: MouseEvent, view: EditorView) {
  276. if (event.shiftKey) {
  277. view.dispatch({ effects: closeAllContextMenusEffect.of(null) })
  278. return false
  279. }
  280. const pos = view.posAtCoords({ x: event.clientX, y: event.clientY })
  281. if (pos === null) {
  282. return false
  283. }
  284. event.preventDefault()
  285. const clickedInsideSelection = isPositionInsideAnyRangeOrCursor(view, pos)
  286. // Set cursor to clicked position if outside selection
  287. let selection: TransactionSpec['selection'] = { anchor: pos }
  288. if (clickedInsideSelection) {
  289. // Keep current selection if inside selection
  290. // so actions apply to the existing selection
  291. selection = view.state.selection
  292. }
  293. openContextMenuAtPosition(
  294. view,
  295. pos,
  296. selection,
  297. event.clientX,
  298. event.clientY
  299. )
  300. return true
  301. },
  302. mousedown(event: MouseEvent, view: EditorView) {
  303. const target = event.target as HTMLElement
  304. const isGutter = isClickOnGutter(target)
  305. const isRightClick = isContextMenuMouseEvent(event)
  306. // Close menu on any click except right-click on non-gutter
  307. if (!isRightClick || isGutter) {
  308. closeContextMenu(view)
  309. }
  310. // Prevent default on right-click to preserve selection,
  311. // but not when Shift is held (native context menu shortcut)
  312. if (isRightClick && !event.shiftKey) {
  313. event.preventDefault()
  314. return true
  315. }
  316. return false
  317. },
  318. })
  319. // High-priority keymap to handle Escape before default handlers
  320. const contextMenuKeymap = (): Extension =>
  321. Prec.high(
  322. keymap.of([
  323. {
  324. key: 'Escape',
  325. run: view => {
  326. const menuState = view.state.field(contextMenuStateField, false)
  327. if (menuState?.tooltip) {
  328. closeContextMenu(view)
  329. return true
  330. }
  331. return false
  332. },
  333. },
  334. {
  335. key: 'Shift-F10',
  336. // Accessibility standard shortcut to open context menu
  337. run: view => openContextMenuAtSelection(view),
  338. },
  339. ])
  340. )
  341. export const contextMenu = (enabled: boolean): Extension =>
  342. enabled && !isMobile
  343. ? [
  344. contextMenuContainerTheme,
  345. contextMenuStateField,
  346. gutterContextMenuPlugin(),
  347. emptyLineFillerContextMenuPlugin(),
  348. editorContextMenuHandlers(),
  349. contextMenuKeymap(),
  350. ]
  351. : []
  352. const contextMenuContainerTheme = EditorView.baseTheme({
  353. '.editor-context-menu-container.cm-tooltip': {
  354. backgroundColor: 'transparent',
  355. border: 'none',
  356. zIndex: 100,
  357. position: 'fixed !important',
  358. top: 'var(--context-menu-top) !important',
  359. left: 'var(--context-menu-left) !important',
  360. },
  361. })