Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/gui/GenericSuggester/SuggesterModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -83,6 +84,11 @@ export class SuggesterModal<T> extends FuzzySuggestModal<T> {
}
}

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;
Expand Down
7 changes: 6 additions & 1 deletion src/gui/suggesters/choiceSuggester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -288,6 +288,11 @@ export default class ChoiceSuggester extends FuzzySuggestModal<IChoice> {
}
}

onOpen(): void {
super.onOpen();
centerSuggestModal(this.modalEl);
}

onClose(): void {
super.onClose();
this.markdownComponent.unload();
Expand Down
15 changes: 15 additions & 0 deletions src/gui/suggesters/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
14 changes: 14 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,20 @@
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.
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: 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)
);
}

/* 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
Expand Down
58 changes: 58 additions & 0 deletions tests/e2e/picker-position.test.ts
Original file line number Diff line number Diff line change
@@ -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<Layout>(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<number>(
"document.querySelectorAll('.prompt .suggestion-item').length",
), POLL_OPTS).toBe(1);
const filtered = await obsidian.dev.evalJson<Layout>(measure(".prompt"));
expect(filtered.inputTop).toBe(picker.inputTop);

await pressKey(obsidian, "Enter");
await waitForElement(obsidian, ".qaInputPrompt .modal");
const valuePrompt = await obsidian.dev.evalJson<Layout>(measure(".qaInputPrompt .modal"));
expect(Math.abs(valuePrompt.centerY - picker.centerY)).toBeLessThan(2);
await pressKey(obsidian, "Escape");
});
Loading