From 5e5985ad3e7a1afab7477a16d6dd816f8b6a7020 Mon Sep 17 00:00:00 2001 From: Maksymilian Galas Date: Thu, 23 May 2024 19:25:44 +0200 Subject: [PATCH 1/3] Improve performance on iOS --- ios/MarkdownTextInputDecoratorView.mm | 19 +- ...TBackedTextFieldDelegateAdapter+Markdown.h | 14 - ...BackedTextFieldDelegateAdapter+Markdown.mm | 43 --- ios/RCTBaseTextInputView+Markdown.h | 11 +- ios/RCTBaseTextInputView+Markdown.mm | 47 +--- ios/RCTMarkdownUtils.h | 1 + ios/RCTMarkdownUtils.mm | 260 +++++++++--------- ios/RCTTextInputComponentView+Markdown.h | 7 +- ios/RCTTextInputComponentView+Markdown.mm | 30 +- ios/RCTUITextView+Markdown.h | 5 + ios/RCTUITextView+Markdown.mm | 47 +++- 11 files changed, 224 insertions(+), 260 deletions(-) delete mode 100644 ios/RCTBackedTextFieldDelegateAdapter+Markdown.h delete mode 100644 ios/RCTBackedTextFieldDelegateAdapter+Markdown.mm diff --git a/ios/MarkdownTextInputDecoratorView.mm b/ios/MarkdownTextInputDecoratorView.mm index eaa8a59b..3619d18c 100644 --- a/ios/MarkdownTextInputDecoratorView.mm +++ b/ios/MarkdownTextInputDecoratorView.mm @@ -3,7 +3,6 @@ #import #import -#import #import #ifdef RCT_NEW_ARCH_ENABLED @@ -23,7 +22,6 @@ @implementation MarkdownTextInputDecoratorView { __weak RCTBaseTextInputView *_textInput; #endif /* RCT_NEW_ARCH_ENABLED */ __weak UIView *_backedTextInputView; - __weak RCTBackedTextFieldDelegateAdapter *_adapter; __weak RCTUITextView *_textView; } @@ -65,9 +63,7 @@ - (void)didMoveToWindow { [_textInput setMarkdownUtils:_markdownUtils]; if ([_backedTextInputView isKindOfClass:[RCTUITextField class]]) { - RCTUITextField *textField = (RCTUITextField *)_backedTextInputView; - _adapter = [textField valueForKey:@"textInputDelegateAdapter"]; - [_adapter setMarkdownUtils:_markdownUtils]; + // Do nothing } else if ([_backedTextInputView isKindOfClass:[RCTUITextView class]]) { _textView = (RCTUITextView *)_backedTextInputView; [_textView setMarkdownUtils:_markdownUtils]; @@ -85,9 +81,6 @@ - (void)willMoveToWindow:(UIWindow *)newWindow if (_textInput != nil) { [_textInput setMarkdownUtils:nil]; } - if (_adapter != nil) { - [_adapter setMarkdownUtils:nil]; - } if (_textView != nil) { [_textView setMarkdownUtils:nil]; if (_textView.layoutManager != nil && [object_getClass(_textView.layoutManager) isEqual:[MarkdownLayoutManager class]]) { @@ -103,11 +96,17 @@ - (void)setMarkdownStyle:(RCTMarkdownStyle *)markdownStyle [_markdownUtils setMarkdownStyle:markdownStyle]; // apply new styles + if (_textView != nil) { + // We want to use `textStorage` for applying markdown when possible. Currently it's only available for UITextView + [_textView textDidChange]; + } else { + // Set attributed string for all other types of views #ifdef RCT_NEW_ARCH_ENABLED - [_textInput _setAttributedString:_backedTextInputView.attributedText]; + [_textInput _setAttributedString:_backedTextInputView.attributedText]; #else - [_textInput setAttributedText:_textInput.attributedText]; + [_textInput setAttributedText:_textInput.attributedText]; #endif /* RCT_NEW_ARCH_ENABLED */ + } } @end diff --git a/ios/RCTBackedTextFieldDelegateAdapter+Markdown.h b/ios/RCTBackedTextFieldDelegateAdapter+Markdown.h deleted file mode 100644 index f8ddc1d2..00000000 --- a/ios/RCTBackedTextFieldDelegateAdapter+Markdown.h +++ /dev/null @@ -1,14 +0,0 @@ -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface RCTBackedTextFieldDelegateAdapter (Markdown) - -@property(nonatomic, nullable, getter=getMarkdownUtils) RCTMarkdownUtils *markdownUtils; - -- (void)markdown_textFieldDidChange; - -@end - -NS_ASSUME_NONNULL_END diff --git a/ios/RCTBackedTextFieldDelegateAdapter+Markdown.mm b/ios/RCTBackedTextFieldDelegateAdapter+Markdown.mm deleted file mode 100644 index 11c3baf8..00000000 --- a/ios/RCTBackedTextFieldDelegateAdapter+Markdown.mm +++ /dev/null @@ -1,43 +0,0 @@ -#import -#import -#import -#import - -@implementation RCTBackedTextFieldDelegateAdapter (Markdown) - -- (void)setMarkdownUtils:(RCTMarkdownUtils *)markdownUtils { - objc_setAssociatedObject(self, @selector(getMarkdownUtils), markdownUtils, OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -- (RCTMarkdownUtils *)getMarkdownUtils { - return objc_getAssociatedObject(self, @selector(getMarkdownUtils)); -} - -- (void)markdown_textFieldDidChange -{ - RCTMarkdownUtils *markdownUtils = [self getMarkdownUtils]; - if (markdownUtils != nil) { - RCTUITextField *backedTextInputView = [self valueForKey:@"_backedTextInputView"]; - UITextRange *range = backedTextInputView.selectedTextRange; - backedTextInputView.attributedText = [markdownUtils parseMarkdown:backedTextInputView.attributedText withAttributes:backedTextInputView.defaultTextAttributes]; - [backedTextInputView setSelectedTextRange:range notifyDelegate:YES]; - } - - // Call the original method - [self markdown_textFieldDidChange]; -} - -+ (void)load -{ - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - Class cls = [self class]; - SEL originalSelector = @selector(textFieldDidChange); - SEL swizzledSelector = @selector(markdown_textFieldDidChange); - Method originalMethod = class_getInstanceMethod(cls, originalSelector); - Method swizzledMethod = class_getInstanceMethod(cls, swizzledSelector); - method_exchangeImplementations(originalMethod, swizzledMethod); - }); -} - -@end diff --git a/ios/RCTBaseTextInputView+Markdown.h b/ios/RCTBaseTextInputView+Markdown.h index 3d37adb2..6dfec6ed 100644 --- a/ios/RCTBaseTextInputView+Markdown.h +++ b/ios/RCTBaseTextInputView+Markdown.h @@ -1,8 +1,15 @@ +// This guard prevent this file to be compiled in the new architecture. +#ifndef RCT_NEW_ARCH_ENABLED + #import #import NS_ASSUME_NONNULL_BEGIN +@interface RCTBaseTextInputView (Private) +- (BOOL)textOf:(NSAttributedString *)newText equals:(NSAttributedString *)oldText; +@end + @interface RCTBaseTextInputView (Markdown) @property(nonatomic, nullable, getter=getMarkdownUtils) RCTMarkdownUtils *markdownUtils; @@ -11,8 +18,8 @@ NS_ASSUME_NONNULL_BEGIN - (BOOL)markdown_textOf:(NSAttributedString *)newText equals:(NSAttributedString *)oldText; -- (void)markdown_updateLocalData; - @end NS_ASSUME_NONNULL_END + +#endif /* RCT_NEW_ARCH_ENABLED */ diff --git a/ios/RCTBaseTextInputView+Markdown.mm b/ios/RCTBaseTextInputView+Markdown.mm index 209dc6bb..525e9419 100644 --- a/ios/RCTBaseTextInputView+Markdown.mm +++ b/ios/RCTBaseTextInputView+Markdown.mm @@ -1,3 +1,8 @@ +// This guard prevent this file to be compiled in the new architecture. +#ifndef RCT_NEW_ARCH_ENABLED + +#import +#import #import #import #import @@ -14,9 +19,11 @@ - (RCTMarkdownUtils *)getMarkdownUtils { - (void)markdown_setAttributedText:(NSAttributedString *)attributedText { - RCTMarkdownUtils *markdownUtils = [self getMarkdownUtils]; - if (markdownUtils != nil) { - attributedText = [markdownUtils parseMarkdown:attributedText withAttributes:self.backedTextInputView.defaultTextAttributes]; + if (![self.backedTextInputView isKindOfClass:[RCTUITextView class]]) { + RCTMarkdownUtils *markdownUtils = [self getMarkdownUtils]; + if (markdownUtils != nil) { + attributedText = [markdownUtils parseMarkdown:attributedText withAttributes:self.backedTextInputView.defaultTextAttributes]; + } } // Call the original method @@ -33,29 +40,6 @@ - (BOOL)markdown_textOf:(NSAttributedString *)newText equals:(NSAttributedString return [self markdown_textOf:newText equals:oldText]; } -- (void)markdown_updateLocalData -{ - RCTMarkdownUtils *markdownUtils = [self getMarkdownUtils]; - if (markdownUtils != nil) { - id backedTextInputView = self.backedTextInputView; - NSAttributedString *oldAttributedText = backedTextInputView.attributedText; - NSAttributedString *newAttributedText = [markdownUtils parseMarkdown:oldAttributedText withAttributes:backedTextInputView.defaultTextAttributes]; - UITextRange *range = backedTextInputView.selectedTextRange; - - // update attributed text without emitting onSelectionChange event - id delegate = backedTextInputView.textInputDelegate; - backedTextInputView.textInputDelegate = nil; - [backedTextInputView setAttributedText:newAttributedText]; - backedTextInputView.textInputDelegate = delegate; - - // restore original selection and emit onSelectionChange event - [backedTextInputView setSelectedTextRange:range notifyDelegate:YES]; - } - - // Call the original method - [self markdown_updateLocalData]; -} - + (void)load { static dispatch_once_t onceToken; @@ -71,15 +55,6 @@ + (void)load method_exchangeImplementations(originalMethod, swizzledMethod); } - { - // swizzle updateLocalData - SEL originalSelector = @selector(updateLocalData); - SEL swizzledSelector = @selector(markdown_updateLocalData); - Method originalMethod = class_getInstanceMethod(cls, originalSelector); - Method swizzledMethod = class_getInstanceMethod(cls, swizzledSelector); - method_exchangeImplementations(originalMethod, swizzledMethod); - } - { // swizzle textOf SEL originalSelector = @selector(textOf:equals:); @@ -92,3 +67,5 @@ + (void)load } @end + +#endif /* RCT_NEW_ARCH_ENABLED */ diff --git a/ios/RCTMarkdownUtils.h b/ios/RCTMarkdownUtils.h index 4d080bb8..2ac76a8b 100644 --- a/ios/RCTMarkdownUtils.h +++ b/ios/RCTMarkdownUtils.h @@ -9,6 +9,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic) NSMutableArray *blockquoteRangesAndLevels; - (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input withAttributes:(nullable NSDictionary*)attributes; +- (void)parseMarkdown:(nullable NSMutableAttributedString *)attributedString; @end diff --git a/ios/RCTMarkdownUtils.mm b/ios/RCTMarkdownUtils.mm index f188429a..b6af3f78 100644 --- a/ios/RCTMarkdownUtils.mm +++ b/ios/RCTMarkdownUtils.mm @@ -11,143 +11,143 @@ @implementation RCTMarkdownUtils { __weak RCTMarkdownStyle *_prevMarkdownStyle; } -- (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input withAttributes:(nullable NSDictionary *)attributes +- (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input withAttributes:(nullable NSDictionary*)attributes { - @synchronized (self) { - if (input == nil) { - return nil; - } - - NSString *inputString = [input string]; - if ([inputString isEqualToString:_prevInputString] && [attributes isEqualToDictionary:_prevTextAttributes] && [_markdownStyle isEqual:_prevMarkdownStyle]) { - return _prevAttributedString; - } - - static JSContext *ctx = nil; - static JSValue *function = nil; - if (ctx == nil) { - NSString *path = [[NSBundle mainBundle] pathForResource:@"react-native-live-markdown-parser" ofType:@"js"]; - assert(path != nil && "[react-native-live-markdown] Markdown parser bundle not found"); - NSString *content = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:NULL]; - assert(content != nil && "[react-native-live-markdown] Markdown parser bundle is empty"); - ctx = [[JSContext alloc] init]; - [ctx evaluateScript:content]; - function = ctx[@"parseExpensiMarkToRanges"]; - } - - JSValue *result = [function callWithArguments:@[inputString]]; - NSArray *ranges = [result toArray]; - - NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:inputString attributes:attributes]; - [attributedString beginEditing]; - - // If the attributed string ends with underlined text, blurring the single-line input imprints the underline style across the whole string. - // It looks like a bug in iOS, as there is no underline style to be found in the attributed string, especially after formatting. - // This is a workaround that applies the NSUnderlineStyleNone to the string before iterating over ranges which resolves this problem. - [attributedString addAttribute:NSUnderlineStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleNone] range:NSMakeRange(0, attributedString.length)]; - - _blockquoteRangesAndLevels = [NSMutableArray new]; - - [ranges enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - NSDictionary *item = obj; - NSString *type = [item valueForKey:@"type"]; - NSInteger location = [[item valueForKey:@"start"] unsignedIntegerValue]; - NSInteger length = [[item valueForKey:@"length"] unsignedIntegerValue]; - NSInteger depth = [[item valueForKey:@"depth"] unsignedIntegerValue] ?: 1; - NSRange range = NSMakeRange(location, length); - - if ([type isEqualToString:@"bold"] || [type isEqualToString:@"italic"] || [type isEqualToString:@"code"] || [type isEqualToString:@"pre"] || [type isEqualToString:@"h1"] || [type isEqualToString:@"emoji"]) { - UIFont *font = [attributedString attribute:NSFontAttributeName atIndex:location effectiveRange:NULL]; - if ([type isEqualToString:@"bold"]) { - font = [RCTFont updateFont:font withWeight:@"bold"]; - } else if ([type isEqualToString:@"italic"]) { - font = [RCTFont updateFont:font withStyle:@"italic"]; - } else if ([type isEqualToString:@"code"]) { - font = [RCTFont updateFont:font withFamily:_markdownStyle.codeFontFamily - size:[NSNumber numberWithFloat:_markdownStyle.codeFontSize] - weight:nil - style:nil - variant:nil - scaleMultiplier:0]; - } else if ([type isEqualToString:@"pre"]) { - font = [RCTFont updateFont:font withFamily:_markdownStyle.preFontFamily - size:[NSNumber numberWithFloat:_markdownStyle.preFontSize] - weight:nil - style:nil - variant:nil - scaleMultiplier:0]; - } else if ([type isEqualToString:@"h1"]) { - font = [RCTFont updateFont:font withFamily:nil - size:[NSNumber numberWithFloat:_markdownStyle.h1FontSize] - weight:@"bold" - style:nil - variant:nil - scaleMultiplier:0]; - } else if ([type isEqualToString:@"emoji"]) { - font = [RCTFont updateFont:font withFamily:nil - size:[NSNumber numberWithFloat:_markdownStyle.emojiFontSize] - weight:nil - style:nil - variant:nil - scaleMultiplier:0]; - } - [attributedString addAttribute:NSFontAttributeName value:font range:range]; - } - - if ([type isEqualToString:@"syntax"]) { - [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.syntaxColor range:range]; - } else if ([type isEqualToString:@"strikethrough"]) { - [attributedString addAttribute:NSStrikethroughStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleSingle] range:range]; - } else if ([type isEqualToString:@"code"]) { - [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.codeColor range:range]; - [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.codeBackgroundColor range:range]; - } else if ([type isEqualToString:@"mention-here"]) { - [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.mentionHereColor range:range]; - [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.mentionHereBackgroundColor range:range]; - } else if ([type isEqualToString:@"mention-user"]) { - // TODO: change mention color when it mentions current user - [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.mentionUserColor range:range]; - [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.mentionUserBackgroundColor range:range]; - } else if ([type isEqualToString:@"mention-report"]) { - [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.mentionReportColor range:range]; - [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.mentionReportBackgroundColor range:range]; - } else if ([type isEqualToString:@"link"]) { - [attributedString addAttribute:NSUnderlineStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleSingle] range:range]; - [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.linkColor range:range]; - } else if ([type isEqualToString:@"blockquote"]) { - CGFloat indent = (_markdownStyle.blockquoteMarginLeft + _markdownStyle.blockquoteBorderWidth + _markdownStyle.blockquotePaddingLeft) * depth; - NSMutableParagraphStyle *paragraphStyle = [NSMutableParagraphStyle new]; - paragraphStyle.firstLineHeadIndent = indent; - paragraphStyle.headIndent = indent; - [attributedString addAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:range]; - [_blockquoteRangesAndLevels addObject:@{ - @"range": [NSValue valueWithRange:range], - @"depth": @(depth) - }]; - } else if ([type isEqualToString:@"pre"]) { - [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.preColor range:range]; - NSRange rangeForBackground = [inputString characterAtIndex:range.location] == '\n' ? NSMakeRange(range.location + 1, range.length - 1) : range; - [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.preBackgroundColor range:rangeForBackground]; - // TODO: pass background color and ranges to layout manager - } else if ([type isEqualToString:@"h1"]) { - NSMutableParagraphStyle *paragraphStyle = [NSMutableParagraphStyle new]; - NSRange rangeWithHashAndSpace = NSMakeRange(range.location - 2, range.length + 2); // we also need to include prepending "# " - [attributedString addAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:rangeWithHashAndSpace]; - } - }]; + auto inputString = [input string]; + if ([inputString isEqualToString:_prevInputString] && [attributes isEqualToDictionary:_prevTextAttributes] && [_markdownStyle isEqual:_prevMarkdownStyle]) { + return _prevAttributedString; + } - RCTApplyBaselineOffset(attributedString); + auto attributedString = [[NSMutableAttributedString alloc] initWithString:inputString attributes:attributes]; + [self parseMarkdown:attributedString]; - [attributedString endEditing]; + _prevInputString = inputString; + _prevAttributedString = attributedString; + _prevTextAttributes = attributes; + _prevMarkdownStyle = _markdownStyle; - _prevInputString = inputString; - _prevAttributedString = attributedString; - _prevTextAttributes = attributes; - _prevMarkdownStyle = _markdownStyle; + return attributedString; +} - return attributedString; +- (void)parseMarkdown:(nullable NSMutableAttributedString *)attributedString +{ + @synchronized (self) { + if (attributedString == nil) { + return; + } + static JSContext *ctx = nil; + static JSValue *function = nil; + if (ctx == nil) { + NSString *path = [[NSBundle mainBundle] pathForResource:@"react-native-live-markdown-parser" ofType:@"js"]; + assert(path != nil && "[react-native-live-markdown] Markdown parser bundle not found"); + NSString *content = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:NULL]; + assert(content != nil && "[react-native-live-markdown] Markdown parser bundle is empty"); + ctx = [[JSContext alloc] init]; + [ctx evaluateScript:content]; + function = ctx[@"parseExpensiMarkToRanges"]; } + + NSString *inputString = [attributedString string]; + JSValue *result = [function callWithArguments:@[inputString]]; + NSArray *ranges = [result toArray]; + + [attributedString beginEditing]; + + // If the attributed string ends with underlined text, blurring the single-line input imprints the underline style across the whole string. + // It looks like a bug in iOS, as there is no underline style to be found in the attributed string, especially after formatting. + // This is a workaround that applies the NSUnderlineStyleNone to the string before iterating over ranges which resolves this problem. + [attributedString addAttribute:NSUnderlineStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleNone] range:NSMakeRange(0, attributedString.length)]; + + _blockquoteRangesAndLevels = [NSMutableArray new]; + + [ranges enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + NSDictionary *item = obj; + NSString *type = [item valueForKey:@"type"]; + NSInteger location = [[item valueForKey:@"start"] unsignedIntegerValue]; + NSInteger length = [[item valueForKey:@"length"] unsignedIntegerValue]; + NSInteger depth = [[item valueForKey:@"depth"] unsignedIntegerValue] ?: 1; + NSRange range = NSMakeRange(location, length); + + if ([type isEqualToString:@"bold"] || [type isEqualToString:@"italic"] || [type isEqualToString:@"code"] || [type isEqualToString:@"pre"] || [type isEqualToString:@"h1"] || [type isEqualToString:@"emoji"]) { + UIFont *font = [attributedString attribute:NSFontAttributeName atIndex:location effectiveRange:NULL]; + if ([type isEqualToString:@"bold"]) { + font = [RCTFont updateFont:font withWeight:@"bold"]; + } else if ([type isEqualToString:@"italic"]) { + font = [RCTFont updateFont:font withStyle:@"italic"]; + } else if ([type isEqualToString:@"code"]) { + font = [RCTFont updateFont:font withFamily:_markdownStyle.codeFontFamily + size:[NSNumber numberWithFloat:_markdownStyle.codeFontSize] + weight:nil + style:nil + variant:nil + scaleMultiplier:0]; + } else if ([type isEqualToString:@"pre"]) { + font = [RCTFont updateFont:font withFamily:_markdownStyle.preFontFamily + size:[NSNumber numberWithFloat:_markdownStyle.preFontSize] + weight:nil + style:nil + variant:nil + scaleMultiplier:0]; + } else if ([type isEqualToString:@"h1"]) { + font = [RCTFont updateFont:font withFamily:nil + size:[NSNumber numberWithFloat:_markdownStyle.h1FontSize] + weight:@"bold" + style:nil + variant:nil + scaleMultiplier:0]; + } else if ([type isEqualToString:@"emoji"]) { + font = [[font copy] fontWithSize:_markdownStyle.emojiFontSize]; + } + [attributedString addAttribute:NSFontAttributeName value:font range:range]; + } + + if ([type isEqualToString:@"syntax"]) { + [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.syntaxColor range:range]; + } else if ([type isEqualToString:@"strikethrough"]) { + [attributedString addAttribute:NSStrikethroughStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleSingle] range:range]; + } else if ([type isEqualToString:@"code"]) { + [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.codeColor range:range]; + [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.codeBackgroundColor range:range]; + } else if ([type isEqualToString:@"mention-here"]) { + [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.mentionHereColor range:range]; + [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.mentionHereBackgroundColor range:range]; + } else if ([type isEqualToString:@"mention-user"]) { + // TODO: change mention color when it mentions current user + [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.mentionUserColor range:range]; + [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.mentionUserBackgroundColor range:range]; + } else if ([type isEqualToString:@"mention-report"]) { + [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.mentionReportColor range:range]; + [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.mentionReportBackgroundColor range:range]; + } else if ([type isEqualToString:@"link"]) { + [attributedString addAttribute:NSUnderlineStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleSingle] range:range]; + [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.linkColor range:range]; + } else if ([type isEqualToString:@"blockquote"]) { + CGFloat indent = (_markdownStyle.blockquoteMarginLeft + _markdownStyle.blockquoteBorderWidth + _markdownStyle.blockquotePaddingLeft) * depth; + NSMutableParagraphStyle *paragraphStyle = [NSMutableParagraphStyle new]; + paragraphStyle.firstLineHeadIndent = indent; + paragraphStyle.headIndent = indent; + [attributedString addAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:range]; + [_blockquoteRangesAndLevels addObject:@{ + @"range": [NSValue valueWithRange:range], + @"depth": @(depth) + }]; + } else if ([type isEqualToString:@"pre"]) { + [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.preColor range:range]; + NSRange rangeForBackground = [inputString characterAtIndex:range.location] == '\n' ? NSMakeRange(range.location + 1, range.length - 1) : range; + [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.preBackgroundColor range:rangeForBackground]; + // TODO: pass background color and ranges to layout manager + } else if ([type isEqualToString:@"h1"]) { + NSMutableParagraphStyle *paragraphStyle = [NSMutableParagraphStyle new]; + NSRange rangeWithHashAndSpace = NSMakeRange(range.location - 2, range.length + 2); // we also need to include prepending "# " + [attributedString addAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:rangeWithHashAndSpace]; + } + }]; + + RCTApplyBaselineOffset(attributedString); + + [attributedString endEditing]; + } } static void RCTApplyBaselineOffset(NSMutableAttributedString *attributedText) diff --git a/ios/RCTTextInputComponentView+Markdown.h b/ios/RCTTextInputComponentView+Markdown.h index 346bc711..bb203d8d 100644 --- a/ios/RCTTextInputComponentView+Markdown.h +++ b/ios/RCTTextInputComponentView+Markdown.h @@ -6,6 +6,11 @@ NS_ASSUME_NONNULL_BEGIN +@interface RCTTextInputComponentView (Private) +- (void)_setAttributedString:(NSAttributedString *)attributedString; +- (BOOL)_textOf:(NSAttributedString *)newText equals:(NSAttributedString *)oldText; +@end + @interface RCTTextInputComponentView (Markdown) @property(nonatomic, nullable, getter=getMarkdownUtils) RCTMarkdownUtils *markdownUtils; @@ -14,8 +19,6 @@ NS_ASSUME_NONNULL_BEGIN - (BOOL)markdown__textOf:(NSAttributedString *)newText equals:(NSAttributedString *)oldText; -- (void)_setAttributedString:(NSAttributedString *)attributedString; - @end NS_ASSUME_NONNULL_END diff --git a/ios/RCTTextInputComponentView+Markdown.mm b/ios/RCTTextInputComponentView+Markdown.mm index 1a4581f3..e4ef9c63 100644 --- a/ios/RCTTextInputComponentView+Markdown.mm +++ b/ios/RCTTextInputComponentView+Markdown.mm @@ -4,6 +4,7 @@ #import #import #import +#import #import #import "MarkdownShadowFamilyRegistry.h" @@ -15,7 +16,7 @@ - (void)setMarkdownUtils:(RCTMarkdownUtils *)markdownUtils { if (markdownUtils != nil) { // force Markdown formatting on first render because `_setAttributedText` is called before `setMarkdownUtils` - RCTUITextField *backedTextInputView = [self getBackedTextInputView]; + auto backedTextInputView = [self getBackedTextInputView]; backedTextInputView.attributedText = [markdownUtils parseMarkdown:backedTextInputView.attributedText withAttributes:backedTextInputView.defaultTextAttributes]; } } @@ -24,23 +25,24 @@ - (RCTMarkdownUtils *)getMarkdownUtils { return objc_getAssociatedObject(self, @selector(getMarkdownUtils)); } -- (RCTUITextField *)getBackedTextInputView { - RCTUITextField *backedTextInputView = [self valueForKey:@"_backedTextInputView"]; - return backedTextInputView; +- (id)getBackedTextInputView { + return [self valueForKey:@"_backedTextInputView"]; } - (void)markdown__setAttributedString:(NSAttributedString *)attributedString { - RCTMarkdownUtils *markdownUtils = [self getMarkdownUtils]; - RCTUITextField *backedTextInputView = [self getBackedTextInputView]; - if (markdownUtils != nil && backedTextInputView != nil) { - attributedString = [markdownUtils parseMarkdown:attributedString withAttributes:backedTextInputView.defaultTextAttributes]; - } else { - // If markdownUtils is undefined, the text input hasn't been mounted yet. It will - // update its state with the unformatted attributed string, we want to prevent displaying - // this state by applying markdown in the commit hook where we can read markdown styles - // from decorator props. - MarkdownShadowFamilyRegistry::forceNextStateUpdate((facebook::react::Tag)self.tag); + auto backedTextInputView = [self getBackedTextInputView]; + if (backedTextInputView != nil && ![backedTextInputView isKindOfClass:[RCTUITextView class]]) { + auto markdownUtils = [self getMarkdownUtils]; + if (markdownUtils != nil) { + attributedString = [markdownUtils parseMarkdown:attributedString withAttributes:backedTextInputView.defaultTextAttributes]; + } else { + // If markdownUtils is undefined, the text input hasn't been mounted yet. It will + // update its state with the unformatted attributed string, we want to prevent displaying + // this state by applying markdown in the commit hook where we can read markdown styles + // from decorator props. + MarkdownShadowFamilyRegistry::forceNextStateUpdate((facebook::react::Tag)self.tag); + } } // Call the original method diff --git a/ios/RCTUITextView+Markdown.h b/ios/RCTUITextView+Markdown.h index f792140e..640efbd5 100644 --- a/ios/RCTUITextView+Markdown.h +++ b/ios/RCTUITextView+Markdown.h @@ -4,6 +4,11 @@ NS_ASSUME_NONNULL_BEGIN +@interface RCTUITextView (Private) +- (void)textDidChange; +- (void)setSelectedTextRange:(UITextRange *)selectedTextRange notifyDelegate:(BOOL)notifyDelegate; +@end + @interface RCTUITextView (Markdown) @property(nonatomic, nullable, getter=getMarkdownUtils) RCTMarkdownUtils *markdownUtils; diff --git a/ios/RCTUITextView+Markdown.mm b/ios/RCTUITextView+Markdown.mm index 70f2d882..75a609c2 100644 --- a/ios/RCTUITextView+Markdown.mm +++ b/ios/RCTUITextView+Markdown.mm @@ -14,28 +14,55 @@ - (RCTMarkdownUtils *)getMarkdownUtils { - (void)markdown_textDidChange { - RCTMarkdownUtils *markdownUtils = [self getMarkdownUtils]; + auto markdownUtils = [self getMarkdownUtils]; if (markdownUtils != nil) { - UITextRange *range = self.selectedTextRange; - super.attributedText = [markdownUtils parseMarkdown:self.attributedText withAttributes:self.defaultTextAttributes]; - [super setSelectedTextRange:range]; // prevents cursor from jumping at the end when typing in the middle of the text - self.typingAttributes = self.defaultTextAttributes; // removes indent in new line when typing after blockquote + [markdownUtils parseMarkdown:self.textStorage]; } // Call the original method [self markdown_textDidChange]; } +#ifdef RCT_NEW_ARCH_ENABLED +- (int)offsetFromTextPosition:(UITextPosition *)textPosition { + return [self offsetFromPosition:self.beginningOfDocument toPosition:textPosition]; +} + +- (void)markdown_setSelectedTextRange:(UITextRange *)selectedTextRange notifyDelegate:(BOOL)notifyDelegate +{ + // Workaround for `Invalid parameter not satisfying: pos` crash on Fabric when selecting all text and replacing it with single character. + if ([self offsetFromTextPosition:selectedTextRange.start] <= 0 && [self offsetFromTextPosition:selectedTextRange.end] <= 0) { + return; + } + + // Call the original method + [self markdown_setSelectedTextRange:selectedTextRange notifyDelegate:notifyDelegate]; +} +#endif /* RCT_NEW_ARCH_ENABLED */ + + (void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ Class cls = [self class]; - SEL originalSelector = @selector(textDidChange); - SEL swizzledSelector = @selector(markdown_textDidChange); - Method originalMethod = class_getInstanceMethod(cls, originalSelector); - Method swizzledMethod = class_getInstanceMethod(cls, swizzledSelector); - method_exchangeImplementations(originalMethod, swizzledMethod); + + { + SEL originalSelector = @selector(textDidChange); + SEL swizzledSelector = @selector(markdown_textDidChange); + Method originalMethod = class_getInstanceMethod(cls, originalSelector); + Method swizzledMethod = class_getInstanceMethod(cls, swizzledSelector); + method_exchangeImplementations(originalMethod, swizzledMethod); + } + +#ifdef RCT_NEW_ARCH_ENABLED + { + SEL originalSelector = @selector(setSelectedTextRange:notifyDelegate:); + SEL swizzledSelector = @selector(markdown_setSelectedTextRange:notifyDelegate:); + Method originalMethod = class_getInstanceMethod(cls, originalSelector); + Method swizzledMethod = class_getInstanceMethod(cls, swizzledSelector); + method_exchangeImplementations(originalMethod, swizzledMethod); + } +#endif /* RCT_NEW_ARCH_ENABLED */ }); } From 7004739db2d24f95ce917bbffe1eb005b3aeef8f Mon Sep 17 00:00:00 2001 From: Maksymilian Galas Date: Tue, 17 Sep 2024 15:49:16 +0100 Subject: [PATCH 2/3] Fix autocomplete on iOS --- ios/RCTTextInputComponentView+Markdown.mm | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/ios/RCTTextInputComponentView+Markdown.mm b/ios/RCTTextInputComponentView+Markdown.mm index 22303d9f..c234873a 100644 --- a/ios/RCTTextInputComponentView+Markdown.mm +++ b/ios/RCTTextInputComponentView+Markdown.mm @@ -33,18 +33,16 @@ - (RCTMarkdownUtils *)getMarkdownUtils { - (void)markdown__setAttributedString:(NSAttributedString *)attributedString { + auto markdownUtils = [self getMarkdownUtils]; auto backedTextInputView = [self getBackedTextInputView]; - if (backedTextInputView != nil && ![backedTextInputView isKindOfClass:[RCTUITextView class]]) { - auto markdownUtils = [self getMarkdownUtils]; - if (markdownUtils != nil) { - attributedString = [markdownUtils parseMarkdown:attributedString withAttributes:backedTextInputView.defaultTextAttributes]; - } else { - // If markdownUtils is undefined, the text input hasn't been mounted yet. It will - // update its state with the unformatted attributed string, we want to prevent displaying - // this state by applying markdown in the commit hook where we can read markdown styles - // from decorator props. - MarkdownShadowFamilyRegistry::forceNextStateUpdate((facebook::react::Tag)self.tag); - } + if (markdownUtils != nil && backedTextInputView != nil) { + attributedString = [markdownUtils parseMarkdown:attributedString withAttributes:backedTextInputView.defaultTextAttributes]; + } else { + // If markdownUtils is undefined, the text input hasn't been mounted yet. It will + // update its state with the unformatted attributed string, we want to prevent displaying + // this state by applying markdown in the commit hook where we can read markdown styles + // from decorator props. + MarkdownShadowFamilyRegistry::forceNextStateUpdate((facebook::react::Tag)self.tag); } // Call the original method From 61f266bc693367b6afc65199a9504d2ce41dd0bd Mon Sep 17 00:00:00 2001 From: Maksymilian Galas Date: Mon, 14 Oct 2024 17:50:16 +0200 Subject: [PATCH 3/3] Fix jumping cursor and autocomplete on iOS --- ...TBackedTextFieldDelegateAdapter+Markdown.h | 14 ------ ...BackedTextFieldDelegateAdapter+Markdown.mm | 43 ------------------- apple/RCTMarkdownUtils.mm | 19 +++++--- apple/RCTTextInputComponentView+Markdown.mm | 20 +++++---- 4 files changed, 24 insertions(+), 72 deletions(-) delete mode 100644 apple/RCTBackedTextFieldDelegateAdapter+Markdown.h delete mode 100644 apple/RCTBackedTextFieldDelegateAdapter+Markdown.mm diff --git a/apple/RCTBackedTextFieldDelegateAdapter+Markdown.h b/apple/RCTBackedTextFieldDelegateAdapter+Markdown.h deleted file mode 100644 index f8ddc1d2..00000000 --- a/apple/RCTBackedTextFieldDelegateAdapter+Markdown.h +++ /dev/null @@ -1,14 +0,0 @@ -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface RCTBackedTextFieldDelegateAdapter (Markdown) - -@property(nonatomic, nullable, getter=getMarkdownUtils) RCTMarkdownUtils *markdownUtils; - -- (void)markdown_textFieldDidChange; - -@end - -NS_ASSUME_NONNULL_END diff --git a/apple/RCTBackedTextFieldDelegateAdapter+Markdown.mm b/apple/RCTBackedTextFieldDelegateAdapter+Markdown.mm deleted file mode 100644 index 11c3baf8..00000000 --- a/apple/RCTBackedTextFieldDelegateAdapter+Markdown.mm +++ /dev/null @@ -1,43 +0,0 @@ -#import -#import -#import -#import - -@implementation RCTBackedTextFieldDelegateAdapter (Markdown) - -- (void)setMarkdownUtils:(RCTMarkdownUtils *)markdownUtils { - objc_setAssociatedObject(self, @selector(getMarkdownUtils), markdownUtils, OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -- (RCTMarkdownUtils *)getMarkdownUtils { - return objc_getAssociatedObject(self, @selector(getMarkdownUtils)); -} - -- (void)markdown_textFieldDidChange -{ - RCTMarkdownUtils *markdownUtils = [self getMarkdownUtils]; - if (markdownUtils != nil) { - RCTUITextField *backedTextInputView = [self valueForKey:@"_backedTextInputView"]; - UITextRange *range = backedTextInputView.selectedTextRange; - backedTextInputView.attributedText = [markdownUtils parseMarkdown:backedTextInputView.attributedText withAttributes:backedTextInputView.defaultTextAttributes]; - [backedTextInputView setSelectedTextRange:range notifyDelegate:YES]; - } - - // Call the original method - [self markdown_textFieldDidChange]; -} - -+ (void)load -{ - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - Class cls = [self class]; - SEL originalSelector = @selector(textFieldDidChange); - SEL swizzledSelector = @selector(markdown_textFieldDidChange); - Method originalMethod = class_getInstanceMethod(cls, originalSelector); - Method swizzledMethod = class_getInstanceMethod(cls, swizzledSelector); - method_exchangeImplementations(originalMethod, swizzledMethod); - }); -} - -@end diff --git a/apple/RCTMarkdownUtils.mm b/apple/RCTMarkdownUtils.mm index 4e678c65..f3dd2085 100644 --- a/apple/RCTMarkdownUtils.mm +++ b/apple/RCTMarkdownUtils.mm @@ -22,7 +22,19 @@ - (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input withA return _prevAttributedString; } - auto attributedString = [[NSMutableAttributedString alloc] initWithString:inputString attributes:attributes]; + auto attributedString = [[NSMutableAttributedString alloc] initWithAttributedString:input]; + auto fullRange = NSMakeRange(0, attributedString.length); + + // `setAttributes:@{}` causes cursor jumping so we should remove attributes manually + if (attributedString.length > 0) { + auto originalAttributes = [attributedString attributesAtIndex:0 effectiveRange:NULL]; + for (NSAttributedStringKey key in originalAttributes.allKeys) { + [attributedString removeAttribute:key range:fullRange]; + } + } + + [attributedString addAttributes:attributes range:fullRange]; + [self parseMarkdown:attributedString]; _prevInputString = inputString; @@ -67,11 +79,6 @@ - (void)parseMarkdown:(nullable NSMutableAttributedString *)attributedString [attributedString beginEditing]; - // If the attributed string ends with underlined text, blurring the single-line input imprints the underline style across the whole string. - // It looks like a bug in iOS, as there is no underline style to be found in the attributed string, especially after formatting. - // This is a workaround that applies the NSUnderlineStyleNone to the string before iterating over ranges which resolves this problem. - [attributedString addAttribute:NSUnderlineStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleNone] range:NSMakeRange(0, attributedString.length)]; - _blockquoteRangesAndLevels = [NSMutableArray new]; for (size_t i = 0, n = ranges.size(rt); i < n; ++i) { diff --git a/apple/RCTTextInputComponentView+Markdown.mm b/apple/RCTTextInputComponentView+Markdown.mm index c234873a..22303d9f 100644 --- a/apple/RCTTextInputComponentView+Markdown.mm +++ b/apple/RCTTextInputComponentView+Markdown.mm @@ -33,16 +33,18 @@ - (RCTMarkdownUtils *)getMarkdownUtils { - (void)markdown__setAttributedString:(NSAttributedString *)attributedString { - auto markdownUtils = [self getMarkdownUtils]; auto backedTextInputView = [self getBackedTextInputView]; - if (markdownUtils != nil && backedTextInputView != nil) { - attributedString = [markdownUtils parseMarkdown:attributedString withAttributes:backedTextInputView.defaultTextAttributes]; - } else { - // If markdownUtils is undefined, the text input hasn't been mounted yet. It will - // update its state with the unformatted attributed string, we want to prevent displaying - // this state by applying markdown in the commit hook where we can read markdown styles - // from decorator props. - MarkdownShadowFamilyRegistry::forceNextStateUpdate((facebook::react::Tag)self.tag); + if (backedTextInputView != nil && ![backedTextInputView isKindOfClass:[RCTUITextView class]]) { + auto markdownUtils = [self getMarkdownUtils]; + if (markdownUtils != nil) { + attributedString = [markdownUtils parseMarkdown:attributedString withAttributes:backedTextInputView.defaultTextAttributes]; + } else { + // If markdownUtils is undefined, the text input hasn't been mounted yet. It will + // update its state with the unformatted attributed string, we want to prevent displaying + // this state by applying markdown in the commit hook where we can read markdown styles + // from decorator props. + MarkdownShadowFamilyRegistry::forceNextStateUpdate((facebook::react::Tag)self.tag); + } } // Call the original method