mirror of
https://github.com/overleaf/overleaf.git
synced 2025-02-10 07:11:58 +00:00
[cm6] select spell checked word with keyboard (#14257)
GitOrigin-RevId: 88b936a80fd63935c007276393a441a17a79c230
This commit is contained in:
parent
fc9d3755c2
commit
b126d1f8f6
2 changed files with 117 additions and 19 deletions
|
@ -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<RangeValue & { spec: { word: Word } }>
|
||||
|
||||
/*
|
||||
* 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<Tooltip | null>({
|
|||
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<HTMLButtonElement>('li button:focus')
|
||||
|
||||
if (!selectedButton) {
|
||||
return list
|
||||
.querySelector<HTMLButtonElement>('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<HTMLButtonElement>('li button:focus')
|
||||
|
||||
if (!selectedButton) {
|
||||
return list
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'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<HTMLButtonElement>('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()
|
||||
}
|
||||
|
|
|
@ -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<RangeValue & { spec: { word: Word } }>
|
||||
|
||||
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
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue