diff --git a/example/src/App.tsx b/example/src/App.tsx index 4763e3ae..3c29531f 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -4,6 +4,7 @@ import { Text, type NativeSyntheticEvent, ScrollView, + Platform, } from 'react-native'; import { EnrichedTextInput, @@ -202,7 +203,11 @@ export default function App() { selectionLimit: 1, }); - const imageUri = response.assets?.[0]?.originalPath; + const imageUri = + Platform.OS === 'android' + ? response.assets?.[0]?.originalPath + : response.assets?.[0]?.uri; + if (!imageUri) return; ref.current?.setImage(imageUri); diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index c3b3739a..f145feef 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -94,7 +94,8 @@ - (void)setDefaults { @([UnorderedListStyle getStyleType]): [[UnorderedListStyle alloc] initWithInput:self], @([OrderedListStyle getStyleType]): [[OrderedListStyle alloc] initWithInput:self], @([BlockQuoteStyle getStyleType]): [[BlockQuoteStyle alloc] initWithInput:self], - @([CodeBlockStyle getStyleType]): [[CodeBlockStyle alloc] initWithInput:self] + @([CodeBlockStyle getStyleType]): [[CodeBlockStyle alloc] initWithInput:self], + @([ImageStyle getStyleType]): [[ImageStyle alloc] initWithInput:self] }; conflictingStyles = @{ @@ -112,7 +113,8 @@ - (void)setDefaults { @([OrderedListStyle getStyleType]): @[@([H1Style getStyleType]), @([H2Style getStyleType]), @([H3Style getStyleType]), @([UnorderedListStyle getStyleType]), @([BlockQuoteStyle getStyleType]), @([CodeBlockStyle getStyleType])], @([BlockQuoteStyle getStyleType]): @[@([H1Style getStyleType]), @([H2Style getStyleType]), @([H3Style getStyleType]), @([UnorderedListStyle getStyleType]), @([OrderedListStyle getStyleType]), @([CodeBlockStyle getStyleType])], @([CodeBlockStyle getStyleType]): @[@([H1Style getStyleType]), @([H2Style getStyleType]), @([H3Style getStyleType]), - @([BoldStyle getStyleType]), @([ItalicStyle getStyleType]), @([UnderlineStyle getStyleType]), @([StrikethroughStyle getStyleType]), @([UnorderedListStyle getStyleType]), @([OrderedListStyle getStyleType]), @([BlockQuoteStyle getStyleType]), @([InlineCodeStyle getStyleType]), @([MentionStyle getStyleType]), @([LinkStyle getStyleType])] + @([BoldStyle getStyleType]), @([ItalicStyle getStyleType]), @([UnderlineStyle getStyleType]), @([StrikethroughStyle getStyleType]), @([UnorderedListStyle getStyleType]), @([OrderedListStyle getStyleType]), @([BlockQuoteStyle getStyleType]), @([InlineCodeStyle getStyleType]), @([MentionStyle getStyleType]), @([LinkStyle getStyleType])], + @([ImageStyle getStyleType]) : @[@([LinkStyle getStyleType]), @([MentionStyle getStyleType])] }; blockingStyles = @{ @@ -120,9 +122,9 @@ - (void)setDefaults { @([ItalicStyle getStyleType]) : @[@([CodeBlockStyle getStyleType])], @([UnderlineStyle getStyleType]) : @[@([CodeBlockStyle getStyleType])], @([StrikethroughStyle getStyleType]) : @[@([CodeBlockStyle getStyleType])], - @([InlineCodeStyle getStyleType]) : @[@([CodeBlockStyle getStyleType])], - @([LinkStyle getStyleType]): @[@([CodeBlockStyle getStyleType])], - @([MentionStyle getStyleType]): @[@([CodeBlockStyle getStyleType])], + @([InlineCodeStyle getStyleType]) : @[@([CodeBlockStyle getStyleType]), @([ImageStyle getStyleType])], + @([LinkStyle getStyleType]): @[@([CodeBlockStyle getStyleType]), @([ImageStyle getStyleType])], + @([MentionStyle getStyleType]): @[@([CodeBlockStyle getStyleType]), @([ImageStyle getStyleType])], @([H1Style getStyleType]): @[], @([H2Style getStyleType]): @[], @([H3Style getStyleType]): @[], @@ -130,6 +132,7 @@ - (void)setDefaults { @([OrderedListStyle getStyleType]): @[], @([BlockQuoteStyle getStyleType]): @[], @([CodeBlockStyle getStyleType]): @[], + @([ImageStyle getStyleType]) : @[@([InlineCodeStyle getStyleType])] }; parser = [[InputParser alloc] initWithInput:self]; @@ -368,6 +371,16 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & stylePropChanged = YES; } + if(newViewProps.htmlStyle.img.width != oldViewProps.htmlStyle.img.width) { + [newConfig setImageWidth:newViewProps.htmlStyle.img.width]; + stylePropChanged = YES; + } + + if(newViewProps.htmlStyle.img.height != oldViewProps.htmlStyle.img.height) { + [newConfig setImageHeight:newViewProps.htmlStyle.img.height]; + stylePropChanged = YES; + } + if(newViewProps.htmlStyle.a.textDecorationLine != oldViewProps.htmlStyle.a.textDecorationLine) { NSString *objcString = [NSString fromCppString:newViewProps.htmlStyle.a.textDecorationLine]; if([objcString isEqualToString:DecorationUnderline]) { @@ -723,7 +736,7 @@ - (void)tryUpdatingActiveStyles { .isOrderedList = [_activeStyles containsObject: @([OrderedListStyle getStyleType])], .isBlockQuote = [_activeStyles containsObject: @([BlockQuoteStyle getStyleType])], .isCodeBlock = [_activeStyles containsObject: @([CodeBlockStyle getStyleType])], - .isImage = NO // [_activeStyles containsObject: @([ImageStyle getStyleType]])], + .isImage = [_activeStyles containsObject: @([ImageStyle getStyleType])], }); } } @@ -793,6 +806,9 @@ - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args { [self toggleParagraphStyle:[BlockQuoteStyle getStyleType]]; } else if([commandName isEqualToString:@"toggleCodeBlock"]) { [self toggleParagraphStyle:[CodeBlockStyle getStyleType]]; + } else if([commandName isEqualToString:@"addImage"]) { + NSString *uri = (NSString *)args[0]; + [self addImage:uri]; } } @@ -939,6 +955,17 @@ - (void)addMention:(NSString *)indicator text:(NSString *)text attributes:(NSStr } } +- (void)addImage:(NSString *)uri +{ + ImageStyle *imageStyleClass = (ImageStyle *)stylesDict[@([ImageStyle getStyleType])]; + if(imageStyleClass == nullptr) { return; } + + if([self handleStyleBlocksAndConflicts:[ImageStyle getStyleType] range:textView.selectedRange]) { + [imageStyleClass addImage:uri]; + [self anyTextMayHaveBeenModified]; + } +} + - (void)startMentionWithIndicator:(NSString *)indicator { MentionStyle *mentionStyleClass = (MentionStyle *)stylesDict[@([MentionStyle getStyleType])]; if(mentionStyleClass == nullptr) { return; } diff --git a/ios/config/InputConfig.h b/ios/config/InputConfig.h index 4778807c..e7393541 100644 --- a/ios/config/InputConfig.h +++ b/ios/config/InputConfig.h @@ -70,4 +70,8 @@ - (void)setCodeBlockBgColor:(UIColor *)newValue; - (CGFloat)codeBlockBorderRadius; - (void)setCodeBlockBorderRadius:(CGFloat)newValue; +- (void)setImageWidth:(CGFloat)newValue; +- (CGFloat)imageWidth; +- (void)setImageHeight:(CGFloat)newValue; +- (CGFloat)imageHeight; @end diff --git a/ios/config/InputConfig.mm b/ios/config/InputConfig.mm index 10dbff10..0e5287c7 100644 --- a/ios/config/InputConfig.mm +++ b/ios/config/InputConfig.mm @@ -39,6 +39,8 @@ @implementation InputConfig { UIColor *_codeBlockFgColor; CGFloat _codeBlockBorderRadius; UIColor *_codeBlockBgColor; + CGFloat _imageWidth; + CGFloat _imageHeight; } - (instancetype) init { @@ -85,6 +87,8 @@ - (id)copyWithZone:(NSZone *)zone { copy->_codeBlockFgColor = [_codeBlockFgColor copy]; copy->_codeBlockBgColor = [_codeBlockBgColor copy]; copy->_codeBlockBorderRadius = _codeBlockBorderRadius; + copy->_imageWidth = _imageWidth; + copy->_imageHeight = _imageHeight; return copy; } @@ -409,4 +413,20 @@ - (void)setCodeBlockBorderRadius:(CGFloat)newValue { _codeBlockBorderRadius = newValue; } +- (CGFloat)imageWidth { + return _imageWidth; +} + +- (void)setImageWidth:(CGFloat)newValue { + _imageWidth = newValue; +} + +- (CGFloat)imageHeight { + return _imageHeight; +} + +- (void)setImageHeight:(CGFloat)newValue { + _imageHeight = newValue; +} + @end diff --git a/ios/inputParser/InputParser.mm b/ios/inputParser/InputParser.mm index 981e298b..10f250ab 100644 --- a/ios/inputParser/InputParser.mm +++ b/ios/inputParser/InputParser.mm @@ -86,6 +86,9 @@ - (NSString *)parseToHtmlFromRange:(NSRange)range { // append closing tags for(NSNumber *style in sortedEndedStyles) { + if ([style isEqualToNumber: @([ImageStyle getStyleType])]) { + continue; + } NSString *tagContent = [self tagContentForStyle:style openingTag:NO location:currentRange.location]; [result appendString: [NSString stringWithFormat:@"", tagContent]]; } @@ -221,6 +224,9 @@ - (NSString *)parseToHtmlFromRange:(NSRange)range { // append closing tags for(NSNumber *style in sortedEndedStyles) { + if ([style isEqualToNumber: @([ImageStyle getStyleType])]) { + continue; + } NSString *tagContent = [self tagContentForStyle:style openingTag:NO location:currentRange.location]; [result appendString: [NSString stringWithFormat:@"", tagContent]]; } @@ -233,7 +239,11 @@ - (NSString *)parseToHtmlFromRange:(NSRange)range { // append opening tags for(NSNumber *style in sortedNewStyles) { NSString *tagContent = [self tagContentForStyle:style openingTag:YES location:currentRange.location]; - [result appendString: [NSString stringWithFormat:@"<%@>", tagContent]]; + if ([style isEqualToNumber: @([ImageStyle getStyleType])]) { + [result appendString: [NSString stringWithFormat:@"<%@/>", tagContent]]; + } else { + [result appendString: [NSString stringWithFormat:@"<%@>", tagContent]]; + } } // append the letter and escape it if needed @@ -254,6 +264,9 @@ - (NSString *)parseToHtmlFromRange:(NSRange)range { // append closing tags for(NSNumber *style in sortedEndedStyles) { + if ([style isEqualToNumber: @([ImageStyle getStyleType])]) { + continue; + } NSString *tagContent = [self tagContentForStyle:style openingTag:NO location:_input->textView.textStorage.string.length - 1]; [result appendString: [NSString stringWithFormat:@"", tagContent]]; } @@ -310,6 +323,19 @@ - (NSString *)tagContentForStyle:(NSNumber *)style openingTag:(BOOL)openingTag l return @"b"; } else if([style isEqualToNumber: @([ItalicStyle getStyleType])]) { return @"i"; + } else if ([style isEqualToNumber: @([ImageStyle getStyleType])]) { + if(openingTag) { + ImageStyle *imageStyle = (ImageStyle *)_input->stylesDict[@([ImageStyle getStyleType])]; + if(imageStyle != nullptr) { + ImageData *data = [imageStyle getImageDataAt:location]; + if(data != nullptr && data.uri != nullptr) { + return [NSString stringWithFormat:@"img src=\"%@\"", data.uri]; + } + } + return @"img"; + } else { + return @""; + } } else if([style isEqualToNumber: @([UnderlineStyle getStyleType])]) { return @"u"; } else if([style isEqualToNumber: @([StrikethroughStyle getStyleType])]) { @@ -642,6 +668,25 @@ - (NSArray *)getTextAndStylesFromHtml:(NSString *)fixedHtml { [styleArr addObject:@([BoldStyle getStyleType])]; } else if([tagName isEqualToString:@"i"]) { [styleArr addObject:@([ItalicStyle getStyleType])]; + } else if([tagName isEqualToString:@"img"]) { + NSRegularExpression *srcRegex = [NSRegularExpression regularExpressionWithPattern:@"src=\".+\"" + options:0 + error:nullptr + ]; + NSTextCheckingResult* match = [srcRegex firstMatchInString:params options:0 range: NSMakeRange(0, params.length)]; + + if(match == nullptr) { + continue; + } + + NSRange srcRange = match.range; + [styleArr addObject:@([ImageStyle getStyleType])]; + // cut only the uri from the src="..." string + NSString *uri = [params substringWithRange:NSMakeRange(srcRange.location + 5, srcRange.length - 6)]; + ImageData *imageData = [[ImageData alloc] init]; + imageData.uri = uri; + + stylePair.styleValue = imageData; } else if([tagName isEqualToString:@"u"]) { [styleArr addObject:@([UnderlineStyle getStyleType])]; } else if([tagName isEqualToString:@"s"]) { diff --git a/ios/styles/ImageStyle.mm b/ios/styles/ImageStyle.mm new file mode 100644 index 00000000..276014b9 --- /dev/null +++ b/ios/styles/ImageStyle.mm @@ -0,0 +1,150 @@ +#import "StyleHeaders.h" +#import "EnrichedTextInputView.h" +#import "OccurenceUtils.h" +#import "TextInsertionUtils.h" + +// custom NSAttributedStringKey to differentiate the image +static NSString *const ImageAttributeName = @"ImageAttributeName"; + +@implementation ImageStyle { + EnrichedTextInputView *_input; +} + ++ (StyleType)getStyleType { return Image; } + ++ (BOOL)isParagraphStyle { return NO; } + +- (instancetype)initWithInput:(id)input { + self = [super init]; + _input = (EnrichedTextInputView *)input; + return self; +} + +- (void)applyStyle:(NSRange)range { + // no-op for image +} + +- (void)addAttributes:(NSRange)range { + // no-op for image +} + +- (void)addTypingAttributes { + // no-op for image +} + +- (void)removeAttributes:(NSRange)range { + [_input->textView.textStorage beginEditing]; + [_input->textView.textStorage removeAttribute:ImageAttributeName range:range]; + [_input->textView.textStorage removeAttribute:NSAttachmentAttributeName range:range]; + [_input->textView.textStorage endEditing]; +} + +- (void)removeTypingAttributes { + NSMutableDictionary *currentAttributes = [_input->textView.typingAttributes mutableCopy]; + [currentAttributes removeObjectForKey:ImageAttributeName]; + [currentAttributes removeObjectForKey:NSAttachmentAttributeName]; + _input->textView.typingAttributes = currentAttributes; +} + +- (BOOL)styleCondition:(id _Nullable)value :(NSRange)range { + return [value isKindOfClass:[ImageData class]]; +} + +- (BOOL)anyOccurence:(NSRange)range { + return [OccurenceUtils any:ImageAttributeName withInput:_input inRange:range + withCondition:^BOOL(id _Nullable value, NSRange range) { + return [self styleCondition:value :range]; + } + ]; +} + +- (BOOL)detectStyle:(NSRange)range { + if (range.length >= 1) { + return [OccurenceUtils detect:ImageAttributeName withInput:_input inRange:range + withCondition:^BOOL(id _Nullable value, NSRange range) { + return [self styleCondition:value :range]; + } + ]; + } else { + return [OccurenceUtils detect:ImageAttributeName withInput:_input atIndex:range.location checkPrevious:YES + withCondition:^BOOL(id _Nullable value, NSRange range) { + return [self styleCondition:value :range]; + } + ]; + } +} + +- (NSArray * _Nullable)findAllOccurences:(NSRange)range { + return [OccurenceUtils all:ImageAttributeName withInput:_input inRange:range + withCondition:^BOOL(id _Nullable value, NSRange range) { + return [self styleCondition:value :range]; + } + ]; +} + +- (ImageData *)getImageDataAt:(NSUInteger)location +{ + NSRange imageRange = NSMakeRange(0, 0); + NSRange inputRange = NSMakeRange(0, _input->textView.textStorage.length); + + // don't search at the very end of input + NSUInteger searchLocation = location; + if(searchLocation == _input->textView.textStorage.length) { + return nullptr; + } + + ImageData *imageData = [_input->textView.textStorage + attribute:ImageAttributeName + atIndex:searchLocation + longestEffectiveRange: &imageRange + inRange:inputRange + ]; + + return imageData; +} + +- (void)addImage:(NSString *)uri { + ImageData *data = [[ImageData alloc] init]; + data.uri = uri; + + NSMutableDictionary *attributes = [@{ + NSAttachmentAttributeName: [self prepareImageAttachement:uri], + ImageAttributeName: data, + } mutableCopy]; + + // Use the Object Replacement Character for Image. + // This tells TextKit "something non-text goes here". + NSString *imagePlaceholder = @"\uFFFC"; + + if (_input->textView.selectedRange.length == 0) { + [TextInsertionUtils insertText:imagePlaceholder at:_input->textView.selectedRange.location additionalAttributes:nullptr input:_input withSelection:YES]; + } else { + [TextInsertionUtils replaceText:imagePlaceholder + at:_input->textView.selectedRange + additionalAttributes:nullptr + input:_input + withSelection:YES]; + } + + NSRange newSelection = _input->textView.selectedRange; + NSRange imageRange = NSMakeRange(newSelection.location - 1, 1); + + [_input->textView.textStorage beginEditing]; + [_input->textView.textStorage addAttributes:attributes range:imageRange]; + [_input->textView.textStorage endEditing]; +} + +-(NSTextAttachment *)prepareImageAttachement:(NSString *)uri +{ + NSURL *url = [NSURL URLWithString:uri]; + NSData *imgData = [NSData dataWithContentsOfURL:url]; + UIImage *image = [UIImage imageWithData:imgData]; + + NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; + attachment.image = image; + attachment.bounds = CGRectMake(0, 0, [_input->config imageWidth], [_input->config imageHeight]); + + return attachment; +} + +@end diff --git a/ios/utils/ImageData.h b/ios/utils/ImageData.h new file mode 100644 index 00000000..cfc44002 --- /dev/null +++ b/ios/utils/ImageData.h @@ -0,0 +1,8 @@ +#pragma once +#import + +@interface ImageData : NSObject + +@property NSString *uri; + +@end diff --git a/ios/utils/ImageData.mm b/ios/utils/ImageData.mm new file mode 100644 index 00000000..e5a6d61d --- /dev/null +++ b/ios/utils/ImageData.mm @@ -0,0 +1,4 @@ +#import "ImageData.h" + +@implementation ImageData +@end diff --git a/ios/utils/StyleHeaders.h b/ios/utils/StyleHeaders.h index a4da5118..f980529c 100644 --- a/ios/utils/StyleHeaders.h +++ b/ios/utils/StyleHeaders.h @@ -2,6 +2,7 @@ #import "BaseStyleProtocol.h" #import "LinkData.h" #import "MentionParams.h" +#import "ImageData.h" @interface BoldStyle : NSObject @end @@ -79,3 +80,8 @@ - (void)manageCodeBlockFontAndColor; - (BOOL)handleBackspaceInRange:(NSRange)range replacementText:(NSString *)text; @end + +@interface ImageStyle : NSObject +- (void)addImage:(NSString *)uri; +- (ImageData *)getImageDataAt:(NSUInteger)location; +@end