fix(view,place): the boards view pads clear of the phone's navbar
The panel had an inset for this; the calendar never did. Its root filled the leaf, and Obsidian's phone navbar floats over a leaf rather than shortening it, so the scroll container ran to the bottom of the screen and the last row of the list sat under the bar - unreachable. `lib/inset.ts` is the same pure measurement the panel plugin grew, with the same tests: the deeper of navbar and keyboard, never their sum, and a bar that sits beside the view covers nothing of it. The view re-measures by frame until it holds still and writes `--bcal-inset-bottom`. `viewport()` subtracts the bar too, so anchored panels and the quick look are placed against what is actually reachable instead of ending under it.
This commit is contained in:
1 parent
f7ccd21d7b
commit
f980abcc1d
6 files changed
+267
-3
No files matched your search
@@ -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));
|
||||||
|
}
|
||||||
+24
-2
@@ -24,13 +24,35 @@ export interface Viewport {
|
|||||||
/**
|
/**
|
||||||
* What is actually visible, which on a phone is not the window: the software
|
* 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
|
* 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 {
|
export function viewport(win: Window = window): Viewport {
|
||||||
const vv = win.visualViewport;
|
const vv = win.visualViewport;
|
||||||
return vv
|
const base: Viewport = vv
|
||||||
? { width: vv.width, height: vv.height, top: vv.offsetTop, left: vv.offsetLeft }
|
? { width: vv.width, height: vv.height, top: vv.offsetTop, left: vv.offsetLeft }
|
||||||
: { width: win.innerWidth, height: win.innerHeight };
|
: { 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. */
|
/** The tallest a panel may be here before it has to scroll its own body. */
|
||||||
|
|||||||
@@ -102,7 +102,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bcal-root {
|
.bcal-root {
|
||||||
|
box-sizing: border-box;
|
||||||
height: 100%;
|
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-family: var(--font-interface);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
|
|||||||
+77
@@ -1,13 +1,20 @@
|
|||||||
import { ItemView } from "obsidian";
|
import { ItemView } from "obsidian";
|
||||||
import type { WorkspaceLeaf } from "obsidian";
|
import type { WorkspaceLeaf } from "obsidian";
|
||||||
import { mount, unmount } from "svelte";
|
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 App from "./ui/app.svelte";
|
||||||
import type BeaverCalendarPlugin from "./main";
|
import type BeaverCalendarPlugin from "./main";
|
||||||
|
|
||||||
export const VIEW_TYPE_BOARDS = "beaver-calendar-boards";
|
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 {
|
export class BoardsView extends ItemView {
|
||||||
private root: Record<string, unknown> | null = null;
|
private root: Record<string, unknown> | null = null;
|
||||||
|
private settling: number | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
leaf: WorkspaceLeaf,
|
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();
|
await this.plugin.store.reloadAll();
|
||||||
this.root = mount(App, {
|
this.root = mount(App, {
|
||||||
target: this.contentEl,
|
target: this.contentEl,
|
||||||
@@ -61,6 +87,57 @@ export class BoardsView extends ItemView {
|
|||||||
component: this,
|
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<HTMLElement>(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<void> {
|
async onClose(): Promise<void> {
|
||||||
|
|||||||
@@ -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> = {}): 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+39
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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";
|
import type { Box } from "../src/lib/place";
|
||||||
|
|
||||||
const VIEW = { width: 1280, height: 800 };
|
const VIEW = { width: 1280, height: 800 };
|
||||||
@@ -95,3 +95,41 @@ describe("roomFor", () => {
|
|||||||
expect(roomFor({ width: 390, height: 60 })).toBeGreaterThan(100);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user