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); + }); +});