FactuGES_ERP/packages/rdx-ui/src/components/pdf-viewer.tsx

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