Compare commits

...
2 Commits
Author SHA1 Message Date
hh 70cf985aa1 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.
2026-09-05 00:49:54 +02:00
hh 413d68fc87 fix(view): the inset chases the keyboard by frame, and comes back when it goes
Two fixed delays into the keyboard's animation is not where it ends: the
composer read the viewport mid-slide, and on the way out - when the navbar
comes back - nothing re-measured at all, so the panel stayed pushed up or
sat back under the bar. In a sidedock drawer no resize event fires either,
so a phone's keyboard moved nothing there.

The inset is now re-measured every frame until the reading holds still, and
blur is a trigger as much as focus. `inset()` returns what it wrote so the
loop can tell it has settled rather than counting milliseconds.
2026-09-05 00:47:44 +02:00
4 changed files with 211 additions and 35 deletions

No files matched your search

+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));
}
+62 -35
View File
@@ -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";
@@ -11,8 +13,9 @@ export const VIEW_TYPE_PANEL = "beaver-panel";
// 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;
// The keyboard and the navbar both animate. Rather than guess how long that
// takes, the inset is re-measured every frame until the reading holds still.
const STEADY_FRAMES = 4;
interface SavedState {
follow?: unknown;
@@ -26,6 +29,7 @@ export class PanelView extends ItemView {
readonly state = new PanelState();
private readonly plugin: BeaverPlugin;
private root: Record<string, unknown> | null = null;
private settling: number | null = null;
constructor(leaf: WorkspaceLeaf, plugin: BeaverPlugin) {
super(leaf);
@@ -82,15 +86,15 @@ export class PanelView extends ItemView {
}
})
);
this.registerEvent(this.app.workspace.on("resize", () => this.inset()));
this.registerEvent(this.app.workspace.on("resize", () => this.settle()));
this.registerEvent(
this.app.workspace.on("layout-change", () => this.inset())
this.app.workspace.on("layout-change", () => this.settle())
);
// 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();
const onViewport = () => this.settle();
vv.addEventListener("resize", onViewport);
vv.addEventListener("scroll", onViewport);
this.register(() => {
@@ -98,49 +102,72 @@ export class PanelView extends ItemView {
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);
});
// Focus opens the keyboard, blur closes it; both animate, and on the way
// out the navbar comes back, so the leaf has to be re-measured after.
this.registerDomEvent(this.contentEl, "focusin", () => this.settle());
this.registerDomEvent(this.contentEl, "focusout", () => this.settle());
this.follow(this.app.workspace.getActiveFile());
this.plugin.views.add(this);
this.mountApp();
requestAnimationFrame(() => this.inset());
this.settle();
return Promise.resolve();
}
// 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 box = this.contentEl.getBoundingClientRect();
if (box.height <= 0) {
/**
* Re-measure until the viewport stops moving. The keyboard animates both
* ways and Obsidian hides its navbar while it is up, so a fixed delay lands
* mid-animation; a frame loop that stops once two readings agree does not
* care how long the animation takes, or whether any event fires at all -
* which is the case in a sidedock drawer.
*/
private settle(): void {
if (this.settling !== null) {
return;
}
let overlap = 0;
for (const bar of BOTTOM_BARS) {
const node = document.querySelector<HTMLElement>(bar);
if (!node || node.offsetParent === null) {
continue;
let last = Number.NaN;
let steady = 0;
const tick = () => {
const floor = this.inset();
steady = floor === last ? steady + 1 : 0;
last = floor;
if (steady >= STEADY_FRAMES) {
this.settling = null;
return;
}
const b = node.getBoundingClientRect();
if (b.height > 0 && b.left < box.right && b.right > box.left) {
overlap = Math.max(overlap, box.bottom - b.top);
this.settling = requestAnimationFrame(tick);
};
this.settling = requestAnimationFrame(tick);
this.register(() => {
if (this.settling !== null) {
cancelAnimationFrame(this.settling);
this.settling = null;
}
});
}
// Whatever floats over the bottom of this leaf, measured rather than
// 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;
}
const bars: Rect[] = [];
for (const selector of BOTTOM_BARS) {
const node = document.querySelector<HTMLElement>(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);
}
this.contentEl.style.setProperty(
"--beaver-inset-bottom",
`${Math.max(0, Math.round(overlap))}px`
const inset = insetFor(
box,
bars,
vv ? { height: vv.height, offsetTop: vv.offsetTop } : null
);
this.contentEl.style.setProperty("--beaver-inset-bottom", `${inset}px`);
return inset;
}
async onClose(): Promise<void> {
+35
View File
@@ -0,0 +1,35 @@
// Evaluate an expression inside a running Obsidian started with
// --remote-debugging-port. Usage: bun t/cdp-eval.ts <port> "<js expression>"
const [port, expression] = process.argv.slice(2);
const targets = (await (
await fetch(`http://localhost:${port}/json`)
).json()) as {
type: string;
title: string;
webSocketDebuggerUrl: string;
}[];
const page = targets.find((t) => t.type === "page");
if (!page) {
throw new Error("no page target");
}
const ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((resolve) => ws.addEventListener("open", resolve));
const reply = new Promise<string>((resolve) => {
ws.addEventListener("message", (event) => {
const data = JSON.parse(String(event.data));
if (data.id === 1) {
resolve(
JSON.stringify(data.result?.result?.value ?? data.result ?? data)
);
}
});
});
ws.send(
JSON.stringify({
id: 1,
method: "Runtime.evaluate",
params: { awaitPromise: true, expression, returnByValue: true },
})
);
console.log(await reply);
ws.close();
+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);
});
});