From 773778ee9073c02db579582f857ae2dda0c6b7b1 Mon Sep 17 00:00:00 2001 From: Richard Roberson Date: Sat, 22 Nov 2025 01:33:53 -0700 Subject: [PATCH] feat(epub): add custom reader navigation and TOC - Implement custom previous/next page buttons for EPUB viewer. - Display current page number out of total pages. - Introduce an in-viewer, toggleable table of contents (TOC) for quick chapter navigation. - Hide default `react-reader` navigation arrows and title bar to prevent redundancy. - Adjust `react-reader` styles to optimize content area and ensure custom controls are visible. - Update description for EPUB theme setting in document settings. - Add `ChevronLeftIcon` and `ChevronRightIcon` to icon library. - Update Playwright tests to reflect new navigation button labels. --- src/components/DocumentSettings.tsx | 4 +- src/components/EPUBViewer.tsx | 67 +++++++++++++++++++++++++++-- src/components/icons/Icons.tsx | 44 +++++++++++++++++++ src/hooks/epub/useEPUBTheme.ts | 67 +++++++++++++++++++++++++---- tests/upload.spec.ts | 8 ++-- 5 files changed, 171 insertions(+), 19 deletions(-) diff --git a/src/components/DocumentSettings.tsx b/src/components/DocumentSettings.tsx index 84abae2..f78d482 100644 --- a/src/components/DocumentSettings.tsx +++ b/src/components/DocumentSettings.tsx @@ -414,10 +414,10 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { onChange={(e) => updateConfigKey('epubTheme', e.target.checked)} className="form-checkbox h-4 w-4 text-accent rounded border-muted" /> - Use theme (experimental) + Use theme

- Apply the current app theme to the EPUB viewer + Apply the current app theme to the EPUB viewer background and text colors

)} diff --git a/src/components/EPUBViewer.tsx b/src/components/EPUBViewer.tsx index 7f0e5c3..7289538 100644 --- a/src/components/EPUBViewer.tsx +++ b/src/components/EPUBViewer.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useRef, useCallback } from 'react'; +import { useEffect, useRef, useCallback, useState } from 'react'; import dynamic from 'next/dynamic'; import { useEPUB } from '@/contexts/EPUBContext'; import { useTTS } from '@/contexts/TTSContext'; @@ -8,6 +8,7 @@ import { useConfig } from '@/contexts/ConfigContext'; import { DocumentSkeleton } from '@/components/DocumentSkeleton'; import { useEPUBTheme, getThemeStyles } from '@/hooks/epub/useEPUBTheme'; import { useEPUBResize } from '@/hooks/epub/useEPUBResize'; +import { DotsVerticalIcon, ChevronLeftIcon, ChevronRightIcon } from '@/components/icons/Icons'; const ReactReader = dynamic(() => import('react-reader').then(mod => mod.ReactReader), { ssr: false, @@ -19,9 +20,12 @@ interface EPUBViewerProps { } export function EPUBViewer({ className = '' }: EPUBViewerProps) { + const [isTocOpen, setIsTocOpen] = useState(false); const { currDocData, currDocName, + currDocPage, + currDocPages, locationRef, handleLocationChanged, bookRef, @@ -116,7 +120,62 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) { return (
-
+
+
+ + +
+ {currDocPages !== undefined && typeof currDocPage === 'number' && ( + + {currDocPage} / {currDocPages} + + )} + +
+ {isTocOpen && tocRef.current && tocRef.current.length > 0 && ( +
+
Skip to chapters
+
+ {tocRef.current.map((item, index) => ( + + ))} +
+
+ )} +
} key={'epub-reader'} @@ -125,8 +184,8 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) { url={currDocData} title={currDocName} tocChanged={(_toc) => (tocRef.current = _toc)} - showToc={true} - readerStyles={epubTheme && getThemeStyles() || undefined} + showToc={false} + readerStyles={getThemeStyles(epubTheme)} getRendition={(_rendition) => { setRendition(_rendition); updateTheme(); diff --git a/src/components/icons/Icons.tsx b/src/components/icons/Icons.tsx index ffbfead..292e937 100644 --- a/src/components/icons/Icons.tsx +++ b/src/components/icons/Icons.tsx @@ -373,6 +373,50 @@ export function DotsVerticalIcon(props: React.SVGProps) { ); } +export function ChevronLeftIcon(props: React.SVGProps) { + return ( + + + + ); +} + +export function ChevronRightIcon(props: React.SVGProps) { + return ( + + + + ); +} + export function SpeedometerIcon(props: React.SVGProps) { return ( { - const baseStyle = { - ...ReactReaderStyle, - readerArea: { - ...ReactReaderStyle.readerArea, - transition: undefined, - } - }; +// Returns ReactReader styles, with: +// - default look when epubTheme === false (except hiding built-in arrows) +// - themed colors + layout tweaks when epubTheme === true +export const getThemeStyles = (epubTheme: boolean): IReactReaderStyle => { + const baseStyle = ReactReaderStyle; + + // Always hide the built-in prev/next arrow buttons so we can + // provide our own navigation controls outside the reader. + if (!epubTheme) { + return { + ...baseStyle, + reader: { + ...baseStyle.reader, + // Always tighten the inset a bit for better use of space + top: 8, + left: 8, + right: 8, + bottom: 8, + }, + prev: { + ...baseStyle.prev, + display: 'none', + pointerEvents: 'none', + }, + next: { + ...baseStyle.next, + display: 'none', + pointerEvents: 'none', + }, + titleArea: { + ...baseStyle.titleArea, + display: 'none', + }, + }; + } const colors = { background: getComputedStyle(document.documentElement).getPropertyValue('--background'), @@ -21,6 +48,25 @@ export const getThemeStyles = (): IReactReaderStyle => { return { ...baseStyle, + reader: { + ...baseStyle.reader, + // Reduce the large default inset (50px 50px 20px) + // so the EPUB content can use more of the available area. + top: 8, + left: 8, + right: 8, + bottom: 8, + }, + prev: { + ...baseStyle.prev, + display: 'none', + pointerEvents: 'none', + }, + next: { + ...baseStyle.next, + display: 'none', + pointerEvents: 'none', + }, arrow: { ...baseStyle.arrow, color: colors.foreground, @@ -54,6 +100,9 @@ export const getThemeStyles = (): IReactReaderStyle => { tocButton: { ...baseStyle.tocButton, color: colors.muted, + // Ensure the TOC toggle sits above the swipe wrapper + // and text iframe, avoiding z-index conflicts. + zIndex: 300, }, tocAreaButton: { ...baseStyle.tocAreaButton, @@ -117,4 +166,4 @@ export const useEPUBTheme = (epubTheme: boolean, rendition: Rendition | undefine }, [epubTheme, rendition, updateTheme]); return { updateTheme }; -}; \ No newline at end of file +}; diff --git a/tests/upload.spec.ts b/tests/upload.spec.ts index fcf2051..dc56f38 100644 --- a/tests/upload.spec.ts +++ b/tests/upload.spec.ts @@ -43,9 +43,9 @@ test.describe('Document Upload Tests', () => { test('displays an EPUB document', async ({ page }) => { await uploadAndDisplay(page, 'sample.epub'); await expectViewerForFile(page, 'sample.epub'); - // Keep navigation button assertions - await expect(page.getByRole('button', { name: '‹' })).toBeVisible(); - await expect(page.getByRole('button', { name: '›' })).toBeVisible(); + // Navigation controls should be exposed via accessible labels + await expect(page.getByRole('button', { name: 'Previous section' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Next section' })).toBeVisible(); }); test('displays a DOCX document as PDF after conversion', async ({ page }) => { @@ -126,4 +126,4 @@ test.describe('Document Upload Tests', () => { // Also ensure no link with that filename exists await expect(page.getByRole('link', { name: /unsupported\.xyz/i })).toHaveCount(0); }); -}); \ No newline at end of file +});