Skip to content

Commit 773778e

Browse files
committed
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.
1 parent 372c65f commit 773778e

File tree

5 files changed

+171
-19
lines changed

5 files changed

+171
-19
lines changed

src/components/DocumentSettings.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -414,10 +414,10 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
414414
onChange={(e) => updateConfigKey('epubTheme', e.target.checked)}
415415
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
416416
/>
417-
<span className="text-sm font-medium text-foreground">Use theme (experimental)</span>
417+
<span className="text-sm font-medium text-foreground">Use theme</span>
418418
</label>
419419
<p className="text-sm text-muted pl-6">
420-
Apply the current app theme to the EPUB viewer
420+
Apply the current app theme to the EPUB viewer background and text colors
421421
</p>
422422
</div>
423423
)}

src/components/EPUBViewer.tsx

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
'use client';
22

3-
import { useEffect, useRef, useCallback } from 'react';
3+
import { useEffect, useRef, useCallback, useState } from 'react';
44
import dynamic from 'next/dynamic';
55
import { useEPUB } from '@/contexts/EPUBContext';
66
import { useTTS } from '@/contexts/TTSContext';
77
import { useConfig } from '@/contexts/ConfigContext';
88
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
99
import { useEPUBTheme, getThemeStyles } from '@/hooks/epub/useEPUBTheme';
1010
import { useEPUBResize } from '@/hooks/epub/useEPUBResize';
11+
import { DotsVerticalIcon, ChevronLeftIcon, ChevronRightIcon } from '@/components/icons/Icons';
1112

