Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions packages/core/src/converters/empty-list-item.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
77 changes: 77 additions & 0 deletions packages/core/src/converters/line-break.test.ts
Original file line number Diff line number Diff line change
@@ -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 <br> 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 <p>')
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')
})
})
30 changes: 25 additions & 5 deletions packages/core/src/converters/md-to-pm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 (`<br>`). 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<ProseMirrorNode | string> {
const segments = text.split('\n')
const children: Array<ProseMirrorNode | string> = []
for (let i = 0; i < segments.length; i++) {
if (i > 0) children.push(nodes.hardBreak())
children.push(segments[i])
}
return children
}

function convertBlockquote(
Expand Down Expand Up @@ -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)),
}
}

Expand Down
21 changes: 19 additions & 2 deletions packages/core/src/converters/pm-to-md.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -248,8 +258,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.
}
}

Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/extensions/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.')),
Expand Down Expand Up @@ -48,6 +49,22 @@ describe('defineEditorExtension', () => {
],
"type": "paragraph",
},
{
"content": [
{
"text": "Line one",
"type": "text",
},
{
"type": "hardBreak",
},
{
"text": "Line two",
"type": "text",
},
],
"type": "paragraph",
},
{
"content": [
{
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/extensions/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -31,6 +32,7 @@ function defineEditorExtensionImpl() {
defineParagraph(),
defineDoc(),
defineText(),
defineHardBreak(),
defineBlockquote(),
defineList(),
defineHeading(),
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/extensions/node-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const NODE_NAMES = [
'doc',
'text',
'paragraph',
'hardBreak',
'heading',
'blockquote',
'list',
Expand Down
Loading