diff --git a/.maestro/enrichedMarkdownText/flows/advanced/block_elements/list_code_block_first_combo_test.yaml b/.maestro/enrichedMarkdownText/flows/advanced/block_elements/list_code_block_first_combo_test.yaml new file mode 100644 index 000000000..0c6509f9f --- /dev/null +++ b/.maestro/enrichedMarkdownText/flows/advanced/block_elements/list_code_block_first_combo_test.yaml @@ -0,0 +1,33 @@ +appId: swmansion.enriched.markdown.example +tags: + - block + - advanced + - list + - code_block +--- +# checks list items whose first (and only) child is a fenced code block — +# the marker must still be drawn in the gutter next to the code block. +- launchApp + +- runFlow: + file: '../../../../subflows/move_to_playground.yaml' + +- runFlow: + file: '../../../subflows/set_enriched_text_value.yaml' + env: + VALUE: | + 1. ``` + npm install + ``` + 2. ``` + gh auth login + ``` + + - ``` + echo bullet + ``` + +- runFlow: + file: '../../../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'list_code_block_first_combo_display' diff --git a/.maestro/enrichedMarkdownText/flows/advanced/block_elements/list_code_block_loose_combo_test.yaml b/.maestro/enrichedMarkdownText/flows/advanced/block_elements/list_code_block_loose_combo_test.yaml new file mode 100644 index 000000000..b9a6b4143 --- /dev/null +++ b/.maestro/enrichedMarkdownText/flows/advanced/block_elements/list_code_block_loose_combo_test.yaml @@ -0,0 +1,40 @@ +appId: swmansion.enriched.markdown.example +tags: + - block + - advanced + - list + - code_block + - text +--- +# checks a "loose" ordered list where items and their fenced code blocks +# are separated by blank lines (each item wraps its content in Paragraph nodes). +- launchApp + +- runFlow: + file: '../../../../subflows/move_to_playground.yaml' + +- runFlow: + file: '../../../subflows/set_enriched_text_value.yaml' + env: + VALUE: | + 1. Install via npm: + ``` + npm install + ``` + + 2. Authenticate with GitHub: + ``` + gh auth login + ``` + + This opens your browser for OAuth. Once authorized, tokens are stored locally at `~/.config/gh/tokens.json`. + + 3. Verify the connection: + ``` + gh repo list + ``` + +- runFlow: + file: '../../../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'list_code_block_loose_combo_display' diff --git a/.maestro/enrichedMarkdownText/flows/advanced/block_elements/list_code_block_tight_combo_test.yaml b/.maestro/enrichedMarkdownText/flows/advanced/block_elements/list_code_block_tight_combo_test.yaml new file mode 100644 index 000000000..eecabf3d1 --- /dev/null +++ b/.maestro/enrichedMarkdownText/flows/advanced/block_elements/list_code_block_tight_combo_test.yaml @@ -0,0 +1,37 @@ +appId: swmansion.enriched.markdown.example +tags: + - block + - advanced + - list + - code_block + - text +--- +# checks a "tight" ordered list where items and their fenced code blocks +# are directly adjacent (no blank lines between). +- launchApp + +- runFlow: + file: '../../../../subflows/move_to_playground.yaml' + +- runFlow: + file: '../../../subflows/set_enriched_text_value.yaml' + env: + VALUE: | + 1. Install via npm: + ``` + npm install + ``` + 2. Authenticate with GitHub: + ``` + gh auth login + ``` + This opens your browser for OAuth. Once authorized, tokens are stored locally at `~/.config/gh/tokens.json`. + 3. Verify the connection: + ``` + gh repo list + ``` + +- runFlow: + file: '../../../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'list_code_block_tight_combo_display' diff --git a/.maestro/enrichedMarkdownText/flows/advanced/block_elements/list_sublist_first_combo_test.yaml b/.maestro/enrichedMarkdownText/flows/advanced/block_elements/list_sublist_first_combo_test.yaml new file mode 100644 index 000000000..6a2951561 --- /dev/null +++ b/.maestro/enrichedMarkdownText/flows/advanced/block_elements/list_sublist_first_combo_test.yaml @@ -0,0 +1,27 @@ +appId: swmansion.enriched.markdown.example +tags: + - block + - advanced + - list + - text +--- +# checks a list item whose first child is a nested sublist — both the outer +# and inner markers must be drawn on the shared first line, at their own gutters. +- launchApp + +- runFlow: + file: '../../../../subflows/move_to_playground.yaml' + +- runFlow: + file: '../../../subflows/set_enriched_text_value.yaml' + env: + VALUE: | + 1. + - alpha + - beta + 2. gamma + +- runFlow: + file: '../../../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'list_sublist_first_combo_display' diff --git a/.maestro/enrichedMarkdownText/flows/advanced/block_elements/task_list_code_block_combo_test.yaml b/.maestro/enrichedMarkdownText/flows/advanced/block_elements/task_list_code_block_combo_test.yaml new file mode 100644 index 000000000..fe0795be9 --- /dev/null +++ b/.maestro/enrichedMarkdownText/flows/advanced/block_elements/task_list_code_block_combo_test.yaml @@ -0,0 +1,29 @@ +appId: swmansion.enriched.markdown.example +tags: + - block + - advanced + - list + - task_list + - code_block +--- +# checks a checked task item containing a fenced code block — the checked +# text decorations (strikethrough / color) must not bleed into the code block. +- launchApp + +- runFlow: + file: '../../../../subflows/move_to_playground.yaml' + +- runFlow: + file: '../../../subflows/set_enriched_text_value.yaml' + env: + VALUE: | + - [x] setup complete + ``` + npm i + ``` + - [ ] still pending + +- runFlow: + file: '../../../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'task_list_code_block_combo_display' diff --git a/.maestro/enrichedMarkdownText/screenshots/android/blockquote_code_block_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/blockquote_code_block_combo_display.png index 2cced62c8..296333b28 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/blockquote_code_block_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/blockquote_code_block_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/blockquote_list_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/blockquote_list_combo_display.png index 2260ac227..7cbb2773e 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/blockquote_list_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/blockquote_list_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/header_blockquote_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/header_blockquote_combo_display.png index 6f3b069fe..39633a3a4 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/header_blockquote_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/header_blockquote_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/header_code_block_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/header_code_block_combo_display.png index 4c6df6e83..19b5f301d 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/header_code_block_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/header_code_block_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/header_display.png b/.maestro/enrichedMarkdownText/screenshots/android/header_display.png index a9e6b7966..7e8945fa2 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/header_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/header_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/header_list_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/header_list_combo_display.png index c7446c7e5..b7051bcad 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/header_list_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/header_list_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/header_paragraph_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/header_paragraph_combo_display.png index 8cb1ba946..3e6ad8c73 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/header_paragraph_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/header_paragraph_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/header_table_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/header_table_combo_display.png index a0744a861..6e6143075 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/header_table_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/header_table_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/list_code_block_first_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/list_code_block_first_combo_display.png new file mode 100644 index 000000000..c48aacbee Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/android/list_code_block_first_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/list_code_block_loose_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/list_code_block_loose_combo_display.png new file mode 100644 index 000000000..62ce86fab Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/android/list_code_block_loose_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/list_code_block_tight_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/list_code_block_tight_combo_display.png new file mode 100644 index 000000000..62ce86fab Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/android/list_code_block_tight_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/list_sublist_first_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/list_sublist_first_combo_display.png new file mode 100644 index 000000000..44b096846 Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/android/list_sublist_first_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/math_display_display.png b/.maestro/enrichedMarkdownText/screenshots/android/math_display_display.png index 833ffd9d8..60fd0230f 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/math_display_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/math_display_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/mixed_nested_list_display.png b/.maestro/enrichedMarkdownText/screenshots/android/mixed_nested_list_display.png index 906e1b31c..d556d6e6b 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/mixed_nested_list_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/mixed_nested_list_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/nested_blockquote_display.png b/.maestro/enrichedMarkdownText/screenshots/android/nested_blockquote_display.png index f39b66cbe..b3becbba6 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/nested_blockquote_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/nested_blockquote_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/paragraph_blockquote_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/paragraph_blockquote_combo_display.png index fa31a017f..0f272f700 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/paragraph_blockquote_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/paragraph_blockquote_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/paragraph_code_block_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/paragraph_code_block_combo_display.png index f139a18d9..16b36d5c4 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/paragraph_code_block_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/paragraph_code_block_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/paragraph_math_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/paragraph_math_combo_display.png index 2d29bf0c8..e9f9632a1 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/paragraph_math_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/paragraph_math_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/task_list_code_block_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/task_list_code_block_combo_display.png new file mode 100644 index 000000000..e1dcc8927 Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/android/task_list_code_block_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/thematic_break_display.png b/.maestro/enrichedMarkdownText/screenshots/android/thematic_break_display.png index f5ff4ff1b..0261f88f1 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/thematic_break_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/thematic_break_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/list_code_block_first_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/list_code_block_first_combo_display.png new file mode 100644 index 000000000..38f45942e Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/ios/list_code_block_first_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/list_code_block_loose_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/list_code_block_loose_combo_display.png new file mode 100644 index 000000000..6dbff7e0f Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/ios/list_code_block_loose_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/list_code_block_tight_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/list_code_block_tight_combo_display.png new file mode 100644 index 000000000..44c8a9234 Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/ios/list_code_block_tight_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/list_sublist_first_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/list_sublist_first_combo_display.png new file mode 100644 index 000000000..f7133eac2 Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/ios/list_sublist_first_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/nested_blockquote_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/nested_blockquote_display.png index 341104c47..d2b313f66 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/nested_blockquote_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/nested_blockquote_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/task_list_code_block_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/task_list_code_block_combo_display.png new file mode 100644 index 000000000..dd303dd03 Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/ios/task_list_code_block_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/task_list_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/task_list_display.png index fc8041ef4..970c8f86f 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/task_list_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/task_list_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/task_list_ordered_list_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/task_list_ordered_list_combo_display.png index fab6c47f2..601b59f77 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/task_list_ordered_list_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/task_list_ordered_list_combo_display.png differ diff --git a/docs/ELEMENTS_STRUCTURE.md b/docs/ELEMENTS_STRUCTURE.md index 1dd5cdeb5..fce66f63b 100644 --- a/docs/ELEMENTS_STRUCTURE.md +++ b/docs/ELEMENTS_STRUCTURE.md @@ -60,6 +60,22 @@ Markdown elements in `react-native-enriched-markdown` are organized into block a 2. Second item ``` +### Lists with Block Content Example + +List items can contain block elements — fenced code blocks, nested lists, and multiple paragraphs: + +````markdown +1. Install via npm: + + ``` + npm install + ``` + +2. Verify the connection. +```` + +Code blocks indent to the item's content column, and the marker is drawn next to the first line even when a code block or nested list is the item's first child. + ### Nested Blockquotes Example ```markdown diff --git a/docs/STYLES.md b/docs/STYLES.md index 353fddb2d..32fa06a15 100644 --- a/docs/STYLES.md +++ b/docs/STYLES.md @@ -315,6 +315,9 @@ function App() { | `borderWidth` | `number` | Border width | | `padding` | `number` | Inner padding | +> [!NOTE] +> Inside list items, code blocks (background included) indent to the item's content column. + ### Inline Code-specific | Property | Type | Description | diff --git a/packages/core/cpp/parser/MD4CParser.cpp b/packages/core/cpp/parser/MD4CParser.cpp index 35bc13557..075580c80 100644 --- a/packages/core/cpp/parser/MD4CParser.cpp +++ b/packages/core/cpp/parser/MD4CParser.cpp @@ -458,6 +458,91 @@ void promoteDisplayMathFromParagraphs(MarkdownASTNode &root) { } } +bool isBlockNode(const MarkdownASTNode &node) { + switch (node.type) { + case NodeType::Document: + case NodeType::Paragraph: + case NodeType::Heading: + case NodeType::Blockquote: + case NodeType::UnorderedList: + case NodeType::OrderedList: + case NodeType::ListItem: + case NodeType::CodeBlock: + case NodeType::ThematicBreak: + case NodeType::LatexMathDisplay: + case NodeType::Table: + case NodeType::TableHead: + case NodeType::TableBody: + case NodeType::TableRow: + case NodeType::TableHeaderCell: + case NodeType::TableCell: + return true; + default: + return false; + } +} + +// md4c omits Paragraph wrappers around the inline content of tight list items. +// Wrap each run of consecutive inline children of a ListItem in a synthetic +// Paragraph (marked "tight") so renderers only ever see block children. +void wrapListItemInlineRuns(MarkdownASTNode &node) { + for (auto &child : node.children) { + wrapListItemInlineRuns(*child); + } + + if (node.type != NodeType::ListItem) + return; + + bool hasInlineChild = false; + for (auto &child : node.children) { + if (!isBlockNode(*child)) { + hasInlineChild = true; + break; + } + } + if (!hasInlineChild) + return; + + std::vector> newChildren; + newChildren.reserve(node.children.size()); + std::vector> run; + + auto flushRun = [&]() { + if (run.empty()) + return; + bool hasContent = false; + for (auto &n : run) { + if (!isSeparatorNode(*n)) { + hasContent = true; + break; + } + } + if (hasContent) { + auto paragraph = std::make_shared(NodeType::Paragraph); + paragraph->setAttribute("tight", "true"); + paragraph->children = std::move(run); + newChildren.push_back(std::move(paragraph)); + } else { + // Whitespace-only run between blocks — keep as-is, a paragraph would add spacing. + for (auto &n : run) { + newChildren.push_back(std::move(n)); + } + } + run.clear(); + }; + + for (auto &child : node.children) { + if (isBlockNode(*child)) { + flushRun(); + newChildren.push_back(std::move(child)); + } else { + run.push_back(std::move(child)); + } + } + flushRun(); + node.children = std::move(newChildren); +} + } // anonymous namespace MD4CParser::MD4CParser() : impl_(std::make_unique()) {} @@ -522,6 +607,7 @@ std::shared_ptr MD4CParser::parse(const std::string &markdown, if (impl_->root) { promoteDisplayMathFromParagraphs(*impl_->root); + wrapListItemInlineRuns(*impl_->root); } return impl_->root ? impl_->root : std::make_shared(NodeType::Document); diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockStyleContext.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockStyleContext.kt index 59e81240a..a3d707866 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockStyleContext.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockStyleContext.kt @@ -42,6 +42,7 @@ class BlockStyleContext { var listType: ListType? = null var listItemNumber = 0 var taskItemCount = 0 + var accumulatedIndent = 0 private val orderedListItemNumbers = ArrayDeque() @@ -147,6 +148,7 @@ class BlockStyleContext { listType = null listItemNumber = 0 taskItemCount = 0 + accumulatedIndent = 0 orderedListItemNumbers.clear() } } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteRenderer.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteRenderer.kt index 4757f539f..b74e6b604 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteRenderer.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteRenderer.kt @@ -19,6 +19,10 @@ class BlockquoteRenderer( onLinkLongPress: ((String) -> Unit)?, factory: RendererFactory, ) { + if (builder.isNotEmpty() && builder.last() != '\n') { + builder.append("\n") + } + val start = builder.length val style = config.style.blockquoteStyle val context = factory.blockStyleContext diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/CodeBlockRenderer.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/CodeBlockRenderer.kt index afb64ce27..53c43816c 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/CodeBlockRenderer.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/CodeBlockRenderer.kt @@ -20,6 +20,10 @@ class CodeBlockRenderer( onLinkLongPress: ((String) -> Unit)?, factory: RendererFactory, ) { + if (builder.isNotEmpty() && builder.last() != '\n') { + builder.append("\n") + } + val start = builder.length val style = config.style.codeBlockStyle val context = factory.blockStyleContext @@ -44,7 +48,7 @@ class CodeBlockRenderer( // Apply background, borders, and horizontal padding to content only builder.setSpan( - CodeBlockSpan(style, factory.context, factory.styleCache), + CodeBlockSpan(style, factory.context, factory.styleCache, context.accumulatedIndent), contentStart, end, SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE, diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/HeadingRenderer.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/HeadingRenderer.kt index 39e7a3978..f867ea8ec 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/HeadingRenderer.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/HeadingRenderer.kt @@ -19,6 +19,10 @@ class HeadingRenderer( onLinkLongPress: ((String) -> Unit)?, factory: RendererFactory, ) { + if (builder.isNotEmpty() && builder.last() != '\n') { + builder.append("\n") + } + val level = node.getAttribute("level")?.toIntOrNull() ?: 1 val start = builder.length diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListItemRenderer.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListItemRenderer.kt index 66f11def6..f9da150f8 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListItemRenderer.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListItemRenderer.kt @@ -5,6 +5,8 @@ import android.text.style.ForegroundColorSpan import android.text.style.StrikethroughSpan import com.swmansion.enriched.markdown.parser.MarkdownASTNode import com.swmansion.enriched.markdown.spans.BaseListSpan +import com.swmansion.enriched.markdown.spans.CodeBlockSpan +import com.swmansion.enriched.markdown.spans.ListMarkerAnchorSpan import com.swmansion.enriched.markdown.spans.OrderedListSpan import com.swmansion.enriched.markdown.spans.TaskListSpan import com.swmansion.enriched.markdown.spans.UnorderedListSpan @@ -33,19 +35,10 @@ class ListItemRenderer( val taskIndex = if (isTask) styleContext.taskItemCount++ else -1 - factory.renderChildren(node, builder, onLinkPress, onLinkLongPress) - - if (builder.length == start || builder.substring(start).isBlank()) return - - while (builder.length > start && builder.last() == '\n') { - builder.delete(builder.length - 1, builder.length) - } - builder.append("\n") - val depth = styleContext.listDepth - 1 val listStyle = config.style.listStyle - val itemEnd = builder.length - val span = + + fun makeSpan(drawsMarker: Boolean): BaseListSpan = if (isTask) { TaskListSpan( taskStyle = config.style.taskListStyle, @@ -55,22 +48,83 @@ class ListItemRenderer( styleCache = factory.styleCache, taskIndex = taskIndex, isChecked = isChecked, + drawsMarker = drawsMarker, ) } else { when (listType) { BlockStyleContext.ListType.UNORDERED -> { - UnorderedListSpan(listStyle, depth, factory.context, factory.styleCache) + UnorderedListSpan(listStyle, depth, factory.context, factory.styleCache, drawsMarker) } BlockStyleContext.ListType.ORDERED -> { - OrderedListSpan(listStyle, depth, factory.context, factory.styleCache).apply { + OrderedListSpan(listStyle, depth, factory.context, factory.styleCache, drawsMarker).apply { setItemNumber(styleContext.listItemNumber) } } } } - builder.setSpan(span, start, itemEnd, SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE) + val markerSpan = makeSpan(drawsMarker = true) + + val prevIndent = styleContext.accumulatedIndent + styleContext.accumulatedIndent = prevIndent + markerSpan.getLeadingMargin(false) + try { + factory.renderChildren(node, builder, onLinkPress, onLinkLongPress) + } finally { + styleContext.accumulatedIndent = prevIndent + } + + if (builder.length == start || builder.substring(start).isBlank()) return + + if (builder.last() != '\n') { + builder.append("\n") + } + + val itemEnd = builder.length + + // Anchor the marker on the first content character so items that open with + // a code block or nested sublist still get their marker. + var anchor = start + while (anchor < itemEnd && builder[anchor].isWhitespace()) anchor++ + if (anchor == itemEnd) anchor = start + + val plainAnchor = + builder.getSpans(anchor, anchor + 1, CodeBlockSpan::class.java).isEmpty() && + builder.getSpans(anchor, anchor + 1, BaseListSpan::class.java).none { it.depth > depth } + + val codeBlockRanges = + builder + .getSpans(start, itemEnd, CodeBlockSpan::class.java) + .map { builder.getSpanStart(it) to builder.getSpanEnd(it) } + .filter { it.first < it.second } + .sortedBy { it.first } + + var pos = start + var isFirstSegment = true + for ((cbStart, cbEnd) in codeBlockRanges) { + if (pos < cbStart) { + builder.setSpan( + if (isFirstSegment && plainAnchor) markerSpan else makeSpan(drawsMarker = false), + pos, + cbStart, + SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE, + ) + isFirstSegment = false + } + pos = maxOf(pos, cbEnd) + } + if (pos < itemEnd) { + builder.setSpan( + if (isFirstSegment && plainAnchor) markerSpan else makeSpan(drawsMarker = false), + pos, + itemEnd, + SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE, + ) + } + + if (!plainAnchor) { + builder.setSpan(ListMarkerAnchorSpan(markerSpan), anchor, anchor + 1, SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE) + } if (isTask && isChecked) { applyCheckedDecorations(builder, start, itemEnd, depth) @@ -90,10 +144,10 @@ class ListItemRenderer( if (checkedTextColor == 0 && !strikethrough) return val excludedRanges = - builder - .getSpans(itemStart, itemEnd, BaseListSpan::class.java) - .filter { it.depth > itemDepth } - .map { builder.getSpanStart(it) to builder.getSpanEnd(it) } + ( + builder.getSpans(itemStart, itemEnd, BaseListSpan::class.java).filter { it.depth > itemDepth } + + builder.getSpans(itemStart, itemEnd, CodeBlockSpan::class.java).toList() + ).map { builder.getSpanStart(it) to builder.getSpanEnd(it) } .sortedBy { it.first } var currentPos = itemStart diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/BaseListSpan.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/BaseListSpan.kt index 5635dacf7..9520584f0 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/BaseListSpan.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/BaseListSpan.kt @@ -22,6 +22,7 @@ abstract class BaseListSpan( protected val blockStyle: BlockStyle, protected val marginLeft: Float, protected val gapWidth: Float, + internal val drawsMarker: Boolean = true, ) : MetricAffectingSpan(), LeadingMarginSpan { private var cachedText: CharSequence? = null @@ -55,6 +56,7 @@ abstract class BaseListSpan( first: Boolean, layout: Layout?, ) { + if (!drawsMarker) return if (!first || shouldSkipDrawing(text, start) || !hasContent(text, start, end)) return // Only draw the marker on the true first line of this span. @@ -62,6 +64,20 @@ abstract class BaseListSpan( // but only the line at the span start should get a bullet/number. if (text is Spanned && text.getSpanStart(this) != start) return + drawMarkerAt(canvas, paint, x, dir, top, baseline, bottom, layout, start) + } + + fun drawMarkerAt( + canvas: Canvas, + paint: Paint, + x: Int, + dir: Int, + top: Int, + baseline: Int, + bottom: Int, + layout: Layout?, + start: Int, + ) { val originalStyle = paint.style val originalColor = paint.color drawMarker(canvas, paint, x, dir, top, baseline, bottom, layout, start) diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBackgroundSpan.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBackgroundSpan.kt index d0dc2c2bd..1b07a2ed4 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBackgroundSpan.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBackgroundSpan.kt @@ -7,6 +7,7 @@ import android.graphics.RectF import android.text.Spanned import android.text.StaticLayout import android.text.TextPaint +import android.text.style.LeadingMarginSpan import android.text.style.LineBackgroundSpan import com.swmansion.enriched.markdown.styles.StyleConfig import kotlin.math.max @@ -58,8 +59,9 @@ class CodeBackgroundSpan( // 2. Calculate coordinates val finalBottom = adjustBottomForMargin(text, end, bottom) - val startX = if (isFirst) getHorizontalOffset(text, start, end, spanStart, p) + left else left.toFloat() - val endX = if (isLast) getHorizontalOffset(text, start, end, spanEnd, p) + left else right.toFloat() + val leadingMargin = leadingMarginAt(text, start) + val startX = if (isFirst) getHorizontalOffset(text, start, end, spanStart, p) + left + leadingMargin else left.toFloat() + leadingMargin + val endX = if (isLast) getHorizontalOffset(text, start, end, spanEnd, p) + left + leadingMargin else right.toFloat() rect.set(min(startX, endX), top.toFloat(), max(startX, endX), finalBottom.toFloat()) @@ -164,6 +166,19 @@ class CodeBackgroundSpan( } } + private fun leadingMarginAt( + text: Spanned, + lineStart: Int, + ): Int { + if (lineStart >= text.length) return 0 + val spans = text.getSpans(lineStart, lineStart + 1, LeadingMarginSpan::class.java) + var margin = 0 + for (span in spans) { + margin += span.getLeadingMargin(false) + } + return margin + } + private fun adjustBottomForMargin( text: Spanned, lineEnd: Int, diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBlockSpan.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBlockSpan.kt index a9e022715..08a2c603d 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBlockSpan.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBlockSpan.kt @@ -22,6 +22,7 @@ class CodeBlockSpan( private val codeBlockStyle: CodeBlockStyle, private val context: Context, private val styleCache: SpanStyleCache, + private val leadingIndent: Int = 0, ) : MetricAffectingSpan(), LineBackgroundSpan, LeadingMarginSpan { @@ -59,7 +60,7 @@ class CodeBlockSpan( color = codeBlockStyle.borderColor } - override fun getLeadingMargin(first: Boolean): Int = codeBlockStyle.padding.toInt() + override fun getLeadingMargin(first: Boolean): Int = codeBlockStyle.padding.toInt() + leadingIndent override fun drawLeadingMargin( c: Canvas?, @@ -105,11 +106,12 @@ class CodeBlockSpan( val inset = codeBlockStyle.borderWidth / 2f rect.set( - left.toFloat() + inset, + left.toFloat() + leadingIndent + inset, top.toFloat() + (if (isFirstLine) inset else 0f), right.toFloat() - inset, bottom.toFloat() - (if (isLastLine) inset else 0f), ) + if (rect.left >= rect.right) return val radius = codeBlockStyle.borderRadius val adjRadius = if (radius > inset) radius - inset else radius diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/ListMarkerAnchorSpan.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/ListMarkerAnchorSpan.kt new file mode 100644 index 000000000..73a9da635 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/ListMarkerAnchorSpan.kt @@ -0,0 +1,38 @@ +package com.swmansion.enriched.markdown.spans + +import android.graphics.Canvas +import android.graphics.Paint +import android.text.Layout +import android.text.Spanned +import android.text.style.LeadingMarginSpan + +// Draws a list marker on a line owned by another block (code block, nested +// sublist) without contributing any margin of its own. +class ListMarkerAnchorSpan( + internal val marker: BaseListSpan, +) : LeadingMarginSpan { + override fun getLeadingMargin(first: Boolean): Int = 0 + + override fun drawLeadingMargin( + c: Canvas, + p: Paint, + x: Int, + dir: Int, + top: Int, + baseline: Int, + bottom: Int, + text: CharSequence?, + start: Int, + end: Int, + first: Boolean, + layout: Layout?, + ) { + if (!first) return + if (text is Spanned && text.getSpanStart(this) != start) return + + // Pass the paragraph origin: drawMarker reconstructs its offset from depth, + // matching the x its own span would receive on a plain text line. + val originX = if (dir > 0) 0 else layout?.width ?: 0 + marker.drawMarkerAt(c, p, originX, dir, top, baseline, bottom, layout, start) + } +} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/OrderedListSpan.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/OrderedListSpan.kt index b74dad3f5..566f52800 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/OrderedListSpan.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/OrderedListSpan.kt @@ -18,6 +18,7 @@ class OrderedListSpan( depth: Int, context: Context, styleCache: SpanStyleCache, + drawsMarker: Boolean = true, ) : BaseListSpan( depth = depth, context = context, @@ -31,6 +32,7 @@ class OrderedListSpan( ), marginLeft = listStyle.marginLeft, gapWidth = listStyle.gapWidth, + drawsMarker = drawsMarker, ) { companion object { private val sharedMarkerPaint = TextPaint().apply { isAntiAlias = true } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/TaskListSpan.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/TaskListSpan.kt index f1a1897e3..a28b133d6 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/TaskListSpan.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/TaskListSpan.kt @@ -19,6 +19,7 @@ class TaskListSpan( styleCache: SpanStyleCache, val taskIndex: Int, val isChecked: Boolean, + drawsMarker: Boolean = true, ) : BaseListSpan( depth = depth, context = context, @@ -32,6 +33,7 @@ class TaskListSpan( ), marginLeft = listStyle.marginLeft, gapWidth = listStyle.gapWidth, + drawsMarker = drawsMarker, ) { private val checkboxSize = taskStyle.checkboxSize private val markerColumnWidth = listStyle.effectiveMarkerWidth(checkboxSize) diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/UnorderedListSpan.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/UnorderedListSpan.kt index 2b3465aa5..5718a2ed0 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/UnorderedListSpan.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/UnorderedListSpan.kt @@ -13,6 +13,7 @@ class UnorderedListSpan( depth: Int, context: Context, styleCache: SpanStyleCache, + drawsMarker: Boolean = true, ) : BaseListSpan( depth = depth, context = context, @@ -26,6 +27,7 @@ class UnorderedListSpan( ), marginLeft = listStyle.marginLeft, gapWidth = listStyle.gapWidth, + drawsMarker = drawsMarker, ) { companion object { private val sharedBulletPaint = diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/interaction/TaskListTapUtils.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/interaction/TaskListTapUtils.kt index 62760c717..f5aa58c06 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/interaction/TaskListTapUtils.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/interaction/TaskListTapUtils.kt @@ -8,6 +8,8 @@ import android.text.style.StrikethroughSpan import android.widget.TextView import com.swmansion.enriched.markdown.renderer.SpanStyleCache import com.swmansion.enriched.markdown.spans.BaseListSpan +import com.swmansion.enriched.markdown.spans.CodeBlockSpan +import com.swmansion.enriched.markdown.spans.ListMarkerAnchorSpan import com.swmansion.enriched.markdown.spans.TaskListSpan import com.swmansion.enriched.markdown.styles.StyleConfig import com.swmansion.enriched.markdown.utils.text.span.SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE @@ -101,51 +103,69 @@ object TaskListTapUtils { } val spannable = SpannableStringBuilder(text) - val taskSpans = spannable.getSpans(0, spannable.length, TaskListSpan::class.java) - - val targetSpan = taskSpans.firstOrNull { it.taskIndex == targetIndex } - if (targetSpan == null) { + val targetSpans = + spannable + .getSpans(0, spannable.length, TaskListSpan::class.java) + .filter { it.taskIndex == targetIndex } + if (targetSpans.isEmpty()) { return false } - if (targetSpan.isChecked == newChecked) { + if (targetSpans.all { it.isChecked == newChecked }) { return true } - val spanStart = spannable.getSpanStart(targetSpan) - val spanEnd = spannable.getSpanEnd(targetSpan) - val itemDepth = targetSpan.depth + val itemDepth = targetSpans.first().depth + val spanStart = targetSpans.minOf { spannable.getSpanStart(it) } + val spanEnd = targetSpans.maxOf { spannable.getSpanEnd(it) } val styleCache = SpanStyleCache(styleConfig) - val newTaskSpan = + + fun toggledCopy(old: TaskListSpan): TaskListSpan = TaskListSpan( taskStyle = styleConfig.taskListStyle, listStyle = styleConfig.listStyle, - depth = itemDepth, + depth = old.depth, context = textView.context, styleCache = styleCache, taskIndex = targetIndex, isChecked = newChecked, + drawsMarker = old.drawsMarker, ) - spannable.removeSpan(targetSpan) - spannable.setSpan(newTaskSpan, spanStart, spanEnd, SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE) - - val verifySpans = spannable.getSpans(spanStart, spanEnd, TaskListSpan::class.java) - val verifySpan = verifySpans.firstOrNull { it.taskIndex == targetIndex } - if (verifySpan == null || verifySpan.isChecked != newChecked) { - return false + // Replace every segment of the item — spans are split around code blocks + for (old in targetSpans) { + val start = spannable.getSpanStart(old) + val end = spannable.getSpanEnd(old) + spannable.removeSpan(old) + spannable.setSpan(toggledCopy(old), start, end, SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE) } + // The marker may be drawn by an anchor span wrapping an unattached copy + spannable + .getSpans(0, spannable.length, ListMarkerAnchorSpan::class.java) + .filter { (it.marker as? TaskListSpan)?.taskIndex == targetIndex } + .forEach { old -> + val start = spannable.getSpanStart(old) + val end = spannable.getSpanEnd(old) + spannable.removeSpan(old) + spannable.setSpan( + ListMarkerAnchorSpan(toggledCopy(old.marker as TaskListSpan)), + start, + end, + SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE, + ) + } + val taskStyle = styleConfig.taskListStyle val checkedTextColor = taskStyle.checkedTextColor val strikethrough = taskStyle.checkedStrikethrough val excludedRanges = - spannable - .getSpans(spanStart, spanEnd, BaseListSpan::class.java) - .filter { it.depth > itemDepth } - .map { spannable.getSpanStart(it) to spannable.getSpanEnd(it) } + ( + spannable.getSpans(spanStart, spanEnd, BaseListSpan::class.java).filter { it.depth > itemDepth } + + spannable.getSpans(spanStart, spanEnd, CodeBlockSpan::class.java).toList() + ).map { spannable.getSpanStart(it) to spannable.getSpanEnd(it) } .sortedBy { it.first } applyDecorationsToRanges( diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/span/BlockSpacing.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/span/BlockSpacing.kt index 37cb66862..6677482b9 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/span/BlockSpacing.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/span/BlockSpacing.kt @@ -1,6 +1,7 @@ package com.swmansion.enriched.markdown.utils.text.span import android.text.SpannableStringBuilder +import com.swmansion.enriched.markdown.spans.CodeBlockSpan import com.swmansion.enriched.markdown.spans.ImageSpan import com.swmansion.enriched.markdown.spans.LineHeightSpan import com.swmansion.enriched.markdown.spans.MarginBottomSpan @@ -11,9 +12,9 @@ fun createLineHeightSpan(lineHeight: Float): AndroidLineHeightSpan = LineHeightS /** * Applies [LineHeightSpan] to [start]..[end] but skips ranges occupied by - * block [ImageSpan]s (and their trailing '\n'). Without this, the fixed line - * height would re-clamp the metrics that [ImageSpan.chooseHeight] expanded, - * causing the image to overflow downward and overlap subsequent content. + * block [ImageSpan]s (and their trailing '\n') and by [CodeBlockSpan]s — + * both of these apply their own font-metric adjustments that a fixed + * line height would override. */ fun applyLineHeightSkippingImages( builder: SpannableStringBuilder, @@ -26,19 +27,25 @@ fun applyLineHeightSkippingImages( .getSpans(start, end, ImageSpan::class.java) .filter { !it.isInline } .map { builder.getSpanStart(it) to builder.getSpanEnd(it) } - .sortedBy { it.first } + + val codeBlockRanges = + builder + .getSpans(start, end, CodeBlockSpan::class.java) + .map { builder.getSpanStart(it) to builder.getSpanEnd(it) } + + val excludedRanges = (blockImageRanges + codeBlockRanges).sortedBy { it.first } var pos = start - for ((imgStart, imgEnd) in blockImageRanges) { - if (pos < imgStart) { + for ((exStart, exEnd) in excludedRanges) { + if (pos < exStart) { builder.setSpan( createLineHeightSpan(lineHeight), pos, - imgStart, + exStart, SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE, ) } - val skipEnd = if (imgEnd < end && builder[imgEnd] == '\n') imgEnd + 1 else imgEnd + val skipEnd = if (exEnd < end && builder[exEnd] == '\n') exEnd + 1 else exEnd pos = maxOf(pos, skipEnd) } if (pos < end) { diff --git a/packages/react-native-enriched-markdown/ios/renderer/BaseRenderer.h b/packages/react-native-enriched-markdown/ios/renderer/BaseRenderer.h index eb2cfeb84..9b7f3c809 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/BaseRenderer.h +++ b/packages/react-native-enriched-markdown/ios/renderer/BaseRenderer.h @@ -3,11 +3,16 @@ @class RendererFactory; @class StyleConfig; +@class RenderContext; +@class MarkdownASTNode; /// Shared base for all node renderers — provides `_rendererFactory` and `_config` /// as protected ivars and the common designated initializer. -/// Concrete subclasses must implement `renderNode:into:context:`. -@interface BaseRenderer : NSObject { +/// `renderNode:into:context:` is a template method: take context snapshot -> +/// `renderNodeContent:into:context:` -> restore snapshot. Subclasses implement +/// `renderNodeContent:into:context:` and, when they mutate scoped context state, +/// override `takeContextSnapshot:` to return `[context snapshotScope]`. +@interface BaseRenderer : NSObject { @protected __weak RendererFactory *_rendererFactory; StyleConfig *_config; @@ -15,4 +20,14 @@ - (instancetype)initWithRendererFactory:(id)rendererFactory config:(id)config; +- (void)renderNodeContent:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + context:(RenderContext *)context; + +/// Default returns nil. +- (nullable id)takeContextSnapshot:(RenderContext *)context; + +/// Default restores a scope frame when snapshot is non-nil, no-op otherwise. +- (void)restoreContextSnapshot:(nullable id)snapshot context:(RenderContext *)context; + @end diff --git a/packages/react-native-enriched-markdown/ios/renderer/BaseRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/BaseRenderer.m index 79cd18fa4..ab2531b3e 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/BaseRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/BaseRenderer.m @@ -1,4 +1,5 @@ #import "BaseRenderer.h" +#import "RenderContext.h" #import "RendererFactory.h" #import "StyleConfig.h" @@ -13,4 +14,33 @@ - (instancetype)initWithRendererFactory:(id)rendererFactory config:(id)config return self; } +- (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context +{ + id snapshot = [self takeContextSnapshot:context]; + @try { + [self renderNodeContent:node into:output context:context]; + } @finally { + [self restoreContextSnapshot:snapshot context:context]; + } +} + +- (void)renderNodeContent:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + context:(RenderContext *)context +{ + [self doesNotRecognizeSelector:_cmd]; +} + +- (id)takeContextSnapshot:(RenderContext *)context +{ + return nil; +} + +- (void)restoreContextSnapshot:(id)snapshot context:(RenderContext *)context +{ + if (snapshot) { + [context restoreScope:snapshot]; + } +} + @end diff --git a/packages/react-native-enriched-markdown/ios/renderer/BlockquoteRenderer.h b/packages/react-native-enriched-markdown/ios/renderer/BlockquoteRenderer.h index ae0d927c3..ca973374f 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/BlockquoteRenderer.h +++ b/packages/react-native-enriched-markdown/ios/renderer/BlockquoteRenderer.h @@ -2,5 +2,5 @@ #import "MarkdownASTNode.h" #import "RenderContext.h" -@interface BlockquoteRenderer : BaseRenderer +@interface BlockquoteRenderer : BaseRenderer @end diff --git a/packages/react-native-enriched-markdown/ios/renderer/BlockquoteRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/BlockquoteRenderer.m index 7b77f472d..b51e4db28 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/BlockquoteRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/BlockquoteRenderer.m @@ -11,8 +11,19 @@ @implementation BlockquoteRenderer -- (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context +- (id)takeContextSnapshot:(RenderContext *)context { + return [context snapshotScope]; +} + +- (void)renderNodeContent:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + context:(RenderContext *)context +{ + if (output.length > 0 && ![output.string hasSuffix:@"\n"]) { + [output appendAttributedString:kNewlineAttributedString]; + } + NSInteger currentDepth = context.blockquoteDepth; context.blockquoteDepth = currentDepth + 1; @@ -22,12 +33,7 @@ - (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)out headingLevel:0]; NSUInteger start = output.length; - @try { - [_rendererFactory renderChildrenOfNode:node into:output context:context]; - } @finally { - [context clearBlockStyle]; - context.blockquoteDepth = currentDepth; - } + [_rendererFactory renderChildrenOfNode:node into:output context:context]; NSUInteger end = output.length; if (end <= start) { diff --git a/packages/react-native-enriched-markdown/ios/renderer/CodeBlockRenderer.h b/packages/react-native-enriched-markdown/ios/renderer/CodeBlockRenderer.h index d1f7e3c1c..74ac77e8e 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/CodeBlockRenderer.h +++ b/packages/react-native-enriched-markdown/ios/renderer/CodeBlockRenderer.h @@ -3,7 +3,7 @@ NS_ASSUME_NONNULL_BEGIN -@interface CodeBlockRenderer : BaseRenderer +@interface CodeBlockRenderer : BaseRenderer @end NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/renderer/CodeBlockRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/CodeBlockRenderer.m index e54cea283..0e962b428 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/CodeBlockRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/CodeBlockRenderer.m @@ -9,10 +9,24 @@ @implementation CodeBlockRenderer -- (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context +- (id)takeContextSnapshot:(RenderContext *)context { + return [context snapshotScope]; +} + +- (void)renderNodeContent:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + context:(RenderContext *)context +{ + // In tight lists md4c emits raw Text siblings without paragraph wrappers, + // so the preceding content may not terminate with a newline. + if (output.length > 0 && ![output.string hasSuffix:@"\n"]) { + [output appendAttributedString:kNewlineAttributedString]; + } + [context setBlockStyle:BlockTypeCodeBlock font:[_config codeBlockFont] color:[_config codeBlockColor] headingLevel:0]; + CGFloat listIndent = context.accumulatedIndent; CGFloat padding = [_config codeBlockPadding]; CGFloat lineHeight = [_config codeBlockLineHeight]; CGFloat marginTop = [_config codeBlockMarginTop]; @@ -31,11 +45,7 @@ - (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)out } NSUInteger contentStart = output.length; - @try { - [_rendererFactory renderChildrenOfNode:node into:output context:context]; - } @finally { - [context clearBlockStyle]; - } + [_rendererFactory renderChildrenOfNode:node into:output context:context]; NSUInteger contentEnd = output.length; if (contentEnd <= contentStart) @@ -60,8 +70,8 @@ - (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)out NSMutableParagraphStyle *baseStyle = [getOrCreateParagraphStyle(output, contentStart) mutableCopy]; baseStyle.baseWritingDirection = NSWritingDirectionLeftToRight; baseStyle.alignment = NSTextAlignmentLeft; - baseStyle.firstLineHeadIndent = padding; - baseStyle.headIndent = padding; + baseStyle.firstLineHeadIndent = padding + listIndent; + baseStyle.headIndent = padding + listIndent; baseStyle.tailIndent = -padding; [output addAttribute:NSParagraphStyleAttributeName value:baseStyle range:contentRange]; @@ -80,6 +90,9 @@ - (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)out // Define the range for background rendering (includes padding, excludes margins) NSRange backgroundRange = NSMakeRange(blockStart, output.length - blockStart); [output addAttribute:CodeBlockAttributeName value:@YES range:backgroundRange]; + if (listIndent > 0) { + [output addAttribute:CodeBlockIndentAttributeName value:@(listIndent) range:backgroundRange]; + } // External Margin: Applied outside the background range if (marginBottom > 0) { diff --git a/packages/react-native-enriched-markdown/ios/renderer/CodeRenderer.h b/packages/react-native-enriched-markdown/ios/renderer/CodeRenderer.h index 146d75e3d..ccc5f8100 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/CodeRenderer.h +++ b/packages/react-native-enriched-markdown/ios/renderer/CodeRenderer.h @@ -3,7 +3,7 @@ NS_ASSUME_NONNULL_BEGIN -@interface CodeRenderer : BaseRenderer +@interface CodeRenderer : BaseRenderer @end NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/renderer/CodeRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/CodeRenderer.m index 74248631b..7bc14aed5 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/CodeRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/CodeRenderer.m @@ -10,7 +10,9 @@ @implementation CodeRenderer -- (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context +- (void)renderNodeContent:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + context:(RenderContext *)context { BlockStyle *blockStyle = [context getBlockStyle]; diff --git a/packages/react-native-enriched-markdown/ios/renderer/ENRMImageRenderer.h b/packages/react-native-enriched-markdown/ios/renderer/ENRMImageRenderer.h index 926bc1a65..6ffa0bd3a 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/ENRMImageRenderer.h +++ b/packages/react-native-enriched-markdown/ios/renderer/ENRMImageRenderer.h @@ -3,7 +3,7 @@ NS_ASSUME_NONNULL_BEGIN -@interface ENRMImageRenderer : BaseRenderer +@interface ENRMImageRenderer : BaseRenderer @end NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/renderer/ENRMImageRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/ENRMImageRenderer.m index 35b8819c3..ec81c5921 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/ENRMImageRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/ENRMImageRenderer.m @@ -10,7 +10,9 @@ @implementation ENRMImageRenderer -- (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context +- (void)renderNodeContent:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + context:(RenderContext *)context { NSString *imageURL = node.attributes[@"url"]; if (!imageURL || imageURL.length == 0) { diff --git a/packages/react-native-enriched-markdown/ios/renderer/ENRMMathInlineRenderer.h b/packages/react-native-enriched-markdown/ios/renderer/ENRMMathInlineRenderer.h index 4b44fb1ac..bdfe794b2 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/ENRMMathInlineRenderer.h +++ b/packages/react-native-enriched-markdown/ios/renderer/ENRMMathInlineRenderer.h @@ -3,7 +3,7 @@ NS_ASSUME_NONNULL_BEGIN -@interface ENRMMathInlineRenderer : BaseRenderer +@interface ENRMMathInlineRenderer : BaseRenderer @end NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/renderer/ENRMMathInlineRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/ENRMMathInlineRenderer.m index e836610bf..9d270ce40 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/ENRMMathInlineRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/ENRMMathInlineRenderer.m @@ -11,7 +11,9 @@ @implementation ENRMMathInlineRenderer -- (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context +- (void)renderNodeContent:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + context:(RenderContext *)context { NSString *latex = [self extractLatexFromNode:node]; if (latex.length == 0) { diff --git a/packages/react-native-enriched-markdown/ios/renderer/ENRMSpoilerRenderer.h b/packages/react-native-enriched-markdown/ios/renderer/ENRMSpoilerRenderer.h index 35ee48d95..71a08284e 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/ENRMSpoilerRenderer.h +++ b/packages/react-native-enriched-markdown/ios/renderer/ENRMSpoilerRenderer.h @@ -1,5 +1,5 @@ #pragma once #import "BaseRenderer.h" -@interface ENRMSpoilerRenderer : BaseRenderer +@interface ENRMSpoilerRenderer : BaseRenderer @end diff --git a/packages/react-native-enriched-markdown/ios/renderer/ENRMSpoilerRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/ENRMSpoilerRenderer.m index 5855d022c..61547246f 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/ENRMSpoilerRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/ENRMSpoilerRenderer.m @@ -6,7 +6,9 @@ @implementation ENRMSpoilerRenderer -- (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context +- (void)renderNodeContent:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + context:(RenderContext *)context { NSUInteger start = output.length; [_rendererFactory renderChildrenOfNode:node into:output context:context]; diff --git a/packages/react-native-enriched-markdown/ios/renderer/EmphasisRenderer.h b/packages/react-native-enriched-markdown/ios/renderer/EmphasisRenderer.h index 4ccef3114..81724e422 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/EmphasisRenderer.h +++ b/packages/react-native-enriched-markdown/ios/renderer/EmphasisRenderer.h @@ -1,5 +1,5 @@ #pragma once #import "BaseRenderer.h" -@interface EmphasisRenderer : BaseRenderer +@interface EmphasisRenderer : BaseRenderer @end diff --git a/packages/react-native-enriched-markdown/ios/renderer/EmphasisRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/EmphasisRenderer.m index 57eafc0f4..f2c699d09 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/EmphasisRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/EmphasisRenderer.m @@ -10,7 +10,9 @@ @implementation EmphasisRenderer #pragma mark - Rendering -- (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context +- (void)renderNodeContent:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + context:(RenderContext *)context { NSUInteger start = output.length; [_rendererFactory renderChildrenOfNode:node into:output context:context]; diff --git a/packages/react-native-enriched-markdown/ios/renderer/HeadingRenderer.h b/packages/react-native-enriched-markdown/ios/renderer/HeadingRenderer.h index 31aa15332..981cbdf2b 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/HeadingRenderer.h +++ b/packages/react-native-enriched-markdown/ios/renderer/HeadingRenderer.h @@ -2,5 +2,5 @@ #import "MarkdownASTNode.h" #import "RenderContext.h" -@interface HeadingRenderer : BaseRenderer +@interface HeadingRenderer : BaseRenderer @end diff --git a/packages/react-native-enriched-markdown/ios/renderer/HeadingRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/HeadingRenderer.m index 7451817ab..ef94e2b67 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/HeadingRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/HeadingRenderer.m @@ -21,8 +21,19 @@ @implementation HeadingRenderer -- (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context +- (id)takeContextSnapshot:(RenderContext *)context { + return [context snapshotScope]; +} + +- (void)renderNodeContent:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + context:(RenderContext *)context +{ + if (output.length > 0 && ![output.string hasSuffix:@"\n"]) { + [output appendAttributedString:kNewlineAttributedString]; + } + NSInteger level = [node.attributes[@"level"] integerValue]; if (level < 1 || level > 6) level = 1; @@ -40,11 +51,7 @@ - (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)out start += offset; } - @try { - [_rendererFactory renderChildrenOfNode:node into:output context:context]; - } @finally { - [context clearBlockStyle]; - } + [_rendererFactory renderChildrenOfNode:node into:output context:context]; NSRange range = NSMakeRange(start, output.length - start); if (range.length == 0) diff --git a/packages/react-native-enriched-markdown/ios/renderer/HighlightRenderer.h b/packages/react-native-enriched-markdown/ios/renderer/HighlightRenderer.h index e743e0209..46cf4ac44 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/HighlightRenderer.h +++ b/packages/react-native-enriched-markdown/ios/renderer/HighlightRenderer.h @@ -3,5 +3,5 @@ extern NSString *const HighlightAttributeName; -@interface HighlightRenderer : BaseRenderer +@interface HighlightRenderer : BaseRenderer @end diff --git a/packages/react-native-enriched-markdown/ios/renderer/HighlightRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/HighlightRenderer.m index 089127c8a..398d7ddd9 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/HighlightRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/HighlightRenderer.m @@ -10,7 +10,9 @@ @implementation HighlightRenderer #pragma mark - Rendering -- (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context +- (void)renderNodeContent:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + context:(RenderContext *)context { NSUInteger start = output.length; [_rendererFactory renderChildrenOfNode:node into:output context:context]; diff --git a/packages/react-native-enriched-markdown/ios/renderer/LinkRenderer.h b/packages/react-native-enriched-markdown/ios/renderer/LinkRenderer.h index b653e6d39..761a3a753 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/LinkRenderer.h +++ b/packages/react-native-enriched-markdown/ios/renderer/LinkRenderer.h @@ -2,5 +2,5 @@ #import "MarkdownASTNode.h" #import "RenderContext.h" -@interface LinkRenderer : BaseRenderer +@interface LinkRenderer : BaseRenderer @end diff --git a/packages/react-native-enriched-markdown/ios/renderer/LinkRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/LinkRenderer.m index 3668136ab..5fdc4a78b 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/LinkRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/LinkRenderer.m @@ -9,7 +9,9 @@ @implementation LinkRenderer #pragma mark - Rendering -- (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context +- (void)renderNodeContent:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + context:(RenderContext *)context { NSUInteger start = output.length; diff --git a/packages/react-native-enriched-markdown/ios/renderer/ListItemRenderer.h b/packages/react-native-enriched-markdown/ios/renderer/ListItemRenderer.h index 8bbb769d3..6bac6121f 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/ListItemRenderer.h +++ b/packages/react-native-enriched-markdown/ios/renderer/ListItemRenderer.h @@ -1,12 +1,24 @@ #import "BaseRenderer.h" +#import "RenderContext.h" extern NSString *const ListDepthAttribute; extern NSString *const ListTypeAttribute; extern NSString *const ListItemNumberAttribute; +extern NSString *const ListItemMarkerStartAttribute; extern NSString *const TaskItemAttribute; extern NSString *const TaskCheckedAttribute; extern NSString *const TaskIndexAttribute; -@interface ListItemRenderer : BaseRenderer +@interface ENRMListMarkerDescriptor : NSObject +@property (nonatomic, assign) BOOL isTask; +@property (nonatomic, assign) BOOL isChecked; +@property (nonatomic, assign) NSInteger taskIndex; +@property (nonatomic, assign) ListType listType; +@property (nonatomic, assign) NSInteger number; +@property (nonatomic, assign) NSInteger depth; +@property (nonatomic, assign) CGFloat indent; +@end + +@interface ListItemRenderer : BaseRenderer @end diff --git a/packages/react-native-enriched-markdown/ios/renderer/ListItemRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/ListItemRenderer.m index 4ce96b6a9..ae457fb70 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/ListItemRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/ListItemRenderer.m @@ -10,10 +10,14 @@ NSString *const ListDepthAttribute = @"ListDepth"; NSString *const ListTypeAttribute = @"ListType"; NSString *const ListItemNumberAttribute = @"ListItemNumber"; +NSString *const ListItemMarkerStartAttribute = @"ListItemMarkerStart"; NSString *const TaskItemAttribute = @"TaskItem"; NSString *const TaskCheckedAttribute = @"TaskChecked"; NSString *const TaskIndexAttribute = @"TaskIndex"; +@implementation ENRMListMarkerDescriptor +@end + @interface ListItemRenderer () - (void)applyCheckedDecorationsTo:(NSMutableAttributedString *)output range:(NSRange)range @@ -22,7 +26,9 @@ - (void)applyCheckedDecorationsTo:(NSMutableAttributedString *)output @implementation ListItemRenderer -- (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context +- (void)renderNodeContent:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + context:(RenderContext *)context { if (!context) return; @@ -39,10 +45,25 @@ - (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)out context.taskItemCount++; } + // currentDepth - 1 handles the horizontal offset for nested lists + const NSInteger nestingLevel = currentDepth - 1; + const CGFloat baseMarkerWidth = isTask ? [_config effectiveListMarginLeftForTask] + : (context.listType == ListTypeOrdered) ? [_config effectiveListMarginLeftForNumber] + : [_config effectiveListMarginLeftForBullet]; + + const CGFloat totalIndent = + baseMarkerWidth + [_config effectiveListGapWidth] + (nestingLevel * [_config listStyleMarginLeft]); + const NSUInteger startLocation = output.length; - // Render the actual content of the list item (text, bolding, etc.) - [_rendererFactory renderChildrenOfNode:node into:output context:context]; + // Manual save/restore — a scope snapshot would also roll back the listItemNumber increment + const CGFloat prevIndent = context.accumulatedIndent; + context.accumulatedIndent = totalIndent; + @try { + [_rendererFactory renderChildrenOfNode:node into:output context:context]; + } @finally { + context.accumulatedIndent = prevIndent; + } // Ensure every list item ends with a newline to prevent paragraph merging if (output.length > startLocation && ![output.string hasSuffix:@"\n"]) { @@ -59,15 +80,6 @@ - (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)out depth:currentDepth isOrdered:(context.listType == ListTypeOrdered)]; - // currentDepth - 1 handles the horizontal offset for nested lists - const NSInteger nestingLevel = currentDepth - 1; - const CGFloat baseMarkerWidth = isTask ? [_config effectiveListMarginLeftForTask] - : (context.listType == ListTypeOrdered) ? [_config effectiveListMarginLeftForNumber] - : [_config effectiveListMarginLeftForBullet]; - - const CGFloat totalIndent = - baseMarkerWidth + [_config effectiveListGapWidth] + (nestingLevel * [_config listStyleMarginLeft]); - const CGFloat lineHeightConfig = [_config listStyleLineHeight]; // Boxing metadata for attributed string storage @@ -83,42 +95,84 @@ - (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)out metadata[TaskIndexAttribute] = @(taskIndex); } - // We enumerate to ensure we don't overwrite styles of nested sub-lists - // or code blocks that may have already been rendered inside this item. + // Preserve styles of nested sub-lists and code blocks by applying the list + // paragraph style only to the surviving gaps between them. + NSMutableArray *skipRanges = [NSMutableArray array]; + [output enumerateAttribute:CodeBlockAttributeName + inRange:itemRange + options:0 + usingBlock:^(id value, NSRange range, BOOL *stop) { + if ([value boolValue]) { + [skipRanges addObject:[NSValue valueWithRange:range]]; + } + }]; [output enumerateAttribute:ListDepthAttribute inRange:itemRange options:0 usingBlock:^(id depthAttr, NSRange range, BOOL *stop) { - // If a segment already has a Depth attribute higher than our current level, - // it belongs to a nested list and we should skip it to preserve its styling. if (depthAttr && [depthAttr integerValue] > nestingLevel) { - return; - } - - // Skip code block ranges — CodeBlockRenderer already applied its own - // paragraph style (padding, LTR indent). Overwriting would add list - // markers ("2.") inside the code block. - NSNumber *isCodeBlock = [output attribute:CodeBlockAttributeName - atIndex:range.location - effectiveRange:nil]; - if ([isCodeBlock boolValue]) { - return; - } - - NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init]; - style.firstLineHeadIndent = totalIndent; - style.headIndent = totalIndent; - - if (lineHeightConfig > 0) { - style.minimumLineHeight = lineHeightConfig; - style.maximumLineHeight = lineHeightConfig; + [skipRanges addObject:[NSValue valueWithRange:range]]; } + }]; + [skipRanges sortUsingComparator:^NSComparisonResult(NSValue *a, NSValue *b) { + NSUInteger la = [a rangeValue].location; + NSUInteger lb = [b rangeValue].location; + return la < lb ? NSOrderedAscending : (la > lb ? NSOrderedDescending : NSOrderedSame); + }]; + + void (^applyStyleToRange)(NSRange) = ^(NSRange range) { + if (range.length == 0) + return; + NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init]; + style.firstLineHeadIndent = totalIndent; + style.headIndent = totalIndent; + if (lineHeightConfig > 0) { + style.minimumLineHeight = lineHeightConfig; + style.maximumLineHeight = lineHeightConfig; + } + NSMutableDictionary *attributesToApply = [metadata mutableCopy]; + attributesToApply[NSParagraphStyleAttributeName] = style; + [output addAttributes:attributesToApply range:range]; + }; + + NSUInteger pos = itemRange.location; + const NSUInteger itemEnd = NSMaxRange(itemRange); + for (NSValue *val in skipRanges) { + NSRange skip = [val rangeValue]; + if (pos < skip.location) { + applyStyleToRange(NSMakeRange(pos, skip.location - pos)); + } + pos = MAX(pos, NSMaxRange(skip)); + } + if (pos < itemEnd) { + applyStyleToRange(NSMakeRange(pos, itemEnd - pos)); + } - NSMutableDictionary *attributesToApply = [metadata mutableCopy]; - attributesToApply[NSParagraphStyleAttributeName] = style; + ENRMListMarkerDescriptor *marker = [[ENRMListMarkerDescriptor alloc] init]; + marker.isTask = isTask; + marker.isChecked = isChecked; + marker.taskIndex = taskIndex; + marker.listType = context.listType; + marker.number = currentPosition; + marker.depth = nestingLevel; + marker.indent = totalIndent; + + // Anchor on the first content character so items that open with a code block + // or nested sublist still get their marker; fall back to startLocation for + // whitespace-only items. + NSUInteger anchorLocation = startLocation; + NSString *string = output.string; + for (NSUInteger i = startLocation; i < itemEnd; i++) { + if ([string characterAtIndex:i] != '\n') { + anchorLocation = i; + break; + } + } - [output addAttributes:attributesToApply range:range]; - }]; + NSArray *existingMarkers = [output attribute:ListItemMarkerStartAttribute atIndex:anchorLocation effectiveRange:NULL]; + NSArray *markers = + [existingMarkers isKindOfClass:[NSArray class]] ? [existingMarkers arrayByAddingObject:marker] : @[ marker ]; + [output addAttribute:ListItemMarkerStartAttribute value:markers range:NSMakeRange(anchorLocation, 1)]; if (isTask && isChecked) { [self applyCheckedDecorationsTo:output range:itemRange nestingLevel:nestingLevel]; diff --git a/packages/react-native-enriched-markdown/ios/renderer/ListRenderer.h b/packages/react-native-enriched-markdown/ios/renderer/ListRenderer.h index 90fb26baf..f32f62417 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/ListRenderer.h +++ b/packages/react-native-enriched-markdown/ios/renderer/ListRenderer.h @@ -1,10 +1,10 @@ -#import "NodeRenderer.h" +#import "BaseRenderer.h" @class RendererFactory; @class StyleConfig; @class RenderContext; -@interface ListRenderer : NSObject +@interface ListRenderer : BaseRenderer - (instancetype)initWithRendererFactory:(RendererFactory *)rendererFactory config:(StyleConfig *)config diff --git a/packages/react-native-enriched-markdown/ios/renderer/ListRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/ListRenderer.m index 6927b4b29..237082df4 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/ListRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/ListRenderer.m @@ -7,36 +7,35 @@ #import "StyleConfig.h" @implementation ListRenderer { - __weak RendererFactory *_rendererFactory; - StyleConfig *_config; BOOL _isOrdered; } - (instancetype)initWithRendererFactory:(RendererFactory *)factory config:(StyleConfig *)config isOrdered:(BOOL)ordered { - if (self = [super init]) { - _rendererFactory = factory; - _config = config; + if (self = [super initWithRendererFactory:factory config:config]) { _isOrdered = ordered; } return self; } -- (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context +- (id)takeContextSnapshot:(RenderContext *)context +{ + return [context snapshotScope]; +} + +- (void)renderNodeContent:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + context:(RenderContext *)context { if (!context) return; const NSInteger prevDepth = context.listDepth; - const ListType prevType = context.listType; - const NSInteger prevNum = context.listItemNumber; - const NSUInteger startLocation = output.length; - NSUInteger contentStart = startLocation; if (prevDepth == 0) { // Apply top margin for root-level list - contentStart += applyBlockSpacingBefore(output, startLocation, _config.listStyleMarginTop); + applyBlockSpacingBefore(output, startLocation, _config.listStyleMarginTop); } else if (output.length > 0 && ![output.string hasSuffix:@"\n"]) { // Ensure nested lists start on a new line [output appendAttributedString:kNewlineAttributedString]; @@ -51,20 +50,12 @@ - (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)out color:_config.listStyleColor headingLevel:0]; - @try { - [_rendererFactory renderChildrenOfNode:node into:output context:context]; - } @finally { - context.listDepth = prevDepth; - context.listType = prevType; - context.listItemNumber = prevNum; - - if (prevDepth == 0) { - [context clearBlockStyle]; + [_rendererFactory renderChildrenOfNode:node into:output context:context]; - // Apply bottom margin for root-level list - applyBlockSpacingAfter(output, _config.listStyleMarginBottom); - } + if (prevDepth == 0) { + // Apply bottom margin for root-level list + applyBlockSpacingAfter(output, _config.listStyleMarginBottom); } } -@end \ No newline at end of file +@end diff --git a/packages/react-native-enriched-markdown/ios/renderer/ParagraphRenderer.h b/packages/react-native-enriched-markdown/ios/renderer/ParagraphRenderer.h index 3675e4c93..b46110772 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/ParagraphRenderer.h +++ b/packages/react-native-enriched-markdown/ios/renderer/ParagraphRenderer.h @@ -2,5 +2,5 @@ #import "MarkdownASTNode.h" #import "RenderContext.h" -@interface ParagraphRenderer : BaseRenderer +@interface ParagraphRenderer : BaseRenderer @end diff --git a/packages/react-native-enriched-markdown/ios/renderer/ParagraphRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/ParagraphRenderer.m index f9625434c..cae273432 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/ParagraphRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/ParagraphRenderer.m @@ -7,8 +7,22 @@ @implementation ParagraphRenderer -- (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context +- (id)takeContextSnapshot:(RenderContext *)context { + return [context snapshotScope]; +} + +- (void)renderNodeContent:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + context:(RenderContext *)context +{ + // Synthetic wrapper around tight list item inlines (see wrapListItemInlineRuns + // in MD4CParser) — render transparently, the list owns spacing and layout. + if ([node.attributes[@"tight"] boolValue]) { + [_rendererFactory renderChildrenOfNode:node into:output context:context]; + return; + } + // Only set block style if a parent element (e.g. List, Blockquote) hasn't already established one BOOL isTopLevel = (context.currentBlockType == BlockTypeNone); @@ -33,13 +47,7 @@ - (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)out start += offset; } - @try { - [_rendererFactory renderChildrenOfNode:node into:output context:context]; - } @finally { - if (isTopLevel) { - [context clearBlockStyle]; - } - } + [_rendererFactory renderChildrenOfNode:node into:output context:context]; if (output.length <= start) return; diff --git a/packages/react-native-enriched-markdown/ios/renderer/RenderContext.h b/packages/react-native-enriched-markdown/ios/renderer/RenderContext.h index e1702735d..4bccd4bd8 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/RenderContext.h +++ b/packages/react-native-enriched-markdown/ios/renderer/RenderContext.h @@ -41,6 +41,7 @@ typedef NS_ENUM(NSInteger, ListType) { ListTypeUnordered, ListTypeOrdered }; @property (nonatomic, assign) NSInteger listDepth; @property (nonatomic, assign) ListType listType; @property (nonatomic, assign) NSInteger listItemNumber; +@property (nonatomic, assign) CGFloat accumulatedIndent; @property (nonatomic, assign) BOOL allowFontScaling; @property (nonatomic, assign) CGFloat maxFontSizeMultiplier; @property (nonatomic, assign) NSInteger taskItemCount; @@ -79,6 +80,15 @@ typedef NS_ENUM(NSInteger, ListType) { ListTypeUnordered, ListTypeOrdered }; - (NSDictionary *)getTextAttributes; - (void)clearBlockStyle; +/** + * Captures/restores the scoped context state: block style, block type, heading level, + * blockquote/list depths, list type, list item counter and accumulated indent. + * Accumulators (range registries, taskItemCount) are excluded on purpose — they + * must survive restores. + */ +- (id)snapshotScope; +- (void)restoreScope:(id)snapshot; + /** * Checks if colors should be preserved based on existing attributes. * Returns YES if the text is inside a link or inline code, which means diff --git a/packages/react-native-enriched-markdown/ios/renderer/RenderContext.m b/packages/react-native-enriched-markdown/ios/renderer/RenderContext.m index 89de9aaea..f7b2f1f9f 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/RenderContext.m +++ b/packages/react-native-enriched-markdown/ios/renderer/RenderContext.m @@ -6,6 +6,30 @@ @implementation BlockStyle @end +static NSArray *ENRMScopedKeyPaths(void) +{ + static NSArray *keyPaths; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + keyPaths = @[ + @"currentBlockType", + @"currentHeadingLevel", + @"currentBlockStyle.fontSize", + @"currentBlockStyle.fontFamily", + @"currentBlockStyle.fontWeight", + @"currentBlockStyle.color", + @"currentBlockStyle.cachedFont", + @"currentBlockStyle.cachedTextAttributes", + @"blockquoteDepth", + @"listDepth", + @"listType", + @"listItemNumber", + @"accumulatedIndent", + ]; + }); + return keyPaths; +} + @implementation RenderContext { NSMutableDictionary *_fontCache; NSParagraphStyle *_baseSpacerTemplate; @@ -235,6 +259,25 @@ - (void)clearBlockStyle _currentBlockStyle.cachedTextAttributes = nil; } +- (id)snapshotScope +{ + NSArray *keyPaths = ENRMScopedKeyPaths(); + NSMutableDictionary *snapshot = [NSMutableDictionary dictionaryWithCapacity:keyPaths.count]; + for (NSString *keyPath in keyPaths) { + snapshot[keyPath] = [self valueForKeyPath:keyPath] ?: [NSNull null]; + } + return snapshot; +} + +- (void)restoreScope:(id)snapshot +{ + NSDictionary *frame = snapshot; + for (NSString *keyPath in ENRMScopedKeyPaths()) { + id value = frame[keyPath]; + [self setValue:(value == [NSNull null] ? nil : value) forKeyPath:keyPath]; + } +} + #pragma mark - Reset - (void)reset @@ -256,6 +299,7 @@ - (void)reset _listDepth = 0; _listType = ListTypeUnordered; _listItemNumber = 0; + _accumulatedIndent = 0; _taskItemCount = 0; // Revert shared style object to baseline defaults diff --git a/packages/react-native-enriched-markdown/ios/renderer/StrikethroughRenderer.h b/packages/react-native-enriched-markdown/ios/renderer/StrikethroughRenderer.h index b00215f4e..8354b2bca 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/StrikethroughRenderer.h +++ b/packages/react-native-enriched-markdown/ios/renderer/StrikethroughRenderer.h @@ -1,5 +1,5 @@ #pragma once #import "BaseRenderer.h" -@interface StrikethroughRenderer : BaseRenderer +@interface StrikethroughRenderer : BaseRenderer @end diff --git a/packages/react-native-enriched-markdown/ios/renderer/StrikethroughRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/StrikethroughRenderer.m index 01d9572a7..15991a2a2 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/StrikethroughRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/StrikethroughRenderer.m @@ -8,7 +8,9 @@ @implementation StrikethroughRenderer #pragma mark - Rendering -- (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context +- (void)renderNodeContent:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + context:(RenderContext *)context { NSUInteger start = output.length; [_rendererFactory renderChildrenOfNode:node into:output context:context]; diff --git a/packages/react-native-enriched-markdown/ios/renderer/StrongRenderer.h b/packages/react-native-enriched-markdown/ios/renderer/StrongRenderer.h index d56e68655..ca377c931 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/StrongRenderer.h +++ b/packages/react-native-enriched-markdown/ios/renderer/StrongRenderer.h @@ -1,5 +1,5 @@ #pragma once #import "BaseRenderer.h" -@interface StrongRenderer : BaseRenderer +@interface StrongRenderer : BaseRenderer @end diff --git a/packages/react-native-enriched-markdown/ios/renderer/StrongRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/StrongRenderer.m index 01df0b18f..15a76af85 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/StrongRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/StrongRenderer.m @@ -10,7 +10,9 @@ @implementation StrongRenderer #pragma mark - Rendering -- (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context +- (void)renderNodeContent:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + context:(RenderContext *)context { NSUInteger start = output.length; [_rendererFactory renderChildrenOfNode:node into:output context:context]; diff --git a/packages/react-native-enriched-markdown/ios/renderer/SubscriptRenderer.h b/packages/react-native-enriched-markdown/ios/renderer/SubscriptRenderer.h index f6e3cdf19..694590407 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/SubscriptRenderer.h +++ b/packages/react-native-enriched-markdown/ios/renderer/SubscriptRenderer.h @@ -1,5 +1,5 @@ #pragma once #import "BaseRenderer.h" -@interface SubscriptRenderer : BaseRenderer +@interface SubscriptRenderer : BaseRenderer @end diff --git a/packages/react-native-enriched-markdown/ios/renderer/SubscriptRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/SubscriptRenderer.m index ef6d39527..f69721658 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/SubscriptRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/SubscriptRenderer.m @@ -7,7 +7,9 @@ @implementation SubscriptRenderer -- (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context +- (void)renderNodeContent:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + context:(RenderContext *)context { NSUInteger start = output.length; [_rendererFactory renderChildrenOfNode:node into:output context:context]; diff --git a/packages/react-native-enriched-markdown/ios/renderer/SuperscriptRenderer.h b/packages/react-native-enriched-markdown/ios/renderer/SuperscriptRenderer.h index 3cacdf8bc..d205fc173 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/SuperscriptRenderer.h +++ b/packages/react-native-enriched-markdown/ios/renderer/SuperscriptRenderer.h @@ -1,5 +1,5 @@ #pragma once #import "BaseRenderer.h" -@interface SuperscriptRenderer : BaseRenderer +@interface SuperscriptRenderer : BaseRenderer @end diff --git a/packages/react-native-enriched-markdown/ios/renderer/SuperscriptRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/SuperscriptRenderer.m index d052a6f7c..edba73647 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/SuperscriptRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/SuperscriptRenderer.m @@ -7,7 +7,9 @@ @implementation SuperscriptRenderer -- (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context +- (void)renderNodeContent:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + context:(RenderContext *)context { NSUInteger start = output.length; [_rendererFactory renderChildrenOfNode:node into:output context:context]; diff --git a/packages/react-native-enriched-markdown/ios/renderer/TextRenderer.h b/packages/react-native-enriched-markdown/ios/renderer/TextRenderer.h index efee41e09..c4e3e924d 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/TextRenderer.h +++ b/packages/react-native-enriched-markdown/ios/renderer/TextRenderer.h @@ -1,6 +1,6 @@ +#import "BaseRenderer.h" #import "MarkdownASTNode.h" -#import "NodeRenderer.h" #import "RenderContext.h" -@interface TextRenderer : NSObject +@interface TextRenderer : BaseRenderer @end diff --git a/packages/react-native-enriched-markdown/ios/renderer/TextRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/TextRenderer.m index a49d7c9fc..cbd3cc725 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/TextRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/TextRenderer.m @@ -3,7 +3,9 @@ @implementation TextRenderer -- (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context +- (void)renderNodeContent:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + context:(RenderContext *)context { if (!node.content) return; diff --git a/packages/react-native-enriched-markdown/ios/renderer/ThematicBreakRenderer.h b/packages/react-native-enriched-markdown/ios/renderer/ThematicBreakRenderer.h index 2e7ff489c..2ae80c721 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/ThematicBreakRenderer.h +++ b/packages/react-native-enriched-markdown/ios/renderer/ThematicBreakRenderer.h @@ -1,4 +1,4 @@ #import "BaseRenderer.h" -@interface ThematicBreakRenderer : BaseRenderer +@interface ThematicBreakRenderer : BaseRenderer @end diff --git a/packages/react-native-enriched-markdown/ios/renderer/ThematicBreakRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/ThematicBreakRenderer.m index 5ac31beca..efaa0b364 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/ThematicBreakRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/ThematicBreakRenderer.m @@ -7,7 +7,9 @@ @implementation ThematicBreakRenderer -- (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context +- (void)renderNodeContent:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + context:(RenderContext *)context { [self ensureStartingNewline:output]; diff --git a/packages/react-native-enriched-markdown/ios/renderer/UnderlineRenderer.h b/packages/react-native-enriched-markdown/ios/renderer/UnderlineRenderer.h index 8f67af17a..a7a94ad81 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/UnderlineRenderer.h +++ b/packages/react-native-enriched-markdown/ios/renderer/UnderlineRenderer.h @@ -1,4 +1,4 @@ #import "BaseRenderer.h" -@interface UnderlineRenderer : BaseRenderer +@interface UnderlineRenderer : BaseRenderer @end diff --git a/packages/react-native-enriched-markdown/ios/renderer/UnderlineRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/UnderlineRenderer.m index ac10aea4b..e772e4ba5 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/UnderlineRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/UnderlineRenderer.m @@ -8,7 +8,9 @@ @implementation UnderlineRenderer #pragma mark - Rendering -- (void)renderNode:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context +- (void)renderNodeContent:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + context:(RenderContext *)context { NSUInteger start = output.length; [_rendererFactory renderChildrenOfNode:node into:output context:context]; diff --git a/packages/react-native-enriched-markdown/ios/utils/CodeBlockBackground.m b/packages/react-native-enriched-markdown/ios/utils/CodeBlockBackground.m index 736e9a4dd..23be20b21 100644 --- a/packages/react-native-enriched-markdown/ios/utils/CodeBlockBackground.m +++ b/packages/react-native-enriched-markdown/ios/utils/CodeBlockBackground.m @@ -53,9 +53,18 @@ - (void)drawCodeBlockBackgroundForRange:(NSRange)range if (CGRectIsEmpty(blockRect)) return; - blockRect.origin.x = origin.x; + NSNumber *indentAttr = [layoutManager.textStorage attribute:CodeBlockIndentAttributeName + atIndex:range.location + effectiveRange:NULL]; + CGFloat indent = [indentAttr doubleValue]; + + CGFloat availableWidth = textContainer.size.width - indent; + if (availableWidth <= 0) + return; + + blockRect.origin.x = origin.x + indent; blockRect.origin.y += origin.y; - blockRect.size.width = textContainer.size.width; + blockRect.size.width = availableWidth; // We extend the background specifically to cover the bottom padding spacer, // excluding any additional marginBottom applied via measurement. diff --git a/packages/react-native-enriched-markdown/ios/utils/LastElementUtils.h b/packages/react-native-enriched-markdown/ios/utils/LastElementUtils.h index a9fafbf07..dbbb357e0 100644 --- a/packages/react-native-enriched-markdown/ios/utils/LastElementUtils.h +++ b/packages/react-native-enriched-markdown/ios/utils/LastElementUtils.h @@ -4,6 +4,7 @@ NS_ASSUME_NONNULL_BEGIN static NSString *const CodeBlockAttributeName = @"CodeBlock"; +static NSString *const CodeBlockIndentAttributeName = @"CodeBlockIndent"; /** * Returns YES when the last semantic block in the storage is a code block, ignoring any diff --git a/packages/react-native-enriched-markdown/ios/utils/ListMarkerDrawer.m b/packages/react-native-enriched-markdown/ios/utils/ListMarkerDrawer.m index cdd927396..315ec083f 100644 --- a/packages/react-native-enriched-markdown/ios/utils/ListMarkerDrawer.m +++ b/packages/react-native-enriched-markdown/ios/utils/ListMarkerDrawer.m @@ -5,11 +5,7 @@ #import "RenderContext.h" #import "StyleConfig.h" -extern NSString *const ListDepthAttribute; -extern NSString *const ListTypeAttribute; -extern NSString *const ListItemNumberAttribute; -extern NSString *const TaskItemAttribute; -extern NSString *const TaskCheckedAttribute; +extern NSString *const ListItemMarkerStartAttribute; @implementation ListMarkerDrawer { StyleConfig *_config; @@ -44,13 +40,13 @@ - (void)drawMarkersForGlyphRange:(NSRange)glyphsToShow if (charRange.location == NSNotFound) return; - // 1. Fetch all attributes at once for efficiency NSDictionary *attrs = [storage attributesAtIndex:charRange.location effectiveRange:NULL]; - if (!attrs[ListDepthAttribute]) + NSArray *markers = attrs[ListItemMarkerStartAttribute]; + if (![markers isKindOfClass:[NSArray class]] || markers.count == 0) return; - // 2. Identify the start of the paragraph + // Identify the start of the paragraph NSRange paraRange = [storage.string paragraphRangeForRange:charRange]; if (charRange.location != paraRange.location || [drawnParagraphs containsObject:@(paraRange.location)]) @@ -61,30 +57,28 @@ - (void)drawMarkersForGlyphRange:(NSRange)glyphsToShow CGPoint glyphLoc = [layoutManager locationForGlyphAtIndex:glyphRange.location]; CGFloat baselineY = origin.y + rect.origin.y + glyphLoc.y; - CGFloat markerX; - if (isRTL) { - markerX = origin.x + usedRect.origin.x + usedRect.size.width + gap; - } else { - markerX = origin.x + usedRect.origin.x - gap; - } - - // 4. Draw marker based on type - if ([attrs[TaskItemAttribute] boolValue]) { - UIFont *font = attrs[NSFontAttributeName] ?: [self defaultFont]; - BOOL checked = [attrs[TaskCheckedAttribute] boolValue]; - const CGFloat size = [_config taskListCheckboxSize]; - CGFloat checkboxX = isRTL ? markerX + size / 2.0 : markerX - size / 2.0; - [self drawCheckboxAtX:checkboxX - centerY:baselineY - (font.capHeight / 2.0) - checked:checked]; - } else if ([attrs[ListTypeAttribute] integerValue] == ListTypeUnordered) { - UIFont *font = attrs[NSFontAttributeName] ?: [self defaultFont]; - NSInteger depth = [attrs[ListDepthAttribute] integerValue]; - [self drawBulletAtX:markerX - centerY:baselineY - (font.xHeight + font.capHeight) / 4.0 - depth:depth]; - } else { - [self drawOrderedMarkerAtX:markerX attrs:attrs baselineY:baselineY isRTL:isRTL]; + UIFont *font = attrs[NSFontAttributeName] ?: [self defaultFont]; + + for (ENRMListMarkerDescriptor *marker in markers) { + CGFloat markerX = isRTL ? origin.x + container.size.width - marker.indent + gap + : origin.x + marker.indent - gap; + + if (marker.isTask) { + const CGFloat size = [_config taskListCheckboxSize]; + CGFloat checkboxX = isRTL ? markerX + size / 2.0 : markerX - size / 2.0; + [self drawCheckboxAtX:checkboxX + centerY:baselineY - (font.capHeight / 2.0) + checked:marker.isChecked]; + } else if (marker.listType == ListTypeUnordered) { + [self drawBulletAtX:markerX + centerY:baselineY - (font.xHeight + font.capHeight) / 4.0 + depth:marker.depth]; + } else { + [self drawOrderedMarkerAtX:markerX + number:marker.number + baselineY:baselineY + isRTL:isRTL]; + } } }]; } @@ -167,17 +161,10 @@ - (void)drawBulletAtX:(CGFloat)x centerY:(CGFloat)y depth:(NSInteger)depth y:y]; } -- (void)drawOrderedMarkerAtX:(CGFloat)boundaryX - attrs:(NSDictionary *)attrs - baselineY:(CGFloat)baselineY - isRTL:(BOOL)isRTL +- (void)drawOrderedMarkerAtX:(CGFloat)boundaryX number:(NSInteger)number baselineY:(CGFloat)baselineY isRTL:(BOOL)isRTL { - NSNumber *num = attrs[ListItemNumberAttribute]; - if (!num) - return; - - NSString *text = isRTL ? [NSString stringWithFormat:@".%ld", (long)num.integerValue] - : [NSString stringWithFormat:@"%ld.", (long)num.integerValue]; + NSString *text = + isRTL ? [NSString stringWithFormat:@".%ld", (long)number] : [NSString stringWithFormat:@"%ld.", (long)number]; UIFont *font = [_config listMarkerFont] ?: [self defaultFont]; NSDictionary *mAttrs = @{NSFontAttributeName : font, NSForegroundColorAttributeName : [_config listStyleMarkerColor]}; diff --git a/packages/react-native-enriched-markdown/ios/utils/TaskListTapUtils.m b/packages/react-native-enriched-markdown/ios/utils/TaskListTapUtils.m index 96b7c5d3f..d15bfbdd8 100644 --- a/packages/react-native-enriched-markdown/ios/utils/TaskListTapUtils.m +++ b/packages/react-native-enriched-markdown/ios/utils/TaskListTapUtils.m @@ -1,5 +1,6 @@ #import "TaskListTapUtils.h" #import "ENRMUIKit.h" +#import "LastElementUtils.h" #import "ListItemRenderer.h" #import "ParagraphStyleUtils.h" #import "StyleConfig.h" @@ -171,6 +172,13 @@ BOOL updateTaskListItemCheckedState(ENRMPlatformTextView *textView, NSInteger ta if (depth && [depth integerValue] > nestingLevel) return; + // Skip code block ranges — preserve CodeBlockRenderer styles. + NSNumber *isCodeBlock = [mutableText attribute:CodeBlockAttributeName + atIndex:segmentRange.location + effectiveRange:nil]; + if ([isCodeBlock boolValue]) + return; + [mutableText addAttribute:TaskCheckedAttribute value:@(newChecked) range:segmentRange]; if (newChecked) { @@ -195,6 +203,22 @@ BOOL updateTaskListItemCheckedState(ENRMPlatformTextView *textView, NSInteger ta } }]; + // The checkbox glyph is drawn from the marker descriptor, not from + // TaskCheckedAttribute — keep it in sync. Enumerated over the full text: + // for code-block-first items the anchor sits outside the metadata ranges. + [mutableText enumerateAttribute:ListItemMarkerStartAttribute + inRange:NSMakeRange(0, mutableText.length) + options:0 + usingBlock:^(NSArray *markers, NSRange range, BOOL *stop) { + if (![markers isKindOfClass:[NSArray class]]) + return; + for (ENRMListMarkerDescriptor *marker in markers) { + if (marker.isTask && marker.taskIndex == targetIndex) { + marker.isChecked = newChecked; + } + } + }]; + ENRMSetAttributedText(textView, mutableText); NSLayoutManager *layoutManager = textView.layoutManager; if (layoutManager) { 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 301595539..bf77bb788 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