From f559099097e498789133e54210d09f4879bb2a0f Mon Sep 17 00:00:00 2001 From: ocavue Date: Fri, 19 Jun 2026 16:39:17 +1000 Subject: [PATCH 1/2] fix(core): preserve markdown soft line breaks as hardBreak nodes --- .../core/src/converters/line-break.test.ts | 77 +++++++++++++++++++ packages/core/src/converters/md-to-pm.ts | 30 ++++++-- packages/core/src/converters/pm-to-md.ts | 11 ++- .../core/src/extensions/extension.test.ts | 17 ++++ packages/core/src/extensions/extension.ts | 2 + packages/core/src/extensions/node-names.ts | 1 + 6 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/converters/line-break.test.ts diff --git a/packages/core/src/converters/line-break.test.ts b/packages/core/src/converters/line-break.test.ts new file mode 100644 index 00000000..f6a952c1 --- /dev/null +++ b/packages/core/src/converters/line-break.test.ts @@ -0,0 +1,77 @@ +import { DOMParser } from '@prosekit/pm/model' +import dedent from 'dedent' +import { describe, expect, it } from 'vitest' +import { page } from 'vitest/browser' + +import { setupFixture } from '../testing/index.ts' + +import { markdownToDoc } from './md-to-pm.ts' +import { docToMarkdown } from './pm-to-md.ts' + +const roundtrip = (markdown: string) => docToMarkdown(markdownToDoc(markdown)) + +describe('markdown soft line breaks survive as hardBreak nodes', () => { + it('parses a soft break into a hardBreak node, not a raw newline', () => { + const doc = markdownToDoc('text\ntext') + const paragraph = doc.child(0) + expect(paragraph.childCount).toBe(3) + expect(paragraph.child(0).text).toBe('text') + expect(paragraph.child(1).type.name).toBe('hardBreak') + expect(paragraph.child(2).text).toBe('text') + // The hardBreak's one-char `\n` leafText keeps inline offsets aligned. + expect(paragraph.textContent).toBe('text\ntext') + }) + + it('round-trips a soft break byte-for-byte', () => { + expect(roundtrip('text\ntext')).toBe('text\ntext\n') + expect(roundtrip('a\nsdas\ns')).toBe('a\nsdas\ns\n') + expect(roundtrip('- [ ] a\nb')).toBe('- [ ] a\n b\n') + }) + + it('renders as a
that ProseMirror can re-parse without collapsing', () => { + using fixture = setupFixture() + const { editor } = fixture + fixture.set(markdownToDoc('text\ntext', editor.nodes)) + const paragraphDom = editor.view.dom.querySelector('p') + if (!paragraphDom) throw new Error('no

') + expect(paragraphDom.querySelector('br')).not.toBeNull() + // Re-parsing the DOM is exactly what `readDOMChange` does; before the fix + // (a raw `\n` text node) this collapsed the newline to a space. + const reparsed = DOMParser.fromSchema(editor.schema).parse(paragraphDom) + expect(reparsed.textContent).toBe('text\ntext') + }) + + it('keeps the break in the model after a real paint (reflect-open regression)', async () => { + using fixture = setupFixture() + const { editor } = fixture + fixture.set(markdownToDoc('text\ntext\n\n- [ ] task', editor.nodes)) + expect(docToMarkdown(editor.view.state.doc)).toBe('text\ntext\n\n- [ ] task\n') + // A screenshot forces a real browser paint, the DOM re-read that used to + // turn the newline into a space. `save: false` keeps it off disk. + await page.screenshot({ base64: true, save: false }) + expect(docToMarkdown(editor.view.state.doc)).toBe('text\ntext\n\n- [ ] task\n') + }) + + it('keeps soft breaks across the full screenshot document', () => { + const full = dedent` + text + text + + - [x] dasdasda + - [ ] dasdasdasdas + - [ ] asdasa + + a + sdas + s + + heading + + - dasdasd + - adasdas + - [ ] Task + - Bullet + ` + expect(roundtrip(full)).toBe(full + '\n') + }) +}) diff --git a/packages/core/src/converters/md-to-pm.ts b/packages/core/src/converters/md-to-pm.ts index 339c1930..7955be28 100644 --- a/packages/core/src/converters/md-to-pm.ts +++ b/packages/core/src/converters/md-to-pm.ts @@ -14,9 +14,10 @@ import { gfmBlockOnlyParser } from '../lezer/parser.ts' * instance and can be inserted without a JSON round trip. * * The output follows the extension set defined in `../extensions/extension.ts` - * (doc, paragraph, text, heading, blockquote, list, codeBlock, table, tableRow, - * tableCell, tableHeaderCell, horizontalRule). The function does not produce - * inline marks because the markdown stays literal text - emphasis / link / + * (doc, paragraph, text, hardBreak, heading, blockquote, list, codeBlock, + * table, tableRow, tableCell, tableHeaderCell, horizontalRule). The only inline + * node produced is `hardBreak` (one per soft line break); the function produces + * no inline marks because the markdown stays literal text - emphasis / link / * inline-code characters survive verbatim. */ export function markdownToDoc( @@ -132,7 +133,26 @@ function convertParagraph( text: string, ): ProseMirrorNode { const content = text.slice(cursor.from, cursor.to) - return nodes.paragraph(content) + return nodes.paragraph(inlineWithBreaks(nodes, content)) +} + +/** + * Split inline text on its soft line breaks, turning each `\n` into a + * `hardBreak` node (`
`). A markdown soft break must not survive as a raw + * `\n` inside a single text node: ProseMirror's DOM parser collapses such a + * newline to a space the moment it re-reads the textblock, silently merging + * the lines in both the view and the saved document. The `hardBreak` node's + * one-character `\n` `leafText` keeps inline offsets aligned for the + * inline-mark plugin. Empty segments are dropped by the node builders. + */ +function inlineWithBreaks(nodes: TypedNodeBuilders, text: string): Array { + const segments = text.split('\n') + const children: Array = [] + for (let i = 0; i < segments.length; i++) { + if (i > 0) children.push(nodes.hardBreak()) + children.push(segments[i]) + } + return children } function convertBlockquote( @@ -253,7 +273,7 @@ function convertTask(nodes: TypedNodeBuilders, cursor: TreeCursor, text: string) if (content.startsWith(' ')) content = content.slice(1) return { checked, - paragraph: content === '' ? nodes.paragraph() : nodes.paragraph(content), + paragraph: nodes.paragraph(inlineWithBreaks(nodes, content)), } } diff --git a/packages/core/src/converters/pm-to-md.ts b/packages/core/src/converters/pm-to-md.ts index bb69f918..df802509 100644 --- a/packages/core/src/converters/pm-to-md.ts +++ b/packages/core/src/converters/pm-to-md.ts @@ -248,8 +248,15 @@ function emitInlineChildren(node: ProseMirrorNode, out: MdOut): void { const count = node.childCount for (let i = 0; i < count; i++) { const child = node.child(i) - if (child.isText && child.text) out.write(child.text) - // Future inline node types (hardBreak, image, mention) go here. + if (child.isText && child.text) { + out.write(child.text) + } else if (child.type.name === ('hardBreak' satisfies NodeName)) { + // A `hardBreak` carries a markdown soft line break: emit a single + // newline. `write` re-applies the current line prefix after it, so the + // break stays inside the same block (list item, blockquote, …). + out.write('\n') + } + // Future inline node types (image, mention) go here. } } diff --git a/packages/core/src/extensions/extension.test.ts b/packages/core/src/extensions/extension.test.ts index a4051200..373c1f26 100644 --- a/packages/core/src/extensions/extension.test.ts +++ b/packages/core/src/extensions/extension.test.ts @@ -12,6 +12,7 @@ describe('defineEditorExtension', () => { const doc = n.doc( n.heading({ level: 1 }, 'Title'), n.paragraph('A paragraph.'), + n.paragraph('Line one', n.hardBreak(), 'Line two'), n.blockquote(n.paragraph('A quote.')), n.list({ kind: 'bullet' }, n.paragraph('A bullet item.')), n.list({ kind: 'ordered', order: 1 }, n.paragraph('An ordered item.')), @@ -48,6 +49,22 @@ describe('defineEditorExtension', () => { ], "type": "paragraph", }, + { + "content": [ + { + "text": "Line one", + "type": "text", + }, + { + "type": "hardBreak", + }, + { + "text": "Line two", + "type": "text", + }, + ], + "type": "paragraph", + }, { "content": [ { diff --git a/packages/core/src/extensions/extension.ts b/packages/core/src/extensions/extension.ts index 1ab1bcbb..a052c407 100644 --- a/packages/core/src/extensions/extension.ts +++ b/packages/core/src/extensions/extension.ts @@ -9,6 +9,7 @@ import { defineBlockquote } from '@prosekit/extensions/blockquote' import { defineCodeBlock } from '@prosekit/extensions/code-block' import { defineDoc } from '@prosekit/extensions/doc' import { defineGapCursor } from '@prosekit/extensions/gap-cursor' +import { defineHardBreak } from '@prosekit/extensions/hard-break' import { defineHorizontalRule } from '@prosekit/extensions/horizontal-rule' import { defineList } from '@prosekit/extensions/list' import { defineModClickPrevention } from '@prosekit/extensions/mod-click-prevention' @@ -31,6 +32,7 @@ function defineEditorExtensionImpl() { defineParagraph(), defineDoc(), defineText(), + defineHardBreak(), defineBlockquote(), defineList(), defineHeading(), diff --git a/packages/core/src/extensions/node-names.ts b/packages/core/src/extensions/node-names.ts index 66b0111b..e86a0e1a 100644 --- a/packages/core/src/extensions/node-names.ts +++ b/packages/core/src/extensions/node-names.ts @@ -5,6 +5,7 @@ export const NODE_NAMES = [ 'doc', 'text', 'paragraph', + 'hardBreak', 'heading', 'blockquote', 'list', From a354dcb94c14ea0a61d66a37cdc6db13a704e985 Mon Sep 17 00:00:00 2001 From: ocavue Date: Fri, 19 Jun 2026 16:39:30 +1000 Subject: [PATCH 2/2] fix(core): keep empty list item markers on serialize --- .../src/converters/empty-list-item.test.ts | 34 +++++++++++++++++++ packages/core/src/converters/pm-to-md.ts | 10 ++++++ 2 files changed, 44 insertions(+) create mode 100644 packages/core/src/converters/empty-list-item.test.ts diff --git a/packages/core/src/converters/empty-list-item.test.ts b/packages/core/src/converters/empty-list-item.test.ts new file mode 100644 index 00000000..4d6e27fe --- /dev/null +++ b/packages/core/src/converters/empty-list-item.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' + +import { markdownToDoc } from './md-to-pm.ts' +import { docToMarkdown } from './pm-to-md.ts' + +const roundtrip = (markdown: string) => docToMarkdown(markdownToDoc(markdown)) + +describe('empty list items keep their marker', () => { + it('does not drop a lone empty bullet', () => { + expect(roundtrip('- ')).toBe('-\n') + expect(roundtrip('-')).toBe('-\n') + }) + + it('keeps an empty item between two filled items', () => { + // Before the fix the middle item vanished and the list collapsed to two. + expect(roundtrip('- a\n- \n- b')).toBe('- a\n-\n- b\n') + }) + + it('keeps a leading and a trailing empty item', () => { + expect(roundtrip('- a\n- ')).toBe('- a\n-\n') + expect(roundtrip('- \n- b')).toBe('-\n- b\n') + }) + + it('keeps an empty task or ordered item marker', () => { + expect(roundtrip('- [ ] ')).toBe('- [ ]\n') + expect(roundtrip('1. ')).toBe('1.\n') + }) + + it('is stable: the canonical empty-item form round-trips again unchanged', () => { + expect(roundtrip('-')).toBe('-\n') + expect(roundtrip('- a\n-\n- b')).toBe('- a\n-\n- b\n') + expect(roundtrip('- [ ]')).toBe('- [ ]\n') + }) +}) diff --git a/packages/core/src/converters/pm-to-md.ts b/packages/core/src/converters/pm-to-md.ts index df802509..250d8730 100644 --- a/packages/core/src/converters/pm-to-md.ts +++ b/packages/core/src/converters/pm-to-md.ts @@ -86,6 +86,16 @@ class MdOut { /** End the current block; the next write gets a blank line before it. */ closeBlock(): void { + // A block with no inline content still owns a line. The clearest case is + // an empty list item (`- `): its marker lives in `pendingFirst` and would + // be dropped entirely if nothing flushed it. Emit the marker (trimmed, so + // the empty line carries no trailing whitespace) before closing. + if (this.atLineStart && this.pendingFirst !== null) { + this.emitDeferredBlankLine() + this.parts.push(this.pendingFirst.trimEnd()) + this.pendingFirst = null + this.atLineStart = false + } if (!this.atLineStart) this.parts.push('\n') this.atLineStart = true this.deferredBlankPrefix = this.linePrefix