Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
209 lines
6.5 KiB
TypeScript
209 lines
6.5 KiB
TypeScript
import * as pdfjsLib from 'pdfjs-dist';
|
||
|
||
import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist';
|
||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
|
||
// worker собирается вместе с приложением (без CDN)
|
||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
||
|
||
const MIN_ZOOM = 0.5;
|
||
const MAX_ZOOM = 3;
|
||
const ZOOM_STEP = 0.25;
|
||
const PAGE_MARGIN = 10; // left + right margin of each page wrapper
|
||
|
||
export default function PdfPages({ data }: { data: ArrayBuffer }) {
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const canvasRefs = useRef<(HTMLCanvasElement | null)[]>([]);
|
||
const [pdfDoc, setPdfDoc] = useState<PDFDocumentProxy | null>(null);
|
||
const [numPages, setNumPages] = useState(0);
|
||
const [zoom, setZoom] = useState(1); // 1 = fit wrapper width
|
||
const [baseScale, setBaseScale] = useState(1);
|
||
const [pageWidthPt, setPageWidthPt] = useState(0);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
let pdf: PDFDocumentProxy | null = null;
|
||
let cancelled = false;
|
||
async function load() {
|
||
// pdf.js забирает переданный буфер себе, поэтому отдаём копию (эффект может выполниться повторно)
|
||
pdf = await pdfjsLib.getDocument({ data: data.slice(0) }).promise;
|
||
if (cancelled) {
|
||
pdf.destroy();
|
||
return;
|
||
}
|
||
const firstPage = await pdf.getPage(1);
|
||
setPageWidthPt(firstPage.getViewport({ scale: 1 }).width);
|
||
canvasRefs.current = new Array(pdf.numPages).fill(null);
|
||
setPdfDoc(pdf);
|
||
setNumPages(pdf.numPages);
|
||
}
|
||
load().catch(() => {
|
||
if (!cancelled) {
|
||
setError('Не удалось открыть PDF');
|
||
}
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
if (pdf) pdf.destroy();
|
||
};
|
||
}, [data]);
|
||
|
||
// fit scale = wrapper inner width / page width.
|
||
// wrapper width = container client width - container padding - page margins
|
||
const updateBaseScale = useCallback(() => {
|
||
if (!containerRef.current || !pageWidthPt) return;
|
||
const CONTAINER_PADDING = 0; // keep 0 horizontally so margins control it
|
||
const available = containerRef.current.clientWidth - CONTAINER_PADDING - PAGE_MARGIN * 2;
|
||
if (available <= 0) return;
|
||
setBaseScale(available / pageWidthPt);
|
||
}, [pageWidthPt]);
|
||
|
||
useEffect(() => {
|
||
updateBaseScale();
|
||
const el = containerRef.current;
|
||
if (!el) return;
|
||
const ro = new ResizeObserver(updateBaseScale);
|
||
ro.observe(el);
|
||
return () => ro.disconnect();
|
||
}, [updateBaseScale]);
|
||
|
||
const scale = baseScale * zoom;
|
||
|
||
useEffect(() => {
|
||
if (!pdfDoc || !scale) return;
|
||
let cancelled = false;
|
||
const doc = pdfDoc;
|
||
const tasks: RenderTask[] = [];
|
||
async function renderAll() {
|
||
for (let i = 1; i <= doc.numPages; i++) {
|
||
const canvas = canvasRefs.current[i - 1];
|
||
if (!canvas) continue;
|
||
const page = await doc.getPage(i);
|
||
if (cancelled) return;
|
||
const outputScale = window.devicePixelRatio || 1;
|
||
const viewport = page.getViewport({ scale });
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) continue;
|
||
canvas.width = Math.floor(viewport.width * outputScale);
|
||
canvas.height = Math.floor(viewport.height * outputScale);
|
||
canvas.style.width = `${Math.floor(viewport.width)}px`;
|
||
canvas.style.height = `${Math.floor(viewport.height)}px`;
|
||
const transform = outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : undefined;
|
||
const task = page.render({
|
||
canvas: null,
|
||
canvasContext: ctx,
|
||
viewport,
|
||
transform,
|
||
});
|
||
tasks.push(task);
|
||
try {
|
||
await task.promise;
|
||
} catch {
|
||
// отменённая отрисовка (смена масштаба) — не ошибка
|
||
}
|
||
}
|
||
}
|
||
renderAll();
|
||
return () => {
|
||
cancelled = true;
|
||
tasks.forEach((t) => t.cancel());
|
||
};
|
||
}, [pdfDoc, scale]);
|
||
|
||
const zoomIn = useCallback(() => setZoom((z) => Math.min(MAX_ZOOM, +(z + ZOOM_STEP).toFixed(2))), []);
|
||
const zoomOut = useCallback(() => setZoom((z) => Math.max(MIN_ZOOM, +(z - ZOOM_STEP).toFixed(2))), []);
|
||
const resetZoom = useCallback(() => setZoom(1), []);
|
||
|
||
if (error) return <div>{error}</div>;
|
||
if (!pdfDoc) return <div>Загрузка PDF...</div>;
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
height: '100%',
|
||
minWidth: 0,
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||
<span style={{ fontWeight: 600 }}>Исходный файл — страниц: {numPages}</span>
|
||
<span
|
||
style={{
|
||
marginLeft: 'auto',
|
||
display: 'flex',
|
||
gap: 8,
|
||
alignItems: 'center',
|
||
}}>
|
||
<button type='button' onClick={zoomOut} disabled={zoom <= MIN_ZOOM}>
|
||
-
|
||
</button>
|
||
<span style={{ minWidth: 48, textAlign: 'center' }}>{Math.round(zoom * 100)}%</span>
|
||
<button type='button' onClick={zoomIn} disabled={zoom >= MAX_ZOOM}>
|
||
+
|
||
</button>
|
||
<button type='button' onClick={resetZoom}>
|
||
По ширине
|
||
</button>
|
||
</span>
|
||
</div>
|
||
|
||
{/* outer vertical scroll of pages */}
|
||
<div
|
||
ref={containerRef}
|
||
style={{
|
||
flex: 1,
|
||
minHeight: 0,
|
||
minWidth: 0,
|
||
overflowY: 'auto',
|
||
overflowX: 'hidden', // layout never widens
|
||
background: '#525659',
|
||
paddingTop: 16,
|
||
paddingBottom: 16,
|
||
}}>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: 16,
|
||
alignItems: 'stretch',
|
||
}}>
|
||
{Array.from({ length: numPages }, (_, i) => (
|
||
// page wrapper: fixed width via margins, does NOT grow.
|
||
// canvas larger than this scrolls horizontally inside here.
|
||
<div
|
||
// biome-ignore lint/suspicious/noArrayIndexKey: страницы PDF идут по порядку и не переупорядочиваются
|
||
key={i}
|
||
style={{
|
||
margin: `0 ${PAGE_MARGIN}px`,
|
||
background: '#fff',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.5)',
|
||
overflowX: 'auto', // internal horizontal scroll when zoomed
|
||
overflowY: 'hidden',
|
||
}}>
|
||
{/* sticky so header stays visible while scrolling the page horizontally */}
|
||
<div
|
||
style={{
|
||
position: 'sticky',
|
||
left: 0,
|
||
padding: '6px 12px',
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
background: '#f1f1f1',
|
||
borderBottom: '1px solid #ddd',
|
||
}}>
|
||
Страница {i + 1} из {numPages}
|
||
</div>
|
||
<canvas
|
||
ref={(el) => {
|
||
canvasRefs.current[i] = el;
|
||
}}
|
||
style={{ display: 'block' }}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|