Files
pdf-tools/src/components/PagePreviewModal.tsx

328 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useRef } from 'react';
import type { PdfFile } from '../pdf/pdfTypes';
import * as pdfjsLib from 'pdfjs-dist';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?worker&url';
// pdf.js worker setup
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(pdfjsLib as any).GlobalWorkerOptions.workerSrc = pdfjsWorker;
interface PagePreviewModalProps {
isOpen: boolean;
pdf: PdfFile | null;
pageIndex: number | null; // original page index, 0-based
rotation: number; // degrees
visualIndex: number | null; // current position in order, 0-based
totalPages: number;
canGoPrevious: boolean;
canGoNext: boolean;
onPrevious: () => void;
onNext: () => void;
onClose: () => void;
}
const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
isOpen,
pdf,
pageIndex,
rotation,
visualIndex,
totalPages,
canGoPrevious,
canGoNext,
onPrevious,
onNext,
onClose,
}) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
return;
}
if (e.key === 'ArrowLeft' && canGoPrevious) {
e.preventDefault();
onPrevious();
return;
}
if (e.key === 'ArrowRight' && canGoNext) {
e.preventDefault();
onNext();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, canGoPrevious, canGoNext, onPrevious, onNext, onClose]);
useEffect(() => {
if (!isOpen || !pdf || pageIndex == null) return;
let cancelled = false;
(async () => {
try {
const canvas = canvasRef.current;
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
}
// copy data for pdf.js (avoid detaching original ArrayBuffer)
const src = new Uint8Array(pdf.arrayBuffer);
const copy = new Uint8Array(src.byteLength);
copy.set(src);
const loadingTask = pdfjsLib.getDocument({ data: copy });
const doc = await loadingTask.promise;
if (cancelled) return;
const page = await doc.getPage(pageIndex + 1);
if (cancelled) return;
const viewport = page.getViewport({ scale: 1 });
const maxWidth = Math.min(window.innerWidth * 0.9, 800);
const maxHeight = window.innerHeight * 0.75;
const scale = Math.min(
maxWidth / viewport.width,
maxHeight / viewport.height
);
const scaledViewport = page.getViewport({ scale });
const visibleCanvas = canvasRef.current;
if (!visibleCanvas) return;
const visibleCtx = visibleCanvas.getContext('2d');
if (!visibleCtx) return;
let canvasWidth = scaledViewport.width;
let canvasHeight = scaledViewport.height;
const angle = ((rotation % 360) + 360) % 360;
if (angle === 90 || angle === 270) {
canvasWidth = scaledViewport.height;
canvasHeight = scaledViewport.width;
}
visibleCanvas.width = canvasWidth;
visibleCanvas.height = canvasHeight;
const baseCanvas = document.createElement('canvas');
const baseCtx = baseCanvas.getContext('2d');
if (!baseCtx) return;
baseCanvas.width = scaledViewport.width;
baseCanvas.height = scaledViewport.height;
const renderTask = page.render({
canvas: baseCanvas,
canvasContext: baseCtx,
viewport: scaledViewport,
});
await renderTask.promise;
if (cancelled) return;
visibleCtx.save();
switch (angle) {
case 90:
visibleCtx.translate(canvasWidth, 0);
visibleCtx.rotate((angle * Math.PI) / 180);
break;
case 180:
visibleCtx.translate(canvasWidth, canvasHeight);
visibleCtx.rotate((angle * Math.PI) / 180);
break;
case 270:
visibleCtx.translate(0, canvasHeight);
visibleCtx.rotate((angle * Math.PI) / 180);
break;
}
visibleCtx.drawImage(baseCanvas, 0, 0);
visibleCtx.restore();
} catch (e) {
console.error('Error rendering preview', e);
}
})();
return () => {
cancelled = true;
};
}, [isOpen, pdf, pageIndex, rotation]);
if (!isOpen || !pdf || pageIndex == null) return null;
const positionLabel =
visualIndex != null && visualIndex >= 0
? `${visualIndex + 1} / ${totalPages}`
: `Page ${pageIndex + 1}`;
return (
<div
onClick={onClose}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(15, 23, 42, 0.8)',
zIndex: 50,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: '1rem',
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
position: 'relative',
background: '#111827',
borderRadius: '0.75rem',
padding: '0.75rem',
maxWidth: '90vw',
maxHeight: '90vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.5rem',
overflow: 'visible',
}}
>
{/* Previous page */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onPrevious();
}}
disabled={!canGoPrevious}
style={{
position: 'absolute',
left: 0,
top: '50%',
transform: 'translate(-50%, -50%)',
width: '2.5rem',
height: '2.5rem',
borderRadius: '999px',
border: 'none',
background: canGoPrevious ? '#374151' : '#1f2937',
color: canGoPrevious ? '#e5e7eb' : '#6b7280',
cursor: canGoPrevious ? 'pointer' : 'default',
fontSize: '1.35rem',
lineHeight: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2,
}}
title="Previous page (←)"
aria-label="Previous page"
>
</button>
{/* Next page */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onNext();
}}
disabled={!canGoNext}
style={{
position: 'absolute',
right: 0,
top: '50%',
transform: 'translate(50%, -50%)',
width: '2.5rem',
height: '2.5rem',
borderRadius: '999px',
border: 'none',
background: canGoNext ? '#374151' : '#1f2937',
color: canGoNext ? '#e5e7eb' : '#6b7280',
cursor: canGoNext ? 'pointer' : 'default',
fontSize: '1.35rem',
lineHeight: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2,
}}
title="Next page (→)"
aria-label="Next page"
>
</button>
{/* Close */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
style={{
position: 'absolute',
top: 0,
right: 0,
transform: 'translate(50%, -50%)',
width: '2.25rem',
height: '2.25rem',
borderRadius: '999px',
border: 'none',
background: '#374151',
color: '#e5e7eb',
cursor: 'pointer',
fontSize: '1.2rem',
lineHeight: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 3,
}}
title="Close preview (Esc)"
aria-label="Close preview"
>
×
</button>
<canvas
ref={canvasRef}
style={{
maxWidth: '100%',
maxHeight: '75vh',
background: 'white',
borderRadius: '0.5rem',
}}
/>
<div style={{ color: '#e5e7eb', fontSize: '0.85rem' }}>
{positionLabel} · Original page {pageIndex + 1} · Rot {rotation}°
</div>
</div>
</div>
);
};
export default PagePreviewModal;