Integrated PDF.js to enable text selection and copy-paste functionality in the PDF viewer. Updated `EnvelopeViewer.razor` to include the necessary scripts and styles, and modified the HTML structure to add a text layer container. Enhanced `envelope-viewer.css` with styles for the text layer and optimized canvas rendering. Added a `renderTextLayer` method in `pdf-viewer.js` to extract and render text content from PDF pages. Updated the rendering process to overlay the text layer on the canvas.
458 lines
15 KiB
JavaScript
458 lines
15 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 };
|
|
console.log('PDF Viewer quality options updated:', this.qualityOptions);
|
|
|
|
// 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) {
|
|
console.warn('Text layer div not found');
|
|
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`;
|
|
|
|
// 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) {
|
|
console.error('Error rendering text layer:', error);
|
|
}
|
|
},
|
|
|
|
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) {
|
|
console.error('PDF document not loaded for thumbnail:', pageNum);
|
|
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) {
|
|
console.error('Canvas not found after retries:', canvasId);
|
|
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) {
|
|
console.error('Error rendering thumbnail', pageNum, ':', error);
|
|
}
|
|
},
|
|
|
|
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;
|
|
}
|
|
};
|
|
|