mirror of https://github.com/buster-so/buster.git
Implement advanced HTML export with canvas snapshot and inline styles
Co-authored-by: natemkelley <natemkelley@gmail.com>
This commit is contained in:
parent
6aeda29592
commit
70bcabd5c4
|
@ -1,9 +0,0 @@
|
|||
import { PAGE_CONTROLLER_ID } from '@/controllers/ReportPageControllers/ReportPageController';
|
||||
import type { PlateEditor } from 'platejs/react';
|
||||
import { ReportPageController } from '@/controllers/ReportPageControllers/ReportPageController';
|
||||
|
||||
export const useBuildExportHtml2 = () => {
|
||||
return async ({ reportId }: { reportId: string }) => {
|
||||
return '';
|
||||
};
|
||||
};
|
|
@ -0,0 +1,196 @@
|
|||
'use client';
|
||||
|
||||
import { PAGE_CONTROLLER_ID, ReportPageController } from '@/controllers/ReportPageControllers/ReportPageController';
|
||||
import { BusterReactQueryProvider } from '@/context/BusterReactQuery/BusterReactQueryAndApi';
|
||||
import { SupabaseContextProvider, useSupabaseContext } from '@/context/Supabase/SupabaseContextProvider';
|
||||
import type { UseSupabaseUserContextType } from '@/lib/supabase';
|
||||
import { printHTMLPage } from './printHTMLPage';
|
||||
import { useMemoizedFn } from '@/hooks/useMemoizedFn';
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
export const useBuildExportHtml2 = ({ reportId: defaultReportId }: { reportId?: string } = {}) => {
|
||||
const user = useSupabaseContext((s) => s.user);
|
||||
const accessToken = useSupabaseContext((s) => s.accessToken);
|
||||
|
||||
const build = useMemoizedFn(
|
||||
async ({
|
||||
reportId = defaultReportId,
|
||||
filename = 'Buster Report',
|
||||
triggerPrint = false
|
||||
}: {
|
||||
reportId?: string;
|
||||
filename?: string;
|
||||
triggerPrint?: boolean;
|
||||
} = {}) => {
|
||||
if (!reportId) throw new Error('reportId is required');
|
||||
|
||||
// 1) Create a hidden, offscreen container
|
||||
const container = document.createElement('div');
|
||||
container.setAttribute('data-report-export-container', reportId);
|
||||
Object.assign(container.style, {
|
||||
position: 'fixed',
|
||||
left: '-10000px',
|
||||
top: '0',
|
||||
width: '850px',
|
||||
maxWidth: '850px',
|
||||
pointerEvents: 'none',
|
||||
opacity: '0',
|
||||
visibility: 'hidden'
|
||||
} as CSSStyleDeclaration);
|
||||
document.body.appendChild(container);
|
||||
|
||||
// 2) Mount the controller wrapped with minimal providers so hooks work
|
||||
const supabaseContext = { user, accessToken } as UseSupabaseUserContextType;
|
||||
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<SupabaseContextProvider supabaseContext={supabaseContext}>
|
||||
<BusterReactQueryProvider>
|
||||
<ReportPageController reportId={reportId} readOnly className="px-0!" />
|
||||
</BusterReactQueryProvider>
|
||||
</SupabaseContextProvider>
|
||||
);
|
||||
|
||||
try {
|
||||
// 3) Wait for the editor DOM to be ready in the hidden mount
|
||||
const controllerSelector = `#${CSS.escape(PAGE_CONTROLLER_ID(reportId))}`;
|
||||
const editorRoot = await waitForElement(
|
||||
() => container.querySelector(`${controllerSelector} [contenteditable="true"]`) as HTMLElement | null,
|
||||
{ timeoutMs: 10000 }
|
||||
);
|
||||
|
||||
// Small settle time for charts/canvases/fonts
|
||||
await delay(150);
|
||||
|
||||
// 4) Build portable HTML (inline styles, snapshot canvases)
|
||||
const contentHtml = await buildPortableHtmlFromRoot(editorRoot);
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>${escapeHtml(filename)}</title>
|
||||
<style>
|
||||
body { margin: 0; background: #f5f5f5; }
|
||||
@media print {
|
||||
body { background: #fff; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${contentHtml}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
if (triggerPrint) {
|
||||
printHTMLPage({ html, filename });
|
||||
}
|
||||
|
||||
return html;
|
||||
} finally {
|
||||
// 5) Cleanup
|
||||
try {
|
||||
root.unmount();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
container.remove();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return build;
|
||||
};
|
||||
|
||||
async function buildPortableHtmlFromRoot(liveRoot: HTMLElement): Promise<string> {
|
||||
// Clone live DOM subtree and inline computed styles
|
||||
const clonedRoot = liveRoot.cloneNode(true) as HTMLElement;
|
||||
|
||||
// Snapshot metric canvases from the LIVE DOM and replace in the clone
|
||||
const liveMetricCanvases = Array.from(
|
||||
liveRoot.querySelectorAll('[data-export-metric] canvas')
|
||||
) as HTMLCanvasElement[];
|
||||
const cloneMetricCanvases = Array.from(
|
||||
clonedRoot.querySelectorAll('[data-export-metric] canvas')
|
||||
) as HTMLCanvasElement[];
|
||||
|
||||
for (let i = 0; i < Math.min(liveMetricCanvases.length, cloneMetricCanvases.length); i++) {
|
||||
const liveCanvas = liveMetricCanvases[i];
|
||||
const cloneCanvas = cloneMetricCanvases[i];
|
||||
try {
|
||||
const dataUrl = liveCanvas.toDataURL('image/png');
|
||||
if (dataUrl) {
|
||||
const img = document.createElement('img');
|
||||
img.src = dataUrl;
|
||||
img.alt = 'Metric';
|
||||
const computed = window.getComputedStyle(liveCanvas);
|
||||
const width = computed.getPropertyValue('width');
|
||||
const height = computed.getPropertyValue('height');
|
||||
if (width) (img.style as CSSStyleDeclaration).width = width;
|
||||
if (height) (img.style as CSSStyleDeclaration).height = height;
|
||||
(img.style as CSSStyleDeclaration).display = 'block';
|
||||
cloneCanvas.replaceWith(img);
|
||||
}
|
||||
} catch (e) {
|
||||
// If canvas is tainted or fails, leave the canvas as-is
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to snapshot metric canvas for HTML export', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline computed styles by pairing original and clone nodes in order
|
||||
const originals = [liveRoot, ...Array.from(liveRoot.querySelectorAll('*'))];
|
||||
const clones = [clonedRoot, ...Array.from(clonedRoot.querySelectorAll('*'))];
|
||||
|
||||
for (let i = 0; i < Math.min(originals.length, clones.length); i++) {
|
||||
const original = originals[i] as HTMLElement;
|
||||
const clone = clones[i] as HTMLElement;
|
||||
const computed = window.getComputedStyle(original);
|
||||
const cssText = Array.from(computed)
|
||||
.map((prop) => `${prop}: ${computed.getPropertyValue(prop)};`)
|
||||
.join(' ');
|
||||
clone.setAttribute('style', cssText);
|
||||
}
|
||||
|
||||
// Return inner HTML of cloned editor root
|
||||
return clonedRoot.outerHTML;
|
||||
}
|
||||
|
||||
async function waitForElement<T extends Element | null>(
|
||||
getter: () => T,
|
||||
{ timeoutMs = 8000, intervalMs = 50 }: { timeoutMs?: number; intervalMs?: number } = {}
|
||||
): Promise<NonNullable<T>> {
|
||||
const start = Date.now();
|
||||
// First try immediate
|
||||
const immediate = getter();
|
||||
if (immediate) return immediate as NonNullable<T>;
|
||||
|
||||
return new Promise<NonNullable<T>>((resolve, reject) => {
|
||||
const timer = setInterval(() => {
|
||||
const el = getter();
|
||||
if (el) {
|
||||
clearInterval(timer);
|
||||
resolve(el as NonNullable<T>);
|
||||
} else if (Date.now() - start > timeoutMs) {
|
||||
clearInterval(timer);
|
||||
reject(new Error('Timed out waiting for element'));
|
||||
}
|
||||
}, intervalMs);
|
||||
});
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
|
@ -320,7 +320,7 @@ const useDownloadPdfSelectMenu = ({ reportId }: { reportId: string }): DropdownI
|
|||
|
||||
const onClick = async () => {
|
||||
try {
|
||||
const html = await buildExportHtml2({ reportId });
|
||||
const html = await buildExportHtml2({ reportId, filename: reportName, triggerPrint: true });
|
||||
console.log('html', html);
|
||||
// const editor = getReportEditor(reportId);
|
||||
|
||||
|
|
Loading…
Reference in New Issue