feat(print/pdf): basic support for printing only a subset of pages

This commit is contained in:
Elian Doran
2026-04-15 10:26:25 +03:00
parent ba13f86f5f
commit bf5d6c4e01
4 changed files with 60 additions and 10 deletions

View File

@@ -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",

View File

@@ -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: ""
});
});

View File

@@ -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<ReturnType<typeof setTimeout>>();
@@ -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<ReturnType<typeof setTimeout>>();
@@ -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<ReturnType<typeof setTimeout>>();
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" && (
<MarginEditor margins={customMargins} onChange={handleCustomMarginChange} disabled={loading} />
)}
<OptionsRow
name="pageRanges"
label={t("print_preview.page_ranges")}
description={!pageRangesValid ? t("print_preview.page_ranges_invalid") : t("print_preview.page_ranges_hint")}
>
<input
type="text"
class={`form-control form-control-sm ${!pageRangesValid ? "is-invalid" : ""}`}
value={pageRanges}
placeholder={t("print_preview.page_ranges_placeholder")}
onInput={(e) => handlePageRangesChange((e.target as HTMLInputElement).value)}
disabled={loading}
style={{ width: "140px" }}
/>
</OptionsRow>
</OptionsSection>
</div>

View File

@@ -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,