fix: preserve hard breaks when toggling editors

This commit is contained in:
lucaronin
2026-04-13 14:04:25 +02:00
parent d4aacf1bea
commit 487e8d0352
2 changed files with 121 additions and 50 deletions

View File

@@ -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')

View File

@@ -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. &#x20; 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')
}