Add better zoom + controls
This commit is contained in:
parent
5edae0e8ae
commit
d78be686c1
3 changed files with 70 additions and 22 deletions
|
|
@ -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} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue