From c35bc6fbd27e8d8a0385f8624835fceaa3466f83 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 14 Apr 2026 22:12:57 +0300 Subject: [PATCH 01/44] feat(options): add tab width selector --- apps/client/src/translations/en/translation.json | 4 +++- .../src/widgets/type_widgets/options/text_notes.tsx | 10 ++++++++++ apps/server/src/routes/api/options.ts | 1 + apps/server/src/services/options_init.ts | 1 + packages/commons/src/lib/options_interface.ts | 1 + 5 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 653e7dd0b2..0e335e4da0 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1930,7 +1930,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" diff --git a/apps/client/src/widgets/type_widgets/options/text_notes.tsx b/apps/client/src/widgets/type_widgets/options/text_notes.tsx index 009def29a9..00c66d92b5 100644 --- a/apps/client/src/widgets/type_widgets/options/text_notes.tsx +++ b/apps/client/src/widgets/type_widgets/options/text_notes.tsx @@ -264,6 +264,7 @@ function CodeBlockStyle() { }, []); const [ codeBlockTheme, setCodeBlockTheme ] = useTriliumOption("codeBlockTheme"); const [ codeBlockWordWrap, setCodeBlockWordWrap ] = useTriliumOptionBool("codeBlockWordWrap"); + const [ codeBlockTabWidth, setCodeBlockTabWidth ] = useTriliumOption("codeBlockTabWidth"); return ( @@ -285,6 +286,15 @@ function CodeBlockStyle() { onChange={setCodeBlockWordWrap} /> + + + + ); diff --git a/apps/server/src/routes/api/options.ts b/apps/server/src/routes/api/options.ts index f9c92c9718..8191b6812e 100644 --- a/apps/server/src/routes/api/options.ts +++ b/apps/server/src/routes/api/options.ts @@ -30,6 +30,7 @@ const ALLOWED_OPTIONS = new Set([ "theme", "codeBlockTheme", "codeBlockWordWrap", + "codeBlockTabWidth", "codeNoteTheme", "syncServerHost", "syncServerTimeout", diff --git a/apps/server/src/services/options_init.ts b/apps/server/src/services/options_init.ts index 4bff15d91e..bdab81af62 100644 --- a/apps/server/src/services/options_init.ts +++ b/apps/server/src/services/options_init.ts @@ -216,6 +216,7 @@ const defaultOptions: DefaultOption[] = [ isSynced: false }, { name: "codeBlockWordWrap", value: "false", isSynced: true }, + { name: "codeBlockTabWidth", value: "4", isSynced: true }, // Text note configuration { name: "textNoteEditorType", value: "ckeditor-balloon", isSynced: true }, diff --git a/packages/commons/src/lib/options_interface.ts b/packages/commons/src/lib/options_interface.ts index 9f7db1d04b..b64ae1e568 100644 --- a/packages/commons/src/lib/options_interface.ts +++ b/packages/commons/src/lib/options_interface.ts @@ -160,6 +160,7 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions Date: Tue, 14 Apr 2026 22:21:29 +0300 Subject: [PATCH 02/44] feat(find): allow searching in view source --- apps/client/src/widgets/find.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/client/src/widgets/find.ts b/apps/client/src/widgets/find.ts index d694d087ad..37756b4750 100644 --- a/apps/client/src/widgets/find.ts +++ b/apps/client/src/widgets/find.ts @@ -195,7 +195,9 @@ export default class FindWidget extends NoteContextAwareWidget { return; } - if (!SUPPORTED_NOTE_TYPES.includes(this.note?.type ?? "")) { + const isSourceView = this.noteContext?.viewScope?.viewMode === "source"; + + if (!isSourceView && !SUPPORTED_NOTE_TYPES.includes(this.note?.type ?? "")) { return; } @@ -204,7 +206,7 @@ export default class FindWidget extends NoteContextAwareWidget { const isReadOnly = await this.noteContext?.isReadOnly(); let selectedText = ""; - if (this.note?.type === "code" && this.noteContext) { + if ((this.note?.type === "code" || isSourceView) && this.noteContext) { const codeEditor = await this.noteContext.getCodeEditor(); selectedText = codeEditor.getSelectedText(); } else { @@ -249,6 +251,11 @@ export default class FindWidget extends NoteContextAwareWidget { } async getHandler() { + // In source view, all note types render via a read-only CodeMirror editor. + if (this.noteContext?.viewScope?.viewMode === "source") { + return this.codeHandler; + } + switch (this.note?.type) { case "render": return this.htmlHandler; @@ -362,7 +369,9 @@ export default class FindWidget extends NoteContextAwareWidget { } isEnabled() { - return super.isEnabled() && SUPPORTED_NOTE_TYPES.includes(this.note?.type ?? ""); + return super.isEnabled() + && (SUPPORTED_NOTE_TYPES.includes(this.note?.type ?? "") + || this.noteContext?.viewScope?.viewMode === "source"); } async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { From 22eb2697d5c45c52c3006e785174f6d306442b8b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 14 Apr 2026 22:25:20 +0300 Subject: [PATCH 03/44] feat(text): adjustable tab width (closes #5701) --- .../src/stylesheets/theme-next/notes/text.css | 4 ++++ .../widgets/type_widgets/options/text_notes.tsx | 15 +++++++-------- .../type_widgets/text/CKEditorWithWatchdog.tsx | 6 +++--- .../widgets/type_widgets/text/EditableText.tsx | 2 ++ .../widgets/type_widgets/text/ReadOnlyText.tsx | 4 +++- 5 files changed, 19 insertions(+), 12 deletions(-) diff --git a/apps/client/src/stylesheets/theme-next/notes/text.css b/apps/client/src/stylesheets/theme-next/notes/text.css index 252a3da6b2..8e0f1d195e 100644 --- a/apps/client/src/stylesheets/theme-next/notes/text.css +++ b/apps/client/src/stylesheets/theme-next/notes/text.css @@ -667,6 +667,10 @@ html .note-detail-editable-text :not(figure, .include-note, hr):first-child { white-space: pre; } +.ck-content pre code { + tab-size: var(--code-block-tab-width, 4); +} + .code-sample-wrapper .hljs { transition: background-color linear 100ms; } diff --git a/apps/client/src/widgets/type_widgets/options/text_notes.tsx b/apps/client/src/widgets/type_widgets/options/text_notes.tsx index 00c66d92b5..f6eed42348 100644 --- a/apps/client/src/widgets/type_widgets/options/text_notes.tsx +++ b/apps/client/src/widgets/type_widgets/options/text_notes.tsx @@ -295,7 +295,7 @@ function CodeBlockStyle() { /> - + ); } @@ -317,7 +317,7 @@ function greet(times) { } `; -function CodeBlockPreview({ theme, wordWrap }: { theme: string, wordWrap: boolean }) { +function CodeBlockPreview({ theme, wordWrap, tabWidth }: { theme: string, wordWrap: boolean, tabWidth: string }) { const [ code, setCode ] = useState(SAMPLE_CODE); useEffect(() => { @@ -337,12 +337,11 @@ function CodeBlockPreview({ theme, wordWrap }: { theme: string, wordWrap: boolea }, [theme]); const codeStyle = useMemo(() => { - if (wordWrap) { - return { whiteSpace: "pre-wrap" }; - } - return { whiteSpace: "pre"}; - - }, [ wordWrap ]); + return { + whiteSpace: wordWrap ? "pre-wrap" : "pre", + tabSize: tabWidth ?? "4" + }; + }, [ wordWrap, tabWidth ]); return (
diff --git a/apps/client/src/widgets/type_widgets/text/CKEditorWithWatchdog.tsx b/apps/client/src/widgets/type_widgets/text/CKEditorWithWatchdog.tsx index dd4d9d02c9..40fd8f55f6 100644 --- a/apps/client/src/widgets/type_widgets/text/CKEditorWithWatchdog.tsx +++ b/apps/client/src/widgets/type_widgets/text/CKEditorWithWatchdog.tsx @@ -20,7 +20,7 @@ export interface CKEditorApi { addImage(noteId: string): Promise; } -interface CKEditorWithWatchdogProps extends Pick, "className" | "tabIndex"> { +interface CKEditorWithWatchdogProps extends Pick, "className" | "tabIndex" | "style"> { contentLanguage: string | null | undefined; isClassicEditor?: boolean; watchdogRef: RefObject; @@ -35,7 +35,7 @@ interface CKEditorWithWatchdogProps extends Pick, "cla containerRef?: RefObject; } -export default function CKEditorWithWatchdog({ containerRef: externalContainerRef, contentLanguage, className, tabIndex, isClassicEditor, watchdogRef: externalWatchdogRef, watchdogConfig, onNotificationWarning, onWatchdogStateChange, onChange, onEditorInitialized, editorApi, templates }: CKEditorWithWatchdogProps) { +export default function CKEditorWithWatchdog({ containerRef: externalContainerRef, contentLanguage, className, style, tabIndex, isClassicEditor, watchdogRef: externalWatchdogRef, watchdogConfig, onNotificationWarning, onWatchdogStateChange, onChange, onEditorInitialized, editorApi, templates }: CKEditorWithWatchdogProps) { const containerRef = useSyncedRef(externalContainerRef, null); const watchdogRef = useRef(null); const [ uiLanguage ] = useTriliumOption("locale"); @@ -232,7 +232,7 @@ export default function CKEditorWithWatchdog({ containerRef: externalContainerRe }, [ editor, onChange ]); return ( -
+
); } diff --git a/apps/client/src/widgets/type_widgets/text/EditableText.tsx b/apps/client/src/widgets/type_widgets/text/EditableText.tsx index fba7d6966e..e7cb326553 100644 --- a/apps/client/src/widgets/type_widgets/text/EditableText.tsx +++ b/apps/client/src/widgets/type_widgets/text/EditableText.tsx @@ -36,6 +36,7 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext const [ language ] = useNoteLabel(note, "language"); const [ textNoteEditorType ] = useTriliumOption("textNoteEditorType"); const [ codeBlockWordWrap ] = useTriliumOptionBool("codeBlockWordWrap"); + const [ codeBlockTabWidth ] = useTriliumOption("codeBlockTabWidth"); const isClassicEditor = isMobile() || textNoteEditorType === "ckeditor-classic"; const initialized = useRef(deferred()); const spacedUpdate = useEditorSpacedUpdate({ @@ -224,6 +225,7 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext {note && !!templates && (null); const [ codeBlockWordWrap ] = useTriliumOptionBool("codeBlockWordWrap"); + const [ codeBlockTabWidth ] = useTriliumOption("codeBlockTabWidth"); const { isRtl } = useNoteLanguage(note); // Apply necessary transforms. @@ -59,6 +60,7 @@ export default function ReadOnlyText({ note, noteContext, ntxId }: TypeWidgetPro Date: Tue, 14 Apr 2026 22:30:20 +0300 Subject: [PATCH 04/44] feat(print): basic PDF preview --- apps/client/src/components/app_context.ts | 2 + apps/client/src/layouts/layout_commons.tsx | 2 + .../src/translations/en/translation.json | 5 ++ apps/client/src/widgets/NoteDetail.tsx | 9 ++- .../src/widgets/dialogs/print_preview.tsx | 68 +++++++++++++++++++ apps/server/src/services/window.ts | 60 ++++++++++++++++ 6 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 apps/client/src/widgets/dialogs/print_preview.tsx 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/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 0e335e4da0..9fb2b1ec0f 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -2305,6 +2305,11 @@ "toggle": "Toggle right panel", "custom_widget_go_to_source": "Go to source code" }, + "print_preview": { + "title": "Print preview", + "close": "Close", + "save": "Save as PDF" + }, "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..26765d46d5 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,11 +147,17 @@ export default function NoteDetail() { toast.closePersistent("printing"); handlePrintReport(printReport); }; + const onPreviewResult = (_e: any, { buffer, title }: { buffer: Uint8Array; title: string }) => { + toast.closePersistent("printing"); + appContext.triggerCommand("showPrintPreview", { pdfBuffer: buffer, title }); + }; 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); }; }, []); @@ -215,7 +222,7 @@ export default function NoteDetail() { showToast("exporting_pdf"); const { ipcRenderer } = dynamicRequire("electron"); - ipcRenderer.send("export-as-pdf", { + ipcRenderer.send("export-as-pdf-preview", { title: note.title, notePath: noteContext.notePath, pageSize: note.getAttributeValue("label", "printPageSize") ?? "Letter", 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..ca8cfdfd46 --- /dev/null +++ b/apps/client/src/widgets/dialogs/print_preview.tsx @@ -0,0 +1,68 @@ +import { useRef, useState } from "preact/hooks"; +import Modal from "../react/Modal"; +import PdfViewer from "../type_widgets/file/PdfViewer"; +import Button from "../react/Button"; +import { useTriliumEvent } from "../react/hooks"; +import { t } from "../../services/i18n"; +import { dynamicRequire } from "../../services/utils"; + +export interface PrintPreviewData { + pdfBuffer: Uint8Array; + title: string; +} + +export default function PrintPreviewDialog() { + const [shown, setShown] = useState(false); + const [pdfUrl, setPdfUrl] = useState(); + const bufferRef = useRef(); + const titleRef = useRef(""); + + useTriliumEvent("showPrintPreview", (data: PrintPreviewData) => { + bufferRef.current = data.pdfBuffer; + titleRef.current = data.title; + + const blob = new Blob([data.pdfBuffer as BlobPart], { type: "application/pdf" }); + const url = URL.createObjectURL(blob); + setPdfUrl(url); + setShown(true); + }); + + function handleClose() { + setShown(false); + if (pdfUrl) { + URL.revokeObjectURL(pdfUrl); + setPdfUrl(undefined); + } + bufferRef.current = undefined; + } + + function handleSave() { + if (!bufferRef.current) return; + + const { ipcRenderer } = dynamicRequire("electron"); + ipcRenderer.send("save-pdf", { + title: titleRef.current, + buffer: bufferRef.current + }); + handleClose(); + } + + return ( + +
From cd84e6ba0827f7f6965f82aa5ba5e6e912f80ac4 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 14 Apr 2026 23:10:57 +0300 Subject: [PATCH 10/44] fix(text): tab width is reset when component is re-rendered --- .../src/widgets/type_widgets/text/CKEditorWithWatchdog.tsx | 6 +++--- apps/client/src/widgets/type_widgets/text/EditableText.tsx | 5 ++++- apps/client/src/widgets/type_widgets/text/ReadOnlyText.tsx | 5 ++++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/client/src/widgets/type_widgets/text/CKEditorWithWatchdog.tsx b/apps/client/src/widgets/type_widgets/text/CKEditorWithWatchdog.tsx index 40fd8f55f6..dd4d9d02c9 100644 --- a/apps/client/src/widgets/type_widgets/text/CKEditorWithWatchdog.tsx +++ b/apps/client/src/widgets/type_widgets/text/CKEditorWithWatchdog.tsx @@ -20,7 +20,7 @@ export interface CKEditorApi { addImage(noteId: string): Promise; } -interface CKEditorWithWatchdogProps extends Pick, "className" | "tabIndex" | "style"> { +interface CKEditorWithWatchdogProps extends Pick, "className" | "tabIndex"> { contentLanguage: string | null | undefined; isClassicEditor?: boolean; watchdogRef: RefObject; @@ -35,7 +35,7 @@ interface CKEditorWithWatchdogProps extends Pick, "cla containerRef?: RefObject; } -export default function CKEditorWithWatchdog({ containerRef: externalContainerRef, contentLanguage, className, style, tabIndex, isClassicEditor, watchdogRef: externalWatchdogRef, watchdogConfig, onNotificationWarning, onWatchdogStateChange, onChange, onEditorInitialized, editorApi, templates }: CKEditorWithWatchdogProps) { +export default function CKEditorWithWatchdog({ containerRef: externalContainerRef, contentLanguage, className, tabIndex, isClassicEditor, watchdogRef: externalWatchdogRef, watchdogConfig, onNotificationWarning, onWatchdogStateChange, onChange, onEditorInitialized, editorApi, templates }: CKEditorWithWatchdogProps) { const containerRef = useSyncedRef(externalContainerRef, null); const watchdogRef = useRef(null); const [ uiLanguage ] = useTriliumOption("locale"); @@ -232,7 +232,7 @@ export default function CKEditorWithWatchdog({ containerRef: externalContainerRe }, [ editor, onChange ]); return ( -
+
); } diff --git a/apps/client/src/widgets/type_widgets/text/EditableText.tsx b/apps/client/src/widgets/type_widgets/text/EditableText.tsx index e7cb326553..2caf37c661 100644 --- a/apps/client/src/widgets/type_widgets/text/EditableText.tsx +++ b/apps/client/src/widgets/type_widgets/text/EditableText.tsx @@ -220,12 +220,15 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext const onWatchdogStateChange = useWatchdogCrashHandling(); + useEffect(() => { + document.body.style.setProperty("--code-block-tab-width", codeBlockTabWidth ?? "4"); + }, [codeBlockTabWidth]); + return ( <> {note && !!templates && { + document.body.style.setProperty("--code-block-tab-width", codeBlockTabWidth ?? "4"); + }, [codeBlockTabWidth]); + // Apply necessary transforms. useEffect(() => { const container = contentRef.current; @@ -60,7 +64,6 @@ export default function ReadOnlyText({ note, noteContext, ntxId }: TypeWidgetPro Date: Tue, 14 Apr 2026 23:24:37 +0300 Subject: [PATCH 11/44] chore(print/pdf): make use of attribute for adjusting orientation --- apps/client/src/widgets/NoteDetail.tsx | 8 +++++--- .../src/widgets/dialogs/print_preview.tsx | 18 +++++++++--------- apps/server/src/services/window.ts | 4 ++-- packages/commons/src/lib/attribute_names.ts | 3 +++ 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/apps/client/src/widgets/NoteDetail.tsx b/apps/client/src/widgets/NoteDetail.tsx index 476c8fe1b7..9ab8fa2e44 100644 --- a/apps/client/src/widgets/NoteDetail.tsx +++ b/apps/client/src/widgets/NoteDetail.tsx @@ -147,9 +147,11 @@ export default function NoteDetail() { toast.closePersistent("printing"); handlePrintReport(printReport); }; - const onPreviewResult = (_e: any, { buffer, title, notePath, pageSize, landscape }: { buffer: Uint8Array; title: string; notePath: string; pageSize: string; landscape: boolean }) => { + const onPreviewResult = (_e: any, { buffer, notePath, pageSize }: { buffer: Uint8Array; notePath: string; pageSize: string }) => { toast.closePersistent("printing"); - appContext.triggerCommand("showPrintPreview", { pdfBuffer: buffer, title, notePath, pageSize, landscape }); + if (note) { + appContext.triggerCommand("showPrintPreview", { pdfBuffer: buffer, note, notePath, pageSize }); + } }; ipcRenderer.on("print-progress", onPrintProgress); ipcRenderer.on("print-done", onPrintDone); @@ -159,7 +161,7 @@ export default function NoteDetail() { ipcRenderer.off("print-done", onPrintDone); ipcRenderer.off("export-as-pdf-preview-result", onPreviewResult); }; - }, []); + }, [note]); useTriliumEvent("executeInActiveNoteDetailWidget", ({ callback }) => { if (!noteContext?.isActive()) return; diff --git a/apps/client/src/widgets/dialogs/print_preview.tsx b/apps/client/src/widgets/dialogs/print_preview.tsx index 43e2edc1f8..2216e06a26 100644 --- a/apps/client/src/widgets/dialogs/print_preview.tsx +++ b/apps/client/src/widgets/dialogs/print_preview.tsx @@ -1,10 +1,11 @@ import { useCallback, 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 { useTriliumEvent } from "../react/hooks"; +import { useNoteLabelBoolean, useTriliumEvent } from "../react/hooks"; import Modal from "../react/Modal"; import PdfViewer from "../type_widgets/file/PdfViewer"; import OptionsRow from "../type_widgets/options/components/OptionsRow"; @@ -12,22 +13,22 @@ import OptionsSection from "../type_widgets/options/components/OptionsSection"; export interface PrintPreviewData { pdfBuffer: Uint8Array; - title: string; + note: FNote; notePath: string; pageSize: string; - landscape: boolean; } export default function PrintPreviewDialog() { const [shown, setShown] = useState(false); const [pdfUrl, setPdfUrl] = useState(); - const [landscape, setLandscape] = useState(false); + const [note, setNote] = useState(); const [loading, setLoading] = useState(false); const bufferRef = useRef(); - const titleRef = useRef(""); const notePathRef = useRef(""); const pageSizeRef = useRef(""); + const [landscape, setLandscape] = useNoteLabelBoolean(note, "printLandscape"); + const updatePreview = useCallback((buffer: Uint8Array) => { bufferRef.current = buffer; @@ -42,10 +43,9 @@ export default function PrintPreviewDialog() { }, [pdfUrl]); useTriliumEvent("showPrintPreview", (data: PrintPreviewData) => { - titleRef.current = data.title; + setNote(data.note); notePathRef.current = data.notePath; pageSizeRef.current = data.pageSize; - setLandscape(data.landscape); updatePreview(data.pdfBuffer); setShown(true); }); @@ -65,7 +65,7 @@ export default function PrintPreviewDialog() { const { ipcRenderer } = dynamicRequire("electron"); ipcRenderer.send("save-pdf", { - title: titleRef.current, + title: note?.title ?? "", buffer: bufferRef.current }); handleClose(); @@ -91,7 +91,7 @@ export default function PrintPreviewDialog() { ipcRenderer.once("export-as-pdf-preview-result", onResult); ipcRenderer.send("export-as-pdf-preview", { - title: titleRef.current, + title: note?.title ?? "", notePath: notePathRef.current, pageSize: pageSizeRef.current, landscape: newLandscape diff --git a/apps/server/src/services/window.ts b/apps/server/src/services/window.ts index 97ab93d267..ea120deb72 100644 --- a/apps/server/src/services/window.ts +++ b/apps/server/src/services/window.ts @@ -168,7 +168,7 @@ electron.ipcMain.on("export-as-pdf", async (e, { title, notePath, landscape, pag } }); -electron.ipcMain.on("export-as-pdf-preview", async (e, { title, notePath, landscape, pageSize }: ExportAsPdfOpts) => { +electron.ipcMain.on("export-as-pdf-preview", async (e, { notePath, landscape, pageSize }: ExportAsPdfOpts) => { try { const { browserWindow, printReport } = await getBrowserWindowForPrinting(e, notePath, "exporting_pdf"); @@ -187,7 +187,7 @@ electron.ipcMain.on("export-as-pdf-preview", async (e, { title, notePath, landsc ` }); - e.sender.send("export-as-pdf-preview-result", { buffer, title, notePath, pageSize, landscape }); + e.sender.send("export-as-pdf-preview-result", { buffer, notePath, pageSize }); } catch (_e) { electron.dialog.showErrorBox(t("pdf.unable-to-export-title"), t("pdf.unable-to-export-message")); } finally { diff --git a/packages/commons/src/lib/attribute_names.ts b/packages/commons/src/lib/attribute_names.ts index ce58c131bb..bde3ba98f2 100644 --- a/packages/commons/src/lib/attribute_names.ts +++ b/packages/commons/src/lib/attribute_names.ts @@ -60,6 +60,9 @@ type Labels = { "presentation:theme": string; "slide:background": string; + // Print/export + printLandscape: boolean; + // Note-type specific webViewSrc: string; "disabled:webViewSrc": string; From f7a36fc9975f722e4ec7ab7b7bd10b48b5c9fcfa Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 14 Apr 2026 23:31:05 +0300 Subject: [PATCH 12/44] feat(print/pdf): adjustable page size --- .../src/translations/en/translation.json | 3 +- apps/client/src/widgets/NoteDetail.tsx | 4 +- .../src/widgets/dialogs/print_preview.tsx | 37 ++++++++++++++----- apps/server/src/services/window.ts | 2 +- packages/commons/src/lib/attribute_names.ts | 1 + 5 files changed, 33 insertions(+), 14 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 41e575e5be..f9cbbb146f 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -2311,7 +2311,8 @@ "save": "Save as PDF", "orientation": "Orientation", "portrait": "Portrait", - "landscape": "Landscape" + "landscape": "Landscape", + "page_size": "Page size" }, "pdf": { "attachments_one": "{{count}} attachment", diff --git a/apps/client/src/widgets/NoteDetail.tsx b/apps/client/src/widgets/NoteDetail.tsx index 9ab8fa2e44..e94a95b6e8 100644 --- a/apps/client/src/widgets/NoteDetail.tsx +++ b/apps/client/src/widgets/NoteDetail.tsx @@ -147,10 +147,10 @@ export default function NoteDetail() { toast.closePersistent("printing"); handlePrintReport(printReport); }; - const onPreviewResult = (_e: any, { buffer, notePath, pageSize }: { buffer: Uint8Array; notePath: string; pageSize: string }) => { + const onPreviewResult = (_e: any, { buffer, notePath }: { buffer: Uint8Array; notePath: string }) => { toast.closePersistent("printing"); if (note) { - appContext.triggerCommand("showPrintPreview", { pdfBuffer: buffer, note, notePath, pageSize }); + appContext.triggerCommand("showPrintPreview", { pdfBuffer: buffer, note, notePath }); } }; ipcRenderer.on("print-progress", onPrintProgress); diff --git a/apps/client/src/widgets/dialogs/print_preview.tsx b/apps/client/src/widgets/dialogs/print_preview.tsx index 2216e06a26..31e00b8f20 100644 --- a/apps/client/src/widgets/dialogs/print_preview.tsx +++ b/apps/client/src/widgets/dialogs/print_preview.tsx @@ -5,17 +5,18 @@ import { t } from "../../services/i18n"; import toast from "../../services/toast"; import { dynamicRequire, isElectron } from "../../services/utils"; import Button, { ButtonGroup } from "../react/Button"; -import { useNoteLabelBoolean, useTriliumEvent } from "../react/hooks"; +import { useNoteLabelBoolean, useNoteLabelWithDefault, useTriliumEvent } from "../react/hooks"; import Modal from "../react/Modal"; 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; + export interface PrintPreviewData { pdfBuffer: Uint8Array; note: FNote; notePath: string; - pageSize: string; } export default function PrintPreviewDialog() { @@ -25,9 +26,9 @@ export default function PrintPreviewDialog() { const [loading, setLoading] = useState(false); const bufferRef = useRef(); const notePathRef = useRef(""); - const pageSizeRef = useRef(""); const [landscape, setLandscape] = useNoteLabelBoolean(note, "printLandscape"); + const [pageSize, setPageSize] = useNoteLabelWithDefault(note, "printPageSize", "Letter"); const updatePreview = useCallback((buffer: Uint8Array) => { bufferRef.current = buffer; @@ -45,7 +46,6 @@ export default function PrintPreviewDialog() { useTriliumEvent("showPrintPreview", (data: PrintPreviewData) => { setNote(data.note); notePathRef.current = data.notePath; - pageSizeRef.current = data.pageSize; updatePreview(data.pdfBuffer); setShown(true); }); @@ -74,16 +74,21 @@ export default function PrintPreviewDialog() { function handleOrientationChange(newLandscape: boolean) { if (newLandscape === landscape) return; setLandscape(newLandscape); - regeneratePreview(newLandscape); + regeneratePreview({ landscape: newLandscape, pageSize }); } - function regeneratePreview(newLandscape: boolean) { + function handlePageSizeChange(newPageSize: string) { + if (newPageSize === pageSize) return; + setPageSize(newPageSize); + regeneratePreview({ landscape, pageSize: newPageSize }); + } + + function regeneratePreview(opts: { landscape: boolean; pageSize: string }) { if (!isElectron()) return; setLoading(true); const { ipcRenderer } = dynamicRequire("electron"); - // Listen for the result once. const onResult = (_e: any, { buffer }: { buffer: Uint8Array }) => { toast.closePersistent("printing"); updatePreview(buffer); @@ -91,10 +96,9 @@ export default function PrintPreviewDialog() { ipcRenderer.once("export-as-pdf-preview-result", onResult); ipcRenderer.send("export-as-pdf-preview", { - title: note?.title ?? "", notePath: notePathRef.current, - pageSize: pageSizeRef.current, - landscape: newLandscape + pageSize: opts.pageSize, + landscape: opts.landscape }); } @@ -135,6 +139,19 @@ export default function PrintPreviewDialog() { /> + + + +
diff --git a/apps/server/src/services/window.ts b/apps/server/src/services/window.ts index ea120deb72..bded84603f 100644 --- a/apps/server/src/services/window.ts +++ b/apps/server/src/services/window.ts @@ -187,7 +187,7 @@ electron.ipcMain.on("export-as-pdf-preview", async (e, { notePath, landscape, pa ` }); - e.sender.send("export-as-pdf-preview-result", { buffer, notePath, pageSize }); + e.sender.send("export-as-pdf-preview-result", { buffer, notePath }); } catch (_e) { electron.dialog.showErrorBox(t("pdf.unable-to-export-title"), t("pdf.unable-to-export-message")); } finally { diff --git a/packages/commons/src/lib/attribute_names.ts b/packages/commons/src/lib/attribute_names.ts index bde3ba98f2..b1e398c5ab 100644 --- a/packages/commons/src/lib/attribute_names.ts +++ b/packages/commons/src/lib/attribute_names.ts @@ -62,6 +62,7 @@ type Labels = { // Print/export printLandscape: boolean; + printPageSize: string; // Note-type specific webViewSrc: string; From 54a6e3d9a1baa8bc0e7336d697980fc106444f0b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 14 Apr 2026 23:33:32 +0300 Subject: [PATCH 13/44] feat(code): adjustable default tab width --- .../src/translations/en/translation.json | 4 ++- .../src/widgets/type_widgets/code/Code.tsx | 2 ++ .../widgets/type_widgets/code/CodeMirror.tsx | 7 +++++ .../type_widgets/options/code_notes.tsx | 26 ++++++++++++++++--- .../type_widgets/options/text_notes.tsx | 7 ++--- apps/server/src/routes/api/options.ts | 1 + apps/server/src/services/options_init.ts | 1 + packages/codemirror/src/index.ts | 13 +++++++++- packages/commons/src/lib/options_interface.ts | 1 + 9 files changed, 53 insertions(+), 9 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index f9cbbb146f..42ceef2e2a 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1268,7 +1268,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", diff --git a/apps/client/src/widgets/type_widgets/code/Code.tsx b/apps/client/src/widgets/type_widgets/code/Code.tsx index f59d6eff3c..ec91a4f836 100644 --- a/apps/client/src/widgets/type_widgets/code/Code.tsx +++ b/apps/client/src/widgets/type_widgets/code/Code.tsx @@ -146,6 +146,7 @@ export function CodeEditor({ parentComponent, ntxId, containerRef: externalConta const initialized = useRef($.Deferred()); const [ codeLineWrapEnabled ] = useTriliumOptionBool("codeLineWrapEnabled"); const [ codeNoteTheme ] = useTriliumOption("codeNoteTheme"); + const [ codeNoteTabWidth ] = useTriliumOption("codeNoteTabWidth"); // React to background color. const [ backgroundColor, setBackgroundColor ] = useState(); @@ -200,6 +201,7 @@ export function CodeEditor({ parentComponent, ntxId, containerRef: externalConta editorRef={codeEditorRef} containerRef={containerRef} lineWrapping={lineWrapping ?? codeLineWrapEnabled} + indentSize={parseInt(codeNoteTabWidth) || 4} onInitialized={() => { if (externalContainerRef && containerRef.current) { externalContainerRef.current = containerRef.current; diff --git a/apps/client/src/widgets/type_widgets/code/CodeMirror.tsx b/apps/client/src/widgets/type_widgets/code/CodeMirror.tsx index ae4f849dd1..ecc5e2c658 100644 --- a/apps/client/src/widgets/type_widgets/code/CodeMirror.tsx +++ b/apps/client/src/widgets/type_widgets/code/CodeMirror.tsx @@ -49,6 +49,13 @@ export default function CodeMirror({ className, content, mime, editorRef: extern // React to line wrapping. useEffect(() => codeEditorRef.current?.setLineWrapping(!!lineWrapping), [ lineWrapping ]); + // React to indent size changes. + useEffect(() => { + if (extraOpts.indentSize != null) { + codeEditorRef.current?.setIndentSize(extraOpts.indentSize); + } + }, [ extraOpts.indentSize ]); + return (
     )
diff --git a/apps/client/src/widgets/type_widgets/options/code_notes.tsx b/apps/client/src/widgets/type_widgets/options/code_notes.tsx
index 7721e0de17..4b8192be96 100644
--- a/apps/client/src/widgets/type_widgets/options/code_notes.tsx
+++ b/apps/client/src/widgets/type_widgets/options/code_notes.tsx
@@ -21,11 +21,12 @@ const SAMPLE_MIME = "application/typescript";
 
 export default function CodeNoteSettings() {
     const [codeLineWrapEnabled, setCodeLineWrapEnabled] = useTriliumOptionBool("codeLineWrapEnabled");
+    const [codeNoteTabWidth] = useTriliumOption("codeNoteTabWidth");
 
     return (
         <>
             
-            
+            
             
         
     );
@@ -39,6 +40,7 @@ interface EditorProps {
 function Editor({ wordWrapping, setWordWrapping }: EditorProps) {
     const [vimKeymapEnabled, setVimKeymapEnabled] = useTriliumOptionBool("vimKeymapEnabled");
     const [autoReadonlySize, setAutoReadonlySize] = useTriliumOption("autoReadonlySizeCode");
+    const [codeNoteTabWidth, setCodeNoteTabWidth] = useTriliumOption("codeNoteTabWidth");
 
     return (
         
@@ -49,6 +51,17 @@ function Editor({ wordWrapping, setWordWrapping }: EditorProps) {
                 onChange={setWordWrapping}
             />
 
+            {/* Avoid using "code" in the name of numeric inputs to prevent KeepassXC from triggering. */}
+            
+                
+            
+
             
                  {
@@ -93,12 +107,12 @@ function Appearance({ wordWrapping }: AppearanceProps) {
                 />
             
 
-            
+            
         
     );
 }
 
-function CodeNotePreview({ themeName, wordWrapping }: { themeName: string, wordWrapping: boolean }) {
+function CodeNotePreview({ themeName, wordWrapping, indentSize }: { themeName: string, wordWrapping: boolean, indentSize: number }) {
     const editorRef = useRef(null);
     const containerRef = useRef(null);
 
@@ -124,6 +138,10 @@ function CodeNotePreview({ themeName, wordWrapping }: { themeName: string, wordW
         editorRef.current?.setLineWrapping(wordWrapping);
     }, [ wordWrapping ]);
 
+    useEffect(() => {
+        editorRef.current?.setIndentSize(indentSize);
+    }, [ indentSize ]);
+
     useEffect(() => {
         if (themeName?.startsWith(DEFAULT_PREFIX)) {
             const theme = getThemeById(themeName.substring(DEFAULT_PREFIX.length));
diff --git a/apps/client/src/widgets/type_widgets/options/text_notes.tsx b/apps/client/src/widgets/type_widgets/options/text_notes.tsx
index 4cf24dcedd..b766dad698 100644
--- a/apps/client/src/widgets/type_widgets/options/text_notes.tsx
+++ b/apps/client/src/widgets/type_widgets/options/text_notes.tsx
@@ -286,12 +286,14 @@ function CodeBlockStyle() {
                 onChange={setCodeBlockWordWrap}
             />
 
-            
+            {/* Avoid using "code" in the name of numeric inputs to prevent KeepassXC from triggering. */}
+            
                 
             
 
@@ -336,7 +338,7 @@ function CodeBlockPreview({ theme, wordWrap, tabWidth }: { theme: string, wordWr
         }
     }, [theme]);
 
-    const codeStyle = useMemo(() => {
+    const codeStyle: CSSProperties = useMemo(() => {
         return {
             whiteSpace: wordWrap ? "pre-wrap" : "pre",
             tabSize: tabWidth ?? "4"
@@ -416,4 +418,3 @@ export function HighlightsListOptions() {
         
     );
 }
-
diff --git a/apps/server/src/routes/api/options.ts b/apps/server/src/routes/api/options.ts
index 8191b6812e..137ab51cf3 100644
--- a/apps/server/src/routes/api/options.ts
+++ b/apps/server/src/routes/api/options.ts
@@ -32,6 +32,7 @@ const ALLOWED_OPTIONS = new Set([
     "codeBlockWordWrap",
     "codeBlockTabWidth",
     "codeNoteTheme",
+    "codeNoteTabWidth",
     "syncServerHost",
     "syncServerTimeout",
     "syncServerTimeoutTimeScale",
diff --git a/apps/server/src/services/options_init.ts b/apps/server/src/services/options_init.ts
index bdab81af62..94151b269c 100644
--- a/apps/server/src/services/options_init.ts
+++ b/apps/server/src/services/options_init.ts
@@ -131,6 +131,7 @@ const defaultOptions: DefaultOption[] = [
     { name: "autoFixConsistencyIssues", value: "true", isSynced: false },
     { name: "vimKeymapEnabled", value: "false", isSynced: false },
     { name: "codeLineWrapEnabled", value: "true", isSynced: false },
+    { name: "codeNoteTabWidth", value: "4", isSynced: true },
     {
         name: "codeNotesMimeTypes",
         value: '["text/x-csrc","text/x-c++src","text/x-csharp","text/css","text/x-elixir","text/x-go","text/x-groovy","text/x-haskell","text/html","message/http","text/x-java","application/javascript;env=frontend","application/javascript;env=backend","application/json","text/x-kotlin","text/x-markdown","text/x-perl","text/x-php","text/x-python","text/x-ruby",null,"text/x-sql","text/x-sqlite;schema=trilium","text/x-swift","text/xml","text/x-yaml","text/x-sh","application/typescript"]',
diff --git a/packages/codemirror/src/index.ts b/packages/codemirror/src/index.ts
index ae6142c1de..7b868c859a 100644
--- a/packages/codemirror/src/index.ts
+++ b/packages/codemirror/src/index.ts
@@ -34,6 +34,8 @@ export interface EditorConfig {
     /** Disables some of the nice-to-have features (bracket matching, syntax highlighting, indentation markers) in order to improve performance. */
     preferPerformance?: boolean;
     tabIndex?: number;
+    /** The number of spaces used for indentation. Defaults to 4. */
+    indentSize?: number;
     onContentChanged?: ContentChangedListener;
 }
 
@@ -44,6 +46,7 @@ export default class CodeMirror extends EditorView {
     private historyCompartment: Compartment;
     private themeCompartment: Compartment;
     private lineWrappingCompartment: Compartment;
+    private indentUnitCompartment: Compartment;
     private searchHighlightCompartment: Compartment;
     private searchPlugin?: SearchHighlighter | null;
 
@@ -52,6 +55,7 @@ export default class CodeMirror extends EditorView {
         const historyCompartment = new Compartment();
         const themeCompartment = new Compartment();
         const lineWrappingCompartment = new Compartment();
+        const indentUnitCompartment = new Compartment();
         const searchHighlightCompartment = new Compartment();
 
         let extensions: Extension[] = [];
@@ -68,7 +72,7 @@ export default class CodeMirror extends EditorView {
             searchHighlightCompartment.of([]),
             highlightActiveLine(),
             lineNumbers(),
-            indentUnit.of(" ".repeat(4)),
+            indentUnitCompartment.of(indentUnit.of(" ".repeat(config.indentSize ?? 4))),
             keymap.of([
                 ...preventCtrlEnterKeymap,
                 ...defaultKeymap,
@@ -121,6 +125,7 @@ export default class CodeMirror extends EditorView {
         this.historyCompartment = historyCompartment;
         this.themeCompartment = themeCompartment;
         this.lineWrappingCompartment = lineWrappingCompartment;
+        this.indentUnitCompartment = indentUnitCompartment;
         this.searchHighlightCompartment = searchHighlightCompartment;
     }
 
@@ -168,6 +173,12 @@ export default class CodeMirror extends EditorView {
         });
     }
 
+    setIndentSize(size: number) {
+        this.dispatch({
+            effects: [ this.indentUnitCompartment.reconfigure(indentUnit.of(" ".repeat(size))) ]
+        });
+    }
+
     /**
      * Clears the history of undo/redo. Generally useful when changing to a new document.
      */
diff --git a/packages/commons/src/lib/options_interface.ts b/packages/commons/src/lib/options_interface.ts
index b64ae1e568..d89f1460fd 100644
--- a/packages/commons/src/lib/options_interface.ts
+++ b/packages/commons/src/lib/options_interface.ts
@@ -161,6 +161,7 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions
Date: Tue, 14 Apr 2026 23:40:39 +0300
Subject: [PATCH 14/44] fix(options): code preview doesn't reflect tab width

---
 .../widgets/type_widgets/options/code_notes.tsx    | 14 +++++++++++++-
 .../widgets/type_widgets/options/text_notes.tsx    |  6 +++---
 2 files changed, 16 insertions(+), 4 deletions(-)

diff --git a/apps/client/src/widgets/type_widgets/options/code_notes.tsx b/apps/client/src/widgets/type_widgets/options/code_notes.tsx
index 4b8192be96..511c40b38f 100644
--- a/apps/client/src/widgets/type_widgets/options/code_notes.tsx
+++ b/apps/client/src/widgets/type_widgets/options/code_notes.tsx
@@ -139,7 +139,10 @@ function CodeNotePreview({ themeName, wordWrapping, indentSize }: { themeName: s
     }, [ wordWrapping ]);
 
     useEffect(() => {
-        editorRef.current?.setIndentSize(indentSize);
+        const editor = editorRef.current;
+        if (!editor) return;
+        editor.setIndentSize(indentSize);
+        editor.setText(reindentSample(codeNoteSample, indentSize));
     }, [ indentSize ]);
 
     useEffect(() => {
@@ -160,6 +163,15 @@ function CodeNotePreview({ themeName, wordWrapping, indentSize }: { themeName: s
     );
 }
 
+const SAMPLE_BASE_INDENT = 4;
+
+function reindentSample(sample: string, indentSize: number): string {
+    return sample.replace(/^( +)/gm, (match) => {
+        const level = match.length / SAMPLE_BASE_INDENT;
+        return " ".repeat(Math.round(level) * indentSize);
+    });
+}
+
 function CodeMimeTypes() {
     return (
         
diff --git a/apps/client/src/widgets/type_widgets/options/text_notes.tsx b/apps/client/src/widgets/type_widgets/options/text_notes.tsx
index b766dad698..7a6319562e 100644
--- a/apps/client/src/widgets/type_widgets/options/text_notes.tsx
+++ b/apps/client/src/widgets/type_widgets/options/text_notes.tsx
@@ -313,9 +313,9 @@ greet(n); // Print "Hello World" for n times
  * @param {number} times    The number of times to print the \`Hello World!\` message.
  */
 function greet(times) {
-  for (let i = 0; i++; i < times) {
-    console.log("Hello World!");
-  }
+\tfor (let i = 0; i++; i < times) {
+\t\tconsole.log("Hello World!");
+\t}
 }
 `;
 

From 457bba9337bd9b67e06e618c7c7c39c6a1d0ce55 Mon Sep 17 00:00:00 2001
From: Elian Doran 
Date: Tue, 14 Apr 2026 23:48:12 +0300
Subject: [PATCH 15/44] feat(print/pdf): basic support for scale

---
 .../src/translations/en/translation.json      |  4 ++-
 apps/client/src/widgets/NoteDetail.tsx        |  3 +-
 .../attribute_widgets/attribute_detail.ts     |  3 +-
 .../src/widgets/dialogs/print_preview.tsx     | 28 ++++++++++++++++---
 apps/server/src/services/window.ts            |  7 +++--
 packages/commons/src/lib/attribute_names.ts   |  1 +
 .../commons/src/lib/builtin_attributes.ts     |  1 +
 7 files changed, 38 insertions(+), 9 deletions(-)

diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json
index 42ceef2e2a..cb20feac5e 100644
--- a/apps/client/src/translations/en/translation.json
+++ b/apps/client/src/translations/en/translation.json
@@ -454,6 +454,7 @@
     "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%).",
     "color_type": "Color"
   },
   "attribute_editor": {
@@ -2314,7 +2315,8 @@
     "orientation": "Orientation",
     "portrait": "Portrait",
     "landscape": "Landscape",
-    "page_size": "Page size"
+    "page_size": "Page size",
+    "scale": "Scale"
   },
   "pdf": {
     "attachments_one": "{{count}} attachment",
diff --git a/apps/client/src/widgets/NoteDetail.tsx b/apps/client/src/widgets/NoteDetail.tsx
index e94a95b6e8..ab8254ba29 100644
--- a/apps/client/src/widgets/NoteDetail.tsx
+++ b/apps/client/src/widgets/NoteDetail.tsx
@@ -228,7 +228,8 @@ export default function NoteDetail() {
             title: note.title,
             notePath: noteContext.notePath,
             pageSize: note.getAttributeValue("label", "printPageSize") ?? "Letter",
-            landscape: note.hasAttribute("label", "printLandscape")
+            landscape: note.hasAttribute("label", "printLandscape"),
+            scale: parseFloat(note.getAttributeValue("label", "printScale") ?? "1") || 1
         });
     });
 
diff --git a/apps/client/src/widgets/attribute_widgets/attribute_detail.ts b/apps/client/src/widgets/attribute_widgets/attribute_detail.ts
index 73bf2cc2d6..aed675ecf0 100644
--- a/apps/client/src/widgets/attribute_widgets/attribute_detail.ts
+++ b/apps/client/src/widgets/attribute_widgets/attribute_detail.ts
@@ -267,7 +267,8 @@ 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")
     },
     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
index 31e00b8f20..0728e68f88 100644
--- a/apps/client/src/widgets/dialogs/print_preview.tsx
+++ b/apps/client/src/widgets/dialogs/print_preview.tsx
@@ -7,6 +7,7 @@ import { dynamicRequire, isElectron } from "../../services/utils";
 import Button, { ButtonGroup } from "../react/Button";
 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";
@@ -29,6 +30,8 @@ export default function PrintPreviewDialog() {
 
     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 updatePreview = useCallback((buffer: Uint8Array) => {
         bufferRef.current = buffer;
@@ -74,16 +77,22 @@ export default function PrintPreviewDialog() {
     function handleOrientationChange(newLandscape: boolean) {
         if (newLandscape === landscape) return;
         setLandscape(newLandscape);
-        regeneratePreview({ landscape: newLandscape, pageSize });
+        regeneratePreview({ landscape: newLandscape, pageSize, scale });
     }
 
     function handlePageSizeChange(newPageSize: string) {
         if (newPageSize === pageSize) return;
         setPageSize(newPageSize);
-        regeneratePreview({ landscape, pageSize: newPageSize });
+        regeneratePreview({ landscape, pageSize: newPageSize, scale });
     }
 
-    function regeneratePreview(opts: { landscape: boolean; pageSize: string }) {
+    function handleScaleChange(newScale: number) {
+        const clamped = Math.min(2, Math.max(0.1, Math.round(newScale * 10) / 10));
+        setScaleStr(String(clamped));
+        regeneratePreview({ landscape, pageSize, scale: clamped });
+    }
+
+    function regeneratePreview(opts: { landscape: boolean; pageSize: string; scale: number }) {
         if (!isElectron()) return;
 
         setLoading(true);
@@ -98,7 +107,8 @@ export default function PrintPreviewDialog() {
         ipcRenderer.send("export-as-pdf-preview", {
             notePath: notePathRef.current,
             pageSize: opts.pageSize,
-            landscape: opts.landscape
+            landscape: opts.landscape,
+            scale: opts.scale
         });
     }
 
@@ -152,6 +162,16 @@ export default function PrintPreviewDialog() {
                             ))}
                         
                     
+
+                    
+                        
+                    
                 
             
diff --git a/apps/server/src/services/window.ts b/apps/server/src/services/window.ts index bded84603f..de39b08594 100644 --- a/apps/server/src/services/window.ts +++ b/apps/server/src/services/window.ts @@ -86,6 +86,7 @@ interface ExportAsPdfOpts { title: string; landscape: boolean; pageSize: "A0" | "A1" | "A2" | "A3" | "A4" | "A5" | "A6" | "Legal" | "Letter" | "Tabloid" | "Ledger"; + scale: number; } electron.ipcMain.on("print-note", async (e, { notePath }: PrintOpts) => { @@ -107,7 +108,7 @@ electron.ipcMain.on("print-note", async (e, { notePath }: PrintOpts) => { } }); -electron.ipcMain.on("export-as-pdf", async (e, { title, notePath, landscape, pageSize }: ExportAsPdfOpts) => { +electron.ipcMain.on("export-as-pdf", async (e, { title, notePath, landscape, pageSize, scale }: ExportAsPdfOpts) => { try { const { browserWindow, printReport } = await getBrowserWindowForPrinting(e, notePath, "exporting_pdf"); @@ -128,6 +129,7 @@ electron.ipcMain.on("export-as-pdf", async (e, { title, notePath, landscape, pag buffer = await browserWindow.webContents.printToPDF({ landscape, pageSize, + scale, generateDocumentOutline: true, generateTaggedPDF: true, printBackground: true, @@ -168,7 +170,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 }: ExportAsPdfOpts) => { +electron.ipcMain.on("export-as-pdf-preview", async (e, { notePath, landscape, pageSize, scale }: ExportAsPdfOpts) => { try { const { browserWindow, printReport } = await getBrowserWindowForPrinting(e, notePath, "exporting_pdf"); @@ -176,6 +178,7 @@ electron.ipcMain.on("export-as-pdf-preview", async (e, { notePath, landscape, pa const buffer = await browserWindow.webContents.printToPDF({ landscape, pageSize, + scale, generateDocumentOutline: true, generateTaggedPDF: true, printBackground: true, diff --git a/packages/commons/src/lib/attribute_names.ts b/packages/commons/src/lib/attribute_names.ts index b1e398c5ab..30f65faefe 100644 --- a/packages/commons/src/lib/attribute_names.ts +++ b/packages/commons/src/lib/attribute_names.ts @@ -63,6 +63,7 @@ type Labels = { // Print/export printLandscape: boolean; printPageSize: string; + printScale: string; // Note-type specific webViewSrc: string; diff --git a/packages/commons/src/lib/builtin_attributes.ts b/packages/commons/src/lib/builtin_attributes.ts index 2172c6fa50..0ef66064ec 100644 --- a/packages/commons/src/lib/builtin_attributes.ts +++ b/packages/commons/src/lib/builtin_attributes.ts @@ -85,6 +85,7 @@ export default [ { type: "label", name: "printLandscape" }, { type: "label", name: "printPageSize" }, + { type: "label", name: "printScale" }, // relation names { type: "relation", name: "internalLink" }, From fc82b9374e2d156f5607f13fed1c9f6f17acfd03 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 14 Apr 2026 23:51:14 +0300 Subject: [PATCH 16/44] feat(print/pdf): debounce rendering when changing scale --- apps/client/src/widgets/dialogs/print_preview.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/client/src/widgets/dialogs/print_preview.tsx b/apps/client/src/widgets/dialogs/print_preview.tsx index 0728e68f88..acffc0652e 100644 --- a/apps/client/src/widgets/dialogs/print_preview.tsx +++ b/apps/client/src/widgets/dialogs/print_preview.tsx @@ -86,10 +86,16 @@ export default function PrintPreviewDialog() { regeneratePreview({ landscape, pageSize: newPageSize, scale }); } + const scaleDebounceRef = useRef>(); + function handleScaleChange(newScale: number) { const clamped = Math.min(2, Math.max(0.1, Math.round(newScale * 10) / 10)); setScaleStr(String(clamped)); - regeneratePreview({ landscape, pageSize, scale: clamped }); + + clearTimeout(scaleDebounceRef.current); + scaleDebounceRef.current = setTimeout(() => { + regeneratePreview({ landscape, pageSize, scale: clamped }); + }, 500); } function regeneratePreview(opts: { landscape: boolean; pageSize: string; scale: number }) { From 6a05288be416b63c815204801917cfe6e45093dc Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 14 Apr 2026 23:58:01 +0300 Subject: [PATCH 17/44] feat(code): adjustable tab width via attribute --- apps/client/src/widgets/type_widgets/code/Code.tsx | 8 ++++++-- packages/commons/src/lib/attribute_names.ts | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/client/src/widgets/type_widgets/code/Code.tsx b/apps/client/src/widgets/type_widgets/code/Code.tsx index ec91a4f836..f5bbc02da6 100644 --- a/apps/client/src/widgets/type_widgets/code/Code.tsx +++ b/apps/client/src/widgets/type_widgets/code/Code.tsx @@ -8,7 +8,7 @@ import appContext, { CommandListenerData } from "../../../components/app_context import FNote from "../../../entities/fnote"; import { t } from "../../../services/i18n"; import utils from "../../../services/utils"; -import { useEditorSpacedUpdate, useKeyboardShortcuts, useLegacyImperativeHandlers, useNoteBlob, useNoteProperty, useSyncedRef, useTriliumEvent, useTriliumOption, useTriliumOptionBool } from "../../react/hooks"; +import { useEditorSpacedUpdate, useKeyboardShortcuts, useLegacyImperativeHandlers, useNoteBlob, useNoteLabelInt, useNoteProperty, useSyncedRef, useTriliumEvent, useTriliumOption, useTriliumOptionBool } from "../../react/hooks"; import { refToJQuerySelector } from "../../react/react_utils"; import TouchBar, { TouchBarButton } from "../../react/TouchBar"; import { CODE_THEME_DEFAULT_PREFIX as DEFAULT_PREFIX } from "../constants"; @@ -36,6 +36,7 @@ export interface EditableCodeProps extends TypeWidgetProps, Omit { if (!blob) return; @@ -55,6 +56,7 @@ export function ReadOnlyCode({ note, viewScope, ntxId, parentComponent }: TypeWi content={content} mime={note.mime} readOnly + {...(noteTabWidth != null && { indentSize: noteTabWidth })} /> ); } @@ -79,6 +81,7 @@ export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentC const editorRef = useRef(null); const containerRef = useRef(null); const [ vimKeymapEnabled ] = useTriliumOptionBool("vimKeymapEnabled"); + const [ noteTabWidth ] = useNoteLabelInt(note, "tabWidth"); const mime = useNoteProperty(note, "mime"); const spacedUpdate = useEditorSpacedUpdate({ note, @@ -129,6 +132,7 @@ export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentC } }} {...editorProps} + {...(noteTabWidth != null && { indentSize: noteTabWidth })} /> @@ -201,7 +205,7 @@ export function CodeEditor({ parentComponent, ntxId, containerRef: externalConta editorRef={codeEditorRef} containerRef={containerRef} lineWrapping={lineWrapping ?? codeLineWrapEnabled} - indentSize={parseInt(codeNoteTabWidth) || 4} + indentSize={editorProps.indentSize ?? (parseInt(codeNoteTabWidth) || 4)} onInitialized={() => { if (externalContainerRef && containerRef.current) { externalContainerRef.current = containerRef.current; diff --git a/packages/commons/src/lib/attribute_names.ts b/packages/commons/src/lib/attribute_names.ts index 30f65faefe..0442031831 100644 --- a/packages/commons/src/lib/attribute_names.ts +++ b/packages/commons/src/lib/attribute_names.ts @@ -69,6 +69,7 @@ type Labels = { webViewSrc: string; "disabled:webViewSrc": string; readOnly: boolean; + tabWidth: number; mapType: string; mapRootNoteId: string; From 692a31772dc873f13a76a60f5e8ef71f6f3accb0 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 15 Apr 2026 09:01:23 +0300 Subject: [PATCH 18/44] feat(code): status bar indentation selector --- .../src/translations/en/translation.json | 7 ++- apps/client/src/widgets/layout/StatusBar.tsx | 51 ++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index cb20feac5e..c5479736f3 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -2297,7 +2297,12 @@ "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_per_note": "Set for this note", + "tab_width_use_default": "Use default ({{width}})" }, "attributes_panel": { "title": "Note Attributes" diff --git a/apps/client/src/widgets/layout/StatusBar.tsx b/apps/client/src/widgets/layout/StatusBar.tsx index 16f7ad1123..10c2997f85 100644 --- a/apps/client/src/widgets/layout/StatusBar.tsx +++ b/apps/client/src/widgets/layout/StatusBar.tsx @@ -20,7 +20,7 @@ import { formatDateTime } from "../../utils/formatters"; import { BacklinksList, useBacklinkCount } from "../FloatingButtonsDefinitions"; import Dropdown, { DropdownProps } from "../react/Dropdown"; import { FormDropdownDivider, FormListItem } from "../react/FormList"; -import { useActiveNoteContext, useLegacyImperativeHandlers, useNoteLabel, useNoteProperty, useStaticTooltip, useTriliumEvent, useTriliumEvents } from "../react/hooks"; +import { useActiveNoteContext, useLegacyImperativeHandlers, useNoteLabel, useNoteLabelInt, useNoteProperty, useStaticTooltip, useTriliumEvent, useTriliumEvents, useTriliumOption } from "../react/hooks"; import Icon from "../react/Icon"; import LinkButton from "../react/LinkButton"; import { ParentComponent } from "../react/react_utils"; @@ -69,6 +69,7 @@ export default function StatusBar() {
+ {!isHiddenNote && } @@ -436,6 +437,54 @@ function NotePaths({ note, hoistedNoteId, notePath }: StatusBarContext) { } //#endregion +//#region Tab width switcher +const TAB_WIDTH_OPTIONS = [1, 2, 3, 4, 6, 8] as const; + +function TabWidthSwitcher({ note }: StatusBarContext) { + const [ codeNoteTabWidth ] = useTriliumOption("codeNoteTabWidth"); + const [ noteTabWidth, setNoteTabWidth ] = useNoteLabelInt(note, "tabWidth"); + const globalTabWidth = parseInt(codeNoteTabWidth) || 4; + const effectiveTabWidth = noteTabWidth ?? globalTabWidth; + const hasPerNoteOverride = noteTabWidth != null; + + return (note.type === "code" && + + {TAB_WIDTH_OPTIONS.map(size => ( + { + if (hasPerNoteOverride) { + setNoteTabWidth(size); + } else { + attributes.setLabel(note.noteId, "tabWidth", String(size)); + } + }} + > + {t("status_bar.tab_width_spaces", { count: size })} + + ))} + + {hasPerNoteOverride + ? attributes.removeOwnedLabelByName(note, "tabWidth")} + > + {t("status_bar.tab_width_use_default", { width: globalTabWidth })} + + : attributes.setLabel(note.noteId, "tabWidth", String(effectiveTabWidth))}> + {t("status_bar.tab_width_per_note")} + + } + + ); +} +//#endregion + //#region Code note switcher function CodeNoteSwitcher({ note }: StatusBarContext) { const [ modalShown, setModalShown ] = useState(false); From b20e2459c3f6e94218a1b916aea210fb4f1bb111 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 15 Apr 2026 09:06:47 +0300 Subject: [PATCH 19/44] refactor(code): simplify status bar widget --- apps/client/src/widgets/layout/StatusBar.tsx | 27 +++++--------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/apps/client/src/widgets/layout/StatusBar.tsx b/apps/client/src/widgets/layout/StatusBar.tsx index 10c2997f85..df5db50ee6 100644 --- a/apps/client/src/widgets/layout/StatusBar.tsx +++ b/apps/client/src/widgets/layout/StatusBar.tsx @@ -20,7 +20,7 @@ import { formatDateTime } from "../../utils/formatters"; import { BacklinksList, useBacklinkCount } from "../FloatingButtonsDefinitions"; import Dropdown, { DropdownProps } from "../react/Dropdown"; import { FormDropdownDivider, FormListItem } from "../react/FormList"; -import { useActiveNoteContext, useLegacyImperativeHandlers, useNoteLabel, useNoteLabelInt, useNoteProperty, useStaticTooltip, useTriliumEvent, useTriliumEvents, useTriliumOption } from "../react/hooks"; +import { useActiveNoteContext, useLegacyImperativeHandlers, useNoteLabel, useNoteLabelInt, useNoteProperty, useStaticTooltip, useTriliumEvent, useTriliumEvents, useTriliumOptionInt } from "../react/hooks"; import Icon from "../react/Icon"; import LinkButton from "../react/LinkButton"; import { ParentComponent } from "../react/react_utils"; @@ -441,9 +441,8 @@ function NotePaths({ note, hoistedNoteId, notePath }: StatusBarContext) { const TAB_WIDTH_OPTIONS = [1, 2, 3, 4, 6, 8] as const; function TabWidthSwitcher({ note }: StatusBarContext) { - const [ codeNoteTabWidth ] = useTriliumOption("codeNoteTabWidth"); + const [ globalTabWidth ] = useTriliumOptionInt("codeNoteTabWidth"); const [ noteTabWidth, setNoteTabWidth ] = useNoteLabelInt(note, "tabWidth"); - const globalTabWidth = parseInt(codeNoteTabWidth) || 4; const effectiveTabWidth = noteTabWidth ?? globalTabWidth; const hasPerNoteOverride = noteTabWidth != null; @@ -457,29 +456,17 @@ function TabWidthSwitcher({ note }: StatusBarContext) { { - if (hasPerNoteOverride) { - setNoteTabWidth(size); - } else { - attributes.setLabel(note.noteId, "tabWidth", String(size)); - } - }} + onClick={() => setNoteTabWidth(size)} > {t("status_bar.tab_width_spaces", { count: size })} ))} - - {hasPerNoteOverride - ? attributes.removeOwnedLabelByName(note, "tabWidth")} - > + {hasPerNoteOverride && <> + + setNoteTabWidth(null)}> {t("status_bar.tab_width_use_default", { width: globalTabWidth })} - : attributes.setLabel(note.noteId, "tabWidth", String(effectiveTabWidth))}> - {t("status_bar.tab_width_per_note")} - - } + } ); } From f12d73a5c7471493e122deff44d655cc85caace5 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 15 Apr 2026 09:14:55 +0300 Subject: [PATCH 20/44] feat(code): reindent on changing tab width --- apps/client/src/widgets/layout/StatusBar.tsx | 33 ++++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/apps/client/src/widgets/layout/StatusBar.tsx b/apps/client/src/widgets/layout/StatusBar.tsx index df5db50ee6..c6b356ab88 100644 --- a/apps/client/src/widgets/layout/StatusBar.tsx +++ b/apps/client/src/widgets/layout/StatusBar.tsx @@ -440,12 +440,39 @@ function NotePaths({ note, hoistedNoteId, notePath }: StatusBarContext) { //#region Tab width switcher const TAB_WIDTH_OPTIONS = [1, 2, 3, 4, 6, 8] as const; -function TabWidthSwitcher({ note }: StatusBarContext) { +/** + * Re-indents leading spaces on each line, converting from `fromWidth` indent units + * to `toWidth` indent units. Tabs and non-leading whitespace are preserved as-is. + */ +function reindentSpaces(content: string, fromWidth: number, toWidth: number): string { + if (fromWidth === toWidth || fromWidth <= 0) return content; + return content.replace(/^( +)/gm, (leading) => { + const levels = Math.round(leading.length / fromWidth); + const remainder = leading.length - levels * fromWidth; + return " ".repeat(levels * toWidth + Math.max(remainder, 0)); + }); +} + +function TabWidthSwitcher({ note, noteContext }: StatusBarContext) { const [ globalTabWidth ] = useTriliumOptionInt("codeNoteTabWidth"); const [ noteTabWidth, setNoteTabWidth ] = useNoteLabelInt(note, "tabWidth"); const effectiveTabWidth = noteTabWidth ?? globalTabWidth; const hasPerNoteOverride = noteTabWidth != null; + const changeTabWidth = async (newWidth: number | null) => { + const oldWidth = effectiveTabWidth; + const resolvedNewWidth = newWidth ?? globalTabWidth; + setNoteTabWidth(newWidth); + if (oldWidth === resolvedNewWidth) return; + + const editor = await noteContext.getCodeEditor(); + if (!editor) return; + const reindented = reindentSpaces(editor.getText(), oldWidth, resolvedNewWidth); + if (reindented !== editor.getText()) { + editor.setText(reindented); + } + }; + return (note.type === "code" && setNoteTabWidth(size)} + onClick={() => changeTabWidth(size)} > {t("status_bar.tab_width_spaces", { count: size })} ))} {hasPerNoteOverride && <> - setNoteTabWidth(null)}> + changeTabWidth(null)}> {t("status_bar.tab_width_use_default", { width: globalTabWidth })} } From 307536b70fd01e45cf412e1167a6e2f7a67ed204 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 15 Apr 2026 09:19:33 +0300 Subject: [PATCH 21/44] feat(code): separate reindentation from display width --- .../src/translations/en/translation.json | 11 ++++-- apps/client/src/widgets/layout/StatusBar.tsx | 36 +++++++++++-------- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index c5479736f3..0f74650dd1 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -2301,8 +2301,15 @@ "tab_width": "Tab Width: {{width}}", "tab_width_title": "Change tab width", "tab_width_spaces": "{{count}} spaces", - "tab_width_per_note": "Set for this note", - "tab_width_use_default": "Use default ({{width}})" + "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" diff --git a/apps/client/src/widgets/layout/StatusBar.tsx b/apps/client/src/widgets/layout/StatusBar.tsx index c6b356ab88..0fc541a828 100644 --- a/apps/client/src/widgets/layout/StatusBar.tsx +++ b/apps/client/src/widgets/layout/StatusBar.tsx @@ -19,7 +19,7 @@ import { openInAppHelpFromUrl } from "../../services/utils"; import { formatDateTime } from "../../utils/formatters"; import { BacklinksList, useBacklinkCount } from "../FloatingButtonsDefinitions"; import Dropdown, { DropdownProps } from "../react/Dropdown"; -import { FormDropdownDivider, FormListItem } from "../react/FormList"; +import { FormDropdownDivider, FormListHeader, FormListItem } from "../react/FormList"; import { useActiveNoteContext, useLegacyImperativeHandlers, useNoteLabel, useNoteLabelInt, useNoteProperty, useStaticTooltip, useTriliumEvent, useTriliumEvents, useTriliumOptionInt } from "../react/hooks"; import Icon from "../react/Icon"; import LinkButton from "../react/LinkButton"; @@ -459,18 +459,15 @@ function TabWidthSwitcher({ note, noteContext }: StatusBarContext) { const effectiveTabWidth = noteTabWidth ?? globalTabWidth; const hasPerNoteOverride = noteTabWidth != null; - const changeTabWidth = async (newWidth: number | null) => { - const oldWidth = effectiveTabWidth; - const resolvedNewWidth = newWidth ?? globalTabWidth; - setNoteTabWidth(newWidth); - if (oldWidth === resolvedNewWidth) return; - + const reindentTo = async (newWidth: number) => { + if (newWidth === effectiveTabWidth) return; const editor = await noteContext.getCodeEditor(); if (!editor) return; - const reindented = reindentSpaces(editor.getText(), oldWidth, resolvedNewWidth); + const reindented = reindentSpaces(editor.getText(), effectiveTabWidth, newWidth); if (reindented !== editor.getText()) { editor.setText(reindented); } + setNoteTabWidth(newWidth); }; return (note.type === "code" && @@ -479,21 +476,32 @@ function TabWidthSwitcher({ note, noteContext }: StatusBarContext) { text={t("status_bar.tab_width", { width: effectiveTabWidth })} title={t("status_bar.tab_width_title")} > + {TAB_WIDTH_OPTIONS.map(size => ( changeTabWidth(size)} + onClick={() => setNoteTabWidth(size)} > {t("status_bar.tab_width_spaces", { count: size })} ))} - {hasPerNoteOverride && <> - - changeTabWidth(null)}> + {hasPerNoteOverride && + setNoteTabWidth(null)}> {t("status_bar.tab_width_use_default", { width: globalTabWidth })} - } + } + + + {TAB_WIDTH_OPTIONS.map(size => ( + reindentTo(size)} + > + {t("status_bar.tab_width_spaces", { count: size })} + + ))} ); } From 9e4a5c892e8f6dcd0f76c23e852317b48d38bb79 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 15 Apr 2026 09:41:45 +0300 Subject: [PATCH 22/44] feat(print/pdf): basic support for margins --- apps/client/src/print.css | 4 - .../src/translations/en/translation.json | 12 +- apps/client/src/widgets/NoteDetail.tsx | 3 +- .../attribute_widgets/attribute_detail.ts | 3 +- .../src/widgets/dialogs/print_preview.tsx | 132 +++++++++++++++++- apps/server/src/services/window.ts | 36 ++++- packages/commons/src/lib/attribute_names.ts | 1 + .../commons/src/lib/builtin_attributes.ts | 1 + 8 files changed, 174 insertions(+), 18 deletions(-) diff --git a/apps/client/src/print.css b/apps/client/src/print.css index 7d551fe0ff..aa9f49a1a2 100644 --- a/apps/client/src/print.css +++ b/apps/client/src/print.css @@ -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; diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 0f74650dd1..ea2b4ff5e1 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -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: 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": { @@ -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", diff --git a/apps/client/src/widgets/NoteDetail.tsx b/apps/client/src/widgets/NoteDetail.tsx index ab8254ba29..f537eedfec 100644 --- a/apps/client/src/widgets/NoteDetail.tsx +++ b/apps/client/src/widgets/NoteDetail.tsx @@ -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" }); }); diff --git a/apps/client/src/widgets/attribute_widgets/attribute_detail.ts b/apps/client/src/widgets/attribute_widgets/attribute_detail.ts index aed675ecf0..af88d65d11 100644 --- a/apps/client/src/widgets/attribute_widgets/attribute_detail.ts +++ b/apps/client/src/widgets/attribute_widgets/attribute_detail.ts @@ -268,7 +268,8 @@ const ATTR_HELP: Record> = { 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"), diff --git a/apps/client/src/widgets/dialogs/print_preview.tsx b/apps/client/src/widgets/dialogs/print_preview.tsx index acffc0652e..74d5ddbf51 100644 --- a/apps/client/src/widgets/dialogs/print_preview.tsx +++ b/apps/client/src/widgets/dialogs/print_preview.tsx @@ -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(); @@ -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>(); @@ -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>(); + + 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} /> + + + + + + {marginPreset === "custom" && ( + + )}
@@ -192,3 +266,47 @@ export default function PrintPreviewDialog() { ); } + +function MarginEditor({ margins, onChange, disabled }: { + margins: CustomMargins; + onChange: (side: keyof CustomMargins, value: number) => void; + disabled: boolean; +}) { + const spinnerStyle = { width: "130px" }; + + return ( +
+ onChange("top", v)} disabled={disabled} style={spinnerStyle} /> +
+ onChange("left", v)} disabled={disabled} style={spinnerStyle} /> + onChange("right", v)} disabled={disabled} style={spinnerStyle} /> +
+ onChange("bottom", v)} disabled={disabled} style={spinnerStyle} /> +
+ ); +} + +function MarginSpinner({ label, value, onChange, disabled, style }: { + label: string; + value: number; + onChange: (value: number) => void; + disabled: boolean; + style?: Record; +}) { + return ( +
+ onChange((e.target as HTMLInputElement).valueAsNumber || 0)} + disabled={disabled} + /> + mm +
+ ); +} diff --git a/apps/server/src/services/window.ts b/apps/server/src/services/window.ts index de39b08594..e20e721c6d 100644 --- a/apps/server/src/services/window.ts +++ b/apps/server/src/services/window.ts @@ -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: `
`, footerTemplate: `
@@ -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: `
`, footerTemplate: `
diff --git a/packages/commons/src/lib/attribute_names.ts b/packages/commons/src/lib/attribute_names.ts index 0442031831..93c99dc3ee 100644 --- a/packages/commons/src/lib/attribute_names.ts +++ b/packages/commons/src/lib/attribute_names.ts @@ -64,6 +64,7 @@ type Labels = { printLandscape: boolean; printPageSize: string; printScale: string; + printMargins: string; // Note-type specific webViewSrc: string; diff --git a/packages/commons/src/lib/builtin_attributes.ts b/packages/commons/src/lib/builtin_attributes.ts index 0ef66064ec..0c23632a6c 100644 --- a/packages/commons/src/lib/builtin_attributes.ts +++ b/packages/commons/src/lib/builtin_attributes.ts @@ -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" }, From 341a5310e1b21c8a144eba8ccc2acbb4d3f2fe54 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 15 Apr 2026 09:42:20 +0300 Subject: [PATCH 23/44] feat(code): basic tabs vs spaces --- apps/client/src/widgets/layout/StatusBar.tsx | 101 ++++++++++++++---- apps/client/src/widgets/react/hooks.tsx | 18 +++- .../src/widgets/type_widgets/code/Code.tsx | 8 +- .../widgets/type_widgets/code/CodeMirror.tsx | 8 +- apps/server/src/routes/api/options.ts | 1 + apps/server/src/services/options_init.ts | 1 + .../codemirror/src/extensions/custom_tab.ts | 15 +-- packages/codemirror/src/index.ts | 30 +++++- packages/commons/src/lib/attribute_names.ts | 1 + packages/commons/src/lib/options_interface.ts | 1 + 10 files changed, 147 insertions(+), 37 deletions(-) diff --git a/apps/client/src/widgets/layout/StatusBar.tsx b/apps/client/src/widgets/layout/StatusBar.tsx index 0fc541a828..df1e5624cc 100644 --- a/apps/client/src/widgets/layout/StatusBar.tsx +++ b/apps/client/src/widgets/layout/StatusBar.tsx @@ -20,7 +20,7 @@ import { formatDateTime } from "../../utils/formatters"; import { BacklinksList, useBacklinkCount } from "../FloatingButtonsDefinitions"; import Dropdown, { DropdownProps } from "../react/Dropdown"; import { FormDropdownDivider, FormListHeader, FormListItem } from "../react/FormList"; -import { useActiveNoteContext, useLegacyImperativeHandlers, useNoteLabel, useNoteLabelInt, useNoteProperty, useStaticTooltip, useTriliumEvent, useTriliumEvents, useTriliumOptionInt } from "../react/hooks"; +import { useActiveNoteContext, useLegacyImperativeHandlers, useNoteLabel, useNoteLabelInt, useNoteLabelOptionalBool, useNoteProperty, useStaticTooltip, useTriliumEvent, useTriliumEvents, useTriliumOptionBool, useTriliumOptionInt } from "../react/hooks"; import Icon from "../react/Icon"; import LinkButton from "../react/LinkButton"; import { ParentComponent } from "../react/react_utils"; @@ -441,41 +441,93 @@ function NotePaths({ note, hoistedNoteId, notePath }: StatusBarContext) { const TAB_WIDTH_OPTIONS = [1, 2, 3, 4, 6, 8] as const; /** - * Re-indents leading spaces on each line, converting from `fromWidth` indent units - * to `toWidth` indent units. Tabs and non-leading whitespace are preserved as-is. + * Converts the leading indentation on each line to a new style. Non-leading whitespace is preserved. + * + * - "spaces" source means leading runs of spaces are grouped by `fromWidth` to compute the indent level. + * - "tabs" source means each leading tab counts as one indent level (leading spaces are preserved as alignment). */ -function reindentSpaces(content: string, fromWidth: number, toWidth: number): string { - if (fromWidth === toWidth || fromWidth <= 0) return content; - return content.replace(/^( +)/gm, (leading) => { - const levels = Math.round(leading.length / fromWidth); - const remainder = leading.length - levels * fromWidth; - return " ".repeat(levels * toWidth + Math.max(remainder, 0)); +function convertIndentation( + content: string, + from: { useTabs: boolean; width: number }, + to: { useTabs: boolean; width: number } +): string { + if (from.useTabs === to.useTabs && from.width === to.width) return content; + const toUnit = to.useTabs ? "\t" : " ".repeat(to.width); + + return content.replace(/^[ \t]+/gm, (leading) => { + let levels: number; + let remainder = ""; + if (from.useTabs) { + const match = leading.match(/^(\t*)(.*)$/s)!; + levels = match[1].length; + remainder = match[2]; + } else { + const spaces = leading.length; + levels = from.width > 0 ? Math.round(spaces / from.width) : 0; + const aligned = levels * from.width; + remainder = spaces > aligned ? " ".repeat(spaces - aligned) : ""; + } + return toUnit.repeat(levels) + remainder; }); } function TabWidthSwitcher({ note, noteContext }: StatusBarContext) { const [ globalTabWidth ] = useTriliumOptionInt("codeNoteTabWidth"); + const [ globalUseTabs ] = useTriliumOptionBool("codeNoteIndentWithTabs"); const [ noteTabWidth, setNoteTabWidth ] = useNoteLabelInt(note, "tabWidth"); + const [ noteUseTabs, setNoteUseTabs ] = useNoteLabelOptionalBool(note, "indentWithTabs"); const effectiveTabWidth = noteTabWidth ?? globalTabWidth; - const hasPerNoteOverride = noteTabWidth != null; + const effectiveUseTabs = noteUseTabs ?? globalUseTabs; + const hasWidthOverride = noteTabWidth != null; + const hasStyleOverride = noteUseTabs != null; - const reindentTo = async (newWidth: number) => { - if (newWidth === effectiveTabWidth) return; + const reindentTo = async (targetUseTabs: boolean, targetWidth: number) => { const editor = await noteContext.getCodeEditor(); if (!editor) return; - const reindented = reindentSpaces(editor.getText(), effectiveTabWidth, newWidth); - if (reindented !== editor.getText()) { - editor.setText(reindented); + const converted = convertIndentation( + editor.getText(), + { useTabs: effectiveUseTabs, width: effectiveTabWidth }, + { useTabs: targetUseTabs, width: targetWidth } + ); + if (converted !== editor.getText()) { + editor.setText(converted); } - setNoteTabWidth(newWidth); + setNoteTabWidth(targetWidth); + setNoteUseTabs(targetUseTabs); }; + const statusText = effectiveUseTabs + ? t("status_bar.tab_width_tabs", { width: effectiveTabWidth }) + : t("status_bar.tab_width_spaces_short", { width: effectiveTabWidth }); + return (note.type === "code" && + + setNoteUseTabs(false)} + > + {t("status_bar.tab_width_style_spaces")} + + setNoteUseTabs(true)} + > + {t("status_bar.tab_width_style_tabs")} + + {hasStyleOverride && + setNoteUseTabs(null)}> + {t("status_bar.tab_width_use_default_style", { + style: globalUseTabs ? t("status_bar.tab_width_style_tabs") : t("status_bar.tab_width_style_spaces") + })} + + } + + {TAB_WIDTH_OPTIONS.map(size => ( ))} - {hasPerNoteOverride && + {hasWidthOverride && setNoteTabWidth(null)}> {t("status_bar.tab_width_use_default", { width: globalTabWidth })} } + {TAB_WIDTH_OPTIONS.map(size => ( reindentTo(size)} + key={`reindent-spaces-${size}`} + disabled={!effectiveUseTabs && effectiveTabWidth === size} + onClick={() => reindentTo(false, size)} > {t("status_bar.tab_width_spaces", { count: size })} ))} + reindentTo(true, effectiveTabWidth)} + > + {t("status_bar.tab_width_style_tabs")} + ); } diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 6c70703385..66a3e44dfa 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -664,13 +664,27 @@ export function useNoteLabelBoolean(note: FNote | undefined | null, labelName: F return [ labelValue, setter ] as const; } -export function useNoteLabelInt(note: FNote | undefined | null, labelName: FilterLabelsByType): [ number | undefined, (newValue: number) => void] { +/** + * Like {@link useNoteLabelBoolean} but returns `undefined` when the label is absent, allowing the caller + * to distinguish between "explicitly false" and "not set" (for inheriting from a global default). + */ +export function useNoteLabelOptionalBool(note: FNote | undefined | null, labelName: FilterLabelsByType): [ boolean | undefined, (newValue: boolean | null) => void] { + //@ts-expect-error `useNoteLabel` only accepts string labels but we need to be able to read boolean ones. + const [ value, setValue ] = useNoteLabel(note, labelName); + useDebugValue(labelName); + return [ + (value == null ? undefined : value !== "false"), + (newValue) => setValue(newValue === null ? null : String(newValue)) + ]; +} + +export function useNoteLabelInt(note: FNote | undefined | null, labelName: FilterLabelsByType): [ number | undefined, (newValue: number | null) => void] { //@ts-expect-error `useNoteLabel` only accepts string properties but we need to be able to read number ones. const [ value, setValue ] = useNoteLabel(note, labelName); useDebugValue(labelName); return [ (value ? parseInt(value, 10) : undefined), - (newValue) => setValue(String(newValue)) + (newValue) => setValue(newValue === null ? null : String(newValue)) ]; } diff --git a/apps/client/src/widgets/type_widgets/code/Code.tsx b/apps/client/src/widgets/type_widgets/code/Code.tsx index f5bbc02da6..17ef41c941 100644 --- a/apps/client/src/widgets/type_widgets/code/Code.tsx +++ b/apps/client/src/widgets/type_widgets/code/Code.tsx @@ -8,7 +8,7 @@ import appContext, { CommandListenerData } from "../../../components/app_context import FNote from "../../../entities/fnote"; import { t } from "../../../services/i18n"; import utils from "../../../services/utils"; -import { useEditorSpacedUpdate, useKeyboardShortcuts, useLegacyImperativeHandlers, useNoteBlob, useNoteLabelInt, useNoteProperty, useSyncedRef, useTriliumEvent, useTriliumOption, useTriliumOptionBool } from "../../react/hooks"; +import { useEditorSpacedUpdate, useKeyboardShortcuts, useLegacyImperativeHandlers, useNoteBlob, useNoteLabelInt, useNoteLabelOptionalBool, useNoteProperty, useSyncedRef, useTriliumEvent, useTriliumOption, useTriliumOptionBool } from "../../react/hooks"; import { refToJQuerySelector } from "../../react/react_utils"; import TouchBar, { TouchBarButton } from "../../react/TouchBar"; import { CODE_THEME_DEFAULT_PREFIX as DEFAULT_PREFIX } from "../constants"; @@ -37,6 +37,7 @@ export function ReadOnlyCode({ note, viewScope, ntxId, parentComponent }: TypeWi const [ content, setContent ] = useState(""); const blob = useNoteBlob(note); const [ noteTabWidth ] = useNoteLabelInt(note, "tabWidth"); + const [ noteUseTabs ] = useNoteLabelOptionalBool(note, "indentWithTabs"); useEffect(() => { if (!blob) return; @@ -57,6 +58,7 @@ export function ReadOnlyCode({ note, viewScope, ntxId, parentComponent }: TypeWi mime={note.mime} readOnly {...(noteTabWidth != null && { indentSize: noteTabWidth })} + {...(noteUseTabs != null && { useTabs: noteUseTabs })} /> ); } @@ -82,6 +84,7 @@ export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentC const containerRef = useRef(null); const [ vimKeymapEnabled ] = useTriliumOptionBool("vimKeymapEnabled"); const [ noteTabWidth ] = useNoteLabelInt(note, "tabWidth"); + const [ noteUseTabs ] = useNoteLabelOptionalBool(note, "indentWithTabs"); const mime = useNoteProperty(note, "mime"); const spacedUpdate = useEditorSpacedUpdate({ note, @@ -133,6 +136,7 @@ export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentC }} {...editorProps} {...(noteTabWidth != null && { indentSize: noteTabWidth })} + {...(noteUseTabs != null && { useTabs: noteUseTabs })} /> @@ -151,6 +155,7 @@ export function CodeEditor({ parentComponent, ntxId, containerRef: externalConta const [ codeLineWrapEnabled ] = useTriliumOptionBool("codeLineWrapEnabled"); const [ codeNoteTheme ] = useTriliumOption("codeNoteTheme"); const [ codeNoteTabWidth ] = useTriliumOption("codeNoteTabWidth"); + const [ codeNoteIndentWithTabs ] = useTriliumOptionBool("codeNoteIndentWithTabs"); // React to background color. const [ backgroundColor, setBackgroundColor ] = useState(); @@ -206,6 +211,7 @@ export function CodeEditor({ parentComponent, ntxId, containerRef: externalConta containerRef={containerRef} lineWrapping={lineWrapping ?? codeLineWrapEnabled} indentSize={editorProps.indentSize ?? (parseInt(codeNoteTabWidth) || 4)} + useTabs={editorProps.useTabs ?? codeNoteIndentWithTabs} onInitialized={() => { if (externalContainerRef && containerRef.current) { externalContainerRef.current = containerRef.current; diff --git a/apps/client/src/widgets/type_widgets/code/CodeMirror.tsx b/apps/client/src/widgets/type_widgets/code/CodeMirror.tsx index ecc5e2c658..609ffe0198 100644 --- a/apps/client/src/widgets/type_widgets/code/CodeMirror.tsx +++ b/apps/client/src/widgets/type_widgets/code/CodeMirror.tsx @@ -49,12 +49,14 @@ export default function CodeMirror({ className, content, mime, editorRef: extern // React to line wrapping. useEffect(() => codeEditorRef.current?.setLineWrapping(!!lineWrapping), [ lineWrapping ]); - // React to indent size changes. + // React to indent size / style changes. useEffect(() => { if (extraOpts.indentSize != null) { - codeEditorRef.current?.setIndentSize(extraOpts.indentSize); + codeEditorRef.current?.setIndent(extraOpts.indentSize, !!extraOpts.useTabs); + } else if (extraOpts.useTabs != null) { + codeEditorRef.current?.setUseTabs(extraOpts.useTabs); } - }, [ extraOpts.indentSize ]); + }, [ extraOpts.indentSize, extraOpts.useTabs ]); return (
diff --git a/apps/server/src/routes/api/options.ts b/apps/server/src/routes/api/options.ts
index 137ab51cf3..3ab6ca0a7e 100644
--- a/apps/server/src/routes/api/options.ts
+++ b/apps/server/src/routes/api/options.ts
@@ -33,6 +33,7 @@ const ALLOWED_OPTIONS = new Set([
     "codeBlockTabWidth",
     "codeNoteTheme",
     "codeNoteTabWidth",
+    "codeNoteIndentWithTabs",
     "syncServerHost",
     "syncServerTimeout",
     "syncServerTimeoutTimeScale",
diff --git a/apps/server/src/services/options_init.ts b/apps/server/src/services/options_init.ts
index 94151b269c..1437a836ff 100644
--- a/apps/server/src/services/options_init.ts
+++ b/apps/server/src/services/options_init.ts
@@ -132,6 +132,7 @@ const defaultOptions: DefaultOption[] = [
     { name: "vimKeymapEnabled", value: "false", isSynced: false },
     { name: "codeLineWrapEnabled", value: "true", isSynced: false },
     { name: "codeNoteTabWidth", value: "4", isSynced: true },
+    { name: "codeNoteIndentWithTabs", value: "false", isSynced: true },
     {
         name: "codeNotesMimeTypes",
         value: '["text/x-csrc","text/x-c++src","text/x-csharp","text/css","text/x-elixir","text/x-go","text/x-groovy","text/x-haskell","text/html","message/http","text/x-java","application/javascript;env=frontend","application/javascript;env=backend","application/json","text/x-kotlin","text/x-markdown","text/x-perl","text/x-php","text/x-python","text/x-ruby",null,"text/x-sql","text/x-sqlite;schema=trilium","text/x-swift","text/xml","text/x-yaml","text/x-sh","application/typescript"]',
diff --git a/packages/codemirror/src/extensions/custom_tab.ts b/packages/codemirror/src/extensions/custom_tab.ts
index e0f39690a1..3cfe25e363 100644
--- a/packages/codemirror/src/extensions/custom_tab.ts
+++ b/packages/codemirror/src/extensions/custom_tab.ts
@@ -1,4 +1,5 @@
 import { indentLess, indentMore } from "@codemirror/commands";
+import { indentUnit } from "@codemirror/language";
 import { EditorSelection, EditorState, SelectionRange, type Transaction, type ChangeSpec } from "@codemirror/state";
 import type { KeyBinding } from "@codemirror/view";
 
@@ -53,11 +54,12 @@ export default smartIndentWithTab;
 function handleSingleLineSelection(state: EditorState, dispatch: (transaction: Transaction) => void) {
     const changes: ChangeSpec[] = [];
     const newSelections: SelectionRange[] = [];
+    const unit = state.facet(indentUnit);
 
-    // Single line selection, replace with tab.
+    // Single line selection, replace with indent unit.
     for (let range of state.selection.ranges) {
-        changes.push({ from: range.from, to: range.to, insert: "\t" });
-        newSelections.push(EditorSelection.cursor(range.from + 1));
+        changes.push({ from: range.from, to: range.to, insert: unit });
+        newSelections.push(EditorSelection.cursor(range.from + unit.length));
     }
 
     dispatch(
@@ -75,6 +77,7 @@ function handleSingleLineSelection(state: EditorState, dispatch: (transaction: T
 function handleEmptySelections(state: EditorState, dispatch: (transaction: Transaction) => void) {
     const changes: ChangeSpec[] = [];
     const newSelections: SelectionRange[] = [];
+    const unit = state.facet(indentUnit);
 
     for (let range of state.selection.ranges) {
         const line = state.doc.lineAt(range.head);
@@ -84,9 +87,9 @@ function handleEmptySelections(state: EditorState, dispatch: (transaction: Trans
             // Only whitespace before cursor → indent line
             return indentMore({ state, dispatch });
         } else {
-            // Insert tab character at cursor
-            changes.push({ from: range.head, to: range.head, insert: "\t" });
-            newSelections.push(EditorSelection.cursor(range.head + 1));
+            // Insert configured indent unit at cursor
+            changes.push({ from: range.head, to: range.head, insert: unit });
+            newSelections.push(EditorSelection.cursor(range.head + unit.length));
         }
     }
 
diff --git a/packages/codemirror/src/index.ts b/packages/codemirror/src/index.ts
index 7b868c859a..516ab8e686 100644
--- a/packages/codemirror/src/index.ts
+++ b/packages/codemirror/src/index.ts
@@ -34,11 +34,17 @@ export interface EditorConfig {
     /** Disables some of the nice-to-have features (bracket matching, syntax highlighting, indentation markers) in order to improve performance. */
     preferPerformance?: boolean;
     tabIndex?: number;
-    /** The number of spaces used for indentation. Defaults to 4. */
+    /** The number of spaces used for indentation (also used as the tab display width). Defaults to 4. */
     indentSize?: number;
+    /** If true, indent using a tab character instead of spaces. Defaults to false. */
+    useTabs?: boolean;
     onContentChanged?: ContentChangedListener;
 }
 
+function buildIndentUnit(indentSize: number, useTabs: boolean) {
+    return useTabs ? "\t" : " ".repeat(indentSize);
+}
+
 export default class CodeMirror extends EditorView {
 
     private config: EditorConfig;
@@ -72,7 +78,10 @@ export default class CodeMirror extends EditorView {
             searchHighlightCompartment.of([]),
             highlightActiveLine(),
             lineNumbers(),
-            indentUnitCompartment.of(indentUnit.of(" ".repeat(config.indentSize ?? 4))),
+            indentUnitCompartment.of([
+                indentUnit.of(buildIndentUnit(config.indentSize ?? 4, !!config.useTabs)),
+                EditorState.tabSize.of(config.indentSize ?? 4)
+            ]),
             keymap.of([
                 ...preventCtrlEnterKeymap,
                 ...defaultKeymap,
@@ -173,12 +182,25 @@ export default class CodeMirror extends EditorView {
         });
     }
 
-    setIndentSize(size: number) {
+    setIndent(size: number, useTabs: boolean) {
+        this.config.indentSize = size;
+        this.config.useTabs = useTabs;
         this.dispatch({
-            effects: [ this.indentUnitCompartment.reconfigure(indentUnit.of(" ".repeat(size))) ]
+            effects: [ this.indentUnitCompartment.reconfigure([
+                indentUnit.of(buildIndentUnit(size, useTabs)),
+                EditorState.tabSize.of(size)
+            ]) ]
         });
     }
 
+    setIndentSize(size: number) {
+        this.setIndent(size, !!this.config.useTabs);
+    }
+
+    setUseTabs(useTabs: boolean) {
+        this.setIndent(this.config.indentSize ?? 4, useTabs);
+    }
+
     /**
      * Clears the history of undo/redo. Generally useful when changing to a new document.
      */
diff --git a/packages/commons/src/lib/attribute_names.ts b/packages/commons/src/lib/attribute_names.ts
index 93c99dc3ee..340714e3d9 100644
--- a/packages/commons/src/lib/attribute_names.ts
+++ b/packages/commons/src/lib/attribute_names.ts
@@ -71,6 +71,7 @@ type Labels = {
     "disabled:webViewSrc": string;
     readOnly: boolean;
     tabWidth: number;
+    indentWithTabs: boolean;
     mapType: string;
     mapRootNoteId: string;
 
diff --git a/packages/commons/src/lib/options_interface.ts b/packages/commons/src/lib/options_interface.ts
index d89f1460fd..5ecc585e61 100644
--- a/packages/commons/src/lib/options_interface.ts
+++ b/packages/commons/src/lib/options_interface.ts
@@ -162,6 +162,7 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions
Date: Wed, 15 Apr 2026 09:52:15 +0300
Subject: [PATCH 24/44] chore(print/pdf): align margin selector

---
 apps/client/src/widgets/dialogs/print_preview.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/apps/client/src/widgets/dialogs/print_preview.tsx b/apps/client/src/widgets/dialogs/print_preview.tsx
index 74d5ddbf51..4449799381 100644
--- a/apps/client/src/widgets/dialogs/print_preview.tsx
+++ b/apps/client/src/widgets/dialogs/print_preview.tsx
@@ -235,7 +235,7 @@ export default function PrintPreviewDialog() {
                         />
                     
 
-                    
+                    
                          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, From 3a1f0b2be1579b02380bdb7a9ffab6087126f5b5 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 15 Apr 2026 17:14:15 +0300 Subject: [PATCH 30/44] chore(code): fix inconsistency in status bar naming --- apps/client/src/translations/en/translation.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index e5dcef0d1b..c99729cc96 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -2303,7 +2303,7 @@ "tab_width_title": "Change tab width", "tab_width_spaces": "{{count}} spaces", "tab_width_spaces_short": "Spaces: {{width}}", - "tab_width_tabs": "Tabs ({{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", From f68a481edcfb7267c95b9ff41fa4923dc4f9d84a Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 15 Apr 2026 17:14:44 +0300 Subject: [PATCH 31/44] feat(print/pdf): disable selection in preview --- .../client/src/widgets/dialogs/print_preview.tsx | 2 +- .../src/widgets/type_widgets/file/PdfViewer.tsx | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/client/src/widgets/dialogs/print_preview.tsx b/apps/client/src/widgets/dialogs/print_preview.tsx index bcc329b9d7..5862802204 100644 --- a/apps/client/src/widgets/dialogs/print_preview.tsx +++ b/apps/client/src/widgets/dialogs/print_preview.tsx @@ -310,7 +310,7 @@ export default function PrintPreviewDialog() {
)} - {pdfUrl && } + {pdfUrl && }
); diff --git a/apps/client/src/widgets/type_widgets/file/PdfViewer.tsx b/apps/client/src/widgets/type_widgets/file/PdfViewer.tsx index 0780a75d72..c072cb328c 100644 --- a/apps/client/src/widgets/type_widgets/file/PdfViewer.tsx +++ b/apps/client/src/widgets/type_widgets/file/PdfViewer.tsx @@ -22,16 +22,18 @@ interface PdfViewerProps extends Pick, "tabInd * If set, enables editable mode which includes persistence of user settings, annotations as well as specific features such as sending table of contents data for the sidebar. */ editable?: boolean; + /** If set, disables text selection in the rendered PDF. */ + disableSelection?: boolean; } /** * Reusable component displaying a PDF. The PDF needs to be provided via a URL. */ -export default function PdfViewer({ iframeRef: externalIframeRef, pdfUrl, onLoad, editable }: PdfViewerProps) { +export default function PdfViewer({ iframeRef: externalIframeRef, pdfUrl, onLoad, editable, disableSelection }: PdfViewerProps) { const iframeRef = useSyncedRef(externalIframeRef, null); const [ locale ] = useTriliumOption("locale"); const [ newLayout ] = useTriliumOptionBool("newLayout"); - const injectStyles = useStyleInjection(iframeRef); + const injectStyles = useStyleInjection(iframeRef, disableSelection); return (