context-menu.ts 8.5 KB

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