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
text/typescript
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();
}
}