From 413d68fc872cd27e7c36ed0f123c34197b39a583 Mon Sep 17 00:00:00 2001 From: h Date: Sat, 5 Sep 2026 00:47:44 +0200 Subject: [PATCH] fix(view): the inset chases the keyboard by frame, and comes back when it goes Two fixed delays into the keyboard's animation is not where it ends: the composer read the viewport mid-slide, and on the way out - when the navbar comes back - nothing re-measured at all, so the panel stayed pushed up or sat back under the bar. In a sidedock drawer no resize event fires either, so a phone's keyboard moved nothing there. The inset is now re-measured every frame until the reading holds still, and blur is a trigger as much as focus. `inset()` returns what it wrote so the loop can tell it has settled rather than counting milliseconds. --- src/view.ts | 69 +++++++++++++++++++++++++++++++++++++-------------- t/cdp-eval.ts | 35 ++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 18 deletions(-) create mode 100644 t/cdp-eval.ts diff --git a/src/view.ts b/src/view.ts index 346b7ca..445d666 100644 --- a/src/view.ts +++ b/src/view.ts @@ -11,8 +11,9 @@ export const VIEW_TYPE_PANEL = "beaver-panel"; // sits under every view, including the one filling the screen. const BOTTOM_BARS = [".status-bar", ".mobile-navbar", ".mobile-toolbar"]; -// iOS animates the keyboard in; the viewport is only honest once it is done. -const KEYBOARD_SETTLE_MS = 350; +// The keyboard and the navbar both animate. Rather than guess how long that +// takes, the inset is re-measured every frame until the reading holds still. +const STEADY_FRAMES = 4; interface SavedState { follow?: unknown; @@ -26,6 +27,7 @@ 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); @@ -82,15 +84,15 @@ export class PanelView extends ItemView { } }) ); - this.registerEvent(this.app.workspace.on("resize", () => this.inset())); + this.registerEvent(this.app.workspace.on("resize", () => this.settle())); this.registerEvent( - this.app.workspace.on("layout-change", () => this.inset()) + 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.inset(); + const onViewport = () => this.settle(); vv.addEventListener("resize", onViewport); vv.addEventListener("scroll", onViewport); this.register(() => { @@ -98,26 +100,58 @@ export class PanelView extends ItemView { vv.removeEventListener("scroll", onViewport); }); } - // Focusing the composer opens the keyboard a beat before the viewport - // settles; re-measure once it has. - this.registerDomEvent(this.contentEl, "focusin", () => { - window.setTimeout(() => this.inset(), KEYBOARD_SETTLE_MS); - }); + // 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()); this.follow(this.app.workspace.getActiveFile()); this.plugin.views.add(this); this.mountApp(); - requestAnimationFrame(() => this.inset()); + 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 { + if (this.settling !== null) { + return; + } + let last = Number.NaN; + let steady = 0; + 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: the desktop status bar, the phone's navbar (which the workspace // does not subtract from a leaf's height), and the software keyboard, which - // shrinks the visual viewport without resizing the layout at all. - private inset(): void { + // shrinks the visual viewport without resizing the layout at all. Returns + // the inset it wrote, so the settle loop can tell when it stops changing. + private inset(): number { const box = this.contentEl.getBoundingClientRect(); if (box.height <= 0) { - return; + return Number.NaN; } let overlap = 0; for (const bar of BOTTOM_BARS) { @@ -137,10 +171,9 @@ export class PanelView extends ItemView { const floor = vv.offsetTop + vv.height; overlap = Math.max(overlap, box.bottom - floor); } - this.contentEl.style.setProperty( - "--beaver-inset-bottom", - `${Math.max(0, Math.round(overlap))}px` - ); + const inset = Math.max(0, Math.round(overlap)); + this.contentEl.style.setProperty("--beaver-inset-bottom", `${inset}px`); + return inset; } async onClose(): Promise { diff --git a/t/cdp-eval.ts b/t/cdp-eval.ts new file mode 100644 index 0000000..ed4d4c1 --- /dev/null +++ b/t/cdp-eval.ts @@ -0,0 +1,35 @@ +// Evaluate an expression inside a running Obsidian started with +// --remote-debugging-port. Usage: bun t/cdp-eval.ts "" +const [port, expression] = process.argv.slice(2); +const targets = (await ( + await fetch(`http://localhost:${port}/json`) +).json()) as { + type: string; + title: string; + webSocketDebuggerUrl: string; +}[]; +const page = targets.find((t) => t.type === "page"); +if (!page) { + throw new Error("no page target"); +} +const ws = new WebSocket(page.webSocketDebuggerUrl); +await new Promise((resolve) => ws.addEventListener("open", resolve)); +const reply = new Promise((resolve) => { + ws.addEventListener("message", (event) => { + const data = JSON.parse(String(event.data)); + if (data.id === 1) { + resolve( + JSON.stringify(data.result?.result?.value ?? data.result ?? data) + ); + } + }); +}); +ws.send( + JSON.stringify({ + id: 1, + method: "Runtime.evaluate", + params: { awaitPromise: true, expression, returnByValue: true }, + }) +); +console.log(await reply); +ws.close();