diff --git a/docs/docs/Choices/CaptureChoice.md b/docs/docs/Choices/CaptureChoice.md index f7c2f870..8ec6a957 100644 --- a/docs/docs/Choices/CaptureChoice.md +++ b/docs/docs/Choices/CaptureChoice.md @@ -58,10 +58,15 @@ If you have a tag called `#people`, and you type `#people` in the _Capture To_ f ## Insert after Insert After will allow you to insert the text after some line with the specified text. -If the matched line is followed by one or more blank lines (including whitespace-only -lines), QuickAdd inserts after those blank lines to preserve spacing under headings. +By default, QuickAdd preserves blank lines after ATX headings to keep heading +spacing intact. Use **Blank lines after match** to control this behavior: -Example (Insert After `# H` with content `X`): +- **Auto (headings only)** - Skip blank lines only when the matched line is + a Markdown heading. +- **Always skip** - Skip all consecutive blank lines after the match. +- **Never skip** - Insert immediately after the matched line. + +Example (Auto, Insert After `# H` with content `X`): ```markdown # H diff --git a/src/formatters/captureChoiceFormatter-frontmatter.test.ts b/src/formatters/captureChoiceFormatter-frontmatter.test.ts index e9d19f52..fcba4517 100644 --- a/src/formatters/captureChoiceFormatter-frontmatter.test.ts +++ b/src/formatters/captureChoiceFormatter-frontmatter.test.ts @@ -111,7 +111,7 @@ const createChoice = (overrides: Partial = {}): ICaptureChoice = prepend: false, appendLink: false, task: false, - insertAfter: { enabled: false, after: '', insertAtEnd: false, considerSubsections: false, createIfNotFound: false, createIfNotFoundLocation: '' }, + insertAfter: { enabled: false, after: '', insertAtEnd: false, considerSubsections: false, createIfNotFound: false, createIfNotFoundLocation: '', blankLineAfterMatchMode: 'auto' }, newLineCapture: { enabled: false, direction: 'below' }, openFile: false, fileOpening: { location: 'tab', direction: 'vertical', mode: 'default', focus: true }, @@ -205,7 +205,10 @@ describe('CaptureChoiceFormatter insert after blank lines', () => { return { formatter, file }; }; - const createInsertAfterChoice = (after: string): ICaptureChoice => + const createInsertAfterChoice = ( + after: string, + blankLineAfterMatchMode?: 'auto' | 'skip' | 'none', + ): ICaptureChoice => createChoice({ insertAfter: { enabled: true, @@ -214,10 +217,11 @@ describe('CaptureChoiceFormatter insert after blank lines', () => { considerSubsections: false, createIfNotFound: false, createIfNotFoundLocation: '', + blankLineAfterMatchMode, }, }); - it('skips one blank line after the match', async () => { + it('auto mode skips one blank line after a heading match', async () => { const { formatter, file } = createFormatter(); const choice = createInsertAfterChoice('# H'); const fileContent = ['# H', '', 'A'].join('\n'); @@ -232,7 +236,7 @@ describe('CaptureChoiceFormatter insert after blank lines', () => { expect(result).toBe(['# H', '', 'X', 'A'].join('\n')); }); - it('skips multiple blank lines after the match', async () => { + it('auto mode skips multiple blank lines after a heading match', async () => { const { formatter, file } = createFormatter(); const choice = createInsertAfterChoice('# H'); const fileContent = ['# H', '', '', 'A'].join('\n'); @@ -247,7 +251,7 @@ describe('CaptureChoiceFormatter insert after blank lines', () => { expect(result).toBe(['# H', '', '', 'X', 'A'].join('\n')); }); - it('treats whitespace-only lines as blank', async () => { + it('auto mode treats whitespace-only lines as blank', async () => { const { formatter, file } = createFormatter(); const choice = createInsertAfterChoice('# H'); const fileContent = ['# H', ' \t', 'A'].join('\n'); @@ -262,7 +266,7 @@ describe('CaptureChoiceFormatter insert after blank lines', () => { expect(result).toBe(['# H', ' \t', 'X', 'A'].join('\n')); }); - it('keeps behavior unchanged when no blank lines follow', async () => { + it('auto mode keeps behavior unchanged when no blank lines follow', async () => { const { formatter, file } = createFormatter(); const choice = createInsertAfterChoice('# H'); const fileContent = ['# H', 'A'].join('\n'); @@ -277,7 +281,7 @@ describe('CaptureChoiceFormatter insert after blank lines', () => { expect(result).toBe(['# H', 'X', 'A'].join('\n')); }); - it('keeps behavior unchanged when match is at EOF', async () => { + it('auto mode keeps behavior unchanged when match is at EOF', async () => { const { formatter, file } = createFormatter(); const choice = createInsertAfterChoice('# H'); const fileContent = '# H'; @@ -292,7 +296,7 @@ describe('CaptureChoiceFormatter insert after blank lines', () => { expect(result).toBe('# H\nX\n'); }); - it('handles CRLF content when skipping blank lines', async () => { + it('auto mode handles CRLF content when skipping blank lines', async () => { const { formatter, file } = createFormatter(); const choice = createInsertAfterChoice('# H'); const fileContent = '# H\r\n\r\nA'; @@ -306,4 +310,49 @@ describe('CaptureChoiceFormatter insert after blank lines', () => { expect(result).toBe('# H\r\n\r\nX\nA'); }); + + it('auto mode does not skip blanks for non-heading matches', async () => { + const { formatter, file } = createFormatter(); + const choice = createInsertAfterChoice('- Item 1'); + const fileContent = ['- Item 1', '', '- Item 2'].join('\n'); + + const result = await formatter.formatContentWithFile( + 'X\n', + choice, + fileContent, + file, + ); + + expect(result).toBe(['- Item 1', 'X', '', '- Item 2'].join('\n')); + }); + + it('always skip mode skips blank lines after non-heading matches', async () => { + const { formatter, file } = createFormatter(); + const choice = createInsertAfterChoice('- Item 1', 'skip'); + const fileContent = ['- Item 1', '', '- Item 2'].join('\n'); + + const result = await formatter.formatContentWithFile( + 'X\n', + choice, + fileContent, + file, + ); + + expect(result).toBe(['- Item 1', '', 'X', '- Item 2'].join('\n')); + }); + + it('never skip mode inserts immediately after the match', async () => { + const { formatter, file } = createFormatter(); + const choice = createInsertAfterChoice('# H', 'none'); + const fileContent = ['# H', '', 'A'].join('\n'); + + const result = await formatter.formatContentWithFile( + 'X\n', + choice, + fileContent, + file, + ); + + expect(result).toBe(['# H', 'X', '', 'A'].join('\n')); + }); }); diff --git a/src/formatters/captureChoiceFormatter.ts b/src/formatters/captureChoiceFormatter.ts index aef99606..69b4c924 100644 --- a/src/formatters/captureChoiceFormatter.ts +++ b/src/formatters/captureChoiceFormatter.ts @@ -7,6 +7,7 @@ import { } from "../constants"; import { log } from "../logger/logManager"; import type ICaptureChoice from "../types/choices/ICaptureChoice"; +import type { BlankLineAfterMatchMode } from "../types/choices/ICaptureChoice"; import { templaterParseTemplate } from "../utilityObsidian"; import { reportError } from "../utils/errorUtils"; import { CompleteFormatter } from "./completeFormatter"; @@ -212,13 +213,31 @@ export class CaptureChoiceFormatter extends CompleteFormatter { return partialIndex; // -1 if no match at all } + private shouldSkipBlankLinesAfterMatch( + mode: BlankLineAfterMatchMode, + line: string, + ): boolean { + if (mode === "skip") return true; + if (mode === "none") return false; + return this.isAtxHeading(line); + } + + private isAtxHeading(line: string): boolean { + return /^\s{0,3}#{1,6}\s+\S/.test(line); + } + private findInsertAfterPositionWithBlankLines( lines: string[], matchIndex: number, body: string, + mode: BlankLineAfterMatchMode, ): number { if (matchIndex < 0) return matchIndex; + const matchLine = lines[matchIndex] ?? ""; + const shouldSkip = this.shouldSkipBlankLinesAfterMatch(mode, matchLine); + if (!shouldSkip) return matchIndex; + // Ignore the trailing empty line that results from split("\n") when the // file ends with a newline. This preserves existing EOF behavior. const scanLimit = body.endsWith("\n") @@ -271,10 +290,13 @@ export class CaptureChoiceFormatter extends CompleteFormatter { targetPosition = endOfSectionIndex ?? fileContentLines.length - 1; } else { + const blankLineMode = + this.choice.insertAfter?.blankLineAfterMatchMode ?? "auto"; targetPosition = this.findInsertAfterPositionWithBlankLines( fileContentLines, targetPosition, this.fileContent, + blankLineMode, ); } diff --git a/src/gui/ChoiceBuilder/captureChoiceBuilder.ts b/src/gui/ChoiceBuilder/captureChoiceBuilder.ts index 55b4707c..58b728a5 100644 --- a/src/gui/ChoiceBuilder/captureChoiceBuilder.ts +++ b/src/gui/ChoiceBuilder/captureChoiceBuilder.ts @@ -441,7 +441,7 @@ export class CaptureChoiceBuilder extends ChoiceBuilder { const descText = "Insert capture after specified line. Accepts format syntax. " + "Tip: use a heading (starts with #) to target a section. " + - "If the matched line is followed by blank lines, QuickAdd inserts after them to preserve spacing."; + "Blank line handling is configurable below."; new Setting(this.contentEl) .setName("Insert after") @@ -497,9 +497,45 @@ export class CaptureChoiceBuilder extends ChoiceBuilder { .addToggle((toggle) => toggle .setValue(this.choice.insertAfter?.insertAtEnd) - .onChange((value) => (this.choice.insertAfter.insertAtEnd = value)), + .onChange((value) => { + this.choice.insertAfter.insertAtEnd = value; + this.reload(); + }), ); + if (!this.choice.insertAfter?.blankLineAfterMatchMode) { + this.choice.insertAfter.blankLineAfterMatchMode = "auto"; + } + + const blankLineModeDesc = + "Controls whether Insert After skips existing blank lines after the matched line."; + const insertAtEndEnabled = !!this.choice.insertAfter?.insertAtEnd; + const blankLineModeSetting: Setting = new Setting(this.contentEl); + blankLineModeSetting + .setName("Blank lines after match") + .setDesc( + insertAtEndEnabled + ? "Not used when inserting at end of section." + : blankLineModeDesc, + ) + .addDropdown((dropdown) => { + dropdown + .addOption("auto", "Auto (headings only)") + .addOption("skip", "Always skip") + .addOption("none", "Never skip") + .setValue( + this.choice.insertAfter?.blankLineAfterMatchMode ?? "auto", + ) + .onChange((value) => { + this.choice.insertAfter.blankLineAfterMatchMode = value as + | "auto" + | "skip" + | "none"; + }); + dropdown.setDisabled(insertAtEndEnabled); + }); + blankLineModeSetting.setDisabled(insertAtEndEnabled); + new Setting(this.contentEl) .setName("Consider subsections") .setDesc( diff --git a/src/types/choices/CaptureChoice.ts b/src/types/choices/CaptureChoice.ts index 1566d6c2..3c2b0e41 100644 --- a/src/types/choices/CaptureChoice.ts +++ b/src/types/choices/CaptureChoice.ts @@ -1,5 +1,6 @@ import { Choice } from "./Choice"; import type ICaptureChoice from "./ICaptureChoice"; +import type { BlankLineAfterMatchMode } from "./ICaptureChoice"; import type { OpenLocation, FileViewMode2 } from "../fileOpening"; import type { AppendLinkOptions } from "../linkPlacement"; @@ -22,6 +23,7 @@ export class CaptureChoice extends Choice implements ICaptureChoice { considerSubsections: boolean; createIfNotFound: boolean; createIfNotFoundLocation: string; + blankLineAfterMatchMode?: BlankLineAfterMatchMode; }; newLineCapture: { enabled: boolean; @@ -60,6 +62,7 @@ export class CaptureChoice extends Choice implements ICaptureChoice { considerSubsections: false, createIfNotFound: false, createIfNotFoundLocation: "top", + blankLineAfterMatchMode: "auto", }; this.newLineCapture = { enabled: false, @@ -89,6 +92,9 @@ export class CaptureChoice extends Choice implements ICaptureChoice { if (loaded.templater.afterCapture !== "wholeFile") { loaded.templater.afterCapture = "none"; } + if (loaded.insertAfter && !loaded.insertAfter.blankLineAfterMatchMode) { + loaded.insertAfter.blankLineAfterMatchMode = "auto"; + } return loaded; } } diff --git a/src/types/choices/ICaptureChoice.ts b/src/types/choices/ICaptureChoice.ts index 25f1fafd..9b2b6d82 100644 --- a/src/types/choices/ICaptureChoice.ts +++ b/src/types/choices/ICaptureChoice.ts @@ -2,6 +2,8 @@ import type IChoice from "./IChoice"; import type { AppendLinkOptions } from "../linkPlacement"; import type { OpenLocation, FileViewMode2 } from "../fileOpening"; +export type BlankLineAfterMatchMode = "auto" | "skip" | "none"; + export default interface ICaptureChoice extends IChoice { captureTo: string; captureToActiveFile: boolean; @@ -33,6 +35,7 @@ export default interface ICaptureChoice extends IChoice { considerSubsections: boolean; createIfNotFound: boolean; createIfNotFoundLocation: string; + blankLineAfterMatchMode?: BlankLineAfterMatchMode; }; newLineCapture: { enabled: boolean;