From 487e8d0352ff395eabc4f22e58916de7177ba891 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 13 Apr 2026 14:04:25 +0200 Subject: [PATCH] fix: preserve hard breaks when toggling editors --- src/utils/compact-markdown.test.ts | 15 +++ src/utils/compact-markdown.ts | 156 ++++++++++++++++++++--------- 2 files changed, 121 insertions(+), 50 deletions(-) diff --git a/src/utils/compact-markdown.test.ts b/src/utils/compact-markdown.test.ts index 889cea47..e0f54d49 100644 --- a/src/utils/compact-markdown.test.ts +++ b/src/utils/compact-markdown.test.ts @@ -47,6 +47,21 @@ describe('compactMarkdown', () => { expect(compactMarkdown(input)).toBe('```js\nconst a = 1\n\nconst b = 2\n```\n') }) + it('removes stray hard-break-only lines after a hard line break', () => { + const input = 'some text\\\\\n\\\\\nafter\n' + expect(compactMarkdown(input)).toBe('some text\\\\\nafter\n') + }) + + it('removes stray hard-break-only lines after formatted hard line breaks', () => { + const input = '**text\\\\**\n\\\\\nafter\n' + expect(compactMarkdown(input)).toBe('**text\\\\**\nafter\n') + }) + + it('preserves intentional backslash-only lines when no hard line break precedes them', () => { + const input = 'just text\n\\\\\nafter\n' + expect(compactMarkdown(input)).toBe('just text\n\\\\\nafter\n') + }) + it('does not add trailing blank lines', () => { const input = '## Title\n\nText.\n\n\n' expect(compactMarkdown(input)).toBe('## Title\n\nText.\n') diff --git a/src/utils/compact-markdown.ts b/src/utils/compact-markdown.ts index 3e04007b..3b518191 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 + * - 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 * - Code block content is never modified @@ -11,84 +12,129 @@ export function compactMarkdown(md: string): string { if (!md) return md - const lines = md.split('\n') - const result: string[] = [] + const source = { lines: md.split('\n') } + const result = { lines: [] as string[] } let inCodeBlock = false - for (let i = 0; i < lines.length; i++) { - let line = lines[i] - - // Track fenced code blocks — never modify content inside them - if (line.trimStart().startsWith('```')) { - inCodeBlock = !inCodeBlock - result.push(line) - continue + for (let i = 0; i < source.lines.length; i++) { + const next = processMarkdownLine({ doc: source, idx: i, inCodeBlock }) + inCodeBlock = next.inCodeBlock + if (next.line !== null) { + result.lines.push(next.line) } - - if (inCodeBlock) { - result.push(line) - continue - } - - // Normalize bullet markers: BlockNote uses `*`, convention is `-` - line = normalizeBulletMarker(line) - - // Decode HTML entities that BlockNote inserts (e.g. for spaces) - line = decodeHtmlEntities(line) - - // Skip blank lines that sit between two list items (tight list rule) - if (line.trim() === '') { - if (isBlankBetweenListItems(lines, i)) continue - // Collapse runs of 3+ blank lines to a single blank line - if (isExcessiveBlankLine(lines, i)) continue - result.push(line) - continue - } - - result.push(line) } - // Trim trailing blank lines, keep exactly one trailing newline - while (result.length > 0 && result[result.length - 1].trim() === '') { - result.pop() - } - if (result.length > 0) result.push('') - - return result.join('\n') + return finalizeMarkdown(result) } const LIST_RE = /^(\s*)([-*+]|\d+\.)\s/ +const HARD_BREAK_ONLY_RE = /^\\+$/ +const TRAILING_INLINE_CLOSERS_RE = /(?:[*_~`]+)$/ + +interface MarkdownDocument { + lines: string[] +} + +interface LinePosition { + doc: MarkdownDocument + idx: number +} + +interface NormalizedLinePosition extends LinePosition { + line: string +} + +interface ProcessMarkdownLineArgs extends LinePosition { + inCodeBlock: boolean +} + +function processMarkdownLine( + { doc, idx, inCodeBlock }: ProcessMarkdownLineArgs, +): { inCodeBlock: boolean; line: string | null } { + const rawLine = doc.lines[idx] + + if (isFenceDelimiter(rawLine)) { + return { inCodeBlock: !inCodeBlock, line: rawLine } + } + + if (inCodeBlock) { + return { inCodeBlock, line: rawLine } + } + + const line = normalizeMarkdownLine(rawLine) + if (shouldSkipLine({ doc, idx, line })) { + return { inCodeBlock, line: null } + } + + return { inCodeBlock, line } +} + +function isFenceDelimiter(line: string): boolean { + return line.trimStart().startsWith('```') +} + +function normalizeMarkdownLine(line: string): string { + return decodeHtmlEntities(normalizeBulletMarker(line)) +} + +function shouldSkipLine({ doc, idx, line }: NormalizedLinePosition): boolean { + if (line.trim() === '') { + return isBlankBetweenListItems({ doc, idx }) || isExcessiveBlankLine({ doc, idx }) + } + + return isRedundantHardBreakLine({ doc, idx, line }) +} /** True if this blank line sits between two list items (including nested) */ -function isBlankBetweenListItems(lines: string[], idx: number): boolean { - const prev = findPrevNonBlank(lines, idx) - const next = findNextNonBlank(lines, idx) +function isBlankBetweenListItems({ doc, idx }: LinePosition): boolean { + const prev = findPrevNonBlank({ doc, idx }) + const next = findNextNonBlank({ doc, idx }) if (prev === null || next === null) return false - return LIST_RE.test(lines[prev]) && LIST_RE.test(lines[next]) + return LIST_RE.test(doc.lines[prev]) && LIST_RE.test(doc.lines[next]) } /** True if this blank line is part of a run of 2+ consecutive blank lines * (i.e. would create 3+ newlines in a row — collapse to just one blank line) */ -function isExcessiveBlankLine(lines: string[], idx: number): boolean { +function isExcessiveBlankLine({ doc, idx }: LinePosition): boolean { // Keep the first blank line in a run, skip subsequent ones - if (idx > 0 && lines[idx - 1].trim() === '') return true + if (idx > 0 && doc.lines[idx - 1].trim() === '') return true return false } -function findPrevNonBlank(lines: string[], idx: number): number | null { +function findPrevNonBlank({ doc, idx }: LinePosition): number | null { for (let i = idx - 1; i >= 0; i--) { - if (lines[i].trim() !== '') return i + if (doc.lines[i].trim() !== '') return i } return null } -function findNextNonBlank(lines: string[], idx: number): number | null { - for (let i = idx + 1; i < lines.length; i++) { - if (lines[i].trim() !== '') return i +function findNextNonBlank({ doc, idx }: LinePosition): number | null { + for (let i = idx + 1; i < doc.lines.length; i++) { + if (doc.lines[i].trim() !== '') return i } return null } +function isRedundantHardBreakLine({ doc, idx, line }: NormalizedLinePosition): boolean { + 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) +} + +function isHardBreakOnlyLine(line: string): boolean { + return HARD_BREAK_ONLY_RE.test(line.trim()) +} + +function endsWithHardBreakMarker(line: string): boolean { + const trimmed = line.trimEnd() + if (trimmed.endsWith('\\\\')) return true + return trimmed.replace(TRAILING_INLINE_CLOSERS_RE, '').endsWith('\\\\') +} + const BULLET_RE = /^(\s*)\*(\s)/ /** Normalize `*` bullet markers to `-` (BlockNote default → standard convention) */ function normalizeBulletMarker(line: string): string { @@ -100,3 +146,13 @@ function decodeHtmlEntities(line: string): string { if (!line.includes('&#x')) return line return line.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))) } + +function finalizeMarkdown(doc: MarkdownDocument): string { + while (doc.lines.length > 0 && doc.lines[doc.lines.length - 1].trim() === '') { + doc.lines.pop() + } + if (doc.lines.length > 0) { + doc.lines.push('') + } + return doc.lines.join('\n') +}