diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownFormatter.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownFormatter.java index c7f683bf..2ef8ccf9 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownFormatter.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownFormatter.java @@ -8,6 +8,7 @@ import com.expensify.livemarkdown.spans.*; import com.facebook.react.views.text.internal.span.CustomLineHeightSpan; +import com.facebook.systrace.Systrace; import java.util.List; import java.util.Objects; @@ -19,30 +20,45 @@ public MarkdownFormatter(@NonNull AssetManager assetManager) { mAssetManager = assetManager; } - public void format(SpannableStringBuilder ssb, List markdownRanges, @NonNull MarkdownStyle markdownStyle) { - Objects.requireNonNull(markdownStyle, "mMarkdownStyle is null"); - removeSpans(ssb); - applyRanges(ssb, markdownRanges, markdownStyle); + public void format(@NonNull SpannableStringBuilder ssb, @NonNull List markdownRanges, @NonNull MarkdownStyle markdownStyle) { + try { + Systrace.beginSection(0, "format"); + Objects.requireNonNull(markdownStyle, "mMarkdownStyle is null"); + removeSpans(ssb); + applyRanges(ssb, markdownRanges, markdownStyle); + } finally { + Systrace.endSection(0); + } } - private void removeSpans(SpannableStringBuilder ssb) { - // We shouldn't use `removeSpans()` because it also removes SpellcheckSpan, SuggestionSpan etc. - MarkdownSpan[] spans = ssb.getSpans(0, ssb.length(), MarkdownSpan.class); - for (MarkdownSpan span : spans) { - ssb.removeSpan(span); + private void removeSpans(@NonNull SpannableStringBuilder ssb) { + try { + Systrace.beginSection(0, "removeSpans"); + // We shouldn't use `removeSpans()` because it also removes SpellcheckSpan, SuggestionSpan etc. + MarkdownSpan[] spans = ssb.getSpans(0, ssb.length(), MarkdownSpan.class); + for (MarkdownSpan span : spans) { + ssb.removeSpan(span); + } + } finally { + Systrace.endSection(0); } } - private void applyRanges(SpannableStringBuilder ssb, List markdownRanges, @NonNull MarkdownStyle markdownStyle) { - for (MarkdownRange markdownRange : markdownRanges) { - applyRange(ssb, markdownRange, markdownStyle); + private void applyRanges(@NonNull SpannableStringBuilder ssb, @NonNull List markdownRanges, @NonNull MarkdownStyle markdownStyle) { + try { + Systrace.beginSection(0, "applyRanges"); + for (MarkdownRange markdownRange : markdownRanges) { + applyRange(ssb, markdownRange, markdownStyle); + } + } finally { + Systrace.endSection(0); } } - private void applyRange(SpannableStringBuilder ssb, MarkdownRange markdownRange, MarkdownStyle markdownStyle) { + private void applyRange(@NonNull SpannableStringBuilder ssb, @NonNull MarkdownRange markdownRange, @NonNull MarkdownStyle markdownStyle) { String type = markdownRange.getType(); int start = markdownRange.getStart(); - int end = start + markdownRange.getLength(); + int end = markdownRange.getEnd(); switch (type) { case "bold": setSpan(ssb, new MarkdownBoldSpan(), start, end); @@ -110,7 +126,7 @@ private void applyRange(SpannableStringBuilder ssb, MarkdownRange markdownRange, } } - private void setSpan(SpannableStringBuilder ssb, MarkdownSpan span, int start, int end) { + private void setSpan(@NonNull SpannableStringBuilder ssb, @NonNull MarkdownSpan span, int start, int end) { ssb.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } } diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java index f1ae4404..2dc82bad 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java @@ -5,6 +5,7 @@ import com.facebook.react.bridge.ReactContext; import com.facebook.react.util.RNLog; import com.facebook.soloader.SoLoader; +import com.facebook.systrace.Systrace; import org.json.JSONArray; import org.json.JSONException; @@ -29,9 +30,9 @@ public MarkdownParser(@NonNull ReactContext reactContext) { mReactContext = reactContext; } - private native String nativeParse(String text, int parserId); + private native String nativeParse(@NonNull String text, int parserId); - private void splitRangesOnEmojis(List markdownRanges, String type) { + private void splitRangesOnEmojis(List markdownRanges, String type) { List emojiRanges = new ArrayList<>(); for (MarkdownRange range : markdownRanges) { if (range.getType().equals("emoji")) { @@ -87,28 +88,37 @@ private List parseRanges(String rangesJSON, String innerText) { splitRangesOnEmojis(markdownRanges, "strikethrough"); return markdownRanges; } + public synchronized List parse(@NonNull String text, int parserId) { + try { + Systrace.beginSection(0, "parse"); - public synchronized List parse(String text, int parserId) { - if (text.equals(mPrevText) && parserId == mPrevParserId) { - return mPrevMarkdownRanges; - } + if (text.equals(mPrevText) && parserId == mPrevParserId) { + return mPrevMarkdownRanges; + } + + String json; + try { + Systrace.beginSection(0, "nativeParse"); + json = nativeParse(text, parserId); + } catch (Exception e) { + // Skip formatting, runGuarded will show the error in LogBox + mPrevText = text; + mPrevParserId = parserId; + mPrevMarkdownRanges = Collections.emptyList(); + return mPrevMarkdownRanges; + } finally { + Systrace.endSection(0); + } + + List markdownRanges = parseRanges(json, text); + Systrace.endSection(0); - String json; - try { - json = nativeParse(text, parserId); - } catch (Exception e) { - // Skip formatting, runGuarded will show the error in LogBox mPrevText = text; mPrevParserId = parserId; - mPrevMarkdownRanges = Collections.emptyList(); + mPrevMarkdownRanges = markdownRanges; return mPrevMarkdownRanges; + } finally { + Systrace.endSection(0); } - - List markdownRanges = parseRanges(json, text); - - mPrevText = text; - mPrevParserId = parserId; - mPrevMarkdownRanges = markdownRanges; - return mPrevMarkdownRanges; } } diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownRange.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownRange.java index 6d5de95d..1fda115b 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownRange.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownRange.java @@ -1,17 +1,19 @@ package com.expensify.livemarkdown; +import androidx.annotation.NonNull; + public class MarkdownRange { - private final String mType; + private final @NonNull String mType; private final int mStart; private final int mEnd; private final int mLength; private final int mDepth; - public MarkdownRange(String type, int start, int length, int depth) { + public MarkdownRange(@NonNull String type, int start, int length, int depth) { mType = type; mStart = start; - mLength = length; mEnd = start + length; + mLength = length; mDepth = depth; } @@ -23,14 +25,14 @@ public int getStart() { return mStart; } - public int getLength() { - return mLength; - } - public int getEnd() { return mEnd; } + public int getLength() { + return mLength; + } + public int getDepth() { return mDepth; } diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownStyle.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownStyle.java index cf691e62..50606f98 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownStyle.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownStyle.java @@ -32,6 +32,7 @@ public class MarkdownStyle { private final float mBlockquotePaddingLeft; + @NonNull private final String mCodeFontFamily; private final float mCodeFontSize; @@ -42,6 +43,7 @@ public class MarkdownStyle { @ColorInt private final int mCodeBackgroundColor; + @NonNull private final String mPreFontFamily; private final float mPreFontSize; diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownTextWatcher.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownTextWatcher.java index 23b215a2..d182be1f 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownTextWatcher.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownTextWatcher.java @@ -7,7 +7,7 @@ import androidx.annotation.NonNull; public class MarkdownTextWatcher implements TextWatcher { - private final MarkdownUtils mMarkdownUtils; + private final @NonNull MarkdownUtils mMarkdownUtils; public MarkdownTextWatcher(@NonNull MarkdownUtils markdownUtils) { mMarkdownUtils = markdownUtils; @@ -25,8 +25,8 @@ public void onTextChanged(CharSequence s, int start, int before, int count) { @Override public void afterTextChanged(Editable editable) { - if (editable instanceof SpannableStringBuilder) { - mMarkdownUtils.applyMarkdownFormatting((SpannableStringBuilder) editable); + if (editable instanceof SpannableStringBuilder ssb) { + mMarkdownUtils.applyMarkdownFormatting(ssb); } } } diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java index be72ec4a..6877e46f 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java @@ -5,6 +5,7 @@ import androidx.annotation.NonNull; import com.facebook.react.bridge.ReactContext; +import com.facebook.systrace.Systrace; import java.util.List; @@ -29,8 +30,13 @@ public void setParserId(int parserId) { } public void applyMarkdownFormatting(SpannableStringBuilder ssb) { - String text = ssb.toString(); - List markdownRanges = mMarkdownParser.parse(text, mParserId); - mMarkdownFormatter.format(ssb, markdownRanges, mMarkdownStyle); + try { + Systrace.beginSection(0, "applyMarkdownFormatting"); + String text = ssb.toString(); + List markdownRanges = mMarkdownParser.parse(text, mParserId); + mMarkdownFormatter.format(ssb, markdownRanges, mMarkdownStyle); + } finally { + Systrace.endSection(0); + } } } diff --git a/apple/MarkdownFormatter.h b/apple/MarkdownFormatter.h new file mode 100644 index 00000000..1f5b1a2e --- /dev/null +++ b/apple/MarkdownFormatter.h @@ -0,0 +1,18 @@ +#import +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +const NSAttributedStringKey RCTLiveMarkdownBlockquoteDepthAttributeName = @"RCTLiveMarkdownBlockquoteDepth"; + +@interface MarkdownFormatter : NSObject + +- (nonnull NSAttributedString *)format:(nonnull NSString *)text + withAttributes:(nullable NSDictionary*)attributes + withMarkdownRanges:(nonnull NSArray *)markdownRanges + withMarkdownStyle:(nonnull RCTMarkdownStyle *)markdownStyle; + +NS_ASSUME_NONNULL_END + +@end diff --git a/apple/MarkdownFormatter.mm b/apple/MarkdownFormatter.mm new file mode 100644 index 00000000..642b487b --- /dev/null +++ b/apple/MarkdownFormatter.mm @@ -0,0 +1,158 @@ +#import "MarkdownFormatter.h" +#import + +@implementation MarkdownFormatter + +- (nonnull NSAttributedString *)format:(nonnull NSString *)text + withAttributes:(nullable NSDictionary *)attributes + withMarkdownRanges:(nonnull NSArray *)markdownRanges + withMarkdownStyle:(nonnull RCTMarkdownStyle *)markdownStyle +{ + NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:text attributes:attributes]; + + [attributedString beginEditing]; + + // If the attributed string ends with underlined text, blurring the single-line input imprints the underline style across the whole string. + // It looks like a bug in iOS, as there is no underline style to be found in the attributed string, especially after formatting. + // This is a workaround that applies the NSUnderlineStyleNone to the string before iterating over ranges which resolves this problem. + [attributedString addAttribute:NSUnderlineStyleAttributeName + value:[NSNumber numberWithInteger:NSUnderlineStyleNone] + range:NSMakeRange(0, attributedString.length)]; + + for (MarkdownRange *markdownRange in markdownRanges) { + [self applyRangeToAttributedString:attributedString + type:std::string([markdownRange.type UTF8String]) + range:markdownRange.range + depth:markdownRange.depth + markdownStyle:markdownStyle]; + } + + RCTApplyBaselineOffset(attributedString); + + [attributedString endEditing]; + + return attributedString; +} + +- (void)applyRangeToAttributedString:(NSMutableAttributedString *)attributedString + type:(const std::string)type + range:(const NSRange)range + depth:(const int)depth + markdownStyle:(nonnull RCTMarkdownStyle *)markdownStyle { + if (type == "bold" || type == "italic" || type == "code" || type == "pre" || type == "h1" || type == "emoji") { + UIFont *font = [attributedString attribute:NSFontAttributeName atIndex:range.location effectiveRange:NULL]; + if (type == "bold") { + font = [RCTFont updateFont:font withWeight:@"bold"]; + } else if (type == "italic") { + font = [RCTFont updateFont:font withStyle:@"italic"]; + } else if (type == "code") { + font = [RCTFont updateFont:font withFamily:markdownStyle.codeFontFamily + size:[NSNumber numberWithFloat:markdownStyle.codeFontSize] + weight:nil + style:nil + variant:nil + scaleMultiplier:0]; + } else if (type == "pre") { + font = [RCTFont updateFont:font withFamily:markdownStyle.preFontFamily + size:[NSNumber numberWithFloat:markdownStyle.preFontSize] + weight:nil + style:nil + variant:nil + scaleMultiplier:0]; + } else if (type == "h1") { + font = [RCTFont updateFont:font withFamily:nil + size:[NSNumber numberWithFloat:markdownStyle.h1FontSize] + weight:@"bold" + style:nil + variant:nil + scaleMultiplier:0]; + } else if (type == "emoji") { + font = [RCTFont updateFont:font withFamily:nil + size:[NSNumber numberWithFloat:markdownStyle.emojiFontSize] + weight:nil + style:nil + variant:nil + scaleMultiplier:0]; + } + [attributedString addAttribute:NSFontAttributeName value:font range:range]; + } + + if (type == "syntax") { + [attributedString addAttribute:NSForegroundColorAttributeName value:markdownStyle.syntaxColor range:range]; + } else if (type == "strikethrough") { + [attributedString addAttribute:NSStrikethroughStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleSingle] range:range]; + } else if (type == "code") { + [attributedString addAttribute:NSForegroundColorAttributeName value:markdownStyle.codeColor range:range]; + [attributedString addAttribute:NSBackgroundColorAttributeName value:markdownStyle.codeBackgroundColor range:range]; + } else if (type == "mention-here") { + [attributedString addAttribute:NSForegroundColorAttributeName value:markdownStyle.mentionHereColor range:range]; + [attributedString addAttribute:NSBackgroundColorAttributeName value:markdownStyle.mentionHereBackgroundColor range:range]; + } else if (type == "mention-user") { + // TODO: change mention color when it mentions current user + [attributedString addAttribute:NSForegroundColorAttributeName value:markdownStyle.mentionUserColor range:range]; + [attributedString addAttribute:NSBackgroundColorAttributeName value:markdownStyle.mentionUserBackgroundColor range:range]; + } else if (type == "mention-report") { + [attributedString addAttribute:NSForegroundColorAttributeName value:markdownStyle.mentionReportColor range:range]; + [attributedString addAttribute:NSBackgroundColorAttributeName value:markdownStyle.mentionReportBackgroundColor range:range]; + } else if (type == "link") { + [attributedString addAttribute:NSUnderlineStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleSingle] range:range]; + [attributedString addAttribute:NSForegroundColorAttributeName value:markdownStyle.linkColor range:range]; + } else if (type == "blockquote") { + CGFloat indent = (markdownStyle.blockquoteMarginLeft + markdownStyle.blockquoteBorderWidth + markdownStyle.blockquotePaddingLeft) * depth; + NSMutableParagraphStyle *paragraphStyle = [NSMutableParagraphStyle new]; + paragraphStyle.firstLineHeadIndent = indent; + paragraphStyle.headIndent = indent; + [attributedString addAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:range]; + [attributedString addAttribute:RCTLiveMarkdownBlockquoteDepthAttributeName value:@(depth) range:range]; + } else if (type == "pre") { + [attributedString addAttribute:NSForegroundColorAttributeName value:markdownStyle.preColor range:range]; + NSRange rangeForBackground = [[attributedString string] characterAtIndex:range.location] == '\n' ? NSMakeRange(range.location + 1, range.length - 1) : range; + [attributedString addAttribute:NSBackgroundColorAttributeName value:markdownStyle.preBackgroundColor range:rangeForBackground]; + // TODO: pass background color and ranges to layout manager + } +} + +static void RCTApplyBaselineOffset(NSMutableAttributedString *attributedText) +{ + __block CGFloat maximumLineHeight = 0; + + [attributedText enumerateAttribute:NSParagraphStyleAttributeName + inRange:NSMakeRange(0, attributedText.length) + options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired + usingBlock:^(NSParagraphStyle *paragraphStyle, __unused NSRange range, __unused BOOL *stop) { + if (!paragraphStyle) { + return; + } + + maximumLineHeight = MAX(paragraphStyle.maximumLineHeight, maximumLineHeight); + }]; + + if (maximumLineHeight == 0) { + // `lineHeight` was not specified, nothing to do. + return; + } + + __block CGFloat maximumFontLineHeight = 0; + + [attributedText enumerateAttribute:NSFontAttributeName + inRange:NSMakeRange(0, attributedText.length) + options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired + usingBlock:^(UIFont *font, NSRange range, __unused BOOL *stop) { + if (!font) { + return; + } + + maximumFontLineHeight = MAX(font.lineHeight, maximumFontLineHeight); + }]; + + if (maximumLineHeight < maximumFontLineHeight) { + return; + } + + CGFloat baseLineOffset = (maximumLineHeight - maximumFontLineHeight) / 2.0; + [attributedText addAttribute:NSBaselineOffsetAttributeName + value:@(baseLineOffset) + range:NSMakeRange(0, attributedText.length)]; +} + +@end diff --git a/apple/MarkdownLayoutManager.h b/apple/MarkdownLayoutManager.h index 29be3508..9e965e11 100644 --- a/apple/MarkdownLayoutManager.h +++ b/apple/MarkdownLayoutManager.h @@ -1,5 +1,6 @@ #import #import +#import NS_ASSUME_NONNULL_BEGIN diff --git a/apple/MarkdownLayoutManager.mm b/apple/MarkdownLayoutManager.mm index 3974ba98..4ea7da64 100644 --- a/apple/MarkdownLayoutManager.mm +++ b/apple/MarkdownLayoutManager.mm @@ -5,34 +5,27 @@ @implementation MarkdownLayoutManager - (void)drawBackgroundForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin { [super drawBackgroundForGlyphRange:glyphsToShow atPoint:origin]; + NSTextStorage *textStorage = self.textStorage; + [self enumerateLineFragmentsForGlyphRange:glyphsToShow usingBlock:^(CGRect rect, CGRect usedRect, NSTextContainer * _Nonnull textContainer, NSRange glyphRange, BOOL * _Nonnull stop) { - __block BOOL isBlockquote = NO; - __block int currentDepth = 0; + NSNumber *depth = [textStorage attribute:RCTLiveMarkdownBlockquoteDepthAttributeName atIndex:glyphRange.location effectiveRange:nil]; + if (depth == nil) { + return; // not a blockquote + } + RCTMarkdownUtils *markdownUtils = [self valueForKey:@"markdownUtils"]; - [markdownUtils.blockquoteRangesAndLevels enumerateObjectsUsingBlock:^(NSDictionary *item, NSUInteger idx, BOOL * _Nonnull stop) { - NSRange range = [[item valueForKey:@"range"] rangeValue]; - currentDepth = [[item valueForKey:@"depth"] unsignedIntegerValue]; - NSUInteger start = range.location; - NSUInteger end = start + range.length; - NSUInteger location = glyphRange.location; - if (location >= start && location < end) { - isBlockquote = YES; - *stop = YES; - } - }]; - if (isBlockquote) { - CGFloat paddingLeft = origin.x; - CGFloat paddingTop = origin.y; - CGFloat y = paddingTop + rect.origin.y; - CGFloat width = markdownUtils.markdownStyle.blockquoteBorderWidth; - CGFloat height = rect.size.height; - CGFloat shift = markdownUtils.markdownStyle.blockquoteMarginLeft + markdownUtils.markdownStyle.blockquoteBorderWidth + markdownUtils.markdownStyle.blockquotePaddingLeft; - for (int level = 0; level < currentDepth; level++) { - CGFloat x = paddingLeft + (level * shift) + markdownUtils.markdownStyle.blockquoteMarginLeft; - CGRect lineRect = CGRectMake(x, y, width, height); - [markdownUtils.markdownStyle.blockquoteBorderColor setFill]; - UIRectFill(lineRect); - } + CGFloat paddingLeft = origin.x; + CGFloat paddingTop = origin.y; + CGFloat y = paddingTop + rect.origin.y; + CGFloat width = markdownUtils.markdownStyle.blockquoteBorderWidth; + CGFloat height = rect.size.height; + CGFloat shift = markdownUtils.markdownStyle.blockquoteMarginLeft + markdownUtils.markdownStyle.blockquoteBorderWidth + markdownUtils.markdownStyle.blockquotePaddingLeft; + + for (NSUInteger level = 0; level < [depth unsignedIntValue]; level++) { + CGFloat x = paddingLeft + (level * shift) + markdownUtils.markdownStyle.blockquoteMarginLeft; + CGRect lineRect = CGRectMake(x, y, width, height); + [markdownUtils.markdownStyle.blockquoteBorderColor setFill]; + UIRectFill(lineRect); } }]; } diff --git a/apple/RCTMarkdownUtils.h b/apple/RCTMarkdownUtils.h index e942139d..ea126472 100644 --- a/apple/RCTMarkdownUtils.h +++ b/apple/RCTMarkdownUtils.h @@ -7,7 +7,6 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic) RCTMarkdownStyle *markdownStyle; @property (nonatomic) NSNumber *parserId; -@property (nonatomic) NSMutableArray *blockquoteRangesAndLevels; - (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input withAttributes:(nullable NSDictionary*)attributes; diff --git a/apple/RCTMarkdownUtils.mm b/apple/RCTMarkdownUtils.mm index 57d90774..3c90238b 100644 --- a/apple/RCTMarkdownUtils.mm +++ b/apple/RCTMarkdownUtils.mm @@ -1,13 +1,10 @@ #import -#import #import -#import "react_native_assert.h" -#import -#import -#include +#import @implementation RCTMarkdownUtils { MarkdownParser *_markdownParser; + MarkdownFormatter *_markdownFormatter; NSString *_prevInputString; NSAttributedString *_prevAttributedString; NSDictionary *_prevTextAttributes; @@ -19,6 +16,7 @@ - (instancetype)init { if (self = [super init]) { _markdownParser = [MarkdownParser new]; + _markdownFormatter = [MarkdownFormatter new]; } return self; @@ -26,167 +24,30 @@ - (instancetype)init - (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input withAttributes:(nullable NSDictionary *)attributes { - @synchronized (self) { - if (input == nil) { - return nil; - } - - NSString *inputString = [input string]; - if ([inputString isEqualToString:_prevInputString] && [attributes isEqualToDictionary:_prevTextAttributes] && [_markdownStyle isEqual:_prevMarkdownStyle] && [_parserId isEqualToNumber:_prevParserId]) { - return _prevAttributedString; - } - - NSArray *markdownRanges = [_markdownParser parse:inputString withParserId:_parserId]; - - NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:inputString attributes:attributes]; - [attributedString beginEditing]; - - // If the attributed string ends with underlined text, blurring the single-line input imprints the underline style across the whole string. - // It looks like a bug in iOS, as there is no underline style to be found in the attributed string, especially after formatting. - // This is a workaround that applies the NSUnderlineStyleNone to the string before iterating over ranges which resolves this problem. - [attributedString addAttribute:NSUnderlineStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleNone] range:NSMakeRange(0, attributedString.length)]; - - _blockquoteRangesAndLevels = [NSMutableArray new]; - - for (MarkdownRange *markdownRange in markdownRanges) { - [self applyRangeToAttributedString:attributedString - type:std::string([markdownRange.type UTF8String]) - range:markdownRange.range - depth:markdownRange.depth]; - } - - RCTApplyBaselineOffset(attributedString); - - [attributedString endEditing]; - - _prevInputString = inputString; - _prevAttributedString = attributedString; - _prevTextAttributes = attributes; - _prevMarkdownStyle = _markdownStyle; - _prevParserId = _parserId; - - return attributedString; - } -} - -- (void)applyRangeToAttributedString:(NSMutableAttributedString *)attributedString type:(const std::string)type range:(NSRange)range depth:(const int)depth { - if (type == "bold" || type == "italic" || type == "code" || type == "pre" || type == "h1" || type == "emoji") { - UIFont *font = [attributedString attribute:NSFontAttributeName atIndex:range.location effectiveRange:NULL]; - if (type == "bold") { - font = [RCTFont updateFont:font withWeight:@"bold"]; - } else if (type == "italic") { - font = [RCTFont updateFont:font withStyle:@"italic"]; - } else if (type == "code") { - font = [RCTFont updateFont:font withFamily:_markdownStyle.codeFontFamily - size:[NSNumber numberWithFloat:_markdownStyle.codeFontSize] - weight:nil - style:nil - variant:nil - scaleMultiplier:0]; - } else if (type == "pre") { - font = [RCTFont updateFont:font withFamily:_markdownStyle.preFontFamily - size:[NSNumber numberWithFloat:_markdownStyle.preFontSize] - weight:nil - style:nil - variant:nil - scaleMultiplier:0]; - } else if (type == "h1") { - font = [RCTFont updateFont:font withFamily:nil - size:[NSNumber numberWithFloat:_markdownStyle.h1FontSize] - weight:@"bold" - style:nil - variant:nil - scaleMultiplier:0]; - } else if (type == "emoji") { - font = [RCTFont updateFont:font withFamily:nil - size:[NSNumber numberWithFloat:_markdownStyle.emojiFontSize] - weight:nil - style:nil - variant:nil - scaleMultiplier:0]; - } - [attributedString addAttribute:NSFontAttributeName value:font range:range]; - } - - if (type == "syntax") { - [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.syntaxColor range:range]; - } else if (type == "strikethrough") { - [attributedString addAttribute:NSStrikethroughStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleSingle] range:range]; - } else if (type == "code") { - [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.codeColor range:range]; - [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.codeBackgroundColor range:range]; - } else if (type == "mention-here") { - [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.mentionHereColor range:range]; - [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.mentionHereBackgroundColor range:range]; - } else if (type == "mention-user") { - // TODO: change mention color when it mentions current user - [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.mentionUserColor range:range]; - [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.mentionUserBackgroundColor range:range]; - } else if (type == "mention-report") { - [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.mentionReportColor range:range]; - [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.mentionReportBackgroundColor range:range]; - } else if (type == "link") { - [attributedString addAttribute:NSUnderlineStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleSingle] range:range]; - [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.linkColor range:range]; - } else if (type == "blockquote") { - CGFloat indent = (_markdownStyle.blockquoteMarginLeft + _markdownStyle.blockquoteBorderWidth + _markdownStyle.blockquotePaddingLeft) * depth; - NSMutableParagraphStyle *paragraphStyle = [NSMutableParagraphStyle new]; - paragraphStyle.firstLineHeadIndent = indent; - paragraphStyle.headIndent = indent; - [attributedString addAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:range]; - [_blockquoteRangesAndLevels addObject:@{ - @"range": [NSValue valueWithRange:range], - @"depth": @(depth) - }]; - } else if (type == "pre") { - [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.preColor range:range]; - NSRange rangeForBackground = [[attributedString string] characterAtIndex:range.location] == '\n' ? NSMakeRange(range.location + 1, range.length - 1) : range; - [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.preBackgroundColor range:rangeForBackground]; - // TODO: pass background color and ranges to layout manager + @synchronized (self) { + if (input == nil) { + return nil; } -} - -static void RCTApplyBaselineOffset(NSMutableAttributedString *attributedText) -{ - __block CGFloat maximumLineHeight = 0; - [attributedText enumerateAttribute:NSParagraphStyleAttributeName - inRange:NSMakeRange(0, attributedText.length) - options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired - usingBlock:^(NSParagraphStyle *paragraphStyle, __unused NSRange range, __unused BOOL *stop) { - if (!paragraphStyle) { - return; + NSString *inputString = [input string]; + if ([inputString isEqualToString:_prevInputString] && [attributes isEqualToDictionary:_prevTextAttributes] && [_markdownStyle isEqual:_prevMarkdownStyle] && [_parserId isEqualToNumber:_prevParserId]) { + return _prevAttributedString; } - maximumLineHeight = MAX(paragraphStyle.maximumLineHeight, maximumLineHeight); - }]; + NSArray *markdownRanges = [_markdownParser parse:inputString withParserId:_parserId]; - if (maximumLineHeight == 0) { - // `lineHeight` was not specified, nothing to do. - return; - } - - __block CGFloat maximumFontLineHeight = 0; - - [attributedText enumerateAttribute:NSFontAttributeName - inRange:NSMakeRange(0, attributedText.length) - options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired - usingBlock:^(UIFont *font, NSRange range, __unused BOOL *stop) { - if (!font) { - return; - } - - maximumFontLineHeight = MAX(font.lineHeight, maximumFontLineHeight); - }]; + NSAttributedString *attributedString = [_markdownFormatter format:inputString + withAttributes:attributes + withMarkdownRanges:markdownRanges + withMarkdownStyle:_markdownStyle]; + _prevInputString = inputString; + _prevAttributedString = attributedString; + _prevTextAttributes = attributes; + _prevMarkdownStyle = _markdownStyle; + _prevParserId = _parserId; - if (maximumLineHeight < maximumFontLineHeight) { - return; + return attributedString; } - - CGFloat baseLineOffset = (maximumLineHeight - maximumFontLineHeight) / 2.0; - [attributedText addAttribute:NSBaselineOffsetAttributeName - value:@(baseLineOffset) - range:NSMakeRange(0, attributedText.length)]; } @end diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index d9e49e6f..f3bb3529 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1497,7 +1497,7 @@ PODS: - React-logger (= 0.75.3) - React-perflogger (= 0.75.3) - React-utils (= 0.75.3) - - RNLiveMarkdown (0.1.190): + - RNLiveMarkdown (0.1.199): - DoubleConversion - glog - hermes-engine @@ -1517,10 +1517,10 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNLiveMarkdown/newarch (= 0.1.190) + - RNLiveMarkdown/newarch (= 0.1.199) - RNReanimated/worklets - Yoga - - RNLiveMarkdown/newarch (0.1.190): + - RNLiveMarkdown/newarch (0.1.199): - DoubleConversion - glog - hermes-engine @@ -1897,7 +1897,7 @@ SPEC CHECKSUMS: React-utils: f2afa6acd905ca2ce7bb8ffb4a22f7f8a12534e8 ReactCodegen: e35c23cdd36922f6d2990c6c1f1b022ade7ad74d ReactCommon: 289214026502e6a93484f4a46bcc0efa4f3f2864 - RNLiveMarkdown: a210cbb45b6cb9db0b28ef09aafdc9c77424dd38 + RNLiveMarkdown: 18dd4ceada29d66a6b7c29b1b0df589e2fc82183 RNReanimated: ab6c33a61e90c4cbe5dbcbe65bd6c7cb3be167e6 SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d Yoga: 1354c027ab07c7736f99a3bef16172d6f1b12b47 diff --git a/package-lock.json b/package-lock.json index 0756d1d2..3b9507a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@expensify/react-native-live-markdown", - "version": "0.1.197", + "version": "0.1.203", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@expensify/react-native-live-markdown", - "version": "0.1.197", + "version": "0.1.203", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index bf6bb60c..0a705c95 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@expensify/react-native-live-markdown", - "version": "0.1.197", + "version": "0.1.203", "description": "Drop-in replacement for React Native's TextInput component with Markdown formatting.", "main": "lib/commonjs/index", "module": "lib/module/index",