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:
parent
c8a35e505f
commit
3a0ed999ac
8 changed files with 132 additions and 82 deletions
20
src/app/(app)/app/layout.tsx
Normal file
20
src/app/(app)/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/app/(app)/epub/[id]/layout.tsx
Normal file
17
src/app/(app)/epub/[id]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
17
src/app/(app)/html/[id]/layout.tsx
Normal file
17
src/app/(app)/html/[id]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/app/(app)/pdf/[id]/layout.tsx
Normal file
17
src/app/(app)/pdf/[id]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue