diff --git a/RNLiveMarkdown.podspec b/RNLiveMarkdown.podspec index 7dd5dd7a..9fe4e900 100644 --- a/RNLiveMarkdown.podspec +++ b/RNLiveMarkdown.podspec @@ -19,4 +19,17 @@ Pod::Spec.new do |s| s.resources = "parser/react-native-live-markdown-parser.js" install_modules_dependencies(s) + + if ENV['USE_FRAMEWORKS'] && ENV['RCT_NEW_ARCH_ENABLED'] + add_dependency(s, "React-Fabric", :additional_framework_paths => [ + "react/renderer/textlayoutmanager/platform/ios", + "react/renderer/components/textinput/iostextinput", + ]) + end + + s.subspec "common" do |ss| + ss.source_files = "cpp/**/*.{cpp,h}" + ss.header_dir = "RNLiveMarkdown" + ss.pod_target_xcconfig = { "HEADER_SEARCH_PATHS" => "\"$(PODS_TARGET_SRCROOT)/cpp\"" } + end end diff --git a/android/build.gradle b/android/build.gradle index 86dcfd2e..c1fe5935 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -53,6 +53,9 @@ android { minSdkVersion getExtOrIntegerDefault("minSdkVersion") targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString() + + consumerProguardFiles "proguard-rules.pro" + externalNativeBuild { cmake { arguments "-DANDROID_STL=c++_shared", "-DANDROID_TOOLCHAIN=clang" @@ -112,6 +115,15 @@ android { "**/libreactnativejni.so", ] } + + packagingOptions { + // For some reason gradle only complains about the duplicated version of librrc_root and libreact_render libraries + // while there are more libraries copied in intermediates folder of the lib build directory, we exclude + // only the ones that make the build fail (ideally we should only include libreanimated but we + // are only allowed to specify exlude patterns) + exclude "**/libreact_render*.so" + exclude "**/librrc_root.so" + } } repositories { diff --git a/android/proguard-rules.pro b/android/proguard-rules.pro new file mode 100644 index 00000000..5b27a393 --- /dev/null +++ b/android/proguard-rules.pro @@ -0,0 +1 @@ +-keep class com.expensify.livemarkdown.** { *; } diff --git a/android/src/main/java/com/expensify/livemarkdown/CustomFabricUIManager.java b/android/src/main/java/com/expensify/livemarkdown/CustomFabricUIManager.java new file mode 100644 index 00000000..c55b6c5c --- /dev/null +++ b/android/src/main/java/com/expensify/livemarkdown/CustomFabricUIManager.java @@ -0,0 +1,47 @@ +package com.expensify.livemarkdown; + +import androidx.annotation.NonNull; + +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) { + Class uiManagerClass = source.getClass(); + + try { + Field mountingManagerField = uiManagerClass.getDeclaredField("mMountingManager"); + mountingManagerField.setAccessible(true); + + 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(viewManagerRegistry, mountItemExecutor, reactContext, markdownProps)); + + return customFabricUIManager; + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException("[LiveMarkdown] Cannot read data from FabricUIManager"); + } + } + + private static 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; + } +} diff --git a/android/src/main/java/com/expensify/livemarkdown/CustomMountingManager.java b/android/src/main/java/com/expensify/livemarkdown/CustomMountingManager.java new file mode 100644 index 00000000..1b4381bc --- /dev/null +++ b/android/src/main/java/com/expensify/livemarkdown/CustomMountingManager.java @@ -0,0 +1,248 @@ +package com.expensify.livemarkdown; + +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; +import android.text.SpannableStringBuilder; +import android.text.TextPaint; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.facebook.common.logging.FLog; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.common.mapbuffer.MapBuffer; +import com.facebook.react.fabric.mounting.MountingManager; +import com.facebook.react.uimanager.PixelUtil; +import com.facebook.react.uimanager.ViewManagerRegistry; +import com.facebook.react.views.text.TextAttributeProps; +import com.facebook.react.views.text.TextInlineViewPlaceholderSpan; +import com.facebook.react.views.text.TextLayoutManagerMapBuffer; +import com.facebook.yoga.YogaMeasureMode; +import com.facebook.yoga.YogaMeasureOutput; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +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 MarkdownUtils markdownUtils; + + public CustomMountingManager( + @NonNull ViewManagerRegistry viewManagerRegistry, + @NonNull MountItemExecutor mountItemExecutor, + @NonNull Context context, + @NonNull ReadableMap decoratorProps) { + super(viewManagerRegistry, mountItemExecutor); + + AssetManager assetManager = context.getAssets(); + MarkdownUtils.maybeInitializeRuntime(assetManager); + + this.markdownUtils = new MarkdownUtils(assetManager); + this.markdownUtils.setMarkdownStyle(new MarkdownStyle(decoratorProps, context)); + } + + @Override + public long measureMapBuffer( + @NonNull ReactContext context, + @NonNull String componentName, + @NonNull MapBuffer attributedString, + @NonNull MapBuffer paragraphAttributes, + @Nullable MapBuffer state, + float width, + @NonNull YogaMeasureMode widthYogaMeasureMode, + float height, + @NonNull YogaMeasureMode heightYogaMeasureMode, + @Nullable float[] attachmentsPositions) { + + Spannable text = + TextLayoutManagerMapBuffer.getOrCreateSpannableForText(context, attributedString, null); + + if (text == null) { + return 0; + } + + int textBreakStrategy = + TextAttributeProps.getTextBreakStrategy( + paragraphAttributes.getString(TextLayoutManagerMapBuffer.PA_KEY_TEXT_BREAK_STRATEGY)); + boolean includeFontPadding = + paragraphAttributes.contains(TextLayoutManagerMapBuffer.PA_KEY_INCLUDE_FONT_PADDING) + ? paragraphAttributes.getBoolean(TextLayoutManagerMapBuffer.PA_KEY_INCLUDE_FONT_PADDING) + : DEFAULT_INCLUDE_FONT_PADDING; + int hyphenationFrequency = + TextAttributeProps.getHyphenationFrequency( + paragraphAttributes.getString(TextLayoutManagerMapBuffer.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"); + + text = sb; + } + + markdownUtils.applyMarkdownFormatting((SpannableStringBuilder)text); + + BoringLayout.Metrics boring = BoringLayout.isBoring(text, sTextPaintInstance); + + Class mapBufferClass = TextLayoutManagerMapBuffer.class; + try { + Method createLayoutMethod = mapBufferClass.getDeclaredMethod("createLayout", Spannable.class, BoringLayout.Metrics.class, float.class, YogaMeasureMode.class, boolean.class, int.class, int.class); + createLayoutMethod.setAccessible(true); + + Layout layout = (Layout)createLayoutMethod.invoke( + null, + text, + boring, + width, + widthYogaMeasureMode, + includeFontPadding, + textBreakStrategy, + hyphenationFrequency); + + int maximumNumberOfLines = + paragraphAttributes.contains(TextLayoutManagerMapBuffer.PA_KEY_MAX_NUMBER_OF_LINES) + ? paragraphAttributes.getInt(TextLayoutManagerMapBuffer.PA_KEY_MAX_NUMBER_OF_LINES) + : UNSET; + + int calculatedLineCount = + maximumNumberOfLines == UNSET || maximumNumberOfLines == 0 + ? layout.getLineCount() + : Math.min(maximumNumberOfLines, layout.getLineCount()); + + // Instead of using `layout.getWidth()` (which may yield a significantly larger width for + // text that is wrapping), compute width using the longest line. + float calculatedWidth = 0; + if (widthYogaMeasureMode == YogaMeasureMode.EXACTLY) { + calculatedWidth = width; + } else { + for (int lineIndex = 0; lineIndex < calculatedLineCount; lineIndex++) { + boolean endsWithNewLine = + text.length() > 0 && text.charAt(layout.getLineEnd(lineIndex) - 1) == '\n'; + float lineWidth = + endsWithNewLine ? layout.getLineMax(lineIndex) : layout.getLineWidth(lineIndex); + if (lineWidth > calculatedWidth) { + calculatedWidth = lineWidth; + } + } + if (widthYogaMeasureMode == YogaMeasureMode.AT_MOST && calculatedWidth > width) { + calculatedWidth = width; + } + } + + // Android 11+ introduces changes in text width calculation which leads to cases + // where the container is measured smaller than text. Math.ceil prevents it + // See T136756103 for investigation + if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.Q) { + calculatedWidth = (float) Math.ceil(calculatedWidth); + } + + float calculatedHeight = height; + if (heightYogaMeasureMode != YogaMeasureMode.EXACTLY) { + calculatedHeight = layout.getLineBottom(calculatedLineCount - 1); + if (heightYogaMeasureMode == YogaMeasureMode.AT_MOST && calculatedHeight > height) { + calculatedHeight = height; + } + } + + // Calculate the positions of the attachments (views) that will be rendered inside the + // Spanned Text. The following logic is only executed when a text contains views inside. + // This follows a similar logic than used in pre-fabric (see ReactTextView.onLayout method). + int attachmentIndex = 0; + int lastAttachmentFoundInSpan; + for (int i = 0; i < text.length(); i = lastAttachmentFoundInSpan) { + lastAttachmentFoundInSpan = + text.nextSpanTransition(i, text.length(), TextInlineViewPlaceholderSpan.class); + TextInlineViewPlaceholderSpan[] placeholders = + text.getSpans(i, lastAttachmentFoundInSpan, TextInlineViewPlaceholderSpan.class); + for (TextInlineViewPlaceholderSpan placeholder : placeholders) { + int start = text.getSpanStart(placeholder); + int line = layout.getLineForOffset(start); + boolean isLineTruncated = layout.getEllipsisCount(line) > 0; + // This truncation check works well on recent versions of Android (tested on 5.1.1 and + // 6.0.1) but not on Android 4.4.4. The reason is that getEllipsisCount is buggy on + // Android 4.4.4. Specifically, it incorrectly returns 0 if an inline view is the + // first thing to be truncated. + if (!(isLineTruncated && start >= layout.getLineStart(line) + layout.getEllipsisStart(line)) + || start >= layout.getLineEnd(line)) { + float placeholderWidth = placeholder.getWidth(); + float placeholderHeight = placeholder.getHeight(); + // Calculate if the direction of the placeholder character is Right-To-Left. + boolean isRtlChar = layout.isRtlCharAt(start); + boolean isRtlParagraph = layout.getParagraphDirection(line) == Layout.DIR_RIGHT_TO_LEFT; + float placeholderLeftPosition; + // There's a bug on Samsung devices where calling getPrimaryHorizontal on + // the last offset in the layout will result in an endless loop. Work around + // this bug by avoiding getPrimaryHorizontal in that case. + if (start == text.length() - 1) { + boolean endsWithNewLine = + text.length() > 0 && text.charAt(layout.getLineEnd(line) - 1) == '\n'; + float lineWidth = endsWithNewLine ? layout.getLineMax(line) : layout.getLineWidth(line); + placeholderLeftPosition = + isRtlParagraph + // Equivalent to `layout.getLineLeft(line)` but `getLineLeft` returns + // incorrect + // values when the paragraph is RTL and `setSingleLine(true)`. + ? calculatedWidth - lineWidth + : layout.getLineRight(line) - placeholderWidth; + } else { + // The direction of the paragraph may not be exactly the direction the string is + // heading + // in at the + // position of the placeholder. So, if the direction of the character is the same + // as the + // paragraph + // use primary, secondary otherwise. + boolean characterAndParagraphDirectionMatch = isRtlParagraph == isRtlChar; + placeholderLeftPosition = + characterAndParagraphDirectionMatch + ? layout.getPrimaryHorizontal(start) + : layout.getSecondaryHorizontal(start); + if (isRtlParagraph) { + // Adjust `placeholderLeftPosition` to work around an Android bug. + // The bug is when the paragraph is RTL and `setSingleLine(true)`, some layout + // methods such as `getPrimaryHorizontal`, `getSecondaryHorizontal`, and + // `getLineRight` return incorrect values. Their return values seem to be off + // by the same number of pixels so subtracting these values cancels out the + // error. + // + // The result is equivalent to bugless versions of + // `getPrimaryHorizontal`/`getSecondaryHorizontal`. + placeholderLeftPosition = + calculatedWidth - (layout.getLineRight(line) - placeholderLeftPosition); + } + if (isRtlChar) { + placeholderLeftPosition -= placeholderWidth; + } + } + // Vertically align the inline view to the baseline of the line of text. + float placeholderTopPosition = layout.getLineBaseline(line) - placeholderHeight; + int attachmentPosition = attachmentIndex * 2; + + // The attachment array returns the positions of each of the attachments as + attachmentsPositions[attachmentPosition] = + PixelUtil.toDIPFromPixel(placeholderTopPosition); + attachmentsPositions[attachmentPosition + 1] = + PixelUtil.toDIPFromPixel(placeholderLeftPosition); + attachmentIndex++; + } + } + } + + float widthInSP = PixelUtil.toDIPFromPixel(calculatedWidth); + float heightInSP = PixelUtil.toDIPFromPixel(calculatedHeight); + + return YogaMeasureOutput.make(widthInSP, heightInSP); + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException(e); + } + } +} diff --git a/android/src/main/java/com/expensify/livemarkdown/LiveMarkdownModule.java b/android/src/main/java/com/expensify/livemarkdown/LiveMarkdownModule.java new file mode 100644 index 00000000..ed1428b8 --- /dev/null +++ b/android/src/main/java/com/expensify/livemarkdown/LiveMarkdownModule.java @@ -0,0 +1,27 @@ +package com.expensify.livemarkdown; + +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.UIManager; +import com.facebook.react.fabric.FabricUIManager; +import com.facebook.react.uimanager.UIManagerHelper; +import com.facebook.react.uimanager.common.UIManagerType; + +public class LiveMarkdownModule extends NativeLiveMarkdownModuleSpec { + private NativeProxy mNativeProxy; + public LiveMarkdownModule(ReactApplicationContext reactContext) { + super(reactContext); + + this.mNativeProxy = new NativeProxy(); + } + + @Override + public boolean install() { + if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + FabricUIManager uiManager = + (FabricUIManager) UIManagerHelper.getUIManager(getReactApplicationContext(), UIManagerType.FABRIC); + mNativeProxy.createCommitHook(uiManager); + } + + return true; + } +} diff --git a/android/src/main/java/com/expensify/livemarkdown/LiveMarkdownPackage.java b/android/src/main/java/com/expensify/livemarkdown/LiveMarkdownPackage.java index c1c8fece..9f55fc87 100644 --- a/android/src/main/java/com/expensify/livemarkdown/LiveMarkdownPackage.java +++ b/android/src/main/java/com/expensify/livemarkdown/LiveMarkdownPackage.java @@ -1,15 +1,22 @@ package com.expensify.livemarkdown; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.facebook.react.ReactPackage; +import com.facebook.react.TurboReactPackage; import com.facebook.react.bridge.NativeModule; import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.module.model.ReactModuleInfo; +import com.facebook.react.module.model.ReactModuleInfoProvider; import com.facebook.react.uimanager.ViewManager; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Map; -public class LiveMarkdownPackage implements ReactPackage { +public class LiveMarkdownPackage extends TurboReactPackage { @Override public List createViewManagers(ReactApplicationContext reactContext) { List viewManagers = new ArrayList<>(); @@ -18,7 +25,27 @@ public List createViewManagers(ReactApplicationContext reactContext } @Override - public List createNativeModules(ReactApplicationContext reactContext) { - return Collections.emptyList(); + public ReactModuleInfoProvider getReactModuleInfoProvider() { + return new ReactModuleInfoProvider() { + @Override + public Map getReactModuleInfos() { + return Map.of(LiveMarkdownModule.NAME, new ReactModuleInfo( + LiveMarkdownModule.NAME, + LiveMarkdownModule.class.getName(), + false, // canOverrideExistingModule + false, // needsEagerInit + false, // isCxxModule + true // isTurboModule + )); + } + }; + } + @Nullable + @Override + public NativeModule getModule(@NonNull String s, @NonNull ReactApplicationContext reactApplicationContext) { + if (s.equals(LiveMarkdownModule.NAME)) { + return new LiveMarkdownModule(reactApplicationContext); + } + return null; } } diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownFontFamilySpan.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownFontFamilySpan.java index facecb82..b9e71dd0 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownFontFamilySpan.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownFontFamilySpan.java @@ -8,6 +8,7 @@ import androidx.annotation.NonNull; +import com.facebook.react.common.assets.ReactFontManager.TypefaceStyle; import com.facebook.react.views.text.ReactFontManager; public class MarkdownFontFamilySpan extends MetricAffectingSpan implements MarkdownSpan { @@ -31,7 +32,12 @@ public void updateDrawState(TextPaint tp) { } private void apply(@NonNull TextPaint textPaint) { - int style = textPaint.getTypeface().getStyle(); + int style = 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); diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownFontSizeSpan.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownFontSizeSpan.java index 6e3c1b93..25d4dadc 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownFontSizeSpan.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownFontSizeSpan.java @@ -2,8 +2,10 @@ import android.text.style.AbsoluteSizeSpan; +import com.facebook.react.uimanager.PixelUtil; + public class MarkdownFontSizeSpan extends AbsoluteSizeSpan implements MarkdownSpan { public MarkdownFontSizeSpan(float fontSize) { - super((int) fontSize, true); + super((int) PixelUtil.toPixelFromDIP(fontSize), false); } } diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java index 971f8691..69dea58a 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java @@ -29,9 +29,7 @@ public class MarkdownUtils { private static boolean IS_RUNTIME_INITIALIZED = false; - @ThreadConfined(UI) - public static void maybeInitializeRuntime(AssetManager assetManager) { - UiThreadUtil.assertOnUiThread(); + public static synchronized void maybeInitializeRuntime(AssetManager assetManager) { if (IS_RUNTIME_INITIALIZED) { return; } @@ -50,9 +48,7 @@ public static void maybeInitializeRuntime(AssetManager assetManager) { private static native void nativeInitializeRuntime(String code); - @ThreadConfined(UI) - private static String parseMarkdown(String input) { - UiThreadUtil.assertOnUiThread(); + private synchronized static String parseMarkdown(String input) { return nativeParseMarkdown(input); } diff --git a/android/src/main/new_arch/CMakeLists.txt b/android/src/main/new_arch/CMakeLists.txt new file mode 100644 index 00000000..f5dfedf7 --- /dev/null +++ b/android/src/main/new_arch/CMakeLists.txt @@ -0,0 +1,82 @@ +cmake_minimum_required(VERSION 3.13) +set(CMAKE_VERBOSE_MAKEFILE ON) +set(CMAKE_CXX_STANDARD 20) + +set(LIB_LITERAL RNLiveMarkdownSpec) +set(LIB_TARGET_NAME react_codegen_${LIB_LITERAL}) + +set(LIB_ANDROID_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../..) +set(LIB_CPP_DIR ${LIB_ANDROID_DIR}/../cpp) +set(LIB_CUSTOM_SOURCES_DIR ${LIB_CPP_DIR}/react/renderer/components/${LIB_LITERAL}) +set(LIB_ANDROID_GENERATED_JNI_DIR ${LIB_ANDROID_DIR}/build/generated/source/codegen/jni) +set(LIB_ANDROID_GENERATED_COMPONENTS_DIR ${LIB_ANDROID_GENERATED_JNI_DIR}/react/renderer/components/${LIB_LITERAL}) + +file(GLOB LIB_MODULE_SRCS CONFIGURE_DEPENDS *.cpp) +file(GLOB LIB_CUSTOM_SRCS CONFIGURE_DEPENDS ${LIB_CUSTOM_SOURCES_DIR}/*.cpp) +file(GLOB LIB_CODEGEN_SRCS CONFIGURE_DEPENDS ${LIB_ANDROID_GENERATED_COMPONENTS_DIR}/*.cpp) + +set(RN_DIR ${LIB_ANDROID_DIR}/../example/node_modules/react-native) + +add_library( + ${LIB_TARGET_NAME} + SHARED + ${LIB_MODULE_SRCS} + ${LIB_CUSTOM_SRCS} + ${LIB_CODEGEN_SRCS} +) + +target_include_directories( + ${LIB_TARGET_NAME} + PUBLIC + . + ${LIB_ANDROID_GENERATED_JNI_DIR} + ${LIB_ANDROID_GENERATED_COMPONENTS_DIR} + ${LIB_CPP_DIR} +) + +find_package(ReactAndroid REQUIRED CONFIG) + +target_link_libraries( + ${LIB_TARGET_NAME} + ReactAndroid::rrc_text + ReactAndroid::rrc_textinput + ReactAndroid::react_render_textlayoutmanager + ReactAndroid::react_render_imagemanager + fabricjni + fbjni + folly_runtime + glog + jsi + react_codegen_rncore + react_debug + react_nativemodule_core + react_render_core + react_render_debug + react_render_graphics + react_render_mapbuffer + ReactAndroid::react_render_uimanager + ReactAndroid::react_render_scheduler + react_utils + runtimeexecutor + rrc_view + turbomodulejsijni + yoga + android + log +) + +target_compile_options( + ${LIB_TARGET_NAME} + PRIVATE + -DLOG_TAG=\"ReactNative\" + -fexceptions + -frtti + -Wall + -std=c++20 +) + +target_include_directories( + ${CMAKE_PROJECT_NAME} + PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} +) \ No newline at end of file diff --git a/android/src/main/new_arch/MarkdownCommitHook.cpp b/android/src/main/new_arch/MarkdownCommitHook.cpp new file mode 100644 index 00000000..9375bb48 --- /dev/null +++ b/android/src/main/new_arch/MarkdownCommitHook.cpp @@ -0,0 +1,173 @@ +#include +#include +#include +#include + +#include "MarkdownCommitHook.h" +#include "react/renderer/components/RNLiveMarkdownSpec/MarkdownShadowFamilyRegistry.h" + +using namespace facebook; +using namespace react; + +namespace livemarkdown { + +MarkdownCommitHook::MarkdownCommitHook( + jni::global_ref + fabricUIManager) + : fabricUIManager_(fabricUIManager), + uiManager_( + fabricUIManager->getBinding()->getScheduler()->getUIManager()) { + uiManager_->registerCommitHook(*this); +} + +MarkdownCommitHook::~MarkdownCommitHook() noexcept { + uiManager_->unregisterCommitHook(*this); +} + +RootShadowNode::Unshared MarkdownCommitHook::shadowTreeWillCommit( + ShadowTree const &, RootShadowNode::Shared const &, + RootShadowNode::Unshared const &newRootShadowNode) noexcept { + auto rootNode = newRootShadowNode->ShadowNode::clone(ShadowNodeFragment{}); + + // A preface to why we do the weird thing below: + // On the new architecture there are two ways of measuring text on Android: by + // passing a cache key, or by passing a measured text with attributes by a map + // buffer. We could implement both, but that would increase the complexity of + // the code and duplication between here and RN core, so we implement + // measurement for map buffers since it's the independent one, and force RN to + // use this path every time. It shouldn't have a negative performance impact, + // since there is a cpp cache layer anyway. + // + // AndroidTextInputShadowNode is closed pretty tightly, but there's a one + // place where we can insert ourselves (well, there are two but we really + // shouldn't mess with vtable). The path to measurement looks like this: + // AndroidTextInputShadowNode::measureContent -> + // TextLayoutManager::measureAndroidComponentMapBuffer + // -> (jni) -> FabricUIManager::measureMapBuffer -> + // MountingManager::measureMapBuffer + // We cannot modify the shadow node directly, but we can replace its + // TextLayoutManager. Literally every method it has is linked statically so we + // cannot override anything, but we can replace its ContextContainer where a + // jni reference to the FabricUIManager is stored. Now `measureMapBuffer` is + // private in the ui manager, but the only thing it does is calling + // `measureMapBuffer` on MountingManager where it's public. At this point the + // path forward is clear: we make a custom MountingManager that will perform + // our measurement, then create a custom FabricUIManager which will direct + // measurement to our MountingManager. Then we only need to create the + // ContextContainer with our FabricUIManager, create the TextLayoutManager + // with the newly created ContextContainer and replace the pointer to + // TextLayoutManager inside the AndroidTextInputShadowNode. + + // In order to properly apply markdown formatting to the text input, we need + // to update the TextInputShadowNode's state to reset the cache key and update + // its TextLayoutManager reference, but we only have access to the + // ShadowNodeFamilies of the decorator components. We also know that a + // markdown decorator is always preceded with the TextInput to decorate, so we + // need to take the sibling. + std::vector nodesToUpdate; + MarkdownShadowFamilyRegistry::runForEveryFamily([&rootNode, &nodesToUpdate]( + ShadowNodeFamily::Shared + family) { + // get the path from the root to the node from the decorator family + const auto ancestors = family->getAncestors(*rootNode); + + if (!ancestors.empty()) { + auto &parentNode = ancestors.back().first.get(); + auto index = ancestors.back().second; + + // this is node represented by one of the registered families and since we + // only register markdown decorator shadow families, static casting should + // be safe here + const auto &decoratorNode = + std::static_pointer_cast( + parentNode.getChildren().at(index)); + // text input always precedes the decorator component + const auto &previousSibling = parentNode.getChildren().at(index - 1); + + if (const auto &textInputNode = + std::dynamic_pointer_cast( + previousSibling)) { + // store the pair of text input and decorator to update in the next step + // we need both, decorator to get markdown style and text input to + // update it + nodesToUpdate.push_back({ + textInputNode, + decoratorNode, + }); + } + } + }); + + for (const auto &nodes : nodesToUpdate) { + const auto &textInputState = + *std::static_pointer_cast>( + nodes.textInput->getState()); + const auto &stateData = textInputState.getData(); + + rootNode = rootNode->cloneTree( + nodes.textInput->getFamily(), + [this, &stateData, &textInputState, &nodes](ShadowNode const &node) { + auto newStateData = + std::make_shared(stateData); + // force measurement of a map buffer + newStateData->cachedAttributedStringId = 0; + + // clone the text input with the new state + auto newNode = node.clone({ + .state = + std::make_shared>( + newStateData, textInputState), + }); + + const auto currentDecoratorProps = + nodes.decorator->getProps()->rawProps["markdownStyle"]; + + // if it's the first time we encounter this particular input or the + // markdown styles have changed (in which case we need to reset the + // cpp cache, to which we don't have a direct access), create a new + // instance of TextLayoutManager that will be performing measurement + // for this particular input + if (!textLayoutManagers_.contains(nodes.textInput->getTag()) || + previousDecoratorProps_[nodes.textInput->getTag()] != + currentDecoratorProps) { + static auto customUIManagerClass = jni::findClassStatic( + "com/expensify/livemarkdown/CustomFabricUIManager"); + static auto createCustomUIManager = + customUIManagerClass + ->getStaticMethod( + "create"); + + auto const decoratorPropsRNM = + ReadableNativeMap::newObjectCxxArgs(currentDecoratorProps); + auto const decoratorPropsRM = + jni::make_local(reinterpret_cast( + decoratorPropsRNM.get())); + + const auto customUIManager = jni::make_global(createCustomUIManager( + customUIManagerClass, fabricUIManager_.get(), + decoratorPropsRM.get())); + const ContextContainer::Shared contextContainer = + std::make_shared(); + contextContainer->insert("FabricUIManager", customUIManager); + textLayoutManagers_[nodes.textInput->getTag()] = + std::make_shared(contextContainer); + previousDecoratorProps_[nodes.textInput->getTag()] = + currentDecoratorProps; + } + + // we need to replace the TextLayoutManager every time to make sure + // the correct measurement code is run + auto newTextInputShadowNode = + std::static_pointer_cast(newNode); + newTextInputShadowNode->setTextLayoutManager( + textLayoutManagers_[nodes.textInput->getTag()]); + + return newNode; + }); + } + + return std::static_pointer_cast(rootNode); +} + +} // namespace livemarkdown diff --git a/android/src/main/new_arch/MarkdownCommitHook.h b/android/src/main/new_arch/MarkdownCommitHook.h new file mode 100644 index 00000000..6e736d7c --- /dev/null +++ b/android/src/main/new_arch/MarkdownCommitHook.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include + +#include +#include +#include + +#include +#include + +#include "react/renderer/components/RNLiveMarkdownSpec/MarkdownTextInputDecoratorShadowNode.h" + +using namespace facebook; +using namespace react; + +namespace livemarkdown { + +struct MarkdownTextInputDecoratorPair { + const std::shared_ptr textInput; + const std::shared_ptr decorator; +}; + +class MarkdownCommitHook : public UIManagerCommitHook { +public: + MarkdownCommitHook( + jni::global_ref + fabricUIManager); + + ~MarkdownCommitHook() noexcept override; + + void commitHookWasRegistered(UIManager const &) noexcept override {} + + void commitHookWasUnregistered(UIManager const &) noexcept override {} + + RootShadowNode::Unshared shadowTreeWillCommit( + ShadowTree const &shadowTree, + RootShadowNode::Shared const &oldRootShadowNode, + RootShadowNode::Unshared const &newRootShadowNode) noexcept override; + +private: + const jni::global_ref + fabricUIManager_; + const std::shared_ptr uiManager_; + std::unordered_map + textLayoutManagers_; + std::unordered_map + previousDecoratorProps_; +}; + +} // namespace livemarkdown diff --git a/android/src/main/new_arch/NativeProxy.cpp b/android/src/main/new_arch/NativeProxy.cpp new file mode 100644 index 00000000..c46297c4 --- /dev/null +++ b/android/src/main/new_arch/NativeProxy.cpp @@ -0,0 +1,38 @@ +#include +#include +#include + +#include + +#include "NativeProxy.h" + +namespace livemarkdown { + +using namespace facebook; +using namespace react; + +NativeProxy::NativeProxy(jni::alias_ref jThis) + : javaPart_(jni::make_global(jThis)) {} + +NativeProxy::~NativeProxy() {} + +void NativeProxy::registerNatives() { + registerHybrid( + {makeNativeMethod("initHybrid", NativeProxy::initHybrid), + makeNativeMethod("createCommitHook", NativeProxy::createCommitHook)}); +} + +void NativeProxy::createCommitHook( + jni::alias_ref + fabricUIManager) { + const auto &globalUIManager = jni::make_global(fabricUIManager); + + this->commitHook_ = std::make_shared(globalUIManager); +} + +jni::local_ref +NativeProxy::initHybrid(jni::alias_ref jThis) { + return makeCxxInstance(jThis); +} + +} // namespace livemarkdown diff --git a/android/src/main/new_arch/NativeProxy.h b/android/src/main/new_arch/NativeProxy.h new file mode 100644 index 00000000..a8b5f51d --- /dev/null +++ b/android/src/main/new_arch/NativeProxy.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include + +#include + +#include "MarkdownCommitHook.h" + +namespace livemarkdown { + +using namespace facebook; +using namespace facebook::jni; + +class NativeProxy : public jni::HybridClass { +public: + static auto constexpr kJavaDescriptor = + "Lcom/expensify/livemarkdown/NativeProxy;"; + static jni::local_ref + initHybrid(jni::alias_ref jThis); + static void registerNatives(); + + ~NativeProxy(); + +private: + friend HybridBase; + jni::global_ref javaPart_; + std::shared_ptr commitHook_; + + explicit NativeProxy(jni::alias_ref jThis); + + void + createCommitHook(jni::alias_ref + fabricUIManager); +}; + +} // namespace livemarkdown diff --git a/android/src/main/new_arch/OnLoad.cpp b/android/src/main/new_arch/OnLoad.cpp new file mode 100644 index 00000000..4ebace0b --- /dev/null +++ b/android/src/main/new_arch/OnLoad.cpp @@ -0,0 +1,8 @@ +#include + +#include "NativeProxy.h" + +JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) { + return facebook::jni::initialize( + vm, [] { livemarkdown::NativeProxy::registerNatives(); }); +} diff --git a/android/src/main/new_arch/RNLiveMarkdownSpec.cpp b/android/src/main/new_arch/RNLiveMarkdownSpec.cpp new file mode 100644 index 00000000..2680f993 --- /dev/null +++ b/android/src/main/new_arch/RNLiveMarkdownSpec.cpp @@ -0,0 +1,44 @@ + +/** + * This code was generated by + * [react-native-codegen](https://www.npmjs.com/package/react-native-codegen). + * + * Do not edit this file as changes may cause incorrect behavior and will be + * lost once the code is regenerated. + * + * @generated by codegen project: GenerateModuleJniCpp.js + */ + +#include "RNLiveMarkdownSpec.h" + +namespace facebook { +namespace react { + +static facebook::jsi::Value +__hostFunction_NativeLiveMarkdownModuleSpecJSI_install( + facebook::jsi::Runtime &rt, TurboModule &turboModule, + const facebook::jsi::Value *args, size_t count) { + static jmethodID cachedMethodId = nullptr; + return static_cast(turboModule) + .invokeJavaMethod(rt, BooleanKind, "install", "()Z", args, count, + cachedMethodId); +} + +NativeLiveMarkdownModuleSpecJSI::NativeLiveMarkdownModuleSpecJSI( + const JavaTurboModule::InitParams ¶ms) + : JavaTurboModule(params) { + methodMap_["install"] = + MethodMetadata{0, __hostFunction_NativeLiveMarkdownModuleSpecJSI_install}; +} + +std::shared_ptr +RNLiveMarkdownSpec_ModuleProvider(const std::string &moduleName, + const JavaTurboModule::InitParams ¶ms) { + if (moduleName == "LiveMarkdownModule") { + return std::make_shared(params); + } + return nullptr; +} + +} // namespace react +} // namespace facebook diff --git a/android/src/main/new_arch/RNLiveMarkdownSpec.h b/android/src/main/new_arch/RNLiveMarkdownSpec.h new file mode 100644 index 00000000..bd63e8ae --- /dev/null +++ b/android/src/main/new_arch/RNLiveMarkdownSpec.h @@ -0,0 +1,37 @@ + +/** + * This code was generated by + * [react-native-codegen](https://www.npmjs.com/package/react-native-codegen). + * + * Do not edit this file as changes may cause incorrect behavior and will be + * lost once the code is regenerated. + * + * @generated by codegen project: GenerateModuleJniH.js + */ + +#pragma once + +#include +#include +#include + +#include + +namespace facebook { +namespace react { + +/** + * JNI C++ class for module 'NativeLiveMarkdownModule' + */ +class JSI_EXPORT NativeLiveMarkdownModuleSpecJSI : public JavaTurboModule { +public: + NativeLiveMarkdownModuleSpecJSI(const JavaTurboModule::InitParams ¶ms); +}; + +JSI_EXPORT +std::shared_ptr +RNLiveMarkdownSpec_ModuleProvider(const std::string &moduleName, + const JavaTurboModule::InitParams ¶ms); + +} // namespace react +} // namespace facebook diff --git a/android/src/newarch/NativeProxy.java b/android/src/newarch/NativeProxy.java new file mode 100644 index 00000000..8e307ed0 --- /dev/null +++ b/android/src/newarch/NativeProxy.java @@ -0,0 +1,24 @@ +package com.expensify.livemarkdown; + +import com.facebook.jni.HybridData; +import com.facebook.proguard.annotations.DoNotStrip; +import com.facebook.react.fabric.FabricUIManager; +import com.facebook.soloader.SoLoader; + +public class NativeProxy { + static { + SoLoader.loadLibrary("react_codegen_RNLiveMarkdownSpec"); + } + + @DoNotStrip + @SuppressWarnings("unused") + private final HybridData mHybridData; + + public NativeProxy() { + mHybridData = initHybrid(); + } + + private native HybridData initHybrid(); + + public native void createCommitHook(FabricUIManager fabricUIManager); +} diff --git a/android/src/oldarch/NativeLiveMarkdownModuleSpec.java b/android/src/oldarch/NativeLiveMarkdownModuleSpec.java new file mode 100644 index 00000000..19fea086 --- /dev/null +++ b/android/src/oldarch/NativeLiveMarkdownModuleSpec.java @@ -0,0 +1,37 @@ + +/** + * This code was generated by [react-native-codegen](https://www.npmjs.com/package/react-native-codegen). + * + * Do not edit this file as changes may cause incorrect behavior and will be lost + * once the code is regenerated. + * + * @generated by codegen project: GenerateModuleJavaSpec.js + * + * @nolint + */ + +package com.expensify.livemarkdown; + +import com.facebook.proguard.annotations.DoNotStrip; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.turbomodule.core.interfaces.TurboModule; +import javax.annotation.Nonnull; + +public abstract class NativeLiveMarkdownModuleSpec extends ReactContextBaseJavaModule implements TurboModule { + public static final String NAME = "LiveMarkdownModule"; + + public NativeLiveMarkdownModuleSpec(ReactApplicationContext reactContext) { + super(reactContext); + } + + @Override + public @Nonnull String getName() { + return NAME; + } + + @ReactMethod(isBlockingSynchronousMethod = true) + @DoNotStrip + public abstract boolean install(); +} diff --git a/android/src/oldarch/NativeProxy.java b/android/src/oldarch/NativeProxy.java new file mode 100644 index 00000000..0a6f66ff --- /dev/null +++ b/android/src/oldarch/NativeProxy.java @@ -0,0 +1,9 @@ +package com.expensify.livemarkdown; + +import com.facebook.react.bridge.UIManager; + +public class NativeProxy { + public void createCommitHook(UIManager uiManager) { + // no-op on the old arch + } +} diff --git a/cpp/react/renderer/components/RNLiveMarkdownSpec/MarkdownShadowFamilyRegistry.cpp b/cpp/react/renderer/components/RNLiveMarkdownSpec/MarkdownShadowFamilyRegistry.cpp new file mode 100644 index 00000000..1a0f4a74 --- /dev/null +++ b/cpp/react/renderer/components/RNLiveMarkdownSpec/MarkdownShadowFamilyRegistry.cpp @@ -0,0 +1,58 @@ +#if defined(RCT_NEW_ARCH_ENABLED) || defined(ANDROID) + +#include "MarkdownShadowFamilyRegistry.h" + +std::set + MarkdownShadowFamilyRegistry::familiesToUpdate_; +std::set MarkdownShadowFamilyRegistry::forcedUpdates_; +std::mutex MarkdownShadowFamilyRegistry::mutex_; + +void MarkdownShadowFamilyRegistry::registerFamilyForUpdates( + facebook::react::ShadowNodeFamily::Shared family) { + auto lock = + std::unique_lock(MarkdownShadowFamilyRegistry::mutex_); + MarkdownShadowFamilyRegistry::familiesToUpdate_.insert(family); +} + +void MarkdownShadowFamilyRegistry::unregisterFamilyForUpdates( + facebook::react::ShadowNodeFamily::Shared family) { + auto lock = + std::unique_lock(MarkdownShadowFamilyRegistry::mutex_); + MarkdownShadowFamilyRegistry::familiesToUpdate_.erase(family); +} + +void MarkdownShadowFamilyRegistry::reset() { + auto lock = + std::unique_lock(MarkdownShadowFamilyRegistry::mutex_); + MarkdownShadowFamilyRegistry::familiesToUpdate_.clear(); + MarkdownShadowFamilyRegistry::forcedUpdates_.clear(); +} + +void MarkdownShadowFamilyRegistry::runForEveryFamily( + std::function fun) { + auto lock = + std::unique_lock(MarkdownShadowFamilyRegistry::mutex_); + for (auto &family : MarkdownShadowFamilyRegistry::familiesToUpdate_) { + fun(family); + } +} + +void MarkdownShadowFamilyRegistry::forceNextStateUpdate( + facebook::react::Tag tag) { + auto lock = + std::unique_lock(MarkdownShadowFamilyRegistry::mutex_); + forcedUpdates_.insert(tag); +} + +bool MarkdownShadowFamilyRegistry::shouldForceUpdate(facebook::react::Tag tag) { + auto lock = + std::unique_lock(MarkdownShadowFamilyRegistry::mutex_); + bool force = forcedUpdates_.contains(tag); + if (force) { + forcedUpdates_.erase(tag); + return true; + } + return false; +} + +#endif diff --git a/cpp/react/renderer/components/RNLiveMarkdownSpec/MarkdownShadowFamilyRegistry.h b/cpp/react/renderer/components/RNLiveMarkdownSpec/MarkdownShadowFamilyRegistry.h new file mode 100644 index 00000000..f76c87d8 --- /dev/null +++ b/cpp/react/renderer/components/RNLiveMarkdownSpec/MarkdownShadowFamilyRegistry.h @@ -0,0 +1,35 @@ +#pragma once +#if defined(RCT_NEW_ARCH_ENABLED) || defined(ANDROID) + +#include + +#include +#include + +// A registry to store pointers to the ShadowNodeFamilies of markdown +// decorators. The only place we can _legally_ access the family of shadow node +// is in the constructor and we need it inside commit hook. To achieve it, we +// use this simple registry where families are registered when nodes are created +// and cleaned up when native view is removed from window or when a turbomodule +// is deallocated. + +class MarkdownShadowFamilyRegistry { +public: + static void + registerFamilyForUpdates(facebook::react::ShadowNodeFamily::Shared family); + static void + unregisterFamilyForUpdates(facebook::react::ShadowNodeFamily::Shared family); + static void reset(); + static void runForEveryFamily( + std::function + fun); + static void forceNextStateUpdate(facebook::react::Tag tag); + static bool shouldForceUpdate(facebook::react::Tag tag); + +private: + static std::set familiesToUpdate_; + static std::set forcedUpdates_; + static std::mutex mutex_; +}; + +#endif diff --git a/cpp/react/renderer/components/RNLiveMarkdownSpec/MarkdownTextInputDecoratorShadowNode.cpp b/cpp/react/renderer/components/RNLiveMarkdownSpec/MarkdownTextInputDecoratorShadowNode.cpp new file mode 100644 index 00000000..104363d3 --- /dev/null +++ b/cpp/react/renderer/components/RNLiveMarkdownSpec/MarkdownTextInputDecoratorShadowNode.cpp @@ -0,0 +1,38 @@ +#if defined(RCT_NEW_ARCH_ENABLED) || defined(ANDROID) + +#include + +#include "MarkdownShadowFamilyRegistry.h" +#include "MarkdownTextInputDecoratorShadowNode.h" + +namespace facebook { +namespace react { + +extern const char MarkdownTextInputDecoratorViewComponentName[] = + "MarkdownTextInputDecoratorView"; + +const ShadowNodeFragment::Value +MarkdownTextInputDecoratorShadowNode::updateFragmentState( + ShadowNodeFragment const &fragment, + ShadowNodeFamily::Shared const &family) { + const auto newStateData = + std::make_shared(family); + + MarkdownShadowFamilyRegistry::registerFamilyForUpdates(family); + + // we pass the pointer to the ShadowNodeFamily in the initial state, so it's + // propagated on every clone we need it to clear the reference in the registry + // when the view is removed from window it cannot be done in the destructor, + // as multiple shadow nodes for the same family may be created + return ShadowNodeFragment::Value({ + .props = fragment.props, + .children = fragment.children, + .state = + std::make_shared(newStateData, *fragment.state), + }); +} + +} // namespace react +} // namespace facebook + +#endif diff --git a/cpp/react/renderer/components/RNLiveMarkdownSpec/MarkdownTextInputDecoratorShadowNode.h b/cpp/react/renderer/components/RNLiveMarkdownSpec/MarkdownTextInputDecoratorShadowNode.h new file mode 100644 index 00000000..294e0d3d --- /dev/null +++ b/cpp/react/renderer/components/RNLiveMarkdownSpec/MarkdownTextInputDecoratorShadowNode.h @@ -0,0 +1,48 @@ +#pragma once +#if defined(RCT_NEW_ARCH_ENABLED) || defined(ANDROID) + +#include "MarkdownShadowFamilyRegistry.h" +#include "MarkdownTextInputDecoratorState.h" +#include +#include +#include +#include + +namespace facebook { +namespace react { + +JSI_EXPORT extern const char MarkdownTextInputDecoratorViewComponentName[]; + +class JSI_EXPORT MarkdownTextInputDecoratorShadowNode final + : public ConcreteViewShadowNode { +public: + MarkdownTextInputDecoratorShadowNode(ShadowNodeFragment const &fragment, + ShadowNodeFamily::Shared const &family, + ShadowNodeTraits traits) + : ConcreteViewShadowNode(static_cast( + updateFragmentState(fragment, family)), + family, traits) {} + + MarkdownTextInputDecoratorShadowNode(ShadowNode const &sourceShadowNode, + ShadowNodeFragment const &fragment) + : ConcreteViewShadowNode(sourceShadowNode, fragment) { + // if the props changed, we need to update the shadow node state to reflect + // potential style changes + if (fragment.props != ShadowNodeFragment::propsPlaceholder()) { + MarkdownShadowFamilyRegistry::forceNextStateUpdate(this->getTag()); + } + } + +private: + static const ShadowNodeFragment::Value + updateFragmentState(ShadowNodeFragment const &fragment, + ShadowNodeFamily::Shared const &family); +}; + +} // namespace react +} // namespace facebook + +#endif diff --git a/cpp/react/renderer/components/RNLiveMarkdownSpec/MarkdownTextInputDecoratorState.h b/cpp/react/renderer/components/RNLiveMarkdownSpec/MarkdownTextInputDecoratorState.h new file mode 100644 index 00000000..7590b996 --- /dev/null +++ b/cpp/react/renderer/components/RNLiveMarkdownSpec/MarkdownTextInputDecoratorState.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +namespace facebook { +namespace react { + +class JSI_EXPORT MarkdownTextInputDecoratorState final { +public: + using Shared = std::shared_ptr; + + MarkdownTextInputDecoratorState() : decoratorFamily(nullptr){}; + MarkdownTextInputDecoratorState( + const ShadowNodeFamily::Shared textInputFamily_) + : decoratorFamily(textInputFamily_){}; + +#ifdef ANDROID + MarkdownTextInputDecoratorState( + MarkdownTextInputDecoratorState const &previousState, folly::dynamic data) + : decoratorFamily(previousState.decoratorFamily){}; +#endif + + const ShadowNodeFamily::Shared decoratorFamily; + +#ifdef ANDROID + folly::dynamic getDynamic() const { + return folly::dynamic::object("decoratorFamily", "pointer should be here?"); + } + MapBuffer getMapBuffer() const { return MapBufferBuilder::EMPTY(); }; +#endif +}; + +} // namespace react +} // namespace facebook diff --git a/cpp/react/renderer/components/RNLiveMarkdownSpec/MarkdownTextInputDecoratorViewComponentDescriptor.h b/cpp/react/renderer/components/RNLiveMarkdownSpec/MarkdownTextInputDecoratorViewComponentDescriptor.h new file mode 100644 index 00000000..c145e45c --- /dev/null +++ b/cpp/react/renderer/components/RNLiveMarkdownSpec/MarkdownTextInputDecoratorViewComponentDescriptor.h @@ -0,0 +1,17 @@ +#pragma once + +#include "MarkdownTextInputDecoratorShadowNode.h" +#include +#include + +namespace facebook { +namespace react { + +class MarkdownTextInputDecoratorViewComponentDescriptor final + : public ConcreteComponentDescriptor { +public: + using ConcreteComponentDescriptor::ConcreteComponentDescriptor; +}; + +} // namespace react +} // namespace facebook diff --git a/example/android/settings.gradle b/example/android/settings.gradle index 48643a6f..a50e9a8e 100644 --- a/example/android/settings.gradle +++ b/example/android/settings.gradle @@ -2,3 +2,11 @@ rootProject.name = 'LiveMarkdownExample' apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings) include ':app' includeBuild('../node_modules/@react-native/gradle-plugin') +includeBuild('../node_modules/react-native') { + dependencySubstitution { + substitute(module("com.facebook.react:react-android")).using(project(":packages:react-native:ReactAndroid")) + substitute(module("com.facebook.react:react-native")).using(project(":packages:react-native:ReactAndroid")) + substitute(module("com.facebook.react:hermes-android")).using(project(":packages:react-native:ReactAndroid:hermes-engine")) + substitute(module("com.facebook.react:hermes-engine")).using(project(":packages:react-native:ReactAndroid:hermes-engine")) + } +} diff --git a/example/ios/LiveMarkdownExample.xcodeproj/project.pbxproj b/example/ios/LiveMarkdownExample.xcodeproj/project.pbxproj index 8dfebe99..1fc07b3e 100644 --- a/example/ios/LiveMarkdownExample.xcodeproj/project.pbxproj +++ b/example/ios/LiveMarkdownExample.xcodeproj/project.pbxproj @@ -572,13 +572,17 @@ ); MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; - OTHER_CFLAGS = "$(inherited)"; + OTHER_CFLAGS = ( + "$(inherited)", + " ", + ); OTHER_CPLUSPLUSFLAGS = ( "$(OTHER_CFLAGS)", "-DFOLLY_NO_CONFIG", "-DFOLLY_MOBILE=1", "-DFOLLY_USE_LIBCPP=1", "-DFOLLY_CFG_NO_COROUTINES=1", + " ", ); OTHER_LDFLAGS = ( "$(inherited)", @@ -643,13 +647,17 @@ "\"$(inherited)\"", ); MTL_ENABLE_DEBUG_INFO = NO; - OTHER_CFLAGS = "$(inherited)"; + OTHER_CFLAGS = ( + "$(inherited)", + " ", + ); OTHER_CPLUSPLUSFLAGS = ( "$(OTHER_CFLAGS)", "-DFOLLY_NO_CONFIG", "-DFOLLY_MOBILE=1", "-DFOLLY_USE_LIBCPP=1", "-DFOLLY_CFG_NO_COROUTINES=1", + " ", ); OTHER_LDFLAGS = ( "$(inherited)", diff --git a/example/package.json b/example/package.json index 2ab52f3a..0af14bb9 100644 --- a/example/package.json +++ b/example/package.json @@ -8,6 +8,8 @@ "start": "react-native start" }, "dependencies": { + "patch-package": "^8.0.0", + "postinstall-postinstall": "^2.1.0", "react": "18.2.0", "react-native": "0.73.6" }, diff --git a/example/patches/@react-native+gradle-plugin+0.73.4.patch b/example/patches/@react-native+gradle-plugin+0.73.4.patch new file mode 100644 index 00000000..e55be2d1 --- /dev/null +++ b/example/patches/@react-native+gradle-plugin+0.73.4.patch @@ -0,0 +1,39 @@ +diff --git a/node_modules/@react-native/gradle-plugin/src/main/kotlin/com/facebook/react/tasks/internal/PreparePrefabHeadersTask.kt b/node_modules/@react-native/gradle-plugin/src/main/kotlin/com/facebook/react/tasks/internal/PreparePrefabHeadersTask.kt +index f3b55e0..ede5c95 100644 +--- a/node_modules/@react-native/gradle-plugin/src/main/kotlin/com/facebook/react/tasks/internal/PreparePrefabHeadersTask.kt ++++ b/node_modules/@react-native/gradle-plugin/src/main/kotlin/com/facebook/react/tasks/internal/PreparePrefabHeadersTask.kt +@@ -45,15 +45,27 @@ abstract class PreparePrefabHeadersTask : DefaultTask() { + fs.copy { + it.from(headerPath) + it.include("**/*.h") ++ it.include("**/*.hpp") + it.exclude("**/*.cpp") + it.exclude("**/*.txt") +- // We don't want to copy all the boost headers as they are 250Mb+ +- it.include("boost/config.hpp") +- it.include("boost/config/**/*.hpp") +- it.include("boost/core/*.hpp") +- it.include("boost/detail/workaround.hpp") +- it.include("boost/operators.hpp") +- it.include("boost/preprocessor/**/*.hpp") ++// // We don't want to copy all the boost headers as they are 250Mb+ ++// it.include("boost/config.hpp") ++// it.include("boost/config/**/*.hpp") ++// it.include("boost/container_hash/**/*.hpp") ++// it.include("boost/core/*.hpp") ++// it.include("boost/intrusive/**/*.hpp") ++// it.include("boost/move/**/*.hpp") ++// it.include("boost/type_traits/**/*.hpp") ++// it.include("boost/describe/**/*.hpp") ++// it.include("boost/iterator/*.hpp") ++// it.include("boost/detail/workaround.hpp") ++// it.include("boost/assert.hpp") ++// it.include("boost/operators.hpp") ++// it.include("boost/utility.hpp") ++// it.include("boost/cstdint.hpp") ++// it.include("boost/version.hpp") ++// it.include("boost/static_assert.hpp") ++// it.include("boost/preprocessor/**/*.hpp") + it.into(File(outputFolder.asFile, headerPrefix)) + } + } diff --git a/example/patches/react-native+0.73.4.patch b/example/patches/react-native+0.73.4.patch new file mode 100644 index 00000000..001a2dec --- /dev/null +++ b/example/patches/react-native+0.73.4.patch @@ -0,0 +1,95 @@ +diff --git a/node_modules/react-native/ReactAndroid/build.gradle b/node_modules/react-native/ReactAndroid/build.gradle +index 78c57eb..ec147fd 100644 +--- a/node_modules/react-native/ReactAndroid/build.gradle ++++ b/node_modules/react-native/ReactAndroid/build.gradle +@@ -125,6 +125,19 @@ final def preparePrefab = tasks.register("preparePrefab", PreparePrefabHeadersTa + "rrc_root", + new Pair("../ReactCommon/react/renderer/components/root/", "react/renderer/components/root/") + ), ++ new PrefabPreprocessingEntry( ++ "rrc_text", ++ [ ++ new Pair("../ReactCommon/react/renderer/components/text/", "react/renderer/components/text/"), ++ new Pair("../ReactCommon/react/renderer/attributedstring", "react/renderer/attributedstring"), ++ ] ++ ), ++ new PrefabPreprocessingEntry( ++ "rrc_textinput", ++ [ ++ new Pair("../ReactCommon/react/renderer/components/textinput/androidtextinput", ""), ++ ] ++ ), + new PrefabPreprocessingEntry( + "rrc_view", + [ +@@ -132,6 +145,13 @@ final def preparePrefab = tasks.register("preparePrefab", PreparePrefabHeadersTa + new Pair("../ReactCommon/react/renderer/components/view/platform/android/", ""), + ] + ), ++ new PrefabPreprocessingEntry( ++ "react_render_textlayoutmanager", ++ [ ++ new Pair("../ReactCommon/react/renderer/textlayoutmanager/", "react/renderer/textlayoutmanager/"), ++ new Pair("../ReactCommon/react/renderer/textlayoutmanager/platform/android/", ""), ++ ] ++ ), + new PrefabPreprocessingEntry( + "rrc_legacyviewmanagerinterop", + new Pair("../ReactCommon/react/renderer/components/legacyviewmanagerinterop/", "react/renderer/components/legacyviewmanagerinterop/") +@@ -559,6 +579,9 @@ android { + "glog", + "fabricjni", + "react_render_mapbuffer", ++ "react_render_textlayoutmanager", ++ "rrc_textinput", ++ "rrc_text", + "yoga", + "folly_runtime", + "react_nativemodule_core", +@@ -683,6 +706,15 @@ android { + rrc_root { + headers(new File(prefabHeadersDir, "rrc_root").absolutePath) + } ++ rrc_text { ++ headers(new File(prefabHeadersDir, "rrc_text").absolutePath) ++ } ++ rrc_textinput { ++ headers(new File(prefabHeadersDir, "rrc_textinput").absolutePath) ++ } ++ react_render_textlayoutmanager { ++ headers(new File(prefabHeadersDir, "react_render_textlayoutmanager").absolutePath) ++ } + rrc_view { + headers(new File(prefabHeadersDir, "rrc_view").absolutePath) + } +diff --git a/node_modules/react-native/ReactAndroid/cmake-utils/ReactNative-application.cmake b/node_modules/react-native/ReactAndroid/cmake-utils/ReactNative-application.cmake +index d49fa9e..3607c69 100644 +--- a/node_modules/react-native/ReactAndroid/cmake-utils/ReactNative-application.cmake ++++ b/node_modules/react-native/ReactAndroid/cmake-utils/ReactNative-application.cmake +@@ -78,11 +78,14 @@ add_library(jsi ALIAS ReactAndroid::jsi) + add_library(glog ALIAS ReactAndroid::glog) + add_library(fabricjni ALIAS ReactAndroid::fabricjni) + add_library(react_render_mapbuffer ALIAS ReactAndroid::react_render_mapbuffer) ++add_library(react_render_textlayoutmanager ALIAS ReactAndroid::react_render_textlayoutmanager) + add_library(yoga ALIAS ReactAndroid::yoga) + add_library(folly_runtime ALIAS ReactAndroid::folly_runtime) + add_library(react_nativemodule_core ALIAS ReactAndroid::react_nativemodule_core) + add_library(react_render_imagemanager ALIAS ReactAndroid::react_render_imagemanager) + add_library(rrc_image ALIAS ReactAndroid::rrc_image) ++add_library(rrc_text ALIAS ReactAndroid::rrc_text) ++add_library(rrc_textinput ALIAS ReactAndroid::rrc_textinput) + add_library(rrc_legacyviewmanagerinterop ALIAS ReactAndroid::rrc_legacyviewmanagerinterop) + + find_package(fbjni REQUIRED CONFIG) +@@ -105,8 +108,11 @@ target_link_libraries(${CMAKE_PROJECT_NAME} + react_render_graphics # prefab ready + react_render_imagemanager # prefab ready + react_render_mapbuffer # prefab ready ++ react_render_textlayoutmanager # prefab ready + rrc_image # prefab ready + rrc_view # prefab ready ++ rrc_text # prefab ready ++ rrc_textinput # prefab ready + rrc_legacyviewmanagerinterop # prefab ready + runtimeexecutor # prefab ready + turbomodulejsijni # prefab ready diff --git a/ios/MarkdownCommitHook.h b/ios/MarkdownCommitHook.h new file mode 100644 index 00000000..92f0acaf --- /dev/null +++ b/ios/MarkdownCommitHook.h @@ -0,0 +1,42 @@ +#pragma once +#ifdef RCT_NEW_ARCH_ENABLED + +#include +#include +#include + +#include + +#include "MarkdownTextInputDecoratorShadowNode.h" + +using namespace facebook::react; + +namespace livemarkdown { + +struct MarkdownTextInputDecoratorPair { + const std::shared_ptr textInput; + const std::shared_ptr decorator; +}; + +class MarkdownCommitHook : public UIManagerCommitHook { +public: + MarkdownCommitHook(const std::shared_ptr &uiManager); + + ~MarkdownCommitHook() noexcept override; + + void commitHookWasRegistered(UIManager const &) noexcept override {} + + void commitHookWasUnregistered(UIManager const &) noexcept override {} + + RootShadowNode::Unshared shadowTreeWillCommit( + ShadowTree const &shadowTree, + RootShadowNode::Shared const &oldRootShadowNode, + RootShadowNode::Unshared const &newRootShadowNode) noexcept override; + +private: + const std::shared_ptr uiManager_; +}; + +} // namespace livemarkdown + +#endif // RCT_NEW_ARCH_ENABLED diff --git a/ios/MarkdownCommitHook.mm b/ios/MarkdownCommitHook.mm new file mode 100644 index 00000000..60123e90 --- /dev/null +++ b/ios/MarkdownCommitHook.mm @@ -0,0 +1,195 @@ +#ifdef RCT_NEW_ARCH_ENABLED + +#include +#include +#include + +#include "MarkdownCommitHook.h" +#include "MarkdownShadowFamilyRegistry.h" +#include "RCTMarkdownStyle.h" +#include "RCTMarkdownUtils.h" + +using namespace facebook::react; + +namespace livemarkdown { + +MarkdownCommitHook::MarkdownCommitHook( + const std::shared_ptr &uiManager) + : uiManager_(uiManager) { + uiManager_->registerCommitHook(*this); +} + +MarkdownCommitHook::~MarkdownCommitHook() noexcept { + uiManager_->unregisterCommitHook(*this); +} + +RootShadowNode::Unshared MarkdownCommitHook::shadowTreeWillCommit( + ShadowTree const &, RootShadowNode::Shared const &, + RootShadowNode::Unshared const &newRootShadowNode) noexcept { + auto rootNode = newRootShadowNode->ShadowNode::clone(ShadowNodeFragment{}); + + // A preface to why we do the weird thing below: + // On the new architecture there are two ways of measuring text on iOS: by + // value and by pointer. When done by value, the attributed string to be + // measured is created on the c++ side. We cannot modify this process as we do + // not extend TextInputShadowNode. We also cannot really change the layout + // manager used to do this, since it's a private field (ok, we can but in a + // not very nice way). But also, the logic for parsing and applying markdown + // is written in JS/ObjC and we really wouldn't want to reimplement it in c++. + // + // Nice thing is that it can also be done by pointer to NSAttributedString, + // which is the platform's way to handle styled text, and is also used by Live + // Markdown. On this path, the measurement is done by the OS APIs. The thing + // we want to make sure of, is that markdown-decorated text input always uses + // this path and uses a pointer to a string with markdown styles applied. + // Thankfully, RN provides nice utility functions that allow to convert + // between the RN's AttributedString and iOS's NSAttributedString. The logic + // below does exactly that. + + // In order to properly apply markdown formatting to the text input, we need + // to update the TextInputShadowNode's state with styled string, but we only + // have access to the ShadowNodeFamilies of the decorator components. We also + // know that a markdown decorator is always preceded with the TextInput to + // decorate, so we need to take the sibling. + std::vector nodesToUpdate; + MarkdownShadowFamilyRegistry::runForEveryFamily([&rootNode, &nodesToUpdate]( + ShadowNodeFamily::Shared + family) { + // get the path from the root to the node from the decorator family + const auto ancestors = family->getAncestors(*rootNode); + + if (!ancestors.empty()) { + auto &parentNode = ancestors.back().first.get(); + auto index = ancestors.back().second; + + // this is node represented by one of the registered families and since we + // only register markdown decorator shadow families, static casting should + // be safe here + const auto &decoratorNode = + std::static_pointer_cast( + parentNode.getChildren().at(index)); + // text input always precedes the decorator component + const auto &previousSibling = parentNode.getChildren().at(index - 1); + + if (const auto &textInputNode = + std::dynamic_pointer_cast( + previousSibling)) { + // store the pair of text input and decorator to update in the next step + // we need both, decorator to get markdown style and text input to + // update it + nodesToUpdate.push_back({ + textInputNode, + decoratorNode, + }); + } + } + }); + + for (const auto &nodes : nodesToUpdate) { + const auto &textInputState = + *std::static_pointer_cast>( + nodes.textInput->getState()); + const auto &stateData = textInputState.getData(); + const auto fontSizeMultiplier = + newRootShadowNode->getConcreteProps().layoutContext.fontSizeMultiplier; + + // We only want to update the shadow node when the attributed string is + // stored by value If it's stored by pointer, the markdown formatting should + // already by applied to it, since the only source of that pointer (besides + // this commit hook) is RCTTextInputComponentView, which has the relevant + // method swizzled to make sure the markdown styles are always applied + // before updating state. There are two caveats: + // 1. On the first render the swizzled method will not apply markdown since + // the native component + // is not mounted yet. In that case we save the tag to update in the + // method applying markdown formatting and apply it here instead, + // preventing wrong layout on reloads. + // 2. When the markdown style prop is changed, the native state needs to be + // updated to reflect + // them. In that case the relevant tag is saved in the registry when the + // new shadow node is created. + if (stateData.attributedStringBox.getMode() == + AttributedStringBox::Mode::Value || + MarkdownShadowFamilyRegistry::shouldForceUpdate( + nodes.textInput->getTag())) { + rootNode = rootNode->cloneTree( + nodes.textInput->getFamily(), + [&nodes, &textInputState, &stateData, + fontSizeMultiplier](const ShadowNode &node) { + const auto &markdownProps = *std::static_pointer_cast< + MarkdownTextInputDecoratorViewProps const>( + nodes.decorator->getProps()); + const auto &textInputProps = + *std::static_pointer_cast( + nodes.textInput->getProps()); + + const auto defaultTextAttributes = + textInputProps.getEffectiveTextAttributes(fontSizeMultiplier); + const auto defaultNSTextAttributes = + RCTNSTextAttributesFromTextAttributes(defaultTextAttributes); + + // this can possibly be optimized + RCTMarkdownStyle *markdownStyle = [[RCTMarkdownStyle alloc] + initWithStruct:markdownProps.markdownStyle]; + RCTMarkdownUtils *utils = [[RCTMarkdownUtils alloc] init]; + [utils setMarkdownStyle:markdownStyle]; + + // convert the attibuted string stored in state to + // NSAttributedString + auto nsAttributedString = + RCTNSAttributedStringFromAttributedStringBox( + stateData.attributedStringBox); + + // Handles the first render, where the text stored in props is + // different than the one stored in state The one in state is empty, + // while the one in props is passed from JS If we don't update the + // state here, we'll end up with a one-default-line-sized text + // input. A better condition to do that can be probably chosen, but + // this seems to work + auto plainString = + std::string([[nsAttributedString string] UTF8String]); + if (plainString != textInputProps.text) { + // creates new AttributedString from props, adapted from + // TextInputShadowNode (ios one, text inputs are + // platform-specific) + auto attributedString = AttributedString{}; + attributedString.appendFragment(AttributedString::Fragment{ + textInputProps.text, defaultTextAttributes}); + + auto attachments = BaseTextShadowNode::Attachments{}; + BaseTextShadowNode::buildAttributedString( + defaultTextAttributes, *nodes.textInput, attributedString, + attachments); + + // convert the newly created attributed string to + // NSAttributedString + nsAttributedString = RCTNSAttributedStringFromAttributedStringBox( + AttributedStringBox{attributedString}); + } + + // apply markdown + auto newString = [utils parseMarkdown:nsAttributedString + withAttributes:defaultNSTextAttributes]; + + // create a clone of the old TextInputState and update the + // attributed string box to point to the string with markdown + // applied + auto newStateData = std::make_shared(stateData); + newStateData->attributedStringBox = + RCTAttributedStringBoxFromNSAttributedString(newString); + + // clone the text input with the new state + return node.clone({ + .state = std::make_shared>( + newStateData, textInputState), + }); + }); + } + } + + return std::static_pointer_cast(rootNode); +} + +} // namespace livemarkdown + +#endif // RCT_NEW_ARCH_ENABLED diff --git a/ios/MarkdownTextInputDecoratorComponentView.mm b/ios/MarkdownTextInputDecoratorComponentView.mm index 91d46656..7fcfa965 100644 --- a/ios/MarkdownTextInputDecoratorComponentView.mm +++ b/ios/MarkdownTextInputDecoratorComponentView.mm @@ -1,18 +1,20 @@ // This guard prevent this file to be compiled in the old architecture. #ifdef RCT_NEW_ARCH_ENABLED -#import #import #import #import #import +#import +#import "MarkdownShadowFamilyRegistry.h" #import "RCTFabricComponentsPlugins.h" using namespace facebook::react; @implementation MarkdownTextInputDecoratorComponentView { MarkdownTextInputDecoratorView *_view; + ShadowNodeFamily::Shared _decoratorFamily; } + (ComponentDescriptorProvider)componentDescriptorProvider @@ -34,6 +36,27 @@ - (instancetype)initWithFrame:(CGRect)frame return self; } +- (void)updateState:(const facebook::react::State::Shared &)state oldState:(const facebook::react::State::Shared &)oldState +{ + auto data = std::static_pointer_cast(state)->getData(); + + if (_decoratorFamily != nullptr) { + MarkdownShadowFamilyRegistry::unregisterFamilyForUpdates(_decoratorFamily); + } + + _decoratorFamily = data.decoratorFamily; + MarkdownShadowFamilyRegistry::registerFamilyForUpdates(_decoratorFamily); +} + +- (void)willMoveToSuperview:(UIView *)newSuperview { + if (newSuperview == nil) { + MarkdownShadowFamilyRegistry::unregisterFamilyForUpdates(_decoratorFamily); + _decoratorFamily = nullptr; + } + + [super willMoveToSuperview:newSuperview]; +} + - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps { const auto &oldViewProps = *std::static_pointer_cast(_props); diff --git a/ios/MarkdownTextInputDecoratorView.mm b/ios/MarkdownTextInputDecoratorView.mm index 4539c2a2..0cec2191 100644 --- a/ios/MarkdownTextInputDecoratorView.mm +++ b/ios/MarkdownTextInputDecoratorView.mm @@ -58,7 +58,7 @@ - (void)didMoveToWindow { UIView *backedTextInputView = _textInput.backedTextInputView; #endif /* RCT_NEW_ARCH_ENABLED */ - _markdownUtils = [[RCTMarkdownUtils alloc] initWithBackedTextInputView:backedTextInputView]; + _markdownUtils = [[RCTMarkdownUtils alloc] init]; react_native_assert(_markdownStyle != nil); [_markdownUtils setMarkdownStyle:_markdownStyle]; @@ -100,7 +100,11 @@ - (void)setMarkdownStyle:(RCTMarkdownStyle *)markdownStyle { _markdownStyle = markdownStyle; [_markdownUtils setMarkdownStyle:markdownStyle]; +#ifdef RCT_NEW_ARCH_ENABLED + [_textInput textInputDidChange]; // apply new styles +#else [_textInput setAttributedText:_textInput.attributedText]; // apply new styles +#endif } @end diff --git a/ios/RCTBackedTextFieldDelegateAdapter+Markdown.m b/ios/RCTBackedTextFieldDelegateAdapter+Markdown.m index bb3423b6..11c3baf8 100644 --- a/ios/RCTBackedTextFieldDelegateAdapter+Markdown.m +++ b/ios/RCTBackedTextFieldDelegateAdapter+Markdown.m @@ -19,7 +19,7 @@ - (void)markdown_textFieldDidChange if (markdownUtils != nil) { RCTUITextField *backedTextInputView = [self valueForKey:@"_backedTextInputView"]; UITextRange *range = backedTextInputView.selectedTextRange; - backedTextInputView.attributedText = [markdownUtils parseMarkdown:backedTextInputView.attributedText]; + backedTextInputView.attributedText = [markdownUtils parseMarkdown:backedTextInputView.attributedText withAttributes:backedTextInputView.defaultTextAttributes]; [backedTextInputView setSelectedTextRange:range notifyDelegate:YES]; } diff --git a/ios/RCTBaseTextInputView+Markdown.m b/ios/RCTBaseTextInputView+Markdown.m index ec2ef82c..e5d5bacc 100644 --- a/ios/RCTBaseTextInputView+Markdown.m +++ b/ios/RCTBaseTextInputView+Markdown.m @@ -16,7 +16,7 @@ - (void)markdown_setAttributedText:(NSAttributedString *)attributedText { RCTMarkdownUtils *markdownUtils = [self getMarkdownUtils]; if (markdownUtils != nil) { - attributedText = [markdownUtils parseMarkdown:attributedText]; + attributedText = [markdownUtils parseMarkdown:attributedText withAttributes:self.backedTextInputView.defaultTextAttributes]; } // Call the original method @@ -29,7 +29,7 @@ - (void)markdown_updateLocalData if (markdownUtils != nil) { id backedTextInputView = self.backedTextInputView; NSAttributedString *oldAttributedText = backedTextInputView.attributedText; - NSAttributedString *newAttributedText = [markdownUtils parseMarkdown:oldAttributedText]; + NSAttributedString *newAttributedText = [markdownUtils parseMarkdown:oldAttributedText withAttributes:backedTextInputView.defaultTextAttributes]; UITextRange *range = backedTextInputView.selectedTextRange; // update attributed text without emitting onSelectionChange event diff --git a/ios/RCTLiveMarkdownModule.h b/ios/RCTLiveMarkdownModule.h new file mode 100644 index 00000000..ceee8fef --- /dev/null +++ b/ios/RCTLiveMarkdownModule.h @@ -0,0 +1,12 @@ +#ifdef RCT_NEW_ARCH_ENABLED + +#import +#import +#import + +// Without inheriting after RCTEventEmitter we don't get access to bridge +@interface RCTLiveMarkdownModule + : RCTEventEmitter +@end + +#endif // RCT_NEW_ARCH_ENABLED diff --git a/ios/RCTLiveMarkdownModule.mm b/ios/RCTLiveMarkdownModule.mm new file mode 100644 index 00000000..fa6eb39e --- /dev/null +++ b/ios/RCTLiveMarkdownModule.mm @@ -0,0 +1,48 @@ +#ifdef RCT_NEW_ARCH_ENABLED + +#import +#import + +#import "MarkdownCommitHook.h" +#import "MarkdownShadowFamilyRegistry.h" +#import "RCTLiveMarkdownModule.h" + +// A turbomodule used to register the commit hook +// I think this is the easiest way to access the UIManager, which we need to +// actually register the hook + +@implementation RCTLiveMarkdownModule { + BOOL installed_; + std::shared_ptr commitHook_; +} + +RCT_EXPORT_MODULE(@"LiveMarkdownModule") + +- (NSNumber *)install { + if (!installed_) { + installed_ = YES; + + RCTBridge *bridge = self.bridge; + RCTSurfacePresenter *surfacePresenter = bridge.surfacePresenter; + RCTScheduler *scheduler = [surfacePresenter scheduler]; + + commitHook_ = + std::make_shared(scheduler.uiManager); + } + return @1; +} + +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params { + return std::make_shared( + params); +} + +- (void)invalidate { + MarkdownShadowFamilyRegistry::reset(); + [super invalidate]; +} + +@end + +#endif // RCT_NEW_ARCH_ENABLED diff --git a/ios/RCTMarkdownUtils.h b/ios/RCTMarkdownUtils.h index 47c395f9..11916b97 100644 --- a/ios/RCTMarkdownUtils.h +++ b/ios/RCTMarkdownUtils.h @@ -9,9 +9,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic) NSMutableArray *blockquoteRangesAndLevels; @property (weak, nonatomic) UIView *backedTextInputView; -- (instancetype)initWithBackedTextInputView:(UIView *)backedTextInputView; - -- (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input; +- (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input withAttributes:(nullable NSDictionary*)attributes; @end diff --git a/ios/RCTMarkdownUtils.mm b/ios/RCTMarkdownUtils.mm index 5870671b..a5228843 100644 --- a/ios/RCTMarkdownUtils.mm +++ b/ios/RCTMarkdownUtils.mm @@ -11,145 +11,138 @@ @implementation RCTMarkdownUtils { __weak RCTMarkdownStyle *_prevMarkdownStyle; } -- (instancetype)initWithBackedTextInputView:(UIView *)backedTextInputView +- (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input withAttributes:(nullable NSDictionary *)attributes { - if (self = [super init]) { - _backedTextInputView = backedTextInputView; - } - return self; -} - -- (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input -{ - RCTAssertMainQueue(); - - if (input == nil) { - return nil; - } - - NSString *inputString = [input string]; - if ([inputString isEqualToString:_prevInputString] && [_backedTextInputView.defaultTextAttributes isEqualToDictionary:_prevTextAttributes] && [_markdownStyle isEqual:_prevMarkdownStyle]) { - return _prevAttributedString; - } - - static JSContext *ctx = nil; - static JSValue *function = nil; - if (ctx == nil) { - NSString *path = [[NSBundle mainBundle] pathForResource:@"react-native-live-markdown-parser" ofType:@"js"]; - assert(path != nil && "[react-native-live-markdown] Markdown parser bundle not found"); - NSString *content = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:NULL]; - assert(content != nil && "[react-native-live-markdown] Markdown parser bundle is empty"); - ctx = [[JSContext alloc] init]; - [ctx evaluateScript:content]; - function = ctx[@"parseExpensiMarkToRanges"]; - } - - JSValue *result = [function callWithArguments:@[inputString]]; - NSArray *ranges = [result toArray]; - - NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:inputString attributes:_backedTextInputView.defaultTextAttributes]; - [attributedString beginEditing]; - - // If the attributed string ends with underlined text, blurring the single-line input imprints the underline style across the whole string. - // It looks like a bug in iOS, as there is no underline style to be found in the attributed string, especially after formatting. - // This is a workaround that applies the NSUnderlineStyleNone to the string before iterating over ranges which resolves this problem. - [attributedString addAttribute:NSUnderlineStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleNone] range:NSMakeRange(0, attributedString.length)]; - - _blockquoteRangesAndLevels = [NSMutableArray new]; - - [ranges enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - NSDictionary *item = obj; - NSString *type = [item valueForKey:@"type"]; - NSInteger location = [[item valueForKey:@"start"] unsignedIntegerValue]; - NSInteger length = [[item valueForKey:@"length"] unsignedIntegerValue]; - NSInteger depth = [[item valueForKey:@"depth"] unsignedIntegerValue] ?: 1; - NSRange range = NSMakeRange(location, length); - - if ([type isEqualToString:@"bold"] || [type isEqualToString:@"italic"] || [type isEqualToString:@"code"] || [type isEqualToString:@"pre"] || [type isEqualToString:@"h1"] || [type isEqualToString:@"emoji"]) { - UIFont *font = [attributedString attribute:NSFontAttributeName atIndex:location effectiveRange:NULL]; - if ([type isEqualToString:@"bold"]) { - font = [RCTFont updateFont:font withWeight:@"bold"]; - } else if ([type isEqualToString:@"italic"]) { - font = [RCTFont updateFont:font withStyle:@"italic"]; - } else if ([type isEqualToString:@"code"]) { - font = [RCTFont updateFont:font withFamily:_markdownStyle.codeFontFamily - size:[NSNumber numberWithFloat:_markdownStyle.codeFontSize] - weight:nil - style:nil - variant:nil - scaleMultiplier:0]; - } else if ([type isEqualToString:@"pre"]) { - font = [RCTFont updateFont:font withFamily:_markdownStyle.preFontFamily - size:[NSNumber numberWithFloat:_markdownStyle.preFontSize] - weight:nil - style:nil - variant:nil - scaleMultiplier:0]; - } else if ([type isEqualToString:@"h1"]) { - font = [RCTFont updateFont:font withFamily:nil - size:[NSNumber numberWithFloat:_markdownStyle.h1FontSize] - weight:@"bold" - style:nil - variant:nil - scaleMultiplier:0]; - } else if ([type isEqualToString:@"emoji"]) { - font = [RCTFont updateFont:font withFamily:nil - size:[NSNumber numberWithFloat:_markdownStyle.emojiFontSize] - weight:nil - style:nil - variant:nil - scaleMultiplier:0]; - } - [attributedString addAttribute:NSFontAttributeName value:font range:range]; - } + @synchronized (self) { + if (input == nil) { + return nil; + } + + NSString *inputString = [input string]; + if ([inputString isEqualToString:_prevInputString] && [attributes isEqualToDictionary:_prevTextAttributes] && [_markdownStyle isEqual:_prevMarkdownStyle]) { + return _prevAttributedString; + } + + static JSContext *ctx = nil; + static JSValue *function = nil; + if (ctx == nil) { + NSString *path = [[NSBundle mainBundle] pathForResource:@"react-native-live-markdown-parser" ofType:@"js"]; + assert(path != nil && "[react-native-live-markdown] Markdown parser bundle not found"); + NSString *content = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:NULL]; + assert(content != nil && "[react-native-live-markdown] Markdown parser bundle is empty"); + ctx = [[JSContext alloc] init]; + [ctx evaluateScript:content]; + function = ctx[@"parseExpensiMarkToRanges"]; + } + + JSValue *result = [function callWithArguments:@[inputString]]; + NSArray *ranges = [result toArray]; + + NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:inputString attributes:attributes]; + [attributedString beginEditing]; + + // If the attributed string ends with underlined text, blurring the single-line input imprints the underline style across the whole string. + // It looks like a bug in iOS, as there is no underline style to be found in the attributed string, especially after formatting. + // This is a workaround that applies the NSUnderlineStyleNone to the string before iterating over ranges which resolves this problem. + [attributedString addAttribute:NSUnderlineStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleNone] range:NSMakeRange(0, attributedString.length)]; + + _blockquoteRangesAndLevels = [NSMutableArray new]; + + [ranges enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + NSDictionary *item = obj; + NSString *type = [item valueForKey:@"type"]; + NSInteger location = [[item valueForKey:@"start"] unsignedIntegerValue]; + NSInteger length = [[item valueForKey:@"length"] unsignedIntegerValue]; + NSInteger depth = [[item valueForKey:@"depth"] unsignedIntegerValue] ?: 1; + NSRange range = NSMakeRange(location, length); + + if ([type isEqualToString:@"bold"] || [type isEqualToString:@"italic"] || [type isEqualToString:@"code"] || [type isEqualToString:@"pre"] || [type isEqualToString:@"h1"] || [type isEqualToString:@"emoji"]) { + UIFont *font = [attributedString attribute:NSFontAttributeName atIndex:location effectiveRange:NULL]; + if ([type isEqualToString:@"bold"]) { + font = [RCTFont updateFont:font withWeight:@"bold"]; + } else if ([type isEqualToString:@"italic"]) { + font = [RCTFont updateFont:font withStyle:@"italic"]; + } else if ([type isEqualToString:@"code"]) { + font = [RCTFont updateFont:font withFamily:_markdownStyle.codeFontFamily + size:[NSNumber numberWithFloat:_markdownStyle.codeFontSize] + weight:nil + style:nil + variant:nil + scaleMultiplier:0]; + } else if ([type isEqualToString:@"pre"]) { + font = [RCTFont updateFont:font withFamily:_markdownStyle.preFontFamily + size:[NSNumber numberWithFloat:_markdownStyle.preFontSize] + weight:nil + style:nil + variant:nil + scaleMultiplier:0]; + } else if ([type isEqualToString:@"h1"]) { + font = [RCTFont updateFont:font withFamily:nil + size:[NSNumber numberWithFloat:_markdownStyle.h1FontSize] + weight:@"bold" + style:nil + variant:nil + scaleMultiplier:0]; + } else if ([type isEqualToString:@"emoji"]) { + font = [RCTFont updateFont:font withFamily:nil + size:[NSNumber numberWithFloat:_markdownStyle.emojiFontSize] + weight:nil + style:nil + variant:nil + scaleMultiplier:0]; + } + [attributedString addAttribute:NSFontAttributeName value:font range:range]; + } + + if ([type isEqualToString:@"syntax"]) { + [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.syntaxColor range:range]; + } else if ([type isEqualToString:@"strikethrough"]) { + [attributedString addAttribute:NSStrikethroughStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleSingle] range:range]; + } else if ([type isEqualToString:@"code"]) { + [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.codeColor range:range]; + [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.codeBackgroundColor range:range]; + } else if ([type isEqualToString:@"mention-here"]) { + [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.mentionHereColor range:range]; + [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.mentionHereBackgroundColor range:range]; + } else if ([type isEqualToString:@"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]; + } else if ([type isEqualToString:@"link"]) { + [attributedString addAttribute:NSUnderlineStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleSingle] range:range]; + [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.linkColor range:range]; + } else if ([type isEqualToString:@"blockquote"]) { + CGFloat indent = (_markdownStyle.blockquoteMarginLeft + _markdownStyle.blockquoteBorderWidth + _markdownStyle.blockquotePaddingLeft) * depth; + NSMutableParagraphStyle *paragraphStyle = [NSMutableParagraphStyle new]; + paragraphStyle.firstLineHeadIndent = indent; + paragraphStyle.headIndent = indent; + [attributedString addAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:range]; + [_blockquoteRangesAndLevels addObject:@{ + @"range": [NSValue valueWithRange:range], + @"depth": @(depth) + }]; + } else if ([type isEqualToString:@"pre"]) { + [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.preColor range:range]; + NSRange rangeForBackground = [inputString characterAtIndex:range.location] == '\n' ? NSMakeRange(range.location + 1, range.length - 1) : range; + [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.preBackgroundColor range:rangeForBackground]; + // TODO: pass background color and ranges to layout manager + } else if ([type isEqualToString:@"h1"]) { + NSMutableParagraphStyle *paragraphStyle = [NSMutableParagraphStyle new]; + NSRange rangeWithHashAndSpace = NSMakeRange(range.location - 2, range.length + 2); // we also need to include prepending "# " + [attributedString addAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:rangeWithHashAndSpace]; + } + }]; + + [attributedString endEditing]; + + _prevInputString = inputString; + _prevAttributedString = attributedString; + _prevTextAttributes = attributes; + _prevMarkdownStyle = _markdownStyle; + + return attributedString; - if ([type isEqualToString:@"syntax"]) { - [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.syntaxColor range:range]; - } else if ([type isEqualToString:@"strikethrough"]) { - [attributedString addAttribute:NSStrikethroughStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleSingle] range:range]; - } else if ([type isEqualToString:@"code"]) { - [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.codeColor range:range]; - [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.codeBackgroundColor range:range]; - } else if ([type isEqualToString:@"mention-here"]) { - [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.mentionHereColor range:range]; - [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.mentionHereBackgroundColor range:range]; - } else if ([type isEqualToString:@"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]; - } else if ([type isEqualToString:@"link"]) { - [attributedString addAttribute:NSUnderlineStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleSingle] range:range]; - [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.linkColor range:range]; - } else if ([type isEqualToString:@"blockquote"]) { - CGFloat indent = (_markdownStyle.blockquoteMarginLeft + _markdownStyle.blockquoteBorderWidth + _markdownStyle.blockquotePaddingLeft) * depth; - NSMutableParagraphStyle *paragraphStyle = [NSMutableParagraphStyle new]; - paragraphStyle.firstLineHeadIndent = indent; - paragraphStyle.headIndent = indent; - [attributedString addAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:range]; - [_blockquoteRangesAndLevels addObject:@{ - @"range": [NSValue valueWithRange:range], - @"depth": @(depth) - }]; - } else if ([type isEqualToString:@"pre"]) { - [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.preColor range:range]; - NSRange rangeForBackground = [inputString characterAtIndex:range.location] == '\n' ? NSMakeRange(range.location + 1, range.length - 1) : range; - [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.preBackgroundColor range:rangeForBackground]; - // TODO: pass background color and ranges to layout manager - } else if ([type isEqualToString:@"h1"]) { - NSMutableParagraphStyle *paragraphStyle = [NSMutableParagraphStyle new]; - NSRange rangeWithHashAndSpace = NSMakeRange(range.location - 2, range.length + 2); // we also need to include prepending "# " - [attributedString addAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:rangeWithHashAndSpace]; } - }]; - - [attributedString endEditing]; - - _prevInputString = inputString; - _prevAttributedString = attributedString; - _prevTextAttributes = _backedTextInputView.defaultTextAttributes; - _prevMarkdownStyle = _markdownStyle; - - return attributedString; } @end diff --git a/ios/RCTTextInputComponentView+Markdown.mm b/ios/RCTTextInputComponentView+Markdown.mm index f729ce61..7c79cfed 100644 --- a/ios/RCTTextInputComponentView+Markdown.mm +++ b/ios/RCTTextInputComponentView+Markdown.mm @@ -6,6 +6,8 @@ #import #import +#import "MarkdownShadowFamilyRegistry.h" + @implementation RCTTextInputComponentView (Markdown) - (void)setMarkdownUtils:(RCTMarkdownUtils *)markdownUtils { @@ -13,8 +15,8 @@ - (void)setMarkdownUtils:(RCTMarkdownUtils *)markdownUtils { if (markdownUtils != nil) { // force Markdown formatting on first render because `_setAttributedText` is called before `setMarkdownUtils` - RCTUITextField *backedTextInputView = [self valueForKey:@"_backedTextInputView"]; - backedTextInputView.attributedText = [markdownUtils parseMarkdown:backedTextInputView.attributedText]; + RCTUITextField *backedTextInputView = [self getBackedTextInputView]; + backedTextInputView.attributedText = [markdownUtils parseMarkdown:backedTextInputView.attributedText withAttributes:backedTextInputView.defaultTextAttributes]; } } @@ -22,11 +24,23 @@ - (RCTMarkdownUtils *)getMarkdownUtils { return objc_getAssociatedObject(self, @selector(getMarkdownUtils)); } +- (RCTUITextField *)getBackedTextInputView { + RCTUITextField *backedTextInputView = [self valueForKey:@"_backedTextInputView"]; + return backedTextInputView; +} + - (void)markdown__setAttributedString:(NSAttributedString *)attributedString { RCTMarkdownUtils *markdownUtils = [self getMarkdownUtils]; - if (markdownUtils != nil) { - attributedString = [markdownUtils parseMarkdown:attributedString]; + RCTUITextField *backedTextInputView = [self getBackedTextInputView]; + if (markdownUtils != nil && backedTextInputView != nil) { + attributedString = [markdownUtils parseMarkdown:attributedString withAttributes:backedTextInputView.defaultTextAttributes]; + } else { + // If markdownUtils is undefined, the text input hasn't been mounted yet. It will + // update its state with the unformatted attributed string, we want to prevent displaying + // this state by applying markdown in the commit hook where we can read markdown styles + // from decorator props. + MarkdownShadowFamilyRegistry::forceNextStateUpdate((facebook::react::Tag)self.tag); } // Call the original method diff --git a/ios/RCTUITextView+Markdown.mm b/ios/RCTUITextView+Markdown.mm index dd08b87b..70f2d882 100644 --- a/ios/RCTUITextView+Markdown.mm +++ b/ios/RCTUITextView+Markdown.mm @@ -17,7 +17,7 @@ - (void)markdown_textDidChange RCTMarkdownUtils *markdownUtils = [self getMarkdownUtils]; if (markdownUtils != nil) { UITextRange *range = self.selectedTextRange; - super.attributedText = [markdownUtils parseMarkdown:self.attributedText]; + super.attributedText = [markdownUtils parseMarkdown:self.attributedText withAttributes:self.defaultTextAttributes]; [super setSelectedTextRange:range]; // prevents cursor from jumping at the end when typing in the middle of the text self.typingAttributes = self.defaultTextAttributes; // removes indent in new line when typing after blockquote } diff --git a/package.json b/package.json index 37058395..684fcacd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@expensify/react-native-live-markdown", - "version": "0.1.44", + "version": "0.1.46", "description": "Drop-in replacement for React Native's TextInput component with Markdown formatting.", "main": "lib/commonjs/index", "module": "lib/module/index", @@ -15,6 +15,7 @@ "ios", "cpp", "*.podspec", + "react-native.config.js", "!ios/build", "!android/build", "!android/gradle", @@ -156,7 +157,7 @@ }, "codegenConfig": { "name": "RNLiveMarkdownSpec", - "type": "components", + "type": "all", "jsSrcsDir": "src" } } diff --git a/react-native.config.js b/react-native.config.js new file mode 100644 index 00000000..2151248f --- /dev/null +++ b/react-native.config.js @@ -0,0 +1,12 @@ +module.exports = { + dependency: { + platforms: { + android: { + componentDescriptors: [ + "MarkdownTextInputDecoratorViewComponentDescriptor", + ], + cmakeListsPath: "../android/src/main/new_arch/CMakeLists.txt" + }, + }, + }, +} diff --git a/src/MarkdownTextInput.tsx b/src/MarkdownTextInput.tsx index 41a00187..95a24f0a 100644 --- a/src/MarkdownTextInput.tsx +++ b/src/MarkdownTextInput.tsx @@ -2,10 +2,15 @@ import {StyleSheet, TextInput, processColor} from 'react-native'; import React from 'react'; import type {TextInputProps} from 'react-native'; import MarkdownTextInputDecoratorViewNativeComponent from './MarkdownTextInputDecoratorViewNativeComponent'; +import NativeLiveMarkdownModule from './NativeLiveMarkdownModule'; import type * as MarkdownTextInputDecoratorViewNativeComponentTypes from './MarkdownTextInputDecoratorViewNativeComponent'; import * as StyleUtils from './styleUtils'; import type * as StyleUtilsTypes from './styleUtils'; +if (NativeLiveMarkdownModule) { + NativeLiveMarkdownModule.install(); +} + type PartialMarkdownStyle = StyleUtilsTypes.PartialMarkdownStyle; type MarkdownStyle = MarkdownTextInputDecoratorViewNativeComponentTypes.MarkdownStyle; diff --git a/src/MarkdownTextInputDecoratorViewNativeComponent.ts b/src/MarkdownTextInputDecoratorViewNativeComponent.ts index 90ce575a..729d183c 100644 --- a/src/MarkdownTextInputDecoratorViewNativeComponent.ts +++ b/src/MarkdownTextInputDecoratorViewNativeComponent.ts @@ -48,6 +48,8 @@ interface NativeProps extends ViewProps { markdownStyle: MarkdownStyle; } -export default codegenNativeComponent('MarkdownTextInputDecoratorView'); +export default codegenNativeComponent('MarkdownTextInputDecoratorView', { + interfaceOnly: true, +}); export type {MarkdownStyle}; diff --git a/src/NativeLiveMarkdownModule.ts b/src/NativeLiveMarkdownModule.ts new file mode 100644 index 00000000..c847838f --- /dev/null +++ b/src/NativeLiveMarkdownModule.ts @@ -0,0 +1,8 @@ +import type {TurboModule} from 'react-native'; +import {TurboModuleRegistry} from 'react-native'; + +interface Spec extends TurboModule { + install: () => boolean; +} + +export default TurboModuleRegistry.get('LiveMarkdownModule'); diff --git a/yarn.lock b/yarn.lock index 00997888..6e9d2666 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1830,7 +1830,9 @@ __metadata: "@react-native/babel-preset": 0.73.21 "@react-native/metro-config": 0.73.5 babel-plugin-module-resolver: ^5.0.0 + patch-package: ^8.0.0 pod-install: ^0.1.0 + postinstall-postinstall: ^2.1.0 react: 18.2.0 react-native: 0.73.6 languageName: unknown @@ -3796,6 +3798,13 @@ __metadata: languageName: node linkType: hard +"@yarnpkg/lockfile@npm:^1.1.0": + version: 1.1.0 + resolution: "@yarnpkg/lockfile@npm:1.1.0" + checksum: 05b881b4866a3546861fee756e6d3812776ea47fa6eb7098f983d6d0eefa02e12b66c3fff931574120f196286a7ad4879ce02743c8bb2be36c6a576c7852083a + languageName: node + linkType: hard + "JSONStream@npm:^1.0.4": version: 1.3.5 resolution: "JSONStream@npm:1.3.5" @@ -4283,6 +4292,13 @@ __metadata: languageName: node linkType: hard +"at-least-node@npm:^1.0.0": + version: 1.0.0 + resolution: "at-least-node@npm:1.0.0" + checksum: 463e2f8e43384f1afb54bc68485c436d7622acec08b6fad269b421cb1d29cebb5af751426793d0961ed243146fe4dc983402f6d5a51b720b277818dbf6f2e49e + languageName: node + linkType: hard + "available-typed-arrays@npm:^1.0.5": version: 1.0.5 resolution: "available-typed-arrays@npm:1.0.5" @@ -4875,7 +4891,7 @@ __metadata: languageName: node linkType: hard -"ci-info@npm:^3.2.0": +"ci-info@npm:^3.2.0, ci-info@npm:^3.7.0": version: 3.9.0 resolution: "ci-info@npm:3.9.0" checksum: 6b19dc9b2966d1f8c2041a838217299718f15d6c4b63ae36e4674edd2bee48f780e94761286a56aa59eb305a85fbea4ddffb7630ec063e7ec7e7e5ad42549a87 @@ -7289,6 +7305,15 @@ __metadata: languageName: node linkType: hard +"find-yarn-workspace-root@npm:^2.0.0": + version: 2.0.0 + resolution: "find-yarn-workspace-root@npm:2.0.0" + dependencies: + micromatch: ^4.0.2 + checksum: fa5ca8f9d08fe7a54ce7c0a5931ff9b7e36f9ee7b9475fb13752bcea80ec6b5f180fa5102d60b376d5526ce924ea3fc6b19301262efa0a5d248dd710f3644242 + languageName: node + linkType: hard + "flat-cache@npm:^2.0.1": version: 2.0.1 resolution: "flat-cache@npm:2.0.1" @@ -7421,6 +7446,18 @@ __metadata: languageName: node linkType: hard +"fs-extra@npm:^9.0.0": + version: 9.1.0 + resolution: "fs-extra@npm:9.1.0" + dependencies: + at-least-node: ^1.0.0 + graceful-fs: ^4.2.0 + jsonfile: ^6.0.1 + universalify: ^2.0.0 + checksum: ba71ba32e0faa74ab931b7a0031d1523c66a73e225de7426e275e238e312d07313d2da2d33e34a52aa406c8763ade5712eb3ec9ba4d9edce652bcacdc29e6b20 + languageName: node + linkType: hard + "fs-minipass@npm:^2.0.0": version: 2.1.0 resolution: "fs-minipass@npm:2.1.0" @@ -9751,6 +9788,18 @@ __metadata: languageName: node linkType: hard +"json-stable-stringify@npm:^1.0.2": + version: 1.1.1 + resolution: "json-stable-stringify@npm:1.1.1" + dependencies: + call-bind: ^1.0.5 + isarray: ^2.0.5 + jsonify: ^0.0.1 + object-keys: ^1.1.1 + checksum: e1ba06600fd278767eeff53f28e408e29c867e79abf564e7aadc3ce8f31f667258f8db278ef28831e45884dd687388fa1910f46e599fc19fb94c9afbbe3a4de8 + languageName: node + linkType: hard + "json-stringify-safe@npm:^5.0.1": version: 5.0.1 resolution: "json-stringify-safe@npm:5.0.1" @@ -9803,6 +9852,13 @@ __metadata: languageName: node linkType: hard +"jsonify@npm:^0.0.1": + version: 0.0.1 + resolution: "jsonify@npm:0.0.1" + checksum: 027287e1c0294fce15f18c0ff990cfc2318e7f01fb76515f784d5cd0784abfec6fc5c2355c3a2f2cb0ad7f4aa2f5b74ebbfe4e80476c35b2d13cabdb572e1134 + languageName: node + linkType: hard + "jsonparse@npm:^1.2.0": version: 1.3.1 resolution: "jsonparse@npm:1.3.1" @@ -9848,6 +9904,15 @@ __metadata: languageName: node linkType: hard +"klaw-sync@npm:^6.0.0": + version: 6.0.0 + resolution: "klaw-sync@npm:6.0.0" + dependencies: + graceful-fs: ^4.1.11 + checksum: 0da397f8961313c3ef8f79fb63af9002cde5a8fb2aeb1a37351feff0dd6006129c790400c3f5c3b4e757bedcabb13d21ec0a5eaef5a593d59515d4f2c291e475 + languageName: node + linkType: hard + "kleur@npm:^3.0.3": version: 3.0.3 resolution: "kleur@npm:3.0.3" @@ -10522,7 +10587,7 @@ __metadata: languageName: node linkType: hard -"micromatch@npm:^4.0.4": +"micromatch@npm:^4.0.2, micromatch@npm:^4.0.4": version: 4.0.5 resolution: "micromatch@npm:4.0.5" dependencies: @@ -11188,7 +11253,7 @@ __metadata: languageName: node linkType: hard -"open@npm:^7.0.3": +"open@npm:^7.0.3, open@npm:^7.4.2": version: 7.4.2 resolution: "open@npm:7.4.2" dependencies: @@ -11482,6 +11547,31 @@ __metadata: languageName: node linkType: hard +"patch-package@npm:^8.0.0": + version: 8.0.0 + resolution: "patch-package@npm:8.0.0" + dependencies: + "@yarnpkg/lockfile": ^1.1.0 + chalk: ^4.1.2 + ci-info: ^3.7.0 + cross-spawn: ^7.0.3 + find-yarn-workspace-root: ^2.0.0 + fs-extra: ^9.0.0 + json-stable-stringify: ^1.0.2 + klaw-sync: ^6.0.0 + minimist: ^1.2.6 + open: ^7.4.2 + rimraf: ^2.6.3 + semver: ^7.5.3 + slash: ^2.0.0 + tmp: ^0.0.33 + yaml: ^2.2.2 + bin: + patch-package: index.js + checksum: d23cddc4d1622e2d8c7ca31b145c6eddb24bd271f69905e766de5e1f199f0b9a5479a6a6939ea857288399d4ed249285639d539a2c00fbddb7daa39934b007a2 + languageName: node + linkType: hard + "path-exists@npm:^3.0.0": version: 3.0.0 resolution: "path-exists@npm:3.0.0" @@ -11642,6 +11732,13 @@ __metadata: languageName: node linkType: hard +"postinstall-postinstall@npm:^2.1.0": + version: 2.1.0 + resolution: "postinstall-postinstall@npm:2.1.0" + checksum: e1d34252cf8d2c5641c7d2db7426ec96e3d7a975f01c174c68f09ef5b8327bc8d5a9aa2001a45e693db2cdbf69577094d3fe6597b564ad2d2202b65fba76134b + languageName: node + linkType: hard + "prelude-ls@npm:^1.2.1": version: 1.2.1 resolution: "prelude-ls@npm:1.2.1" @@ -12650,6 +12747,17 @@ __metadata: languageName: node linkType: hard +"rimraf@npm:^2.6.3": + version: 2.7.1 + resolution: "rimraf@npm:2.7.1" + dependencies: + glob: ^7.1.3 + bin: + rimraf: ./bin.js + checksum: cdc7f6eacb17927f2a075117a823e1c5951792c6498ebcce81ca8203454a811d4cf8900314154d3259bb8f0b42ab17f67396a8694a54cae3283326e57ad250cd + languageName: node + linkType: hard + "rimraf@npm:^3.0.2": version: 3.0.2 resolution: "rimraf@npm:3.0.2" @@ -13011,6 +13119,13 @@ __metadata: languageName: node linkType: hard +"slash@npm:^2.0.0": + version: 2.0.0 + resolution: "slash@npm:2.0.0" + checksum: 512d4350735375bd11647233cb0e2f93beca6f53441015eea241fe784d8068281c3987fbaa93e7ef1c38df68d9c60013045c92837423c69115297d6169aa85e6 + languageName: node + linkType: hard + "slash@npm:^3.0.0": version: 3.0.0 resolution: "slash@npm:3.0.0" @@ -14678,6 +14793,15 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.2.2": + version: 2.4.1 + resolution: "yaml@npm:2.4.1" + bin: + yaml: bin.mjs + checksum: 4c391d07a5d5e935e058babb71026c9cdc9a6fd889e35dd91b53cfb0a12691b67c6c5c740858e71345fef18cd9c13c554a6dda9196f59820d769d94041badb0b + languageName: node + linkType: hard + "yargs-parser@npm:21.1.1, yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1"