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.
This commit is contained in:
+51
-18
@@ -11,8 +11,9 @@ export const VIEW_TYPE_PANEL = "beaver-panel";
|
|||||||
// sits under every view, including the one filling the screen.
|
// sits under every view, including the one filling the screen.
|
||||||
const BOTTOM_BARS = [".status-bar", ".mobile-navbar", ".mobile-toolbar"];
|
const BOTTOM_BARS = [".status-bar", ".mobile-navbar", ".mobile-toolbar"];
|
||||||
|
|
||||||
// iOS animates the keyboard in; the viewport is only honest once it is done.
|
// The keyboard and the navbar both animate. Rather than guess how long that
|
||||||
const KEYBOARD_SETTLE_MS = 350;
|
// takes, the inset is re-measured every frame until the reading holds still.
|
||||||
|
const STEADY_FRAMES = 4;
|
||||||
|
|
||||||
interface SavedState {
|
interface SavedState {
|
||||||
follow?: unknown;
|
follow?: unknown;
|
||||||
@@ -26,6 +27,7 @@ export class PanelView extends ItemView {
|
|||||||
readonly state = new PanelState();
|
readonly state = new PanelState();
|
||||||
private readonly plugin: BeaverPlugin;
|
private readonly plugin: BeaverPlugin;
|
||||||
private root: Record<string, unknown> | null = null;
|
private root: Record<string, unknown> | null = null;
|
||||||
|
private settling: number | null = null;
|
||||||
|
|
||||||
constructor(leaf: WorkspaceLeaf, plugin: BeaverPlugin) {
|
constructor(leaf: WorkspaceLeaf, plugin: BeaverPlugin) {
|
||||||
super(leaf);
|
super(leaf);
|
||||||
@@ -82,15 +84,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.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
|
// The keyboard resizes the visual viewport only: no workspace event
|
||||||
// fires, so the composer would stay behind it without these two.
|
// fires, so the composer would stay behind it without these two.
|
||||||
const vv = window.visualViewport;
|
const vv = window.visualViewport;
|
||||||
if (vv) {
|
if (vv) {
|
||||||
const onViewport = () => this.inset();
|
const onViewport = () => this.settle();
|
||||||
vv.addEventListener("resize", onViewport);
|
vv.addEventListener("resize", onViewport);
|
||||||
vv.addEventListener("scroll", onViewport);
|
vv.addEventListener("scroll", onViewport);
|
||||||
this.register(() => {
|
this.register(() => {
|
||||||
@@ -98,26 +100,58 @@ export class PanelView extends ItemView {
|
|||||||
vv.removeEventListener("scroll", onViewport);
|
vv.removeEventListener("scroll", onViewport);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Focusing the composer opens the keyboard a beat before the viewport
|
// Focus opens the keyboard, blur closes it; both animate, and on the way
|
||||||
// settles; re-measure once it has.
|
// out the navbar comes back, so the leaf has to be re-measured after.
|
||||||
this.registerDomEvent(this.contentEl, "focusin", () => {
|
this.registerDomEvent(this.contentEl, "focusin", () => this.settle());
|
||||||
window.setTimeout(() => this.inset(), KEYBOARD_SETTLE_MS);
|
this.registerDomEvent(this.contentEl, "focusout", () => this.settle());
|
||||||
});
|
|
||||||
this.follow(this.app.workspace.getActiveFile());
|
this.follow(this.app.workspace.getActiveFile());
|
||||||
this.plugin.views.add(this);
|
this.plugin.views.add(this);
|
||||||
this.mountApp();
|
this.mountApp();
|
||||||
requestAnimationFrame(() => this.inset());
|
this.settle();
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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;
|
||||||
|
}
|
||||||
|
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
|
// Whatever floats over the bottom of this leaf, measured rather than
|
||||||
// guessed: the desktop status bar, the phone's navbar (which the workspace
|
// 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
|
// does not subtract from a leaf's height), and the software keyboard, which
|
||||||
// shrinks the visual viewport without resizing the layout at all.
|
// shrinks the visual viewport without resizing the layout at all. Returns
|
||||||
private inset(): void {
|
// the inset it wrote, so the settle loop can tell when it stops changing.
|
||||||
|
private inset(): number {
|
||||||
const box = this.contentEl.getBoundingClientRect();
|
const box = this.contentEl.getBoundingClientRect();
|
||||||
if (box.height <= 0) {
|
if (box.height <= 0) {
|
||||||
return;
|
return Number.NaN;
|
||||||
}
|
}
|
||||||
let overlap = 0;
|
let overlap = 0;
|
||||||
for (const bar of BOTTOM_BARS) {
|
for (const bar of BOTTOM_BARS) {
|
||||||
@@ -137,10 +171,9 @@ export class PanelView extends ItemView {
|
|||||||
const floor = vv.offsetTop + vv.height;
|
const floor = vv.offsetTop + vv.height;
|
||||||
overlap = Math.max(overlap, box.bottom - floor);
|
overlap = Math.max(overlap, box.bottom - floor);
|
||||||
}
|
}
|
||||||
this.contentEl.style.setProperty(
|
const inset = Math.max(0, Math.round(overlap));
|
||||||
"--beaver-inset-bottom",
|
this.contentEl.style.setProperty("--beaver-inset-bottom", `${inset}px`);
|
||||||
`${Math.max(0, Math.round(overlap))}px`
|
return inset;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async onClose(): Promise<void> {
|
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();
|
||||||
Reference in New Issue
Block a user