From edce693cce5d81ea84da980d425e74f17cefa189 Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Thu, 17 Oct 2024 11:29:38 +0200 Subject: [PATCH 01/19] Use `NSTextStorageDelegate` instead of method swizzling --- apple/MarkdownCommitHook.mm | 8 +- apple/MarkdownTextInputDecoratorView.mm | 74 +++++-------- apple/MarkdownTextStorageDelegate.h | 15 +++ apple/MarkdownTextStorageDelegate.mm | 15 +++ ...TBackedTextFieldDelegateAdapter+Markdown.h | 14 --- ...BackedTextFieldDelegateAdapter+Markdown.mm | 43 -------- apple/RCTBaseTextInputView+Markdown.h | 18 ---- apple/RCTBaseTextInputView+Markdown.mm | 101 ------------------ apple/RCTMarkdownUtils.h | 2 +- apple/RCTMarkdownUtils.mm | 42 ++------ apple/RCTTextInputComponentView+Markdown.h | 23 ---- apple/RCTTextInputComponentView+Markdown.mm | 97 ----------------- apple/RCTUITextView+Markdown.h | 19 ---- apple/RCTUITextView+Markdown.mm | 42 -------- example/ios/Podfile.lock | 8 +- example/src/App.tsx | 4 +- 16 files changed, 81 insertions(+), 444 deletions(-) create mode 100644 apple/MarkdownTextStorageDelegate.h create mode 100644 apple/MarkdownTextStorageDelegate.mm delete mode 100644 apple/RCTBackedTextFieldDelegateAdapter+Markdown.h delete mode 100644 apple/RCTBackedTextFieldDelegateAdapter+Markdown.mm delete mode 100644 apple/RCTBaseTextInputView+Markdown.h delete mode 100644 apple/RCTBaseTextInputView+Markdown.mm delete mode 100644 apple/RCTTextInputComponentView+Markdown.h delete mode 100644 apple/RCTTextInputComponentView+Markdown.mm delete mode 100644 apple/RCTUITextView+Markdown.h delete mode 100644 apple/RCTUITextView+Markdown.mm 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/MarkdownTextInputDecoratorView.mm b/apple/MarkdownTextInputDecoratorView.mm index c6ae82fb..1803e4e2 100644 --- a/apple/MarkdownTextInputDecoratorView.mm +++ b/apple/MarkdownTextInputDecoratorView.mm @@ -1,29 +1,18 @@ #import +#import +#import #import "react_native_assert.h" #import #import -#import -#import - -#ifdef RCT_NEW_ARCH_ENABLED -#import -#else -#import -#endif /* RCT_NEW_ARCH_ENABLED */ +#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; __weak RCTUITextView *_textView; } @@ -51,26 +40,33 @@ - (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; + // TODO: implement on Paper + react_native_assert(false && "Not implemented on Paper yet"); #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]]) { + // TODO: implement for singleline input + react_native_assert(false && "Not implemented for singleline input yet"); + } else if ([backedTextInputView isKindOfClass:[RCTUITextView class]]) { + _textView = (RCTUITextView *)backedTextInputView; + + _markdownTextStorageDelegate = [[MarkdownTextStorageDelegate alloc] init]; + _markdownTextStorageDelegate.markdownUtils = _markdownUtils; + _markdownTextStorageDelegate.textView = _textView; + + // register delegate for future edits + _textView.textStorage.delegate = _markdownTextStorageDelegate; + + // 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) @@ -90,14 +86,9 @@ - (void)didMoveToWindow { - (void)willMoveToWindow:(UIWindow *)newWindow { - if (_textInput != nil) { - [_textInput setMarkdownUtils:nil]; - } - if (_adapter != nil) { - [_adapter setMarkdownUtils:nil]; - } if (_textView != nil) { - [_textView setMarkdownUtils:nil]; + _textView.textStorage.delegate = nil; + if (_textView.layoutManager != nil && [object_getClass(_textView.layoutManager) isEqual:[MarkdownLayoutManager class]]) { [_textView.layoutManager setValue:nil forKey:@"markdownUtils"]; object_setClass(_textView.layoutManager, [NSLayoutManager class]); @@ -110,17 +101,8 @@ - (void)setMarkdownStyle:(RCTMarkdownStyle *)markdownStyle _markdownStyle = markdownStyle; [_markdownUtils setMarkdownStyle:markdownStyle]; - 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 */ - } + // trigger reformatting + [_textView.textStorage setAttributedString:_textView.attributedText]; } @end diff --git a/apple/MarkdownTextStorageDelegate.h b/apple/MarkdownTextStorageDelegate.h new file mode 100644 index 00000000..bfdebbbd --- /dev/null +++ b/apple/MarkdownTextStorageDelegate.h @@ -0,0 +1,15 @@ +#import +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface MarkdownTextStorageDelegate : NSObject + +@property(nonatomic, nullable) RCTMarkdownUtils *markdownUtils; + +@property(nonatomic, nullable, strong) RCTUITextView *textView; + +@end + +NS_ASSUME_NONNULL_END diff --git a/apple/MarkdownTextStorageDelegate.mm b/apple/MarkdownTextStorageDelegate.mm new file mode 100644 index 00000000..475c8dab --- /dev/null +++ b/apple/MarkdownTextStorageDelegate.mm @@ -0,0 +1,15 @@ +#import + +@implementation MarkdownTextStorageDelegate + +- (void)textStorage:(NSTextStorage *)textStorage didProcessEditing:(NSTextStorageEditActions)editedMask range:(NSRange)editedRange changeInLength:(NSInteger)delta { + react_native_assert(_markdownUtils != nil); + react_native_assert(_textView != nil); + 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) +} + +@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..bf5db0a4 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,27 @@ - (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]; + [attributedString addAttributes:defaultTextAttributes range:fullRange]; + // 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 addAttribute:NSUnderlineStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleNone] range:fullRange]; + // TODO: confirm if this workaround is still necessary _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); @@ -74,14 +62,6 @@ - (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input withA RCTApplyBaselineOffset(attributedString); [attributedString endEditing]; - - _prevInputString = inputString; - _prevAttributedString = attributedString; - _prevTextAttributes = attributes; - _prevMarkdownStyle = _markdownStyle; - - return attributedString; - } } - (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..743b1a82 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.172): - 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.172) - Yoga - - RNLiveMarkdown/newarch (0.1.169): + - RNLiveMarkdown/newarch (0.1.172): - DoubleConversion - glog - hermes-engine @@ -1805,7 +1805,7 @@ SPEC CHECKSUMS: React-utils: 81a715d9c0a2a49047e77a86f3a2247408540deb ReactCodegen: 60973d382704c793c605b9be0fc7f31cb279442f ReactCommon: 6ef348087d250257c44c0204461c03f036650e9b - RNLiveMarkdown: 00ab78496be2ae15a15a83f14ba732c01624f02c + RNLiveMarkdown: 0d4f090ee84cfa2d2684b5b079547c5c9d2453b2 SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d Yoga: 2a45d7e59592db061217551fd3bbe2dd993817ae diff --git a/example/src/App.tsx b/example/src/App.tsx index d3d63426..042383e4 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -6,7 +6,9 @@ import * as TEST_CONST from './testConstants'; import {PlatformInfo} from './PlatformInfo'; export default function App() { - const [value, setValue] = React.useState(TEST_CONST.EXAMPLE_CONTENT); + const [value, setValue] = React.useState( + 'Hello *world*!\n> Lorem ipsum\nexample.com\n# Hello world\n# Hello world\n# Hello world', + ); const [textColorState, setTextColorState] = React.useState(false); const [linkColorState, setLinkColorState] = React.useState(false); const [textFontSizeState, setTextFontSizeState] = React.useState(false); From 1ea65e45049903b35df6bb775a87ddb3cb8a1a2b Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Thu, 17 Oct 2024 11:52:39 +0200 Subject: [PATCH 02/19] Fix spellcheck --- apple/RCTMarkdownUtils.mm | 6 ------ 1 file changed, 6 deletions(-) diff --git a/apple/RCTMarkdownUtils.mm b/apple/RCTMarkdownUtils.mm index bf5db0a4..b18afa35 100644 --- a/apple/RCTMarkdownUtils.mm +++ b/apple/RCTMarkdownUtils.mm @@ -40,12 +40,6 @@ - (void)applyFormatting:(nonnull NSMutableAttributedString *)attributedString wi [attributedString addAttributes:defaultTextAttributes range:fullRange]; - // 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:fullRange]; - // TODO: confirm if this workaround is still necessary - _blockquoteRangesAndLevels = [NSMutableArray new]; // TODO: use custom attribute to mark blockquotes From 20166dd78afbdc8c147aaa48f70b206ba038a3e6 Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Mon, 21 Oct 2024 18:16:29 +0200 Subject: [PATCH 03/19] Add singleline implementation --- apple/MarkdownTextFieldObserver.h | 19 ++++++++++++++ apple/MarkdownTextFieldObserver.mm | 33 +++++++++++++++++++++++++ apple/MarkdownTextInputDecoratorView.mm | 32 ++++++++++++++++++++++-- example/ios/Podfile.lock | 8 +++--- 4 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 apple/MarkdownTextFieldObserver.h create mode 100644 apple/MarkdownTextFieldObserver.mm diff --git a/apple/MarkdownTextFieldObserver.h b/apple/MarkdownTextFieldObserver.h new file mode 100644 index 00000000..c9a1b0b8 --- /dev/null +++ b/apple/MarkdownTextFieldObserver.h @@ -0,0 +1,19 @@ +#import +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface MarkdownTextFieldObserver : NSObject + +@property (nonatomic, nullable, strong) RCTMarkdownUtils *markdownUtils; + +@property (nonatomic, nullable, strong) RCTUITextField *textField; + +@property (nonatomic) BOOL active; + +- (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..341dd5c4 --- /dev/null +++ b/apple/MarkdownTextFieldObserver.mm @@ -0,0 +1,33 @@ +#import + +@implementation MarkdownTextFieldObserver + +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context +{ + react_native_assert(_textField != nil); + + if (_active && ([keyPath isEqualToString:@"text"] || [keyPath isEqualToString:@"attributedText"])) { + [self textFieldDidChange:_textField]; + } +} + +- (void)textFieldDidChange:(__unused UITextField *)textField { + react_native_assert(_markdownUtils != nil); + react_native_assert(_textField != nil); + 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 1803e4e2..ba3508c6 100644 --- a/apple/MarkdownTextInputDecoratorView.mm +++ b/apple/MarkdownTextInputDecoratorView.mm @@ -6,6 +6,7 @@ #import #import #import +#import #import @@ -13,7 +14,9 @@ @implementation MarkdownTextInputDecoratorView { RCTMarkdownUtils *_markdownUtils; RCTMarkdownStyle *_markdownStyle; MarkdownTextStorageDelegate *_markdownTextStorageDelegate; + MarkdownTextFieldObserver *_markdownTextFieldObserver; __weak RCTUITextView *_textView; + __weak RCTUITextField *_textField; } - (void)didMoveToWindow { @@ -52,8 +55,25 @@ - (void)didMoveToWindow { [_markdownUtils setMarkdownStyle:_markdownStyle]; if ([backedTextInputView isKindOfClass:[RCTUITextField class]]) { - // TODO: implement for singleline input - react_native_assert(false && "Not implemented for singleline input yet"); + _textField = (RCTUITextField *)backedTextInputView; + + // make sure `adjustsFontSizeToFitWidth` is disabled, otherwise formatting will be overwritten + react_native_assert(_textField.adjustsFontSizeToFitWidth == NO); + + _markdownTextFieldObserver = [[MarkdownTextFieldObserver alloc] init]; + _markdownTextFieldObserver.markdownUtils = _markdownUtils; + _markdownTextFieldObserver.textField = _textField; + _markdownTextFieldObserver.active = YES; + + // 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 } else if ([backedTextInputView isKindOfClass:[RCTUITextView class]]) { _textView = (RCTUITextView *)backedTextInputView; @@ -94,6 +114,14 @@ - (void)willMoveToWindow:(UIWindow *)newWindow object_setClass(_textView.layoutManager, [NSLayoutManager class]); } } + + 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; + } } - (void)setMarkdownStyle:(RCTMarkdownStyle *)markdownStyle diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 743b1a82..00d45c71 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.172): + - RNLiveMarkdown (0.1.176): - DoubleConversion - glog - hermes-engine @@ -1517,9 +1517,9 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNLiveMarkdown/newarch (= 0.1.172) + - RNLiveMarkdown/newarch (= 0.1.176) - Yoga - - RNLiveMarkdown/newarch (0.1.172): + - RNLiveMarkdown/newarch (0.1.176): - DoubleConversion - glog - hermes-engine @@ -1805,7 +1805,7 @@ SPEC CHECKSUMS: React-utils: 81a715d9c0a2a49047e77a86f3a2247408540deb ReactCodegen: 60973d382704c793c605b9be0fc7f31cb279442f ReactCommon: 6ef348087d250257c44c0204461c03f036650e9b - RNLiveMarkdown: 0d4f090ee84cfa2d2684b5b079547c5c9d2453b2 + RNLiveMarkdown: 0b8756147a5e8eeea98d3e1187c0c27d5a96d1ff SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d Yoga: 2a45d7e59592db061217551fd3bbe2dd993817ae From b0898305d4085eadbee33bdcba9180304fdcf090 Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Mon, 21 Oct 2024 18:18:39 +0200 Subject: [PATCH 04/19] Improve multiline cleanup --- apple/MarkdownTextInputDecoratorView.mm | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apple/MarkdownTextInputDecoratorView.mm b/apple/MarkdownTextInputDecoratorView.mm index ba3508c6..4a703644 100644 --- a/apple/MarkdownTextInputDecoratorView.mm +++ b/apple/MarkdownTextInputDecoratorView.mm @@ -107,12 +107,13 @@ - (void)didMoveToWindow { - (void)willMoveToWindow:(UIWindow *)newWindow { if (_textView != nil) { - _textView.textStorage.delegate = 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) { From d5cc035f93318a0f66a15fb1246411f011293d8e Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Mon, 21 Oct 2024 18:33:00 +0200 Subject: [PATCH 05/19] Add TODO about spellcheck --- apple/MarkdownTextStorageDelegate.mm | 1 + 1 file changed, 1 insertion(+) diff --git a/apple/MarkdownTextStorageDelegate.mm b/apple/MarkdownTextStorageDelegate.mm index 475c8dab..3f78be6b 100644 --- a/apple/MarkdownTextStorageDelegate.mm +++ b/apple/MarkdownTextStorageDelegate.mm @@ -10,6 +10,7 @@ - (void)textStorage:(NSTextStorage *)textStorage didProcessEditing:(NSTextStorag [_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 From 80c0686462eba341d75047dda9e94184b0bdbc4a Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Mon, 21 Oct 2024 20:44:20 +0200 Subject: [PATCH 06/19] Add missing imports --- apple/MarkdownTextFieldObserver.mm | 1 + apple/MarkdownTextStorageDelegate.mm | 1 + 2 files changed, 2 insertions(+) diff --git a/apple/MarkdownTextFieldObserver.mm b/apple/MarkdownTextFieldObserver.mm index 341dd5c4..7ad54e25 100644 --- a/apple/MarkdownTextFieldObserver.mm +++ b/apple/MarkdownTextFieldObserver.mm @@ -1,4 +1,5 @@ #import +#import "react_native_assert.h" @implementation MarkdownTextFieldObserver diff --git a/apple/MarkdownTextStorageDelegate.mm b/apple/MarkdownTextStorageDelegate.mm index 3f78be6b..ffd13a58 100644 --- a/apple/MarkdownTextStorageDelegate.mm +++ b/apple/MarkdownTextStorageDelegate.mm @@ -1,4 +1,5 @@ #import +#import "react_native_assert.h" @implementation MarkdownTextStorageDelegate From 0d57eeb519698de1a7ab8176d8cd03e01400c355 Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Mon, 21 Oct 2024 20:46:13 +0200 Subject: [PATCH 07/19] Add support for old architecture --- apple/MarkdownTextInputDecoratorView.mm | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apple/MarkdownTextInputDecoratorView.mm b/apple/MarkdownTextInputDecoratorView.mm index 4a703644..7a5ac4ca 100644 --- a/apple/MarkdownTextInputDecoratorView.mm +++ b/apple/MarkdownTextInputDecoratorView.mm @@ -1,6 +1,7 @@ #import #import #import +#import #import "react_native_assert.h" #import @@ -46,8 +47,9 @@ - (void)didMoveToWindow { RCTTextInputComponentView *textInputComponentView = (RCTTextInputComponentView *)view; UIView *backedTextInputView = [textInputComponentView valueForKey:@"_backedTextInputView"]; #else - // TODO: implement on Paper - react_native_assert(false && "Not implemented on Paper yet"); + react_native_assert([view isKindOfClass:[RCTBaseTextInputView class]] && "Previous sibling component is not an instance of RCTBaseTextInputView."); + RCTBaseTextInputView *baseTextInputView = (RCTBaseTextInputView *)view; + UIView *backedTextInputView = baseTextInputView.backedTextInputView; #endif /* RCT_NEW_ARCH_ENABLED */ _markdownUtils = [[RCTMarkdownUtils alloc] init]; @@ -84,8 +86,12 @@ - (void)didMoveToWindow { // register delegate for future edits _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 From 5675e0416417d1d35a2a4b91e43091df8bee289a Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Mon, 21 Oct 2024 20:48:30 +0200 Subject: [PATCH 08/19] Add #ifdef for imports --- apple/MarkdownTextInputDecoratorView.mm | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apple/MarkdownTextInputDecoratorView.mm b/apple/MarkdownTextInputDecoratorView.mm index 7a5ac4ca..2fafd16e 100644 --- a/apple/MarkdownTextInputDecoratorView.mm +++ b/apple/MarkdownTextInputDecoratorView.mm @@ -1,8 +1,12 @@ #import #import +#import "react_native_assert.h" + +#ifdef RCT_NEW_ARCH_ENABLED #import +#else #import -#import "react_native_assert.h" +#endif #import #import From e1fca809f0fd2b9f5e105445c3f4d2a9ce8550b4 Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Wed, 23 Oct 2024 08:55:40 +0200 Subject: [PATCH 09/19] Restore original App.tsx --- example/src/App.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/example/src/App.tsx b/example/src/App.tsx index 47959ef4..235ff535 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -5,9 +5,7 @@ import * as TEST_CONST from './testConstants'; import {PlatformInfo} from './PlatformInfo'; export default function App() { - const [value, setValue] = React.useState( - 'Hello *world*!\n> Lorem ipsum\nexample.com\n# Hello world\n# Hello world\n# Hello world', - ); + const [value, setValue] = React.useState(TEST_CONST.EXAMPLE_CONTENT); const [textColorState, setTextColorState] = React.useState(false); const [linkColorState, setLinkColorState] = React.useState(false); const [textFontSizeState, setTextFontSizeState] = React.useState(false); From 5a1651fbaa47e87a5c4f20a94fc4aec54aff7d9b Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Wed, 23 Oct 2024 08:58:00 +0200 Subject: [PATCH 10/19] Don't mix styles --- apple/RCTMarkdownUtils.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apple/RCTMarkdownUtils.mm b/apple/RCTMarkdownUtils.mm index b18afa35..6e478da8 100644 --- a/apple/RCTMarkdownUtils.mm +++ b/apple/RCTMarkdownUtils.mm @@ -27,7 +27,7 @@ - (void)applyFormatting:(nonnull NSMutableAttributedString *)attributedString wi } jsi::Runtime &rt = *runtime; - auto text = jsi::String::createFromUtf8(rt, [attributedString.string UTF8String]); + auto text = jsi::String::createFromUtf8(rt, attributedString.string.UTF8String); auto func = rt.global().getPropertyAsFunction(rt, "parseExpensiMarkToRanges"); auto output = func.call(rt, text); From e9e0e60dff1b43146909e71e6889257f37e9ea67 Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Tue, 5 Nov 2024 09:22:59 +0100 Subject: [PATCH 11/19] Update Podfile.lock --- example/ios/Podfile.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 00d45c71..df65eab1 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.176): + - RNLiveMarkdown (0.1.180): - DoubleConversion - glog - hermes-engine @@ -1517,9 +1517,9 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNLiveMarkdown/newarch (= 0.1.176) + - RNLiveMarkdown/newarch (= 0.1.180) - Yoga - - RNLiveMarkdown/newarch (0.1.176): + - RNLiveMarkdown/newarch (0.1.180): - DoubleConversion - glog - hermes-engine @@ -1805,7 +1805,7 @@ SPEC CHECKSUMS: React-utils: 81a715d9c0a2a49047e77a86f3a2247408540deb ReactCodegen: 60973d382704c793c605b9be0fc7f31cb279442f ReactCommon: 6ef348087d250257c44c0204461c03f036650e9b - RNLiveMarkdown: 0b8756147a5e8eeea98d3e1187c0c27d5a96d1ff + RNLiveMarkdown: fc07b203a3ed832e2e5d3950e69cd4fc3b0568b6 SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d Yoga: 2a45d7e59592db061217551fd3bbe2dd993817ae From d0ef4efadf67d023638cad3158e3b6fa927a73e8 Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Tue, 5 Nov 2024 09:29:25 +0100 Subject: [PATCH 12/19] Assert that delegate is nil --- apple/MarkdownTextInputDecoratorView.mm | 1 + 1 file changed, 1 insertion(+) diff --git a/apple/MarkdownTextInputDecoratorView.mm b/apple/MarkdownTextInputDecoratorView.mm index 2fafd16e..d4358d4d 100644 --- a/apple/MarkdownTextInputDecoratorView.mm +++ b/apple/MarkdownTextInputDecoratorView.mm @@ -88,6 +88,7 @@ - (void)didMoveToWindow { _markdownTextStorageDelegate.textView = _textView; // register delegate for future edits + react_native_assert(_textView.textStorage.delegate == nil); _textView.textStorage.delegate = _markdownTextStorageDelegate; #ifdef RCT_NEW_ARCH_ENABLED From 8c06be0092004e09bce3f1951d8f36d5a485957d Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Tue, 5 Nov 2024 09:44:19 +0100 Subject: [PATCH 13/19] Add comment with link to the issue --- apple/MarkdownTextInputDecoratorView.mm | 1 + 1 file changed, 1 insertion(+) diff --git a/apple/MarkdownTextInputDecoratorView.mm b/apple/MarkdownTextInputDecoratorView.mm index d4358d4d..c6a725d6 100644 --- a/apple/MarkdownTextInputDecoratorView.mm +++ b/apple/MarkdownTextInputDecoratorView.mm @@ -80,6 +80,7 @@ - (void)didMoveToWindow { [_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; From 9ff01d5ee84b47b7818532d7d6bb04fa3fa1a31c Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Tue, 5 Nov 2024 09:58:46 +0100 Subject: [PATCH 14/19] Reformat singleline input on `markdownStyle` change --- apple/MarkdownTextInputDecoratorView.mm | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apple/MarkdownTextInputDecoratorView.mm b/apple/MarkdownTextInputDecoratorView.mm index c6a725d6..d2ef2cdd 100644 --- a/apple/MarkdownTextInputDecoratorView.mm +++ b/apple/MarkdownTextInputDecoratorView.mm @@ -143,7 +143,12 @@ - (void)setMarkdownStyle:(RCTMarkdownStyle *)markdownStyle [_markdownUtils setMarkdownStyle:markdownStyle]; // trigger reformatting - [_textView.textStorage setAttributedString:_textView.attributedText]; + if (_textView != nil) { + [_textView.textStorage setAttributedString:_textView.attributedText]; + } + if (_textField != nil) { + [_markdownTextFieldObserver textFieldDidChange:_textField]; + } } @end From 40abeba0913419fe066c9ca868fb0be5bbbf57ac Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Tue, 5 Nov 2024 12:57:30 +0100 Subject: [PATCH 15/19] Add nice constructors for `MarkdownTextStorageDelegate` and `MarkdownTextFieldObserver` --- apple/MarkdownTextFieldObserver.h | 6 +----- apple/MarkdownTextFieldObserver.mm | 23 ++++++++++++++++++----- apple/MarkdownTextInputDecoratorView.mm | 10 ++-------- apple/MarkdownTextStorageDelegate.h | 4 +--- apple/MarkdownTextStorageDelegate.mm | 19 ++++++++++++++++--- 5 files changed, 38 insertions(+), 24 deletions(-) diff --git a/apple/MarkdownTextFieldObserver.h b/apple/MarkdownTextFieldObserver.h index c9a1b0b8..f063c30f 100644 --- a/apple/MarkdownTextFieldObserver.h +++ b/apple/MarkdownTextFieldObserver.h @@ -6,11 +6,7 @@ NS_ASSUME_NONNULL_BEGIN @interface MarkdownTextFieldObserver : NSObject -@property (nonatomic, nullable, strong) RCTMarkdownUtils *markdownUtils; - -@property (nonatomic, nullable, strong) RCTUITextField *textField; - -@property (nonatomic) BOOL active; +- (instancetype)initWithTextField:(nonnull RCTUITextField *)textField markdownUtils:(nonnull RCTMarkdownUtils *)markdownUtils; - (void)textFieldDidChange:(UITextField *)textField; diff --git a/apple/MarkdownTextFieldObserver.mm b/apple/MarkdownTextFieldObserver.mm index 7ad54e25..8544c1d0 100644 --- a/apple/MarkdownTextFieldObserver.mm +++ b/apple/MarkdownTextFieldObserver.mm @@ -1,20 +1,33 @@ #import #import "react_native_assert.h" -@implementation MarkdownTextFieldObserver +@implementation MarkdownTextFieldObserver { + RCTUITextField *_textField; + RCTMarkdownUtils *_markdownUtils; + BOOL _active; +} -- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context +- (instancetype)initWithTextField:(nonnull RCTUITextField *)textField markdownUtils:(nonnull RCTMarkdownUtils *)markdownUtils { - react_native_assert(_textField != nil); + 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(_markdownUtils != nil); - react_native_assert(_textField != nil); react_native_assert(_textField.defaultTextAttributes != nil); if (_textField.markedTextRange != nil) { diff --git a/apple/MarkdownTextInputDecoratorView.mm b/apple/MarkdownTextInputDecoratorView.mm index d2ef2cdd..bc9cbf4b 100644 --- a/apple/MarkdownTextInputDecoratorView.mm +++ b/apple/MarkdownTextInputDecoratorView.mm @@ -66,10 +66,7 @@ - (void)didMoveToWindow { // make sure `adjustsFontSizeToFitWidth` is disabled, otherwise formatting will be overwritten react_native_assert(_textField.adjustsFontSizeToFitWidth == NO); - _markdownTextFieldObserver = [[MarkdownTextFieldObserver alloc] init]; - _markdownTextFieldObserver.markdownUtils = _markdownUtils; - _markdownTextFieldObserver.textField = _textField; - _markdownTextFieldObserver.active = YES; + _markdownTextFieldObserver = [[MarkdownTextFieldObserver alloc] initWithTextField:_textField markdownUtils:_markdownUtils]; // register observers for future edits [_textField addTarget:_markdownTextFieldObserver action:@selector(textFieldDidChange:) forControlEvents:UIControlEventEditingChanged]; @@ -84,12 +81,9 @@ - (void)didMoveToWindow { } else if ([backedTextInputView isKindOfClass:[RCTUITextView class]]) { _textView = (RCTUITextView *)backedTextInputView; - _markdownTextStorageDelegate = [[MarkdownTextStorageDelegate alloc] init]; - _markdownTextStorageDelegate.markdownUtils = _markdownUtils; - _markdownTextStorageDelegate.textView = _textView; - // 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 diff --git a/apple/MarkdownTextStorageDelegate.h b/apple/MarkdownTextStorageDelegate.h index bfdebbbd..e7cb244a 100644 --- a/apple/MarkdownTextStorageDelegate.h +++ b/apple/MarkdownTextStorageDelegate.h @@ -6,9 +6,7 @@ NS_ASSUME_NONNULL_BEGIN @interface MarkdownTextStorageDelegate : NSObject -@property(nonatomic, nullable) RCTMarkdownUtils *markdownUtils; - -@property(nonatomic, nullable, strong) RCTUITextView *textView; +- (instancetype)initWithTextView:(nonnull RCTUITextView *)textView markdownUtils:(nonnull RCTMarkdownUtils *)markdownUtils; @end diff --git a/apple/MarkdownTextStorageDelegate.mm b/apple/MarkdownTextStorageDelegate.mm index ffd13a58..9bcb40dd 100644 --- a/apple/MarkdownTextStorageDelegate.mm +++ b/apple/MarkdownTextStorageDelegate.mm @@ -1,11 +1,24 @@ #import #import "react_native_assert.h" -@implementation MarkdownTextStorageDelegate +@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(_markdownUtils != nil); - react_native_assert(_textView != nil); react_native_assert(_textView.defaultTextAttributes != nil); [_markdownUtils applyFormatting:textStorage withDefaultTextAttributes:_textView.defaultTextAttributes]; From 67968ecf3b157d7eb38d750c7aedb4e9b8648eaf Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Tue, 5 Nov 2024 14:32:19 +0100 Subject: [PATCH 16/19] Fix emoji not visible inside text (seriously, this took like 3 hours) --- apple/RCTMarkdownUtils.mm | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apple/RCTMarkdownUtils.mm b/apple/RCTMarkdownUtils.mm index 6e478da8..583e9c97 100644 --- a/apple/RCTMarkdownUtils.mm +++ b/apple/RCTMarkdownUtils.mm @@ -55,6 +55,8 @@ - (void)applyFormatting:(nonnull NSMutableAttributedString *)attributedString wi RCTApplyBaselineOffset(attributedString); + [attributedString fixAttributesInRange:fullRange]; + [attributedString endEditing]; } From d37704303db518473c7d6b34e48f876c4db4b445 Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Tue, 5 Nov 2024 16:08:38 +0100 Subject: [PATCH 17/19] Fix updating `style` without updating `markdownStyle` for multiline input --- apple/RCTMarkdownUtils.mm | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apple/RCTMarkdownUtils.mm b/apple/RCTMarkdownUtils.mm index 583e9c97..f74d59d8 100644 --- a/apple/RCTMarkdownUtils.mm +++ b/apple/RCTMarkdownUtils.mm @@ -57,6 +57,8 @@ - (void)applyFormatting:(nonnull NSMutableAttributedString *)attributedString wi [attributedString fixAttributesInRange:fullRange]; + [attributedString removeAttribute:@"NSOriginalFont" range:fullRange]; + [attributedString endEditing]; } From 6e40703e8e229f0374fca0ddda94da3dc4476a59 Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Tue, 5 Nov 2024 16:19:19 +0100 Subject: [PATCH 18/19] Add comments --- apple/RCTMarkdownUtils.mm | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apple/RCTMarkdownUtils.mm b/apple/RCTMarkdownUtils.mm index f74d59d8..9f6a2656 100644 --- a/apple/RCTMarkdownUtils.mm +++ b/apple/RCTMarkdownUtils.mm @@ -55,8 +55,22 @@ - (void)applyFormatting:(nonnull NSMutableAttributedString *)attributedString wi RCTApplyBaselineOffset(attributedString); + /* + 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]; [attributedString endEditing]; From 1fa1b549fba49a2a25677627edac098217424326 Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Wed, 6 Nov 2024 07:36:00 +0100 Subject: [PATCH 19/19] Update Podfile.lock --- example/ios/Podfile.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index df65eab1..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.180): + - RNLiveMarkdown (0.1.182): - DoubleConversion - glog - hermes-engine @@ -1517,9 +1517,9 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNLiveMarkdown/newarch (= 0.1.180) + - RNLiveMarkdown/newarch (= 0.1.182) - Yoga - - RNLiveMarkdown/newarch (0.1.180): + - RNLiveMarkdown/newarch (0.1.182): - DoubleConversion - glog - hermes-engine @@ -1805,7 +1805,7 @@ SPEC CHECKSUMS: React-utils: 81a715d9c0a2a49047e77a86f3a2247408540deb ReactCodegen: 60973d382704c793c605b9be0fc7f31cb279442f ReactCommon: 6ef348087d250257c44c0204461c03f036650e9b - RNLiveMarkdown: fc07b203a3ed832e2e5d3950e69cd4fc3b0568b6 + RNLiveMarkdown: 4060f283dbbf0d1fb7e535707a6bc60441b282de SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d Yoga: 2a45d7e59592db061217551fd3bbe2dd993817ae