From 69a9bcc0fe42fca022db2279e75a37e2d2c1664f Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 21:40:04 +0200 Subject: [PATCH 1/2] test(prompt): expect no peek over another plugin's modal (#2234) --- .../GenericInputPrompt.peek.test.ts | 32 +++++++ src/preflight/OnePageInputModal.peek.test.ts | 19 +++++ tests/e2e/peek-over-plugin-modal.test.ts | 83 +++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 tests/e2e/peek-over-plugin-modal.test.ts diff --git a/src/gui/GenericInputPrompt/GenericInputPrompt.peek.test.ts b/src/gui/GenericInputPrompt/GenericInputPrompt.peek.test.ts index 7d9f3df08..08234ae59 100644 --- a/src/gui/GenericInputPrompt/GenericInputPrompt.peek.test.ts +++ b/src/gui/GenericInputPrompt/GenericInputPrompt.peek.test.ts @@ -152,6 +152,38 @@ describe("GenericInputPrompt peek", () => { await expect(waitForClose).resolves.toBe("done"); }); + it("offers no peek over another plugin's modal", async () => { + document.body.createDiv({ cls: "modal-container" }); + + const waitForClose = GenericInputPrompt.Prompt( + fakeApp as never, + "Name", + undefined, + undefined, + undefined, + { allowPeek: true }, + ); + (document.querySelector(".qa-peek-button") as HTMLButtonElement).click(); + expect(PromptPeekSession.isPeeking()).toBe(false); + + const input = document.querySelector( + ".qaInputPrompt input", + ) as HTMLInputElement; + input.dispatchEvent( + new KeyboardEvent("keydown", { + key: "E", + shiftKey: true, + ctrlKey: true, + }), + ); + expect(PromptPeekSession.isPeeking()).toBe(false); + + input.value = "Ada"; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + await expect(waitForClose).resolves.toBe("Ada"); + }); + it("keeps Ok ahead of Peek in the tab order", async () => { const waitForClose = GenericInputPrompt.Prompt( fakeApp as never, diff --git a/src/preflight/OnePageInputModal.peek.test.ts b/src/preflight/OnePageInputModal.peek.test.ts index a9c15fee5..37923a001 100644 --- a/src/preflight/OnePageInputModal.peek.test.ts +++ b/src/preflight/OnePageInputModal.peek.test.ts @@ -154,6 +154,25 @@ describe("OnePageInputModal peek", () => { await expect(modal.waitForClose).rejects.toBeInstanceOf(UserCancelError); }); + it("offers no peek over another plugin's modal", async () => { + document.body.createDiv({ cls: "modal-container" }); + + const modal = new OnePageInputModal(fakeApp as never, [ + { id: "title", label: "Title", type: "text" }, + ]); + findButton(modal.modalEl, "Peek at note").click(); + expect(PromptPeekSession.isPeeking()).toBe(false); + + const scope = modal.scope as unknown as { + trigger: (key: string) => unknown; + }; + scope.trigger("E"); + expect(PromptPeekSession.isPeeking()).toBe(false); + + modal.close(); + await expect(modal.waitForClose).rejects.toBeInstanceOf(UserCancelError); + }); + it("inserts the editor selection into the last focused text field", async () => { fakeApp = makeFakeApp("selected"); setQuickAddInstance({ diff --git a/tests/e2e/peek-over-plugin-modal.test.ts b/tests/e2e/peek-over-plugin-modal.test.ts new file mode 100644 index 000000000..2d5cd2414 --- /dev/null +++ b/tests/e2e/peek-over-plugin-modal.test.ts @@ -0,0 +1,83 @@ +import { afterEach, expect, it } from "vitest"; +import { CaptureChoice } from "../../src/types/choices/CaptureChoice"; +import type IChoice from "../../src/types/choices/IChoice"; +import { createQuickAddE2EHarness, seedVaultFile } from "./e2eVault"; +import { closeOpenPrompts, insertText, jsLiteral, POLL_OPTS, pressKey } from "./uiHelpers"; + +const getContext = createQuickAddE2EHarness("peek-over-plugin-modal"); + +const PROMPT_STATE = `(() => ({ + prompt: Boolean(document.querySelector(".qaInputPrompt:not(.qa-prompt-peek-hidden)")), + peekVisible: Array.from(document.querySelectorAll(".qaInputPrompt .qa-peek-button")) + .some((button) => button.getClientRects().length > 0), + chip: Boolean(document.querySelector(".qa-peek-chip")), +}))()`; + +afterEach(async () => { + const { obsidian } = getContext(); + await closeOpenPrompts(obsidian); + await obsidian.dev.evalJson(`(() => { window.__pluginModal?.close(); return true; })()`); +}); + +async function runFromNote(withPluginModal: boolean) { + const { obsidian, plugin, sandbox } = getContext(); + const note = await seedVaultFile(obsidian, sandbox, "Event.md", ""); + const choice = new CaptureChoice("Name capture"); + choice.command = true; + choice.captureToActiveFile = true; + choice.onePageInput = "never"; + choice.format = { enabled: true, format: "Person: {{VALUE:Name}}" }; + await plugin.data<{ choices: IChoice[] }>().patch((data) => { + data.choices.push(choice); + }); + await plugin.reload({ waitUntilReady: true }); + + await obsidian.dev.evalJsonAsync(`(async () => { + const leaf = app.workspace.getLeaf(false); + await leaf.openFile(app.vault.getAbstractFileByPath(${jsLiteral(note)})); + app.workspace.setActiveLeaf(leaf, { focus: true }); + leaf.view.editor.focus(); + if (${withPluginModal}) { + // A dialog like Full Calendar's event editor, with its title field focused. + const Modal = Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(app.setting))).constructor; + const modal = new Modal(app); + if (!modal.contentEl) throw new Error("Modal lookup via app.setting's prototype chain broke"); + const input = modal.contentEl.createEl("input", { type: "text" }); + modal.open(); + input.focus(); + window.__pluginModal = modal; + } + app.commands.executeCommandById(${jsLiteral(`quickadd:choice:${choice.id}`)}); + return true; + })()`); + await expect.poll(() => obsidian.dev.evalJson<{ prompt: boolean }>(PROMPT_STATE).then((s) => s.prompt), POLL_OPTS).toBe(true); + return note; +} + +it("offers Peek on a prompt over the note", async () => { + const { obsidian } = getContext(); + await runFromNote(false); + expect(await obsidian.dev.evalJson(PROMPT_STATE)).toEqual({ prompt: true, peekVisible: true, chip: false }); +}); + +it("keeps a prompt over another plugin's modal in front, without Peek", async () => { + const { obsidian } = getContext(); + const note = await runFromNote(true); + expect(await obsidian.dev.evalJson(PROMPT_STATE)).toEqual({ prompt: true, peekVisible: false, chip: false }); + + await obsidian.dev.evalJson(`(() => { + document.querySelector(".qaInputPrompt input").dispatchEvent( + new KeyboardEvent("keydown", { key: "E", ctrlKey: true, metaKey: true, shiftKey: true, bubbles: true }), + ); + return true; + })()`); + expect(await obsidian.dev.evalJson(PROMPT_STATE)).toEqual({ prompt: true, peekVisible: false, chip: false }); + + await insertText(obsidian, "Ada"); + await pressKey(obsidian, "Enter"); + await expect.poll(() => obsidian.dev.evalJson(`(() => ({ + pluginModalOpen: window.__pluginModal.containerEl.isConnected, + editor: app.workspace.activeLeaf.view.editor.getValue(), + }))()`), POLL_OPTS).toEqual({ pluginModalOpen: true, editor: "Person: Ada" }); + expect(await obsidian.dev.evalJson(`app.workspace.getActiveFile()?.path`)).toBe(note); +}); From 7ad712121eed215610dc88c4d3159390b6135133 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 21:40:04 +0200 Subject: [PATCH 2/2] fix(prompt): drop Peek when a prompt opens over another plugin's modal A run started while another plugin's dialog is open, such as Full Calendar Remastered's event editor, opens its prompt on top of that dialog. Peek then hid the prompt, showed the dialog instead of the note, moved focus to the editor behind the dialog's backdrop, and parked the run behind a "QuickAdd is waiting" chip. A CSS rule hides the Peek button while a non-QuickAdd modal container is in the document, and peek() checks the same containers so the Ctrl/Cmd+Shift+E shortcut does nothing there. The rule is live, so a command palette still animating closed on a phone hides Peek only until it detaches. Fixes #2234 --- docs/src/content/docs/docs/ControllingPrompts.md | 2 +- src/gui/promptPeek/InputPromptPeek.ts | 11 ++++++++++- src/styles.css | 7 +++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/docs/ControllingPrompts.md b/docs/src/content/docs/docs/ControllingPrompts.md index e4b1dd0f5..9209a958f 100644 --- a/docs/src/content/docs/docs/ControllingPrompts.md +++ b/docs/src/content/docs/docs/ControllingPrompts.md @@ -109,7 +109,7 @@ Single-line and multi-line text prompts, plus the one-page input form, can get o - **Peek at note** on the prompt, or `Ctrl/Cmd+Shift+E` on desktop, hides the prompt without cancelling the run. On the one-page form, it hides the whole form. Everything in the prompt survives the peek: the draft, its undo history, a paste still saving, even the text you had selected in the field. - A chip stays on screen: **Insert selection** replaces the field's selection (or drops in at the caret) and returns, **Return** comes back as-is, and **Cancel** aborts the run. On the one-page form, **Insert selection** uses the last text field you focused, or the first text field if you have not focused one. On desktop, `Ctrl/Cmd+Shift+E` also returns. Peek does not bind `Esc` in the editor, so Vim users can still leave insert mode, and your other Obsidian hotkeys keep working while the chip is up. - The command **QuickAdd: Return to prompt** is available while a peek is open. On a phone the chip is a one-line bar under the note header, so the bottom nav and home indicator stay free, and prompt actions stay on one row (Peek / Cancel / Ok) so the keyboard does not cover Cancel. -- Peek appears on text prompts opened during a choice run or from the [API](/docs/QuickAddAPI/), and on the one-page input form. It does not appear on QuickAdd's own settings and builder prompts. Only one run can be parked: peeking a second prompt cancels the first, and returning waits until any other open prompt is closed. +- Peek appears on text prompts opened during a choice run or from the [API](/docs/QuickAddAPI/), and on the one-page input form. It does not appear on QuickAdd's own settings and builder prompts, or when the prompt opens over another dialog, because there is no note to look at. Only one run can be parked: peeking a second prompt cancels the first, and returning waits until any other open prompt is closed. ## Autocomplete while you type {#autocomplete-inside-prompts} diff --git a/src/gui/promptPeek/InputPromptPeek.ts b/src/gui/promptPeek/InputPromptPeek.ts index ca9022975..f54b65692 100644 --- a/src/gui/promptPeek/InputPromptPeek.ts +++ b/src/gui/promptPeek/InputPromptPeek.ts @@ -36,7 +36,7 @@ export class InputPromptPeek { constructor(private readonly host: InputPromptPeekHost) {} peek(): void { - if (this.peeking) return; + if (this.peeking || isOverAnotherModal(this.host.containerEl)) return; const field = this.host.getField(); if (field) this.host.setValue(field.value); this.host.persistDraft(); @@ -121,6 +121,15 @@ export class InputPromptPeek { } } +/** Mirrors the styles.css rule that hides the Peek button over another modal. */ +function isOverAnotherModal(containerEl: HTMLElement): boolean { + return ( + containerEl.ownerDocument.body.querySelector( + ":scope > .modal-container:not(.quickAddModal)", + ) !== null + ); +} + function focusActiveEditor(app: App): void { const editor = getActiveMarkdownEditorView(app)?.editor as | { focus?: () => void } diff --git a/src/styles.css b/src/styles.css index e4f02c896..f054c6481 100644 --- a/src/styles.css +++ b/src/styles.css @@ -850,6 +850,13 @@ body:not(.is-phone) .setting-item.qa-field > .setting-item-control { display: none; } +/* Over another modal, peeking would show that modal instead of the + note and park the run behind it. InputPromptPeek.peek() matches the same + containers for the shortcut. */ +body:has(> .modal-container:not(.quickAddModal)) .quickAddModal .qa-peek-button { + display: none; +} + .quickAddModal.qa-prompt-compact .qa-prompt-actions, .is-phone .quickAddModal .qa-prompt-actions { --button-radius: 6px;