926 lines
31 KiB
JavaScript
926 lines
31 KiB
JavaScript
/**
|
||
* CyberCore Signer - PdfStampPicker
|
||
* Componente visual para pré-visualização de PDF, posicionamento interativo de selos
|
||
* e geração do payload de assinatura em tempo real.
|
||
*/
|
||
|
||
export class PdfStampPicker {
|
||
/**
|
||
* @param {Object} options Configurações do componente.
|
||
* @param {HTMLElement|string} options.container Elemento DOM ou seletor onde renderizar o picker.
|
||
* @param {File|Blob|ArrayBuffer|string} [options.pdf] Arquivo ou Base64 inicial do PDF.
|
||
* @param {Object} [options.sealTheme] Configurações visuais do selo a estampar.
|
||
* @param {number} [options.initialScale=1.0] Escala inicial do selo (ex: 1.0 = 100%).
|
||
* @param {Function} [options.onPositionChange] Callback chamado a cada alteração de página ou coordenadas: fn(info).
|
||
* @param {Function} [options.onSignRequest] Callback acionado ao clicar em "Assinar": fn(payload).
|
||
*/
|
||
constructor(options = {}) {
|
||
this.container = typeof options.container === 'string'
|
||
? document.querySelector(options.container)
|
||
: options.container;
|
||
|
||
if (!this.container) {
|
||
throw new Error('Container DOM obrigatório para instanciar PdfStampPicker.');
|
||
}
|
||
|
||
this.onPositionChange = options.onPositionChange || null;
|
||
this.onSignRequest = options.onSignRequest || null;
|
||
this.sealTheme = options.sealTheme || null;
|
||
|
||
// Estado interno
|
||
this.pdfDocument = null;
|
||
this.pdfBytes = null;
|
||
this.pdfBase64 = null;
|
||
this.documentName = 'documento.pdf';
|
||
this.totalPages = 1;
|
||
this.currentPageNumber = 1;
|
||
this.zoom = 1.0;
|
||
|
||
// Dimensões do PDF na página ativa (em pontos tipográficos 72 DPI)
|
||
this.pagePdfWidth = 595.28;
|
||
this.pagePdfHeight = 841.89;
|
||
|
||
// Estado do selo
|
||
this.sealScale = options.initialScale || 1.0;
|
||
this.baseSealWidth = 230; // pixels base no visualizador
|
||
this.baseSealHeight = 60;
|
||
this.sealPosX = null; // pixel relativo ao container da folha
|
||
this.sealPosY = null;
|
||
this.isDragging = false;
|
||
this.dragStartX = 0;
|
||
this.dragStartY = 0;
|
||
|
||
// Coordenadas finais em pontos do PDF
|
||
this.calculatedPdfX = 0;
|
||
this.calculatedPdfY = 0;
|
||
this.selectedPositionEnum = 'BOTTOM_RIGHT';
|
||
|
||
this._initDom();
|
||
this._attachEvents();
|
||
|
||
if (options.pdf) {
|
||
this.loadPdf(options.pdf);
|
||
}
|
||
}
|
||
|
||
_initDom() {
|
||
this.container.innerHTML = `
|
||
<div class="cyber-picker-root">
|
||
<!-- Barra Superior de Ferramentas -->
|
||
<div class="picker-toolbar">
|
||
<div class="toolbar-group">
|
||
<label class="btn-picker-upload">
|
||
<span>📄</span> Carregar PDF
|
||
<input type="file" accept=".pdf,application/pdf" class="picker-file-input" style="display:none;" />
|
||
</label>
|
||
<span class="picker-doc-name" title="Nenhum arquivo carregado">Nenhum PDF selecionado</span>
|
||
</div>
|
||
|
||
<div class="toolbar-group nav-group">
|
||
<button type="button" class="btn-picker-tool btn-first-page" title="Primeira Página">⏮</button>
|
||
<button type="button" class="btn-picker-tool btn-prev-page" title="Página Anterior">◀</button>
|
||
<span class="page-counter">Pág. <b class="current-page-num">1</b> de <span class="total-pages-num">1</span></span>
|
||
<button type="button" class="btn-picker-tool btn-next-page" title="Próxima Página">▶</button>
|
||
<button type="button" class="btn-picker-tool btn-last-page" title="Última Página">⏭</button>
|
||
</div>
|
||
|
||
<div class="toolbar-group zoom-group">
|
||
<button type="button" class="btn-picker-tool btn-zoom-out" title="Diminuir Zoom">-</button>
|
||
<span class="zoom-level-text">100%</span>
|
||
<button type="button" class="btn-picker-tool btn-zoom-in" title="Aumentar Zoom">+</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Área Central Dividida: Folha Interativa + Painel de Payload -->
|
||
<div class="picker-body">
|
||
<!-- Visor da Folha do PDF -->
|
||
<div class="picker-viewport">
|
||
<div class="picker-sheet-wrapper">
|
||
<div class="sheet-container">
|
||
<canvas class="pdf-canvas"></canvas>
|
||
<!-- Selo Visual Interativo (Drag & Drop) -->
|
||
<div class="stamp-overlay" style="display:none;">
|
||
<div class="stamp-content">
|
||
<div class="stamp-icon-box">
|
||
<svg viewBox="0 0 24 24" class="stamp-svg-icon" fill="currentColor">
|
||
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-1 6h2v2h-2V7zm0 4h2v6h-2v-6z"/>
|
||
</svg>
|
||
</div>
|
||
<div class="stamp-info-box">
|
||
<div class="stamp-badge-text">ASSINADO DIGITALMENTE • ICP-BRASIL</div>
|
||
<div class="stamp-signer-name">NOME DO TITULAR DO CERTIFICADO</div>
|
||
<div class="stamp-signer-meta">CPF: ***.456.789-** • VALIDADE JURÍDICA</div>
|
||
</div>
|
||
</div>
|
||
<div class="stamp-resize-handle" title="Arraste para redimensionar o selo"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="picker-empty-state">
|
||
<div class="empty-icon">📄</div>
|
||
<p>Nenhum PDF carregado</p>
|
||
<small>Arraste um PDF aqui ou clique em "Carregar PDF"</small>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Painel Lateral: Controles & Inspetor de Payload em Tempo Real -->
|
||
<div class="picker-sidebar">
|
||
<div class="sidebar-section">
|
||
<h3>📍 Posição do Selo</h3>
|
||
<div class="preset-buttons">
|
||
<button type="button" class="btn-preset active" data-pos="BOTTOM_RIGHT">Inferior Dir.</button>
|
||
<button type="button" class="btn-preset" data-pos="BOTTOM_LEFT">Inferior Esq.</button>
|
||
<button type="button" class="btn-preset" data-pos="TOP_RIGHT">Superior Dir.</button>
|
||
<button type="button" class="btn-preset" data-pos="TOP_LEFT">Superior Esq.</button>
|
||
</div>
|
||
<small class="hint-text">Dica: Você também pode clicar ou arrastar o selo livremente sobre a folha.</small>
|
||
</div>
|
||
|
||
<div class="sidebar-section">
|
||
<h3>📏 Tamanho do Selo</h3>
|
||
<div class="scale-slider-row">
|
||
<input type="range" class="seal-scale-range" min="0.4" max="1.8" step="0.05" value="1.0" />
|
||
<span class="scale-val-label">100%</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sidebar-section">
|
||
<div class="coords-grid">
|
||
<div class="coord-item">
|
||
<span class="coord-label">Página:</span>
|
||
<span class="coord-val val-page">1</span>
|
||
</div>
|
||
<div class="coord-item">
|
||
<span class="coord-label">Coordenada X:</span>
|
||
<span class="coord-val val-x">0.0 pt</span>
|
||
</div>
|
||
<div class="coord-item">
|
||
<span class="coord-label">Coordenada Y:</span>
|
||
<span class="coord-val val-y">0.0 pt</span>
|
||
</div>
|
||
<div class="coord-item">
|
||
<span class="coord-label">Escala:</span>
|
||
<span class="coord-val val-scale">1.0x</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bloco de Código com o Payload Formatado -->
|
||
<div class="sidebar-section flex-grow">
|
||
<div class="payload-header">
|
||
<h3>⚡ Payload da Requisição</h3>
|
||
<button type="button" class="btn-copy-payload" title="Copiar JSON">Copiar</button>
|
||
</div>
|
||
<pre class="payload-code-box"><code>{}</code></pre>
|
||
</div>
|
||
|
||
<div class="sidebar-footer">
|
||
<button type="button" class="btn-trigger-sign">
|
||
<span>✍️</span> Assinar com CyberCore
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
this._injectStyles();
|
||
|
||
// Cache de elementos do DOM
|
||
this.root = this.container.querySelector('.cyber-picker-root');
|
||
this.fileInput = this.root.querySelector('.picker-file-input');
|
||
this.docNameLabel = this.root.querySelector('.picker-doc-name');
|
||
this.viewport = this.root.querySelector('.picker-viewport');
|
||
this.sheetWrapper = this.root.querySelector('.picker-sheet-wrapper');
|
||
this.sheetContainer = this.root.querySelector('.sheet-container');
|
||
this.canvas = this.root.querySelector('.pdf-canvas');
|
||
this.stampOverlay = this.root.querySelector('.stamp-overlay');
|
||
this.emptyState = this.root.querySelector('.picker-empty-state');
|
||
this.currentPageLabel = this.root.querySelector('.current-page-num');
|
||
this.totalPagesLabel = this.root.querySelector('.total-pages-num');
|
||
this.zoomLabel = this.root.querySelector('.zoom-level-text');
|
||
this.scaleRange = this.root.querySelector('.seal-scale-range');
|
||
this.scaleValLabel = this.root.querySelector('.scale-val-label');
|
||
this.valPage = this.root.querySelector('.val-page');
|
||
this.valX = this.root.querySelector('.val-x');
|
||
this.valY = this.root.querySelector('.val-y');
|
||
this.valScale = this.root.querySelector('.val-scale');
|
||
this.payloadCode = this.root.querySelector('.payload-code-box code');
|
||
this.btnCopyPayload = this.root.querySelector('.btn-copy-payload');
|
||
this.btnTriggerSign = this.root.querySelector('.btn-trigger-sign');
|
||
}
|
||
|
||
_injectStyles() {
|
||
if (document.getElementById('cyber-picker-styles')) return;
|
||
const style = document.createElement('style');
|
||
style.id = 'cyber-picker-styles';
|
||
style.textContent = `
|
||
.cyber-picker-root {
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: #090d16;
|
||
border: 1px solid #24304f;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
color: #f8fafc;
|
||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4);
|
||
}
|
||
.picker-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 10px 16px;
|
||
background: #0f172a;
|
||
border-bottom: 1px solid #24304f;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
}
|
||
.toolbar-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.btn-picker-upload {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 14px;
|
||
background: #1e293b;
|
||
border: 1px solid #38bdf8;
|
||
border-radius: 6px;
|
||
color: #38bdf8;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
.btn-picker-upload:hover {
|
||
background: rgba(56, 189, 248, 0.15);
|
||
}
|
||
.picker-doc-name {
|
||
font-size: 13px;
|
||
color: #94a3b8;
|
||
max-width: 180px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.btn-picker-tool {
|
||
background: #18223c;
|
||
border: 1px solid #24304f;
|
||
color: #f8fafc;
|
||
padding: 5px 10px;
|
||
border-radius: 5px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
transition: all 0.2s;
|
||
}
|
||
.btn-picker-tool:hover:not(:disabled) {
|
||
background: #24304f;
|
||
color: #38bdf8;
|
||
}
|
||
.btn-picker-tool:disabled {
|
||
opacity: 0.4;
|
||
cursor: not-allowed;
|
||
}
|
||
.page-counter, .zoom-level-text {
|
||
font-size: 12px;
|
||
color: #94a3b8;
|
||
padding: 0 4px;
|
||
}
|
||
.picker-body {
|
||
display: flex;
|
||
min-height: 520px;
|
||
height: 600px;
|
||
}
|
||
.picker-viewport {
|
||
flex: 1;
|
||
background: #060911;
|
||
overflow: auto;
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 24px;
|
||
}
|
||
.picker-sheet-wrapper {
|
||
display: none;
|
||
box-shadow: 0 12px 35px rgba(0,0,0,0.65);
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
background: #ffffff;
|
||
}
|
||
.sheet-container {
|
||
position: relative;
|
||
line-height: 0;
|
||
cursor: crosshair;
|
||
user-select: none;
|
||
}
|
||
.pdf-canvas {
|
||
display: block;
|
||
max-width: none;
|
||
}
|
||
.stamp-overlay {
|
||
position: absolute;
|
||
width: 230px;
|
||
height: 60px;
|
||
border: 2px dashed #0284c7;
|
||
background: rgba(13, 71, 161, 0.08);
|
||
border-radius: 6px;
|
||
box-shadow: 0 4px 15px rgba(0,0,0,0.25);
|
||
cursor: grab;
|
||
box-sizing: border-box;
|
||
overflow: hidden;
|
||
z-index: 20;
|
||
transition: box-shadow 0.2s, border-color 0.2s;
|
||
}
|
||
.stamp-overlay:active {
|
||
cursor: grabbing;
|
||
border-color: #38bdf8;
|
||
box-shadow: 0 6px 20px rgba(56, 189, 248, 0.4);
|
||
}
|
||
.stamp-content {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 4px 8px;
|
||
box-sizing: border-box;
|
||
gap: 8px;
|
||
pointer-events: none;
|
||
}
|
||
.stamp-icon-box {
|
||
width: 34px;
|
||
height: 34px;
|
||
color: #0d47a1;
|
||
flex-shrink: 0;
|
||
}
|
||
.stamp-svg-icon {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
.stamp-info-box {
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
line-height: 1.15;
|
||
}
|
||
.stamp-badge-text {
|
||
font-size: 7.5px;
|
||
font-weight: 800;
|
||
color: #0d47a1;
|
||
letter-spacing: 0.3px;
|
||
}
|
||
.stamp-signer-name {
|
||
font-size: 10px;
|
||
font-weight: 800;
|
||
color: #0f172a;
|
||
margin: 1px 0;
|
||
}
|
||
.stamp-signer-meta {
|
||
font-size: 7.5px;
|
||
color: #475569;
|
||
}
|
||
.picker-empty-state {
|
||
text-align: center;
|
||
color: #64748b;
|
||
}
|
||
.picker-empty-state .empty-icon {
|
||
font-size: 48px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.picker-sidebar {
|
||
width: 340px;
|
||
background: #0f172a;
|
||
border-left: 1px solid #24304f;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 16px;
|
||
box-sizing: border-box;
|
||
gap: 16px;
|
||
overflow-y: auto;
|
||
}
|
||
.sidebar-section h3 {
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.6px;
|
||
color: #38bdf8;
|
||
margin-bottom: 8px;
|
||
}
|
||
.preset-buttons {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 6px;
|
||
}
|
||
.btn-preset {
|
||
background: #18223c;
|
||
border: 1px solid #24304f;
|
||
color: #cbd5e1;
|
||
font-size: 11px;
|
||
padding: 6px 8px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
.btn-preset:hover {
|
||
background: #24304f;
|
||
color: #f8fafc;
|
||
}
|
||
.btn-preset.active {
|
||
background: rgba(56, 189, 248, 0.2);
|
||
border-color: #38bdf8;
|
||
color: #38bdf8;
|
||
font-weight: 600;
|
||
}
|
||
.hint-text {
|
||
display: block;
|
||
font-size: 11px;
|
||
color: #64748b;
|
||
margin-top: 6px;
|
||
}
|
||
.scale-slider-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.seal-scale-range {
|
||
flex: 1;
|
||
accent-color: #38bdf8;
|
||
}
|
||
.scale-val-label {
|
||
font-size: 12px;
|
||
color: #38bdf8;
|
||
font-weight: 600;
|
||
min-width: 40px;
|
||
}
|
||
.coords-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 8px;
|
||
background: #131c31;
|
||
padding: 10px;
|
||
border-radius: 8px;
|
||
border: 1px solid #24304f;
|
||
}
|
||
.coord-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
.coord-label {
|
||
font-size: 10px;
|
||
color: #94a3b8;
|
||
text-transform: uppercase;
|
||
}
|
||
.coord-val {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: #f8fafc;
|
||
font-family: monospace;
|
||
}
|
||
.payload-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 6px;
|
||
}
|
||
.btn-copy-payload {
|
||
background: #1e293b;
|
||
border: 1px solid #24304f;
|
||
color: #cbd5e1;
|
||
font-size: 11px;
|
||
padding: 3px 8px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
.btn-copy-payload:hover {
|
||
background: #38bdf8;
|
||
color: #0f172a;
|
||
}
|
||
.payload-code-box {
|
||
background: #090d16;
|
||
border: 1px solid #1e293b;
|
||
border-radius: 6px;
|
||
padding: 10px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 11px;
|
||
color: #34d399;
|
||
overflow: auto;
|
||
max-height: 160px;
|
||
margin: 0;
|
||
}
|
||
.flex-grow {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.btn-trigger-sign {
|
||
width: 100%;
|
||
background: linear-gradient(135deg, #0284c7, #2563eb);
|
||
border: none;
|
||
color: white;
|
||
padding: 10px;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
transition: all 0.2s;
|
||
box-shadow: 0 4px 12px rgba(2, 132, 199, 0.35);
|
||
}
|
||
.btn-trigger-sign:hover {
|
||
background: linear-gradient(135deg, #0ea5e9, #3b82f6);
|
||
box-shadow: 0 6px 18px rgba(2, 132, 199, 0.5);
|
||
}
|
||
`;
|
||
document.head.appendChild(style);
|
||
}
|
||
|
||
_attachEvents() {
|
||
// Carregamento de arquivo
|
||
this.fileInput.addEventListener('change', e => {
|
||
if (e.target.files.length) {
|
||
this.loadPdf(e.target.files[0]);
|
||
}
|
||
});
|
||
|
||
// Drag and drop do arquivo PDF no viewport
|
||
this.viewport.addEventListener('dragover', e => {
|
||
e.preventDefault();
|
||
this.viewport.style.background = '#0d1527';
|
||
});
|
||
this.viewport.addEventListener('dragleave', () => {
|
||
this.viewport.style.background = '#060911';
|
||
});
|
||
this.viewport.addEventListener('drop', e => {
|
||
e.preventDefault();
|
||
this.viewport.style.background = '#060911';
|
||
if (e.dataTransfer.files.length) {
|
||
this.loadPdf(e.dataTransfer.files[0]);
|
||
}
|
||
});
|
||
|
||
// Navegação de páginas
|
||
this.root.querySelector('.btn-first-page').addEventListener('click', () => this.goToPage(1));
|
||
this.root.querySelector('.btn-prev-page').addEventListener('click', () => this.goToPage(this.currentPageNumber - 1));
|
||
this.root.querySelector('.btn-next-page').addEventListener('click', () => this.goToPage(this.currentPageNumber + 1));
|
||
this.root.querySelector('.btn-last-page').addEventListener('click', () => this.goToPage(this.totalPages));
|
||
|
||
// Zoom
|
||
this.root.querySelector('.btn-zoom-in').addEventListener('click', () => this.setZoom(this.zoom + 0.15));
|
||
this.root.querySelector('.btn-zoom-out').addEventListener('click', () => this.setZoom(this.zoom - 0.15));
|
||
|
||
// Escala do selo
|
||
this.scaleRange.addEventListener('input', e => {
|
||
this.setSealScale(parseFloat(e.target.value));
|
||
});
|
||
|
||
// Presets de posição
|
||
this.root.querySelectorAll('.btn-preset').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
this.root.querySelectorAll('.btn-preset').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
this.applyPositionPreset(btn.dataset.pos);
|
||
});
|
||
});
|
||
|
||
// Clique direto na folha para posicionar o selo
|
||
this.sheetContainer.addEventListener('click', e => {
|
||
if (this.isDragging) return;
|
||
if (e.target.closest('.stamp-overlay')) return;
|
||
const rect = this.sheetContainer.getBoundingClientRect();
|
||
const clickX = e.clientX - rect.left;
|
||
const clickY = e.clientY - rect.top;
|
||
const stampW = this.baseSealWidth * this.sealScale;
|
||
const stampH = this.baseSealHeight * this.sealScale;
|
||
this.moveStampTo(clickX - stampW / 2, clickY - stampH / 2);
|
||
});
|
||
|
||
// Arraste do selo (mouse)
|
||
this.stampOverlay.addEventListener('mousedown', e => {
|
||
e.preventDefault();
|
||
this.isDragging = true;
|
||
const rect = this.stampOverlay.getBoundingClientRect();
|
||
this.dragStartX = e.clientX - rect.left;
|
||
this.dragStartY = e.clientY - rect.top;
|
||
|
||
const onMouseMove = moveEvent => {
|
||
if (!this.isDragging) return;
|
||
const containerRect = this.sheetContainer.getBoundingClientRect();
|
||
const newX = moveEvent.clientX - containerRect.left - this.dragStartX;
|
||
const newY = moveEvent.clientY - containerRect.top - this.dragStartY;
|
||
this.moveStampTo(newX, newY);
|
||
};
|
||
|
||
const onMouseUp = () => {
|
||
this.isDragging = false;
|
||
window.removeEventListener('mousemove', onMouseMove);
|
||
window.removeEventListener('mouseup', onMouseUp);
|
||
};
|
||
|
||
window.addEventListener('mousemove', onMouseMove);
|
||
window.addEventListener('mouseup', onMouseUp);
|
||
});
|
||
|
||
// Copiar Payload
|
||
this.btnCopyPayload.addEventListener('click', () => {
|
||
const payload = this.getPayload();
|
||
navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
|
||
const oldText = this.btnCopyPayload.textContent;
|
||
this.btnCopyPayload.textContent = '✓ Copiado!';
|
||
this.btnCopyPayload.style.color = '#34d399';
|
||
setTimeout(() => {
|
||
this.btnCopyPayload.textContent = oldText;
|
||
this.btnCopyPayload.style.color = '';
|
||
}, 1500);
|
||
});
|
||
|
||
// Assinar
|
||
this.btnTriggerSign.addEventListener('click', () => {
|
||
if (this.onSignRequest) {
|
||
this.onSignRequest(this.getPayload());
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Carrega um documento PDF para visualização e cálculo interativo.
|
||
* @param {File|Blob|ArrayBuffer|string} source
|
||
*/
|
||
async loadPdf(source) {
|
||
try {
|
||
this.docNameLabel.textContent = 'Carregando documento...';
|
||
|
||
let arrayBuffer;
|
||
if (typeof source === 'string') {
|
||
// Base64
|
||
const clean = source.includes(',') ? source.split(',')[1] : source;
|
||
const binary = atob(clean);
|
||
const len = binary.length;
|
||
const bytes = new Uint8Array(len);
|
||
for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
|
||
arrayBuffer = bytes.buffer;
|
||
this.pdfBase64 = clean;
|
||
this.documentName = 'documento.pdf';
|
||
} else if (source instanceof ArrayBuffer) {
|
||
arrayBuffer = source;
|
||
this.pdfBase64 = this._bufferToBase64(source);
|
||
} else if (source instanceof Blob || (typeof File !== 'undefined' && source instanceof File)) {
|
||
this.documentName = source.name || 'documento.pdf';
|
||
arrayBuffer = await source.arrayBuffer();
|
||
this.pdfBase64 = this._bufferToBase64(arrayBuffer);
|
||
}
|
||
|
||
this.pdfBytes = new Uint8Array(arrayBuffer);
|
||
this.docNameLabel.textContent = this.documentName;
|
||
this.docNameLabel.title = this.documentName;
|
||
|
||
// Garante suporte ao PDF.js
|
||
await this._ensurePdfJsLoaded();
|
||
|
||
const loadingTask = window.pdfjsLib.getDocument({ data: this.pdfBytes });
|
||
this.pdfDocument = await loadingTask.promise;
|
||
this.totalPages = this.pdfDocument.numPages;
|
||
this.totalPagesLabel.textContent = this.totalPages;
|
||
|
||
this.emptyState.style.display = 'none';
|
||
this.sheetWrapper.style.display = 'block';
|
||
|
||
// Posiciona na última página por padrão (recomendação ICP-Brasil PAdES)
|
||
await this.goToPage(this.totalPages);
|
||
|
||
// Posiciona o selo no canto inferior direito inicial
|
||
this.applyPositionPreset('BOTTOM_RIGHT');
|
||
|
||
} catch (err) {
|
||
console.error('Erro ao renderizar PDF no picker:', err);
|
||
this.docNameLabel.textContent = 'Erro ao carregar PDF';
|
||
alert('Não foi possível ler o arquivo PDF: ' + err.message);
|
||
}
|
||
}
|
||
|
||
async _ensurePdfJsLoaded() {
|
||
if (window.pdfjsLib) return;
|
||
return new Promise((resolve, reject) => {
|
||
const script = document.createElement('script');
|
||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
|
||
script.onload = () => {
|
||
window.pdfjsLib.GlobalWorkerOptions.workerSrc =
|
||
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||
resolve();
|
||
};
|
||
script.onerror = () => reject(new Error('Falha ao carregar a biblioteca de renderização de PDF.'));
|
||
document.head.appendChild(script);
|
||
});
|
||
}
|
||
|
||
async goToPage(pageNum) {
|
||
if (!this.pdfDocument) return;
|
||
const pageTarget = Math.max(1, Math.min(pageNum, this.totalPages));
|
||
this.currentPageNumber = pageTarget;
|
||
this.currentPageLabel.textContent = pageTarget;
|
||
|
||
const page = await this.pdfDocument.getPage(pageTarget);
|
||
const unscaledViewport = page.getViewport({ scale: 1.0 });
|
||
this.pagePdfWidth = unscaledViewport.width;
|
||
this.pagePdfHeight = unscaledViewport.height;
|
||
|
||
// Renderiza a folha no Canvas com o zoom atual
|
||
const viewport = page.getViewport({ scale: this.zoom });
|
||
this.canvas.width = viewport.width;
|
||
this.canvas.height = viewport.height;
|
||
this.sheetContainer.style.width = `${viewport.width}px`;
|
||
this.sheetContainer.style.height = `${viewport.height}px`;
|
||
|
||
const ctx = this.canvas.getContext('2d');
|
||
await page.render({ canvasContext: ctx, viewport: viewport }).promise;
|
||
|
||
this.stampOverlay.style.display = 'block';
|
||
|
||
// Recalcula limites se já houver posição definida
|
||
if (this.sealPosX !== null && this.sealPosY !== null) {
|
||
this.moveStampTo(this.sealPosX, this.sealPosY);
|
||
} else {
|
||
this.applyPositionPreset('BOTTOM_RIGHT');
|
||
}
|
||
}
|
||
|
||
setZoom(newZoom) {
|
||
const clamped = Math.max(0.5, Math.min(newZoom, 2.0));
|
||
this.zoom = Math.round(clamped * 100) / 100;
|
||
this.zoomLabel.textContent = `${Math.round(this.zoom * 100)}%`;
|
||
if (this.pdfDocument) {
|
||
this.goToPage(this.currentPageNumber);
|
||
}
|
||
}
|
||
|
||
setSealScale(scale) {
|
||
this.sealScale = scale;
|
||
this.scaleValLabel.textContent = `${Math.round(scale * 100)}%`;
|
||
this.valScale.textContent = `${scale.toFixed(2)}x`;
|
||
|
||
const stampW = this.baseSealWidth * this.sealScale;
|
||
const stampH = this.baseSealHeight * this.sealScale;
|
||
this.stampOverlay.style.width = `${stampW}px`;
|
||
this.stampOverlay.style.height = `${stampH}px`;
|
||
|
||
if (this.sealPosX !== null && this.sealPosY !== null) {
|
||
this.moveStampTo(this.sealPosX, this.sealPosY);
|
||
}
|
||
}
|
||
|
||
applyPositionPreset(preset) {
|
||
this.selectedPositionEnum = preset;
|
||
const sheetW = this.canvas.width;
|
||
const sheetH = this.canvas.height;
|
||
const stampW = this.baseSealWidth * this.sealScale;
|
||
const stampH = this.baseSealHeight * this.sealScale;
|
||
const margin = 20 * this.zoom;
|
||
|
||
let targetX = 0;
|
||
let targetY = 0;
|
||
|
||
switch (preset) {
|
||
case 'BOTTOM_LEFT':
|
||
targetX = margin;
|
||
targetY = sheetH - stampH - margin;
|
||
break;
|
||
case 'TOP_RIGHT':
|
||
targetX = sheetW - stampW - margin;
|
||
targetY = margin;
|
||
break;
|
||
case 'TOP_LEFT':
|
||
targetX = margin;
|
||
targetY = margin;
|
||
break;
|
||
case 'BOTTOM_RIGHT':
|
||
default:
|
||
targetX = sheetW - stampW - margin;
|
||
targetY = sheetH - stampH - margin;
|
||
break;
|
||
}
|
||
|
||
this.moveStampTo(targetX, targetY, preset);
|
||
}
|
||
|
||
moveStampTo(x, y, presetName = 'CUSTOM') {
|
||
const sheetW = this.canvas.width;
|
||
const sheetH = this.canvas.height;
|
||
const stampW = this.baseSealWidth * this.sealScale;
|
||
const stampH = this.baseSealHeight * this.sealScale;
|
||
|
||
// Clamping para não ultrapassar as bordas da folha
|
||
const clampedX = Math.max(0, Math.min(x, sheetW - stampW));
|
||
const clampedY = Math.max(0, Math.min(y, sheetH - stampH));
|
||
|
||
this.sealPosX = clampedX;
|
||
this.sealPosY = clampedY;
|
||
this.stampOverlay.style.left = `${clampedX}px`;
|
||
this.stampOverlay.style.top = `${clampedY}px`;
|
||
|
||
if (presetName !== 'CUSTOM') {
|
||
this.selectedPositionEnum = presetName;
|
||
} else {
|
||
this.selectedPositionEnum = 'CUSTOM';
|
||
this.root.querySelectorAll('.btn-preset').forEach(b => b.classList.remove('active'));
|
||
}
|
||
|
||
// =========================================================================
|
||
// Conversão Matemática para Pontos do PDF (Origem no Rodapé da Página)
|
||
// =========================================================================
|
||
const scaleRatioX = this.pagePdfWidth / sheetW;
|
||
const scaleRatioY = this.pagePdfHeight / sheetH;
|
||
|
||
this.calculatedPdfX = Math.round((clampedX * scaleRatioX) * 10) / 10;
|
||
// No PDF ISO 32000, o eixo Y = 0 é o canto inferior esquerdo:
|
||
this.calculatedPdfY = Math.round(((sheetH - clampedY - stampH) * scaleRatioY) * 10) / 10;
|
||
|
||
this._updateUiFeedback();
|
||
}
|
||
|
||
_updateUiFeedback() {
|
||
this.valPage.textContent = this.currentPageNumber;
|
||
this.valX.textContent = `${this.calculatedPdfX.toFixed(1)} pt`;
|
||
this.valY.textContent = `${this.calculatedPdfY.toFixed(1)} pt`;
|
||
this.valScale.textContent = `${this.sealScale.toFixed(2)}x`;
|
||
|
||
const payload = this.getPayload();
|
||
this.payloadCode.textContent = JSON.stringify(payload, null, 2);
|
||
|
||
if (this.onPositionChange) {
|
||
this.onPositionChange({
|
||
page: this.currentPageNumber,
|
||
x: this.calculatedPdfX,
|
||
y: this.calculatedPdfY,
|
||
scale: this.sealScale,
|
||
position: this.selectedPositionEnum,
|
||
pagePdfWidth: this.pagePdfWidth,
|
||
pagePdfHeight: this.pagePdfHeight,
|
||
payload: payload
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Retorna o payload estruturado pronto para a requisição POST /ccsp/v1/sign-pdf.
|
||
*/
|
||
getPayload() {
|
||
return {
|
||
version: '1',
|
||
operation: 'sign_pdf',
|
||
document_name: this.documentName,
|
||
page: this.currentPageNumber,
|
||
position: this.selectedPositionEnum,
|
||
x: this.calculatedPdfX,
|
||
y: this.calculatedPdfY,
|
||
scale: Number(this.sealScale.toFixed(2)),
|
||
visible_seal: true,
|
||
seal_theme_id: this.sealTheme?.id || 'icp-brasil',
|
||
seal_custom: this.sealTheme || null,
|
||
reason: 'Assinatura Digital ICP-Brasil',
|
||
location: 'Brasil',
|
||
pdf: this.pdfBase64 ? `${this.pdfBase64.substring(0, 32)}...[${this.pdfBytes?.length || 0} bytes]` : null
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Retorna os dados completos com o Base64 real para a execução de assinatura.
|
||
*/
|
||
getFullPayload(certificateId) {
|
||
return {
|
||
version: '1',
|
||
operation: 'sign_pdf',
|
||
certificate_hint: certificateId,
|
||
document_name: this.documentName,
|
||
page: this.currentPageNumber,
|
||
position: this.selectedPositionEnum,
|
||
x: this.calculatedPdfX,
|
||
y: this.calculatedPdfY,
|
||
scale: Number(this.sealScale.toFixed(2)),
|
||
visible_seal: true,
|
||
seal_theme_id: this.sealTheme?.id || 'icp-brasil',
|
||
seal_custom: this.sealTheme || null,
|
||
reason: 'Assinatura Digital ICP-Brasil',
|
||
location: 'Brasil',
|
||
pdf: this.pdfBase64
|
||
};
|
||
}
|
||
|
||
_bufferToBase64(buffer) {
|
||
const bytes = new Uint8Array(buffer);
|
||
let binary = '';
|
||
const len = bytes.byteLength;
|
||
for (let i = 0; i < len; i++) {
|
||
binary += String.fromCharCode(bytes[i]);
|
||
}
|
||
return btoa(binary);
|
||
}
|
||
}
|
||
|
||
export default PdfStampPicker;
|