Skip to content

Commit bdeea79

Browse files
committed
fix: add blank-line mode for insert after
1 parent 765bf7a commit bdeea79

File tree

6 files changed

+132
-13
lines changed

6 files changed

+132
-13
lines changed

docs/docs/Choices/CaptureChoice.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,15 @@ If you have a tag called `#people`, and you type `#people` in the _Capture To_ f
5757
## Insert after
5858

5959
Insert After will allow you to insert the text after some line with the specified text.
60-
If the matched line is followed by one or more blank lines (including whitespace-only
61-
lines), QuickAdd inserts after those blank lines to preserve spacing under headings.
60+
By default, QuickAdd preserves blank lines after ATX headings to keep heading
61+
spacing intact. Use **Blank lines after match** to control this behavior:
6262

63-
Example (Insert After `# H` with content `X`):
63+
- **Auto (headings only)** - Skip blank lines only when the matched line is
64+
a Markdown heading.
65+
- **Always skip** - Skip all consecutive blank lines after the match.
66+
- **Never skip** - Insert immediately after the matched line.
67+
68+
Example (Auto, Insert After `# H` with content `X`):
6469

6570
```markdown
6671
# H

src/formatters/captureChoiceFormatter-frontmatter.test.ts

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ const createChoice = (overrides: Partial<ICaptureChoice> = {}): ICaptureChoice =
111111
prepend: false,
112112
appendLink: false,
113113
task: false,
114-
insertAfter: { enabled: false, after: '', insertAtEnd: false, considerSubsections: false, createIfNotFound: false, createIfNotFoundLocation: '' },
114+
insertAfter: { enabled: false, after: '', insertAtEnd: false, considerSubsections: false, createIfNotFound: false, createIfNotFoundLocation: '', blankLineAfterMatchMode: 'auto' },
115115
newLineCapture: { enabled: false, direction: 'below' },
116116
openFile: false,
117117
fileOpening: { location: 'tab', direction: 'vertical', mode: 'default', focus: true },
@@ -205,7 +205,10 @@ describe('CaptureChoiceFormatter insert after blank lines', () => {
205205
return { formatter, file };
206206
};
207207

208-
const createInsertAfterChoice = (after: string): ICaptureChoice =>
208+
const createInsertAfterChoice = (
209+
after: string,
210+
blankLineAfterMatchMode?: 'auto' | 'skip' | 'none',
211+
): ICaptureChoice =>
209212
createChoice({
210213
insertAfter: {
211214
enabled: true,
@@ -214,10 +217,11 @@ describe('CaptureChoiceFormatter insert after blank lines', () => {
214217
considerSubsections: false,
215218
createIfNotFound: false,
216219
createIfNotFoundLocation: '',
220+
blankLineAfterMatchMode,
217221
},
218222
});
219223

220-
it('skips one blank line after the match', async () => {
224+
it('auto mode skips one blank line after a heading match', async () => {
221225
const { formatter, file } = createFormatter();
222226
const choice = createInsertAfterChoice('# H');
223227
const fileContent = ['# H', '', 'A'].join('\n');
@@ -232,7 +236,7 @@ describe('CaptureChoiceFormatter insert after blank lines', () => {
232236
expect(result).toBe(['# H', '', 'X', 'A'].join('\n'));
233237
});
234238

235-
it('skips multiple blank lines after the match', async () => {
239+
it('auto mode skips multiple blank lines after a heading match', async () => {
236240
const { formatter, file } = createFormatter();
237241
const choice = createInsertAfterChoice('# H');
238242
const fileContent = ['# H', '', '', 'A'].join('\n');
@@ -247,7 +251,7 @@ describe('CaptureChoiceFormatter insert after blank lines', () => {
247251
expect(result).toBe(['# H', '', '', 'X', 'A'].join('\n'));
248252
});
249253

250-
it('treats whitespace-only lines as blank', async () => {
254+
it('auto mode treats whitespace-only lines as blank', async () => {
251255
const { formatter, file } = createFormatter();
252256
const choice = createInsertAfterChoice('# H');
253257
const fileContent = ['# H', ' \t', 'A'].join('\n');
@@ -262,7 +266,7 @@ describe('CaptureChoiceFormatter insert after blank lines', () => {
262266
expect(result).toBe(['# H', ' \t', 'X', 'A'].join('\n'));
263267
});
264268

265-
it('keeps behavior unchanged when no blank lines follow', async () => {
269+
it('auto mode keeps behavior unchanged when no blank lines follow', async () => {
266270
const { formatter, file } = createFormatter();
267271
const choice = createInsertAfterChoice('# H');
268272
const fileContent = ['# H', 'A'].join('\n');
@@ -277,7 +281,7 @@ describe('CaptureChoiceFormatter insert after blank lines', () => {
277281
expect(result).toBe(['# H', 'X', 'A'].join('\n'));
278282
});
279283

280-
it('keeps behavior unchanged when match is at EOF', async () => {
284+
it('auto mode keeps behavior unchanged when match is at EOF', async () => {
281285
const { formatter, file } = createFormatter();
282286
const choice = createInsertAfterChoice('# H');
283287
const fileContent = '# H';
@@ -292,7 +296,7 @@ describe('CaptureChoiceFormatter insert after blank lines', () => {
292296
expect(result).toBe('# H\nX\n');
293297
});
294298

295-
it('handles CRLF content when skipping blank lines', async () => {
299+
it('auto mode handles CRLF content when skipping blank lines', async () => {
296300
const { formatter, file } = createFormatter();
297301
const choice = createInsertAfterChoice('# H');
298302
const fileContent = '# H\r\n\r\nA';
@@ -306,4 +310,49 @@ describe('CaptureChoiceFormatter insert after blank lines', () => {
306310

307311
expect(result).toBe('# H\r\n\r\nX\nA');
308312
});
313+
314+
it('auto mode does not skip blanks for non-heading matches', async () => {
315+
const { formatter, file } = createFormatter();
316+
const choice = createInsertAfterChoice('- Item 1');
317+
const fileContent = ['- Item 1', '', '- Item 2'].join('\n');
318+
319+
const result = await formatter.formatContentWithFile(
320+
'X\n',
321+
choice,
322+
fileContent,
323+
file,
324+
);
325+
326+
expect(result).toBe(['- Item 1', 'X', '', '- Item 2'].join('\n'));
327+
});
328+
329+
it('always skip mode skips blank lines after non-heading matches', async () => {
330+
const { formatter, file } = createFormatter();
331+
const choice = createInsertAfterChoice('- Item 1', 'skip');
332+
const fileContent = ['- Item 1', '', '- Item 2'].join('\n');
333+
334+
const result = await formatter.formatContentWithFile(
335+
'X\n',
336+
choice,
337+
fileContent,
338+
file,
339+
);
340+
341+
expect(result).toBe(['- Item 1', '', 'X', '- Item 2'].join('\n'));
342+
});
343+
344+
it('never skip mode inserts immediately after the match', async () => {
345+
const { formatter, file } = createFormatter();
346+
const choice = createInsertAfterChoice('# H', 'none');
347+
const fileContent = ['# H', '', 'A'].join('\n');
348+
349+
const result = await formatter.formatContentWithFile(
350+
'X\n',
351+
choice,
352+
fileContent,
353+
file,
354+
);
355+
356+
expect(result).toBe(['# H', 'X', '', 'A'].join('\n'));
357+
});
309358
});

src/formatters/captureChoiceFormatter.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from "../constants";
88
import { log } from "../logger/logManager";
99
import type ICaptureChoice from "../types/choices/ICaptureChoice";
10+
import type { BlankLineAfterMatchMode } from "../types/choices/ICaptureChoice";
1011
import { templaterParseTemplate } from "../utilityObsidian";
1112
import { reportError } from "../utils/errorUtils";
1213
import { CompleteFormatter } from "./completeFormatter";
@@ -198,13 +199,31 @@ export class CaptureChoiceFormatter extends CompleteFormatter {
198199
return partialIndex; // -1 if no match at all
199200
}
200201

202+
private shouldSkipBlankLinesAfterMatch(
203+
mode: BlankLineAfterMatchMode,
204+
line: string,
205+
): boolean {
206+
if (mode === "skip") return true;
207+
if (mode === "none") return false;
208+
return this.isAtxHeading(line);
209+
}
210+
211+
private isAtxHeading(line: string): boolean {
212+
return /^\s{0,3}#{1,6}\s+\S/.test(line);
213+
}
214+
201215
private findInsertAfterPositionWithBlankLines(
202216
lines: string[],
203217
matchIndex: number,
204218
body: string,
219+
mode: BlankLineAfterMatchMode,
205220
): number {
206221
if (matchIndex < 0) return matchIndex;
207222

223+
const matchLine = lines[matchIndex] ?? "";
224+
const shouldSkip = this.shouldSkipBlankLinesAfterMatch(mode, matchLine);
225+
if (!shouldSkip) return matchIndex;
226+
208227
// Ignore the trailing empty line that results from split("\n") when the
209228
// file ends with a newline. This preserves existing EOF behavior.
210229
const scanLimit = body.endsWith("\n")
@@ -257,10 +276,13 @@ export class CaptureChoiceFormatter extends CompleteFormatter {
257276

258277
targetPosition = endOfSectionIndex ?? fileContentLines.length - 1;
259278
} else {
279+
const blankLineMode =
280+
this.choice.insertAfter?.blankLineAfterMatchMode ?? "auto";
260281
targetPosition = this.findInsertAfterPositionWithBlankLines(
261282
fileContentLines,
262283
targetPosition,
263284
this.fileContent,
285+
blankLineMode,
264286
);
265287
}
266288

src/gui/ChoiceBuilder/captureChoiceBuilder.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,7 @@ export class CaptureChoiceBuilder extends ChoiceBuilder {
410410
const descText =
411411
"Insert capture after specified line. Accepts format syntax. " +
412412
"Tip: use a heading (starts with #) to target a section. " +
413-
"If the matched line is followed by blank lines, QuickAdd inserts after them to preserve spacing.";
413+
"Blank line handling is configurable below.";
414414

415415
new Setting(this.contentEl)
416416
.setName("Insert after")
@@ -466,9 +466,43 @@ export class CaptureChoiceBuilder extends ChoiceBuilder {
466466
.addToggle((toggle) =>
467467
toggle
468468
.setValue(this.choice.insertAfter?.insertAtEnd)
469-
.onChange((value) => (this.choice.insertAfter.insertAtEnd = value)),
469+
.onChange((value) => {
470+
this.choice.insertAfter.insertAtEnd = value;
471+
this.reload();
472+
}),
470473
);
471474

475+
if (!this.choice.insertAfter?.blankLineAfterMatchMode) {
476+
this.choice.insertAfter.blankLineAfterMatchMode = "auto";
477+
}
478+
479+
const blankLineModeDesc =
480+
"Controls whether Insert After skips existing blank lines after the matched line.";
481+
const insertAtEndEnabled = !!this.choice.insertAfter?.insertAtEnd;
482+
const blankLineModeSetting: Setting = new Setting(this.contentEl);
483+
blankLineModeSetting
484+
.setName("Blank lines after match")
485+
.setDesc(
486+
insertAtEndEnabled
487+
? "Not used when inserting at end of section."
488+
: blankLineModeDesc,
489+
)
490+
.addDropdown((dropdown) => {
491+
dropdown
492+
.addOption("auto", "Auto (headings only)")
493+
.addOption("skip", "Always skip")
494+
.addOption("none", "Never skip")
495+
.setValue(this.choice.insertAfter.blankLineAfterMatchMode)
496+
.onChange((value) => {
497+
this.choice.insertAfter.blankLineAfterMatchMode = value as
498+
| "auto"
499+
| "skip"
500+
| "none";
501+
});
502+
dropdown.setDisabled(insertAtEndEnabled);
503+
});
504+
blankLineModeSetting.setDisabled(insertAtEndEnabled);
505+
472506
new Setting(this.contentEl)
473507
.setName("Consider subsections")
474508
.setDesc(

src/types/choices/CaptureChoice.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Choice } from "./Choice";
22
import type ICaptureChoice from "./ICaptureChoice";
3+
import type { BlankLineAfterMatchMode } from "./ICaptureChoice";
34
import type { OpenLocation, FileViewMode2 } from "../fileOpening";
45
import type { AppendLinkOptions } from "../linkPlacement";
56

@@ -21,6 +22,7 @@ export class CaptureChoice extends Choice implements ICaptureChoice {
2122
considerSubsections: boolean;
2223
createIfNotFound: boolean;
2324
createIfNotFoundLocation: string;
25+
blankLineAfterMatchMode?: BlankLineAfterMatchMode;
2426
};
2527
newLineCapture: {
2628
enabled: boolean;
@@ -59,6 +61,7 @@ export class CaptureChoice extends Choice implements ICaptureChoice {
5961
considerSubsections: false,
6062
createIfNotFound: false,
6163
createIfNotFoundLocation: "top",
64+
blankLineAfterMatchMode: "auto",
6265
};
6366
this.newLineCapture = {
6467
enabled: false,
@@ -88,6 +91,9 @@ export class CaptureChoice extends Choice implements ICaptureChoice {
8891
if (loaded.templater.afterCapture !== "wholeFile") {
8992
loaded.templater.afterCapture = "none";
9093
}
94+
if (loaded.insertAfter && !loaded.insertAfter.blankLineAfterMatchMode) {
95+
loaded.insertAfter.blankLineAfterMatchMode = "auto";
96+
}
9197
return loaded;
9298
}
9399
}

src/types/choices/ICaptureChoice.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import type IChoice from "./IChoice";
22
import type { AppendLinkOptions } from "../linkPlacement";
33
import type { OpenLocation, FileViewMode2 } from "../fileOpening";
44

5+
export type BlankLineAfterMatchMode = "auto" | "skip" | "none";
6+
57
export default interface ICaptureChoice extends IChoice {
68
captureTo: string;
79
captureToActiveFile: boolean;
@@ -28,6 +30,7 @@ export default interface ICaptureChoice extends IChoice {
2830
considerSubsections: boolean;
2931
createIfNotFound: boolean;
3032
createIfNotFoundLocation: string;
33+
blankLineAfterMatchMode?: BlankLineAfterMatchMode;
3134
};
3235
newLineCapture: {
3336
enabled: boolean;

0 commit comments

Comments
 (0)