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 (