326 lines
8.5 KiB
TypeScript
326 lines
8.5 KiB
TypeScript
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({
|
||
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; |