Enabled PDF printing in `SenderPdfImageEditor` and `SenderPdfImageEditorToolbar`. Added `CanPrintPdf` property to determine print availability and a new `OnPrintPdfAsync` method for handling print logic via JavaScript interop. Updated the toolbar with a "Print" button that triggers the print functionality. Implemented the `printDataUrlPdf` function in `envelope-editor.js` to handle printing from a data URL. This function creates an iframe, loads the PDF, and invokes the browser's print functionality with proper cleanup of resources.
325 lines
10 KiB
JavaScript
325 lines
10 KiB
JavaScript
window.envelopeEditor = {
|
|
|
|
_moveThrottle: {
|
|
rafId: 0,
|
|
latest: null,
|
|
waiters: []
|
|
},
|
|
|
|
/**
|
|
* Returns the click position normalised to [0,1] relative to the rendered PDF page
|
|
* element inside DxPdfViewer (or DxReportViewer as fallback).
|
|
*
|
|
* Normalising means the result is independent of zoom level: no matter how much the
|
|
* user has zoomed in/out, the same physical spot on the PDF will always yield the same
|
|
* normalised value. C# multiplies by the page's point dimensions to get PDF points.
|
|
*
|
|
* @param {string} viewerCssClass - CssClass set on DxPdfViewer (e.g. "sender-editor-pdf-viewer")
|
|
* @param {number} clientX - MouseEvent.clientX from Blazor
|
|
* @param {number} clientY - MouseEvent.clientY from Blazor
|
|
* @returns {{ normX, normY, pageIndex } | null}
|
|
* normX / normY : 0..1 fraction within the page element
|
|
* pageIndex : 0-based index of the page the click landed on (-1 if not found)
|
|
*/
|
|
getClickCoordsOnPdfPage: function (viewerCssClass, clientX, clientY) {
|
|
|
|
// Find the viewer root element
|
|
const viewer = document.querySelector('.' + viewerCssClass);
|
|
if (!viewer) {
|
|
console.warn('[envelopeEditor] viewer not found for class:', viewerCssClass);
|
|
return null;
|
|
}
|
|
|
|
// --- Candidate page elements (ordered by preference) ---
|
|
// Sender editor custom renderer uses data-editor-page blocks.
|
|
// Legacy Dx viewers remain as fallback selectors.
|
|
const pageSelectors = [
|
|
'[data-editor-page]',
|
|
'.dxbl-pdfv-page',
|
|
'.dxbrv-report-preview-page',
|
|
'.dxbrv-report-preview-content-img',
|
|
];
|
|
|
|
let allPages = [];
|
|
for (const sel of pageSelectors) {
|
|
const found = Array.from(viewer.querySelectorAll(sel));
|
|
if (found.length > 0) {
|
|
allPages = found;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (allPages.length === 0) {
|
|
console.warn('[envelopeEditor] no page elements found inside viewer');
|
|
return null;
|
|
}
|
|
|
|
// --- Find which page the click landed on ---
|
|
// Walk through all pages; pick the one whose bounding rect contains the click.
|
|
// If none contains it exactly, fall back to the page closest vertically.
|
|
let targetPage = null;
|
|
let targetIndex = -1;
|
|
let minDist = Infinity;
|
|
|
|
for (let i = 0; i < allPages.length; i++) {
|
|
const rect = allPages[i].getBoundingClientRect();
|
|
|
|
// Exact hit
|
|
if (clientX >= rect.left && clientX <= rect.right &&
|
|
clientY >= rect.top && clientY <= rect.bottom) {
|
|
targetPage = allPages[i];
|
|
targetIndex = i;
|
|
break;
|
|
}
|
|
|
|
// Track closest page (vertical centre distance) as fallback
|
|
const cy = rect.top + rect.height / 2;
|
|
const dist = Math.abs(clientY - cy);
|
|
if (dist < minDist) {
|
|
minDist = dist;
|
|
targetPage = allPages[i];
|
|
targetIndex = i;
|
|
}
|
|
}
|
|
|
|
if (!targetPage) return null;
|
|
|
|
const pageRect = targetPage.getBoundingClientRect();
|
|
|
|
// Clamp click inside page boundaries before normalising
|
|
const clampedX = Math.max(pageRect.left, Math.min(clientX, pageRect.right));
|
|
const clampedY = Math.max(pageRect.top, Math.min(clientY, pageRect.bottom));
|
|
|
|
const normX = (clampedX - pageRect.left) / pageRect.width;
|
|
const normY = (clampedY - pageRect.top) / pageRect.height;
|
|
|
|
return {
|
|
normX: normX,
|
|
normY: normY,
|
|
pageIndex: targetIndex,
|
|
pageNumber: parseInt(targetPage.getAttribute('data-editor-page') || String(targetIndex + 1), 10)
|
|
};
|
|
},
|
|
|
|
getClickCoordsOnPdfPageThrottled: function (viewerCssClass, clientX, clientY) {
|
|
return new Promise((resolve) => {
|
|
const state = window.envelopeEditor._moveThrottle;
|
|
state.latest = { viewerCssClass, clientX, clientY };
|
|
state.waiters.push(resolve);
|
|
|
|
if (state.rafId !== 0) {
|
|
return;
|
|
}
|
|
|
|
state.rafId = window.requestAnimationFrame(() => {
|
|
state.rafId = 0;
|
|
|
|
const args = state.latest;
|
|
state.latest = null;
|
|
|
|
const result = args
|
|
? window.envelopeEditor.getClickCoordsOnPdfPage(args.viewerCssClass, args.clientX, args.clientY)
|
|
: null;
|
|
|
|
const waiters = state.waiters.splice(0, state.waiters.length);
|
|
for (const waiter of waiters) {
|
|
waiter(result);
|
|
}
|
|
});
|
|
});
|
|
},
|
|
|
|
scrollToPdfPage: function (viewerCssClass, pageNumber) {
|
|
const viewer = document.querySelector('.' + viewerCssClass);
|
|
if (!viewer) {
|
|
return;
|
|
}
|
|
|
|
const targetPageNumber = Math.max(1, Number(pageNumber) || 1);
|
|
const page = viewer.querySelector('[data-editor-page="' + targetPageNumber + '"]');
|
|
if (!page) {
|
|
return;
|
|
}
|
|
|
|
const viewerRect = viewer.getBoundingClientRect();
|
|
const pageRect = page.getBoundingClientRect();
|
|
const targetScrollTop = viewer.scrollTop + (pageRect.top - viewerRect.top) - 8;
|
|
|
|
viewer.scrollTo({
|
|
top: Math.max(0, targetScrollTop),
|
|
behavior: 'smooth'
|
|
});
|
|
},
|
|
|
|
getVisiblePdfPageNumber: function (viewerCssClass) {
|
|
const viewer = document.querySelector('.' + viewerCssClass);
|
|
if (!viewer) {
|
|
return null;
|
|
}
|
|
|
|
const pages = Array.from(viewer.querySelectorAll('[data-editor-page]'));
|
|
if (pages.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const viewerRect = viewer.getBoundingClientRect();
|
|
let bestPage = null;
|
|
let bestVisibleHeight = -1;
|
|
|
|
for (const page of pages) {
|
|
const rect = page.getBoundingClientRect();
|
|
const visibleTop = Math.max(rect.top, viewerRect.top);
|
|
const visibleBottom = Math.min(rect.bottom, viewerRect.bottom);
|
|
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
|
|
|
|
if (visibleHeight > bestVisibleHeight) {
|
|
bestVisibleHeight = visibleHeight;
|
|
bestPage = page;
|
|
}
|
|
}
|
|
|
|
if (!bestPage) {
|
|
return null;
|
|
}
|
|
|
|
const raw = bestPage.getAttribute('data-editor-page');
|
|
const parsed = Number.parseInt(raw || '0', 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
},
|
|
|
|
scrollPdfViewerByDelta: function (viewerCssClass, deltaY) {
|
|
const viewer = document.querySelector('.' + viewerCssClass);
|
|
if (!viewer) {
|
|
return;
|
|
}
|
|
|
|
viewer.scrollTop += Number(deltaY) || 0;
|
|
},
|
|
|
|
ensureZoomOverride: function (viewerCssClass) {
|
|
const viewer = document.querySelector('.' + viewerCssClass);
|
|
if (!viewer) {
|
|
return;
|
|
}
|
|
|
|
if (viewer.dataset.zoomOverrideAttached === '1') {
|
|
return;
|
|
}
|
|
|
|
viewer.dataset.zoomOverrideAttached = '1';
|
|
|
|
viewer.addEventListener('wheel', function (event) {
|
|
event.preventDefault();
|
|
}, { passive: false });
|
|
|
|
viewer.addEventListener('keydown', function (event) {
|
|
if (!event.ctrlKey) {
|
|
return;
|
|
}
|
|
|
|
const key = event.key;
|
|
if (key === '+' || key === '=' || key === '-' || key === '0') {
|
|
event.preventDefault();
|
|
}
|
|
});
|
|
|
|
viewer.addEventListener('touchmove', function () {
|
|
}, { passive: true });
|
|
},
|
|
|
|
downloadDataUrlAsFile: function (dataUrl, fileName, mimeType) {
|
|
if (!dataUrl || typeof dataUrl !== 'string') {
|
|
return;
|
|
}
|
|
|
|
const downloadName = fileName || 'document.pdf';
|
|
|
|
const link = document.createElement('a');
|
|
link.href = dataUrl;
|
|
link.download = downloadName;
|
|
link.rel = 'noopener';
|
|
|
|
const host = document.body || document.documentElement;
|
|
host.appendChild(link);
|
|
link.click();
|
|
host.removeChild(link);
|
|
},
|
|
|
|
printDataUrlPdf: async function (dataUrl, mimeType) {
|
|
if (!dataUrl || typeof dataUrl !== 'string') {
|
|
return;
|
|
}
|
|
|
|
let objectUrl = null;
|
|
let printSrc = dataUrl;
|
|
|
|
if (dataUrl.startsWith('data:')) {
|
|
try {
|
|
const response = await fetch(dataUrl);
|
|
const blob = await response.blob();
|
|
objectUrl = URL.createObjectURL(blob);
|
|
printSrc = objectUrl;
|
|
} catch {
|
|
printSrc = dataUrl;
|
|
}
|
|
}
|
|
|
|
const frame = document.createElement('iframe');
|
|
frame.style.position = 'fixed';
|
|
frame.style.right = '0';
|
|
frame.style.bottom = '0';
|
|
frame.style.width = '0';
|
|
frame.style.height = '0';
|
|
frame.style.border = '0';
|
|
frame.setAttribute('aria-hidden', 'true');
|
|
|
|
const host = document.body || document.documentElement;
|
|
host.appendChild(frame);
|
|
|
|
let cleaned = false;
|
|
const cleanup = function () {
|
|
if (cleaned) {
|
|
return;
|
|
}
|
|
|
|
cleaned = true;
|
|
|
|
if (frame.parentNode) {
|
|
frame.parentNode.removeChild(frame);
|
|
}
|
|
|
|
if (objectUrl) {
|
|
URL.revokeObjectURL(objectUrl);
|
|
objectUrl = null;
|
|
}
|
|
};
|
|
|
|
frame.onload = function () {
|
|
try {
|
|
const frameWindow = frame.contentWindow;
|
|
if (!frameWindow) {
|
|
cleanup();
|
|
return;
|
|
}
|
|
|
|
frameWindow.addEventListener('afterprint', function () {
|
|
window.setTimeout(cleanup, 250);
|
|
}, { once: true });
|
|
|
|
window.addEventListener('focus', function () {
|
|
window.setTimeout(cleanup, 1500);
|
|
}, { once: true });
|
|
|
|
frameWindow.focus();
|
|
frameWindow.print();
|
|
} catch {
|
|
cleanup();
|
|
}
|
|
};
|
|
|
|
frame.src = printSrc;
|
|
|
|
window.setTimeout(cleanup, 120000);
|
|
}
|
|
};
|