Skip to content

Commit 250768a

Browse files
authored
feat: add capture selection-as-value controls (#1055)
1 parent fcd058f commit 250768a

19 files changed

+575
-31
lines changed

docs/docs/Advanced/onePageInputs.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ This feature is currently in Beta.
3232
## Skipping the modal
3333
- If all required inputs already have values (e.g., prefilled by an earlier macro step), the modal will not open.
3434
- Empty string is considered an intentional value and will not prompt again.
35+
- For Capture choices, a non-empty editor selection will prefill `{{VALUE}}` during preflight when selection-as-value is enabled.
3536

3637
Note: For date fields with a default, leaving the input blank will apply the default automatically at submit time.
3738

docs/docs/Choices/CaptureChoice.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ If you have a tag called `#people`, and you type `#people` in the _Capture To_ f
4242

4343
- _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).
4444
- _Task_ will format your captured text as a task.
45+
- _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}}`.
4546
- _Write to bottom of file_ will put whatever you enter at the bottom of the file.
4647
- _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:
4748
- **Enabled (requires active file)** – keeps the legacy behavior and throws an error if no note is focused
@@ -119,7 +120,7 @@ Content
119120
Capture format lets you specify the exact format that you want what you're capturing to be inserted as.
120121
You can do practically anything here. Think of it as a mini template.
121122

122-
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.
123+
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.
123124

124125
You can use [format syntax](/FormatSyntax.md) here, which allows you to use dynamic values in your capture format.
125126

docs/docs/FormatSyntax.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ title: Format syntax
88
| `{{DATE:<DATEFORMAT>}}` | Replace `<DATEFORMAT>` with a [Moment.js date format](https://momentjs.com/docs/#/displaying/format/). You could write `{{DATE<DATEFORMAT>+3}}` to offset the date with 3 days. |
99
| `{{VDATE:<variable name>, <date format>}}` | 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}}` |
1010
| `{{VDATE:<variable name>, <date format>\|<default>}}` | 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. |
11-
| `{{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'. |
11+
| `{{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'. |
1212
| `{{VALUE:<variable name>}}` | 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. |
1313
| `{{VALUE:<variable name>\|label:<helper text>}}` | 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}}`). |
1414
| `{{VALUE:<variable name>\|<default>}}` | 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:<value>` instead of the shorthand (mixing option keys with a bare default is not supported). |

