UNPKG

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