138 lines
3.8 KiB
TypeScript
138 lines
3.8 KiB
TypeScript
|
|
import { useEffect, useMemo, useState } from "react";
|
||
|
|
|
||
|
|
type Props = {
|
||
|
|
url?: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
export function PDFPreview({ url }: Props) {
|
||
|
|
const [loaded, setLoaded] = useState(false);
|
||
|
|
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
const [stage, setStage] = useState<"fetch" | "viewer">("fetch");
|
||
|
|
|
||
|
|
if (!url) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const fileUrl = useMemo(() => {
|
||
|
|
try {
|
||
|
|
const u = new URL(url);
|
||
|
|
if (import.meta.env.DEV && u.pathname.startsWith("/anon/")) {
|
||
|
|
return `${u.pathname}${u.search}`;
|
||
|
|
}
|
||
|
|
return url;
|
||
|
|
} catch {
|
||
|
|
return url;
|
||
|
|
}
|
||
|
|
}, [url]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
let revokedUrl: string | null = null;
|
||
|
|
const controller = new AbortController();
|
||
|
|
const timeoutMs = 20000;
|
||
|
|
|
||
|
|
setLoaded(false);
|
||
|
|
setError(null);
|
||
|
|
setStage("fetch");
|
||
|
|
setBlobUrl((prev) => {
|
||
|
|
if (prev) URL.revokeObjectURL(prev);
|
||
|
|
return null;
|
||
|
|
});
|
||
|
|
|
||
|
|
(async () => {
|
||
|
|
try {
|
||
|
|
const resp = (await Promise.race([
|
||
|
|
fetch(fileUrl, {
|
||
|
|
credentials: "include",
|
||
|
|
signal: controller.signal,
|
||
|
|
}),
|
||
|
|
new Promise((_, reject) =>
|
||
|
|
setTimeout(() => {
|
||
|
|
try {
|
||
|
|
controller.abort();
|
||
|
|
} catch {
|
||
|
|
// ignore
|
||
|
|
}
|
||
|
|
reject(new Error("PDF fetch timeout"));
|
||
|
|
}, timeoutMs)
|
||
|
|
),
|
||
|
|
])) as Response;
|
||
|
|
|
||
|
|
if (!resp.ok) {
|
||
|
|
throw new Error(`HTTP ${resp.status}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const buf = await resp.arrayBuffer();
|
||
|
|
|
||
|
|
const head = new Uint8Array(buf.slice(0, 5));
|
||
|
|
const isPdf =
|
||
|
|
head.length === 5 &&
|
||
|
|
head[0] === 0x25 &&
|
||
|
|
head[1] === 0x50 &&
|
||
|
|
head[2] === 0x44 &&
|
||
|
|
head[3] === 0x46 &&
|
||
|
|
head[4] === 0x2d;
|
||
|
|
if (!isPdf) {
|
||
|
|
throw new Error("Response is not a PDF");
|
||
|
|
}
|
||
|
|
|
||
|
|
const blob = new Blob([buf], { type: "application/pdf" });
|
||
|
|
const nextUrl = URL.createObjectURL(blob);
|
||
|
|
revokedUrl = nextUrl;
|
||
|
|
setBlobUrl(nextUrl);
|
||
|
|
setStage("viewer");
|
||
|
|
} catch (e) {
|
||
|
|
if ((e as Error)?.name === "AbortError") return;
|
||
|
|
setError((e as Error)?.message || "Failed to load PDF");
|
||
|
|
}
|
||
|
|
})();
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
controller.abort();
|
||
|
|
if (revokedUrl) URL.revokeObjectURL(revokedUrl);
|
||
|
|
};
|
||
|
|
}, [fileUrl]);
|
||
|
|
|
||
|
|
const viewerSrc = useMemo(() => {
|
||
|
|
if (!blobUrl) return null;
|
||
|
|
const base = import.meta.env.BASE_URL;
|
||
|
|
const viewerUrl = `${base}pdfjs/web/viewer.html?file=${encodeURIComponent(blobUrl)}`;
|
||
|
|
return viewerUrl;
|
||
|
|
}, [blobUrl]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex flex-col h-full w-full">
|
||
|
|
<div className="flex-auto overflow-auto bg-gray-100">
|
||
|
|
<div className="p-4">
|
||
|
|
<div
|
||
|
|
className="relative h-[calc(100vh-220px)] min-h-[480px] w-full overflow-auto rounded-md border bg-white"
|
||
|
|
>
|
||
|
|
{!!error && (
|
||
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-sm text-red-600">
|
||
|
|
<div>{error}</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{!error && !loaded && (
|
||
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-sm text-muted-foreground">
|
||
|
|
<div>
|
||
|
|
{stage === "fetch" ? "Loading..." : "Opening viewer..."}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{viewerSrc && (
|
||
|
|
<iframe
|
||
|
|
title="PDF Viewer"
|
||
|
|
src={viewerSrc}
|
||
|
|
className="h-full w-full border-0"
|
||
|
|
onLoad={() => {
|
||
|
|
setLoaded(true);
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|