mirror of
https://github.com/zadam/trilium.git
synced 2026-05-06 09:36:37 +02:00
feat(code): basic tabs vs spaces
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -33,6 +33,7 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
|
||||
"codeBlockTabWidth",
|
||||
"codeNoteTheme",
|
||||
"codeNoteTabWidth",
|
||||
"codeNoteIndentWithTabs",
|
||||
"syncServerHost",
|
||||
"syncServerTimeout",
|
||||
"syncServerTimeoutTimeScale",
|
||||
|
||||
@@ -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"]',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -71,6 +71,7 @@ type Labels = {
|
||||
"disabled:webViewSrc": string;
|
||||
readOnly: boolean;
|
||||
tabWidth: number;
|
||||
indentWithTabs: boolean;
|
||||
mapType: string;
|
||||
mapRootNoteId: string;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user