From 70cf985aa1269df7c8775965d57004477baacae6 Mon Sep 17 00:00:00 2001 From: h Date: Sat, 5 Sep 2026 00:49:54 +0200 Subject: [PATCH] refactor(inset): the bottom-inset arithmetic becomes testable, and is tested The measurement lived inside the view, where the only way to check it was to run Obsidian on a phone. It moves to a pure `insetFor(leaf, bars, visible)` against the numbers a phone reports - including the case the events hid: while the keyboard is up the navbar is behind it, so the deeper of the two is the inset and adding them would push the composer twice as far as it must go. --- src/inset.ts | 47 +++++++++++++++++++++++++++++++ src/view.ts | 34 ++++++++++------------- tests/inset.test.ts | 67 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 20 deletions(-) create mode 100644 src/inset.ts create mode 100644 tests/inset.test.ts diff --git a/src/inset.ts b/src/inset.ts new file mode 100644 index 0000000..d92b823 --- /dev/null +++ b/src/inset.ts @@ -0,0 +1,47 @@ +/** + * How much of a leaf's bottom is covered by something the operator cannot + * move: Obsidian's desktop status bar, a phone's navbar (which paints over a + * leaf rather than shortening it), and the software keyboard, which shrinks + * the visual viewport while the layout keeps its full height. + * + * Pure on purpose - the view feeds it live rectangles, the tests feed it the + * numbers a phone actually reports. + */ + +export interface Rect { + bottom: number; + height: number; + left: number; + right: number; + top: number; +} + +export interface Visible { + /** `visualViewport.height`: what is left once the keyboard is up. */ + height: number; + /** `visualViewport.offsetTop`: where the visible area starts. */ + offsetTop: number; +} + +export function insetFor( + leaf: Rect, + bars: Rect[], + visible: Visible | null +): number { + if (leaf.height <= 0) { + return 0; + } + let overlap = 0; + for (const bar of bars) { + // A bar that does not sit over this leaf horizontally covers nothing of + // it - the desktop status bar next to a left-hand sidedock, say. + if (bar.height > 0 && bar.left < leaf.right && bar.right > leaf.left) { + overlap = Math.max(overlap, leaf.bottom - bar.top); + } + } + if (visible) { + const floor = visible.offsetTop + visible.height; + overlap = Math.max(overlap, leaf.bottom - floor); + } + return Math.max(0, Math.round(overlap)); +} diff --git a/src/view.ts b/src/view.ts index 445d666..7cefbf7 100644 --- a/src/view.ts +++ b/src/view.ts @@ -1,6 +1,8 @@ import type { ViewStateResult, WorkspaceLeaf } from "obsidian"; import { ItemView, TFile } from "obsidian"; import { mount, unmount } from "svelte"; +import type { Rect } from "./inset"; +import { insetFor } from "./inset"; import type BeaverPlugin from "./main"; import { PanelState } from "./state.svelte"; import App from "./ui/app.svelte"; @@ -144,34 +146,26 @@ export class PanelView extends ItemView { } // 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. Returns - // the inset it wrote, so the settle loop can tell when it stops changing. + // guessed. Returns the inset it wrote, so the settle loop can tell when the + // reading stops changing; the arithmetic itself lives in ``inset.ts``. private inset(): number { const box = this.contentEl.getBoundingClientRect(); if (box.height <= 0) { return Number.NaN; } - 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 bars: Rect[] = []; + for (const selector of BOTTOM_BARS) { + const node = document.querySelector(selector); + if (node && node.offsetParent !== null) { + bars.push(node.getBoundingClientRect()); } } 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); - } - const inset = Math.max(0, Math.round(overlap)); + const inset = insetFor( + box, + bars, + vv ? { height: vv.height, offsetTop: vv.offsetTop } : null + ); this.contentEl.style.setProperty("--beaver-inset-bottom", `${inset}px`); return inset; } diff --git a/tests/inset.test.ts b/tests/inset.test.ts new file mode 100644 index 0000000..939a57b --- /dev/null +++ b/tests/inset.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import type { Rect } from "../src/inset"; +import { insetFor } from "../src/inset"; + +const rect = (over: Partial = {}): Rect => ({ + bottom: 780, + height: 780, + left: 0, + right: 390, + top: 0, + ...over, +}); + +// A phone: 780 tall, a 56px navbar over the bottom, 340px of keyboard. +const NAVBAR = rect({ bottom: 780, height: 56, top: 724 }); + +describe("insetFor", () => { + it("clears nothing when the leaf stands alone", () => { + expect(insetFor(rect(), [], null)).toBe(0); + }); + + it("clears the navbar that paints over the leaf", () => { + expect(insetFor(rect(), [NAVBAR], null)).toBe(56); + }); + + it("clears the keyboard, which shortens no layout", () => { + expect(insetFor(rect(), [], { height: 440, offsetTop: 0 })).toBe(340); + }); + + it("takes the deeper of the two rather than their sum", () => { + // While the keyboard is up the navbar is behind it: adding them would + // push the composer twice as far as it has to go. + expect(insetFor(rect(), [NAVBAR], { height: 440, offsetTop: 0 })).toBe(340); + }); + + it("comes back to the navbar once the keyboard goes", () => { + expect(insetFor(rect(), [NAVBAR], { height: 780, offsetTop: 0 })).toBe(56); + }); + + it("ignores a bar that sits beside the leaf, not over it", () => { + // The desktop status bar floats bottom-right; a left-hand sidedock never + // reaches it. + const sidedock = rect({ right: 300 }); + const statusBar = rect({ + bottom: 780, + height: 20, + left: 700, + right: 980, + top: 760, + }); + expect(insetFor(sidedock, [statusBar], null)).toBe(0); + }); + + it("measures from where the visible area starts, not from zero", () => { + // iOS scrolls the page up under the keyboard instead of resizing it. + expect(insetFor(rect(), [], { height: 440, offsetTop: 100 })).toBe(240); + }); + + it("says nothing about a leaf with no height yet", () => { + expect(insetFor(rect({ bottom: 0, height: 0 }), [NAVBAR], null)).toBe(0); + }); + + it("never returns a negative inset", () => { + const short = rect({ bottom: 300, height: 300 }); + expect(insetFor(short, [], { height: 780, offsetTop: 0 })).toBe(0); + }); +});