From ca0fa449d5735049ec5aae207739123b574aa993 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Mon, 5 May 2025 03:27:23 +0200 Subject: [PATCH] Symbol Palette: improve keyboard input experience --- .../components/symbol-palette-close-button.js | 12 ++- .../components/symbol-palette-item.js | 22 ++++-- .../components/symbol-palette-items.js | 78 +++++++++++++------ 3 files changed, 83 insertions(+), 29 deletions(-) diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js index dbde7ca775..0a2d3c02fc 100644 --- a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js +++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js @@ -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 (
) } + +SymbolPaletteCloseButton.propTypes = { + focusInput: PropTypes.func, +} diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-item.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-item.js index 1369534106..400da81b1e 100644 --- a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-item.js +++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-item.js @@ -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({ ) -} +}) SymbolPaletteItem.propTypes = { symbol: PropTypes.shape({ @@ -70,3 +81,4 @@ SymbolPaletteItem.propTypes = { handleSelect: PropTypes.func.isRequired, focused: PropTypes.bool, } +export default SymbolPaletteItem diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-items.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-items.js index 44835261f5..8d95439f5a 100644 --- a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-items.js +++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-items.js @@ -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 + }} /> ))} ) } + SymbolPaletteItems.propTypes = { items: PropTypes.arrayOf( PropTypes.shape({ @@ -84,3 +115,4 @@ SymbolPaletteItems.propTypes = { handleSelect: PropTypes.func.isRequired, focusInput: PropTypes.func.isRequired, } +