diff --git a/src/gui/keepFocusedFieldInView.ts b/src/gui/keepFocusedFieldInView.ts new file mode 100644 index 00000000..40c02717 --- /dev/null +++ b/src/gui/keepFocusedFieldInView.ts @@ -0,0 +1,20 @@ +import type { Plugin } from "obsidian"; + +/** + * On a phone a QuickAdd dialog shrinks to the space above the keyboard + * (styles.css), which can leave the field being typed in below its scroll + * area, under a pinned footer. Obsidian's own keyboard scroll measures the + * document selection, which for an is not the field. Once the keyboard + * is up, scroll the focused field back into view. + */ +export function keepFocusedFieldInView(plugin: Plugin): void { + const onKeyboardShown = () => { + const field = activeDocument.activeElement; + if (field?.instanceOf(HTMLElement) && field.closest(".quickAddModal")) { + field.scrollIntoView({ block: "nearest" }); + } + }; + // Capacitor's Keyboard plugin dispatches these on window in the mobile app. + window.addEventListener("keyboardDidShow", onKeyboardShown); + plugin.register(() => window.removeEventListener("keyboardDidShow", onKeyboardShown)); +} diff --git a/src/main.ts b/src/main.ts index a756dcaf..5cd5a05c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -57,6 +57,7 @@ import { setQuickAddInstance } from "./quickAddInstance"; import { registerQuickAddUri } from "./uri/registerQuickAddUri"; import { registerCoreCommands } from "./plugin/registerCoreCommands"; import { scheduleStartupModelSync } from "./ai/startupModelSync"; +import { keepFocusedFieldInView } from "./gui/keepFocusedFieldInView"; // The settingsStore subscriber fires on every store change — including high-frequency // ones like folder collapse toggles. Coalesce those full-settings disk writes into one @@ -152,6 +153,7 @@ export default class QuickAdd extends Plugin { // with nothing but a console line. Now it reports through the same channel as // every other failure (#1576). registerUnhandledRejectionReporter(this); + keepFocusedFieldInView(this); if (this.settings.enableRibbonIcon) { this.addRibbonIcon("file-plus", "QuickAdd", () => { diff --git a/src/styles.css b/src/styles.css index cb739104..5c02a443 100644 --- a/src/styles.css +++ b/src/styles.css @@ -166,9 +166,31 @@ padding-right: var(--safe-area-inset-right); } -/* Keep the phone footer's buttons clear of the home indicator. */ +/* Keep the phone footer's buttons clear of the home indicator. While the + keyboard is up Obsidian sets the inset to the keyboard's height, which the + container below already leaves out. */ .is-phone .quickAddModal .modal > .qa-modal-footer { - padding-bottom: max(var(--size-4-4), var(--safe-area-inset-bottom)); + padding-bottom: max(var(--size-4-4), calc(var(--safe-area-inset-bottom) - var(--keyboard-height))); +} + +/* On a phone the keyboard covers the bottom of the screen, so a dialog sits in + the space above it, below the status bar, with its buttons and the field + being typed in visible. Core's phone height is 100vh minus the top inset; + with the keyboard down all three lines change nothing. */ +.is-phone .modal-container.quickAddModal { + --dialog-max-height: calc(100vh - var(--keyboard-height) - var(--safe-area-inset-top)); + bottom: var(--keyboard-height); + padding-top: min(var(--keyboard-height), var(--safe-area-inset-top)); +} + +/* A prompt taller than that space scrolls; its buttons stay in view. */ +.is-phone .quickAddModal .modal-content .qa-prompt-actions { + position: sticky; + bottom: calc(-1 * var(--size-4-4)); + /* Covers the content's bottom padding, so the layout is unchanged. */ + padding-bottom: var(--size-4-4); + margin-bottom: calc(-1 * var(--size-4-4)); + background: var(--modal-background); } .quickAddModal.qa-choice-builder .qa-builder-footer { @@ -1468,7 +1490,8 @@ width: min(42rem, calc(100vw - var(--size-4-8))); min-width: min(35rem, calc(100vw - var(--size-4-8))); max-width: calc(100vw - var(--size-4-8)); - max-height: min(80vh, 48rem); + /* --dialog-max-height leaves out a phone's keyboard (desktop: 85vh). */ + max-height: min(80vh, 48rem, var(--dialog-max-height)); overflow: hidden; } diff --git a/tests/e2e/phone-keyboard.test.ts b/tests/e2e/phone-keyboard.test.ts new file mode 100644 index 00000000..c0b63fd5 --- /dev/null +++ b/tests/e2e/phone-keyboard.test.ts @@ -0,0 +1,106 @@ +import { afterEach, expect, it } from "vitest"; +import { MacroChoice } from "../../src/types/choices/MacroChoice"; +import type IChoice from "../../src/types/choices/IChoice"; +import { createQuickAddE2EHarness } from "./e2eVault"; +import { jsLiteral, POLL_OPTS, waitForElement } from "./uiHelpers"; + +const getContext = createQuickAddE2EHarness("phone-keyboard"); + +// #2132: on a phone the keyboard covered the lower part of QuickAdd dialogs: +// the date prompt's Ok, the multi-select's Done, and the macro builder's field +// under its pinned footer. Obsidian sets --keyboard-height while the keyboard +// is up; the dialog now sits in the space above it. +const KEYBOARD = 345; + +// Settings shows its tab list instead of a page on a phone, so the macro test +// emulates the phone once the builder is open. +const emulatePhoneWithKeyboard = () => getContext().obsidian.dev.evalJson(`(() => { + window.__qaPhoneClasses = document.body.className; + document.body.classList.remove("is-tablet"); + document.body.classList.add("is-mobile", "is-phone"); + document.documentElement.style.setProperty("--keyboard-height", "${KEYBOARD}px"); + return true; + })()`); + +afterEach(async () => { + const { obsidian } = getContext(); + await obsidian.dev.evalJson(`(() => { + if (window.__qaPhoneClasses !== undefined) document.body.className = window.__qaPhoneClasses; + delete window.__qaPhoneClasses; + document.documentElement.style.removeProperty("--keyboard-height"); + for (const container of [...document.querySelectorAll(".modal-container")].reverse()) { + const close = [...container.querySelectorAll("button")].find((b) => /^(Cancel|Done)$/.test(b.textContent.trim())); + (close ?? container.querySelector(".modal-close-button"))?.click(); + } + app.setting.close(); + return true; + })()`); + await expect.poll(() => obsidian.dev.evalJson( + 'document.querySelectorAll(".modal-container").length', + ), POLL_OPTS).toBe(0); +}); + +const aboveKeyboard = (selector: string) => `(() => { + const el = [...document.querySelectorAll(".modal-container")].pop().querySelector(${jsLiteral(selector)}); + const rect = el.getBoundingClientRect(); + return rect.top >= 0 && rect.bottom <= innerHeight - ${KEYBOARD}; +})()`; + +it("keeps the date prompt's field and Ok above the keyboard", async () => { + const { obsidian } = getContext(); + await emulatePhoneWithKeyboard(); + await obsidian.dev.evalJson(`(() => { + app.plugins.plugins.quickadd.api.format("{{VDATE:due,YYYY-MM-DD}}").catch(() => {}); + return true; + })()`); + await waitForElement(obsidian, ".qaDatePrompt .qa-date-picker"); + expect(await obsidian.dev.evalJson(aboveKeyboard(".qa-vdate-input"))).toBe(true); + expect(await obsidian.dev.evalJson(aboveKeyboard(".qa-prompt-actions-primary button.mod-cta"))).toBe(true); +}); + +// Enough options that the dialog is taller than the space above the keyboard. +// The desktop window is wider than 540px, as a phone is in landscape, so the +// multi-select's wide-screen height cap applies. +const OPTIONS = Array.from({ length: 20 }, (_, i) => `Option ${i + 1}`).join(","); + +it("keeps the multi-select's Done above the keyboard", async () => { + const { obsidian } = getContext(); + await emulatePhoneWithKeyboard(); + await obsidian.dev.evalJson(`(() => { + app.plugins.plugins.quickadd.api.format("{{VALUE:${OPTIONS}|multi}}").catch(() => {}); + return true; + })()`); + await waitForElement(obsidian, ".qaMultiSuggester .qa-multi-actions"); + expect(await obsidian.dev.evalJson(aboveKeyboard(".qa-multi-actions button.mod-cta"))).toBe(true); +}); + +it("brings the macro builder's focused field out from under its footer once the keyboard is up", async () => { + const { obsidian, plugin } = getContext(); + const macro = new MacroChoice("Keyboard macro"); + await plugin.data<{ choices: IChoice[] }>().patch((data) => { + data.choices = [macro]; + }); + await plugin.reload({ waitUntilReady: true }); + await obsidian.dev.evalJson("app.setting.open(); app.setting.openTabById('quickadd'); true"); + await waitForElement(obsidian, '[aria-label="Configure Keyboard macro"]'); + await obsidian.dev.evalJson(`document.querySelector('[aria-label="Configure Keyboard macro"]').click(), true`); + await waitForElement(obsidian, ".macroBuilder .qa-modal-footer"); + await emulatePhoneWithKeyboard(); + + const coveredByFooter = `(() => { + const modal = document.querySelector(".macroBuilder"); + const field = [...modal.querySelectorAll("input")].find((input) => input.placeholder.startsWith("Start typing script")); + return field.getBoundingClientRect().bottom > modal.querySelector(".qa-modal-footer").getBoundingClientRect().top; + })()`; + // The field sits low in the builder: with the keyboard taking the bottom of + // the screen it starts under the footer, as on a phone. + expect(await obsidian.dev.evalJson(`(() => { + const modal = document.querySelector(".macroBuilder"); + modal.querySelector(".modal-content").scrollTop = 0; + [...modal.querySelectorAll("input")].find((input) => input.placeholder.startsWith("Start typing script")).focus({ preventScroll: true }); + return ${coveredByFooter}; + })()`)).toBe(true); + + await obsidian.dev.evalJson('window.dispatchEvent(new Event("keyboardDidShow")), true'); + expect(await obsidian.dev.evalJson(coveredByFooter)).toBe(false); +});