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;