feat(print): integrate with print preview and merge with export to PDF

This commit is contained in:
Elian Doran
2026-04-15 17:37:27 +03:00
parent edb2ec2a6f
commit 94e70c0318
5 changed files with 128 additions and 51 deletions

View File

@@ -1808,7 +1808,7 @@
"note_detail": {
"could_not_find_typewidget": "Could not find typeWidget for type '{{type}}'",
"printing": "Printing in progress...",
"printing_pdf": "Exporting to PDF in progress...",
"printing_pdf": "Preparing print preview...",
"print_report_title": "Print report",
"print_report_error_title": "Failed to print",
"print_report_stack_trace": "Stack trace",
@@ -2325,6 +2325,9 @@
"title": "Print preview",
"close": "Close",
"save": "Save as PDF",
"print": "Print",
"export_pdf": "Export as PDF",
"system_print": "Print using system dialog",
"orientation": "Orientation",
"portrait": "Portrait",
"landscape": "Landscape",

View File

@@ -182,57 +182,51 @@ export default function NoteDetail() {
useTriliumEvent("printActiveNote", () => {
if (!noteContext?.isActive() || !note) return;
showToast("printing");
if (isElectron()) {
// On Electron, open the print preview dialog. Actual print/PDF actions
// are triggered from the dialog's footer buttons.
showToast("exporting_pdf");
const { ipcRenderer } = dynamicRequire("electron");
ipcRenderer.send("print-note", {
notePath: noteContext.notePath
ipcRenderer.send("export-as-pdf-preview", {
title: note.title,
notePath: noteContext.notePath,
pageSize: note.getAttributeValue("label", "printPageSize") ?? "Letter",
landscape: note.hasAttribute("label", "printLandscape"),
scale: parseFloat(note.getAttributeValue("label", "printScale") ?? "1") || 1,
margins: note.getAttributeValue("label", "printMargins") ?? "default",
pageRanges: ""
});
} else {
const iframe = document.createElement('iframe');
iframe.src = `?print#${noteContext.notePath}`;
iframe.className = "print-iframe";
document.body.appendChild(iframe);
iframe.onload = () => {
if (!iframe.contentWindow) {
toast.closePersistent("printing");
document.body.removeChild(iframe);
return;
return;
}
// Browser fallback: render the print page in a hidden iframe and use window.print().
showToast("printing");
const iframe = document.createElement('iframe');
iframe.src = `?print#${noteContext.notePath}`;
iframe.className = "print-iframe";
document.body.appendChild(iframe);
iframe.onload = () => {
if (!iframe.contentWindow) {
toast.closePersistent("printing");
document.body.removeChild(iframe);
return;
}
iframe.contentWindow.addEventListener("note-load-progress", (e) => {
showToast("printing", e.detail.progress);
});
iframe.contentWindow.addEventListener("note-ready", (e) => {
toast.closePersistent("printing");
if ("detail" in e) {
handlePrintReport(e.detail as PrintReport);
}
iframe.contentWindow.addEventListener("note-load-progress", (e) => {
showToast("printing", e.detail.progress);
});
iframe.contentWindow.addEventListener("note-ready", (e) => {
toast.closePersistent("printing");
if ("detail" in e) {
handlePrintReport(e.detail as PrintReport);
}
iframe.contentWindow?.print();
document.body.removeChild(iframe);
});
};
}
});
useTriliumEvent("exportAsPdf", () => {
if (!noteContext?.isActive() || !note) return;
showToast("exporting_pdf");
const { ipcRenderer } = dynamicRequire("electron");
ipcRenderer.send("export-as-pdf-preview", {
title: note.title,
notePath: noteContext.notePath,
pageSize: note.getAttributeValue("label", "printPageSize") ?? "Letter",
landscape: note.hasAttribute("label", "printLandscape"),
scale: parseFloat(note.getAttributeValue("label", "printScale") ?? "1") || 1,
margins: note.getAttributeValue("label", "printMargins") ?? "default",
pageRanges: ""
});
iframe.contentWindow?.print();
document.body.removeChild(iframe);
});
};
});
return (

View File

@@ -110,7 +110,7 @@ export default function PrintPreviewDialog() {
setLoading(false);
}
function handleSave() {
function handleExportPdf() {
if (!bufferRef.current) return;
const { ipcRenderer } = dynamicRequire("electron");
@@ -121,6 +121,21 @@ export default function PrintPreviewDialog() {
handleClose();
}
function handlePrint(silent: boolean) {
if (!isElectron()) return;
const { ipcRenderer } = dynamicRequire("electron");
ipcRenderer.send("print-from-preview", {
notePath: notePathRef.current,
pageSize,
landscape,
scale,
margins: marginsStr,
pageRanges,
silent
});
handleClose();
}
function handleOrientationChange(newLandscape: boolean) {
if (newLandscape === landscape) return;
setLandscape(newLandscape);
@@ -215,10 +230,23 @@ export default function PrintPreviewDialog() {
show={shown}
onHidden={handleClose}
bodyStyle={{ height: "78vh", padding: 0, display: "flex" }}
footerAlignment="between"
footer={
<>
<Button text={t("print_preview.close")} onClick={handleClose} />
<Button text={t("print_preview.save")} className="btn-primary" onClick={handleSave} disabled={loading} />
<a
href="#"
class={loading ? "disabled" : ""}
onClick={(e) => {
e.preventDefault();
if (!loading) handlePrint(false);
}}
>
{t("print_preview.system_print")}
</a>
<div style={{ display: "flex", gap: "8px" }}>
<Button text={t("print_preview.export_pdf")} icon="bx-file" onClick={handleExportPdf} disabled={loading} />
<Button text={t("print_preview.print")} icon="bx-printer" className="btn-primary" onClick={() => handlePrint(true)} disabled={loading} />
</div>
</>
}
>

View File

@@ -143,7 +143,6 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote
notePath: noteContext.notePath,
defaultType: "single"
})} />
{isElectron && <CommandItem command="exportAsPdf" icon="bx bxs-file-pdf" disabled={!isPrintable} text={t("note_actions.print_pdf")} />}
{isExportableToImage && isNormalViewMode && isContentAvailable && <ExportAsImage ntxId={noteContext.ntxId} parentComponent={parentComponent} />}
<CommandItem command="printActiveNote" icon="bx bx-printer" disabled={!isPrintable} text={t("note_actions.print_note")} />

View File

@@ -270,6 +270,59 @@ electron.ipcMain.on("save-pdf", async (_e, { title, buffer }: { title: string; b
electron.shell.openPath(filePath);
});
interface PrintFromPreviewOpts extends ExportAsPdfOpts {
silent: boolean;
}
electron.ipcMain.on("print-from-preview", async (e, { notePath, landscape, pageSize, scale, margins, pageRanges, silent }: PrintFromPreviewOpts) => {
try {
const { browserWindow, printReport } = await getBrowserWindowForPrinting(e, notePath, "printing");
// print() accepts most of the same options as printToPDF, but typing differs
// slightly (e.g. no "Ledger" pageSize). Cast to keep this concise.
const printOpts: Electron.WebContentsPrintOptions = {
silent,
landscape,
pageSize: pageSize === "Ledger" ? "Tabloid" : pageSize,
scaleFactor: Math.round(scale * 100),
margins: parseMargins(margins),
pageRanges: parsePageRangesForPrint(pageRanges),
printBackground: true
};
browserWindow.webContents.print(printOpts, (success, failureReason) => {
if (!success && failureReason !== "Print job canceled") {
electron.dialog.showErrorBox(t("pdf.unable-to-print"), failureReason);
}
e.sender.send("print-from-preview-done");
e.sender.send("print-done", printReport);
browserWindow.destroy();
});
} catch (err) {
e.sender.send("print-from-preview-done");
e.sender.send("print-done", {
type: "error",
message: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined
});
}
});
/** Convert "1-5, 8, 11-13" into PageRanges array form expected by webContents.print. */
function parsePageRangesForPrint(pageRanges: string): { from: number; to: number }[] | undefined {
if (!pageRanges?.trim()) return undefined;
const ranges: { from: number; to: number }[] = [];
for (const part of pageRanges.split(",")) {
const trimmed = part.trim();
if (!trimmed) continue;
const [fromStr, toStr] = trimmed.split("-").map(s => s.trim());
const from = parseInt(fromStr, 10);
const to = toStr ? parseInt(toStr, 10) : from;
if (!isNaN(from) && !isNaN(to)) ranges.push({ from, to });
}
return ranges.length ? ranges : undefined;
}
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.