Implement native print dialog for PDF export

- Replaced jsPDF client-side generation with Tauri's native print dialog
- Created PrintView component that renders note content in a print-optimized layout
- Added print-export window capability with webview creation and print permissions
- Implemented event-based communication between main window and print window
- Moved shared print/export utilities to printExport.ts (title extraction, filename sanitization)
- Changed export button icon from download
This commit is contained in:
drelich
2026-04-05 21:30:31 +02:00
parent f5b448808d
commit 42eec85826
5 changed files with 499 additions and 156 deletions

View File

@@ -0,0 +1,366 @@
import { useEffect, useState } from 'react';
import { emit, listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow, LogicalSize } from '@tauri-apps/api/window';
import {
PrintExportPayload,
} from '../printExport';
interface PrintViewProps {
jobId: string;
}
const waitForImages = async () => {
const images = Array.from(document.images).filter((image) => !image.complete);
await Promise.all(
images.map(
(image) =>
new Promise<void>((resolve) => {
image.addEventListener('load', () => resolve(), { once: true });
image.addEventListener('error', () => resolve(), { once: true });
})
)
);
};
export function PrintView({ jobId }: PrintViewProps) {
const [payload, setPayload] = useState<PrintExportPayload | null>(null);
const [error, setError] = useState('');
useEffect(() => {
const currentWindow = getCurrentWindow();
let timeoutId = 0;
let cleanup = () => {};
void (async () => {
cleanup = await listen<PrintExportPayload>(
'print-export-payload',
(event) => {
window.clearTimeout(timeoutId);
setPayload(event.payload);
},
{
target: { kind: 'WebviewWindow', label: currentWindow.label },
}
);
await emit('print-export-ready', { jobId });
timeoutId = window.setTimeout(() => {
setError('Print data was not received. Please close this window and try exporting again.');
}, 5000);
})();
return () => {
window.clearTimeout(timeoutId);
cleanup();
};
}, [jobId]);
useEffect(() => {
if (!payload) return;
const currentWindow = getCurrentWindow();
let cancelled = false;
let printFlowStarted = false;
let lostFocusDuringPrint = false;
let closeTimerId = 0;
let destroyIntervalId = 0;
let removeFocusListener = () => {};
document.title = payload.fileName;
const closePrintWindow = () => {
if (cancelled) return;
if (destroyIntervalId) return;
window.clearTimeout(closeTimerId);
closeTimerId = window.setTimeout(() => {
destroyIntervalId = window.setInterval(() => {
void currentWindow.destroy().catch(() => undefined);
}, 250);
void currentWindow.destroy().catch(() => undefined);
}, 150);
};
const handleAfterPrint = () => {
closePrintWindow();
};
window.addEventListener('afterprint', handleAfterPrint);
void (async () => {
try {
removeFocusListener = await currentWindow.onFocusChanged(({ payload: focused }) => {
if (!printFlowStarted) return;
if (!focused) {
lostFocusDuringPrint = true;
return;
}
if (lostFocusDuringPrint) {
closePrintWindow();
}
});
if ('fonts' in document) {
await document.fonts.ready;
}
await waitForImages();
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
);
if (cancelled) return;
await currentWindow.show().catch(() => undefined);
await currentWindow.setFocus().catch(() => undefined);
window.setTimeout(async () => {
if (cancelled) return;
try {
printFlowStarted = true;
await invoke('plugin:webview|print', {
label: currentWindow.label,
});
await currentWindow
.setSize(new LogicalSize(520, 260))
.catch(() => undefined);
closePrintWindow();
} catch (err) {
console.error('Native webview print failed, falling back to window.print():', err);
printFlowStarted = true;
window.print();
}
}, 120);
} catch (err) {
console.error('Failed to initialize print view:', err);
setError('The print view could not be prepared. Please close this window and try again.');
}
})();
return () => {
cancelled = true;
window.clearTimeout(closeTimerId);
window.clearInterval(destroyIntervalId);
removeFocusListener();
window.removeEventListener('afterprint', handleAfterPrint);
};
}, [jobId, payload]);
if (error) {
return (
<div className="min-h-screen bg-gray-100 px-8 py-12 text-gray-900">
<div className="mx-auto max-w-2xl rounded-2xl bg-white p-8 shadow-sm">
<h1 className="mb-3 text-2xl font-semibold">Print Export Failed</h1>
<p>{error}</p>
</div>
</div>
);
}
if (!payload) {
return (
<div className="min-h-screen bg-gray-100 px-8 py-12 text-gray-900">
<div className="mx-auto max-w-2xl rounded-2xl bg-white p-8 shadow-sm">
Preparing print view...
</div>
</div>
);
}
return (
<>
<style>{`
:root {
color-scheme: light;
}
body {
margin: 0;
background: #e5e7eb;
color: #0f172a;
}
@page {
size: A4;
margin: 18mm 16mm 18mm 16mm;
}
@media print {
body {
background: #ffffff;
}
.print-shell {
display: block !important;
}
.print-status {
display: none !important;
}
}
`}</style>
<div className="print-status min-h-screen bg-slate-100 px-6 py-6 text-slate-900">
<div className="mx-auto flex max-w-md items-center gap-4 rounded-2xl bg-white px-5 py-4 shadow-lg">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-slate-200 border-t-slate-600" />
<div>
<div className="text-sm font-semibold">Opening system print dialog...</div>
<div className="text-sm text-slate-500">{payload.fileName}</div>
</div>
</div>
</div>
<div className="print-shell absolute left-[-200vw] top-0 min-h-screen w-[820px] bg-gray-200 px-6 py-8 print:static print:min-h-0 print:w-auto print:bg-white print:px-0 print:py-0">
<article
className="mx-auto min-h-[calc(100vh-4rem)] max-w-[820px] rounded-[20px] bg-white px-14 py-12 text-slate-900 shadow-xl print:min-h-0 print:max-w-none print:rounded-none print:px-0 print:py-0 print:shadow-none"
style={{
fontFamily: `"${payload.previewFont}", Georgia, serif`,
fontSize: `${payload.previewFontSize}px`,
lineHeight: 1.7,
}}
>
<style>{`
.print-note {
color: #0f172a;
}
.print-note h1,
.print-note h2,
.print-note h3 {
color: #020617;
font-weight: 700;
page-break-after: avoid;
}
.print-note h1 {
font-size: 2em;
line-height: 1.15;
margin: 0 0 1.35em;
letter-spacing: -0.015em;
text-align: center;
}
.print-note h2 {
font-size: 1.55em;
line-height: 1.2;
margin: 1.25em 0 0.45em;
}
.print-note h3 {
font-size: 1.25em;
line-height: 1.3;
margin: 1.1em 0 0.4em;
}
.print-note p {
margin: 0 0 0.9em;
}
.print-note ul,
.print-note ol {
margin: 0.75em 0 1em;
padding-left: 1.7em;
list-style-position: outside;
}
.print-note ul {
list-style-type: disc;
}
.print-note ol {
list-style-type: decimal;
}
.print-note li {
margin: 0.28em 0;
padding-left: 0.18em;
}
.print-note li > p {
margin: 0;
}
.print-note li::marker {
color: #334155;
}
.print-note blockquote {
margin: 1.15em 0;
padding-left: 1em;
border-left: 3px solid #cbd5e1;
color: #475569;
font-style: italic;
}
.print-note blockquote > :first-child {
margin-top: 0;
}
.print-note blockquote > :last-child {
margin-bottom: 0;
}
.print-note code {
font-family: "Source Code Pro", "Courier New", monospace;
font-size: 0.92em;
}
.print-note :not(pre) > code {
padding: 0.08em 0.28em;
border-radius: 4px;
border: 1px solid #dbe4f0;
background: #f8fafc;
color: #1e293b;
}
.print-note pre {
margin: 1em 0 1.15em;
padding: 0.9em 1em;
border-radius: 10px;
background: #f8fafc;
border: 1px solid #e2e8f0;
color: #0f172a;
white-space: pre-wrap;
overflow-wrap: anywhere;
page-break-inside: avoid;
}
.print-note pre code {
font-size: 0.92em;
line-height: 1.55;
}
.print-note a {
color: #1d4ed8;
text-decoration: underline;
}
.print-note img {
display: block;
max-width: 100%;
height: auto;
margin: 1.25em auto;
border-radius: 10px;
page-break-inside: avoid;
}
.print-note hr {
border: 0;
border-top: 1px solid #cbd5e1;
margin: 1.6em 0;
}
`}</style>
<div
className="print-note"
dangerouslySetInnerHTML={{ __html: payload.html }}
/>
</article>
</div>
</>
);
}