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/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