209 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}