From bf5d6c4e01e2791bc3452c67be5245e4ff19267b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 15 Apr 2026 10:26:25 +0300 Subject: [PATCH] feat(print/pdf): basic support for printing only a subset of pages --- .../src/translations/en/translation.json | 6 ++- apps/client/src/widgets/NoteDetail.tsx | 3 +- .../src/widgets/dialogs/print_preview.tsx | 54 ++++++++++++++++--- apps/server/src/services/window.ts | 7 ++- 4 files changed, 60 insertions(+), 10 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 6dbc18ead7..e5dcef0d1b 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -2339,7 +2339,11 @@ "margin_top": "Top", "margin_right": "Right", "margin_bottom": "Bottom", - "margin_left": "Left" + "margin_left": "Left", + "page_ranges": "Pages", + "page_ranges_hint": "Leave empty to print all pages.", + "page_ranges_invalid": "Invalid format. Use e.g. 1-5, 8, 11-13.", + "page_ranges_placeholder": "e.g. 1-5, 8, 11-13" }, "pdf": { "attachments_one": "{{count}} attachment", diff --git a/apps/client/src/widgets/NoteDetail.tsx b/apps/client/src/widgets/NoteDetail.tsx index f537eedfec..39da7717b4 100644 --- a/apps/client/src/widgets/NoteDetail.tsx +++ b/apps/client/src/widgets/NoteDetail.tsx @@ -230,7 +230,8 @@ export default function NoteDetail() { 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" + margins: note.getAttributeValue("label", "printMargins") ?? "default", + pageRanges: "" }); }); diff --git a/apps/client/src/widgets/dialogs/print_preview.tsx b/apps/client/src/widgets/dialogs/print_preview.tsx index 60f3f13582..bcc329b9d7 100644 --- a/apps/client/src/widgets/dialogs/print_preview.tsx +++ b/apps/client/src/widgets/dialogs/print_preview.tsx @@ -41,6 +41,13 @@ function serializeMargins(preset: MarginPreset | "custom", custom: CustomMargins return `${custom.top},${custom.right},${custom.bottom},${custom.left}`; } +/** Validates a page-range string such as "1-5, 8, 11-13". Empty string is valid (= all pages). */ +function isValidPageRanges(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) return true; + return /^\s*\d+(\s*-\s*\d+)?(\s*,\s*\d+(\s*-\s*\d+)?)*\s*$/.test(trimmed); +} + export interface PrintPreviewData { pdfBuffer: Uint8Array; note: FNote; @@ -52,6 +59,7 @@ interface PreviewOpts { pageSize: string; scale: number; margins: string; + pageRanges: string; } export default function PrintPreviewDialog() { @@ -69,6 +77,10 @@ export default function PrintPreviewDialog() { const [marginsStr, setMarginsStr] = useNoteLabelWithDefault(note, "printMargins", "default"); const { preset: marginPreset, custom: customMargins } = useMemo(() => parseMarginValue(marginsStr), [marginsStr]); + // Page ranges are kept local — they're one-off per export, not a persistent preference. + const [pageRanges, setPageRanges] = useState(""); + const pageRangesValid = isValidPageRanges(pageRanges); + const updatePreview = useCallback((buffer: Uint8Array) => { bufferRef.current = buffer; @@ -112,13 +124,13 @@ export default function PrintPreviewDialog() { function handleOrientationChange(newLandscape: boolean) { if (newLandscape === landscape) return; setLandscape(newLandscape); - regeneratePreview({ landscape: newLandscape, pageSize, scale, margins: marginsStr }); + regeneratePreview({ landscape: newLandscape, pageSize, scale, margins: marginsStr, pageRanges }); } function handlePageSizeChange(newPageSize: string) { if (newPageSize === pageSize) return; setPageSize(newPageSize); - regeneratePreview({ landscape, pageSize: newPageSize, scale, margins: marginsStr }); + regeneratePreview({ landscape, pageSize: newPageSize, scale, margins: marginsStr, pageRanges }); } const scaleDebounceRef = useRef>(); @@ -129,7 +141,7 @@ export default function PrintPreviewDialog() { clearTimeout(scaleDebounceRef.current); scaleDebounceRef.current = setTimeout(() => { - regeneratePreview({ landscape, pageSize, scale: clamped, margins: marginsStr }); + regeneratePreview({ landscape, pageSize, scale: clamped, margins: marginsStr, pageRanges }); }, 500); } @@ -137,7 +149,7 @@ export default function PrintPreviewDialog() { if (newPreset === marginPreset) return; const newValue = serializeMargins(newPreset as MarginPreset | "custom", customMargins); setMarginsStr(newValue); - regeneratePreview({ landscape, pageSize, scale, margins: newValue }); + regeneratePreview({ landscape, pageSize, scale, margins: newValue, pageRanges }); } const marginDebounceRef = useRef>(); @@ -149,10 +161,23 @@ export default function PrintPreviewDialog() { clearTimeout(marginDebounceRef.current); marginDebounceRef.current = setTimeout(() => { - regeneratePreview({ landscape, pageSize, scale, margins: newValue }); + regeneratePreview({ landscape, pageSize, scale, margins: newValue, pageRanges }); }, 500); } + const pageRangesDebounceRef = useRef>(); + + function handlePageRangesChange(newValue: string) { + setPageRanges(newValue); + + clearTimeout(pageRangesDebounceRef.current); + if (!isValidPageRanges(newValue)) return; + + pageRangesDebounceRef.current = setTimeout(() => { + regeneratePreview({ landscape, pageSize, scale, margins: marginsStr, pageRanges: newValue.trim() }); + }, 600); + } + function regeneratePreview(opts: PreviewOpts) { if (!isElectron()) return; @@ -177,7 +202,8 @@ export default function PrintPreviewDialog() { pageSize: opts.pageSize, landscape: opts.landscape, scale: opts.scale, - margins: opts.margins + margins: opts.margins, + pageRanges: opts.pageRanges }); } @@ -259,6 +285,22 @@ export default function PrintPreviewDialog() { {marginPreset === "custom" && ( )} + + + handlePageRangesChange((e.target as HTMLInputElement).value)} + disabled={loading} + style={{ width: "140px" }} + /> + diff --git a/apps/server/src/services/window.ts b/apps/server/src/services/window.ts index 65787a698d..3084bcc4d7 100644 --- a/apps/server/src/services/window.ts +++ b/apps/server/src/services/window.ts @@ -88,6 +88,7 @@ interface ExportAsPdfOpts { pageSize: "A0" | "A1" | "A2" | "A3" | "A4" | "A5" | "A6" | "Legal" | "Letter" | "Tabloid" | "Ledger"; scale: number; margins: string; + pageRanges: string; } /** Parses the printMargins attribute into Electron margins. @@ -141,7 +142,7 @@ electron.ipcMain.on("print-note", async (e, { notePath }: PrintOpts) => { } }); -electron.ipcMain.on("export-as-pdf", async (e, { title, notePath, landscape, pageSize, scale, margins }: ExportAsPdfOpts) => { +electron.ipcMain.on("export-as-pdf", async (e, { title, notePath, landscape, pageSize, scale, margins, pageRanges }: ExportAsPdfOpts) => { try { const { browserWindow, printReport } = await getBrowserWindowForPrinting(e, notePath, "exporting_pdf"); @@ -164,6 +165,7 @@ electron.ipcMain.on("export-as-pdf", async (e, { title, notePath, landscape, pag pageSize, scale, margins: parseMargins(margins), + pageRanges: pageRanges || undefined, generateDocumentOutline: true, generateTaggedPDF: true, printBackground: true, @@ -204,7 +206,7 @@ electron.ipcMain.on("export-as-pdf", async (e, { title, notePath, landscape, pag } }); -electron.ipcMain.on("export-as-pdf-preview", async (e, { notePath, landscape, pageSize, scale, margins }: ExportAsPdfOpts) => { +electron.ipcMain.on("export-as-pdf-preview", async (e, { notePath, landscape, pageSize, scale, margins, pageRanges }: ExportAsPdfOpts) => { try { const { browserWindow, printReport } = await getBrowserWindowForPrinting(e, notePath, "exporting_pdf"); @@ -214,6 +216,7 @@ electron.ipcMain.on("export-as-pdf-preview", async (e, { notePath, landscape, pa pageSize, scale, margins: parseMargins(margins), + pageRanges: pageRanges || undefined, generateDocumentOutline: true, generateTaggedPDF: true, printBackground: true,