diff --git a/.maestro/enrichedMarkdownText/flows/basic/block_elements/hard_break_test.yaml b/.maestro/enrichedMarkdownText/flows/basic/block_elements/hard_break_test.yaml new file mode 100644 index 000000000..0ed7d66f5 --- /dev/null +++ b/.maestro/enrichedMarkdownText/flows/basic/block_elements/hard_break_test.yaml @@ -0,0 +1,29 @@ +appId: swmansion.enriched.markdown.example +tags: + - smoke + - block + - paragraph + - text +--- +# A trailing backslash is a CommonMark hard break: it forces a line break while +# staying inside one paragraph, so the two lines are separated by line height +# only. The blank line then starts a second paragraph that carries paragraph +# margins — the screenshot pins the spacing difference between the two. +- launchApp + +- runFlow: + file: '../../../../subflows/move_to_playground.yaml' + +- runFlow: + file: '../../../subflows/set_enriched_text_value.yaml' + env: + VALUE: | + This line ends with a backslash\ + so this text continues inside the same paragraph. + + A blank line starts a new paragraph with its own spacing. + +- runFlow: + file: '../../../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'hard_break_display' diff --git a/.maestro/enrichedMarkdownText/flows/basic/block_elements/soft_break_test.yaml b/.maestro/enrichedMarkdownText/flows/basic/block_elements/soft_break_test.yaml new file mode 100644 index 000000000..98b5654f9 --- /dev/null +++ b/.maestro/enrichedMarkdownText/flows/basic/block_elements/soft_break_test.yaml @@ -0,0 +1,26 @@ +appId: swmansion.enriched.markdown.example +tags: + - smoke + - block + - paragraph + - text +--- +# Regression for #541: a single newline inside a paragraph is a CommonMark soft +# break and renders as a space — both lines flow together into one wrapped +# paragraph with no forced line break after "your". +- launchApp + +- runFlow: + file: '../../../../subflows/move_to_playground.yaml' + +- runFlow: + file: '../../../subflows/set_enriched_text_value.yaml' + env: + VALUE: | + Let's start simple: keep taking your medications as prescribed, and we'll begin tracking your + blood sugar and blood pressure so we can see how you're really doing. + +- runFlow: + file: '../../../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'soft_break_display' diff --git a/.maestro/enrichedMarkdownText/screenshots/android/hard_break_display.png b/.maestro/enrichedMarkdownText/screenshots/android/hard_break_display.png new file mode 100644 index 000000000..c6adb5670 Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/android/hard_break_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/soft_break_display.png b/.maestro/enrichedMarkdownText/screenshots/android/soft_break_display.png new file mode 100644 index 000000000..1e855b64b Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/android/soft_break_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/hard_break_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/hard_break_display.png new file mode 100644 index 000000000..5bc9cb979 Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/ios/hard_break_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/soft_break_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/soft_break_display.png new file mode 100644 index 000000000..029e9424d Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/ios/soft_break_display.png differ diff --git a/docs/ELEMENTS_STRUCTURE.md b/docs/ELEMENTS_STRUCTURE.md index fce66f63b..7cb0bc315 100644 --- a/docs/ELEMENTS_STRUCTURE.md +++ b/docs/ELEMENTS_STRUCTURE.md @@ -110,6 +110,25 @@ Block elements are structural containers that define the layout and establish th Inline elements modify text within blocks and apply additional styling on top of the block's typography. +## Line Breaks + +Newlines follow standard CommonMark semantics: + +- **Blank line**: Starts a new paragraph. +- **Single newline (soft break)**: Renders as a space — consecutive lines flow together into one wrapped paragraph, matching how GitHub and other CommonMark renderers display Markdown. +- **Hard break**: Ends a line with two spaces or a backslash to force a line break within the paragraph. + +```markdown +This line and +this line render as one continuous sentence. + +A blank line starts a new paragraph. + +Two trailing spaces +or a trailing backslash\ +force a line break within the paragraph. +``` + ## Images: Block vs Inline Images are automatically detected as block or inline based on context: @@ -117,7 +136,7 @@ Images are automatically detected as block or inline based on context: - **Block images**: When an image is the only content in a paragraph (standalone), it's treated as a block image and uses block-level spacing - **Inline images**: When an image appears alongside other text content, it's treated as inline and aligns with the text baseline -You don't need to specify which type—the renderer automatically determines this based on the image's position in the content. +You don't need to specify which type—the renderer automatically determines this based on the image's position in the content. Note that a single newline doesn't split a paragraph, so an image on its own source line directly below text is still inline; separate it with a blank line to make it a block image. ## Nested Elements diff --git a/packages/android-enriched-markdown/parser/src/main/cpp/jni/ParserJni.cpp b/packages/android-enriched-markdown/parser/src/main/cpp/jni/ParserJni.cpp index 1f204059e..94f64800f 100644 --- a/packages/android-enriched-markdown/parser/src/main/cpp/jni/ParserJni.cpp +++ b/packages/android-enriched-markdown/parser/src/main/cpp/jni/ParserJni.cpp @@ -20,6 +20,8 @@ namespace { static_assert(static_cast(NodeType::Highlight) == 29, "NodeType enum must stay in sync with Kotlin MarkdownASTNode.NodeType"); +static_assert(static_cast(NodeType::SoftBreak) == 30, + "NodeType enum must stay in sync with Kotlin MarkdownASTNode.NodeType"); local_ref createJavaNode(const std::shared_ptr &node) { if (!node) { diff --git a/packages/android-enriched-markdown/parser/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt b/packages/android-enriched-markdown/parser/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt index c4749f30d..00aefb156 100644 --- a/packages/android-enriched-markdown/parser/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt +++ b/packages/android-enriched-markdown/parser/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt @@ -37,6 +37,7 @@ data class MarkdownASTNode( Superscript, Subscript, Highlight, + SoftBreak, } fun getAttribute(key: String): String? = attributes[key] diff --git a/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt b/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt index 04f591a1e..e9a4436bb 100644 --- a/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt +++ b/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt @@ -74,6 +74,7 @@ class RendererFactory( private val textRenderer = TextRenderer() private val lineBreakRenderer = LineBreakRenderer() + private val softBreakRenderer = SoftBreakRenderer() private val renderers: Map by lazy { buildMap { @@ -92,6 +93,7 @@ class RendererFactory( put(MarkdownASTNode.NodeType.Code, CodeRenderer(config)) put(MarkdownASTNode.NodeType.Image, ImageRenderer()) put(MarkdownASTNode.NodeType.LineBreak, lineBreakRenderer) + put(MarkdownASTNode.NodeType.SoftBreak, softBreakRenderer) put(MarkdownASTNode.NodeType.ThematicBreak, ThematicBreakRenderer(config)) } } diff --git a/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/renderer/SoftBreakRenderer.kt b/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/renderer/SoftBreakRenderer.kt new file mode 100644 index 000000000..334b5458e --- /dev/null +++ b/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/renderer/SoftBreakRenderer.kt @@ -0,0 +1,16 @@ +package com.swmansion.enriched.markdown.renderer + +import android.text.SpannableStringBuilder +import com.swmansion.enriched.markdown.parser.MarkdownASTNode + +class SoftBreakRenderer : NodeRenderer { + override fun render( + node: MarkdownASTNode, + builder: SpannableStringBuilder, + onLinkPress: ((String) -> Unit)?, + onLinkLongPress: ((String) -> Unit)?, + factory: RendererFactory, + ) { + builder.append(" ") + } +} diff --git a/packages/android-enriched-markdown/ui/src/test/java/com/swmansion/enriched/markdown/CommonMarkRendererTest.kt b/packages/android-enriched-markdown/ui/src/test/java/com/swmansion/enriched/markdown/CommonMarkRendererTest.kt index ed9c37b42..385785243 100644 --- a/packages/android-enriched-markdown/ui/src/test/java/com/swmansion/enriched/markdown/CommonMarkRendererTest.kt +++ b/packages/android-enriched-markdown/ui/src/test/java/com/swmansion/enriched/markdown/CommonMarkRendererTest.kt @@ -23,10 +23,12 @@ import com.swmansion.enriched.markdown.test.TestAstFactory.document import com.swmansion.enriched.markdown.test.TestAstFactory.emphasis import com.swmansion.enriched.markdown.test.TestAstFactory.heading import com.swmansion.enriched.markdown.test.TestAstFactory.image +import com.swmansion.enriched.markdown.test.TestAstFactory.lineBreak import com.swmansion.enriched.markdown.test.TestAstFactory.link import com.swmansion.enriched.markdown.test.TestAstFactory.listItem import com.swmansion.enriched.markdown.test.TestAstFactory.orderedList import com.swmansion.enriched.markdown.test.TestAstFactory.paragraph +import com.swmansion.enriched.markdown.test.TestAstFactory.softBreak import com.swmansion.enriched.markdown.test.TestAstFactory.strong import com.swmansion.enriched.markdown.test.TestAstFactory.text import com.swmansion.enriched.markdown.test.TestAstFactory.thematicBreak @@ -47,6 +49,38 @@ class CommonMarkRendererTest { rendered.assertContains("Hello CommonMark") } + @Test + fun rendersSoftBreakAsSpace() { + val rendered = + render( + document( + paragraph( + text("tracking your"), + softBreak(), + text("blood sugar"), + ), + ), + ) + + rendered.assertContains("tracking your blood sugar") + } + + @Test + fun rendersHardBreakAsNewline() { + val rendered = + render( + document( + paragraph( + text("line one"), + lineBreak(), + text("line two"), + ), + ), + ) + + rendered.assertContains("line one\nline two") + } + @Test fun rendersBoldText() { val rendered = diff --git a/packages/android-enriched-markdown/ui/src/test/java/com/swmansion/enriched/markdown/test/TestAstFactory.kt b/packages/android-enriched-markdown/ui/src/test/java/com/swmansion/enriched/markdown/test/TestAstFactory.kt index 43b3c01c5..68279b07a 100644 --- a/packages/android-enriched-markdown/ui/src/test/java/com/swmansion/enriched/markdown/test/TestAstFactory.kt +++ b/packages/android-enriched-markdown/ui/src/test/java/com/swmansion/enriched/markdown/test/TestAstFactory.kt @@ -57,4 +57,8 @@ object TestAstFactory { ) fun thematicBreak(): MarkdownASTNode = MarkdownASTNode(NodeType.ThematicBreak) + + fun softBreak(): MarkdownASTNode = MarkdownASTNode(NodeType.SoftBreak) + + fun lineBreak(): MarkdownASTNode = MarkdownASTNode(NodeType.LineBreak) } diff --git a/packages/core/cpp/parser/MD4CParser.cpp b/packages/core/cpp/parser/MD4CParser.cpp index ed914dc3b..a87705b7d 100644 --- a/packages/core/cpp/parser/MD4CParser.cpp +++ b/packages/core/cpp/parser/MD4CParser.cpp @@ -354,10 +354,13 @@ class MD4CParser::Impl { return 0; auto *impl = static_cast(userdata); - // Handle soft/hard line breaks - if (type == MD_TEXT_SOFTBR || type == MD_TEXT_BR) { - auto brNode = std::make_shared(NodeType::LineBreak); - impl->addInlineNode(brNode); + if (type == MD_TEXT_SOFTBR) { + impl->addInlineNode(std::make_shared(NodeType::SoftBreak)); + return 0; + } + + if (type == MD_TEXT_BR) { + impl->addInlineNode(std::make_shared(NodeType::LineBreak)); return 0; } @@ -379,7 +382,7 @@ bool isDisplayMathNode(const MarkdownASTNode &node) { } bool isSeparatorNode(const MarkdownASTNode &node) { - return node.type == NodeType::LineBreak || + return node.type == NodeType::LineBreak || node.type == NodeType::SoftBreak || (node.type == NodeType::Text && node.content.find_first_not_of(" \t\n\r") == std::string::npos); } diff --git a/packages/core/cpp/parser/MarkdownASTNode.hpp b/packages/core/cpp/parser/MarkdownASTNode.hpp index b1748c7de..9570bf553 100644 --- a/packages/core/cpp/parser/MarkdownASTNode.hpp +++ b/packages/core/cpp/parser/MarkdownASTNode.hpp @@ -37,7 +37,8 @@ enum class NodeType { Spoiler, Superscript, Subscript, - Highlight + Highlight, + SoftBreak }; struct MarkdownASTNode { diff --git a/packages/core/cpp/wasm/ASTSerializer.cpp b/packages/core/cpp/wasm/ASTSerializer.cpp index b5bbe69ac..0c02258df 100644 --- a/packages/core/cpp/wasm/ASTSerializer.cpp +++ b/packages/core/cpp/wasm/ASTSerializer.cpp @@ -18,6 +18,8 @@ static const char *nodeTypeToString(NodeType type) { return "Heading"; case NodeType::LineBreak: return "LineBreak"; + case NodeType::SoftBreak: + return "SoftBreak"; case NodeType::Strong: return "Strong"; case NodeType::Emphasis: diff --git a/packages/react-native-enriched-markdown/android/src/main/cpp/jni-adapter.cpp b/packages/react-native-enriched-markdown/android/src/main/cpp/jni-adapter.cpp index 608e8fc9f..56076b241 100644 --- a/packages/react-native-enriched-markdown/android/src/main/cpp/jni-adapter.cpp +++ b/packages/react-native-enriched-markdown/android/src/main/cpp/jni-adapter.cpp @@ -72,6 +72,8 @@ static jint nodeTypeToJavaOrdinal(NodeType type) { return 28; case NodeType::Highlight: return 29; + case NodeType::SoftBreak: + return 30; default: return 0; } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputParser.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputParser.kt index 33888ac8a..108ea6cf2 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputParser.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputParser.kt @@ -72,7 +72,7 @@ object InputParser { if (node.type == NodeType.Text) { plainText.append(node.content) - } else if (node.type == NodeType.LineBreak) { + } else if (node.type == NodeType.LineBreak || node.type == NodeType.SoftBreak) { plainText.append("\n") } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt index e2e0f35e6..e283f7fab 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt @@ -37,6 +37,7 @@ data class MarkdownASTNode( Superscript, Subscript, Highlight, + SoftBreak, } fun getAttribute(key: String): String? = attributes[key] diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt index b937d4531..d51b7cef5 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt @@ -80,6 +80,7 @@ class RendererFactory( private val textRenderer = TextRenderer() private val lineBreakRenderer = LineBreakRenderer() + private val softBreakRenderer = SoftBreakRenderer() private val renderers: Map by lazy { buildMap { @@ -103,6 +104,7 @@ class RendererFactory( put(MarkdownASTNode.NodeType.Code, CodeRenderer(config)) put(MarkdownASTNode.NodeType.Image, ImageRenderer()) put(MarkdownASTNode.NodeType.LineBreak, lineBreakRenderer) + put(MarkdownASTNode.NodeType.SoftBreak, softBreakRenderer) put(MarkdownASTNode.NodeType.ThematicBreak, ThematicBreakRenderer(config)) put(MarkdownASTNode.NodeType.Spoiler, SpoilerRenderer()) if (FeatureFlags.IS_MATH_ENABLED) { diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/SoftBreakRenderer.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/SoftBreakRenderer.kt new file mode 100644 index 000000000..334b5458e --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/SoftBreakRenderer.kt @@ -0,0 +1,16 @@ +package com.swmansion.enriched.markdown.renderer + +import android.text.SpannableStringBuilder +import com.swmansion.enriched.markdown.parser.MarkdownASTNode + +class SoftBreakRenderer : NodeRenderer { + override fun render( + node: MarkdownASTNode, + builder: SpannableStringBuilder, + onLinkPress: ((String) -> Unit)?, + onLinkLongPress: ((String) -> Unit)?, + factory: RendererFactory, + ) { + builder.append(" ") + } +} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/serialization/MarkdownASTSerializer.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/serialization/MarkdownASTSerializer.kt index a16ac95ce..e5a298956 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/serialization/MarkdownASTSerializer.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/serialization/MarkdownASTSerializer.kt @@ -28,6 +28,10 @@ object MarkdownASTSerializer { } NodeType.LineBreak -> { + buffer.append("\\\n") + } + + NodeType.SoftBreak -> { buffer.append("\n") } diff --git a/packages/react-native-enriched-markdown/android/src/math/java/com/swmansion/enriched/markdown/renderer/MathInlineRenderer.kt b/packages/react-native-enriched-markdown/android/src/math/java/com/swmansion/enriched/markdown/renderer/MathInlineRenderer.kt index 3ad866896..8d263d307 100644 --- a/packages/react-native-enriched-markdown/android/src/math/java/com/swmansion/enriched/markdown/renderer/MathInlineRenderer.kt +++ b/packages/react-native-enriched-markdown/android/src/math/java/com/swmansion/enriched/markdown/renderer/MathInlineRenderer.kt @@ -41,7 +41,12 @@ class MathInlineRenderer( } private fun extractLatex(node: MarkdownASTNode): String { - if (!node.content.isNullOrEmpty()) return node.content!! - return node.children.mapNotNull { it.content }.joinToString("") + if (node.content.isNotEmpty()) return node.content + return node.children.joinToString("") { child -> + when (child.type) { + MarkdownASTNode.NodeType.SoftBreak, MarkdownASTNode.NodeType.LineBreak -> " " + else -> child.content + } + } } } diff --git a/packages/react-native-enriched-markdown/ios/parser/MarkdownASTNode.h b/packages/react-native-enriched-markdown/ios/parser/MarkdownASTNode.h index f4598dc38..5ace29eef 100644 --- a/packages/react-native-enriched-markdown/ios/parser/MarkdownASTNode.h +++ b/packages/react-native-enriched-markdown/ios/parser/MarkdownASTNode.h @@ -30,7 +30,8 @@ typedef NS_ENUM(NSInteger, MarkdownNodeType) { MarkdownNodeTypeSpoiler, MarkdownNodeTypeSuperscript, MarkdownNodeTypeSubscript, - MarkdownNodeTypeHighlight + MarkdownNodeTypeHighlight, + MarkdownNodeTypeSoftBreak }; @interface MarkdownASTNode : NSObject diff --git a/packages/react-native-enriched-markdown/ios/parser/MarkdownParserBridge.mm b/packages/react-native-enriched-markdown/ios/parser/MarkdownParserBridge.mm index 3a762aa0e..a682d69c8 100644 --- a/packages/react-native-enriched-markdown/ios/parser/MarkdownParserBridge.mm +++ b/packages/react-native-enriched-markdown/ios/parser/MarkdownParserBridge.mm @@ -104,6 +104,9 @@ case Markdown::NodeType::Highlight: objcType = MarkdownNodeTypeHighlight; break; + case Markdown::NodeType::SoftBreak: + objcType = MarkdownNodeTypeSoftBreak; + break; } MarkdownASTNode *objcNode = [[MarkdownASTNode alloc] initWithType:objcType]; diff --git a/packages/react-native-enriched-markdown/ios/renderer/ENRMImageRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/ENRMImageRenderer.m index ec81c5921..d19cd1396 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/ENRMImageRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/ENRMImageRenderer.m @@ -47,6 +47,8 @@ - (void)_appendChildTextFromNode:(MarkdownASTNode *)node toBuffer:(NSMutableStri { if (node.content.length > 0) { [buffer appendString:node.content]; + } else if (node.type == MarkdownNodeTypeSoftBreak || node.type == MarkdownNodeTypeLineBreak) { + [buffer appendString:@" "]; } for (MarkdownASTNode *child in node.children) { diff --git a/packages/react-native-enriched-markdown/ios/renderer/ENRMMathInlineRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/ENRMMathInlineRenderer.m index 9d270ce40..bff00b34d 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/ENRMMathInlineRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/ENRMMathInlineRenderer.m @@ -52,6 +52,8 @@ - (NSString *)extractLatexFromNode:(MarkdownASTNode *)node for (MarkdownASTNode *child in node.children) { if (child.content.length > 0) { [buffer appendString:child.content]; + } else if (child.type == MarkdownNodeTypeSoftBreak || child.type == MarkdownNodeTypeLineBreak) { + [buffer appendString:@" "]; } } return buffer; diff --git a/packages/react-native-enriched-markdown/ios/renderer/RendererFactory.m b/packages/react-native-enriched-markdown/ios/renderer/RendererFactory.m index 5d42fe599..1f4ff795e 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/RendererFactory.m +++ b/packages/react-native-enriched-markdown/ios/renderer/RendererFactory.m @@ -143,6 +143,12 @@ - (void)renderChildrenOfNode:(MarkdownASTNode *)node [output appendAttributedString:lineBreak]; continue; } + if (child.type == MarkdownNodeTypeSoftBreak) { + NSAttributedString *softBreak = [[NSAttributedString alloc] initWithString:@" " + attributes:[context getTextAttributes]]; + [output appendAttributedString:softBreak]; + continue; + } id renderer = [self rendererForNodeType:child.type]; if (renderer) { [renderer renderNode:child into:output context:context]; diff --git a/packages/react-native-enriched-markdown/ios/utils/MarkdownASTSerializer.m b/packages/react-native-enriched-markdown/ios/utils/MarkdownASTSerializer.m index 74080c64e..cc595db5d 100644 --- a/packages/react-native-enriched-markdown/ios/utils/MarkdownASTSerializer.m +++ b/packages/react-native-enriched-markdown/ios/utils/MarkdownASTSerializer.m @@ -22,6 +22,10 @@ static void serializeNode(MarkdownASTNode *node, NSMutableString *buffer) break; case MarkdownNodeTypeLineBreak: + [buffer appendString:@"\\\n"]; + break; + + case MarkdownNodeTypeSoftBreak: [buffer appendString:@"\n"]; break; diff --git a/packages/react-native-enriched-markdown/src/web/renderers/InlineRenderers.tsx b/packages/react-native-enriched-markdown/src/web/renderers/InlineRenderers.tsx index 55ae16fcf..091eb196b 100644 --- a/packages/react-native-enriched-markdown/src/web/renderers/InlineRenderers.tsx +++ b/packages/react-native-enriched-markdown/src/web/renderers/InlineRenderers.tsx @@ -12,6 +12,10 @@ function LineBreakRenderer(_props: RendererProps) { return
; } +function SoftBreakRenderer(_props: RendererProps) { + return <> ; +} + function StrongRenderer({ node, styles, renderChildren }: RendererProps) { return {renderChildren(node)}; } @@ -108,6 +112,7 @@ function LatexMathInlineRenderer({ export const inlineRenderers: RendererMap = { Text: TextRenderer, LineBreak: LineBreakRenderer, + SoftBreak: SoftBreakRenderer, Strong: StrongRenderer, Emphasis: EmphasisRenderer, Strikethrough: StrikethroughRenderer, diff --git a/packages/react-native-enriched-markdown/src/web/types.ts b/packages/react-native-enriched-markdown/src/web/types.ts index 6f8f30856..ccb2940b5 100644 --- a/packages/react-native-enriched-markdown/src/web/types.ts +++ b/packages/react-native-enriched-markdown/src/web/types.ts @@ -15,6 +15,7 @@ export type NodeType = | 'Link' | 'Heading' | 'LineBreak' + | 'SoftBreak' | 'Strong' | 'Emphasis' | 'Strikethrough' @@ -63,7 +64,7 @@ export interface ASTNode { content?: string; /** Present on nodes that carry structural metadata (Heading, Link, etc.). */ attributes?: NodeAttributes; - /** Child nodes; absent on leaf nodes (Text, LineBreak, ThematicBreak). */ + /** Child nodes; absent on leaf nodes (Text, LineBreak, SoftBreak, ThematicBreak). */ children?: ASTNode[]; } diff --git a/packages/react-native-enriched-markdown/src/web/utils.ts b/packages/react-native-enriched-markdown/src/web/utils.ts index dea6f0d0c..7d47f1286 100644 --- a/packages/react-native-enriched-markdown/src/web/utils.ts +++ b/packages/react-native-enriched-markdown/src/web/utils.ts @@ -2,6 +2,7 @@ import type { ASTNode } from './types'; /** Recursively collects plain text content from an AST node's subtree. */ export function extractNodeText(node: ASTNode): string { + if (node.type === 'SoftBreak' || node.type === 'LineBreak') return ' '; if (node.content !== undefined) return node.content; return node.children?.map(extractNodeText).join('') ?? ''; } diff --git a/packages/react-native-enriched-markdown/src/web/wasm/md4c.js b/packages/react-native-enriched-markdown/src/web/wasm/md4c.js index 0be72e56b..cbb7eb672 100644 Binary files a/packages/react-native-enriched-markdown/src/web/wasm/md4c.js and b/packages/react-native-enriched-markdown/src/web/wasm/md4c.js differ