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;
useFixedToolbarKit?: boolean;
onReady?: (editor: IReportEditor) => void;
id?: string;
}
export type IReportEditor = TPlateEditor<Value, AnyPluginConfig>;
@ -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 (
<ThemeWrapper>
<ThemeWrapper id={id}>
<Plate editor={editor} readOnly={readOnly} onValueChange={onValueChangePreflight}>
<EditorContainer
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'> {
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 (
<>
<div
id={id}
style={EDITOR_THEME}
className={cn(
'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 { BaseBasicBlocksKit } from './plugins/basic-blocks-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 { BaseCodeBlockKit } from './plugins/code-block-base-kit';
import { BaseColumnKit } from './plugins/column-base-kit';
@ -42,5 +43,6 @@ export const BaseEditorKit = [
...BaseLineHeightKit,
...BaseCommentKit,
...BaseSuggestionKit,
...MarkdownKit
...MarkdownKit,
...BusterStreamKit
];

View File

@ -2,52 +2,51 @@ import { EditorStatic } from '../elements/EditorStatic';
import type { PlateEditor } from 'platejs/react';
import { createSlateEditor, serializeHtml } from 'platejs';
// Build a complete HTML document string for export, rasterizing
// metric elements (which may contain canvas) into <img> 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 <canvas> elements inside metrics to <img> tags to avoid
// blank canvases in the exported HTML.
export const buildExportHtml = async (editor: PlateEditor): Promise<string> => {
// 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 <img> 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<string> => {
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<string> => {
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<string> => {
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<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>
</head>
<body>
@ -136,5 +143,3 @@ export const buildExportHtml = async (editor: PlateEditor): Promise<string> => {
};
export default buildExportHtml;

View File

@ -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<AppReportRef>(null);
@ -54,6 +56,7 @@ export const ReportPageController: React.FC<{
return (
<div
id={PAGE_CONTROLLER_ID(reportId)}
className={cn(
'h-full space-y-1.5 overflow-y-auto pt-9 sm:px-[max(64px,calc(50%-350px))]',
className
@ -76,6 +79,7 @@ export const ReportPageController: React.FC<{
readOnly={readOnly || !report}
className="px-0!"
onReady={onReady}
id={PAGE_CONTROLLER_ID(reportId)}
/>
</>
) : (
@ -83,4 +87,6 @@ export const ReportPageController: React.FC<{
)}
</div>
);
};
});
ReportPageController.displayName = 'ReportPageController';