From 2b85f4e45f0d610bb5cd5fa4df32702134af6c14 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Thu, 16 Apr 2026 22:31:19 +0200 Subject: [PATCH] fix: preserve valid bold markdown in rich editor --- src/utils/compact-markdown.test.ts | 12 ++++- src/utils/compact-markdown.ts | 52 ++++++++++++++----- .../editor-formatting-persistence.spec.ts | 40 ++++++++++---- 3 files changed, 82 insertions(+), 22 deletions(-) diff --git a/src/utils/compact-markdown.test.ts b/src/utils/compact-markdown.test.ts index e0f54d49..cb7fd5f0 100644 --- a/src/utils/compact-markdown.test.ts +++ b/src/utils/compact-markdown.test.ts @@ -122,7 +122,17 @@ describe('compactMarkdown', () => { it('decodes HTML entities from BlockNote bold+code output', () => { const input = '**Remove **`NoteWindow`** and render the full **`App`** component.**\n' - expect(compactMarkdown(input)).toBe('**Remove **`NoteWindow`** and render the full **`App`** component.**\n') + expect(compactMarkdown(input)).toBe('**Remove** `NoteWindow` **and render the full** `App` **component.**\n') + }) + + it('moves trailing whitespace outside bold markers', () => { + const input = '**Luca **\n' + expect(compactMarkdown(input)).toBe('**Luca** \n') + }) + + it('moves leading whitespace outside bold markers', () => { + const input = '** Luca**\n' + expect(compactMarkdown(input)).toBe(' **Luca**\n') }) it('decodes multiple HTML entity types', () => { diff --git a/src/utils/compact-markdown.ts b/src/utils/compact-markdown.ts index 3b518191..a3b1c90d 100644 --- a/src/utils/compact-markdown.ts +++ b/src/utils/compact-markdown.ts @@ -4,6 +4,7 @@ * - Tight lists (no blank lines between consecutive list items) * - Bullet list markers normalized to `-` (BlockNote outputs `*`) * - HTML entities like ` ` decoded back to spaces + * - Leading/trailing inline whitespace moved outside bold markers * - Stray hard-break-only lines removed after a markdown hard break * - No runs of 3+ blank lines (collapsed to one blank line) * - No trailing blank lines @@ -30,6 +31,7 @@ export function compactMarkdown(md: string): string { const LIST_RE = /^(\s*)([-*+]|\d+\.)\s/ const HARD_BREAK_ONLY_RE = /^\\+$/ const TRAILING_INLINE_CLOSERS_RE = /(?:[*_~`]+)$/ +const STRONG_RE = /\*\*([^*\n]*?)\*\*/g interface MarkdownDocument { lines: string[] @@ -40,6 +42,10 @@ interface LinePosition { idx: number } +interface MarkdownLineValue { + line: string +} + interface NormalizedLinePosition extends LinePosition { line: string } @@ -53,7 +59,7 @@ function processMarkdownLine( ): { inCodeBlock: boolean; line: string | null } { const rawLine = doc.lines[idx] - if (isFenceDelimiter(rawLine)) { + if (isFenceDelimiter({ line: rawLine })) { return { inCodeBlock: !inCodeBlock, line: rawLine } } @@ -61,7 +67,7 @@ function processMarkdownLine( return { inCodeBlock, line: rawLine } } - const line = normalizeMarkdownLine(rawLine) + const line = normalizeMarkdownLine({ line: rawLine }) if (shouldSkipLine({ doc, idx, line })) { return { inCodeBlock, line: null } } @@ -69,12 +75,14 @@ function processMarkdownLine( return { inCodeBlock, line } } -function isFenceDelimiter(line: string): boolean { +function isFenceDelimiter({ line }: MarkdownLineValue): boolean { return line.trimStart().startsWith('```') } -function normalizeMarkdownLine(line: string): string { - return decodeHtmlEntities(normalizeBulletMarker(line)) +function normalizeMarkdownLine({ line }: MarkdownLineValue): string { + const normalizedBullets = normalizeBulletMarker({ line }) + const decodedEntities = decodeHtmlEntities({ line: normalizedBullets }) + return normalizeStrongWhitespace({ line: decodedEntities }) } function shouldSkipLine({ doc, idx, line }: NormalizedLinePosition): boolean { @@ -116,20 +124,20 @@ function findNextNonBlank({ doc, idx }: LinePosition): number | null { } function isRedundantHardBreakLine({ doc, idx, line }: NormalizedLinePosition): boolean { - if (!isHardBreakOnlyLine(line)) return false + if (!isHardBreakOnlyLine({ line })) return false const prev = findPrevNonBlank({ doc, idx }) if (prev === null) return false - const prevLine = normalizeMarkdownLine(doc.lines[prev]) - return isHardBreakOnlyLine(prevLine) || endsWithHardBreakMarker(prevLine) + const prevLine = normalizeMarkdownLine({ line: doc.lines[prev] }) + return isHardBreakOnlyLine({ line: prevLine }) || endsWithHardBreakMarker({ line: prevLine }) } -function isHardBreakOnlyLine(line: string): boolean { +function isHardBreakOnlyLine({ line }: MarkdownLineValue): boolean { return HARD_BREAK_ONLY_RE.test(line.trim()) } -function endsWithHardBreakMarker(line: string): boolean { +function endsWithHardBreakMarker({ line }: MarkdownLineValue): boolean { const trimmed = line.trimEnd() if (trimmed.endsWith('\\\\')) return true return trimmed.replace(TRAILING_INLINE_CLOSERS_RE, '').endsWith('\\\\') @@ -137,16 +145,36 @@ function endsWithHardBreakMarker(line: string): boolean { const BULLET_RE = /^(\s*)\*(\s)/ /** Normalize `*` bullet markers to `-` (BlockNote default → standard convention) */ -function normalizeBulletMarker(line: string): string { +function normalizeBulletMarker({ line }: MarkdownLineValue): string { return line.replace(BULLET_RE, '$1-$2') } /** Decode HTML entities that BlockNote inserts ( & etc.) */ -function decodeHtmlEntities(line: string): string { +function decodeHtmlEntities({ line }: MarkdownLineValue): string { if (!line.includes('&#x')) return line return line.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))) } +function normalizeStrongWhitespace({ line }: MarkdownLineValue): string { + return line.replace(STRONG_RE, (match, content: string) => { + const leadingWhitespace = content.match(/^\s+/)?.[0] ?? '' + const trailingWhitespace = content.match(/\s+$/)?.[0] ?? '' + if (!leadingWhitespace && !trailingWhitespace) { + return match + } + + const strongContent = content.slice( + leadingWhitespace.length, + content.length - trailingWhitespace.length, + ) + if (!strongContent) { + return match + } + + return `${leadingWhitespace}**${strongContent}**${trailingWhitespace}` + }) +} + function finalizeMarkdown(doc: MarkdownDocument): string { while (doc.lines.length > 0 && doc.lines[doc.lines.length - 1].trim() === '') { doc.lines.pop() diff --git a/tests/smoke/editor-formatting-persistence.spec.ts b/tests/smoke/editor-formatting-persistence.spec.ts index 4a7d9372..47f4cdc0 100644 --- a/tests/smoke/editor-formatting-persistence.spec.ts +++ b/tests/smoke/editor-formatting-persistence.spec.ts @@ -127,6 +127,26 @@ async function assertSlashMenuBlockCommandPersists(page: Page, options: { ).toContainText(options.insertedText) } +async function assertKeyboardBoldPersists(page: Page, options: { + selectionText: string + rawIncludes: string + rawExcludes?: string +}) { + await openNote(page, 'Note B') + await selectWord(page, 1, options.selectionText) + await page.keyboard.press('Meta+b') + await page.waitForTimeout(700) + + await roundTripThroughAnotherNote(page) + await openRawMode(page) + + const raw = await getRawEditorContent(page) + expect(raw).toContain(options.rawIncludes) + if (options.rawExcludes) { + expect(raw).not.toContain(options.rawExcludes) + } +} + const slashMenuPersistenceScenarios = [ { name: 'slash menu block commands persist bullet lists', @@ -179,16 +199,18 @@ test('toolbar only exposes audited markdown-safe formatting controls', async ({ }) test('supported inline formatting persists after note switches when applied from keyboard', async ({ page }) => { - await openNote(page, 'Note B') - await selectWord(page, 1, 'referenced') - await page.keyboard.press('Meta+b') - await page.waitForTimeout(700) + await assertKeyboardBoldPersists(page, { + selectionText: 'referenced', + rawIncludes: 'This is Note B, **referenced** by Alpha Project.', + }) +}) - await roundTripThroughAnotherNote(page) - await openRawMode(page) - - const raw = await getRawEditorContent(page) - expect(raw).toContain('This is Note B, **referenced** by Alpha Project.') +test('bold formatting keeps trailing whitespace outside markdown markers', async ({ page }) => { + await assertKeyboardBoldPersists(page, { + selectionText: 'referenced ', + rawIncludes: 'This is Note B, **referenced** by Alpha Project.', + rawExcludes: '**referenced **', + }) }) test('toolbar block-type commands persist numbered lists', async ({ page }) => {