396 lines
10 KiB
TypeScript
396 lines
10 KiB
TypeScript
import {
|
|
type CSSProperties,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState
|
|
} from "react";
|
|
import {
|
|
getDocument,
|
|
GlobalWorkerOptions,
|
|
RenderingCancelledException,
|
|
TextLayer,
|
|
type PDFDocumentProxy,
|
|
type PDFPageProxy,
|
|
type RenderTask
|
|
} from "pdfjs-dist";
|
|
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
|
import {
|
|
getPdfCanvasRenderScale,
|
|
getPdfPageCssDimensions
|
|
} from "./pdf-page-geometry";
|
|
import { PdfPageRenderQueue } from "./pdf-render-queue";
|
|
|
|
GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
|
|
|
|
interface PdfPageDescriptor {
|
|
pageNumber: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
export interface PrecisePdfPreviewProps {
|
|
blob: Blob;
|
|
zoomPercent: number;
|
|
onPageCount?: (pageCount: number) => void;
|
|
}
|
|
|
|
interface PrecisePdfPageProps {
|
|
document: PDFDocumentProxy;
|
|
descriptor: PdfPageDescriptor;
|
|
renderQueue: PdfPageRenderQueue;
|
|
zoomPercent: number;
|
|
}
|
|
|
|
function useNearViewport(elementRef: React.RefObject<HTMLElement | null>) {
|
|
const [nearViewport, setNearViewport] = useState(false);
|
|
const releaseTimerRef = useRef<number | undefined>(undefined);
|
|
|
|
useEffect(() => {
|
|
const element = elementRef.current;
|
|
if (!element) {
|
|
return;
|
|
}
|
|
if (!("IntersectionObserver" in window)) {
|
|
setNearViewport(true);
|
|
return;
|
|
}
|
|
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => {
|
|
if (entry?.isIntersecting) {
|
|
if (releaseTimerRef.current !== undefined) {
|
|
window.clearTimeout(releaseTimerRef.current);
|
|
releaseTimerRef.current = undefined;
|
|
}
|
|
setNearViewport(true);
|
|
return;
|
|
}
|
|
|
|
releaseTimerRef.current = window.setTimeout(() => {
|
|
releaseTimerRef.current = undefined;
|
|
setNearViewport(false);
|
|
}, 1_500);
|
|
},
|
|
{
|
|
rootMargin: "1200px 0px"
|
|
}
|
|
);
|
|
observer.observe(element);
|
|
return () => {
|
|
observer.disconnect();
|
|
if (releaseTimerRef.current !== undefined) {
|
|
window.clearTimeout(releaseTimerRef.current);
|
|
}
|
|
};
|
|
}, [elementRef]);
|
|
|
|
return nearViewport;
|
|
}
|
|
|
|
function PrecisePdfPage({
|
|
document,
|
|
descriptor,
|
|
renderQueue,
|
|
zoomPercent
|
|
}: PrecisePdfPageProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const textLayerRef = useRef<HTMLDivElement>(null);
|
|
const [displayWidth, setDisplayWidth] = useState(0);
|
|
const [renderError, setRenderError] = useState("");
|
|
const [renderState, setRenderState] = useState<
|
|
"idle" | "queued" | "rendering" | "rendered"
|
|
>("idle");
|
|
const nearViewport = useNearViewport(containerRef);
|
|
const dimensions = getPdfPageCssDimensions(
|
|
descriptor.width,
|
|
descriptor.height,
|
|
zoomPercent / 100
|
|
);
|
|
const pageStyle = {
|
|
width: `${dimensions.width}px`,
|
|
maxWidth: `${zoomPercent}%`,
|
|
aspectRatio: `${descriptor.width} / ${descriptor.height}`
|
|
} satisfies CSSProperties;
|
|
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
const updateWidth = () => setDisplayWidth(container.clientWidth);
|
|
updateWidth();
|
|
const observer = new ResizeObserver(updateWidth);
|
|
observer.observe(container);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
const updateVisibility = useCallback(
|
|
(visible: boolean) => {
|
|
renderQueue.setVisible(descriptor.pageNumber, visible);
|
|
},
|
|
[descriptor.pageNumber, renderQueue]
|
|
);
|
|
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
if (!("IntersectionObserver" in window)) {
|
|
updateVisibility(true);
|
|
return () => updateVisibility(false);
|
|
}
|
|
|
|
const observer = new IntersectionObserver(([entry]) => {
|
|
updateVisibility(Boolean(entry?.isIntersecting));
|
|
});
|
|
observer.observe(container);
|
|
return () => {
|
|
observer.disconnect();
|
|
updateVisibility(false);
|
|
};
|
|
}, [updateVisibility]);
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
const textContainer = textLayerRef.current;
|
|
if (
|
|
!nearViewport ||
|
|
displayWidth <= 0 ||
|
|
!canvas ||
|
|
!textContainer
|
|
) {
|
|
return;
|
|
}
|
|
|
|
let disposed = false;
|
|
let page: PDFPageProxy | undefined;
|
|
let renderTask: RenderTask | undefined;
|
|
let textLayer: TextLayer | undefined;
|
|
|
|
setRenderError("");
|
|
setRenderState("queued");
|
|
const cancelQueuedRender = renderQueue.schedule(
|
|
descriptor.pageNumber,
|
|
async () => {
|
|
try {
|
|
if (disposed) {
|
|
return;
|
|
}
|
|
setRenderState("rendering");
|
|
const loadedPage = await document.getPage(
|
|
descriptor.pageNumber
|
|
);
|
|
page = loadedPage;
|
|
const outputScale = Math.min(
|
|
window.devicePixelRatio || 1,
|
|
2
|
|
);
|
|
const renderScale = getPdfCanvasRenderScale(
|
|
descriptor.width,
|
|
displayWidth,
|
|
outputScale
|
|
);
|
|
const displayScale = displayWidth / descriptor.width;
|
|
const renderViewport = page.getViewport({
|
|
scale: renderScale
|
|
});
|
|
const displayViewport = page.getViewport({
|
|
scale: displayScale
|
|
});
|
|
const context = canvas.getContext("2d");
|
|
if (!context) {
|
|
throw new Error("浏览器无法创建 PDF Canvas");
|
|
}
|
|
|
|
canvas.width = Math.ceil(renderViewport.width);
|
|
canvas.height = Math.ceil(renderViewport.height);
|
|
canvas.style.width = `${displayViewport.width}px`;
|
|
canvas.style.height = `${displayViewport.height}px`;
|
|
textContainer.replaceChildren();
|
|
|
|
renderTask = page.render({
|
|
canvas,
|
|
canvasContext: context,
|
|
viewport: renderViewport
|
|
});
|
|
await renderTask.promise;
|
|
if (disposed) {
|
|
return;
|
|
}
|
|
setRenderState("rendered");
|
|
|
|
textLayer = new TextLayer({
|
|
container: textContainer,
|
|
textContentSource: page.streamTextContent(),
|
|
viewport: displayViewport
|
|
});
|
|
await textLayer.render();
|
|
} catch (reason: unknown) {
|
|
if (
|
|
disposed ||
|
|
reason instanceof RenderingCancelledException
|
|
) {
|
|
return;
|
|
}
|
|
setRenderError(
|
|
reason instanceof Error
|
|
? reason.message
|
|
: "PDF 页面渲染失败"
|
|
);
|
|
}
|
|
}
|
|
);
|
|
|
|
return () => {
|
|
disposed = true;
|
|
cancelQueuedRender();
|
|
renderTask?.cancel();
|
|
textLayer?.cancel();
|
|
page?.cleanup();
|
|
canvas.width = 0;
|
|
canvas.height = 0;
|
|
textContainer.replaceChildren();
|
|
setRenderState("idle");
|
|
};
|
|
}, [
|
|
descriptor.height,
|
|
descriptor.pageNumber,
|
|
descriptor.width,
|
|
displayWidth,
|
|
document,
|
|
nearViewport,
|
|
renderQueue
|
|
]);
|
|
|
|
return (
|
|
<article
|
|
ref={containerRef}
|
|
className="precise-pdf-page"
|
|
style={pageStyle}
|
|
aria-label={`PDF 第 ${descriptor.pageNumber} 页`}
|
|
>
|
|
<canvas
|
|
ref={canvasRef}
|
|
className={renderState === "rendered" ? "is-rendered" : ""}
|
|
width={0}
|
|
height={0}
|
|
aria-hidden="true"
|
|
/>
|
|
<div ref={textLayerRef} className="textLayer" />
|
|
{renderState !== "rendered" ? (
|
|
<span className="precise-pdf-page-placeholder">
|
|
{renderState === "rendering"
|
|
? `正在渲染第 ${descriptor.pageNumber} 页…`
|
|
: `第 ${descriptor.pageNumber} 页`}
|
|
</span>
|
|
) : null}
|
|
{renderError ? (
|
|
<span className="precise-pdf-page-error">{renderError}</span>
|
|
) : null}
|
|
</article>
|
|
);
|
|
}
|
|
|
|
export function PrecisePdfPreview({
|
|
blob,
|
|
zoomPercent,
|
|
onPageCount
|
|
}: PrecisePdfPreviewProps) {
|
|
const [document, setDocument] = useState<PDFDocumentProxy>();
|
|
const [pages, setPages] = useState<PdfPageDescriptor[]>([]);
|
|
const [error, setError] = useState("");
|
|
const renderQueue = useMemo(
|
|
() => new PdfPageRenderQueue(1),
|
|
[document]
|
|
);
|
|
|
|
useEffect(() => () => renderQueue.dispose(), [renderQueue]);
|
|
|
|
useEffect(() => {
|
|
let disposed = false;
|
|
let loadingTask:
|
|
| ReturnType<typeof getDocument>
|
|
| undefined;
|
|
|
|
setDocument(undefined);
|
|
setPages([]);
|
|
setError("");
|
|
|
|
void blob
|
|
.arrayBuffer()
|
|
.then((buffer) => {
|
|
if (disposed) {
|
|
return undefined;
|
|
}
|
|
loadingTask = getDocument({
|
|
data: new Uint8Array(buffer)
|
|
});
|
|
return loadingTask.promise;
|
|
})
|
|
.then(async (loadedDocument) => {
|
|
if (!loadedDocument || disposed) {
|
|
return;
|
|
}
|
|
const descriptors = await Promise.all(
|
|
Array.from(
|
|
{ length: loadedDocument.numPages },
|
|
async (_, index) => {
|
|
const page = await loadedDocument.getPage(index + 1);
|
|
const viewport = page.getViewport({ scale: 1 });
|
|
page.cleanup();
|
|
return {
|
|
pageNumber: index + 1,
|
|
width: viewport.width,
|
|
height: viewport.height
|
|
};
|
|
}
|
|
)
|
|
);
|
|
if (disposed) {
|
|
return;
|
|
}
|
|
setDocument(loadedDocument);
|
|
setPages(descriptors);
|
|
onPageCount?.(loadedDocument.numPages);
|
|
})
|
|
.catch((reason: unknown) => {
|
|
if (!disposed) {
|
|
setError(
|
|
reason instanceof Error ? reason.message : "无法读取 PDF"
|
|
);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
disposed = true;
|
|
void loadingTask?.destroy();
|
|
};
|
|
}, [blob, onPageCount]);
|
|
|
|
if (error) {
|
|
return <div className="precise-pdf-status is-error">{error}</div>;
|
|
}
|
|
if (!document || pages.length === 0) {
|
|
return <div className="precise-pdf-status">正在读取精确预览…</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="precise-pdf-document">
|
|
{pages.map((page) => (
|
|
<PrecisePdfPage
|
|
key={page.pageNumber}
|
|
document={document}
|
|
descriptor={page}
|
|
renderQueue={renderQueue}
|
|
zoomPercent={zoomPercent}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|