From fa36710f0c9a5b9ed4f36bd9a7c00dc15dbd268e Mon Sep 17 00:00:00 2001 From: h Date: Sat, 5 Sep 2026 01:14:54 +0200 Subject: [PATCH] fix(view,ui): follow the keyboard through the navbar hiding behind it The panel drifted while the keyboard came up and then slid under it at the end. Two reasons, both about the ground moving twice: iOS animates the keyboard, and once it is up Obsidian hides its own navbar - a second move that the old four-frame watch had already stopped waiting for, and that a trigger arriving mid-watch was dropped for instead of restarting it. The watch now runs until the reading holds still for twelve frames, a fresh trigger restarts it, and Obsidian's Capacitor keyboard events (`keyboardWill/ DidShow`, `...Hide`) are listened to as well - they fire around the animation that `visualViewport` does not always report, and never fire on the desktop. --- src/ui/anchored-panel.svelte | 34 +++++++++++++++++++++++++++------- src/view.ts | 24 ++++++++++++++++++++---- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/src/ui/anchored-panel.svelte b/src/ui/anchored-panel.svelte index 0a8ab0a..a15015f 100644 --- a/src/ui/anchored-panel.svelte +++ b/src/ui/anchored-panel.svelte @@ -28,8 +28,17 @@ const view = useView(); // The keyboard animates both ways: rather than guess how long that takes, - // the panel re-places every frame until its position holds still. - const STEADY_FRAMES = 4; + // the panel re-places every frame until its position holds still. Long enough + // to outlast Obsidian hiding its navbar, which moves the ground a second time. + const STEADY_FRAMES = 12; + + /** Obsidian's Capacitor keyboard events on mobile; absent on the desktop. */ + const KEYBOARD_EVENTS = [ + "keyboardWillShow", + "keyboardDidShow", + "keyboardWillHide", + "keyboardDidHide", + ]; let panel = $state(null); let at = $state({ top: 0, left: 0 }); @@ -76,15 +85,18 @@ }; const reflow = (): void => measure(); /** - * Re-place every frame until the position holds still. The keyboard - * animates both ways, and in a sidedock nothing fires an event at all - - * a frame loop needs neither a duration to guess nor an event to wait for. + * Re-place every frame until the position has held still for a while. The + * keyboard animates, and Obsidian hides its navbar *after* it is up, which + * moves the ground under the panel a second time - a short "steady" run + * exits between the two and leaves the panel where neither wanted it. In a + * sidedock no resize event fires at all, so a frame loop is the only thing + * that notices either move. */ let frame: number | null = null; const settle = (): void => { - if (frame !== null) return; let last = Number.NaN; let steady = 0; + if (frame !== null) win.cancelAnimationFrame(frame); const tick = (): void => { measure(); steady = at.top === last ? steady + 1 : 0; @@ -103,7 +115,12 @@ const vv = win.visualViewport; vv?.addEventListener("resize", settle); vv?.addEventListener("scroll", settle); - // The panel's own input takes focus a beat before the keyboard is up, and + // Obsidian's own keyboard events (Capacitor, mobile only): they fire before + // the animation and again as the navbar comes back, which `visualViewport` + // on its own does not always report. + for (const name of KEYBOARD_EVENTS) { + win.addEventListener(name, settle); + } // The panel's own input takes focus a beat before the keyboard is up, and // swapping fields re-opens it. node.addEventListener("focusin", settle); settle(); @@ -124,6 +141,9 @@ window.removeEventListener("resize", reflow); vv?.removeEventListener("resize", settle); vv?.removeEventListener("scroll", settle); + for (const name of KEYBOARD_EVENTS) { + win.removeEventListener(name, settle); + } node.removeEventListener("focusin", settle); if (frame !== null) win.cancelAnimationFrame(frame); watcher?.disconnect(); diff --git a/src/view.ts b/src/view.ts index 5a64c58..1ef928c 100644 --- a/src/view.ts +++ b/src/view.ts @@ -8,9 +8,18 @@ import type BeaverCalendarPlugin from "./main"; export const VIEW_TYPE_BOARDS = "beaver-calendar-boards"; -// 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; +// 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; export class BoardsView extends ItemView { private root: Record | null = null; @@ -76,6 +85,11 @@ export class BoardsView extends ItemView { } this.registerDomEvent(this.contentEl, "focusin", () => this.settle()); this.registerDomEvent(this.contentEl, "focusout", () => this.settle()); + for (const name of KEYBOARD_EVENTS) { + const onKeyboard = () => this.settle(); + window.addEventListener(name, onKeyboard); + this.register(() => window.removeEventListener(name, onKeyboard)); + } await this.plugin.store.reloadAll(); this.root = mount(App, { @@ -96,9 +110,11 @@ export class BoardsView extends ItemView { * neither a duration to guess nor an event that a sidedock never sends. */ private settle(): void { - if (this.settling !== null) return; 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 inset = this.inset(); steady = inset === last ? steady + 1 : 0;