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:
@@ -2,9 +2,11 @@
|
|||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for the main window",
|
"description": "Capability for the main window",
|
||||||
"windows": ["main"],
|
"windows": ["main", "print-export-*"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
|
"core:webview:allow-create-webview-window",
|
||||||
|
"core:webview:allow-print",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
"http:default",
|
"http:default",
|
||||||
{
|
{
|
||||||
|
|||||||
15
src/App.tsx
15
src/App.tsx
@@ -9,8 +9,10 @@ import { syncManager, SyncStatus } from './services/syncManager';
|
|||||||
import { localDB } from './db/localDB';
|
import { localDB } from './db/localDB';
|
||||||
import { useOnlineStatus } from './hooks/useOnlineStatus';
|
import { useOnlineStatus } from './hooks/useOnlineStatus';
|
||||||
import { categoryColorsSync } from './services/categoryColorsSync';
|
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 [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
const [api, setApi] = useState<NextcloudAPI | null>(null);
|
const [api, setApi] = useState<NextcloudAPI | null>(null);
|
||||||
const [notes, setNotes] = useState<Note[]>([]);
|
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;
|
export default App;
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import jsPDF from 'jspdf';
|
|
||||||
import { message } from '@tauri-apps/plugin-dialog';
|
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 { Note } from '../types';
|
||||||
import { NextcloudAPI } from '../api/nextcloud';
|
import { NextcloudAPI } from '../api/nextcloud';
|
||||||
import { FloatingToolbar } from './FloatingToolbar';
|
import { FloatingToolbar } from './FloatingToolbar';
|
||||||
import { InsertToolbar } from './InsertToolbar';
|
import { InsertToolbar } from './InsertToolbar';
|
||||||
|
import {
|
||||||
|
getNoteTitleFromContent,
|
||||||
|
PRINT_EXPORT_QUERY_PARAM,
|
||||||
|
sanitizeFileName,
|
||||||
|
} from '../printExport';
|
||||||
|
|
||||||
interface NoteEditorProps {
|
interface NoteEditorProps {
|
||||||
note: Note | null;
|
note: Note | null;
|
||||||
@@ -30,7 +36,6 @@ marked.use({
|
|||||||
breaks: true,
|
breaks: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChanges, categories, isFocusMode, onToggleFocusMode, editorFont = 'Source Code Pro', editorFontSize = 14, previewFont = 'Merriweather', previewFontSize = 16, api }: NoteEditorProps) {
|
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 [localContent, setLocalContent] = useState('');
|
||||||
const [localCategory, setLocalCategory] = useState('');
|
const [localCategory, setLocalCategory] = useState('');
|
||||||
@@ -187,8 +192,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
|
|
||||||
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
|
const title = getNoteTitleFromContent(localContent);
|
||||||
const title = firstLine || 'Untitled';
|
|
||||||
|
|
||||||
onUpdateNote({
|
onUpdateNote({
|
||||||
...note,
|
...note,
|
||||||
@@ -214,87 +218,20 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
|||||||
setHasUnsavedChanges(false);
|
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 () => {
|
const handleExportPDF = async () => {
|
||||||
if (!note) return;
|
if (!note) return;
|
||||||
|
|
||||||
setIsExportingPDF(true);
|
setIsExportingPDF(true);
|
||||||
|
const exportState: {
|
||||||
try {
|
printWindow: WebviewWindow | null;
|
||||||
// Create PDF
|
unlistenReady: (() => void) | null;
|
||||||
const pdf = new jsPDF({
|
} = {
|
||||||
orientation: 'portrait',
|
printWindow: null,
|
||||||
unit: 'mm',
|
unlistenReady: null,
|
||||||
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 {
|
try {
|
||||||
const regularBase64 = await loadFontAsBase64(selectedFont.regular);
|
let contentForPrint = localContent;
|
||||||
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;
|
|
||||||
if (api) {
|
if (api) {
|
||||||
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||||
const matches = [...localContent.matchAll(imageRegex)];
|
const matches = [...localContent.matchAll(imageRegex)];
|
||||||
@@ -311,100 +248,95 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
|||||||
const cacheKey = `${note.id}:${imagePath}`;
|
const cacheKey = `${note.id}:${imagePath}`;
|
||||||
if (imageCache.has(cacheKey)) {
|
if (imageCache.has(cacheKey)) {
|
||||||
const dataUrl = imageCache.get(cacheKey)!;
|
const dataUrl = imageCache.get(cacheKey)!;
|
||||||
contentForPDF = contentForPDF.replace(fullMatch, ``);
|
contentForPrint = contentForPrint.replace(fullMatch, ``);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dataUrl = await api.fetchAttachment(note.id, imagePath, note.category);
|
const dataUrl = await api.fetchAttachment(note.id, imagePath, note.category);
|
||||||
imageCache.set(cacheKey, dataUrl);
|
imageCache.set(cacheKey, dataUrl);
|
||||||
contentForPDF = contentForPDF.replace(fullMatch, ``);
|
contentForPrint = contentForPrint.replace(fullMatch, ``);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to fetch attachment for PDF: ${imagePath}`, error);
|
console.error(`Failed to fetch attachment for PDF: ${imagePath}`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = document.createElement('div');
|
const title = getNoteTitleFromContent(localContent);
|
||||||
container.style.fontFamily = `"${previewFont}", Georgia, serif`;
|
const fileName = `${sanitizeFileName(title)}.pdf`;
|
||||||
container.style.fontSize = `${previewFontSize}px`;
|
const jobId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
container.style.lineHeight = '1.6';
|
const windowLabel = `print-export-${jobId}`;
|
||||||
container.style.color = '#000000';
|
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);
|
||||||
|
|
||||||
const titleElement = document.createElement('h1');
|
listen<{ jobId: string }>('print-export-ready', (event) => {
|
||||||
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
|
if (settled || event.payload.jobId !== jobId) return;
|
||||||
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');
|
settled = true;
|
||||||
const html = marked.parse(contentForPDF || '', { async: false }) as string;
|
window.clearTimeout(timeoutId);
|
||||||
contentElement.innerHTML = html;
|
exportState.unlistenReady?.();
|
||||||
contentElement.style.fontSize = `${previewFontSize}px`;
|
|
||||||
contentElement.style.lineHeight = '1.6';
|
|
||||||
contentElement.style.color = '#000000';
|
|
||||||
container.appendChild(contentElement);
|
|
||||||
|
|
||||||
const style = document.createElement('style');
|
void emitTo(windowLabel, 'print-export-payload', {
|
||||||
style.textContent = `
|
fileName,
|
||||||
body, p, h1, h2, h3, div { font-family: "${previewFont}", Georgia, serif !important; }
|
title,
|
||||||
code, pre, pre * { font-family: "Source Code Pro", "Courier New", monospace !important; }
|
html,
|
||||||
pre { background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; }
|
previewFont,
|
||||||
code { padding: 0; }
|
previewFontSize,
|
||||||
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; }
|
.then(() => resolve())
|
||||||
h3 { font-size: 1.17em; font-weight: bold; margin-top: 1em; margin-bottom: 1em; }
|
.catch(reject);
|
||||||
p { margin: 0.5em 0; }
|
})
|
||||||
ul, ol { margin: 0.5em 0; padding-left: 2em; list-style-position: outside; font-family: "${previewFont}", Georgia, serif !important; }
|
.then((unlisten) => {
|
||||||
ul { list-style-type: disc; }
|
exportState.unlistenReady = unlisten;
|
||||||
ol { list-style-type: decimal; }
|
exportState.printWindow = new WebviewWindow(windowLabel, {
|
||||||
li { margin: 0.25em 0; display: list-item; font-family: "${previewFont}", Georgia, serif !important; }
|
url: `/?${PRINT_EXPORT_QUERY_PARAM}=${encodeURIComponent(jobId)}`,
|
||||||
em { font-style: italic; vertical-align: baseline; }
|
title: 'Opening Print Dialog',
|
||||||
strong { font-weight: bold; vertical-align: baseline; line-height: inherit; }
|
width: 960,
|
||||||
img { max-width: 100%; height: auto; display: block; margin: 1em 0; }
|
height: 1100,
|
||||||
`;
|
center: true,
|
||||||
container.appendChild(style);
|
visible: true,
|
||||||
|
focus: true,
|
||||||
// Use jsPDF's html() method with custom font set
|
skipTaskbar: true,
|
||||||
await pdf.html(container, {
|
resizable: false,
|
||||||
callback: async (doc) => {
|
parent: 'main',
|
||||||
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');
|
void exportState.printWindow.once('tauri://error', (event) => {
|
||||||
}
|
if (settled) return;
|
||||||
setIsExportingPDF(false);
|
settled = true;
|
||||||
}, 500);
|
window.clearTimeout(timeoutId);
|
||||||
},
|
exportState.unlistenReady?.();
|
||||||
margin: [20, 20, 20, 20],
|
reject(new Error(String(event.payload ?? 'Failed to create print window.')));
|
||||||
autoPaging: 'text',
|
});
|
||||||
width: 170,
|
})
|
||||||
windowWidth: 650,
|
.catch(reject);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('PDF export failed:', error);
|
console.error('PDF export failed:', error);
|
||||||
|
const pendingPrintWindow = exportState.printWindow;
|
||||||
|
if (pendingPrintWindow) {
|
||||||
|
void pendingPrintWindow.close().catch(() => undefined);
|
||||||
|
}
|
||||||
try {
|
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',
|
title: 'Export Failed',
|
||||||
kind: 'error',
|
kind: 'error',
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Could not show error dialog');
|
console.error('Could not show error dialog');
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
const disposeReadyListener = exportState.unlistenReady;
|
||||||
|
if (disposeReadyListener) {
|
||||||
|
disposeReadyListener();
|
||||||
|
}
|
||||||
setIsExportingPDF(false);
|
setIsExportingPDF(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -418,8 +350,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
|||||||
onToggleFavorite(note, newFavorite);
|
onToggleFavorite(note, newFavorite);
|
||||||
} else if (note) {
|
} else if (note) {
|
||||||
// Fallback to full update if no callback provided
|
// Fallback to full update if no callback provided
|
||||||
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
|
const title = getNoteTitleFromContent(localContent);
|
||||||
const title = firstLine || 'Untitled';
|
|
||||||
|
|
||||||
onUpdateNote({
|
onUpdateNote({
|
||||||
...note,
|
...note,
|
||||||
@@ -788,7 +719,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
|||||||
? 'text-blue-500 cursor-wait'
|
? 'text-blue-500 cursor-wait'
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
: '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 ? (
|
{isExportingPDF ? (
|
||||||
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/printExport.ts
Normal file
28
src/printExport.ts
Normal 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';
|
||||||
Reference in New Issue
Block a user