+
{!isHiddenNote &&
}
@@ -436,6 +438,104 @@ 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, 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 ?? 4;
+ const effectiveUseTabs = noteUseTabs ?? globalUseTabs;
+ const hasWidthOverride = noteTabWidth != null;
+ const hasStyleOverride = noteUseTabs != null;
+
+ const reindentTo = async (targetUseTabs: boolean, targetWidth: number) => {
+ const editor = await noteContext.getCodeEditor();
+ if (!editor) return;
+ const converted = convertIndentation(
+ editor.getText(),
+ { useTabs: effectiveUseTabs, width: effectiveTabWidth },
+ { useTabs: targetUseTabs, width: targetWidth }
+ );
+ if (converted !== editor.getText()) {
+ editor.setText(converted);
+ }
+ 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 => (
+ setNoteTabWidth(size)}
+ >
+ {t("status_bar.tab_width_spaces", { count: size })}
+
+ ))}
+ {hasWidthOverride &&
+ setNoteTabWidth(null)}>
+ {t("status_bar.tab_width_use_default", { width: globalTabWidth })}
+
+ }
+
+
+
+ {TAB_WIDTH_OPTIONS.map(size => (
+ reindentTo(false, size)}
+ >
+ {t("status_bar.tab_width_spaces", { count: size })}
+
+ ))}
+ reindentTo(true, effectiveTabWidth)}
+ >
+ {t("status_bar.tab_width_style_tabs")}
+
+
+ );
+}
+//#endregion
+
//#region Code note switcher
function CodeNoteSwitcher({ note }: StatusBarContext) {
const [ modalShown, setModalShown ] = useState(false);
diff --git a/apps/client/src/widgets/layout/reindentation.spec.ts b/apps/client/src/widgets/layout/reindentation.spec.ts
new file mode 100644
index 0000000000..5487342dce
--- /dev/null
+++ b/apps/client/src/widgets/layout/reindentation.spec.ts
@@ -0,0 +1,98 @@
+import { describe, expect, it } from "vitest";
+import { convertIndentation } from "./reindentation";
+
+describe("convertIndentation", () => {
+ it("returns content unchanged when source and target match", () => {
+ const content = " const x = 1;\n const y = 2;\n";
+ expect(convertIndentation(content, { useTabs: false, width: 4 }, { useTabs: false, width: 4 })).toBe(content);
+ });
+
+ it("returns content unchanged for zero or negative widths", () => {
+ const content = " x\n";
+ expect(convertIndentation(content, { useTabs: false, width: 0 }, { useTabs: false, width: 4 })).toBe(content);
+ expect(convertIndentation(content, { useTabs: false, width: 4 }, { useTabs: false, width: 0 })).toBe(content);
+ });
+
+ it("converts spaces to a narrower width", () => {
+ const input = " a\n b\n c\n";
+ const expected = " a\n b\n c\n";
+ expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: false, width: 2 })).toBe(expected);
+ });
+
+ it("converts spaces to a wider width", () => {
+ const input = " a\n b\n";
+ const expected = " a\n b\n";
+ expect(convertIndentation(input, { useTabs: false, width: 2 }, { useTabs: false, width: 4 })).toBe(expected);
+ });
+
+ it("converts spaces to tabs", () => {
+ const input = " a\n b\n";
+ const expected = "\ta\n\t\tb\n";
+ expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: true, width: 4 })).toBe(expected);
+ });
+
+ it("converts tabs to spaces", () => {
+ const input = "\ta\n\t\tb\n";
+ const expected = " a\n b\n";
+ expect(convertIndentation(input, { useTabs: true, width: 4 }, { useTabs: false, width: 4 })).toBe(expected);
+ });
+
+ it("converts tabs to a different tab display width", () => {
+ // When both source and target are tabs, the content doesn't change (tab count is preserved)
+ // regardless of visual tab width.
+ const input = "\ta\n\t\tb\n";
+ expect(convertIndentation(input, { useTabs: true, width: 4 }, { useTabs: true, width: 2 })).toBe(input);
+ });
+
+ it("handles mixed tabs and spaces on the same line (tab then spaces)", () => {
+ // One tab (→ col 4) + 2 spaces → 6 columns → 1 level + 2 remainder spaces at width=4.
+ // Target spaces width=4: 1 level (4 spaces) + 2 remainder = 6 spaces.
+ const input = "\t statement;\n";
+ expect(convertIndentation(input, { useTabs: true, width: 4 }, { useTabs: false, width: 4 })).toBe(" statement;\n");
+ });
+
+ it("preserves alignment remainder when converting spaces to tabs", () => {
+ // 6 spaces at width=4 → 1 level + 2 remainder spaces.
+ const input = " alignedText\n";
+ const expected = "\t alignedText\n";
+ expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: true, width: 4 })).toBe(expected);
+ });
+
+ it("preserves alignment remainder when converting spaces to narrower spaces", () => {
+ // 5 spaces at width=4 → 1 level + 1 remainder → 1 level at width 2 = 2 spaces + 1 remainder = 3 spaces.
+ const input = " x\n";
+ expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: false, width: 2 })).toBe(" x\n");
+ });
+
+ it("handles interleaved spaces and tabs (space, then tab)", () => {
+ // " \t" at tabWidth=4: 2 cols (spaces), then tab advances to next multiple of 4 = col 4.
+ // Total 4 cols = 1 level.
+ const input = " \tstmt\n";
+ expect(convertIndentation(input, { useTabs: true, width: 4 }, { useTabs: false, width: 4 })).toBe(" stmt\n");
+ });
+
+ it("does not touch non-leading whitespace", () => {
+ const input = "const a = \" \\t \";\n if (x)\n";
+ const expected = "const a = \" \\t \";\n if (x)\n";
+ expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: false, width: 2 })).toBe(expected);
+ });
+
+ it("leaves blank lines alone", () => {
+ const input = " a\n\n b\n";
+ const expected = " a\n\n b\n";
+ expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: false, width: 2 })).toBe(expected);
+ });
+
+ it("handles mixed indentation styles across lines", () => {
+ // Line 1 uses tabs, line 2 uses spaces.
+ const input = "\t\tnested\n alsoNested\n";
+ // At from=tabs,width=4: line 1 has 8 cols → 2 levels. Line 2 has 8 spaces → 8 cols → 2 levels.
+ // Target = spaces width 2 → both lines become " " (4 spaces).
+ expect(convertIndentation(input, { useTabs: true, width: 4 }, { useTabs: false, width: 2 })).toBe(" nested\n alsoNested\n");
+ });
+
+ it("preserves content without leading whitespace", () => {
+ const input = "no indent\nalso no indent\n";
+ expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: true, width: 4 })).toBe(input);
+ });
+});
diff --git a/apps/client/src/widgets/layout/reindentation.ts b/apps/client/src/widgets/layout/reindentation.ts
new file mode 100644
index 0000000000..39276086d7
--- /dev/null
+++ b/apps/client/src/widgets/layout/reindentation.ts
@@ -0,0 +1,41 @@
+export interface IndentStyle {
+ useTabs: boolean;
+ width: number;
+}
+
+/**
+ * Computes the visual column span of a leading-whitespace run. Tabs advance to the next
+ * multiple of `tabWidth`; spaces advance by 1.
+ */
+function measureLeadingColumns(leading: string, tabWidth: number): number {
+ let cols = 0;
+ for (const ch of leading) {
+ if (ch === "\t") {
+ cols += tabWidth - (cols % tabWidth);
+ } else {
+ cols += 1;
+ }
+ }
+ return cols;
+}
+
+/**
+ * Rewrites the leading whitespace on every line, converting it from the `from` style to the `to`
+ * style. Non-leading whitespace is preserved.
+ *
+ * Handles lines with mixed tabs and spaces in the leading whitespace by measuring the total visual
+ * column span (using `from.width` as the tab stop) and then dividing into `to.width`-sized levels.
+ * Any leftover columns that don't fit a whole level are emitted as spaces (alignment preserved).
+ */
+export function convertIndentation(content: string, from: IndentStyle, to: IndentStyle): string {
+ if (from.useTabs === to.useTabs && from.width === to.width) return content;
+ if (!Number.isFinite(from.width) || !Number.isFinite(to.width) || from.width <= 0 || to.width <= 0) return content;
+ const toUnit = to.useTabs ? "\t" : " ".repeat(to.width);
+
+ return content.replace(/^[ \t]+/gm, (leading) => {
+ const cols = measureLeadingColumns(leading, from.width);
+ const levels = Math.floor(cols / from.width);
+ const remainder = cols % from.width;
+ return toUnit.repeat(levels) + " ".repeat(remainder);
+ });
+}
diff --git a/apps/client/src/widgets/react/FormTextBox.tsx b/apps/client/src/widgets/react/FormTextBox.tsx
index 27df6326cb..c8672c515a 100644
--- a/apps/client/src/widgets/react/FormTextBox.tsx
+++ b/apps/client/src/widgets/react/FormTextBox.tsx
@@ -19,6 +19,9 @@ export default function FormTextBox({ inputRef, className, type, currentValue, o
if (type === "number") {
const { min, max } = rest;
const currentValueNum = parseInt(value, 10);
+ if (!Number.isFinite(currentValueNum)) {
+ return String(min ?? "");
+ }
if (min && currentValueNum < parseInt(String(min), 10)) {
return String(min);
} else if (max && currentValueNum > parseInt(String(max), 10)) {
diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx
index 6c70703385..5b23276923 100644
--- a/apps/client/src/widgets/react/hooks.tsx
+++ b/apps/client/src/widgets/react/hooks.tsx
@@ -664,13 +664,28 @@ 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] {
- //@ts-expect-error `useNoteLabel` only accepts string properties but we need to be able to read number ones.
+/**
+ * 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 ? parseInt(value, 10) : undefined),
- (newValue) => setValue(String(newValue))
+ (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);
+ const parsed = value ? parseInt(value, 10) : undefined;
+ return [
+ (Number.isFinite(parsed) ? parsed : undefined),
+ (newValue) => setValue(newValue === null ? null : String(newValue))
];
}
diff --git a/apps/client/src/widgets/ribbon/NoteActions.tsx b/apps/client/src/widgets/ribbon/NoteActions.tsx
index 84c75619ee..679b624c10 100644
--- a/apps/client/src/widgets/ribbon/NoteActions.tsx
+++ b/apps/client/src/widgets/ribbon/NoteActions.tsx
@@ -22,7 +22,7 @@ import MovePaneButton from "../buttons/move_pane_button";
import ActionButton from "../react/ActionButton";
import Dropdown from "../react/Dropdown";
import { FormDropdownDivider, FormDropdownSubmenu, FormListHeader, FormListItem, FormListToggleableItem } from "../react/FormList";
-import { useIsNoteReadOnly, useNoteContext, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useSyncedRef, useTriliumEvent, useTriliumOption } from "../react/hooks";
+import { useIsNoteReadOnly, useNoteContext, useNoteLabel, useNoteLabelBoolean, useNoteLabelOptionalBool, useNoteProperty, useSyncedRef, useTriliumEvent, useTriliumOption } from "../react/hooks";
import { ParentComponent } from "../react/react_utils";
import { NoteTypeDropdownContent, useNoteBookmarkState, useShareState } from "./BasicPropertiesTab";
import NoteActionsCustom from "./NoteActionsCustom";
@@ -115,6 +115,8 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote
>
{itemsAtStart}
+ {note.type === "code" && }
+
{isReadOnly && <>
enableEditing()} />
@@ -143,7 +145,6 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote
notePath: noteContext.notePath,
defaultType: "single"
})} />
- {isElectron && }
{isExportableToImage && isNormalViewMode && isContentAvailable && }
@@ -180,6 +181,27 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote
);
}
+function CodeProperties({ note }: { note: FNote }) {
+ const [ wrapLines, setWrapLines ] = useNoteLabelOptionalBool(note, "wrapLines");
+
+ return (
+ <>
+
+ setWrapLines(null)} description={t("note_actions.word_wrap_auto_description")}>
+ {t("note_actions.word_wrap_auto")}
+
+ setWrapLines(true)}>
+ {t("note_actions.word_wrap_on")}
+
+ setWrapLines(false)}>
+ {t("note_actions.word_wrap_off")}
+
+
+
+ >
+ );
+}
+
function NoteBasicProperties({ note, focus }: {
note: FNote;
focus: RefObject;
diff --git a/apps/client/src/widgets/type_widgets/code/Code.tsx b/apps/client/src/widgets/type_widgets/code/Code.tsx
index f59d6eff3c..4f34edd96b 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, 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";
@@ -36,6 +36,9 @@ export interface EditableCodeProps extends TypeWidgetProps, Omit {
if (!blob) return;
@@ -55,6 +58,9 @@ export function ReadOnlyCode({ note, viewScope, ntxId, parentComponent }: TypeWi
content={content}
mime={note.mime}
readOnly
+ {...(noteTabWidth != null && { indentSize: noteTabWidth })}
+ {...(noteUseTabs != null && { useTabs: noteUseTabs })}
+ {...(noteWrapLines != null && { lineWrapping: noteWrapLines })}
/>
);
}
@@ -79,6 +85,9 @@ 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 [ noteUseTabs ] = useNoteLabelOptionalBool(note, "indentWithTabs");
+ const [ noteWrapLines ] = useNoteLabelOptionalBool(note, "wrapLines");
const mime = useNoteProperty(note, "mime");
const spacedUpdate = useEditorSpacedUpdate({
note,
@@ -129,6 +138,9 @@ export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentC
}
}}
{...editorProps}
+ {...(noteTabWidth != null && { indentSize: noteTabWidth })}
+ {...(noteUseTabs != null && { useTabs: noteUseTabs })}
+ {...(noteWrapLines != null && { lineWrapping: noteWrapLines })}
/>
@@ -146,6 +158,8 @@ export function CodeEditor({ parentComponent, ntxId, containerRef: externalConta
const initialized = useRef($.Deferred());
const [ codeLineWrapEnabled ] = useTriliumOptionBool("codeLineWrapEnabled");
const [ codeNoteTheme ] = useTriliumOption("codeNoteTheme");
+ const [ codeNoteTabWidth ] = useTriliumOption("codeNoteTabWidth");
+ const [ codeNoteIndentWithTabs ] = useTriliumOptionBool("codeNoteIndentWithTabs");
// React to background color.
const [ backgroundColor, setBackgroundColor ] = useState();
@@ -200,6 +214,8 @@ export function CodeEditor({ parentComponent, ntxId, containerRef: externalConta
editorRef={codeEditorRef}
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 ae4f849dd1..c52c8e7676 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 / style changes.
+ useEffect(() => {
+ if (extraOpts.indentSize != null || extraOpts.useTabs != null) {
+ codeEditorRef.current?.setIndent(extraOpts.indentSize ?? 4, !!extraOpts.useTabs);
+ }
+ }, [ extraOpts.indentSize, extraOpts.useTabs ]);
+
return (
)
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 (