diff --git a/docs/docs/Advanced/onePageInputs.md b/docs/docs/Advanced/onePageInputs.md index b5ea907c..9c0d3f52 100644 --- a/docs/docs/Advanced/onePageInputs.md +++ b/docs/docs/Advanced/onePageInputs.md @@ -32,6 +32,7 @@ This feature is currently in Beta. ## Skipping the modal - If all required inputs already have values (e.g., prefilled by an earlier macro step), the modal will not open. - Empty string is considered an intentional value and will not prompt again. +- For Capture choices, a non-empty editor selection will prefill `{{VALUE}}` during preflight when selection-as-value is enabled. Note: For date fields with a default, leaving the input blank will apply the default automatically at submit time. diff --git a/docs/docs/Choices/CaptureChoice.md b/docs/docs/Choices/CaptureChoice.md index 6e0245ab..b039f56c 100644 --- a/docs/docs/Choices/CaptureChoice.md +++ b/docs/docs/Choices/CaptureChoice.md @@ -42,6 +42,7 @@ If you have a tag called `#people`, and you type `#people` in the _Capture To_ f - _Create file if it doesn't exist_ will do as the name implies - you can also create the file from a template, if you specify the template (the input box will appear below the setting). - _Task_ will format your captured text as a task. +- _Use editor selection as default value_ controls whether the current editor selection is used as `{{VALUE}}`. Choose **Follow global setting**, **Use selection**, or **Ignore selection** (global default lives in Settings > Input). This does not affect `{{SELECTED}}`. - _Write to bottom of file_ will put whatever you enter at the bottom of the file. - _Append link_ will append a link to the file you have open in the file you're capturing to. You can choose between three modes: - **Enabled (requires active file)** – keeps the legacy behavior and throws an error if no note is focused @@ -119,7 +120,7 @@ Content Capture format lets you specify the exact format that you want what you're capturing to be inserted as. You can do practically anything here. Think of it as a mini template. -If you do not enable this, QuickAdd will default to `{{VALUE}}`, which will just insert whatever you enter in the prompt that appears when activating the Capture. +If you do not enable this, QuickAdd will default to `{{VALUE}}`, which will insert whatever you enter in the prompt, or (if selection-as-value is enabled) the current editor selection. You can use [format syntax](/FormatSyntax.md) here, which allows you to use dynamic values in your capture format. diff --git a/docs/docs/FormatSyntax.md b/docs/docs/FormatSyntax.md index e90098fe..2f77b4dd 100644 --- a/docs/docs/FormatSyntax.md +++ b/docs/docs/FormatSyntax.md @@ -8,7 +8,7 @@ title: Format syntax | `{{DATE:}}` | Replace `` with a [Moment.js date format](https://momentjs.com/docs/#/displaying/format/). You could write `{{DATE+3}}` to offset the date with 3 days. | | `{{VDATE:, }}` | You'll get prompted to enter a date and it'll be parsed to the given date format. You could write 'today' or 'in two weeks' and it'll give you the date for that. Short aliases like `t` (today), `tm` (tomorrow), and `yd` (yesterday) are also supported and configurable in settings. Works like variables, so you can use the date in multiple places with different formats - enter once, format many times! Example: `{{VDATE:date,YYYY}}/{{VDATE:date,MM}}/{{VDATE:date,DD}}` | | `{{VDATE:, \|}}` | Same as above, but with a default value. If you leave the prompt empty, the default value will be used instead. Example: `{{VDATE:date,YYYY-MM-DD\|today}}` will use "today" if no input is provided. Default values can be any natural language date like "tomorrow", "next monday", "+7 days", etc. Short aliases like `t`, `tm`, and `yd` work here too. **Note:** If your date format contains pipe characters (`|`), you'll need to escape them as `\|` or use square brackets like `[|]` to avoid conflicts with the default value separator. | -| `{{VALUE}}` or `{{NAME}}` | Interchangeable. Represents the value given in an input prompt. If text is selected in the current editor, it will be used as the value. When using the QuickAdd API, this can be passed programmatically using the reserved variable name 'value'. | +| `{{VALUE}}` or `{{NAME}}` | Interchangeable. Represents the value given in an input prompt. If text is selected in the current editor, it will be used as the value. For Capture choices, selection-as-value can be disabled globally or per-capture. When using the QuickAdd API, this can be passed programmatically using the reserved variable name 'value'. | | `{{VALUE:}}` | You can now use variable names in values. They'll get saved and inserted just like values, but the difference is that you can have as many of them as you want. Use comma separation to get a suggester rather than a prompt. | | `{{VALUE:\|label:}}` | Adds helper text to the prompt for a single-value input. The helper appears below the header and is useful for reminders or instructions. For multi-value lists, use the same syntax to label the suggester (e.g., `{{VALUE:Red,Green,Blue\|label:Pick a color}}`). | | `{{VALUE:\|}}` | Same as above, but with a default value. For single-value prompts (e.g., `{{VALUE:name\|Anonymous}}`), the default is pre-populated in the input field - press Enter to accept or clear/edit it. For multi-value suggesters without `\|custom`, you must select one of the provided options (no default applies). If you combine options like `\|label:...`, use `\|default:` instead of the shorthand (mixing option keys with a bare default is not supported). | diff --git a/src/engine/CaptureChoiceEngine.notice.test.ts b/src/engine/CaptureChoiceEngine.notice.test.ts index cdc7e2a8..0a408327 100644 --- a/src/engine/CaptureChoiceEngine.notice.test.ts +++ b/src/engine/CaptureChoiceEngine.notice.test.ts @@ -6,6 +6,7 @@ vi.mock("../quickAddSettingsTab", () => { inputPrompt: "single-line", devMode: false, templateFolderPath: "", + useSelectionAsCaptureValue: true, announceUpdates: "major", version: "0.0.0", globalVariables: {}, @@ -47,6 +48,7 @@ vi.mock("../formatters/captureChoiceFormatter", () => { setTitle() {} setDestinationFile() {} setDestinationSourcePath() {} + setUseSelectionAsCaptureValue() {} async formatContentOnly(content: string) { return content; } diff --git a/src/engine/CaptureChoiceEngine.selection.test.ts b/src/engine/CaptureChoiceEngine.selection.test.ts new file mode 100644 index 00000000..0a2611ec --- /dev/null +++ b/src/engine/CaptureChoiceEngine.selection.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { App } from "obsidian"; +import { CaptureChoiceEngine } from "./CaptureChoiceEngine"; +import type ICaptureChoice from "../types/choices/ICaptureChoice"; +import type { IChoiceExecutor } from "../IChoiceExecutor"; + +const { setUseSelectionAsCaptureValueMock } = vi.hoisted(() => ({ + setUseSelectionAsCaptureValueMock: vi.fn(), +})); + +vi.mock("../formatters/captureChoiceFormatter", () => ({ + CaptureChoiceFormatter: class { + setLinkToCurrentFileBehavior() {} + setUseSelectionAsCaptureValue(value: boolean) { + setUseSelectionAsCaptureValueMock(value); + } + setTitle() {} + setDestinationFile() {} + setDestinationSourcePath() {} + async formatContentOnly(content: string) { + return content; + } + async formatContentWithFile() { + return ""; + } + async formatFileName(name: string) { + return name; + } + getAndClearTemplatePropertyVars() { + return new Map(); + } + }, + setUseSelectionAsCaptureValueMock, +})); + +vi.mock("../utilityObsidian", () => ({ + appendToCurrentLine: vi.fn(), + getMarkdownFilesInFolder: vi.fn(() => []), + getMarkdownFilesWithTag: vi.fn(() => []), + insertFileLinkToActiveView: vi.fn(), + insertOnNewLineAbove: vi.fn(), + insertOnNewLineBelow: vi.fn(), + isFolder: vi.fn(() => false), + isTemplaterTriggerOnCreateEnabled: vi.fn(() => false), + jumpToNextTemplaterCursorIfPossible: vi.fn(), + openExistingFileTab: vi.fn(() => null), + openFile: vi.fn(), + overwriteTemplaterOnce: vi.fn(), + templaterParseTemplate: vi.fn(async (_app, content) => content), + waitForTemplaterTriggerOnCreateToComplete: vi.fn(), +})); + +vi.mock("three-way-merge", () => ({ + default: vi.fn(() => ({})), + __esModule: true, +})); + +vi.mock("src/gui/InputSuggester/inputSuggester", () => ({ + default: class {}, +})); + +vi.mock("obsidian-dataview", () => ({ + getAPI: vi.fn(), +})); + +vi.mock("../main", () => ({ + default: class QuickAddMock {}, +})); + +const createApp = () => + ({ + vault: { + adapter: { + exists: vi.fn(async () => false), + }, + }, + workspace: { + getActiveFile: vi.fn(() => null), + }, + fileManager: { + getNewFileParent: vi.fn(() => ({ path: "" })), + }, + } as unknown as App); + +const createChoice = (overrides: Partial = {}): ICaptureChoice => ({ + id: "capture-choice-id", + name: "Capture Choice", + type: "Capture", + command: false, + captureTo: "Inbox.md", + captureToActiveFile: false, + createFileIfItDoesntExist: { + enabled: false, + createWithTemplate: false, + template: "", + }, + format: { enabled: false, format: "" }, + prepend: false, + appendLink: false, + task: false, + insertAfter: { + enabled: false, + after: "", + insertAtEnd: false, + considerSubsections: false, + createIfNotFound: false, + createIfNotFoundLocation: "", + }, + newLineCapture: { + enabled: false, + direction: "below", + }, + openFile: false, + fileOpening: { + location: "tab", + direction: "vertical", + mode: "default", + focus: true, + }, + ...overrides, +}); + +const createExecutor = (): IChoiceExecutor => ({ + execute: vi.fn(), + variables: new Map(), +}); + +describe("CaptureChoiceEngine selection-as-value resolution", () => { + beforeEach(() => { + setUseSelectionAsCaptureValueMock.mockClear(); + }); + + it("uses global setting when no override is set", async () => { + const engine = new CaptureChoiceEngine( + createApp(), + { settings: { useSelectionAsCaptureValue: false } } as any, + createChoice(), + createExecutor(), + ); + + await engine.run(); + + expect(setUseSelectionAsCaptureValueMock).toHaveBeenCalledWith(false); + }); + + it("uses per-choice override when provided", async () => { + const engine = new CaptureChoiceEngine( + createApp(), + { settings: { useSelectionAsCaptureValue: true } } as any, + createChoice({ useSelectionAsCaptureValue: false }), + createExecutor(), + ); + + await engine.run(); + + expect(setUseSelectionAsCaptureValueMock).toHaveBeenCalledWith(false); + }); + + it("allows per-choice override to enable selection", async () => { + const engine = new CaptureChoiceEngine( + createApp(), + { settings: { useSelectionAsCaptureValue: false } } as any, + createChoice({ useSelectionAsCaptureValue: true }), + createExecutor(), + ); + + await engine.run(); + + expect(setUseSelectionAsCaptureValueMock).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/engine/CaptureChoiceEngine.template-property-types.test.ts b/src/engine/CaptureChoiceEngine.template-property-types.test.ts index 09f26100..dccb7916 100644 --- a/src/engine/CaptureChoiceEngine.template-property-types.test.ts +++ b/src/engine/CaptureChoiceEngine.template-property-types.test.ts @@ -11,6 +11,7 @@ vi.mock("../quickAddSettingsTab", () => { inputPrompt: "single-line", devMode: false, templateFolderPath: "", + useSelectionAsCaptureValue: true, announceUpdates: "major", version: "0.0.0", globalVariables: {}, diff --git a/src/engine/CaptureChoiceEngine.ts b/src/engine/CaptureChoiceEngine.ts index e7c9e48d..9da7418f 100644 --- a/src/engine/CaptureChoiceEngine.ts +++ b/src/engine/CaptureChoiceEngine.ts @@ -104,6 +104,14 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { ? "optional" : "required", ); + const selectionOverride = this.choice.useSelectionAsCaptureValue; + const globalSelectionAsValue = + this.plugin.settings.useSelectionAsCaptureValue ?? true; + const useSelectionAsCaptureValue = + typeof selectionOverride === "boolean" + ? selectionOverride + : globalSelectionAsValue; + this.formatter.setUseSelectionAsCaptureValue(useSelectionAsCaptureValue); const filePath = await this.getFormattedPathToCaptureTo( this.choice.captureToActiveFile, diff --git a/src/engine/MacroChoiceEngine.notice.test.ts b/src/engine/MacroChoiceEngine.notice.test.ts index 967bfb0a..b0243000 100644 --- a/src/engine/MacroChoiceEngine.notice.test.ts +++ b/src/engine/MacroChoiceEngine.notice.test.ts @@ -6,6 +6,7 @@ vi.mock("../quickAddSettingsTab", () => { inputPrompt: "single-line", devMode: false, templateFolderPath: "", + useSelectionAsCaptureValue: true, announceUpdates: "major", version: "0.0.0", globalVariables: {}, diff --git a/src/engine/TemplateChoiceEngine.notice.test.ts b/src/engine/TemplateChoiceEngine.notice.test.ts index b2768703..ed938ddf 100644 --- a/src/engine/TemplateChoiceEngine.notice.test.ts +++ b/src/engine/TemplateChoiceEngine.notice.test.ts @@ -6,6 +6,7 @@ vi.mock("../quickAddSettingsTab", () => { inputPrompt: "single-line", devMode: false, templateFolderPath: "", + useSelectionAsCaptureValue: true, announceUpdates: "major", version: "0.0.0", globalVariables: {}, diff --git a/src/formatters/captureChoiceFormatter-selection.test.ts b/src/formatters/captureChoiceFormatter-selection.test.ts new file mode 100644 index 00000000..65451738 --- /dev/null +++ b/src/formatters/captureChoiceFormatter-selection.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { App } from "obsidian"; +import { CaptureChoiceFormatter } from "./captureChoiceFormatter"; + +const { promptMock } = vi.hoisted(() => ({ + promptMock: vi.fn().mockResolvedValue("prompted"), +})); + +vi.mock("../gui/InputPrompt", () => ({ + __esModule: true, + default: class { + factory() { + return { + Prompt: promptMock, + PromptWithContext: promptMock, + } as any; + } + }, +})); + +vi.mock("../quickAddSettingsTab", () => ({ + QuickAddSettingsTab: class {}, +})); + +vi.mock("../main", () => ({ + __esModule: true, + default: class QuickAddMock {}, +})); + +vi.mock("obsidian-dataview", () => ({ + __esModule: true, + getAPI: vi.fn().mockReturnValue(null), +})); + +const createFormatter = (selection: string | null) => { + const app = { + workspace: { + getActiveViewOfType: vi.fn().mockReturnValue( + selection === null + ? null + : { + editor: { + getSelection: () => selection, + }, + }, + ), + getActiveFile: vi.fn().mockReturnValue(null), + }, + } as unknown as App; + + const plugin = { + settings: { + inputPrompt: "single-line", + enableTemplatePropertyTypes: false, + globalVariables: {}, + useSelectionAsCaptureValue: true, + }, + } as any; + + return new CaptureChoiceFormatter(app, plugin); +}; + +describe("CaptureChoiceFormatter selection-as-value behavior", () => { + beforeEach(() => { + promptMock.mockClear(); + }); + + it("uses selection for {{VALUE}} when enabled", async () => { + const formatter = createFormatter("Selected text"); + formatter.setUseSelectionAsCaptureValue(true); + + const result = await formatter.formatContentOnly("{{VALUE}}"); + + expect(result).toBe("Selected text"); + expect(promptMock).not.toHaveBeenCalled(); + }); + + it("prompts for {{VALUE}} when selection use is disabled", async () => { + const formatter = createFormatter("Selected text"); + formatter.setUseSelectionAsCaptureValue(false); + + const result = await formatter.formatContentOnly( + "Value: {{VALUE}} / Selected: {{SELECTED}}", + ); + + expect(result).toBe("Value: prompted / Selected: Selected text"); + expect(promptMock).toHaveBeenCalledTimes(1); + }); + + it("treats whitespace-only selection as empty when enabled", async () => { + const formatter = createFormatter(" \n\t "); + formatter.setUseSelectionAsCaptureValue(true); + + const result = await formatter.formatContentOnly("{{VALUE}}"); + + expect(result).toBe("prompted"); + expect(promptMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/formatters/captureChoiceFormatter.ts b/src/formatters/captureChoiceFormatter.ts index 49728b6d..553a8a84 100644 --- a/src/formatters/captureChoiceFormatter.ts +++ b/src/formatters/captureChoiceFormatter.ts @@ -18,6 +18,7 @@ export class CaptureChoiceFormatter extends CompleteFormatter { private file: TFile | null = null; private fileContent = ""; private sourcePath: string | null = null; + private useSelectionAsCaptureValue = true; /** * Tracks whether the current formatter instance has already run Templater on the * capture payload. This prevents the same content from being parsed twice in @@ -36,6 +37,19 @@ export class CaptureChoiceFormatter extends CompleteFormatter { this.file = null; } + public setUseSelectionAsCaptureValue(value: boolean): void { + this.useSelectionAsCaptureValue = value; + } + + protected shouldUseSelectionForValue(): boolean { + return this.useSelectionAsCaptureValue; + } + + protected async getSelectedTextForValue(): Promise { + const selectedText = await this.getSelectedText(); + return selectedText.trim().length > 0 ? selectedText : ""; + } + protected getLinkSourcePath(): string | null { return this.sourcePath ?? this.file?.path ?? null; } diff --git a/src/formatters/completeFormatter.ts b/src/formatters/completeFormatter.ts index 0213bdf4..13f918fa 100644 --- a/src/formatters/completeFormatter.ts +++ b/src/formatters/completeFormatter.ts @@ -155,35 +155,45 @@ export class CompleteFormatter extends Formatter { return (this.variables.get(variableName) as string) ?? ""; } + protected shouldUseSelectionForValue(): boolean { + return true; + } + + protected async getSelectedTextForValue(): Promise { + return await this.getSelectedText(); + } + protected async promptForValue(header?: string): Promise { - if (!this.value) { - const selectedText: string = await this.getSelectedText(); - if (selectedText) { - this.value = selectedText; - } else { - try { - const linkSourcePath = this.getLinkSourcePath(); - if (linkSourcePath) { - this.value = await new InputPrompt() - .factory() - .PromptWithContext( - this.app, - this.valueHeader ?? `Enter value`, - undefined, - undefined, - linkSourcePath - ); - } else { - this.value = await new InputPrompt() - .factory() - .Prompt(this.app, this.valueHeader ?? `Enter value`); - } - } catch (error) { - if (isCancellationError(error)) { - throw new MacroAbortError("Input cancelled by user"); - } - throw error; + if (this.value === undefined) { + if (this.shouldUseSelectionForValue()) { + const selectedText: string = await this.getSelectedTextForValue(); + if (selectedText) { + this.value = selectedText; + return this.value; + } + } + try { + const linkSourcePath = this.getLinkSourcePath(); + if (linkSourcePath) { + this.value = await new InputPrompt() + .factory() + .PromptWithContext( + this.app, + this.valueHeader ?? `Enter value`, + undefined, + undefined, + linkSourcePath + ); + } else { + this.value = await new InputPrompt() + .factory() + .Prompt(this.app, this.valueHeader ?? `Enter value`); + } + } catch (error) { + if (isCancellationError(error)) { + throw new MacroAbortError("Input cancelled by user"); } + throw error; } } diff --git a/src/gui/ChoiceBuilder/captureChoiceBuilder.ts b/src/gui/ChoiceBuilder/captureChoiceBuilder.ts index 2a8e7ef8..5f8608dd 100644 --- a/src/gui/ChoiceBuilder/captureChoiceBuilder.ts +++ b/src/gui/ChoiceBuilder/captureChoiceBuilder.ts @@ -63,7 +63,6 @@ export class CaptureChoiceBuilder extends ChoiceBuilder { // Behavior new Setting(this.contentEl).setName("Behavior").setHeading(); - this.addTemplaterAfterCaptureSetting(); if (!this.choice.captureToActiveFile) { this.addOpenFileSetting("Open the captured file."); @@ -71,6 +70,8 @@ export class CaptureChoiceBuilder extends ChoiceBuilder { this.addFileOpeningSetting("captured"); } } + this.addSelectionAsValueSetting(); + this.addTemplaterAfterCaptureSetting(); this.addOnePageOverrideSetting(this.choice); } @@ -91,6 +92,36 @@ export class CaptureChoiceBuilder extends ChoiceBuilder { }); } + private addSelectionAsValueSetting() { + new Setting(this.contentEl) + .setName("Use editor selection as default value") + .setDesc( + "Controls whether this Capture uses the current editor selection as {{VALUE}}. Does not affect {{SELECTED}}.", + ) + .addDropdown((dropdown) => { + dropdown.addOptions({ + "": "Follow global setting", + enabled: "Use selection", + disabled: "Ignore selection", + }); + const override = this.choice.useSelectionAsCaptureValue; + dropdown.setValue( + typeof override === "boolean" + ? override + ? "enabled" + : "disabled" + : "", + ); + dropdown.onChange((value) => { + if (value === "") { + this.choice.useSelectionAsCaptureValue = undefined; + return; + } + this.choice.useSelectionAsCaptureValue = value === "enabled"; + }); + }); + } + private addCapturedToSetting() { new Setting(this.contentEl) .setName("Capture to") diff --git a/src/preflight/runOnePagePreflight.selection.test.ts b/src/preflight/runOnePagePreflight.selection.test.ts new file mode 100644 index 00000000..522d55ca --- /dev/null +++ b/src/preflight/runOnePagePreflight.selection.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { App } from "obsidian"; +import { runOnePagePreflight } from "./runOnePagePreflight"; +import type ICaptureChoice from "../types/choices/ICaptureChoice"; +import type { IChoiceExecutor } from "../IChoiceExecutor"; + +const { modalOpenMock } = vi.hoisted(() => ({ + modalOpenMock: vi.fn(), +})); + +let modalResult: Record = {}; + +vi.mock("./OnePageInputModal", () => ({ + OnePageInputModal: class { + waitForClose = Promise.resolve(modalResult); + constructor(...args: unknown[]) { + modalOpenMock(...args); + } + }, +})); + +vi.mock("src/quickAddSettingsTab", () => ({ + QuickAddSettingsTab: class {}, +})); + +vi.mock("src/main", () => ({ + __esModule: true, + default: class QuickAddMock {}, +})); + +vi.mock("obsidian-dataview", () => ({ + __esModule: true, + getAPI: vi.fn().mockReturnValue(null), +})); + +vi.mock("src/utilityObsidian", () => ({ + getMarkdownFilesInFolder: vi.fn(() => []), + getMarkdownFilesWithTag: vi.fn(() => []), + getUserScript: vi.fn(), + isFolder: vi.fn(() => false), +})); + +const createApp = (selection: string | null) => + ({ + workspace: { + getActiveViewOfType: vi.fn().mockReturnValue( + selection === null + ? null + : { + editor: { + getSelection: () => selection, + }, + }, + ), + }, + } as unknown as App); + +const createChoice = (): ICaptureChoice => ({ + id: "capture-choice-id", + name: "Capture Choice", + type: "Capture", + command: false, + captureTo: "Inbox.md", + captureToActiveFile: true, + createFileIfItDoesntExist: { + enabled: false, + createWithTemplate: false, + template: "", + }, + format: { enabled: true, format: "{{VALUE}}" }, + prepend: false, + appendLink: false, + task: false, + insertAfter: { + enabled: false, + after: "", + insertAtEnd: false, + considerSubsections: false, + createIfNotFound: false, + createIfNotFoundLocation: "", + }, + newLineCapture: { + enabled: false, + direction: "below", + }, + openFile: false, + fileOpening: { + location: "tab", + direction: "vertical", + mode: "default", + focus: true, + }, +}); + +const createExecutor = (): IChoiceExecutor => ({ + execute: vi.fn(), + variables: new Map(), +}); + +describe("runOnePagePreflight selection-as-value", () => { + beforeEach(() => { + modalOpenMock.mockClear(); + modalResult = {}; + }); + + it("prefills {{VALUE}} from selection when enabled", async () => { + const choice = createChoice(); + const executor = createExecutor(); + const plugin = { + settings: { + inputPrompt: "single-line", + globalVariables: {}, + useSelectionAsCaptureValue: true, + }, + } as any; + + const result = await runOnePagePreflight( + createApp("Selected text"), + plugin, + executor, + choice, + ); + + expect(result).toBe(false); + expect(executor.variables.get("value")).toBe("Selected text"); + expect(modalOpenMock).not.toHaveBeenCalled(); + }); + + it("does not prefill when selection usage is disabled", async () => { + const choice = createChoice(); + const executor = createExecutor(); + const plugin = { + settings: { + inputPrompt: "single-line", + globalVariables: {}, + useSelectionAsCaptureValue: false, + }, + } as any; + modalResult = { value: "Manual" }; + + const result = await runOnePagePreflight( + createApp("Selected text"), + plugin, + executor, + choice, + ); + + expect(result).toBe(true); + expect(executor.variables.get("value")).toBe("Manual"); + expect(modalOpenMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/preflight/runOnePagePreflight.ts b/src/preflight/runOnePagePreflight.ts index 195c9f1c..c95dd77e 100644 --- a/src/preflight/runOnePagePreflight.ts +++ b/src/preflight/runOnePagePreflight.ts @@ -1,5 +1,5 @@ import type { App } from "obsidian"; -import { TFile } from "obsidian"; +import { MarkdownView, TFile } from "obsidian"; import type { IChoiceExecutor } from "src/IChoiceExecutor"; import { MARKDOWN_FILE_EXTENSION_REGEX, @@ -135,6 +135,12 @@ async function collectForCaptureChoice( return collector; } +function getEditorSelection(app: App): string { + const activeView = app.workspace.getActiveViewOfType(MarkdownView); + if (!activeView) return ""; + return activeView.editor.getSelection(); +} + export async function runOnePagePreflight( app: App, plugin: QuickAdd, @@ -159,6 +165,23 @@ export async function runOnePagePreflight( choiceExecutor, choice as ICaptureChoice, ); + const captureChoice = choice as ICaptureChoice; + const selectionOverride = captureChoice.useSelectionAsCaptureValue; + const globalSelectionAsValue = + plugin.settings.useSelectionAsCaptureValue ?? true; + const useSelectionAsCaptureValue = + typeof selectionOverride === "boolean" + ? selectionOverride + : globalSelectionAsValue; + if (useSelectionAsCaptureValue) { + const existingValue = choiceExecutor.variables.get("value"); + if (existingValue === undefined || existingValue === null) { + const selectedText = getEditorSelection(app); + if (selectedText.trim().length > 0) { + choiceExecutor.variables.set("value", selectedText); + } + } + } } else if (choice.type === "Macro") { // Phase 2 (limited): Collect declared inputs from user scripts in the macro const macro = choice as IMacroChoice; diff --git a/src/quickAddSettingsTab.ts b/src/quickAddSettingsTab.ts index b866cc3f..711fb1ba 100644 --- a/src/quickAddSettingsTab.ts +++ b/src/quickAddSettingsTab.ts @@ -57,6 +57,7 @@ export class QuickAddSettingsTab extends PluginSettingTab { const inputGroup = this.createSettingGroup("Input"); this.addUseMultiLineInputPromptSetting(inputGroup); this.addPersistInputPromptDraftsSetting(inputGroup); + this.addUseSelectionAsValueSetting(inputGroup); this.addOnePageInputSetting(inputGroup); this.addDateAliasesSetting(inputGroup); @@ -372,6 +373,23 @@ export class QuickAddSettingsTab extends PluginSettingTab { }); } + private addUseSelectionAsValueSetting(group: SettingGroupLike) { + group.addSetting((setting) => { + setting + .setName("Use editor selection as default Capture value") + .setDesc( + "When enabled, Capture uses the current editor selection as {{VALUE}} and may skip the prompt. When disabled, Capture always prompts for {{VALUE}}.", + ) + .addToggle((toggle) => + toggle + .setValue(settingsStore.getState().useSelectionAsCaptureValue) + .onChange((value) => { + settingsStore.setState({ useSelectionAsCaptureValue: value }); + }), + ); + }); + } + private addTemplateFolderPathSetting(group: SettingGroupLike) { group.addSetting((setting) => { setting.setName("Template Folder Path"); diff --git a/src/settings.ts b/src/settings.ts index fed9f551..60fc3bc2 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -7,6 +7,10 @@ export interface QuickAddSettings { choices: IChoice[]; inputPrompt: "multi-line" | "single-line"; persistInputPromptDrafts: boolean; + /** + * When enabled, Capture uses the current editor selection as the default {{VALUE}}. + */ + useSelectionAsCaptureValue: boolean; devMode: boolean; templateFolderPath: string; announceUpdates: "all" | "major" | "none"; @@ -51,6 +55,7 @@ export const DEFAULT_SETTINGS: QuickAddSettings = { choices: [], inputPrompt: "single-line", persistInputPromptDrafts: true, + useSelectionAsCaptureValue: true, devMode: false, templateFolderPath: "", announceUpdates: "major", diff --git a/src/types/choices/CaptureChoice.ts b/src/types/choices/CaptureChoice.ts index 756da793..1566d6c2 100644 --- a/src/types/choices/CaptureChoice.ts +++ b/src/types/choices/CaptureChoice.ts @@ -14,6 +14,7 @@ export class CaptureChoice extends Choice implements ICaptureChoice { template: string; }; format: { enabled: boolean; format: string }; + useSelectionAsCaptureValue?: boolean; insertAfter: { enabled: boolean; after: string; diff --git a/src/types/choices/ICaptureChoice.ts b/src/types/choices/ICaptureChoice.ts index 86c224c2..25f1fafd 100644 --- a/src/types/choices/ICaptureChoice.ts +++ b/src/types/choices/ICaptureChoice.ts @@ -12,6 +12,11 @@ export default interface ICaptureChoice extends IChoice { template: string; }; format: { enabled: boolean; format: string }; + /** + * Override whether editor selection should be used as the default {{VALUE}}. + * Undefined means follow the global setting. + */ + useSelectionAsCaptureValue?: boolean; /** Capture to bottom of file (after current file content). */ prepend: boolean; /**