|
| 1 | +package com.expensify.livemarkdown; |
| 2 | + |
| 3 | +import static com.facebook.react.views.text.TextAttributeProps.UNSET; |
| 4 | + |
| 5 | +import android.content.Context; |
| 6 | +import android.content.res.AssetManager; |
| 7 | +import android.text.BoringLayout; |
| 8 | +import android.text.Layout; |
| 9 | +import android.text.Spannable; |
| 10 | +import android.text.SpannableStringBuilder; |
| 11 | +import android.text.TextPaint; |
| 12 | + |
| 13 | +import androidx.annotation.NonNull; |
| 14 | +import androidx.annotation.Nullable; |
| 15 | + |
| 16 | +import com.facebook.common.logging.FLog; |
| 17 | +import com.facebook.react.bridge.ReactContext; |
| 18 | +import com.facebook.react.bridge.ReadableMap; |
| 19 | +import com.facebook.react.common.mapbuffer.MapBuffer; |
| 20 | +import com.facebook.react.fabric.mounting.MountingManager; |
| 21 | +import com.facebook.react.uimanager.PixelUtil; |
| 22 | +import com.facebook.react.uimanager.ViewManagerRegistry; |
| 23 | +import com.facebook.react.views.text.TextAttributeProps; |
| 24 | +import com.facebook.react.views.text.TextInlineViewPlaceholderSpan; |
| 25 | +import com.facebook.react.views.text.TextLayoutManagerMapBuffer; |
| 26 | +import com.facebook.yoga.YogaMeasureMode; |
| 27 | +import com.facebook.yoga.YogaMeasureOutput; |
| 28 | + |
| 29 | +import java.lang.reflect.InvocationTargetException; |
| 30 | +import java.lang.reflect.Method; |
| 31 | + |
| 32 | +public class CustomMountingManager extends MountingManager { |
| 33 | + private static final boolean DEFAULT_INCLUDE_FONT_PADDING = true; |
| 34 | + private static final TextPaint sTextPaintInstance = new TextPaint(TextPaint.ANTI_ALIAS_FLAG); |
| 35 | + |
| 36 | + private MarkdownUtils markdownUtils; |
| 37 | + |
| 38 | + public CustomMountingManager( |
| 39 | + @NonNull ViewManagerRegistry viewManagerRegistry, |
| 40 | + @NonNull MountItemExecutor mountItemExecutor, |
| 41 | + @NonNull Context context, |
| 42 | + @NonNull ReadableMap decoratorProps) { |
| 43 | + super(viewManagerRegistry, mountItemExecutor); |
| 44 | + |
| 45 | + AssetManager assetManager = context.getAssets(); |
| 46 | + MarkdownUtils.maybeInitializeRuntime(assetManager); |
| 47 | + |
| 48 | + this.markdownUtils = new MarkdownUtils(assetManager); |
| 49 | + this.markdownUtils.setMarkdownStyle(new MarkdownStyle(decoratorProps, context)); |
| 50 | + } |
| 51 | + |
| 52 | + @Override |
| 53 | + public long measureMapBuffer( |
| 54 | + @NonNull ReactContext context, |
| 55 | + @NonNull String componentName, |
| 56 | + @NonNull MapBuffer attributedString, |
| 57 | + @NonNull MapBuffer paragraphAttributes, |
| 58 | + @Nullable MapBuffer state, |
| 59 | + float width, |
| 60 | + @NonNull YogaMeasureMode widthYogaMeasureMode, |
| 61 | + float height, |
| 62 | + @NonNull YogaMeasureMode heightYogaMeasureMode, |
| 63 | + @Nullable float[] attachmentsPositions) { |
| 64 | + |
| 65 | + Spannable text = |
| 66 | + TextLayoutManagerMapBuffer.getOrCreateSpannableForText(context, attributedString, null); |
| 67 | + |
| 68 | + if (text == null) { |
| 69 | + return 0; |
| 70 | + } |
| 71 | + |
| 72 | + int textBreakStrategy = |
| 73 | + TextAttributeProps.getTextBreakStrategy( |
| 74 | + paragraphAttributes.getString(TextLayoutManagerMapBuffer.PA_KEY_TEXT_BREAK_STRATEGY)); |
| 75 | + boolean includeFontPadding = |
| 76 | + paragraphAttributes.contains(TextLayoutManagerMapBuffer.PA_KEY_INCLUDE_FONT_PADDING) |
| 77 | + ? paragraphAttributes.getBoolean(TextLayoutManagerMapBuffer.PA_KEY_INCLUDE_FONT_PADDING) |
| 78 | + : DEFAULT_INCLUDE_FONT_PADDING; |
| 79 | + int hyphenationFrequency = |
| 80 | + TextAttributeProps.getHyphenationFrequency( |
| 81 | + paragraphAttributes.getString(TextLayoutManagerMapBuffer.PA_KEY_HYPHENATION_FREQUENCY)); |
| 82 | + |
| 83 | + // StaticLayout returns wrong metrics for the last line if it's empty, add something to the |
| 84 | + // last line so it's measured correctly |
| 85 | + if (text.toString().endsWith("\n")) { |
| 86 | + SpannableStringBuilder sb = new SpannableStringBuilder(text); |
| 87 | + sb.append("I"); |
| 88 | + |
| 89 | + text = sb; |
| 90 | + } |
| 91 | + |
| 92 | + markdownUtils.applyMarkdownFormatting((SpannableStringBuilder)text); |
| 93 | + |
| 94 | + BoringLayout.Metrics boring = BoringLayout.isBoring(text, sTextPaintInstance); |
| 95 | + |
| 96 | + Class<TextLayoutManagerMapBuffer> mapBufferClass = TextLayoutManagerMapBuffer.class; |
| 97 | + try { |
| 98 | + Method createLayoutMethod = mapBufferClass.getDeclaredMethod("createLayout", Spannable.class, BoringLayout.Metrics.class, float.class, YogaMeasureMode.class, boolean.class, int.class, int.class); |
| 99 | + createLayoutMethod.setAccessible(true); |
| 100 | + |
| 101 | + Layout layout = (Layout)createLayoutMethod.invoke( |
| 102 | + null, |
| 103 | + text, |
| 104 | + boring, |
| 105 | + width, |
| 106 | + widthYogaMeasureMode, |
| 107 | + includeFontPadding, |
| 108 | + textBreakStrategy, |
| 109 | + hyphenationFrequency); |
| 110 | + |
| 111 | + int maximumNumberOfLines = |
| 112 | + paragraphAttributes.contains(TextLayoutManagerMapBuffer.PA_KEY_MAX_NUMBER_OF_LINES) |
| 113 | + ? paragraphAttributes.getInt(TextLayoutManagerMapBuffer.PA_KEY_MAX_NUMBER_OF_LINES) |
| 114 | + : UNSET; |
| 115 | + |
| 116 | + int calculatedLineCount = |
| 117 | + maximumNumberOfLines == UNSET || maximumNumberOfLines == 0 |
| 118 | + ? layout.getLineCount() |
| 119 | + : Math.min(maximumNumberOfLines, layout.getLineCount()); |
| 120 | + |
| 121 | + // Instead of using `layout.getWidth()` (which may yield a significantly larger width for |
| 122 | + // text that is wrapping), compute width using the longest line. |
| 123 | + float calculatedWidth = 0; |
| 124 | + if (widthYogaMeasureMode == YogaMeasureMode.EXACTLY) { |
| 125 | + calculatedWidth = width; |
| 126 | + } else { |
| 127 | + for (int lineIndex = 0; lineIndex < calculatedLineCount; lineIndex++) { |
| 128 | + boolean endsWithNewLine = |
| 129 | + text.length() > 0 && text.charAt(layout.getLineEnd(lineIndex) - 1) == '\n'; |
| 130 | + float lineWidth = |
| 131 | + endsWithNewLine ? layout.getLineMax(lineIndex) : layout.getLineWidth(lineIndex); |
| 132 | + if (lineWidth > calculatedWidth) { |
| 133 | + calculatedWidth = lineWidth; |
| 134 | + } |
| 135 | + } |
| 136 | + if (widthYogaMeasureMode == YogaMeasureMode.AT_MOST && calculatedWidth > width) { |
| 137 | + calculatedWidth = width; |
| 138 | + } |
| 139 | + } |
| 140 | + |
| 141 | + // Android 11+ introduces changes in text width calculation which leads to cases |
| 142 | + // where the container is measured smaller than text. Math.ceil prevents it |
| 143 | + // See T136756103 for investigation |
| 144 | + if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.Q) { |
| 145 | + calculatedWidth = (float) Math.ceil(calculatedWidth); |
| 146 | + } |
| 147 | + |
| 148 | + float calculatedHeight = height; |
| 149 | + if (heightYogaMeasureMode != YogaMeasureMode.EXACTLY) { |
| 150 | + calculatedHeight = layout.getLineBottom(calculatedLineCount - 1); |
| 151 | + if (heightYogaMeasureMode == YogaMeasureMode.AT_MOST && calculatedHeight > height) { |
| 152 | + calculatedHeight = height; |
| 153 | + } |
| 154 | + } |
| 155 | + |
| 156 | + // Calculate the positions of the attachments (views) that will be rendered inside the |
| 157 | + // Spanned Text. The following logic is only executed when a text contains views inside. |
| 158 | + // This follows a similar logic than used in pre-fabric (see ReactTextView.onLayout method). |
| 159 | + int attachmentIndex = 0; |
| 160 | + int lastAttachmentFoundInSpan; |
| 161 | + for (int i = 0; i < text.length(); i = lastAttachmentFoundInSpan) { |
| 162 | + lastAttachmentFoundInSpan = |
| 163 | + text.nextSpanTransition(i, text.length(), TextInlineViewPlaceholderSpan.class); |
| 164 | + TextInlineViewPlaceholderSpan[] placeholders = |
| 165 | + text.getSpans(i, lastAttachmentFoundInSpan, TextInlineViewPlaceholderSpan.class); |
| 166 | + for (TextInlineViewPlaceholderSpan placeholder : placeholders) { |
| 167 | + int start = text.getSpanStart(placeholder); |
| 168 | + int line = layout.getLineForOffset(start); |
| 169 | + boolean isLineTruncated = layout.getEllipsisCount(line) > 0; |
| 170 | + // This truncation check works well on recent versions of Android (tested on 5.1.1 and |
| 171 | + // 6.0.1) but not on Android 4.4.4. The reason is that getEllipsisCount is buggy on |
| 172 | + // Android 4.4.4. Specifically, it incorrectly returns 0 if an inline view is the |
| 173 | + // first thing to be truncated. |
| 174 | + if (!(isLineTruncated && start >= layout.getLineStart(line) + layout.getEllipsisStart(line)) |
| 175 | + || start >= layout.getLineEnd(line)) { |
| 176 | + float placeholderWidth = placeholder.getWidth(); |
| 177 | + float placeholderHeight = placeholder.getHeight(); |
| 178 | + // Calculate if the direction of the placeholder character is Right-To-Left. |
| 179 | + boolean isRtlChar = layout.isRtlCharAt(start); |
| 180 | + boolean isRtlParagraph = layout.getParagraphDirection(line) == Layout.DIR_RIGHT_TO_LEFT; |
| 181 | + float placeholderLeftPosition; |
| 182 | + // There's a bug on Samsung devices where calling getPrimaryHorizontal on |
| 183 | + // the last offset in the layout will result in an endless loop. Work around |
| 184 | + // this bug by avoiding getPrimaryHorizontal in that case. |
| 185 | + if (start == text.length() - 1) { |
| 186 | + boolean endsWithNewLine = |
| 187 | + text.length() > 0 && text.charAt(layout.getLineEnd(line) - 1) == '\n'; |
| 188 | + float lineWidth = endsWithNewLine ? layout.getLineMax(line) : layout.getLineWidth(line); |
| 189 | + placeholderLeftPosition = |
| 190 | + isRtlParagraph |
| 191 | + // Equivalent to `layout.getLineLeft(line)` but `getLineLeft` returns |
| 192 | + // incorrect |
| 193 | + // values when the paragraph is RTL and `setSingleLine(true)`. |
| 194 | + ? calculatedWidth - lineWidth |
| 195 | + : layout.getLineRight(line) - placeholderWidth; |
| 196 | + } else { |
| 197 | + // The direction of the paragraph may not be exactly the direction the string is |
| 198 | + // heading |
| 199 | + // in at the |
| 200 | + // position of the placeholder. So, if the direction of the character is the same |
| 201 | + // as the |
| 202 | + // paragraph |
| 203 | + // use primary, secondary otherwise. |
| 204 | + boolean characterAndParagraphDirectionMatch = isRtlParagraph == isRtlChar; |
| 205 | + placeholderLeftPosition = |
| 206 | + characterAndParagraphDirectionMatch |
| 207 | + ? layout.getPrimaryHorizontal(start) |
| 208 | + : layout.getSecondaryHorizontal(start); |
| 209 | + if (isRtlParagraph) { |
| 210 | + // Adjust `placeholderLeftPosition` to work around an Android bug. |
| 211 | + // The bug is when the paragraph is RTL and `setSingleLine(true)`, some layout |
| 212 | + // methods such as `getPrimaryHorizontal`, `getSecondaryHorizontal`, and |
| 213 | + // `getLineRight` return incorrect values. Their return values seem to be off |
| 214 | + // by the same number of pixels so subtracting these values cancels out the |
| 215 | + // error. |
| 216 | + // |
| 217 | + // The result is equivalent to bugless versions of |
| 218 | + // `getPrimaryHorizontal`/`getSecondaryHorizontal`. |
| 219 | + placeholderLeftPosition = |
| 220 | + calculatedWidth - (layout.getLineRight(line) - placeholderLeftPosition); |
| 221 | + } |
| 222 | + if (isRtlChar) { |
| 223 | + placeholderLeftPosition -= placeholderWidth; |
| 224 | + } |
| 225 | + } |
| 226 | + // Vertically align the inline view to the baseline of the line of text. |
| 227 | + float placeholderTopPosition = layout.getLineBaseline(line) - placeholderHeight; |
| 228 | + int attachmentPosition = attachmentIndex * 2; |
| 229 | + |
| 230 | + // The attachment array returns the positions of each of the attachments as |
| 231 | + attachmentsPositions[attachmentPosition] = |
| 232 | + PixelUtil.toDIPFromPixel(placeholderTopPosition); |
| 233 | + attachmentsPositions[attachmentPosition + 1] = |
| 234 | + PixelUtil.toDIPFromPixel(placeholderLeftPosition); |
| 235 | + attachmentIndex++; |
| 236 | + } |
| 237 | + } |
| 238 | + } |
| 239 | + |
| 240 | + float widthInSP = PixelUtil.toDIPFromPixel(calculatedWidth); |
| 241 | + float heightInSP = PixelUtil.toDIPFromPixel(calculatedHeight); |
| 242 | + |
| 243 | + return YogaMeasureOutput.make(widthInSP, heightInSP); |
| 244 | + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { |
| 245 | + throw new RuntimeException(e); |
| 246 | + } |
| 247 | + } |
| 248 | +} |
0 commit comments