mirror of https://github.com/buster-so/buster.git
update PDF export features
This commit is contained in:
parent
fb5abb8a75
commit
edabcac7f2
|
@ -26,7 +26,7 @@ export const MetricCardThreeMenuContainer = ({
|
|||
isOpen && 'pointer-events-auto block',
|
||||
className
|
||||
)}>
|
||||
<Dropdown items={dropdownItems} side="top" align="end" onOpenChange={setIsOpen}>
|
||||
<Dropdown items={dropdownItems} side="left" align="center" onOpenChange={setIsOpen}>
|
||||
{children}
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
|
|
@ -47,7 +47,9 @@ export const MetricElement = withHOC(
|
|||
className="rounded-md"
|
||||
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}>
|
||||
<div contentEditable={false}>{content}</div>
|
||||
|
@ -85,6 +87,7 @@ const MetricResizeContainer: React.FC<PropsWithChildren> = ({ children }) => {
|
|||
|
||||
return (
|
||||
<figure
|
||||
data-metric-figure
|
||||
onClick={selectNode}
|
||||
ref={ref}
|
||||
contentEditable={false}
|
||||
|
|
|
@ -80,21 +80,113 @@ export const useExportReport = () => {
|
|||
|
||||
const exportToPdf = async (editor: PlateEditor) => {
|
||||
try {
|
||||
const canvas = await getCanvas(editor);
|
||||
const PDFLib = await import('pdf-lib');
|
||||
const pdfDoc = await PDFLib.PDFDocument.create();
|
||||
const page = pdfDoc.addPage([canvas.width, canvas.height]);
|
||||
const imageEmbed = await pdfDoc.embedPng(canvas.toDataURL('PNG'));
|
||||
const { height, width } = imageEmbed.scale(1);
|
||||
page.drawImage(imageEmbed, {
|
||||
height,
|
||||
width,
|
||||
x: 0,
|
||||
y: 0
|
||||
});
|
||||
const pdfBase64 = await pdfDoc.saveAsBase64({ dataUri: true });
|
||||
const { default: html2canvas } = await import('html2canvas-pro');
|
||||
|
||||
const editorNode = editor.api.toDOMNode(editor);
|
||||
if (!editorNode) throw new Error('Editor not found');
|
||||
|
||||
// Clone the editor DOM to a sandbox so we can mutate it for printing
|
||||
const clonedRoot = editorNode.cloneNode(true) as HTMLElement;
|
||||
|
||||
// Sandbox: attach off-screen to compute sizes and rasterize metrics
|
||||
const sandbox = document.createElement('div');
|
||||
sandbox.setAttribute('data-report-print-sandbox', 'true');
|
||||
sandbox.style.position = 'fixed';
|
||||
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);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
|
|
@ -28,7 +28,7 @@ export const useDownloadPNGSelectMenu = ({
|
|||
() => ({
|
||||
label: 'Download as PNG',
|
||||
value: 'download-png',
|
||||
disabled: !canDownload,
|
||||
disabled: true,
|
||||
icon: <SquareChart />,
|
||||
onClick: async () => {
|
||||
const node = document.getElementById(METRIC_CHART_CONTAINER_ID(metricId)) as HTMLElement;
|
||||
|
|
|
@ -154,8 +154,9 @@ export const ThreeDotMenuButton = React.memo(
|
|||
]
|
||||
);
|
||||
|
||||
|
||||
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" />
|
||||
</Dropdown>
|
||||
);
|
||||
|
|
Loading…
Reference in New Issue