UNPKG

insta-toc

Version:

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

217 lines (183 loc) 7.26 kB
import { MarkdownView, type App, type CachedMetadata, type Editor, type EditorPosition, type EventRef, type Events, type MarkdownViewModeType, type TAbstractFile, type TFile, type WorkspaceLeaf } from "obsidian"; type ViewMode = MarkdownViewModeType | "live-preview" | null; type EditorData = { file: TFile | null; cache: CachedMetadata | null; view: { view: MarkdownView | null; mode: ViewMode; }; editor: Editor | undefined; }; type MetadataCacheEvents = { changed: [file: TFile, data: string, cache: CachedMetadata]; }; type WorkspaceEvents = { "active-leaf-change": [leaf: WorkspaceLeaf | null]; "file-open": [file: TFile | null]; "layout-change": []; }; type VaultEvents = { rename: [file: TAbstractFile, oldPath: string]; }; type ObsidianEvent = MetadataCacheEvents & WorkspaceEvents & VaultEvents; type EventRegistration<M extends Record<string, any> = ObsidianEvent> = { [K in keyof M]: { name: K; callback: (...args: M[K]) => unknown; }; }[keyof M]; type RegisterReturnType = (eventRef: EventRef) => void; const metadataCacheEventNames: ReadonlySet<keyof MetadataCacheEvents> = new Set([ "changed" ] as const); const workspaceEventNames: ReadonlySet<keyof WorkspaceEvents> = new Set( [ "active-leaf-change", "file-open", "layout-change" ] as const ); const vaultEventNames: ReadonlySet<keyof VaultEvents> = new Set([ "rename" ] as const); function event<K extends keyof ObsidianEvent>( name: K, callback: (...args: ObsidianEvent[K]) => unknown ): EventRegistration { return { name, callback } as EventRegistration; } export default class EditorService { private app: App; private _registerEventHandle: RegisterReturnType; private builtinEvents: EventRegistration[] = [ event("active-leaf-change", () => this.syncState()), event("file-open", (file) => this.setData(file)), event("layout-change", () => this.syncState()) ]; public file: TFile | null = null; public cache: CachedMetadata | null = null; public view: MarkdownView | null = null; public viewMode: ViewMode = null; public editorData: EditorData = { file: this.file, cache: this.cache, view: { view: this.view, mode: this.viewMode }, editor: this.editor }; constructor(app: App, registerEventHandle: RegisterReturnType, addonEvents: EventRegistration[]) { this.app = app; this._registerEventHandle = registerEventHandle; this.syncState(); this.registerEvents(addonEvents); } public get editor(): Editor | undefined { return this.view?.editor; } public syncState(metadataCache?: CachedMetadata | null): void { const activeFile = this.app.workspace.getActiveFile(); this.setFileData(activeFile, metadataCache); this.setViewData(); } private getEmitter(name: keyof ObsidianEvent): Events { const isMetadataCacheEvent: boolean = metadataCacheEventNames.has(name as keyof MetadataCacheEvents); const isWorkspaceEvent: boolean = workspaceEventNames.has(name as keyof WorkspaceEvents); const isVaultEvent: boolean = vaultEventNames.has(name as keyof VaultEvents); if (isMetadataCacheEvent) return this.app.metadataCache; else if (isWorkspaceEvent) return this.app.workspace; else if (isVaultEvent) return this.app.vault; throw new Error(`Unknown event name: ${name}`); } private registerEvents(addonEvents: EventRegistration[]): void { const registered = new Set<string>(); for (const { name, callback: addonCb } of addonEvents) { const builtin = this.builtinEvents.find((e) => e.name === name); const cb = addonCb as (...args: any[]) => unknown; const emitter = this.getEmitter(name); this.registerEvent(emitter.on( name, builtin ? (...args: any[]) => { (builtin.callback as (...args: any[]) => unknown)(...args); cb(...args); } : cb )); registered.add(name as string); } for (const { name, callback } of this.builtinEvents) { if (registered.has(name as string)) continue; const emitter = this.getEmitter(name); this.registerEvent(emitter.on(name, callback as (...data: unknown[]) => unknown)); } } private registerEvent(eventRef: EventRef): void { this._registerEventHandle(eventRef); } private setData(file: TFile | null): void { this.setFileData(file); this.setViewData(); } private setViewData(): void { this.view = this.app.workspace.getActiveViewOfType(MarkdownView); const state = this.view?.getState(); if (!state) { this.viewMode = null; return; } if (state.mode === "source") { this.viewMode = state.source === false ? "live-preview" : "source"; return; } // state.mode === "preview" this.viewMode = "preview"; } private setFileData(activeTFile: TFile | null, metadataCache?: CachedMetadata | null): void { this.file = activeTFile; this.cache = metadataCache !== undefined ? metadataCache : this.file ? this.app.metadataCache.getFileCache(this.file) : null; } public async applyEditorChange( from: EditorPosition, to: EditorPosition, insert: string, editor?: Editor ): Promise<void> { this.syncState(); editor = editor ?? this.editor; const activeFile = this.file; const view = this.view; if (!editor || !activeFile || !view) { const message = `[WARN] ${ !editor ? "Editor" : !activeFile ? "File" // !view : "View" } not found in EditorService.applyEditorChange.`; new Notice(message); console.log(message); return; } const fromOffset = editor.posToOffset(from); const toOffset = editor.posToOffset(to); const cm = editor.cm; cm.dispatch({ changes: { from: fromOffset, to: toOffset, insert }, scrollIntoView: false // disable auto-scroll upon file edits }); if (this.viewMode === "preview") { await this.app.vault.process(activeFile, (content) => { const lines = content.split("\n"); let fromPos = 0; for (let i = 0; i < from.line && i < lines.length; i++) { fromPos += lines[i].length + 1; } fromPos += from.ch; let toPos = 0; for (let i = 0; i < to.line && i < lines.length; i++) { toPos += lines[i].length + 1; } toPos += to.ch; return content.slice(0, fromPos) + insert + content.slice(toPos); }); view.previewMode.rerender(true); } } }