Files
cinny/src/app/components/Pdf-viewer/PdfViewer.tsx
2024-07-18 23:20:20 +10:00

262 lines
8.7 KiB
TypeScript

/* eslint-disable no-param-reassign */
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import React, { FormEventHandler, MouseEventHandler, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import {
Box,
Button,
Chip,
Header,
Icon,
IconButton,
Icons,
Input,
Menu,
PopOut,
RectCords,
Scroll,
Spinner,
Text,
as,
config,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import FileSaver from 'file-saver';
import * as css from './PdfViewer.css';
import { AsyncStatus } from '../../hooks/useAsyncCallback';
import { useZoom } from '../../hooks/useZoom';
import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '../../plugins/pdfjs-dist';
import { stopPropagation } from '../../utils/keyboard';
export type PdfViewerProps = {
name: string;
src: string;
requestClose: () => void;
};
export const PdfViewer = as<'div', PdfViewerProps>(
({ className, name, src, requestClose, ...props }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const [pdfJSState, loadPdfJS] = usePdfJSLoader();
const [docState, loadPdfDocument] = usePdfDocumentLoader(
pdfJSState.status === AsyncStatus.Success ? pdfJSState.data : undefined,
src
);
const isLoading =
pdfJSState.status === AsyncStatus.Loading || docState.status === AsyncStatus.Loading;
const isError =
pdfJSState.status === AsyncStatus.Error || docState.status === AsyncStatus.Error;
const [pageNo, setPageNo] = useState(1);
const [jumpAnchor, setJumpAnchor] = useState<RectCords>();
useEffect(() => {
loadPdfJS();
}, [loadPdfJS]);
useEffect(() => {
if (pdfJSState.status === AsyncStatus.Success) {
loadPdfDocument();
}
}, [pdfJSState, loadPdfDocument]);
useEffect(() => {
if (docState.status === AsyncStatus.Success) {
const doc = docState.data;
if (pageNo < 0 || pageNo > doc.numPages) return;
createPage(doc, pageNo, { scale: zoom }).then((canvas) => {
const container = containerRef.current;
if (!container) return;
container.textContent = '';
container.append(canvas);
scrollRef.current?.scrollTo({
top: 0,
});
});
}
}, [docState, pageNo, zoom]);
const handleDownload = () => {
FileSaver.saveAs(src, name);
};
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (docState.status !== AsyncStatus.Success) return;
const jumpInput = evt.currentTarget.jumpInput as HTMLInputElement;
if (!jumpInput) return;
const jumpTo = parseInt(jumpInput.value, 10);
setPageNo(Math.max(1, Math.min(docState.data.numPages, jumpTo)));
setJumpAnchor(undefined);
};
const handlePrevPage = () => {
setPageNo((n) => Math.max(n - 1, 1));
};
const handleNextPage = () => {
if (docState.status !== AsyncStatus.Success) return;
setPageNo((n) => Math.min(n + 1, docState.data.numPages));
};
const handleOpenJump: MouseEventHandler<HTMLButtonElement> = (evt) => {
setJumpAnchor(evt.currentTarget.getBoundingClientRect());
};
return (
<Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}>
<Header className={css.PdfViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} />
</IconButton>
<Text size="T300" truncate>
{name}
</Text>
</Box>
<Box shrink="No" alignItems="Center" gap="200">
<IconButton
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom < 1}
size="300"
radii="Pill"
onClick={zoomOut}
aria-label="Zoom Out"
>
<Icon size="50" src={Icons.Minus} />
</IconButton>
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
<Text size="B300">{Math.round(zoom * 100)}%</Text>
</Chip>
<IconButton
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom > 1}
size="300"
radii="Pill"
onClick={zoomIn}
aria-label="Zoom In"
>
<Icon size="50" src={Icons.Plus} />
</IconButton>
<Chip
variant="Primary"
onClick={handleDownload}
radii="300"
before={<Icon size="50" src={Icons.Download} />}
>
<Text size="B300">Download</Text>
</Chip>
</Box>
</Header>
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
{isLoading && <Spinner variant="Secondary" size="600" />}
{isError && (
<>
<Text>Failed to load PDF</Text>
<Button
variant="Critical"
fill="Soft"
size="300"
radii="300"
before={<Icon src={Icons.Warning} size="50" />}
onClick={loadPdfJS}
>
<Text size="B300">Retry</Text>
</Button>
</>
)}
{docState.status === AsyncStatus.Success && (
<Scroll
ref={scrollRef}
size="300"
direction="Both"
variant="Surface"
visibility="Hover"
>
<Box>
<div className={css.PdfViewerContent} ref={containerRef} />
</Box>
</Scroll>
)}
</Box>
{docState.status === AsyncStatus.Success && (
<Header as="footer" className={css.PdfViewerFooter} size="400">
<Chip
variant="Secondary"
radii="300"
before={<Icon size="50" src={Icons.ChevronLeft} />}
onClick={handlePrevPage}
aria-disabled={pageNo <= 1}
>
<Text size="B300">Previous</Text>
</Chip>
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
<PopOut
anchor={jumpAnchor}
align="Center"
position="Top"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setJumpAnchor(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu variant="Surface">
<Box
as="form"
onSubmit={handleJumpSubmit}
style={{ padding: config.space.S200 }}
direction="Column"
gap="200"
>
<Input
name="jumpInput"
size="300"
variant="Background"
defaultValue={pageNo}
min={1}
max={docState.data.numPages}
step={1}
outlined
type="number"
radii="300"
aria-label="Page Number"
/>
<Button type="submit" size="300" variant="Primary" radii="300">
<Text size="B300">Jump To Page</Text>
</Button>
</Box>
</Menu>
</FocusTrap>
}
>
<Chip
onClick={handleOpenJump}
variant="SurfaceVariant"
radii="300"
aria-pressed={jumpAnchor !== undefined}
>
<Text size="B300">{`${pageNo}/${docState.data.numPages}`}</Text>
</Chip>
</PopOut>
</Box>
<Chip
variant="Primary"
radii="300"
after={<Icon size="50" src={Icons.ChevronRight} />}
onClick={handleNextPage}
aria-disabled={pageNo >= docState.data.numPages}
>
<Text size="B300">Next</Text>
</Chip>
</Header>
)}
</Box>
);
}
);