Shift+Enter inserts a line break, end to end
hard_break was already in the editor's schema — it comes with
prosemirror-schema-basic — but nothing bound a key to it, so there was
no way to type one, and nothing downstream would have kept it if there
had been. core/src/fragment.rs had no case for the node, which means the
read view, the PDF and the Markdown file would each have run the two
lines together as one and lost the break silently, the failure this
repo's renderers are otherwise careful about.
So all five places learn it at once:
- fixtures/editor-entry.js binds Shift-Enter in its own keymap, ahead of
baseKeymap and not appended to it (baseKeymap is a shared module-level
object). Nothing bound it before: baseKeymap has Enter and Mod-Enter,
and prosemirror-keymap looks up the full modified name for a named key
like Enter, so Shift+Enter reached no command at all.
- core/src/fragment.rs renders `<br>` in HTML and a single newline in
text — one newline, where a block boundary is a blank line, because a
break ends the line and the block carries on. It is deliberately not
in the is_block set. fragment_json needs no case: an element with no
children already comes out as {"type":"hard_break","content":[]},
which is how this renderer spells every childless element.
- internal/export/pdf.go tokenizes it as a break token and ends the line
on it; a break with nothing on the line yet consumes a line box of its
own, so two in a row leave a blank line exactly as <br><br> does.
- internal/export/markdown.go writes a trailing backslash.
The node NAME is the contract, not a choice: y-prosemirror stores every
non-text node as a Y.XmlElement named after its ProseMirror type, so the
tag in the CRDT is whatever the editor's schema calls the node.
Backslash rather than two trailing spaces, which are the other CommonMark
spelling: only the backslash round-trips. Trailing spaces are invisible
in the source and every editor, formatter and pre-commit hook that trims
trailing whitespace deletes them without anyone seeing, at which point
the break silently becomes a soft wrap — the exact failure this change
exists to stop. A backslash is visible, nothing strips it, and it cannot
be confused with one the author typed, because escapeMarkdown escapes
that to `\\`. Two Markdown cases fall out of choosing it: a break at the
end of a paragraph is dropped (a trailing backslash is not a break, it
is a literal backslash nobody typed), and a break inside a heading
becomes a space (a heading is one line and Markdown cannot spell a
second one, so a backslash would strand the rest of it as a paragraph).
Every test here was watched failing first: the two Rust ones on
"<p>firstsecond</p>" and "onetwo\n\nthree", the Markdown ones on
"firstsecond" and "- onetwo", and the two PDF ones on both halves
sharing a baseline. The end-of-paragraph case was written after the
naive emitter was in place, so it could fail on "only\\\n\\\n" rather
than pass vacuously against code that emitted nothing at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
9 files changed,
+352
−13
core/src/fragment.rs+25 −0core/tests/fragment.rs+59 −0fixtures/editor-entry.js+35 −0internal/core/testdata/docs_core.wasmbinaryinternal/docs/static/{editor.0c0322ca56426314.js => editor.26513ec0d7ae331b.js}+11 −11internal/export/markdown.go+47 −2internal/export/markdown_test.go+86 −0internal/export/pdf.go+29 −0internal/export/pdf_test.go+60 −0
core/src/fragment.rsLoading diff…
core/tests/fragment.rsLoading diff…
fixtures/editor-entry.jsLoading diff…
internal/core/testdata/docs_core.wasmLoading diff…
internal/docs/static/{editor.0c0322ca56426314.js => editor.26513ec0d7ae331b.js}Loading diff…
internal/export/markdown.goLoading diff…
internal/export/markdown_test.goLoading diff…
internal/export/pdf.goLoading diff…
internal/export/pdf_test.goLoading diff…