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.
This commit is contained in:
hh committed 2026-09-05 01:14:54 +02:00
1 parent f980abcc1d
commit fa36710f0c
2 files changed
+47 -11

No files matched your search

+27 -7
View File
@@ -28,8 +28,17 @@
const view = useView(); const view = useView();
// The keyboard animates both ways: rather than guess how long that takes, // The keyboard animates both ways: rather than guess how long that takes,
// the panel re-places every frame until its position holds still. // the panel re-places every frame until its position holds still. Long enough
const STEADY_FRAMES = 4; // 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<HTMLElement | null>(null); let panel = $state<HTMLElement | null>(null);
let at = $state({ top: 0, left: 0 }); let at = $state({ top: 0, left: 0 });
@@ -76,15 +85,18 @@
}; };
const reflow = (): void => measure(); const reflow = (): void => measure();
/** /**
* Re-place every frame until the position holds still. The keyboard * Re-place every frame until the position has held still for a while. The
* animates both ways, and in a sidedock nothing fires an event at all - * keyboard animates, and Obsidian hides its navbar *after* it is up, which
* a frame loop needs neither a duration to guess nor an event to wait for. * 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; let frame: number | null = null;
const settle = (): void => { const settle = (): void => {
if (frame !== null) return;
let last = Number.NaN; let last = Number.NaN;
let steady = 0; let steady = 0;
if (frame !== null) win.cancelAnimationFrame(frame);
const tick = (): void => { const tick = (): void => {
measure(); measure();
steady = at.top === last ? steady + 1 : 0; steady = at.top === last ? steady + 1 : 0;
@@ -103,7 +115,12 @@
const vv = win.visualViewport; const vv = win.visualViewport;
vv?.addEventListener("resize", settle); vv?.addEventListener("resize", settle);
vv?.addEventListener("scroll", 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. // swapping fields re-opens it.
node.addEventListener("focusin", settle); node.addEventListener("focusin", settle);
settle(); settle();
@@ -124,6 +141,9 @@
window.removeEventListener("resize", reflow); window.removeEventListener("resize", reflow);
vv?.removeEventListener("resize", settle); vv?.removeEventListener("resize", settle);
vv?.removeEventListener("scroll", settle); vv?.removeEventListener("scroll", settle);
for (const name of KEYBOARD_EVENTS) {
win.removeEventListener(name, settle);
}
node.removeEventListener("focusin", settle); node.removeEventListener("focusin", settle);
if (frame !== null) win.cancelAnimationFrame(frame); if (frame !== null) win.cancelAnimationFrame(frame);
watcher?.disconnect(); watcher?.disconnect();
+20 -4
View File
@@ -8,9 +8,18 @@ import type BeaverCalendarPlugin from "./main";
export const VIEW_TYPE_BOARDS = "beaver-calendar-boards"; export const VIEW_TYPE_BOARDS = "beaver-calendar-boards";
// The keyboard and the navbar both animate. Rather than guess how long that // The keyboard and the navbar both animate, and Obsidian hides the navbar
// takes, the inset is re-measured every frame until the reading holds still. // *after* the keyboard is up: the inset is re-measured every frame until the
const STEADY_FRAMES = 4; // 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 { export class BoardsView extends ItemView {
private root: Record<string, unknown> | null = null; private root: Record<string, unknown> | null = null;
@@ -76,6 +85,11 @@ export class BoardsView extends ItemView {
} }
this.registerDomEvent(this.contentEl, "focusin", () => this.settle()); this.registerDomEvent(this.contentEl, "focusin", () => this.settle());
this.registerDomEvent(this.contentEl, "focusout", () => 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(); await this.plugin.store.reloadAll();
this.root = mount(App, { 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. * neither a duration to guess nor an event that a sidedock never sends.
*/ */
private settle(): void { private settle(): void {
if (this.settling !== null) return;
let last = Number.NaN; let last = Number.NaN;
let steady = 0; 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 tick = () => {
const inset = this.inset(); const inset = this.inset();
steady = inset === last ? steady + 1 : 0; steady = inset === last ? steady + 1 : 0;