Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/src/content/docs/docs/ControllingPrompts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
32 changes: 32 additions & 0 deletions src/gui/GenericInputPrompt/GenericInputPrompt.peek.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion src/gui/promptPeek/InputPromptPeek.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 }
Expand Down
19 changes: 19 additions & 0 deletions src/preflight/OnePageInputModal.peek.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
7 changes: 7 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
83 changes: 83 additions & 0 deletions tests/e2e/peek-over-plugin-modal.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>(`app.workspace.getActiveFile()?.path`)).toBe(note);
});
Loading