From 09fde4b6c43fd10be6cc2cdaca736c3aa5a262b2 Mon Sep 17 00:00:00 2001 From: Maksymilian Galas Date: Mon, 29 Apr 2024 16:07:49 +0200 Subject: [PATCH] Implement code and pre blocks support on Android --- .../MarkdownBackgroundColorSpan.java | 11 - ...FamilySpan.java => MarkdownBlockSpan.java} | 25 +- .../livemarkdown/MarkdownCodeSpan.java | 13 + .../livemarkdown/MarkdownFontSizeSpan.java | 11 - .../livemarkdown/MarkdownH1Span.java | 47 ++++ .../livemarkdown/MarkdownLineHeightSpan.java | 18 -- .../livemarkdown/MarkdownLinkSpan.java | 30 +++ .../livemarkdown/MarkdownMentionSpan.java | 32 +++ .../livemarkdown/MarkdownPreSpan.java | 28 ++ .../expensify/livemarkdown/MarkdownStyle.java | 76 ++++-- .../MarkdownTextInputDecoratorView.java | 240 +++++++++++++++++- .../livemarkdown/MarkdownUnderlineSpan.java | 5 - .../expensify/livemarkdown/MarkdownUtils.java | 63 ++--- 13 files changed, 492 insertions(+), 107 deletions(-) delete mode 100644 android/src/main/java/com/expensify/livemarkdown/MarkdownBackgroundColorSpan.java rename android/src/main/java/com/expensify/livemarkdown/{MarkdownFontFamilySpan.java => MarkdownBlockSpan.java} (58%) create mode 100644 android/src/main/java/com/expensify/livemarkdown/MarkdownCodeSpan.java delete mode 100644 android/src/main/java/com/expensify/livemarkdown/MarkdownFontSizeSpan.java create mode 100644 android/src/main/java/com/expensify/livemarkdown/MarkdownH1Span.java delete mode 100644 android/src/main/java/com/expensify/livemarkdown/MarkdownLineHeightSpan.java create mode 100644 android/src/main/java/com/expensify/livemarkdown/MarkdownLinkSpan.java create mode 100644 android/src/main/java/com/expensify/livemarkdown/MarkdownMentionSpan.java create mode 100644 android/src/main/java/com/expensify/livemarkdown/MarkdownPreSpan.java delete mode 100644 android/src/main/java/com/expensify/livemarkdown/MarkdownUnderlineSpan.java diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownBackgroundColorSpan.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownBackgroundColorSpan.java deleted file mode 100644 index 4a4ce1d1..00000000 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownBackgroundColorSpan.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.expensify.livemarkdown; - -import android.text.style.BackgroundColorSpan; - -import androidx.annotation.ColorInt; - -public class MarkdownBackgroundColorSpan extends BackgroundColorSpan implements MarkdownSpan { - public MarkdownBackgroundColorSpan(@ColorInt int color) { - super(color); - } -} diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownFontFamilySpan.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownBlockSpan.java similarity index 58% rename from android/src/main/java/com/expensify/livemarkdown/MarkdownFontFamilySpan.java rename to android/src/main/java/com/expensify/livemarkdown/MarkdownBlockSpan.java index b9e71dd0..b9b205aa 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownFontFamilySpan.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownBlockSpan.java @@ -6,19 +6,24 @@ import android.text.TextPaint; import android.text.style.MetricAffectingSpan; +import androidx.annotation.ColorInt; import androidx.annotation.NonNull; -import com.facebook.react.common.assets.ReactFontManager.TypefaceStyle; -import com.facebook.react.views.text.ReactFontManager; +import com.facebook.react.common.assets.ReactFontManager; +import com.facebook.react.uimanager.PixelUtil; -public class MarkdownFontFamilySpan extends MetricAffectingSpan implements MarkdownSpan { +public class MarkdownBlockSpan extends MetricAffectingSpan implements MarkdownSpan { - private final @NonNull String mFontFamily; private final @NonNull AssetManager mAssetManager; + private final @NonNull String mFontFamily; + private final float mFontSize; + private final int mColor; - public MarkdownFontFamilySpan(@NonNull String fontFamily, @NonNull AssetManager assetManager) { - mFontFamily = fontFamily; + public MarkdownBlockSpan(@NonNull AssetManager assetManager, @NonNull String fontFamily, float fontSize, @ColorInt int color) { mAssetManager = assetManager; + mFontFamily = fontFamily; + mFontSize = PixelUtil.toPixelFromDIP(fontSize); + mColor = color; } @Override @@ -31,15 +36,15 @@ public void updateDrawState(TextPaint tp) { apply(tp); } - private void apply(@NonNull TextPaint textPaint) { - int style = TypefaceStyle.NORMAL; + void apply(@NonNull TextPaint textPaint) { + int style = ReactFontManager.TypefaceStyle.NORMAL; if (textPaint.getTypeface() != null) { style = textPaint.getTypeface().getStyle(); - } else { - style = TypefaceStyle.NORMAL; } Typeface typeface = ReactFontManager.getInstance().getTypeface(mFontFamily, style, mAssetManager); textPaint.setTypeface(typeface); textPaint.setFlags(textPaint.getFlags() | Paint.SUBPIXEL_TEXT_FLAG); + textPaint.setTextSize(mFontSize); + textPaint.setColor(mColor); } } diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownCodeSpan.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownCodeSpan.java new file mode 100644 index 00000000..c71d2f22 --- /dev/null +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownCodeSpan.java @@ -0,0 +1,13 @@ +package com.expensify.livemarkdown; + +import android.content.res.AssetManager; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; + +public class MarkdownCodeSpan extends MarkdownBlockSpan { + public MarkdownCodeSpan(@NonNull AssetManager assetManager, @NonNull String fontFamily, float fontSize, @ColorInt int color) { + super(assetManager, fontFamily, fontSize, color); + } +} + diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownFontSizeSpan.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownFontSizeSpan.java deleted file mode 100644 index 25d4dadc..00000000 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownFontSizeSpan.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.expensify.livemarkdown; - -import android.text.style.AbsoluteSizeSpan; - -import com.facebook.react.uimanager.PixelUtil; - -public class MarkdownFontSizeSpan extends AbsoluteSizeSpan implements MarkdownSpan { - public MarkdownFontSizeSpan(float fontSize) { - super((int) PixelUtil.toPixelFromDIP(fontSize), false); - } -} diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownH1Span.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownH1Span.java new file mode 100644 index 00000000..797e7c5b --- /dev/null +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownH1Span.java @@ -0,0 +1,47 @@ +package com.expensify.livemarkdown; + +import android.graphics.Paint; +import android.graphics.Typeface; +import android.text.TextPaint; +import android.text.style.LineHeightSpan; +import android.text.style.StyleSpan; + +import androidx.annotation.NonNull; + +import com.facebook.react.uimanager.PixelUtil; + +public class MarkdownH1Span extends StyleSpan implements LineHeightSpan, MarkdownSpan { + + private final float mFontSize; + private final Integer mLineHeight; + + public MarkdownH1Span(float fontSize, Integer lineHeight) { + super(Typeface.BOLD); + mFontSize = PixelUtil.toPixelFromDIP(fontSize); + mLineHeight = lineHeight; + } + + @Override + public void updateMeasureState(@NonNull TextPaint textPaint) { + super.updateMeasureState(textPaint); + apply(textPaint); + } + + @Override + public void updateDrawState(TextPaint tp) { + super.updateDrawState(tp); + apply(tp); + } + + private void apply(@NonNull TextPaint textPaint) { + textPaint.setTextSize(mFontSize); + } + + @Override + public void chooseHeight(CharSequence text, int start, int end, int spanstartv, int lineHeight, Paint.FontMetricsInt fm) { + if (mLineHeight != null) { + fm.top -= mLineHeight / 4; + fm.ascent -= mLineHeight / 4; + } + } +} diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownLineHeightSpan.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownLineHeightSpan.java deleted file mode 100644 index ca660f37..00000000 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownLineHeightSpan.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.expensify.livemarkdown; - -import android.graphics.Paint; -import android.text.style.LineHeightSpan; - -public class MarkdownLineHeightSpan implements MarkdownSpan, LineHeightSpan { - private final float mLineHeight; - - public MarkdownLineHeightSpan(float lineHeight) { - mLineHeight = lineHeight; - } - - @Override - public void chooseHeight(CharSequence text, int start, int end, int spanstartv, int lineHeight, Paint.FontMetricsInt fm) { - fm.top -= mLineHeight / 4; - fm.ascent -= mLineHeight / 4; - } -} diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownLinkSpan.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownLinkSpan.java new file mode 100644 index 00000000..0d0aa8ba --- /dev/null +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownLinkSpan.java @@ -0,0 +1,30 @@ +package com.expensify.livemarkdown; + +import android.text.TextPaint; +import android.text.style.MetricAffectingSpan; + +import androidx.annotation.NonNull; + +public class MarkdownLinkSpan extends MetricAffectingSpan implements MarkdownSpan { + + private final int mForegroundColor; + + public MarkdownLinkSpan(int foregroundColor) { + mForegroundColor = foregroundColor; + } + + @Override + public void updateMeasureState(@NonNull TextPaint textPaint) { + apply(textPaint); + } + + @Override + public void updateDrawState(TextPaint tp) { + apply(tp); + } + + private void apply(@NonNull TextPaint textPaint) { + textPaint.setUnderlineText(true); + textPaint.setColor(mForegroundColor); + } +} diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownMentionSpan.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownMentionSpan.java new file mode 100644 index 00000000..5ebd8ccf --- /dev/null +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownMentionSpan.java @@ -0,0 +1,32 @@ +package com.expensify.livemarkdown; + +import android.text.TextPaint; +import android.text.style.MetricAffectingSpan; + +import androidx.annotation.NonNull; + +public class MarkdownMentionSpan extends MetricAffectingSpan implements MarkdownSpan { + + private final int mBackgroundColor; + private final int mForegroundColor; + + public MarkdownMentionSpan(int backgroundColor, int foregroundColor) { + mBackgroundColor = backgroundColor; + mForegroundColor = foregroundColor; + } + + @Override + public void updateMeasureState(@NonNull TextPaint textPaint) { + apply(textPaint); + } + + @Override + public void updateDrawState(TextPaint tp) { + apply(tp); + } + + private void apply(@NonNull TextPaint textPaint) { + textPaint.bgColor = mBackgroundColor; + textPaint.setColor(mForegroundColor); + } +} diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownPreSpan.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownPreSpan.java new file mode 100644 index 00000000..a773e4b3 --- /dev/null +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownPreSpan.java @@ -0,0 +1,28 @@ +package com.expensify.livemarkdown; + +import android.content.res.AssetManager; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.text.Layout; +import android.text.style.LeadingMarginSpan; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; + +public class MarkdownPreSpan extends MarkdownBlockSpan implements LeadingMarginSpan { + + private final int mLeadingMargin; + + public MarkdownPreSpan(@NonNull AssetManager assetManager, @NonNull String fontFamily, float fontSize, @ColorInt int color, int leadingMargin) { + super(assetManager, fontFamily, fontSize, color); + mLeadingMargin = leadingMargin; + } + + @Override + public int getLeadingMargin(boolean first) { + return mLeadingMargin; + } + + @Override + public void drawLeadingMargin(Canvas c, Paint p, int x, int dir, int top, int baseline, int bottom, CharSequence text, int start, int end, boolean first, Layout layout) {} +} diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownStyle.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownStyle.java index cf691e62..147905a3 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownStyle.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownStyle.java @@ -25,42 +25,41 @@ public class MarkdownStyle { @ColorInt private final int mBlockquoteBorderColor; - private final float mBlockquoteBorderWidth; - private final float mBlockquoteMarginLeft; - private final float mBlockquotePaddingLeft; private final String mCodeFontFamily; - private final float mCodeFontSize; - @ColorInt private final int mCodeColor; - @ColorInt private final int mCodeBackgroundColor; + @ColorInt + private final int mCodeBorderColor; + private final float mCodeBorderWidth; + private final float mCodeBorderRadius; + private final float mCodePadding; private final String mPreFontFamily; - private final float mPreFontSize; - @ColorInt private final int mPreColor; - @ColorInt private final int mPreBackgroundColor; + @ColorInt + private final int mPreBorderColor; + private final float mPreBorderWidth; + private final float mPreBorderRadius; + private final float mPrePadding; @ColorInt private final int mMentionHereColor; - @ColorInt private final int mMentionHereBackgroundColor; @ColorInt private final int mMentionUserColor; - @ColorInt private final int mMentionUserBackgroundColor; @@ -83,10 +82,18 @@ public MarkdownStyle(@NonNull ReadableMap map, @NonNull Context context) { mCodeFontSize = parseFloat(map, "code", "fontSize"); mCodeColor = parseColor(map, "code", "color", context); mCodeBackgroundColor = parseColor(map, "code", "backgroundColor", context); + mCodeBorderColor = parseColor(map, "code", "borderColor", context); + mCodeBorderWidth = parseFloat(map, "code", "borderWidth"); + mCodeBorderRadius = parseFloat(map, "code", "borderRadius"); + mCodePadding = parseFloat(map, "code", "padding"); mPreFontFamily = parseString(map, "pre", "fontFamily"); mPreFontSize = parseFloat(map, "pre", "fontSize"); mPreColor = parseColor(map, "pre", "color", context); mPreBackgroundColor = parseColor(map, "pre", "backgroundColor", context); + mPreBorderColor = parseColor(map, "pre", "borderColor", context); + mPreBorderWidth = parseFloat(map, "pre", "borderWidth"); + mPreBorderRadius = parseFloat(map, "pre", "borderRadius"); + mPrePadding = parseFloat(map, "pre", "padding"); mMentionHereColor = parseColor(map, "mentionHere", "color", context); mMentionHereBackgroundColor = parseColor(map, "mentionHere", "backgroundColor", context); mMentionUserColor = parseColor(map, "mentionUser", "color", context); @@ -99,14 +106,11 @@ private static int parseColor(@NonNull ReadableMap map, @NonNull String key, @No ReadableMap style = map.getMap(key); Objects.requireNonNull(style); Dynamic value = style.getDynamic(prop); - switch (value.getType()) { - case Number: - return ColorPropConverter.getColor(value.asDouble(), context); - case Map: - return ColorPropConverter.getColor(value.asMap(), context); - default: - throw new JSApplicationCausedNativeException("ColorValue: the value must be a number or Object."); - } + return switch (value.getType()) { + case Number -> ColorPropConverter.getColor(value.asDouble(), context); + case Map -> ColorPropConverter.getColor(value.asMap(), context); + default -> throw new JSApplicationCausedNativeException("ColorValue: the value must be a number or Object."); + }; } private static float parseFloat(@NonNull ReadableMap map, @NonNull String key, @NonNull String prop) { @@ -175,6 +179,23 @@ public int getCodeBackgroundColor() { return mCodeBackgroundColor; } + @ColorInt + public int getCodeBorderColor() { + return mCodeBorderColor; + } + + public float getCodeBorderWidth() { + return mCodeBorderWidth; + } + + public float getCodeBorderRadius() { + return mCodeBorderRadius; + } + + public float getCodePadding() { + return mCodePadding; + } + public String getPreFontFamily() { return mPreFontFamily; } @@ -193,6 +214,23 @@ public int getPreBackgroundColor() { return mPreBackgroundColor; } + @ColorInt + public int getPreBorderColor() { + return mPreBorderColor; + } + + public float getPreBorderWidth() { + return mPreBorderWidth; + } + + public float getPreBorderRadius() { + return mPreBorderRadius; + } + + public float getPrePadding() { + return mPrePadding; + } + @ColorInt public int getMentionHereColor() { return mMentionHereColor; diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownTextInputDecoratorView.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownTextInputDecoratorView.java index 99512ce2..0abbc3f9 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownTextInputDecoratorView.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownTextInputDecoratorView.java @@ -1,9 +1,19 @@ package com.expensify.livemarkdown; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import android.content.Context; import android.content.res.AssetManager; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Region; +import android.text.Editable; +import android.text.TextPaint; import android.text.TextWatcher; import android.util.AttributeSet; @@ -11,7 +21,12 @@ import android.view.ViewGroup; import android.view.ViewParent; +import com.facebook.react.uimanager.PixelUtil; import com.facebook.react.views.textinput.ReactEditText; +import com.facebook.react.views.view.ReactViewBackgroundDrawable; +import com.facebook.react.views.view.ReactViewBackgroundManager; + +import java.lang.reflect.Field; public class MarkdownTextInputDecoratorView extends View { @@ -35,15 +50,21 @@ public MarkdownTextInputDecoratorView(Context context, @Nullable AttributeSet at private TextWatcher mTextWatcher; + private Rect mDrawableRect = new Rect(); + private final Rect mTmpRect = new Rect(); + private final TextPaint mSpanPaint = new TextPaint(); + private final Path clipPath = new Path(); + private int mBackgroundColor = Color.TRANSPARENT; + private float mBackgroundRadius = 0; + @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); View previousSibling = null; final ViewParent parent = this.getParent(); - if (parent instanceof ViewGroup) { - final ViewGroup viewGroup = (ViewGroup) parent; - for (int i = 1; i < viewGroup.getChildCount(); i++) { + if (parent instanceof ViewGroup viewGroup) { + for (int i = 1; i < viewGroup.getChildCount(); i++) { if (viewGroup.getChildAt(i) == this) { previousSibling = viewGroup.getChildAt(i - 1); break; @@ -60,6 +81,26 @@ protected void onAttachedToWindow() { mTextWatcher = new MarkdownTextWatcher(mMarkdownUtils); mReactEditText.addTextChangedListener(mTextWatcher); } + + try { + Field backgroundManagerField = ReactEditText.class.getDeclaredField("mReactBackgroundManager"); + backgroundManagerField.setAccessible(true); + ReactViewBackgroundManager backgroundManager = (ReactViewBackgroundManager)backgroundManagerField.get(mReactEditText); + + Field backgroundDrawableField = ReactViewBackgroundManager.class.getDeclaredField("mReactBackgroundDrawable"); + backgroundDrawableField.setAccessible(true); + ReactViewBackgroundDrawable backgroundDrawable = (ReactViewBackgroundDrawable)backgroundDrawableField.get(backgroundManager); + assert backgroundDrawable != null; + mBackgroundColor = backgroundDrawable.getColor(); + mBackgroundRadius = backgroundDrawable.getFullBorderRadius(); + } catch (NoSuchFieldException | IllegalAccessException ignored) {} + + mReactEditText.setBackgroundColor(Color.TRANSPARENT); + mReactEditText.bringToFront(); + + mReactEditText.getViewTreeObserver().addOnScrollChangedListener(this::invalidate); + + mMarkdownUtils.redrawCall = this::invalidate; } @Override @@ -85,4 +126,197 @@ protected void setMarkdownStyle(MarkdownStyle markdownStyle) { mReactEditText.setSelection(selectionStart, selectionEnd); } } + + @Override + protected void onDraw(@NonNull Canvas canvas) { + super.onDraw(canvas); + + Editable text = mReactEditText.getText(); + if (text == null) { + return; + } + + float left = mReactEditText.getLeft(); + float top = mReactEditText.getTop() - getTop(); + float right = mReactEditText.getRight(); + float bottom = top + mReactEditText.getHeight(); + float radius = mBackgroundRadius; + clipPath.addRoundRect(left, top, right, bottom, radius, radius, Path.Direction.CW); + canvas.clipPath(clipPath, Region.Op.INTERSECT); + canvas.drawColor(mBackgroundColor); + + canvas.save(); + canvas.translate(mReactEditText.getTotalPaddingLeft() + left, mReactEditText.getTotalPaddingTop() + top); + canvas.translate(-mReactEditText.getScrollX(), -mReactEditText.getScrollY()); + + int lineCount = mReactEditText.getLineCount(); + drawCodeBackground(canvas, text, lineCount); + drawPreBackground(canvas, text, lineCount); + canvas.restore(); + } + + private void drawCodeBackground(@NonNull Canvas canvas, Editable text, int lineCount) { + for (int line = 0; line < lineCount; line++) { + int lineStart = mReactEditText.getLayout().getLineStart(line); + int lineEnd = mReactEditText.getLayout().getLineEnd(line); + + MarkdownCodeSpan[] codeSpans = text.getSpans(lineStart, lineEnd, MarkdownCodeSpan.class); + for (MarkdownCodeSpan span : codeSpans) { + int start = lineStart; + int end = lineEnd; + int paddingLeft = 0; + boolean isLeftSideOpen = true; + boolean isRightSideOpen = true; + int spanStart = text.getSpanStart(span); + int spanEnd = text.getSpanEnd(span); + + if (spanStart > start && spanStart < end) { + Rect paddingRect = new Rect(); + mReactEditText.getPaint().getTextBounds(text.toString(), start, spanStart, paddingRect); + paddingLeft = paddingRect.width(); + start = spanStart; + isLeftSideOpen = false; + } + if (spanEnd > start && spanEnd < end) { + end = spanEnd; + isRightSideOpen = false; + } + + span.apply(mSpanPaint); + mSpanPaint.getTextBounds(text.toString(), start, end, mDrawableRect); + mDrawableRect.left += paddingLeft; + mDrawableRect.right += paddingLeft; + + float width = mSpanPaint.measureText(text.toString(), start, end); + mDrawableRect.right = mDrawableRect.left + (int) width; + + int editTextWidth = mReactEditText.getWidth(); + if (mDrawableRect.right > editTextWidth) { + mDrawableRect.right = editTextWidth; + } + + canvas.save(); + canvas.translate(mReactEditText.getLayout().getLineLeft(line), mReactEditText.getLayout().getLineBaseline(line)); + drawBackground( + canvas, + mDrawableRect, + isLeftSideOpen, + isRightSideOpen, + mMarkdownStyle.getCodeBackgroundColor(), + mMarkdownStyle.getCodeBorderColor(), + mMarkdownStyle.getCodeBorderWidth(), + mMarkdownStyle.getCodeBorderRadius(), + mMarkdownStyle.getCodePadding() + ); + canvas.restore(); + } + } + } + + private void drawPreBackground(@NonNull Canvas canvas, Editable text, int lineCount) { + MarkdownPreSpan[] preSpans = text.getSpans(0, text.length(), MarkdownPreSpan.class); + for (MarkdownPreSpan span : preSpans) { + int spanStart = text.getSpanStart(span); + int spanEnd = text.getSpanEnd(span); + + int firstLine = -1; + for (int line = 0; line < lineCount; line++) { + int lineStart = mReactEditText.getLayout().getLineStart(line); + int lineEnd = mReactEditText.getLayout().getLineEnd(line); + if (lineStart >= spanStart && lineStart < spanEnd) { + span.apply(mSpanPaint); + mSpanPaint.getTextBounds(text.toString(), lineStart, lineEnd, mTmpRect); + + int width = (int) mSpanPaint.measureText(text.toString(), lineStart, lineEnd); + mTmpRect.right = mTmpRect.left + width; + + int padding = span.getLeadingMargin(false); + mTmpRect.left += padding; + mTmpRect.right += padding; + + if (firstLine == -1) { + firstLine = line; + mDrawableRect = new Rect(mTmpRect); + } else { + if (mTmpRect.right > mDrawableRect.right) { + mDrawableRect.right = mTmpRect.right; + } + mDrawableRect.bottom = mReactEditText.getLayout().getLineBottom(line) - mReactEditText.getLayout().getLineBaseline(firstLine); + } + } + } + + canvas.save(); + if (firstLine >= 0) { + canvas.translate(mReactEditText.getLayout().getLineLeft(firstLine), mReactEditText.getLayout().getLineBaseline(firstLine)); + } + drawBackground( + canvas, + mDrawableRect, + false, + false, + mMarkdownStyle.getPreBackgroundColor(), + mMarkdownStyle.getPreBorderColor(), + mMarkdownStyle.getPreBorderWidth(), + mMarkdownStyle.getPreBorderRadius(), + mMarkdownStyle.getPrePadding() + ); + canvas.restore(); + } + } + + private void drawBackground(@NonNull Canvas canvas, Rect rect, boolean isLeftSideOpen, boolean isRightSideOpen, int backgroundColor, int borderColor, float borderWidth, float borderRadius, float padding) { + float[] corners = getCorners(PixelUtil.toPixelFromDIP(borderRadius), isLeftSideOpen, isRightSideOpen); + + Path path = new Path(); + applyPadding(rect, (int) PixelUtil.toPixelFromDIP(padding)); + path.addRoundRect(new RectF(rect), corners, Path.Direction.CW); + + Paint paint = new Paint(); + paint.setStyle(Paint.Style.FILL); + paint.setColor(backgroundColor); + canvas.drawPath(path, paint); + + float pxBorderWidth = PixelUtil.toPixelFromDIP(borderWidth); + paint.setStyle(Paint.Style.STROKE); + paint.setColor(borderColor); + paint.setStrokeWidth(pxBorderWidth); + canvas.drawPath(path, paint); + + float adjustedTop = rect.top + pxBorderWidth/2; + float adjustedBottom = rect.bottom - pxBorderWidth/2; + + path = new Path(); + if (isLeftSideOpen) { + path.moveTo(rect.left, adjustedTop); + path.lineTo(rect.left, adjustedBottom); + } + if (isRightSideOpen) { + path.moveTo(rect.right, adjustedTop); + path.lineTo(rect.right, adjustedBottom); + } + + paint.setColor(backgroundColor); + paint.setStrokeWidth(pxBorderWidth); + canvas.drawPath(path, paint); + } + + @NonNull + private static float[] getCorners(float radius, boolean isLeftSideOpen, boolean isRightSideOpen) { + float leftRadius = isLeftSideOpen ? 0 : radius; + float rightRadius = isRightSideOpen ? 0 : radius; + return new float[]{ + leftRadius, leftRadius, // Top left + rightRadius, rightRadius, // Top right + rightRadius, rightRadius, // Bottom right + leftRadius, leftRadius // Bottom left + }; + } + + private static void applyPadding(Rect rect, int padding) { + rect.left -= padding; + rect.top -= padding; + rect.right += padding; + rect.bottom += padding; + } } diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownUnderlineSpan.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownUnderlineSpan.java deleted file mode 100644 index 08fcbe4f..00000000 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownUnderlineSpan.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.expensify.livemarkdown; - -import android.text.style.UnderlineSpan; - -public class MarkdownUnderlineSpan extends UnderlineSpan implements MarkdownSpan {} diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java index cf7cf5ff..7a1f71af 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java @@ -1,16 +1,12 @@ package com.expensify.livemarkdown; -import static com.facebook.infer.annotation.ThreadConfined.UI; - import android.content.res.AssetManager; import android.text.SpannableStringBuilder; import android.text.Spanned; import androidx.annotation.NonNull; -import com.facebook.infer.annotation.Assertions; -import com.facebook.infer.annotation.ThreadConfined; -import com.facebook.react.bridge.UiThreadUtil; +import com.facebook.react.uimanager.PixelUtil; import com.facebook.react.views.text.CustomLineHeightSpan; import com.facebook.soloader.SoLoader; @@ -27,6 +23,8 @@ public class MarkdownUtils { SoLoader.loadLibrary("livemarkdown"); } + interface Thunk { void apply(); } + private static boolean IS_RUNTIME_INITIALIZED = false; public static synchronized void maybeInitializeRuntime(AssetManager assetManager) { @@ -58,7 +56,9 @@ public MarkdownUtils(@NonNull AssetManager assetManager) { mAssetManager = assetManager; } - private final @NonNull AssetManager mAssetManager; + final @NonNull AssetManager mAssetManager; + + Thunk redrawCall; private String mPrevInput; @@ -96,6 +96,7 @@ public void applyMarkdownFormatting(SpannableStringBuilder ssb) { int end = start + length; applyRange(ssb, type, start, end, depth); } + redrawCall.apply(); } catch (JSONException e) { // Do nothing } @@ -116,55 +117,57 @@ private void applyRange(SpannableStringBuilder ssb, String type, int start, int setSpan(ssb, new MarkdownEmojiSpan(mMarkdownStyle.getEmojiFontSize()), start, end); break; case "mention-here": - setSpan(ssb, new MarkdownForegroundColorSpan(mMarkdownStyle.getMentionHereColor()), start, end); - setSpan(ssb, new MarkdownBackgroundColorSpan(mMarkdownStyle.getMentionHereBackgroundColor()), start, end); + setSpan(ssb, new MarkdownMentionSpan(mMarkdownStyle.getMentionHereBackgroundColor(), mMarkdownStyle.getMentionHereColor()), start, end); break; case "mention-user": // TODO: change mention color when it mentions current user - setSpan(ssb, new MarkdownForegroundColorSpan(mMarkdownStyle.getMentionUserColor()), start, end); - setSpan(ssb, new MarkdownBackgroundColorSpan(mMarkdownStyle.getMentionUserBackgroundColor()), start, end); + setSpan(ssb, new MarkdownMentionSpan(mMarkdownStyle.getMentionUserBackgroundColor(), mMarkdownStyle.getMentionUserColor()), start, end); break; case "mention-report": - setSpan(ssb, new MarkdownForegroundColorSpan(mMarkdownStyle.getMentionReportColor()), start, end); - setSpan(ssb, new MarkdownBackgroundColorSpan(mMarkdownStyle.getMentionReportBackgroundColor()), start, end); + setSpan(ssb, new MarkdownMentionSpan(mMarkdownStyle.getMentionReportBackgroundColor(), mMarkdownStyle.getMentionReportColor()), start, end); break; case "syntax": setSpan(ssb, new MarkdownForegroundColorSpan(mMarkdownStyle.getSyntaxColor()), start, end); break; case "link": - setSpan(ssb, new MarkdownUnderlineSpan(), start, end); - setSpan(ssb, new MarkdownForegroundColorSpan(mMarkdownStyle.getLinkColor()), start, end); + setSpan(ssb, new MarkdownLinkSpan(mMarkdownStyle.getLinkColor()), start, end); break; case "code": - setSpan(ssb, new MarkdownFontFamilySpan(mMarkdownStyle.getCodeFontFamily(), mAssetManager), start, end); - setSpan(ssb, new MarkdownFontSizeSpan(mMarkdownStyle.getCodeFontSize()), start, end); - setSpan(ssb, new MarkdownForegroundColorSpan(mMarkdownStyle.getCodeColor()), start, end); - setSpan(ssb, new MarkdownBackgroundColorSpan(mMarkdownStyle.getCodeBackgroundColor()), start, end); + MarkdownCodeSpan codeSpan = new MarkdownCodeSpan( + mAssetManager, + mMarkdownStyle.getCodeFontFamily(), + mMarkdownStyle.getCodeFontSize(), + mMarkdownStyle.getCodeColor() + ); + setSpan(ssb, codeSpan, start, end); break; case "pre": - setSpan(ssb, new MarkdownFontFamilySpan(mMarkdownStyle.getPreFontFamily(), mAssetManager), start, end); - setSpan(ssb, new MarkdownFontSizeSpan(mMarkdownStyle.getPreFontSize()), start, end); - setSpan(ssb, new MarkdownForegroundColorSpan(mMarkdownStyle.getPreColor()), start, end); - setSpan(ssb, new MarkdownBackgroundColorSpan(mMarkdownStyle.getPreBackgroundColor()), start, end); + MarkdownPreSpan preSpan = new MarkdownPreSpan( + mAssetManager, + mMarkdownStyle.getPreFontFamily(), + mMarkdownStyle.getPreFontSize(), + mMarkdownStyle.getPreColor(), + (int) PixelUtil.toPixelFromDIP(mMarkdownStyle.getPrePadding()) + ); + setSpan(ssb, preSpan, start, end); break; case "h1": - setSpan(ssb, new MarkdownBoldSpan(), start, end); + Integer lineHeight = null; CustomLineHeightSpan[] spans = ssb.getSpans(0, ssb.length(), CustomLineHeightSpan.class); if (spans.length >= 1) { - int lineHeight = spans[0].getLineHeight(); - setSpan(ssb, new MarkdownLineHeightSpan(lineHeight * 1.5f), start, end); + lineHeight = spans[0].getLineHeight(); } - // NOTE: size span must be set after line height span to avoid height jumps - setSpan(ssb, new MarkdownFontSizeSpan(mMarkdownStyle.getH1FontSize()), start, end); + setSpan(ssb, new MarkdownH1Span(mMarkdownStyle.getH1FontSize(), lineHeight), start, end); break; case "blockquote": - MarkdownBlockquoteSpan span = new MarkdownBlockquoteSpan( + MarkdownBlockquoteSpan blockquoteSpan = new MarkdownBlockquoteSpan( mMarkdownStyle.getBlockquoteBorderColor(), mMarkdownStyle.getBlockquoteBorderWidth(), mMarkdownStyle.getBlockquoteMarginLeft(), mMarkdownStyle.getBlockquotePaddingLeft(), - depth); - setSpan(ssb, span, start, end); + depth + ); + setSpan(ssb, blockquoteSpan, start, end); break; default: throw new IllegalStateException("Unsupported type: " + type);