mirror of https://github.com/buster-so/buster.git
pass down ids and update pdf generation logic
This commit is contained in:
parent
3745dc6ac2
commit
5c6bfdf96a
|
@ -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}
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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
|
||||||
];
|
];
|
||||||
|
|
|
@ -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[];
|
||||||
const img = document.createElement('img');
|
|
||||||
img.src = dataUrl;
|
for (let i = 0; i < Math.min(liveMetricCanvases.length, cloneMetricCanvases.length); i++) {
|
||||||
img.alt = 'Metric';
|
const liveCanvas = liveMetricCanvases[i];
|
||||||
(img.style as CSSStyleDeclaration).width = '100%';
|
const cloneCanvas = cloneMetricCanvases[i];
|
||||||
(img.style as CSSStyleDeclaration).height = 'auto';
|
try {
|
||||||
node.replaceWith(img);
|
// 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
|
// 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;
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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';
|
||||||
|
|
Loading…
Reference in New Issue