diff --git a/apps/web/src/components/ui/report/EditorContainer.tsx b/apps/web/src/components/ui/report/EditorContainer.tsx
index dc734ea14..5cc719b5e 100644
--- a/apps/web/src/components/ui/report/EditorContainer.tsx
+++ b/apps/web/src/components/ui/report/EditorContainer.tsx
@@ -10,7 +10,7 @@ interface EditorContainerProps {
}
const editorContainerVariants = cva(
- 'relative w-full cursor-text bg-transparent caret-primary select-text selection:bg-brand/25 focus-visible:outline-none [&_.slate-selection-area]:z-50 [&_.slate-selection-area]:border [&_.slate-selection-area]:border-brand/25 [&_.slate-selection-area]:bg-brand/15',
+ 'relative w-full cursor-text bg-transparent caret-primary select-text selection:bg-brand/15 focus-visible:outline-none [&_.slate-selection-area]:z-50 [&_.slate-selection-area]:border [&_.slate-selection-area]:border-brand/25 [&_.slate-selection-area]:bg-brand/15',
{
variants: {
diff --git a/apps/web/src/components/ui/report/hooks/useExportReport.ts b/apps/web/src/components/ui/report/hooks/useExportReport.ts
index 29ea0ce53..2128c6bd8 100644
--- a/apps/web/src/components/ui/report/hooks/useExportReport.ts
+++ b/apps/web/src/components/ui/report/hooks/useExportReport.ts
@@ -9,6 +9,139 @@ import { useMemo } from 'react';
export const useExportReport = () => {
const { openErrorMessage, openInfoMessage } = useBusterNotifications();
+ // Build a complete HTML document string for export, rasterizing
+ // metric elements (which may contain canvas) into
tags AND
+ // inlining computed CSS styles so no external CSS is required.
+ const buildExportHtml = async (editor: PlateEditor): Promise => {
+ // Prefer using the live editor DOM to inline computed styles
+ const liveRoot = document.querySelector('[contenteditable="true"]') as HTMLElement | null;
+
+ // Create images for each metric element from the LIVE DOM (if available)
+ const { default: html2canvas } = await import('html2canvas-pro');
+ const metricDataUrls: string[] = [];
+ const liveMetricFigures = liveRoot
+ ? (Array.from(
+ liveRoot.querySelectorAll('[data-export-metric] [data-metric-figure]')
+ ) as HTMLElement[])
+ : [];
+
+ for (const figureEl of liveMetricFigures) {
+ try {
+ const canvas = await html2canvas(figureEl, { backgroundColor: null });
+ metricDataUrls.push(canvas.toDataURL('image/png'));
+ } catch (e) {
+ console.error('Failed to rasterize metric element for HTML export', e);
+ metricDataUrls.push('');
+ }
+ }
+
+ let contentHtml = '';
+
+ if (liveRoot) {
+ // Clone live DOM subtree and inline computed styles
+ const clonedRoot = liveRoot.cloneNode(true) as HTMLElement;
+
+ // Replace metric figures with
tags in the clone
+ const clonedMetricFigures = Array.from(
+ clonedRoot.querySelectorAll('[data-export-metric] [data-metric-figure]')
+ );
+ clonedMetricFigures.forEach((node, index) => {
+ const dataUrl = metricDataUrls[index];
+ if (!dataUrl) return;
+ const img = document.createElement('img');
+ img.src = dataUrl;
+ img.alt = 'Metric';
+ (img.style as CSSStyleDeclaration).width = '100%';
+ (img.style as CSSStyleDeclaration).height = 'auto';
+ node.replaceWith(img);
+ });
+
+ // 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 < originals.length; i++) {
+ const orig = originals[i] as HTMLElement;
+ const clone = clones[i] as HTMLElement | undefined;
+ if (!clone) continue;
+ const computed = window.getComputedStyle(orig);
+ const parts: string[] = [];
+ for (let j = 0; j < computed.length; j++) {
+ const propName = computed.item(j);
+ if (!propName) continue;
+ const value = computed.getPropertyValue(propName);
+ if (!value) continue;
+ parts.push(`${propName}: ${value};`);
+ }
+ const existing = clone.getAttribute('style') || '';
+ clone.setAttribute('style', `${existing}; ${parts.join(' ')}`.trim());
+ // Remove interactive attributes
+ clone.removeAttribute('contenteditable');
+ clone.removeAttribute('draggable');
+ clone.removeAttribute('spellcheck');
+ }
+
+ // Wrap content with a fixed width container for consistency
+ const wrapper = document.createElement('div');
+ wrapper.setAttribute(
+ 'style',
+ 'width: 850px; max-width: 850px; min-width: 850px; margin: 0 auto;'
+ );
+ wrapper.appendChild(clonedRoot);
+ contentHtml = wrapper.outerHTML;
+ } else {
+ // Fallback: serialize using static editor and include minimal inline styles
+ const BaseEditorKit = await import('../editor-base-kit').then(
+ (module) => module.BaseEditorKit
+ );
+
+ const editorStatic = createSlateEditor({
+ plugins: BaseEditorKit,
+ value: editor.children
+ });
+
+ const serializedHtml = await serializeHtml(editorStatic, {
+ editorComponent: EditorStatic,
+ props: { style: { padding: '0 calc(50% - 350px)', paddingBottom: '' } }
+ });
+
+ // Replace metric figures in serialized HTML
+ const container = document.createElement('div');
+ container.innerHTML = serializedHtml;
+ const clonedMetricFiguresFallback = Array.from(
+ container.querySelectorAll('[data-export-metric] [data-metric-figure]')
+ );
+ clonedMetricFiguresFallback.forEach((node, index) => {
+ const dataUrl = metricDataUrls[index];
+ if (!dataUrl) return;
+ const img = document.createElement('img');
+ img.src = dataUrl;
+ img.alt = 'Metric';
+ (img.style as CSSStyleDeclaration).width = '100%';
+ (img.style as CSSStyleDeclaration).height = 'auto';
+ node.replaceWith(img);
+ });
+ contentHtml = container.innerHTML;
+ }
+
+ // Build a minimal HTML document without external CSS
+ const html = `
+
+
+
+
+
+
+
+
+ ${contentHtml}
+
+ `;
+
+ return html;
+ };
+
const getCanvas = async (editor: PlateEditor) => {
const { default: html2canvas } = await import('html2canvas-pro');
@@ -79,7 +212,39 @@ export const useExportReport = () => {
};
const exportToPdf = async (editor: PlateEditor) => {
- openInfoMessage('PDF export coming soon...');
+ try {
+ const html = await buildExportHtml(editor);
+
+ // Open a print window with the rendered HTML so the user can save as PDF
+ const printWindow = window.open('', '_blank');
+ if (!printWindow) throw new Error('Unable to open print window');
+
+ printWindow.document.open();
+ printWindow.document.write(html);
+ printWindow.document.close();
+
+ // Trigger print when resources are loaded
+ const triggerPrint = () => {
+ try {
+ printWindow.focus();
+ printWindow.print();
+ } catch (e) {
+ console.error('Failed to trigger print dialog', e);
+ }
+ };
+
+ if (printWindow.document.readyState === 'complete') {
+ // Give a brief moment for styles to apply
+ setTimeout(triggerPrint, 100);
+ } else {
+ printWindow.addEventListener('load', () => setTimeout(triggerPrint, 100));
+ }
+
+ openInfoMessage(NodeTypeLabels.pdfExportedSuccessfully.label);
+ } catch (error) {
+ console.error(error);
+ openErrorMessage(NodeTypeLabels.failedToExportPdf.label);
+ }
};
const exportToImage = async (editor: PlateEditor) => {
@@ -95,50 +260,7 @@ export const useExportReport = () => {
const exportToHtml = async (editor: PlateEditor) => {
try {
- const BaseEditorKit = await import('../editor-base-kit').then(
- (module) => module.BaseEditorKit
- );
-
- const editorStatic = createSlateEditor({
- plugins: BaseEditorKit,
- value: editor.children
- });
-
- const editorHtml = await serializeHtml(editorStatic, {
- editorComponent: EditorStatic,
- props: { style: { padding: '0 calc(50% - 350px)', paddingBottom: '' } }
- });
-
- const siteUrl = 'https://platejs.org';
- const tailwindCss = ``;
- const katexCss = ``;
-
- const html = `
-
-
-
-
-
-
-
-
- ${tailwindCss}
- ${katexCss}
-
-
-
- ${editorHtml}
-
- `;
-
+ const html = await buildExportHtml(editor);
const url = `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
await downloadFile(url, 'plate.html');
diff --git a/apps/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/MetricContainerHeaderButtons/MetricThreeDotMenu.tsx b/apps/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/MetricContainerHeaderButtons/MetricThreeDotMenu.tsx
index f1fdd5fef..5399ad1d6 100644
--- a/apps/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/MetricContainerHeaderButtons/MetricThreeDotMenu.tsx
+++ b/apps/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/MetricContainerHeaderButtons/MetricThreeDotMenu.tsx
@@ -154,7 +154,6 @@ export const ThreeDotMenuButton = React.memo(
]
);
-
return (
} variant="ghost" data-testid="three-dot-menu-button" />