diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 3c4e824..a2605a2 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,9 +2,11 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for the main window", - "windows": ["main"], + "windows": ["main", "print-export-*"], "permissions": [ "core:default", + "core:webview:allow-create-webview-window", + "core:webview:allow-print", "opener:default", "http:default", { diff --git a/src/App.tsx b/src/App.tsx index a48b7e5..2a164ce 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(null); const [notes, setNotes] = useState([]); @@ -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 ; + } + + return ; +} + export default App; diff --git a/src/components/NoteEditor.tsx b/src/components/NoteEditor.tsx index 9cecb8b..b275a8f 100644 --- a/src/components/NoteEditor.tsx +++ b/src/components/NoteEditor.tsx @@ -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 => { - 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((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 ? ( @@ -797,7 +728,10 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan ) : ( - + + + + )} diff --git a/src/components/PrintView.tsx b/src/components/PrintView.tsx new file mode 100644 index 0000000..b1df11f --- /dev/null +++ b/src/components/PrintView.tsx @@ -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((resolve) => { + image.addEventListener('load', () => resolve(), { once: true }); + image.addEventListener('error', () => resolve(), { once: true }); + }) + ) + ); +}; + +export function PrintView({ jobId }: PrintViewProps) { + const [payload, setPayload] = useState(null); + const [error, setError] = useState(''); + + useEffect(() => { + const currentWindow = getCurrentWindow(); + let timeoutId = 0; + let cleanup = () => {}; + + void (async () => { + cleanup = await listen( + '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((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 ( +
+
+

Print Export Failed

+

{error}

+
+
+ ); + } + + if (!payload) { + return ( +
+
+ Preparing print view... +
+
+ ); + } + + return ( + <> + +
+
+
+
+
Opening system print dialog...
+
{payload.fileName}
+
+
+
+
+
+ +
+
+
+ + ); +} diff --git a/src/printExport.ts b/src/printExport.ts new file mode 100644 index 0000000..780fe73 --- /dev/null +++ b/src/printExport.ts @@ -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';