Обычная сборка pdfjs-dist 5.7 в воркере вызывает Uint8Array.prototype.toHex (отпечаток документа) — метод есть только в Chromium 140+, в Яндекс Браузере getDocument падает «a.toHex is not a function». PdfPages берёт модуль и воркер из pdfjs-dist/legacy/build (с полифиллами core-js) через новый widgets/DocumentReview/pdfjs.ts; регрессионный тест открывает PDF без Uint8Array.prototype.toHex. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
181 lines
5.5 KiB
TypeScript
181 lines
5.5 KiB
TypeScript
import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist';
|
||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
import { pdfjsLib } from './pdfjs';
|
||
|
||
export const MIN_ZOOM = 0.5;
|
||
export const MAX_ZOOM = 3;
|
||
export const ZOOM_STEP = 0.25;
|
||
const PAGE_MARGIN = 10; // left + right margin of each page wrapper
|
||
|
||
/** zoom: 1 = по ширине области; управляется кнопками в шапке карточки (SourcePreview). */
|
||
export default function PdfPages({ data, zoom }: { data: ArrayBuffer; zoom: number }) {
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const canvasRefs = useRef<(HTMLCanvasElement | null)[]>([]);
|
||
const [pdfDoc, setPdfDoc] = useState<PDFDocumentProxy | null>(null);
|
||
const [numPages, setNumPages] = useState(0);
|
||
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]);
|
||
|
||
if (error) return <div>{error}</div>;
|
||
if (!pdfDoc) return <div>Загрузка PDF...</div>;
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
height: '100%',
|
||
minWidth: 0,
|
||
}}>
|
||
{/* outer vertical scroll of pages */}
|
||
<div
|
||
ref={containerRef}
|
||
style={{
|
||
flex: 1,
|
||
minHeight: 0,
|
||
minWidth: 0,
|
||
overflowY: 'auto',
|
||
overflowX: 'hidden', // layout never widens
|
||
background: '#f5f7f8',
|
||
borderRadius: 8,
|
||
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',
|
||
border: '1px solid #e4e7ec',
|
||
borderRadius: 12,
|
||
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: 14,
|
||
fontWeight: 500,
|
||
background: '#f8f9fa',
|
||
color: '#344054',
|
||
}}>
|
||
Страница {i + 1}
|
||
</div>
|
||
<canvas
|
||
ref={(el) => {
|
||
canvasRefs.current[i] = el;
|
||
}}
|
||
style={{ display: 'block' }}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|