feat(editor): add gutter-based heading decorator renderer

Add an optional gutter column renderer as an alternative to inline
Decoration.widget placement. When enabled, heading decorations render
in a separate CodeMirror gutter outside the document flow, preserving
text alignment.

- Extract shared counter creation into common/counter-factory.ts
- Add HeadingGutterMarker (GutterMarker) and createHeadingGutterExtension
- Support toggling between inline and gutter modes at runtime via
  in-place extension swap (workspace.updateOptions pattern)
- Add useGutter toggle and gutterPosition dropdown to settings UI
- Add i18n strings for en, zh, zh-TW
- Add gutter CSS with opacity variants and table-cell hiding
This commit is contained in:
Victor Araújo 2026-02-17 17:54:01 -03:00
parent 2af9551ea8
commit 7d21665b7f
11 changed files with 430 additions and 123 deletions

123
common/counter-factory.ts Normal file
View file

@ -0,0 +1,123 @@
import type { HeadingDecoratorSettings } from "./data";
import { getUnorderedLevelHeadings, getOrderedCustomIdents } from "./data";
import {
Querier,
UnorderedCounter,
OrderedCounter,
IndependentCounter,
SpliceCounter,
} from "./counter";
import { Heading } from "./heading";
interface DocumentLineReader {
lines: number;
line(n: number): { text: string };
}
export function createCounterFromSettings(
settings: HeadingDecoratorSettings,
doc: DocumentLineReader
): Counter {
const {
decoratorMode = "orderd",
maxRecLevel,
orderedStyleType,
orderedDelimiter,
orderedTrailingDelimiter,
orderedCustomTrailingDelimiter,
orderedLeadingDelimiter,
orderedCustomLeadingDelimiter,
orderedCustomIdents,
orderedSpecifiedString,
orderedAlwaysIgnore,
orderedIgnoreSingle,
orderedIgnoreMaximum = 6,
orderedBasedOnExisting,
orderedAllowZeroLevel,
unorderedLevelHeadings,
independentSettings,
spliceSettings,
} = settings;
if (decoratorMode === "unordered") {
return new UnorderedCounter(
getUnorderedLevelHeadings(unorderedLevelHeadings),
maxRecLevel
);
}
let ignoreTopLevel = 0;
const ignoreSingle = !orderedAlwaysIgnore && orderedIgnoreSingle;
const ignoreLimit = orderedAlwaysIgnore ? orderedIgnoreMaximum : 0;
if (ignoreSingle || orderedBasedOnExisting) {
const querier = new Querier(orderedAllowZeroLevel, maxRecLevel);
const heading = new Heading();
for (let lineIndex = 1; lineIndex <= doc.lines; lineIndex++) {
const line = doc.line(lineIndex);
const lineText = line.text;
const nextLineIndex = lineIndex + 1;
const nextLineText =
nextLineIndex <= doc.lines ? doc.line(nextLineIndex).text : "";
const level = heading.handler(lineIndex, lineText, nextLineText);
if (level === -1) {
continue;
}
querier.handler(level);
ignoreTopLevel = querier.query(ignoreSingle, orderedIgnoreMaximum);
if (ignoreTopLevel <= ignoreLimit) {
break;
}
}
}
if (ignoreTopLevel < ignoreLimit) {
ignoreTopLevel = ignoreLimit;
}
if (decoratorMode === "independent") {
return new IndependentCounter({
maxRecLevel,
ignoreTopLevel,
allowZeroLevel: orderedAllowZeroLevel,
orderedRecLevel: independentSettings?.orderedRecLevel,
h1: independentSettings?.h1,
h2: independentSettings?.h2,
h3: independentSettings?.h3,
h4: independentSettings?.h4,
h5: independentSettings?.h5,
h6: independentSettings?.h6,
});
} else if (decoratorMode === "splice") {
return new SpliceCounter({
maxRecLevel,
ignoreTopLevel,
allowZeroLevel: orderedAllowZeroLevel,
delimiter: spliceSettings?.delimiter,
trailingDelimiter: spliceSettings?.trailingDelimiter,
customTrailingDelimiter: spliceSettings?.customTrailingDelimiter,
leadingDelimiter: spliceSettings?.leadingDelimiter,
customLeadingDelimiter: spliceSettings?.customLeadingDelimiter,
h1: spliceSettings?.h1,
h2: spliceSettings?.h2,
h3: spliceSettings?.h3,
h4: spliceSettings?.h4,
h5: spliceSettings?.h5,
h6: spliceSettings?.h6,
});
} else {
return new OrderedCounter({
maxRecLevel,
ignoreTopLevel,
allowZeroLevel: orderedAllowZeroLevel,
styleType: orderedStyleType,
delimiter: orderedDelimiter,
trailingDelimiter: orderedTrailingDelimiter,
customTrailingDelimiter: orderedCustomTrailingDelimiter,
leadingDelimiter: orderedLeadingDelimiter,
customLeadingDelimiter: orderedCustomLeadingDelimiter,
customIdents: getOrderedCustomIdents(orderedCustomIdents),
specifiedString: orderedSpecifiedString,
});
}
}

