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