feat(code): basic tabs vs spaces

This commit is contained in:
Elian Doran
2026-04-15 09:42:20 +03:00
parent 9e4a5c892e
commit 341a5310e1
10 changed files with 147 additions and 37 deletions

View File

@@ -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" &&
<StatusBarDropdown
icon="bx bx-right-indent"
text={t("status_bar.tab_width", { width: effectiveTabWidth })}
text={statusText}
title={t("status_bar.tab_width_title")}
>
<FormListHeader text={t("status_bar.tab_width_style_header")} />
<FormListItem
checked={!effectiveUseTabs}
onClick={() => setNoteUseTabs(false)}
>
{t("status_bar.tab_width_style_spaces")}
</FormListItem>
<FormListItem
checked={effectiveUseTabs}
onClick={() => setNoteUseTabs(true)}
>
{t("status_bar.tab_width_style_tabs")}
</FormListItem>
{hasStyleOverride &&
<FormListItem icon="bx bx-x" onClick={() => 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")
})}
</FormListItem>
}
<FormDropdownDivider />
<FormListHeader text={t("status_bar.tab_width_display_header")} />
{TAB_WIDTH_OPTIONS.map(size => (
<FormListItem
@@ -486,22 +538,29 @@ function TabWidthSwitcher({ note, noteContext }: StatusBarContext) {
{t("status_bar.tab_width_spaces", { count: size })}
</FormListItem>
))}
{hasPerNoteOverride &&
{hasWidthOverride &&
<FormListItem icon="bx bx-x" onClick={() => setNoteTabWidth(null)}>
{t("status_bar.tab_width_use_default", { width: globalTabWidth })}
</FormListItem>
}
<FormDropdownDivider />
<FormListHeader text={t("status_bar.tab_width_reindent_header")} />
{TAB_WIDTH_OPTIONS.map(size => (
<FormListItem
key={`reindent-${size}`}
disabled={effectiveTabWidth === size}
onClick={() => reindentTo(size)}
key={`reindent-spaces-${size}`}
disabled={!effectiveUseTabs && effectiveTabWidth === size}
onClick={() => reindentTo(false, size)}
>
{t("status_bar.tab_width_spaces", { count: size })}
</FormListItem>
))}
<FormListItem
disabled={effectiveUseTabs}
onClick={() => reindentTo(true, effectiveTabWidth)}
>
{t("status_bar.tab_width_style_tabs")}
</FormListItem>
</StatusBarDropdown>
);
}

View File

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

View File

@@ -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<HTMLPreElement>(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 })}
/>
<TouchBar>
@@ -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<string>();
@@ -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;

View File

@@ -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 (
<pre ref={parentRef} className={className} />

View File

@@ -33,6 +33,7 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
"codeBlockTabWidth",
"codeNoteTheme",
"codeNoteTabWidth",
"codeNoteIndentWithTabs",
"syncServerHost",
"syncServerTimeout",
"syncServerTimeoutTimeScale",

View File

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

View File

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

View File

@@ -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.
*/

View File

@@ -71,6 +71,7 @@ type Labels = {
"disabled:webViewSrc": string;
readOnly: boolean;
tabWidth: number;
indentWithTabs: boolean;
mapType: string;
mapRootNoteId: string;

View File

@@ -162,6 +162,7 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions<KeyboardActi
codeBlockWordWrap: boolean;
codeBlockTabWidth: number;
codeNoteTabWidth: number;
codeNoteIndentWithTabs: boolean;
textNoteEditorMultilineToolbar: boolean;
/** Whether keyboard auto-completion for emojis is triggered when typing `:`. */
textNoteEmojiCompletionEnabled: boolean;