View file

@ -140,6 +140,8 @@ export type HeadingPluginSettings = {
enabledInSource: boolean;
enabledSourceSettings: boolean;
sourceHideNumberSigns: boolean;
useGutter: boolean;
gutterPosition: GutterPosition;
enabledInOutline: boolean;
enabledOutlineSettings: boolean;
enabledInQuietOutline: boolean;
@ -160,6 +162,8 @@ export type HeadingPluginData = Omit<
| "readingSettings"
| "enabledPreviewSettings"
| "enabledSourceSettings"
| "useGutter"
| "gutterPosition"
| "enabledInOutline"
| "outlineSettings"
| "enabledInQuietOutline"
@ -182,6 +186,8 @@ export const className = {
quietOutlineContainer: "quiet-outline-custom-heading-container",
fileExplorer: "file-explorer-custom-heading-decorator",
fileExplorerContainer: "file-explorer-custom-heading-container",
gutter: "heading-decorator-gutter",
gutterMarker: "heading-decorator-gutter-marker",
before: "before-heading-decorator",
beforeInside: "before-inside-heading-decorator",
after: "after-heading-decorator",
@ -313,6 +319,8 @@ export function defaultSettings(): HeadingPluginSettings {
enabledSourceSettings: false,
sourceSettings: defaultHeadingDecoratorSettings(),
sourceHideNumberSigns: false,
useGutter: false,
gutterPosition: "before-line-numbers",
enabledInOutline: false,
enabledOutlineSettings: false,
outlineSettings: defaultHeadingDecoratorSettings(),

142
components/editor-gutter.ts Normal file
View file

@ -0,0 +1,142 @@
import { editorLivePreviewField } from "obsidian";
import {
ViewPlugin,
ViewUpdate,
EditorView,
gutter,
GutterMarker,
} from "@codemirror/view";
import { Prec, RangeSet, RangeSetBuilder } from "@codemirror/state";
import type { HeadingPluginData } from "../common/data";
import { className } from "../common/data";
import { createCounterFromSettings } from "../common/counter-factory";
import { Heading } from "../common/heading";
import { updateEditorMode } from "./editor";
class HeadingGutterMarker extends GutterMarker {
readonly content: string;
readonly level: number;
readonly opacity: OpacityOptions;
constructor(content: string, level: number, opacity: OpacityOptions) {
super();
this.content = content;
this.level = level;
this.opacity = opacity;
}
toDOM(): HTMLElement {
const div = document.createElement("div");
div.className = className.gutterMarker;
div.dataset.decoratorOpacity = `${this.opacity}%`;
div.dataset.decoratorLevel = this.level.toString();
div.textContent = this.content;
return div;
}
eq(other: HeadingGutterMarker): boolean {
return (
this.content === other.content &&
this.level === other.level &&
this.opacity === other.opacity
);
}
}
export function createHeadingGutterExtension(
getPluginData: () => Promise<HeadingPluginData>,
showBeforeLineNumbers: boolean
): Array<ViewPlugin<HeadingGutterViewPlugin> | ReturnType<typeof Prec.high>> {
const markers = ViewPlugin.fromClass(
class HeadingGutterViewPlugin {
markers: RangeSet<HeadingGutterMarker> = RangeSet.empty;
private getPluginData: () => Promise<HeadingPluginData>;
constructor(view: EditorView) {
this.getPluginData = getPluginData;
this.buildMarkers(view, view.state.field(editorLivePreviewField));
}
update(update: ViewUpdate) {
if (
update.docChanged ||
update.viewportChanged ||
update.transactions.some((tr) =>
tr.effects.some((e) => e.is(updateEditorMode))
)
) {
this.buildMarkers(
update.view,
update.state.field(editorLivePreviewField)
);
}
}
private async buildMarkers(
view: EditorView,
isLivePreviewMode: boolean
) {
const pluginData = await this.getPluginData();
const enabled =
(isLivePreviewMode && pluginData.enabledInPreview) ||
(!isLivePreviewMode && pluginData.enabledInSource);
if (!enabled) {
this.markers = RangeSet.empty;
view.requestMeasure();
return;
}
const doc = view.state.doc;
const settings = isLivePreviewMode
? pluginData.previewSettings
: pluginData.sourceSettings;
const { opacity } = settings;
const counter = createCounterFromSettings(settings, doc);
const builder = new RangeSetBuilder<HeadingGutterMarker>();
const heading = new Heading();
for (let lineIndex = 1; lineIndex <= doc.lines; lineIndex++) {
const line = doc.line(lineIndex);
const lineText = line.text;
const nextLineIndex = lineIndex + 1;
const nextLineText =
nextLineIndex <= doc.lines ? doc.line(nextLineIndex).text : "";
const level = heading.handler(lineIndex, lineText, nextLineText);
if (level === -1) {
continue;
}
const content = counter.decorator(level);
if (content) {
const marker = new HeadingGutterMarker(content, level, opacity);
builder.add(line.from, line.from, marker);
}
}
this.markers = builder.finish();
view.requestMeasure();
}
}
);
const gutterPrec = showBeforeLineNumbers ? Prec.high : Prec.low;
return [
markers,
gutterPrec(
gutter({
class: className.gutter,
markers(view) {
return view.plugin(markers)?.markers ?? RangeSet.empty;
},
})
),
];
}
type HeadingGutterViewPlugin = {
markers: RangeSet<HeadingGutterMarker>;
};

View file

@ -9,19 +9,8 @@ import {
import { RangeSetBuilder, StateEffect, StateField } from "@codemirror/state";
import { HeadingWidget } from "./weight";
import type { HeadingPluginData } from "../common/data";
import {
className,
getUnorderedLevelHeadings,
getOrderedCustomIdents,
findFirstCharacterIndex,
} from "../common/data";
import {
Querier,
UnorderedCounter,
OrderedCounter,
IndependentCounter,
SpliceCounter,
} from "../common/counter";
import { className, findFirstCharacterIndex } from "../common/data";
import { createCounterFromSettings } from "../common/counter-factory";
import { Heading } from "../common/heading";
/** A StateEffect for updating decorations */
@ -103,113 +92,13 @@ export class HeadingEditorViewPlugin implements PluginValue {
const builder = new RangeSetBuilder<Decoration>();
const doc = view.state.doc;
const {
decoratorMode = "orderd",
position,
opacity,
maxRecLevel,
orderedStyleType,
orderedDelimiter,
orderedTrailingDelimiter,
orderedCustomTrailingDelimiter,
orderedLeadingDelimiter,
orderedCustomLeadingDelimiter,
orderedCustomIdents,
orderedSpecifiedString,
orderedAlwaysIgnore,
orderedIgnoreSingle,
orderedIgnoreMaximum = 6,
orderedBasedOnExisting,
orderedAllowZeroLevel,
unorderedLevelHeadings,
independentSettings,
spliceSettings,
} = isLivePreviwMode
const settings = isLivePreviwMode
? pluginData.previewSettings
: pluginData.sourceSettings;
let counter: Counter;
if (decoratorMode === "unordered") {
counter = new UnorderedCounter(
getUnorderedLevelHeadings(unorderedLevelHeadings),
maxRecLevel
);
} else {
let ignoreTopLevel = 0;
const ignoreSingle = !orderedAlwaysIgnore && orderedIgnoreSingle;
const ignoreLimit = orderedAlwaysIgnore ? orderedIgnoreMaximum : 0;
if (ignoreSingle || orderedBasedOnExisting) {
const queier = new Querier(orderedAllowZeroLevel, maxRecLevel);
const heading = new Heading();
for (let lineIndex = 1; lineIndex <= doc.lines; lineIndex++) {
const line = doc.line(lineIndex);
const lineText = line.text;
const nextLineIndex = lineIndex + 1;
const nextLineText =
nextLineIndex <= doc.lines ? doc.line(nextLineIndex).text : "";
const level = heading.handler(lineIndex, lineText, nextLineText);
if (level === -1) {
continue;
}
const { position, opacity } = settings;
queier.handler(level);
ignoreTopLevel = queier.query(ignoreSingle, orderedIgnoreMaximum);
if (ignoreTopLevel <= ignoreLimit) {
break;
}
}
}
if (ignoreTopLevel < ignoreLimit) {
ignoreTopLevel = ignoreLimit;
}
if (decoratorMode === "independent") {
counter = new IndependentCounter({
maxRecLevel,
ignoreTopLevel,
allowZeroLevel: orderedAllowZeroLevel,
orderedRecLevel: independentSettings?.orderedRecLevel,
h1: independentSettings?.h1,
h2: independentSettings?.h2,
h3: independentSettings?.h3,
h4: independentSettings?.h4,
h5: independentSettings?.h5,
h6: independentSettings?.h6,
});
} else if (decoratorMode === "splice") {
counter = new SpliceCounter({
maxRecLevel,
ignoreTopLevel,
allowZeroLevel: orderedAllowZeroLevel,
delimiter: spliceSettings?.delimiter,
trailingDelimiter: spliceSettings?.trailingDelimiter,
customTrailingDelimiter: spliceSettings?.customTrailingDelimiter,
leadingDelimiter: spliceSettings?.leadingDelimiter,
customLeadingDelimiter: spliceSettings?.customLeadingDelimiter,
h1: spliceSettings?.h1,
h2: spliceSettings?.h2,
h3: spliceSettings?.h3,
h4: spliceSettings?.h4,
h5: spliceSettings?.h5,
h6: spliceSettings?.h6,
});
} else {
counter = new OrderedCounter({
maxRecLevel,
ignoreTopLevel,
allowZeroLevel: orderedAllowZeroLevel,
styleType: orderedStyleType,
delimiter: orderedDelimiter,
trailingDelimiter: orderedTrailingDelimiter,
customTrailingDelimiter: orderedCustomTrailingDelimiter,
leadingDelimiter: orderedLeadingDelimiter,
customLeadingDelimiter: orderedCustomLeadingDelimiter,
customIdents: getOrderedCustomIdents(orderedCustomIdents),
specifiedString: orderedSpecifiedString,
});
}
}
const counter = createCounterFromSettings(settings, doc);
const heading = new Heading();
for (let lineIndex = 1; lineIndex <= doc.lines; lineIndex++) {

View file

@ -8,6 +8,7 @@ import {
debounce,
} from "obsidian";
import { EditorView, ViewPlugin } from "@codemirror/view";
import type { Extension } from "@codemirror/state";
import { i18n } from "../locales";
import type { HeadingPluginSettings, HeadingPluginData } from "../common/data";
import {
@ -23,6 +24,7 @@ import {
editorModeField,
updateEditorMode,
} from "./editor";
import { createHeadingGutterExtension } from "./editor-gutter";
import { ViewChildComponent } from "./child";
import { ReadingChild } from "./reading-child";
import {
@ -61,6 +63,7 @@ export class HeadingPlugin extends Plugin {
i18n = i18n;
private revokes: (() => void)[] = [];
private editorExtensions: Extension[] = [];
private readingComponents: ReadingChild[] = [];
@ -165,11 +168,8 @@ export class HeadingPlugin extends Plugin {
await this.loadSettings();
// Register editor extension
this.registerEditorExtension([
headingDecorationsField,
editorModeField,
this.craeteHeadingViewPlugin(this.getPluginData.bind(this)),
]);
this.editorExtensions = this.buildEditorExtensions();
this.registerEditorExtension(this.editorExtensions);
// Register markdown post processor
this.registerMarkdownPostProcessor((element, context) => {
@ -348,6 +348,8 @@ export class HeadingPlugin extends Plugin {
} else {
this.unloadFileExplorerComponents();
}
} else if (path === "useGutter" || path === "gutterPosition") {
this.swapEditorExtensions();
} else if (
path === "enabledOutlineSettings" ||
path.startsWith("outlineSettings")
@ -434,7 +436,30 @@ export class HeadingPlugin extends Plugin {
this.fileExplorerIdSet.clear();
}
private craeteHeadingViewPlugin(
private buildEditorExtensions(): Extension[] {
const getPluginData = this.getPluginData.bind(this);
if (this.settings.useGutter) {
const showBefore = this.settings.gutterPosition === "before-line-numbers";
return [
editorModeField,
...createHeadingGutterExtension(getPluginData, showBefore),
];
}
return [
headingDecorationsField,
editorModeField,
this.createInlineViewPlugin(getPluginData),
];
}
private swapEditorExtensions(): void {
const newExts = this.buildEditorExtensions();
this.editorExtensions.length = 0;
this.editorExtensions.push(...newExts);
this.app.workspace.updateOptions();
}
private createInlineViewPlugin(
getPluginData: () => Promise<HeadingPluginData>
) {
return ViewPlugin.fromClass(

View file

@ -74,6 +74,49 @@ export class HeadingSettingTab extends PluginSettingTab {
});
});
new Setting(containerEl)
.setName(i18n.t("setting.editorDisplay"))
.setHeading();
//* useGutter
const gutterPositionManager = new SettingDisplayManager();
new Setting(containerEl)
.setName(i18n.t("setting.useGutter"))
.setDesc(i18n.t("setting.useGutterDesc"))
.addToggle((toggle) =>
toggle.setValue(settings.useGutter).onChange((value) => {
settings.useGutter = value;
value ? gutterPositionManager.show() : gutterPositionManager.hide();
this.plugin.saveSettings();
})
);
//* gutterPosition
gutterPositionManager.add(
new Setting(containerEl)
.setName(i18n.t("setting.gutterPosition"))
.setDesc(i18n.t("setting.gutterPositionDesc"))
.addDropdown((dropdown) => {
dropdown
.addOptions({
"before-line-numbers": i18n.t("setting.beforeLineNumbers"),
"after-line-numbers": i18n.t("setting.afterLineNumbers"),
})
.setValue(settings.gutterPosition)
.onChange((value) => {
settings.gutterPosition = this.isGutterPosition(value)
? value
: "before-line-numbers";
this.plugin.saveSettings();
});
})
);
if (!settings.useGutter) {
gutterPositionManager.hide();
}
new Setting(containerEl).setName(i18n.t("setting.reading")).setHeading();
//* enabledInReading
@ -448,6 +491,10 @@ export class HeadingSettingTab extends PluginSettingTab {
return ["partial", "full"].includes(value);
}
private isGutterPosition(value: string): value is GutterPosition {
return ["before-line-numbers", "after-line-numbers"].includes(value);
}
private manageHeadingDecoratorSettings(
settingsType: PluginDecoratorSettingsType
) {

View file

@ -154,6 +154,13 @@
"fileRegexBlocklistIndex": "Note name regex blocklist {index}",
"fileRegexBlocklistPlaceholder": "e.g., /^daily.*/i",
"fileRegexBlocklistAddTip": "Add a new note name regex blocklist",
"editorDisplay": "Editor display",
"useGutter": "Use gutter for editor decorations",
"useGutterDesc": "Render heading decorations in a gutter column instead of inline with the text. Applies to both live preview and source mode.",
"gutterPosition": "Gutter position",
"gutterPositionDesc": "Whether the gutter column appears before or after line numbers.",
"beforeLineNumbers": "Before line numbers",
"afterLineNumbers": "After line numbers",
"logic": "Logic",
"h1": "H1",
"h2": "H2",

View file

@ -154,6 +154,13 @@
"fileRegexBlocklistIndex": "筆記名稱正則表示式阻止列表 {index}",
"fileRegexBlocklistPlaceholder": "例如:/^daily.*/i",
"fileRegexBlocklistAddTip": "新增新的筆記名稱正則表示式到阻止列表",
"editorDisplay": "編輯器顯示",
"useGutter": "使用邊欄顯示編輯器裝飾",
"useGutterDesc": "在邊欄列中顯示標題裝飾,而不是內嵌在文字中。適用於實際預覽和原始碼模式。",
"gutterPosition": "邊欄位置",
"gutterPositionDesc": "邊欄列顯示在行號之前還是之後。",
"beforeLineNumbers": "在行號之前",
"afterLineNumbers": "在行號之後",
"logic": "邏輯控制",
"h1": "H1 級別",
"h2": "H2 級別",

View file

@ -154,6 +154,13 @@
"fileRegexBlocklistIndex": "笔记名称正则表达式阻止列表 {index}",
"fileRegexBlocklistPlaceholder": "例如:/^daily.*/i",
"fileRegexBlocklistAddTip": "添加新的笔记名称正则表达式到阻止列表",
"editorDisplay": "编辑器显示",
"useGutter": "使用边栏显示编辑器装饰",
"useGutterDesc": "在边栏列中显示标题装饰,而不是内联在文本中。适用于实时阅览和源码模式。",
"gutterPosition": "边栏位置",
"gutterPositionDesc": "边栏列显示在行号之前还是之后。",
"beforeLineNumbers": "在行号之前",
"afterLineNumbers": "在行号之后",
"logic": "逻辑控制",
"h1": "H1 级别",
"h2": "H2 级别",

View file

@ -442,4 +442,55 @@ body {
.file-explorer-custom-heading-decorator.after-heading-decorator[data-decorator-opacity="100%"]::after {
opacity: 100%;
}
/* file-explorer start */
/* file-explorer end */
/* gutter start */
.heading-decorator-gutter {
font-size: var(--font-smallest);
letter-spacing: 0.015em;
text-align: end;
min-width: 3ch;
}
.heading-decorator-gutter-marker {
color: var(--text-faint);
display: inline-block;
white-space: nowrap;
user-select: none;
}
.heading-decorator-gutter-marker[data-decorator-opacity="10%"] {
opacity: 10%;
}
.heading-decorator-gutter-marker[data-decorator-opacity="20%"] {
opacity: 20%;
}
.heading-decorator-gutter-marker[data-decorator-opacity="30%"] {
opacity: 30%;
}
.heading-decorator-gutter-marker[data-decorator-opacity="40%"] {
opacity: 40%;
}
.heading-decorator-gutter-marker[data-decorator-opacity="50%"] {
opacity: 50%;
}
.heading-decorator-gutter-marker[data-decorator-opacity="60%"] {
opacity: 60%;
}
.heading-decorator-gutter-marker[data-decorator-opacity="70%"] {
opacity: 70%;
}
.heading-decorator-gutter-marker[data-decorator-opacity="80%"] {
opacity: 80%;
}
.heading-decorator-gutter-marker[data-decorator-opacity="90%"] {
opacity: 90%;
}
.heading-decorator-gutter-marker[data-decorator-opacity="100%"] {
opacity: 100%;
}
.table-cell-wrapper .heading-decorator-gutter {
display: none;
}
/* gutter end */

1
types.d.ts vendored
View file

@ -13,6 +13,7 @@ type HeadingTuple = TupleOf<string, 6>;
type DecoratorMode = "orderd" | "independent" | "splice" | "unordered";
type OpacityOptions = 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100;
type PostionOptions = "before" | "before-inside" | "after" | "after-inside";
type GutterPosition = "before-line-numbers" | "after-line-numbers";
type RenderPolicy = "partial" | "full";
type PluginDecoratorSettingsType =