From 9ce09b282e2bc2e2fd228238784edbd739ba2530 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Sun, 26 Jul 2026 00:48:56 +0200 Subject: [PATCH] refactor(input): push inline style toggle logic into FormattingStore (iOS & Android) Move blocking checks, conflict stripping, and the active-check-then-add-or-remove toggle pattern from the view into FormattingStore on both platforms. The view now calls high-level store operations (toggleStyle, isToggleBlocked) instead of manually orchestrating addRange/removeType sequences. Pending style management stays in the view as it is transient caret-level state tied to the UI. Co-authored-by: Cursor --- .../input/EnrichedMarkdownTextInputView.kt | 37 ++----------- .../input/formatting/FormattingStore.kt | 49 +++++++++++++++++ .../ios/input/ENRMFormattingStore.h | 17 ++++++ .../ios/input/ENRMFormattingStore.mm | 53 +++++++++++++++++++ .../ios/input/EnrichedMarkdownTextInput.mm | 44 ++++----------- 5 files changed, 133 insertions(+), 67 deletions(-) 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 0a1e7a63d..e22d132c9 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 @@ -460,21 +460,12 @@ class EnrichedMarkdownTextInputView( fun toggleInlineStyle(styleType: StyleType) { val handler = formatter.handlers[styleType] ?: return val mergingConfig = handler.mergingConfig - val selStart = selectionStart val selEnd = selectionEnd - // Check blocking rules: if any blocking style is active, refuse to toggle on. - if (mergingConfig.blockingStyles.isNotEmpty()) { - val isCurrentlyActive = formattingStore.isStyleActive(styleType, selStart) - if (!isCurrentlyActive) { - for (blocker in mergingConfig.blockingStyles) { - if (formattingStore.isStyleActive(blocker, selStart)) { - return - } - } - } - } + if (formattingStore.isToggleBlocked(styleType, selStart, mergingConfig.blockingStyles)) return + + val result = formattingStore.toggleStyle(styleType, selStart, selEnd, mergingConfig.conflictingStyles) if (selStart == selEnd) { if (pendingStyleRemovals.contains(styleType)) { @@ -483,23 +474,13 @@ class EnrichedMarkdownTextInputView( } else if (pendingStyles.contains(styleType)) { pendingStyles.remove(styleType) pendingStyleRemovals.add(styleType) - } else if (formattingStore.isStyleActive(styleType, selStart)) { + } else if (result == FormattingStore.ToggleResult.WAS_ACTIVE) { pendingStyleRemovals.add(styleType) } else { pendingStyles.add(styleType) } eventEmitter.emitState() } else { - val isActive = formattingStore.isStyleActive(styleType, selStart) - if (isActive) { - formattingStore.removeType(styleType, selStart, selEnd) - } else { - // Remove conflicting styles from the range before applying. - for (conflict in mergingConfig.conflictingStyles) { - formattingStore.removeType(conflict, selStart, selEnd) - } - formattingStore.addRange(FormattingRange(styleType, selStart, selEnd)) - } applyFormattingAndEmit() } } @@ -1066,15 +1047,7 @@ class EnrichedMarkdownTextInputView( ) { if (start >= end) return val handler = formatter.handlers[styleType] ?: return - val isActive = formattingStore.isStyleActive(styleType, start) - if (isActive) { - formattingStore.removeType(styleType, start, end) - } else { - for (conflict in handler.mergingConfig.conflictingStyles) { - formattingStore.removeType(conflict, start, end) - } - formattingStore.addRange(FormattingRange(styleType, start, end)) - } + formattingStore.toggleStyle(styleType, start, end, handler.mergingConfig.conflictingStyles) applyFormattingAndEmit() } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/FormattingStore.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/FormattingStore.kt index 365994b0c..a615b1f21 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/FormattingStore.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/FormattingStore.kt @@ -53,6 +53,55 @@ class FormattingStore { return if (link != null && start > link.start && start < link.end) Pair(link.end, link.end) else null } + enum class ToggleResult { + /** The style was active and has been removed (or is active at the caret). */ + WAS_ACTIVE, + + /** The style was inactive and has been added (or is inactive at the caret). */ + WAS_INACTIVE, + } + + /** + * Toggles [type] on the range `[start, end)`. For a selection (`start < end`), + * removes the style when active at `start`, otherwise strips [conflictingStyles] + * and adds it. For a caret (`start == end`), reports the current state without + * mutating — the caller manages pending-style bookkeeping. + */ + fun toggleStyle( + type: StyleType, + start: Int, + end: Int, + conflictingStyles: Set, + ): ToggleResult { + val isActive = isStyleActive(type, start) + if (start < end) { + if (isActive) { + removeType(type, start, end) + } else { + for (conflict in conflictingStyles) { + removeType(conflict, start, end) + } + addRange(FormattingRange(type, start, end)) + } + } + return if (isActive) ToggleResult.WAS_ACTIVE else ToggleResult.WAS_INACTIVE + } + + /** + * Returns true when toggling [type] ON at [position] should be refused + * because one of the [blockingStyles] is currently active there. + * Toggling OFF (the style is already active) is never blocked. + */ + fun isToggleBlocked( + type: StyleType, + position: Int, + blockingStyles: Set, + ): Boolean { + if (blockingStyles.isEmpty()) return false + if (isStyleActive(type, position)) return false + return blockingStyles.any { isStyleActive(it, position) } + } + fun addRange(newRange: FormattingRange) { var mergedStart = newRange.start var mergedEnd = newRange.end diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMFormattingStore.h b/packages/react-native-enriched-markdown/ios/input/ENRMFormattingStore.h index 0fda0904f..d51f7c6c4 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMFormattingStore.h +++ b/packages/react-native-enriched-markdown/ios/input/ENRMFormattingStore.h @@ -21,6 +21,23 @@ NS_ASSUME_NONNULL_BEGIN /// the whole link, a caret inside a link moves to its end (unchanged when no adjustment is needed). - (NSRange)selectionAdjustedForAtomicLinks:(NSRange)selection; +/// YES when the entire `range` is covered by adjacent/overlapping ranges of `type`. +- (BOOL)isStyleFullyActive:(ENRMInputStyleType)type inRange:(NSRange)range; + +/// Toggles `type` on `range`. For a selection (length > 0), removes the style +/// when fully active, otherwise strips `conflictingStyles` and adds it. +/// For a caret (length == 0), reports the current state without mutating. +/// Returns YES when the style was active (removed / pending removal). +- (BOOL)toggleStyle:(ENRMInputStyleType)type + inRange:(NSRange)range + conflictingStyles:(NSSet *)conflictingStyles; + +/// YES when toggling `type` ON at `position` should be refused because one of +/// the `blockingStyles` is active there. Toggling OFF is never blocked. +- (BOOL)isToggleBlocked:(ENRMInputStyleType)type + atPosition:(NSUInteger)position + blockingStyles:(NSSet *)blockingStyles; + - (void)addRange:(ENRMFormattingRange *)range; - (void)removeType:(ENRMInputStyleType)type inRange:(NSRange)range; - (void)removeRange:(ENRMFormattingRange *)range; diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMFormattingStore.mm b/packages/react-native-enriched-markdown/ios/input/ENRMFormattingStore.mm index 1d4879e79..1f5567f5c 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMFormattingStore.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMFormattingStore.mm @@ -108,6 +108,59 @@ - (BOOL)isStyleAdjacentBefore:(ENRMInputStyleType)type position:(NSUInteger)posi containingPosition:position - 1] != nil; } +- (BOOL)isStyleFullyActive:(ENRMInputStyleType)type inRange:(NSRange)range +{ + NSUInteger pos = range.location; + NSUInteger end = NSMaxRange(range); + while (pos < end) { + ENRMFormattingRange *match = [self rangeOfType:type containingPosition:pos]; + if (match == nil) { + return NO; + } + pos = NSMaxRange(match.range); + } + return YES; +} + +- (BOOL)toggleStyle:(ENRMInputStyleType)type + inRange:(NSRange)range + conflictingStyles:(NSSet *)conflictingStyles +{ + BOOL wasActive; + if (range.length > 0) { + wasActive = [self isStyleFullyActive:type inRange:range]; + if (wasActive) { + [self removeType:type inRange:range]; + } else { + for (NSNumber *conflict in conflictingStyles) { + [self removeType:(ENRMInputStyleType)conflict.integerValue inRange:range]; + } + [self addRange:[ENRMFormattingRange rangeWithType:type range:range]]; + } + } else { + wasActive = [self isStyleActive:type atPosition:range.location]; + } + return wasActive; +} + +- (BOOL)isToggleBlocked:(ENRMInputStyleType)type + atPosition:(NSUInteger)position + blockingStyles:(NSSet *)blockingStyles +{ + if (blockingStyles.count == 0) { + return NO; + } + if ([self isStyleActive:type atPosition:position]) { + return NO; + } + for (NSNumber *blocker in blockingStyles) { + if ([self isStyleActive:(ENRMInputStyleType)blocker.integerValue atPosition:position]) { + return YES; + } + } + return NO; +} + - (void)addRange:(ENRMFormattingRange *)newRange { NSMutableIndexSet *mergeIndexes = [NSMutableIndexSet indexSet]; diff --git a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm index eadee8779..f91e2e0b8 100644 --- a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm +++ b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm @@ -872,53 +872,27 @@ - (void)toggleInlineStyle:(ENRMInputStyleType)styleType ENRMStyleMergingConfig *mergingConfig = handler.mergingConfig; NSRange selection = _textView.selectedRange; - NSUInteger cursor = selection.location; NSNumber *key = @(styleType); - // Check blocking rules: if any blocking style is active, refuse to toggle on. - if (mergingConfig.blockingStyles.count > 0) { - BOOL isCurrentlyActive = [_formattingStore isStyleActive:styleType atPosition:cursor]; - if (!isCurrentlyActive) { - for (NSNumber *blockerNum in mergingConfig.blockingStyles) { - if ([_formattingStore isStyleActive:(ENRMInputStyleType)blockerNum.integerValue atPosition:cursor]) { - return; - } - } - } + if ([_formattingStore isToggleBlocked:styleType + atPosition:selection.location + blockingStyles:mergingConfig.blockingStyles]) { + return; } + BOOL wasActive = [_formattingStore toggleStyle:styleType + inRange:selection + conflictingStyles:mergingConfig.conflictingStyles]; + if (selection.length > 0) { - BOOL fullyStyled = YES; - NSUInteger pos = selection.location; - NSUInteger selEnd = NSMaxRange(selection); - while (pos < selEnd) { - ENRMFormattingRange *match = [_formattingStore rangeOfType:styleType containingPosition:pos]; - if (match == nil) { - fullyStyled = NO; - break; - } - pos = NSMaxRange(match.range); - } - if (fullyStyled) { - [_formattingStore removeType:styleType inRange:selection]; - } else { - // Remove conflicting styles from the range before applying. - for (NSNumber *conflictNum in mergingConfig.conflictingStyles) { - [_formattingStore removeType:(ENRMInputStyleType)conflictNum.integerValue inRange:selection]; - } - ENRMFormattingRange *newRange = [ENRMFormattingRange rangeWithType:styleType range:selection]; - [_formattingStore addRange:newRange]; - } [_pendingStyles removeObject:key]; [_pendingStyleRemovals removeObject:key]; } else { - BOOL isInsideRange = [_formattingStore isStyleActive:styleType atPosition:cursor]; - if ([_pendingStyleRemovals containsObject:key]) { [_pendingStyleRemovals removeObject:key]; } else if ([_pendingStyles containsObject:key]) { [_pendingStyles removeObject:key]; - } else if (isInsideRange) { + } else if (wasActive) { [_pendingStyleRemovals addObject:key]; } else { [_pendingStyles addObject:key];