move helpers to their own files

This commit is contained in:
Nate Kelley 2025-08-08 11:57:32 -06:00
parent e02015206a
commit 3745dc6ac2
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
8 changed files with 362 additions and 278 deletions

View File

@ -0,0 +1,140 @@
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.
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);
});
// 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;
};
export default buildExportHtml;

View File

@ -0,0 +1,21 @@
// Download a file pointed to by a data URL or remote URL
export const downloadFile = async (url: string, filename: string) => {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
// Clean up the blob URL
window.URL.revokeObjectURL(blobUrl);
};
export default downloadFile;

View File

@ -0,0 +1,27 @@
import type { PlateEditor } from 'platejs/react';
import { NodeTypeLabels } from '../config/labels';
import { buildExportHtml } from './buildExportHtml';
import { downloadFile } from './downloadFile';
type Notifier = (message: string) => void;
export const exportToHtml = async (
editor: PlateEditor,
openInfoMessage: Notifier,
openErrorMessage: Notifier
) => {
try {
const html = await buildExportHtml(editor);
const url = `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
await downloadFile(url, 'plate.html');
openInfoMessage(NodeTypeLabels.htmlExportedSuccessfully.label);
} catch (error) {
console.error(error);
openErrorMessage(NodeTypeLabels.failedToExportHtml.label);
}
};
export default exportToHtml;

View File

@ -0,0 +1,25 @@
import type { PlateEditor } from 'platejs/react';
import { NodeTypeLabels } from '../config/labels';
import { getCanvas } from './getCanvas';
import { downloadFile } from './downloadFile';
type Notifier = (message: string) => void;
export const exportToImage = async (
editor: PlateEditor,
openInfoMessage: Notifier,
openErrorMessage: Notifier
) => {
try {
const canvas = await getCanvas(editor);
await downloadFile(canvas.toDataURL('image/png'), 'plate.png');
openInfoMessage(NodeTypeLabels.imageExportedSuccessfully.label);
} catch (error) {
console.error(error);
openErrorMessage(NodeTypeLabels.failedToExportImage.label);
}
};
export default exportToImage;

View File

@ -0,0 +1,26 @@
import type { PlateEditor } from 'platejs/react';
import { NodeTypeLabels } from '../config/labels';
import { MarkdownPlugin } from '@platejs/markdown';
import { downloadFile } from './downloadFile';
type Notifier = (message: string) => void;
export const exportToMarkdown = async (
editor: PlateEditor,
openInfoMessage: Notifier,
openErrorMessage: Notifier
) => {
try {
const md = editor.getApi(MarkdownPlugin).markdown.serialize();
const url = `data:text/markdown;charset=utf-8,${encodeURIComponent(md)}`;
await downloadFile(url, 'plate.md');
openInfoMessage(NodeTypeLabels.markdownExportedSuccessfully.label);
} catch (error) {
console.error(error);
openErrorMessage(NodeTypeLabels.failedToExportMarkdown.label);
}
};
export default exportToMarkdown;

View File

@ -0,0 +1,49 @@
import type { PlateEditor } from 'platejs/react';
import { NodeTypeLabels } from '../config/labels';
import { buildExportHtml } from './buildExportHtml';
type Notifier = (message: string) => void;
export const exportToPdf = async (
editor: PlateEditor,
openInfoMessage: Notifier,
openErrorMessage: Notifier
) => {
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);
}
};
export default exportToPdf;

View File

@ -0,0 +1,58 @@
import type { PlateEditor } from 'platejs/react';
// Render the current editor content to a canvas for image export.
export const getCanvas = async (editor: PlateEditor) => {
const { default: html2canvas } = await import('html2canvas-pro');
// Create a temporary style element to isolate any styles needed during export.
// Ensure it is always cleaned up, even if an error occurs during rendering.
const style = document.createElement('style');
style.setAttribute('data-report-export-style', 'true');
document.head.append(style);
// Standard width for consistent PDF output (equivalent to A4 width with margins)
const standardWidth = '850px';
const node = editor.api.toDOMNode(editor)!;
if (!node) {
// Ensure cleanup of style element if editor is missing
style.remove();
throw new Error('Editor not found');
}
try {
const canvas = await html2canvas(node, {
onclone: (document: Document) => {
const editorElement = document.querySelector('[contenteditable="true"]');
if (editorElement) {
// Force consistent width for the editor element
const existingStyle = editorElement.getAttribute('style') || '';
editorElement.setAttribute(
'style',
`${existingStyle}; width: ${standardWidth} !important; max-width: ${standardWidth} !important; min-width: ${standardWidth} !important;`
);
// Apply consistent font family to all elements
Array.from(editorElement.querySelectorAll('*')).forEach((element) => {
const elementStyle = element.getAttribute('style') || '';
element.setAttribute(
'style',
`${elementStyle}; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif !important`
);
});
} else {
throw new Error('Editor element not found');
}
}
});
return canvas;
} finally {
// Always remove temporary style tag
style.remove();
}
};
export default getCanvas;

View File

@ -1,294 +1,32 @@
import { EditorStatic } from '../elements/EditorStatic';
import { useBusterNotifications } from '@/context/BusterNotifications';
import type { PlateEditor } from 'platejs/react';
import { NodeTypeLabels } from '../config/labels';
import { createSlateEditor, serializeHtml } from 'platejs';
import { MarkdownPlugin } from '@platejs/markdown';
import { useMemo } from 'react';
import { exportToPdf } from './exportToPdf';
import { exportToImage } from './exportToImage';
import { exportToHtml } from './exportToHtml';
import { exportToMarkdown } from './exportToMarkdown';
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;
const exportToPdfLocal = async (editor: PlateEditor) =>
exportToPdf(editor, openInfoMessage, openErrorMessage);
// 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[])
: [];
const exportToImageLocal = async (editor: PlateEditor) =>
exportToImage(editor, openInfoMessage, openErrorMessage);
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('');
}
}
const exportToHtmlLocal = async (editor: PlateEditor) =>
exportToHtml(editor, openInfoMessage, openErrorMessage);
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');
// Create a temporary style element to isolate any styles needed during export.
// Ensure it is always cleaned up, even if an error occurs during rendering.
const style = document.createElement('style');
style.setAttribute('data-report-export-style', 'true');
document.head.append(style);
// Standard width for consistent PDF output (equivalent to A4 width with margins)
const standardWidth = '850px';
const node = editor.api.toDOMNode(editor)!;
if (!node) {
// Ensure cleanup of style element if editor is missing
style.remove();
throw new Error('Editor not found');
}
try {
const canvas = await html2canvas(node, {
onclone: (document: Document) => {
const editorElement = document.querySelector('[contenteditable="true"]');
if (editorElement) {
// Force consistent width for the editor element
const existingStyle = editorElement.getAttribute('style') || '';
editorElement.setAttribute(
'style',
`${existingStyle}; width: ${standardWidth} !important; max-width: ${standardWidth} !important; min-width: ${standardWidth} !important;`
);
// Apply consistent font family to all elements
Array.from(editorElement.querySelectorAll('*')).forEach((element) => {
const elementStyle = element.getAttribute('style') || '';
element.setAttribute(
'style',
`${elementStyle}; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif !important`
);
});
} else {
throw new Error('Editor element not found');
}
}
});
return canvas;
} finally {
// Always remove temporary style tag
style.remove();
}
};
const downloadFile = async (url: string, filename: string) => {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
// Clean up the blob URL
window.URL.revokeObjectURL(blobUrl);
};
const exportToPdf = async (editor: PlateEditor) => {
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) => {
try {
const canvas = await getCanvas(editor);
await downloadFile(canvas.toDataURL('image/png'), 'plate.png');
openInfoMessage(NodeTypeLabels.imageExportedSuccessfully.label);
} catch (error) {
console.error(error);
openErrorMessage(NodeTypeLabels.failedToExportImage.label);
}
};
const exportToHtml = async (editor: PlateEditor) => {
try {
const html = await buildExportHtml(editor);
const url = `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
await downloadFile(url, 'plate.html');
openInfoMessage(NodeTypeLabels.htmlExportedSuccessfully.label);
} catch (error) {
console.error(error);
openErrorMessage(NodeTypeLabels.failedToExportHtml.label);
}
};
const exportToMarkdown = async (editor: PlateEditor) => {
try {
const md = editor.getApi(MarkdownPlugin).markdown.serialize();
const url = `data:text/markdown;charset=utf-8,${encodeURIComponent(md)}`;
await downloadFile(url, 'plate.md');
openInfoMessage(NodeTypeLabels.markdownExportedSuccessfully.label);
} catch (error) {
console.error(error);
openErrorMessage(NodeTypeLabels.failedToExportMarkdown.label);
}
};
const exportToMarkdownLocal = async (editor: PlateEditor) =>
exportToMarkdown(editor, openInfoMessage, openErrorMessage);
return useMemo(
() => ({
exportToPdf,
exportToImage,
exportToHtml,
exportToMarkdown
exportToPdf: exportToPdfLocal,
exportToImage: exportToImageLocal,
exportToHtml: exportToHtmlLocal,
exportToMarkdown: exportToMarkdownLocal
}),
[]
);