mirror of
https://github.com/zadam/trilium.git
synced 2026-05-07 09:15:49 +02:00
feat(print/pdf): basic support for margins
This commit is contained in:
@@ -12,10 +12,6 @@ body {
|
||||
color: black;
|
||||
}
|
||||
|
||||
@page {
|
||||
margin: 2cm;
|
||||
}
|
||||
|
||||
.note-list-widget.full-height,
|
||||
.note-list-widget.full-height .note-list-widget-content {
|
||||
height: unset !important;
|
||||
|
||||
@@ -455,6 +455,7 @@
|
||||
"print_landscape": "When exporting to PDF, changes the orientation of the page to landscape instead of portrait.",
|
||||
"print_page_size": "When exporting to PDF, changes the size of the page. Supported values: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.",
|
||||
"print_scale": "When exporting to PDF, changes the scale of the rendered content. Values range from 0.1 (10%) to 2 (200%), default is 1 (100%).",
|
||||
"print_margins": "When exporting to PDF, sets page margins. Use <code>default</code>, <code>none</code>, <code>minimum</code>, or custom values as <code>top,right,bottom,left</code> in millimeters.",
|
||||
"color_type": "Color"
|
||||
},
|
||||
"attribute_editor": {
|
||||
@@ -2328,7 +2329,16 @@
|
||||
"portrait": "Portrait",
|
||||
"landscape": "Landscape",
|
||||
"page_size": "Page size",
|
||||
"scale": "Scale"
|
||||
"scale": "Scale",
|
||||
"margins": "Margins",
|
||||
"margins_default": "Default",
|
||||
"margins_none": "None",
|
||||
"margins_minimum": "Minimum",
|
||||
"margins_custom": "Custom",
|
||||
"margin_top": "Top",
|
||||
"margin_right": "Right",
|
||||
"margin_bottom": "Bottom",
|
||||
"margin_left": "Left"
|
||||
},
|
||||
"pdf": {
|
||||
"attachments_one": "{{count}} attachment",
|
||||
|
||||
@@ -229,7 +229,8 @@ export default function NoteDetail() {
|
||||
notePath: noteContext.notePath,
|
||||
pageSize: note.getAttributeValue("label", "printPageSize") ?? "Letter",
|
||||
landscape: note.hasAttribute("label", "printLandscape"),
|
||||
scale: parseFloat(note.getAttributeValue("label", "printScale") ?? "1") || 1
|
||||
scale: parseFloat(note.getAttributeValue("label", "printScale") ?? "1") || 1,
|
||||
margins: note.getAttributeValue("label", "printMargins") ?? "default"
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -268,7 +268,8 @@ const ATTR_HELP: Record<string, Record<string, string>> = {
|
||||
hideHighlightWidget: t("attribute_detail.hide_highlight_widget"),
|
||||
printLandscape: t("attribute_detail.print_landscape"),
|
||||
printPageSize: t("attribute_detail.print_page_size"),
|
||||
printScale: t("attribute_detail.print_scale")
|
||||
printScale: t("attribute_detail.print_scale"),
|
||||
printMargins: t("attribute_detail.print_margins")
|
||||
},
|
||||
relation: {
|
||||
runOnNoteCreation: t("attribute_detail.run_on_note_creation"),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef, useState } from "preact/hooks";
|
||||
import { useCallback, useMemo, useRef, useState } from "preact/hooks";
|
||||
|
||||
import FNote from "../../entities/fnote";
|
||||
import { t } from "../../services/i18n";
|
||||
@@ -13,6 +13,33 @@ import OptionsRow from "../type_widgets/options/components/OptionsRow";
|
||||
import OptionsSection from "../type_widgets/options/components/OptionsSection";
|
||||
|
||||
const PAGE_SIZES = ["A0", "A1", "A2", "A3", "A4", "A5", "A6", "Legal", "Letter", "Tabloid", "Ledger"] as const;
|
||||
const MARGIN_PRESETS = ["default", "none", "minimum"] as const;
|
||||
type MarginPreset = typeof MARGIN_PRESETS[number];
|
||||
|
||||
interface CustomMargins {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
function parseMarginValue(value: string): { preset: MarginPreset | "custom"; custom: CustomMargins } {
|
||||
if (MARGIN_PRESETS.includes(value as MarginPreset)) {
|
||||
return { preset: value as MarginPreset, custom: { top: 10, right: 10, bottom: 10, left: 10 } };
|
||||
}
|
||||
|
||||
const parts = value.split(",").map(Number);
|
||||
if (parts.length === 4 && parts.every((n) => !isNaN(n))) {
|
||||
return { preset: "custom", custom: { top: parts[0], right: parts[1], bottom: parts[2], left: parts[3] } };
|
||||
}
|
||||
|
||||
return { preset: "default", custom: { top: 10, right: 10, bottom: 10, left: 10 } };
|
||||
}
|
||||
|
||||
function serializeMargins(preset: MarginPreset | "custom", custom: CustomMargins): string {
|
||||
if (preset !== "custom") return preset;
|
||||
return `${custom.top},${custom.right},${custom.bottom},${custom.left}`;
|
||||
}
|
||||
|
||||
export interface PrintPreviewData {
|
||||
pdfBuffer: Uint8Array;
|
||||
@@ -20,6 +47,13 @@ export interface PrintPreviewData {
|
||||
notePath: string;
|
||||
}
|
||||
|
||||
interface PreviewOpts {
|
||||
landscape: boolean;
|
||||
pageSize: string;
|
||||
scale: number;
|
||||
margins: string;
|
||||
}
|
||||
|
||||
export default function PrintPreviewDialog() {
|
||||
const [shown, setShown] = useState(false);
|
||||
const [pdfUrl, setPdfUrl] = useState<string>();
|
||||
@@ -32,11 +66,12 @@ export default function PrintPreviewDialog() {
|
||||
const [pageSize, setPageSize] = useNoteLabelWithDefault(note, "printPageSize", "Letter");
|
||||
const [scaleStr, setScaleStr] = useNoteLabelWithDefault(note, "printScale", "1");
|
||||
const scale = parseFloat(scaleStr) || 1;
|
||||
const [marginsStr, setMarginsStr] = useNoteLabelWithDefault(note, "printMargins", "default");
|
||||
const { preset: marginPreset, custom: customMargins } = useMemo(() => parseMarginValue(marginsStr), [marginsStr]);
|
||||
|
||||
const updatePreview = useCallback((buffer: Uint8Array) => {
|
||||
bufferRef.current = buffer;
|
||||
|
||||
// Revoke old URL before creating new one.
|
||||
if (pdfUrl) {
|
||||
URL.revokeObjectURL(pdfUrl);
|
||||
}
|
||||
@@ -77,13 +112,13 @@ export default function PrintPreviewDialog() {
|
||||
function handleOrientationChange(newLandscape: boolean) {
|
||||
if (newLandscape === landscape) return;
|
||||
setLandscape(newLandscape);
|
||||
regeneratePreview({ landscape: newLandscape, pageSize, scale });
|
||||
regeneratePreview({ landscape: newLandscape, pageSize, scale, margins: marginsStr });
|
||||
}
|
||||
|
||||
function handlePageSizeChange(newPageSize: string) {
|
||||
if (newPageSize === pageSize) return;
|
||||
setPageSize(newPageSize);
|
||||
regeneratePreview({ landscape, pageSize: newPageSize, scale });
|
||||
regeneratePreview({ landscape, pageSize: newPageSize, scale, margins: marginsStr });
|
||||
}
|
||||
|
||||
const scaleDebounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
@@ -94,11 +129,31 @@ export default function PrintPreviewDialog() {
|
||||
|
||||
clearTimeout(scaleDebounceRef.current);
|
||||
scaleDebounceRef.current = setTimeout(() => {
|
||||
regeneratePreview({ landscape, pageSize, scale: clamped });
|
||||
regeneratePreview({ landscape, pageSize, scale: clamped, margins: marginsStr });
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function regeneratePreview(opts: { landscape: boolean; pageSize: string; scale: number }) {
|
||||
function handleMarginPresetChange(newPreset: string) {
|
||||
if (newPreset === marginPreset) return;
|
||||
const newValue = serializeMargins(newPreset as MarginPreset | "custom", customMargins);
|
||||
setMarginsStr(newValue);
|
||||
regeneratePreview({ landscape, pageSize, scale, margins: newValue });
|
||||
}
|
||||
|
||||
const marginDebounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
function handleCustomMarginChange(side: keyof CustomMargins, value: number) {
|
||||
const newCustom = { ...customMargins, [side]: Math.max(0, value) };
|
||||
const newValue = serializeMargins("custom", newCustom);
|
||||
setMarginsStr(newValue);
|
||||
|
||||
clearTimeout(marginDebounceRef.current);
|
||||
marginDebounceRef.current = setTimeout(() => {
|
||||
regeneratePreview({ landscape, pageSize, scale, margins: newValue });
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function regeneratePreview(opts: PreviewOpts) {
|
||||
if (!isElectron()) return;
|
||||
|
||||
setLoading(true);
|
||||
@@ -114,7 +169,8 @@ export default function PrintPreviewDialog() {
|
||||
notePath: notePathRef.current,
|
||||
pageSize: opts.pageSize,
|
||||
landscape: opts.landscape,
|
||||
scale: opts.scale
|
||||
scale: opts.scale,
|
||||
margins: opts.margins
|
||||
});
|
||||
}
|
||||
|
||||
@@ -178,6 +234,24 @@ export default function PrintPreviewDialog() {
|
||||
onChange={handleScaleChange}
|
||||
/>
|
||||
</OptionsRow>
|
||||
|
||||
<OptionsRow name="margins" label={t("print_preview.margins")} stacked>
|
||||
<select
|
||||
class="form-select form-select-sm"
|
||||
value={marginPreset}
|
||||
onChange={(e) => handleMarginPresetChange((e.target as HTMLSelectElement).value)}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="default">{t("print_preview.margins_default")}</option>
|
||||
<option value="none">{t("print_preview.margins_none")}</option>
|
||||
<option value="minimum">{t("print_preview.margins_minimum")}</option>
|
||||
<option value="custom">{t("print_preview.margins_custom")}</option>
|
||||
</select>
|
||||
</OptionsRow>
|
||||
|
||||
{marginPreset === "custom" && (
|
||||
<MarginEditor margins={customMargins} onChange={handleCustomMarginChange} disabled={loading} />
|
||||
)}
|
||||
</OptionsSection>
|
||||
</div>
|
||||
|
||||
@@ -192,3 +266,47 @@ export default function PrintPreviewDialog() {
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function MarginEditor({ margins, onChange, disabled }: {
|
||||
margins: CustomMargins;
|
||||
onChange: (side: keyof CustomMargins, value: number) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const spinnerStyle = { width: "130px" };
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "4px", padding: "8px 0" }}>
|
||||
<MarginSpinner label={t("print_preview.margin_top")} value={margins.top} onChange={(v) => onChange("top", v)} disabled={disabled} style={spinnerStyle} />
|
||||
<div style={{ display: "flex", gap: "24px", alignItems: "center" }}>
|
||||
<MarginSpinner label={t("print_preview.margin_left")} value={margins.left} onChange={(v) => onChange("left", v)} disabled={disabled} style={spinnerStyle} />
|
||||
<MarginSpinner label={t("print_preview.margin_right")} value={margins.right} onChange={(v) => onChange("right", v)} disabled={disabled} style={spinnerStyle} />
|
||||
</div>
|
||||
<MarginSpinner label={t("print_preview.margin_bottom")} value={margins.bottom} onChange={(v) => onChange("bottom", v)} disabled={disabled} style={spinnerStyle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MarginSpinner({ label, value, onChange, disabled, style }: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
disabled: boolean;
|
||||
style?: Record<string, string>;
|
||||
}) {
|
||||
return (
|
||||
<div class="input-group input-group-sm" style={style}>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control form-control-sm"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
value={value}
|
||||
min={0}
|
||||
step={1}
|
||||
onChange={(e) => onChange((e.target as HTMLInputElement).valueAsNumber || 0)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span class="input-group-text">mm</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,6 +87,29 @@ interface ExportAsPdfOpts {
|
||||
landscape: boolean;
|
||||
pageSize: "A0" | "A1" | "A2" | "A3" | "A4" | "A5" | "A6" | "Legal" | "Letter" | "Tabloid" | "Ledger";
|
||||
scale: number;
|
||||
margins: string;
|
||||
}
|
||||
|
||||
/** Parses the printMargins attribute. Preset values map to Electron margin types.
|
||||
* Custom values are stored as "top,right,bottom,left" in mm and converted to inches for Electron. */
|
||||
function parseMargins(margins: string): Electron.Margins | undefined {
|
||||
if (!margins || margins === "default") return { marginType: "default" };
|
||||
if (margins === "none") return { marginType: "none" };
|
||||
if (margins === "minimum") return { marginType: "printableArea" };
|
||||
|
||||
const parts = margins.split(",").map(Number);
|
||||
if (parts.length === 4 && parts.every((n) => !isNaN(n))) {
|
||||
const mmToInches = (mm: number) => mm / 25.4;
|
||||
return {
|
||||
marginType: "custom",
|
||||
top: mmToInches(parts[0]),
|
||||
right: mmToInches(parts[1]),
|
||||
bottom: mmToInches(parts[2]),
|
||||
left: mmToInches(parts[3])
|
||||
};
|
||||
}
|
||||
|
||||
return { marginType: "default" };
|
||||
}
|
||||
|
||||
electron.ipcMain.on("print-note", async (e, { notePath }: PrintOpts) => {
|
||||
@@ -108,7 +131,7 @@ electron.ipcMain.on("print-note", async (e, { notePath }: PrintOpts) => {
|
||||
}
|
||||
});
|
||||
|
||||
electron.ipcMain.on("export-as-pdf", async (e, { title, notePath, landscape, pageSize, scale }: ExportAsPdfOpts) => {
|
||||
electron.ipcMain.on("export-as-pdf", async (e, { title, notePath, landscape, pageSize, scale, margins }: ExportAsPdfOpts) => {
|
||||
try {
|
||||
const { browserWindow, printReport } = await getBrowserWindowForPrinting(e, notePath, "exporting_pdf");
|
||||
|
||||
@@ -130,10 +153,14 @@ electron.ipcMain.on("export-as-pdf", async (e, { title, notePath, landscape, pag
|
||||
landscape,
|
||||
pageSize,
|
||||
scale,
|
||||
margins: parseMargins(margins),
|
||||
generateDocumentOutline: true,
|
||||
generateTaggedPDF: true,
|
||||
printBackground: true,
|
||||
displayHeaderFooter: true,
|
||||
// displayHeaderFooter forces Chromium to use fixed default margins
|
||||
// (to make room for the header/footer), overriding our `margins` setting.
|
||||
// Only enable it when the user hasn't customized margins.
|
||||
displayHeaderFooter: !margins || margins === "default",
|
||||
headerTemplate: `<div></div>`,
|
||||
footerTemplate: `
|
||||
<div class="pageNumber" style="width: 100%; text-align: center; font-size: 10pt;">
|
||||
@@ -170,7 +197,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 }: ExportAsPdfOpts) => {
|
||||
electron.ipcMain.on("export-as-pdf-preview", async (e, { notePath, landscape, pageSize, scale, margins }: ExportAsPdfOpts) => {
|
||||
try {
|
||||
const { browserWindow, printReport } = await getBrowserWindowForPrinting(e, notePath, "exporting_pdf");
|
||||
|
||||
@@ -179,10 +206,11 @@ electron.ipcMain.on("export-as-pdf-preview", async (e, { notePath, landscape, pa
|
||||
landscape,
|
||||
pageSize,
|
||||
scale,
|
||||
margins: parseMargins(margins),
|
||||
generateDocumentOutline: true,
|
||||
generateTaggedPDF: true,
|
||||
printBackground: true,
|
||||
displayHeaderFooter: true,
|
||||
displayHeaderFooter: !margins || margins === "default",
|
||||
headerTemplate: `<div></div>`,
|
||||
footerTemplate: `
|
||||
<div class="pageNumber" style="width: 100%; text-align: center; font-size: 10pt;">
|
||||
|
||||
@@ -64,6 +64,7 @@ type Labels = {
|
||||
printLandscape: boolean;
|
||||
printPageSize: string;
|
||||
printScale: string;
|
||||
printMargins: string;
|
||||
|
||||
// Note-type specific
|
||||
webViewSrc: string;
|
||||
|
||||
@@ -86,6 +86,7 @@ export default [
|
||||
{ type: "label", name: "printLandscape" },
|
||||
{ type: "label", name: "printPageSize" },
|
||||
{ type: "label", name: "printScale" },
|
||||
{ type: "label", name: "printMargins" },
|
||||
|
||||
// relation names
|
||||
{ type: "relation", name: "internalLink" },
|
||||
|
||||
Reference in New Issue
Block a user