Skip to content

Commit 6001c2d

Browse files
committed
Merge branch 'main' into @tomekzaw/android-improve-remove-spans
2 parents 7d925fb + ea2b1af commit 6001c2d

33 files changed

+818
-735
lines changed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ const FONT_FAMILY_MONOSPACE = Platform.select({
6666
default: 'monospace',
6767
});
6868

69+
const FONT_FAMILY_EMOJI = Platform.select({
70+
ios: 'Apple Color Emoji',
71+
android: 'Noto Color Emoji',
72+
default: 'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji',
73+
});
74+
6975
const markdownStyle: MarkdownStyle = {
7076
syntax: {
7177
color: 'gray',
@@ -78,6 +84,7 @@ const markdownStyle: MarkdownStyle = {
7884
},
7985
emoji: {
8086
fontSize: 20,
87+
fontFamily: FONT_FAMILY_EMOJI,
8188
},
8289
blockquote: {
8390
borderColor: 'gray',

WebExample/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"expo-status-bar": "2.2.0",
1717
"react": "^19.0.0",
1818
"react-dom": "^19.0.0",
19-
"react-native": "0.79.1",
19+
"react-native": "0.79.2",
2020
"react-native-web": "~0.20.0"
2121
},
2222
"devDependencies": {

android/src/main/java/com/expensify/livemarkdown/MarkdownFormatter.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ private void applyRange(@NonNull SpannableStringBuilder ssb, @NonNull MarkdownRa
7272
setSpan(ssb, new MarkdownStrikethroughSpan(), start, end);
7373
break;
7474
case "emoji":
75-
setSpan(ssb, new MarkdownEmojiSpan(markdownStyle.getEmojiFontSize()), start, end);
75+
setSpan(ssb, new MarkdownFontFamilySpan(markdownStyle.getEmojiFontFamily(), mAssetManager), start, end);
76+
setSpan(ssb, new MarkdownFontSizeSpan(markdownStyle.getEmojiFontSize()), start, end);
7677
break;
7778
case "mention-here":
7879
setSpan(ssb, new MarkdownForegroundColorSpan(markdownStyle.getMentionHereColor()), start, end);

android/src/main/java/com/expensify/livemarkdown/MarkdownStyle.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ public class MarkdownStyle {
2121

2222
private final float mH1FontSize;
2323

24+
@NonNull
25+
private final String mEmojiFontFamily;
26+
2427
private final float mEmojiFontSize;
2528

2629
@ColorInt
@@ -77,6 +80,7 @@ public MarkdownStyle(@NonNull ReadableMap map, @NonNull Context context) {
7780
mLinkColor = parseColor(map, "link", "color", context);
7881
mH1FontSize = parseFloat(map, "h1", "fontSize");
7982
mEmojiFontSize = parseFloat(map, "emoji", "fontSize");
83+
mEmojiFontFamily = parseString(map, "emoji", "fontFamily");
8084
mBlockquoteBorderColor = parseColor(map, "blockquote", "borderColor", context);
8185
mBlockquoteBorderWidth = parseFloat(map, "blockquote", "borderWidth");
8286
mBlockquoteMarginLeft = parseFloat(map, "blockquote", "marginLeft");
@@ -142,6 +146,10 @@ public float getEmojiFontSize() {
142146
return mEmojiFontSize;
143147
}
144148

149+
public String getEmojiFontFamily() {
150+
return mEmojiFontFamily;
151+
}
152+
145153
@ColorInt
146154
public int getBlockquoteBorderColor() {
147155
return mBlockquoteBorderColor;

android/src/main/java/com/expensify/livemarkdown/spans/MarkdownEmojiSpan.java

Lines changed: 0 additions & 11 deletions
This file was deleted.

apple/MarkdownFormatter.h

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@
44

55
NS_ASSUME_NONNULL_BEGIN
66

7+
const NSAttributedStringKey RCTLiveMarkdownTextAttributeName = @"RCTLiveMarkdownText";
8+
79
const NSAttributedStringKey RCTLiveMarkdownBlockquoteDepthAttributeName = @"RCTLiveMarkdownBlockquoteDepth";
810

911
@interface MarkdownFormatter : NSObject
1012

11-
- (nonnull NSAttributedString *)format:(nonnull NSString *)text
12-
withDefaultTextAttributes:(nonnull NSDictionary<NSAttributedStringKey, id> *)defaultTextAttributes
13-
withMarkdownRanges:(nonnull NSArray<MarkdownRange *> *)markdownRanges
14-
withMarkdownStyle:(nonnull RCTMarkdownStyle *)markdownStyle;
13+
- (void)formatAttributedString:(nonnull NSMutableAttributedString *)attributedString
14+
withDefaultTextAttributes:(nonnull NSDictionary<NSAttributedStringKey, id> *)defaultTextAttributes
15+
withMarkdownRanges:(nonnull NSArray<MarkdownRange *> *)markdownRanges
16+
withMarkdownStyle:(nonnull RCTMarkdownStyle *)markdownStyle;
1517

1618
NS_ASSUME_NONNULL_END
1719

apple/MarkdownFormatter.mm

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,19 @@
33

44
@implementation MarkdownFormatter
55

6-
- (nonnull NSAttributedString *)format:(nonnull NSString *)text
7-
withDefaultTextAttributes:(nonnull NSDictionary<NSAttributedStringKey, id> *)defaultTextAttributes
8-
withMarkdownRanges:(nonnull NSArray<MarkdownRange *> *)markdownRanges
9-
withMarkdownStyle:(nonnull RCTMarkdownStyle *)markdownStyle
6+
- (void)formatAttributedString:(nonnull NSMutableAttributedString *)attributedString
7+
withDefaultTextAttributes:(nonnull NSDictionary<NSAttributedStringKey, id> *)defaultTextAttributes
8+
withMarkdownRanges:(nonnull NSArray<MarkdownRange *> *)markdownRanges
9+
withMarkdownStyle:(nonnull RCTMarkdownStyle *)markdownStyle
1010
{
11-
NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:text attributes:defaultTextAttributes];
11+
NSRange fullRange = NSMakeRange(0, attributedString.length);
1212

1313
[attributedString beginEditing];
1414

15-
// If the attributed string ends with underlined text, blurring the single-line input imprints the underline style across the whole string.
16-
// It looks like a bug in iOS, as there is no underline style to be found in the attributed string, especially after formatting.
17-
// This is a workaround that applies the NSUnderlineStyleNone to the string before iterating over ranges which resolves this problem.
18-
[attributedString addAttribute:NSUnderlineStyleAttributeName
19-
value:[NSNumber numberWithInteger:NSUnderlineStyleNone]
20-
range:NSMakeRange(0, attributedString.length)];
15+
[attributedString setAttributes:defaultTextAttributes range:fullRange];
16+
17+
// We add a custom attribute to force a different comparison mode in swizzled `_textOf` method.
18+
[attributedString addAttribute:RCTLiveMarkdownTextAttributeName value:@(YES) range:fullRange];
2119

2220
for (MarkdownRange *markdownRange in markdownRanges) {
2321
[self applyRangeToAttributedString:attributedString
@@ -28,15 +26,15 @@ - (nonnull NSAttributedString *)format:(nonnull NSString *)text
2826
defaultTextAttributes:defaultTextAttributes];
2927
}
3028

31-
[attributedString.string enumerateSubstringsInRange:NSMakeRange(0, attributedString.length)
29+
[attributedString.string enumerateSubstringsInRange:fullRange
3230
options:NSStringEnumerationByLines | NSStringEnumerationSubstringNotRequired
3331
usingBlock:^(NSString * _Nullable substring, NSRange substringRange, NSRange enclosingRange, BOOL * _Nonnull stop) {
3432
RCTApplyBaselineOffset(attributedString, enclosingRange);
3533
}];
3634

37-
[attributedString endEditing];
35+
[attributedString fixAttributesInRange:fullRange];
3836

39-
return attributedString;
37+
[attributedString endEditing];
4038
}
4139

4240
- (void)applyRangeToAttributedString:(NSMutableAttributedString *)attributedString
@@ -74,7 +72,7 @@ - (void)applyRangeToAttributedString:(NSMutableAttributedString *)attributedStri
7472
variant:nil
7573
scaleMultiplier:0];
7674
} else if (type == "emoji") {
77-
font = [RCTFont updateFont:font withFamily:nil
75+
font = [RCTFont updateFont:font withFamily:markdownStyle.emojiFontFamily
7876
size:[NSNumber numberWithFloat:markdownStyle.emojiFontSize]
7977
weight:nil
8078
style:nil

apple/MarkdownTextFieldObserver.h

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#import <UIKit/UIKit.h>
2+
#import <React/RCTUITextField.h>
3+
#import <RNLiveMarkdown/RCTMarkdownUtils.h>
4+
5+
NS_ASSUME_NONNULL_BEGIN
6+
7+
@interface MarkdownTextFieldObserver : NSObject
8+
9+
- (instancetype)initWithTextField:(nonnull RCTUITextField *)textField markdownUtils:(nonnull RCTMarkdownUtils *)markdownUtils;
10+
11+
- (void)textFieldDidChange:(UITextField *)textField;
12+
13+
- (void)textFieldDidEndEditing:(UITextField *)textField;
14+
15+
@end
16+
17+
NS_ASSUME_NONNULL_END

apple/MarkdownTextFieldObserver.mm

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#import <RNLiveMarkdown/MarkdownTextFieldObserver.h>
2+
#import "react_native_assert.h"
3+
4+
@implementation MarkdownTextFieldObserver {
5+
RCTUITextField *_textField;
6+
RCTMarkdownUtils *_markdownUtils;
7+
BOOL _active;
8+
}
9+
10+
- (instancetype)initWithTextField:(nonnull RCTUITextField *)textField markdownUtils:(nonnull RCTMarkdownUtils *)markdownUtils
11+
{
12+
if ((self = [super init])) {
13+
react_native_assert(textField != nil);
14+
react_native_assert(markdownUtils != nil);
15+
16+
_textField = textField;
17+
_markdownUtils = markdownUtils;
18+
_active = YES;
19+
}
20+
return self;
21+
}
22+
23+
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
24+
{
25+
if (_active && ([keyPath isEqualToString:@"text"] || [keyPath isEqualToString:@"attributedText"])) {
26+
[self applyMarkdownFormatting];
27+
}
28+
}
29+
30+
- (void)textFieldDidChange:(__unused UITextField *)textField
31+
{
32+
[self applyMarkdownFormatting];
33+
}
34+
35+
- (void)textFieldDidEndEditing:(__unused UITextField *)textField
36+
{
37+
// In order to prevent iOS from applying underline to the whole text if text ends with a link on blur,
38+
// we need to update `defaultTextAttributes` which at this point doesn't contain NSUnderline attribute yet.
39+
// It seems like the setter performs deep comparision, so we differentiate the new value using a counter,
40+
// otherwise this trick would work only once.
41+
static NSAttributedStringKey RCTLiveMarkdownForceUpdateAttributeName = @"RCTLiveMarkdownForceUpdate";
42+
static NSUInteger counter = 0;
43+
NSMutableDictionary *defaultTextAttributes = [_textField.defaultTextAttributes mutableCopy];
44+
defaultTextAttributes[RCTLiveMarkdownForceUpdateAttributeName] = @(counter++);
45+
_textField.defaultTextAttributes = defaultTextAttributes;
46+
[self applyMarkdownFormatting];
47+
}
48+
49+
- (void)applyMarkdownFormatting
50+
{
51+
react_native_assert(_textField.defaultTextAttributes != nil);
52+
53+
if (_textField.markedTextRange != nil) {
54+
return; // skip formatting during multi-stage input to avoid breaking internal state
55+
}
56+
57+
NSMutableAttributedString *attributedText = [_textField.attributedText mutableCopy];
58+
[_markdownUtils applyMarkdownFormatting:attributedText withDefaultTextAttributes:_textField.defaultTextAttributes];
59+
60+
UITextRange *textRange = _textField.selectedTextRange;
61+
62+
_active = NO; // prevent recursion
63+
_textField.attributedText = attributedText;
64+
_active = YES;
65+
66+
// Restore cursor position
67+
[_textField setSelectedTextRange:textRange notifyDelegate:NO];
68+
69+
// Eliminate underline blinks while typing if previous text ends with a link
70+
_textField.typingAttributes = _textField.defaultTextAttributes;
71+
}
72+
73+
@end

apple/MarkdownTextInputDecoratorComponentView.mm

Lines changed: 64 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,17 @@
22
#import <react/renderer/components/RNLiveMarkdownSpec/Props.h>
33
#import <React/RCTFabricComponentsPlugins.h>
44
#import <React/RCTUITextField.h>
5+
#import <React/RCTUITextView.h>
6+
#import <React/RCTTextInputComponentView.h>
57

68
#import <RNLiveMarkdown/MarkdownBackedTextInputDelegate.h>
79
#import <RNLiveMarkdown/MarkdownLayoutManager.h>
10+
#import <RNLiveMarkdown/MarkdownTextFieldObserver.h>
11+
#import <RNLiveMarkdown/MarkdownTextViewObserver.h>
812
#import <RNLiveMarkdown/MarkdownTextInputDecoratorComponentView.h>
913
#import <RNLiveMarkdown/MarkdownTextInputDecoratorViewComponentDescriptor.h>
10-
#import <RNLiveMarkdown/RCTBackedTextFieldDelegateAdapter+Markdown.h>
14+
#import <RNLiveMarkdown/MarkdownTextStorageDelegate.h>
1115
#import <RNLiveMarkdown/RCTMarkdownStyle.h>
12-
#import <RNLiveMarkdown/RCTTextInputComponentView+Markdown.h>
13-
#import <RNLiveMarkdown/RCTUITextView+Markdown.h>
1416

1517
#import <objc/runtime.h>
1618

@@ -21,10 +23,11 @@ @implementation MarkdownTextInputDecoratorComponentView {
2123
RCTMarkdownStyle *_markdownStyle;
2224
NSNumber *_parserId;
2325
MarkdownBackedTextInputDelegate *_markdownBackedTextInputDelegate;
24-
__weak RCTTextInputComponentView *_textInput;
25-
__weak UIView<RCTBackedTextInputViewProtocol> *_backedTextInputView;
26-
__weak RCTBackedTextFieldDelegateAdapter *_adapter;
26+
MarkdownTextStorageDelegate *_markdownTextStorageDelegate;
27+
MarkdownTextViewObserver *_markdownTextViewObserver;
28+
MarkdownTextFieldObserver *_markdownTextFieldObserver;
2729
__weak RCTUITextView *_textView;
30+
__weak RCTUITextField *_textField;
2831
}
2932

3033
+ (ComponentDescriptorProvider)componentDescriptorProvider
@@ -51,21 +54,47 @@ - (instancetype)initWithFrame:(CGRect)frame
5154
- (void)didAddSubview:(UIView *)subview
5255
{
5356
react_native_assert([subview isKindOfClass:[RCTTextInputComponentView class]] && "Child component of MarkdownTextInputDecoratorComponentView is not an instance of RCTTextInputComponentView.");
54-
_textInput = (RCTTextInputComponentView *)subview;
55-
_backedTextInputView = [_textInput valueForKey:@"_backedTextInputView"];
57+
RCTTextInputComponentView *textInputComponentView = (RCTTextInputComponentView *)subview;
58+
UIView<RCTBackedTextInputViewProtocol> *backedTextInputView = [textInputComponentView valueForKey:@"_backedTextInputView"];
5659

5760
_markdownUtils = [[RCTMarkdownUtils alloc] init];
5861
[_markdownUtils setMarkdownStyle:_markdownStyle];
5962
[_markdownUtils setParserId:_parserId];
6063

61-
[_textInput setMarkdownUtils:_markdownUtils];
62-
if ([_backedTextInputView isKindOfClass:[RCTUITextField class]]) {
63-
RCTUITextField *textField = (RCTUITextField *)_backedTextInputView;
64-
_adapter = [textField valueForKey:@"textInputDelegateAdapter"];
65-
[_adapter setMarkdownUtils:_markdownUtils];
66-
} else if ([_backedTextInputView isKindOfClass:[RCTUITextView class]]) {
67-
_textView = (RCTUITextView *)_backedTextInputView;
68-
[_textView setMarkdownUtils:_markdownUtils];
64+
if ([backedTextInputView isKindOfClass:[RCTUITextField class]]) {
65+
_textField = (RCTUITextField *)backedTextInputView;
66+
67+
// make sure `adjustsFontSizeToFitWidth` is disabled, otherwise formatting will be overwritten
68+
react_native_assert(_textField.adjustsFontSizeToFitWidth == NO);
69+
70+
_markdownTextFieldObserver = [[MarkdownTextFieldObserver alloc] initWithTextField:_textField markdownUtils:_markdownUtils];
71+
72+
// register observers for future edits
73+
[_textField addTarget:_markdownTextFieldObserver action:@selector(textFieldDidChange:) forControlEvents:UIControlEventEditingChanged];
74+
[_textField addTarget:_markdownTextFieldObserver action:@selector(textFieldDidEndEditing:) forControlEvents:UIControlEventEditingDidEnd];
75+
[_textField addObserver:_markdownTextFieldObserver forKeyPath:@"text" options:NSKeyValueObservingOptionNew context:NULL];
76+
[_textField addObserver:_markdownTextFieldObserver forKeyPath:@"attributedText" options:NSKeyValueObservingOptionNew context:NULL];
77+
78+
// format initial value
79+
[_markdownTextFieldObserver textFieldDidChange:_textField];
80+
81+
// TODO: register blockquotes layout manager
82+
// https://github.com/Expensify/react-native-live-markdown/issues/87
83+
} else if ([backedTextInputView isKindOfClass:[RCTUITextView class]]) {
84+
_textView = (RCTUITextView *)backedTextInputView;
85+
86+
// register delegate for future edits
87+
react_native_assert(_textView.textStorage.delegate == nil);
88+
_markdownTextStorageDelegate = [[MarkdownTextStorageDelegate alloc] initWithTextView:_textView markdownUtils:_markdownUtils];
89+
_textView.textStorage.delegate = _markdownTextStorageDelegate;
90+
91+
// register observer for default text attributes
92+
_markdownTextViewObserver = [[MarkdownTextViewObserver alloc] initWithTextView:_textView markdownUtils:_markdownUtils];
93+
[_textView addObserver:_markdownTextViewObserver forKeyPath:@"defaultTextAttributes" options:NSKeyValueObservingOptionNew context:NULL];
94+
95+
// format initial value
96+
[_textView.textStorage setAttributedString:_textView.attributedText];
97+
6998
NSLayoutManager *layoutManager = _textView.layoutManager; // switching to TextKit 1 compatibility mode
7099

71100
// Correct content height in TextKit 1 compatibility mode. (See https://github.com/Expensify/App/issues/41567)
@@ -91,19 +120,26 @@ - (void)willMoveToWindow:(UIWindow *)newWindow
91120
if (newWindow != nil) {
92121
return;
93122
}
94-
if (_textInput != nil) {
95-
[_textInput setMarkdownUtils:nil];
96-
}
97-
if (_adapter != nil) {
98-
[_adapter setMarkdownUtils:nil];
99-
}
100123
if (_textView != nil) {
101-
_markdownBackedTextInputDelegate = nil;
102-
[_textView setMarkdownUtils:nil];
103124
if (_textView.layoutManager != nil && [object_getClass(_textView.layoutManager) isEqual:[MarkdownLayoutManager class]]) {
104125
[_textView.layoutManager setValue:nil forKey:@"markdownUtils"];
105126
object_setClass(_textView.layoutManager, [NSLayoutManager class]);
106127
}
128+
_markdownBackedTextInputDelegate = nil;
129+
[_textView removeObserver:_markdownTextViewObserver forKeyPath:@"defaultTextAttributes" context:NULL];
130+
_markdownTextViewObserver = nil;
131+
_markdownTextStorageDelegate = nil;
132+
_textView.textStorage.delegate = nil;
133+
_textView = nil;
134+
}
135+
136+
if (_textField != nil) {
137+
[_textField removeTarget:_markdownTextFieldObserver action:@selector(textFieldDidChange:) forControlEvents:UIControlEventEditingChanged];
138+
[_textField removeTarget:_markdownTextFieldObserver action:@selector(textFieldDidEndEditing:) forControlEvents:UIControlEventEditingDidEnd];
139+
[_textField removeObserver:_markdownTextFieldObserver forKeyPath:@"text" context:NULL];
140+
[_textField removeObserver:_markdownTextFieldObserver forKeyPath:@"attributedText" context:NULL];
141+
_markdownTextFieldObserver = nil;
142+
_textField = nil;
107143
}
108144
}
109145

@@ -130,11 +166,10 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &
130166
- (void)applyNewStyles
131167
{
132168
if (_textView != nil) {
133-
// We want to use `textStorage` for applying markdown when possible. Currently it's only available for UITextView
134-
[_textView textDidChange];
135-
} else {
136-
// apply new styles
137-
[_textInput _setAttributedString:_backedTextInputView.attributedText];
169+
[_textView.textStorage setAttributedString:_textView.attributedText];
170+
}
171+
if (_textField != nil) {
172+
[_markdownTextFieldObserver textFieldDidChange:_textField];
138173
}
139174
}
140175

0 commit comments

Comments
 (0)