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)}
|
onChange={(e) => updateConfigKey('epubTheme', e.target.checked)}
|
||||||
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
|
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>
|
</label>
|
||||||
<p className="text-sm text-muted pl-6">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useCallback } from 'react';
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useEPUB } from '@/contexts/EPUBContext';
|
import { useEPUB } from '@/contexts/EPUBContext';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
|
|
@ -8,6 +8,7 @@ import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||||
import { useEPUBTheme, getThemeStyles } from '@/hooks/epub/useEPUBTheme';
|
import { useEPUBTheme, getThemeStyles } from '@/hooks/epub/useEPUBTheme';
|
||||||
import { useEPUBResize } from '@/hooks/epub/useEPUBResize';
|
import { useEPUBResize } from '@/hooks/epub/useEPUBResize';
|
||||||
|
import { DotsVerticalIcon, ChevronLeftIcon, ChevronRightIcon } from '@/components/icons/Icons';
|
||||||
|
|
||||||
const ReactReader = dynamic(() => import('react-reader').then(mod => mod.ReactReader), {
|
const ReactReader = dynamic(() => import('react-reader').then(mod => mod.ReactReader), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
|
|
@ -19,9 +20,12 @@ interface EPUBViewerProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
||||||
|
const [isTocOpen, setIsTocOpen] = useState(false);
|
||||||
const {
|
const {
|
||||||
currDocData,
|
currDocData,
|
||||||
currDocName,
|
currDocName,
|
||||||
|
currDocPage,
|
||||||
|
currDocPages,
|
||||||
locationRef,
|
locationRef,
|
||||||
handleLocationChanged,
|
handleLocationChanged,
|
||||||
bookRef,
|
bookRef,
|
||||||
|
|
@ -116,7 +120,62 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`h-full flex flex-col relative z-0 ${className}`} ref={containerRef}>
|
<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
|
<ReactReader
|
||||||
loadingView={<DocumentSkeleton />}
|
loadingView={<DocumentSkeleton />}
|
||||||
key={'epub-reader'}
|
key={'epub-reader'}
|
||||||
|
|
@ -125,8 +184,8 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
||||||
url={currDocData}
|
url={currDocData}
|
||||||
title={currDocName}
|
title={currDocName}
|
||||||
tocChanged={(_toc) => (tocRef.current = _toc)}
|
tocChanged={(_toc) => (tocRef.current = _toc)}
|
||||||
showToc={true}
|
showToc={false}
|
||||||
readerStyles={epubTheme && getThemeStyles() || undefined}
|
readerStyles={getThemeStyles(epubTheme)}
|
||||||
getRendition={(_rendition) => {
|
getRendition={(_rendition) => {
|
||||||
setRendition(_rendition);
|
setRendition(_rendition);
|
||||||
updateTheme();
|
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>) {
|
export function SpeedometerIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,41 @@ import { useCallback, useEffect } from 'react';
|
||||||
import { Rendition } from 'epubjs';
|
import { Rendition } from 'epubjs';
|
||||||
import { ReactReaderStyle, IReactReaderStyle } from 'react-reader';
|
import { ReactReaderStyle, IReactReaderStyle } from 'react-reader';
|
||||||
|
|
||||||
export const getThemeStyles = (): IReactReaderStyle => {
|
// Returns ReactReader styles, with:
|
||||||
const baseStyle = {
|
// - default look when epubTheme === false (except hiding built-in arrows)
|
||||||
...ReactReaderStyle,
|
// - themed colors + layout tweaks when epubTheme === true
|
||||||
readerArea: {
|
export const getThemeStyles = (epubTheme: boolean): IReactReaderStyle => {
|
||||||
...ReactReaderStyle.readerArea,
|
const baseStyle = ReactReaderStyle;
|
||||||
transition: undefined,
|
|
||||||
}
|
// 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 = {
|
const colors = {
|
||||||
background: getComputedStyle(document.documentElement).getPropertyValue('--background'),
|
background: getComputedStyle(document.documentElement).getPropertyValue('--background'),
|
||||||
|
|
@ -21,6 +48,25 @@ export const getThemeStyles = (): IReactReaderStyle => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...baseStyle,
|
...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: {
|
arrow: {
|
||||||
...baseStyle.arrow,
|
...baseStyle.arrow,
|
||||||
color: colors.foreground,
|
color: colors.foreground,
|
||||||
|
|
@ -54,6 +100,9 @@ export const getThemeStyles = (): IReactReaderStyle => {
|
||||||
tocButton: {
|
tocButton: {
|
||||||
...baseStyle.tocButton,
|
...baseStyle.tocButton,
|
||||||
color: colors.muted,
|
color: colors.muted,
|
||||||
|
// Ensure the TOC toggle sits above the swipe wrapper
|
||||||
|
// and text iframe, avoiding z-index conflicts.
|
||||||
|
zIndex: 300,
|
||||||
},
|
},
|
||||||
tocAreaButton: {
|
tocAreaButton: {
|
||||||
...baseStyle.tocAreaButton,
|
...baseStyle.tocAreaButton,
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,9 @@ test.describe('Document Upload Tests', () => {
|
||||||
test('displays an EPUB document', async ({ page }) => {
|
test('displays an EPUB document', async ({ page }) => {
|
||||||
await uploadAndDisplay(page, 'sample.epub');
|
await uploadAndDisplay(page, 'sample.epub');
|
||||||
await expectViewerForFile(page, 'sample.epub');
|
await expectViewerForFile(page, 'sample.epub');
|
||||||
// Keep navigation button assertions
|
// Navigation controls should be exposed via accessible labels
|
||||||
await expect(page.getByRole('button', { name: '‹' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Previous section' })).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: '›' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Next section' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('displays a DOCX document as PDF after conversion', async ({ page }) => {
|
test('displays a DOCX document as PDF after conversion', async ({ page }) => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue