diff --git a/apple/MarkdownFormatter.h b/apple/MarkdownFormatter.h index 1cf86c851..e583e2741 100644 --- a/apple/MarkdownFormatter.h +++ b/apple/MarkdownFormatter.h @@ -4,14 +4,16 @@ NS_ASSUME_NONNULL_BEGIN +const NSAttributedStringKey RCTLiveMarkdownTextAttributeName = @"RCTLiveMarkdownText"; + const NSAttributedStringKey RCTLiveMarkdownBlockquoteDepthAttributeName = @"RCTLiveMarkdownBlockquoteDepth"; @interface MarkdownFormatter : NSObject -- (nonnull NSAttributedString *)format:(nonnull NSString *)text - withDefaultTextAttributes:(nonnull NSDictionary *)defaultTextAttributes - withMarkdownRanges:(nonnull NSArray *)markdownRanges - withMarkdownStyle:(nonnull RCTMarkdownStyle *)markdownStyle; +- (void)formatAttributedString:(nonnull NSMutableAttributedString *)attributedString + withDefaultTextAttributes:(nonnull NSDictionary *)defaultTextAttributes + withMarkdownRanges:(nonnull NSArray *)markdownRanges + withMarkdownStyle:(nonnull RCTMarkdownStyle *)markdownStyle; NS_ASSUME_NONNULL_END diff --git a/apple/MarkdownFormatter.mm b/apple/MarkdownFormatter.mm index 942a0a9ff..b47c4ac74 100644 --- a/apple/MarkdownFormatter.mm +++ b/apple/MarkdownFormatter.mm @@ -3,21 +3,19 @@ @implementation MarkdownFormatter -- (nonnull NSAttributedString *)format:(nonnull NSString *)text - withDefaultTextAttributes:(nonnull NSDictionary *)defaultTextAttributes - withMarkdownRanges:(nonnull NSArray *)markdownRanges - withMarkdownStyle:(nonnull RCTMarkdownStyle *)markdownStyle +- (void)formatAttributedString:(nonnull NSMutableAttributedString *)attributedString + withDefaultTextAttributes:(nonnull NSDictionary *)defaultTextAttributes + withMarkdownRanges:(nonnull NSArray *)markdownRanges + withMarkdownStyle:(nonnull RCTMarkdownStyle *)markdownStyle { - NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:text attributes:defaultTextAttributes]; + NSRange fullRange = NSMakeRange(0, attributedString.length); [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)]; + [attributedString setAttributes:defaultTextAttributes range:fullRange]; + + // We add a custom attribute to force a different comparison mode in swizzled `_textOf` method. + [attributedString addAttribute:RCTLiveMarkdownTextAttributeName value:@(YES) range:fullRange]; for (MarkdownRange *markdownRange in markdownRanges) { [self applyRangeToAttributedString:attributedString @@ -28,15 +26,15 @@ - (nonnull NSAttributedString *)format:(nonnull NSString *)text defaultTextAttributes:defaultTextAttributes]; } - [attributedString.string enumerateSubstringsInRange:NSMakeRange(0, attributedString.length) + [attributedString.string enumerateSubstringsInRange:fullRange options:NSStringEnumerationByLines | NSStringEnumerationSubstringNotRequired usingBlock:^(NSString * _Nullable substring, NSRange substringRange, NSRange enclosingRange, BOOL * _Nonnull stop) { RCTApplyBaselineOffset(attributedString, enclosingRange); }]; - [attributedString endEditing]; + [attributedString fixAttributesInRange:fullRange]; - return attributedString; + [attributedString endEditing]; } - (void)applyRangeToAttributedString:(NSMutableAttributedString *)attributedString diff --git a/apple/MarkdownTextFieldObserver.h b/apple/MarkdownTextFieldObserver.h new file mode 100644 index 000000000..da6004517 --- /dev/null +++ b/apple/MarkdownTextFieldObserver.h @@ -0,0 +1,17 @@ +#import +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface MarkdownTextFieldObserver : NSObject + +- (instancetype)initWithTextField:(nonnull RCTUITextField *)textField markdownUtils:(nonnull RCTMarkdownUtils *)markdownUtils; + +- (void)textFieldDidChange:(UITextField *)textField; + +- (void)textFieldDidEndEditing:(UITextField *)textField; + +@end + +NS_ASSUME_NONNULL_END diff --git a/apple/MarkdownTextFieldObserver.mm b/apple/MarkdownTextFieldObserver.mm new file mode 100644 index 000000000..d0f94bd8b --- /dev/null +++ b/apple/MarkdownTextFieldObserver.mm @@ -0,0 +1,73 @@ +#import +#import "react_native_assert.h" + +@implementation MarkdownTextFieldObserver { + RCTUITextField *_textField; + RCTMarkdownUtils *_markdownUtils; + BOOL _active; +} + +- (instancetype)initWithTextField:(nonnull RCTUITextField *)textField markdownUtils:(nonnull RCTMarkdownUtils *)markdownUtils +{ + if ((self = [super init])) { + react_native_assert(textField != nil); + react_native_assert(markdownUtils != nil); + + _textField = textField; + _markdownUtils = markdownUtils; + _active = YES; + } + return self; +} + +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context +{ + if (_active && ([keyPath isEqualToString:@"text"] || [keyPath isEqualToString:@"attributedText"])) { + [self applyMarkdownFormatting]; + } +} + +- (void)textFieldDidChange:(__unused UITextField *)textField +{ + [self applyMarkdownFormatting]; +} + +- (void)textFieldDidEndEditing:(__unused UITextField *)textField +{ + // In order to prevent iOS from applying underline to the whole text if text ends with a link on blur, + // we need to update `defaultTextAttributes` which at this point doesn't contain NSUnderline attribute yet. + // It seems like the setter performs deep comparision, so we differentiate the new value using a counter, + // otherwise this trick would work only once. + static NSAttributedStringKey RCTLiveMarkdownForceUpdateAttributeName = @"RCTLiveMarkdownForceUpdate"; + static NSUInteger counter = 0; + NSMutableDictionary *defaultTextAttributes = [_textField.defaultTextAttributes mutableCopy]; + defaultTextAttributes[RCTLiveMarkdownForceUpdateAttributeName] = @(counter++); + _textField.defaultTextAttributes = defaultTextAttributes; + [self applyMarkdownFormatting]; +} + +- (void)applyMarkdownFormatting +{ + react_native_assert(_textField.defaultTextAttributes != nil); + + if (_textField.markedTextRange != nil) { + return; // skip formatting during multi-stage input to avoid breaking internal state + } + + NSMutableAttributedString *attributedText = [_textField.attributedText mutableCopy]; + [_markdownUtils applyMarkdownFormatting:attributedText withDefaultTextAttributes:_textField.defaultTextAttributes]; + + UITextRange *textRange = _textField.selectedTextRange; + + _active = NO; // prevent recursion + _textField.attributedText = attributedText; + _active = YES; + + // Restore cursor position + [_textField setSelectedTextRange:textRange notifyDelegate:NO]; + + // Eliminate underline blinks while typing if previous text ends with a link + _textField.typingAttributes = _textField.defaultTextAttributes; +} + +@end diff --git a/apple/MarkdownTextInputDecoratorComponentView.mm b/apple/MarkdownTextInputDecoratorComponentView.mm index c5959be0b..4aa2d8819 100644 --- a/apple/MarkdownTextInputDecoratorComponentView.mm +++ b/apple/MarkdownTextInputDecoratorComponentView.mm @@ -2,15 +2,17 @@ #import #import #import +#import +#import #import #import +#import +#import #import #import -#import +#import #import -#import -#import #import @@ -21,10 +23,11 @@ @implementation MarkdownTextInputDecoratorComponentView { RCTMarkdownStyle *_markdownStyle; NSNumber *_parserId; MarkdownBackedTextInputDelegate *_markdownBackedTextInputDelegate; - __weak RCTTextInputComponentView *_textInput; - __weak UIView *_backedTextInputView; - __weak RCTBackedTextFieldDelegateAdapter *_adapter; + MarkdownTextStorageDelegate *_markdownTextStorageDelegate; + MarkdownTextViewObserver *_markdownTextViewObserver; + MarkdownTextFieldObserver *_markdownTextFieldObserver; __weak RCTUITextView *_textView; + __weak RCTUITextField *_textField; } + (ComponentDescriptorProvider)componentDescriptorProvider @@ -51,21 +54,47 @@ - (instancetype)initWithFrame:(CGRect)frame - (void)didAddSubview:(UIView *)subview { react_native_assert([subview isKindOfClass:[RCTTextInputComponentView class]] && "Child component of MarkdownTextInputDecoratorComponentView is not an instance of RCTTextInputComponentView."); - _textInput = (RCTTextInputComponentView *)subview; - _backedTextInputView = [_textInput valueForKey:@"_backedTextInputView"]; + RCTTextInputComponentView *textInputComponentView = (RCTTextInputComponentView *)subview; + UIView *backedTextInputView = [textInputComponentView valueForKey:@"_backedTextInputView"]; _markdownUtils = [[RCTMarkdownUtils alloc] init]; [_markdownUtils setMarkdownStyle:_markdownStyle]; [_markdownUtils setParserId:_parserId]; - [_textInput setMarkdownUtils:_markdownUtils]; - if ([_backedTextInputView isKindOfClass:[RCTUITextField class]]) { - RCTUITextField *textField = (RCTUITextField *)_backedTextInputView; - _adapter = [textField valueForKey:@"textInputDelegateAdapter"]; - [_adapter setMarkdownUtils:_markdownUtils]; - } else if ([_backedTextInputView isKindOfClass:[RCTUITextView class]]) { - _textView = (RCTUITextView *)_backedTextInputView; - [_textView setMarkdownUtils:_markdownUtils]; + if ([backedTextInputView isKindOfClass:[RCTUITextField class]]) { + _textField = (RCTUITextField *)backedTextInputView; + + // make sure `adjustsFontSizeToFitWidth` is disabled, otherwise formatting will be overwritten + react_native_assert(_textField.adjustsFontSizeToFitWidth == NO); + + _markdownTextFieldObserver = [[MarkdownTextFieldObserver alloc] initWithTextField:_textField markdownUtils:_markdownUtils]; + + // register observers for future edits + [_textField addTarget:_markdownTextFieldObserver action:@selector(textFieldDidChange:) forControlEvents:UIControlEventEditingChanged]; + [_textField addTarget:_markdownTextFieldObserver action:@selector(textFieldDidEndEditing:) forControlEvents:UIControlEventEditingDidEnd]; + [_textField addObserver:_markdownTextFieldObserver forKeyPath:@"text" options:NSKeyValueObservingOptionNew context:NULL]; + [_textField addObserver:_markdownTextFieldObserver forKeyPath:@"attributedText" options:NSKeyValueObservingOptionNew context:NULL]; + + // format initial value + [_markdownTextFieldObserver textFieldDidChange:_textField]; + + // TODO: register blockquotes layout manager + // https://github.com/Expensify/react-native-live-markdown/issues/87 + } else if ([backedTextInputView isKindOfClass:[RCTUITextView class]]) { + _textView = (RCTUITextView *)backedTextInputView; + + // register delegate for future edits + react_native_assert(_textView.textStorage.delegate == nil); + _markdownTextStorageDelegate = [[MarkdownTextStorageDelegate alloc] initWithTextView:_textView markdownUtils:_markdownUtils]; + _textView.textStorage.delegate = _markdownTextStorageDelegate; + + // register observer for default text attributes + _markdownTextViewObserver = [[MarkdownTextViewObserver alloc] initWithTextView:_textView markdownUtils:_markdownUtils]; + [_textView addObserver:_markdownTextViewObserver forKeyPath:@"defaultTextAttributes" options:NSKeyValueObservingOptionNew context:NULL]; + + // format initial value + [_textView.textStorage setAttributedString:_textView.attributedText]; + NSLayoutManager *layoutManager = _textView.layoutManager; // switching to TextKit 1 compatibility mode // Correct content height in TextKit 1 compatibility mode. (See https://github.com/Expensify/App/issues/41567) @@ -91,19 +120,26 @@ - (void)willMoveToWindow:(UIWindow *)newWindow if (newWindow != nil) { return; } - if (_textInput != nil) { - [_textInput setMarkdownUtils:nil]; - } - if (_adapter != nil) { - [_adapter setMarkdownUtils:nil]; - } if (_textView != nil) { - _markdownBackedTextInputDelegate = nil; - [_textView setMarkdownUtils:nil]; if (_textView.layoutManager != nil && [object_getClass(_textView.layoutManager) isEqual:[MarkdownLayoutManager class]]) { [_textView.layoutManager setValue:nil forKey:@"markdownUtils"]; object_setClass(_textView.layoutManager, [NSLayoutManager class]); } + _markdownBackedTextInputDelegate = nil; + [_textView removeObserver:_markdownTextViewObserver forKeyPath:@"defaultTextAttributes" context:NULL]; + _markdownTextViewObserver = nil; + _markdownTextStorageDelegate = nil; + _textView.textStorage.delegate = nil; + _textView = nil; + } + + if (_textField != nil) { + [_textField removeTarget:_markdownTextFieldObserver action:@selector(textFieldDidChange:) forControlEvents:UIControlEventEditingChanged]; + [_textField removeTarget:_markdownTextFieldObserver action:@selector(textFieldDidEndEditing:) forControlEvents:UIControlEventEditingDidEnd]; + [_textField removeObserver:_markdownTextFieldObserver forKeyPath:@"text" context:NULL]; + [_textField removeObserver:_markdownTextFieldObserver forKeyPath:@"attributedText" context:NULL]; + _markdownTextFieldObserver = nil; + _textField = nil; } } @@ -130,11 +166,10 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & - (void)applyNewStyles { if (_textView != nil) { - // We want to use `textStorage` for applying markdown when possible. Currently it's only available for UITextView - [_textView textDidChange]; - } else { - // apply new styles - [_textInput _setAttributedString:_backedTextInputView.attributedText]; + [_textView.textStorage setAttributedString:_textView.attributedText]; + } + if (_textField != nil) { + [_markdownTextFieldObserver textFieldDidChange:_textField]; } } diff --git a/apple/MarkdownTextInputDecoratorShadowNode.mm b/apple/MarkdownTextInputDecoratorShadowNode.mm index a4ab8ffa7..cacab7d14 100644 --- a/apple/MarkdownTextInputDecoratorShadowNode.mm +++ b/apple/MarkdownTextInputDecoratorShadowNode.mm @@ -188,8 +188,8 @@ } // apply markdown - auto newString = [utils parseMarkdown:nsAttributedString - withDefaultTextAttributes:defaultNSTextAttributes]; + NSMutableAttributedString *newString = [nsAttributedString mutableCopy]; + [utils applyMarkdownFormatting:newString withDefaultTextAttributes:defaultNSTextAttributes]; // create a clone of the old TextInputState and update the // attributed string box to point to the string with markdown @@ -200,8 +200,8 @@ AttributedStringBox::Mode::OpaquePointer) { // apply markdown - auto newString = [utils parseMarkdown:nsAttributedString - withDefaultTextAttributes:defaultNSTextAttributes]; + NSMutableAttributedString *newString = [nsAttributedString mutableCopy]; + [utils applyMarkdownFormatting:newString withDefaultTextAttributes:defaultNSTextAttributes]; // create a clone of the old TextInputState and update the // attributed string box to point to the string with markdown diff --git a/apple/MarkdownTextStorageDelegate.h b/apple/MarkdownTextStorageDelegate.h new file mode 100644 index 000000000..e7cb244aa --- /dev/null +++ b/apple/MarkdownTextStorageDelegate.h @@ -0,0 +1,13 @@ +#import +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface MarkdownTextStorageDelegate : NSObject + +- (instancetype)initWithTextView:(nonnull RCTUITextView *)textView markdownUtils:(nonnull RCTMarkdownUtils *)markdownUtils; + +@end + +NS_ASSUME_NONNULL_END diff --git a/apple/MarkdownTextStorageDelegate.mm b/apple/MarkdownTextStorageDelegate.mm new file mode 100644 index 000000000..cba85744c --- /dev/null +++ b/apple/MarkdownTextStorageDelegate.mm @@ -0,0 +1,27 @@ +#import +#import "react_native_assert.h" + +@implementation MarkdownTextStorageDelegate { + RCTUITextView *_textView; + RCTMarkdownUtils *_markdownUtils; +} + +- (instancetype)initWithTextView:(nonnull RCTUITextView *)textView markdownUtils:(nonnull RCTMarkdownUtils *)markdownUtils +{ + if ((self = [super init])) { + react_native_assert(textView != nil); + react_native_assert(markdownUtils != nil); + + _textView = textView; + _markdownUtils = markdownUtils; + } + return self; +} + +- (void)textStorage:(NSTextStorage *)textStorage didProcessEditing:(NSTextStorageEditActions)editedMask range:(NSRange)editedRange changeInLength:(NSInteger)delta { + react_native_assert(_textView.defaultTextAttributes != nil); + + [_markdownUtils applyMarkdownFormatting:textStorage withDefaultTextAttributes:_textView.defaultTextAttributes]; +} + +@end diff --git a/apple/MarkdownTextViewObserver.h b/apple/MarkdownTextViewObserver.h new file mode 100644 index 000000000..d5d2abbfc --- /dev/null +++ b/apple/MarkdownTextViewObserver.h @@ -0,0 +1,13 @@ +#import +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface MarkdownTextViewObserver : NSObject + +- (instancetype)initWithTextView:(nonnull RCTUITextView *)textView markdownUtils:(nonnull RCTMarkdownUtils *)markdownUtils; + +@end + +NS_ASSUME_NONNULL_END diff --git a/apple/MarkdownTextViewObserver.mm b/apple/MarkdownTextViewObserver.mm new file mode 100644 index 000000000..5ef84c93c --- /dev/null +++ b/apple/MarkdownTextViewObserver.mm @@ -0,0 +1,28 @@ +#import +#import "react_native_assert.h" + +@implementation MarkdownTextViewObserver { + RCTUITextView *_textView; + RCTMarkdownUtils *_markdownUtils; +} + +- (instancetype)initWithTextView:(nonnull RCTUITextView *)textView markdownUtils:(nonnull RCTMarkdownUtils *)markdownUtils +{ + if ((self = [super init])) { + react_native_assert(textView != nil); + react_native_assert(markdownUtils != nil); + + _textView = textView; + _markdownUtils = markdownUtils; + } + return self; +} + +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context +{ + if ([keyPath isEqualToString:@"defaultTextAttributes"]) { + [_textView.textStorage setAttributedString:_textView.attributedText]; + } +} + +@end diff --git a/apple/RCTBackedTextFieldDelegateAdapter+Markdown.h b/apple/RCTBackedTextFieldDelegateAdapter+Markdown.h deleted file mode 100644 index f8ddc1d2a..000000000 --- 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 d4d8f8214..000000000 --- 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 withDefaultTextAttributes: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.h b/apple/RCTMarkdownUtils.h index fed14596d..12fb1ba9f 100644 --- a/apple/RCTMarkdownUtils.h +++ b/apple/RCTMarkdownUtils.h @@ -8,8 +8,8 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic) RCTMarkdownStyle *markdownStyle; @property (nonatomic) NSNumber *parserId; -- (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input - withDefaultTextAttributes:(nonnull NSDictionary *)defaultTextAttributes; +- (void)applyMarkdownFormatting:(nonnull NSMutableAttributedString *)attributedString + withDefaultTextAttributes:(nonnull NSDictionary *)defaultTextAttributes; @end diff --git a/apple/RCTMarkdownUtils.mm b/apple/RCTMarkdownUtils.mm index 952a75031..71e147025 100644 --- a/apple/RCTMarkdownUtils.mm +++ b/apple/RCTMarkdownUtils.mm @@ -5,11 +5,6 @@ @implementation RCTMarkdownUtils { MarkdownParser *_markdownParser; MarkdownFormatter *_markdownFormatter; - NSString *_prevInputString; - NSAttributedString *_prevAttributedString; - NSDictionary *_prevDefaultTextAttributes; - __weak RCTMarkdownStyle *_prevMarkdownStyle; - __weak NSNumber *_prevParserId; } - (instancetype)init @@ -22,39 +17,21 @@ - (instancetype)init return self; } -- (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input - withDefaultTextAttributes:(nonnull NSDictionary *)defaultTextAttributes +- (void)applyMarkdownFormatting:(nonnull NSMutableAttributedString *)attributedString + withDefaultTextAttributes:(nonnull NSDictionary *)defaultTextAttributes { - @synchronized (self) { - if (input == nil) { - return nil; - } - - // `_markdownStyle` and `_parserId` may not be initialized immediately due to the order of mount instructions - // props update will be executed after the view hierarchy is initialized. - if (_markdownStyle == nil || _parserId == nil) { - return nil; - } - - NSString *inputString = [input string]; - if ([inputString isEqualToString:_prevInputString] && [defaultTextAttributes isEqualToDictionary:_prevDefaultTextAttributes] && [_markdownStyle isEqual:_prevMarkdownStyle] && [_parserId isEqualToNumber:_prevParserId]) { - return _prevAttributedString; - } - - NSArray *markdownRanges = [_markdownParser parse:inputString withParserId:_parserId]; + // `_markdownStyle` and `_parserId` may not be initialized immediately due to the order of mount instructions + // props update will be executed after the view hierarchy is initialized. + if (_markdownStyle == nil || _parserId == nil) { + return; + } - NSAttributedString *attributedString = [_markdownFormatter format:inputString - withDefaultTextAttributes:defaultTextAttributes - withMarkdownRanges:markdownRanges - withMarkdownStyle:_markdownStyle]; - _prevInputString = inputString; - _prevAttributedString = attributedString; - _prevDefaultTextAttributes = defaultTextAttributes; - _prevMarkdownStyle = _markdownStyle; - _prevParserId = _parserId; + NSArray *markdownRanges = [_markdownParser parse:attributedString.string withParserId:_parserId]; - return attributedString; - } + [_markdownFormatter formatAttributedString:attributedString + withDefaultTextAttributes:defaultTextAttributes + withMarkdownRanges:markdownRanges + withMarkdownStyle:_markdownStyle]; } @end diff --git a/apple/RCTTextInputComponentView+Markdown.h b/apple/RCTTextInputComponentView+Markdown.h index 29862ad4b..46c35b776 100644 --- a/apple/RCTTextInputComponentView+Markdown.h +++ b/apple/RCTTextInputComponentView+Markdown.h @@ -1,18 +1,11 @@ #import -#import NS_ASSUME_NONNULL_BEGIN @interface RCTTextInputComponentView (Markdown) -@property(nonatomic, nullable, getter=getMarkdownUtils) RCTMarkdownUtils *markdownUtils; - -- (void)markdown__setAttributedString:(NSAttributedString *)attributedString; - - (BOOL)markdown__textOf:(NSAttributedString *)newText equals:(NSAttributedString *)oldText; -- (void)_setAttributedString:(NSAttributedString *)attributedString; - @end NS_ASSUME_NONNULL_END diff --git a/apple/RCTTextInputComponentView+Markdown.mm b/apple/RCTTextInputComponentView+Markdown.mm index 211292950..ffde655f6 100644 --- a/apple/RCTTextInputComponentView+Markdown.mm +++ b/apple/RCTTextInputComponentView+Markdown.mm @@ -1,63 +1,23 @@ #import -#import -#import +#import #import @implementation RCTTextInputComponentView (Markdown) -- (void)setMarkdownUtils:(RCTMarkdownUtils *)markdownUtils { - objc_setAssociatedObject(self, @selector(getMarkdownUtils), markdownUtils, OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -- (RCTMarkdownUtils *)getMarkdownUtils { - return objc_getAssociatedObject(self, @selector(getMarkdownUtils)); -} - -- (RCTUITextView *)getBackedTextInputView { - return [self valueForKey:@"_backedTextInputView"]; -} - -- (void)markdown__setAttributedString:(NSAttributedString *)attributedString -{ - RCTMarkdownUtils *markdownUtils = [self getMarkdownUtils]; - RCTUITextView *backedTextInputView = [self getBackedTextInputView]; - if (markdownUtils != nil && backedTextInputView != nil) { - attributedString = [markdownUtils parseMarkdown:attributedString withDefaultTextAttributes:backedTextInputView.defaultTextAttributes]; - } - - // Call the original method - [self markdown__setAttributedString:attributedString]; - - if (markdownUtils != nil && backedTextInputView != nil) { - // After adding a newline at the end of the blockquote, the typing attributes in the next line still contain - // NSParagraphStyle with non-zero firstLineHeadIndent and headIntent added by `_updateTypingAttributes` call. - // This causes the cursor to be shifted to the right instead of being located at the beginning of the line. - // The following code resets firstLineHeadIndent and headIndent in NSParagraphStyle in typing attributes - // in order to fix the position of the cursor. - NSDictionary *typingAttributes = backedTextInputView.typingAttributes; - if (typingAttributes[NSParagraphStyleAttributeName] != nil) { - NSMutableDictionary *mutableTypingAttributes = [typingAttributes mutableCopy]; - NSMutableParagraphStyle *mutableParagraphStyle = [typingAttributes[NSParagraphStyleAttributeName] mutableCopy]; - mutableParagraphStyle.firstLineHeadIndent = 0; - mutableParagraphStyle.headIndent = 0; - mutableTypingAttributes[NSParagraphStyleAttributeName] = mutableParagraphStyle; - backedTextInputView.typingAttributes = mutableTypingAttributes; - } - } -} - - (BOOL)markdown__textOf:(NSAttributedString *)newText equals:(NSAttributedString *)oldText { - RCTMarkdownUtils *markdownUtils = [self getMarkdownUtils]; - if (markdownUtils != nil) { - // Emoji characters are automatically assigned an AppleColorEmoji NSFont and the original font is moved to NSOriginalFont - // We need to remove these attributes before comparison - NSMutableAttributedString *newTextCopy = [newText mutableCopy]; - NSMutableAttributedString *oldTextCopy = [oldText mutableCopy]; - [newTextCopy removeAttribute:@"NSFont" range:NSMakeRange(0, newTextCopy.length)]; - [oldTextCopy removeAttribute:@"NSFont" range:NSMakeRange(0, oldTextCopy.length)]; - [oldTextCopy removeAttribute:@"NSOriginalFont" range:NSMakeRange(0, oldTextCopy.length)]; - return [newTextCopy isEqualToAttributedString:oldTextCopy]; + __block BOOL isMarkdownTextInput = false; + [oldText enumerateAttribute:RCTLiveMarkdownTextAttributeName + inRange:NSMakeRange(0, oldText.length) + options:0 + usingBlock:^(id value, NSRange range, BOOL *stop) { + if (value) { + isMarkdownTextInput = true; + *stop = YES; + } + }]; + if (isMarkdownTextInput) { + return [newText.string isEqualToString:oldText.string]; } return [self markdown__textOf:newText equals:oldText]; @@ -67,25 +27,13 @@ + (void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ - { - // swizzle _setAttributedString - Class cls = [self class]; - SEL originalSelector = @selector(_setAttributedString:); - SEL swizzledSelector = @selector(markdown__setAttributedString:); - Method originalMethod = class_getInstanceMethod(cls, originalSelector); - Method swizzledMethod = class_getInstanceMethod(cls, swizzledSelector); - method_exchangeImplementations(originalMethod, swizzledMethod); - } - - { - // swizzle _textOf - Class cls = [self class]; - SEL originalSelector = @selector(_textOf:equals:); - SEL swizzledSelector = @selector(markdown__textOf:equals:); - Method originalMethod = class_getInstanceMethod(cls, originalSelector); - Method swizzledMethod = class_getInstanceMethod(cls, swizzledSelector); - method_exchangeImplementations(originalMethod, swizzledMethod); - } + // swizzle _textOf + Class cls = [self class]; + SEL originalSelector = @selector(_textOf:equals:); + SEL swizzledSelector = @selector(markdown__textOf:equals:); + Method originalMethod = class_getInstanceMethod(cls, originalSelector); + Method swizzledMethod = class_getInstanceMethod(cls, swizzledSelector); + method_exchangeImplementations(originalMethod, swizzledMethod); }); } diff --git a/apple/RCTUITextView+Markdown.h b/apple/RCTUITextView+Markdown.h deleted file mode 100644 index 40deedad5..000000000 --- a/apple/RCTUITextView+Markdown.h +++ /dev/null @@ -1,19 +0,0 @@ -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface RCTUITextView (Private) -- (void)textDidChange; -@end - -@interface RCTUITextView (Markdown) - -@property(nonatomic, nullable, getter=getMarkdownUtils) RCTMarkdownUtils *markdownUtils; - -- (void)markdown_textDidChange; - -@end - -NS_ASSUME_NONNULL_END diff --git a/apple/RCTUITextView+Markdown.mm b/apple/RCTUITextView+Markdown.mm deleted file mode 100644 index 3815d83f2..000000000 --- a/apple/RCTUITextView+Markdown.mm +++ /dev/null @@ -1,41 +0,0 @@ -#import -#import -#import - -@implementation RCTUITextView (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_textDidChange -{ - RCTMarkdownUtils *markdownUtils = [self getMarkdownUtils]; - if (markdownUtils != nil) { - UITextRange *range = self.selectedTextRange; - super.attributedText = [markdownUtils parseMarkdown:self.attributedText withDefaultTextAttributes:self.defaultTextAttributes]; - [super setSelectedTextRange:range]; // prevents cursor from jumping at the end when typing in the middle of the text - } - - // Call the original method - [self markdown_textDidChange]; -} - -+ (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); - }); -} - -@end diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 7bf062a9c..d8130e3df 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1654,7 +1654,7 @@ PODS: - React-logger (= 0.79.2) - React-perflogger (= 0.79.2) - React-utils (= 0.79.2) - - RNLiveMarkdown (0.1.272): + - RNLiveMarkdown (0.1.274): - DoubleConversion - glog - hermes-engine @@ -2103,7 +2103,7 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 04d5eb15eb46be6720e17a4a7fa92940a776e584 ReactCodegen: 913f5c1e00981f2366e36dcb9ae5caa01c2745f4 ReactCommon: 76d2dc87136d0a667678668b86f0fca0c16fdeb0 - RNLiveMarkdown: d9f07026e8a9bffeb43c1b61f94782cc38a9458f + RNLiveMarkdown: 9ef67cf60c3caf0ee3e7f49fbeaeda8f519fb6bc RNReanimated: 8011ddcc1f6bc2fc1f27a5ac384d7495829ab6a0 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: c758bfb934100bb4bf9cbaccb52557cee35e8bdf diff --git a/example/src/App.tsx b/example/src/App.tsx index e7db95397..729436cd1 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -10,6 +10,7 @@ import {handleFormatSelection} from './formatSelectionUtils'; export default function App() { const [value, setValue] = React.useState(TEST_CONST.EXAMPLE_CONTENT); + const [multiline, setMultiline] = React.useState(true); const [textColorState, setTextColorState] = React.useState(false); const [linkColorState, setLinkColorState] = React.useState(false); const [textFontSizeState, setTextFontSizeState] = React.useState(false); @@ -43,7 +44,7 @@ export default function App() { style={styles.content}> +