279 lines
9.5 KiB
React
279 lines
9.5 KiB
React
import { useEffect, useRef, useState } from 'react';
|
||
import { Document, Page, pdfjs } from 'react-pdf';
|
||
import 'react-pdf/dist/Page/AnnotationLayer.css';
|
||
import 'react-pdf/dist/Page/TextLayer.css';
|
||
import { detectClientSignatureContext } from '../../utils/clientSignatureContext';
|
||
|
||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||
import.meta.url,
|
||
).toString();
|
||
|
||
const SIG_W = 200;
|
||
const SIG_H = 60;
|
||
|
||
const getSignatureWidth = (style, signerName = '') => {
|
||
if (style !== 'qr' && style !== 'firmaec') return SIG_W;
|
||
const normalizedName = signerName.trim();
|
||
const calculatedWidth = 78 + (normalizedName.length * 4.9);
|
||
return Math.max(170, Math.min(270, Math.round(calculatedWidth)));
|
||
};
|
||
|
||
export default function PdfSignaturePlacer({
|
||
pdfBlob,
|
||
signerName,
|
||
signatureStyle = 'classic',
|
||
qrPosition = 'left',
|
||
qrBackground = 'transparent',
|
||
qrBorder = 'none',
|
||
signatureImage = null,
|
||
onPlaced,
|
||
}) {
|
||
const clientSignatureContext = detectClientSignatureContext();
|
||
const [numPages, setNumPages] = useState(null);
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [sigBox, setSigBox] = useState(null); // { x, y } in container px
|
||
const stageRef = useRef(null);
|
||
const containerRef = useRef(null);
|
||
const [pageSize, setPageSize] = useState(null); // rendered px size
|
||
const [pageRenderWidth, setPageRenderWidth] = useState(600);
|
||
const signatureWidth = getSignatureWidth(signatureStyle, signerName);
|
||
|
||
useEffect(() => {
|
||
const stage = stageRef.current;
|
||
if (!stage) return undefined;
|
||
|
||
const updateWidth = () => {
|
||
const nextWidth = Math.max(280, Math.min(600, Math.floor(stage.clientWidth || 600)));
|
||
setPageRenderWidth(nextWidth);
|
||
};
|
||
|
||
updateWidth();
|
||
|
||
if (typeof ResizeObserver === 'undefined') {
|
||
window.addEventListener('resize', updateWidth);
|
||
return () => window.removeEventListener('resize', updateWidth);
|
||
}
|
||
|
||
const observer = new ResizeObserver(() => updateWidth());
|
||
observer.observe(stage);
|
||
return () => observer.disconnect();
|
||
}, []);
|
||
|
||
const getCanvasMetrics = () => {
|
||
const container = containerRef.current;
|
||
const canvas = container?.querySelector('.react-pdf__Page__canvas');
|
||
if (!container || !canvas) return null;
|
||
|
||
const containerRect = container.getBoundingClientRect();
|
||
const canvasRect = canvas.getBoundingClientRect();
|
||
|
||
return {
|
||
containerRect,
|
||
canvasRect,
|
||
offsetX: canvasRect.left - containerRect.left,
|
||
offsetY: canvasRect.top - containerRect.top,
|
||
width: canvasRect.width,
|
||
height: canvasRect.height,
|
||
};
|
||
};
|
||
|
||
const handleDocLoad = ({ numPages }) => setNumPages(numPages);
|
||
|
||
const handlePageLoad = (page) => {
|
||
setPageSize({ width: page.width, height: page.height });
|
||
};
|
||
|
||
const handlePointerDown = (event) => {
|
||
const metrics = getCanvasMetrics();
|
||
if (!metrics) return;
|
||
|
||
const x = event.clientX - metrics.canvasRect.left;
|
||
const y = event.clientY - metrics.canvasRect.top;
|
||
const clampedX = Math.min(Math.max(x, 0), metrics.width - signatureWidth);
|
||
const clampedY = Math.min(Math.max(y, 0), metrics.height - SIG_H);
|
||
|
||
setSigBox({
|
||
x: metrics.offsetX + clampedX,
|
||
y: metrics.offsetY + clampedY,
|
||
});
|
||
};
|
||
|
||
const handleConfirm = () => {
|
||
if (!sigBox || !pageSize) return;
|
||
const metrics = getCanvasMetrics();
|
||
if (!metrics) return;
|
||
|
||
const relativeX = sigBox.x - metrics.offsetX;
|
||
const relativeY = sigBox.y - metrics.offsetY;
|
||
// PDF coordinates: origin bottom-left, Y inverted
|
||
const scaleX = pageSize.pdfWidth / metrics.width;
|
||
const scaleY = pageSize.pdfHeight / metrics.height;
|
||
const pdfX = relativeX * scaleX;
|
||
const pdfY = pageSize.pdfHeight - (relativeY + SIG_H) * scaleY;
|
||
onPlaced({
|
||
page: currentPage - 1,
|
||
rect: {
|
||
x: Math.round(pdfX),
|
||
y: Math.round(pdfY),
|
||
width: Math.round(signatureWidth * scaleX),
|
||
height: Math.round(SIG_H * scaleY),
|
||
},
|
||
});
|
||
};
|
||
|
||
const previewDate = new Date().toLocaleDateString(clientSignatureContext.locale, {
|
||
timeZone: clientSignatureContext.timeZone,
|
||
});
|
||
|
||
const renderPreview = () => {
|
||
if (signatureStyle === 'qr') {
|
||
return (
|
||
<div className="sig-preview-body sig-preview-body--qr">
|
||
{qrPosition === 'left' && (
|
||
<div className="sig-preview-qr-box" data-background={qrBackground} data-border={qrBorder}>
|
||
<div className="sig-preview-qr-pattern" />
|
||
<span>QR</span>
|
||
</div>
|
||
)}
|
||
<div className="sig-preview-text">
|
||
<strong>{signerName}</strong>
|
||
<small>Verificación digital</small>
|
||
</div>
|
||
{qrPosition === 'right' && (
|
||
<div className="sig-preview-qr-box" data-background={qrBackground} data-border={qrBorder}>
|
||
<div className="sig-preview-qr-pattern" />
|
||
<span>QR</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (signatureStyle === 'professional') {
|
||
return (
|
||
<div className="sig-preview-body sig-preview-body--professional">
|
||
<div className="sig-preview-pro-header">FIRMA DIGITAL</div>
|
||
<div className="sig-preview-pro-content">
|
||
<strong>{signerName}</strong>
|
||
<small>{previewDate}</small>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (signatureStyle === 'firmaec') {
|
||
return (
|
||
<div className="sig-preview-body sig-preview-body--firmaec">
|
||
{qrPosition === 'left' && (
|
||
<>
|
||
<div className="sig-preview-qr-box sig-preview-qr-box--firmaec" data-background={qrBackground}>
|
||
<div className="sig-preview-qr-pattern sig-preview-qr-pattern--firmaec" />
|
||
</div>
|
||
{qrBorder === 'line' && <div className="sig-preview-divider" />}
|
||
</>
|
||
)}
|
||
<div className="sig-preview-text sig-preview-text--firmaec">
|
||
<small>Validar unicamente en FirmaEC.</small>
|
||
<small>Firmado electronicamente por:</small>
|
||
<strong>{String(signerName).toUpperCase()}</strong>
|
||
</div>
|
||
{qrPosition === 'right' && (
|
||
<>
|
||
{qrBorder === 'line' && <div className="sig-preview-divider" />}
|
||
<div className="sig-preview-qr-box sig-preview-qr-box--firmaec" data-background={qrBackground}>
|
||
<div className="sig-preview-qr-pattern sig-preview-qr-pattern--firmaec" />
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (signatureStyle === 'image') {
|
||
return (
|
||
<div className="sig-preview-body sig-preview-body--image">
|
||
<div className="sig-preview-image-area">
|
||
{signatureImage
|
||
? <img src={signatureImage} alt="firma" className="sig-preview-image" />
|
||
: <span className="sig-preview-image-placeholder">Firma</span>}
|
||
</div>
|
||
<div className="sig-preview-text">
|
||
<strong>{signerName}</strong>
|
||
<small>Firma digital · {previewDate}</small>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="sig-preview-body">
|
||
<span className="sig-preview-badge">DS</span>
|
||
<div className="sig-preview-text">
|
||
<strong>{signerName}</strong>
|
||
<small>Firma digital · {previewDate}</small>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div className="pdf-placer">
|
||
<div className="pdf-placer-toolbar">
|
||
<span className="pdf-placer-hint">
|
||
Haz clic en el documento para colocar la firma
|
||
</span>
|
||
<div className="pdf-placer-pages">
|
||
<button className="btn btn-ghost" disabled={currentPage <= 1}
|
||
onClick={() => { setCurrentPage(p => p - 1); setSigBox(null); }}>‹</button>
|
||
<span>Pág. {currentPage} / {numPages || '…'}</span>
|
||
<button className="btn btn-ghost" disabled={currentPage >= numPages}
|
||
onClick={() => { setCurrentPage(p => p + 1); setSigBox(null); }}>›</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div ref={stageRef} className="pdf-placer-stage">
|
||
<div
|
||
ref={containerRef}
|
||
className="pdf-placer-canvas"
|
||
onPointerDown={handlePointerDown}
|
||
style={{ cursor: 'crosshair', position: 'relative', display: 'inline-block' }}
|
||
>
|
||
<Document file={pdfBlob} onLoadSuccess={handleDocLoad}>
|
||
<Page
|
||
pageNumber={currentPage}
|
||
width={pageRenderWidth}
|
||
onLoadSuccess={(p) => setPageSize({
|
||
width: p.width, height: p.height,
|
||
pdfWidth: p.originalWidth, pdfHeight: p.originalHeight,
|
||
})}
|
||
/>
|
||
</Document>
|
||
|
||
{sigBox && (
|
||
<div
|
||
className={`sig-preview-box sig-preview-box--${signatureStyle}`}
|
||
data-background={signatureStyle === 'qr' ? qrBackground : undefined}
|
||
data-border={signatureStyle === 'qr' ? qrBorder : undefined}
|
||
style={{ left: sigBox.x, top: sigBox.y, width: signatureWidth, height: SIG_H }}
|
||
onPointerDown={(e) => e.stopPropagation()}
|
||
>
|
||
{signatureStyle === 'classic' && <div className="sig-preview-bar" />}
|
||
{renderPreview()}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{sigBox && (
|
||
<div className="pdf-placer-actions">
|
||
<button className="btn btn-ghost" onClick={() => setSigBox(null)}>Limpiar</button>
|
||
<button className="btn btn-primary" onClick={handleConfirm}>
|
||
Confirmar posición
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|