import { useState, useCallback, useEffect, useRef } from 'react' import type { VirtuosoHandle } from 'react-virtuoso' import type { VaultEntry } from '../types' interface NoteListKeyboardOptions { items: VaultEntry[] selectedNotePath: string | null onOpen: (entry: VaultEntry) => void onEnterNeighborhood?: (entry: VaultEntry) => void | Promise onPrefetch?: (entry: VaultEntry) => void enabled: boolean } function resolveHighlightedPath(items: VaultEntry[], selectedNotePath: string | null): string | null { if (items.length === 0) return null if (!selectedNotePath) return items[0].path return items.some((entry) => entry.path === selectedNotePath) ? selectedNotePath : items[0].path } function isListActive(container: HTMLDivElement | null): boolean { if (!container) return false const activeElement = document.activeElement return activeElement instanceof Node && container.contains(activeElement) } function isEditableElement(element: Element | null): boolean { if (!element) return false if ( element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement ) return true if (!(element instanceof HTMLElement)) return false return element.isContentEditable || !!element.closest('[contenteditable="true"]') } function isInteractiveElement(element: Element | null): boolean { if (!element) return false if (isEditableElement(element)) return true if (!(element instanceof HTMLElement)) return false return element instanceof HTMLButtonElement || element instanceof HTMLAnchorElement || element.getAttribute('role') === 'button' } function isNestedInteractiveTarget( target: EventTarget | null, currentTarget: EventTarget | null, ): boolean { return target instanceof Element && currentTarget instanceof Element && target !== currentTarget && currentTarget.contains(target) && isInteractiveElement(target) } function resolveCurrentIndex( items: VaultEntry[], highlightedPath: string | null, selectedNotePath: string | null, ): number { const activePath = highlightedPath ?? selectedNotePath return activePath ? items.findIndex((entry) => entry.path === activePath) : -1 } function moveHighlightIndex( previousIndex: number, direction: 1 | -1, itemCount: number, ): number { if (itemCount === 0) return -1 if (previousIndex < 0) return direction === 1 ? 0 : itemCount - 1 const currentIndex = Math.min(previousIndex, itemCount - 1) const nextIndex = currentIndex + direction if (nextIndex < 0 || nextIndex >= itemCount) return previousIndex return nextIndex } function resolveHighlightedEntry(items: VaultEntry[], highlightedPath: string | null): VaultEntry | undefined { if (!highlightedPath) return undefined return items.find((entry) => entry.path === highlightedPath) } function usesCommandModifier(event: Pick): boolean { return event.metaKey || event.ctrlKey } function isNeighborhoodKey(event: Pick): boolean { return event.key === 'Enter' && usesCommandModifier(event) && !event.altKey } function useKeyboardItemRefs(items: VaultEntry[], selectedNotePath: string | null) { const itemsRef = useRef(items) const selectedNotePathRef = useRef(selectedNotePath) useEffect(() => { itemsRef.current = items selectedNotePathRef.current = selectedNotePath }, [items, selectedNotePath]) return { itemsRef, selectedNotePathRef } } function useHighlightedPath() { const [highlightedPathState, setHighlightedPath] = useState(null) const highlightedPathRef = useRef(null) const syncHighlightedPath = useCallback((nextPath: string | null) => { highlightedPathRef.current = nextPath setHighlightedPath(nextPath) }, []) return { highlightedPathRef, highlightedPathState, syncHighlightedPath } } function useSelectionSync( itemsRef: React.RefObject, selectedNotePathRef: React.RefObject, syncHighlightedPath: (nextPath: string | null) => void, ) { return useCallback(() => { syncHighlightedPath(resolveHighlightedPath(itemsRef.current, selectedNotePathRef.current)) }, [itemsRef, selectedNotePathRef, syncHighlightedPath]) } interface ScheduledOpenState { entry: VaultEntry | null frameId: number | null } function cancelScheduledOpen(stateRef: React.RefObject): void { const frameId = stateRef.current.frameId if (frameId !== null) cancelAnimationFrame(frameId) stateRef.current.entry = null stateRef.current.frameId = null } function flushScheduledOpen( stateRef: React.RefObject, onOpen: (entry: VaultEntry) => void, entry?: VaultEntry, ): void { if (entry) stateRef.current.entry = entry const nextEntry = stateRef.current.entry if (!nextEntry) return if (stateRef.current.frameId !== null) cancelAnimationFrame(stateRef.current.frameId) stateRef.current.entry = null stateRef.current.frameId = null onOpen(nextEntry) } function scheduleOpenForNextFrame( stateRef: React.RefObject, onOpen: (entry: VaultEntry) => void, entry: VaultEntry, ): void { stateRef.current.entry = entry if (stateRef.current.frameId !== null) return stateRef.current.frameId = requestAnimationFrame(() => { flushScheduledOpen(stateRef, onOpen) }) } function useScheduledOpen(onOpen: (entry: VaultEntry) => void, enabled: boolean) { const stateRef = useRef({ entry: null, frameId: null }) const scheduleOpen = useCallback((entry: VaultEntry) => { scheduleOpenForNextFrame(stateRef, onOpen, entry) }, [onOpen]) const flushOpen = useCallback((entry?: VaultEntry) => { flushScheduledOpen(stateRef, onOpen, entry) }, [onOpen]) const cancelOpen = useCallback(() => { cancelScheduledOpen(stateRef) }, []) useEffect(() => { if (enabled) return cancelOpen() }, [cancelOpen, enabled]) useEffect(() => cancelOpen, [cancelOpen]) return { cancelOpen, flushOpen, scheduleOpen } } function useMoveHighlight({ items, selectedNotePath, highlightedPathRef, syncHighlightedPath, virtuosoRef, onPrefetch, scheduleOpen, }: { items: VaultEntry[] selectedNotePath: string | null highlightedPathRef: React.RefObject syncHighlightedPath: (nextPath: string | null) => void virtuosoRef: React.RefObject onPrefetch?: (entry: VaultEntry) => void scheduleOpen: (entry: VaultEntry) => void }) { return useCallback((direction: 1 | -1) => { const currentIndex = resolveCurrentIndex(items, highlightedPathRef.current, selectedNotePath) const nextIndex = moveHighlightIndex(currentIndex, direction, items.length) const currentPath = highlightedPathRef.current ?? selectedNotePath const nextItem = items[nextIndex] if (!nextItem || nextItem.path === currentPath) return syncHighlightedPath(nextItem.path) virtuosoRef.current?.scrollIntoView({ index: nextIndex, behavior: 'auto' }) scheduleOpen(nextItem) onPrefetch?.(nextItem) }, [highlightedPathRef, items, onPrefetch, scheduleOpen, selectedNotePath, syncHighlightedPath, virtuosoRef]) } function resolveEntryForActivation( items: VaultEntry[], highlightedPathRef: React.RefObject, ): VaultEntry | undefined { return resolveHighlightedEntry(items, highlightedPathRef.current) } function handleNeighborhoodActivation(options: { event: Pick items: VaultEntry[] highlightedPathRef: React.RefObject cancelOpen: () => void onEnterNeighborhood?: (entry: VaultEntry) => void | Promise }): boolean { const { event, items, highlightedPathRef, cancelOpen, onEnterNeighborhood, } = options const highlightedItem = resolveEntryForActivation(items, highlightedPathRef) if (!highlightedItem) return false event.preventDefault() cancelOpen() void onEnterNeighborhood?.(highlightedItem) return true } function handleArrowNavigation( event: Pick, moveHighlight: (direction: 1 | -1) => void, ): boolean { if (event.key === 'ArrowDown') { event.preventDefault() moveHighlight(1) return true } if (event.key === 'ArrowUp') { event.preventDefault() moveHighlight(-1) return true } return false } function handleHighlightedOpen(options: { event: Pick items: VaultEntry[] highlightedPathRef: React.RefObject flushOpen: (entry?: VaultEntry) => void }): boolean { const { event, items, highlightedPathRef, flushOpen, } = options const highlightedItem = resolveEntryForActivation(items, highlightedPathRef) if (!highlightedItem) return false event.preventDefault() flushOpen(highlightedItem) return true } function useProcessKeyDown({ enabled, items, highlightedPathRef, moveHighlight, flushOpen, cancelOpen, onEnterNeighborhood, }: { enabled: boolean items: VaultEntry[] highlightedPathRef: React.RefObject moveHighlight: (direction: 1 | -1) => void flushOpen: (entry?: VaultEntry) => void cancelOpen: () => void onEnterNeighborhood?: (entry: VaultEntry) => void | Promise }) { return useCallback((event: Pick) => { if (!enabled || items.length === 0) return if (isNeighborhoodKey(event)) { handleNeighborhoodActivation({ event, items, highlightedPathRef, cancelOpen, onEnterNeighborhood, }) return } if (usesCommandModifier(event) || event.altKey) return if (handleArrowNavigation(event, moveHighlight)) return if (event.key !== 'Enter') return handleHighlightedOpen({ event, items, highlightedPathRef, flushOpen, }) }, [cancelOpen, enabled, flushOpen, highlightedPathRef, items, moveHighlight, onEnterNeighborhood]) } function useFocusHandlers({ containerRef, syncToCurrentSelection, syncHighlightedPath, }: { containerRef: React.RefObject syncToCurrentSelection: () => void syncHighlightedPath: (nextPath: string | null) => void }) { const handleFocus = useCallback(() => { syncToCurrentSelection() }, [syncToCurrentSelection]) const handleBlur = useCallback(() => { syncHighlightedPath(null) }, [syncHighlightedPath]) const focusList = useCallback(() => { const container = containerRef.current if (!container) return container.focus() requestAnimationFrame(() => { if (isListActive(containerRef.current)) syncToCurrentSelection() }) }, [containerRef, syncToCurrentSelection]) return { focusList, handleBlur, handleFocus } } function useGlobalKeyboardHandling({ enabled, containerRef, processKeyDown, }: { enabled: boolean containerRef: React.RefObject processKeyDown: (event: KeyboardEvent) => void }) { useEffect(() => { if (!enabled) return const handleWindowKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return const activeElement = document.activeElement if (isEditableElement(activeElement)) return if ( activeElement !== containerRef.current && containerRef.current?.contains(activeElement) && isInteractiveElement(activeElement) ) return processKeyDown(event) } window.addEventListener('keydown', handleWindowKeyDown) return () => window.removeEventListener('keydown', handleWindowKeyDown) }, [containerRef, enabled, processKeyDown]) } export function useNoteListKeyboard({ items, selectedNotePath, onOpen, onEnterNeighborhood, onPrefetch, enabled, }: NoteListKeyboardOptions) { const virtuosoRef = useRef(null) const containerRef = useRef(null) const { itemsRef, selectedNotePathRef } = useKeyboardItemRefs(items, selectedNotePath) const { highlightedPathRef, highlightedPathState, syncHighlightedPath } = useHighlightedPath() const syncToCurrentSelection = useSelectionSync(itemsRef, selectedNotePathRef, syncHighlightedPath) const { cancelOpen, flushOpen, scheduleOpen } = useScheduledOpen(onOpen, enabled) const moveHighlight = useMoveHighlight({ items, selectedNotePath, highlightedPathRef, syncHighlightedPath, virtuosoRef, onPrefetch, scheduleOpen, }) const processKeyDown = useProcessKeyDown({ enabled, items, highlightedPathRef, moveHighlight, flushOpen, cancelOpen, onEnterNeighborhood, }) const handleKeyDown = useCallback((event: React.KeyboardEvent) => { if (isNestedInteractiveTarget(event.target, event.currentTarget)) return processKeyDown(event) }, [processKeyDown]) const { focusList, handleBlur, handleFocus } = useFocusHandlers({ containerRef, syncToCurrentSelection, syncHighlightedPath, }) useGlobalKeyboardHandling({ enabled, containerRef, processKeyDown }) useEffect(() => { cancelOpen() }, [cancelOpen, selectedNotePath]) const highlightedPath = items.some((entry) => entry.path === highlightedPathState) ? highlightedPathState : null return { containerRef, focusList, highlightedPath, handleBlur, handleKeyDown, handleFocus, virtuosoRef, } }