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