1213
const ReactReader = dynamic(() => import('react-reader').then(mod => mod.ReactReader), {
1314
ssr: false,
@@ -19,9 +20,12 @@ interface EPUBViewerProps {
1920
}
2021

2122
export function EPUBViewer({ className = '' }: EPUBViewerProps) {
23+
const [isTocOpen, setIsTocOpen] = useState(false);
2224
const {
2325
currDocData,
2426
currDocName,
27+
currDocPage,
28+
currDocPages,
2529
locationRef,
2630
handleLocationChanged,
2731
bookRef,
@@ -116,7 +120,62 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
116120

117121
return (
118122
<div className={`h-full flex flex-col relative z-0 ${className}`} ref={containerRef}>
119-
<div className="flex-1">
123+
<div className="flex items-center justify-between px-2 py-1 border-b border-offbase bg-base text-xs text-muted">
124+
<div className="flex items-center gap-2">
125+
<button
126+
type="button"
127+
onClick={() => setIsTocOpen(open => !open)}
128+
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"
129+
aria-label={isTocOpen ? 'Hide chapters' : 'Show chapters'}
130+
>
131+
<DotsVerticalIcon className="w-4 h-4" />
132+
</button>
133+
<button
134+
type="button"
135+
onClick={() => renditionRef.current?.prev()}
136+
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"
137+
aria-label="Previous section"
138+
>
139+
<ChevronLeftIcon className="w-4 h-4" />
140+
</button>
141+
</div>
142+
{currDocPages !== undefined && typeof currDocPage === 'number' && (
143+
<span className="px-2 tabular-nums">
144+
{currDocPage} / {currDocPages}
145+
</span>
146+
)}
147+
<button
148+
type="button"
149+
onClick={() => renditionRef.current?.next()}
150+
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"
151+
aria-label="Next section"
152+
>
153+
<ChevronRightIcon className="w-4 h-4" />
154+
</button>
155+
</div>
156+
{isTocOpen && tocRef.current && tocRef.current.length > 0 && (
157+
<div className="border-b border-offbase bg-background text-xs overflow-y-auto max-h-64 p-2">
158+
<div className="font-semibold text-muted pb-1">Skip to chapters</div>
159+
<div className="flex flex-wrap gap-1">
160+
{tocRef.current.map((item, index) => (
161+
<button
162+
key={`${item.href}-${index}`}
163+
type="button"
164+
onClick={() => {
165+
if (item.href) {
166+
handleLocationChanged(item.href);
167+
}
168+
setIsTocOpen(false);
169+
}}
170+
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"
171+
>
172+
{item.label}
173+
</button>
174+
))}
175+
</div>
176+
</div>
177+
)}
178+
<div className="flex-1 min-h-0">
120179
<ReactReader
121180
loadingView={<DocumentSkeleton />}
122181
key={'epub-reader'}
@@ -125,8 +184,8 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
125184
url={currDocData}
126185
title={currDocName}
127186
tocChanged={(_toc) => (tocRef.current = _toc)}
128-
showToc={true}
129-
readerStyles={epubTheme && getThemeStyles() || undefined}
187+
showToc={false}
188+
readerStyles={getThemeStyles(epubTheme)}
130189
getRendition={(_rendition) => {
131190
setRendition(_rendition);
132191
updateTheme();

src/components/icons/Icons.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,50 @@ export function DotsVerticalIcon(props: React.SVGProps<SVGSVGElement>) {
373373
);
374374
}
375375

376+
export function ChevronLeftIcon(props: React.SVGProps<SVGSVGElement>) {
377+
return (
378+
<svg
379+
xmlns="http://www.w3.org/2000/svg"
380+
viewBox="0 0 24 24"
381+
fill="none"
382+
stroke="currentColor"
383+
className={props.className}
384+
width={props.width || "1.25em"}
385+
height={props.height || "1.25em"}
386+
{...props}
387+
>
388+
<path
389+
strokeLinecap="round"
390+
strokeLinejoin="round"
391+
strokeWidth="2"
392+
d="M11 19l-7-7m0 0l7-7m-7 7h18"
393+
/>
394+
</svg>
395+
);
396+
}
397+
398+
export function ChevronRightIcon(props: React.SVGProps<SVGSVGElement>) {
399+
return (
400+
<svg
401+
xmlns="http://www.w3.org/2000/svg"
402+
viewBox="0 0 24 24"
403+
fill="none"
404+
stroke="currentColor"
405+
className={props.className}
406+
width={props.width || "1.25em"}
407+
height={props.height || "1.25em"}
408+
{...props}
409+
>
410+
<path
411+
strokeLinecap="round"
412+
strokeLinejoin="round"
413+
strokeWidth="2"
414+
d="M13 5l7 7-7 7M5 5l7 7-7 7"
415+
/>
416+
</svg>
417+
);
418+
}
419+
376420
export function SpeedometerIcon(props: React.SVGProps<SVGSVGElement>) {
377421
return (
378422
<svg

src/hooks/epub/useEPUBTheme.ts

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,41 @@ import { useCallback, useEffect } from 'react';
22
import { Rendition } from 'epubjs';
33
import { ReactReaderStyle, IReactReaderStyle } from 'react-reader';
44

5-
export const getThemeStyles = (): IReactReaderStyle => {
6-
const baseStyle = {
7-
...ReactReaderStyle,
8-
readerArea: {
9-
...ReactReaderStyle.readerArea,
10-
transition: undefined,
11-
}
12-
};
5+
// Returns ReactReader styles, with:
6+
// - default look when epubTheme === false (except hiding built-in arrows)
7+
// - themed colors + layout tweaks when epubTheme === true
8+
export const getThemeStyles = (epubTheme: boolean): IReactReaderStyle => {
9+
const baseStyle = ReactReaderStyle;
10+
11+
// Always hide the built-in prev/next arrow buttons so we can
12+
// provide our own navigation controls outside the reader.
13+
if (!epubTheme) {
14+
return {
15+
...baseStyle,
16+
reader: {
17+
...baseStyle.reader,
18+
// Always tighten the inset a bit for better use of space
19+
top: 8,
20+
left: 8,
21+
right: 8,
22+
bottom: 8,
23+
},
24+
prev: {
25+
...baseStyle.prev,
26+
display: 'none',
27+
pointerEvents: 'none',
28+
},
29+
next: {
30+
...baseStyle.next,
31+
display: 'none',
32+
pointerEvents: 'none',
33+
},
34+
titleArea: {
35+
...baseStyle.titleArea,
36+
display: 'none',
37+
},
38+
};
39+
}
1340

1441
const colors = {
1542
background: getComputedStyle(document.documentElement).getPropertyValue('--background'),
@@ -21,6 +48,25 @@ export const getThemeStyles = (): IReactReaderStyle => {
2148

2249
return {
2350
...baseStyle,
51+
reader: {
52+
...baseStyle.reader,
53+
// Reduce the large default inset (50px 50px 20px)
54+
// so the EPUB content can use more of the available area.
55+
top: 8,
56+
left: 8,
57+
right: 8,
58+
bottom: 8,
59+
},
60+
prev: {
61+
...baseStyle.prev,
62+
display: 'none',
63+
pointerEvents: 'none',
64+
},
65+
next: {
66+
...baseStyle.next,
67+
display: 'none',
68+
pointerEvents: 'none',
69+
},
2470
arrow: {
2571
...baseStyle.arrow,
2672
color: colors.foreground,
@@ -54,6 +100,9 @@ export const getThemeStyles = (): IReactReaderStyle => {
54100
tocButton: {
55101
...baseStyle.tocButton,
56102
color: colors.muted,
103+
// Ensure the TOC toggle sits above the swipe wrapper
104+
// and text iframe, avoiding z-index conflicts.
105+
zIndex: 300,
57106
},
58107
tocAreaButton: {
59108
...baseStyle.tocAreaButton,
@@ -117,4 +166,4 @@ export const useEPUBTheme = (epubTheme: boolean, rendition: Rendition | undefine
117166
}, [epubTheme, rendition, updateTheme]);
118167

119168
return { updateTheme };
120-
};
169+
};

tests/upload.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,9 @@ test.describe('Document Upload Tests', () => {
4343
test('displays an EPUB document', async ({ page }) => {
4444
await uploadAndDisplay(page, 'sample.epub');
4545
await expectViewerForFile(page, 'sample.epub');
46-
// Keep navigation button assertions
47-
await expect(page.getByRole('button', { name: '' })).toBeVisible();
48-
await expect(page.getByRole('button', { name: '' })).toBeVisible();
46+
// Navigation controls should be exposed via accessible labels
47+
await expect(page.getByRole('button', { name: 'Previous section' })).toBeVisible();
48+
await expect(page.getByRole('button', { name: 'Next section' })).toBeVisible();
4949
});
5050

5151
test('displays a DOCX document as PDF after conversion', async ({ page }) => {
@@ -126,4 +126,4 @@ test.describe('Document Upload Tests', () => {
126126
// Also ensure no link with that filename exists
127127
await expect(page.getByRole('link', { name: /unsupported\.xyz/i })).toHaveCount(0);
128128
});
129-
});
129+
});

0 commit comments

Comments
 (0)