diff --git a/ui/src/components/sources/SourceShow.tsx b/ui/src/components/sources/SourceShow.tsx index 5e7c938e..c5d987d4 100644 --- a/ui/src/components/sources/SourceShow.tsx +++ b/ui/src/components/sources/SourceShow.tsx @@ -24,7 +24,7 @@ import NoteCreate from "../notes/NoteCreate.tsx" import { StopKeyboardContext, useStopKeyboard } from "./SourceShow_SourceComponent.ts" import { getUsage, useFocusTextWithKeyboard } from "./SourceShow_SourceNavComponent.ts" import { otherTranslationTexts, useChangeTermWithKeyboard } from "./SourceShow_TermsComponent.ts" -import { useFocusTokenWithKeyboard } from "./SourceShow_TokensComponent.ts" +import { SelectedToken, useFocusTokenWithKeyboard } from "./SourceShow_TokensComponent.ts" import { PartCreateForm, PartUpdateForm, @@ -32,7 +32,7 @@ import { SourceEditHeader, SourcePartDetailMenu, } from "./SourceShow_Update.tsx" -import React, { useContext, useEffect, useMemo, useRef, useState } from "react" +import React, { SyntheticEvent, useContext, useEffect, useMemo, useRef, useState } from "react" import { useFetcher } from "react-router-dom" export interface ISourceShowData { @@ -249,24 +249,38 @@ const SourceNavComponent: React.FC<{ readonly source: Source; readonly safeSet: [createNoteData, setStopKeyboardEvents], ) - const [selectedToken, setSelectedToken] = useState(null) + const [selectedToken, setSelectedToken] = useState(null) const isTokenSelected = selectedToken !== null - const [partFocusIndex, textFocusIndex, focusElement, setText] = useFocusTextWithKeyboard( + const [partFocusIndex, textFocusIndex, setText] = useFocusTextWithKeyboard( source.parts, isTokenSelected, () => setCreateNoteData(createNoteDataFromUsage(getUsage(source, partFocusIndex, textFocusIndex))), () => setSelectedToken(null), ) + const setTextOnClick = ( + e: SyntheticEvent, + textFocused: boolean, + partIndex: number, + textIndex: number, + // eslint-disable-next-line max-params + ) => { + if (textFocused) return + e.preventDefault() + setText(partIndex, textIndex) + setCustomToken(null) + setSelectedToken(null) + } - const textRefs = useRef<(HTMLDivElement | null)[][]>([]) - useEffect(() => { + const [customToken, setCustomToken] = useState(null) + const onCustomToken = (token: SelectedToken | null) => { + setCustomToken(token) setSelectedToken(null) - const textElement = textRefs.current[partFocusIndex][textFocusIndex] - if (!textElement) return - focusElement(textElement) - scrollTo(textElement) - }, [focusElement, partFocusIndex, textFocusIndex]) + } + const onTokenSearch = (token: SelectedToken) => { + setCustomToken(null) + setSelectedToken(token) + } return ( <> @@ -277,9 +291,7 @@ const SourceNavComponent: React.FC<{ readonly source: Source; readonly safeSet: setCreateNoteData(null) setSelectedToken(null) }} - onClose={() => { - setCreateNoteData(null) - }} + onClose={() => setCreateNoteData(null)} /> )} @@ -287,13 +299,8 @@ const SourceNavComponent: React.FC<{ readonly source: Source; readonly safeSet: const textFocused = partIndex === partFocusIndex && textIndex === textFocusIndex return (
{ - if (!textRefs.current[partIndex]) textRefs.current[partIndex] = [] - textRefs.current[partIndex][textIndex] = ref - }} - tabIndex={-1} className={joinClasses(textFocused ? "py-4 bg-gray-std" : "", "group py-2")} - onClick={preventDefault(() => setText(partIndex, textIndex))} + onClick={(e) => setTextOnClick(e, textFocused, partIndex, textIndex)} >
{tokenizedText.text} @@ -302,8 +309,8 @@ const SourceNavComponent: React.FC<{ readonly source: Source; readonly safeSet: setSelectedToken(tokenizedText.tokens[tokenIndex])} - onTokenChange={(tokenElement) => focusElement(tokenElement)} + onTokenSelect={setSelectedToken} + onCustomToken={onCustomToken} /> ) : null}
@@ -322,6 +329,9 @@ const SourceNavComponent: React.FC<{ readonly source: Source; readonly safeSet: }} /> ) : null} + {textFocused && customToken ? ( + + ) : null}
) }} @@ -333,16 +343,21 @@ const SourceNavComponent: React.FC<{ readonly source: Source; readonly safeSet: const TokensComponent: React.FC<{ readonly tokens: Token[] readonly isTokenSelected: boolean - readonly onTokenSelect: (tokenFocusIndex: number) => void - readonly onTokenChange: (tokenElement: HTMLDivElement) => void -}> = ({ tokens, isTokenSelected, onTokenSelect, onTokenChange }) => { - const [tokenFocusIndex] = useFocusTokenWithKeyboard(tokens, isTokenSelected, onTokenSelect) + readonly onTokenSelect: (token: SelectedToken) => void + readonly onCustomToken: (token: SelectedToken | null) => void +}> = ({ tokens, isTokenSelected, onTokenSelect, onCustomToken }) => { + const [tokenFocusIndex] = useFocusTokenWithKeyboard( + tokens, + isTokenSelected, + onTokenSelect, + onCustomToken, + ) const tokenRefs = useRef<(HTMLDivElement | null)[]>([]) useEffect(() => { const element = tokenRefs.current[tokenFocusIndex] - if (element) onTokenChange(element) - }, [onTokenChange, tokenFocusIndex]) + if (element) scrollTo(element) + }, [tokenFocusIndex]) return (
@@ -376,6 +391,38 @@ const TokensComponent: React.FC<{ ) } +const SearchTokensComponent: React.FC<{ + readonly customToken: SelectedToken + readonly onTokenSearch: (token: SelectedToken) => void +}> = ({ customToken, onTokenSearch }) => { + useEffect(() => { + // keyboard trigger to show component adds a character, so timeout + const id = setTimeout(() => textRef.current?.focus(), 50) + return () => clearTimeout(id) + }, []) + + const { setStopKeyboardEvents } = useContext(StopKeyboardContext) + useEffect(() => { + setStopKeyboardEvents(true) + return () => setStopKeyboardEvents(false) + }, [setStopKeyboardEvents]) + + const textRef = useRef(null) + return ( +
+ onTokenSearch({ text: textRef.current?.value || "", partOfSpeech: "" }), + )} + > + + +
+ ) +} + interface ITermsShowData { terms: Term[] } @@ -383,7 +430,7 @@ interface ITermsShowData { const termsComponentClass = "grid-std text-left text-lg py-2 space-y-2" const TermsComponent: React.FC<{ - readonly token: Token + readonly token: SelectedToken readonly onTermSelect: (term: Term) => void }> = ({ token, onTermSelect }) => { const fetcher = useFetcher() @@ -398,9 +445,6 @@ const TermsComponent: React.FC<{ onTermSelect, ) - const termRefs = useRef<(HTMLDivElement | null)[]>([]) - useEffect(() => termRefs.current[termFocusIndex]?.focus(), [terms, pageIndex, termFocusIndex]) - if (!fetcher.data) return
Loading...
return (
@@ -411,9 +455,10 @@ const TermsComponent: React.FC<{ {paginate(terms, maxPageSize, pageIndex).map((term, index) => (
(termRefs.current[index] = ref)} - tabIndex={-1} - className={joinClasses(index === termFocusIndex ? "underline" : "", "py-1")} + className={joinClasses( + index === termFocusIndex ? "underline focus-ring" : "", + "py-1", + )} >
{term.text}  diff --git a/ui/src/components/sources/SourceShow_SourceNavComponent.ts b/ui/src/components/sources/SourceShow_SourceNavComponent.ts index a218070f..cde94295 100644 --- a/ui/src/components/sources/SourceShow_SourceNavComponent.ts +++ b/ui/src/components/sources/SourceShow_SourceNavComponent.ts @@ -10,13 +10,7 @@ export function useFocusTextWithKeyboard( isTokenSelected: boolean, onCreateTextNote: () => void, onEscape: () => void, -): readonly [ - number, - number, - (element: HTMLElement) => void, - (partFocusIndex: number, textFocusIndex: number) => void, -] { - const [focusElement, focusLastElement] = useFocusElement() +): readonly [number, number, (partFocusIndex: number, textFocusIndex: number) => void] { const [partFocusIndex, textFocusIndex, decrementText, incrementText, setText] = useChangeFocus(parts) @@ -28,9 +22,9 @@ export function useFocusTextWithKeyboard( switch (e.code) { case "Escape": if (!isTokenSelected) return - focusLastElement() onEscape() - break + e.preventDefault() + return default: } @@ -54,17 +48,9 @@ export function useFocusTextWithKeyboard( e.preventDefault() }, - [ - stopKeyboardEvents, - isTokenSelected, - focusLastElement, - onEscape, - decrementText, - incrementText, - onCreateTextNote, - ], + [stopKeyboardEvents, isTokenSelected, onEscape, decrementText, incrementText, onCreateTextNote], ) - return [partFocusIndex, textFocusIndex, focusElement, setText] as const + return [partFocusIndex, textFocusIndex, setText] as const } export function getUsage(source: Source, partFocusIndex: number, textFocusIndex: number) { @@ -78,16 +64,6 @@ export function getUsage(source: Source, partFocusIndex: number, textFocusIndex: } } -function useFocusElement(): readonly [(element: HTMLElement) => void, () => void] { - const [lastFocusedElement, setLastFocusedElement] = useState(null) - const focusElement = useCallback((element: HTMLElement) => { - setLastFocusedElement(element) - element.focus() - }, []) - - return [focusElement, () => lastFocusedElement?.focus()] as const -} - function useChangeFocus( parts: SourcePart[], ): readonly [ diff --git a/ui/src/components/sources/SourceShow_TermsComponent.ts b/ui/src/components/sources/SourceShow_TermsComponent.ts index 45fcc23a..170ed04a 100644 --- a/ui/src/components/sources/SourceShow_TermsComponent.ts +++ b/ui/src/components/sources/SourceShow_TermsComponent.ts @@ -4,7 +4,7 @@ import { pageSize, totalPages } from "../../utils/HtmlUtil.ts" import { useKeyDownEffect, useTimedState } from "../../utils/JSXUtil.ts" import { decrement, increment } from "../../utils/NumberUtil.ts" import { StopKeyboardContext } from "./SourceShow_SourceComponent.ts" -import { useContext, useMemo, useState } from "react" +import { useCallback, useContext, useMemo, useState } from "react" const maxPageSize = 5 @@ -17,39 +17,52 @@ export function useChangeTermWithKeyboard( const pagesLen = useMemo(() => totalPages(terms, maxPageSize), [terms]) const [shake, setShake] = useTimedState(100) - const { stopKeyboardEvents } = useContext(StopKeyboardContext) - - useKeyDownEffect( - (e: KeyboardEvent) => { - if (stopKeyboardEvents) return + const changeTermFocusIndex = useCallback( + (e: KeyboardEvent, change: (index: number, length: number) => number) => { if (terms.length === 1) { setShake(true) e.preventDefault() return } + setTermFocusIndex(change(termFocusIndex, pageSize(terms.length, maxPageSize, pageIndex))) + }, + [pageIndex, setShake, termFocusIndex, terms.length], + ) + const changePage = useCallback( + (e: KeyboardEvent, change: (index: number, length: number) => number) => { + if (pagesLen === 1) { + setShake(true) + e.preventDefault() + return + } + setPageIndex(change(pageIndex, pagesLen)) + setTermFocusIndex(0) + }, + [pageIndex, pagesLen, setShake], + ) + + const { stopKeyboardEvents } = useContext(StopKeyboardContext) + + useKeyDownEffect( + (e: KeyboardEvent) => { + if (stopKeyboardEvents) return switch (e.code) { case "ArrowUp": case "KeyW": - setTermFocusIndex( - decrement(termFocusIndex, pageSize(terms.length, maxPageSize, pageIndex)), - ) + changeTermFocusIndex(e, decrement) break case "ArrowDown": case "KeyS": - setTermFocusIndex( - increment(termFocusIndex, pageSize(terms.length, maxPageSize, pageIndex)), - ) + changeTermFocusIndex(e, increment) break case "ArrowLeft": case "KeyA": - setPageIndex(decrement(pageIndex, pagesLen)) - setTermFocusIndex(0) + changePage(e, decrement) break case "ArrowRight": case "KeyD": - setPageIndex(increment(pageIndex, pagesLen)) - setTermFocusIndex(0) + changePage(e, increment) break case "Enter": case "Space": @@ -60,7 +73,7 @@ export function useChangeTermWithKeyboard( } e.preventDefault() }, - [stopKeyboardEvents, termFocusIndex, terms, pageIndex, pagesLen, onTermSelect, setShake], + [changePage, changeTermFocusIndex, onTermSelect, stopKeyboardEvents, termFocusIndex, terms], ) return [termFocusIndex, pageIndex, pagesLen, maxPageSize, shake] as const } diff --git a/ui/src/components/sources/SourceShow_TokensComponent.ts b/ui/src/components/sources/SourceShow_TokensComponent.ts index d3ac90a8..be36f8da 100644 --- a/ui/src/components/sources/SourceShow_TokensComponent.ts +++ b/ui/src/components/sources/SourceShow_TokensComponent.ts @@ -4,10 +4,17 @@ import { decrement, increment } from "../../utils/NumberUtil.ts" import { StopKeyboardContext } from "./SourceShow_SourceComponent.ts" import { useContext, useMemo, useState } from "react" +export interface SelectedToken { + text: string + partOfSpeech: string +} + +// eslint-disable-next-line max-params export function useFocusTokenWithKeyboard( tokens: Token[], isTokenSelected: boolean, - onTokenSelect: (tokenFocusIndex: number) => void, + onTokenSelect: (token: SelectedToken) => void, + onCustomToken: (token: SelectedToken | null) => void, ): readonly [number] { const [tokenFocusIndex, setTokenFocusIndex] = useState(0) const isAllPunct = useMemo( @@ -18,7 +25,25 @@ export function useFocusTokenWithKeyboard( const { stopKeyboardEvents } = useContext(StopKeyboardContext) useKeyDownEffect( (e: KeyboardEvent) => { - if (stopKeyboardEvents || isTokenSelected || isAllPunct) return + switch (e.code) { + case "Escape": + onCustomToken(null) + e.preventDefault() + return + default: + } + + if (stopKeyboardEvents) return + + switch (e.code) { + case "KeyC": + onCustomToken(tokens[tokenFocusIndex]) + e.preventDefault() + return + default: + } + + if (isTokenSelected || isAllPunct) return switch (e.code) { case "ArrowLeft": @@ -31,7 +56,7 @@ export function useFocusTokenWithKeyboard( break case "Enter": case "Space": - onTokenSelect(tokenFocusIndex) + onTokenSelect(tokens[tokenFocusIndex]) break default: return @@ -39,7 +64,15 @@ export function useFocusTokenWithKeyboard( e.preventDefault() }, - [stopKeyboardEvents, isTokenSelected, isAllPunct, tokens, tokenFocusIndex, onTokenSelect], + [ + stopKeyboardEvents, + isTokenSelected, + isAllPunct, + tokens, + tokenFocusIndex, + onTokenSelect, + onCustomToken, + ], ) return [tokenFocusIndex] as const }