context-menu.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  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(value, transaction) {
  87. if (value) {
  88. value = {
  89. ...value,
  90. pos: transaction.changes.mapPos(value.pos),
  91. end: value.end ? transaction.changes.mapPos(value.end) : undefined,
  92. }
  93. }
  94. for (const effect of transaction.effects) {
  95. if (effect.is(hideSpellingMenu)) {
  96. value = null
  97. } else if (effect.is(showSpellingMenu)) {
  98. const { mark, word } = effect.value
  99. // Build a "Tooltip" showing the suggestions
  100. value = {
  101. pos: mark.from,
  102. end: mark.to,
  103. above: false,
  104. strictSide: false,
  105. create: view => {
  106. return createSpellingSuggestionList(word, view)
  107. },
  108. }
  109. }
  110. }
  111. return value
  112. },
  113. provide: field => {
  114. return [
  115. showTooltip.from(field),
  116. EditorView.domEventHandlers({
  117. contextmenu: handleContextMenuEvent,
  118. click: handleClickEvent,
  119. }),
  120. Prec.highest(
  121. keymap.of([
  122. { key: 'Ctrl-Space', run: handleShortcutEvent },
  123. { key: 'Alt-Space', run: handleShortcutEvent },
  124. ])
  125. ),
  126. ]
  127. },
  128. })
  129. const showSpellingMenu = StateEffect.define<{ mark: Mark; word: Word }>()
  130. const hideSpellingMenu = StateEffect.define()
  131. /*
  132. * Creates the suggestion menu dom, to be displayed in the
  133. * spelling menu "tooltip"
  134. * */
  135. const createSpellingSuggestionList = (word: Word, view: EditorView) => {
  136. // Wrapper div.
  137. // Note, CM6 doesn't like showing complex elements
  138. // 'inside' its Tooltip element, so we style this
  139. // wrapper div to be basically invisible, and allow
  140. // the dropdown list to hang off of it, giving the illusion that
  141. // the list _is_ the tooltip.
  142. // See the theme in spelling/index for styling that makes this work.
  143. const dom = document.createElement('div')
  144. dom.classList.add('ol-cm-spelling-context-menu-tooltip')
  145. // List
  146. const list = document.createElement('ul')
  147. list.setAttribute('tabindex', '0')
  148. list.setAttribute('role', 'menu')
  149. list.addEventListener('keydown', event => {
  150. if (event.code === 'Tab') {
  151. // preventing selecting next element
  152. event.preventDefault()
  153. }
  154. })
  155. list.addEventListener('keyup', event => {
  156. switch (event.code) {
  157. case 'ArrowDown': {
  158. // get currently selected option
  159. const selectedButton =
  160. list.querySelector<HTMLButtonElement>('li button:focus')
  161. if (!selectedButton) {
  162. return list
  163. .querySelector<HTMLButtonElement>('li[role="option"] button')
  164. ?.focus()
  165. }
  166. // get next option
  167. let nextElement = selectedButton.parentElement?.nextElementSibling
  168. if (nextElement?.role !== 'option') {
  169. nextElement = nextElement?.nextElementSibling
  170. }
  171. nextElement?.querySelector('button')?.focus()
  172. break
  173. }
  174. case 'ArrowUp': {
  175. // get currently selected option
  176. const selectedButton =
  177. list.querySelector<HTMLButtonElement>('li button:focus')
  178. if (!selectedButton) {
  179. return list
  180. .querySelector<HTMLButtonElement>(
  181. 'li[role="option"]:last-child button'
  182. )
  183. ?.focus()
  184. }
  185. // get previous option
  186. let previousElement =
  187. selectedButton.parentElement?.previousElementSibling
  188. if (previousElement?.role !== 'option') {
  189. previousElement = previousElement?.previousElementSibling
  190. }
  191. previousElement?.querySelector('button')?.focus()
  192. break
  193. }
  194. case 'Escape':
  195. case 'Tab': {
  196. view.dispatch({
  197. effects: hideSpellingMenu.of(null),
  198. })
  199. view.focus()
  200. break
  201. }
  202. }
  203. })
  204. list.classList.add('dropdown-menu', 'dropdown-menu-unpositioned')
  205. // List items, with links inside
  206. if (Array.isArray(word.suggestions)) {
  207. for (const suggestion of word.suggestions.slice(0, ITEMS_TO_SHOW)) {
  208. const li = makeLinkItem(suggestion, event => {
  209. const text = (event.target as HTMLElement).innerText
  210. handleCorrectWord(word, text, view)
  211. event.preventDefault()
  212. })
  213. list.appendChild(li)
  214. }
  215. }
  216. setTimeout(() => {
  217. list.querySelector<HTMLButtonElement>('li:first-child button')?.focus()
  218. }, 0)
  219. // Divider
  220. const divider = document.createElement('li')
  221. divider.classList.add('divider')
  222. list.append(divider)
  223. // Add to Dictionary
  224. const addToDictionary = makeLinkItem(
  225. 'Add to Dictionary',
  226. async function (event: Event) {
  227. await handleLearnWord(word, view)
  228. event.preventDefault()
  229. }
  230. )
  231. list.append(addToDictionary)
  232. dom.appendChild(list)
  233. return { dom }
  234. }
  235. const makeLinkItem = (suggestion: string, handler: EventListener) => {
  236. const li = document.createElement('li')
  237. const button = document.createElement('button')
  238. li.setAttribute('role', 'option')
  239. button.classList.add('btn-link', 'text-left', 'dropdown-menu-button')
  240. button.onclick = handler
  241. button.textContent = suggestion
  242. li.appendChild(button)
  243. return li
  244. }
  245. /*
  246. * Learn a word, adding it to the local cache
  247. * and sending it to the spelling backend
  248. */
  249. const handleLearnWord = async function (word: Word, view: EditorView) {
  250. try {
  251. await learnWordRequest(word)
  252. view.dispatch({
  253. effects: [addIgnoredWord.of(word), hideSpellingMenu.of(null)],
  254. })
  255. } catch (err) {
  256. console.error(err)
  257. }
  258. }
  259. /*
  260. * Correct a word, removing the marked range
  261. * and replacing it with the chosen text
  262. */
  263. const handleCorrectWord = (word: Word, text: string, view: EditorView) => {
  264. const tooltip = view.state.field(spellingMenuField)
  265. if (!tooltip) {
  266. throw new Error('No active tooltip')
  267. }
  268. const existingText = view.state.doc.sliceString(tooltip.pos, tooltip.end)
  269. // Defend against erroneous replacement, if the word at this
  270. // position is not actually what we think it is
  271. if (existingText !== word.text) {
  272. console.debug(
  273. '>> spelling word-to-correct does not match, stopping',
  274. tooltip.pos,
  275. tooltip.end,
  276. existingText,
  277. word
  278. )
  279. return
  280. }
  281. view.dispatch({
  282. changes: [
  283. {
  284. from: tooltip.pos,
  285. to: tooltip.end,
  286. insert: text,
  287. },
  288. ],
  289. effects: [hideSpellingMenu.of(null)],
  290. })
  291. view.focus()
  292. }