From 29bff405eb270b75eea1e29b4e5b64aa270b07e9 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sat, 26 Sep 2026 00:11:05 +0200 Subject: [PATCH 1/2] fix(gui): open run-time pickers vertically centered like input prompts Core anchors suggest modals 80px from the top while QuickAdd's input prompts are centered, so a Multi picker followed by a VALUE prompt jumped across the screen. The choice picker and generic suggester now open centered, pinned to their height at open so filtering doesn't move the input. Fixes #1796 --- src/gui/GenericSuggester/SuggesterModal.ts | 6 +++ src/gui/suggesters/choiceSuggester.ts | 7 ++- src/gui/suggesters/utils.ts | 15 ++++++ src/styles.css | 13 +++++ tests/e2e/picker-position.test.ts | 58 ++++++++++++++++++++++ 5 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/picker-position.test.ts diff --git a/src/gui/GenericSuggester/SuggesterModal.ts b/src/gui/GenericSuggester/SuggesterModal.ts index 633b0b201..91c57a45b 100644 --- a/src/gui/GenericSuggester/SuggesterModal.ts +++ b/src/gui/GenericSuggester/SuggesterModal.ts @@ -2,6 +2,7 @@ import { FuzzySuggestModal } from "obsidian"; import type { FuzzyMatch, App } from "obsidian"; import { log } from "src/logger/logManager"; import { + centerSuggestModal, createRenderFallbackWarner, installSkipAffordance, normalizeDisplayItem, @@ -83,6 +84,11 @@ export class SuggesterModal extends FuzzySuggestModal { } } + onOpen(): void { + super.onOpen(); + centerSuggestModal(this.modalEl); + } + getItemText(item: T): string { const index = this.items.indexOf(item); const displayItem = index >= 0 ? this.displayItems[index] : undefined; diff --git a/src/gui/suggesters/choiceSuggester.ts b/src/gui/suggesters/choiceSuggester.ts index f247af85f..74330dd43 100644 --- a/src/gui/suggesters/choiceSuggester.ts +++ b/src/gui/suggesters/choiceSuggester.ts @@ -13,7 +13,7 @@ import { MultiChoice } from "../../types/choices/MultiChoice"; import type IMultiChoice from "../../types/choices/IMultiChoice"; import type QuickAdd from "../../main"; import type { IChoiceExecutor } from "../../IChoiceExecutor"; -import { createRenderFallbackWarner } from "./utils"; +import { centerSuggestModal, createRenderFallbackWarner } from "./utils"; import { isCancellationError, reportUnlessCancelled, toError } from "../../utils/errorUtils"; import { promptCancelled } from "../../errors/UserCancelError"; import { settingsStore } from "../../settingsStore"; @@ -288,6 +288,11 @@ export default class ChoiceSuggester extends FuzzySuggestModal { } } + onOpen(): void { + super.onOpen(); + centerSuggestModal(this.modalEl); + } + onClose(): void { super.onClose(); this.markdownComponent.unload(); diff --git a/src/gui/suggesters/utils.ts b/src/gui/suggesters/utils.ts index fd90b3e6f..e4790401d 100644 --- a/src/gui/suggesters/utils.ts +++ b/src/gui/suggesters/utils.ts @@ -100,6 +100,21 @@ export function createRenderFallbackWarner( }; } +/** + * Opens a suggest modal vertically centered, where QuickAdd's input prompts + * open, instead of at core's fixed spot near the top, so a run doesn't jump + * between the two (#1796). Centering uses the height at open, so filtering + * shrinks the list without moving the input. Call after `super.onOpen()`, + * which renders the initial suggestions. + */ +export function centerSuggestModal(modalEl: HTMLElement): void { + modalEl.style.setProperty( + "--qa-prompt-open-height", + `${modalEl.offsetHeight}px`, + ); + modalEl.addClass("qa-centered-prompt"); +} + export function normalizeQuery(value: unknown): string { return normalizeDisplayItem(value); } diff --git a/src/styles.css b/src/styles.css index a8b701d24..977f63b72 100644 --- a/src/styles.css +++ b/src/styles.css @@ -181,6 +181,19 @@ display: block; } +/* Run-time pickers open vertically centered like the input prompts around them, + instead of core's 80px from the top. The offset uses the height at open (set + in centerSuggestModal), so filtering doesn't move the input, and a list that + grows while typing (nested search) scrolls instead of running off-screen. + Mobile keeps core's layout. */ +body:not(.is-mobile) .prompt.qa-centered-prompt { + top: calc(50% - var(--qa-prompt-open-height) / 2); + max-height: min( + var(--prompt-max-height), + calc(50% + var(--qa-prompt-open-height) / 2 - 80px) + ); +} + /* Run-time prompt context: which choice is asking, and where the answer lands. One line on a roomy modal: the middle of a long path is elided in JS (elideMiddlePath) so the file name survives, and the tail is clipped here as diff --git a/tests/e2e/picker-position.test.ts b/tests/e2e/picker-position.test.ts new file mode 100644 index 000000000..d804dae59 --- /dev/null +++ b/tests/e2e/picker-position.test.ts @@ -0,0 +1,58 @@ +import { expect, it } from "vitest"; +import { MultiChoice } from "../../src/types/choices/MultiChoice"; +import { TemplateChoice } from "../../src/types/choices/TemplateChoice"; +import type IChoice from "../../src/types/choices/IChoice"; +import { createQuickAddE2EHarness, seedVaultFile } from "./e2eVault"; +import { POLL_OPTS, pressKey, typeInto, waitForElement } from "./uiHelpers"; + +const getContext = createQuickAddE2EHarness("picker-position"); + +type Layout = { centerY: number; inputTop: number; windowCenterY: number }; + +const measure = (selector: string) => `(() => { + const el = document.querySelector(${JSON.stringify(selector)}); + const box = el.getBoundingClientRect(); + return { + centerY: box.top + box.height / 2, + inputTop: el.querySelector('input').getBoundingClientRect().top, + windowCenterY: window.innerHeight / 2, + }; +})()`; + +it("opens a Multi's picker centered like the value prompt that follows it (#1796)", async () => { + const { obsidian, plugin, sandbox } = getContext(); + const templatePath = await seedVaultFile(obsidian, sandbox, "Picker template.md"); + const folder = new MultiChoice("Picker position"); + for (const name of ["AI Context", "Topic", "Person", "Blank"]) { + const template = new TemplateChoice(name); + template.templatePath = templatePath; + template.fileNameFormat = { enabled: true, format: "{{VALUE:Title}}" }; + folder.addChoice(template); + } + await plugin.data<{ choices: IChoice[] }>().patch((data) => { + data.choices = [folder]; + }); + await plugin.reload({ waitUntilReady: true }); + await obsidian.dev.evalJson(`(() => { + void app.plugins.plugins.quickadd.api.executeChoice(${JSON.stringify(folder.name)}).catch(() => {}); + return true; + })()`); + + await waitForElement(obsidian, ".prompt .suggestion-item"); + const picker = await obsidian.dev.evalJson(measure(".prompt")); + expect(Math.abs(picker.centerY - picker.windowCenterY)).toBeLessThan(2); + + // Filtering shrinks the list; the input must not move under the cursor. + await typeInto(obsidian, ".prompt input", "Topic"); + await expect.poll(() => obsidian.dev.evalJson( + "document.querySelectorAll('.prompt .suggestion-item').length", + ), POLL_OPTS).toBe(1); + const filtered = await obsidian.dev.evalJson(measure(".prompt")); + expect(filtered.inputTop).toBe(picker.inputTop); + + await pressKey(obsidian, "Enter"); + await waitForElement(obsidian, ".qaInputPrompt .modal"); + const valuePrompt = await obsidian.dev.evalJson(measure(".qaInputPrompt .modal")); + expect(Math.abs(valuePrompt.centerY - picker.centerY)).toBeLessThan(2); + await pressKey(obsidian, "Escape"); +}); From 23d661196d9f489eb48777536640446f42342c7a Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sat, 26 Sep 2026 00:14:21 +0200 Subject: [PATCH 2/2] fix(gui): keep a centered picker on-screen when the window shrinks (#1799) --- src/styles.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/styles.css b/src/styles.css index 977f63b72..5a4e040f5 100644 --- a/src/styles.css +++ b/src/styles.css @@ -185,9 +185,10 @@ instead of core's 80px from the top. The offset uses the height at open (set in centerSuggestModal), so filtering doesn't move the input, and a list that grows while typing (nested search) scrolls instead of running off-screen. - Mobile keeps core's layout. */ + Core's 80px stays the minimum, so shrinking the window can't push the input + off the top. Mobile keeps core's layout. */ body:not(.is-mobile) .prompt.qa-centered-prompt { - top: calc(50% - var(--qa-prompt-open-height) / 2); + top: max(80px, calc(50% - var(--qa-prompt-open-height) / 2)); max-height: min( var(--prompt-max-height), calc(50% + var(--qa-prompt-open-height) / 2 - 80px)