From f71ddaed92a7ef4691348ddaceefc41895f2dd0d Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Fri, 18 Aug 2023 12:21:44 +0200 Subject: [PATCH] [cm6] select spell checked word with keyboard (#14257) GitOrigin-RevId: 88b936a80fd63935c007276393a441a17a79c230 --- .../extensions/spelling/context-menu.ts | 114 +++++++++++++++--- .../extensions/spelling/spellchecker.ts | 22 +++- 2 files changed, 117 insertions(+), 19 deletions(-) diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.ts b/services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.ts index 3a6ad49f31..bf76e98a95 100644 --- a/services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.ts +++ b/services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.ts @@ -1,20 +1,16 @@ import { StateField, StateEffect, - Range, - RangeValue, EditorSelection, + Prec, } from '@codemirror/state' -import { EditorView, showTooltip, Tooltip } from '@codemirror/view' -import { misspelledWordsField } from './misspelled-words' +import { EditorView, showTooltip, Tooltip, keymap } from '@codemirror/view' import { addIgnoredWord } from './ignored-words' import { learnWordRequest } from './backend' -import { Word } from './spellchecker' +import { Word, Mark, getMarkAtPosition } from './spellchecker' const ITEMS_TO_SHOW = 8 -type Mark = Range - /* * The time until which a click event will be ignored, so it doesn't immediately close the spelling menu. * Safari emits an additional "click" event when event.preventDefault() is called in the "contextmenu" event listener. @@ -48,21 +44,13 @@ const handleContextMenuEvent = (event: MouseEvent, view: EditorView) => { }, false ) + const targetMark = getMarkAtPosition(view, position) - const marks = view.state.field(misspelledWordsField) - - let targetMark: Mark | null = null - marks.between(view.viewport.from, view.viewport.to, (from, to, value) => { - if (position >= from && position <= to) { - targetMark = { from, to, value } - return false - } - }) if (!targetMark) { return } - const { from, to, value } = targetMark as Mark + const { from, to, value } = targetMark const targetWord = value.spec.word if (!targetWord) { @@ -85,6 +73,24 @@ const handleContextMenuEvent = (event: MouseEvent, view: EditorView) => { }) } +const handleShortcutEvent = (view: EditorView) => { + const targetMark = getMarkAtPosition(view, view.state.selection.main.from) + + if (!targetMark || !targetMark.value) { + return false + } + + view.dispatch({ + selection: EditorSelection.range(targetMark.from, targetMark.to), + effects: showSpellingMenu.of({ + mark: targetMark, + word: targetMark.value.spec.word, + }), + }) + + return true +} + /* * Spelling menu "tooltip" field. * Manages the menu of suggestions shown on right-click @@ -119,6 +125,12 @@ export const spellingMenuField = StateField.define({ contextmenu: handleContextMenuEvent, click: handleClickEvent, }), + Prec.highest( + keymap.of([ + { key: 'Ctrl-Space', run: handleShortcutEvent }, + { key: 'Alt-Space', run: handleShortcutEvent }, + ]) + ), ] }, }) @@ -148,6 +160,68 @@ const createSpellingSuggestionList = ( // List const list = document.createElement('ul') + list.setAttribute('tabindex', '0') + list.setAttribute('role', 'menu') + list.addEventListener('keydown', event => { + if (event.code === 'Tab') { + // preventing selecting next element + event.preventDefault() + } + }) + list.addEventListener('keyup', event => { + switch (event.code) { + case 'ArrowDown': { + // get currently selected option + const selectedButton = + list.querySelector('li button:focus') + + if (!selectedButton) { + return list + .querySelector('li[role="option"] button') + ?.focus() + } + + // get next option + let nextElement = selectedButton.parentElement?.nextElementSibling + if (nextElement?.role !== 'option') { + nextElement = nextElement?.nextElementSibling + } + nextElement?.querySelector('button')?.focus() + break + } + case 'ArrowUp': { + // get currently selected option + const selectedButton = + list.querySelector('li button:focus') + + if (!selectedButton) { + return list + .querySelector( + 'li[role="option"]:last-child button' + ) + ?.focus() + } + + // get previous option + let previousElement = + selectedButton.parentElement?.previousElementSibling + if (previousElement?.role !== 'option') { + previousElement = previousElement?.previousElementSibling + } + previousElement?.querySelector('button')?.focus() + break + } + case 'Escape': + case 'Tab': { + view.dispatch({ + effects: hideSpellingMenu.of(null), + }) + view.focus() + break + } + } + }) + list.classList.add('dropdown-menu', 'dropdown-menu-unpositioned') // List items, with links inside @@ -162,6 +236,10 @@ const createSpellingSuggestionList = ( } } + setTimeout(() => { + list.querySelector('li:first-child button')?.focus() + }, 0) + // Divider const divider = document.createElement('li') divider.classList.add('divider') @@ -184,6 +262,7 @@ const createSpellingSuggestionList = ( const makeLinkItem = (suggestion: string, handler: EventListener) => { const li = document.createElement('li') const button = document.createElement('button') + li.setAttribute('role', 'option') button.classList.add('btn-link', 'text-left', 'dropdown-menu-button') button.onclick = handler button.textContent = suggestion @@ -238,4 +317,5 @@ const handleCorrectWord = ( ], effects: [hideSpellingMenu.of(null)], }) + view.focus() } diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/spellchecker.ts b/services/web/frontend/js/features/source-editor/extensions/spelling/spellchecker.ts index 0a334496b9..57331689dc 100644 --- a/services/web/frontend/js/features/source-editor/extensions/spelling/spellchecker.ts +++ b/services/web/frontend/js/features/source-editor/extensions/spelling/spellchecker.ts @@ -1,4 +1,4 @@ -import { addMisspelledWords } from './misspelled-words' +import { addMisspelledWords, misspelledWordsField } from './misspelled-words' import { ignoredWordsField, resetSpellChecker } from './ignored-words' import { LineTracker } from './line-tracker' import { cacheField, addWordToCache, WordCacheValue } from './cache' @@ -6,7 +6,7 @@ import { WORD_REGEX } from './helpers' import OError from '@overleaf/o-error' import { spellCheckRequest } from './backend' import { EditorView, ViewUpdate } from '@codemirror/view' -import { Line } from '@codemirror/state' +import { Line, Range, RangeValue } from '@codemirror/state' import { IgnoredWords } from '../../../dictionary/ignored-words' import { getNormalTextSpansFromLine, @@ -385,3 +385,21 @@ export const getWordsFromLine = ( }) return words } + +export type Mark = Range + +export const getMarkAtPosition = ( + view: EditorView, + position: number +): Mark | null => { + const marks = view.state.field(misspelledWordsField) + + let targetMark: Mark | null = null + marks.between(view.viewport.from, view.viewport.to, (from, to, value) => { + if (position >= from && position <= to) { + targetMark = { from, to, value } + return false + } + }) + return targetMark +}