diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts
index 7019617714..27fc2e1396 100644
--- a/apps/client/src/components/app_context.ts
+++ b/apps/client/src/components/app_context.ts
@@ -24,6 +24,7 @@ import { IncludeNoteOpts } from "../widgets/dialogs/include_note.jsx";
import type { InfoProps } from "../widgets/dialogs/info.jsx";
import type { MarkdownImportOpts } from "../widgets/dialogs/markdown_import.jsx";
import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx";
+import type { PrintPreviewData } from "../widgets/dialogs/print_preview.jsx";
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
import type NoteTreeWidget from "../widgets/note_tree.js";
import Component from "./component.js";
@@ -330,6 +331,7 @@ export type CommandMappings = {
toggleRightPane: CommandData;
printActiveNote: CommandData;
exportAsPdf: CommandData;
+ showPrintPreview: PrintPreviewData;
openNoteExternally: CommandData;
openNoteCustom: CommandData;
openNoteOnServer: CommandData;
diff --git a/apps/client/src/layouts/layout_commons.tsx b/apps/client/src/layouts/layout_commons.tsx
index 50550ea4b5..52f232eaa7 100644
--- a/apps/client/src/layouts/layout_commons.tsx
+++ b/apps/client/src/layouts/layout_commons.tsx
@@ -24,6 +24,7 @@ import InfoDialog from "../widgets/dialogs/info.js";
import IncorrectCpuArchDialog from "../widgets/dialogs/incorrect_cpu_arch.js";
import CallToActionDialog from "../widgets/dialogs/call_to_action.jsx";
import PopupEditorDialog from "../widgets/dialogs/PopupEditor.jsx";
+import PrintPreviewDialog from "../widgets/dialogs/print_preview.jsx";
import ToastContainer from "../widgets/Toast.jsx";
export function applyModals(rootContainer: RootContainer) {
@@ -51,6 +52,7 @@ export function applyModals(rootContainer: RootContainer) {
.child()
.child()
.child()
+ .child()
.child()
.child();
}
diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json
index 0e335e4da0..9fb2b1ec0f 100644
--- a/apps/client/src/translations/en/translation.json
+++ b/apps/client/src/translations/en/translation.json
@@ -2305,6 +2305,11 @@
"toggle": "Toggle right panel",
"custom_widget_go_to_source": "Go to source code"
},
+ "print_preview": {
+ "title": "Print preview",
+ "close": "Close",
+ "save": "Save as PDF"
+ },
"pdf": {
"attachments_one": "{{count}} attachment",
"attachments_other": "{{count}} attachments",
diff --git a/apps/client/src/widgets/NoteDetail.tsx b/apps/client/src/widgets/NoteDetail.tsx
index 40ae3e49e9..26765d46d5 100644
--- a/apps/client/src/widgets/NoteDetail.tsx
+++ b/apps/client/src/widgets/NoteDetail.tsx
@@ -4,6 +4,7 @@ import clsx from "clsx";
import { isValidElement, VNode } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
+import appContext from "../components/app_context";
import NoteContext from "../components/note_context";
import FNote from "../entities/fnote";
import type { PrintReport } from "../print";
@@ -146,11 +147,17 @@ export default function NoteDetail() {
toast.closePersistent("printing");
handlePrintReport(printReport);
};
+ const onPreviewResult = (_e: any, { buffer, title }: { buffer: Uint8Array; title: string }) => {
+ toast.closePersistent("printing");
+ appContext.triggerCommand("showPrintPreview", { pdfBuffer: buffer, title });
+ };
ipcRenderer.on("print-progress", onPrintProgress);
ipcRenderer.on("print-done", onPrintDone);
+ ipcRenderer.on("export-as-pdf-preview-result", onPreviewResult);
return () => {
ipcRenderer.off("print-progress", onPrintProgress);
ipcRenderer.off("print-done", onPrintDone);
+ ipcRenderer.off("export-as-pdf-preview-result", onPreviewResult);
};
}, []);
@@ -215,7 +222,7 @@ export default function NoteDetail() {
showToast("exporting_pdf");
const { ipcRenderer } = dynamicRequire("electron");
- ipcRenderer.send("export-as-pdf", {
+ ipcRenderer.send("export-as-pdf-preview", {
title: note.title,
notePath: noteContext.notePath,
pageSize: note.getAttributeValue("label", "printPageSize") ?? "Letter",
diff --git a/apps/client/src/widgets/dialogs/print_preview.tsx b/apps/client/src/widgets/dialogs/print_preview.tsx
new file mode 100644
index 0000000000..ca8cfdfd46
--- /dev/null
+++ b/apps/client/src/widgets/dialogs/print_preview.tsx
@@ -0,0 +1,68 @@
+import { useRef, useState } from "preact/hooks";
+import Modal from "../react/Modal";
+import PdfViewer from "../type_widgets/file/PdfViewer";
+import Button from "../react/Button";
+import { useTriliumEvent } from "../react/hooks";
+import { t } from "../../services/i18n";
+import { dynamicRequire } from "../../services/utils";
+
+export interface PrintPreviewData {
+ pdfBuffer: Uint8Array;
+ title: string;
+}
+
+export default function PrintPreviewDialog() {
+ const [shown, setShown] = useState(false);
+ const [pdfUrl, setPdfUrl] = useState();
+ const bufferRef = useRef();
+ const titleRef = useRef("");
+
+ useTriliumEvent("showPrintPreview", (data: PrintPreviewData) => {
+ bufferRef.current = data.pdfBuffer;
+ titleRef.current = data.title;
+
+ const blob = new Blob([data.pdfBuffer as BlobPart], { type: "application/pdf" });
+ const url = URL.createObjectURL(blob);
+ setPdfUrl(url);
+ setShown(true);
+ });
+
+ function handleClose() {
+ setShown(false);
+ if (pdfUrl) {
+ URL.revokeObjectURL(pdfUrl);
+ setPdfUrl(undefined);
+ }
+ bufferRef.current = undefined;
+ }
+
+ function handleSave() {
+ if (!bufferRef.current) return;
+
+ const { ipcRenderer } = dynamicRequire("electron");
+ ipcRenderer.send("save-pdf", {
+ title: titleRef.current,
+ buffer: bufferRef.current
+ });
+ handleClose();
+ }
+
+ return (
+
+
+
+ >
+ }
+ >
+ {pdfUrl && }
+
+ );
+}
diff --git a/apps/server/src/services/window.ts b/apps/server/src/services/window.ts
index 44ab3b2281..d0a83c30b8 100644
--- a/apps/server/src/services/window.ts
+++ b/apps/server/src/services/window.ts
@@ -168,6 +168,66 @@ electron.ipcMain.on("export-as-pdf", async (e, { title, notePath, landscape, pag
}
});
+electron.ipcMain.on("export-as-pdf-preview", async (e, { title, notePath, landscape, pageSize }: ExportAsPdfOpts) => {
+ try {
+ const { browserWindow, printReport } = await getBrowserWindowForPrinting(e, notePath, "exporting_pdf");
+
+ try {
+ const buffer = await browserWindow.webContents.printToPDF({
+ landscape,
+ pageSize,
+ generateDocumentOutline: true,
+ generateTaggedPDF: true,
+ printBackground: true,
+ displayHeaderFooter: true,
+ headerTemplate: ``,
+ footerTemplate: `
+
+
+ `
+ });
+
+ e.sender.send("export-as-pdf-preview-result", { buffer, title });
+ } catch (_e) {
+ electron.dialog.showErrorBox(t("pdf.unable-to-export-title"), t("pdf.unable-to-export-message"));
+ } finally {
+ e.sender.send("print-done", printReport);
+ browserWindow.destroy();
+ }
+ } catch (err) {
+ e.sender.send("print-done", {
+ type: "error",
+ message: err instanceof Error ? err.message : String(err),
+ stack: err instanceof Error ? err.stack : undefined
+ });
+ }
+});
+
+electron.ipcMain.on("save-pdf", async (_e, { title, buffer }: { title: string; buffer: Buffer }) => {
+ const focusedWindow = electron.BrowserWindow.getFocusedWindow();
+ if (!focusedWindow) return;
+
+ const filePath = electron.dialog.showSaveDialogSync(focusedWindow, {
+ defaultPath: formatDownloadTitle(title, "file", "application/pdf"),
+ filters: [
+ {
+ name: t("pdf.export_filter"),
+ extensions: ["pdf"]
+ }
+ ]
+ });
+ if (!filePath) return;
+
+ try {
+ await fs.writeFile(filePath, Buffer.from(buffer));
+ } catch (_e) {
+ electron.dialog.showErrorBox(t("pdf.unable-to-export-title"), t("pdf.unable-to-save-message"));
+ return;
+ }
+
+ electron.shell.openPath(filePath);
+});
+
async function getBrowserWindowForPrinting(e: IpcMainEvent, notePath: string, action: "printing" | "exporting_pdf") {
// Offscreen rendering crashes on Wayland due to a Chromium bug where the OSR surface
// lacks a valid xdg_toplevel role, causing a fatal zxdg_exporter_v2 protocol error.