pass down ids and update pdf generation logic

This commit is contained in:
Nate Kelley 2025-08-08 13:09:50 -06:00
parent 3745dc6ac2
commit 5c6bfdf96a
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
5 changed files with 82 additions and 65 deletions

View File

@ -24,6 +24,7 @@ interface ReportEditorProps {
onValueChange?: (value: ReportElements) => void; onValueChange?: (value: ReportElements) => void;
useFixedToolbarKit?: boolean; useFixedToolbarKit?: boolean;
onReady?: (editor: IReportEditor) => void; onReady?: (editor: IReportEditor) => void;
id?: string;
} }
export type IReportEditor = TPlateEditor<Value, AnyPluginConfig>; export type IReportEditor = TPlateEditor<Value, AnyPluginConfig>;
@ -40,6 +41,7 @@ export const ReportEditor = React.memo(
{ {
value, value,
placeholder, placeholder,
id,
onValueChange, onValueChange,
onReady, onReady,
variant = 'default', variant = 'default',
@ -89,7 +91,7 @@ export const ReportEditor = React.memo(
if (!editor) return null; if (!editor) return null;
return ( return (
<ThemeWrapper> <ThemeWrapper id={id}>
<Plate editor={editor} readOnly={readOnly} onValueChange={onValueChangePreflight}> <Plate editor={editor} readOnly={readOnly} onValueChange={onValueChangePreflight}>
<EditorContainer <EditorContainer
variant={variant} variant={variant}

View File

@ -6,6 +6,7 @@ import { THEME_RESET_COLORS, THEME_RESET_STYLE } from '@/styles/theme-reset';
interface ThemeWrapperProps extends React.ComponentProps<'div'> { interface ThemeWrapperProps extends React.ComponentProps<'div'> {
defaultTheme?: string; 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 }; 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 ( return (
<> <>
<div <div
id={id}
style={EDITOR_THEME} style={EDITOR_THEME}
className={cn( className={cn(
'themes-wrapper h-full w-full overflow-visible bg-transparent antialiased', 'themes-wrapper h-full w-full overflow-visible bg-transparent antialiased',

View File

@ -3,6 +3,7 @@
import { BaseAlignKit } from './plugins/align-base-kit'; import { BaseAlignKit } from './plugins/align-base-kit';
import { BaseBasicBlocksKit } from './plugins/basic-blocks-base-kit'; import { BaseBasicBlocksKit } from './plugins/basic-blocks-base-kit';
import { BaseBasicMarksKit } from './plugins/basic-marks-base-kit'; import { BaseBasicMarksKit } from './plugins/basic-marks-base-kit';
import { BusterStreamKit } from './plugins/buster-stream-kit';
import { BaseCalloutKit } from './plugins/callout-base-kit'; import { BaseCalloutKit } from './plugins/callout-base-kit';
import { BaseCodeBlockKit } from './plugins/code-block-base-kit'; import { BaseCodeBlockKit } from './plugins/code-block-base-kit';
import { BaseColumnKit } from './plugins/column-base-kit'; import { BaseColumnKit } from './plugins/column-base-kit';
@ -42,5 +43,6 @@ export const BaseEditorKit = [
...BaseLineHeightKit, ...BaseLineHeightKit,
...BaseCommentKit, ...BaseCommentKit,
...BaseSuggestionKit, ...BaseSuggestionKit,
...MarkdownKit ...MarkdownKit,
...BusterStreamKit
]; ];

View File

@ -2,52 +2,51 @@ import { EditorStatic } from '../elements/EditorStatic';
import type { PlateEditor } from 'platejs/react'; import type { PlateEditor } from 'platejs/react';
import { createSlateEditor, serializeHtml } from 'platejs'; import { createSlateEditor, serializeHtml } from 'platejs';
// Build a complete HTML document string for export, rasterizing // Build a complete HTML document string for export, inlining
// metric elements (which may contain canvas) into <img> tags AND // computed CSS styles so no external CSS is required. Additionally,
// inlining computed CSS styles so no external CSS is required. // snapshot <canvas> elements inside metrics to <img> tags to avoid
// blank canvases in the exported HTML.
export const buildExportHtml = async (editor: PlateEditor): Promise<string> => { export const buildExportHtml = async (editor: PlateEditor): Promise<string> => {
// Prefer using the live editor DOM to inline computed styles // Prefer using the live editor DOM to inline computed styles
const liveRoot = document.querySelector('[contenteditable="true"]') as HTMLElement | null; 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 = ''; let contentHtml = '';
if (liveRoot) { if (liveRoot) {
// Clone live DOM subtree and inline computed styles // Clone live DOM subtree and inline computed styles
const clonedRoot = liveRoot.cloneNode(true) as HTMLElement; const clonedRoot = liveRoot.cloneNode(true) as HTMLElement;
// Replace metric figures with <img> tags in the clone // Snapshot metric canvases from the LIVE DOM and replace in the clone
const clonedMetricFigures = Array.from( const liveMetricCanvases = Array.from(
clonedRoot.querySelectorAll('[data-export-metric] [data-metric-figure]') liveRoot.querySelectorAll('[data-export-metric] canvas')
); ) as HTMLCanvasElement[];
clonedMetricFigures.forEach((node, index) => { const cloneMetricCanvases = Array.from(
const dataUrl = metricDataUrls[index]; clonedRoot.querySelectorAll('[data-export-metric] canvas')
if (!dataUrl) return; ) 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'); const img = document.createElement('img');
img.src = dataUrl; img.src = dataUrl;
img.alt = 'Metric'; img.alt = 'Metric';
(img.style as CSSStyleDeclaration).width = '100%'; const computed = window.getComputedStyle(liveCanvas);
(img.style as CSSStyleDeclaration).height = 'auto'; const width = computed.getPropertyValue('width');
node.replaceWith(img); 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 // Inline computed styles by pairing original and clone nodes in order
const originals = [liveRoot, ...Array.from(liveRoot.querySelectorAll('*'))]; const originals = [liveRoot, ...Array.from(liveRoot.querySelectorAll('*'))];
@ -73,19 +72,25 @@ export const buildExportHtml = async (editor: PlateEditor): Promise<string> => {
clone.removeAttribute('spellcheck'); 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'); const wrapper = document.createElement('div');
wrapper.setAttribute( wrapper.setAttribute(
'style', '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); wrapper.appendChild(clonedRoot);
contentHtml = wrapper.outerHTML; contentHtml = wrapper.outerHTML;
} else { } else {
// Fallback: serialize using static editor and include minimal inline styles // Fallback: serialize using static editor and include minimal inline styles
const BaseEditorKit = await import('../editor-base-kit').then( const BaseEditorKit = await import('../editor-base-kit').then((module) => module.BaseEditorKit);
(module) => module.BaseEditorKit
);
const editorStatic = createSlateEditor({ const editorStatic = createSlateEditor({
plugins: BaseEditorKit, plugins: BaseEditorKit,
@ -97,23 +102,22 @@ export const buildExportHtml = async (editor: PlateEditor): Promise<string> => {
props: { style: { padding: '0 calc(50% - 350px)', paddingBottom: '' } } props: { style: { padding: '0 calc(50% - 350px)', paddingBottom: '' } }
}); });
// Replace metric figures in serialized HTML // Wrap the serialized HTML to match the centered, fixed-width layout
const container = document.createElement('div'); const wrapper = document.createElement('div');
container.innerHTML = serializedHtml; wrapper.setAttribute(
const clonedMetricFiguresFallback = Array.from( 'style',
container.querySelectorAll('[data-export-metric] [data-metric-figure]') [
'width: 816px',
'max-width: 816px',
'min-width: 816px',
'margin: 24px auto',
'background: #ffffff',
'padding: 40px',
'box-sizing: border-box'
].join('; ')
); );
clonedMetricFiguresFallback.forEach((node, index) => { wrapper.innerHTML = serializedHtml;
const dataUrl = metricDataUrls[index]; contentHtml = wrapper.outerHTML;
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 // Build a minimal HTML document without external CSS
@ -124,7 +128,10 @@ export const buildExportHtml = async (editor: PlateEditor): Promise<string> => {
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" /> <meta name="color-scheme" content="light dark" />
<style> <style>
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } } body { margin: 0; background: #f5f5f5; }
@media print {
body { background: #fff; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
</style> </style>
</head> </head>
<body> <body>
@ -136,5 +143,3 @@ export const buildExportHtml = async (editor: PlateEditor): Promise<string> => {
}; };
export default buildExportHtml; export default buildExportHtml;

View File

@ -12,11 +12,13 @@ import { ReportEditor, type IReportEditor } from '@/components/ui/report/ReportE
import { registerReportEditor, unregisterReportEditor } from './editorRegistry'; import { registerReportEditor, unregisterReportEditor } from './editorRegistry';
import { ReportEditorSkeleton } from '@/components/ui/report/ReportEditorSkeleton'; import { ReportEditorSkeleton } from '@/components/ui/report/ReportEditorSkeleton';
export const PAGE_CONTROLLER_ID = (reportId: string) => `report-page-controller-${reportId}`;
export const ReportPageController: React.FC<{ export const ReportPageController: React.FC<{
reportId: string; reportId: string;
readOnly?: boolean; readOnly?: boolean;
className?: string; className?: string;
}> = ({ reportId, readOnly = false, className = '' }) => { }> = React.memo(({ reportId, readOnly = false, className = '' }) => {
const { data: report } = useGetReport({ reportId, versionNumber: undefined }); const { data: report } = useGetReport({ reportId, versionNumber: undefined });
// const editor = useRef<AppReportRef>(null); // const editor = useRef<AppReportRef>(null);
@ -54,6 +56,7 @@ export const ReportPageController: React.FC<{
return ( return (
<div <div
id={PAGE_CONTROLLER_ID(reportId)}
className={cn( className={cn(
'h-full space-y-1.5 overflow-y-auto pt-9 sm:px-[max(64px,calc(50%-350px))]', 'h-full space-y-1.5 overflow-y-auto pt-9 sm:px-[max(64px,calc(50%-350px))]',
className className
@ -76,6 +79,7 @@ export const ReportPageController: React.FC<{
readOnly={readOnly || !report} readOnly={readOnly || !report}
className="px-0!" className="px-0!"
onReady={onReady} onReady={onReady}
id={PAGE_CONTROLLER_ID(reportId)}
/> />
</> </>
) : ( ) : (
@ -83,4 +87,6 @@ export const ReportPageController: React.FC<{
)} )}
</div> </div>
); );
}; });
ReportPageController.displayName = 'ReportPageController';