diff --git a/apple/MarkdownCommitHook.mm b/apple/MarkdownCommitHook.mm index f1923a3d..fcff8127 100644 --- a/apple/MarkdownCommitHook.mm +++ b/apple/MarkdownCommitHook.mm @@ -170,8 +170,8 @@ } // apply markdown - auto newString = [utils parseMarkdown:nsAttributedString - withAttributes:defaultNSTextAttributes]; + NSMutableAttributedString *newString = [nsAttributedString mutableCopy]; + [utils applyFormatting:newString withDefaultTextAttributes:defaultNSTextAttributes]; // create a clone of the old TextInputState and update the // attributed string box to point to the string with markdown @@ -217,8 +217,8 @@ stateData.attributedStringBox); // apply markdown - auto newString = [utils parseMarkdown:nsAttributedString - withAttributes:defaultNSTextAttributes]; + NSMutableAttributedString *newString = [nsAttributedString mutableCopy]; + [utils applyFormatting: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/MarkdownTextFieldObserver.h b/apple/MarkdownTextFieldObserver.h new file mode 100644 index 00000000..f063c30f --- /dev/null +++ b/apple/MarkdownTextFieldObserver.h @@ -0,0 +1,15 @@ +#import +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface MarkdownTextFieldObserver : NSObject + +- (instancetype)initWithTextField:(nonnull RCTUITextField *)textField markdownUtils:(nonnull RCTMarkdownUtils *)markdownUtils; + +- (void)textFieldDidChange:(UITextField *)textField; + +@end + +NS_ASSUME_NONNULL_END diff --git a/apple/MarkdownTextFieldObserver.mm b/apple/MarkdownTextFieldObserver.mm new file mode 100644 index 00000000..8544c1d0 --- /dev/null +++ b/apple/MarkdownTextFieldObserver.mm @@ -0,0 +1,47 @@ +#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 textFieldDidChange:_textField]; + } +} + +- (void)textFieldDidChange:(__unused UITextField *)textField { + 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 applyFormatting:attributedText withDefaultTextAttributes:_textField.defaultTextAttributes]; + + UITextRange *textRange = _textField.selectedTextRange; + _active = NO; // prevent recursion + _textField.attributedText = attributedText; + _active = YES; + [_textField setSelectedTextRange:textRange notifyDelegate:NO]; +} + +@end diff --git a/apple/MarkdownTextInputDecoratorView.mm b/apple/MarkdownTextInputDecoratorView.mm index c6ae82fb..bc9cbf4b 100644 --- a/apple/MarkdownTextInputDecoratorView.mm +++ b/apple/MarkdownTextInputDecoratorView.mm @@ -1,30 +1,27 @@ #import +#import #import "react_native_assert.h" -#import -#import -#import -#import - #ifdef RCT_NEW_ARCH_ENABLED -#import +#import #else -#import -#endif /* RCT_NEW_ARCH_ENABLED */ +#import +#endif + +#import +#import +#import +#import #import @implementation MarkdownTextInputDecoratorView { RCTMarkdownUtils *_markdownUtils; RCTMarkdownStyle *_markdownStyle; -#ifdef RCT_NEW_ARCH_ENABLED - __weak RCTTextInputComponentView *_textInput; -#else - __weak RCTBaseTextInputView *_textInput; -#endif /* RCT_NEW_ARCH_ENABLED */ - __weak UIView *_backedTextInputView; - __weak RCTBackedTextFieldDelegateAdapter *_adapter; + MarkdownTextStorageDelegate *_markdownTextStorageDelegate; + MarkdownTextFieldObserver *_markdownTextFieldObserver; __weak RCTUITextView *_textView; + __weak RCTUITextField *_textField; } - (void)didMoveToWindow { @@ -51,26 +48,51 @@ - (void)didMoveToWindow { #ifdef RCT_NEW_ARCH_ENABLED react_native_assert([view isKindOfClass:[RCTTextInputComponentView class]] && "Previous sibling component is not an instance of RCTTextInputComponentView."); - _textInput = (RCTTextInputComponentView *)view; - _backedTextInputView = [_textInput valueForKey:@"_backedTextInputView"]; + RCTTextInputComponentView *textInputComponentView = (RCTTextInputComponentView *)view; + UIView *backedTextInputView = [textInputComponentView valueForKey:@"_backedTextInputView"]; #else react_native_assert([view isKindOfClass:[RCTBaseTextInputView class]] && "Previous sibling component is not an instance of RCTBaseTextInputView."); - _textInput = (RCTBaseTextInputView *)view; - _backedTextInputView = _textInput.backedTextInputView; + RCTBaseTextInputView *baseTextInputView = (RCTBaseTextInputView *)view; + UIView *backedTextInputView = baseTextInputView.backedTextInputView; #endif /* RCT_NEW_ARCH_ENABLED */ _markdownUtils = [[RCTMarkdownUtils alloc] init]; react_native_assert(_markdownStyle != nil); [_markdownUtils setMarkdownStyle:_markdownStyle]; - [_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 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; + +#ifdef RCT_NEW_ARCH_ENABLED + // format initial value + [_textView.textStorage setAttributedString:_textView.attributedText]; +#else + // `_textView.defaultTextAttributes` is nil here, initial value will be passed to `setAttributedText:` that will be called later +#endif + 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) @@ -90,18 +112,22 @@ - (void)didMoveToWindow { - (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]]) { [_textView.layoutManager setValue:nil forKey:@"markdownUtils"]; object_setClass(_textView.layoutManager, [NSLayoutManager class]); } + _markdownTextStorageDelegate = nil; + _textView.textStorage.delegate = nil; + _textView = nil; + } + + if (_textField != nil) { + [_textField removeTarget:_markdownTextFieldObserver action:@selector(textFieldDidChange:) forControlEvents:UIControlEventEditingChanged]; + [_textField removeObserver:_markdownTextFieldObserver forKeyPath:@"text" context:NULL]; + [_textField removeObserver:_markdownTextFieldObserver forKeyPath:@"attributedText" context:NULL]; + _markdownTextFieldObserver = nil; + _textField = nil; } } @@ -110,16 +136,12 @@ - (void)setMarkdownStyle:(RCTMarkdownStyle *)markdownStyle _markdownStyle = markdownStyle; [_markdownUtils setMarkdownStyle:markdownStyle]; + // trigger reformatting 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 -#ifdef RCT_NEW_ARCH_ENABLED - [_textInput _setAttributedString:_backedTextInputView.attributedText]; -#else - [_textInput setAttributedText:_textInput.attributedText]; -#endif /* RCT_NEW_ARCH_ENABLED */ + [_textView.textStorage setAttributedString:_textView.attributedText]; + } + if (_textField != nil) { + [_markdownTextFieldObserver textFieldDidChange:_textField]; } } diff --git a/apple/MarkdownTextStorageDelegate.h b/apple/MarkdownTextStorageDelegate.h new file mode 100644 index 00000000..e7cb244a --- /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 00000000..9bcb40dd --- /dev/null +++ b/apple/MarkdownTextStorageDelegate.mm @@ -0,0 +1,30 @@ +#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 applyFormatting:textStorage withDefaultTextAttributes:_textView.defaultTextAttributes]; + + // TODO: fix cursor position when adding newline after a blockquote (probably not here though) + // TODO: fix spellcheck not working for any of previous words when component value is controlled and contains bold (probably not here though) +} + +@end 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/RCTBaseTextInputView+Markdown.h b/apple/RCTBaseTextInputView+Markdown.h deleted file mode 100644 index 3d37adb2..00000000 --- a/apple/RCTBaseTextInputView+Markdown.h +++ /dev/null @@ -1,18 +0,0 @@ -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface RCTBaseTextInputView (Markdown) - -@property(nonatomic, nullable, getter=getMarkdownUtils) RCTMarkdownUtils *markdownUtils; - -- (void)markdown_setAttributedText:(NSAttributedString *)attributedText; - -- (BOOL)markdown_textOf:(NSAttributedString *)newText equals:(NSAttributedString *)oldText; - -- (void)markdown_updateLocalData; - -@end - -NS_ASSUME_NONNULL_END diff --git a/apple/RCTBaseTextInputView+Markdown.mm b/apple/RCTBaseTextInputView+Markdown.mm deleted file mode 100644 index 7662d545..00000000 --- a/apple/RCTBaseTextInputView+Markdown.mm +++ /dev/null @@ -1,101 +0,0 @@ -#import -#import -#import - -@implementation RCTBaseTextInputView (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_setAttributedText:(NSAttributedString *)attributedText -{ - RCTMarkdownUtils *markdownUtils = [self getMarkdownUtils]; - if (markdownUtils != nil) { - attributedText = [markdownUtils parseMarkdown:attributedText withAttributes:self.backedTextInputView.defaultTextAttributes]; - } - - // Call the original method - [self markdown_setAttributedText:attributedText]; -} - -- (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]; - } - - 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; - dispatch_once(&onceToken, ^{ - Class cls = [self class]; - - { - // swizzle setAttributedText - SEL originalSelector = @selector(setAttributedText:); - SEL swizzledSelector = @selector(markdown_setAttributedText:); - Method originalMethod = class_getInstanceMethod(cls, originalSelector); - Method swizzledMethod = class_getInstanceMethod(cls, swizzledSelector); - 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:); - SEL swizzledSelector = @selector(markdown_textOf:equals:); - 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 4d080bb8..da40ab16 100644 --- a/apple/RCTMarkdownUtils.h +++ b/apple/RCTMarkdownUtils.h @@ -8,7 +8,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic) RCTMarkdownStyle *markdownStyle; @property (nonatomic) NSMutableArray *blockquoteRangesAndLevels; -- (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input withAttributes:(nullable NSDictionary*)attributes; +- (void)applyFormatting:(nonnull NSMutableAttributedString *)attributedString withDefaultTextAttributes:(nonnull NSDictionary *)defaultTextAttributes; @end diff --git a/apple/RCTMarkdownUtils.mm b/apple/RCTMarkdownUtils.mm index c22a8784..9f6a2656 100644 --- a/apple/RCTMarkdownUtils.mm +++ b/apple/RCTMarkdownUtils.mm @@ -8,25 +8,10 @@ using namespace facebook; -@implementation RCTMarkdownUtils { - NSString *_prevInputString; - NSAttributedString *_prevAttributedString; - NSDictionary *_prevTextAttributes; - __weak RCTMarkdownStyle *_prevMarkdownStyle; -} +@implementation RCTMarkdownUtils -- (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input withAttributes:(nullable NSDictionary *)attributes +- (void)applyFormatting:(nonnull NSMutableAttributedString *)attributedString withDefaultTextAttributes:(nonnull NSDictionary *)defaultTextAttributes { - @synchronized (self) { - if (input == nil) { - return nil; - } - - NSString *inputString = [input string]; - if ([inputString isEqualToString:_prevInputString] && [attributes isEqualToDictionary:_prevTextAttributes] && [_markdownStyle isEqual:_prevMarkdownStyle]) { - return _prevAttributedString; - } - static std::shared_ptr runtime; static std::mutex runtimeMutex; auto lock = std::lock_guard(runtimeMutex); @@ -42,24 +27,21 @@ - (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input withA } jsi::Runtime &rt = *runtime; - auto text = jsi::String::createFromUtf8(rt, [inputString UTF8String]); + auto text = jsi::String::createFromUtf8(rt, attributedString.string.UTF8String); auto func = rt.global().getPropertyAsFunction(rt, "parseExpensiMarkToRanges"); auto output = func.call(rt, text); - if (output.isUndefined()) { - return input; - } + // TODO: memoize parser output const auto &ranges = output.asObject(rt).asArray(rt); - NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:inputString attributes:attributes]; + 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 addAttributes:defaultTextAttributes range:fullRange]; _blockquoteRangesAndLevels = [NSMutableArray new]; + // TODO: use custom attribute to mark blockquotes for (size_t i = 0, n = ranges.size(rt); i < n; ++i) { const auto &item = ranges.getValueAtIndex(rt, i).asObject(rt); @@ -73,15 +55,25 @@ - (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input withA RCTApplyBaselineOffset(attributedString); - [attributedString endEditing]; - - _prevInputString = inputString; - _prevAttributedString = attributedString; - _prevTextAttributes = attributes; - _prevMarkdownStyle = _markdownStyle; + /* + Calling `[attributedString addAttributes:defaultTextAttributes range:fullRange]` breaks the font for emojis. + Before, NSFont attribute is ".SFUI-Regular" and NSOriginalFont attribute is ".AppleColorEmoji". + After the call, both are set to ".SFUI-Regular" which makes emoji invisible and zero-width. + Calling `fixAttributesInRange:` fixes this problem. + */ + [attributedString fixAttributesInRange:fullRange]; + + /* + When updating MarkdownTextInput's `style` property without changing `markdownStyle`, + React Native calls `[RCTTextInputComponentView _setAttributedString:]` which skips update if strings are equal. + See https://github.com/facebook/react-native/blob/287e20033207df5e59d199a347b7ae2b4cd7a59e/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm#L680-L684 + The attributed strings are compared using `[RCTTextInputComponentView _textOf:equals:]` which compares only raw strings + if NSOriginalFont attribute is present. So we purposefully remove this attribute to force update. + See https://github.com/facebook/react-native/blob/287e20033207df5e59d199a347b7ae2b4cd7a59e/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm#L751-L784 + */ + [attributedString removeAttribute:@"NSOriginalFont" range:fullRange]; - return attributedString; - } + [attributedString endEditing]; } - (void)applyRangeToAttributedString:(NSMutableAttributedString *)attributedString type:(const std::string)type start:(const int)start length:(const int)length depth:(const int)depth { diff --git a/apple/RCTTextInputComponentView+Markdown.h b/apple/RCTTextInputComponentView+Markdown.h deleted file mode 100644 index 346bc711..00000000 --- a/apple/RCTTextInputComponentView+Markdown.h +++ /dev/null @@ -1,23 +0,0 @@ -// This guard prevent this file to be compiled in the old architecture. -#ifdef RCT_NEW_ARCH_ENABLED - -#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 - -#endif /* RCT_NEW_ARCH_ENABLED */ diff --git a/apple/RCTTextInputComponentView+Markdown.mm b/apple/RCTTextInputComponentView+Markdown.mm deleted file mode 100644 index 5ce1e63e..00000000 --- a/apple/RCTTextInputComponentView+Markdown.mm +++ /dev/null @@ -1,97 +0,0 @@ -// This guard prevent this file to be compiled in the old architecture. -#ifdef RCT_NEW_ARCH_ENABLED - -#import -#import -#import -#import - -#import "MarkdownShadowFamilyRegistry.h" - -using namespace expensify::livemarkdown; - -@implementation RCTTextInputComponentView (Markdown) - -- (void)setMarkdownUtils:(RCTMarkdownUtils *)markdownUtils { - objc_setAssociatedObject(self, @selector(getMarkdownUtils), markdownUtils, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - - if (markdownUtils != nil) { - // force Markdown formatting on first render because `_setAttributedText` is called before `setMarkdownUtils` - RCTUITextField *backedTextInputView = [self getBackedTextInputView]; - backedTextInputView.attributedText = [markdownUtils parseMarkdown:backedTextInputView.attributedText withAttributes:backedTextInputView.defaultTextAttributes]; - } -} - -- (RCTMarkdownUtils *)getMarkdownUtils { - return objc_getAssociatedObject(self, @selector(getMarkdownUtils)); -} - -- (RCTUITextField *)getBackedTextInputView { - RCTUITextField *backedTextInputView = [self valueForKey:@"_backedTextInputView"]; - return 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); - } - - // Call the original method - [self markdown__setAttributedString:attributedString]; -} - -- (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]; - } - - return [self markdown__textOf:newText equals:oldText]; -} - -+ (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); - } - }); -} - -@end - -#endif /* RCT_NEW_ARCH_ENABLED */ diff --git a/apple/RCTUITextView+Markdown.h b/apple/RCTUITextView+Markdown.h deleted file mode 100644 index 40deedad..00000000 --- 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 70f2d882..00000000 --- a/apple/RCTUITextView+Markdown.mm +++ /dev/null @@ -1,42 +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 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 - } - - // 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 14c774cb..5358991f 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1497,7 +1497,7 @@ PODS: - React-logger (= 0.75.2) - React-perflogger (= 0.75.2) - React-utils (= 0.75.2) - - RNLiveMarkdown (0.1.169): + - RNLiveMarkdown (0.1.182): - DoubleConversion - glog - hermes-engine @@ -1517,9 +1517,9 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNLiveMarkdown/newarch (= 0.1.169) + - RNLiveMarkdown/newarch (= 0.1.182) - Yoga - - RNLiveMarkdown/newarch (0.1.169): + - RNLiveMarkdown/newarch (0.1.182): - DoubleConversion - glog - hermes-engine @@ -1805,7 +1805,7 @@ SPEC CHECKSUMS: React-utils: 81a715d9c0a2a49047e77a86f3a2247408540deb ReactCodegen: 60973d382704c793c605b9be0fc7f31cb279442f ReactCommon: 6ef348087d250257c44c0204461c03f036650e9b - RNLiveMarkdown: 00ab78496be2ae15a15a83f14ba732c01624f02c + RNLiveMarkdown: 4060f283dbbf0d1fb7e535707a6bc60441b282de SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d Yoga: 2a45d7e59592db061217551fd3bbe2dd993817ae