UNPKG

insta-toc

Version:

Simultaneously generate, update, and maintain a table of contents for your notes in real time.

294 lines (242 loc) 11.2 kB
import type { App, CachedMetadata, Debouncer, EditorPosition, EventRef, MarkdownPostProcessorContext, PluginManifest } from "obsidian"; import { TFile, debounce } from "obsidian"; import { PluginBase } from "obsidian-dev-utils/obsidian/plugin/plugin-base"; import { initPluginContext } from "obsidian-dev-utils/obsidian/plugin/plugin-context"; import { ManageToc } from "./ManageToc"; import { deepMerge } from "./Utils"; import { instaTocCodeBlockId } from "./constants"; import EditorService from "./editorService"; import { PluginSettingsManager } from "./settings/PluginSettingManager"; import type { InstaTocSettings } from "./settings/Settings"; import { getDefaultLocalSettings } from "./settings/Settings"; import { SettingTab } from "./settings/SettingsTab"; import { CodeBlockComponent, LocalSettingsModal, MarkdownComponentMounter } from "./svelte"; import { TocModel } from "./tocModel"; import type { FileKey, LocalTocSettings, PluginTypes } from "./types"; import UiStateManager from "./uiStateManager"; import { Validator, hasInstaTocSection } from "./validator"; type ReloadedTocState = { activeFile: TFile; validator: Validator; isValid: boolean; }; type ReloadOpts = { forceValidate?: boolean; cache?: CachedMetadata; manageToc?: boolean; }; export default class InstaTocPlugin extends PluginBase<PluginTypes> { private _validator: Validator | undefined; private _editorService: EditorService | undefined; private _uiStateManager: UiStateManager | undefined; private modifyEventRef: EventRef | undefined; private debouncer!: Debouncer<[fileCache: CachedMetadata], void>; constructor(app: App, manifest: PluginManifest) { super(app, manifest); } public override get settings(): InstaTocSettings { return this.settingsManager.settingsWrapper.settings as InstaTocSettings; } public get validator(): Validator { assert(this._validator, "Validator is not initialized yet."); return this._validator; } public get editorService(): EditorService { assert(this._editorService, "EditorService is not initialied yet."); return this._editorService; } public get uiStateManager(): UiStateManager { assert(this._uiStateManager, "UiStateManager is not initialied yet."); return this._uiStateManager; } public get isMobile(): boolean { return this.app.isMobile; } protected override createSettingsManager(): PluginSettingsManager { const settingsManager = new PluginSettingsManager(this); this._uiStateManager ??= new UiStateManager(settingsManager); return settingsManager; } protected override createSettingsTab(): SettingTab { return new SettingTab(this); } public override async onloadImpl(): Promise<void> { await super.onloadImpl(); initPluginContext(this.app, "insta-toc"); console.log(`Loading Insta TOC Plugin`); this._uiStateManager ??= new UiStateManager(this.settingsManager); this._editorService = new EditorService(this.app, this.registerEvent.bind(this), [ { name: "file-open", // Immediate TOC update on file-open ensures TOC renders/updates for every file callback: (file) => { void this.uiStateManager.flushPersistedData(); if (!(file instanceof TFile)) return; const cache = this._editorService?.cache; if (!cache) return; this.debouncer.cancel(); this.debouncer(cache).run(); } }, { name: "active-leaf-change", callback: () => { void this.uiStateManager.flushPersistedData(); } }, { name: "layout-change", callback: () => { void this.uiStateManager.flushPersistedData(); } }, { name: "rename", // Replace old fold state data upon rename callback: (file, oldPath) => { const newPath = file.path as FileKey; this.uiStateManager.pruneTocFoldStateForPath(oldPath, { replacementFile: newPath }); } } ]); this.updateModifyEventListener(); // Custom codeblock processor for the insta-toc codeblock this.registerMarkdownCodeBlockProcessor( "insta-toc", async (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext): Promise<void> => { const sourcePath: FileKey = ctx.sourcePath as FileKey; const tocModel = new TocModel(this.uiStateManager, this.app, this.settings, { localSettings: source, sourceFilePath: sourcePath }, (key) => this.uiStateManager.getTocFoldState(key)); const mounter = new MarkdownComponentMounter(el, { component: CodeBlockComponent, props: { plugin: this, sourcePath, model: tocModel.model, onOpenEditBlock: el.parentElement ? () => { el.parentElement! .querySelector<HTMLDivElement>( ".edit-block-button[aria-label='Edit this block']:not(.insta-toc-action-button)" ) ?.click(); } : null } }); ctx.addChild(mounter); } ); this.addCommand({ id: "add-insta-toc-block", name: "Add Insta TOC Block", allowPreview: true, editorCallback: async (_editor, _ctx) => { await this.addInstaTocBlock(); } }); this.addRibbonIcon("table-of-contents", "Add Insta TOC Block", async (_evt) => { await this.addInstaTocBlock(); }); } protected override async onunloadImpl(): Promise<void> { this.debouncer?.run(); // flush pending TOC update await this.uiStateManager.flushPersistedData(); await this.settingsManager.saveToFile(); await super.onunloadImpl(); console.log(`Insta TOC Plugin Unloaded.`); this.app.vault.configDir; } public async reload( { forceValidate = false, cache: cacheOverride, manageToc = true }: ReloadOpts = {} ): Promise<ReloadedTocState | undefined> { this.editorService.syncState(cacheOverride); const { editor, cache, file } = this.editorService; if (!editor || !cache || !file) { console.warn(`[WARNING] Unable to reload insta-toc.\neditor: ${editor}\ncache: ${cache}\nfile: ${file}`); return; } const sourcePath = file.path; if (this._validator) { this._validator.update(this.editorService, this.settings, cache, sourcePath); } else { this._validator = new Validator(this.editorService, this.settings, cache, sourcePath); } const validator = this.validator; const isValid = validator.isValid(forceValidate); if (manageToc && isValid) { await ManageToc.run(this.editorService, this.settings, validator); } return { activeFile: file, validator, isValid }; } private async addInstaTocBlock(): Promise<void> { const service = this.editorService; service.syncState(); const { file, cache, view, editor } = service; const hasSection: boolean | undefined = editor && cache?.sections ? hasInstaTocSection(editor, cache.sections) : undefined; if (!file || !cache || !view || hasSection === true) { const consoleMessage = hasSection !== true ? !file ? "[WARN] No active file to insert TOC into." : !cache ? "[WARN] No metadataCache available." // !view : "[WARN] Active view is not a Markdown view." : "[WARN] InstaToc section detected in active file. Aborting..."; new Notice(consoleMessage); console.log(consoleMessage); return; } this.app.plugins.getPlugin(""); const fmEnd = cache.frontmatterPosition?.end; const insertPos: EditorPosition = fmEnd ? { line: fmEnd.line + 1, ch: 0 } : { line: 0, ch: 0 }; const tocBlock = `\`\`\`${instaTocCodeBlockId}\n\n\`\`\`\n`; const tocString = fmEnd ? `\n\n${tocBlock}\n` : tocBlock; await service.applyEditorChange(insertPos, insertPos, tocString); } // Dynamically update the debounce delay for ToC updates public updateModifyEventListener(): void { if (this.modifyEventRef) { // Unregister the previous event listener this.app.metadataCache.offref(this.modifyEventRef); } this.setDebouncer(); // Register the new event listener with the updated debounce delay this.modifyEventRef = this.app.metadataCache.on( "changed", // file cache (containing heading cache) has been updated (file: TFile, _data: string, cache: CachedMetadata) => { const activeFile: TFile | null = this.editorService.file; if (!activeFile || activeFile.path !== file.path) return; this.debouncer(cache); } ); this.registerEvent(this.modifyEventRef); } // Needed for dynamically setting the debounce delay private setDebouncer(): void { this.debouncer = debounce( async (fileCache: CachedMetadata) => { const state = await this.reload({ cache: fileCache }); if (!state) { console.log("[WARNING] Unable to reload the active TOC state during the debounced update."); return; } }, this.settings.updateDelay, true ); } public async openLocalSettingsModal(): Promise<void> { const state = await this.reload({ forceValidate: true, manageToc: false }); assert(state, "TOC state is required before opening the local settings modal."); const initialSettings = state.isValid ? state.validator.localTocSettings : getDefaultLocalSettings(); const mergedInitialSettings = deepMerge<LocalTocSettings>(getDefaultLocalSettings(), initialSettings); state.validator.localTocSettings = mergedInitialSettings; state.validator.updatedLocalSettings = mergedInitialSettings; await new LocalSettingsModal(this, async (result: string): Promise<boolean> => { const didApply = this.validator.applyLocalSettingsYaml(result); if (!didApply) return false; await ManageToc.run(this.editorService, this.settings, this.validator); return true; }) .open(); } }