import type { ViewStateResult, WorkspaceLeaf } from "obsidian"; import { ItemView, TFile } from "obsidian"; import { mount, unmount } from "svelte"; import type { Rect } from "./inset"; import { insetFor } from "./inset"; import type BeaverPlugin from "./main"; import { PanelState } from "./state.svelte"; import App from "./ui/app.svelte"; export const VIEW_TYPE_PANEL = "beaver-panel"; // Bars that paint over a leaf instead of shortening it. On a phone the navbar // sits under every view, including the one filling the screen. const BOTTOM_BARS = [".status-bar", ".mobile-navbar", ".mobile-toolbar"]; // The keyboard and the navbar both animate, and Obsidian hides the navbar // *after* the keyboard is up: the inset is re-measured every frame until the // reading has held still long enough to outlast both moves. const STEADY_FRAMES = 12; /** Obsidian's Capacitor keyboard events on mobile; absent on the desktop. */ const KEYBOARD_EVENTS = [ "keyboardWillShow", "keyboardDidShow", "keyboardWillHide", "keyboardDidHide", ] as const; interface SavedState { follow?: unknown; selected?: unknown; } // One panel leaf: a sidedock column, a tab, or the phone screen. Follows // the active note's ``conversation_id`` unless pinned, and remembers both // across restarts through the workspace layout. export class PanelView extends ItemView { readonly state = new PanelState(); private readonly plugin: BeaverPlugin; private root: Record | null = null; private settling: number | null = null; constructor(leaf: WorkspaceLeaf, plugin: BeaverPlugin) { super(leaf); this.plugin = plugin; this.navigation = false; } getViewType(): string { return VIEW_TYPE_PANEL; } getDisplayText(): string { return "Beaver"; } getIcon(): string { return "bot"; } getState(): Record { return { follow: this.state.follow, selected: this.state.selected }; } async setState(state: unknown, result: ViewStateResult): Promise { const saved = (state ?? {}) as SavedState; if (typeof saved.follow === "boolean") { this.state.follow = saved.follow; } if (typeof saved.selected === "string" || saved.selected === null) { this.state.selected = saved.selected; } await super.setState(state, result); } onOpen(): Promise { this.contentEl.empty(); this.contentEl.style.padding = "0"; this.contentEl.style.overflow = "hidden"; this.registerEvent( this.app.workspace.on("file-open", (file) => { // ``null`` means the active leaf is not a file (this panel got // focus, say): keep following the last note rather than drop it. if (file) { this.follow(file); } }) ); this.registerEvent( this.app.metadataCache.on("changed", (file) => { // The note we follow just got its conversation_id - typically // right after the first send. Re-resolve. if (file.path === this.state.sourcePath) { this.follow(file); } }) ); this.registerEvent(this.app.workspace.on("resize", () => this.settle())); this.registerEvent( this.app.workspace.on("layout-change", () => this.settle()) ); // The keyboard resizes the visual viewport only: no workspace event // fires, so the composer would stay behind it without these two. const vv = window.visualViewport; if (vv) { const onViewport = () => this.settle(); vv.addEventListener("resize", onViewport); vv.addEventListener("scroll", onViewport); this.register(() => { vv.removeEventListener("resize", onViewport); vv.removeEventListener("scroll", onViewport); }); } // Focus opens the keyboard, blur closes it; both animate, and on the way // out the navbar comes back, so the leaf has to be re-measured after. this.registerDomEvent(this.contentEl, "focusin", () => this.settle()); this.registerDomEvent(this.contentEl, "focusout", () => this.settle()); // Obsidian's own keyboard events (Capacitor, mobile only) fire before the // animation and again as the navbar returns, which visualViewport alone // does not always report. for (const name of KEYBOARD_EVENTS) { const onKeyboard = () => this.settle(); window.addEventListener(name, onKeyboard); this.register(() => window.removeEventListener(name, onKeyboard)); } this.follow(this.app.workspace.getActiveFile()); this.plugin.views.add(this); this.mountApp(); this.settle(); return Promise.resolve(); } /** * Re-measure until the viewport stops moving. The keyboard animates both * ways and Obsidian hides its navbar while it is up, so a fixed delay lands * mid-animation; a frame loop that stops once two readings agree does not * care how long the animation takes, or whether any event fires at all - * which is the case in a sidedock drawer. */ private settle(): void { let last = Number.NaN; let steady = 0; // A fresh trigger restarts the watch rather than being dropped: the navbar // coming back is a second move, after the first one has settled. if (this.settling !== null) { cancelAnimationFrame(this.settling); } const tick = () => { const floor = this.inset(); steady = floor === last ? steady + 1 : 0; last = floor; if (steady >= STEADY_FRAMES) { this.settling = null; return; } this.settling = requestAnimationFrame(tick); }; this.settling = requestAnimationFrame(tick); this.register(() => { if (this.settling !== null) { cancelAnimationFrame(this.settling); this.settling = null; } }); } // Whatever floats over the bottom of this leaf, measured rather than // guessed. Returns the inset it wrote, so the settle loop can tell when the // reading stops changing; the arithmetic itself lives in ``inset.ts``. private inset(): number { const box = this.contentEl.getBoundingClientRect(); if (box.height <= 0) { return Number.NaN; } const bars: Rect[] = []; for (const selector of BOTTOM_BARS) { const node = document.querySelector(selector); if (node && node.offsetParent !== null) { bars.push(node.getBoundingClientRect()); } } const vv = window.visualViewport; const inset = insetFor( box, bars, vv ? { height: vv.height, offsetTop: vv.offsetTop } : null ); this.contentEl.style.setProperty("--beaver-inset-bottom", `${inset}px`); return inset; } async onClose(): Promise { this.plugin.views.delete(this); await this.unmountApp(); this.contentEl.empty(); } // The active note changed: remember it, and switch to its conversation // when following and it has one. follow(file: TFile | null): void { if (!(file instanceof TFile)) { return; } this.state.sourcePath = file.path; if (!this.state.follow) { return; } const id = this.plugin.conversationIdOf(file); if (id) { this.state.selected = id; this.state.companion = !this.inTabs(); } } show(id: string): void { this.state.selected = id; this.state.companion = false; } private inTabs(): boolean { return this.leaf.getRoot() === this.app.workspace.rootSplit; } // Settings changed: the client is built from them, so rebuild the tree. async remount(): Promise { await this.unmountApp(); this.mountApp(); } private mountApp(): void { this.root = mount(App, { props: { app: this.app, chatsFolder: () => this.plugin.settings.chatsFolder, client: this.plugin.apiClient(), component: this, onOpenSettings: () => this.plugin.openSettings(), state: this.state, }, target: this.contentEl, }); } private async unmountApp(): Promise { if (this.root) { await unmount(this.root); this.root = null; } } }