src/engine/CaptureChoiceEngine.notice.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ vi.mock("../quickAddSettingsTab", () => {
66
inputPrompt: "single-line",
77
devMode: false,
88
templateFolderPath: "",
9+
useSelectionAsCaptureValue: true,
910
announceUpdates: "major",
1011
version: "0.0.0",
1112
globalVariables: {},
@@ -47,6 +48,7 @@ vi.mock("../formatters/captureChoiceFormatter", () => {
4748
setTitle() {}
4849
setDestinationFile() {}
4950
setDestinationSourcePath() {}
51+
setUseSelectionAsCaptureValue() {}
5052
async formatContentOnly(content: string) {
5153
return content;
5254
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { App } from "obsidian";
3+
import { CaptureChoiceEngine } from "./CaptureChoiceEngine";
4+
import type ICaptureChoice from "../types/choices/ICaptureChoice";
5+
import type { IChoiceExecutor } from "../IChoiceExecutor";
6+
7+
const { setUseSelectionAsCaptureValueMock } = vi.hoisted(() => ({
8+
setUseSelectionAsCaptureValueMock: vi.fn(),
9+
}));
10+
11+
vi.mock("../formatters/captureChoiceFormatter", () => ({
12+
CaptureChoiceFormatter: class {
13+
setLinkToCurrentFileBehavior() {}
14+
setUseSelectionAsCaptureValue(value: boolean) {
15+
setUseSelectionAsCaptureValueMock(value);
16+
}
17+
setTitle() {}
18+
setDestinationFile() {}
19+
setDestinationSourcePath() {}
20+
async formatContentOnly(content: string) {
21+
return content;
22+
}
23+
async formatContentWithFile() {
24+
return "";
25+
}
26+
async formatFileName(name: string) {
27+
return name;
28+
}
29+
getAndClearTemplatePropertyVars() {
30+
return new Map();
31+
}
32+
},
33+
setUseSelectionAsCaptureValueMock,
34+
}));
35+
36+
vi.mock("../utilityObsidian", () => ({
37+
appendToCurrentLine: vi.fn(),
38+
getMarkdownFilesInFolder: vi.fn(() => []),
39+
getMarkdownFilesWithTag: vi.fn(() => []),
40+
insertFileLinkToActiveView: vi.fn(),
41+
insertOnNewLineAbove: vi.fn(),
42+
insertOnNewLineBelow: vi.fn(),
43+
isFolder: vi.fn(() => false),
44+
isTemplaterTriggerOnCreateEnabled: vi.fn(() => false),
45+
jumpToNextTemplaterCursorIfPossible: vi.fn(),
46+
openExistingFileTab: vi.fn(() => null),
47+
openFile: vi.fn(),
48+
overwriteTemplaterOnce: vi.fn(),
49+
templaterParseTemplate: vi.fn(async (_app, content) => content),
50+
waitForTemplaterTriggerOnCreateToComplete: vi.fn(),
51+
}));
52+
53+
vi.mock("three-way-merge", () => ({
54+
default: vi.fn(() => ({})),
55+
__esModule: true,
56+
}));
57+
58+
vi.mock("src/gui/InputSuggester/inputSuggester", () => ({
59+
default: class {},
60+
}));
61+
62+
vi.mock("obsidian-dataview", () => ({
63+
getAPI: vi.fn(),
64+
}));
65+
66+
vi.mock("../main", () => ({
67+
default: class QuickAddMock {},
68+
}));
69+
70+
const createApp = () =>
71+
({
72+
vault: {
73+
adapter: {
74+
exists: vi.fn(async () => false),
75+
},
76+
},
77+
workspace: {
78+
getActiveFile: vi.fn(() => null),
79+
},
80+
fileManager: {
81+
getNewFileParent: vi.fn(() => ({ path: "" })),
82+
},
83+
} as unknown as App);
84+
85+
const createChoice = (overrides: Partial<ICaptureChoice> = {}): ICaptureChoice => ({
86+
id: "capture-choice-id",
87+
name: "Capture Choice",
88+
type: "Capture",
89+
command: false,
90+
captureTo: "Inbox.md",
91+
captureToActiveFile: false,
92+
createFileIfItDoesntExist: {
93+
enabled: false,
94+
createWithTemplate: false,
95+
template: "",
96+
},
97+
format: { enabled: false, format: "" },
98+
prepend: false,
99+
appendLink: false,
100+
task: false,
101+
insertAfter: {
102+
enabled: false,
103+
after: "",
104+
insertAtEnd: false,
105+
considerSubsections: false,
106+
createIfNotFound: false,
107+
createIfNotFoundLocation: "",
108+
},
109+
newLineCapture: {
110+
enabled: false,
111+
direction: "below",
112+
},
113+
openFile: false,
114+
fileOpening: {
115+
location: "tab",
116+
direction: "vertical",
117+
mode: "default",
118+
focus: true,
119+
},
120+
...overrides,
121+
});
122+
123+
const createExecutor = (): IChoiceExecutor => ({
124+
execute: vi.fn(),
125+
variables: new Map<string, unknown>(),
126+
});
127+
128+
describe("CaptureChoiceEngine selection-as-value resolution", () => {
129+
beforeEach(() => {
130+
setUseSelectionAsCaptureValueMock.mockClear();
131+
});
132+
133+
it("uses global setting when no override is set", async () => {
134+
const engine = new CaptureChoiceEngine(
135+
createApp(),
136+
{ settings: { useSelectionAsCaptureValue: false } } as any,
137+
createChoice(),
138+
createExecutor(),
139+
);
140+
141+
await engine.run();
142+
143+
expect(setUseSelectionAsCaptureValueMock).toHaveBeenCalledWith(false);
144+
});
145+
146+
it("uses per-choice override when provided", async () => {
147+
const engine = new CaptureChoiceEngine(
148+
createApp(),
149+
{ settings: { useSelectionAsCaptureValue: true } } as any,
150+
createChoice({ useSelectionAsCaptureValue: false }),
151+
createExecutor(),
152+
);
153+
154+
await engine.run();
155+
156+
expect(setUseSelectionAsCaptureValueMock).toHaveBeenCalledWith(false);
157+
});
158+
159+
it("allows per-choice override to enable selection", async () => {
160+
const engine = new CaptureChoiceEngine(
161+
createApp(),
162+
{ settings: { useSelectionAsCaptureValue: false } } as any,
163+
createChoice({ useSelectionAsCaptureValue: true }),
164+
createExecutor(),
165+
);
166+
167+
await engine.run();
168+
169+
expect(setUseSelectionAsCaptureValueMock).toHaveBeenCalledWith(true);
170+
});
171+
});

src/engine/CaptureChoiceEngine.template-property-types.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ vi.mock("../quickAddSettingsTab", () => {
1111
inputPrompt: "single-line",
1212
devMode: false,
1313
templateFolderPath: "",
14+
useSelectionAsCaptureValue: true,
1415
announceUpdates: "major",
1516
version: "0.0.0",
1617
globalVariables: {},

src/engine/CaptureChoiceEngine.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,14 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
104104
? "optional"
105105
: "required",
106106
);
107+
const selectionOverride = this.choice.useSelectionAsCaptureValue;
108+
const globalSelectionAsValue =
109+
this.plugin.settings.useSelectionAsCaptureValue ?? true;
110+
const useSelectionAsCaptureValue =
111+
typeof selectionOverride === "boolean"
112+
? selectionOverride
113+
: globalSelectionAsValue;
114+
this.formatter.setUseSelectionAsCaptureValue(useSelectionAsCaptureValue);
107115

108116
const filePath = await this.getFormattedPathToCaptureTo(
109117
this.choice.captureToActiveFile,

src/engine/MacroChoiceEngine.notice.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ vi.mock("../quickAddSettingsTab", () => {
66
inputPrompt: "single-line",
77
devMode: false,
88
templateFolderPath: "",
9+
useSelectionAsCaptureValue: true,
910
announceUpdates: "major",
1011
version: "0.0.0",
1112
globalVariables: {},

src/engine/TemplateChoiceEngine.notice.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ vi.mock("../quickAddSettingsTab", () => {
66
inputPrompt: "single-line",
77
devMode: false,
88
templateFolderPath: "",
9+
useSelectionAsCaptureValue: true,
910
announceUpdates: "major",
1011
version: "0.0.0",
1112
globalVariables: {},
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { App } from "obsidian";
3+
import { CaptureChoiceFormatter } from "./captureChoiceFormatter";
4+
5+
const { promptMock } = vi.hoisted(() => ({
6+
promptMock: vi.fn().mockResolvedValue("prompted"),
7+
}));
8+
9+
vi.mock("../gui/InputPrompt", () => ({
10+
__esModule: true,
11+
default: class {
12+
factory() {
13+
return {
14+
Prompt: promptMock,
15+
PromptWithContext: promptMock,
16+
} as any;
17+
}
18+
},
19+
}));
20+
21+
vi.mock("../quickAddSettingsTab", () => ({
22+
QuickAddSettingsTab: class {},
23+
}));
24+
25+
vi.mock("../main", () => ({
26+
__esModule: true,
27+
default: class QuickAddMock {},
28+
}));
29+
30+
vi.mock("obsidian-dataview", () => ({
31+
__esModule: true,
32+
getAPI: vi.fn().mockReturnValue(null),
33+
}));
34+
35+
const createFormatter = (selection: string | null) => {
36+
const app = {
37+
workspace: {
38+
getActiveViewOfType: vi.fn().mockReturnValue(
39+
selection === null
40+
? null
41+
: {
42+
editor: {
43+
getSelection: () => selection,
44+
},
45+
},
46+
),
47+
getActiveFile: vi.fn().mockReturnValue(null),
48+
},
49+
} as unknown as App;
50+
51+
const plugin = {
52+
settings: {
53+
inputPrompt: "single-line",
54+
enableTemplatePropertyTypes: false,
55+
globalVariables: {},
56+
useSelectionAsCaptureValue: true,
57+
},
58+
} as any;
59+
60+
return new CaptureChoiceFormatter(app, plugin);
61+
};
62+
63+
describe("CaptureChoiceFormatter selection-as-value behavior", () => {
64+
beforeEach(() => {
65+
promptMock.mockClear();
66+
});
67+
68+
it("uses selection for {{VALUE}} when enabled", async () => {
69+
const formatter = createFormatter("Selected text");
70+
formatter.setUseSelectionAsCaptureValue(true);
71+
72+
const result = await formatter.formatContentOnly("{{VALUE}}");
73+
74+
expect(result).toBe("Selected text");
75+
expect(promptMock).not.toHaveBeenCalled();
76+
});
77+
78+
it("prompts for {{VALUE}} when selection use is disabled", async () => {
79+
const formatter = createFormatter("Selected text");
80+
formatter.setUseSelectionAsCaptureValue(false);
81+
82+
const result = await formatter.formatContentOnly(
83+
"Value: {{VALUE}} / Selected: {{SELECTED}}",
84+
);
85+
86+
expect(result).toBe("Value: prompted / Selected: Selected text");
87+
expect(promptMock).toHaveBeenCalledTimes(1);
88+
});
89+
90+
it("treats whitespace-only selection as empty when enabled", async () => {
91+
const formatter = createFormatter(" \n\t ");
92+
formatter.setUseSelectionAsCaptureValue(true);
93+
94+
const result = await formatter.formatContentOnly("{{VALUE}}");
95+
96+
expect(result).toBe("prompted");
97+
expect(promptMock).toHaveBeenCalledTimes(1);
98+
});
99+
});

0 commit comments

Comments
 (0)