From 3a0ed999ac0c1c6fd46563d3cb4050d50c5c4153 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 13 May 2026 13:32:37 -0600 Subject: [PATCH] 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. --- src/app/(app)/app/layout.tsx | 20 ++++++ src/app/(app)/epub/[id]/layout.tsx | 17 +++++ src/app/(app)/epub/[id]/page.tsx | 12 +++- src/app/(app)/html/[id]/layout.tsx | 17 +++++ src/app/(app)/pdf/[id]/layout.tsx | 17 +++++ src/app/providers.tsx | 79 +++++------------------ src/components/reader/SegmentsSidebar.tsx | 16 ++--- src/hooks/epub/useEPUBResize.ts | 36 ++++++++--- 8 files changed, 132 insertions(+), 82 deletions(-) create mode 100644 src/app/(app)/app/layout.tsx create mode 100644 src/app/(app)/epub/[id]/layout.tsx create mode 100644 src/app/(app)/html/[id]/layout.tsx create mode 100644 src/app/(app)/pdf/[id]/layout.tsx diff --git a/src/app/(app)/app/layout.tsx b/src/app/(app)/app/layout.tsx new file mode 100644 index 0000000..f8abbbb --- /dev/null +++ b/src/app/(app)/app/layout.tsx @@ -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 ( + + + <> + {children} + + + + + ); +} diff --git a/src/app/(app)/epub/[id]/layout.tsx b/src/app/(app)/epub/[id]/layout.tsx new file mode 100644 index 0000000..701e1e7 --- /dev/null +++ b/src/app/(app)/epub/[id]/layout.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/src/app/(app)/epub/[id]/page.tsx b/src/app/(app)/epub/[id]/page.tsx index 878beff..b3ee043 100644 --- a/src/app/(app)/epub/[id]/page.tsx +++ b/src/app/(app)/epub/[id]/page.tsx @@ -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(null); @@ -36,6 +36,7 @@ export default function EPUBPage() { const [maxPadPx, setMaxPadPx] = useState(0); const inFlightDocIdRef = useRef(null); const loadedDocIdRef = useRef(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} /> ); diff --git a/src/app/(app)/html/[id]/layout.tsx b/src/app/(app)/html/[id]/layout.tsx new file mode 100644 index 0000000..45189a7 --- /dev/null +++ b/src/app/(app)/html/[id]/layout.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/src/app/(app)/pdf/[id]/layout.tsx b/src/app/(app)/pdf/[id]/layout.tsx new file mode 100644 index 0000000..f5ac472 --- /dev/null +++ b/src/app/(app)/pdf/[id]/layout.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 361f3d3..c777ca0 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -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 ? ( - - - - - <> - {children} - {authEnabled && } - - - - - - ) : ( - - - - - - - - - - - <> - {children} - {authEnabled && } - - - - - - - - - - - - - ); return ( - {content} + + + + + <> + {children} + {authEnabled && } + + + + + ); } diff --git a/src/components/reader/SegmentsSidebar.tsx b/src/components/reader/SegmentsSidebar.tsx index 6dfbdf8..6994b8c 100644 --- a/src/components/reader/SegmentsSidebar.tsx +++ b/src/components/reader/SegmentsSidebar.tsx @@ -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; } 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({ 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) diff --git a/src/hooks/epub/useEPUBResize.ts b/src/hooks/epub/useEPUBResize.ts index 2a215d3..506bd35 100644 --- a/src/hooks/epub/useEPUBResize.ts +++ b/src/hooks/epub/useEPUBResize.ts @@ -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) { const [isResizing, setIsResizing] = useState(false); const [dimensions, setDimensions] = useState(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) { }, 150); const resizeObserver = new ResizeObserver((entries) => { - // if (!isResizing) { - // setIsResizing(true); - // } debouncedResize(entries); }); @@ -28,7 +46,6 @@ export function useEPUBResize(containerRef: RefObject) { 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) { 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) { }, [containerRef]); return { isResizing, setIsResizing, dimensions }; -} \ No newline at end of file +}