From 8272e255522d8001c8cf06bc6c5dc24dbc0d5f40 Mon Sep 17 00:00:00 2001 From: IvanIhnatsiuk Date: Wed, 19 Nov 2025 23:21:06 +0100 Subject: [PATCH 01/12] feat: add foreground color style for iOS --- .../enriched/EnrichedTextInputViewManager.kt | 12 +- apps/example/src/App.tsx | 17 +- apps/example/src/components/ColorPreview.tsx | 32 ++ apps/example/src/components/Toolbar.tsx | 29 +- .../src/components/ToolbarColorButton.tsx | 47 +++ ios/EnrichedTextInputView.mm | 158 +++++++--- ios/inputParser/InputParser.mm | 127 +++++--- ios/styles/ColorStyle.mm | 297 ++++++++++++++++++ ios/utils/ColorExtension.h | 8 + ios/utils/ColorExtension.mm | 154 +++++++++ ios/utils/ColorUtils.m | 8 + ios/utils/StyleHeaders.h | 7 + ios/utils/StyleTypeEnum.h | 1 + src/EnrichedTextInput.tsx | 10 + src/EnrichedTextInputNativeComponent.ts | 16 + 15 files changed, 820 insertions(+), 103 deletions(-) create mode 100644 apps/example/src/components/ColorPreview.tsx create mode 100644 apps/example/src/components/ToolbarColorButton.tsx create mode 100644 ios/styles/ColorStyle.mm create mode 100644 ios/utils/ColorUtils.m diff --git a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt index d74f08a9e..2d0719085 100644 --- a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt @@ -338,13 +338,11 @@ class EnrichedTextInputViewManager : view?.verifyAndToggleStyle(EnrichedSpans.UNORDERED_LIST) } - override fun addLink( - view: EnrichedTextInputView?, - start: Int, - end: Int, - text: String, - url: String, - ) { + override fun toggleColor(view: EnrichedTextInputView?, color: String) { + // no-op for now + } + + override fun addLink(view: EnrichedTextInputView?, start: Int, end: Int, text: String, url: String) { view?.addLink(start, end, text, url) } diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx index d91708992..11b393cc2 100644 --- a/apps/example/src/App.tsx +++ b/apps/example/src/App.tsx @@ -26,6 +26,8 @@ import { DEFAULT_IMAGE_WIDTH, prepareImageDimensions, } from './utils/prepareImageDimensions'; +import type { OnChangeColorEvent } from '../../src/EnrichedTextInputNativeComponent'; +import ColorPreview from './components/ColorPreview'; type StylesState = OnChangeStateEvent; @@ -45,6 +47,7 @@ const DEFAULT_STYLE_STATE = { const DEFAULT_STYLES: StylesState = { bold: DEFAULT_STYLE_STATE, + colored: DEFAULT_STYLE_STATE, italic: DEFAULT_STYLE_STATE, underline: DEFAULT_STYLE_STATE, strikeThrough: DEFAULT_STYLE_STATE, @@ -94,6 +97,7 @@ export default function App() { const [stylesState, setStylesState] = useState(DEFAULT_STYLES); const [currentLink, setCurrentLink] = useState(DEFAULT_LINK_STATE); + const [selectionColor, setSelectionColor] = useState(PRIMARY_COLOR); const ref = useRef(null); @@ -293,6 +297,14 @@ export default function App() { setSelection(sel); }; + const handleSelectionColorChange = ( + e: NativeSyntheticEvent + ) => { + if (e.nativeEvent.color) { + setSelectionColor(e.nativeEvent.color); + } + }; + return ( <> handleChangeText(e.nativeEvent)} onChangeHtml={(e) => handleChangeHtml(e.nativeEvent)} onChangeState={(e) => handleChangeState(e.nativeEvent)} + onColorChangeInSelection={handleSelectionColorChange} onLinkDetected={handleLinkDetected} onMentionDetected={console.log} onStartMention={handleStartMention} @@ -330,6 +343,7 @@ export default function App() { @@ -345,6 +359,7 @@ export default function App() { /> {DEBUG_SCROLLABLE && } + = ({ color }) => { + return ( + <> + + {color} + + ); +}; + +const styles = StyleSheet.create({ + preview: { + marginVertical: 8, + width: 40, + height: 40, + borderRadius: 20, + }, +}); + +export default ColorPreview; diff --git a/apps/example/src/components/Toolbar.tsx b/apps/example/src/components/Toolbar.tsx index 78335499b..3a21a0d0b 100644 --- a/apps/example/src/components/Toolbar.tsx +++ b/apps/example/src/components/Toolbar.tsx @@ -5,6 +5,7 @@ import type { EnrichedTextInputInstance, } from 'react-native-enriched'; import type { FC } from 'react'; +import { ToolbarColorButton } from './ToolbarColorButton'; const STYLE_ITEMS = [ { @@ -79,6 +80,16 @@ const STYLE_ITEMS = [ name: 'ordered-list', icon: 'list-ol', }, + { + name: 'color', + value: '#FF0000', + text: 'A', + }, + { + name: 'color', + value: '#E6FF5C', + text: 'A', + }, ] as const; type Item = (typeof STYLE_ITEMS)[number]; @@ -89,6 +100,7 @@ export interface ToolbarProps { editorRef?: React.RefObject; onOpenLinkModal: () => void; onSelectImage: () => void; + selectionColor: string | null; } export const Toolbar: FC = ({ @@ -96,6 +108,7 @@ export const Toolbar: FC = ({ editorRef, onOpenLinkModal, onSelectImage, + selectionColor, }) => { const handlePress = (item: Item) => { const currentRef = editorRef?.current; @@ -202,6 +215,10 @@ export const Toolbar: FC = ({ } }; + const handleColorButtonPress = (color: string) => { + editorRef?.current?.toggleColor(color); + }; + const isActive = (item: Item) => { switch (item.name) { case 'bold': @@ -246,7 +263,14 @@ export const Toolbar: FC = ({ }; const renderItem = ({ item }: ListRenderItemInfo) => { - return ( + return item.name === 'color' ? ( + + ) : ( = ({ ); }; - const keyExtractor = (item: Item) => item.name; + const keyExtractor = (item: Item) => + item.name === 'color' ? item.value : item.name; return ( void; + color: string; +} + +export const ToolbarColorButton: FC = ({ + text, + isActive, + onPress, + color, +}) => { + const handlePress = () => { + onPress(color); + }; + + const containerStyle = useMemo( + () => [ + styles.container, + { backgroundColor: isActive ? color : 'rgba(0, 26, 114, 0.8)' }, + ], + [isActive, color] + ); + + return ( + + {text} + + ); +}; + +const styles = StyleSheet.create({ + container: { + justifyContent: 'center', + alignItems: 'center', + width: 56, + height: 56, + }, + text: { + color: 'white', + fontSize: 20, + }, +}); diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 28a806890..85a3fb223 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -15,6 +15,7 @@ #import #import #import +#import "ColorExtension.h" #define GET_STYLE_STATE(TYPE_ENUM) \ { \ @@ -42,6 +43,7 @@ @implementation EnrichedTextInputView { NSRange _recentlyActiveMentionRange; NSString *_recentlyEmittedHtml; BOOL _emitHtml; + NSString *_recentlyEmittedColor; UILabel *_placeholderLabel; UIColor *_placeholderColor; BOOL _emitFocusBlur; @@ -88,6 +90,7 @@ - (void)setDefaults { _recentInputString = @""; _recentlyEmittedHtml = @"\n

\n"; _emitHtml = NO; + _recentlyEmittedColor = nil; blockEmitting = NO; _emitFocusBlur = YES; _emitTextChange = NO; @@ -104,6 +107,7 @@ - (void)setDefaults { [[StrikethroughStyle alloc] initWithInput:self], @([InlineCodeStyle getStyleType]) : [[InlineCodeStyle alloc] initWithInput:self], + @([ColorStyle getStyleType]) : [[ColorStyle alloc] initWithInput:self], @([LinkStyle getStyleType]) : [[LinkStyle alloc] initWithInput:self], @([MentionStyle getStyleType]) : [[MentionStyle alloc] initWithInput:self], @([H1Style getStyleType]) : [[H1Style alloc] initWithInput:self], @@ -134,6 +138,7 @@ - (void)setDefaults { @([InlineCodeStyle getStyleType]), @([LinkStyle getStyleType]), @([MentionStyle getStyleType]) ], + @([ColorStyle getStyleType]) : @[@([InlineCodeStyle getStyleType]), @([MentionStyle getStyleType])], @([MentionStyle getStyleType]) : @[ @([InlineCodeStyle getStyleType]), @([LinkStyle getStyleType]) ], @([H1Style getStyleType]) : @[ @@ -215,6 +220,7 @@ - (void)setDefaults { blockingStyles = [@{ @([BoldStyle getStyleType]) : @[ @([CodeBlockStyle getStyleType]) ], + @([ColorStyle getStyleType]) : @[ @([CodeBlockStyle getStyleType]) ], @([ItalicStyle getStyleType]) : @[ @([CodeBlockStyle getStyleType]) ], @([UnderlineStyle getStyleType]) : @[ @([CodeBlockStyle getStyleType]) ], @([StrikethroughStyle getStyleType]) : @@ -1025,29 +1031,38 @@ - (void)tryUpdatingActiveStyles { _blockedStyles = newBlockedStyles; emitter->onChangeStateDeprecated({ - .isBold = [self isStyleActive:[BoldStyle getStyleType]], - .isItalic = [self isStyleActive:[ItalicStyle getStyleType]], - .isUnderline = [self isStyleActive:[UnderlineStyle getStyleType]], - .isStrikeThrough = - [self isStyleActive:[StrikethroughStyle getStyleType]], - .isInlineCode = [self isStyleActive:[InlineCodeStyle getStyleType]], - .isLink = [self isStyleActive:[LinkStyle getStyleType]], - .isMention = [self isStyleActive:[MentionStyle getStyleType]], - .isH1 = [self isStyleActive:[H1Style getStyleType]], - .isH2 = [self isStyleActive:[H2Style getStyleType]], - .isH3 = [self isStyleActive:[H3Style getStyleType]], - .isH4 = [self isStyleActive:[H4Style getStyleType]], - .isH5 = [self isStyleActive:[H5Style getStyleType]], - .isH6 = [self isStyleActive:[H6Style getStyleType]], - .isUnorderedList = - [self isStyleActive:[UnorderedListStyle getStyleType]], - .isOrderedList = [self isStyleActive:[OrderedListStyle getStyleType]], - .isBlockQuote = [self isStyleActive:[BlockQuoteStyle getStyleType]], - .isCodeBlock = [self isStyleActive:[CodeBlockStyle getStyleType]], - .isImage = [self isStyleActive:[ImageStyle getStyleType]], + .isBold = [_activeStyles containsObject:@([BoldStyle getStyleType])], + .isItalic = + [_activeStyles containsObject:@([ItalicStyle getStyleType])], + .isUnderline = + [_activeStyles containsObject:@([UnderlineStyle getStyleType])], + .isStrikeThrough = + [_activeStyles containsObject:@([StrikethroughStyle getStyleType])], + .isColored = [_activeStyles containsObject: @([ColorStyle getStyleType])], + .isInlineCode = + [_activeStyles containsObject:@([InlineCodeStyle getStyleType])], + .isLink = [_activeStyles containsObject:@([LinkStyle getStyleType])], + .isMention = + [_activeStyles containsObject:@([MentionStyle getStyleType])], + .isH1 = [_activeStyles containsObject:@([H1Style getStyleType])], + .isH2 = [_activeStyles containsObject:@([H2Style getStyleType])], + .isH3 = [_activeStyles containsObject:@([H3Style getStyleType])], + .isH4 = [_activeStyles containsObject:@([H4Style getStyleType])], + .isH5 = [_activeStyles containsObject:@([H5Style getStyleType])], + .isH6 = [_activeStyles containsObject:@([H6Style getStyleType])], + .isUnorderedList = + [_activeStyles containsObject:@([UnorderedListStyle getStyleType])], + .isOrderedList = + [_activeStyles containsObject:@([OrderedListStyle getStyleType])], + .isBlockQuote = + [_activeStyles containsObject:@([BlockQuoteStyle getStyleType])], + .isCodeBlock = + [_activeStyles containsObject:@([CodeBlockStyle getStyleType])], + .isImage = [_activeStyles containsObject:@([ImageStyle getStyleType])], }); emitter->onChangeState( {.bold = GET_STYLE_STATE([BoldStyle getStyleType]), + .colored = GET_STYLE_STATE([ColorStyle getStyleType]), .italic = GET_STYLE_STATE([ItalicStyle getStyleType]), .underline = GET_STYLE_STATE([UnderlineStyle getStyleType]), .strikeThrough = GET_STYLE_STATE([StrikethroughStyle getStyleType]), @@ -1084,7 +1099,7 @@ - (void)tryUpdatingActiveStyles { _recentlyActiveMentionParams = detectedMentionParams; _recentlyActiveMentionRange = detectedMentionRange; } - + [self emitCurrentSelectionColorIfChanged]; // emit onChangeHtml event if needed [self tryEmittingOnChangeHtmlEvent]; } @@ -1135,19 +1150,23 @@ - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args { } else if ([commandName isEqualToString:@"setValue"]) { NSString *value = (NSString *)args[0]; [self setValue:value]; - } else if ([commandName isEqualToString:@"toggleBold"]) { - [self toggleRegularStyle:[BoldStyle getStyleType]]; - } else if ([commandName isEqualToString:@"toggleItalic"]) { - [self toggleRegularStyle:[ItalicStyle getStyleType]]; - } else if ([commandName isEqualToString:@"toggleUnderline"]) { - [self toggleRegularStyle:[UnderlineStyle getStyleType]]; - } else if ([commandName isEqualToString:@"toggleStrikeThrough"]) { - [self toggleRegularStyle:[StrikethroughStyle getStyleType]]; - } else if ([commandName isEqualToString:@"toggleInlineCode"]) { - [self toggleRegularStyle:[InlineCodeStyle getStyleType]]; - } else if ([commandName isEqualToString:@"addLink"]) { - NSInteger start = [((NSNumber *)args[0]) integerValue]; - NSInteger end = [((NSNumber *)args[1]) integerValue]; + } else if([commandName isEqualToString:@"toggleBold"]) { + [self toggleRegularStyle: [BoldStyle getStyleType]]; + } else if([commandName isEqualToString:@"toggleItalic"]) { + [self toggleRegularStyle: [ItalicStyle getStyleType]]; + } else if([commandName isEqualToString:@"toggleUnderline"]) { + [self toggleRegularStyle: [UnderlineStyle getStyleType]]; + } else if([commandName isEqualToString:@"toggleStrikeThrough"]) { + [self toggleRegularStyle: [StrikethroughStyle getStyleType]]; + } else if([commandName isEqualToString:@"toggleColor"]) { + NSString *colorText = (NSString *)args[0]; + UIColor *color = [UIColor colorFromString: colorText]; + [self toggleColorStyle: color]; + } else if([commandName isEqualToString:@"toggleInlineCode"]) { + [self toggleRegularStyle: [InlineCodeStyle getStyleType]]; + } else if([commandName isEqualToString:@"addLink"]) { + NSInteger start = [((NSNumber*)args[0]) integerValue]; + NSInteger end = [((NSNumber*)args[1]) integerValue]; NSString *text = (NSString *)args[2]; NSString *url = (NSString *)args[3]; [self addLinkAt:start end:end text:text url:url]; @@ -1282,9 +1301,50 @@ - (void)emitOnLinkDetectedEvent:(NSString *)text } } -- (void)emitOnMentionDetectedEvent:(NSString *)text - indicator:(NSString *)indicator - attributes:(NSString *)attributes { +- (void)emitCurrentSelectionColorIfChanged { + NSRange selRange = textView.selectedRange; + UIColor *uniformColor = nil; + + if (selRange.length == 0) { + id colorAttr = textView.typingAttributes[NSForegroundColorAttributeName]; + uniformColor = colorAttr ? (UIColor *)colorAttr : [config primaryColor]; + } else { + // Selection range: check for uniform color + __block UIColor *firstColor = nil; + __block BOOL hasMultiple = NO; + + [textView.textStorage enumerateAttribute:NSForegroundColorAttributeName + inRange:selRange + options:0 + usingBlock:^(id _Nullable value, NSRange range, BOOL *_Nonnull stop) { + UIColor *thisColor = value ? (UIColor *)value : [config primaryColor]; + if (firstColor == nil) { + firstColor = thisColor; + } else if (![firstColor isEqual:thisColor]) { + hasMultiple = YES; + *stop = YES; + } + }]; + + if (!hasMultiple && firstColor != nil) { + uniformColor = firstColor; + } + } + + NSString *hexColor = uniformColor ? [uniformColor hexString] : [config.primaryColor hexString]; + + if(![_recentlyEmittedColor isEqual: hexColor]) { + auto emitter = [self getEventEmitter]; + if(emitter != nullptr) { + emitter->onColorChangeInSelection({ + .color = [hexColor toCppString] + }); + } + _recentlyEmittedColor = hexColor; + } +} + +- (void)emitOnMentionDetectedEvent:(NSString *)text indicator:(NSString *)indicator attributes:(NSString *)attributes { auto emitter = [self getEventEmitter]; if (emitter != nullptr) { emitter->onMentionDetected({.text = [text toCppString], @@ -1341,6 +1401,13 @@ - (void)requestHTML:(NSInteger)requestId { // MARK: - Styles manipulation +- (void)toggleColorStyle:(UIColor *)color { + ColorStyle *colorStyle = stylesDict[@(Colored)]; + + [colorStyle applyStyle:textView.selectedRange color: color]; + [self anyTextMayHaveBeenModified]; +} + - (void)toggleRegularStyle:(StyleType)type { id styleClass = stylesDict[@(type)]; @@ -1712,15 +1779,14 @@ - (void)textViewDidBeginEditing:(UITextView *)textView { if (_emitFocusBlur) { emitter->onInputFocus({}); } - - NSString *textAtSelection = - [[[NSMutableString alloc] initWithString:textView.textStorage.string] - substringWithRange:textView.selectedRange]; - emitter->onChangeSelection( - {.start = static_cast(textView.selectedRange.location), - .end = static_cast(textView.selectedRange.location + - textView.selectedRange.length), - .text = [textAtSelection toCppString]}); + + NSString *textAtSelection = [[[NSMutableString alloc] initWithString:textView.textStorage.string] substringWithRange: textView.selectedRange]; + emitter->onChangeSelection({ + .start = static_cast(textView.selectedRange.location), + .end = static_cast(textView.selectedRange.location + textView.selectedRange.length), + .text = [textAtSelection toCppString] + }); + [self emitCurrentSelectionColorIfChanged]; } // manage selection changes since textViewDidChangeSelection sometimes doesn't // run on focus diff --git a/ios/inputParser/InputParser.mm b/ios/inputParser/InputParser.mm index d6e96afd5..38bf86f70 100644 --- a/ios/inputParser/InputParser.mm +++ b/ios/inputParser/InputParser.mm @@ -4,6 +4,7 @@ #import "StyleHeaders.h" #import "TextInsertionUtils.h" #import "UIView+React.h" +#import "ColorExtension.h" @implementation InputParser { EnrichedTextInputView *_input; @@ -34,8 +35,11 @@ - (NSString *)parseToHtmlFromRange:(NSRange)range { BOOL inBlockQuote = NO; BOOL inCodeBlock = NO; unichar lastCharacter = 0; + + // Track current values for valued styles + UIColor *previousColor = nil; - for (int i = 0; i < text.length; i++) { + for(int i = 0; i < text.length; i++) { NSRange currentRange = NSMakeRange(offset + i, 1); NSMutableSet *currentActiveStyles = [[NSMutableSet alloc] init]; @@ -55,19 +59,28 @@ - (NSString *)parseToHtmlFromRange:(NSRange)range { [currentActiveStylesBeginning removeObjectForKey:type]; } } - - NSString *currentCharacterStr = - [_input->textView.textStorage.string substringWithRange:currentRange]; - unichar currentCharacterChar = [_input->textView.textStorage.string - characterAtIndex:currentRange.location]; - - if ([[NSCharacterSet newlineCharacterSet] - characterIsMember:currentCharacterChar]) { - if (newLine) { - // we can either have an empty list item OR need to close the list and - // put a BR in such a situation the existence of the list must be - // checked on 0 length range, not on the newline character - if (inOrderedList) { + + // Handle valued styles changes + UIColor *currentColor = nil; + + if([currentActiveStyles containsObject:@(Colored)]) { + ColorStyle *colorStyle = _input->stylesDict[@(Colored)]; + currentColor = [colorStyle getColorAt:currentRange.location]; + if(previousColor && ![currentColor isEqual:previousColor]) { + // Treat as end of previous color and start of new + [currentActiveStyles removeObject:@(Colored)]; + currentActiveStylesBeginning[@(Colored)] = [NSNumber numberWithInt:i]; + } + } + + NSString *currentCharacterStr = [_input->textView.textStorage.string substringWithRange:currentRange]; + unichar currentCharacterChar = [_input->textView.textStorage.string characterAtIndex:currentRange.location]; + + if([[NSCharacterSet newlineCharacterSet] characterIsMember:currentCharacterChar]) { + if(newLine) { + // we can either have an empty list item OR need to close the list and put a BR in such a situation + // the existence of the list must be checked on 0 length range, not on the newline character + if(inOrderedList) { OrderedListStyle *oStyle = _input->stylesDict[@(OrderedList)]; BOOL detected = [oStyle detectStyle:NSMakeRange(currentRange.location, 0)]; @@ -152,10 +165,11 @@ - (NSString *)parseToHtmlFromRange:(NSRange)range { [result appendString:@"

"]; } } - - // clear the previous styles - previousActiveStyles = [[NSSet alloc] init]; - + + // clear the previous styles and valued trackers + previousActiveStyles = [[NSSet alloc]init]; + previousColor = nil; + // next character opens new paragraph newLine = YES; } else { @@ -243,33 +257,21 @@ - (NSString *)parseToHtmlFromRange:(NSRange)range { } // get styles that have ended - NSMutableSet *endedStyles = - [previousActiveStyles mutableCopy]; - [endedStyles minusSet:currentActiveStyles]; - - // also finish styles that should be ended becasue they are nested in a - // style that ended + NSMutableSet *endedStyles = [previousActiveStyles mutableCopy]; + [endedStyles minusSet: currentActiveStyles]; + + // also finish styles that should be ended because they are nested in a style that ended NSMutableSet *fixedEndedStyles = [endedStyles mutableCopy]; NSMutableSet *stylesToBeReAdded = [[NSMutableSet alloc] init]; - + for (NSNumber *style in endedStyles) { - NSInteger styleBeginning = - [currentActiveStylesBeginning[style] integerValue]; - - for (NSNumber *activeStyle in currentActiveStyles) { - NSInteger activeStyleBeginning = - [currentActiveStylesBeginning[activeStyle] integerValue]; - - // we end the styles that began after the currently ended style but - // not at the "i" (cause the old style ended at exactly "i-1" also the - // ones that began in the exact same place but are "inner" in relation - // to them due to StyleTypeEnum integer values - - if ((activeStyleBeginning > styleBeginning && - activeStyleBeginning < i) || - (activeStyleBeginning == styleBeginning && - activeStyleBeginning< - i && [activeStyle integerValue]>[style integerValue])) { + NSInteger styleBeginning = [currentActiveStylesBeginning[style] integerValue]; + + for(NSNumber *activeStyle in currentActiveStyles) { + NSInteger activeStyleBeginning = [currentActiveStylesBeginning[activeStyle] integerValue]; + + if((activeStyleBeginning > styleBeginning && activeStyleBeginning < i) || + (activeStyleBeginning == styleBeginning && activeStyleBeginning < i && [activeStyle integerValue] > [style integerValue])) { [fixedEndedStyles addObject:activeStyle]; [stylesToBeReAdded addObject:activeStyle]; } @@ -341,6 +343,8 @@ - (NSString *)parseToHtmlFromRange:(NSRange)range { // save current styles for next character's checks previousActiveStyles = currentActiveStyles; + + previousColor = currentColor; } // set last character @@ -467,7 +471,18 @@ - (NSString *)tagContentForStyle:(NSNumber *)style return @"u"; } else if ([style isEqualToNumber:@([StrikethroughStyle getStyleType])]) { return @"s"; - } else if ([style isEqualToNumber:@([InlineCodeStyle getStyleType])]) { + } else if([style isEqualToNumber:@([ColorStyle getStyleType])]) { + if(openingTag) { + ColorStyle *colorSpan = _input->stylesDict[@([ColorStyle getStyleType])]; + UIColor *color = [colorSpan getColorAt: location]; + if(color) { + NSString *hex = [color hexString]; + return [NSString stringWithFormat:@"font color=\"%@\"", hex]; + }; + } else { + return @"font"; + } + } else if([style isEqualToNumber: @([InlineCodeStyle getStyleType])]) { return @"code"; } else if ([style isEqualToNumber:@([LinkStyle getStyleType])]) { if (openingTag) { @@ -631,9 +646,10 @@ - (void)applyProcessedStyles:(NSArray *)processedStyles params:params]; } else if ([styleType isEqualToNumber:@([ImageStyle getStyleType])]) { ImageData *imgData = (ImageData *)stylePair.styleValue; - [((ImageStyle *)baseStyle) addImageAtRange:styleRange - imageData:imgData - withSelection:NO]; + [((ImageStyle *)baseStyle) addImageAtRange:styleRange imageData:imgData withSelection:NO]; + } else if([styleType isEqualToNumber: @([ColorStyle getStyleType])]) { + UIColor *color = (UIColor *)stylePair.styleValue; + [((ColorStyle *)baseStyle) applyStyle:styleRange color: color]; } else { BOOL shouldAddTypingAttr = styleRange.location + styleRange.length == plainTextLength; @@ -1187,7 +1203,24 @@ - (NSArray *)getTextAndStylesFromHtml:(NSString *)fixedHtml { [styleArr addObject:@([UnderlineStyle getStyleType])]; } else if ([tagName isEqualToString:@"s"]) { [styleArr addObject:@([StrikethroughStyle getStyleType])]; - } else if ([tagName isEqualToString:@"code"]) { + } else if([tagName isEqualToString:@"font"]) { + [styleArr addObject:@([ColorStyle getStyleType])]; + + NSString *pattern = @"color=\"([^\"]+)\""; + NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:pattern options:0 error:nil]; + NSTextCheckingResult *match = [regex firstMatchInString:params options:0 range:NSMakeRange(0, params.length)]; + + if(match.numberOfRanges == 2) { + NSString *colorString = [params substringWithRange:[match rangeAtIndex:1]]; + + UIColor *color = [UIColor colorFromString:colorString]; + if(color == nil) { + continue; + } + + stylePair.styleValue = color; + } + } else if([tagName isEqualToString:@"code"]) { [styleArr addObject:@([InlineCodeStyle getStyleType])]; } else if ([tagName isEqualToString:@"a"]) { NSRegularExpression *hrefRegex = diff --git a/ios/styles/ColorStyle.mm b/ios/styles/ColorStyle.mm new file mode 100644 index 000000000..a26839f00 --- /dev/null +++ b/ios/styles/ColorStyle.mm @@ -0,0 +1,297 @@ +#import "StyleHeaders.h" +#import "EnrichedTextInputView.h" +#import "OccurenceUtils.h" +#import "FontExtension.h" +#import "StyleTypeEnum.h" +#import "ColorExtension.h" + +@implementation ColorStyle { + EnrichedTextInputView *_input; +} + ++ (StyleType)getStyleType { return Colored; } + +- (instancetype)initWithInput:(id)input { + self = [super init]; + _input = (EnrichedTextInputView *)input; + return self; +} + +- (void)applyStyle:(NSRange)range { +} + +- (void)applyStyle:(NSRange)range color:(UIColor *)color { + BOOL isStylePresent = [self detectStyle:range color:color]; + + if (range.length >= 1) { + isStylePresent ? [self removeAttributes:range] : [self addAttributes:range color: color]; + } else { + isStylePresent ? [self removeTypingAttributes] : [self addTypingAttributes: color]; + } +} + +#pragma mark - Add attributes + +- (void)addAttributes:(NSRange)range { +} + +- (void)addAttributes:(NSRange)range color:(UIColor *)color { + if (color == nil) return; + [_input->textView.textStorage beginEditing]; + [_input->textView.textStorage addAttributes:@{ + NSForegroundColorAttributeName: color, + NSUnderlineColorAttributeName: color, + NSStrikethroughColorAttributeName: color + } range:range]; + [_input->textView.textStorage endEditing]; + _color = color; +} + +- (void)addTypingAttributes { +} + +-(void)addTypingAttributes:(UIColor *)color { + NSMutableDictionary *newTypingAttrs = [_input->textView.typingAttributes mutableCopy]; + newTypingAttrs[NSForegroundColorAttributeName] = color; + newTypingAttrs[NSUnderlineColorAttributeName] = color; + newTypingAttrs[NSStrikethroughColorAttributeName] = color; + _input->textView.typingAttributes = newTypingAttrs; +} + +#pragma mark - Remove attributes + +- (void)removeAttributes:(NSRange)range { + NSTextStorage *textStorage = _input->textView.textStorage; + + LinkStyle *linkStyle = _input->stylesDict[@(Link)]; + InlineCodeStyle *inlineCodeStyle = _input->stylesDict[@(InlineCode)]; + BlockQuoteStyle *blockQuoteStyle = _input->stylesDict[@(BlockQuote)]; + MentionStyle *mentionStyle = _input->stylesDict[@(Mention)]; + + NSArray *linkOccurrences = [linkStyle findAllOccurences:range]; + NSArray *inlineOccurrences = [inlineCodeStyle findAllOccurences:range]; + NSArray *blockQuoteOccurrences = [blockQuoteStyle findAllOccurences:range]; + NSArray *mentionOccurrences = [mentionStyle findAllOccurences:range]; + + NSMutableSet *points = [NSMutableSet new]; + [points addObject:@(range.location)]; + [points addObject:@(NSMaxRange(range))]; + + for (StylePair *pair in linkOccurrences) { + [points addObject:@([pair.rangeValue rangeValue].location)]; + [points addObject:@(NSMaxRange([pair.rangeValue rangeValue]))]; + } + for (StylePair *pair in inlineOccurrences) { + [points addObject:@([pair.rangeValue rangeValue].location)]; + [points addObject:@(NSMaxRange([pair.rangeValue rangeValue]))]; + } + for (StylePair *pair in blockQuoteOccurrences) { + [points addObject:@([pair.rangeValue rangeValue].location)]; + [points addObject:@(NSMaxRange([pair.rangeValue rangeValue]))]; + } + for (StylePair *pair in mentionOccurrences) { + [points addObject:@([pair.rangeValue rangeValue].location)]; + [points addObject:@(NSMaxRange([pair.rangeValue rangeValue]))]; + } + + NSArray *sortedPoints = [points.allObjects sortedArrayUsingSelector:@selector(compare:)]; + + [textStorage beginEditing]; + for (NSUInteger i = 0; i < sortedPoints.count - 1; i++) { + NSUInteger start = sortedPoints[i].unsignedIntegerValue; + NSUInteger end = sortedPoints[i + 1].unsignedIntegerValue; + if (start >= end) continue; + + NSRange subrange = NSMakeRange(start, end - start); + + UIColor *baseColor = [self baseColorForLocation: subrange.location]; + + [textStorage addAttribute:NSForegroundColorAttributeName value:baseColor range:subrange]; + [textStorage addAttribute:NSUnderlineColorAttributeName value:baseColor range:subrange]; + [textStorage addAttribute:NSStrikethroughColorAttributeName value:baseColor range:subrange]; + } + [textStorage endEditing]; +} + +- (void)removeTypingAttributes { + NSMutableDictionary *newTypingAttrs = [_input->textView.typingAttributes mutableCopy]; + NSRange selectedRange = _input->textView.selectedRange; + NSUInteger location = selectedRange.location; + + UIColor *baseColor = [self baseColorForLocation:location]; + + newTypingAttrs[NSForegroundColorAttributeName] = baseColor; + newTypingAttrs[NSUnderlineColorAttributeName] = baseColor; + newTypingAttrs[NSStrikethroughColorAttributeName] = baseColor; + _input->textView.typingAttributes = newTypingAttrs; +} + +#pragma mark - Detection + +-(BOOL)isInStyle:(NSRange) range styleType:(StyleType)styleType { + id style = _input->stylesDict[@(styleType)]; + + return (range.length > 0 + ? [style anyOccurence:range] + : [style detectStyle:range]); +} + +- (BOOL)inLinkAndForegroundColorIsLinkColor:(id)value :(NSRange)range { + BOOL isInLink = [self isInStyle:range styleType: Link]; + + return isInLink && [(UIColor *)value isEqualToColor:[_input->config linkColor]]; +} + +- (BOOL)inInlineCodeAndHasTheSameColor:(id)value :(NSRange)range { + BOOL isInInlineCode = [self isInStyle:range styleType:InlineCode]; + + return isInInlineCode && [(UIColor *)value isEqualToColor:[_input->config inlineCodeFgColor]]; +} + +- (BOOL)inBlockQuoteAndHasTheSameColor:(id)value :(NSRange)range { + BOOL isInBlockQuote = [self isInStyle:range styleType:BlockQuote]; + + return isInBlockQuote && [(UIColor *)value isEqualToColor:[_input->config blockquoteColor]]; +} + +- (BOOL)inMentionAndHasTheSameColor:(id)value :(NSRange)range { + MentionStyle *mentionStyle = _input->stylesDict[@(Mention)]; + BOOL isInMention = [self isInStyle:range styleType:Mention]; + + if (!isInMention) return NO; + + MentionParams *params = [mentionStyle getMentionParamsAt:range.location]; + if (params == nil) return NO; + + MentionStyleProps *styleProps = [_input->config mentionStylePropsForIndicator:params.indicator]; + + return [(UIColor *)value isEqualToColor:styleProps.color]; +} + +- (BOOL)styleCondition:(id)value :(NSRange)range { + if (value == nil) { return NO; } + + if ([(UIColor *)value isEqualToColor:_input->config.primaryColor]) { return NO; } + if ([self inBlockQuoteAndHasTheSameColor:value :range]) { return NO; } + if ([self inLinkAndForegroundColorIsLinkColor:value :range]) { return NO; } + if ([self inInlineCodeAndHasTheSameColor:value :range]) { return NO; } + if ([self inMentionAndHasTheSameColor:value :range]) { return NO; } + + return YES; +} + +- (BOOL)detectStyle:(NSRange)range { + UIColor *color = [self getColorInRange:range]; + + return [self detectStyle:range color:color]; +} + +- (BOOL)detectStyle:(NSRange)range color:(UIColor *)color { + if(range.length >= 1) { + return [OccurenceUtils detect:NSForegroundColorAttributeName withInput:_input inRange:range + withCondition: ^BOOL(id _Nullable value, NSRange range) { + return [(UIColor *)value isEqualToColor:color] && [self styleCondition:value :range]; + } + ]; + } else { + return [OccurenceUtils detect:NSForegroundColorAttributeName withInput:_input atIndex:range.location checkPrevious:YES + withCondition:^BOOL(id _Nullable value, NSRange range) { + return [(UIColor *)value isEqualToColor:color] && [self styleCondition:value :range]; + } + ]; + } +} + +- (BOOL)detectExcludingColor:(UIColor *)excludedColor inRange:(NSRange)range { + if (![self detectStyle:range]) { + return NO; + } + UIColor *currentColor = [self getColorInRange:range]; + return currentColor != nil && ![currentColor isEqualToColor:excludedColor]; +} + +- (BOOL)anyOccurence:(NSRange)range { + return [OccurenceUtils any:NSForegroundColorAttributeName withInput:_input inRange:range + withCondition:^BOOL(id _Nullable value, NSRange range) { + return [self styleCondition:value :range]; + } + ]; +} + +- (NSArray *_Nullable)findAllOccurences:(NSRange)range { + return [OccurenceUtils all:NSForegroundColorAttributeName withInput:_input inRange:range + withCondition:^BOOL(id _Nullable value, NSRange range) { + return [self styleCondition:value :range]; + } + ]; +} + +- (UIColor *)getColorAt:(NSUInteger)location { + NSRange effectiveRange = NSMakeRange(0, 0); + NSRange inputRange = NSMakeRange(0, _input->textView.textStorage.length); + + if(location == _input->textView.textStorage.length) { + UIColor *typingColor = _input->textView.typingAttributes[NSForegroundColorAttributeName]; + return typingColor ?: [_input->config primaryColor]; + } + + return [_input->textView.textStorage + attribute:NSForegroundColorAttributeName + atIndex:location + longestEffectiveRange: &effectiveRange + inRange:inputRange + ]; +} + +- (UIColor *)getColorInRange:(NSRange)range { + NSUInteger location = range.location; + NSUInteger length = range.length; + + NSRange effectiveRange = NSMakeRange(0, 0); + NSRange inputRange = NSMakeRange(0, _input->textView.textStorage.length); + + if(location == _input->textView.textStorage.length) { + UIColor *typingColor = _input->textView.typingAttributes[NSForegroundColorAttributeName]; + return typingColor ?: [_input->config primaryColor]; + } + + NSUInteger queryLocation = location; + if (length == 0 && location > 0) { + queryLocation = location - 1; + } + + UIColor *color = [_input->textView.textStorage + attribute:NSForegroundColorAttributeName + atIndex:queryLocation + longestEffectiveRange: &effectiveRange + inRange:inputRange + ]; + + return color; +} + +- (UIColor *)baseColorForLocation:(NSUInteger)location { + BOOL inLink = [self isInStyle:NSMakeRange(location, 0) styleType:Link]; + BOOL inInlineCode = [self isInStyle:NSMakeRange(location, 0) styleType:InlineCode]; + BOOL inBlockQuote = [self isInStyle:NSMakeRange(location, 0) styleType:BlockQuote]; + BOOL inMention = [self isInStyle:NSMakeRange(location, 0) styleType:Mention]; + + UIColor *baseColor = [_input->config primaryColor]; + if (inMention) { + MentionStyle *mentionStyle = _input->stylesDict[@(Mention)]; + MentionParams *params = [mentionStyle getMentionParamsAt:location]; + if (params != nil) { + MentionStyleProps *styleProps = [_input->config mentionStylePropsForIndicator:params.indicator]; + baseColor = styleProps.color; + } + } else if (inLink) { + baseColor = [_input->config linkColor]; + } else if (inInlineCode) { + baseColor = [_input->config inlineCodeFgColor]; + } else if (inBlockQuote) { + baseColor = [_input->config blockquoteColor]; + } + return baseColor; +} + +@end diff --git a/ios/utils/ColorExtension.h b/ios/utils/ColorExtension.h index 1ed38519a..f11b8c871 100644 --- a/ios/utils/ColorExtension.h +++ b/ios/utils/ColorExtension.h @@ -5,3 +5,11 @@ - (BOOL)isEqualToColor:(UIColor *)otherColor; - (UIColor *)colorWithAlphaIfNotTransparent:(CGFloat)newAlpha; @end + +@interface UIColor (FromString) ++ (UIColor *)colorFromString:(NSString *)string; +@end + +@interface UIColor (HexString) +- (NSString *)hexString; +@end diff --git a/ios/utils/ColorExtension.mm b/ios/utils/ColorExtension.mm index 0cc59b727..b12ea9230 100644 --- a/ios/utils/ColorExtension.mm +++ b/ios/utils/ColorExtension.mm @@ -36,3 +36,157 @@ - (UIColor *)colorWithAlphaIfNotTransparent:(CGFloat)newAlpha { return self; } @end + +@implementation UIColor (FromString) ++ (UIColor *)colorFromString:(NSString *)string { + if (!string) return nil; + + NSString *input = [[string stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] lowercaseString]; + + // Expanded named colors (add more from CSS list as needed) + NSDictionary *namedColors = @{ + @"red": [UIColor redColor], + @"green": [UIColor greenColor], + @"blue": [UIColor blueColor], + @"black": [UIColor blackColor], + @"white": [UIColor whiteColor], + @"yellow": [UIColor yellowColor], + @"gray": [UIColor grayColor], + @"grey": [UIColor grayColor], + @"transparent": [UIColor clearColor], + @"aliceblue": [UIColor colorWithRed:0.941 green:0.973 blue:1.0 alpha:1.0], + // Add additional CSS names here, e.g., "chartreuse": [UIColor colorWithRed:0.498 green:1.0 blue:0.0 alpha:1.0], + }; + + UIColor *namedColor = namedColors[input]; + if (namedColor) return namedColor; + + // Hex parsing (including short forms) + if ([input hasPrefix:@"#"]) { + NSString *hex = [input substringFromIndex:1]; + if (hex.length == 3 || hex.length == 4) { // Short hex: #rgb or #rgba + NSMutableString *expanded = [NSMutableString string]; + for (NSUInteger i = 0; i < hex.length; i++) { + unichar c = [hex characterAtIndex:i]; + [expanded appendFormat:@"%c%c", c, c]; + } + hex = expanded; + } + if (hex.length == 6 || hex.length == 8) { + unsigned int hexValue = 0; + NSScanner *scanner = [NSScanner scannerWithString:hex]; + if ([scanner scanHexInt:&hexValue]) { + CGFloat r, g, b, a = 1.0; + if (hex.length == 6) { + r = ((hexValue & 0xFF0000) >> 16) / 255.0; + g = ((hexValue & 0x00FF00) >> 8) / 255.0; + b = (hexValue & 0x0000FF) / 255.0; + } else { + r = ((hexValue & 0xFF000000) >> 24) / 255.0; + g = ((hexValue & 0x00FF0000) >> 16) / 255.0; + b = ((hexValue & 0x0000FF00) >> 8) / 255.0; + a = (hexValue & 0x000000FF) / 255.0; + } + return [UIColor colorWithRed:r green:g blue:b alpha:a]; + } + } + return nil; + } + + // RGB/RGBA parsing (with percentages) + if ([input hasPrefix:@"rgb"] || [input hasPrefix:@"rgba"]) { + NSString *clean = [input stringByReplacingOccurrencesOfString:@"rgb" withString:@""]; + clean = [clean stringByReplacingOccurrencesOfString:@"a" withString:@""]; + clean = [clean stringByReplacingOccurrencesOfString:@"(" withString:@""]; + clean = [clean stringByReplacingOccurrencesOfString:@")" withString:@""]; + clean = [clean stringByReplacingOccurrencesOfString:@" " withString:@""]; + + NSArray *parts = [clean componentsSeparatedByString:@","]; + if (parts.count == 3 || parts.count == 4) { + CGFloat r = [self parseColorComponent:parts[0] max:255.0]; + CGFloat g = [self parseColorComponent:parts[1] max:255.0]; + CGFloat b = [self parseColorComponent:parts[2] max:255.0]; + CGFloat a = parts.count == 4 ? [self parseColorComponent:parts[3] max:1.0] : 1.0; + if (r >= 0 && g >= 0 && b >= 0 && a >= 0) { + return [UIColor colorWithRed:r green:g blue:b alpha:a]; + } + } + return nil; + } + + // HSL/HSLA parsing (basic implementation) + if ([input hasPrefix:@"hsl"] || [input hasPrefix:@"hsla"]) { + NSString *clean = [input stringByReplacingOccurrencesOfString:@"hsl" withString:@""]; + clean = [clean stringByReplacingOccurrencesOfString:@"a" withString:@""]; + clean = [clean stringByReplacingOccurrencesOfString:@"(" withString:@""]; + clean = [clean stringByReplacingOccurrencesOfString:@")" withString:@""]; + clean = [clean stringByReplacingOccurrencesOfString:@" " withString:@""]; + + NSArray *parts = [clean componentsSeparatedByString:@","]; + if (parts.count == 3 || parts.count == 4) { + CGFloat h = [self parseColorComponent:parts[0] max:360.0]; + CGFloat s = [self parseColorComponent:parts[1] max:1.0]; + CGFloat l = [self parseColorComponent:parts[2] max:1.0]; + CGFloat a = parts.count == 4 ? [self parseColorComponent:parts[3] max:1.0] : 1.0; + return [UIColor colorWithHue:h / 360.0 saturation:s brightness:l alpha:a]; // Note: Uses HSB approximation + } + return nil; + } + + return nil; +} + ++ (CGFloat)parseColorComponent:(NSString *)comp max:(CGFloat)max { + if ([comp hasSuffix:@"%"]) { + comp = [comp stringByReplacingOccurrencesOfString:@"%" withString:@""]; + return [comp floatValue] / 100.0; + } + return [comp floatValue] / max; +} +@end + +@implementation UIColor (HexString) +- (NSString *)hexString { + CGColorRef colorRef = self.CGColor; + size_t numComponents = CGColorGetNumberOfComponents(colorRef); + const CGFloat *components = CGColorGetComponents(colorRef); + + CGFloat r = 0.0, g = 0.0, b = 0.0, a = 1.0; + + if (numComponents == 2) { // Monochrome (grayscale) + r = components[0]; + g = components[0]; + b = components[0]; + a = components[1]; + } else if (numComponents == 4) { // RGBA + r = components[0]; + g = components[1]; + b = components[2]; + a = components[3]; + } else if (numComponents == 3) { // RGB (no alpha) + r = components[0]; + g = components[1]; + b = components[2]; + } else { + // Unsupported color space (e.g., pattern colors) + return @"#FFFFFF"; // Or return nil for better error handling + } + + int red = (int)lroundf(r * 255.0f); + int green = (int)lroundf(g * 255.0f); + int blue = (int)lroundf(b * 255.0f); + int alpha = (int)lroundf(a * 255.0f); + + // Clamp values to 0-255 to prevent overflow + red = MAX(0, MIN(255, red)); + green = MAX(0, MIN(255, green)); + blue = MAX(0, MIN(255, blue)); + alpha = MAX(0, MIN(255, alpha)); + + if (alpha < 255) { + return [NSString stringWithFormat:@"#%02X%02X%02X%02X", red, green, blue, alpha]; + } else { + return [NSString stringWithFormat:@"#%02X%02X%02X", red, green, blue]; + } +} +@end diff --git a/ios/utils/ColorUtils.m b/ios/utils/ColorUtils.m new file mode 100644 index 000000000..011b0554f --- /dev/null +++ b/ios/utils/ColorUtils.m @@ -0,0 +1,8 @@ +// +// ColorUtils.m +// ReactNativeEnriched +// +// Created by Ivan Ignathuk on 19/11/2025. +// + +#import diff --git a/ios/utils/StyleHeaders.h b/ios/utils/StyleHeaders.h index 635242009..3d290d552 100644 --- a/ios/utils/StyleHeaders.h +++ b/ios/utils/StyleHeaders.h @@ -20,6 +20,13 @@ - (void)handleNewlines; @end +@interface ColorStyle : NSObject +@property (nonatomic, strong) UIColor *color; +- (UIColor *)getColorAt:(NSUInteger)location; +- (void)applyStyle:(NSRange)range color:(UIColor *)color; +- (BOOL)detectExcludingColor:(UIColor *)excludedColor inRange:(NSRange)range; +@end + @interface LinkStyle : NSObject - (void)addLink:(NSString *)text url:(NSString *)url diff --git a/ios/utils/StyleTypeEnum.h b/ios/utils/StyleTypeEnum.h index b19fcb087..d09d251fc 100644 --- a/ios/utils/StyleTypeEnum.h +++ b/ios/utils/StyleTypeEnum.h @@ -21,5 +21,6 @@ typedef NS_ENUM(NSInteger, StyleType) { Italic, Underline, Strikethrough, + Colored, None, }; diff --git a/src/EnrichedTextInput.tsx b/src/EnrichedTextInput.tsx index 2d8105b74..04fafc967 100644 --- a/src/EnrichedTextInput.tsx +++ b/src/EnrichedTextInput.tsx @@ -20,6 +20,7 @@ import EnrichedTextInputNativeComponent, { type OnRequestHtmlResultEvent, type MentionStyleProperties, type OnChangeStateDeprecatedEvent, + type OnChangeColorEvent, } from './EnrichedTextInputNativeComponent'; import type { ColorValue, @@ -68,6 +69,7 @@ export interface EnrichedTextInputInstance extends NativeMethods { text: string, attributes?: Record ) => void; + toggleColor: (color: string) => void; } export interface OnChangeMentionEvent { @@ -153,6 +155,9 @@ export interface EnrichedTextInputProps extends Omit { onChangeMention?: (e: OnChangeMentionEvent) => void; onEndMention?: (indicator: string) => void; onChangeSelection?: (e: NativeSyntheticEvent) => void; + onColorChangeInSelection?: ( + color: NativeSyntheticEvent + ) => void; /** * If true, Android will use experimental synchronous events. * This will prevent from input flickering when updating component size. @@ -210,6 +215,7 @@ export const EnrichedTextInput = ({ onChangeMention, onEndMention, onChangeSelection, + onColorChangeInSelection, androidExperimentalSynchronousEvents = false, scrollEnabled = true, ...rest @@ -352,6 +358,9 @@ export const EnrichedTextInput = ({ setSelection: (start: number, end: number) => { Commands.setSelection(nullthrows(nativeRef.current), start, end); }, + toggleColor: (color: string) => { + Commands.toggleColor(nullthrows(nativeRef.current), color); + }, })); const handleMentionEvent = (e: NativeSyntheticEvent) => { @@ -426,6 +435,7 @@ export const EnrichedTextInput = ({ onMention={handleMentionEvent} onChangeSelection={onChangeSelection} onRequestHtmlResult={handleRequestHtmlResult} + onColorChangeInSelection={onColorChangeInSelection} androidExperimentalSynchronousEvents={ androidExperimentalSynchronousEvents } diff --git a/src/EnrichedTextInputNativeComponent.ts b/src/EnrichedTextInputNativeComponent.ts index 907fc178b..f63083837 100644 --- a/src/EnrichedTextInputNativeComponent.ts +++ b/src/EnrichedTextInputNativeComponent.ts @@ -32,6 +32,11 @@ export interface OnChangeStateEvent { isConflicting: boolean; isBlocking: boolean; }; + colored: { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; italic: { isActive: boolean; isConflicting: boolean; @@ -138,6 +143,7 @@ export interface OnChangeStateDeprecatedEvent { isLink: boolean; isImage: boolean; isMention: boolean; + isColored: boolean; } export interface OnLinkDetected { @@ -186,6 +192,10 @@ type Heading = { bold?: boolean; }; +export interface OnChangeColorEvent { + color: string | null; +} + export interface HtmlStyleInternal { h1?: Heading; h2?: Heading; @@ -256,6 +266,7 @@ export interface NativeProps extends ViewProps { onMention?: DirectEventHandler; onChangeSelection?: DirectEventHandler; onRequestHtmlResult?: DirectEventHandler; + onColorChangeInSelection?: DirectEventHandler; // Style related props - used for generating proper setters in component's manager // These should not be passed as regular props @@ -330,6 +341,10 @@ interface NativeCommands { viewRef: React.ElementRef, requestId: Int32 ) => void; + toggleColor: ( + viewRef: React.ElementRef, + color: string + ) => void; } export const Commands: NativeCommands = codegenNativeCommands({ @@ -361,6 +376,7 @@ export const Commands: NativeCommands = codegenNativeCommands({ 'startMention', 'addMention', 'requestHTML', + 'toggleColor', ], }); From 4a1814c2c477d4fe10744eca114332d800c230ee Mon Sep 17 00:00:00 2001 From: IvanIhnatsiuk Date: Wed, 19 Nov 2025 23:37:37 +0100 Subject: [PATCH 02/12] fix: remove unused file --- ios/utils/ColorUtils.m | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 ios/utils/ColorUtils.m diff --git a/ios/utils/ColorUtils.m b/ios/utils/ColorUtils.m deleted file mode 100644 index 011b0554f..000000000 --- a/ios/utils/ColorUtils.m +++ /dev/null @@ -1,8 +0,0 @@ -// -// ColorUtils.m -// ReactNativeEnriched -// -// Created by Ivan Ignathuk on 19/11/2025. -// - -#import From e0b99b86e4a187ffb2db5622e8e431eaa06ba6a1 Mon Sep 17 00:00:00 2001 From: IvanIhnatsiuk Date: Mon, 24 Nov 2025 23:05:07 +0100 Subject: [PATCH 03/12] fix: rebase with latest main --- ios/styles/ColorStyle.mm | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ios/styles/ColorStyle.mm b/ios/styles/ColorStyle.mm index a26839f00..ea576cab6 100644 --- a/ios/styles/ColorStyle.mm +++ b/ios/styles/ColorStyle.mm @@ -20,6 +20,8 @@ - (instancetype)initWithInput:(id)input { - (void)applyStyle:(NSRange)range { } ++ (BOOL)isParagraphStyle { return NO; } + - (void)applyStyle:(NSRange)range color:(UIColor *)color { BOOL isStylePresent = [self detectStyle:range color:color]; @@ -136,6 +138,12 @@ -(BOOL)isInStyle:(NSRange) range styleType:(StyleType)styleType { : [style detectStyle:range]); } +- (BOOL)isInCodeBlockAndHasTheSameColor:(id)value :(NSRange)range { + BOOL isInCodeBlock = [self isInStyle:range styleType: CodeBlock]; + + return isInCodeBlock && [(UIColor *)value isEqualToColor:[_input->config codeBlockFgColor]]; +} + - (BOOL)inLinkAndForegroundColorIsLinkColor:(id)value :(NSRange)range { BOOL isInLink = [self isInStyle:range styleType: Link]; @@ -176,6 +184,7 @@ - (BOOL)styleCondition:(id)value :(NSRange)range { if ([self inLinkAndForegroundColorIsLinkColor:value :range]) { return NO; } if ([self inInlineCodeAndHasTheSameColor:value :range]) { return NO; } if ([self inMentionAndHasTheSameColor:value :range]) { return NO; } + if ([self isInCodeBlockAndHasTheSameColor:value :range]) { return NO; } return YES; } From 4ecf7af27ccac31bf7491487a146d3cb86f2419f Mon Sep 17 00:00:00 2001 From: IvanIhnatsiuk Date: Fri, 5 Dec 2025 23:14:48 +0100 Subject: [PATCH 04/12] fix: add setColor and removeColor methods --- .../enriched/EnrichedTextInputViewManager.kt | 6 +++++- apps/example/src/App.tsx | 11 ++++++++++- apps/example/src/components/ColorPreview.tsx | 5 ++--- apps/example/src/components/Toolbar.tsx | 2 +- ios/EnrichedTextInputView.mm | 9 ++++++--- ios/inputParser/InputParser.mm | 7 +++++-- ios/utils/ColorExtension.mm | 3 +-- src/EnrichedTextInput.tsx | 10 +++++++--- src/EnrichedTextInputNativeComponent.ts | 9 ++++----- src/index.tsx | 1 + 10 files changed, 42 insertions(+), 21 deletions(-) diff --git a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt index 2d0719085..e0d87a3c1 100644 --- a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt @@ -338,7 +338,11 @@ class EnrichedTextInputViewManager : view?.verifyAndToggleStyle(EnrichedSpans.UNORDERED_LIST) } - override fun toggleColor(view: EnrichedTextInputView?, color: String) { + override fun setColor(view: EnrichedTextInputView?, color: String) { + // no-op for now + } + + override fun removeColor(view: EnrichedTextInputView?) { // no-op for now } diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx index 11b393cc2..1211831eb 100644 --- a/apps/example/src/App.tsx +++ b/apps/example/src/App.tsx @@ -27,7 +27,7 @@ import { prepareImageDimensions, } from './utils/prepareImageDimensions'; import type { OnChangeColorEvent } from '../../src/EnrichedTextInputNativeComponent'; -import ColorPreview from './components/ColorPreview'; +import { ColorPreview } from './components/ColorPreview'; type StylesState = OnChangeStateEvent; @@ -305,6 +305,10 @@ export default function App() { } }; + const handleRemoveColor = () => { + ref.current?.removeColor(); + }; + return ( <> +