From 7e87d3ac63d0616206d4ffe9ad569842a752680e Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 1 Jul 2026 17:16:43 +0200 Subject: [PATCH] fix(HardBreak): render DOM linebreak as soft breaks Fixes: #8775 prosemirror-model 1.25.4 now conversts newlines into the schema's linebreakReplacement (our HardBreak), producing spurious `
` in nested bullet lists as we use `preserveWhitspace: true` there.. The commit fixes this by distinguishing HardBreaks: when created as linebreakReplacement, they become type 'soft', otherwise the old behaviour is retained. The added tests are not regression tests for #8775. They ensure that multi-line list items are preserved in Markdown (which they would not with `preserveWhitespace: false` in BulletList). Signed-off-by: Jonas --- src/nodes/HardBreak.js | 31 ++++++++++++++++++++++++++----- src/tests/markdown.spec.js | 3 +++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/nodes/HardBreak.js b/src/nodes/HardBreak.js index aee34e042b2..d764067a0e7 100644 --- a/src/nodes/HardBreak.js +++ b/src/nodes/HardBreak.js @@ -9,7 +9,7 @@ const HardBreak = TipTapHardBreak.extend({ addAttributes() { return { syntax: { - default: ' ', + default: 'soft', rendered: false, keepOnSplit: true, parseHTML: (el) => el.getAttribute('data-syntax') || ' ', @@ -17,6 +17,14 @@ const HardBreak = TipTapHardBreak.extend({ } }, + renderHTML({ node, HTMLAttributes }) { + if (node.attrs.syntax === 'soft') { + // Rendered as inline whitespace, not a visible line break + return ['span', { ...HTMLAttributes, 'data-soft-break': '' }, ' '] + } + return ['br', HTMLAttributes] + }, + addCommands() { return { ...this?.parent(), @@ -26,7 +34,18 @@ const HardBreak = TipTapHardBreak.extend({ if (ctx.state.selection.$from.node(d).type.name === 'heading') return false } - return this.parent().setHardBreak()(ctx) + // Call upstream setHardBreak, then set syntax property for type 'soft' + return ctx.chain() + .command(this.parent().setHardBreak()) + .command(({ tr }) => { + const pos = tr.selection.$anchor.pos -1 + const node = tr.doc.nodeAt(pos) + if (node?.type.name === 'hardBreak' && node.attrs.syntax === 'soft') { + tr.setNodeMarkup(pos, null, { ...node.attrs, syntax: ' ' }) + } + return true + }) + .run() }, } }, @@ -34,11 +53,13 @@ const HardBreak = TipTapHardBreak.extend({ toMarkdown(state, node, parent, index) { for (let i = index + 1; i < parent.childCount; i++) { if (parent.child(i).type !== node.type) { - if (node.attrs.syntax !== 'html') { + if (node.attrs.syntax === 'soft') { + state.write('\n') + } else if (node.attrs.syntax === 'html') { + state.write('
') + } else { state.write(node.attrs.syntax) if (!parent.child(i).text?.startsWith('\n')) state.write('\n') - } else { - state.write('
') } return } diff --git a/src/tests/markdown.spec.js b/src/tests/markdown.spec.js index 357464f9932..46361dfe0d2 100644 --- a/src/tests/markdown.spec.js +++ b/src/tests/markdown.spec.js @@ -52,6 +52,9 @@ describe('Markdown though editor', () => { expect(markdownThroughEditor('- foo\n- bar')).toBe('- foo\n- bar') expect(markdownThroughEditor('- foo\n\n- bar')).toBe('- foo\n- bar') expect(markdownThroughEditor('- foo\n\n\n- bar')).toBe('- foo\n- bar') + expect(markdownThroughEditor('- foo\n - bar')).toBe('- foo\n - bar') + expect(markdownThroughEditor('- foo\n - bar\n- baz')).toBe('- foo\n - bar\n- baz') + expect(markdownThroughEditor('- foo\n bar\n baz')).toBe('- foo\n bar\n baz') }) test('ol', () => { expect(markdownThroughEditor('1. foo\n2. bar')).toBe('1. foo\n2. bar')