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.
This commit is contained in:
hh
2026-09-05 00:49:54 +02:00
parent 413d68fc87
commit 70cf985aa1
3 changed files with 128 additions and 20 deletions
+47
View File
@@ -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));
}
+14 -20
View File
@@ -1,6 +1,8 @@
import type { ViewStateResult, WorkspaceLeaf } from "obsidian"; import type { ViewStateResult, WorkspaceLeaf } from "obsidian";
import { ItemView, TFile } from "obsidian"; import { ItemView, TFile } from "obsidian";
import { mount, unmount } from "svelte"; import { mount, unmount } from "svelte";
import type { Rect } from "./inset";
import { insetFor } from "./inset";
import type BeaverPlugin from "./main"; import type BeaverPlugin from "./main";
import { PanelState } from "./state.svelte"; import { PanelState } from "./state.svelte";
import App from "./ui/app.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 // Whatever floats over the bottom of this leaf, measured rather than
// guessed: the desktop status bar, the phone's navbar (which the workspace // guessed. Returns the inset it wrote, so the settle loop can tell when the
// does not subtract from a leaf's height), and the software keyboard, which // reading stops changing; the arithmetic itself lives in ``inset.ts``.
// 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.
private inset(): number { private inset(): number {
const box = this.contentEl.getBoundingClientRect(); const box = this.contentEl.getBoundingClientRect();
if (box.height <= 0) { if (box.height <= 0) {
return Number.NaN; return Number.NaN;
} }
let overlap = 0; const bars: Rect[] = [];
for (const bar of BOTTOM_BARS) { for (const selector of BOTTOM_BARS) {
const node = document.querySelector<HTMLElement>(bar); const node = document.querySelector<HTMLElement>(selector);
if (!node || node.offsetParent === null) { if (node && node.offsetParent !== null) {
continue; bars.push(node.getBoundingClientRect());
}
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; const vv = window.visualViewport;
if (vv) { const inset = insetFor(
// The keyboard eats the bottom of the window; anything of this leaf box,
// below that line is unreachable until it goes away. bars,
const floor = vv.offsetTop + vv.height; vv ? { height: vv.height, offsetTop: vv.offsetTop } : null
overlap = Math.max(overlap, box.bottom - floor); );
}
const inset = Math.max(0, Math.round(overlap));
this.contentEl.style.setProperty("--beaver-inset-bottom", `${inset}px`); this.contentEl.style.setProperty("--beaver-inset-bottom", `${inset}px`);
return inset; return inset;
} }
+67
View File
@@ -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> = {}): 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);
});
});