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:
parent
372c65f23e
commit
773778ee90
5 changed files with 171 additions and 19 deletions
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue