handle after print

This commit is contained in:
Nate Kelley 2025-08-08 13:58:25 -06:00
parent 5c6bfdf96a
commit cb02074cba
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
20 changed files with 178 additions and 94 deletions

View File

@ -17,6 +17,7 @@ import { MarkdownKit } from './plugins/markdown-kit';
import { BaseMathKit } from './plugins/math-base-kit';
import { BaseMediaKit } from './plugins/media-base-kit';
import { BaseMentionKit } from './plugins/mention-base-kit';
import { MetricBaseKit } from './plugins/metric-kit/metric-base-kit';
import { BaseSuggestionKit } from './plugins/suggestion-base-kit';
import { BaseTableKit } from './plugins/table-base-kit';
import { BaseTocKit } from './plugins/toc-base-kit';
@ -44,5 +45,6 @@ export const BaseEditorKit = [
...BaseCommentKit,
...BaseSuggestionKit,
...MarkdownKit,
...BusterStreamKit
...BusterStreamKit,
...MetricBaseKit
];

View File

@ -34,16 +34,16 @@ export function ExportToolbarButton({ children, ...props }: DropdownMenuProps) {
<DropdownMenuContent align="start">
<DropdownMenuGroup>
<DropdownMenuItem onSelect={() => exportToHtml(editor)}>
<DropdownMenuItem onSelect={() => exportToHtml({ editor })}>
{NodeTypeLabels.exportAsHtml.label}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => exportToPdf(editor)}>
<DropdownMenuItem onSelect={() => exportToPdf({ editor })}>
{NodeTypeLabels.exportAsPdf.label}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => exportToImage(editor)}>
<DropdownMenuItem onSelect={() => exportToImage({ editor })}>
{NodeTypeLabels.exportAsImage.label}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => exportToMarkdown(editor)}>
<DropdownMenuItem onSelect={() => exportToMarkdown({ editor })}>
{NodeTypeLabels.exportAsMarkdown.label}
</DropdownMenuItem>
</DropdownMenuGroup>

View File

@ -5,17 +5,18 @@ import { useChatLayoutContextSelector } from '@/layouts/ChatLayout';
import { assetParamsToRoute } from '@/lib/assets/assetParamsToRoute';
import React, { useMemo, useRef } from 'react';
import { useMetricContentThreeDotMenuItems } from './useMetricContentThreeDotMenuItems';
import { cn } from '@/lib/classMerge';
export const MetricContent = React.memo(
({
metricId,
metricVersionNumber,
isBaseElement = false,
readOnly = false
}: {
metricId: string;
metricVersionNumber: number | undefined;
readOnly?: boolean;
isBaseElement?: boolean;
}) => {
const chatId = useChatLayoutContextSelector((x) => x.chatId);
const reportId = useChatLayoutContextSelector((x) => x.reportId) || '';
@ -23,7 +24,7 @@ export const MetricContent = React.memo(
const ref = useRef<HTMLDivElement>(null);
const [inViewport] = useInViewport(ref, {
threshold: 0.33
threshold: isBaseElement ? 0 : 0.33
});
const renderChart = inViewport;
@ -72,7 +73,7 @@ export const MetricContent = React.memo(
return (
<MetricCard
metricLink={link}
animate
animate={!isBaseElement}
metricId={metricId}
readOnly={readOnly}
isDragOverlay={false}

View File

@ -14,7 +14,7 @@ import { ResizableProvider, useResizableValue } from '@platejs/resizable';
import { MetricEmbedPlaceholder } from './MetricPlaceholder';
import { Caption, CaptionTextarea } from '../CaptionNode';
import { mediaResizeHandleVariants, Resizable, ResizeHandle } from '../ResizeHandle';
import { type TMetricElement } from '../../plugins/metric-plugin';
import { type TMetricElement } from '../../plugins/metric-kit';
import React, { useMemo, useRef, type PropsWithChildren } from 'react';
import { useSize } from '@/hooks/useSize';
import { MetricContent } from './MetricContent';

View File

@ -0,0 +1,39 @@
import { MetricContent } from './MetricContent';
import type { TMetricElement } from '../../plugins/metric-kit';
import {
SlateElement,
type TCaptionProps,
type TResizableProps,
type SlateElementProps
} from 'platejs';
import { MetricEmbedPlaceholder } from './MetricPlaceholder';
export const MetricElementStatic = (
props: SlateElementProps<TMetricElement & TCaptionProps & TResizableProps>
) => {
const metricId = props.element.metricId;
const metricVersionNumber = props.element.metricVersionNumber;
const readOnly = true;
const { align = 'center', caption, url, width } = props.element;
const content = metricId ? (
<MetricContent
metricId={metricId}
metricVersionNumber={metricVersionNumber}
readOnly={readOnly}
/>
) : (
<MetricEmbedPlaceholder />
);
return (
<SlateElement {...props}>
<figure className="group relative m-0 inline-block" style={{ width }}>
<div className="relative max-w-full min-w-[92px]" style={{ textAlign: align }}>
{content}
</div>
<div className="h-0">{props.children}</div>
</figure>
</SlateElement>
);
};

View File

@ -13,7 +13,7 @@ import {
import React, { useEffect } from 'react';
import { useMemoizedFn } from '@/hooks/useMemoizedFn';
import { AddMetricModal } from '@/components/features/modal/AddMetricModal';
import { MetricPlugin, type TMetricElement } from '../../plugins/metric-plugin';
import { MetricPlugin, type TMetricElement } from '../../plugins/metric-kit';
export const MetricEmbedPlaceholder: React.FC = () => {
const [openModal, setOpenModal] = React.useState(false);

View File

@ -19,7 +19,7 @@ import { TablePlugin } from '@platejs/table/react';
import { insertToc } from '@platejs/toc';
import { type NodeEntry, type Path, type TElement, KEYS, PathApi } from 'platejs';
import { CUSTOM_KEYS } from '../config/keys';
import { insertMetric } from '../plugins/metric-plugin';
import { insertMetric } from '../plugins/metric-kit';
const ACTION_THREE_COLUMNS = 'action_three_columns';

View File

@ -6,7 +6,18 @@ import { createSlateEditor, serializeHtml } from 'platejs';
// computed CSS styles so no external CSS is required. Additionally,
// snapshot <canvas> elements inside metrics to <img> tags to avoid
// blank canvases in the exported HTML.
export const buildExportHtml = async (editor: PlateEditor): Promise<string> => {
// Options for building the export HTML document
type BuildExportHtmlOptions = {
// Optional document title to embed in the HTML head
title?: string;
};
export const buildExportHtml = async (
editor: PlateEditor,
options?: BuildExportHtmlOptions
): Promise<string> => {
// Resolve the document title with a sensible default
const documentTitle = options?.title || 'Buster Report';
// Prefer using the live editor DOM to inline computed styles
const liveRoot = document.querySelector('[contenteditable="true"]') as HTMLElement | null;
@ -89,35 +100,7 @@ export const buildExportHtml = async (editor: PlateEditor): Promise<string> => {
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: '' } }
});
// Wrap the serialized HTML to match the centered, fixed-width layout
const wrapper = document.createElement('div');
wrapper.setAttribute(
'style',
[
'width: 816px',
'max-width: 816px',
'min-width: 816px',
'margin: 24px auto',
'background: #ffffff',
'padding: 40px',
'box-sizing: border-box'
].join('; ')
);
wrapper.innerHTML = serializedHtml;
contentHtml = wrapper.outerHTML;
throw new Error('No live root found');
}
// Build a minimal HTML document without external CSS
@ -127,6 +110,7 @@ export const buildExportHtml = async (editor: PlateEditor): Promise<string> => {
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>${documentTitle}</title>
<style>
body { margin: 0; background: #f5f5f5; }
@media print {

View File

@ -0,0 +1,5 @@
import type { PlateEditor } from 'platejs/react';
export const buildExportHtml = async (editor: PlateEditor, options?: {}): Promise<string> => {
return '';
};

View File

@ -5,23 +5,29 @@ import { downloadFile } from './downloadFile';
type Notifier = (message: string) => void;
export const exportToHtml = async (
editor: PlateEditor,
openInfoMessage: Notifier,
openErrorMessage: Notifier
) => {
type ExportToHtmlOptions = {
editor: PlateEditor;
filename?: string;
openInfoMessage?: Notifier;
openErrorMessage?: Notifier;
};
export const exportToHtml = async ({
editor,
filename = 'buster-report.html',
openInfoMessage,
openErrorMessage
}: ExportToHtmlOptions) => {
try {
const html = await buildExportHtml(editor);
const html = await buildExportHtml(editor, { title: filename.replace(/\.html$/i, '') });
const url = `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
await downloadFile(url, 'plate.html');
openInfoMessage(NodeTypeLabels.htmlExportedSuccessfully.label);
await downloadFile(url, filename);
openInfoMessage?.(NodeTypeLabels.htmlExportedSuccessfully.label);
} catch (error) {
console.error(error);
openErrorMessage(NodeTypeLabels.failedToExportHtml.label);
openErrorMessage?.(NodeTypeLabels.failedToExportHtml.label);
}
};
export default exportToHtml;

View File

@ -5,18 +5,26 @@ import { downloadFile } from './downloadFile';
type Notifier = (message: string) => void;
export const exportToImage = async (
editor: PlateEditor,
openInfoMessage: Notifier,
openErrorMessage: Notifier
) => {
type ExportToImageOptions = {
editor: PlateEditor;
filename?: string;
openInfoMessage?: Notifier;
openErrorMessage?: Notifier;
};
export const exportToImage = async ({
editor,
filename = 'plate.png',
openInfoMessage,
openErrorMessage
}: ExportToImageOptions) => {
try {
const canvas = await getCanvas(editor);
await downloadFile(canvas.toDataURL('image/png'), 'plate.png');
openInfoMessage(NodeTypeLabels.imageExportedSuccessfully.label);
await downloadFile(canvas.toDataURL('image/png'), filename);
openInfoMessage?.(NodeTypeLabels.imageExportedSuccessfully.label);
} catch (error) {
console.error(error);
openErrorMessage(NodeTypeLabels.failedToExportImage.label);
openErrorMessage?.(NodeTypeLabels.failedToExportImage.label);
}
};

View File

@ -5,22 +5,26 @@ import { downloadFile } from './downloadFile';
type Notifier = (message: string) => void;
export const exportToMarkdown = async (
editor: PlateEditor,
openInfoMessage: Notifier,
openErrorMessage: Notifier
) => {
type ExportToMarkdownOptions = {
editor: PlateEditor;
openInfoMessage?: Notifier;
openErrorMessage?: Notifier;
};
export const exportToMarkdown = async ({
editor,
openInfoMessage,
openErrorMessage
}: ExportToMarkdownOptions) => {
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);
openInfoMessage?.(NodeTypeLabels.markdownExportedSuccessfully.label);
} catch (error) {
console.error(error);
openErrorMessage(NodeTypeLabels.failedToExportMarkdown.label);
openErrorMessage?.(NodeTypeLabels.failedToExportMarkdown.label);
}
};
export default exportToMarkdown;

View File

@ -4,13 +4,21 @@ import { buildExportHtml } from './buildExportHtml';
type Notifier = (message: string) => void;
export const exportToPdf = async (
editor: PlateEditor,
openInfoMessage: Notifier,
openErrorMessage: Notifier
) => {
type ExportToPdfOptions = {
filename?: string;
openInfoMessage?: Notifier;
openErrorMessage?: Notifier;
editor: PlateEditor;
};
export const exportToPdf = async ({
editor,
filename = 'Buster Report',
openInfoMessage,
openErrorMessage
}: ExportToPdfOptions) => {
try {
const html = await buildExportHtml(editor);
const html = await buildExportHtml(editor, { title: filename });
// Open a print window with the rendered HTML so the user can save as PDF
const printWindow = window.open('', '_blank');
@ -20,10 +28,22 @@ export const exportToPdf = async (
printWindow.document.write(html);
printWindow.document.close();
// Close the print window after the user prints or cancels
const handleAfterPrint = () => {
try {
// printWindow.close();
} catch (e) {
console.error('Failed to close print window', e);
}
};
printWindow.addEventListener('afterprint', handleAfterPrint);
// Trigger print when resources are loaded
const triggerPrint = () => {
try {
printWindow.focus();
// Set the title for the print window so the OS save dialog suggests it
printWindow.document.title = filename;
printWindow.print();
} catch (e) {
console.error('Failed to trigger print dialog', e);
@ -37,13 +57,11 @@ export const exportToPdf = async (
printWindow.addEventListener('load', () => setTimeout(triggerPrint, 100));
}
openInfoMessage(NodeTypeLabels.pdfExportedSuccessfully.label);
openInfoMessage?.(NodeTypeLabels.pdfExportedSuccessfully.label);
} catch (error) {
console.error(error);
openErrorMessage(NodeTypeLabels.failedToExportPdf.label);
openErrorMessage?.(NodeTypeLabels.failedToExportPdf.label);
}
};
export default exportToPdf;

View File

@ -1,5 +1,4 @@
import { useBusterNotifications } from '@/context/BusterNotifications';
import type { PlateEditor } from 'platejs/react';
import { useMemo } from 'react';
import { exportToPdf } from './exportToPdf';
import { exportToImage } from './exportToImage';
@ -9,17 +8,17 @@ import { exportToMarkdown } from './exportToMarkdown';
export const useExportReport = () => {
const { openErrorMessage, openInfoMessage } = useBusterNotifications();
const exportToPdfLocal = async (editor: PlateEditor) =>
exportToPdf(editor, openInfoMessage, openErrorMessage);
const exportToPdfLocal = async (params: Parameters<typeof exportToPdf>[0]) =>
exportToPdf({ ...params, openInfoMessage, openErrorMessage });
const exportToImageLocal = async (editor: PlateEditor) =>
exportToImage(editor, openInfoMessage, openErrorMessage);
const exportToImageLocal = async (params: Parameters<typeof exportToImage>[0]) =>
exportToImage({ ...params, openInfoMessage, openErrorMessage });
const exportToHtmlLocal = async (editor: PlateEditor) =>
exportToHtml(editor, openInfoMessage, openErrorMessage);
const exportToHtmlLocal = async (params: Parameters<typeof exportToHtml>[0]) =>
exportToHtml({ ...params, openInfoMessage, openErrorMessage });
const exportToMarkdownLocal = async (editor: PlateEditor) =>
exportToMarkdown(editor, openInfoMessage, openErrorMessage);
const exportToMarkdownLocal = async (params: Parameters<typeof exportToMarkdown>[0]) =>
exportToMarkdown({ ...params, openInfoMessage, openErrorMessage });
return useMemo(
() => ({

View File

@ -2,10 +2,10 @@
import { BannerPlugin } from './banner-plugin';
import { CharacterCounterPlugin } from './character-counter-kit';
import { MetricPlugin } from './metric-plugin';
import { MetricKit } from './metric-kit';
export const BusterStreamKit = [
//BannerPlugin,
CharacterCounterPlugin,
MetricPlugin
...MetricKit
];

View File

@ -1,2 +1,2 @@
export * from './metric-plugin';
export * from './metric-kit';
export * from './insert-metric';

View File

@ -1,7 +1,7 @@
import type { InsertNodesOptions } from 'platejs';
import type { PlateEditor } from 'platejs/react';
import { CUSTOM_KEYS } from '../../config/keys';
import { MetricPlugin, type TMetricElement } from './metric-plugin';
import { MetricPlugin, type TMetricElement } from './metric-kit';
export const insertMetric = (editor: PlateEditor, options?: InsertNodesOptions) => {
editor.tf.insertNode<TMetricElement>(
@ -19,5 +19,5 @@ export const insertMetric = (editor: PlateEditor, options?: InsertNodesOptions)
setTimeout(() => {
const plugin = editor.getPlugin(MetricPlugin);
plugin.api.metric.openAddMetricModal();
}, 50);
}, 25);
};

View File

@ -0,0 +1,15 @@
import { createPlatePlugin } from 'platejs/react';
import type { CUSTOM_KEYS } from '../../config/keys';
import type { TMetricElement } from './metric-kit';
import { MetricElementStatic } from '../../elements/MetricElement/MetricElementStatic';
export const MetricBaseKit = [
createPlatePlugin<typeof CUSTOM_KEYS.metric, {}, {}, TMetricElement>({
key: 'metric',
node: {
isElement: true,
isVoid: false,
component: MetricElementStatic
}
})
];

View File

@ -48,3 +48,5 @@ export const MetricPlugin = createPlatePlugin<
}
};
});
export const MetricKit = [MetricPlugin];

View File

@ -314,6 +314,7 @@ const useDuplicateReportSelectMenu = (): DropdownItem => {
// Download as PDF
const useDownloadPdfSelectMenu = ({ reportId }: { reportId: string }): DropdownItem => {
const { openErrorMessage } = useBusterNotifications();
const { data: reportName } = useGetReport({ reportId }, { select: (x) => x.name });
const { exportToPdf } = useExportReport();
const onClick = async () => {
@ -326,7 +327,7 @@ const useDownloadPdfSelectMenu = ({ reportId }: { reportId: string }): DropdownI
return;
}
await exportToPdf(editor);
await exportToPdf({ editor, filename: reportName });
} catch (error) {
console.error(error);
openErrorMessage('Failed to export report as PDF');