251 lines
7.9 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|