context-menu.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. import {
  2. StateField,
  3. StateEffect,
  4. Range,
  5. RangeValue,
  6. EditorSelection,
  7. } from '@codemirror/state'
  8. import { EditorView, showTooltip, Tooltip } from '@codemirror/view'
  9. import { misspelledWordsField } from './misspelled-words'
  10. import { addIgnoredWord } from './ignored-words'
  11. import { learnWordRequest } from './backend'
  12. import { Word } from './spellchecker'
  13. const ITEMS_TO_SHOW = 8
  14. type Mark = Range<RangeValue & { spec: { word: Word } }>
  15. /*
  16. * The time until which a click event will be ignored, so it doesn't immediately close the spelling menu.
  17. * Safari emits an additional "click" event when event.preventDefault() is called in the "contextmenu" event listener.
  18. */
  19. let openingUntil = 0
  20. /*
  21. * Hide the spelling menu on click
  22. */
  23. const handleClickEvent = (event: MouseEvent, view: EditorView) => {
  24. if (Date.now() < openingUntil) {
  25. return
  26. }
  27. if (view.state.field(spellingMenuField, false)) {
  28. view.dispatch({
  29. effects: hideSpellingMenu.of(null),
  30. })
  31. }
  32. }
  33. /*
  34. * Detect when the user right-clicks on a misspelled word,
  35. * and show a menu of suggestions
  36. */
  37. const handleContextMenuEvent = (event: MouseEvent, view: EditorView) => {
  38. const position = view.posAtCoords(
  39. {
  40. x: event.pageX,
  41. y: event.pageY,
  42. },
  43. false
  44. )
  45. const marks = view.state.field(misspelledWordsField)
  46. let targetMark: Mark | null = null
  47. marks.between(view.viewport.from, view.viewport.to, (from, to, value) => {
  48. if (position >= from && position <= to) {
  49. targetMark = { from, to, value }
  50. return false
  51. }
  52. })
  53. if (!targetMark) {
  54. return
  55. }
  56. const { from, to, value } = targetMark as Mark
  57. const targetWord = value.spec.word
  58. if (!targetWord) {
  59. console.debug(
  60. '>> spelling no word associated with decorated range, stopping'
  61. )
  62. return
  63. }
  64. event.preventDefault()
  65. openingUntil = Date.now() + 100
  66. view.dispatch({
  67. selection: EditorSelection.range(from, to),
  68. effects: showSpellingMenu.of({
  69. mark: targetMark,
  70. word: targetWord,
  71. }),
  72. })
  73. }
  74. /*
  75. * Spelling menu "tooltip" field.
  76. * Manages the menu of suggestions shown on right-click
  77. */
  78. export const spellingMenuField = StateField.define<Tooltip | null>({
  79. create() {
  80. return null
  81. },
  82. update(menu, transaction) {
  83. for (const effect of transaction.effects) {
  84. if (effect.is(hideSpellingMenu)) {
  85. return null
  86. } else if (effect.is(showSpellingMenu)) {
  87. const { mark, word } = effect.value
  88. // Build a "Tooltip" showing the suggestions
  89. return {
  90. pos: mark.from,
  91. above: false,
  92. strictSide: false,
  93. create: view => {
  94. return createSpellingSuggestionList(mark, word, view)
  95. },
  96. }
  97. }
  98. }
  99. return menu
  100. },
  101. provide: field => {
  102. return [
  103. showTooltip.from(field),
  104. EditorView.domEventHandlers({
  105. contextmenu: handleContextMenuEvent,
  106. click: handleClickEvent,
  107. }),
  108. ]
  109. },
  110. })
  111. const showSpellingMenu = StateEffect.define<{ mark: Mark; word: Word }>()
  112. const hideSpellingMenu = StateEffect.define()
  113. /*
  114. * Creates the suggestion menu dom, to be displayed in the
  115. * spelling menu "tooltip"
  116. * */
  117. const createSpellingSuggestionList = (
  118. mark: Mark,
  119. word: Word,
  120. view: EditorView
  121. ) => {
  122. // Wrapper div.
  123. // Note, CM6 doesn't like showing complex elements
  124. // 'inside' its Tooltip element, so we style this
  125. // wrapper div to be basically invisible, and allow
  126. // the dropdown list to hang off of it, giving the illusion that
  127. // the list _is_ the tooltip.
  128. // See the theme in spelling/index for styling that makes this work.
  129. const dom = document.createElement('div')
  130. dom.classList.add('ol-cm-spelling-context-menu-tooltip')
  131. // List
  132. const list = document.createElement('ul')
  133. list.classList.add('dropdown-menu', 'dropdown-menu-unpositioned')
  134. // List items, with links inside
  135. if (Array.isArray(word.suggestions)) {
  136. for (const suggestion of word.suggestions.slice(0, ITEMS_TO_SHOW)) {
  137. const li = makeLinkItem(suggestion, event => {
  138. const text = (event.target as HTMLElement).innerText
  139. handleCorrectWord(mark, word, text, view)
  140. event.preventDefault()
  141. })
  142. list.appendChild(li)
  143. }
  144. }
  145. // Divider
  146. const divider = document.createElement('li')
  147. divider.classList.add('divider')
  148. list.append(divider)
  149. // Add to Dictionary
  150. const addToDictionary = makeLinkItem(
  151. 'Add to Dictionary',
  152. async function (event: Event) {
  153. await handleLearnWord(word, view)
  154. event.preventDefault()
  155. }
  156. )
  157. list.append(addToDictionary)
  158. dom.appendChild(list)
  159. return { dom }
  160. }
  161. const makeLinkItem = (suggestion: string, handler: EventListener) => {
  162. const li = document.createElement('li')
  163. const button = document.createElement('button')
  164. button.classList.add('btn-link', 'text-left', 'dropdown-menu-button')
  165. button.onclick = handler
  166. button.textContent = suggestion
  167. li.appendChild(button)
  168. return li
  169. }
  170. /*
  171. * Learn a word, adding it to the local cache
  172. * and sending it to the spelling backend
  173. */
  174. const handleLearnWord = async function (word: Word, view: EditorView) {
  175. try {
  176. await learnWordRequest(word)
  177. view.dispatch({
  178. effects: [addIgnoredWord.of(word), hideSpellingMenu.of(null)],
  179. })
  180. } catch (err) {
  181. console.error(err)
  182. }
  183. }
  184. /*
  185. * Correct a word, removing the marked range
  186. * and replacing it with the chosen text
  187. */
  188. const handleCorrectWord = (
  189. mark: Mark,
  190. word: Word,
  191. text: string,
  192. view: EditorView
  193. ) => {
  194. const existingText = view.state.doc.sliceString(mark.from, mark.to)
  195. // Defend against erroneous replacement, if the word at this
  196. // position is not actually what we think it is
  197. if (existingText !== word.text) {
  198. console.debug(
  199. '>> spelling word-to-correct does not match, stopping',
  200. mark,
  201. existingText,
  202. word
  203. )
  204. return
  205. }
  206. view.dispatch({
  207. changes: [
  208. {
  209. from: mark.from,
  210. to: mark.to,
  211. insert: text,
  212. },
  213. ],
  214. effects: [hideSpellingMenu.of(null)],
  215. })
  216. }