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/print.css b/apps/client/src/print.css index 7d551fe0ff..d0123c810e 100644 --- a/apps/client/src/print.css +++ b/apps/client/src/print.css @@ -5,6 +5,10 @@ --ck-content-color-image-caption-background: transparent !important; } +@page { + margin: 2cm; +} + html, body { width: 100%; @@ -12,10 +16,6 @@ body { color: black; } -@page { - margin: 2cm; -} - .note-list-widget.full-height, .note-list-widget.full-height .note-list-widget-content { height: unset !important; diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css index 0ffd81bf02..c1672f50c4 100644 --- a/apps/client/src/stylesheets/style.css +++ b/apps/client/src/stylesheets/style.css @@ -2701,3 +2701,7 @@ iframe.print-iframe { line-height: 1.4; white-space: pre-wrap; } + +.ck-content pre code { + tab-size: var(--code-block-tab-width, 4); +} diff --git a/apps/client/src/stylesheets/theme-next/forms.css b/apps/client/src/stylesheets/theme-next/forms.css index cbe5f2cda0..6efe42a9d7 100644 --- a/apps/client/src/stylesheets/theme-next/forms.css +++ b/apps/client/src/stylesheets/theme-next/forms.css @@ -21,6 +21,20 @@ button.ck.ck-button:is(.ck-button-action, .ck-button-save, .ck-button-cancel, .c &.dropdown-toggle-split { min-width: unset; } + + .btn-group > & { + border-radius: 0; + } + + .btn-group > &:first-child { + border-start-start-radius: 6px; + border-end-start-radius: 6px; + } + + .btn-group > &:last-child { + border-start-end-radius: 6px; + border-end-end-radius: 6px; + } } button.btn.btn-primary:hover, diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 653e7dd0b2..e6c88a6c19 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -454,6 +454,8 @@ "and_more": "... and {{count}} more.", "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: A0, A1, A2, A3, A4, A5, A6, Legal, Letter, Tabloid, Ledger.", + "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 default, none, minimum, or custom values as top,right,bottom,left in millimeters.", "color_type": "Color" }, "attribute_editor": { @@ -693,6 +695,11 @@ "move_right": "Move right" }, "note_actions": { + "word_wrap": "Word wrap", + "word_wrap_auto": "Auto", + "word_wrap_auto_description": "Follow the global setting", + "word_wrap_on": "On", + "word_wrap_off": "Off", "convert_into_attachment": "Convert into attachment", "re_render_note": "Re-render note", "search_in_note": "Search in note", @@ -1268,7 +1275,9 @@ "unit": "characters" }, "code-editor-options": { - "title": "Editor" + "title": "Editor", + "tab_width": "Tab width", + "tab_width_unit": "spaces" }, "code_mime_types": { "title": "Available MIME types in the dropdown", @@ -1804,7 +1813,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", @@ -1930,7 +1939,9 @@ "theme_group_light": "Light themes", "theme_group_dark": "Dark themes", "copy_title": "Copy to clipboard", - "click_to_copy": "Click to copy" + "click_to_copy": "Click to copy", + "tab_width": "Tab width", + "tab_width_unit": "spaces" }, "classic_editor_toolbar": { "title": "Formatting" @@ -2292,7 +2303,19 @@ "note_paths_one": "{{count}} path", "note_paths_other": "{{count}} paths", "note_paths_title": "Note paths", - "code_note_switcher": "Change language mode" + "code_note_switcher": "Change language mode", + "tab_width": "Tab Width: {{width}}", + "tab_width_title": "Change tab width", + "tab_width_spaces": "{{count}} spaces", + "tab_width_spaces_short": "Spaces: {{width}}", + "tab_width_tabs": "Tabs: {{width}}", + "tab_width_use_default": "Use default ({{width}})", + "tab_width_use_default_style": "Use default ({{style}})", + "tab_width_display_header": "Display width", + "tab_width_reindent_header": "Re-indent content to", + "tab_width_style_header": "Indent using", + "tab_width_style_spaces": "Spaces", + "tab_width_style_tabs": "Tabs" }, "attributes_panel": { "title": "Note Attributes" @@ -2303,6 +2326,37 @@ "toggle": "Toggle right panel", "custom_widget_go_to_source": "Go to source code" }, + "print_preview": { + "title": "Print preview", + "close": "Close", + "save": "Save as PDF", + "print": "Print", + "export_pdf": "Export as PDF", + "system_print": "Print using system dialog", + "destination": "Destination", + "destination_pdf": "Save as PDF", + "destination_printers": "Printers", + "destination_default": "Default", + "orientation": "Orientation", + "portrait": "Portrait", + "landscape": "Landscape", + "page_size": "Page size", + "scale": "Scale", + "margins": "Margins", + "render_error": "Unable to render PDF with the current settings. Please check the margins and scale.", + "margins_default": "Default", + "margins_none": "None", + "margins_minimum": "Minimum", + "margins_custom": "Custom", + "margin_top": "Top", + "margin_right": "Right", + "margin_bottom": "Bottom", + "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", "attachments_other": "{{count}} attachments", diff --git a/apps/client/src/widgets/NoteDetail.tsx b/apps/client/src/widgets/NoteDetail.tsx index 40ae3e49e9..e734bbf9c7 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,13 +147,21 @@ export default function NoteDetail() { toast.closePersistent("printing"); handlePrintReport(printReport); }; + const onPreviewResult = (_e: any, { buffer, notePath }: { buffer: Uint8Array; notePath: string }) => { + toast.closePersistent("printing"); + if (note) { + appContext.triggerCommand("showPrintPreview", { pdfBuffer: buffer, note, notePath }); + } + }; 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); }; - }, []); + }, [note]); useTriliumEvent("executeInActiveNoteDetailWidget", ({ callback }) => { if (!noteContext?.isActive()) return; @@ -173,54 +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", { - title: note.title, - notePath: noteContext.notePath, - pageSize: note.getAttributeValue("label", "printPageSize") ?? "Letter", - landscape: note.hasAttribute("label", "printLandscape") - }); + iframe.contentWindow?.print(); + document.body.removeChild(iframe); + }); + }; }); return ( diff --git a/apps/client/src/widgets/attribute_widgets/attribute_detail.ts b/apps/client/src/widgets/attribute_widgets/attribute_detail.ts index 73bf2cc2d6..af88d65d11 100644 --- a/apps/client/src/widgets/attribute_widgets/attribute_detail.ts +++ b/apps/client/src/widgets/attribute_widgets/attribute_detail.ts @@ -267,7 +267,9 @@ const ATTR_HELP: Record> = { newNotesOnTop: t("attribute_detail.new_notes_on_top"), hideHighlightWidget: t("attribute_detail.hide_highlight_widget"), printLandscape: t("attribute_detail.print_landscape"), - printPageSize: t("attribute_detail.print_page_size") + printPageSize: t("attribute_detail.print_page_size"), + printScale: t("attribute_detail.print_scale"), + printMargins: t("attribute_detail.print_margins") }, relation: { runOnNoteCreation: t("attribute_detail.run_on_note_creation"), 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..c99e63cd17 --- /dev/null +++ b/apps/client/src/widgets/dialogs/print_preview.tsx @@ -0,0 +1,480 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; + +import FNote from "../../entities/fnote"; +import { t } from "../../services/i18n"; +import toast from "../../services/toast"; +import { dynamicRequire, isElectron } from "../../services/utils"; +import Button, { ButtonGroup } from "../react/Button"; +import Dropdown from "../react/Dropdown"; +import { FormListHeader, FormListItem } from "../react/FormList"; +import { useNoteLabelBoolean, useNoteLabelWithDefault, useTriliumEvent } from "../react/hooks"; +import Modal from "../react/Modal"; +import Slider from "../react/Slider"; +import PdfViewer from "../type_widgets/file/PdfViewer"; +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; + +/** Pseudo-printer name used to route the Print button to the PDF export flow. */ +const DESTINATION_PDF = "__pdf__"; + +interface PrinterInfo { + name: string; + displayName: string; + description: string; + location: string; + isDefault: boolean; +} + +/** Builds the description line shown under a printer in the dropdown. */ +function buildPrinterDescription(printer: PrinterInfo): string | undefined { + const parts: string[] = []; + if (printer.isDefault) parts.push(t("print_preview.destination_default")); + if (printer.location) parts.push(printer.location); + else if (printer.description) parts.push(printer.description); + return parts.length ? parts.join(" · ") : undefined; +} +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}`; +} + +/** 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; + notePath: string; +} + +interface PreviewOpts { + landscape: boolean; + pageSize: string; + scale: number; + margins: string; + pageRanges: string; +} + +export default function PrintPreviewDialog() { + const [shown, setShown] = useState(false); + const [pdfUrl, setPdfUrl] = useState(); + const [note, setNote] = useState(); + const [loading, setLoading] = useState(false); + const bufferRef = useRef(); + const notePathRef = useRef(""); + const pdfUrlRef = useRef(); + const generationRef = useRef(0); + + const [landscape, setLandscape] = useNoteLabelBoolean(note, "printLandscape"); + 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]); + + // Page ranges are kept local — they're one-off per export, not a persistent preference. + const [pageRanges, setPageRanges] = useState(""); + const pageRangesValid = isValidPageRanges(pageRanges); + + // Printer list and current destination. DESTINATION_PDF means "Save as PDF"; + // any other value is the system printer name to use for silent printing. + const [printers, setPrinters] = useState([]); + const [destination, setDestination] = useState(DESTINATION_PDF); + + const skipNextRegenRef = useRef(false); + + useEffect(() => { + if (!shown || !isElectron()) return; + const { ipcRenderer } = dynamicRequire("electron"); + ipcRenderer.invoke("get-printers").then((list: PrinterInfo[]) => { + setPrinters(list ?? []); + const defaultPrinter = list?.find((p) => p.isDefault); + if (defaultPrinter) setDestination(defaultPrinter.name); + }); + }, [shown]); + + const updatePreview = useCallback((buffer: Uint8Array) => { + bufferRef.current = buffer; + + if (pdfUrlRef.current) { + URL.revokeObjectURL(pdfUrlRef.current); + } + + const blob = new Blob([buffer as BlobPart], { type: "application/pdf" }); + const url = URL.createObjectURL(blob); + pdfUrlRef.current = url; + setPdfUrl(url); + setLoading(false); + }, []); + + useTriliumEvent("showPrintPreview", (data: PrintPreviewData) => { + // When the dialog is already open, it manages its own regeneration via + // a persistent IPC listener. Ignore duplicate events from NoteDetail's + // listener to avoid overwriting the preview with stale data. + if (shown) return; + + skipNextRegenRef.current = true; + setNote(data.note); + notePathRef.current = data.notePath; + updatePreview(data.pdfBuffer); + setShown(true); + }); + + // Handle regeneration results via a persistent listener scoped to the + // dialog's lifecycle. A generation counter discards stale results when + // multiple requests overlap. + useEffect(() => { + if (!shown || !isElectron()) return; + const { ipcRenderer } = dynamicRequire("electron"); + + const onResult = (_e: any, { buffer, error }: { buffer?: Uint8Array; error?: string }) => { + if (generationRef.current <= 0) return; + + toast.closePersistent("printing"); + if (error) { + setLoading(false); + if (pdfUrlRef.current) { + URL.revokeObjectURL(pdfUrlRef.current); + pdfUrlRef.current = undefined; + setPdfUrl(undefined); + } + toast.showPersistent({ + id: "print-preview-error", + icon: "bx bx-error-circle", + message: `${t("print_preview.render_error")}\n\n${error}` + }); + return; + } + toast.closePersistent("print-preview-error"); + if (buffer) { + updatePreview(buffer); + } + }; + ipcRenderer.on("export-as-pdf-preview-result", onResult); + return () => { + ipcRenderer.off("export-as-pdf-preview-result", onResult); + }; + }, [shown, updatePreview]); + + const regeneratePreview = useCallback((opts: PreviewOpts) => { + if (!isElectron()) return; + + ++generationRef.current; + setLoading(true); + const { ipcRenderer } = dynamicRequire("electron"); + ipcRenderer.send("export-as-pdf-preview", { + notePath: notePathRef.current, + pageSize: opts.pageSize, + landscape: opts.landscape, + scale: opts.scale, + margins: opts.margins, + pageRanges: opts.pageRanges + }); + }, []); + + useEffect(() => { + if (!shown || !pageRangesValid) return; + if (skipNextRegenRef.current) { + skipNextRegenRef.current = false; + return; + } + const handle = setTimeout(() => { + regeneratePreview({ landscape, pageSize, scale, margins: marginsStr, pageRanges: pageRanges.trim() }); + }, 400); + return () => clearTimeout(handle); + }, [shown, landscape, pageSize, scale, marginsStr, pageRanges, pageRangesValid, regeneratePreview]); + + function handleClose() { + setShown(false); + toast.closePersistent("print-preview-error"); + if (pdfUrlRef.current) { + URL.revokeObjectURL(pdfUrlRef.current); + pdfUrlRef.current = undefined; + setPdfUrl(undefined); + } + bufferRef.current = undefined; + setLoading(false); + } + + function handleExportPdf() { + if (!bufferRef.current) return; + + const { ipcRenderer } = dynamicRequire("electron"); + ipcRenderer.send("save-pdf", { + title: note?.title ?? "", + buffer: bufferRef.current + }); + handleClose(); + } + + function handlePrint(silent: boolean, deviceName?: string) { + if (!isElectron()) return; + const { ipcRenderer } = dynamicRequire("electron"); + ipcRenderer.send("print-from-preview", { + notePath: notePathRef.current, + pageSize, + landscape, + scale, + margins: marginsStr, + pageRanges, + silent, + deviceName + }); + handleClose(); + } + + /** Primary action: route to PDF export or silent print based on the selected destination. */ + function handlePrimaryAction() { + if (destination === DESTINATION_PDF) { + handleExportPdf(); + } else { + handlePrint(true, destination); + } + } + + function handleScaleChange(newScale: number) { + const clamped = Math.min(2, Math.max(0.1, Math.round(newScale * 10) / 10)); + setScaleStr(String(clamped)); + } + + function handleCustomMarginChange(side: keyof CustomMargins, value: number) { + const newCustom = { ...customMargins, [side]: Math.max(0, value) }; + setMarginsStr(serializeMargins("custom", newCustom)); + } + + return ( + + { + e.preventDefault(); + if (loading) return; + // When a specific printer is selected, pre-select it in the system dialog. + const deviceName = destination === DESTINATION_PDF ? undefined : destination; + handlePrint(false, deviceName); + }} + > + {t("print_preview.system_print")} + +