context-menu.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. import { createRoot } from 'react-dom/client'
  2. import {
  3. StateField,
  4. StateEffect,
  5. Prec,
  6. EditorSelection,
  7. } from '@codemirror/state'
  8. import { EditorView, showTooltip, Tooltip, keymap } from '@codemirror/view'
  9. import { Word, Mark, getMarkAtPosition } from './spellchecker'
  10. import { debugConsole } from '@/utils/debugging'
  11. import {
  12. getSpellChecker,
  13. getSpellCheckLanguage,
  14. } from '@/features/source-editor/extensions/spelling/index'
  15. import { sendMB } from '@/infrastructure/event-tracking'
  16. import { SpellingSuggestions } from '@/features/source-editor/extensions/spelling/spelling-suggestions'
  17. import { SplitTestProvider } from '@/shared/context/split-test-context'
  18. import { addLearnedWord } from '@/features/source-editor/extensions/spelling/learned-words'
  19. import { postJSON } from '@/infrastructure/fetch-json'
  20. import { closeAllContextMenusEffect } from '../../utils/close-all-context-menus-effect'
  21. /*
  22. * The time until which a click event will be ignored, so it doesn't immediately close the spelling menu.
  23. * Safari emits an additional "click" event when event.preventDefault() is called in the "contextmenu" event listener.
  24. */
  25. let openingUntil = 0
  26. /*
  27. * Hide the spelling menu on click
  28. */
  29. const handleClickEvent = (event: MouseEvent, view: EditorView) => {
  30. if (Date.now() < openingUntil) {
  31. return
  32. }
  33. if (view.state.field(spellingMenuField, false)) {
  34. view.dispatch({
  35. effects: hideSpellingMenu.of(null),
  36. })
  37. }
  38. }
  39. /*
  40. * Detect when the user right-clicks on a misspelled word,
  41. * and show a menu of suggestions
  42. */
  43. const handleContextMenuEvent = (event: MouseEvent, view: EditorView) => {
  44. if (event.shiftKey) {
  45. view.dispatch({ effects: closeAllContextMenusEffect.of(null) })
  46. return
  47. }
  48. const position = view.posAtCoords(
  49. {
  50. x: event.pageX,
  51. y: event.pageY,
  52. },
  53. false
  54. )
  55. const targetMark = getMarkAtPosition(view, position)
  56. if (!targetMark) {
  57. return
  58. }
  59. const { value } = targetMark
  60. const targetWord = value.spec.word
  61. if (!targetWord) {
  62. debugConsole.debug(
  63. '>> spelling no word associated with decorated range, stopping'
  64. )
  65. return
  66. }
  67. event.preventDefault()
  68. openingUntil = Date.now() + 100
  69. view.dispatch({
  70. effects: [
  71. closeAllContextMenusEffect.of(null),
  72. showSpellingMenu.of({
  73. mark: targetMark,
  74. word: targetWord,
  75. }),
  76. ],
  77. })
  78. }
  79. const handleShortcutEvent = (view: EditorView) => {
  80. const targetMark = getMarkAtPosition(view, view.state.selection.main.from)
  81. if (!targetMark || !targetMark.value) {
  82. return false
  83. }
  84. view.dispatch({
  85. effects: [
  86. closeAllContextMenusEffect.of(null),
  87. showSpellingMenu.of({
  88. mark: targetMark,
  89. word: targetMark.value.spec.word,
  90. }),
  91. ],
  92. })
  93. return true
  94. }
  95. /*
  96. * Spelling menu "tooltip" field.
  97. * Manages the menu of suggestions shown on right-click
  98. */
  99. export const spellingMenuField = StateField.define<Tooltip | null>({
  100. create() {
  101. return null
  102. },
  103. update(value, transaction) {
  104. if (value) {
  105. value = {
  106. ...value,
  107. pos: transaction.changes.mapPos(value.pos),
  108. end: value.end ? transaction.changes.mapPos(value.end) : undefined,
  109. }
  110. }
  111. for (const effect of transaction.effects) {
  112. if (effect.is(hideSpellingMenu)) {
  113. value = null
  114. } else if (effect.is(showSpellingMenu)) {
  115. const { mark, word } = effect.value
  116. // Build a "Tooltip" showing the suggestions
  117. value = {
  118. pos: mark.from,
  119. end: mark.to,
  120. above: false,
  121. strictSide: false,
  122. create: createSpellingSuggestionList(word),
  123. }
  124. } else if (effect.is(closeAllContextMenusEffect)) {
  125. value = null
  126. }
  127. }
  128. return value
  129. },
  130. provide: field => {
  131. return [
  132. showTooltip.from(field),
  133. EditorView.domEventHandlers({
  134. contextmenu: handleContextMenuEvent,
  135. click: handleClickEvent,
  136. }),
  137. Prec.highest(
  138. keymap.of([
  139. { key: 'Ctrl-Space', run: handleShortcutEvent },
  140. { key: 'Alt-Space', run: handleShortcutEvent },
  141. ])
  142. ),
  143. ]
  144. },
  145. })
  146. const showSpellingMenu = StateEffect.define<{ mark: Mark; word: Word }>()
  147. export const hideSpellingMenu = StateEffect.define()
  148. /*
  149. * Creates the suggestion menu dom, to be displayed in the
  150. * spelling menu "tooltip"
  151. * */
  152. const createSpellingSuggestionList = (word: Word) => (view: EditorView) => {
  153. const dom = document.createElement('div')
  154. dom.classList.add('ol-cm-spelling-context-menu-tooltip')
  155. const root = createRoot(dom)
  156. root.render(
  157. <SplitTestProvider>
  158. <SpellingSuggestions
  159. word={word}
  160. spellCheckLanguage={getSpellCheckLanguage(view.state)}
  161. spellChecker={getSpellChecker(view.state)}
  162. handleClose={(focus = true) => {
  163. view.dispatch({
  164. effects: hideSpellingMenu.of(null),
  165. })
  166. if (focus) {
  167. view.focus()
  168. }
  169. }}
  170. handleLearnWord={() => {
  171. const tooltip = view.state.field(spellingMenuField)
  172. if (tooltip) {
  173. window.setTimeout(() => {
  174. view.dispatch({
  175. selection: EditorSelection.cursor(tooltip.end ?? tooltip.pos),
  176. })
  177. })
  178. }
  179. view.focus()
  180. postJSON('/spelling/learn', {
  181. body: {
  182. word: word.text,
  183. },
  184. })
  185. .then(() => {
  186. view.dispatch(addLearnedWord(word.text), {
  187. effects: hideSpellingMenu.of(null),
  188. })
  189. sendMB('spelling-word-added', {
  190. language: getSpellCheckLanguage(view.state),
  191. })
  192. })
  193. .catch(error => {
  194. debugConsole.error(error)
  195. })
  196. }}
  197. handleCorrectWord={(text: string) => {
  198. const tooltip = view.state.field(spellingMenuField)
  199. if (!tooltip) {
  200. throw new Error('No active tooltip')
  201. }
  202. const existingText = view.state.doc.sliceString(
  203. tooltip.pos,
  204. tooltip.end
  205. )
  206. if (existingText !== word.text) {
  207. return
  208. }
  209. window.setTimeout(() => {
  210. const changes = view.state.changes([
  211. { from: tooltip.pos, to: tooltip.end, insert: text },
  212. ])
  213. view.dispatch({
  214. changes,
  215. effects: [hideSpellingMenu.of(null)],
  216. selection: EditorSelection.cursor(tooltip.end ?? tooltip.pos).map(
  217. changes
  218. ),
  219. })
  220. })
  221. view.focus()
  222. sendMB('spelling-suggestion-click', {
  223. language: getSpellCheckLanguage(view.state),
  224. })
  225. }}
  226. />
  227. </SplitTestProvider>
  228. )
  229. const destroy = () => {
  230. root.unmount()
  231. }
  232. return { dom, destroy }
  233. }