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/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/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/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/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; 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); +});