fix(input): keep styles and list block when typing over a leading selection; stop iOS edit-menu jitter - #612
Conversation
…ection Replacing a selection that starts exactly at a stored range's start dropped the range (bold lost) and shoved the block range off its line start, where the orphan-anchor prune deleted the list item. Make the shared range adjustment inheritance-aware: replacement text joins inline styles and block ranges anchored at the edit start (UIKit gives replacement text the attributes of the first replaced character); links still never extend. Seed pending styles from the selection start so continued typing stays styled through the post-edit grace period. Mirrored on Android. Also stop the selection edit menu from jumping on iOS: defer the atomic-link selection snap while a selection gesture is live and clamp it to the text length (removes a dismiss/re-present loop), skip the empty-bullet redraw when there is no marker to draw or clear, and stop rewriting typing attributes for non-empty selections. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
eszlamczyk
left a comment
There was a problem hiding this comment.
hi @wildseansy, the fix seems sound and reasonable! One note: Im not sure why this bump version got included in this, even though this isn't a release commit. Im gonna clean up this and a bunch of other code nitpicks and merge this today!
There was a problem hiding this comment.
Pull request overview
This PR fixes selection-replacement and selection-change behavior in EnrichedMarkdownTextInput, addressing (1) style/block loss when typing over a leading selection (iOS + mirrored Android logic) and (2) iOS edit-menu jitter during live selection changes by reducing selection-change side effects and deferring atomic-link snapping.
Changes:
- Update shared range-edit adjustment (iOS/Android) to optionally let replacement text “inherit” a range when the deletion starts at the range start (with links explicitly excluded from inheritance).
- Improve typing-attributes/pending-style behavior to preserve styles when typing over a non-empty selection and avoid redundant typing-attributes writes.
- Reduce iOS selection-change churn by deferring atomic-link snaps until selection gestures settle and avoiding unnecessary layout/storage mutations when updating empty-list bullet markers.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| packages/react-native-enriched-markdown/ios/input/ENRMRangeEditAdjustment.mm | Adds inheritsReplacementAtStart behavior to range adjustment for replacement edits starting at a range start. |
| packages/react-native-enriched-markdown/ios/input/ENRMRangeEditAdjustment.h | Documents and exposes the new inheritsReplacementAtStart parameter. |
| packages/react-native-enriched-markdown/ios/input/ENRMInputTypingAttributesController.mm | Avoids redundant typing-attributes writes; seeds pending inline styles from the first selected character for non-empty selections. |
| packages/react-native-enriched-markdown/ios/input/ENRMFormattingStore.mm | Enables replacement inheritance for inline styles except links (links remain “replace” semantics). |
| packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.mm | Forces block ranges to inherit at start so list items remain anchored through replacements. |
| packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm | Defers atomic-link snapping until gestures settle; clamps snaps to text length; reduces empty-bullet marker layout/storage churn. |
| packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/RangeEditAdjustment.kt | Adds configurable replacement inheritance at range start via a generic inheritsReplacementAtStart predicate. |
| packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/FormattingStore.kt | Applies inheritance to all inline styles except links. |
| packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/BlockStore.kt | Applies inheritance for blocks and prevents duplicate restoration when inheritance keeps a block alive. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Again thanks for this - the block-range reasoning is sound and the range-adjustment parity is carefully done. Unfortunately, testing it interactively, the inline-style behavior regresses, and I think the root cause is that the approach fixes the stored ranges / serialization but not the visual application - and the only automated coverage here asserts serialized markdown, which is exactly the part that stays correct in every failing case. So the green check doesn't tell us the feature works.
I'll keep this to the two regressions I've confirmed are introduced by this PR (there are some other visual glitches in this area, but they reproduce on main too, so they're out of scope here).
Repro (both platforms): - **boldword** rest, double-tap-select the bold word, type a replacement.
Android - only the first typed char stays bold (**t**est instead of **test**)
Confirmed in code. The PR ports the range-inheritance half to Android but not the "seed pending styles from the selection" half that the iOS side got:
RangeEditAdjustmentinheritance makes the bold range[start, start+1]after the first char. But the insert-only branch doesn't grow a range when inserting atrange.end(RangeEditAdjustment.kt- both conditions are false for an insert atrange.end), so char 2+ fall outside it.- Nothing carries the style forward:
EnrichedMarkdownTextInputView.onSelectionChangedonly clearspendingStyles/pendingStyleRemovals(~L377-378), it never seeds them from the selection start, unlike iOSENRMInputTypingAttributesController.rebuildFromContext.
So the PR description's "the shared logic is mirrored there [Android]" only holds for the range-inheritance, not the pending-style seed. Android needs the equivalent seed in onSelectionChanged for a non-empty selection.
iOS - the list bullet disappears when you replace the first word of a list item
Repro: in a list item, double-tap the first word and type over it -> the bullet marker vanishes (indent and serialized
markdown stay correct). This is tied to the new block-range inheritance path (ENRMBlockStore now passes inheritsReplacementAtStart = YES): the list block range is adjusted differently on that edit and the marker stops drawing. I haven't fully root-caused the internal mechanism (it needs on-device tracing of the block-range state after adjustForEdit + normalizeToLineBounds and what the marker drawer reads), but it's reliably reproducible and specific to this change.
Ask before merge
- Android: seed pending styles from the selection start (mirror iOS
rebuildFromContext). - iOS: fix the bullet-marker regression on first-word replacement.
- Add a visual regression check (screenshot/attribute assertion) rather than serialization-only - that's what let both
of these through.
|
Thanks for the review @eszlamczyk, I'll take a look tonight and update with points 1-3. |
Summary
Fixes two
EnrichedMarkdownTextInputbugs reported on iOS (the first reproduces on Android too — the shared logic is mirrored there):1. Selecting a bold run at the beginning of a list item and typing over it lost the bold style and deleted the list item.
Root cause is in the shared range edit adjustment: a replacement whose deletion starts exactly at a stored range's start classified as
FullyDeleted/ClippedStartand either removed the range outright (bold lost) or moved it past the inserted text — shoving the block range off its line start, wherepruneOrphanedBlockAnchorsthen deleted the whole list block beforenormalizeToLineBoundscould self-heal it.ENRMRangeEditAdjustment/RangeEditAdjustment.kt: replacements starting at a range's start now let the inserted text join the range, matching UIKit's rule that replacement text inherits the attributes of the first replaced character. Links deliberately keep the old behavior — typing over a selected link/mention replaces it rather than extending it.BlockStoreguards against re-adding a collapsed block that survived adjustment via inheritance.2. The selection edit menu repeatedly jumped up and down while selecting text.
Three compounding causes in
EnrichedMarkdownTextInput.mm, all on the selection-change hot path:selectedRangeon every frame of a live selection-handle drag; each programmatic write makes UIKit dismiss and re-present the edit menu while the gesture immediately reasserts the finger-derived range. The snap is now deferred until the gesture settles, and is clamped to the text length (a stale link range extending past the text end could previously drive an unbounded snap/clamp loop).updateEmptyBulletMarkerunconditionally forcedlayoutIfNeeded+ a full-viewsetNeedsDisplayon every selection-change delegate fire; it now early-returns when there is no marker to draw or clear, and only stamps the paragraph style into storage when it is actually missing.Testing
- **boldword** rest, double-tap-select the bold word, type over it — now serializes- **typed** rest(bold kept, list item intact).mainas well (two missing iOS screenshot baselines forordered_list_test/list_toggle_test,link_test'stoolbar-linksitting off-screen in the scrolling toolbar, and a sub-threshold pixel diff on a newer iOS runtime than the baselines).compileDebugKotlin+ktlintCheckpass;tscclean.While testing this we also noticed a pre-existing issue on
main(reproduces without this change): aftersetValue()the input renders without the bullet, indent, or bold until the first edit, while the display component preview renders the same markdown correctly. Happy to file it separately.🤖 Generated with Claude Code