From cc46abc34f46895d07e59e8642ccb5248fa5f475 Mon Sep 17 00:00:00 2001 From: h Date: Sat, 5 Sep 2026 00:20:33 +0200 Subject: [PATCH] fix(view,panel): the composer clears the navbar and the keyboard, panel opens on the master The inset only knew the desktop status bar, so on a phone the composer sat under the navbar - which paints over a leaf instead of shortening it - and the software keyboard hid it outright. Every bottom bar is measured now, and the keyboard is read off `visualViewport`: it shrinks the visible area without resizing the layout, so no workspace event fires and the panel had no way to know. Focus re-measures once iOS has finished animating the keys in. The panel asks the shell to fall back to the latest master, so opening it on a phone lands in the day's thread rather than on a prompt. The preview fixtures anchor at midday instead of the wall clock: spread over hours, a run just after midnight pushed half of them into yesterday and emptied the "Today" group the switcher smoke checks. --- preview/fake-client.ts | 7 ++++- src/ui/app.svelte | 1 + src/view.ts | 61 +++++++++++++++++++++++++++++++++++------- tests/smoke.mjs | 17 ++++++++++++ 4 files changed, 76 insertions(+), 10 deletions(-) diff --git a/preview/fake-client.ts b/preview/fake-client.ts index 6893b9c..3d8ea13 100644 --- a/preview/fake-client.ts +++ b/preview/fake-client.ts @@ -8,7 +8,12 @@ import type { const MINUTE = 60_000; const HOUR = 60 * MINUTE; -const now = Date.now(); +// Anchored inside the day rather than at the wall clock: the fixtures are +// spread over hours, and a run just after midnight would push half of them +// into yesterday and empty the "Today" group the rail is checked against. +const wall = new Date(); +const now = + wall.getHours() < 12 ? new Date(wall).setHours(12, 0, 0, 0) : wall.getTime(); const ago = (ms: number) => new Date(now - ms).toISOString(); export const MASTER = "c1master0000"; diff --git a/src/ui/app.svelte b/src/ui/app.svelte index 0a810b4..7735cd8 100644 --- a/src/ui/app.svelte +++ b/src/ui/app.svelte @@ -58,6 +58,7 @@ host.openNote?.(`мета/бобер/${path}`)} showFollow diff --git a/src/view.ts b/src/view.ts index b641de3..346b7ca 100644 --- a/src/view.ts +++ b/src/view.ts @@ -7,6 +7,13 @@ 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"]; + +// iOS animates the keyboard in; the viewport is only honest once it is done. +const KEYBOARD_SETTLE_MS = 350; + interface SavedState { follow?: unknown; selected?: unknown; @@ -79,6 +86,23 @@ export class PanelView extends ItemView { this.registerEvent( this.app.workspace.on("layout-change", () => this.inset()) ); + // 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(); + vv.addEventListener("resize", onViewport); + vv.addEventListener("scroll", onViewport); + this.register(() => { + vv.removeEventListener("resize", onViewport); + 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); + }); this.follow(this.app.workspace.getActiveFile()); this.plugin.views.add(this); this.mountApp(); @@ -86,18 +110,37 @@ export class PanelView extends ItemView { return Promise.resolve(); } - // Obsidian's status bar floats over the bottom-right of the workspace; a - // leaf that reaches down there gets padding so the composer stays clear. + // 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 { - const bar = document.querySelector(".status-bar"); const box = this.contentEl.getBoundingClientRect(); - let overlap = 0; - if (bar && box.height > 0) { - const b = bar.getBoundingClientRect(); - const crosses = b.left < box.right && b.right > box.left; - overlap = crosses ? Math.max(0, box.bottom - b.top) : 0; + if (box.height <= 0) { + return; } - this.contentEl.style.setProperty("--beaver-inset-bottom", `${overlap}px`); + let overlap = 0; + for (const bar of BOTTOM_BARS) { + const node = document.querySelector(bar); + if (!node || node.offsetParent === null) { + continue; + } + const b = node.getBoundingClientRect(); + if (b.height > 0 && b.left < box.right && b.right > box.left) { + overlap = Math.max(overlap, box.bottom - b.top); + } + } + const vv = window.visualViewport; + if (vv) { + // The keyboard eats the bottom of the window; anything of this leaf + // below that line is unreachable until it goes away. + 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` + ); } async onClose(): Promise { diff --git a/tests/smoke.mjs b/tests/smoke.mjs index d447058..608a024 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -191,6 +191,23 @@ check( text().includes("Xiaomi Mi Blaster") ); +// Nothing picked - the panel just opened on a phone - lands on the master +// rather than on a prompt to pick something. +state.selected = null; +flushSync(); +await settle(); +flushSync(); +check( + "with nothing picked it falls back to the master", + state.selected === MASTER, + String(state.selected) +); +state.selected = BRANCH_BLASTER; +flushSync(); +await settle(); +flushSync(); +check("an explicit pick still wins", state.selected === BRANCH_BLASTER); + const composer = target.querySelector("textarea"); check("composer is there", composer !== null); if (composer) {