mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-15 14:43:46 +01:00
[cm6] select spell checked word with keyboard (#14257)
GitOrigin-RevId: 88b936a80fd63935c007276393a441a17a79c230
This commit is contained in:
committed by
Copybot
parent
427a2d92f7
commit
f71ddaed92
+97
-17
@@ -1,20 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
StateField,
|
StateField,
|
||||||
StateEffect,
|
StateEffect,
|
||||||
Range,
|
|
||||||
RangeValue,
|
|
||||||
EditorSelection,
|
EditorSelection,
|
||||||
|
Prec,
|
||||||
} from '@codemirror/state'
|
} from '@codemirror/state'
|
||||||
import { EditorView, showTooltip, Tooltip } from '@codemirror/view'
|
import { EditorView, showTooltip, Tooltip, keymap } from '@codemirror/view'
|
||||||
import { misspelledWordsField } from './misspelled-words'
|
|
||||||
import { addIgnoredWord } from './ignored-words'
|
import { addIgnoredWord } from './ignored-words'
|
||||||
import { learnWordRequest } from './backend'
|
import { learnWordRequest } from './backend'
|
||||||
import { Word } from './spellchecker'
|
import { Word, Mark, getMarkAtPosition } from './spellchecker'
|
||||||
|
|
||||||
const ITEMS_TO_SHOW = 8
|
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.
|
* 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.
|
* 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
|
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) {
|
if (!targetMark) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { from, to, value } = targetMark as Mark
|
const { from, to, value } = targetMark
|
||||||
|
|
||||||
const targetWord = value.spec.word
|
const targetWord = value.spec.word
|
||||||
if (!targetWord) {
|
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.
|
* Spelling menu "tooltip" field.
|
||||||
* Manages the menu of suggestions shown on right-click
|
* Manages the menu of suggestions shown on right-click
|
||||||
@@ -119,6 +125,12 @@ export const spellingMenuField = StateField.define<Tooltip | null>({
|
|||||||
contextmenu: handleContextMenuEvent,
|
contextmenu: handleContextMenuEvent,
|
||||||
click: handleClickEvent,
|
click: handleClickEvent,
|
||||||
}),
|
}),
|
||||||
|
Prec.highest(
|
||||||
|
keymap.of([
|
||||||
|
{ key: 'Ctrl-Space', run: handleShortcutEvent },
|
||||||
|
{ key: 'Alt-Space', run: handleShortcutEvent },
|
||||||
|
])
|
||||||
|
),
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -148,6 +160,68 @@ const createSpellingSuggestionList = (
|
|||||||
|
|
||||||
// List
|
// List
|
||||||
const list = document.createElement('ul')
|
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.classList.add('dropdown-menu', 'dropdown-menu-unpositioned')
|
||||||
|
|
||||||
// List items, with links inside
|
// List items, with links inside
|
||||||
@@ -162,6 +236,10 @@ const createSpellingSuggestionList = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
list.querySelector<HTMLButtonElement>('li:first-child button')?.focus()
|
||||||
|
}, 0)
|
||||||
|
|
||||||
// Divider
|
// Divider
|
||||||
const divider = document.createElement('li')
|
const divider = document.createElement('li')
|
||||||
divider.classList.add('divider')
|
divider.classList.add('divider')
|
||||||
@@ -184,6 +262,7 @@ const createSpellingSuggestionList = (
|
|||||||
const makeLinkItem = (suggestion: string, handler: EventListener) => {
|
const makeLinkItem = (suggestion: string, handler: EventListener) => {
|
||||||
const li = document.createElement('li')
|
const li = document.createElement('li')
|
||||||
const button = document.createElement('button')
|
const button = document.createElement('button')
|
||||||
|
li.setAttribute('role', 'option')
|
||||||
button.classList.add('btn-link', 'text-left', 'dropdown-menu-button')
|
button.classList.add('btn-link', 'text-left', 'dropdown-menu-button')
|
||||||
button.onclick = handler
|
button.onclick = handler
|
||||||
button.textContent = suggestion
|
button.textContent = suggestion
|
||||||
@@ -238,4 +317,5 @@ const handleCorrectWord = (
|
|||||||
],
|
],
|
||||||
effects: [hideSpellingMenu.of(null)],
|
effects: [hideSpellingMenu.of(null)],
|
||||||
})
|
})
|
||||||
|
view.focus()
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-2
@@ -1,4 +1,4 @@
|
|||||||
import { addMisspelledWords } from './misspelled-words'
|
import { addMisspelledWords, misspelledWordsField } from './misspelled-words'
|
||||||
import { ignoredWordsField, resetSpellChecker } from './ignored-words'
|
import { ignoredWordsField, resetSpellChecker } from './ignored-words'
|
||||||
import { LineTracker } from './line-tracker'
|
import { LineTracker } from './line-tracker'
|
||||||
import { cacheField, addWordToCache, WordCacheValue } from './cache'
|
import { cacheField, addWordToCache, WordCacheValue } from './cache'
|
||||||
@@ -6,7 +6,7 @@ import { WORD_REGEX } from './helpers'
|
|||||||
import OError from '@overleaf/o-error'
|
import OError from '@overleaf/o-error'
|
||||||
import { spellCheckRequest } from './backend'
|
import { spellCheckRequest } from './backend'
|
||||||
import { EditorView, ViewUpdate } from '@codemirror/view'
|
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 { IgnoredWords } from '../../../dictionary/ignored-words'
|
||||||
import {
|
import {
|
||||||
getNormalTextSpansFromLine,
|
getNormalTextSpansFromLine,
|
||||||
@@ -385,3 +385,21 @@ export const getWordsFromLine = (
|
|||||||
})
|
})
|
||||||
return words
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user