Add force-reparse confirmation flow in PDF UI

This commit is contained in:
Richard R 2026-05-21 10:34:29 -06:00
parent 10748c7fcd
commit dd494d50aa
4 changed files with 66 additions and 4 deletions

View file

@ -10,6 +10,7 @@ import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu';
import { SegmentsSidebar } from '@/components/reader/SegmentsSidebar';
import { Header } from '@/components/Header';
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import type { TTSAudiobookChapter } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client';
import TTSPlayer from '@/components/player/TTSPlayer';
@ -19,6 +20,12 @@ import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { LoadingSpinner } from '@/components/Spinner';
import {
FORCE_REPARSE_CONFIRM_MESSAGE,
FORCE_REPARSE_CONFIRM_TEXT,
FORCE_REPARSE_CONFIRM_TITLE,
isForceReparseDisabled,
} from '@/lib/client/pdf/force-reparse';
import { usePdfDocument } from './usePdfDocument';
// Dynamic import for client-side rendering only
@ -58,6 +65,7 @@ export default function PDFViewerPage() {
const [isPdfViewerReady, setIsPdfViewerReady] = useState(false);
const [zoomLevel, setZoomLevel] = useState<number>(100);
const [activeSidebar, setActiveSidebar] = useState<null | 'settings' | 'audiobook' | 'segments'>(null);
const [showForceReparseConfirm, setShowForceReparseConfirm] = useState(false);
const [containerHeight, setContainerHeight] = useState<string>('auto');
const inFlightDocIdRef = useRef<string | null>(null);
const loadedDocIdRef = useRef<string | null>(null);
@ -66,6 +74,7 @@ export default function PDFViewerPage() {
const [isNavigatingBack, setIsNavigatingBack] = useState(false);
const parseState = parseStatus ?? 'pending';
const isParseReady = parseState === 'ready';
const forceReparseDisabled = isForceReparseDisabled(parseStatus);
useEffect(() => {
setIsLoading(true);
@ -198,6 +207,16 @@ export default function PDFViewerPage() {
}, delayMs);
}, [isNavigatingBack, stop, activeSidebar, router]);
const requestForceReparse = useCallback(() => {
if (forceReparseDisabled) return;
setShowForceReparseConfirm(true);
}, [forceReparseDisabled]);
const confirmForceReparse = useCallback(() => {
setShowForceReparseConfirm(false);
void forceReparseParsedPdf();
}, [forceReparseParsedPdf]);
const handleGenerateAudiobook = useCallback(async (
onProgress: (progress: number) => void,
signal: AbortSignal,
@ -339,7 +358,7 @@ export default function PDFViewerPage() {
<div className="flex justify-start">
<button
type="button"
onClick={() => forceReparseParsedPdf()}
onClick={requestForceReparse}
className="inline-flex items-center rounded-md border border-offbase bg-offbase px-2.5 py-1 text-xs text-foreground hover:text-accent transition-colors"
>
Retry Parse
@ -445,9 +464,18 @@ export default function PDFViewerPage() {
},
});
},
onForceReparse: () => forceReparseParsedPdf(),
onForceReparse: requestForceReparse,
}}
/>
<ConfirmDialog
isOpen={showForceReparseConfirm}
onClose={() => setShowForceReparseConfirm(false)}
onConfirm={confirmForceReparse}
title={FORCE_REPARSE_CONFIRM_TITLE}
message={FORCE_REPARSE_CONFIRM_MESSAGE}
confirmText={FORCE_REPARSE_CONFIRM_TEXT}
cancelText="Cancel"
/>
<SegmentsSidebar
isOpen={activeSidebar === 'segments'}
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'segments' : (prev === 'segments' ? null : prev))}

View file

@ -18,6 +18,7 @@ import {
import { Section, ToggleRow, CheckItem, buttonClass, segmentedButtonClass, segmentedGroupClass } from '@/components/formPrimitives';
import { RefreshIcon, SparkleIcon } from '@/components/icons/Icons';
import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf';
import { isForceReparseDisabled } from '@/lib/client/pdf/force-reparse';
const PDF_SKIP_KIND_OPTIONS: Array<{ kind: ParsedPdfBlockKind; label: string }> = [
{ kind: 'header', label: 'Header' },
@ -221,10 +222,10 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
type="button"
className={buttonClass({ variant: 'ghost', size: 'icon', className: '!h-5 !w-5 hover:scale-[1.1] shrink-0' })}
onClick={pdf.onForceReparse}
disabled={pdf.parseStatus === 'running' || pdf.parseStatus === 'pending'}
disabled={isForceReparseDisabled(pdf.parseStatus)}
title="Force reparse"
>
<RefreshIcon className={`h-3 w-3 ${pdf.parseStatus === 'running' || pdf.parseStatus === 'pending' ? 'animate-spin' : ''}`} />
<RefreshIcon className={`h-3 w-3 ${isForceReparseDisabled(pdf.parseStatus) ? 'animate-spin' : ''}`} />
</button>
</span>
</div>

View file

@ -0,0 +1,9 @@
import type { PdfParseStatus } from '@/types/parsed-pdf';
export const FORCE_REPARSE_CONFIRM_TITLE = 'Reparse PDF Layout?';
export const FORCE_REPARSE_CONFIRM_MESSAGE = 'This reruns page layout parsing from scratch and can take a while on large documents.';
export const FORCE_REPARSE_CONFIRM_TEXT = 'Reparse Now';
export function isForceReparseDisabled(status: PdfParseStatus | null): boolean {
return status === 'pending' || status === 'running';
}

View file

@ -0,0 +1,24 @@
import { expect, test } from '@playwright/test';
import {
FORCE_REPARSE_CONFIRM_MESSAGE,
FORCE_REPARSE_CONFIRM_TEXT,
FORCE_REPARSE_CONFIRM_TITLE,
isForceReparseDisabled,
} from '../../src/lib/client/pdf/force-reparse';
test.describe('pdf force reparse controls', () => {
test('disables action while parse is pending or running', () => {
expect(isForceReparseDisabled('pending')).toBeTruthy();
expect(isForceReparseDisabled('running')).toBeTruthy();
expect(isForceReparseDisabled('ready')).toBeFalsy();
expect(isForceReparseDisabled('failed')).toBeFalsy();
expect(isForceReparseDisabled(null)).toBeFalsy();
});
test('confirmation copy warns about expensive rerun', () => {
expect(FORCE_REPARSE_CONFIRM_TITLE).toContain('Reparse');
expect(FORCE_REPARSE_CONFIRM_TEXT).toContain('Reparse');
expect(FORCE_REPARSE_CONFIRM_MESSAGE.toLowerCase()).toContain('from scratch');
expect(FORCE_REPARSE_CONFIRM_MESSAGE.toLowerCase()).toContain('take a while');
});
});