diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt index 020fb075..0323c1b1 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt @@ -198,13 +198,20 @@ class EnrichedMarkdownTextInputManager : if (view == null || value == null) return val style = MarkdownStyleParser.parse(value) - view.setAutoLinkStyle(style) - val changed = view.formatter.updateStyle(style) + val changed = view.setMarkdownStyleFromProps(style) if (changed) { view.applyFormatting() } } + @ReactProp(name = "listItemSpacing", defaultInt = 0) + override fun setListItemSpacing( + view: EnrichedMarkdownTextInputView?, + value: Int, + ) { + view?.setListItemSpacingFromProps(value.toFloat()) + } + @ReactProp(name = "color", customType = "Color") override fun setColor( view: EnrichedMarkdownTextInputView?, diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt index b9363edf..d38d5e88 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt @@ -101,6 +101,8 @@ class EnrichedMarkdownTextInputView( private var headingOverrideBaseSizePx: Float? = null private var savedHintTextColors: ColorStateList? = null + private var listItemSpacingPx = 0 + init { setupDetectorPipeline() prepareComponent() @@ -338,16 +340,16 @@ class EnrichedMarkdownTextInputView( } /** - * Drops heading ranges no longer anchored at a line start (e.g. Backspace merged + * Drops anchored blocks no longer anchored at a line start (e.g. Backspace merged * their line into the previous one). Must run BEFORE [BlockStore.normalizeToLineBounds] * so a merged range is judged on its unsnapped anchor and can't grow over the line * it merged into. */ - private fun pruneOrphanedHeadingAnchors() { + private fun pruneOrphanedAnchors() { val editable = text ?: return val orphans = blockStore.allRanges.filter { range -> - range.type in BlockType.HEADINGS && !isAtLineStart(editable, range.start) + range.type in BlockType.ANCHORED && !isAtLineStart(editable, range.start) } for (orphan in orphans) { blockStore.removeBlock(orphan.start, orphan.start, editable) @@ -365,7 +367,7 @@ class EnrichedMarkdownTextInputView( /** * Adjusts both [formattingStore] and [blockStore] for a text edit, then prunes - * orphaned heading anchors and normalizes block ranges to line bounds. Every + * orphaned anchors and normalizes block ranges to line bounds. Every * code path that mutates the text buffer must call this so block ranges stay in * sync — mirrors iOS's `replaceTextInRange:withText:formattingRanges:blockRanges:`. */ @@ -376,7 +378,7 @@ class EnrichedMarkdownTextInputView( ) { formattingStore.adjustForEdit(editStart, deletedLength, insertedLength) blockStore.adjustForEdit(editStart, deletedLength, insertedLength) - pruneOrphanedHeadingAnchors() + pruneOrphanedAnchors() text?.let { blockStore.normalizeToLineBounds(it) } } @@ -845,6 +847,35 @@ class EnrichedMarkdownTextInputView( autoLinkDetector.style = style } + // The markdownStyle prop, kept so density + listItemSpacing (sourced outside the + // prop) can be folded into the formatter style whenever any of them changes. + private var baseStyle: InputFormatterStyle? = null + + /** + * Applies the parsed `markdownStyle`, folding in the display density and the current + * `listItemSpacing` so block handlers can build density-correct, spacing-aware spans. + * Returns true if the effective style changed (caller re-applies formatting). + */ + fun setMarkdownStyleFromProps(style: InputFormatterStyle): Boolean { + baseStyle = style + setAutoLinkStyle(style) + return applyComposedStyle() + } + + private fun applyComposedStyle(): Boolean { + val base = baseStyle ?: return false + val composed = base.copy(displayDensity = resources.displayMetrics.density, listItemSpacingPx = listItemSpacingPx) + return formatter.updateStyle(composed) + } + + /** Sets the vertical spacing (dp) above each list item, re-stamping list spans. */ + fun setListItemSpacingFromProps(spacingDp: Float) { + val px = if (spacingDp > 0f) PixelUtil.toPixelFromDIP(spacingDp).toInt() else 0 + if (px == listItemSpacingPx) return + listItemSpacingPx = px + if (applyComposedStyle()) applyFormatting() + } + fun allFormattingRangesForSerialization(): List { val editable = text ?: return formattingStore.allRanges val transientRanges = detectorPipeline.allTransientFormattingRanges(editable) diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/BlockStore.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/BlockStore.kt index 2f3b9fa2..47641fc7 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/BlockStore.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/BlockStore.kt @@ -2,6 +2,7 @@ package com.swmansion.enriched.markdown.input.formatting import com.swmansion.enriched.markdown.input.model.BlockRange import com.swmansion.enriched.markdown.input.model.BlockType +import com.swmansion.enriched.markdown.input.model.MAX_LIST_DEPTH import java.util.Collections /** @@ -24,6 +25,7 @@ class BlockStore { fun setRanges(newRanges: List) { ranges.clear() ranges.addAll(newRanges.sortedBy { it.start }) + recomputeListMetadata() } fun clearAll() { @@ -44,9 +46,9 @@ class BlockStore { ) { val (start, end) = paragraphBounds(paragraphStart, paragraphEnd, text) removeBlocksOverlapping(start, end) - // A heading on an empty line is kept as a zero-length anchor (see adjustForEdit); - // other blocks need real content, so an empty line yields no block. - if (end < start || (end == start && type !in BlockType.HEADINGS)) return + // An anchored block (heading, list item) on an empty line is kept as a zero-length + // anchor (see adjustForEdit); other blocks need real content. + if (end < start || (end == start && type !in BlockType.ANCHORED)) return val block = BlockRange(type, start, end, level) ranges.add(sortedInsertionIndex(ranges, start), block) @@ -67,10 +69,10 @@ class BlockStore { /** * Shifts/clips block ranges to follow a text edit (see [RangeEditAdjustment]), - * with heading persistence layered on top: a heading deleted exactly to its - * end collapses to a zero-length anchor at the edit location (its line - * survives), and existing anchors shift/keep/drop with their line. The view's - * prune/normalize pass reconciles anchors against the final text. + * with anchored-block (heading / list item) persistence layered on top: a block + * deleted exactly to its end collapses to a zero-length anchor at the edit + * location (its line survives), and existing anchors shift/keep/drop with their + * line. The view's prune/normalize pass reconciles anchors against the final text. */ fun adjustForEdit( editLocation: Int, @@ -82,14 +84,14 @@ class BlockStore { val deleteEnd = editLocation + deletedLength val delta = insertedLength - deletedLength - val anchors = ranges.filter { it.length == 0 && it.type in BlockType.HEADINGS } + val anchors = ranges.filter { it.length == 0 && it.type in BlockType.ANCHORED } ranges.removeAll { it.length == 0 } // At most one range can end exactly at deleteEnd, so this restores at most - // one collapsed heading. + // one collapsed block. val collapsed = ranges.firstOrNull { - it.type in BlockType.HEADINGS && it.start >= editLocation && it.end == deleteEnd + it.type in BlockType.ANCHORED && it.start >= editLocation && it.end == deleteEnd } RangeEditAdjustment.adjustForEdit(ranges, editLocation, deletedLength, insertedLength) @@ -121,9 +123,11 @@ class BlockStore { /** * Snaps every stored range to the line bounds of its start position. * Absorbs edge-typed chars, clips split ranges to first line, drops - * duplicates. On an empty line a heading persists as a zero-length anchor; - * any other collapsed range is dropped. Call after [adjustForEdit] once - * [text] is final. Idempotent. + * duplicates. On an empty line an anchored block (heading, list item) + * persists as a zero-length anchor; any other collapsed range is dropped. + * List depths are clamped so an item nests at most one level under the + * previous adjacent item (CommonMark cannot represent orphan nesting). + * Call after [adjustForEdit] once [text] is final. Idempotent. */ fun normalizeToLineBounds(text: CharSequence) { if (ranges.isEmpty()) return @@ -134,7 +138,7 @@ class BlockStore { val range = iterator.next() val (lineStart, lineEnd) = paragraphBounds(range.start, range.start, text) val isEmptyLine = lineEnd == lineStart - if ((isEmptyLine && range.type !in BlockType.HEADINGS) || lineStart <= previousEnd) { + if ((isEmptyLine && range.type !in BlockType.ANCHORED) || lineStart <= previousEnd) { iterator.remove() continue } @@ -142,6 +146,46 @@ class BlockStore { range.end = lineEnd previousEnd = lineEnd } + + recomputeListMetadata() + } + + /** + * Clamps list depths to valid ancestry (an item nests at most one level under + * the previous adjacent list item — CommonMark cannot represent orphan nesting) + * and renumbers ordered items among their adjacent same-depth, same-type run. + */ + private fun recomputeListMetadata() { + var prevEnd = -2 + var prevDepth = -1 + val counters = IntArray(MAX_LIST_DEPTH + 2) + val counterTypes = arrayOfNulls(MAX_LIST_DEPTH + 2) + for (range in ranges) { + if (range.type !in BlockType.LIST_ITEMS) { + prevDepth = -1 + continue + } + val adjacent = prevDepth >= 0 && range.start == prevEnd + 1 + if (!adjacent) { + counters.fill(0) + counterTypes.fill(null) + } + val maxDepth = if (adjacent) prevDepth + 1 else 0 + if (range.level > maxDepth) range.level = maxDepth + val depth = range.level + for (i in depth + 1..MAX_LIST_DEPTH + 1) { + counters[i] = 0 + counterTypes[i] = null + } + if (counterTypes[depth] != range.type) { + counters[depth] = 0 + counterTypes[depth] = range.type + } + counters[depth]++ + range.ordinal = counters[depth] + prevEnd = range.end + prevDepth = range.level + } } /** diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputFormatter.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputFormatter.kt index 474f80fc..a9cea3bf 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputFormatter.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputFormatter.kt @@ -7,15 +7,18 @@ import com.swmansion.enriched.markdown.input.model.BlockType import com.swmansion.enriched.markdown.input.model.FormattingRange import com.swmansion.enriched.markdown.input.model.InputFormatterStyle import com.swmansion.enriched.markdown.input.model.StyleType +import com.swmansion.enriched.markdown.input.spans.InputListItemSpacingSpan import com.swmansion.enriched.markdown.input.styles.BlockHandler import com.swmansion.enriched.markdown.input.styles.BoldStyleHandler import com.swmansion.enriched.markdown.input.styles.HeadingBlockHandler import com.swmansion.enriched.markdown.input.styles.ItalicStyleHandler import com.swmansion.enriched.markdown.input.styles.LinkStyleHandler +import com.swmansion.enriched.markdown.input.styles.OrderedListBlockHandler import com.swmansion.enriched.markdown.input.styles.SpoilerStyleHandler import com.swmansion.enriched.markdown.input.styles.StrikethroughStyleHandler import com.swmansion.enriched.markdown.input.styles.StyleHandler import com.swmansion.enriched.markdown.input.styles.UnderlineStyleHandler +import com.swmansion.enriched.markdown.input.styles.UnorderedListBlockHandler /** * Marker interface so we only remove spans we created, leaving @@ -37,11 +40,15 @@ class InputFormatter { /** * Block handlers, keyed by block type. A single [HeadingBlockHandler] serves all * six heading levels — it reads the level from the [BlockRange] — so it is mapped - * under every `HEADING_n` key. + * under every `HEADING_n` key. One [UnorderedListBlockHandler] serves every list + * depth (depth lives on the range), so it is mapped under the single list key. */ val blockHandlers: Map = - HeadingBlockHandler().let { heading -> - BlockType.HEADINGS.associateWith { heading } + buildMap { + val heading = HeadingBlockHandler() + for (type in BlockType.HEADINGS) put(type, heading) + put(BlockType.UNORDERED_LIST_ITEM, UnorderedListBlockHandler()) + put(BlockType.ORDERED_LIST_ITEM, OrderedListBlockHandler()) } fun handlerForBlock(type: BlockType): BlockHandler? = blockHandlers[type] @@ -170,23 +177,32 @@ class InputFormatter { } for (range in blockRanges) { - val isHeadingAnchor = range.length == 0 && range.type in BlockType.HEADINGS - if (!isHeadingAnchor && (range.start >= range.end || range.start < 0 || range.end > spannable.length)) continue - if (isHeadingAnchor && (range.start < 0 || range.start > spannable.length)) continue - if (isHeadingAnchor) { + val isAnchor = range.length == 0 && range.type in BlockType.ANCHORED + if (!isAnchor && (range.start >= range.end || range.start < 0 || range.end > spannable.length)) continue + if (isAnchor && (range.start < 0 || range.start > spannable.length)) continue + if (isAnchor) { if (range.start < start || range.start > end) continue } else if (range.end <= start || range.start >= end) { continue } val handler = blockHandlers[range.type] ?: continue - // Extend a zero-length heading anchor to cover the next character (usually - // '\n') so the Layout gives the empty line heading metrics. At the very end - // of text the span stays zero-length; the view-level paint override handles + // Extend a zero-length anchor to cover the next character (usually '\n') so + // the Layout gives the empty line the block's metrics. At the very end of + // text the span stays zero-length; the view-level paint override handles // cursor height for that edge case. - val spanEnd = if (isHeadingAnchor && range.start < spannable.length) range.start + 1 else range.end - val flags = if (isHeadingAnchor) Spannable.SPAN_INCLUSIVE_INCLUSIVE else Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + val anchorEnd = if (isAnchor && range.start < spannable.length) range.start + 1 else range.end + val flags = if (isAnchor) Spannable.SPAN_INCLUSIVE_INCLUSIVE else Spannable.SPAN_EXCLUSIVE_EXCLUSIVE for (span in handler.createSpans(range, currentStyle)) { + // A LineHeightSpan (list-item spacing) must cover only the item's first + // character so it spaces just the first visual line, not wrapped lines. + val spanEnd = + if (span is InputListItemSpacingSpan) { + (range.start + 1).coerceAtMost(range.end).coerceAtMost(spannable.length) + } else { + anchorEnd + } + if (span is InputListItemSpacingSpan && spanEnd <= range.start) continue spannable.setSpan(span, range.start, spanEnd, flags) } } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputParser.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputParser.kt index 33888ac8..2c31fe0b 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputParser.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputParser.kt @@ -3,6 +3,7 @@ package com.swmansion.enriched.markdown.input.formatting import com.swmansion.enriched.markdown.input.model.BlockRange import com.swmansion.enriched.markdown.input.model.BlockType import com.swmansion.enriched.markdown.input.model.FormattingRange +import com.swmansion.enriched.markdown.input.model.MAX_LIST_DEPTH import com.swmansion.enriched.markdown.input.model.StyleType import com.swmansion.enriched.markdown.parser.MarkdownASTNode import com.swmansion.enriched.markdown.parser.MarkdownASTNode.NodeType @@ -43,7 +44,7 @@ object InputParser { .toList(), ) - walkNode(ast, plainText, ranges, blockRanges, ArrayDeque(), blankRuns) + walkNode(ast, plainText, ranges, blockRanges, ArrayDeque(), blankRuns, 0, false) return ParseResult(plainText.toString(), ranges, blockRanges) } @@ -55,6 +56,8 @@ object InputParser { blockRanges: MutableList, activeStyles: ArrayDeque, blankRuns: ArrayDeque, + listDepth: Int, + orderedContainer: Boolean, ) { val styleType = nodeTypeToStyleType(node.type) @@ -63,6 +66,20 @@ object InputParser { activeStyles.addLast(ActiveStyle(styleType, plainText.length, url)) } + // A list node increments the nesting depth of its items, so a list item's depth + // is (enclosing list nodes − 1) — matching the iOS depth derivation. Depth comes + // from this AST nesting, never from counting leading spaces. + val isListContainer = node.type == NodeType.UnorderedList || node.type == NodeType.OrderedList + val childListDepth = if (isListContainer) listDepth + 1 else listDepth + val childOrdered = if (isListContainer) node.type == NodeType.OrderedList else orderedContainer + + // Each list item starts on its own line; md4c emits no separator between sibling + // items or before a nested sublist, so insert the line break ourselves. + if (node.type == NodeType.ListItem && plainText.isNotEmpty() && !plainText.endsWith("\n")) { + plainText.append("\n") + } + val itemStart = if (node.type == NodeType.ListItem) plainText.length else -1 + // Block-level node: record where its text content begins so we can build a // BlockRange on the way back out. PARAGRAPH is the implicit default and is // dropped below, so it produces no stored block range. @@ -77,11 +94,27 @@ object InputParser { } for ((index, child) in node.children.withIndex()) { - // Keep the source's blank lines between top-level blocks (md4c drops them, iOS keeps them). - if (index > 0 && child.type.isTopLevelBlock() && plainText.isNotEmpty()) { + // Keep the source's blank lines between genuinely top-level blocks (md4c drops + // them, iOS keeps them). Inside a list, items are separated by a single newline + // (inserted above), not blank lines. + if (index > 0 && listDepth == 0 && child.type.isTopLevelBlock() && plainText.isNotEmpty()) { plainText.append("\n".repeat(blankRuns.removeFirstOrNull() ?: 2)) } - walkNode(child, plainText, ranges, blockRanges, activeStyles, blankRuns) + walkNode(child, plainText, ranges, blockRanges, activeStyles, blankRuns, childListDepth, childOrdered) + } + + // A list item owns its own first line; a nested sublist lives on later lines, so + // the item's range ends at the first newline after its start. + if (itemStart >= 0) { + var lineEnd = plainText.indexOf('\n', itemStart) + if (lineEnd < 0) lineEnd = plainText.length + if (lineEnd > itemStart) { + // The item sees listDepth already incremented by its enclosing list node, so + // its 0-based depth is listDepth − 1 (enclosing list nodes − 1, per iOS). + val depth = (listDepth - 1).coerceIn(0, MAX_LIST_DEPTH) + val itemType = if (orderedContainer) BlockType.ORDERED_LIST_ITEM else BlockType.UNORDERED_LIST_ITEM + blockRanges.add(BlockRange(itemType, itemStart, lineEnd, depth)) + } } if (styleType != null) { @@ -93,8 +126,9 @@ object InputParser { } // Emit the block range for handler-claimed block types only; PARAGRAPH (the - // implicit default) yields nothing, so PR1 produces an empty block list. - if (blockType != null && blockType != BlockType.PARAGRAPH) { + // implicit default) yields nothing. List items are emitted above with their own + // line bounds, so they are excluded here. + if (blockType != null && blockType != BlockType.PARAGRAPH && blockType !in BlockType.LIST_ITEMS) { val end = plainText.length if (end > blockStartPosition) { blockRanges.add(BlockRange(blockType, blockStartPosition, end, blockLevel)) @@ -125,7 +159,13 @@ object InputParser { ): BlockType? = when (nodeType) { NodeType.Paragraph -> BlockType.PARAGRAPH + NodeType.Heading -> BlockType.forHeadingLevel(level) ?: BlockType.PARAGRAPH + + // List items are recognized here but emitted with their own per-line bounds and + // AST-derived nesting depth in the walk above, not via the generic emission. + NodeType.ListItem -> BlockType.UNORDERED_LIST_ITEM + else -> null } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/MarkdownSerializer.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/MarkdownSerializer.kt index fd3b8c8b..843ec2a8 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/MarkdownSerializer.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/MarkdownSerializer.kt @@ -2,18 +2,24 @@ package com.swmansion.enriched.markdown.input.formatting import android.util.Log import com.swmansion.enriched.markdown.input.model.BlockRange +import com.swmansion.enriched.markdown.input.model.BlockType import com.swmansion.enriched.markdown.input.model.FormattingRange import com.swmansion.enriched.markdown.input.model.StyleType object MarkdownSerializer { private const val TAG = "MarkdownSerializer" + // Zero-width space used by the editor to anchor an empty bullet line; it is an + // internal editing aid and must never appear in serialized markdown. + private const val ZWSP = "\u200B" + /** * Block-aware serialization: serializes inline styles exactly as the inline-only * overload, then prepends each line's block prefix. [blockPrefixProvider] is * asked, per block range, for the markdown line marker (e.g. `"# "`, `"- "`); * returning `""` leaves the line unprefixed. With empty [blockRanges] the output - * is identical to the inline-only overload. + * is identical to the inline-only overload. Any ZWSP empty-line anchor is stripped + * so an empty bullet still serializes to a bare `"- "` rather than `"- ​"`. */ fun serialize( text: String, @@ -22,7 +28,7 @@ object MarkdownSerializer { blockPrefixProvider: (BlockRange) -> String, ): String { val inlineMarkdown = serialize(text, ranges) - if (blockRanges.isEmpty()) return inlineMarkdown + if (blockRanges.isEmpty()) return inlineMarkdown.replace(ZWSP, "") // Block prefixes attach per line. Inline serialization only inserts inline // delimiters (never newlines), so the serialized output has the same line @@ -37,14 +43,15 @@ object MarkdownSerializer { // not crash the host app over lost block prefixes. if (plainLines.size != markdownLines.size) { Log.e(TAG, "Block serialization line-count invariant violated: plain=${plainLines.size} markdown=${markdownLines.size}") - return inlineMarkdown + return inlineMarkdown.replace(ZWSP, "") } + // Plain-text character offset at the start of each line. val lineStartOffsets = IntArray(plainLines.size) var runningOffset = 0 for (i in plainLines.indices) { lineStartOffsets[i] = runningOffset - runningOffset += plainLines[i].length + 1 + runningOffset += plainLines[i].length + 1 // +1 for the '\n' separator } for (blockRange in blockRanges) { @@ -52,17 +59,22 @@ object MarkdownSerializer { if (prefix.isEmpty()) continue val isZeroLength = blockRange.length == 0 + val isListItem = blockRange.type in BlockType.LIST_ITEMS for (lineIndex in plainLines.indices) { val lineStart = lineStartOffsets[lineIndex] val lineEnd = lineStart + plainLines[lineIndex].length val overlaps = if (isZeroLength) lineStart == blockRange.start else lineEnd >= blockRange.start && lineStart < blockRange.end if (overlaps) { + // A marker-only list line ("- " with no content) re-parses as a setext + // underline for the previous line; emit an empty list line bare. An + // empty "# " heading is valid ATX and keeps its prefix. + if (isListItem && plainLines[lineIndex].replace(ZWSP, "").isEmpty()) continue markdownLines[lineIndex] = prefix + markdownLines[lineIndex] } } } - return markdownLines.joinToString("\n") + return markdownLines.joinToString("\n").replace(ZWSP, "") } fun serialize( diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/model/BlockRange.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/model/BlockRange.kt index 3627d344..5a10bdbe 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/model/BlockRange.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/model/BlockRange.kt @@ -12,13 +12,16 @@ package com.swmansion.enriched.markdown.input.model * @property start inclusive start offset, in plain-text characters. * @property end exclusive end offset, in plain-text characters. * @property level generic integer payload, 0 by default. Headings use it for the - * H-level (1-6); list items will use it for nesting depth. + * H-level (1-6); list items use it for nesting depth. + * @property ordinal 1-based position of an ordered list item among its adjacent + * siblings at the same depth; recomputed by the store's list-metadata pass. */ class BlockRange( val type: BlockType, override var start: Int, override var end: Int, var level: Int = 0, + var ordinal: Int = 1, ) : MutableRangeBounds { val length: Int get() = end - start } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/model/BlockType.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/model/BlockType.kt index 6e0bccaa..476dace7 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/model/BlockType.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/model/BlockType.kt @@ -18,6 +18,8 @@ enum class BlockType { HEADING_4, HEADING_5, HEADING_6, + UNORDERED_LIST_ITEM, + ORDERED_LIST_ITEM, ; companion object { @@ -25,6 +27,15 @@ enum class BlockType { val HEADINGS: List = listOf(HEADING_1, HEADING_2, HEADING_3, HEADING_4, HEADING_5, HEADING_6) + /** List-item block types; nesting depth rides in the range's level payload. */ + val LIST_ITEMS: Set = setOf(UNORDERED_LIST_ITEM, ORDERED_LIST_ITEM) + + /** + * Block types whose emptied line persists as a zero-length anchor (an emptied + * heading stays a heading, an emptied list item keeps its marker) until toggled off. + */ + val ANCHORED: Set = HEADINGS.toSet() + LIST_ITEMS + /** * Maps an H-level (1-6) to its [BlockType], or null when out of range. Used by * the parser to turn an AST heading node's `level` attribute into a block type. @@ -32,3 +43,6 @@ enum class BlockType { fun forHeadingLevel(level: Int): BlockType? = HEADINGS.getOrNull(level - 1) } } + +/** Maximum bullet-list nesting depth (0-based), so indent can't run away unbounded. */ +const val MAX_LIST_DEPTH = 5 diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/model/InputFormatterStyle.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/model/InputFormatterStyle.kt index bc1cc6b3..6124e061 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/model/InputFormatterStyle.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/model/InputFormatterStyle.kt @@ -11,6 +11,10 @@ data class InputFormatterStyle( val spoilerBackgroundColor: Int, /** Per-level heading styling, indexed 0..5 for H1..H6. Always length 6. */ val headings: List, + /** Display density (px per dp) so block handlers can build density-correct spans without a Context. */ + val displayDensity: Float = 1f, + /** Extra vertical spacing (px) above each bullet item, from the `listItemSpacing` prop. */ + val listItemSpacingPx: Int = 0, ) { /** * Resolves the heading style for an H-level (1-6), clamping out-of-range levels diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/spans/InputBulletSpan.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/spans/InputBulletSpan.kt new file mode 100644 index 00000000..f17d898c --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/spans/InputBulletSpan.kt @@ -0,0 +1,82 @@ +package com.swmansion.enriched.markdown.input.spans + +import android.graphics.Canvas +import android.graphics.Paint +import android.text.Layout +import android.text.Spanned +import android.text.style.LeadingMarginSpan +import com.swmansion.enriched.markdown.input.formatting.MarkdownSpan + +/** + * Bullet list item span: reserves a per-depth leading margin and draws the marker + * glyph (dot / ring / square, cycling by `depth % 3`) on the item's first line. + * Mirrors the readonly renderer's bullet. Tagged [MarkdownSpan] for formatter + * cleanup. + */ +class InputBulletSpan( + val depth: Int, + density: Float, +) : LeadingMarginSpan, + MarkdownSpan { + private val indentPerDepth = INDENT_PER_DEPTH_DP * density + private val markerWidth = MARKER_WIDTH_DP * density + + override fun getLeadingMargin(first: Boolean): Int = (depth * indentPerDepth + markerWidth).toInt() + + override fun drawLeadingMargin( + canvas: Canvas, + paint: Paint, + x: Int, + dir: Int, + top: Int, + baseline: Int, + bottom: Int, + text: CharSequence?, + start: Int, + end: Int, + first: Boolean, + layout: Layout?, + ) { + if (!first) return + // Wrapped continuation lines also report first=true; draw only on the visual + // line where this span actually begins. + if (text is Spanned && text.getSpanStart(this) != start) return + + // Bullet size tracks the text size (~30%), with a small floor so it stays visible. + val size = (paint.textSize * BULLET_SIZE_RATIO).coerceAtLeast(MIN_BULLET_PX) + val radius = size / 2f + val centerX = x + (depth * indentPerDepth + markerWidth * 0.5f) * dir + val fm = paint.fontMetrics + val centerY = baseline + (fm.ascent + fm.descent) / 2f + + val originalStyle = paint.style + val originalStrokeWidth = paint.strokeWidth + when (depth % 3) { + 0 -> { + paint.style = Paint.Style.FILL + canvas.drawCircle(centerX, centerY, radius, paint) + } + + 1 -> { + paint.style = Paint.Style.STROKE + paint.strokeWidth = (size * RING_STROKE_RATIO).coerceAtLeast(1f) + canvas.drawCircle(centerX, centerY, radius - paint.strokeWidth / 2f, paint) + } + + else -> { + paint.style = Paint.Style.FILL + canvas.drawRect(centerX - radius, centerY - radius, centerX + radius, centerY + radius, paint) + } + } + paint.style = originalStyle + paint.strokeWidth = originalStrokeWidth + } + + companion object { + private const val INDENT_PER_DEPTH_DP = 18f + private const val MARKER_WIDTH_DP = 18f + private const val BULLET_SIZE_RATIO = 0.3f + private const val MIN_BULLET_PX = 4f + private const val RING_STROKE_RATIO = 0.15f + } +} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/spans/InputListItemSpacingSpan.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/spans/InputListItemSpacingSpan.kt new file mode 100644 index 00000000..7d729753 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/spans/InputListItemSpacingSpan.kt @@ -0,0 +1,28 @@ +package com.swmansion.enriched.markdown.input.spans + +import android.graphics.Paint +import android.text.style.LineHeightSpan +import com.swmansion.enriched.markdown.input.formatting.MarkdownSpan + +/** + * Adds [spacingPx] of vertical space above a bullet item (counterpart to iOS + * `paragraphSpacingBefore`). Must span only the item's first character so wrapped + * continuation lines aren't spaced. Tagged [MarkdownSpan] for formatter cleanup. + */ +class InputListItemSpacingSpan( + val spacingPx: Int, +) : LineHeightSpan, + MarkdownSpan { + override fun chooseHeight( + text: CharSequence?, + start: Int, + end: Int, + spanstartv: Int, + lineHeight: Int, + fm: Paint.FontMetricsInt?, + ) { + if (fm == null || spacingPx <= 0) return + fm.ascent -= spacingPx + fm.top -= spacingPx + } +} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/spans/InputOrderedListMarkerSpan.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/spans/InputOrderedListMarkerSpan.kt new file mode 100644 index 00000000..f4039940 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/spans/InputOrderedListMarkerSpan.kt @@ -0,0 +1,61 @@ +package com.swmansion.enriched.markdown.input.spans + +import android.graphics.Canvas +import android.graphics.Paint +import android.text.Layout +import android.text.Spanned +import android.text.style.LeadingMarginSpan +import com.swmansion.enriched.markdown.input.formatting.MarkdownSpan + +/** + * Ordered list item span: reserves the same per-depth leading margin as + * [InputBulletSpan] and draws the item's number ("3.") right-aligned before the + * text column. The ordinal comes from the block store's list-metadata pass. + * Tagged [MarkdownSpan] for formatter cleanup. + */ +class InputOrderedListMarkerSpan( + val depth: Int, + private val ordinal: Int, + density: Float, +) : LeadingMarginSpan, + MarkdownSpan { + private val indentPerDepth = INDENT_PER_DEPTH_DP * density + private val markerWidth = MARKER_WIDTH_DP * density + private val markerGap = MARKER_GAP_DP * density + + override fun getLeadingMargin(first: Boolean): Int = (depth * indentPerDepth + markerWidth).toInt() + + override fun drawLeadingMargin( + canvas: Canvas, + paint: Paint, + x: Int, + dir: Int, + top: Int, + baseline: Int, + bottom: Int, + text: CharSequence?, + start: Int, + end: Int, + first: Boolean, + layout: Layout?, + ) { + if (!first) return + // Wrapped continuation lines also report first=true; draw only on the visual + // line where this span actually begins. + if (text is Spanned && text.getSpanStart(this) != start) return + + val label = "$ordinal." + val originalAlign = paint.textAlign + paint.textAlign = Paint.Align.RIGHT + val markerRight = x + (depth * indentPerDepth + markerWidth - markerGap) * dir + canvas.drawText(label, markerRight, baseline.toFloat(), paint) + paint.textAlign = originalAlign + } + + companion object { + // Geometry mirrors InputBulletSpan so ordered and bullet items align. + private const val INDENT_PER_DEPTH_DP = 18f + private const val MARKER_WIDTH_DP = 18f + private const val MARKER_GAP_DP = 4f + } +} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/styles/OrderedListBlockHandler.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/styles/OrderedListBlockHandler.kt new file mode 100644 index 00000000..077ecf23 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/styles/OrderedListBlockHandler.kt @@ -0,0 +1,33 @@ +package com.swmansion.enriched.markdown.input.styles + +import com.swmansion.enriched.markdown.input.model.BlockRange +import com.swmansion.enriched.markdown.input.model.BlockType +import com.swmansion.enriched.markdown.input.model.InputFormatterStyle +import com.swmansion.enriched.markdown.input.spans.InputListItemSpacingSpan +import com.swmansion.enriched.markdown.input.spans.InputOrderedListMarkerSpan + +/** + * Block handler for ordered list items. One instance serves every nesting depth — + * the 0-based depth rides in [BlockRange.level] and the displayed number in + * [BlockRange.ordinal], both maintained by the block store. + */ +class OrderedListBlockHandler : BlockHandler { + override val blockType: BlockType = BlockType.ORDERED_LIST_ITEM + + override fun createSpans( + blockRange: BlockRange, + style: InputFormatterStyle, + ): List { + val spans = mutableListOf(InputOrderedListMarkerSpan(blockRange.level, blockRange.ordinal, style.displayDensity)) + if (style.listItemSpacingPx > 0) { + spans.add(InputListItemSpacingSpan(style.listItemSpacingPx)) + } + return spans + } + + override fun spanClasses(): List> = listOf(InputOrderedListMarkerSpan::class.java, InputListItemSpacingSpan::class.java) + + /** `"1. "` numbering from the range's ordinal, indented three spaces per nesting depth. */ + override fun markdownLinePrefix(blockRange: BlockRange): String = + " ".repeat(blockRange.level.coerceAtLeast(0)) + "${blockRange.ordinal}. " +} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/styles/UnorderedListBlockHandler.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/styles/UnorderedListBlockHandler.kt new file mode 100644 index 00000000..96bca483 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/styles/UnorderedListBlockHandler.kt @@ -0,0 +1,32 @@ +package com.swmansion.enriched.markdown.input.styles + +import com.swmansion.enriched.markdown.input.model.BlockRange +import com.swmansion.enriched.markdown.input.model.BlockType +import com.swmansion.enriched.markdown.input.model.InputFormatterStyle +import com.swmansion.enriched.markdown.input.spans.InputBulletSpan +import com.swmansion.enriched.markdown.input.spans.InputListItemSpacingSpan + +/** + * Block handler for unordered (bullet) list items. One instance serves every + * nesting depth — the 0-based depth rides in [BlockRange.level] and drives the + * indent, marker glyph, and serialized indentation. + */ +class UnorderedListBlockHandler : BlockHandler { + override val blockType: BlockType = BlockType.UNORDERED_LIST_ITEM + + override fun createSpans( + blockRange: BlockRange, + style: InputFormatterStyle, + ): List { + val spans = mutableListOf(InputBulletSpan(blockRange.level, style.displayDensity)) + if (style.listItemSpacingPx > 0) { + spans.add(InputListItemSpacingSpan(style.listItemSpacingPx)) + } + return spans + } + + override fun spanClasses(): List> = listOf(InputBulletSpan::class.java, InputListItemSpacingSpan::class.java) + + /** `"- "` for a top-level item, indented three spaces per nesting depth (wide enough for ordered markers). */ + override fun markdownLinePrefix(blockRange: BlockRange): String = " ".repeat(blockRange.level.coerceAtLeast(0)) + "- " +} diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMBlockRange.h b/packages/react-native-enriched-markdown/ios/input/ENRMBlockRange.h index aad15f33..6dc010fe 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMBlockRange.h +++ b/packages/react-native-enriched-markdown/ios/input/ENRMBlockRange.h @@ -13,9 +13,13 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, assign) NSRange range; /// Generic integer payload for the block. 0 by default. Headings use it for the -/// H-level (1-6); list items will use it for nesting depth. +/// H-level (1-6); list items use it for nesting depth. @property (nonatomic, assign) NSInteger level; +/// 1-based position of an ordered item among its adjacent same-depth siblings; +/// recomputed by the block store's list-metadata pass. +@property (nonatomic, assign) NSInteger ordinal; + + (instancetype)rangeWithType:(ENRMInputBlockType)type range:(NSRange)range; + (instancetype)rangeWithType:(ENRMInputBlockType)type range:(NSRange)range level:(NSInteger)level; diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMBlockRange.mm b/packages/react-native-enriched-markdown/ios/input/ENRMBlockRange.mm index 1992f46c..8d8569e0 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMBlockRange.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMBlockRange.mm @@ -13,6 +13,7 @@ + (instancetype)rangeWithType:(ENRMInputBlockType)type range:(NSRange)range leve blockRange.type = type; blockRange.range = range; blockRange.level = level; + blockRange.ordinal = 1; return blockRange; } @@ -22,6 +23,7 @@ - (id)copyWithZone:(NSZone *)zone copy.type = _type; copy.range = _range; copy.level = _level; + copy.ordinal = _ordinal; return copy; } diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.h b/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.h index 80f25e2f..668e0dda 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.h +++ b/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.h @@ -39,8 +39,10 @@ NS_ASSUME_NONNULL_BEGIN /// Snaps every stored range to the line bounds of its start position. /// Absorbs edge-typed chars, clips split ranges to first line, drops /// duplicates. On an empty line a heading persists as a zero-length anchor; -/// any other collapsed range is dropped. Call after adjustForEditAtLocation: -/// once text is final. Idempotent. +/// any other collapsed range is dropped. List depths are clamped so an item +/// nests at most one level under the previous adjacent item (CommonMark cannot +/// represent orphan nesting). Call after adjustForEditAtLocation: once text is +/// final. Idempotent. - (void)normalizeToLineBoundsInText:(NSString *)text; @end diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.mm b/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.mm index e8fab8d4..3e6b022b 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.mm @@ -45,6 +45,7 @@ - (void)setRanges:(NSArray *)ranges return NSOrderedDescending; return NSOrderedSame; }] mutableCopy]; + [self recomputeListMetadata]; } - (void)clearAll @@ -94,7 +95,7 @@ - (void)setBlockType:(ENRMInputBlockType)type paragraphRange = ENRMTrimLineTerminators(paragraphRange, text); - if (paragraphRange.length == 0 && ENRMHeadingLevelForBlockType(type) == 0) { + if (paragraphRange.length == 0 && !ENRMBlockTypePersistsWhenEmpty(type)) { return; } @@ -122,13 +123,13 @@ - (void)adjustForEditAtLocation:(NSUInteger)editLocation for (NSUInteger idx = 0; idx < _ranges.count; idx++) { ENRMBlockRange *blockRange = _ranges[idx]; - BOOL isHeading = ENRMHeadingLevelForBlockType(blockRange.type) > 0; + BOOL persists = ENRMBlockTypePersistsWhenEmpty(blockRange.type); - // Zero-length heading anchors don't follow the shared adjustment: one at - // the edit location stays put (normalize grows it over the typed text), - // one past the edit shifts with it, one inside the deletion is dropped. + // Zero-length anchors don't follow the shared adjustment: one at the edit + // location stays put (normalize grows it over the typed text), one past + // the edit shifts with it, one inside the deletion is dropped. if (blockRange.range.length == 0) { - if (!isHeading) { + if (!persists) { [indexesToRemove addIndex:idx]; } else if (blockRange.range.location >= deleteEnd && blockRange.range.location > editLocation) { blockRange.range = NSMakeRange(blockRange.range.location - deletedLength + insertedLength, 0); @@ -140,10 +141,10 @@ - (void)adjustForEditAtLocation:(NSUInteger)editLocation ENRMAdjustedRange adjusted = ENRMAdjustRangeForEdit(blockRange.range, editLocation, deletedLength, insertedLength); if (adjusted.shouldRemove) { - // A heading deleted exactly to its end collapses to a zero-length anchor - // (the line's newline survived, so the line stays a heading); a deletion - // running past its end removed the line, so drop the heading with it. - if (isHeading && NSMaxRange(blockRange.range) == deleteEnd && blockRange.range.location >= editLocation) { + // A persisting block deleted exactly to its end collapses to a zero-length + // anchor (the line's newline survived, so the line stays the block); a + // deletion running past its end removed the line, so drop the block with it. + if (persists && NSMaxRange(blockRange.range) == deleteEnd && blockRange.range.location >= editLocation) { blockRange.range = NSMakeRange(editLocation, 0); } else { [indexesToRemove addIndex:idx]; @@ -155,10 +156,12 @@ - (void)adjustForEditAtLocation:(NSUInteger)editLocation ENRMRemoveIndexesInReverse(_ranges, indexesToRemove); + // Prune zero-length ranges, but keep zero-length persisting blocks: they anchor + // an emptied-but-still-present heading/bullet line (see the collapse rule above). NSMutableIndexSet *emptyIndexes = [NSMutableIndexSet indexSet]; for (NSUInteger idx = 0; idx < _ranges.count; idx++) { ENRMBlockRange *range = _ranges[idx]; - if (range.range.length == 0 && ENRMHeadingLevelForBlockType(range.type) == 0) { + if (range.range.length == 0 && !ENRMBlockTypePersistsWhenEmpty(range.type)) { [emptyIndexes addIndex:idx]; } } @@ -182,8 +185,9 @@ - (void)normalizeToLineBoundsInText:(NSString *)text lineRange = ENRMTrimLineTerminators(lineRange, text); - BOOL isHeading = ENRMHeadingLevelForBlockType(blockRange.type) > 0; - if ((lineRange.length == 0 && !isHeading) || (NSInteger)lineRange.location <= previousEnd) { + BOOL emptyLine = lineRange.length == 0; + if ((emptyLine && !ENRMBlockTypePersistsWhenEmpty(blockRange.type)) || + (NSInteger)lineRange.location <= previousEnd) { [indexesToRemove addIndex:idx]; continue; } @@ -193,6 +197,49 @@ - (void)normalizeToLineBoundsInText:(NSString *)text } ENRMRemoveIndexesInReverse(_ranges, indexesToRemove); + [self recomputeListMetadata]; +} + +/// Clamps list depths to valid ancestry (an item nests at most one level under +/// the previous adjacent list item — CommonMark cannot represent orphan nesting) +/// and renumbers ordered items among their adjacent same-depth, same-type run. +- (void)recomputeListMetadata +{ + NSInteger prevEnd = -2; + NSInteger prevDepth = -1; + NSInteger counters[kENRMMaxListDepth + 2]; + ENRMInputBlockType counterTypes[kENRMMaxListDepth + 2]; + memset(counters, 0, sizeof(counters)); + memset(counterTypes, 0, sizeof(counterTypes)); + + for (ENRMBlockRange *blockRange in _ranges) { + if (!ENRMBlockTypeIsListItem(blockRange.type)) { + prevDepth = -1; + continue; + } + BOOL adjacent = prevDepth >= 0 && (NSInteger)blockRange.range.location == prevEnd + 1; + if (!adjacent) { + memset(counters, 0, sizeof(counters)); + memset(counterTypes, 0, sizeof(counterTypes)); + } + NSInteger maxDepth = adjacent ? prevDepth + 1 : 0; + if (blockRange.level > maxDepth) { + blockRange.level = maxDepth; + } + NSInteger depth = blockRange.level; + for (NSInteger i = depth + 1; i <= kENRMMaxListDepth + 1; i++) { + counters[i] = 0; + counterTypes[i] = (ENRMInputBlockType)0; + } + if (counterTypes[depth] != blockRange.type) { + counters[depth] = 0; + counterTypes[depth] = blockRange.type; + } + counters[depth]++; + blockRange.ordinal = counters[depth]; + prevEnd = (NSInteger)NSMaxRange(blockRange.range); + prevDepth = blockRange.level; + } } @end diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputBlockType.h b/packages/react-native-enriched-markdown/ios/input/ENRMInputBlockType.h index 592ea187..092da49e 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputBlockType.h +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputBlockType.h @@ -19,8 +19,28 @@ typedef NS_ENUM(NSInteger, ENRMInputBlockType) { ENRMInputBlockTypeHeading4 = 4, ENRMInputBlockTypeHeading5 = 5, ENRMInputBlockTypeHeading6 = 6, + ENRMInputBlockTypeUnorderedListItem = 7, + ENRMInputBlockTypeOrderedListItem = 8, }; +/// Whether the type is a list item (nesting depth rides in the range's level). +static inline BOOL ENRMBlockTypeIsListItem(ENRMInputBlockType type) +{ + return type == ENRMInputBlockTypeUnorderedListItem || type == ENRMInputBlockTypeOrderedListItem; +} + +/// Maximum bullet-list nesting depth (0-based). Depth is carried as the block's +/// level payload (ENRMBlockRange.level), clamped into [0, kENRMMaxListDepth]. +static const NSInteger kENRMMaxListDepth = 5; + +/// Bullet-list layout metrics (points). Shared by the list block handler (which +/// reserves the head-indent column via the paragraph style) and the layout +/// manager (which draws the marker glyph into that column). Text for a list item +/// at depth d starts at (d + 1) * kENRMListIndentPerDepth from the leading edge; +/// the marker is centered kENRMListBulletGap before the text. +static const CGFloat kENRMListIndentPerDepth = 18.0; +static const CGFloat kENRMListBulletGap = 9.0; + /// Maps a heading level (1-6) to its ENRMInputBlockType. Levels outside 1-6 are /// clamped into range. The contiguous Heading1..Heading6 enum values make this a /// simple offset from the Heading1 base. @@ -44,6 +64,14 @@ static inline NSInteger ENRMHeadingLevelForBlockType(ENRMInputBlockType type) return 0; } +/// Whether an emptied line of this type keeps a zero-length anchor (an emptied +/// heading stays a heading, an emptied bullet keeps its marker) instead of +/// reverting to a plain paragraph. +static inline BOOL ENRMBlockTypePersistsWhenEmpty(ENRMInputBlockType type) +{ + return (type >= ENRMInputBlockTypeHeading1 && type <= ENRMInputBlockTypeHeading6) || ENRMBlockTypeIsListItem(type); +} + /// NSAttributedString attribute carrying the ENRMInputBlockType (boxed NSNumber) /// of the paragraph a character belongs to. Set by ENRMInputFormatter on the /// paragraphs a block claims; the next block pass uses it to find and strip the @@ -55,4 +83,9 @@ extern NSAttributedStringKey const ENRMBlockTypeAttributeName; /// Applied and stripped alongside ENRMBlockTypeAttributeName. extern NSAttributedStringKey const ENRMBlockLevelAttributeName; +/// NSAttributedString attribute carrying an ordered item's 1-based ordinal +/// (boxed NSNumber) so the layout manager can draw its number. Applied and +/// stripped alongside ENRMBlockTypeAttributeName. +extern NSAttributedStringKey const ENRMBlockOrdinalAttributeName; + NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputBlockType.mm b/packages/react-native-enriched-markdown/ios/input/ENRMInputBlockType.mm index 1136ff4b..60d1645b 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputBlockType.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputBlockType.mm @@ -2,3 +2,4 @@ NSAttributedStringKey const ENRMBlockTypeAttributeName = @"ENRMBlockType"; NSAttributedStringKey const ENRMBlockLevelAttributeName = @"ENRMBlockLevel"; +NSAttributedStringKey const ENRMBlockOrdinalAttributeName = @"ENRMBlockOrdinal"; diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.h b/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.h index ecee2f93..a70aba1b 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.h +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.h @@ -41,6 +41,11 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, strong, nullable) RCTUIColor *spoilerColor; @property (nonatomic, strong, nullable) RCTUIColor *spoilerBackgroundColor; +/// Vertical spacing (points) added above each list item via the paragraph +/// style's paragraphSpacingBefore, so bullets read as separate rows. Configured +/// from the `listItemSpacing` prop; defaults to 0 (items pack tightly). +@property (nonatomic, assign) CGFloat listItemSpacing; + /// Per-level heading config, indexed by level 1-6. A nil/0 entry means the level /// uses a built-in default derived from the base font. Configured from the /// `markdownStyle.h1..h6` props. diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.mm b/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.mm index ad66ac48..5c515a74 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputFormatter.mm @@ -4,10 +4,12 @@ #import "ENRMHeadingBlockHandler.h" #import "ENRMItalicStyleHandler.h" #import "ENRMLinkStyleHandler.h" +#import "ENRMOrderedListBlockHandler.h" #import "ENRMSpoilerStyleHandler.h" #import "ENRMStrikethroughStyleHandler.h" #import "ENRMStyleHandler.h" #import "ENRMUnderlineStyleHandler.h" +#import "ENRMUnorderedListBlockHandler.h" #import "FontUtils.h" @implementation ENRMInputLinkVariantStyle @@ -52,6 +54,7 @@ - (id)copyWithZone:(NSZone *)zone copy.linkVariants = [_linkVariants copy]; copy.spoilerColor = _spoilerColor; copy.spoilerBackgroundColor = _spoilerBackgroundColor; + copy.listItemSpacing = _listItemSpacing; for (NSInteger level = 1; level <= 6; level++) { [copy setHeadingFontSize:_headingFontSizes[level] forLevel:level]; [copy setHeadingFontWeight:_headingFontWeights[level] forLevel:level]; @@ -191,6 +194,8 @@ - (instancetype)init for (NSInteger level = 1; level <= 6; level++) { blockMap[@(ENRMBlockTypeForHeadingLevel(level))] = headingHandler; } + blockMap[@(ENRMInputBlockTypeUnorderedListItem)] = [[ENRMUnorderedListBlockHandler alloc] init]; + blockMap[@(ENRMInputBlockTypeOrderedListItem)] = [[ENRMOrderedListBlockHandler alloc] init]; _blockHandlers = [blockMap copy]; } return self; @@ -363,6 +368,7 @@ - (void)applyBlockRanges:(NSArray *)blockRanges NSRange range = rangeValue.rangeValue; [textStorage removeAttribute:ENRMBlockTypeAttributeName range:range]; [textStorage removeAttribute:ENRMBlockLevelAttributeName range:range]; + [textStorage removeAttribute:ENRMBlockOrdinalAttributeName range:range]; [textStorage removeAttribute:NSParagraphStyleAttributeName range:range]; } @@ -409,6 +415,7 @@ - (void)applyBlockRanges:(NSArray *)blockRanges attributes[NSParagraphStyleAttributeName] = paragraphStyle; attributes[ENRMBlockTypeAttributeName] = @(blockRange.type); attributes[ENRMBlockLevelAttributeName] = @(blockRange.level); + attributes[ENRMBlockOrdinalAttributeName] = @(blockRange.ordinal); // For a zero-length heading anchor on an empty line, stamp onto the line // terminator so the paragraph style (heading font size) takes effect. diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.h b/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.h index 56324f07..392cf823 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.h +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.h @@ -4,8 +4,39 @@ NS_ASSUME_NONNULL_BEGIN +/// Layout manager that draws the bullet marker for unordered-list lines into the +/// head-indent column the list block handler reserves. Non-empty list lines are +/// found by scanning the block-type/level attributes the formatter stamps; an +/// empty list line (a just-toggled or just-continued item with no characters to +/// anchor a marker to) is flagged explicitly by the orchestrator via the +/// emptyBullet* properties below. @interface ENRMInputLayoutManager : NSLayoutManager +/// Depth of the marker to draw on an otherwise-empty list line. Negative disables +/// the empty-line marker. +@property (nonatomic, assign) NSInteger emptyBulletDepth; + +/// Whether the empty list line is an ordered item (draws its number instead of a +/// bullet glyph), and the 1-based number to draw. +@property (nonatomic, assign) BOOL emptyBulletOrdered; +@property (nonatomic, assign) NSInteger emptyBulletOrdinal; + +/// Character location of the empty list line's paragraph (start of the line). +@property (nonatomic, assign) NSUInteger emptyBulletLocation; + +/// Font/color for an empty list line's marker (no character to read them from). +@property (nonatomic, strong, nullable) UIFont *emptyBulletFont; +@property (nonatomic, strong, nullable) UIColor *emptyBulletColor; + +/// Leading spacing (points) applied above list items, so the empty-line marker's +/// baseline can account for the spacing that pushes the text down in the fragment. +@property (nonatomic, assign) CGFloat listItemSpacing; + +/// Draws the marker for a wholly empty editor. `drawGlyphsForGlyphRange:` is +/// never called when there are zero glyphs, so the text view drives this from its +/// own `drawRect:`. No-op unless an empty list line is flagged. +- (void)drawEmptyEditorBulletWithInset:(UIEdgeInsets)inset; + @end NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.mm b/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.mm index 5c46a813..915f5cd7 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.mm @@ -1,5 +1,236 @@ #import "ENRMInputLayoutManager.h" +#import "ENRMInputBlockType.h" +#import @implementation ENRMInputLayoutManager +- (instancetype)init +{ + if (self = [super init]) { + _emptyBulletDepth = -1; + } + return self; +} + +/// Draws the depth-styled bullet (filled dot, ring, then square — cycling every +/// three levels) centered at (markerX, centerY). The glyph is sized at ~30% of +/// the text point size so it reads as a marker, not a character. +- (void)drawBulletAtX:(CGFloat)markerX + centerY:(CGFloat)centerY + depth:(NSInteger)depth + font:(UIFont *)font + color:(UIColor *)color +{ + CGContextRef ctx = UIGraphicsGetCurrentContext(); + if (!ctx || isnan(markerX) || isnan(centerY)) { + return; + } + CGFloat size = MAX(4.0, font.pointSize * 0.30); + CGRect bulletRect = CGRectMake(markerX - size / 2.0, centerY - size / 2.0, size, size); + CGContextSaveGState(ctx); + switch (((depth % 3) + 3) % 3) { + case 0: + [color setFill]; + CGContextFillEllipseInRect(ctx, bulletRect); + break; + case 1: { + CGFloat lineWidth = MAX(1.0, size * 0.15); + [color setStroke]; + CGContextSetLineWidth(ctx, lineWidth); + CGContextStrokeEllipseInRect(ctx, CGRectInset(bulletRect, lineWidth / 2.0, lineWidth / 2.0)); + break; + } + default: + [color setFill]; + CGContextFillRect(ctx, bulletRect); + break; + } + CGContextRestoreGState(ctx); +} + +/// Draws an ordered item's number ("3.") right-aligned so the dot sits a small +/// pad before the text column, sharing the bullet's baseline math. +- (void)drawOrderedMarkerEndingAtX:(CGFloat)markerRight + baselineY:(CGFloat)baselineY + ordinal:(NSInteger)ordinal + font:(UIFont *)font + color:(UIColor *)color +{ + NSString *label = [NSString stringWithFormat:@"%ld.", (long)MAX(ordinal, (NSInteger)1)]; + NSDictionary *attrs = @{NSFontAttributeName : font, NSForegroundColorAttributeName : color}; + CGSize size = [label sizeWithAttributes:attrs]; + [label drawAtPoint:CGPointMake(markerRight - size.width, baselineY - font.ascender) withAttributes:attrs]; +} + +- (void)drawGlyphsForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin +{ + [super drawGlyphsForGlyphRange:glyphsToShow atPoint:origin]; + + NSTextStorage *storage = self.textStorage; + NSString *string = storage.string; + NSMutableSet *drawnParagraphs = [NSMutableSet set]; + + [self enumerateLineFragmentsForGlyphRange:glyphsToShow + usingBlock:^(CGRect rect, CGRect usedRect, NSTextContainer *container, + NSRange glyphRange, BOOL *stop) { + NSRange charRange = [self characterRangeForGlyphRange:glyphRange + actualGlyphRange:NULL]; + if (charRange.location == NSNotFound) { + return; + } + + NSRange paraRange = (charRange.location < string.length) + ? [string paragraphRangeForRange:charRange] + : NSMakeRange(charRange.location, 0); + // Wrapped continuation fragments share their line's paragraph; only the + // first fragment of a list line draws a marker. + if ([drawnParagraphs containsObject:@(paraRange.location)]) { + return; + } + + BOOL isAttributedListLine = NO; + BOOL isOrdered = NO; + NSInteger depth = 0; + NSInteger ordinal = 1; + if (charRange.location < storage.length) { + NSNumber *type = [storage attribute:ENRMBlockTypeAttributeName + atIndex:charRange.location + effectiveRange:NULL]; + if (type && ENRMBlockTypeIsListItem((ENRMInputBlockType)type.integerValue) && + charRange.location == paraRange.location) { + isAttributedListLine = YES; + isOrdered = type.integerValue == ENRMInputBlockTypeOrderedListItem; + NSNumber *depthValue = [storage attribute:ENRMBlockLevelAttributeName + atIndex:charRange.location + effectiveRange:NULL]; + depth = depthValue ? depthValue.integerValue : 0; + NSNumber *ordinalValue = [storage attribute:ENRMBlockOrdinalAttributeName + atIndex:charRange.location + effectiveRange:NULL]; + ordinal = ordinalValue ? ordinalValue.integerValue : 1; + } + } + + // An empty list line has no character carrying the attribute, so the + // orchestrator points us at it explicitly. + BOOL isEmptyListLine = self.emptyBulletDepth >= 0 && + charRange.location == self.emptyBulletLocation && + !isAttributedListLine; + if (isEmptyListLine) { + depth = self.emptyBulletDepth; + isOrdered = self.emptyBulletOrdered; + ordinal = self.emptyBulletOrdinal; + } + + if (!isAttributedListLine && !isEmptyListLine) { + return; + } + [drawnParagraphs addObject:@(paraRange.location)]; + + UIFont *font = nil; + UIColor *color = nil; + // An empty list line has no character carrying the font/color, so + // use the values the orchestrator supplied for the empty marker. + if (isEmptyListLine) { + font = self.emptyBulletFont; + color = self.emptyBulletColor; + } + if (!font && charRange.location < storage.length) { + font = [storage attribute:NSFontAttributeName + atIndex:charRange.location + effectiveRange:NULL]; + } + if (!color && charRange.location < storage.length) { + color = [storage attribute:NSForegroundColorAttributeName + atIndex:charRange.location + effectiveRange:NULL]; + } + if (!font) { + font = [UIFont systemFontOfSize:16]; + } + if (!color) { + color = [UIColor labelColor]; + } + + // An empty line's only glyph is a newline, whose location sits at the + // line's bottom rather than a text baseline — using it would draw the + // marker too low. Derive the baseline from the font ascender (plus the + // leading paragraph spacing, which pushes the text down within the + // fragment) so an empty list line's bullet lands exactly where the + // first typed glyph's bullet will. + CGFloat baselineOffset = isEmptyListLine + ? self.listItemSpacing + font.ascender + : [self locationForGlyphAtIndex:glyphRange.location].y; + CGFloat baselineY = origin.y + rect.origin.y + baselineOffset; + if (isOrdered) { + CGFloat markerRight = origin.x + usedRect.origin.x - kENRMListBulletGap / 2.0; + [self drawOrderedMarkerEndingAtX:markerRight + baselineY:baselineY + ordinal:ordinal + font:font + color:color]; + } else { + CGFloat markerX = origin.x + usedRect.origin.x - kENRMListBulletGap; + CGFloat centerY = baselineY - (font.xHeight + font.capHeight) / 4.0; + [self drawBulletAtX:markerX centerY:centerY depth:depth font:font color:color]; + } + }]; + + // The trailing empty line (including a wholly empty editor) has no glyph + // fragment — the enumeration above skips it — so draw its marker via the extra + // line fragment when the orchestrator has flagged it as an empty list line. + if (self.emptyBulletDepth >= 0 && self.emptyBulletLocation >= storage.length && + self.extraLineFragmentTextContainer != nil) { + UIFont *font = self.emptyBulletFont ?: [UIFont systemFontOfSize:16]; + UIColor *color = self.emptyBulletColor ?: [UIColor labelColor]; + CGRect used = self.extraLineFragmentUsedRect; + CGFloat markerX = origin.x + used.origin.x - kENRMListBulletGap; + CGFloat markerRight = origin.x + used.origin.x - kENRMListBulletGap / 2.0; + // Use the same optical center as the in-text marker (baseline minus half the + // cap/x-height), not the geometric line-box center (font.lineHeight / 2): the + // two diverge by a font-dependent amount, so a geometric center would shift + // the empty-line bullet relative to where the first typed glyph's bullet lands + // (visible with fonts whose ascender/descender are asymmetric). + CGFloat baselineY = origin.y + used.origin.y + font.ascender; + if (self.emptyBulletOrdered) { + [self drawOrderedMarkerEndingAtX:markerRight + baselineY:baselineY + ordinal:self.emptyBulletOrdinal + font:font + color:color]; + } else { + CGFloat centerY = baselineY - (font.xHeight + font.capHeight) / 4.0; + [self drawBulletAtX:markerX centerY:centerY depth:self.emptyBulletDepth font:font color:color]; + } + } +} + +- (void)drawEmptyEditorBulletWithInset:(UIEdgeInsets)inset +{ + if (self.emptyBulletDepth < 0) { + return; + } + UIFont *font = self.emptyBulletFont ?: [UIFont systemFontOfSize:16]; + UIColor *color = self.emptyBulletColor ?: [UIColor labelColor]; + // Text for a depth-d item starts at (d + 1) * kENRMListIndentPerDepth from the + // leading edge (see the list block handler); the marker sits a gap before that. + CGFloat headIndent = (self.emptyBulletDepth + 1) * kENRMListIndentPerDepth; + CGFloat markerX = inset.left + headIndent - kENRMListBulletGap; + // Optical center (baseline minus half the cap/x-height) to match the in-text + // marker, rather than the geometric line-box center; see the extra-line-fragment + // path. TextKit doesn't apply paragraphSpacingBefore to the first paragraph, so + // no spacing offset here. + CGFloat baselineY = inset.top + font.ascender; + if (self.emptyBulletOrdered) { + [self drawOrderedMarkerEndingAtX:(markerX + kENRMListBulletGap / 2.0) + baselineY:baselineY + ordinal:self.emptyBulletOrdinal + font:font + color:color]; + return; + } + CGFloat centerY = baselineY - (font.xHeight + font.capHeight) / 4.0; + [self drawBulletAtX:markerX centerY:centerY depth:self.emptyBulletDepth font:font color:color]; +} + @end diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputParser.mm b/packages/react-native-enriched-markdown/ios/input/ENRMInputParser.mm index f7dc03c3..47fa7095 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputParser.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputParser.mm @@ -105,6 +105,10 @@ static NSInteger resolveBlockLevel(MD_BLOCKTYPE blockType, void *detail) std::vector openBlockStack; std::vector resolvedBlocks; size_t lastTextEnd = 0; + // Open list containers, innermost last (true = ordered). An item's depth is + // the stack size - 1 and its type the innermost container's; md4c carries no + // per-item depth, so both derive from the open UL/OL ancestors. + std::vector listContainerStack; }; static std::vector buildByteToUTF16Map(const char *utf8, size_t byteLength) @@ -171,12 +175,32 @@ static size_t closingDelimiterEndByte(const InlineSpanInfo &span, const char *ut // discarded later when building results. static int onEnterBlock(MD_BLOCKTYPE blockType, void *detail, void *userdata) { + auto *context = static_cast(userdata); + + // List nesting is tracked by the container stack, not stored as its own + // block: the UL/OL container only frames the items that read it. + if (blockType == MD_BLOCK_UL || blockType == MD_BLOCK_OL) { + context->listContainerStack.push_back(blockType == MD_BLOCK_OL); + return 0; + } + + // Tag the item itself, not its inner paragraph: md4c emits MD_BLOCK_P inside + // items only for *loose* lists, so a tight list has no paragraph to tag. The + // item's range is clipped back to its own first line when building results. + if (blockType == MD_BLOCK_LI) { + BlockInfo blockInfo; + BOOL ordered = !context->listContainerStack.empty() && context->listContainerStack.back(); + blockInfo.type = ordered ? ENRMInputBlockTypeOrderedListItem : ENRMInputBlockTypeUnorderedListItem; + blockInfo.level = context->listContainerStack.empty() ? 0 : (NSInteger)context->listContainerStack.size() - 1; + context->openBlockStack.push_back(blockInfo); + return 0; + } + ENRMInputBlockType mappedType; if (!isSupportedBlock(blockType, mappedType)) { return 0; } - auto *context = static_cast(userdata); BlockInfo blockInfo; blockInfo.level = resolveBlockLevel(blockType, detail); // Headings share one md4c block type but split into six ENRMInputBlockTypes by @@ -188,12 +212,30 @@ static int onEnterBlock(MD_BLOCKTYPE blockType, void *detail, void *userdata) static int onLeaveBlock(MD_BLOCKTYPE blockType, void *, void *userdata) { + auto *context = static_cast(userdata); + + if (blockType == MD_BLOCK_UL || blockType == MD_BLOCK_OL) { + if (!context->listContainerStack.empty()) { + context->listContainerStack.pop_back(); + } + return 0; + } + + // List items are tagged in onEnterBlock (not via isSupportedBlock); resolve + // them here the same way as supported blocks. + if (blockType == MD_BLOCK_LI) { + if (!context->openBlockStack.empty()) { + context->resolvedBlocks.push_back(context->openBlockStack.back()); + context->openBlockStack.pop_back(); + } + return 0; + } + ENRMInputBlockType mappedType; if (!isSupportedBlock(blockType, mappedType)) { return 0; } - auto *context = static_cast(userdata); if (context->openBlockStack.empty()) { return 0; } @@ -363,10 +405,9 @@ static bool runMd4cParse(NSString *markdown, ParseContext &context) // Builds block-level ranges (raw-markdown UTF-16 coords) from the same // completed parse, mirroring styledRangesFromContext for inline spans. // Paragraph blocks (the implicit default) are omitted — only blocks a handler -// claims are returned. In PR1 only MD_BLOCK_P is mapped, so this returns @[]; -// a heading handler's block type lights it up. +// claims are returned. static NSArray *blockRangesFromContext(const ParseContext &context, - const std::vector &byteMap) + const std::vector &byteMap, NSString *markdown) { NSMutableArray *results = [NSMutableArray arrayWithCapacity:context.resolvedBlocks.size()]; @@ -385,6 +426,22 @@ static bool runMd4cParse(NSString *markdown, ParseContext &context) continue; } + // A list item accumulates all text in its subtree, so a parent item's range + // runs through its nested sublist. Clip each item to its own first line so + // nested items keep their own (deeper) depth rather than being overwritten + // by the parent's range. Input list items are single-line. + if (ENRMBlockTypeIsListItem(blockInfo.type) && contentEnd <= markdown.length) { + NSRange newline = [markdown rangeOfString:@"\n" + options:0 + range:NSMakeRange(contentStart, contentEnd - contentStart)]; + if (newline.location != NSNotFound) { + contentEnd = newline.location; + } + if (contentEnd <= contentStart) { + continue; + } + } + [results addObject:[ENRMBlockRange rangeWithType:blockInfo.type range:NSMakeRange(contentStart, contentEnd - contentStart) level:blockInfo.level]]; @@ -431,7 +488,7 @@ - (ENRMParseResult *)parseToPlainTextAndRanges:(NSString *)markdown if (runMd4cParse(markdown, context)) { auto byteMap = buildByteToUTF16Map(context.buffer, context.bufferLength); styledRanges = styledRangesFromContext(context, byteMap); - rawBlockRanges = blockRangesFromContext(context, byteMap); + rawBlockRanges = blockRangesFromContext(context, byteMap, markdown); } NSUInteger rawLength = markdown.length; diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMMarkdownSerializer.mm b/packages/react-native-enriched-markdown/ios/input/ENRMMarkdownSerializer.mm index 3d40530e..3eed9c8a 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMMarkdownSerializer.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMMarkdownSerializer.mm @@ -254,7 +254,7 @@ + (NSString *)serializePlainText:(NSString *)text NSUInteger runningOffset = 0; for (NSString *line in plainLines) { [lineStartOffsets addObject:@(runningOffset)]; - runningOffset += line.length + 1; + runningOffset += line.length + 1; // +1 for the '\n' separator } for (ENRMBlockRange *blockRange in blockRanges) { @@ -266,12 +266,19 @@ + (NSString *)serializePlainText:(NSString *)text NSUInteger blockStart = blockRange.range.location; NSUInteger blockEnd = NSMaxRange(blockRange.range); BOOL isZeroLength = blockRange.range.length == 0; + BOOL isListItem = ENRMBlockTypeIsListItem(blockRange.type); for (NSUInteger lineIndex = 0; lineIndex < plainLines.count; lineIndex++) { NSUInteger lineStart = lineStartOffsets[lineIndex].unsignedIntegerValue; NSUInteger lineEnd = lineStart + plainLines[lineIndex].length; BOOL overlaps = isZeroLength ? (lineStart == blockStart) : (lineEnd >= blockStart && lineStart < blockEnd); if (overlaps) { + // A marker-only list line ("- " with no content) re-parses as a setext + // underline for the previous line; emit an empty list line bare. An + // empty "# " heading is valid ATX and keeps its prefix. + if (isListItem && plainLines[lineIndex].length == 0) { + continue; + } markdownLines[lineIndex] = [prefix stringByAppendingString:markdownLines[lineIndex]]; } } diff --git a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm index 14eb8fe0..0ce744c0 100644 --- a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm +++ b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm @@ -608,7 +608,7 @@ - (void)replaceTextInRange:(NSRange)selection NSString *plainText = ENRMGetPlainText(_textView); [_formattingStore adjustForEditAtLocation:editLocation deletedLength:selection.length insertedLength:text.length]; [_blockStore adjustForEditAtLocation:editLocation deletedLength:selection.length insertedLength:text.length]; - [self pruneOrphanedHeadingBlocks]; + [self pruneOrphanedBlockAnchors]; [_blockStore normalizeToLineBoundsInText:plainText]; for (ENRMFormattingRange *range in ranges) { @@ -953,15 +953,15 @@ - (void)syncTypingAttributesWithCursorBlock _textView.typingAttributes = attrs; } -/// Reverts to a plain paragraph any heading no longer anchored at a line start -/// (e.g. Backspace merged its line into the previous one). Must run BEFORE +/// Reverts to a plain paragraph any anchored block no longer anchored at a line +/// start (e.g. Backspace merged its line into the previous one). Must run BEFORE /// normalizeToLineBoundsInText: so a merged range is judged on its unsnapped /// anchor and can't grow over the line it merged into. Mirrors Android. -- (void)pruneOrphanedHeadingBlocks +- (void)pruneOrphanedBlockAnchors { NSString *text = _textView.textStorage.string; for (ENRMBlockRange *block in _blockStore.allRanges) { - if (ENRMHeadingLevelForBlockType(block.type) == 0) { + if (!ENRMBlockTypePersistsWhenEmpty(block.type)) { continue; } NSUInteger anchor = MIN(block.range.location, text.length); @@ -1670,7 +1670,7 @@ - (void)handleTextChanged [_formattingStore adjustForEditAtLocation:editLocation deletedLength:deletedLength insertedLength:insertedLength]; [_blockStore adjustForEditAtLocation:editLocation deletedLength:deletedLength insertedLength:insertedLength]; - [self pruneOrphanedHeadingBlocks]; + [self pruneOrphanedBlockAnchors]; [_blockStore normalizeToLineBoundsInText:ENRMGetPlainText(_textView)]; if (insertedLength > 0) { diff --git a/packages/react-native-enriched-markdown/ios/input/InputStylePropsUtils.h b/packages/react-native-enriched-markdown/ios/input/InputStylePropsUtils.h index 0dba41d9..c6170996 100644 --- a/packages/react-native-enriched-markdown/ios/input/InputStylePropsUtils.h +++ b/packages/react-native-enriched-markdown/ios/input/InputStylePropsUtils.h @@ -169,6 +169,11 @@ BOOL applyInputStyleProps(ENRMInputFormatterStyle *style, const InputProps &newP changed = YES; } + if (newProps.listItemSpacing != oldProps.listItemSpacing) { + style.listItemSpacing = newProps.listItemSpacing; + changed = YES; + } + changed |= applyHeadingLevelProps(style, 1, newProps.markdownStyle.h1, oldProps.markdownStyle.h1); changed |= applyHeadingLevelProps(style, 2, newProps.markdownStyle.h2, oldProps.markdownStyle.h2); changed |= applyHeadingLevelProps(style, 3, newProps.markdownStyle.h3, oldProps.markdownStyle.h3); diff --git a/packages/react-native-enriched-markdown/ios/input/styles/ENRMOrderedListBlockHandler.h b/packages/react-native-enriched-markdown/ios/input/styles/ENRMOrderedListBlockHandler.h new file mode 100644 index 00000000..daf7d2ea --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/input/styles/ENRMOrderedListBlockHandler.h @@ -0,0 +1,14 @@ +#pragma once + +#import "ENRMBlockHandler.h" + +NS_ASSUME_NONNULL_BEGIN + +/// Block handler for ordered list items. Reserves the same head-indent column as +/// the bullet handler (the layout manager draws the number into it) and +/// serializes to an `N. ` line prefix indented three spaces per nesting level. +/// Depth rides in ENRMBlockRange.level, the number in ENRMBlockRange.ordinal. +@interface ENRMOrderedListBlockHandler : NSObject +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/input/styles/ENRMOrderedListBlockHandler.mm b/packages/react-native-enriched-markdown/ios/input/styles/ENRMOrderedListBlockHandler.mm new file mode 100644 index 00000000..3d2beefd --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/input/styles/ENRMOrderedListBlockHandler.mm @@ -0,0 +1,43 @@ +#import "ENRMOrderedListBlockHandler.h" +#import "ENRMInputBlockType.h" + +@implementation ENRMOrderedListBlockHandler + +- (ENRMInputBlockType)blockType +{ + return ENRMInputBlockTypeOrderedListItem; +} + +- (void)applyAttributesToParagraphStyle:(NSMutableParagraphStyle *)paragraphStyle + attributes:(NSMutableDictionary *)attributes + blockRange:(ENRMBlockRange *)blockRange + style:(ENRMInputFormatterStyle *)style +{ + NSInteger depth = blockRange.level; + if (depth < 0) { + depth = 0; + } else if (depth > kENRMMaxListDepth) { + depth = kENRMMaxListDepth; + } + + // Same indent column as the bullet handler so mixed lists align; wrapped + // lines hang under the text, not under the number. + CGFloat indent = (depth + 1) * kENRMListIndentPerDepth; + paragraphStyle.firstLineHeadIndent = indent; + paragraphStyle.headIndent = indent; + paragraphStyle.paragraphSpacingBefore = style.listItemSpacing; +} + +- (NSString *)markdownLinePrefixForBlockRange:(ENRMBlockRange *)blockRange +{ + NSInteger depth = blockRange.level; + if (depth < 0) { + depth = 0; + } else if (depth > kENRMMaxListDepth) { + depth = kENRMMaxListDepth; + } + NSString *indent = [@"" stringByPaddingToLength:depth * 3 withString:@" " startingAtIndex:0]; + return [indent stringByAppendingFormat:@"%ld. ", (long)blockRange.ordinal]; +} + +@end diff --git a/packages/react-native-enriched-markdown/ios/input/styles/ENRMUnorderedListBlockHandler.h b/packages/react-native-enriched-markdown/ios/input/styles/ENRMUnorderedListBlockHandler.h new file mode 100644 index 00000000..7f184b4e --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/input/styles/ENRMUnorderedListBlockHandler.h @@ -0,0 +1,15 @@ +#pragma once + +#import "ENRMBlockHandler.h" + +NS_ASSUME_NONNULL_BEGIN + +/// Block handler for unordered (bullet) list items. Reserves the head-indent +/// column for the marker via the paragraph style (the layout manager draws the +/// glyph into it) and serializes to a `- ` line prefix indented two spaces per +/// nesting level. Nesting depth is carried in ENRMBlockRange.level. Continues on +/// Return (a new item at the same depth) unlike single-line heading blocks. +@interface ENRMUnorderedListBlockHandler : NSObject +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/input/styles/ENRMUnorderedListBlockHandler.mm b/packages/react-native-enriched-markdown/ios/input/styles/ENRMUnorderedListBlockHandler.mm new file mode 100644 index 00000000..50a6a77e --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/input/styles/ENRMUnorderedListBlockHandler.mm @@ -0,0 +1,46 @@ +#import "ENRMUnorderedListBlockHandler.h" +#import "ENRMInputBlockType.h" + +@implementation ENRMUnorderedListBlockHandler + +- (ENRMInputBlockType)blockType +{ + return ENRMInputBlockTypeUnorderedListItem; +} + +- (void)applyAttributesToParagraphStyle:(NSMutableParagraphStyle *)paragraphStyle + attributes:(NSMutableDictionary *)attributes + blockRange:(ENRMBlockRange *)blockRange + style:(ENRMInputFormatterStyle *)style +{ + NSInteger depth = blockRange.level; + if (depth < 0) { + depth = 0; + } else if (depth > kENRMMaxListDepth) { + depth = kENRMMaxListDepth; + } + + // Reserve a marker column per nesting level. Both the first (marker) line and + // wrapped continuation lines align to the same text inset so wrapped text + // hangs under the text, not under the bullet. + CGFloat indent = (depth + 1) * kENRMListIndentPerDepth; + paragraphStyle.firstLineHeadIndent = indent; + paragraphStyle.headIndent = indent; + paragraphStyle.paragraphSpacingBefore = style.listItemSpacing; +} + +- (NSString *)markdownLinePrefixForBlockRange:(ENRMBlockRange *)blockRange +{ + NSInteger depth = blockRange.level; + if (depth < 0) { + depth = 0; + } else if (depth > kENRMMaxListDepth) { + depth = kENRMMaxListDepth; + } + // Three spaces of indent per nesting level (wide enough that ordered markers + // nest under bullets and vice versa), then the bullet marker. + NSString *indent = [@"" stringByPaddingToLength:depth * 3 withString:@" " startingAtIndex:0]; + return [indent stringByAppendingString:@"- "]; +} + +@end diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx index de09ff68..e70df5ab 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx @@ -242,6 +242,12 @@ export interface EnrichedMarkdownTextInputProps extends Omit< * @platform ios */ writingDirection?: 'auto' | 'ltr' | 'rtl' | 'first-strong'; + /** + * Vertical spacing (points) above each bullet list item so items read as + * separate rows. iOS uses `paragraphSpacingBefore`; Android a `LineHeightSpan`. + * @default 0 + */ + listItemSpacing?: number; } type PendingRequest = { @@ -289,6 +295,7 @@ export const EnrichedMarkdownTextInput = ({ formatMenuConfig, linkRegex: _linkRegex, writingDirection = 'first-strong', + listItemSpacing = 0, ...rest }: EnrichedMarkdownTextInputProps) => { const nativeRef = useRef(null); @@ -590,6 +597,7 @@ export const EnrichedMarkdownTextInput = ({ multiline={multiline} cursorColor={cursorColor} selectionColor={selectionColor} + listItemSpacing={listItemSpacing} isOnChangeMarkdownSet={onChangeMarkdown !== undefined} onChangeText={handleChangeText as NativeProps['onChangeText']} onChangeMarkdown={handleChangeMarkdown as NativeProps['onChangeMarkdown']} diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInputNativeComponent.ts b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInputNativeComponent.ts index 38dbbaed..0db07650 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInputNativeComponent.ts +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInputNativeComponent.ts @@ -272,6 +272,14 @@ export interface NativeProps extends ViewProps { */ writingDirection?: CodegenTypes.WithDefault; + /** + * Vertical spacing (points) added above each bullet list item so items read as + * separate rows. iOS applies it via paragraphSpacingBefore; Android via a + * LineHeightSpan. + * @default 0 + */ + listItemSpacing?: CodegenTypes.WithDefault; + // Events onChangeText?: CodegenTypes.DirectEventHandler; onChangeMarkdown?: CodegenTypes.DirectEventHandler;