Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 11 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,16 +188,17 @@ Currently, `react-native-live-markdown` supports only [ExpensiMark](https://gith

`react-native-live-markdown` supports only latest React Native minor releases with the New Architecture enabled.

| @expensify/react-native-live-markdown | 0.73 | 0.74 | 0.75 | 0.76 | 0.77 | 0.78 | 0.79 |
| :-----------------------------------: | :--: | :--: | :--: | :--: | :--: | :--: | :--: |
| 0.1.260+ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
| 0.1.256 – 0.1.259 | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ |
| 0.1.248 – 0.1.255 | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
| 0.1.235 – 0.1.247 | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ |
| 0.1.141 – 0.1.234 | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| 0.1.129 – 0.1.140 | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
| 0.1.122 – 0.1.128 | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| 0.1.15 – 0.1.121 | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| @expensify/react-native-live-markdown | 0.73 | 0.74 | 0.75 | 0.76 | 0.77 | 0.78 | 0.79 | 0.80 |
| :-----------------------------------: | :--: | :--: | :--: | :--: | :--: | :--: | :--: | :--: |
| 0.1.297+ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
| 0.1.260 – 0.1.296 | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ |
| 0.1.256 – 0.1.259 | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| 0.1.248 – 0.1.255 | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| 0.1.235 – 0.1.247 | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| 0.1.141 – 0.1.234 | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| 0.1.129 – 0.1.140 | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| 0.1.122 – 0.1.128 | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| 0.1.15 – 0.1.121 | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |

## License

Expand Down
8 changes: 4 additions & 4 deletions WebExample/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,16 @@
"babel-plugin-module-resolver": "^5.0.0",
"expo": "53.0.0-preview.5",
"expo-status-bar": "2.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-native": "0.79.2",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.80.1",
"react-native-web": "~0.20.0"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"@playwright/test": "^1.43.1",
"@types/node": "^20.12.7",
"@types/react": "~19.0.0",
"@types/react": "^19.1.0",
"typescript": "~5.3.3"
},
"overrides": {
Expand Down
4 changes: 2 additions & 2 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@ android {
]

// TextLayoutManager
if (REACT_NATIVE_MINOR_VERSION <= 76 ) {
java.srcDirs += "src/reactNativeVersionPatch/CustomMountingManager/76"
if (REACT_NATIVE_MINOR_VERSION <= 79 ) {
java.srcDirs += "src/reactNativeVersionPatch/CustomMountingManager/79"
} else {
java.srcDirs += "src/reactNativeVersionPatch/CustomMountingManager/latest"
}
Expand Down
1 change: 1 addition & 0 deletions android/src/main/new_arch/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ target_compile_options(
-frtti
-Wall
-std=c++20
-DREACT_NATIVE_MINOR_VERSION=${ReactAndroid_VERSION_MINOR}
)

target_include_directories(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
#include <fbjni/fbjni.h>
#include <react/fabric/JFabricUIManager.h>
#include <react/jni/ReadableNativeMap.h>
#if REACT_NATIVE_MINOR_VERSION < 80
#include <react/jni/SafeReleaseJniRef.h>
#endif // REACT_NATIVE_MINOR_VERSION < 80
#include <react/renderer/components/view/conversions.h>
#include <react/renderer/core/ComponentDescriptor.h>
#include <yoga/Yoga.h>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import static com.facebook.react.views.text.TextAttributeProps.UNSET;

import android.content.Context;
import android.content.res.AssetManager;
import android.text.BoringLayout;
import android.text.Layout;
import android.text.Spannable;
Expand All @@ -12,6 +11,7 @@

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.util.Preconditions;

import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.ReadableMap;
Expand All @@ -21,6 +21,7 @@
import com.facebook.react.uimanager.ViewManagerRegistry;
import com.facebook.react.views.text.TextAttributeProps;
import com.facebook.react.views.text.TextLayoutManager;
import com.facebook.react.views.text.internal.span.ReactTextPaintHolderSpan;
import com.facebook.react.views.text.internal.span.TextInlineViewPlaceholderSpan;
import com.facebook.yoga.YogaMeasureMode;
import com.facebook.yoga.YogaMeasureOutput;
Expand All @@ -30,7 +31,13 @@

public class CustomMountingManager extends MountingManager {
private static final boolean DEFAULT_INCLUDE_FONT_PADDING = true;
private static final TextPaint sTextPaintInstance = new TextPaint(TextPaint.ANTI_ALIAS_FLAG);
private static final ThreadLocal<TextPaint> sTextPaintInstance =
new ThreadLocal<TextPaint>() {
@Override
protected TextPaint initialValue() {
return new TextPaint(TextPaint.ANTI_ALIAS_FLAG);
}
};

private MarkdownUtils markdownUtils;

Expand Down Expand Up @@ -77,24 +84,48 @@ public long measureMapBuffer(
TextAttributeProps.getHyphenationFrequency(
paragraphAttributes.getString(TextLayoutManager.PA_KEY_HYPHENATION_FREQUENCY));

// StaticLayout returns wrong metrics for the last line if it's empty, add something to the
// last line so it's measured correctly
if (text.toString().endsWith("\n")) {
SpannableStringBuilder sb = new SpannableStringBuilder(text);
sb.append("I");
try {
Class<TextLayoutManager> textLayoutManagerClass = TextLayoutManager.class;

text = sb;
}
Method getTextAlignmentAttrMethod = textLayoutManagerClass.getDeclaredMethod("getTextAlignmentAttr", MapBuffer.class);
getTextAlignmentAttrMethod.setAccessible(true);

Layout.Alignment alignment = TextLayoutManager.getTextAlignment(attributedString, text);
String textAlignmentAttr = (String)getTextAlignmentAttrMethod.invoke(null, attributedString);

markdownUtils.applyMarkdownFormatting((SpannableStringBuilder)text);
Method getTextAlignmentMethod = textLayoutManagerClass.getDeclaredMethod("getTextAlignment", MapBuffer.class, Spannable.class, String.class);
getTextAlignmentMethod.setAccessible(true);

BoringLayout.Metrics boring = BoringLayout.isBoring(text, sTextPaintInstance);
Layout.Alignment alignment = (Layout.Alignment)getTextAlignmentMethod.invoke(
null,
attributedString,
text,
textAlignmentAttr
);

Class<TextLayoutManager> mapBufferClass = TextLayoutManager.class;
try {
Method createLayoutMethod = mapBufferClass.getDeclaredMethod("createLayout", Spannable.class, BoringLayout.Metrics.class, float.class, YogaMeasureMode.class, boolean.class, int.class, int.class, Layout.Alignment.class);
Method getTextJustificationModeMethod = textLayoutManagerClass.getDeclaredMethod("getTextJustificationMode", String.class);
getTextJustificationModeMethod.setAccessible(true);

Integer justificationMode = (Integer) getTextJustificationModeMethod.invoke(null, textAlignmentAttr);


markdownUtils.applyMarkdownFormatting((SpannableStringBuilder)text);

TextPaint paint;
if (attributedString.contains(TextLayoutManager.AS_KEY_CACHE_ID)) {
paint = text.getSpans(0, 0, ReactTextPaintHolderSpan.class)[0].getTextPaint();
} else {
TextAttributeProps baseTextAttributes =
TextAttributeProps.fromMapBuffer(attributedString.getMapBuffer(TextLayoutManager.AS_KEY_BASE_ATTRIBUTES));
paint = Preconditions.checkNotNull(sTextPaintInstance.get());

Method updateTextPaintMethod = textLayoutManagerClass.getDeclaredMethod("updateTextPaint", TextPaint.class, TextAttributeProps.class, Context.class);
updateTextPaintMethod.setAccessible(true);
updateTextPaintMethod.invoke(null, paint, baseTextAttributes, context);
}

BoringLayout.Metrics boring = BoringLayout.isBoring(text, paint);

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);
createLayoutMethod.setAccessible(true);

Layout layout = (Layout)createLayoutMethod.invoke(
Expand All @@ -106,7 +137,9 @@ public long measureMapBuffer(
includeFontPadding,
textBreakStrategy,
hyphenationFrequency,
alignment);
alignment,
justificationMode,
paint);

int maximumNumberOfLines =
paragraphAttributes.contains(TextLayoutManager.PA_KEY_MAX_NUMBER_OF_LINES)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.expensify.livemarkdown;

import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.fabric.FabricUIManager;
import com.facebook.react.fabric.mounting.MountingManager;
import com.facebook.react.uimanager.ViewManagerRegistry;
import com.facebook.react.uimanager.events.BatchEventDispatchedListener;

import java.lang.reflect.Field;

public class CustomFabricUIManager {

public static FabricUIManager create(FabricUIManager source, ReadableMap markdownProps, int parserId) {
Class<? extends FabricUIManager> uiManagerClass = source.getClass();

try {
Field mountingManagerField = uiManagerClass.getDeclaredField("mMountingManager");
mountingManagerField.setAccessible(true);

MountingManager sourceMountingManager = readPrivateField(source, "mMountingManager");
ReactApplicationContext reactContext = readPrivateField(source, "mReactApplicationContext");
ViewManagerRegistry viewManagerRegistry = readPrivateField(source, "mViewManagerRegistry");
BatchEventDispatchedListener batchEventDispatchedListener = readPrivateField(source, "mBatchEventDispatchedListener");
MountingManager.MountItemExecutor mountItemExecutor = readPrivateField(source, "mMountItemExecutor");

FabricUIManager customFabricUIManager = new FabricUIManager(reactContext, viewManagerRegistry, batchEventDispatchedListener);

mountingManagerField.set(customFabricUIManager, new CustomMountingManager(sourceMountingManager, viewManagerRegistry, mountItemExecutor, reactContext, markdownProps, parserId));

return customFabricUIManager;
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new RuntimeException("[LiveMarkdown] Cannot read data from FabricUIManager");
}
}

@SuppressWarnings("unchecked")
private static <T> T readPrivateField(Object obj, String name) throws NoSuchFieldException, IllegalAccessException {
Class<?> clazz = obj.getClass();

Field field = clazz.getDeclaredField(name);
field.setAccessible(true);
T value = (T) field.get(obj);

return value;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,18 @@
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.TextPaint;
import android.text.TextUtils;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.util.Preconditions;

import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.common.ReactConstants;
import com.facebook.react.common.mapbuffer.MapBuffer;
import com.facebook.react.fabric.mounting.MountingManager;
import com.facebook.react.fabric.mounting.SurfaceMountingManager;
import com.facebook.react.uimanager.PixelUtil;
import com.facebook.react.uimanager.ViewManagerRegistry;
import com.facebook.react.views.text.TextAttributeProps;
Expand All @@ -39,20 +42,28 @@ protected TextPaint initialValue() {
}
};

private MountingManager sourceMountingManager;
private MarkdownUtils markdownUtils;

public CustomMountingManager(
@NonNull MountingManager sourceMountingManager,
@NonNull ViewManagerRegistry viewManagerRegistry,
@NonNull MountItemExecutor mountItemExecutor,
@NonNull Context context,
@NonNull ReadableMap decoratorProps,
int parserId) {
super(viewManagerRegistry, mountItemExecutor);
this.sourceMountingManager = sourceMountingManager;
this.markdownUtils = new MarkdownUtils((ReactContext) context);
this.markdownUtils.setMarkdownStyle(new MarkdownStyle(decoratorProps, context));
this.markdownUtils.setParserId(parserId);
}

@Override
public SurfaceMountingManager getSurfaceManagerEnforced(int surfaceId, String context) {
return sourceMountingManager.getSurfaceManagerEnforced(surfaceId, context);
}

@Override
public long measureMapBuffer(
@NonNull ReactContext context,
Expand Down Expand Up @@ -125,9 +136,20 @@ public long measureMapBuffer(

BoringLayout.Metrics boring = BoringLayout.isBoring(text, paint);

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);
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, TextUtils.TruncateAt.class, int.class, TextPaint.class);
createLayoutMethod.setAccessible(true);

int maximumNumberOfLines =
paragraphAttributes.contains(TextLayoutManager.PA_KEY_MAX_NUMBER_OF_LINES)
? paragraphAttributes.getInt(TextLayoutManager.PA_KEY_MAX_NUMBER_OF_LINES)
: ReactConstants.UNSET;
@Nullable
TextUtils.TruncateAt ellipsizeMode =
paragraphAttributes.contains(TextLayoutManager.PA_KEY_ELLIPSIZE_MODE)
? TextAttributeProps.getEllipsizeMode(
paragraphAttributes.getString(TextLayoutManager.PA_KEY_ELLIPSIZE_MODE))
: null;

Layout layout = (Layout)createLayoutMethod.invoke(
null,
text,
Expand All @@ -139,13 +161,10 @@ public long measureMapBuffer(
hyphenationFrequency,
alignment,
justificationMode,
ellipsizeMode,
maximumNumberOfLines,
paint);

int maximumNumberOfLines =
paragraphAttributes.contains(TextLayoutManager.PA_KEY_MAX_NUMBER_OF_LINES)
? paragraphAttributes.getInt(TextLayoutManager.PA_KEY_MAX_NUMBER_OF_LINES)
: UNSET;

int calculatedLineCount =
maximumNumberOfLines == UNSET || maximumNumberOfLines == 0
? layout.getLineCount()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,11 @@ import android.app.Application
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactHost
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.react.soloader.OpenSourceMergedSoMapping

import com.facebook.soloader.SoLoader

class MainApplication : Application(), ReactApplication {

Expand All @@ -36,10 +33,6 @@ class MainApplication : Application(), ReactApplication {

override fun onCreate() {
super.onCreate()
SoLoader.init(this, OpenSourceMergedSoMapping)
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// If you opted-in for the New Architecture, we load the native entry point for this app.
load()
}
loadReactNative(this)
}
}
2 changes: 1 addition & 1 deletion example/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ buildscript {
compileSdkVersion = 35
targetSdkVersion = 35
ndkVersion = "27.1.12297006"
kotlinVersion = "2.0.21"
kotlinVersion = "2.1.20"
}
repositories {
google()
Expand Down
Binary file modified example/android/gradle/wrapper/gradle-wrapper.jar
Binary file not shown.
2 changes: 1 addition & 1 deletion example/android/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
Expand Down
5 changes: 2 additions & 3 deletions example/android/gradlew
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ done
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)

APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit

# Use the maximum available, or set MAX_FD != -1 to use that value.
Expand Down Expand Up @@ -115,7 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac

CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
CLASSPATH="\\\"\\\""


# Determine the Java command to use to start the JVM.
Expand Down Expand Up @@ -214,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"

# Stop when "xargs" is not available.
Expand Down
Loading
Loading