Skip to content

Commit a4c1d18

Browse files
committed
Add PDF note export for desktop and web
1 parent cd1dc81 commit a4c1d18

23 files changed

Lines changed: 724 additions & 19 deletions

File tree

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@zennotes/desktop",
33
"productName": "ZenNotes",
4-
"version": "1.1.3",
4+
"version": "1.1.4",
55
"description": "ZenNotes desktop shell",
66
"private": true,
77
"main": "./out/main/index.js",

apps/desktop/src/main/index.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,6 +766,124 @@ async function disconnectRemoteWorkspace(): Promise<VaultInfo | null> {
766766
return null
767767
}
768768

769+
function noteTitleFromRelPath(relPath: string): string {
770+
const base = path.posix.basename(relPath)
771+
return base.replace(/\.md$/i, '') || 'Note'
772+
}
773+
774+
function sanitizePdfFilename(name: string): string {
775+
const sanitized = name
776+
.replace(/[<>:"/\\|?*\x00-\x1f]/g, ' ')
777+
.replace(/\s+/g, ' ')
778+
.trim()
779+
return sanitized || 'Note'
780+
}
781+
782+
function ensurePdfExtension(targetPath: string): string {
783+
return targetPath.toLowerCase().endsWith('.pdf') ? targetPath : `${targetPath}.pdf`
784+
}
785+
786+
async function waitForExportWindowState(
787+
win: BrowserWindow,
788+
timeoutMs = 15000
789+
): Promise<void> {
790+
const startedAt = Date.now()
791+
while (!win.isDestroyed()) {
792+
const state = await win.webContents.executeJavaScript(
793+
'document.body?.dataset.exportState ?? ""',
794+
true
795+
)
796+
if (state === 'ready') return
797+
if (state === 'error') {
798+
const message = await win.webContents.executeJavaScript(
799+
'document.body?.dataset.exportError ?? "The export renderer reported an error."',
800+
true
801+
)
802+
throw new Error(typeof message === 'string' ? message : 'The export renderer reported an error.')
803+
}
804+
if (Date.now() - startedAt >= timeoutMs) {
805+
throw new Error('Timed out while preparing the note preview for PDF export.')
806+
}
807+
await new Promise((resolve) => setTimeout(resolve, 100))
808+
}
809+
throw new Error('The export window closed before PDF export completed.')
810+
}
811+
812+
async function exportNotePdf(
813+
relPath: string,
814+
parentWindow: BrowserWindow | null | undefined
815+
): Promise<string | null> {
816+
const current = currentVault ?? (isRemoteWorkspaceActive() ? await requireRemoteWorkspaceClient().getCurrentVault() : null)
817+
if (!current) {
818+
throw new Error('No active vault is available for PDF export.')
819+
}
820+
821+
const suggestedName = `${sanitizePdfFilename(noteTitleFromRelPath(relPath))}.pdf`
822+
const result = await dialog.showSaveDialog(parentWindow ?? undefined, {
823+
title: 'Export Note as PDF',
824+
defaultPath: path.join(app.getPath('documents'), suggestedName),
825+
buttonLabel: 'Export PDF',
826+
filters: [{ name: 'PDF', extensions: ['pdf'] }]
827+
})
828+
if (result.canceled || !result.filePath) return null
829+
830+
const targetPath = ensurePdfExtension(result.filePath)
831+
const mac = isMac()
832+
const exportWindow = new BrowserWindow({
833+
width: 1024,
834+
height: 1400,
835+
show: false,
836+
autoHideMenuBar: true,
837+
titleBarStyle: mac ? 'hiddenInset' : 'hidden',
838+
trafficLightPosition: { x: 12, y: 12 },
839+
...(mac
840+
? {
841+
backgroundColor: '#ffffff'
842+
}
843+
: {
844+
backgroundColor: '#ffffff',
845+
icon: windowIconPath()
846+
}),
847+
webPreferences: {
848+
preload: path.join(__dirname, '../preload/index.js'),
849+
sandbox: false,
850+
contextIsolation: true,
851+
nodeIntegration: false
852+
}
853+
})
854+
855+
try {
856+
installNavigationGuards(exportWindow)
857+
applyZoomFactor(exportWindow, currentZoomFactor)
858+
const params = `?exportNote=${encodeURIComponent(relPath)}`
859+
const devServerUrl = process.env['ELECTRON_RENDERER_URL']
860+
if (devServerUrl) {
861+
await exportWindow.loadURL(`${devServerUrl}${params}`)
862+
} else {
863+
await exportWindow.loadFile(path.join(__dirname, '../renderer/index.html'), {
864+
search: params.slice(1)
865+
})
866+
}
867+
868+
await waitForExportWindowState(exportWindow)
869+
await exportWindow.webContents.executeJavaScript(
870+
'document.fonts ? document.fonts.ready.then(() => true) : Promise.resolve(true)',
871+
true
872+
)
873+
const pdf = await exportWindow.webContents.printToPDF({
874+
printBackground: true,
875+
preferCSSPageSize: true
876+
})
877+
await fsp.mkdir(path.dirname(targetPath), { recursive: true })
878+
await fsp.writeFile(targetPath, pdf)
879+
return targetPath
880+
} finally {
881+
if (!exportWindow.isDestroyed()) {
882+
exportWindow.destroy()
883+
}
884+
}
885+
}
886+
769887
async function listRemoteWorkspaceProfiles(): Promise<RemoteWorkspaceProfile[]> {
770888
const cfg = await loadConfig()
771889
return await Promise.all(
@@ -1346,6 +1464,10 @@ function registerIpc(): void {
13461464
return await duplicateNote(v.root, relPath)
13471465
})
13481466

1467+
handle(IPC.VAULT_EXPORT_NOTE_PDF, async (event, relPath: string) => {
1468+
return await exportNotePdf(relPath, BrowserWindow.fromWebContents(event.sender))
1469+
})
1470+
13491471
handle(IPC.VAULT_REVEAL_NOTE, async (_e, relPath: string) => {
13501472
if (isRemoteWorkspaceActive()) {
13511473
throw new Error('Reveal in file manager is only available for local vaults.')

apps/desktop/src/preload/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,8 @@ const api: ZenBridge = {
241241
ipcRenderer.invoke(IPC.VAULT_UNARCHIVE_NOTE, relPath),
242242
duplicateNote: (relPath: string): Promise<NoteMeta> =>
243243
ipcRenderer.invoke(IPC.VAULT_DUPLICATE_NOTE, relPath),
244+
exportNotePdf: (relPath: string): Promise<string | null> =>
245+
ipcRenderer.invoke(IPC.VAULT_EXPORT_NOTE_PDF, relPath),
244246
revealNote: (relPath: string): Promise<void> => ipcRenderer.invoke(IPC.VAULT_REVEAL_NOTE, relPath),
245247
moveNote: (
246248
relPath: string,
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import React, { useEffect, useState } from 'react'
2+
import ReactDOM from 'react-dom/client'
3+
import type { AssetMeta, NoteContent, NoteMeta, VaultInfo } from '@shared/ipc'
4+
import { Preview } from '@renderer/components/Preview'
5+
import { useStore } from '@renderer/store'
6+
import '@renderer/styles/index.css'
7+
8+
const PREFS_KEY = 'zen:prefs:v2'
9+
10+
type ExportPrefs = {
11+
editorFontSize: number
12+
editorLineHeight: number
13+
previewMaxWidth: number
14+
editorMaxWidth: number
15+
contentAlign: 'center' | 'left'
16+
interfaceFont: string | null
17+
textFont: string | null
18+
monoFont: string | null
19+
}
20+
21+
const DEFAULT_EXPORT_PREFS: ExportPrefs = {
22+
editorFontSize: 16,
23+
editorLineHeight: 1.7,
24+
previewMaxWidth: 920,
25+
editorMaxWidth: 920,
26+
contentAlign: 'center',
27+
interfaceFont: null,
28+
textFont: null,
29+
monoFont: null
30+
}
31+
32+
function setExportState(state: 'loading' | 'ready' | 'error', message?: string): void {
33+
if (!document.body) return
34+
document.body.dataset.exportState = state
35+
if (message) document.body.dataset.exportError = message
36+
else delete document.body.dataset.exportError
37+
}
38+
39+
function safeString(value: unknown): string | null {
40+
return typeof value === 'string' && value.trim() ? value : null
41+
}
42+
43+
function safeNumber(value: unknown, fallback: number): number {
44+
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
45+
}
46+
47+
function loadExportPrefs(): ExportPrefs {
48+
try {
49+
const raw = window.localStorage.getItem(PREFS_KEY)
50+
if (!raw) return DEFAULT_EXPORT_PREFS
51+
const parsed = JSON.parse(raw) as Record<string, unknown>
52+
const contentAlign = parsed.contentAlign === 'left' ? 'left' : 'center'
53+
return {
54+
editorFontSize: safeNumber(parsed.editorFontSize, DEFAULT_EXPORT_PREFS.editorFontSize),
55+
editorLineHeight: safeNumber(parsed.editorLineHeight, DEFAULT_EXPORT_PREFS.editorLineHeight),
56+
previewMaxWidth: safeNumber(parsed.previewMaxWidth, DEFAULT_EXPORT_PREFS.previewMaxWidth),
57+
editorMaxWidth: safeNumber(parsed.editorMaxWidth, DEFAULT_EXPORT_PREFS.editorMaxWidth),
58+
contentAlign,
59+
interfaceFont: safeString(parsed.interfaceFont),
60+
textFont: safeString(parsed.textFont),
61+
monoFont: safeString(parsed.monoFont)
62+
}
63+
} catch {
64+
return DEFAULT_EXPORT_PREFS
65+
}
66+
}
67+
68+
function applyExportPrefs(prefs: ExportPrefs): void {
69+
const html = document.documentElement
70+
html.dataset.theme = 'github-light'
71+
html.dataset.contentAlign = prefs.contentAlign
72+
html.setAttribute('data-opaque', '')
73+
html.style.colorScheme = 'light'
74+
html.style.setProperty('--z-editor-font-size', `${prefs.editorFontSize}px`)
75+
html.style.setProperty('--z-editor-line-height', String(prefs.editorLineHeight))
76+
html.style.setProperty('--z-preview-max-width', `${prefs.previewMaxWidth}px`)
77+
html.style.setProperty('--z-editor-max-width', `${prefs.editorMaxWidth}px`)
78+
79+
const setFont = (name: string, value: string | null, fallback: string): void => {
80+
if (value) html.style.setProperty(name, `"${value}", ${fallback}`)
81+
else html.style.removeProperty(name)
82+
}
83+
setFont(
84+
'--z-interface-font',
85+
prefs.interfaceFont,
86+
'-apple-system, BlinkMacSystemFont, "SF Pro Text", Inter, system-ui, sans-serif'
87+
)
88+
setFont(
89+
'--z-text-font',
90+
prefs.textFont,
91+
'"SF Mono", "SFMono-Regular", ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace'
92+
)
93+
setFont(
94+
'--z-mono-font',
95+
prefs.monoFont,
96+
'"SF Mono", "SFMono-Regular", ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace'
97+
)
98+
}
99+
100+
function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element {
101+
const [note, setNote] = useState<NoteContent | null>(null)
102+
const [error, setError] = useState<string | null>(null)
103+
104+
useEffect(() => {
105+
applyExportPrefs(loadExportPrefs())
106+
setExportState('loading')
107+
108+
let cancelled = false
109+
110+
const load = async (): Promise<void> => {
111+
try {
112+
const [vault, notes, assetFiles, noteContent] = await Promise.all([
113+
window.zen.getCurrentVault(),
114+
window.zen.listNotes(),
115+
window.zen.listAssets(),
116+
window.zen.readNote(notePath)
117+
])
118+
if (cancelled) return
119+
if (!vault) {
120+
throw new Error('No active vault was available for PDF export.')
121+
}
122+
123+
useStore.setState({
124+
vault: vault as VaultInfo,
125+
notes: notes as NoteMeta[],
126+
assetFiles: assetFiles as AssetMeta[],
127+
selectedPath: noteContent.path,
128+
activeNote: noteContent
129+
})
130+
document.title = noteContent.title
131+
setNote(noteContent)
132+
} catch (err) {
133+
if (cancelled) return
134+
const message = err instanceof Error ? err.message : String(err)
135+
setError(message)
136+
setExportState('error', message)
137+
}
138+
}
139+
140+
void load()
141+
142+
return () => {
143+
cancelled = true
144+
}
145+
}, [notePath])
146+
147+
if (error) {
148+
return (
149+
<main className="min-h-screen bg-[color:rgb(var(--z-bg))] px-10 py-12 text-[color:rgb(var(--z-fg))]">
150+
<div className="mx-auto max-w-3xl rounded-2xl border border-[color:rgb(var(--z-red)/0.35)] bg-[color:rgb(var(--z-bg-1))] px-6 py-5">
151+
<h1 className="text-xl font-semibold text-[color:rgb(var(--z-red))]">PDF export failed</h1>
152+
<p className="mt-3 whitespace-pre-wrap text-sm leading-7 text-[color:rgb(var(--z-fg-2))]">
153+
{error}
154+
</p>
155+
</div>
156+
</main>
157+
)
158+
}
159+
160+
if (!note) {
161+
return (
162+
<main className="min-h-screen bg-[color:rgb(var(--z-bg))] px-10 py-12 text-[color:rgb(var(--z-fg))]">
163+
<div className="mx-auto max-w-3xl rounded-2xl border border-[color:rgb(var(--z-bg-3))] bg-[color:rgb(var(--z-bg-1))] px-6 py-5">
164+
<p className="text-sm leading-7 text-[color:rgb(var(--z-fg-2))]">Preparing note export…</p>
165+
</div>
166+
</main>
167+
)
168+
}
169+
170+
return (
171+
<>
172+
<style>{`
173+
@page {
174+
margin: 0.7in;
175+
}
176+
html,
177+
body,
178+
#root {
179+
height: auto !important;
180+
min-height: 0 !important;
181+
overflow: visible !important;
182+
background: #ffffff !important;
183+
}
184+
body,
185+
#root {
186+
display: block !important;
187+
margin: 0 !important;
188+
padding: 0 !important;
189+
}
190+
body {
191+
user-select: text !important;
192+
}
193+
.export-note-shell {
194+
min-height: auto;
195+
width: 100%;
196+
overflow: visible;
197+
background: #ffffff;
198+
color: rgb(var(--z-fg));
199+
}
200+
.export-note-shell .prose-zen {
201+
padding: 32px 40px 48px;
202+
}
203+
@media print {
204+
html,
205+
body,
206+
#root {
207+
height: auto !important;
208+
min-height: 0 !important;
209+
overflow: visible !important;
210+
background: #ffffff !important;
211+
}
212+
.export-note-shell {
213+
min-height: auto;
214+
overflow: visible;
215+
}
216+
.export-note-shell .prose-zen {
217+
max-width: none;
218+
width: 100%;
219+
padding: 0;
220+
margin: 0;
221+
}
222+
}
223+
`}</style>
224+
<main className="export-note-shell">
225+
<Preview
226+
markdown={note.body}
227+
notePath={note.path}
228+
onRendered={() => setExportState('ready')}
229+
/>
230+
</main>
231+
</>
232+
)
233+
}
234+
235+
export function renderExportNoteWindow(root: HTMLElement, notePath: string): void {
236+
ReactDOM.createRoot(root).render(<ExportNoteWindow notePath={notePath} />)
237+
}

0 commit comments

Comments
 (0)