mirror of
https://github.com/suitenumerique/drive.git
synced 2026-09-26 19:45:04 +02:00
✨(frontend) add PDF viewer core components
Implements a full-featured PDF viewer with thumbnail sidebar, zoom controls, page navigation and external link interception. Includes polyfills for older browsers and a fallback component for unsupported environments.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,12 @@
|
||||
import { IconProps, IconSvg } from "./Icon";
|
||||
|
||||
export const LeftSidebarIcon = (props: IconProps) => {
|
||||
return (
|
||||
<IconSvg {...props}>
|
||||
<path
|
||||
d="M4.04601 20.3381C3.04306 20.3381 2.28465 20.0812 1.77079 19.5673C1.25693 19.0597 1 18.3105 1 17.32V6.01815C1 5.02139 1.25693 4.26917 1.77079 3.7615C2.28465 3.25383 3.04306 3 4.04601 3H19.9447C20.9538 3 21.7154 3.25383 22.2292 3.7615C22.7431 4.26917 23 5.02139 23 6.01815V17.32C23 18.3105 22.7431 19.0597 22.2292 19.5673C21.7154 20.0812 20.9538 20.3381 19.9447 20.3381H4.04601ZM4.15745 18.5087H19.8425C20.2697 18.5087 20.5979 18.3972 20.8269 18.1743C21.056 17.9515 21.1705 17.614 21.1705 17.1621V6.16674C21.1705 5.72098 21.056 5.38666 20.8269 5.16378C20.5979 4.9409 20.2697 4.82946 19.8425 4.82946H4.15745C3.72407 4.82946 3.39285 4.9409 3.16378 5.16378C2.9409 5.38666 2.82946 5.72098 2.82946 6.16674V17.1621C2.82946 17.614 2.9409 17.9515 3.16378 18.1743C3.39285 18.3972 3.72407 18.5087 4.15745 18.5087ZM8.38286 18.8058V4.51372H10.1195V18.8058H8.38286ZM6.56268 8.22837H4.65893C4.49796 8.22837 4.35556 8.16646 4.23174 8.04263C4.11411 7.91881 4.0553 7.78261 4.0553 7.63402C4.0553 7.47305 4.11411 7.33376 4.23174 7.21613C4.35556 7.09849 4.49796 7.03968 4.65893 7.03968H6.56268C6.72984 7.03968 6.87224 7.09849 6.98987 7.21613C7.11369 7.33376 7.1756 7.47305 7.1756 7.63402C7.1756 7.78261 7.11369 7.91881 6.98987 8.04263C6.87224 8.16646 6.72984 8.22837 6.56268 8.22837ZM6.56268 10.7172H4.65893C4.49796 10.7172 4.35556 10.6584 4.23174 10.5407C4.11411 10.4169 4.0553 10.2745 4.0553 10.1136C4.0553 9.95877 4.11411 9.82257 4.23174 9.70494C4.35556 9.58731 4.49796 9.52849 4.65893 9.52849H6.56268C6.72984 9.52849 6.87224 9.58731 6.98987 9.70494C7.11369 9.82257 7.1756 9.95877 7.1756 10.1136C7.1756 10.2745 7.11369 10.4169 6.98987 10.5407C6.87224 10.6584 6.72984 10.7172 6.56268 10.7172ZM6.56268 13.1967H4.65893C4.49796 13.1967 4.35556 13.1379 4.23174 13.0203C4.11411 12.9026 4.0553 12.7664 4.0553 12.6117C4.0553 12.4507 4.11411 12.3114 4.23174 12.1938C4.35556 12.0761 4.49796 12.0173 4.65893 12.0173H6.56268C6.72984 12.0173 6.87224 12.0761 6.98987 12.1938C7.11369 12.3114 7.1756 12.4507 7.1756 12.6117C7.1756 12.7664 7.11369 12.9026 6.98987 13.0203C6.87224 13.1379 6.72984 13.1967 6.56268 13.1967Z"
|
||||
fill="#555E74"
|
||||
/>
|
||||
</IconSvg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { IconSvg } from "./Icon";
|
||||
|
||||
import { IconProps } from "./Icon";
|
||||
|
||||
export const ZoomIn = (props: IconProps) => {
|
||||
return (
|
||||
<IconSvg {...props}>
|
||||
<path
|
||||
d="M10.3281 6.72363C10.5456 6.72373 10.7302 6.79958 10.8818 6.95117C11.0334 7.10281 11.1094 7.28736 11.1094 7.50488V9.27539H12.8799C13.0974 9.27539 13.2788 9.35142 13.4238 9.50293C13.5755 9.65461 13.6514 9.83998 13.6514 10.0576C13.6513 10.2751 13.5754 10.4597 13.4238 10.6113C13.2788 10.7563 13.0974 10.8291 12.8799 10.8291H11.1094V12.5996C11.1094 12.8171 11.0334 13.0017 10.8818 13.1533C10.7302 13.3049 10.5456 13.3808 10.3281 13.3809C10.1171 13.3809 9.93489 13.305 9.7832 13.1533C9.63175 13.0017 9.55567 12.8171 9.55566 12.5996V10.8291H7.77539C7.56443 10.8291 7.38309 10.7563 7.23145 10.6113C7.07986 10.4597 7.004 10.2751 7.00391 10.0576C7.00391 9.83998 7.07976 9.65461 7.23145 9.50293C7.38307 9.35139 7.56447 9.27541 7.77539 9.27539H9.55566V7.50488C9.55568 7.28743 9.63174 7.10278 9.7832 6.95117C9.93489 6.79949 10.1171 6.72363 10.3281 6.72363Z"
|
||||
fill="#626A80"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10.3281 2.00488C11.4425 2.00493 12.4848 2.2125 13.4541 2.62793C14.43 3.04338 15.2869 3.62405 16.0254 4.36914C16.764 5.10778 17.3414 5.96535 17.7568 6.94141C18.1788 7.9108 18.3896 8.94975 18.3896 10.0576C18.3896 10.928 18.2547 11.7554 17.9844 12.54C17.7206 13.3247 17.3548 14.0403 16.8867 14.6865L21.3975 19.2178C21.5359 19.3496 21.6382 19.5046 21.7041 19.6826C21.7766 19.8606 21.8125 20.0484 21.8125 20.2461C21.8125 20.523 21.7502 20.7739 21.625 20.998C21.4997 21.2223 21.328 21.3972 21.1104 21.5225C20.8927 21.6543 20.642 21.7207 20.3584 21.7207C20.1607 21.7207 19.9697 21.6838 19.7852 21.6113C19.6071 21.5454 19.4449 21.44 19.2998 21.2949L14.7598 16.7539C14.1266 17.176 13.4335 17.5099 12.6816 17.7539C11.9367 17.9912 11.1521 18.1093 10.3281 18.1094C9.2203 18.1094 8.1781 17.9017 7.20215 17.4863C6.23269 17.0708 5.37828 16.4935 4.63965 15.7549C3.90113 15.0163 3.32048 14.1627 2.89844 13.1934C2.48301 12.2174 2.27544 11.172 2.27539 10.0576C2.27539 8.94977 2.48304 7.91078 2.89844 6.94141C3.32052 5.96535 3.90101 5.10778 4.63965 4.36914C5.37828 3.62391 6.23269 3.04341 7.20215 2.62793C8.17809 2.21256 9.22032 2.00488 10.3281 2.00488ZM10.3281 4.07227C9.50376 4.07227 8.72838 4.22715 8.00293 4.53711C7.27752 4.84047 6.63794 5.2693 6.08398 5.82324C5.53662 6.37061 5.10781 7.007 4.79785 7.73242C4.49448 8.45787 4.34277 9.23325 4.34277 10.0576C4.34282 10.8884 4.49458 11.6663 4.79785 12.3916C5.10781 13.1104 5.53661 13.7436 6.08398 14.291C6.63793 14.8384 7.27753 15.2704 8.00293 15.5869C8.72838 15.8969 9.50376 16.0518 10.3281 16.0518C11.1589 16.0517 11.9336 15.8968 12.6523 15.5869C13.3778 15.2704 14.0141 14.8384 14.5615 14.291C15.1089 13.7436 15.5377 13.1105 15.8477 12.3916C16.1641 11.6663 16.3222 10.8884 16.3223 10.0576C16.3223 9.23325 16.1642 8.45787 15.8477 7.73242C15.5377 7.007 15.1089 6.37061 14.5615 5.82324C14.0142 5.2693 13.3778 4.84046 12.6523 4.53711C11.9336 4.22723 11.1589 4.07232 10.3281 4.07227Z"
|
||||
fill="#626A80"
|
||||
/>
|
||||
</IconSvg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { IconProps, IconSvg } from "./Icon";
|
||||
|
||||
export const ZoomOut = (props: IconProps) => {
|
||||
return (
|
||||
<IconSvg {...props}>
|
||||
<path
|
||||
d="M12.8799 9.27539C13.0908 9.27539 13.2722 9.35464 13.4238 9.5127C13.5755 9.66438 13.6514 9.84658 13.6514 10.0576C13.6513 10.2685 13.5754 10.45 13.4238 10.6016C13.2722 10.7531 13.0908 10.8291 12.8799 10.8291H7.76562C7.55469 10.8291 7.37332 10.7531 7.22168 10.6016C7.07009 10.45 6.99424 10.2685 6.99414 10.0576C6.99414 9.84658 7.07 9.66438 7.22168 9.5127C7.3733 9.35463 7.55473 9.27539 7.76562 9.27539H12.8799Z"
|
||||
fill="#626A80"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10.3281 2.00488C11.4425 2.00493 12.4848 2.2125 13.4541 2.62793C14.43 3.04338 15.2869 3.62405 16.0254 4.36914C16.764 5.10778 17.3414 5.96535 17.7568 6.94141C18.1788 7.9108 18.3896 8.94975 18.3896 10.0576C18.3896 10.928 18.2547 11.7554 17.9844 12.54C17.7206 13.3247 17.3548 14.0403 16.8867 14.6865L21.3975 19.2178C21.5359 19.3496 21.6382 19.5046 21.7041 19.6826C21.7766 19.8606 21.8125 20.0484 21.8125 20.2461C21.8125 20.523 21.7502 20.7739 21.625 20.998C21.4997 21.2223 21.328 21.3972 21.1104 21.5225C20.8927 21.6543 20.642 21.7207 20.3584 21.7207C20.1607 21.7207 19.9697 21.6838 19.7852 21.6113C19.6071 21.5454 19.4449 21.44 19.2998 21.2949L14.7598 16.7539C14.1266 17.176 13.4335 17.5099 12.6816 17.7539C11.9367 17.9912 11.1521 18.1093 10.3281 18.1094C9.2203 18.1094 8.1781 17.9017 7.20215 17.4863C6.23269 17.0708 5.37828 16.4935 4.63965 15.7549C3.90113 15.0163 3.32048 14.1627 2.89844 13.1934C2.48301 12.2174 2.27544 11.172 2.27539 10.0576C2.27539 8.94977 2.48304 7.91078 2.89844 6.94141C3.32052 5.96535 3.90101 5.10778 4.63965 4.36914C5.37828 3.62391 6.23269 3.04341 7.20215 2.62793C8.17809 2.21256 9.22032 2.00488 10.3281 2.00488ZM10.3281 4.07227C9.50376 4.07227 8.72838 4.22715 8.00293 4.53711C7.27752 4.84047 6.63794 5.2693 6.08398 5.82324C5.53662 6.37061 5.10781 7.007 4.79785 7.73242C4.49448 8.45787 4.34277 9.23325 4.34277 10.0576C4.34282 10.8884 4.49458 11.6663 4.79785 12.3916C5.10781 13.1104 5.53661 13.7436 6.08398 14.291C6.63793 14.8384 7.27753 15.2704 8.00293 15.5869C8.72838 15.8969 9.50376 16.0518 10.3281 16.0518C11.1589 16.0517 11.9336 15.8968 12.6523 15.5869C13.3778 15.2704 14.0141 14.8384 14.5615 14.291C15.1089 13.7436 15.5377 13.1105 15.8477 12.3916C16.1641 11.6663 16.3222 10.8884 16.3223 10.0576C16.3223 9.23325 16.1642 8.45787 15.8477 7.73242C15.5377 7.007 15.1089 6.37061 14.5615 5.82324C14.0142 5.2693 13.3778 4.84046 12.6523 4.53711C11.9336 4.22723 11.1589 4.07232 10.3281 4.07227Z"
|
||||
fill="#626A80"
|
||||
/>
|
||||
</IconSvg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { IconProps, IconSvg } from "./Icon";
|
||||
|
||||
export const ZoomReset = (props: IconProps) => {
|
||||
return (
|
||||
<IconSvg {...props}>
|
||||
<path
|
||||
d="M10.6641 6.42676C10.9213 6.42676 11.1225 6.50261 11.2676 6.6543C11.4192 6.80597 11.4951 7.00091 11.4951 7.23828V12.9561C11.495 13.1867 11.4287 13.3545 11.2969 13.46C11.165 13.5654 10.9901 13.6182 10.7725 13.6182C10.555 13.6181 10.3767 13.5527 10.2383 13.4209C10.1065 13.2891 10.0411 13.1075 10.041 12.877V8H9.97168L9.04199 8.65234C8.95629 8.71168 8.88356 8.75487 8.82422 8.78125C8.76489 8.80762 8.68895 8.8203 8.59668 8.82031C8.45159 8.82031 8.32305 8.76763 8.21094 8.66211C8.10544 8.5566 8.05273 8.42804 8.05273 8.27637C8.0528 8.03908 8.17094 7.83766 8.4082 7.67285L9.68457 6.7832C9.88242 6.6513 10.0478 6.55862 10.1797 6.50586C10.3181 6.45318 10.4795 6.42676 10.6641 6.42676Z"
|
||||
fill="#626A80"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10.3281 2.00488C11.4425 2.00493 12.4848 2.2125 13.4541 2.62793C14.43 3.04338 15.2869 3.62405 16.0254 4.36914C16.764 5.10778 17.3414 5.96535 17.7568 6.94141C18.1788 7.9108 18.3896 8.94975 18.3896 10.0576C18.3896 10.928 18.2547 11.7554 17.9844 12.54C17.7206 13.3247 17.3548 14.0403 16.8867 14.6865L21.3975 19.2178C21.5359 19.3496 21.6382 19.5046 21.7041 19.6826C21.7766 19.8606 21.8125 20.0484 21.8125 20.2461C21.8125 20.523 21.7502 20.7739 21.625 20.998C21.4997 21.2223 21.328 21.3972 21.1104 21.5225C20.8927 21.6543 20.642 21.7207 20.3584 21.7207C20.1607 21.7207 19.9697 21.6838 19.7852 21.6113C19.6071 21.5454 19.4449 21.44 19.2998 21.2949L14.7598 16.7539C14.1266 17.176 13.4335 17.5099 12.6816 17.7539C11.9367 17.9912 11.1521 18.1093 10.3281 18.1094C9.2203 18.1094 8.1781 17.9017 7.20215 17.4863C6.23269 17.0708 5.37828 16.4935 4.63965 15.7549C3.90113 15.0163 3.32048 14.1627 2.89844 13.1934C2.48301 12.2174 2.27544 11.172 2.27539 10.0576C2.27539 8.94977 2.48304 7.91078 2.89844 6.94141C3.32052 5.96535 3.90101 5.10778 4.63965 4.36914C5.37828 3.62391 6.23269 3.04341 7.20215 2.62793C8.17809 2.21256 9.22032 2.00488 10.3281 2.00488ZM10.3281 4.07227C9.50376 4.07227 8.72838 4.22715 8.00293 4.53711C7.27752 4.84047 6.63794 5.2693 6.08398 5.82324C5.53662 6.37061 5.10781 7.007 4.79785 7.73242C4.49448 8.45787 4.34277 9.23325 4.34277 10.0576C4.34282 10.8884 4.49458 11.6663 4.79785 12.3916C5.10781 13.1104 5.53661 13.7436 6.08398 14.291C6.63793 14.8384 7.27753 15.2704 8.00293 15.5869C8.72838 15.8969 9.50376 16.0518 10.3281 16.0518C11.1589 16.0517 11.9336 15.8968 12.6523 15.5869C13.3778 15.2704 14.0141 14.8384 14.5615 14.291C15.1089 13.7436 15.5377 13.1105 15.8477 12.3916C16.1641 11.6663 16.3222 10.8884 16.3223 10.0576C16.3223 9.23325 16.1642 8.45787 15.8477 7.73242C15.5377 7.007 15.1089 6.37061 14.5615 5.82324C14.0142 5.2693 13.3778 4.84046 12.6523 4.53711C11.9336 4.22723 11.1589 4.07232 10.3281 4.07227Z"
|
||||
fill="#626A80"
|
||||
/>
|
||||
</IconSvg>
|
||||
);
|
||||
};
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Icon, IconType } from "@gouvfr-lasuite/ui-kit";
|
||||
|
||||
export const OutdatedBrowserPreview = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="file-preview-unsupported">
|
||||
<div className="file-preview-unsupported__icon">
|
||||
<Icon name="security" type={IconType.OUTLINED} size={48} />
|
||||
</div>
|
||||
<p className="file-preview-unsupported__title">
|
||||
{t("file_preview.outdated_browser.title")}
|
||||
</p>
|
||||
<p className="file-preview-unsupported__description">
|
||||
{t("file_preview.outdated_browser.description")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Button } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { LeftSidebarIcon } from "../../components/icon/LeftSidebarIcon";
|
||||
import { ZoomOut } from "../../components/icon/ZoomOut";
|
||||
import { ZoomIn } from "../../components/icon/ZoomIn";
|
||||
import { ZoomReset } from "../../components/icon/ZoomReset";
|
||||
|
||||
interface PdfControlsProps {
|
||||
numPages: number;
|
||||
pageInputValue: string;
|
||||
isSidebarOpen: boolean;
|
||||
onToggleSidebar: () => void;
|
||||
onPageInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onPageInputSubmit: () => void;
|
||||
onPageInputKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomReset: () => void;
|
||||
onZoomOut: () => void;
|
||||
}
|
||||
|
||||
export function PdfControls({
|
||||
numPages,
|
||||
pageInputValue,
|
||||
onToggleSidebar,
|
||||
onPageInputChange,
|
||||
onPageInputSubmit,
|
||||
onPageInputKeyDown,
|
||||
onZoomIn,
|
||||
onZoomReset,
|
||||
onZoomOut,
|
||||
}: PdfControlsProps) {
|
||||
return (
|
||||
<div className="pdf-preview__controls">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
color="neutral"
|
||||
onClick={onToggleSidebar}
|
||||
aria-label="Toggle sidebar"
|
||||
size="small"
|
||||
icon={<LeftSidebarIcon />}
|
||||
/>
|
||||
<div className="controls-vertical-separator" />
|
||||
<div className="pdf-preview__page-nav">
|
||||
<div className="pdf-preview__page-indicator">
|
||||
<input
|
||||
type="text"
|
||||
className="pdf-preview__page-input"
|
||||
value={pageInputValue}
|
||||
onChange={onPageInputChange}
|
||||
onBlur={onPageInputSubmit}
|
||||
onKeyDown={onPageInputKeyDown}
|
||||
aria-label="Current page"
|
||||
size={pageInputValue.length || 1}
|
||||
/>
|
||||
<span className="pdf-preview__page-total">/ {numPages}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="controls-vertical-separator" />
|
||||
<div className="pdf-preview__zoom-controls">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
color="neutral"
|
||||
onClick={onZoomOut}
|
||||
icon={<ZoomOut />}
|
||||
size="small"
|
||||
/>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
color="neutral"
|
||||
onClick={onZoomReset}
|
||||
icon={<ZoomReset />}
|
||||
size="small"
|
||||
/>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
color="neutral"
|
||||
onClick={onZoomIn}
|
||||
icon={<ZoomIn />}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Document, Page } from "react-pdf";
|
||||
import type { PDFDocumentProxy } from "pdfjs-dist";
|
||||
import { AutoSizer, List } from "react-virtualized";
|
||||
import type { ListRowRenderer, Index } from "react-virtualized";
|
||||
import { useDebouncedResize } from "./useDebouncedResize";
|
||||
import { pdfOptions } from "./pdfOptions";
|
||||
|
||||
const PAGE_GAP = 16;
|
||||
const BASE_WIDTH = 800;
|
||||
const PAGE_MARGIN = 32;
|
||||
|
||||
export interface PdfPageViewerHandle {
|
||||
scrollToPage: (page: number) => void;
|
||||
}
|
||||
|
||||
interface PdfPageViewerProps {
|
||||
file?: File | null;
|
||||
numPages: number;
|
||||
zoom: number;
|
||||
onDocumentLoadSuccess: (pdf: PDFDocumentProxy) => void;
|
||||
onCurrentPageChange: (page: number) => void;
|
||||
onClick: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onItemClick: (args: { pageNumber: number }) => void;
|
||||
onLoadError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export function PdfPageViewer({
|
||||
file,
|
||||
numPages,
|
||||
zoom,
|
||||
onDocumentLoadSuccess,
|
||||
onCurrentPageChange,
|
||||
onClick,
|
||||
onItemClick,
|
||||
onLoadError,
|
||||
ref,
|
||||
}: PdfPageViewerProps & { ref?: React.Ref<PdfPageViewerHandle> }) {
|
||||
const listRef = useRef<List>(null);
|
||||
const prevZoomRef = useRef(zoom);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const listHeightRef = useRef(0);
|
||||
|
||||
const size = useDebouncedResize();
|
||||
|
||||
const width = useMemo(() => {
|
||||
return BASE_WIDTH + PAGE_MARGIN > size.width
|
||||
? size.width - PAGE_MARGIN
|
||||
: BASE_WIDTH;
|
||||
}, [size.width]);
|
||||
|
||||
const pageHeight = width * 1.414;
|
||||
|
||||
const rowHeightForIndex = useCallback(
|
||||
(index: number) => {
|
||||
const h = pageHeight * zoom;
|
||||
// No trailing gap on the last row
|
||||
if (index === numPages - 1) return h;
|
||||
return h + PAGE_GAP * zoom;
|
||||
},
|
||||
[pageHeight, zoom, numPages],
|
||||
);
|
||||
|
||||
const rowHeight = useCallback(
|
||||
({ index }: Index) => rowHeightForIndex(index),
|
||||
[rowHeightForIndex],
|
||||
);
|
||||
|
||||
// When zoom, pageHeight or numPages change, row heights must be recalculated
|
||||
// because react-virtualized caches them internally.
|
||||
// On zoom change we also scale scrollTop proportionally so the user stays
|
||||
// on the same part of the document (e.g. 2x zoom → 2x scroll offset).
|
||||
useEffect(() => {
|
||||
listRef.current?.recomputeRowHeights();
|
||||
|
||||
if (prevZoomRef.current !== zoom && listRef.current) {
|
||||
const ratio = zoom / prevZoomRef.current;
|
||||
const newScrollTop = Math.round(scrollTop * ratio);
|
||||
|
||||
listRef.current.scrollToPosition(newScrollTop);
|
||||
prevZoomRef.current = zoom;
|
||||
}
|
||||
}, [zoom, pageHeight, numPages]);
|
||||
|
||||
// Find which page contains the vertical center of the viewport by walking
|
||||
// cumulative row heights until we pass the midpoint.
|
||||
const currentPage = useMemo(() => {
|
||||
if (numPages === 0) return 1;
|
||||
|
||||
const viewportCenter = scrollTop + listHeightRef.current / 2;
|
||||
|
||||
let offset = 0;
|
||||
for (let i = 0; i < numPages; i++) {
|
||||
const h = rowHeightForIndex(i);
|
||||
if (offset + h > viewportCenter) {
|
||||
return i + 1;
|
||||
}
|
||||
offset += h;
|
||||
}
|
||||
|
||||
return numPages;
|
||||
}, [scrollTop, numPages, rowHeightForIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
onCurrentPageChange(currentPage);
|
||||
}, [currentPage]);
|
||||
|
||||
const scrollToPage = useCallback(
|
||||
(page: number) => {
|
||||
if (!listRef.current) return;
|
||||
|
||||
// Compute offset for the target page
|
||||
let offset = 0;
|
||||
for (let i = 0; i < page - 1; i++) {
|
||||
offset += rowHeightForIndex(i);
|
||||
}
|
||||
listRef.current.scrollToPosition(offset);
|
||||
},
|
||||
[rowHeightForIndex],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({ scrollToPage }), [scrollToPage]);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
({
|
||||
scrollTop: st,
|
||||
clientHeight,
|
||||
}: {
|
||||
scrollTop: number;
|
||||
clientHeight: number;
|
||||
}) => {
|
||||
listHeightRef.current = clientHeight;
|
||||
setScrollTop(st);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const rowRenderer: ListRowRenderer = ({ index, key, style }) => (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
...style,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
paddingTop: PAGE_GAP * zoom,
|
||||
}}
|
||||
>
|
||||
<Page
|
||||
pageNumber={index + 1}
|
||||
width={width}
|
||||
scale={zoom}
|
||||
loading={pageSkeleton}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const pageSkeleton = (
|
||||
<div
|
||||
className="pdf-preview__page-skeleton"
|
||||
style={{ height: pageHeight * zoom, width: width * zoom }}
|
||||
/>
|
||||
);
|
||||
|
||||
const loadingContainerSkeleton = (
|
||||
<div className="pdf-preview__container-skeleton">{pageSkeleton}</div>
|
||||
);
|
||||
|
||||
if (!file) {
|
||||
return (
|
||||
<div className="pdf-preview__container">{loadingContainerSkeleton}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pdf-preview__container" onClick={onClick}>
|
||||
<Document
|
||||
file={file}
|
||||
onLoadSuccess={onDocumentLoadSuccess}
|
||||
onItemClick={onItemClick}
|
||||
options={pdfOptions}
|
||||
loading={loadingContainerSkeleton}
|
||||
onLoadError={onLoadError}
|
||||
>
|
||||
<AutoSizer>
|
||||
{({ height, width: autoWidth }) => {
|
||||
// When zoomed in the page content may exceed the viewport width.
|
||||
// The wrapper div is clamped to the viewport (autoWidth) and scrolls
|
||||
// horizontally, while the List itself is sized to the full content
|
||||
// width so pages render uncropped.
|
||||
const listWidth = Math.max(autoWidth, width * zoom);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: autoWidth,
|
||||
height,
|
||||
}}
|
||||
className="pdf-preview__horizontal-scroll"
|
||||
>
|
||||
<List
|
||||
ref={listRef}
|
||||
height={height}
|
||||
width={listWidth}
|
||||
rowCount={numPages}
|
||||
rowHeight={rowHeight}
|
||||
overscanRowCount={3}
|
||||
onScroll={handleScroll}
|
||||
rowRenderer={rowRenderer}
|
||||
style={{ outline: "none" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
</Document>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Document, Thumbnail } from "react-pdf";
|
||||
import { AutoSizer, List } from "react-virtualized";
|
||||
import type { ListRowRenderer } from "react-virtualized";
|
||||
import { pdfOptions } from "./pdfOptions";
|
||||
|
||||
interface PdfThumbnailSidebarProps {
|
||||
file?: File | null;
|
||||
numPages: number;
|
||||
currentPage: number;
|
||||
goToPage: (page: number) => void;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
const TRANSITION_DELAY = 300;
|
||||
const THUMBNAIL_HEIGHT = 178;
|
||||
const THUMBNAIL_GAP = 12;
|
||||
const ROW_HEIGHT = THUMBNAIL_HEIGHT + THUMBNAIL_GAP;
|
||||
|
||||
// Two-phase mount/unmount to allow CSS transitions to play out:
|
||||
// Opening: mount immediately (unmount=false), then defer isOpenProxy=true
|
||||
// so the DOM is present before the "open" class triggers the transition.
|
||||
// Closing: remove the "open" class first (isOpenProxy=false), wait for the
|
||||
// transition to finish (TRANSITION_DELAY), then unmount the component.
|
||||
// When closed the component is not mounted, which is better for performance
|
||||
// as this component is quite heavy and is not needed when closed.
|
||||
export function PdfThumbnailSidebar(props: PdfThumbnailSidebarProps) {
|
||||
const [unmount, setUnmount] = useState(true);
|
||||
const [isOpenProxy, setIsOpenProxy] = useState(props.isOpen);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
if (props.isOpen) {
|
||||
setUnmount(false);
|
||||
timer = setTimeout(() => setIsOpenProxy(true), 100);
|
||||
} else {
|
||||
setIsOpenProxy(false);
|
||||
// The 1.1 is to allow for the transition to finish.
|
||||
// It is a safety margin to avoid the component being unmounted too early.
|
||||
timer = setTimeout(() => setUnmount(true), TRANSITION_DELAY * 1.1);
|
||||
}
|
||||
return () => clearTimeout(timer);
|
||||
}, [props.isOpen]);
|
||||
|
||||
if (unmount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <PdfThumbnailSidebarContent {...props} isOpen={isOpenProxy} />;
|
||||
}
|
||||
|
||||
export function PdfThumbnailSidebarContent({
|
||||
file,
|
||||
numPages,
|
||||
currentPage,
|
||||
goToPage,
|
||||
isOpen,
|
||||
}: PdfThumbnailSidebarProps) {
|
||||
const listRef = useRef<List>(null);
|
||||
const [isDocLoaded, setIsDocLoaded] = useState(false);
|
||||
|
||||
// Auto-scroll active thumbnail into view
|
||||
useEffect(() => {
|
||||
if (!isDocLoaded || !listRef.current) return;
|
||||
listRef.current.scrollToRow(currentPage - 1);
|
||||
}, [isDocLoaded, currentPage]);
|
||||
|
||||
const thumbnailSkeleton = <div className="pdf-preview__thumbnail-skeleton" />;
|
||||
|
||||
const rowRenderer: ListRowRenderer = ({ index, key, style }) => {
|
||||
const page = index + 1;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
...style,
|
||||
paddingBottom: index < numPages - 1 ? THUMBNAIL_GAP : 0,
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
data-thumb-page={page}
|
||||
className={`pdf-preview__thumbnail${currentPage === page ? " pdf-preview__thumbnail--active" : ""}`}
|
||||
onClick={() => goToPage(page)}
|
||||
aria-label={`Go to page ${page}`}
|
||||
>
|
||||
<Thumbnail
|
||||
pageNumber={page}
|
||||
width={105}
|
||||
loading={thumbnailSkeleton}
|
||||
/>
|
||||
<span className="pdf-preview__thumbnail-number">{page}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const loadingContainerSkeleton = (
|
||||
<div className="pdf-preview__sidebar-skeleton">{thumbnailSkeleton}</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`pdf-preview__sidebar${!isOpen ? " pdf-preview__sidebar--closed" : ""}`}
|
||||
>
|
||||
{file ? (
|
||||
<Document
|
||||
file={file}
|
||||
options={pdfOptions}
|
||||
loading={loadingContainerSkeleton}
|
||||
onLoadSuccess={() => setIsDocLoaded(true)}
|
||||
>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<List
|
||||
ref={listRef}
|
||||
height={height}
|
||||
width={width}
|
||||
rowCount={numPages}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
overscanRowCount={5}
|
||||
rowRenderer={rowRenderer}
|
||||
scrollToAlignment="center"
|
||||
style={{ outline: "none" }}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</Document>
|
||||
) : (
|
||||
loadingContainerSkeleton
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
.pdf-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__sidebar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
width: 150px;
|
||||
overflow: hidden;
|
||||
|
||||
padding: 12px 0;
|
||||
border-right: 1px solid var(--c--contextuals--border--surface--primary);
|
||||
background: var(--c--contextuals--background--surface--primary);
|
||||
transform: translateX(0);
|
||||
transition: transform 0.3s;
|
||||
|
||||
.react-pdf__Document {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&--closed {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
[role="gridcell"] {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__thumbnail {
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
background: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
transition: border-color 0.15s ease;
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
|
||||
.react-pdf__Thumbnail {
|
||||
box-shadow: 0 0.5px 5px 0 rgba(0, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(
|
||||
--c--contextuals--content--semantic--neutral--secondary
|
||||
);
|
||||
}
|
||||
|
||||
&--active {
|
||||
border: 2px solid var(--c--contextuals--border--semantic--brand--primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__page-skeleton {
|
||||
border-radius: 4px;
|
||||
background: var(--c--contextuals--background--surface--primary);
|
||||
box-shadow: 0 3.649px 14.597px 0 rgba(0, 0, 0, 0.1);
|
||||
animation: pdf-skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&__sidebar-skeleton {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__thumbnail-skeleton {
|
||||
width: 106px;
|
||||
height: 150px;
|
||||
border-radius: 4px;
|
||||
background: var(--c--contextuals--border--surface--primary);
|
||||
animation: pdf-skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&__thumbnail-number {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--c--contextuals--content--semantic--neutral--secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 400px;
|
||||
|
||||
&-skeleton {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.react-pdf__Document {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__horizontal-scroll {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
&__page-wrapper {
|
||||
.react-pdf__Document {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__container .react-pdf__Page,
|
||||
&__page-wrapper .react-pdf__Page {
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 3.649px 14.597px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&__controls {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--c--globals--spacings--xs);
|
||||
border-radius: var(--c--globals--spacings--3xs);
|
||||
padding: var(--c--globals--spacings--3xs);
|
||||
border-radius: var(--xs, 8px);
|
||||
border: 1px solid var(--c--contextuals--border--surface--primary);
|
||||
background: var(--c--contextuals--background--surface--primary);
|
||||
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
&__zoom-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__page-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--c--globals--spacings--3xs);
|
||||
}
|
||||
|
||||
&__page-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--c--contextuals--content--semantic--neutral--secondary);
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
&__page-input {
|
||||
height: 26px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--c--contextuals--border--semantic--neutral--tertiary);
|
||||
background: var(--c--contextuals--background--surface--tertiary);
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--c--contextuals--content--semantic--neutral--secondary);
|
||||
background: transparent;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--c--contextuals--content--semantic--brand--primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__page-total {
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--c--contextuals--content--semantic--neutral--secondary);
|
||||
}
|
||||
|
||||
&__error {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--c--contextuals--content--semantic--danger--primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.controls-vertical-separator {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
border-radius: 1px;
|
||||
overflow: hidden;
|
||||
background: var(--c--contextuals--border--surface--primary);
|
||||
}
|
||||
|
||||
.zoom-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--c--globals--spacings--3xs);
|
||||
|
||||
&__value {
|
||||
text-align: center;
|
||||
min-width: 35px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--c--contextuals--content--semantic--neutral--secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pdf-skeleton-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pdf-preview {
|
||||
&__controls {
|
||||
padding: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__page-input {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&__page-indicator {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.zoom-control__value {
|
||||
min-width: 40px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pdf-preview__page-skeleton {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.pdf-preview__thumbnail-skeleton {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.pdf-preview__sidebar {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.pdf-preview__external-link {
|
||||
word-break: break-all;
|
||||
display: block;
|
||||
padding: 8px;
|
||||
background-color: var(--background-alt-grey);
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
@@ -1,14 +1,189 @@
|
||||
interface PreviewPdfProps {
|
||||
src?: string;
|
||||
}
|
||||
import "./pdfPolyfills";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { pdfjs } from "react-pdf";
|
||||
import "react-pdf/dist/Page/AnnotationLayer.css";
|
||||
import "react-pdf/dist/Page/TextLayer.css";
|
||||
import "react-virtualized/styles.css";
|
||||
import { Icon, IconType } from "@gouvfr-lasuite/ui-kit";
|
||||
|
||||
export const PreviewPdf = ({ src }: PreviewPdfProps) => {
|
||||
return (
|
||||
<iframe
|
||||
src={src}
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="pdf-container__iframe"
|
||||
/>
|
||||
import { usePdfNavigation } from "./usePdfNavigation";
|
||||
import { PdfThumbnailSidebar } from "./PdfThumbnailSidebar";
|
||||
import { PdfControls } from "./PdfControls";
|
||||
import { PdfPageViewer } from "./PdfPageViewer";
|
||||
import type { PdfPageViewerHandle } from "./PdfPageViewer";
|
||||
import { useRedirectDisclaimer } from "./useRedirectDisclaimer";
|
||||
import { OutdatedBrowserPreview } from "./OutdatedBrowserPreview";
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.mjs";
|
||||
|
||||
export function PreviewPdf({ src }: { src: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [numPages, setNumPages] = useState<number>(1);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [documentError, setDocumentError] = useState<
|
||||
"generic" | "outdated" | null
|
||||
>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const viewerRef = useRef<PdfPageViewerHandle>(null);
|
||||
const { handlePdfClick } = useRedirectDisclaimer();
|
||||
|
||||
const [zoom, setZoom] = useState(1);
|
||||
|
||||
const zoomIn = useCallback(() => {
|
||||
setZoom((prev) => Math.min(3, prev + 0.25));
|
||||
}, []);
|
||||
const zoomOut = useCallback(() => {
|
||||
setZoom((prev) => Math.max(0.5, prev - 0.25));
|
||||
}, []);
|
||||
const zoomReset = useCallback(() => {
|
||||
setZoom(1);
|
||||
}, []);
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setIsSidebarOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const scrollToPage = useCallback((page: number) => {
|
||||
viewerRef.current?.scrollToPage(page);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
goToPage,
|
||||
onDocumentLoadSuccess: onNavLoadSuccess,
|
||||
pageInputValue,
|
||||
setPageInputValue,
|
||||
handlePageInputChange,
|
||||
handlePageInputSubmit,
|
||||
handlePageInputKeyDown,
|
||||
} = usePdfNavigation({ numPages, currentPage, scrollToPage });
|
||||
|
||||
// onItemClick handles internal PDF links (e.g. table of contents entries).
|
||||
// It is called by react-pdf's Document via a viewer ref that is created once
|
||||
// with useRef, so the callback is captured in a stale closure from the first
|
||||
// render. We use a ref to always access the latest goToPage (which depends
|
||||
// on numPages) so navigation targets the correct page.
|
||||
//
|
||||
// onClick (handlePdfClick) handles regular DOM clicks on the annotation layer
|
||||
// — it intercepts external links to show a redirect disclaimer modal.
|
||||
const goToPageRef = useRef(goToPage);
|
||||
useEffect(() => {
|
||||
goToPageRef.current = goToPage;
|
||||
}, [goToPage]);
|
||||
|
||||
const onItemClick = useCallback((args: { pageNumber: number }) => {
|
||||
goToPageRef.current(args.pageNumber);
|
||||
}, []);
|
||||
|
||||
// Sync page input value when currentPage changes from scrolling
|
||||
useEffect(() => {
|
||||
setPageInputValue(String(currentPage));
|
||||
}, [currentPage, setPageInputValue]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const fetchPdf = async () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(src, {
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch PDF: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const filename = src.split("/").pop() || "document.pdf";
|
||||
const pdfFile = new File([blob], filename, { type: "application/pdf" });
|
||||
|
||||
setFile(pdfFile);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setError(err instanceof Error ? err.message : "Failed to load PDF");
|
||||
}
|
||||
};
|
||||
|
||||
fetchPdf();
|
||||
return () => controller.abort();
|
||||
}, [src]);
|
||||
|
||||
const onDocumentLoadSuccess = useCallback(
|
||||
(pdf: Parameters<typeof onNavLoadSuccess>[0]) => {
|
||||
const nextNumPages = onNavLoadSuccess(pdf);
|
||||
setNumPages(nextNumPages);
|
||||
},
|
||||
[onNavLoadSuccess],
|
||||
);
|
||||
};
|
||||
|
||||
const handleDocumentError = useCallback((error: Error) => {
|
||||
const pdfErrors = [
|
||||
"InvalidPDFException",
|
||||
"FormatError",
|
||||
"PasswordException",
|
||||
"ResponseException",
|
||||
];
|
||||
setDocumentError(pdfErrors.includes(error.name) ? "generic" : "outdated");
|
||||
}, []);
|
||||
|
||||
if (error || documentError === "generic") {
|
||||
return (
|
||||
<div className="file-preview-unsupported">
|
||||
<div className="file-preview-unsupported__icon">
|
||||
<Icon name="error" type={IconType.OUTLINED} size={48} />
|
||||
</div>
|
||||
<p className="file-preview-unsupported__title">
|
||||
{t("file_preview.error.title")}
|
||||
</p>
|
||||
<p className="file-preview-unsupported__description">
|
||||
{t("file_preview.error.description")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (documentError === "outdated") {
|
||||
return <OutdatedBrowserPreview />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pdf-preview">
|
||||
<div className="pdf-preview__body">
|
||||
<PdfThumbnailSidebar
|
||||
file={file}
|
||||
numPages={numPages}
|
||||
currentPage={currentPage}
|
||||
goToPage={goToPage}
|
||||
isOpen={isSidebarOpen}
|
||||
/>
|
||||
<PdfPageViewer
|
||||
ref={viewerRef}
|
||||
file={file}
|
||||
numPages={numPages}
|
||||
zoom={zoom}
|
||||
onDocumentLoadSuccess={onDocumentLoadSuccess}
|
||||
onCurrentPageChange={setCurrentPage}
|
||||
onClick={handlePdfClick}
|
||||
onItemClick={onItemClick}
|
||||
onLoadError={handleDocumentError}
|
||||
/>
|
||||
</div>
|
||||
<PdfControls
|
||||
numPages={numPages}
|
||||
pageInputValue={pageInputValue}
|
||||
isSidebarOpen={isSidebarOpen}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
onPageInputChange={handlePageInputChange}
|
||||
onPageInputSubmit={handlePageInputSubmit}
|
||||
onPageInputKeyDown={handlePageInputKeyDown}
|
||||
onZoomIn={zoomIn}
|
||||
onZoomReset={zoomReset}
|
||||
onZoomOut={zoomOut}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.pdf-container__iframe {
|
||||
border: none;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const pdfOptions = {
|
||||
cMapUrl: "/cmaps/",
|
||||
standardFontDataUrl: "/standard_fonts/",
|
||||
wasmUrl: "/wasm/",
|
||||
isEvalSupported: false,
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Polyfills required by pdfjs-dist for older browsers (e.g. Edge)
|
||||
* that lack these newer APIs.
|
||||
*
|
||||
* They live in a dedicated file so they are imported as the very first
|
||||
* module in PreviewPdf.tsx — before react-pdf or pdfjs-dist execute —
|
||||
* guaranteeing the APIs exist when PDF code initialises.
|
||||
* A separate file also makes it easy to remove once browser support
|
||||
* catches up.
|
||||
*/
|
||||
|
||||
if (typeof (URL as any).parse === "undefined") {
|
||||
(URL as any).parse = function (url: string, base?: string) {
|
||||
try {
|
||||
return new URL(url, base);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof Promise.withResolvers === "undefined") {
|
||||
Promise.withResolvers = function <T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
timeout = 300,
|
||||
): ((...args: Parameters<T>) => void) & { cancel: () => void } {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const debouncedFunc = (...args: Parameters<T>) => {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
func(...args);
|
||||
}, timeout);
|
||||
};
|
||||
debouncedFunc.cancel = () => {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
return debouncedFunc;
|
||||
}
|
||||
|
||||
export const useDebouncedResize = () => {
|
||||
const [size, setSize] = useState({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = debounce(() => {
|
||||
setSize({ width: window.innerWidth, height: window.innerHeight });
|
||||
}, 100);
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => {
|
||||
handleResize.cancel();
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return size;
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { PDFDocumentProxy } from "pdfjs-dist";
|
||||
|
||||
interface UsePdfNavigationParams {
|
||||
numPages: number;
|
||||
currentPage: number;
|
||||
scrollToPage: (page: number) => void;
|
||||
}
|
||||
|
||||
export const usePdfNavigation = ({
|
||||
numPages,
|
||||
currentPage,
|
||||
scrollToPage,
|
||||
}: UsePdfNavigationParams) => {
|
||||
const [pageInputValue, setPageInputValue] = useState<string>("1");
|
||||
|
||||
const goToPage = useCallback(
|
||||
(page: number) => {
|
||||
const clamped = Math.max(1, Math.min(numPages, page));
|
||||
setPageInputValue(String(clamped));
|
||||
scrollToPage(clamped);
|
||||
},
|
||||
[numPages, scrollToPage],
|
||||
);
|
||||
|
||||
const onDocumentLoadSuccess = useCallback(
|
||||
({ numPages: nextNumPages }: PDFDocumentProxy) => {
|
||||
setPageInputValue("1");
|
||||
return nextNumPages;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handlePageInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPageInputValue(e.target.value);
|
||||
};
|
||||
|
||||
const handlePageInputSubmit = () => {
|
||||
const parsed = parseInt(pageInputValue, 10);
|
||||
if (isNaN(parsed)) {
|
||||
setPageInputValue(String(currentPage));
|
||||
return;
|
||||
}
|
||||
goToPage(parsed);
|
||||
};
|
||||
|
||||
const handlePageInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
handlePageInputSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
goToPage,
|
||||
onDocumentLoadSuccess,
|
||||
pageInputValue,
|
||||
setPageInputValue,
|
||||
handlePageInputChange,
|
||||
handlePageInputSubmit,
|
||||
handlePageInputKeyDown,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useCallback } from "react";
|
||||
import { useModals } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const SAFE_PROTOCOLS = ["https:", "http:"];
|
||||
|
||||
export const useRedirectDisclaimer = () => {
|
||||
const modals = useModals();
|
||||
const { t } = useTranslation();
|
||||
const handlePdfClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const anchor = target.closest("a");
|
||||
if (anchor && anchor.href) {
|
||||
if (anchor.closest("[data-internal-link]")) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
try {
|
||||
const url = new URL(anchor.href);
|
||||
if (!SAFE_PROTOCOLS.includes(url.protocol)) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
modals.confirmationModal({
|
||||
title: t("file_preview.external_link.title"),
|
||||
children: (
|
||||
<div>
|
||||
<p>{t("file_preview.external_link.description")}</p>
|
||||
<span className="pdf-preview__external-link">{anchor.href}</span>
|
||||
<p>{t("file_preview.external_link.confirm_question")}</p>
|
||||
</div>
|
||||
),
|
||||
onDecide: (decision) => {
|
||||
if (decision === "yes") {
|
||||
window.open(anchor.href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[modals, t],
|
||||
);
|
||||
return { handlePdfClick };
|
||||
};
|
||||
Reference in New Issue
Block a user