Symbol Palette: improve keyboard input experience

This commit is contained in:
yu-i-i
2025-05-05 03:27:23 +02:00
parent ea2c644695
commit 7e91256ae3
3 changed files with 83 additions and 29 deletions

View File

@@ -1,19 +1,29 @@
import { useEditorContext } from '../../../shared/context/editor-context'
import { useTranslation } from 'react-i18next'
import PropTypes from 'prop-types'
export default function SymbolPaletteCloseButton() {
const { toggleSymbolPalette } = useEditorContext()
const { t } = useTranslation()
const handleClick = () => {
toggleSymbolPalette()
window.dispatchEvent(new CustomEvent('editor:focus'))
}
return (
<div className="symbol-palette-close-button-outer">
<button
type="button"
className="btn-close symbol-palette-close-button"
onClick={toggleSymbolPalette}
onClick={handleClick}
aria-label={t('close')}
>
</button>
</div>
)
}
SymbolPaletteCloseButton.propTypes = {
focusInput: PropTypes.func,
}

View File

@@ -1,16 +1,27 @@
import { useEffect, useRef } from 'react'
import { useEffect, useRef, forwardRef } from 'react'
import PropTypes from 'prop-types'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
export default function SymbolPaletteItem({
const SymbolPaletteItem = forwardRef(function ({
focused,
handleSelect,
handleKeyDown,
symbol,
}) {
}, ref) {
const buttonRef = useRef(null)
// call focus() on this item when appropriate
// Forward internal ref to parent
useEffect(() => {
if (ref) {
if (typeof ref === 'function') {
ref(buttonRef.current)
} else {
ref.current = buttonRef.current
}
}
}, [ref])
// Focus the item when it becomes focused
useEffect(() => {
if (
focused &&
@@ -56,7 +67,7 @@ export default function SymbolPaletteItem({
</button>
</OLTooltip>
)
}
})
SymbolPaletteItem.propTypes = {
symbol: PropTypes.shape({
@@ -70,3 +81,4 @@ SymbolPaletteItem.propTypes = {
handleSelect: PropTypes.func.isRequired,
focused: PropTypes.bool,
}
export default SymbolPaletteItem

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import PropTypes from 'prop-types'
import { useEditorContext } from '../../../shared/context/editor-context'
import SymbolPaletteItem from './symbol-palette-item'
export default function SymbolPaletteItems({
@@ -8,54 +9,80 @@ export default function SymbolPaletteItems({
focusInput,
}) {
const [focusedIndex, setFocusedIndex] = useState(0)
const itemRefs = useRef([])
// reset the focused item when the list of items changes
useEffect(() => {
itemRefs.current = items.map((_, i) => itemRefs.current[i] || null)
setFocusedIndex(0)
}, [items])
// navigate through items with left and right arrows
const getItemRects = () => {
return itemRefs.current.map(ref => ref?.getBoundingClientRect?.() ?? null)
}
const { toggleSymbolPalette } = useEditorContext()
const handleKeyDown = useCallback(
event => {
if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) {
return
}
if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return
const rects = getItemRects()
const currentRect = rects[focusedIndex]
if (!currentRect) return
let newIndex = focusedIndex
switch (event.key) {
// focus previous item
case 'ArrowLeft':
case 'ArrowUp':
setFocusedIndex(index => (index > 0 ? index - 1 : items.length - 1))
newIndex = focusedIndex > 0 ? focusedIndex - 1 : items.length - 1
break
// focus next item
case 'ArrowRight':
case 'ArrowDown':
setFocusedIndex(index => (index < items.length - 1 ? index + 1 : 0))
newIndex = focusedIndex < items.length - 1 ? focusedIndex + 1 : 0
break
case 'ArrowUp':
case 'ArrowDown': {
const direction = event.key === 'ArrowUp' ? -1 : 1
const candidates = rects
.map((rect, i) => ({ rect, i }))
.filter(({ rect }, i) =>
i !== focusedIndex &&
rect &&
Math.abs(rect.x - currentRect.x) < currentRect.width * 0.8 &&
(direction === -1 ? rect.y < currentRect.y : rect.y > currentRect.y)
)
// focus first item
if (candidates.length > 0) {
const closest = candidates.reduce((a, b) =>
Math.abs(b.rect.y - currentRect.y) < Math.abs(a.rect.y - currentRect.y) ? b : a
)
newIndex = closest.i
}
break
}
case 'Home':
setFocusedIndex(0)
newIndex = 0
break
// focus last item
case 'End':
setFocusedIndex(items.length - 1)
newIndex = items.length - 1
break
// allow the default action
case 'Enter':
case ' ':
handleSelect(items[focusedIndex])
toggleSymbolPalette()
break
case 'Escape':
toggleSymbolPalette()
window.dispatchEvent(new CustomEvent('editor:focus'))
break
// any other key returns focus to the input
default:
focusInput()
break
return
}
event.preventDefault()
setFocusedIndex(newIndex)
},
[focusInput, items.length]
[focusedIndex, items, focusInput, handleSelect]
)
return (
@@ -70,11 +97,15 @@ export default function SymbolPaletteItems({
}}
handleKeyDown={handleKeyDown}
focused={index === focusedIndex}
ref={el => {
itemRefs.current[index] = el
}}
/>
))}
</div>
)
}
SymbolPaletteItems.propTypes = {
items: PropTypes.arrayOf(
PropTypes.shape({
@@ -84,3 +115,4 @@ SymbolPaletteItems.propTypes = {
handleSelect: PropTypes.func.isRequired,
focusInput: PropTypes.func.isRequired,
}