update save functionality

This commit is contained in:
Nate Kelley 2025-08-08 11:52:07 -06:00
parent d3dd804a3f
commit e02015206a
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
3 changed files with 168 additions and 47 deletions

View File

@ -10,7 +10,7 @@ interface EditorContainerProps {
} }
const editorContainerVariants = cva( 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: { variants: {

View File

@ -9,6 +9,139 @@ import { useMemo } from 'react';
export const useExportReport = () => { export const useExportReport = () => {
const { openErrorMessage, openInfoMessage } = useBusterNotifications(); const { openErrorMessage, openInfoMessage } = useBusterNotifications();
// 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.
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);
});
// 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 = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<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; } }
</style>
</head>
<body>
${contentHtml}
</body>
</html>`;
return html;
};
const getCanvas = async (editor: PlateEditor) => { const getCanvas = async (editor: PlateEditor) => {
const { default: html2canvas } = await import('html2canvas-pro'); const { default: html2canvas } = await import('html2canvas-pro');
@ -79,7 +212,39 @@ export const useExportReport = () => {
}; };
const exportToPdf = async (editor: PlateEditor) => { 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) => { const exportToImage = async (editor: PlateEditor) => {
@ -95,50 +260,7 @@ export const useExportReport = () => {
const exportToHtml = async (editor: PlateEditor) => { const exportToHtml = async (editor: PlateEditor) => {
try { try {
const BaseEditorKit = await import('../editor-base-kit').then( const html = await buildExportHtml(editor);
(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 = `<link rel="stylesheet" href="${siteUrl}/tailwind.css">`;
const katexCss = `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.18/dist/katex.css" integrity="sha384-9PvLvaiSKCPkFKB1ZsEoTjgnJn+O3KvEwtsz37/XrkYft3DTk2gHdYvd9oWgW3tV" crossorigin="anonymous">`;
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400..700&family=JetBrains+Mono:wght@400..700&display=swap"
rel="stylesheet"
/>
${tailwindCss}
${katexCss}
<style>
:root {
--font-sans: 'Inter', 'Inter Fallback';
--font-mono: 'JetBrains Mono', 'JetBrains Mono Fallback';
}
</style>
</head>
<body>
${editorHtml}
</body>
</html>`;
const url = `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; const url = `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
await downloadFile(url, 'plate.html'); await downloadFile(url, 'plate.html');

View File

@ -154,7 +154,6 @@ export const ThreeDotMenuButton = React.memo(
] ]
); );
return ( return (
<Dropdown items={items} side="left" align="end" contentClassName="max-h-fit" modal> <Dropdown items={items} side="left" align="end" contentClassName="max-h-fit" modal>
<Button prefix={<Dots />} variant="ghost" data-testid="three-dot-menu-button" /> <Button prefix={<Dots />} variant="ghost" data-testid="three-dot-menu-button" />