Rescue Paperjet v1 implementation

This commit is contained in:
Elijah 2026-08-14 21:05:22 -07:00
parent 7ff5c8130d
commit db9e2ca51b
83 changed files with 6186 additions and 3894 deletions

View file

@ -1,87 +1,127 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist';
import { AnnotationLayer } from '../canvas/AnnotationLayer';
interface PageRendererProps {
pdfDoc: PDFDocumentProxy;
pageNumber: number;
pageIndex: number;
scale: number;
dpr: number;
}
export function PageRenderer({ pdfDoc, pageNumber, scale, dpr }: PageRendererProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
export function PageRenderer({ pdfDoc, pageIndex, scale, dpr }: PageRendererProps) {
const [page, setPage] = useState<PDFPageProxy | null>(null);
useEffect(() => {
let active = true;
pdfDoc.getPage(pageNumber).then(p => {
if (active) setPage(p);
const pageNumber = pageIndex + 1;
pdfDoc.getPage(pageNumber).then((nextPage) => {
if (active) setPage(nextPage);
});
return () => { active = false; };
}, [pdfDoc, pageNumber]);
useEffect(() => {
if (!page || !canvasRef.current) return;
const viewport = page.getViewport({ scale: scale * dpr });
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
if (!context) return;
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = `${viewport.width / dpr}px`;
canvas.style.height = `${viewport.height / dpr}px`;
const renderContext = {
canvas: canvas,
viewport: viewport,
};
let renderTask = page.render(renderContext);
return () => {
renderTask.cancel();
active = false;
};
}, [page, scale, dpr]);
}, [pdfDoc, pageIndex]);
if (!page) {
return (
<div
className="bg-white shadow-sm flex items-center justify-center text-neutral-400 border border-neutral-200"
<div
className="flex items-center justify-center border border-neutral-200 bg-white text-neutral-400 shadow-sm"
style={{ width: 612 * scale, height: 792 * scale }}
>
Loading page {pageNumber}...
Loading page {pageIndex + 1}...
</div>
);
}
const baseViewport = page ? page.getViewport({ scale: 1, rotation: 0 }) : null;
const canonicalViewport = page.getViewport({ scale: 1, rotation: 0 });
const renderedViewport = page.getViewport({
scale: scale * dpr,
rotation: page.rotate,
});
const cssWidth = renderedViewport.width / dpr;
const cssHeight = renderedViewport.height / dpr;
return (
<div className="relative shadow-xl bg-white border border-neutral-200 group">
<canvas ref={canvasRef} className="block" />
{baseViewport && (
<AnnotationLayer
pageNumber={pageNumber}
width={baseViewport.width * scale}
height={baseViewport.height * scale}
viewportParams={{
scale,
rotation: page.rotate,
canonicalWidth: baseViewport.width,
canonicalHeight: baseViewport.height,
dpr
}}
/>
)}
<div className="absolute -left-10 top-2 text-xs font-bold text-neutral-400 bg-white/80 rounded-md px-1.5 py-0.5 shadow-sm border border-neutral-200">
{pageNumber}
<RenderedPage
page={page}
pageIndex={pageIndex}
scale={scale}
dpr={dpr}
canonicalWidth={canonicalViewport.width}
canonicalHeight={canonicalViewport.height}
renderedWidth={renderedViewport.width}
renderedHeight={renderedViewport.height}
cssWidth={cssWidth}
cssHeight={cssHeight}
/>
);
}
interface RenderedPageProps {
page: PDFPageProxy;
pageIndex: number;
scale: number;
dpr: number;
canonicalWidth: number;
canonicalHeight: number;
renderedWidth: number;
renderedHeight: number;
cssWidth: number;
cssHeight: number;
}
function RenderedPage({
page,
pageIndex,
scale,
dpr,
canonicalWidth,
canonicalHeight,
renderedWidth,
renderedHeight,
cssWidth,
cssHeight,
}: RenderedPageProps) {
const viewportParams = useMemo(() => ({
scale,
rotation: page.rotate,
canonicalWidth,
canonicalHeight,
dpr,
}), [canonicalHeight, canonicalWidth, dpr, page.rotate, scale]);
useEffect(() => {
const canvas = document.querySelector<HTMLCanvasElement>(
`[data-paperjet-page="${pageIndex}"]`,
);
if (!canvas) return;
const context = canvas.getContext('2d');
if (!context) return;
canvas.width = renderedWidth;
canvas.height = renderedHeight;
canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`;
const renderTask = page.render({ canvas, viewport: page.getViewport({ scale: scale * dpr, rotation: page.rotate }) });
renderTask.promise.catch(() => undefined);
return () => {
renderTask.cancel();
};
}, [page, pageIndex, scale, dpr, renderedWidth, renderedHeight, cssWidth, cssHeight]);
return (
<div className="group relative border border-neutral-200 bg-white shadow-xl" style={{ width: cssWidth, height: cssHeight }}>
<canvas data-paperjet-page={pageIndex} className="block" />
<AnnotationLayer
pageNumber={pageIndex}
width={cssWidth}
height={cssHeight}
viewportParams={viewportParams}
/>
<div className="absolute -left-10 top-2 rounded-md border border-neutral-200 bg-white/80 px-1.5 py-0.5 text-xs font-bold text-neutral-400 shadow-sm">
{pageIndex + 1}
</div>
</div>
);

View file

@ -8,12 +8,13 @@ interface PageStackProps {
export function PageStack({ pdfDoc }: PageStackProps) {
const numPages = pdfDoc.numPages;
const pages = Array.from({ length: numPages }, (_, i) => i + 1);
const pages = Array.from({ length: numPages }, (_, i) => i);
const zoom = useEditorStore(state => state.zoom);
// For now, render all pages vertically. Virtualization comes in Phase 7.
const scale = zoom * 2.0;
// PDF points map directly to CSS pixels at 100%. Device pixel ratio is
// applied only to the PDF.js backing canvas, not the annotation overlay.
const scale = zoom;
const dpr = window.devicePixelRatio || 1;
return (
@ -22,7 +23,7 @@ export function PageStack({ pdfDoc }: PageStackProps) {
<PageRenderer
key={pageNum}
pdfDoc={pdfDoc}
pageNumber={pageNum}
pageIndex={pageNum}
scale={scale}
dpr={dpr}
/>

View file

@ -4,50 +4,61 @@ import { PageStack } from './PageStack';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.mjs',
import.meta.url
import.meta.url,
).toString();
interface LoadedDocument {
id: string;
document: pdfjsLib.PDFDocumentProxy;
}
interface DocumentError {
id: string;
message: string;
}
export function PdfDocument({ documentId }: { documentId: string }) {
const [pdfDoc, setPdfDoc] = useState<pdfjsLib.PDFDocumentProxy | null>(null);
const [error, setError] = useState<string | null>(null);
const [loaded, setLoaded] = useState<LoadedDocument | null>(null);
const [error, setError] = useState<DocumentError | null>(null);
useEffect(() => {
let active = true;
const url = `/api/v1/documents/${documentId}/file`;
const loadingTask = pdfjsLib.getDocument({ url });
loadingTask.promise.then((doc) => {
if (active) setPdfDoc(doc);
}).catch(err => {
console.error('Failed to load PDF', err);
if (active) setError(err.message);
const loadingTask = pdfjsLib.getDocument({ url: `/api/v1/documents/${documentId}/file` });
loadingTask.promise.then((document) => {
if (active) setLoaded({ id: documentId, document });
}).catch((reason: unknown) => {
if (active) {
const message = reason instanceof Error ? reason.message : 'Unable to load this PDF.';
setError({ id: documentId, message });
}
});
return () => {
active = false;
loadingTask.destroy();
void loadingTask.destroy();
};
}, [documentId]);
if (error) {
const currentError = error?.id === documentId ? error.message : null;
const currentDocument = loaded?.id === documentId ? loaded.document : null;
if (currentError) {
return (
<div className="flex items-center justify-center h-full w-full">
<div className="text-red-500 bg-red-50 px-4 py-3 rounded-lg border border-red-200">
Error loading PDF: {error}
</div>
<div className="flex h-full w-full items-center justify-center">
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-red-600">Error loading PDF: {currentError}</div>
</div>
);
}
if (!pdfDoc) {
if (!currentDocument) {
return (
<div className="flex flex-col items-center justify-center h-full w-full gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent-500"></div>
<div className="text-neutral-500 font-medium animate-pulse">Loading document...</div>
<div className="flex h-full w-full flex-col items-center justify-center gap-4">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-accent-500" />
<div className="font-medium animate-pulse text-neutral-500">Loading document</div>
</div>
);
}
return <PageStack pdfDoc={pdfDoc} />;
return <PageStack pdfDoc={currentDocument} />;
}