context-menu.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  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 { addIgnoredWord } from './ignored-words'
  9. import { learnWordRequest } from './backend'
  10. import { Word, Mark, getMarkAtPosition } from './spellchecker'
  11. const ITEMS_TO_SHOW = 8
  12. /*
  13. * The time until which a click event will be ignored, so it doesn't immediately close the spelling menu.
  14. * Safari emits an additional "click" event when event.preventDefault() is called in the "contextmenu" event listener.
  15. */
  16. let openingUntil = 0
  17. /*
  18. * Hide the spelling menu on click
  19. */
  20. const handleClickEvent = (event: MouseEvent, view: EditorView) => {
  21. if (Date.now() < openingUntil) {
  22. return
  23. }
  24. if (view.state.field(spellingMenuField, false)) {
  25. view.dispatch({
  26. effects: hideSpellingMenu.of(null),
  27. })
  28. }
  29. }
  30. /*
  31. * Detect when the user right-clicks on a misspelled word,
  32. * and show a menu of suggestions
  33. */
  34. const handleContextMenuEvent = (event: MouseEvent, view: EditorView) => {
  35. const position = view.posAtCoords(
  36. {
  37. x: event.pageX,
  38. y: event.pageY,
  39. },
  40. false
  41. )
  42. const targetMark = getMarkAtPosition(view, position)
  43. if (!targetMark) {
  44. return
  45. }
  46. const { from, to, value } = targetMark
  47. const targetWord = value.spec.word
  48. if (!targetWord) {
  49. console.debug(
  50. '>> spelling no word associated with decorated range, stopping'
  51. )
  52. return
  53. }
  54. event.preventDefault()
  55. openingUntil = Date.now() + 100
  56. view.dispatch({
  57. selection: EditorSelection.range(from, to),
  58. effects: showSpellingMenu.of({
  59. mark: targetMark,
  60. word: targetWord,
  61. }),
  62. })
  63. }
  64. const handleShortcutEvent = (view: EditorView) => {
  65. const targetMark = getMarkAtPosition(view, view.state.selection.main.from)
  66. if (!targetMark || !targetMark.value) {
  67. return false
  68. }
  69. view.dispatch({
  70. selection: EditorSelection.range(targetMark.from, targetMark.to),
  71. effects: showSpellingMenu.of({
  72. mark: targetMark,
  73. word: targetMark.value.spec.word,
  74. }),
  75. })
  76. return true
  77. }
  78. /*
  79. * Spelling menu "tooltip" field.
  80. * Manages the menu of suggestions shown on right-click
  81. */
  82. export const spellingMenuField = StateField.define<Tooltip | null>({
  83. create() {
  84. return null
  85. },
  86. update(menu, transaction) {
  87. for (const effect of transaction.effects) {
  88. if (effect.is(hideSpellingMenu)) {
  89. return null
  90. } else if (effect.is(showSpellingMenu)) {
  91. const { mark, word } = effect.value
  92. // Build a "Tooltip" showing the suggestions
  93. return {
  94. pos: mark.from,
  95. above: false,
  96. strictSide: false,
  97. create: view => {
  98. return createSpellingSuggestionList(mark, word, view)
  99. },
  100. }
  101. }
  102. }
  103. return menu
  104. },
  105. provide: field => {
  106. return [
  107. showTooltip.from(field),
  108. EditorView.domEventHandlers({
  109. contextmenu: handleContextMenuEvent,
  110. click: handleClickEvent,
  111. }),
  112. Prec.highest(
  113. keymap.of([
  114. { key: 'Ctrl-Space', run: handleShortcutEvent },
  115. { key: 'Alt-Space', run: handleShortcutEvent },
  116. ])
  117. ),
  118. ]
  119. },
  120. })
  121. const showSpellingMenu = StateEffect.define<{ mark: Mark; word: Word }>()
  122. const hideSpellingMenu = StateEffect.define()
  123. /*
  124. * Creates the suggestion menu dom, to be displayed in the
  125. * spelling menu "tooltip"
  126. * */
  127. const createSpellingSuggestionList = (
  128. mark: Mark,
  129. word: Word,
  130. view: EditorView
  131. ) => {
  132. // Wrapper div.
  133. // Note, CM6 doesn't like showing complex elements
  134. // 'inside' its Tooltip element, so we style this
  135. // wrapper div to be basically invisible, and allow
  136. // the dropdown list to hang off of it, giving the illusion that
  137. // the list _is_ the tooltip.
  138. // See the theme in spelling/index for styling that makes this work.
  139. const dom = document.createElement('div')
  140. dom.classList.add('ol-cm-spelling-context-menu-tooltip')
  141. // List
  142. const list = document.createElement('ul')
  143. list.setAttribute('tabindex', '0')
  144. list.setAttribute('role', 'menu')
  145. list.addEventListener('keydown', event => {
  146. if (event.code === 'Tab') {
  147. // preventing selecting next element
  148. event.preventDefault()
  149. }
  150. })
  151. list.addEventListener('keyup', event => {
  152. switch (event.code) {
  153. case 'ArrowDown': {
  154. // get currently selected option
  155. const selectedButton =
  156. list.querySelector<HTMLButtonElement>('li button:focus')
  157. if (!selectedButton) {
  158. return list
  159. .querySelector<HTMLButtonElement>('li[role="option"] button')
  160. ?.focus()
  161. }
  162. // get next option
  163. let nextElement = selectedButton.parentElement?.nextElementSibling
  164. if (nextElement?.role !== 'option') {
  165. nextElement = nextElement?.nextElementSibling
  166. }
  167. nextElement?.querySelector('button')?.focus()
  168. break
  169. }
  170. case 'ArrowUp': {
  171. // get currently selected option
  172. const selectedButton =
  173. list.querySelector<HTMLButtonElement>('li button:focus')
  174. if (!selectedButton) {
  175. return list
  176. .querySelector<HTMLButtonElement>(
  177. 'li[role="option"]:last-child button'
  178. )
  179. ?.focus()
  180. }
  181. // get previous option
  182. let previousElement =
  183. selectedButton.parentElement?.previousElementSibling
  184. if (previousElement?.role !== 'option') {
  185. previousElement = previousElement?.previousElementSibling
  186. }
  187. previousElement?.querySelector('button')?.focus()
  188. break
  189. }
  190. case 'Escape':
  191. case 'Tab': {
  192. view.dispatch({
  193. effects: hideSpellingMenu.of(null),
  194. })
  195. view.focus()
  196. break
  197. }
  198. }
  199. })
  200. list.classList.add('dropdown-menu', 'dropdown-menu-unpositioned')
  201. // List items, with links inside
  202. if (Array.isArray(word.suggestions)) {
  203. for (const suggestion of word.suggestions.slice(0, ITEMS_TO_SHOW)) {
  204. const li = makeLinkItem(suggestion, event => {
  205. const text = (event.target as HTMLElement).innerText
  206. handleCorrectWord(mark, word, text, view)
  207. event.preventDefault()
  208. })
  209. list.appendChild(li)
  210. }
  211. }
  212. setTimeout(() => {
  213. list.querySelector<HTMLButtonElement>('li:first-child button')?.focus()
  214. }, 0)
  215. // Divider
  216. const divider = document.createElement('li')
  217. divider.classList.add('divider')
  218. list.append(divider)
  219. // Add to Dictionary
  220. const addToDictionary = makeLinkItem(
  221. 'Add to Dictionary',
  222. async function (event: Event) {
  223. await handleLearnWord(word, view)
  224. event.preventDefault()
  225. }
  226. )
  227. list.append(addToDictionary)
  228. dom.appendChild(list)
  229. return { dom }
  230. }
  231. const makeLinkItem = (suggestion: string, handler: EventListener) => {
  232. const li = document.createElement('li')
  233. const button = document.createElement('button')
  234. li.setAttribute('role', 'option')
  235. button.classList.add('btn-link', 'text-left', 'dropdown-menu-button')
  236. button.onclick = handler
  237. button.textContent = suggestion
  238. li.appendChild(button)
  239. return li
  240. }
  241. /*
  242. * Learn a word, adding it to the local cache
  243. * and sending it to the spelling backend
  244. */
  245. const handleLearnWord = async function (word: Word, view: EditorView) {
  246. try {
  247. await learnWordRequest(word)
  248. view.dispatch({
  249. effects: [addIgnoredWord.of(word), hideSpellingMenu.of(null)],
  250. })
  251. } catch (err) {
  252. console.error(err)
  253. }
  254. }
  255. /*
  256. * Correct a word, removing the marked range
  257. * and replacing it with the chosen text
  258. */
  259. const handleCorrectWord = (
  260. mark: Mark,
  261. word: Word,
  262. text: string,
  263. view: EditorView
  264. ) => {
  265. const existingText = view.state.doc.sliceString(mark.from, mark.to)
  266. // Defend against erroneous replacement, if the word at this
  267. // position is not actually what we think it is
  268. if (existingText !== word.text) {
  269. console.debug(
  270. '>> spelling word-to-correct does not match, stopping',
  271. mark,
  272. existingText,
  273. word
  274. )
  275. return
  276. }
  277. view.dispatch({
  278. changes: [
  279. {
  280. from: mark.from,
  281. to: mark.to,
  282. insert: text,
  283. },
  284. ],
  285. effects: [hideSpellingMenu.of(null)],
  286. })
  287. view.focus()
  288. }