Files
beaver-plugin-obsidian/src/view.ts
T

251 lines
7.9 KiB
TypeScript

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<string, unknown> | 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<string, unknown> {
return { follow: this.state.follow, selected: this.state.selected };
}
async setState(state: unknown, result: ViewStateResult): Promise<void> {
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<void> {
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<HTMLElement>(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<void> {
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<void> {
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<void> {
if (this.root) {
await unmount(this.root);
this.root = null;
}
}
}