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(
|
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: {
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
Loading…
Reference in New Issue