From 5c6bfdf96a6827a1d5872dac9c114d1a730f804f Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Fri, 8 Aug 2025 13:09:50 -0600 Subject: [PATCH] pass down ids and update pdf generation logic --- .../src/components/ui/report/ReportEditor.tsx | 4 +- .../ui/report/ThemeWrapper/ThemeWrapper.tsx | 4 +- .../components/ui/report/editor-base-kit.tsx | 4 +- .../ui/report/hooks/buildExportHtml.ts | 125 +++++++++--------- .../ReportPageController.tsx | 10 +- 5 files changed, 82 insertions(+), 65 deletions(-) diff --git a/apps/web/src/components/ui/report/ReportEditor.tsx b/apps/web/src/components/ui/report/ReportEditor.tsx index 6f3b415f9..38aa213a1 100644 --- a/apps/web/src/components/ui/report/ReportEditor.tsx +++ b/apps/web/src/components/ui/report/ReportEditor.tsx @@ -24,6 +24,7 @@ interface ReportEditorProps { onValueChange?: (value: ReportElements) => void; useFixedToolbarKit?: boolean; onReady?: (editor: IReportEditor) => void; + id?: string; } export type IReportEditor = TPlateEditor; @@ -40,6 +41,7 @@ export const ReportEditor = React.memo( { value, placeholder, + id, onValueChange, onReady, variant = 'default', @@ -89,7 +91,7 @@ export const ReportEditor = React.memo( if (!editor) return null; return ( - + { defaultTheme?: string; + id?: string; } /** @@ -43,10 +44,11 @@ const CSS_VARIABLES_THEME = Object.entries(THEME_RESET_COLORS.light).reduce( const EDITOR_THEME = { ...CSS_VARIABLES_THEME, ...THEME_RESET_STYLE }; -export function ThemeWrapper({ children, className, defaultTheme }: ThemeWrapperProps) { +export function ThemeWrapper({ children, className, defaultTheme, id }: ThemeWrapperProps) { return ( <>
tags AND -// inlining computed CSS styles so no external CSS is required. +// Build a complete HTML document string for export, inlining +// computed CSS styles so no external CSS is required. Additionally, +// snapshot elements inside metrics to tags to avoid +// blank canvases in the exported HTML. export 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); - }); + // 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 { + // Use the raw canvas data to preserve exact rendering + 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 + 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('*'))]; @@ -73,19 +72,25 @@ export const buildExportHtml = async (editor: PlateEditor): Promise => { clone.removeAttribute('spellcheck'); } - // Wrap content with a fixed width container for consistency + // Wrap content with a fixed width, centered container for consistency const wrapper = document.createElement('div'); wrapper.setAttribute( 'style', - 'width: 850px; max-width: 850px; min-width: 850px; margin: 0 auto;' + [ + 'width: 816px', + 'max-width: 816px', + 'min-width: 816px', + 'margin: 24px auto', + 'background: #ffffff', + 'padding: 40px', + 'box-sizing: border-box' + ].join('; ') ); 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 BaseEditorKit = await import('../editor-base-kit').then((module) => module.BaseEditorKit); const editorStatic = createSlateEditor({ plugins: BaseEditorKit, @@ -97,23 +102,22 @@ export const buildExportHtml = async (editor: PlateEditor): Promise => { 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]') + // Wrap the serialized HTML to match the centered, fixed-width layout + const wrapper = document.createElement('div'); + wrapper.setAttribute( + 'style', + [ + 'width: 816px', + 'max-width: 816px', + 'min-width: 816px', + 'margin: 24px auto', + 'background: #ffffff', + 'padding: 40px', + 'box-sizing: border-box' + ].join('; ') ); - 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; + wrapper.innerHTML = serializedHtml; + contentHtml = wrapper.outerHTML; } // Build a minimal HTML document without external CSS @@ -124,7 +128,10 @@ export const buildExportHtml = async (editor: PlateEditor): Promise => { @@ -136,5 +143,3 @@ export const buildExportHtml = async (editor: PlateEditor): Promise => { }; export default buildExportHtml; - - diff --git a/apps/web/src/controllers/ReportPageControllers/ReportPageController/ReportPageController.tsx b/apps/web/src/controllers/ReportPageControllers/ReportPageController/ReportPageController.tsx index ea2e1dac7..2eadd3632 100644 --- a/apps/web/src/controllers/ReportPageControllers/ReportPageController/ReportPageController.tsx +++ b/apps/web/src/controllers/ReportPageControllers/ReportPageController/ReportPageController.tsx @@ -12,11 +12,13 @@ import { ReportEditor, type IReportEditor } from '@/components/ui/report/ReportE import { registerReportEditor, unregisterReportEditor } from './editorRegistry'; import { ReportEditorSkeleton } from '@/components/ui/report/ReportEditorSkeleton'; +export const PAGE_CONTROLLER_ID = (reportId: string) => `report-page-controller-${reportId}`; + export const ReportPageController: React.FC<{ reportId: string; readOnly?: boolean; className?: string; -}> = ({ reportId, readOnly = false, className = '' }) => { +}> = React.memo(({ reportId, readOnly = false, className = '' }) => { const { data: report } = useGetReport({ reportId, versionNumber: undefined }); // const editor = useRef(null); @@ -54,6 +56,7 @@ export const ReportPageController: React.FC<{ return (
) : ( @@ -83,4 +87,6 @@ export const ReportPageController: React.FC<{ )}
); -}; +}); + +ReportPageController.displayName = 'ReportPageController';