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

@@ -9,8 +9,10 @@ import { syncManager, SyncStatus } from './services/syncManager';
import { localDB } from './db/localDB';
import { useOnlineStatus } from './hooks/useOnlineStatus';
import { categoryColorsSync } from './services/categoryColorsSync';
import { PrintView } from './components/PrintView';
import { PRINT_EXPORT_QUERY_PARAM } from './printExport';
function App() {
function MainApp() {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [api, setApi] = useState<NextcloudAPI | null>(null);
const [notes, setNotes] = useState<Note[]>([]);
@@ -390,4 +392,15 @@ function App() {
);
}
function App() {
const params = new URLSearchParams(window.location.search);
const printJobId = params.get(PRINT_EXPORT_QUERY_PARAM);
if (printJobId) {
return <PrintView jobId={printJobId} />;
}
return <MainApp />;
}
export default App;

View File

@@ -1,11 +1,17 @@
import { useState, useEffect, useRef } from 'react';
import { marked } from 'marked';
import jsPDF from 'jspdf';
import { message } from '@tauri-apps/plugin-dialog';
import { emitTo, listen } from '@tauri-apps/api/event';
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import { Note } from '../types';
import { NextcloudAPI } from '../api/nextcloud';
import { FloatingToolbar } from './FloatingToolbar';
import { InsertToolbar } from './InsertToolbar';
import {
getNoteTitleFromContent,
PRINT_EXPORT_QUERY_PARAM,
sanitizeFileName,
} from '../printExport';
interface NoteEditorProps {
note: Note | null;
@@ -30,7 +36,6 @@ marked.use({
breaks: true,
});
export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChanges, categories, isFocusMode, onToggleFocusMode, editorFont = 'Source Code Pro', editorFontSize = 14, previewFont = 'Merriweather', previewFontSize = 16, api }: NoteEditorProps) {
const [localContent, setLocalContent] = useState('');
const [localCategory, setLocalCategory] = useState('');
@@ -187,8 +192,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
setIsSaving(true);
setHasUnsavedChanges(false);
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
const title = firstLine || 'Untitled';
const title = getNoteTitleFromContent(localContent);
onUpdateNote({
...note,
@@ -214,87 +218,20 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
setHasUnsavedChanges(false);
};
const loadFontAsBase64 = async (fontPath: string): Promise<string> => {
const response = await fetch(fontPath);
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const base64 = reader.result as string;
// Remove data URL prefix to get just the base64 string
resolve(base64.split(',')[1]);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
};
const handleExportPDF = async () => {
if (!note) return;
setIsExportingPDF(true);
const exportState: {
printWindow: WebviewWindow | null;
unlistenReady: (() => void) | null;
} = {
printWindow: null,
unlistenReady: null,
};
try {
// Create PDF
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
});
// Load and add custom fonts based on preview font selection
const fontMap: { [key: string]: { regular: string; italic: string; name: string } } = {
'Merriweather': {
regular: '/fonts/Merriweather-VariableFont_opsz,wdth,wght.ttf',
italic: '/fonts/Merriweather-Italic-VariableFont_opsz,wdth,wght.ttf',
name: 'Merriweather'
},
'Crimson Pro': {
regular: '/fonts/CrimsonPro-VariableFont_wght.ttf',
italic: '/fonts/CrimsonPro-Italic-VariableFont_wght.ttf',
name: 'CrimsonPro'
},
'Roboto Serif': {
regular: '/fonts/RobotoSerif-VariableFont_GRAD,opsz,wdth,wght.ttf',
italic: '/fonts/RobotoSerif-Italic-VariableFont_GRAD,opsz,wdth,wght.ttf',
name: 'RobotoSerif'
},
'Average': {
regular: '/fonts/Average-Regular.ttf',
italic: '/fonts/Average-Regular.ttf', // No italic variant
name: 'Average'
}
};
const selectedFont = fontMap[previewFont];
if (selectedFont) {
try {
const regularBase64 = await loadFontAsBase64(selectedFont.regular);
pdf.addFileToVFS(`${selectedFont.name}-normal.ttf`, regularBase64);
pdf.addFont(`${selectedFont.name}-normal.ttf`, selectedFont.name, 'normal');
const italicBase64 = await loadFontAsBase64(selectedFont.italic);
pdf.addFileToVFS(`${selectedFont.name}-italic.ttf`, italicBase64);
pdf.addFont(`${selectedFont.name}-italic.ttf`, selectedFont.name, 'italic');
// Set the custom font as default
pdf.setFont(selectedFont.name, 'normal');
} catch (fontError) {
console.error('Failed to load custom font, using default:', fontError);
}
}
// Add Source Code Pro for code blocks
try {
const codeFont = await loadFontAsBase64('/fonts/SourceCodePro-VariableFont_wght.ttf');
pdf.addFileToVFS('SourceCodePro-normal.ttf', codeFont);
pdf.addFont('SourceCodePro-normal.ttf', 'SourceCodePro', 'normal');
} catch (codeFontError) {
console.error('Failed to load code font:', codeFontError);
}
// Process images to embed them as data URLs
let contentForPDF = localContent;
let contentForPrint = localContent;
if (api) {
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
const matches = [...localContent.matchAll(imageRegex)];
@@ -311,100 +248,95 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
const cacheKey = `${note.id}:${imagePath}`;
if (imageCache.has(cacheKey)) {
const dataUrl = imageCache.get(cacheKey)!;
contentForPDF = contentForPDF.replace(fullMatch, `![${alt}](${dataUrl})`);
contentForPrint = contentForPrint.replace(fullMatch, `![${alt}](${dataUrl})`);
continue;
}
try {
const dataUrl = await api.fetchAttachment(note.id, imagePath, note.category);
imageCache.set(cacheKey, dataUrl);
contentForPDF = contentForPDF.replace(fullMatch, `![${alt}](${dataUrl})`);
contentForPrint = contentForPrint.replace(fullMatch, `![${alt}](${dataUrl})`);
} catch (error) {
console.error(`Failed to fetch attachment for PDF: ${imagePath}`, error);
}
}
}
const container = document.createElement('div');
container.style.fontFamily = `"${previewFont}", Georgia, serif`;
container.style.fontSize = `${previewFontSize}px`;
container.style.lineHeight = '1.6';
container.style.color = '#000000';
const titleElement = document.createElement('h1');
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
titleElement.textContent = firstLine || 'Untitled';
titleElement.style.marginTop = '0';
titleElement.style.marginBottom = '20px';
titleElement.style.fontSize = '24px';
titleElement.style.fontWeight = 'bold';
titleElement.style.color = '#000000';
titleElement.style.textAlign = 'center';
titleElement.style.fontFamily = `"${previewFont}", Georgia, serif`;
container.appendChild(titleElement);
const contentElement = document.createElement('div');
const html = marked.parse(contentForPDF || '', { async: false }) as string;
contentElement.innerHTML = html;
contentElement.style.fontSize = `${previewFontSize}px`;
contentElement.style.lineHeight = '1.6';
contentElement.style.color = '#000000';
container.appendChild(contentElement);
const style = document.createElement('style');
style.textContent = `
body, p, h1, h2, h3, div { font-family: "${previewFont}", Georgia, serif !important; }
code, pre, pre * { font-family: "Source Code Pro", "Courier New", monospace !important; }
pre { background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; }
code { padding: 0; }
h1 { font-size: 2em; font-weight: bold; margin-top: 0.67em; margin-bottom: 0.67em; }
h2 { font-size: 1.5em; font-weight: bold; margin-top: 0.83em; margin-bottom: 0.83em; }
h3 { font-size: 1.17em; font-weight: bold; margin-top: 1em; margin-bottom: 1em; }
p { margin: 0.5em 0; }
ul, ol { margin: 0.5em 0; padding-left: 2em; list-style-position: outside; font-family: "${previewFont}", Georgia, serif !important; }
ul { list-style-type: disc; }
ol { list-style-type: decimal; }
li { margin: 0.25em 0; display: list-item; font-family: "${previewFont}", Georgia, serif !important; }
em { font-style: italic; vertical-align: baseline; }
strong { font-weight: bold; vertical-align: baseline; line-height: inherit; }
img { max-width: 100%; height: auto; display: block; margin: 1em 0; }
`;
container.appendChild(style);
const title = getNoteTitleFromContent(localContent);
const fileName = `${sanitizeFileName(title)}.pdf`;
const jobId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const windowLabel = `print-export-${jobId}`;
const html = marked.parse(contentForPrint || '', { async: false }) as string;
await new Promise<void>((resolve, reject) => {
let settled = false;
const timeoutId = window.setTimeout(() => {
if (settled) return;
settled = true;
exportState.unlistenReady?.();
reject(new Error('Print view initialization timed out.'));
}, 10000);
// Use jsPDF's html() method with custom font set
await pdf.html(container, {
callback: async (doc) => {
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
const fileName = `${firstLine || 'note'}.pdf`;
doc.save(fileName);
setTimeout(async () => {
try {
await message(`PDF exported successfully!\n\nFile: ${fileName}\nLocation: Downloads folder`, {
title: 'Export Complete',
kind: 'info',
});
} catch (err) {
console.log('Dialog shown successfully or not available');
}
setIsExportingPDF(false);
}, 500);
},
margin: [20, 20, 20, 20],
autoPaging: 'text',
width: 170,
windowWidth: 650,
listen<{ jobId: string }>('print-export-ready', (event) => {
if (settled || event.payload.jobId !== jobId) return;
settled = true;
window.clearTimeout(timeoutId);
exportState.unlistenReady?.();
void emitTo(windowLabel, 'print-export-payload', {
fileName,
title,
html,
previewFont,
previewFontSize,
})
.then(() => resolve())
.catch(reject);
})
.then((unlisten) => {
exportState.unlistenReady = unlisten;
exportState.printWindow = new WebviewWindow(windowLabel, {
url: `/?${PRINT_EXPORT_QUERY_PARAM}=${encodeURIComponent(jobId)}`,
title: 'Opening Print Dialog',
width: 960,
height: 1100,
center: true,
visible: true,
focus: true,
skipTaskbar: true,
resizable: false,
parent: 'main',
});
void exportState.printWindow.once('tauri://error', (event) => {
if (settled) return;
settled = true;
window.clearTimeout(timeoutId);
exportState.unlistenReady?.();
reject(new Error(String(event.payload ?? 'Failed to create print window.')));
});
})
.catch(reject);
});
} catch (error) {
console.error('PDF export failed:', error);
const pendingPrintWindow = exportState.printWindow;
if (pendingPrintWindow) {
void pendingPrintWindow.close().catch(() => undefined);
}
try {
await message('Failed to export PDF. Please try again.', {
await message('Failed to open the print-to-PDF view. Please try again.', {
title: 'Export Failed',
kind: 'error',
});
} catch (err) {
console.error('Could not show error dialog');
}
} finally {
const disposeReadyListener = exportState.unlistenReady;
if (disposeReadyListener) {
disposeReadyListener();
}
setIsExportingPDF(false);
}
};
@@ -418,8 +350,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
onToggleFavorite(note, newFavorite);
} else if (note) {
// Fallback to full update if no callback provided
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
const title = firstLine || 'Untitled';
const title = getNoteTitleFromContent(localContent);
onUpdateNote({
...note,
@@ -788,7 +719,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
? 'text-blue-500 cursor-wait'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={isExportingPDF ? "Generating PDF..." : "Export as PDF"}
title={isExportingPDF ? "Opening print dialog..." : "Print Note"}
>
{isExportingPDF ? (
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
@@ -797,7 +728,10 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 9V4a1 1 0 011-1h10a1 1 0 011 1v5" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18H5a2 2 0 01-2-2v-5a2 2 0 012-2h14a2 2 0 012 2v5a2 2 0 01-2 2h-1" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 14h8v7H8z" />
<circle cx="17" cy="11.5" r="0.75" fill="currentColor" stroke="none" />
</svg>
)}
</button>

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>
</>
);
}

28
src/printExport.ts Normal file
View File

@@ -0,0 +1,28 @@
export interface PrintExportPayload {
fileName: string;
title: string;
html: string;
previewFont: string;
previewFontSize: number;
}
export const PRINT_EXPORT_QUERY_PARAM = 'printJob';
const PRINT_EXPORT_STORAGE_PREFIX = 'print-export:';
export const getPrintExportStorageKey = (jobId: string) =>
`${PRINT_EXPORT_STORAGE_PREFIX}${jobId}`;
export const getNoteTitleFromContent = (content: string) => {
const firstLine = content
.split('\n')
.map((line) => line.trim())
.find((line) => line.length > 0);
return (firstLine || 'Untitled').replace(/^#+\s*/, '').trim();
};
export const sanitizeFileName = (name: string) =>
name
.replace(/[\\/:*?"<>|]/g, '-')
.replace(/\s+/g, ' ')
.trim() || 'note';