Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0986812
add border radius to mention user
war-in Aug 12, 2025
195ad24
mention-user highlighting v2
war-in Aug 13, 2025
1b26ad4
mention-user highlighting v3 - it finally works!
war-in Aug 13, 2025
521e78c
rewrite code to enable new mentions in blockquotes & add border radiu…
war-in Aug 14, 2025
9d04008
fix web styles test
war-in Aug 14, 2025
5a7c73c
rename attribute & use custom objects instead of dict
war-in Aug 19, 2025
ddeba51
address review
war-in Aug 19, 2025
ac410ee
support rounded background on android
war-in Aug 20, 2025
870b5da
move `RCTMarkdownTextBackground` & `RCTMarkdownTextBackgroundWithRang…
war-in Aug 20, 2025
99cf1fb
rename cornerRadius to borderRadius
war-in Aug 20, 2025
8b416a5
rename cornerRadius to borderRadius iOS
war-in Aug 20, 2025
28b6a13
fix blockquote `\n` issue
war-in Aug 22, 2025
98dafda
use StaticLayout to correctly calculate text position
war-in Aug 26, 2025
a105c10
create layout for the specific part of the text to improve performance
war-in Aug 26, 2025
db307b2
Merge branch 'main' into war-in/add-mention-user-border-radius
war-in Sep 2, 2025
8d5e9b2
Merge branch 'refs/heads/main' into war-in/add-mention-user-border-ra…
war-in Oct 7, 2025
bd8aaf3
Merge branch 'refs/heads/main' into war-in/add-mention-user-border-ra…
war-in Oct 22, 2025
2a5069e
feat: add support for rounded corners in singleline input on iOS
war-in Oct 22, 2025
cbab252
fix: rounded background issues on singeline input when mentions were …
war-in Oct 27, 2025
f1b8c74
fix: Android - wrap mentions tightly, don't highlight entire line height
war-in Oct 28, 2025
46ad23f
fix: iOS - wrap mentions tightly, don't highlight entire line height
war-in Oct 28, 2025
fe24512
fix: iOS - highlighting multiline mentions
war-in Oct 28, 2025
39e0318
fix: Android - apply density to borderRadius to align radius on all d…
war-in Oct 29, 2025
0da8207
chore: improve the iOS algorithm
war-in Oct 29, 2025
48830e5
Merge branch 'refs/heads/main' into war-in/add-mention-user-border-ra…
war-in Nov 14, 2025
6665f83
chore: iOS - apply review
war-in Nov 14, 2025
c556257
chore: Android - apply review
war-in Nov 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions WebExample/__tests__/styles.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,15 @@ test.describe('markdown content styling', () => {
});

test('mention-here', async ({page}) => {
await testMarkdownContentStyle({testContent: 'here', style: 'color: green; background-color: lime;', page});
await testMarkdownContentStyle({testContent: 'here', style: 'color: green; background-color: lime; border-radius: 5px;', page});
});

test('mention-user', async ({page}) => {
await testMarkdownContentStyle({testContent: '[email protected]', style: 'color: blue; background-color: cyan;', page});
await testMarkdownContentStyle({testContent: '[email protected]', style: 'color: blue; background-color: cyan; border-radius: 5px;', page});
});

test('mention-report', async ({page}) => {
await testMarkdownContentStyle({testContent: 'mention-report', style: 'color: red; background-color: pink;', page});
await testMarkdownContentStyle({testContent: 'mention-report', style: 'color: red; background-color: pink; border-radius: 5px;', page});
});

test('blockquote', async ({page, browserName}) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,16 +75,16 @@ private void applyRange(@NonNull SpannableStringBuilder ssb, @NonNull MarkdownRa
break;
case "mention-here":
setSpan(ssb, new MarkdownForegroundColorSpan(markdownStyle.getMentionHereColor()), start, end);
setSpan(ssb, new MarkdownBackgroundColorSpan(markdownStyle.getMentionHereBackgroundColor()), start, end);
setSpan(ssb, new MarkdownBackgroundSpan(markdownStyle.getMentionHereBackgroundColor(), markdownStyle.getMentionHereBorderRadius(), start, end), start, end);
break;
case "mention-user":
// TODO: change mention color when it mentions current user
setSpan(ssb, new MarkdownForegroundColorSpan(markdownStyle.getMentionUserColor()), start, end);
setSpan(ssb, new MarkdownBackgroundColorSpan(markdownStyle.getMentionUserBackgroundColor()), start, end);
setSpan(ssb, new MarkdownBackgroundSpan(markdownStyle.getMentionUserBackgroundColor(), markdownStyle.getMentionUserBorderRadius(), start, end), start, end);
break;
case "mention-report":
setSpan(ssb, new MarkdownForegroundColorSpan(markdownStyle.getMentionReportColor()), start, end);
setSpan(ssb, new MarkdownBackgroundColorSpan(markdownStyle.getMentionReportBackgroundColor()), start, end);
setSpan(ssb, new MarkdownBackgroundSpan(markdownStyle.getMentionReportBackgroundColor(), markdownStyle.getMentionReportBorderRadius(), start, end), start, end);
break;
case "syntax":
setSpan(ssb, new MarkdownForegroundColorSpan(markdownStyle.getSyntaxColor()), start, end);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,27 @@ public class MarkdownStyle {
@ColorInt
private final int mMentionHereBackgroundColor;

private final float mMentionHereBorderRadius;

@ColorInt
private final int mMentionUserColor;

@ColorInt
private final int mMentionUserBackgroundColor;

private final float mMentionUserBorderRadius;

@ColorInt
private final int mMentionReportColor;

@ColorInt
private final int mMentionReportBackgroundColor;

private final float mMentionReportBorderRadius;

public MarkdownStyle(@NonNull ReadableMap map, @NonNull Context context) {
float screenDensity = context.getResources().getDisplayMetrics().density;

mSyntaxColor = parseColor(map, "syntax", "color", context);
mLinkColor = parseColor(map, "link", "color", context);
mH1FontSize = parseFloat(map, "h1", "fontSize");
Expand All @@ -95,10 +103,13 @@ public MarkdownStyle(@NonNull ReadableMap map, @NonNull Context context) {
mPreBackgroundColor = parseColor(map, "pre", "backgroundColor", context);
mMentionHereColor = parseColor(map, "mentionHere", "color", context);
mMentionHereBackgroundColor = parseColor(map, "mentionHere", "backgroundColor", context);
mMentionHereBorderRadius = parseFloat(map, "mentionHere", "borderRadius") * screenDensity;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather keep the original value of borderRadius in MarkdownStyle and multiply it by screenDensity near the drawing logic.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried that but context is not available in span so I had to pass it down and the code looked messy
We can do that but this approach is much cleaner

mMentionUserColor = parseColor(map, "mentionUser", "color", context);
mMentionUserBackgroundColor = parseColor(map, "mentionUser", "backgroundColor", context);
mMentionUserBorderRadius = parseFloat(map, "mentionUser", "borderRadius") * screenDensity;
mMentionReportColor = parseColor(map, "mentionReport", "color", context);
mMentionReportBackgroundColor = parseColor(map, "mentionReport", "backgroundColor", context);
mMentionReportBorderRadius = parseFloat(map, "mentionReport", "borderRadius") * screenDensity;
}

private static int parseColor(@NonNull ReadableMap map, @NonNull String key, @NonNull String prop, @NonNull Context context) {
Expand Down Expand Up @@ -213,6 +224,10 @@ public int getMentionHereBackgroundColor() {
return mMentionHereBackgroundColor;
}

public float getMentionHereBorderRadius() {
return mMentionHereBorderRadius;
}

@ColorInt
public int getMentionUserColor() {
return mMentionUserColor;
Expand All @@ -223,6 +238,10 @@ public int getMentionUserBackgroundColor() {
return mMentionUserBackgroundColor;
}

public float getMentionUserBorderRadius() {
return mMentionUserBorderRadius;
}

@ColorInt
public int getMentionReportColor() {
return mMentionReportColor;
Expand All @@ -232,4 +251,8 @@ public int getMentionReportColor() {
public int getMentionReportBackgroundColor() {
return mMentionReportBackgroundColor;
}

public float getMentionReportBorderRadius() {
return mMentionReportBorderRadius;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package com.expensify.livemarkdown.spans;

import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RectF;
import android.text.StaticLayout;
import android.text.TextPaint;
import android.text.style.LineBackgroundSpan;

import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;

public class MarkdownBackgroundSpan implements MarkdownSpan, LineBackgroundSpan {

private final int backgroundColor;
private final int mentionStart;
private final int mentionEnd;
private final float borderRadius;

private StaticLayout layout;
private Path backgroundPath;

public MarkdownBackgroundSpan(@ColorInt int backgroundColor, float borderRadius, int mentionStart, int mentionEnd) {
this.backgroundColor = backgroundColor;
this.borderRadius = borderRadius;
this.mentionStart = mentionStart;
this.mentionEnd = mentionEnd;
this.backgroundPath = new Path();
}

@Override
public void drawBackground(
@NonNull Canvas canvas,
@NonNull Paint paint,
int left,
int right,
int top,
int baseline,
int bottom,
@NonNull CharSequence text,
int start,
int end,
int lnum
) {
CharSequence lineText = text.subSequence(start, end);
if (layout == null || layout.getText() != lineText || layout.getWidth() != right || layout.getLineEnd(0) != lineText.length()) {
int currentLineStart = 0;
int currentLineEnd = lineText.length();
// Create layout for the current line only
layout = StaticLayout.Builder.obtain(lineText, currentLineStart, currentLineEnd, (TextPaint) paint, right).build();

int relativeMentionStart = mentionStart - start;
int relativeMentionEnd = mentionEnd - start;

boolean mentionStartsInCurrentLine = currentLineStart <= relativeMentionStart;
boolean mentionEndsInCurrentLine = currentLineEnd >= relativeMentionEnd;

float startX = layout.getPrimaryHorizontal(mentionStartsInCurrentLine ? relativeMentionStart: currentLineStart);
float endX = layout.getPrimaryHorizontal(mentionEndsInCurrentLine ? relativeMentionEnd : currentLineEnd);

Paint.FontMetrics fm = paint.getFontMetrics();
float startY = baseline + fm.ascent;
float endY = baseline + fm.descent;

RectF lineRect = new RectF(startX, startY, endX, endY);
backgroundPath.reset();
backgroundPath.addRoundRect(lineRect, createRadii(mentionStartsInCurrentLine, mentionEndsInCurrentLine), Path.Direction.CW);
}

int originalColor = paint.getColor();
paint.setColor(backgroundColor);

canvas.drawPath(backgroundPath, paint);

paint.setColor(originalColor);
}

private float[] createRadii(boolean roundedLeft, boolean roundedRight) {
float[] radii = new float[8];

if (roundedLeft) {
radii[0] = radii[1] = borderRadius; // top-left
radii[6] = radii[7] = borderRadius; // bottom-left
}

if (roundedRight) {
radii[2] = radii[3] = borderRadius; // top-right
radii[4] = radii[5] = borderRadius; // bottom-right
}

return radii;
}
}
51 changes: 0 additions & 51 deletions apple/BlockquoteTextLayoutFragment.mm

This file was deleted.

2 changes: 2 additions & 0 deletions apple/MarkdownFormatter.h
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ NS_ASSUME_NONNULL_BEGIN

const NSAttributedStringKey RCTLiveMarkdownTextAttributeName = @"RCTLiveMarkdownText";

const NSAttributedStringKey RCTLiveMarkdownTextBackgroundAttributeName = @"RCTLiveMarkdownTextBackground";

const NSAttributedStringKey RCTLiveMarkdownBlockquoteDepthAttributeName = @"RCTLiveMarkdownBlockquoteDepth";

@interface MarkdownFormatter : NSObject
Expand Down
42 changes: 36 additions & 6 deletions apple/MarkdownFormatter.mm
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#import "MarkdownFormatter.h"
#import <React/RCTFont.h>
#import <RNLiveMarkdown/RCTMarkdownTextBackgroundWithRange.h>

@implementation MarkdownFormatter

Expand Down Expand Up @@ -90,15 +91,24 @@ - (void)applyRangeToAttributedString:(NSMutableAttributedString *)attributedStri
[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];
[self applyMentionFormatting:attributedString
range:range
mentionColor:markdownStyle.mentionHereColor
backgroundColor:markdownStyle.mentionHereBackgroundColor
borderRadius:markdownStyle.mentionHereBorderRadius];
} 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];
[self applyMentionFormatting:attributedString
range:range
mentionColor:markdownStyle.mentionUserColor
backgroundColor:markdownStyle.mentionUserBackgroundColor
borderRadius:markdownStyle.mentionUserBorderRadius];
} else if (type == "mention-report") {
[attributedString addAttribute:NSForegroundColorAttributeName value:markdownStyle.mentionReportColor range:range];
[attributedString addAttribute:NSBackgroundColorAttributeName value:markdownStyle.mentionReportBackgroundColor range:range];
[self applyMentionFormatting:attributedString
range:range
mentionColor:markdownStyle.mentionReportColor
backgroundColor:markdownStyle.mentionReportBackgroundColor
borderRadius:markdownStyle.mentionReportBorderRadius];
} else if (type == "link") {
[attributedString addAttribute:NSUnderlineStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleSingle] range:range];
[attributedString addAttribute:NSForegroundColorAttributeName value:markdownStyle.linkColor range:range];
Expand All @@ -118,6 +128,26 @@ - (void)applyRangeToAttributedString:(NSMutableAttributedString *)attributedStri
}
}

- (void)applyMentionFormatting:(NSMutableAttributedString *)attributedString
range:(const NSRange)range
mentionColor:(UIColor *)mentionColor
backgroundColor:(UIColor *)backgroundColor
borderRadius:(CGFloat)borderRadius
{
[attributedString addAttribute:NSForegroundColorAttributeName value:mentionColor range:range];
if (@available(iOS 16.0, *)) {
RCTMarkdownTextBackground *textBackground = [[RCTMarkdownTextBackground alloc] init];
textBackground.color = backgroundColor;
textBackground.borderRadius = borderRadius;

[attributedString addAttribute:RCTLiveMarkdownTextBackgroundAttributeName
value:textBackground
range:range];
} else {
[attributedString addAttribute:NSBackgroundColorAttributeName value:backgroundColor range:range];
}
}

static void RCTApplyBaselineOffset(NSMutableAttributedString *attributedText, NSRange attributedTextRange)
{
__block CGFloat maximumLineHeight = 0;
Expand Down
17 changes: 15 additions & 2 deletions apple/MarkdownTextInputDecoratorComponentView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ - (void)addTextInputObservers
react_native_assert([childView isKindOfClass:[RCTTextInputComponentView class]] && "Child component of MarkdownTextInputDecoratorComponentView is not an instance of RCTTextInputComponentView.");
RCTTextInputComponentView *textInputComponentView = (RCTTextInputComponentView *)childView;
UIView<RCTBackedTextInputViewProtocol> *backedTextInputView = [textInputComponentView valueForKey:@"_backedTextInputView"];

_observersAdded = true;

if ([backedTextInputView isKindOfClass:[RCTUITextField class]]) {
Expand All @@ -100,6 +100,19 @@ - (void)addTextInputObservers
// format initial value
[_markdownTextFieldObserver textFieldDidChange:_textField];

if (@available(iOS 16.0, *)) {
std::string text = "txet";
std::reverse(text.begin(), text.end());
NSTextStorage *textStorage = [_textField valueForKey:[NSString stringWithFormat:@"%sStorage", text.c_str()]];
NSTextContainer *textContainer = [_textField valueForKey:[NSString stringWithFormat:@"%sContainer", text.c_str()]];
NSTextLayoutManager *textLayoutManager = [textContainer valueForKey:[NSString stringWithFormat:@"%sLayoutManager", text.c_str()]];

_markdownTextLayoutManagerDelegate = [[MarkdownTextLayoutManagerDelegate alloc] init];
_markdownTextLayoutManagerDelegate.textStorage = textStorage;
_markdownTextLayoutManagerDelegate.markdownUtils = _markdownUtils;
textLayoutManager.delegate = _markdownTextLayoutManagerDelegate;
}

// TODO: register blockquotes layout manager
// https://github.com/Expensify/react-native-live-markdown/issues/87
} else if ([backedTextInputView isKindOfClass:[RCTUITextView class]]) {
Expand Down Expand Up @@ -229,7 +242,7 @@ - (void)prepareForRecycle
{
react_native_assert(!_observersAdded && "MarkdownTextInputDecoratorComponentView was being recycled with TextInput observers still attached");
[super prepareForRecycle];

static const auto defaultProps = std::make_shared<const MarkdownTextInputDecoratorViewProps>();
_props = defaultProps;
_markdownUtils = [[RCTMarkdownUtils alloc] init];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
#import <RNLiveMarkdown/RCTMarkdownUtils.h>
#import <RNLiveMarkdown/RCTMarkdownTextBackgroundWithRange.h>
#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

API_AVAILABLE(ios(15.0))
@interface BlockquoteTextLayoutFragment : NSTextLayoutFragment
@interface MarkdownTextLayoutFragment : NSTextLayoutFragment

@property (nonnull, atomic) RCTMarkdownUtils *markdownUtils;

@property NSUInteger depth;

@property NSMutableArray<RCTMarkdownTextBackgroundWithRange *> *mentions;

@end

NS_ASSUME_NONNULL_END
Loading
Loading