context-menu.tsx 6.4 KB

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