Files
linkedsign/frontend/src/components/ui/PdfSignaturePlacer.jsx
T

279 lines
9.5 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}