refactor(reader,epub,providers): unify layout structure and improve resize handling

Adopt new layout files for app, epub, html, and pdf routes to standardize
Next.js nested layout structure. Refactor SegmentsSidebar to accept an explicit
epubBookRef prop, decoupling it from internal context. Enhance EPUB resize hook
to ignore initial baseline rect and avoid unnecessary TTS interruptions on load.
Simplify Providers by removing legacy context nesting and conditional logic.
This commit is contained in:
Richard R 2026-05-13 13:32:37 -06:00
parent c8a35e505f
commit 3a0ed999ac
8 changed files with 132 additions and 82 deletions

View file

@ -0,0 +1,20 @@
'use client';
import type { ReactNode } from 'react';
import { ConfigProvider } from '@/contexts/ConfigContext';
import { DocumentProvider } from '@/contexts/DocumentContext';
import { DexieMigrationModal } from '@/components/documents/DexieMigrationModal';
export default function AppHomeLayout({ children }: { children: ReactNode }) {
return (
<ConfigProvider>
<DocumentProvider>
<>
{children}
<DexieMigrationModal />
</>
</DocumentProvider>
</ConfigProvider>
);
}

View file

@ -0,0 +1,17 @@
'use client';
import type { ReactNode } from 'react';
import { ConfigProvider } from '@/contexts/ConfigContext';
import { TTSProvider } from '@/contexts/TTSContext';
import { EPUBProvider } from '@/contexts/EPUBContext';
export default function EpubReaderLayout({ children }: { children: ReactNode }) {
return (
<ConfigProvider>
<TTSProvider>
<EPUBProvider>{children}</EPUBProvider>
</TTSProvider>
</ConfigProvider>
);
}

View file

