diff --git a/apps/readest-app/.claude/memory/MEMORY.md b/apps/readest-app/.claude/memory/MEMORY.md index 0a7dc282..3236ed6f 100644 --- a/apps/readest-app/.claude/memory/MEMORY.md +++ b/apps/readest-app/.claude/memory/MEMORY.md @@ -16,6 +16,9 @@ - `src/app/reader/components/FoliateViewer.tsx` - Reader view orchestration - `src/app/reader/components/annotator/Annotator.tsx` - Annotation lifecycle +## Feature Notes +- [D-pad Navigation](dpad-navigation.md) — Android TV remote / keyboard arrow navigation design, key files, and pitfalls + ## Architecture Notes - foliate-js is a git submodule at `packages/foliate-js/` - Multiview paginator: loads adjacent sections in background, multiple View/Overlayer instances per book diff --git a/apps/readest-app/.claude/memory/dpad-navigation.md b/apps/readest-app/.claude/memory/dpad-navigation.md new file mode 100644 index 00000000..a204b088 --- /dev/null +++ b/apps/readest-app/.claude/memory/dpad-navigation.md @@ -0,0 +1,40 @@ +--- +name: D-pad Navigation Design +description: Android TV / Bluetooth remote D-pad navigation architecture, key files, and pitfalls encountered during implementation +type: project +--- + +## D-pad Navigation Architecture + +D-pad support enables Bluetooth remote controller navigation on Android TV (and keyboard arrow navigation on desktop). + +### Key Files + +- `src/app/reader/hooks/useSpatialNavigation.ts` — Reader toolbar D-pad navigation. Left/Right navigates between buttons, Up/Down moves between header↔footer. Auto-focuses first button on show. Uses focus-probe technique for visibility detection. +- `src/app/library/hooks/useSpatialNavigation.ts` — Library grid D-pad navigation. Arrow keys move between BookshelfItem elements. ArrowDown from outside bookshelf (e.g. header) enters the grid via window-level listener. +- `src/helpers/shortcuts.ts` — `onToggleToolbar` (Enter key) toggles reader toolbar visibility. +- `src/app/reader/hooks/useBookShortcuts.ts` — `toggleToolbar` handler shows/hides header+footer bars. Skips when a ` + + + + + ); +} + +function TestHeaderFooter({ isVisible }: { isVisible: boolean }) { + const headerRef = useRef(null); + const footerRef = useRef(null); + useSpatialNavigation(headerRef, isVisible); + useSpatialNavigation(footerRef, isVisible); + + return ( + <> +
+ + +
+
+ + +
+ + ); +} + +function pressKey(element: HTMLElement, key: string) { + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + }); + const spy = vi.spyOn(event, 'stopPropagation'); + element.dispatchEvent(event); + return { event, stopPropagationCalled: spy.mock.calls.length > 0 }; +} + +function simulateKeyboardActivation() { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); +} + +async function waitForAutoFocus() { + await act(() => new Promise((r) => setTimeout(r, 150))); +} + +describe('useToolbarKeyNavigation', () => { + beforeEach(() => { + _resetLastKeyboardTime(); + }); + afterEach(() => cleanup()); + + describe('auto-focus', () => { + it('focuses first enabled button when keyboard-activated', async () => { + simulateKeyboardActivation(); + render(); + await waitForAutoFocus(); + expect(document.activeElement).toBe(document.querySelector('[data-testid="btn-1"]')); + }); + + it('does not auto-focus when not visible', async () => { + simulateKeyboardActivation(); + render(); + await waitForAutoFocus(); + expect(document.activeElement).not.toBe(document.querySelector('[data-testid="btn-1"]')); + }); + + it('does not auto-focus when mouse-activated (no recent keyboard)', async () => { + // No simulateKeyboardActivation() — simulates mouse hover activation + render(); + await waitForAutoFocus(); + expect(document.activeElement).not.toBe(document.querySelector('[data-testid="btn-1"]')); + }); + }); + + describe('left/right navigation', () => { + it('ArrowRight moves focus to the next button', async () => { + simulateKeyboardActivation(); + render(); + await waitForAutoFocus(); + + const btn1 = document.querySelector('[data-testid="btn-1"]')!; + const btn2 = document.querySelector('[data-testid="btn-2"]')!; + expect(document.activeElement).toBe(btn1); + + pressKey(btn1, 'ArrowRight'); + expect(document.activeElement).toBe(btn2); + }); + + it('ArrowLeft moves focus to the previous button', async () => { + simulateKeyboardActivation(); + render(); + await waitForAutoFocus(); + + const btn1 = document.querySelector('[data-testid="btn-1"]')!; + const btn2 = document.querySelector('[data-testid="btn-2"]')!; + + btn2.focus(); + pressKey(btn2, 'ArrowLeft'); + expect(document.activeElement).toBe(btn1); + }); + + it('ArrowRight does not go past the last enabled button', async () => { + simulateKeyboardActivation(); + render(); + await waitForAutoFocus(); + + const btn3 = document.querySelector('[data-testid="btn-3"]')!; + btn3.focus(); + pressKey(btn3, 'ArrowRight'); + expect(document.activeElement).toBe(btn3); + }); + + it('ArrowLeft does not go before the first button', async () => { + simulateKeyboardActivation(); + render(); + await waitForAutoFocus(); + + const btn1 = document.querySelector('[data-testid="btn-1"]')!; + pressKey(btn1, 'ArrowLeft'); + expect(document.activeElement).toBe(btn1); + }); + + it('ArrowRight calls stopPropagation', async () => { + simulateKeyboardActivation(); + render(); + await waitForAutoFocus(); + + const btn1 = document.querySelector('[data-testid="btn-1"]')!; + const { stopPropagationCalled } = pressKey(btn1, 'ArrowRight'); + expect(stopPropagationCalled).toBe(true); + }); + }); + + describe('up/down navigation between header and footer', () => { + it('ArrowDown from header focuses first footer button', async () => { + render(); + await waitForAutoFocus(); + + const headerBtn = document.querySelector('[data-testid="header-btn-1"]')!; + const footerBtn1 = document.querySelector('[data-testid="footer-btn-1"]')!; + + headerBtn.focus(); + pressKey(headerBtn, 'ArrowDown'); + expect(document.activeElement).toBe(footerBtn1); + }); + + it('ArrowUp from footer focuses first header button', async () => { + render(); + await waitForAutoFocus(); + + const headerBtn1 = document.querySelector('[data-testid="header-btn-1"]')!; + const footerBtn = document.querySelector('[data-testid="footer-btn-1"]')!; + + footerBtn.focus(); + pressKey(footerBtn, 'ArrowUp'); + expect(document.activeElement).toBe(headerBtn1); + }); + + it('ArrowDown from header calls stopPropagation', async () => { + render(); + await waitForAutoFocus(); + + const headerBtn = document.querySelector('[data-testid="header-btn-1"]')!; + headerBtn.focus(); + + const { stopPropagationCalled } = pressKey(headerBtn, 'ArrowDown'); + expect(stopPropagationCalled).toBe(true); + }); + }); +}); diff --git a/apps/readest-app/src/app/library/components/Bookshelf.tsx b/apps/readest-app/src/app/library/components/Bookshelf.tsx index 659ba77d..3852274d 100644 --- a/apps/readest-app/src/app/library/components/Bookshelf.tsx +++ b/apps/readest-app/src/app/library/components/Bookshelf.tsx @@ -32,6 +32,7 @@ import { } from '../utils/libraryUtils'; import { eventDispatcher } from '@/utils/event'; +import { useSpatialNavigation } from '../hooks/useSpatialNavigation'; import Alert from '@/components/Alert'; import Spinner from '@/components/Spinner'; import ModalPortal from '@/components/ModalPortal'; @@ -101,6 +102,7 @@ const Bookshelf: React.FC = ({ const isImportingBook = useRef(false); const iconSize15 = useResponsiveSize(15); const autofocusRef = useAutoFocus(); + useSpatialNavigation(autofocusRef); const { setCurrentBookshelf, setLibrary, updateBooks } = useLibraryStore(); const { setSelectedBooks, getSelectedBooks, toggleSelectedBook } = useLibraryStore(); diff --git a/apps/readest-app/src/app/library/hooks/useSpatialNavigation.ts b/apps/readest-app/src/app/library/hooks/useSpatialNavigation.ts new file mode 100644 index 00000000..eed25c94 --- /dev/null +++ b/apps/readest-app/src/app/library/hooks/useSpatialNavigation.ts @@ -0,0 +1,95 @@ +import { useEffect, useCallback } from 'react'; + +const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']); + +function getGridColumnCount(items: HTMLElement[]): number { + if (items.length < 2) return 1; + const firstTop = items[0]!.getBoundingClientRect().top; + for (let i = 1; i < items.length; i++) { + if (items[i]!.getBoundingClientRect().top > firstTop + 5) return i; + } + return items.length; +} + +function getGridItems(container: HTMLElement): HTMLElement[] { + return Array.from(container.querySelectorAll('[role="button"][tabindex="0"]')); +} + +export function useSpatialNavigation(containerRef: React.RefObject) { + // Grid navigation within the bookshelf + const handleGridKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!ARROW_KEYS.has(e.key)) return; + + const container = containerRef.current; + if (!container) return; + + const items = getGridItems(container); + if (items.length === 0) return; + + const currentIndex = items.indexOf(document.activeElement as HTMLElement); + if (currentIndex === -1) { + items[0]?.focus(); + e.preventDefault(); + return; + } + + const cols = getGridColumnCount(items); + let targetIndex = currentIndex; + + switch (e.key) { + case 'ArrowRight': + targetIndex = currentIndex + 1; + break; + case 'ArrowLeft': + targetIndex = currentIndex - 1; + break; + case 'ArrowDown': + targetIndex = currentIndex + cols; + break; + case 'ArrowUp': + targetIndex = currentIndex - cols; + break; + } + + if (targetIndex >= 0 && targetIndex < items.length && targetIndex !== currentIndex) { + items[targetIndex]?.focus(); + items[targetIndex]?.scrollIntoView({ block: 'nearest' }); + e.preventDefault(); + } + }, + [containerRef], + ); + + // Handle focus transition from outside the bookshelf (e.g. header) into the grid + const handleWindowKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key !== 'ArrowDown') return; + + const container = containerRef.current; + if (!container) return; + + // Only handle when focus is outside the bookshelf + if (container.contains(document.activeElement)) return; + + const items = getGridItems(container); + if (items.length === 0) return; + + items[0]?.focus(); + items[0]?.scrollIntoView({ block: 'nearest' }); + e.preventDefault(); + }, + [containerRef], + ); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + container.addEventListener('keydown', handleGridKeyDown); + window.addEventListener('keydown', handleWindowKeyDown); + return () => { + container.removeEventListener('keydown', handleGridKeyDown); + window.removeEventListener('keydown', handleWindowKeyDown); + }; + }, [handleGridKeyDown, handleWindowKeyDown, containerRef]); +} diff --git a/apps/readest-app/src/app/reader/components/HeaderBar.tsx b/apps/readest-app/src/app/reader/components/HeaderBar.tsx index 7a0bafa0..cf2d12d5 100644 --- a/apps/readest-app/src/app/reader/components/HeaderBar.tsx +++ b/apps/readest-app/src/app/reader/components/HeaderBar.tsx @@ -13,6 +13,7 @@ import { useSettingsStore } from '@/store/settingsStore'; import { useTrafficLightStore } from '@/store/trafficLightStore'; import { useTrafficLight } from '@/hooks/useTrafficLight'; import { useResponsiveSize } from '@/hooks/useResponsiveSize'; +import { useSpatialNavigation } from '@/app/reader/hooks/useSpatialNavigation'; import { getHighlightColorHex } from '../utils/annotatorUtil'; import { annotationToolQuickActions } from './annotator/AnnotationTools'; import { AnnotationToolType } from '@/types/annotator'; @@ -133,6 +134,8 @@ const HeaderBar: React.FC = ({ const isHeaderCompact = headerWidth > 0 && headerWidth < 350; const insets = window.innerWidth < 640 ? screenInsets : gridInsets; const isHeaderVisible = hoveredBookKey === bookKey || isDropdownOpen; + + useSpatialNavigation(headerRef, isHeaderVisible); const trafficLightInHeader = appService?.hasTrafficLight && !trafficLightInFullscreen && !isSideBarVisible && isTopLeft; diff --git a/apps/readest-app/src/app/reader/components/Reader.tsx b/apps/readest-app/src/app/reader/components/Reader.tsx index 8c0aceac..d482e9a3 100644 --- a/apps/readest-app/src/app/reader/components/Reader.tsx +++ b/apps/readest-app/src/app/reader/components/Reader.tsx @@ -106,7 +106,11 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => { const handleKeyDown = (event: CustomEvent) => { if (event.detail.keyName === 'Back') { - if (getIsSideBarVisible() && !isSideBarPinned) { + const { hoveredBookKey, setHoveredBookKey } = useReaderStore.getState(); + if (hoveredBookKey) { + setHoveredBookKey(''); + (document.activeElement as HTMLElement)?.blur(); + } else if (getIsSideBarVisible() && !isSideBarPinned) { setSideBarVisible(false); } else if (getIsNotebookVisible() && !isNotebookPinned) { setNotebookVisible(false); diff --git a/apps/readest-app/src/app/reader/components/footerbar/FooterBar.tsx b/apps/readest-app/src/app/reader/components/footerbar/FooterBar.tsx index 5e9bc04f..504c449e 100644 --- a/apps/readest-app/src/app/reader/components/footerbar/FooterBar.tsx +++ b/apps/readest-app/src/app/reader/components/footerbar/FooterBar.tsx @@ -1,6 +1,7 @@ import clsx from 'clsx'; -import React, { useState, useCallback, useMemo, useEffect } from 'react'; +import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import { useEnv } from '@/context/EnvContext'; +import { useSpatialNavigation } from '@/app/reader/hooks/useSpatialNavigation'; import { useReaderStore } from '@/store/readerStore'; import { useSidebarStore } from '@/store/sidebarStore'; import { useBookDataStore } from '@/store/bookDataStore'; @@ -159,11 +160,13 @@ const FooterBar: React.FC = ({ if (event instanceof CustomEvent) { if (event.detail.keyName === 'Back') { setHoveredBookKey(''); + (document.activeElement as HTMLElement)?.blur(); return true; } } else { if (event.key === 'Escape') { setHoveredBookKey(''); + (document.activeElement as HTMLElement)?.blur(); } event.stopPropagation(); } @@ -188,6 +191,9 @@ const FooterBar: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [hoveredBookKey]); + const footerBarRef = useRef(null); + useSpatialNavigation(footerBarRef, isVisible); + const commonProps: FooterBarChildProps = { bookKey, gridInsets, @@ -236,6 +242,7 @@ const FooterBar: React.FC = ({ {/* Main footer container */}
{ + if (!sideBarBookKey) return; + // Don't intercept Enter when a button is focused (let native click fire) + const active = document.activeElement; + if (active && active.tagName === 'BUTTON') return; + const { hoveredBookKey, setHoveredBookKey } = useReaderStore.getState(); + setHoveredBookKey(hoveredBookKey === sideBarBookKey ? '' : sideBarBookKey); + }; + const toggleTTS = () => { if (!sideBarBookKey) return; const bookKey = sideBarBookKey; @@ -337,6 +346,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) = onToggleScrollMode: toggleScrollMode, onToggleBookmark: toggleBookmark, onToggleParagraphMode: toggleParagraphMode, + onToggleToolbar: toggleToolbar, onOpenFontLayoutSettings: () => setSettingsDialogOpen(true), onShowSearchBar: showSearchBar, onToggleFullscreen: toggleFullscreen, diff --git a/apps/readest-app/src/app/reader/hooks/useSpatialNavigation.ts b/apps/readest-app/src/app/reader/hooks/useSpatialNavigation.ts new file mode 100644 index 00000000..546c014f --- /dev/null +++ b/apps/readest-app/src/app/reader/hooks/useSpatialNavigation.ts @@ -0,0 +1,113 @@ +import { useEffect, useRef } from 'react'; + +// Track last keyboard activity to distinguish keyboard vs mouse activation. +// Pointer events from iframes don't bubble to the main document, but keyboard +// events always reach here via useShortcuts on window. +let lastKeyboardTime = 0; +/** @internal Reset for testing only */ +export function _resetLastKeyboardTime() { + lastKeyboardTime = 0; +} +if (typeof document !== 'undefined') { + document.addEventListener( + 'keydown', + () => { + lastKeyboardTime = Date.now(); + }, + true, + ); +} + +function getButtons(container: HTMLElement): HTMLButtonElement[] { + return Array.from(container.querySelectorAll('button:not([disabled])')); +} + +function getFocusableButtons(container: HTMLElement): HTMLButtonElement[] { + // Try each button to see if it can actually receive focus. + // This correctly handles all hiding methods (display:none, visibility:hidden, + // fixed positioning where offsetParent is null, etc.) + const prev = document.activeElement as HTMLElement | null; + const focusable: HTMLButtonElement[] = []; + for (const btn of getButtons(container)) { + btn.focus(); + if (document.activeElement === btn) { + focusable.push(btn); + } + } + prev?.focus(); + return focusable; +} + +function focusFirstButton(container: HTMLElement) { + for (const btn of getButtons(container)) { + btn.focus(); + if (document.activeElement === btn) return; + } +} + +/** + * Arrow key navigation for toolbar containers (header bar, footer bar). + * Left/Right navigate between buttons within the toolbar. + * Up/Down move focus between the header bar and footer bar. + * Auto-focuses the first button when the toolbar becomes visible. + */ +export function useSpatialNavigation( + containerRef: React.RefObject, + isVisible: boolean, +) { + // Auto-focus first button when toolbar becomes visible via keyboard (not mouse hover) + const wasKeyboardActivated = useRef(false); + useEffect(() => { + if (isVisible) { + // If recent keyboard activity, this was keyboard-activated (Enter key) + wasKeyboardActivated.current = Date.now() - lastKeyboardTime < 200; + } + }, [isVisible]); + + useEffect(() => { + if (!isVisible || !wasKeyboardActivated.current) return; + const container = containerRef.current; + if (!container) return; + const timer = setTimeout(() => focusFirstButton(container), 100); + return () => clearTimeout(timer); + }, [isVisible, containerRef]); + + useEffect(() => { + if (!isVisible) return; + const container = containerRef.current; + if (!container) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + const buttons = getFocusableButtons(container); + if (buttons.length === 0) return; + + const currentIndex = buttons.indexOf(document.activeElement as HTMLButtonElement); + if (currentIndex === -1) return; + + const targetIndex = e.key === 'ArrowRight' ? currentIndex + 1 : currentIndex - 1; + + if (targetIndex >= 0 && targetIndex < buttons.length) { + buttons[targetIndex]?.focus(); + } + + e.stopPropagation(); + e.preventDefault(); + } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + const target = + e.key === 'ArrowDown' + ? document.querySelector('.footer-bar') + : document.querySelector('.header-bar'); + + if (target) { + focusFirstButton(target); + e.stopPropagation(); + e.preventDefault(); + } + } + }; + + container.addEventListener('keydown', handleKeyDown); + return () => container.removeEventListener('keydown', handleKeyDown); + }, [isVisible, containerRef]); +} diff --git a/apps/readest-app/src/helpers/shortcuts.ts b/apps/readest-app/src/helpers/shortcuts.ts index 0433c5f1..0cd80f47 100644 --- a/apps/readest-app/src/helpers/shortcuts.ts +++ b/apps/readest-app/src/helpers/shortcuts.ts @@ -78,6 +78,11 @@ const DEFAULT_SHORTCUTS = { description: _('Toggle Paragraph Mode'), section: 'General', }, + onToggleToolbar: { + keys: ['Enter'], + description: _('Toggle Toolbar'), + section: 'General', + }, onHighlightSelection: { keys: ['ctrl+h', 'cmd+h'], description: _('Highlight Selection'),