fix: soft markdown line breaks#585
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces a distinct SoftBreak AST node to differentiate CommonMark soft line breaks from hard line breaks, and updates renderers/serializers across Web, iOS, Android, and core C++ to render/serialize them appropriately.
Changes:
- Add
SoftBreakto the AST/node-type enums/unions and propagate it through platform bridges/serializers. - Render soft breaks as spaces while keeping hard breaks as explicit line breaks (
<br />,\n, or markdown hard-break syntax in serializers). - Add Android UI tests covering soft-break vs hard-break rendering behavior.
Reviewed changes
Copilot reviewed 25 out of 26 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/react-native-enriched-markdown/src/web/utils.ts | Treat SoftBreak/LineBreak as whitespace when extracting plain text. |
| packages/react-native-enriched-markdown/src/web/types.ts | Add SoftBreak to NodeType and update AST node docs. |
| packages/react-native-enriched-markdown/src/web/renderers/InlineRenderers.tsx | Add SoftBreak renderer and register it in inlineRenderers. |
| packages/react-native-enriched-markdown/ios/utils/MarkdownASTSerializer.m | Differentiate serialization of hard breaks vs soft breaks. |
| packages/react-native-enriched-markdown/ios/renderer/RendererFactory.m | Render SoftBreak as a space in attributed output. |
| packages/react-native-enriched-markdown/ios/renderer/ENRMMathInlineRenderer.m | Treat breaks as spaces when extracting inline LaTeX. |
| packages/react-native-enriched-markdown/ios/renderer/ENRMImageRenderer.m | Treat breaks as spaces when building image text/alt-like content. |
| packages/react-native-enriched-markdown/ios/parser/MarkdownParserBridge.mm | Bridge C++ SoftBreak node type to ObjC enum. |
| packages/react-native-enriched-markdown/ios/parser/MarkdownASTNode.h | Add MarkdownNodeTypeSoftBreak to the ObjC node-type enum. |
| packages/react-native-enriched-markdown/android/src/math/java/com/swmansion/enriched/markdown/renderer/MathInlineRenderer.kt | Treat soft/hard breaks as spaces when extracting LaTeX. |
| packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/serialization/MarkdownASTSerializer.kt | Differentiate serialization of hard breaks vs soft breaks. |
| packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/SoftBreakRenderer.kt | New renderer to output a space for SoftBreak. |
| packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt | Register SoftBreak renderer in the Android renderer factory. |
| packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt | Add SoftBreak to the Android node-type enum. |
| packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputParser.kt | Treat SoftBreak similarly to LineBreak during plain-text extraction for input parsing. |
| packages/react-native-enriched-markdown/android/src/main/cpp/jni-adapter.cpp | Map C++ SoftBreak to the correct Kotlin enum ordinal. |
| packages/core/cpp/wasm/ASTSerializer.cpp | Add stringification support for SoftBreak in WASM serializer. |
| packages/core/cpp/parser/MD4CParser.cpp | Emit SoftBreak vs LineBreak nodes from md4c and treat soft breaks as separators. |
| packages/core/cpp/parser/MarkdownASTNode.hpp | Add SoftBreak to core C++ NodeType. |
| packages/android-enriched-markdown/ui/src/test/java/com/swmansion/enriched/markdown/test/TestAstFactory.kt | Add factory helpers for softBreak() and lineBreak(). |
| packages/android-enriched-markdown/ui/src/test/java/com/swmansion/enriched/markdown/CommonMarkRendererTest.kt | Add tests for soft-break-as-space and hard-break-as-newline. |
| packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/renderer/SoftBreakRenderer.kt | New UI renderer for SoftBreak. |
| packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt | Register SoftBreak renderer in UI renderer factory. |
| packages/android-enriched-markdown/parser/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt | Add SoftBreak to parser module node-type enum. |
| packages/android-enriched-markdown/parser/src/main/cpp/jni/ParserJni.cpp | Update JNI enum-sync assertion for new node type. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
hryhoriiK97
left a comment
There was a problem hiding this comment.
Looks good overall! One minor nit below.
| @@ -42,6 +42,11 @@ class MathInlineRenderer( | |||
|
|
|||
| private fun extractLatex(node: MarkdownASTNode): String { | |||
| if (!node.content.isNullOrEmpty()) return node.content!! | |||
There was a problem hiding this comment.
Nit: Since MarkdownASTNode.content is String (non-nullable, defaults to ""), both isNullOrEmpty() and !! are unnecessary here. While you're already touching this function, could simplify to:
private fun extractLatex(node: MarkdownASTNode): String {
if (node.content.isNotEmpty()) return node.content
return node.children.joinToString("") { child ->
when (child.type) {
MarkdownASTNode.NodeType.SoftBreak, MarkdownASTNode.NodeType.LineBreak -> " "
else -> child.content
}
}
}
What/Why?
Fixes #541
Soft Markdown line breaks (a single
\ninside a paragraph) rendered as visual line breaks on native, unlikestandard CommonMark/GFM renderers which collapse them to a space.
The shared C++ parser mapped both
MD_TEXT_SOFTBRandMD_TEXT_BRtoNodeType::LineBreak. Instead of appending a literal space in the parser (which would destroy information that downstream logic depends on - display-math block promotion, editor round-trips), this PR introduces a dedicatedNodeType::SoftBreakand lets each consumer decide:SoftBreakrenders as a space;LineBreak(hard break: trailingor\) keeps rendering as a visual break.isSeparatorNodeacceptsSoftBreak, so display math on its own line inside a paragraph is still promoted to a block.InputParsermapsSoftBreak→\nso the editor stays WYSIWYG and value round-trips are lossless (iOS input works on raw text and needed no change).SoftBreak→\n,LineBreak→\+ newline (CommonMark hard-break syntax), so hard breaks no longer degrade to soft breaks on serialize→parse cycles.$$a\nb$$extracted asab).SoftBreakis appended last in every enum - both Android JNI bridges map C++NodeTypeto Kotlin by ordinal (guarded by thestatic_assertinParserJni.cpp, nowSoftBreak == 30).Known behavior change
text\non Android now renders the image inline instead of block. iOS already rendered it inline (its block check tests for\nwhileLineBreakrenders U+2028), so platforms are now consistent - but Android users relying on that layout will see a difference. Standalone images (![img]as its own paragraph) areunaffected.
Testing
Unit: two new render tests in
CommonMarkRendererTest(soft break → space, hard break → newline); full standalone suite.Manual: these exact strings hit the combinations most likely to regress (parser post-processing and renderer heuristics key off break nodes, so they're the flaky spots):
keep taking your medications as prescribed, and we'll begin tracking your\nblood sugar and blood pressure→ single wrapped paragraph, no forced break after "your".line one \nline two(two trailing spaces) andline one\\nline two(trailing backslash) → visible line break within one paragraph.2.1. Pagragraph vs Hard break:
line1\n\nline2→ two paragraphs with paragraph margin between them;line1\\\nline2→ one paragraph, so the lines are separated by line height only (no paragraph margin).isSeparatorNode):text before\n$$x^2$$\ntext after→ math must still render as its own centered block, text split around it$$x^2$$alone → block (unchanged)a $$x^2$$ b(same line) → stays inline (unchanged)$$a +\nb$$→ typesetsa + b; previously the break was dropped (ab).some text\n→ image renders inline on bothplatforms now (was block on Android).
alone in a paragraph → still block with block styling(
maxHeight/aspectRatio/resizeMode).- item\n continuedand> quote\ncontinued→ continuation joins with a space inside the item/quote.`code\nspan`renders with a space (md4c handles both before the AST — should be identical to before).value={"line1\nline2"}onEnrichedMarkdownTextInput→ editor shows two lines; type/edit and confirmonChangeMarkdownoutput re-renders identically (breaks must not collapse into spaces after an edit cycle).PR Checklist