@ -25,7 +25,7 @@ export default function EPUBPage() {
const canExportAudiobook = useFeatureFlag('enableAudiobookExport');
const { id } = useParams();
const router = useRouter();
const { setCurrentDocument, currDocName, clearCurrDoc, createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB();
const { setCurrentDocument, currDocName, clearCurrDoc, createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter, bookRef } = useEPUB();
const { stop } = useTTS();
const { isAtLimit } = useAuthRateLimit();
const [error, setError] = useState<string | null>(null);
@ -36,6 +36,7 @@ export default function EPUBPage() {
const [maxPadPx, setMaxPadPx] = useState<number>(0);
const inFlightDocIdRef = useRef<string | null>(null);
const loadedDocIdRef = useRef<string | null>(null);
const didInitPadPctRef = useRef(false);
useEffect(() => {
setIsLoading(true);
@ -118,7 +119,13 @@ export default function EPUBPage() {
// Nudge EPUB renderer to reflow on horizontal padding changes
useEffect(() => {
// Some EPUB renderers listen to window resize; emit a synthetic event
// Some EPUB renderers listen to window resize; emit a synthetic event only
// for user-driven pad changes. Skipping initial mount avoids startup races
// that can interrupt first-play TTS requests in tests/browsers like Firefox.
if (!didInitPadPctRef.current) {
didInitPadPctRef.current = true;
return;
}
window.dispatchEvent(new Event('resize'));
}, [padPct]);
@ -235,6 +242,7 @@ export default function EPUBPage() {
isOpen={activeSidebar === 'segments'}
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'segments' : (prev === 'segments' ? null : prev))}
documentId={id as string}
epubBookRef={bookRef}
/>
</>
);

View file

@ -0,0 +1,17 @@
'use client';
import type { ReactNode } from 'react';
import { ConfigProvider } from '@/contexts/ConfigContext';
import { TTSProvider } from '@/contexts/TTSContext';
import { HTMLProvider } from '@/contexts/HTMLContext';
export default function HtmlReaderLayout({ children }: { children: ReactNode }) {
return (
<ConfigProvider>
<TTSProvider>
<HTMLProvider>{children}</HTMLProvider>
</TTSProvider>
</ConfigProvider>
);
}

View file

@ -0,0 +1,17 @@
'use client';
import type { ReactNode } from 'react';
import { ConfigProvider } from '@/contexts/ConfigContext';
import { TTSProvider } from '@/contexts/TTSContext';
import { PDFProvider } from '@/contexts/PDFContext';
export default function PdfReaderLayout({ children }: { children: ReactNode }) {
return (
<ConfigProvider>
<TTSProvider>
<PDFProvider>{children}</PDFProvider>
</TTSProvider>
</ConfigProvider>
);
}

View file

@ -1,21 +1,13 @@
'use client';
import { ReactNode, useState } from 'react';
import { usePathname } from 'next/navigation';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { DocumentProvider } from '@/contexts/DocumentContext';
import { PDFProvider } from '@/contexts/PDFContext';
import { EPUBProvider } from '@/contexts/EPUBContext';
import { TTSProvider } from '@/contexts/TTSContext';
import { ThemeProvider } from '@/contexts/ThemeContext';
import { ConfigProvider } from '@/contexts/ConfigContext';
import { HTMLProvider } from '@/contexts/HTMLContext';
import { AuthRateLimitProvider } from '@/contexts/AuthRateLimitContext';
import { RuntimeConfigProvider } from '@/contexts/RuntimeConfigContext';
import { PrivacyModal } from '@/components/PrivacyModal';
import { AuthLoader } from '@/components/auth/AuthLoader';
import { DexieMigrationModal } from '@/components/documents/DexieMigrationModal';
interface ProvidersProps {
children: ReactNode;
@ -26,7 +18,6 @@ interface ProvidersProps {
}
export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled }: ProvidersProps) {
const pathname = usePathname();
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
@ -35,62 +26,26 @@ export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAu
},
},
}));
const isAuthPage = pathname?.startsWith('/signin') || pathname?.startsWith('/signup');
const content = isAuthPage ? (
<RuntimeConfigProvider>
<AuthRateLimitProvider
authEnabled={authEnabled}
authBaseUrl={authBaseUrl}
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
githubAuthEnabled={githubAuthEnabled}
>
<ThemeProvider>
<AuthLoader>
<>
{children}
{authEnabled && <PrivacyModal />}
</>
</AuthLoader>
</ThemeProvider>
</AuthRateLimitProvider>
</RuntimeConfigProvider>
) : (
<RuntimeConfigProvider>
<AuthRateLimitProvider
authEnabled={authEnabled}
authBaseUrl={authBaseUrl}
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
githubAuthEnabled={githubAuthEnabled}
>
<ThemeProvider>
<AuthLoader>
<ConfigProvider>
<DocumentProvider>
<TTSProvider>
<PDFProvider>
<EPUBProvider>
<HTMLProvider>
<>
{children}
{authEnabled && <PrivacyModal />}
<DexieMigrationModal />
</>
</HTMLProvider>
</EPUBProvider>
</PDFProvider>
</TTSProvider>
</DocumentProvider>
</ConfigProvider>
</AuthLoader>
</ThemeProvider>
</AuthRateLimitProvider>
</RuntimeConfigProvider>
);
return (
<QueryClientProvider client={queryClient}>
{content}
<RuntimeConfigProvider>
<AuthRateLimitProvider
authEnabled={authEnabled}
authBaseUrl={authBaseUrl}
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
githubAuthEnabled={githubAuthEnabled}
>
<ThemeProvider>
<AuthLoader>
<>
{children}
{authEnabled && <PrivacyModal />}
</>
</AuthLoader>
</ThemeProvider>
</AuthRateLimitProvider>
</RuntimeConfigProvider>
</QueryClientProvider>
);
}

View file

