diff --git a/.maestro/enrichedMarkdownInput/flows/basic/block_elements/list_toggle_test.yaml b/.maestro/enrichedMarkdownInput/flows/basic/block_elements/list_toggle_test.yaml new file mode 100644 index 000000000..d9474bed6 --- /dev/null +++ b/.maestro/enrichedMarkdownInput/flows/basic/block_elements/list_toggle_test.yaml @@ -0,0 +1,61 @@ +appId: swmansion.enriched.markdown.example +tags: + - input + - smoke + - unordered-list + - ordered-list + - block +--- +- launchApp + +- runFlow: + file: '../../../../subflows/move_to_playground.yaml' + +# Toggling one list type on an item carrying the other must replace the marker +# while keeping the item's nesting depth: build a two-item bullet list with the +# second item indented, then toggle the indented item to an ordered list. +- tapOn: + id: focus-button +- waitForAnimationToEnd + +# The list buttons sit past H1-H6 in the horizontally scrolling toolbar and are +# offscreen on phone-width viewports; swipe the toolbar left to reveal them. +- swipe: + from: + id: toolbar-bold + direction: LEFT +- swipe: + from: + id: toolbar-h2 + direction: LEFT +- waitForAnimationToEnd + +- tapOn: + id: toolbar-unordered-list +- waitForAnimationToEnd + +- inputText: 'Bullet item' +- waitForAnimationToEnd + +- pressKey: Enter +- waitForAnimationToEnd + +- inputText: 'Nested item' +- waitForAnimationToEnd + +- tapOn: + id: toolbar-indent +- waitForAnimationToEnd + +# Switch the nested bullet to a numbered item — the depth must be preserved. +- tapOn: + id: toolbar-ordered-list +- waitForAnimationToEnd + +- tapOn: + id: blur-button + +- runFlow: + file: '../../../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'list_toggle_input' diff --git a/.maestro/enrichedMarkdownInput/flows/basic/block_elements/ordered_list_test.yaml b/.maestro/enrichedMarkdownInput/flows/basic/block_elements/ordered_list_test.yaml new file mode 100644 index 000000000..75d1a9627 --- /dev/null +++ b/.maestro/enrichedMarkdownInput/flows/basic/block_elements/ordered_list_test.yaml @@ -0,0 +1,69 @@ +appId: swmansion.enriched.markdown.example +tags: + - input + - smoke + - ordered-list + - block +--- +- launchApp + +- runFlow: + file: '../../../../subflows/move_to_playground.yaml' + +# Start a numbered list and type three items separated by Return. Return on a +# non-empty item must continue the list (next ordinal at the same depth). The +# second item is indented (its ordinal restarts at 1 on the deeper level, and +# the third item must renumber to 2 at depth 0) and then outdented back, which +# must restore 1./2./3. numbering. +- tapOn: + id: focus-button +- waitForAnimationToEnd + +# The list buttons sit past H1-H6 in the horizontally scrolling toolbar and are +# offscreen on phone-width viewports; swipe the toolbar left to reveal them. +- swipe: + from: + id: toolbar-bold + direction: LEFT +- swipe: + from: + id: toolbar-h2 + direction: LEFT +- waitForAnimationToEnd + +- tapOn: + id: toolbar-ordered-list +- waitForAnimationToEnd + +- inputText: 'First item' +- waitForAnimationToEnd + +- pressKey: Enter +- waitForAnimationToEnd + +- inputText: 'Second item' +- waitForAnimationToEnd + +- pressKey: Enter +- waitForAnimationToEnd + +- inputText: 'Third item' +- waitForAnimationToEnd + +# Indent the third item one level (ordinal restarts at 1 on the deeper level), +# then outdent it back so the run renumbers to 1./2./3. +- tapOn: + id: toolbar-indent +- waitForAnimationToEnd + +- tapOn: + id: toolbar-outdent +- waitForAnimationToEnd + +- tapOn: + id: blur-button + +- runFlow: + file: '../../../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'ordered_list_input' diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 9787fee7f..11bc160b8 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -569,7 +569,7 @@ Style configuration for formatted text in the input. ### `listItemSpacing` -Vertical spacing (points) added above each bullet list item so items read as separate rows. iOS applies it via `paragraphSpacingBefore`; Android via a `LineHeightSpan`. +Vertical spacing (points) added above each list item (bullet and numbered alike) so items read as separate rows. iOS applies it via `paragraphSpacingBefore`; Android via a `LineHeightSpan`. | Type | Default Value | Platform | | -------- | ------------- | -------- | @@ -934,11 +934,11 @@ Toggles a heading of the given level (`1`–`6`) on the cursor's paragraph. Call ### `toggleUnorderedList()` -Turns the cursor's paragraph(s) into bullet list items, or back into regular paragraphs if they already are. Operates on the whole paragraph. +Turns the cursor's paragraph(s) into bullet list items, or back into regular paragraphs if they already are. Operates on the whole paragraph. List items are single-line — each item is exactly one paragraph, and Markdown imported with multi-paragraph (loose) items keeps only each item's first line as a list item. ### `toggleOrderedList()` -Turns the cursor's paragraph(s) into numbered list items, or back into regular paragraphs if they already are. Numbering derives from an item's position among its adjacent same-depth siblings. Toggling one list type on a line carrying the other replaces it. +Turns the cursor's paragraph(s) into numbered list items, or back into regular paragraphs if they already are. Numbering derives from an item's position among its adjacent same-depth siblings. Toggling one list type on a line carrying the other replaces it, keeping the item's nesting depth. ### `indentList()` diff --git a/docs/INPUT.md b/docs/INPUT.md index 3702e7242..374ca0505 100644 --- a/docs/INPUT.md +++ b/docs/INPUT.md @@ -85,7 +85,7 @@ Behavior notes: Heading font sizes, weights, and colors are configurable per level — see [Customizing Styles](#customizing-enrichedmarkdowntextinput--styles). -## Bullet Lists +## Lists `EnrichedMarkdownTextInput` supports nested unordered (bullet) and ordered (numbered) lists, serialized to Markdown as `- ` / `1. ` with three spaces of indentation per nesting level. Ordered items are numbered by their position among adjacent same-depth siblings, so numbering stays correct as items are added, removed, or reordered. Like headings, list items are block-level — they apply to whole paragraphs. Manage them through the component ref: @@ -102,10 +102,11 @@ Behavior notes: - **Backspace at the start of an item** outdents it; at depth 0 it removes the bullet. - An emptied list line stays a list item (its bullet keeps showing) until you toggle it off or Backspace out of it, so text you type next continues in the list. - The bullet marker exists only in the serialized Markdown and the rendered glyph — it is never part of the editor's text, so it won't collide with `-`-prefixed text such as mentions. +- **List items are single-line.** Each item is exactly one paragraph. Importing or pasting Markdown with *loose* list items (an item containing multiple paragraphs) keeps only the item's first line as a list item — continuation paragraphs become regular paragraphs, not list-styled blocks. Whether the cursor's paragraph is a list item, and its nesting depth, are reported through the [`onChangeState`](API_REFERENCE.md#onchangestate) payload as `unorderedList` / `orderedList` (`{ isActive, depth }`), so you can highlight the list buttons in your toolbar. -Use the [`listItemSpacing`](API_REFERENCE.md#listitemspacing) prop to add vertical spacing between items so they read as separate rows. +Use the [`listItemSpacing`](API_REFERENCE.md#listitemspacing) prop to add vertical spacing between list items (bullet and numbered alike) so they read as separate rows. ## Links 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 057de226c..0124ae9a8 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 @@ -683,8 +683,9 @@ class EnrichedMarkdownTextInputView( /** * Toggles a list of [type] on the cursor's paragraph(s): turns the touched lines - * into depth-0 items (replacing an item of the other list type), or clears them - * back to plain paragraphs when the cursor's line already carries [type]. + * into depth-0 items (an item of the other list type is replaced keeping its + * depth), or clears them back to plain paragraphs when the cursor's line already + * carries [type]. */ private fun toggleListType(type: BlockType) { val editable = text ?: return @@ -692,7 +693,11 @@ class EnrichedMarkdownTextInputView( if (turningOff) { forEachSelectedLine { ls, le -> blockStore.removeBlock(ls, le, editable) } } else { - setListBlockOnLines(type, 0) + forEachSelectedLine { ls, le -> + // Switching unordered <-> ordered keeps each line's nesting depth. + val existing = blockStore.allRanges.firstOrNull { it.start == ls && it.type in BlockType.LIST_ITEMS } + blockStore.setBlock(type, existing?.level ?: 0, ls, le, editable) + } } blockStore.normalizeToLineBounds(editable) applyFormattingAndEmit() @@ -729,17 +734,6 @@ class EnrichedMarkdownTextInputView( syncEmptyListAnchor() } - /** Sets a [type] list block at [depth] on every line the selection touches. */ - private fun setListBlockOnLines( - type: BlockType, - depth: Int, - ) { - val editable = text ?: return - forEachSelectedLine { ls, le -> - blockStore.setBlock(type, depth, ls, le, editable) - } - } - /** Runs [action] with the `[lineStart, lineEnd)` content bounds of each line the selection touches. */ private inline fun forEachSelectedLine(action: (lineStart: Int, lineEnd: Int) -> Unit) { val editable = text ?: return diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputEventEmitter.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputEventEmitter.kt index 14482b362..36226decf 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputEventEmitter.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputEventEmitter.kt @@ -23,6 +23,9 @@ import com.swmansion.enriched.markdown.input.model.BlockType import com.swmansion.enriched.markdown.input.model.CaretRect import com.swmansion.enriched.markdown.input.model.StyleType +/** Empty-list-line anchor character; see EnrichedMarkdownTextInputView's ZWSP. */ +private const val ZWSP = "\u200B" + class InputEventEmitter( private val view: EnrichedMarkdownTextInputView, ) { @@ -33,7 +36,9 @@ class InputEventEmitter( private var prevCaretRect: CaretRect? = null fun emitChangeText() { - val plainText = view.text?.toString() ?: "" + // The empty-list-line ZWSP anchor is an internal editing detail — never leak + // it to JS (the markdown serializer already strips it from that payload). + val plainText = (view.text?.toString() ?: "").replace(ZWSP, "") dispatch(OnChangeTextEvent(surfaceId(), view.id, plainText)) } 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 index f40399403..7d1ef9a8f 100644 --- 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 @@ -44,11 +44,13 @@ class InputOrderedListMarkerSpan( // line where this span actually begins. if (text is Spanned && text.getSpanStart(this) != start) return - val label = "$ordinal." + // RTL (dir < 0) mirrors the marker to the trailing side and flips the label + // to ".3", matching the readonly renderer's OrderedListSpan. + val label = if (dir < 0) ".$ordinal" else "$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 = if (dir < 0) Paint.Align.LEFT else Paint.Align.RIGHT + val markerEdge = x + (depth * indentPerDepth + markerWidth - markerGap) * dir + canvas.drawText(label, markerEdge, baseline.toFloat(), paint) paint.textAlign = originalAlign } diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.mm b/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.mm index 3e6b022b9..c1998e62d 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.mm @@ -222,9 +222,14 @@ - (void)recomputeListMetadata memset(counters, 0, sizeof(counters)); memset(counterTypes, 0, sizeof(counterTypes)); } - NSInteger maxDepth = adjacent ? prevDepth + 1 : 0; + // Coerce into [0, kENRMMaxListDepth] before ancestry-clamping: parser and + // indent commands cap their depths, but this pass indexes the counter + // arrays by depth, so an out-of-bounds level must never survive it. + NSInteger maxDepth = MIN(adjacent ? prevDepth + 1 : 0, kENRMMaxListDepth); if (blockRange.level > maxDepth) { blockRange.level = maxDepth; + } else if (blockRange.level < 0) { + blockRange.level = 0; } NSInteger depth = blockRange.level; for (NSInteger i = depth + 1; i <= kENRMMaxListDepth + 1; i++) { diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.h b/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.h index 392cf823a..7588779f1 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.h +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.h @@ -28,6 +28,10 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, strong, nullable) UIFont *emptyBulletFont; @property (nonatomic, strong, nullable) UIColor *emptyBulletColor; +/// Whether the empty list line lays out right-to-left (no character to resolve +/// the paragraph direction from), so its marker mirrors to the trailing edge. +@property (nonatomic, assign) BOOL emptyBulletRTL; + /// 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; diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.mm b/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.mm index 915f5cd7a..38eab48a9 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputLayoutManager.mm @@ -1,5 +1,6 @@ #import "ENRMInputLayoutManager.h" #import "ENRMInputBlockType.h" +#import "ParagraphStyleUtils.h" #import @implementation ENRMInputLayoutManager @@ -62,6 +63,28 @@ - (void)drawOrderedMarkerEndingAtX:(CGFloat)markerRight [label drawAtPoint:CGPointMake(markerRight - size.width, baselineY - font.ascender) withAttributes:attrs]; } +/// RTL counterpart of drawOrderedMarkerEndingAtX:: draws ".3" left-aligned from +/// `markerLeft` so the dot sits a small pad after the (right-anchored) text +/// column. Mirrors the readonly renderer's ListMarkerDrawer. +- (void)drawOrderedMarkerStartingAtX:(CGFloat)markerLeft + 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}; + [label drawAtPoint:CGPointMake(markerLeft, baselineY - font.ascender) withAttributes:attrs]; +} + +/// Marker anchor for an RTL list line: the text column is anchored a head +/// indent from the trailing (right) edge, so the marker mirrors past it. +/// `leadingOffset` is the same padding+indent that positions the LTR column. +static CGFloat ENRMTrailingMarkerX(CGPoint origin, NSTextContainer *container, CGFloat leadingOffset) +{ + return origin.x + container.size.width - leadingOffset; +} + - (void)drawGlyphsForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin { [super drawGlyphsForGlyphRange:glyphsToShow atPoint:origin]; @@ -162,15 +185,42 @@ - (void)drawGlyphsForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin ? self.listItemSpacing + font.ascender : [self locationForGlyphAtIndex:glyphRange.location].y; CGFloat baselineY = origin.y + rect.origin.y + baselineOffset; + + // An RTL paragraph anchors its text column to the trailing edge, so + // the marker mirrors to the right of the column (and an ordered + // marker flips to ".N"), matching the readonly ListMarkerDrawer. + BOOL isRTL; + if (isEmptyListLine) { + isRTL = self.emptyBulletRTL; + } else { + isRTL = ENRMParagraphIsRTL([storage attribute:NSParagraphStyleAttributeName + atIndex:charRange.location + effectiveRange:NULL]); + } + CGFloat leadingOffset = + container.lineFragmentPadding + (depth + 1) * kENRMListIndentPerDepth; + if (isOrdered) { - CGFloat markerRight = origin.x + usedRect.origin.x - kENRMListBulletGap / 2.0; - [self drawOrderedMarkerEndingAtX:markerRight - baselineY:baselineY - ordinal:ordinal - font:font - color:color]; + if (isRTL) { + CGFloat markerLeft = ENRMTrailingMarkerX(origin, container, leadingOffset) + + kENRMListBulletGap / 2.0; + [self drawOrderedMarkerStartingAtX:markerLeft + baselineY:baselineY + ordinal:ordinal + font:font + color:color]; + } else { + 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 markerX = isRTL ? ENRMTrailingMarkerX(origin, container, leadingOffset) + + kENRMListBulletGap + : 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]; } @@ -184,8 +234,11 @@ - (void)drawGlyphsForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin 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; + NSTextContainer *extraContainer = self.extraLineFragmentTextContainer; + BOOL isRTL = self.emptyBulletRTL; + CGFloat leadingOffset = extraContainer.lineFragmentPadding + (self.emptyBulletDepth + 1) * kENRMListIndentPerDepth; + CGFloat trailingX = ENRMTrailingMarkerX(origin, extraContainer, leadingOffset); + CGFloat markerX = isRTL ? trailingX + kENRMListBulletGap : origin.x + used.origin.x - kENRMListBulletGap; // 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 @@ -193,11 +246,19 @@ - (void)drawGlyphsForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin // (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]; + if (isRTL) { + [self drawOrderedMarkerStartingAtX:trailingX + kENRMListBulletGap / 2.0 + baselineY:baselineY + ordinal:self.emptyBulletOrdinal + font:font + color:color]; + } else { + [self drawOrderedMarkerEndingAtX:origin.x + used.origin.x - kENRMListBulletGap / 2.0 + 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]; @@ -214,19 +275,31 @@ - (void)drawEmptyEditorBulletWithInset:(UIEdgeInsets)inset 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. + // In RTL the leading edge is the container's right side, so mirror the anchor. CGFloat headIndent = (self.emptyBulletDepth + 1) * kENRMListIndentPerDepth; - CGFloat markerX = inset.left + headIndent - kENRMListBulletGap; + NSTextContainer *container = self.textContainers.firstObject; + BOOL isRTL = self.emptyBulletRTL && container != nil; + CGFloat trailingX = inset.left + (container ? container.size.width : 0) - headIndent; + CGFloat markerX = isRTL ? trailingX + kENRMListBulletGap : 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]; + if (isRTL) { + [self drawOrderedMarkerStartingAtX:(trailingX + kENRMListBulletGap / 2.0) + baselineY:baselineY + ordinal:self.emptyBulletOrdinal + font:font + color:color]; + } else { + [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; diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputParser.mm b/packages/react-native-enriched-markdown/ios/input/ENRMInputParser.mm index 07be0ca0b..d47748093 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputParser.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputParser.mm @@ -193,7 +193,12 @@ static int onEnterBlock(MD_BLOCKTYPE blockType, void *detail, void *userdata) 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; + // Clamp to the shared max depth (matches Android's parser): markdown can + // nest deeper than the editor represents, and downstream metadata passes + // size their counters by kENRMMaxListDepth. + blockInfo.level = context->listContainerStack.empty() + ? 0 + : MIN((NSInteger)context->listContainerStack.size() - 1, kENRMMaxListDepth); context->openBlockStack.push_back(blockInfo); return 0; } diff --git a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm index 90df4fa75..6f49b06f5 100644 --- a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm +++ b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm @@ -72,7 +72,6 @@ @implementation EnrichedMarkdownTextInput { ENRMPlaceholderLabel *_placeholderLabel; NSUInteger _lastTextLength; - NSUInteger _lastLineCount; NSRange _lastSelectedRange; NSRange _preEditSelectedRange; @@ -91,6 +90,10 @@ @implementation EnrichedMarkdownTextInput { ENRMInputBlockType _preEditBlockType; NSInteger _preEditBlockLevel; BOOL _preEditParagraphWasEmpty; + // Whether the replaced range contained a line break, captured before the edit + // applies (the deleted characters are gone afterwards). Drives the full-vs- + // scoped reformat decision in handleTextChanged. + BOOL _preEditReplacedNewline; std::optional _prevCaretRect; @@ -940,7 +943,12 @@ - (void)toggleBlockType:(ENRMInputBlockType)type level:(NSInteger)level [storage endEditing]; } } else if (paragraphRange.length == 0) { - [_blockStore setBlockType:type level:level forParagraphRange:paragraphRange inText:text]; + // Switching unordered <-> ordered keeps the item's nesting depth. + NSInteger resolvedLevel = level; + if (ENRMBlockTypeIsListItem(type) && current != nil && ENRMBlockTypeIsListItem(current.type)) { + resolvedLevel = current.level; + } + [_blockStore setBlockType:type level:resolvedLevel forParagraphRange:paragraphRange inText:text]; } else { // Blocks are single-paragraph: set one range per paragraph the selection // touches, not one range spanning them all — otherwise the next edit's @@ -949,8 +957,16 @@ - (void)toggleBlockType:(ENRMInputBlockType)type level:(NSInteger)level enumerateSubstringsInRange:paragraphRange options:NSStringEnumerationByParagraphs | NSStringEnumerationSubstringNotRequired usingBlock:^(NSString *substring, NSRange substringRange, NSRange enclosingRange, BOOL *stop) { + // Switching unordered <-> ordered keeps each line's nesting depth. + NSInteger paragraphLevel = level; + if (ENRMBlockTypeIsListItem(type)) { + ENRMBlockRange *existing = [self listBlockForParagraphAtPosition:substringRange.location]; + if (existing != nil) { + paragraphLevel = existing.level; + } + } [self->_blockStore setBlockType:type - level:level + level:paragraphLevel forParagraphRange:substringRange inText:text]; }]; @@ -1008,14 +1024,32 @@ - (void)changeListDepthBy:(NSInteger)delta return; } - NSInteger newDepth = MIN(MAX(listBlock.level + delta, (NSInteger)0), kENRMMaxListDepth); - if (newDepth == listBlock.level) { - return; - } - NSString *text = ENRMGetPlainText(_textView); NSRange paragraphRange = [text paragraphRangeForRange:_textView.selectedRange]; - [_blockStore setBlockType:listBlock.type level:newDepth forParagraphRange:paragraphRange inText:text]; + + // Adjust every selected paragraph that is a list item, each clamped from its + // own depth (mirrors Android's forEachSelectedLine and toggleBlockType's + // paragraph enumeration — one range per line, so normalize can't clip the + // block to the selection's first line). + if (paragraphRange.length == 0) { + NSInteger newDepth = MIN(MAX(listBlock.level + delta, (NSInteger)0), kENRMMaxListDepth); + [_blockStore setBlockType:listBlock.type level:newDepth forParagraphRange:paragraphRange inText:text]; + } else { + [text + enumerateSubstringsInRange:paragraphRange + options:NSStringEnumerationByParagraphs | NSStringEnumerationSubstringNotRequired + usingBlock:^(NSString *substring, NSRange substringRange, NSRange enclosingRange, BOOL *stop) { + ENRMBlockRange *block = [self listBlockForParagraphAtPosition:substringRange.location]; + if (block == nil) { + return; + } + NSInteger newDepth = MIN(MAX(block.level + delta, (NSInteger)0), kENRMMaxListDepth); + [self->_blockStore setBlockType:block.type + level:newDepth + forParagraphRange:substringRange + inText:text]; + }]; + } [_blockStore normalizeToLineBoundsInText:text]; [self applyFormatting]; @@ -1077,6 +1111,19 @@ - (void)capturePreEditBlockForRange:(NSRange)range } } +/// Whether `range` of the current (pre-edit) text contains a line break. Scans +/// only the replaced run, mirroring Android's editTouchedNewline. +- (BOOL)replacedRangeTouchesNewline:(NSRange)range +{ + NSString *text = _textView.textStorage.string; + if (range.length == 0 || range.location >= text.length) { + return NO; + } + NSRange clamped = NSMakeRange(range.location, MIN(range.length, text.length - range.location)); + return [text rangeOfCharacterFromSet:[NSCharacterSet newlineCharacterSet] options:0 range:clamped].location != + NSNotFound; +} + /// Whether the caret's paragraph had no glyph content at the start of the edit /// (an empty line). Used to decide whether Return continues or exits the list. - (BOOL)preEditParagraphWasEmpty:(NSRange)range @@ -1257,6 +1304,7 @@ - (void)updateEmptyBulletMarker _layoutManager.emptyBulletLocation = location; _layoutManager.emptyBulletFont = _formatterStyle.baseFont; _layoutManager.emptyBulletColor = _formatterStyle.baseTextColor; + _layoutManager.emptyBulletRTL = [self emptyListLineIsRTL]; _layoutManager.listItemSpacing = _formatterStyle.listItemSpacing; // An empty editor never runs the formatter (it early-returns at length 0), so @@ -1271,6 +1319,24 @@ - (void)updateEmptyBulletMarker [self updatePlaceholderVisibility]; } +/// Writing direction the empty list line resolves to, for mirroring its marker. +/// An empty line has no strong character, so Auto and FirstStrong fall back the +/// same way the formatter's direction pass would. +- (BOOL)emptyListLineIsRTL +{ + switch (_writingDirectionMode) { + case ENRMWritingDirectionModeLTR: + return NO; + case ENRMWritingDirectionModeRTL: + return YES; + case ENRMWritingDirectionModeFirstStrong: + return _resolvedLayoutDirection == NSWritingDirectionRightToLeft; + case ENRMWritingDirectionModeAuto: + default: + return ENRMParagraphIsRTL(nil); + } +} + /// Heading level (1-6, or 0) of the caret's paragraph, matched by line start so /// line-end carets and empty-line anchors register (mirrors inline isActive). - (NSInteger)headingLevelForCursorParagraph @@ -2142,6 +2208,9 @@ - (void)handleTextChanged // lists arrive via replaceTextInRange:.) if (!insertedHasGlyphContent && insertedLength == 1) { [self reconcileBlockContinuationAfterNewlineAt:editLocation previousItemWasEmpty:_preEditParagraphWasEmpty]; + // Continuation re-seeds fresh block ranges whose ordinal defaults to 1; + // normalize again so the list-metadata pass renumbers the adjacent run. + [_blockStore normalizeToLineBoundsInText:plainText]; } } @@ -2159,16 +2228,25 @@ - (void)handleTextChanged // An edit that adds or removes lines can shift the depth clamp and ordered // numbering of items far below the edited line; re-stamp the whole document - // in that case. Same-line typing keeps the scoped per-keystroke path. - NSString *postEditText = ENRMGetPlainText(_textView); - NSUInteger lineCount = 1; - for (NSUInteger i = 0; i < postEditText.length; i++) { - if ([postEditText characterAtIndex:i] == '\n') { - lineCount++; + // in that case. Same-line typing keeps the scoped per-keystroke path. Like + // Android's editTouchedNewline, only the edited runs are scanned — O(edit + // size), not O(document): the inserted run in the post-edit text here, and + // the replaced run when the change was intercepted (the deleted characters + // are gone by now, so shouldChangeTextInRange captured that half). + BOOL touchedNewline = _preEditReplacedNewline; + _preEditReplacedNewline = NO; + if (!touchedNewline && insertedLength > 0) { + NSString *postEditText = ENRMGetPlainText(_textView); + NSUInteger insertedEnd = MIN(editLocation + insertedLength, postEditText.length); + if (editLocation < insertedEnd) { + NSRange insertedRun = NSMakeRange(editLocation, insertedEnd - editLocation); + touchedNewline = [postEditText rangeOfCharacterFromSet:[NSCharacterSet newlineCharacterSet] + options:0 + range:insertedRun] + .location != NSNotFound; } } - if (lineCount != _lastLineCount) { - _lastLineCount = lineCount; + if (touchedNewline) { [self applyFormatting]; } else { [self applyFormattingScopedToEditAtLocation:editLocation insertedLength:insertedLength]; @@ -2275,6 +2353,7 @@ - (BOOL)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range r _preEditSelectedRange = _lastSelectedRange; [self capturePreEditBlockForRange:range]; _preEditParagraphWasEmpty = [self preEditParagraphWasEmpty:range]; + _preEditReplacedNewline = [self replacedRangeTouchesNewline:range]; _isTextChanging = YES; [self stripLinkTypingAttributes]; return YES; @@ -2386,6 +2465,7 @@ - (nullable NSString *)textInputShouldChangeText:(NSString *)text inRange:(NSRan return nil; } _preEditSelectedRange = _lastSelectedRange; + _preEditReplacedNewline = [self replacedRangeTouchesNewline:range]; _isTextChanging = YES; return text; } diff --git a/packages/react-native-enriched-markdown/ios/input/styles/ENRMUnorderedListBlockHandler.h b/packages/react-native-enriched-markdown/ios/input/styles/ENRMUnorderedListBlockHandler.h index 7f184b4e2..021a8495a 100644 --- a/packages/react-native-enriched-markdown/ios/input/styles/ENRMUnorderedListBlockHandler.h +++ b/packages/react-native-enriched-markdown/ios/input/styles/ENRMUnorderedListBlockHandler.h @@ -6,7 +6,7 @@ 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 +/// glyph into it) and serializes to a `- ` line prefix indented three 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