Skip to content

Commit 231e908

Browse files
authored
fix: add insert-after blank-line mode (#1056)
* fix: add blank-line mode for insert after * fix: guard blank-line mode default in UI
1 parent 818c036 commit 231e908

File tree

6 files changed

+134
-13
lines changed

6 files changed

+134
-13
lines changed

docs/docs/Choices/CaptureChoice.md

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

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

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

6671
```markdown
6772
# 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";
@@ -212,13 +213,31 @@ export class CaptureChoiceFormatter extends CompleteFormatter {
212213
return partialIndex; // -1 if no match at all
213214
}
214215

216+
private shouldSkipBlankLinesAfterMatch(
217+
mode: BlankLineAfterMatchMode,
218+
line: string,
219+
): boolean {
220+
if (mode === "skip") return true;
221+
if (mode === "none") return false;
222+
return this.isAtxHeading(line);
223+
}
224+
225+
private isAtxHeading(line: string): boolean {
226+
return /^\s{0,3}#{1,6}\s+\S/.test(line);
227+
}
228+
215229
private findInsertAfterPositionWithBlankLines(
216230
lines: string[],
217231
matchIndex: number,
218232
body: string,
233+
mode: BlankLineAfterMatchMode,
219234
): number {
220235
if (matchIndex < 0) return matchIndex;
221236

237+
const matchLine = lines[matchIndex] ?? "";
238+
const shouldSkip = this.shouldSkipBlankLinesAfterMatch(mode, matchLine);
239+
if (!shouldSkip) return matchIndex;
240+
222241
// Ignore the trailing empty line that results from split("\n") when the
223242
// file ends with a newline. This preserves existing EOF behavior.
224243
const scanLimit = body.endsWith("\n")
@@ -271,10 +290,13 @@ export class CaptureChoiceFormatter extends CompleteFormatter {
271290

272291
targetPosition = endOfSectionIndex ?? fileContentLines.length - 1;
273292
} else {
293+
const blankLineMode =
294+
this.choice.insertAfter?.blankLineAfterMatchMode ?? "auto";
274295
targetPosition = this.findInsertAfterPositionWithBlankLines(
275296
fileContentLines,
276297
targetPosition,
277298
this.fileContent,
299+
blankLineMode,
278300
);
279301
}
280302

src/gui/ChoiceBuilder/captureChoiceBuilder.ts

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

446446
new Setting(this.contentEl)
447447
.setName("Insert after")
@@ -497,9 +497,45 @@ export class CaptureChoiceBuilder extends ChoiceBuilder {
497497
.addToggle((toggle) =>
498498
toggle
499499
.setValue(this.choice.insertAfter?.insertAtEnd)
500-
.onChange((value) => (this.choice.insertAfter.insertAtEnd = value)),
500+
.onChange((value) => {
501+
this.choice.insertAfter.insertAtEnd = value;
502+
this.reload();
503+
}),
501504
);
502505

506+
if (!this.choice.insertAfter?.blankLineAfterMatchMode) {
507+
this.choice.insertAfter.blankLineAfterMatchMode = "auto";
508+
}
509+
510+
const blankLineModeDesc =
511+
"Controls whether Insert After skips existing blank lines after the matched line.";
512+
const insertAtEndEnabled = !!this.choice.insertAfter?.insertAtEnd;
513+
const blankLineModeSetting: Setting = new Setting(this.contentEl);
514+
blankLineModeSetting
515+
.setName("Blank lines after match")
516+
.setDesc(
517+
insertAtEndEnabled
518+
? "Not used when inserting at end of section."
519+
: blankLineModeDesc,
520+
)
521+
.addDropdown((dropdown) => {
522+
dropdown
523+
.addOption("auto", "Auto (headings only)")
524+
.addOption("skip", "Always skip")
525+
.addOption("none", "Never skip")
526+
.setValue(
527+
this.choice.insertAfter?.blankLineAfterMatchMode ?? "auto",
528+
)
529+
.onChange((value) => {
530+
this.choice.insertAfter.blankLineAfterMatchMode = value as
531+
| "auto"
532+
| "skip"
533+
| "none";
534+
});
535+
dropdown.setDisabled(insertAtEndEnabled);
536+
});
537+
blankLineModeSetting.setDisabled(insertAtEndEnabled);
538+
503539
new Setting(this.contentEl)
504540
.setName("Consider subsections")
505541
.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

@@ -22,6 +23,7 @@ export class CaptureChoice extends Choice implements ICaptureChoice {
2223
considerSubsections: boolean;
2324
createIfNotFound: boolean;
2425
createIfNotFoundLocation: string;
26+
blankLineAfterMatchMode?: BlankLineAfterMatchMode;
2527
};
2628
newLineCapture: {
2729
enabled: boolean;
@@ -60,6 +62,7 @@ export class CaptureChoice extends Choice implements ICaptureChoice {
6062
considerSubsections: false,
6163
createIfNotFound: false,
6264
createIfNotFoundLocation: "top",
65+
blankLineAfterMatchMode: "auto",
6366
};
6467
this.newLineCapture = {
6568
enabled: false,
@@ -89,6 +92,9 @@ export class CaptureChoice extends Choice implements ICaptureChoice {
8992
if (loaded.templater.afterCapture !== "wholeFile") {
9093
loaded.templater.afterCapture = "none";
9194
}
95+
if (loaded.insertAfter && !loaded.insertAfter.blankLineAfterMatchMode) {
96+
loaded.insertAfter.blankLineAfterMatchMode = "auto";
97+
}
9298
return loaded;
9399
}
94100
}

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;
@@ -33,6 +35,7 @@ export default interface ICaptureChoice extends IChoice {
3335
considerSubsections: boolean;
3436
createIfNotFound: boolean;
3537
createIfNotFoundLocation: string;
38+
blankLineAfterMatchMode?: BlankLineAfterMatchMode;
3639
};
3740
newLineCapture: {
3841
enabled: boolean;

0 commit comments

Comments
 (0)