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.
This commit is contained in:
Richard Roberson 2025-11-22 01:33:53 -07:00
parent 372c65f23e
commit 773778ee90
5 changed files with 171 additions and 19 deletions

View file

@ -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"
/>
<span className="text-sm font-medium text-foreground">Use theme (experimental)</span>
<span className="text-sm font-medium text-foreground">Use theme</span>
</label>
<p className="text-sm text-muted pl-6">
Apply the current app theme to the EPUB viewer
Apply the current app theme to the EPUB viewer background and text colors
</p>
</div>
)}

View file

@ -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 (
<div className={`h-full flex flex-col relative z-0 ${className}`} ref={containerRef}>
<div className="flex-1">
<div className="flex items-center justify-between px-2 py-1 border-b border-offbase bg-base text-xs text-muted">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setIsTocOpen(open => !open)}
className="inline-flex items-center py-1 px-1 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out transform hover:scale-[1.09] hover:text-accent"
aria-label={isTocOpen ? 'Hide chapters' : 'Show chapters'}
>
<DotsVerticalIcon className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => renditionRef.current?.prev()}
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out transform hover:scale-[1.09] hover:text-accent"
aria-label="Previous section"
>
<ChevronLeftIcon className="w-4 h-4" />
</button>
</div>
{currDocPages !== undefined && typeof currDocPage === 'number' && (
<span className="px-2 tabular-nums">
{currDocPage} / {currDocPages}
</span>
)}
<button
type="button"
onClick={() => renditionRef.current?.next()}
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out transform hover:scale-[1.09] hover:text-accent"
aria-label="Next section"
>
<ChevronRightIcon className="w-4 h-4" />
</button>
</div>
{isTocOpen && tocRef.current && tocRef.current.length > 0 && (
<div className="border-b border-offbase bg-background text-xs overflow-y-auto max-h-64 p-2">
<div className="font-semibold text-muted pb-1">Skip to chapters</div>
<div className="flex flex-wrap gap-1">
{tocRef.current.map((item, index) => (
<button
key={`${item.href}-${index}`}
type="button"
onClick={() => {
if (item.href) {
handleLocationChanged(item.href);
}
setIsTocOpen(false);
}}
className="w-full px-2 py-1 rounded-md text-foreground text-center bg-base hover:bg-offbase hover:text-accent transition-colors duration-150"
>
{item.label}
</button>
))}
</div>
</div>
)}
<div className="flex-1 min-h-0">
<ReactReader
loadingView={<DocumentSkeleton />}
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();

View file

@ -373,6 +373,50 @@ export function DotsVerticalIcon(props: React.SVGProps<SVGSVGElement>) {
);
}
export function ChevronLeftIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className={props.className}
width={props.width || "1.25em"}
height={props.height || "1.25em"}
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M11 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
);
}
export function ChevronRightIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className={props.className}
width={props.width || "1.25em"}
height={props.height || "1.25em"}
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13 5l7 7-7 7M5 5l7 7-7 7"
/>
</svg>
);
}
export function SpeedometerIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg

View file

@ -2,14 +2,41 @@ import { useCallback, useEffect } from 'react';
import { Rendition } from 'epubjs';
import { ReactReaderStyle, IReactReaderStyle } from 'react-reader';
export const getThemeStyles = (): IReactReaderStyle => {
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 };
};
};

View file

@ -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);
});
});
});