insta-toc
Version:
Simultaneously generate, update, and maintain a table of contents for your notes in real time.
158 lines (127 loc) • 6.06 kB
text/typescript
import type { EditorRange, HeadingCache } from "obsidian";
import { stringifyYaml } from "obsidian";
import { instaTocCodeBlockId } from "./constants";
import type EditorService from "./editorService";
import type { InstaTocSettings } from "./settings/Settings";
import { resolveTocTitle } from "./settings/localTocSettings";
import { normalizeLocalTocSettings } from "./settings/localTocSettings";
import type { Validator } from "./validator";
export class ManageToc {
private editorService: EditorService;
private settings: InstaTocSettings;
private validator: Validator;
private headingLevelStack: number[];
private constructor(editorService: EditorService, settings: InstaTocSettings, validator: Validator) {
this.editorService = editorService;
this.settings = settings;
this.validator = validator;
this.headingLevelStack = [];
}
public static async run(
editorService: EditorService,
settings: InstaTocSettings,
validator: Validator
): Promise<void> {
const instance = new ManageToc(editorService, settings, validator);
await instance.updateAutoToc();
}
// Determine the correct indentation level
private getIndentationLevel(headingLevel: number): number {
// Pop from the stack until we find a heading level less than the current
while (
this.headingLevelStack.length > 0 // Avoid indentation for the first heading
&& headingLevel <= this.headingLevelStack[this.headingLevelStack.length - 1]
) {
this.headingLevelStack.pop();
}
this.headingLevelStack.push(headingLevel);
const currentIndentLevel = this.headingLevelStack.length - 1;
return currentIndentLevel;
}
private createTocContent(tocHeadingRefs: string[]): string {
const title = resolveTocTitle(this.validator.localTocSettings, this.settings);
const localSettingsYaml = stringifyYaml(normalizeLocalTocSettings(this.validator.localTocSettings)).trimEnd();
const localSettingsContent = `---\n${localSettingsYaml}\n---`;
const titleContent = title ? `${"#".repeat(title.level)} ${title.text}` : "";
const tocList = tocHeadingRefs.join("\n");
return [ localSettingsContent, titleContent, tocList ].filter((section) => section.length > 0).join("\n\n");
}
private getMinimalDiff(
currentTocBlock: string,
nextTocBlock: string
): { startOffset: number; endOffset: number; insert: string; } | null {
if (currentTocBlock === nextTocBlock) {
return null;
}
let startOffset = 0;
const maxPrefixLength = Math.min(currentTocBlock.length, nextTocBlock.length);
while (startOffset < maxPrefixLength && currentTocBlock[startOffset] === nextTocBlock[startOffset]) {
startOffset += 1;
}
let currentEndOffset = currentTocBlock.length;
let nextEndOffset = nextTocBlock.length;
while (
currentEndOffset > startOffset
&& nextEndOffset > startOffset
&& currentTocBlock[currentEndOffset - 1] === nextTocBlock[nextEndOffset - 1]
) {
currentEndOffset -= 1;
nextEndOffset -= 1;
}
return { startOffset, endOffset: currentEndOffset, insert: nextTocBlock.slice(startOffset, nextEndOffset) };
}
private offsetToPosition(source: string, offset: number, start: EditorRange["from"]): EditorRange["from"] {
const precedingText = source.slice(0, offset);
const lines = precedingText.split("\n");
const lineOffset = lines.length - 1;
const lastLine = lines[lineOffset] ?? "";
return { line: start.line + lineOffset, ch: lineOffset === 0 ? start.ch + lastLine.length : lastLine.length };
}
/** Generates a new insta-toc codeblock with normal dash-type bullets */
private generateToc(): string {
const tocHeadingRefs: string[] = [];
const fileHeadings: HeadingCache[] = this.validator.fileHeadings;
for (const headingCache of fileHeadings) {
const headingLevel: number = headingCache.level;
const headingText: string = headingCache.heading;
if (headingText.length === 0) continue;
const currentIndentLevel = this.getIndentationLevel(headingLevel);
// Calculate the indentation based on the current indentation level
const indent: string = " ".repeat(currentIndentLevel * 4);
const tocHeadingRef = `${indent}- ${headingText}`;
tocHeadingRefs.push(tocHeadingRef);
}
const tocContent: string = this.createTocContent(tocHeadingRefs);
return `\`\`\`${instaTocCodeBlockId}\n${tocContent}\n\`\`\``;
}
private getTocUpdate(
insertRange: EditorRange,
newTocBlock: string
): { from: EditorRange["from"]; to: EditorRange["to"]; insert: string; } | null {
const existingTocBlock: string | undefined = this.editorService.editor?.getRange(
insertRange.from,
insertRange.to
);
if (existingTocBlock === undefined) {
return null;
}
const diff = this.getMinimalDiff(existingTocBlock, newTocBlock);
if (!diff) {
return null;
}
return {
from: this.offsetToPosition(existingTocBlock, diff.startOffset, insertRange.from),
to: this.offsetToPosition(existingTocBlock, diff.endOffset, insertRange.from),
insert: diff.insert
};
}
// Dynamically update the TOC
private async updateAutoToc(): Promise<void> {
const tocInsertRange: EditorRange = this.validator.tocInsertPos;
const newTocBlock: string = this.generateToc();
const tocUpdate = this.getTocUpdate(tocInsertRange, newTocBlock);
if (tocUpdate) {
await this.editorService.applyEditorChange(tocUpdate.from, tocUpdate.to, tocUpdate.insert);
}
}
}