context-menu.tsx 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. import {
  2. StateField,
  3. StateEffect,
  4. EditorSelection,
  5. Prec,
  6. } from '@codemirror/state'
  7. import { EditorView, showTooltip, Tooltip, keymap } from '@codemirror/view'
  8. import { Word, Mark, getMarkAtPosition } from './spellchecker'
  9. import { debugConsole } from '@/utils/debugging'
  10. import {
  11. getSpellChecker,
  12. getSpellCheckLanguage,
  13. } from '@/features/source-editor/extensions/spelling/index'
  14. import { sendMB } from '@/infrastructure/event-tracking'
  15. import ReactDOM from 'react-dom'
  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 { from, to, 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. selection: EditorSelection.range(from, to),
  66. effects: showSpellingMenu.of({
  67. mark: targetMark,
  68. word: targetWord,
  69. }),
  70. })
  71. }
  72. const handleShortcutEvent = (view: EditorView) => {
  73. const targetMark = getMarkAtPosition(view, view.state.selection.main.from)
  74. if (!targetMark || !targetMark.value) {
  75. return false
  76. }
  77. view.dispatch({
  78. selection: EditorSelection.range(targetMark.from, targetMark.to),
  79. effects: showSpellingMenu.of({
  80. mark: targetMark,
  81. word: targetMark.value.spec.word,
  82. }),
  83. })
  84. return true
  85. }
  86. /*
  87. * Spelling menu "tooltip" field.
  88. * Manages the menu of suggestions shown on right-click
  89. */
  90. export const spellingMenuField = StateField.define<Tooltip | null>({
  91. create() {
  92. return null
  93. },
  94. update(value, transaction) {
  95. if (value) {
  96. value = {
  97. ...value,
  98. pos: transaction.changes.mapPos(value.pos),
  99. end: value.end ? transaction.changes.mapPos(value.end) : undefined,
  100. }
  101. }
  102. for (const effect of transaction.effects) {
  103. if (effect.is(hideSpellingMenu)) {
  104. value = null
  105. } else if (effect.is(showSpellingMenu)) {
  106. const { mark, word } = effect.value
  107. // Build a "Tooltip" showing the suggestions
  108. value = {
  109. pos: mark.from,
  110. end: mark.to,
  111. above: false,
  112. strictSide: false,
  113. create: createSpellingSuggestionList(word),
  114. }
  115. }
  116. }
  117. return value
  118. },
  119. provide: field => {
  120. return [
  121. showTooltip.from(field),
  122. EditorView.domEventHandlers({
  123. contextmenu: handleContextMenuEvent,
  124. click: handleClickEvent,
  125. }),
  126. Prec.highest(
  127. keymap.of([
  128. { key: 'Ctrl-Space', run: handleShortcutEvent },
  129. { key: 'Alt-Space', run: handleShortcutEvent },
  130. ])
  131. ),
  132. ]
  133. },
  134. })
  135. const showSpellingMenu = StateEffect.define<{ mark: Mark; word: Word }>()
  136. export const hideSpellingMenu = StateEffect.define()
  137. /*
  138. * Creates the suggestion menu dom, to be displayed in the
  139. * spelling menu "tooltip"
  140. * */
  141. const createSpellingSuggestionList = (word: Word) => (view: EditorView) => {
  142. const dom = document.createElement('div')
  143. dom.classList.add('ol-cm-spelling-context-menu-tooltip')
  144. ReactDOM.render(
  145. <SplitTestProvider>
  146. <SpellingSuggestions
  147. word={word}
  148. spellCheckLanguage={getSpellCheckLanguage(view.state)}
  149. spellChecker={getSpellChecker(view.state)}
  150. handleClose={(focus = true) => {
  151. view.dispatch({
  152. effects: hideSpellingMenu.of(null),
  153. })
  154. if (focus) {
  155. view.focus()
  156. }
  157. }}
  158. handleLearnWord={() => {
  159. postJSON('/spelling/learn', {
  160. body: {
  161. word: word.text,
  162. },
  163. })
  164. .then(() => {
  165. view.dispatch(addLearnedWord(word.text), {
  166. effects: hideSpellingMenu.of(null),
  167. })
  168. sendMB('spelling-word-added', {
  169. language: getSpellCheckLanguage(view.state),
  170. })
  171. })
  172. .catch(error => {
  173. debugConsole.error(error)
  174. })
  175. }}
  176. handleCorrectWord={(text: string) => {
  177. const tooltip = view.state.field(spellingMenuField)
  178. if (!tooltip) {
  179. throw new Error('No active tooltip')
  180. }
  181. const existingText = view.state.doc.sliceString(
  182. tooltip.pos,
  183. tooltip.end
  184. )
  185. if (existingText !== word.text) {
  186. return
  187. }
  188. window.setTimeout(() => {
  189. view.dispatch({
  190. changes: [{ from: tooltip.pos, to: tooltip.end, insert: text }],
  191. effects: [hideSpellingMenu.of(null)],
  192. })
  193. })
  194. view.focus()
  195. sendMB('spelling-suggestion-click', {
  196. language: getSpellCheckLanguage(view.state),
  197. })
  198. }}
  199. />
  200. </SplitTestProvider>,
  201. dom
  202. )
  203. const destroy = () => {
  204. ReactDOM.unmountComponentAtNode(dom)
  205. }
  206. return { dom, destroy }
  207. }