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:
15
src/App.tsx
15
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<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;
|
||||
|
||||
@@ -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, ``);
|
||||
contentForPrint = contentForPrint.replace(fullMatch, ``);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const dataUrl = await api.fetchAttachment(note.id, imagePath, note.category);
|
||||
imageCache.set(cacheKey, dataUrl);
|
||||
contentForPDF = contentForPDF.replace(fullMatch, ``);
|
||||
contentForPrint = contentForPrint.replace(fullMatch, ``);
|
||||
} 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>
|
||||
|
||||
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