mirror of https://github.com/buster-so/buster.git
update save functionality
This commit is contained in:
parent
d3dd804a3f
commit
e02015206a
|
@ -10,7 +10,7 @@ interface EditorContainerProps {
|
|||
}
|
||||
|
||||
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: {
|
||||
|
|
|
@ -9,6 +9,139 @@ import { useMemo } from 'react';
|
|||
export const useExportReport = () => {
|
||||
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 { default: html2canvas } = await import('html2canvas-pro');
|
||||
|
||||
|
@ -79,7 +212,39 @@ export const useExportReport = () => {
|
|||
};
|
||||
|
||||
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) => {
|
||||
|
@ -95,50 +260,7 @@ export const useExportReport = () => {
|
|||
|
||||
const exportToHtml = async (editor: PlateEditor) => {
|
||||
try {
|
||||
const BaseEditorKit = await import('../editor-base-kit').then(
|
||||
(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 html = await buildExportHtml(editor);
|
||||
const url = `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
|
||||
|
||||
await downloadFile(url, 'plate.html');
|
||||
|
|
|
@ -154,7 +154,6 @@ export const ThreeDotMenuButton = React.memo(
|
|||
]
|
||||
);
|
||||
|
||||
|
||||
return (
|
||||
<Dropdown items={items} side="left" align="end" contentClassName="max-h-fit" modal>
|
||||
<Button prefix={<Dots />} variant="ghost" data-testid="three-dot-menu-button" />
|
||||
|
|
Loading…
Reference in New Issue