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:
366
src/components/PrintView.tsx
Normal file
366
src/components/PrintView.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user