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',
|
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>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
Loading…
Reference in New Issue