update PDF export features

This commit is contained in:
Nate Kelley 2025-08-08 11:31:46 -06:00
parent fb5abb8a75
commit edabcac7f2
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
5 changed files with 114 additions and 18 deletions

View File

@ -26,7 +26,7 @@ export const MetricCardThreeMenuContainer = ({
isOpen && 'pointer-events-auto block', isOpen && 'pointer-events-auto block',
className className
)}> )}>
<Dropdown items={dropdownItems} side="top" align="end" onOpenChange={setIsOpen}> <Dropdown items={dropdownItems} side="left" align="center" onOpenChange={setIsOpen}>
{children} {children}
</Dropdown> </Dropdown>
</div> </div>

View File

@ -47,7 +47,9 @@ export const MetricElement = withHOC(
className="rounded-md" className="rounded-md"
attributes={{ attributes={{
...attributes, ...attributes,
'data-plate-open-context-menu': true 'data-plate-open-context-menu': true,
// Mark metric element for export so we can target it for rasterization
'data-export-metric': true
}} }}
{...props}> {...props}>
<div contentEditable={false}>{content}</div> <div contentEditable={false}>{content}</div>
@ -85,6 +87,7 @@ const MetricResizeContainer: React.FC<PropsWithChildren> = ({ children }) => {
return ( return (
<figure <figure
data-metric-figure
onClick={selectNode} onClick={selectNode}
ref={ref} ref={ref}
contentEditable={false} contentEditable={false}

View File

@ -80,21 +80,113 @@ export const useExportReport = () => {
const exportToPdf = async (editor: PlateEditor) => { const exportToPdf = async (editor: PlateEditor) => {
try { try {
const canvas = await getCanvas(editor); const { default: html2canvas } = await import('html2canvas-pro');
const PDFLib = await import('pdf-lib');
const pdfDoc = await PDFLib.PDFDocument.create(); const editorNode = editor.api.toDOMNode(editor);
const page = pdfDoc.addPage([canvas.width, canvas.height]); if (!editorNode) throw new Error('Editor not found');
const imageEmbed = await pdfDoc.embedPng(canvas.toDataURL('PNG'));
const { height, width } = imageEmbed.scale(1); // Clone the editor DOM to a sandbox so we can mutate it for printing
page.drawImage(imageEmbed, { const clonedRoot = editorNode.cloneNode(true) as HTMLElement;
height,
width, // Sandbox: attach off-screen to compute sizes and rasterize metrics
x: 0, const sandbox = document.createElement('div');
y: 0 sandbox.setAttribute('data-report-print-sandbox', 'true');
}); sandbox.style.position = 'fixed';
const pdfBase64 = await pdfDoc.saveAsBase64({ dataUri: true }); sandbox.style.left = '-10000px';
sandbox.style.top = '0';
sandbox.style.width = '850px';
sandbox.style.pointerEvents = 'none';
sandbox.appendChild(clonedRoot);
document.body.appendChild(sandbox);
// Ensure consistent fonts in the clone
clonedRoot.style.fontFamily =
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
// Replace metric figures with rasterized images so only charts become images in PDF
const figureNodes = Array.from(
clonedRoot.querySelectorAll<HTMLElement>('figure[data-metric-figure]')
);
await Promise.all(
figureNodes.map(async (figure) => {
// Render the figure to a canvas, then replace with an image element
const canvas = await html2canvas(figure, {
backgroundColor: '#ffffff',
useCORS: true
});
const image = document.createElement('img');
image.src = canvas.toDataURL('image/png');
image.style.width = `${canvas.width}px`;
image.style.height = 'auto';
image.setAttribute('data-exported-metric-image', 'true');
figure.replaceWith(image);
})
);
// Build printable HTML document
const printWindow = window.open('', '_blank');
if (!printWindow) throw new Error('Unable to open print window');
const headStyles = Array.from(document.querySelectorAll('link[rel="stylesheet"], style'))
.map((n) => n.outerHTML)
.join('\n');
const printHtml = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
${headStyles}
<style>
@page { size: A4; margin: 16mm; }
html, body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
.report-print-root { width: 850px; margin: 0 auto; }
/* Hide interactive cursors/placeholders if any slipped in */
[contenteditable="true"] { outline: none; }
</style>
</head>
<body>
<div class="report-print-root">${clonedRoot.outerHTML}</div>
</body>
</html>`;
printWindow.document.open();
printWindow.document.write(printHtml);
printWindow.document.close();
// Wait for images to load in the print window before printing
await new Promise<void>((resolve) => {
const imgs = Array.from(printWindow.document.images);
if (imgs.length === 0) return resolve();
let loaded = 0;
imgs.forEach((img) => {
if (img.complete) {
loaded++;
if (loaded === imgs.length) resolve();
} else {
img.onload = () => {
loaded++;
if (loaded === imgs.length) resolve();
};
img.onerror = () => {
loaded++;
if (loaded === imgs.length) resolve();
};
}
});
});
// Cleanup sandbox
sandbox.remove();
// Print. User can select "Save as PDF" to get a true text PDF with charts as images.
printWindow.focus();
printWindow.print();
await downloadFile(pdfBase64, 'plate.pdf');
openInfoMessage(NodeTypeLabels.pdfExportedSuccessfully.label); openInfoMessage(NodeTypeLabels.pdfExportedSuccessfully.label);
} catch (error) { } catch (error) {
console.error(error); console.error(error);

View File

@ -28,7 +28,7 @@ export const useDownloadPNGSelectMenu = ({
() => ({ () => ({
label: 'Download as PNG', label: 'Download as PNG',
value: 'download-png', value: 'download-png',
disabled: !canDownload, disabled: true,
icon: <SquareChart />, icon: <SquareChart />,
onClick: async () => { onClick: async () => {
const node = document.getElementById(METRIC_CHART_CONTAINER_ID(metricId)) as HTMLElement; const node = document.getElementById(METRIC_CHART_CONTAINER_ID(metricId)) as HTMLElement;

View File

@ -154,8 +154,9 @@ export const ThreeDotMenuButton = React.memo(
] ]
); );
return ( return (
<Dropdown items={items} side="bottom" align="end" contentClassName="max-h-fit" modal> <Dropdown items={items} side="left" align="end" contentClassName="max-h-fit" modal>
<Button prefix={<Dots />} variant="ghost" data-testid="three-dot-menu-button" /> <Button prefix={<Dots />} variant="ghost" data-testid="three-dot-menu-button" />
</Dropdown> </Dropdown>
); );