Add better zoom + controls

This commit is contained in:
Richard Roberson 2025-01-22 13:34:48 -07:00
parent 5edae0e8ae
commit d78be686c1
3 changed files with 70 additions and 22 deletions

View file

@ -25,6 +25,7 @@ export default function PDFViewerPage() {
const [document, setDocument] = useState<{ name: string; data: Blob } | null>(null); const [document, setDocument] = useState<{ name: string; data: Blob } | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [zoomLevel, setZoomLevel] = useState<number>(100);
useEffect(() => { useEffect(() => {
async function loadDocument() { async function loadDocument() {
@ -46,6 +47,9 @@ export default function PDFViewerPage() {
loadDocument(); loadDocument();
}, [id, getDocument]); }, [id, getDocument]);
const handleZoomIn = () => setZoomLevel(prev => Math.min(prev + 10, 200));
const handleZoomOut = () => setZoomLevel(prev => Math.max(prev - 10, 50));
if (error) { if (error) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen"> <div className="flex flex-col items-center justify-center min-h-screen">
@ -71,20 +75,39 @@ export default function PDFViewerPage() {
<> <>
<TTSPlayer /> <TTSPlayer />
<div className="p-2 pb-2 border-b border-offbase"> <div className="p-2 pb-2 border-b border-offbase">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between">
<Link <div className="flex items-center gap-4">
href="/" <Link
onClick={() => { href="/"
setText(''); onClick={() => {
stop(); setText('');
}} stop();
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-colors" }}
> className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-colors"
<svg className="w-4 h-4 mr-2 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24"> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" /> <svg className="w-4 h-4 mr-2 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</svg> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
Documents </svg>
</Link> Documents
</Link>
<div className="bg-offbase px-2 py-0.5 rounded-full flex items-center gap-2">
<button
onClick={handleZoomOut}
className="text-xs hover:text-accent transition-colors"
aria-label="Zoom out"
>
</button>
<span className="text-xs">{zoomLevel}%</span>
<button
onClick={handleZoomIn}
className="text-xs hover:text-accent transition-colors"
aria-label="Zoom in"
>
</button>
</div>
</div>
<h1 className="mr-2 text-md font-semibold text-foreground"> <h1 className="mr-2 text-md font-semibold text-foreground">
{isLoading ? 'Loading...' : document?.name} {isLoading ? 'Loading...' : document?.name}
</h1> </h1>
@ -95,7 +118,7 @@ export default function PDFViewerPage() {
<PDFSkeleton /> <PDFSkeleton />
</div> </div>
) : ( ) : (
<PDFViewer pdfData={document?.data} /> <PDFViewer pdfData={document?.data} zoomLevel={zoomLevel} />
)} )}
</> </>
); );

View file

@ -11,10 +11,12 @@ import { usePDF } from '@/contexts/PDFContext';
interface PDFViewerProps { interface PDFViewerProps {
pdfData: Blob | undefined; pdfData: Blob | undefined;
zoomLevel: number;
} }
export function PDFViewer({ pdfData }: PDFViewerProps) { export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
const [numPages, setNumPages] = useState<number>(); const [numPages, setNumPages] = useState<number>();
const [containerWidth, setContainerWidth] = useState<number>(0);
const { setText, currentSentence, stopAndPlayFromIndex, isProcessing } = useTTS(); const { setText, currentSentence, stopAndPlayFromIndex, isProcessing } = useTTS();
const [pdfText, setPdfText] = useState(''); const [pdfText, setPdfText] = useState('');
const [pdfDataUrl, setPdfDataUrl] = useState<string>(); const [pdfDataUrl, setPdfDataUrl] = useState<string>();
@ -143,6 +145,29 @@ export function PDFViewer({ pdfData }: PDFViewerProps) {
}; };
}, [pdfText, currentSentence, highlightPattern, clearHighlights]); }, [pdfText, currentSentence, highlightPattern, clearHighlights]);
// Add scale calculation function
const calculateScale = (pageWidth: number = 595) => { // 595 is default PDF width in points
const margin = 24; // 24px padding on each side
const targetWidth = containerWidth - margin;
const baseScale = targetWidth / pageWidth;
return baseScale * (zoomLevel / 100);
};
// Add resize observer effect
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver(entries => {
const width = entries[0]?.contentRect.width;
if (width) {
setContainerWidth(width);
}
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
function onDocumentLoadSuccess({ numPages }: { numPages: number }): void { function onDocumentLoadSuccess({ numPages }: { numPages: number }): void {
setNumPages(numPages); setNumPages(numPages);
} }
@ -150,7 +175,7 @@ export function PDFViewer({ pdfData }: PDFViewerProps) {
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className="flex flex-col items-center overflow-auto max-h-[calc(100vh-100px)]" className="flex flex-col items-center overflow-auto max-h-[calc(100vh-100px)] w-full px-6"
style={{ WebkitTapHighlightColor: 'transparent' }} style={{ WebkitTapHighlightColor: 'transparent' }}
> >
{loadingError ? ( {loadingError ? (
@ -161,7 +186,7 @@ export function PDFViewer({ pdfData }: PDFViewerProps) {
noData={<PDFSkeleton />} noData={<PDFSkeleton />}
file={pdfDataUrl} file={pdfDataUrl}
onLoadSuccess={onDocumentLoadSuccess} onLoadSuccess={onDocumentLoadSuccess}
className="flex flex-col items-center" className="flex flex-col items-center m-0"
> >
{Array.from( {Array.from(
new Array(numPages), new Array(numPages),
@ -178,7 +203,7 @@ export function PDFViewer({ pdfData }: PDFViewerProps) {
renderAnnotationLayer={true} renderAnnotationLayer={true}
renderTextLayer={true} renderTextLayer={true}
className="shadow-lg" className="shadow-lg"
scale={1.2} scale={calculateScale()}
/> />
</div> </div>
</div> </div>

View file

@ -131,7 +131,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
}, [audioContext]); }, [audioContext]);
// Text preprocessing function to clean and normalize text // Text preprocessing function to clean and normalize text
const preprocessText = (text: string): string => { const preprocessSentenceForAudio = (text: string): string => {
return text return text
// Replace URLs with descriptive text including domain // Replace URLs with descriptive text including domain
.replace(/\S*(?:https?:\/\/|www\.)([^\/\s]+)(?:\/\S*)?/gi, '- (link to $1) -') .replace(/\S*(?:https?:\/\/|www\.)([^\/\s]+)(?:\/\S*)?/gi, '- (link to $1) -')
@ -147,7 +147,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
const splitIntoSentences = (text: string): string[] => { const splitIntoSentences = (text: string): string[] => {
// Preprocess the text before splitting into sentences // Preprocess the text before splitting into sentences
const cleanedText = preprocessText(text); const cleanedText = preprocessSentenceForAudio(text);
const doc = nlp(cleanedText); const doc = nlp(cleanedText);
return doc.sentences().out('array') as string[]; return doc.sentences().out('array') as string[];
}; };
@ -173,7 +173,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
try { try {
// Only set processing if we need to fetch from API // Only set processing if we need to fetch from API
const cleanedSentence = preprocessText(sentence); const cleanedSentence = preprocessSentenceForAudio(sentence);
if (!audioCacheRef.current.has(cleanedSentence)) { if (!audioCacheRef.current.has(cleanedSentence)) {
setIsProcessing(true); setIsProcessing(true);
} }