@ -1,7 +1,8 @@
'use client';
import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Fragment, type ReactNode, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react';
import type { Book } from 'epubjs';
import toast from 'react-hot-toast';
import { useTTS } from '@/contexts/TTSContext';
import { useConfig } from '@/contexts/ConfigContext';
@ -9,7 +10,6 @@ import { RefreshIcon, InfoIcon } from '@/components/icons/Icons';
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator';
import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan';
import { useEPUB } from '@/contexts/EPUBContext';
import {
canonicalizeEpubSegmentsAgainstSpineText,
type CanonicalizedEpubSegment,
@ -36,6 +36,7 @@ interface SegmentsSidebarProps {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
documentId: string;
epubBookRef?: RefObject<Book | null>;
}
type FetchState =
@ -172,7 +173,7 @@ function isElementFullyVisibleWithinContainer(element: HTMLElement, container: H
return elRect.top >= containerRect.top && elRect.bottom <= containerRect.bottom;
}
export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSidebarProps) {
export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: SegmentsSidebarProps) {
const {
sentences,
currentSentenceIndex,
@ -192,7 +193,6 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
ttsSegmentMaxBlockLength,
updateConfigKey,
} = useConfig();
const { bookRef } = useEPUB();
/**
* Canonicalized per-sentence identities for the currently rendered page.
@ -206,7 +206,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
setSynthRowCanonical([]);
return;
}
const book = bookRef.current;
const book = epubBookRef?.current;
if (!book?.isOpen) {
setSynthRowCanonical([]);
return;
@ -234,7 +234,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
if (!cancelled) setSynthRowCanonical(next);
})();
return () => { cancelled = true; };
}, [bookRef, currDocPage, sentences, documentId, activeReaderType, ttsSegmentMaxBlockLength]);
}, [epubBookRef, currDocPage, sentences, documentId, activeReaderType, ttsSegmentMaxBlockLength]);
const [state, setState] = useState<FetchState>({ kind: 'idle' });
const [isClearing, setIsClearing] = useState(false);
@ -427,7 +427,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
// have a spine concept.
const inferredCurrentLocator: TTSSegmentLocator | null = (() => {
if (activeReaderType === 'epub' && typeof currDocPage === 'string' && currDocPage.length > 0) {
const book = bookRef.current;
const book = epubBookRef?.current;
const spine = book && book.isOpen ? resolveSpineFromCfi(book, currDocPage) : null;
if (spine) {
return {
@ -545,7 +545,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
});
return entries;
}, [state, currDocPage, currDocPageNumber, sentences, bookRef, documentId, activeReaderType, synthRowCanonical]);
}, [state, currDocPage, currDocPageNumber, sentences, epubBookRef, documentId, activeReaderType, synthRowCanonical]);
const totalVariants = state.kind === 'ready'
? rowsToRender.reduce((sum, r) => sum + r.row.variants.length, 0)

View file

@ -1,15 +1,36 @@
import { useEffect, RefObject, useState } from 'react';
import { useEffect, RefObject, useRef, useState } from 'react';
import { debounce } from '@/lib/client/pdf';
export function useEPUBResize(containerRef: RefObject<HTMLDivElement | null>) {
const [isResizing, setIsResizing] = useState(false);
const [dimensions, setDimensions] = useState<DOMRectReadOnly | null>(null);
const hasBaselineRectRef = useRef(false);
const lastRectRef = useRef<{ width: number; height: number } | null>(null);
useEffect(() => {
useEffect(() => {
const debouncedResize = debounce((...args: unknown[]) => {
const entries = args[0] as ResizeObserverEntry[];
console.log('Debounced resize', entries[0].contentRect);
setDimensions(entries[0].contentRect);
const rect = entries[0]?.contentRect;
if (!rect) return;
const nextWidth = Math.round(rect.width);
const nextHeight = Math.round(rect.height);
const prev = lastRectRef.current;
lastRectRef.current = { width: nextWidth, height: nextHeight };
// First callback after observer attach reflects initial layout, not a user
// resize. Treat it as baseline to avoid pausing TTS on page load.
if (!hasBaselineRectRef.current) {
hasBaselineRectRef.current = true;
setDimensions(rect);
return;
}
if (prev && prev.width === nextWidth && prev.height === nextHeight) {
return;
}
setDimensions(rect);
setIsResizing((prev) => {
if (!prev) return true;
return prev;
@ -17,9 +38,6 @@ export function useEPUBResize(containerRef: RefObject<HTMLDivElement | null>) {
}, 150);
const resizeObserver = new ResizeObserver((entries) => {
// if (!isResizing) {
// setIsResizing(true);
// }
debouncedResize(entries);
});
@ -28,7 +46,6 @@ export function useEPUBResize(containerRef: RefObject<HTMLDivElement | null>) {
if (mutation.addedNodes.length) {
const container = containerRef.current?.querySelector('.epub-container');
if (container) {
console.log('Observer attached to epub-container');
resizeObserver.observe(container);
mutationObserver.disconnect();
break;
@ -45,7 +62,6 @@ export function useEPUBResize(containerRef: RefObject<HTMLDivElement | null>) {
const container = containerRef.current.querySelector('.epub-container');
if (container) {
console.log('Container already exists, attaching observer');
resizeObserver.observe(container);
mutationObserver.disconnect();
}
@ -58,4 +74,4 @@ export function useEPUBResize(containerRef: RefObject<HTMLDivElement | null>) {
}, [containerRef]);
return { isResizing, setIsResizing, dimensions };
}
}