Introduced functionality to render interactive signature buttons on the PDF viewer. Added support for fetching and displaying signature data (`SignatureDto`) dynamically based on the current page. - Added `@using` directives in `EnvelopeViewer.razor` for required namespaces. - Introduced `_signatures` field to store signature data. - Updated `OnInitializedAsync` to fetch and process signatures. - Implemented `RenderSignatureButtonsAsync` to dynamically render buttons. - Added `[JSInvokable]` method `OnSignatureButtonClick` for button events. - Updated CSS to style `pdf-signature-layer` and `signature-button`. - Enhanced `pdf-viewer.js` with methods to render and clear buttons. - Ensured buttons respond to zoom and page navigation changes. - Added error handling and logging for signature rendering. These changes improve user interaction by enabling signing functionality directly on the PDF viewer.
601 lines
21 KiB
JavaScript
601 lines
21 KiB
JavaScript
// PDF.js Viewer for Blazor WASM
|
|
window.pdfViewer = {
|
|
pdfDoc: null,
|
|
pageNum: 1,
|
|
pageRendering: false,
|
|
pageNumPending: null,
|
|
scale: 1.5,
|
|
canvas: null,
|
|
ctx: null,
|
|
totalPages: 0,
|
|
currentRenderTask: null,
|
|
dotNetReference: null,
|
|
wheelEventAttached: false,
|
|
|
|
// Quality options (configurable from appsettings.json)
|
|
qualityOptions: {
|
|
thumbnailBaseScale: 0.75,
|
|
thumbnailEnableHiDPI: true,
|
|
thumbnailMaxDPR: 2.0,
|
|
mainCanvasEnableHiDPI: true,
|
|
mainCanvasMaxDPR: 2.0,
|
|
enableSmoothZoom: true,
|
|
zoomTransitionDuration: 150,
|
|
renderingOpacity: 0.85,
|
|
zoomStepPercentage: 5
|
|
},
|
|
|
|
setQualityOptions(options) {
|
|
this.qualityOptions = { ...this.qualityOptions, ...options };
|
|
|
|
// Apply CSS variables for dynamic styling
|
|
document.documentElement.style.setProperty('--zoom-transition-duration', `${options.zoomTransitionDuration}ms`);
|
|
document.documentElement.style.setProperty('--rendering-opacity', options.renderingOpacity);
|
|
},
|
|
|
|
async initialize(canvasId, pdfDataUrl, dotNetRef) {
|
|
try {
|
|
this.dotNetReference = dotNetRef;
|
|
|
|
if (typeof window.pdfjsLib === 'undefined') {
|
|
await this.waitForPdfJs();
|
|
}
|
|
|
|
const pdfjsLib = window.pdfjsLib;
|
|
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
|
|
|
this.canvas = document.getElementById(canvasId);
|
|
if (!this.canvas) {
|
|
return false;
|
|
}
|
|
|
|
this.ctx = this.canvas.getContext('2d');
|
|
this.attachWheelEvent();
|
|
|
|
const uint8Array = this.base64ToUint8Array(pdfDataUrl);
|
|
const loadingTask = pdfjsLib.getDocument({ data: uint8Array });
|
|
this.pdfDoc = await loadingTask.promise;
|
|
this.totalPages = this.pdfDoc.numPages;
|
|
|
|
await this.renderPage(this.pageNum);
|
|
return true;
|
|
} catch (error) {
|
|
console.error('PDF viewer initialization failed:', error);
|
|
return false;
|
|
}
|
|
},
|
|
|
|
attachWheelEvent() {
|
|
if (this.wheelEventAttached) return;
|
|
|
|
// Attach to the entire document body for global zoom control
|
|
document.body.addEventListener('wheel', (e) => {
|
|
// Check if Ctrl key is pressed (zoom gesture)
|
|
if (e.ctrlKey || e.metaKey) {
|
|
e.preventDefault();
|
|
|
|
const step = this.qualityOptions.zoomStepPercentage / 100; // Convert to decimal
|
|
|
|
if (e.deltaY < 0) {
|
|
// Scroll up = Zoom In
|
|
this.scale = Math.min(this.scale + step, 3.0);
|
|
this.queueRenderPage(this.pageNum);
|
|
if (this.dotNetReference) {
|
|
this.dotNetReference.invokeMethodAsync('OnZoomChanged', this.scale);
|
|
}
|
|
} else {
|
|
// Scroll down = Zoom Out
|
|
this.scale = Math.max(this.scale - step, 0.5);
|
|
this.queueRenderPage(this.pageNum);
|
|
if (this.dotNetReference) {
|
|
this.dotNetReference.invokeMethodAsync('OnZoomChanged', this.scale);
|
|
}
|
|
}
|
|
}
|
|
}, { passive: false });
|
|
|
|
this.wheelEventAttached = true;
|
|
},
|
|
|
|
async waitForPdfJs() {
|
|
for (let i = 0; i < 50; i++) {
|
|
if (typeof window.pdfjsLib !== 'undefined') {
|
|
return;
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
}
|
|
throw new Error('PDF.js failed to load after 5 seconds');
|
|
},
|
|
|
|
base64ToUint8Array(base64) {
|
|
// Remove data URL prefix if present
|
|
const base64String = base64.includes(',') ? base64.split(',')[1] : base64;
|
|
const raw = atob(base64String);
|
|
const uint8Array = new Uint8Array(raw.length);
|
|
for (let i = 0; i < raw.length; i++) {
|
|
uint8Array[i] = raw.charCodeAt(i);
|
|
}
|
|
return uint8Array;
|
|
},
|
|
|
|
async renderPage(num) {
|
|
this.pageRendering = true;
|
|
|
|
// Add rendering class for smooth transition (if enabled)
|
|
if (this.qualityOptions.enableSmoothZoom) {
|
|
this.canvas.classList.add('rendering');
|
|
}
|
|
|
|
try {
|
|
// Get scroll container
|
|
const container = this.canvas.closest('.pdf-canvas-wrapper');
|
|
|
|
// Store scroll position and viewport center BEFORE rendering
|
|
let scrollLeft = 0, scrollTop = 0;
|
|
let centerX = 0, centerY = 0;
|
|
let oldWidth = this.canvas.width;
|
|
let oldHeight = this.canvas.height;
|
|
|
|
if (container) {
|
|
scrollLeft = container.scrollLeft;
|
|
scrollTop = container.scrollTop;
|
|
centerX = scrollLeft + container.clientWidth / 2;
|
|
centerY = scrollTop + container.clientHeight / 2;
|
|
}
|
|
|
|
const page = await this.pdfDoc.getPage(num);
|
|
|
|
// HiDPI support for main canvas (configurable)
|
|
const dpr = this.qualityOptions.mainCanvasEnableHiDPI
|
|
? Math.min(window.devicePixelRatio || 1, this.qualityOptions.mainCanvasMaxDPR)
|
|
: 1.0;
|
|
const viewport = page.getViewport({ scale: this.scale * dpr });
|
|
|
|
// Set internal canvas resolution (high quality)
|
|
this.canvas.width = viewport.width;
|
|
this.canvas.height = viewport.height;
|
|
|
|
// Set CSS display size (visual size)
|
|
this.canvas.style.width = `${viewport.width / dpr}px`;
|
|
this.canvas.style.height = `${viewport.height / dpr}px`;
|
|
|
|
const renderContext = {
|
|
canvasContext: this.ctx,
|
|
viewport: viewport
|
|
};
|
|
|
|
if (this.currentRenderTask) {
|
|
this.currentRenderTask.cancel();
|
|
}
|
|
|
|
// Enable high-quality rendering
|
|
this.ctx.imageSmoothingEnabled = true;
|
|
this.ctx.imageSmoothingQuality = 'high';
|
|
|
|
this.currentRenderTask = page.render(renderContext);
|
|
await this.currentRenderTask.promise;
|
|
|
|
// Render text layer for copy-paste functionality
|
|
await this.renderTextLayer(page, viewport, dpr);
|
|
|
|
// Restore viewport center position AFTER rendering
|
|
if (container && oldWidth > 0 && oldHeight > 0) {
|
|
const scaleX = this.canvas.width / oldWidth;
|
|
const scaleY = this.canvas.height / oldHeight;
|
|
|
|
const newCenterX = centerX * scaleX;
|
|
const newCenterY = centerY * scaleY;
|
|
|
|
container.scrollLeft = newCenterX - container.clientWidth / 2;
|
|
container.scrollTop = newCenterY - container.clientHeight / 2;
|
|
}
|
|
|
|
// Remove rendering class after completion
|
|
if (this.qualityOptions.enableSmoothZoom) {
|
|
this.canvas.classList.remove('rendering');
|
|
}
|
|
|
|
this.currentRenderTask = null;
|
|
this.pageRendering = false;
|
|
|
|
if (this.pageNumPending !== null) {
|
|
this.renderPage(this.pageNumPending);
|
|
this.pageNumPending = null;
|
|
}
|
|
} catch (error) {
|
|
if (error.name !== 'RenderingCancelledException') {
|
|
console.error('Render error:', error);
|
|
}
|
|
this.canvas.classList.remove('rendering');
|
|
this.currentRenderTask = null;
|
|
this.pageRendering = false;
|
|
}
|
|
},
|
|
|
|
async renderTextLayer(page, viewport, dpr) {
|
|
try {
|
|
const textLayerDiv = document.getElementById('pdf-text-layer');
|
|
if (!textLayerDiv) {
|
|
return;
|
|
}
|
|
|
|
// Clear previous text layer
|
|
textLayerDiv.innerHTML = '';
|
|
|
|
// Set text layer dimensions to match canvas display size
|
|
textLayerDiv.style.width = `${viewport.width / dpr}px`;
|
|
textLayerDiv.style.height = `${viewport.height / dpr}px`;
|
|
|
|
// Set --scale-factor CSS variable required by PDF.js
|
|
textLayerDiv.style.setProperty('--scale-factor', this.scale);
|
|
|
|
// Get text content from PDF
|
|
const textContent = await page.getTextContent();
|
|
|
|
// Create viewport for text layer (without DPR scaling for positioning)
|
|
const textViewport = page.getViewport({ scale: this.scale });
|
|
|
|
// Render text layer using PDF.js built-in function
|
|
const pdfjsLib = window.pdfjsLib;
|
|
await pdfjsLib.renderTextLayer({
|
|
textContentSource: textContent,
|
|
container: textLayerDiv,
|
|
viewport: textViewport,
|
|
textDivs: []
|
|
}).promise;
|
|
|
|
} catch (error) {
|
|
// Silently handle text layer errors (non-critical feature)
|
|
}
|
|
},
|
|
|
|
queueRenderPage(num) {
|
|
if (this.pageRendering) {
|
|
this.pageNumPending = num;
|
|
} else {
|
|
this.renderPage(num);
|
|
}
|
|
},
|
|
|
|
nextPage() {
|
|
if (this.pageNum >= this.totalPages) {
|
|
return false;
|
|
}
|
|
this.pageNum++;
|
|
this.queueRenderPage(this.pageNum);
|
|
return true;
|
|
},
|
|
|
|
previousPage() {
|
|
if (this.pageNum <= 1) {
|
|
return false;
|
|
}
|
|
this.pageNum--;
|
|
this.queueRenderPage(this.pageNum);
|
|
return true;
|
|
},
|
|
|
|
goToPage(num) {
|
|
if (num < 1 || num > this.totalPages) {
|
|
return false;
|
|
}
|
|
this.pageNum = num;
|
|
this.queueRenderPage(this.pageNum);
|
|
return true;
|
|
},
|
|
|
|
zoomIn() {
|
|
const step = this.qualityOptions.zoomStepPercentage / 100;
|
|
this.scale = Math.min(this.scale + step, 3.0);
|
|
this.queueRenderPage(this.pageNum);
|
|
},
|
|
|
|
zoomOut() {
|
|
const step = this.qualityOptions.zoomStepPercentage / 100;
|
|
this.scale = Math.max(this.scale - step, 0.5);
|
|
this.queueRenderPage(this.pageNum);
|
|
},
|
|
|
|
setScale(scale) {
|
|
if (scale >= 0.5 && scale <= 3.0) {
|
|
this.scale = scale;
|
|
this.queueRenderPage(this.pageNum);
|
|
}
|
|
},
|
|
|
|
async fitToWidth() {
|
|
const container = this.canvas.closest('.pdf-frame');
|
|
if (!container || !this.pdfDoc) return;
|
|
|
|
try {
|
|
const page = await this.pdfDoc.getPage(this.pageNum);
|
|
const viewport = page.getViewport({ scale: 1.0 });
|
|
|
|
const containerWidth = container.clientWidth - 80;
|
|
const optimalScale = containerWidth / viewport.width;
|
|
|
|
this.scale = Math.min(Math.max(optimalScale, 0.5), 3.0);
|
|
this.queueRenderPage(this.pageNum);
|
|
} catch (error) {
|
|
console.error('Error fitting to width:', error);
|
|
}
|
|
},
|
|
|
|
async renderThumbnail(pageNum, canvasId) {
|
|
if (!this.pdfDoc) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Wait for canvas to be in DOM
|
|
let canvas = document.getElementById(canvasId);
|
|
let retries = 0;
|
|
while (!canvas && retries < 10) {
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
canvas = document.getElementById(canvasId);
|
|
retries++;
|
|
}
|
|
|
|
if (!canvas) {
|
|
return;
|
|
}
|
|
|
|
// Check if canvas is already being used by a render task
|
|
if (canvas._renderTask) {
|
|
try {
|
|
await canvas._renderTask;
|
|
} catch (e) {
|
|
// Ignore cancellation errors
|
|
}
|
|
}
|
|
|
|
const page = await this.pdfDoc.getPage(pageNum);
|
|
|
|
// High-quality rendering with HiDPI support (configurable)
|
|
const dpr = this.qualityOptions.thumbnailEnableHiDPI
|
|
? Math.min(window.devicePixelRatio || 1, this.qualityOptions.thumbnailMaxDPR)
|
|
: 1.0;
|
|
const baseScale = this.qualityOptions.thumbnailBaseScale;
|
|
const scale = baseScale * dpr;
|
|
|
|
const viewport = page.getViewport({ scale: scale });
|
|
|
|
// Set actual canvas pixel dimensions (internal resolution)
|
|
canvas.width = viewport.width;
|
|
canvas.height = viewport.height;
|
|
|
|
// Remove any inline styles - let CSS handle display size
|
|
canvas.style.width = '';
|
|
canvas.style.height = '';
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// Enable maximum quality rendering
|
|
ctx.imageSmoothingEnabled = true;
|
|
ctx.imageSmoothingQuality = 'high';
|
|
|
|
const renderContext = {
|
|
canvasContext: ctx,
|
|
viewport: viewport
|
|
};
|
|
|
|
// Store render task on canvas to track active renders
|
|
const renderTask = page.render(renderContext);
|
|
canvas._renderTask = renderTask.promise;
|
|
|
|
await renderTask.promise;
|
|
|
|
// Clear the task when done
|
|
delete canvas._renderTask;
|
|
} catch (error) {
|
|
// Silently handle thumbnail errors (non-critical)
|
|
}
|
|
},
|
|
|
|
getCurrentPage() {
|
|
return this.pageNum;
|
|
},
|
|
|
|
getTotalPages() {
|
|
return this.totalPages;
|
|
},
|
|
|
|
getScale() {
|
|
return this.scale;
|
|
},
|
|
|
|
dispose() {
|
|
if (this.wheelEventAttached) {
|
|
this.wheelEventAttached = false;
|
|
this.dotNetReference = null;
|
|
}
|
|
this.detachResizeListeners();
|
|
},
|
|
|
|
// Resizable splitter functionality
|
|
isResizing: false,
|
|
resizeMouseMoveHandler: null,
|
|
resizeMouseUpHandler: null,
|
|
|
|
attachResizeListeners(dotNetRef) {
|
|
this.dotNetReference = dotNetRef;
|
|
|
|
this.resizeMouseMoveHandler = (e) => {
|
|
if (this.isResizing && this.dotNetReference) {
|
|
this.dotNetReference.invokeMethodAsync('OnSplitterMouseMove', e.clientX);
|
|
}
|
|
};
|
|
|
|
this.resizeMouseUpHandler = () => {
|
|
if (this.isResizing && this.dotNetReference) {
|
|
this.isResizing = false;
|
|
this.dotNetReference.invokeMethodAsync('OnSplitterMouseUp');
|
|
}
|
|
};
|
|
|
|
document.addEventListener('mousemove', this.resizeMouseMoveHandler);
|
|
document.addEventListener('mouseup', this.resizeMouseUpHandler);
|
|
},
|
|
|
|
detachResizeListeners() {
|
|
if (this.resizeMouseMoveHandler) {
|
|
document.removeEventListener('mousemove', this.resizeMouseMoveHandler);
|
|
this.resizeMouseMoveHandler = null;
|
|
}
|
|
if (this.resizeMouseUpHandler) {
|
|
document.removeEventListener('mouseup', this.resizeMouseUpHandler);
|
|
this.resizeMouseUpHandler = null;
|
|
}
|
|
this.isResizing = false;
|
|
},
|
|
|
|
startResize() {
|
|
this.isResizing = true;
|
|
},
|
|
|
|
// Signature button functionality
|
|
signatureButtons: [],
|
|
|
|
/**
|
|
* Renders clickable signature buttons on the PDF canvas.
|
|
* @param {Array} signatures - Array of SignatureDto objects with x, y coordinates in PDF POINTS
|
|
* @param {number} currentPageNum - Current page number (1-based)
|
|
* @param {object} dotNetRef - .NET reference for callbacks
|
|
*/
|
|
async renderSignatureButtons(signatures, currentPageNum, dotNetRef) {
|
|
// Clear existing buttons
|
|
this.clearSignatureButtons();
|
|
|
|
if (!this.pdfDoc || !signatures || signatures.length === 0) {
|
|
return;
|
|
}
|
|
|
|
this.dotNetReference = dotNetRef;
|
|
|
|
try {
|
|
// Filter signatures for current page
|
|
const pageSignatures = signatures.filter(sig => sig.page === currentPageNum);
|
|
|
|
if (pageSignatures.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Get current page and viewport
|
|
const page = await this.pdfDoc.getPage(currentPageNum);
|
|
const dpr = this.qualityOptions.mainCanvasEnableHiDPI
|
|
? Math.min(window.devicePixelRatio || 1, this.qualityOptions.mainCanvasMaxDPR)
|
|
: 1.0;
|
|
const viewport = page.getViewport({ scale: this.scale * dpr });
|
|
|
|
// Get signature layer container
|
|
const signatureLayer = document.getElementById('pdf-signature-layer');
|
|
if (!signatureLayer) {
|
|
console.warn('Signature layer not found');
|
|
return;
|
|
}
|
|
|
|
// Set signature layer dimensions to match canvas display size
|
|
signatureLayer.style.width = `${viewport.width / dpr}px`;
|
|
signatureLayer.style.height = `${viewport.height / dpr}px`;
|
|
|
|
// Create button for each signature
|
|
pageSignatures.forEach(sig => {
|
|
// Coordinates are in PDF POINTS - convert to display pixels
|
|
const xPx = (sig.x * this.scale);
|
|
const yPx = (sig.y * this.scale);
|
|
|
|
// Create button element
|
|
const button = document.createElement('button');
|
|
button.className = 'signature-button';
|
|
button.setAttribute('data-signature-id', sig.id);
|
|
button.setAttribute('type', 'button');
|
|
button.setAttribute('tabindex', '0');
|
|
button.style.position = 'absolute';
|
|
button.style.left = `${xPx}px`;
|
|
button.style.top = `${yPx}px`;
|
|
button.style.width = '150px';
|
|
button.style.height = '60px';
|
|
button.style.backgroundColor = '#4F46E5';
|
|
button.style.color = 'white';
|
|
button.style.border = 'none';
|
|
button.style.borderRadius = '8px';
|
|
button.style.cursor = 'pointer';
|
|
button.style.fontSize = '16px';
|
|
button.style.fontWeight = '600';
|
|
button.style.display = 'flex';
|
|
button.style.flexDirection = 'column';
|
|
button.style.alignItems = 'center';
|
|
button.style.justifyContent = 'center';
|
|
button.style.gap = '4px';
|
|
button.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)';
|
|
button.style.transition = 'all 0.2s ease';
|
|
button.style.zIndex = '100';
|
|
|
|
// Add text
|
|
const textDiv = document.createElement('div');
|
|
textDiv.textContent = 'Sign';
|
|
textDiv.style.fontSize = '18px';
|
|
textDiv.style.fontWeight = '700';
|
|
|
|
// Add SVG icon
|
|
const svgNS = 'http://www.w3.org/2000/svg';
|
|
const svg = document.createElementNS(svgNS, 'svg');
|
|
svg.setAttribute('width', '24');
|
|
svg.setAttribute('height', '24');
|
|
svg.setAttribute('viewBox', '0 8 32 36');
|
|
svg.setAttribute('fill', 'none');
|
|
svg.style.filter = 'drop-shadow(0 1px 2px rgba(0,0,0,0.2))';
|
|
|
|
const path = document.createElementNS(svgNS, 'path');
|
|
path.setAttribute('fill-rule', 'evenodd');
|
|
path.setAttribute('clip-rule', 'evenodd');
|
|
path.setAttribute('d', 'M25.061 6.90625L23.7115 8.25503C23.2861 8.05188 22.8241 7.9503 22.3621 7.9503C21.5605 7.9503 20.7589 8.25613 20.1483 8.86778L8.18147 20.8336L6.70565 26.7379H6.70557V27.7817H26.5372V26.7379H6.70671L12.6102 25.2623L24.576 13.2955C25.5404 12.3318 25.7445 10.8952 25.1882 9.73146L26.5369 8.38214L25.061 6.90625ZM23.174 10.27C22.9569 10.0539 22.6688 9.93388 22.362 9.93388C22.0551 9.93388 21.767 10.0539 21.5499 10.27L13.5323 18.2876L15.1564 19.9117L23.174 11.8941C23.6218 11.4463 23.6218 10.7177 23.174 10.27ZM14.4922 20.5759L12.868 18.9518L9.97241 21.8475L9.43069 24.0133L11.5965 23.4716L14.4922 20.5759Z');
|
|
path.setAttribute('fill', 'white');
|
|
|
|
svg.appendChild(path);
|
|
button.appendChild(textDiv);
|
|
button.appendChild(svg);
|
|
|
|
// Add hover effect
|
|
button.addEventListener('mouseenter', () => {
|
|
button.style.backgroundColor = '#4338CA';
|
|
button.style.transform = 'scale(1.05)';
|
|
button.style.boxShadow = '0 4px 12px rgba(79, 70, 229, 0.4)';
|
|
});
|
|
button.addEventListener('mouseleave', () => {
|
|
button.style.backgroundColor = '#4F46E5';
|
|
button.style.transform = 'scale(1)';
|
|
button.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)';
|
|
});
|
|
|
|
// Add click handler
|
|
button.addEventListener('click', () => {
|
|
if (this.dotNetReference) {
|
|
this.dotNetReference.invokeMethodAsync('OnSignatureButtonClick', sig.id);
|
|
}
|
|
});
|
|
|
|
signatureLayer.appendChild(button);
|
|
this.signatureButtons.push(button);
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Error rendering signature buttons:', error);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Clears all signature buttons from the canvas.
|
|
*/
|
|
clearSignatureButtons() {
|
|
this.signatureButtons.forEach(button => {
|
|
if (button.parentNode) {
|
|
button.parentNode.removeChild(button);
|
|
}
|
|
});
|
|
this.signatureButtons = [];
|
|
}
|
|
};
|
|
|
|
|