diff --git a/src/lib/inset.ts b/src/lib/inset.ts new file mode 100644 index 0000000..ba53737 --- /dev/null +++ b/src/lib/inset.ts @@ -0,0 +1,54 @@ +/** + * How much of the view's bottom is covered by something the operator cannot + * move: Obsidian's desktop status bar, a phone's navbar (which paints over a + * leaf instead of 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; +} + +/** Bars that float over a leaf rather than making room for it. */ +export const BOTTOM_BARS = [ + ".status-bar", + ".mobile-navbar", + ".mobile-toolbar", +] as const; + +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 beside 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) { + // While the keyboard is up the navbar is behind it: the deeper of the two + // is the inset, and summing them would push the view twice as far. + overlap = Math.max(overlap, leaf.bottom - (visible.offsetTop + visible.height)); + } + return Math.max(0, Math.round(overlap)); +} diff --git a/src/lib/place.ts b/src/lib/place.ts index 26450f4..14274b1 100644 --- a/src/lib/place.ts +++ b/src/lib/place.ts @@ -24,13 +24,35 @@ export interface Viewport { /** * What is actually visible, which on a phone is not the window: the software * keyboard shrinks the visual viewport and leaves `innerHeight` alone, so a - * panel placed against the window lands behind the keys. + * panel placed against the window lands behind the keys. The navbar is not in + * the viewport at all - it floats over it - so it is subtracted by hand. */ export function viewport(win: Window = window): Viewport { const vv = win.visualViewport; - return vv + const base: Viewport = vv ? { width: vv.width, height: vv.height, top: vv.offsetTop, left: vv.offsetLeft } : { width: win.innerWidth, height: win.innerHeight }; + return { ...base, height: base.height - floating(win, base) }; +} + +/** + * How much of the bottom of `view` a floating bar covers. Obsidian's phone + * navbar paints over every leaf, so a panel that ends under it cannot be + * reached even though it is inside the viewport. + */ +function floating(win: Window, view: Viewport): number { + const doc = win.document; + const bottom = (view.top ?? 0) + view.height; + let covered = 0; + for (const selector of [".mobile-navbar", ".mobile-toolbar", ".status-bar"]) { + const node = doc.querySelector(selector) as HTMLElement | null; + if (!node || node.offsetParent === null) continue; + const box = node.getBoundingClientRect(); + if (box.height > 0 && box.top < bottom) { + covered = Math.max(covered, bottom - box.top); + } + } + return Math.min(covered, Math.max(0, view.height - 120)); } /** The tallest a panel may be here before it has to scroll its own body. */ diff --git a/src/tailwind.css b/src/tailwind.css index 6c49ae2..97224d7 100644 --- a/src/tailwind.css +++ b/src/tailwind.css @@ -102,7 +102,12 @@ } .bcal-root { + box-sizing: border-box; height: 100%; + /* The phone's navbar floats over the leaf rather than shortening it, so the + last row of the list would sit underneath it. The view measures how much + and sets this; on a desktop it stays 0. */ + padding-bottom: var(--bcal-inset-bottom, 0px); font-family: var(--font-interface); font-size: var(--text-sm); color: var(--foreground); diff --git a/src/view.ts b/src/view.ts index 053a9fb..5a64c58 100644 --- a/src/view.ts +++ b/src/view.ts @@ -1,13 +1,20 @@ import { ItemView } from "obsidian"; import type { WorkspaceLeaf } from "obsidian"; import { mount, unmount } from "svelte"; +import { BOTTOM_BARS, insetFor } from "./lib/inset"; +import type { Rect } from "./lib/inset"; import App from "./ui/app.svelte"; import type BeaverCalendarPlugin from "./main"; export const VIEW_TYPE_BOARDS = "beaver-calendar-boards"; +// 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; + export class BoardsView extends ItemView { private root: Record | null = null; + private settling: number | null = null; constructor( leaf: WorkspaceLeaf, @@ -51,6 +58,25 @@ export class BoardsView extends ItemView { }), ); + this.registerEvent(this.app.workspace.on("resize", () => this.settle())); + this.registerEvent( + this.app.workspace.on("layout-change", () => this.settle()), + ); + // The keyboard shrinks the visual viewport without resizing the layout, so + // no workspace event fires - and in a sidedock none fires at all. + const vv = window.visualViewport; + if (vv) { + const onViewport = () => this.settle(); + vv.addEventListener("resize", onViewport); + vv.addEventListener("scroll", onViewport); + this.register(() => { + vv.removeEventListener("resize", onViewport); + vv.removeEventListener("scroll", onViewport); + }); + } + this.registerDomEvent(this.contentEl, "focusin", () => this.settle()); + this.registerDomEvent(this.contentEl, "focusout", () => this.settle()); + await this.plugin.store.reloadAll(); this.root = mount(App, { target: this.contentEl, @@ -61,6 +87,57 @@ export class BoardsView extends ItemView { component: this, }, }); + this.settle(); + } + + /** + * Re-measure until the viewport stops moving: the keyboard animates both + * ways, Obsidian hides its navbar while it is up, and a frame loop needs + * neither a duration to guess nor an event that a sidedock never sends. + */ + private settle(): void { + if (this.settling !== null) return; + let last = Number.NaN; + let steady = 0; + const tick = () => { + const inset = this.inset(); + steady = inset === last ? steady + 1 : 0; + last = inset; + if (steady >= STEADY_FRAMES) { + this.settling = null; + return; + } + this.settling = requestAnimationFrame(tick); + }; + this.settling = requestAnimationFrame(tick); + this.register(() => { + if (this.settling !== null) { + cancelAnimationFrame(this.settling); + this.settling = null; + } + }); + } + + /** Pads the view clear of the navbar and the keyboard. */ + 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(selector); + if (node && node.offsetParent !== null) { + bars.push(node.getBoundingClientRect()); + } + } + const vv = window.visualViewport; + const inset = insetFor( + box, + bars, + vv ? { height: vv.height, offsetTop: vv.offsetTop } : null, + ); + this.contentEl.style.setProperty("--bcal-inset-bottom", `${inset}px`); + return inset; } async onClose(): Promise { diff --git a/tests/inset.test.ts b/tests/inset.test.ts new file mode 100644 index 0000000..24e669c --- /dev/null +++ b/tests/inset.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { BOTTOM_BARS, insetFor } from "../src/lib/inset"; +import type { Rect } from "../src/lib/inset"; + +const rect = (over: Partial = {}): Rect => ({ + top: 0, + left: 0, + right: 390, + bottom: 780, + height: 780, + ...over, +}); + +// A phone: 780 tall, a 56px navbar floating over the bottom, 340px of keyboard. +const NAVBAR = rect({ top: 724, bottom: 780, height: 56 }); + +describe("insetFor", () => { + it("clears nothing when the view stands alone", () => { + expect(insetFor(rect(), [], null)).toBe(0); + }); + + it("clears the navbar that paints over the view", () => { + 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", () => { + // The navbar hides behind the keyboard while it is up. + 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 view, not over it", () => { + const sidedock = rect({ right: 300 }); + const statusBar = rect({ + top: 760, + bottom: 780, + height: 20, + left: 700, + right: 980, + }); + expect(insetFor(sidedock, [statusBar], null)).toBe(0); + }); + + it("measures from where the visible area starts", () => { + expect(insetFor(rect(), [], { height: 440, offsetTop: 100 })).toBe(240); + }); + + it("says nothing about a view with no height yet", () => { + expect(insetFor(rect({ height: 0, bottom: 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); + }); + + it("names the bars that float rather than make room", () => { + expect(BOTTOM_BARS).toContain(".mobile-navbar"); + expect(BOTTOM_BARS).toContain(".status-bar"); + }); +}); diff --git a/tests/place.test.ts b/tests/place.test.ts index a0ae705..03223cc 100644 --- a/tests/place.test.ts +++ b/tests/place.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { onScreen, place, roomFor } from "../src/lib/place"; +import { onScreen, place, roomFor, viewport } from "../src/lib/place"; import type { Box } from "../src/lib/place"; const VIEW = { width: 1280, height: 800 }; @@ -95,3 +95,41 @@ describe("roomFor", () => { expect(roomFor({ width: 390, height: 60 })).toBeGreaterThan(100); }); }); + +// Obsidian's phone navbar is not part of the visual viewport - it floats over +// it - so a panel sized to the viewport still ends up underneath it. +describe("viewport minus the floating navbar", () => { + function fakeWin(navbarTop: number | null, height = 780): Window { + const bar = { + getBoundingClientRect: () => ({ + top: navbarTop ?? 0, + bottom: height, + height: height - (navbarTop ?? 0), + left: 0, + right: 390, + }), + offsetParent: {}, + }; + return { + innerWidth: 390, + innerHeight: height, + visualViewport: { width: 390, height, offsetTop: 0, offsetLeft: 0 }, + document: { + querySelector: (selector: string) => + selector === ".mobile-navbar" && navbarTop !== null ? bar : null, + }, + } as unknown as Window; + } + + it("takes the navbar off the bottom", () => { + expect(viewport(fakeWin(724)).height).toBe(724); + }); + + it("leaves the height alone with no navbar in the way", () => { + expect(viewport(fakeWin(null)).height).toBe(780); + }); + + it("still leaves a usable strip if a bar somehow covers everything", () => { + expect(viewport(fakeWin(0)).height).toBeGreaterThanOrEqual(120); + }); +});