403 lines
12 KiB
TypeScript
403 lines
12 KiB
TypeScript
import { FormSectionCard, FormSectionGrid } from "@repo/rdx-ui/components";
|
|
import {
|
|
Alert,
|
|
AlertDescription,
|
|
AlertTitle,
|
|
Button,
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
Spinner,
|
|
} from "@repo/shadcn-ui/components";
|
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
|
import {
|
|
DownloadIcon,
|
|
ExternalLinkIcon,
|
|
EyeIcon,
|
|
PrinterIcon,
|
|
ZoomInIcon,
|
|
ZoomOutIcon,
|
|
} from "lucide-react";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
const DEFAULT_ZOOM_OPTIONS = [50, 75, 100, 125, 150];
|
|
const FIT_ZOOM_VALUE = "fit";
|
|
const PDF_PAGE_WIDTH = 794;
|
|
const PDF_PAGE_HEIGHT = 1123;
|
|
|
|
export interface PDFViewerLabels {
|
|
zoomOut: string;
|
|
zoomIn: string;
|
|
zoomSelect: string;
|
|
fit: string;
|
|
open: string;
|
|
download: string;
|
|
print: string;
|
|
loading: string;
|
|
refreshing: string;
|
|
errorTitle: string;
|
|
retry: string;
|
|
emptyTitle: string;
|
|
emptyDescription: string;
|
|
}
|
|
|
|
type PDFViewerProps = {
|
|
src?: string | null;
|
|
title?: string;
|
|
description?: string;
|
|
className?: string;
|
|
variant?: "panel" | "native-inline";
|
|
showOpenAction?: boolean;
|
|
showDownloadAction?: boolean;
|
|
showPrinterAction?: boolean;
|
|
loading?: boolean;
|
|
isRefreshing?: boolean;
|
|
error?: string | null;
|
|
defaultZoom?: number | "fit";
|
|
zoomOptions?: number[];
|
|
onOpen?: () => void;
|
|
onDownload?: () => void;
|
|
onPrint?: () => void;
|
|
onRetry?: () => void;
|
|
labels?: Partial<PDFViewerLabels>;
|
|
};
|
|
|
|
const clampZoom = (value: number, zoomOptions: number[]) => {
|
|
const min = Math.min(...zoomOptions);
|
|
const max = Math.max(...zoomOptions);
|
|
|
|
return Math.min(Math.max(value, min), max);
|
|
};
|
|
|
|
export function PDFViewer({
|
|
src,
|
|
title = "Vista previa",
|
|
description = "Documento PDF embebido en la pantalla.",
|
|
className,
|
|
variant = "panel",
|
|
showOpenAction = true,
|
|
showDownloadAction = true,
|
|
showPrinterAction = true,
|
|
loading = false,
|
|
isRefreshing = false,
|
|
error = null,
|
|
defaultZoom = FIT_ZOOM_VALUE,
|
|
zoomOptions = DEFAULT_ZOOM_OPTIONS,
|
|
onOpen,
|
|
onDownload,
|
|
onPrint,
|
|
onRetry,
|
|
labels,
|
|
}: PDFViewerProps) {
|
|
const viewportRef = useRef<HTMLDivElement | null>(null);
|
|
const [fitZoom, setFitZoom] = useState(100);
|
|
const [zoom, setZoom] = useState<string>(() =>
|
|
defaultZoom === FIT_ZOOM_VALUE ? FIT_ZOOM_VALUE : String(clampZoom(defaultZoom, zoomOptions))
|
|
);
|
|
const minZoom = Math.min(...zoomOptions);
|
|
const maxZoom = Math.max(...zoomOptions);
|
|
const resolvedLabels: PDFViewerLabels = {
|
|
zoomOut: "Alejar",
|
|
zoomIn: "Acercar",
|
|
zoomSelect: "Seleccionar zoom",
|
|
fit: "Ajustar",
|
|
open: "Abrir PDF",
|
|
download: "Descargar PDF",
|
|
print: "Imprimir PDF",
|
|
loading: "Cargando vista previa del PDF...",
|
|
refreshing: "Actualizando documento...",
|
|
errorTitle: "No se pudo cargar la vista previa",
|
|
retry: "Reintentar",
|
|
emptyTitle: "Vista previa no disponible",
|
|
emptyDescription: "El PDF del documento todavía no está listo para mostrarse.",
|
|
...labels,
|
|
};
|
|
|
|
const resolvedSrc = useMemo(() => {
|
|
if (!src) {
|
|
return null;
|
|
}
|
|
|
|
if (variant === "native-inline") {
|
|
return src;
|
|
}
|
|
|
|
const separator = src.includes("#") ? "&" : "#";
|
|
return `${src}${separator}toolbar=0&navpanes=0&scrollbar=1&page=1&zoom=page-width`;
|
|
}, [src, variant]);
|
|
|
|
useEffect(() => {
|
|
if (zoom !== FIT_ZOOM_VALUE) {
|
|
return;
|
|
}
|
|
|
|
const element = viewportRef.current;
|
|
if (!element) {
|
|
return;
|
|
}
|
|
|
|
const updateFitZoom = () => {
|
|
const styles = window.getComputedStyle(element);
|
|
const paddingX =
|
|
Number.parseFloat(styles.paddingLeft) + Number.parseFloat(styles.paddingRight);
|
|
const availableWidth = Math.max(element.clientWidth - paddingX, 320);
|
|
const widthScale = availableWidth / PDF_PAGE_WIDTH;
|
|
const nextScale = Math.min(widthScale, maxZoom / 100);
|
|
setFitZoom(Math.max(Math.round(nextScale * 100), 25));
|
|
};
|
|
|
|
updateFitZoom();
|
|
|
|
const observer = new ResizeObserver(() => updateFitZoom());
|
|
observer.observe(element);
|
|
window.addEventListener("resize", updateFitZoom);
|
|
|
|
return () => {
|
|
observer.disconnect();
|
|
window.removeEventListener("resize", updateFitZoom);
|
|
};
|
|
}, [maxZoom, zoom]);
|
|
|
|
const effectiveZoom = zoom === FIT_ZOOM_VALUE ? fitZoom : Number(zoom);
|
|
const zoomScale = effectiveZoom / 100;
|
|
const scaledPageHeight = PDF_PAGE_HEIGHT * zoomScale;
|
|
|
|
const handleOpen = () => {
|
|
if (onOpen) {
|
|
onOpen();
|
|
return;
|
|
}
|
|
|
|
if (src) {
|
|
window.open(src, "_blank", "noopener,noreferrer");
|
|
}
|
|
};
|
|
|
|
const handleDownload = () => {
|
|
if (onDownload) {
|
|
onDownload();
|
|
return;
|
|
}
|
|
|
|
if (!src) {
|
|
return;
|
|
}
|
|
|
|
const link = document.createElement("a");
|
|
link.href = src;
|
|
link.download = "";
|
|
link.click();
|
|
};
|
|
|
|
const handlePrint = () => {
|
|
if (onPrint) {
|
|
onPrint();
|
|
return;
|
|
}
|
|
|
|
if (src) {
|
|
window.open(src, "_blank", "noopener,noreferrer");
|
|
}
|
|
};
|
|
|
|
const viewerContent = (
|
|
<FormSectionGrid
|
|
className={cn("gap-y-6 md:grid-cols-1", variant === "native-inline" && "h-full")}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"flex-1 overflow-auto bg-muted p-1.5 sm:p-3 md:p-5",
|
|
variant === "native-inline" && "flex h-full min-h-0 flex-col bg-transparent p-0 md:p-0"
|
|
)}
|
|
ref={viewportRef}
|
|
>
|
|
{loading ? (
|
|
<div className="flex h-full min-h-[480px] items-center justify-center rounded-xl border border-dashed bg-background/80">
|
|
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
|
<Spinner className="size-4" />
|
|
{resolvedLabels.loading}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{!loading && error ? (
|
|
<div className="flex h-full min-h-[480px] items-center justify-center rounded-xl border border-dashed bg-background/80 px-6 text-center">
|
|
<Alert className="max-w-md text-left">
|
|
<AlertTitle>{resolvedLabels.errorTitle}</AlertTitle>
|
|
<AlertDescription className="space-y-4">
|
|
<p>{error}</p>
|
|
{onRetry ? (
|
|
<Button onClick={onRetry} type="button" variant="outline">
|
|
{resolvedLabels.retry}
|
|
</Button>
|
|
) : null}
|
|
</AlertDescription>
|
|
</Alert>
|
|
</div>
|
|
) : null}
|
|
|
|
{!(loading || error) && resolvedSrc ? (
|
|
<div
|
|
className={cn(
|
|
"mx-auto flex min-h-full justify-center",
|
|
variant === "native-inline" && "flex-1 min-h-0 w-full"
|
|
)}
|
|
style={variant === "native-inline" ? undefined : { minHeight: `${scaledPageHeight}px` }}
|
|
>
|
|
{variant === "native-inline" ? (
|
|
<iframe
|
|
className="h-full min-h-0 w-full bg-background"
|
|
src={resolvedSrc}
|
|
style={{ border: "0" }}
|
|
title={title}
|
|
/>
|
|
) : (
|
|
<div
|
|
className="origin-top overflow-hidden rounded-lg border border-black/8 bg-white shadow-[0_10px_30px_rgba(15,23,42,0.10),0_2px_8px_rgba(15,23,42,0.06)] transition-transform"
|
|
style={{
|
|
transform: `scale(${zoomScale})`,
|
|
transformOrigin: "top center",
|
|
width: `${PDF_PAGE_WIDTH}px`,
|
|
}}
|
|
>
|
|
<iframe
|
|
className="block w-full bg-white"
|
|
src={resolvedSrc}
|
|
style={{ border: "0", height: `${PDF_PAGE_HEIGHT}px` }}
|
|
title={title}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
|
|
{loading || error || resolvedSrc ? null : (
|
|
<div className="flex h-full min-h-[480px] items-center justify-center rounded-xl border border-dashed bg-background/80 px-6 text-center">
|
|
<div className="space-y-2">
|
|
<p className="font-medium text-foreground">{resolvedLabels.emptyTitle}</p>
|
|
<p className="text-sm text-muted-foreground">{resolvedLabels.emptyDescription}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</FormSectionGrid>
|
|
);
|
|
|
|
if (variant === "native-inline") {
|
|
return (
|
|
<div className={cn("flex h-full min-h-[32rem] min-h-0 flex-col", className)}>
|
|
{viewerContent}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<FormSectionCard
|
|
actions={
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
aria-label={resolvedLabels.zoomOut}
|
|
disabled={loading || !src || (zoom !== FIT_ZOOM_VALUE && Number(zoom) <= minZoom)}
|
|
onClick={() =>
|
|
setZoom((current) => {
|
|
if (current === FIT_ZOOM_VALUE) {
|
|
return String(Math.max(fitZoom - 25, minZoom));
|
|
}
|
|
|
|
return String(clampZoom(Number(current) - 25, zoomOptions));
|
|
})
|
|
}
|
|
size="icon-sm"
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
<ZoomOutIcon className="size-4" />
|
|
</Button>
|
|
|
|
<Select onValueChange={setZoom} value={zoom}>
|
|
<SelectTrigger aria-label={resolvedLabels.zoomSelect} className="w-24 bg-background">
|
|
<SelectValue placeholder={resolvedLabels.zoomSelect} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={FIT_ZOOM_VALUE}>{resolvedLabels.fit}</SelectItem>
|
|
{zoomOptions.map((option) => (
|
|
<SelectItem key={option} value={String(option)}>
|
|
{option}%
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Button
|
|
aria-label={resolvedLabels.zoomIn}
|
|
disabled={loading || !src || (zoom !== FIT_ZOOM_VALUE && Number(zoom) >= maxZoom)}
|
|
onClick={() =>
|
|
setZoom((current) => {
|
|
if (current === FIT_ZOOM_VALUE) {
|
|
return String(clampZoom(fitZoom + 25, zoomOptions));
|
|
}
|
|
|
|
return String(clampZoom(Number(current) + 25, zoomOptions));
|
|
})
|
|
}
|
|
size="icon-sm"
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
<ZoomInIcon className="size-4" />
|
|
</Button>
|
|
|
|
{showOpenAction ? (
|
|
<Button disabled={loading || !src} onClick={handleOpen} type="button" variant="outline">
|
|
<ExternalLinkIcon className="mr-2 size-4" />
|
|
{resolvedLabels.open}
|
|
</Button>
|
|
) : null}
|
|
|
|
{showDownloadAction ? (
|
|
<Button
|
|
disabled={loading || !src}
|
|
onClick={handleDownload}
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
<DownloadIcon className="mr-2 size-4" />
|
|
{resolvedLabels.download}
|
|
</Button>
|
|
) : null}
|
|
|
|
{showPrinterAction ? (
|
|
<Button
|
|
aria-label={resolvedLabels.print}
|
|
disabled={loading || !src}
|
|
onClick={handlePrint}
|
|
size="icon-sm"
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
<PrinterIcon className="size-4" />
|
|
</Button>
|
|
) : null}
|
|
|
|
{isRefreshing && src ? (
|
|
<div className="inline-flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Spinner className="size-4" />
|
|
<span>{resolvedLabels.refreshing}</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
}
|
|
className={cn("shadow-sm", className)}
|
|
contentClassName="space-y-3 px-2 sm:space-y-4 sm:px-4 md:space-y-5 md:px-6"
|
|
description={description}
|
|
headerClassName="px-2 sm:px-4 md:px-6"
|
|
icon={<EyeIcon className="size-5" />}
|
|
title={title}
|
|
>
|
|
{viewerContent}
|
|
</FormSectionCard>
|
|
);
|
|
}
|