Compare commits
2
Commits
a04ad0094f
...
70cf985aa1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70cf985aa1 | ||
|
|
413d68fc87 |
No files matched your search
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user