327 lines
8.5 KiB
TypeScript
327 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;
|