diff --git a/README.md b/README.md index e5c2c7efa..99027bd8c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ npm install @expensify/react-native-live-markdown react-native-reanimated expens npx expo install @expensify/react-native-live-markdown react-native-reanimated expensify-common ``` -React Native Live Markdown requires [react-native-reanimated](https://github.com/software-mansion/react-native-reanimated) 3.16.3 or newer and [expensify-common](https://github.com/Expensify/expensify-common) 2.0.106 or newer. +React Native Live Markdown requires [react-native-reanimated](https://github.com/software-mansion/react-native-reanimated) 3.16.4 or newer and [expensify-common](https://github.com/Expensify/expensify-common) 2.0.108 or newer. Then, install the iOS dependencies with CocoaPods: diff --git a/RNLiveMarkdown.podspec b/RNLiveMarkdown.podspec index de9baafc1..34e9fd00c 100644 --- a/RNLiveMarkdown.podspec +++ b/RNLiveMarkdown.podspec @@ -4,6 +4,10 @@ react_native_node_modules_dir = ENV['REACT_NATIVE_NODE_MODULES_DIR'] || File.joi react_native_json = JSON.parse(File.read(File.join(react_native_node_modules_dir, 'react-native/package.json'))) react_native_minor_version = react_native_json['version'].split('.')[1].to_i +pods_root = Pod::Config.instance.project_pods_root +react_native_reanimated_node_modules_dir = ENV['REACT_NATIVE_REANIMATED_NODE_MODULES_DIR'] || File.dirname(`cd "#{Pod::Config.instance.installation_root.to_s}" && node --print "require.resolve('react-native-reanimated/package.json')"`) +react_native_reanimated_node_modules_dir_from_pods_root = Pathname.new(react_native_reanimated_node_modules_dir).relative_path_from(pods_root).to_s + package = JSON.parse(File.read(File.join(__dir__, "package.json"))) folly_compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -Wno-comma -Wno-shorten-64-to-32' @@ -23,7 +27,11 @@ Pod::Spec.new do |s| s.dependency "RNReanimated/worklets" s.xcconfig = { - "OTHER_CFLAGS" => "$(inherited) -DREACT_NATIVE_MINOR_VERSION=#{react_native_minor_version}" + "OTHER_CFLAGS" => "$(inherited) -DREACT_NATIVE_MINOR_VERSION=#{react_native_minor_version}", + "HEADER_SEARCH_PATHS" => [ + "\"$(PODS_ROOT)/#{react_native_reanimated_node_modules_dir_from_pods_root}/apple\"", + "\"$(PODS_ROOT)/#{react_native_reanimated_node_modules_dir_from_pods_root}/Common/cpp\"", + ].join(' '), } install_modules_dependencies(s) @@ -33,6 +41,7 @@ Pod::Spec.new do |s| "react/renderer/textlayoutmanager/platform/ios", "react/renderer/components/textinput/platform/ios", ]) + add_dependency(s, "React-rendererconsistency") end if ENV['RCT_NEW_ARCH_ENABLED'] == '1' diff --git a/android/src/main/cpp/MarkdownParser.cpp b/android/src/main/cpp/MarkdownParser.cpp new file mode 100644 index 000000000..b645eb48b --- /dev/null +++ b/android/src/main/cpp/MarkdownParser.cpp @@ -0,0 +1,35 @@ +#include "MarkdownParser.h" +#include "MarkdownGlobal.h" + +#include + +using namespace facebook; + +namespace expensify { +namespace livemarkdown { + jni::local_ref MarkdownParser::nativeParse( + jni::alias_ref jThis, + jni::alias_ref text, + const int parserId) { + static std::mutex workletRuntimeMutex; // this needs to be global since the worklet runtime is also global + const auto lock = std::lock_guard(workletRuntimeMutex); + + const auto markdownRuntime = expensify::livemarkdown::getMarkdownRuntime(); + jsi::Runtime &rt = markdownRuntime->getJSIRuntime(); + + const auto markdownWorklet = expensify::livemarkdown::getMarkdownWorklet(parserId); + + const auto input = jsi::String::createFromUtf8(rt, text->toStdString()); + const auto output = markdownRuntime->runGuarded(markdownWorklet, input); + + const auto json = rt.global().getPropertyAsObject(rt, "JSON").getPropertyAsFunction(rt, "stringify").call(rt, output).asString(rt).utf8(rt); + return jni::make_jstring(json); + } + + void MarkdownParser::registerNatives() { + registerHybrid({ + makeNativeMethod("nativeParse", MarkdownParser::nativeParse)}); + } + +} // namespace livemarkdown +} // namespace expensify diff --git a/android/src/main/cpp/MarkdownUtils.h b/android/src/main/cpp/MarkdownParser.h similarity index 71% rename from android/src/main/cpp/MarkdownUtils.h rename to android/src/main/cpp/MarkdownParser.h index 4a509b873..fc3313b54 100644 --- a/android/src/main/cpp/MarkdownUtils.h +++ b/android/src/main/cpp/MarkdownParser.h @@ -15,16 +15,16 @@ using namespace facebook; namespace expensify { namespace livemarkdown { - class MarkdownUtils : public jni::HybridClass, + class MarkdownParser : public jni::HybridClass, public jsi::HostObject { public: static constexpr auto kJavaDescriptor = - "Lcom/expensify/livemarkdown/MarkdownUtils;"; + "Lcom/expensify/livemarkdown/MarkdownParser;"; - static jni::local_ref nativeParseMarkdown( + static jni::local_ref nativeParse( jni::alias_ref jThis, - jni::alias_ref input, - int parserId); + jni::alias_ref text, + const int parserId); static void registerNatives(); diff --git a/android/src/main/cpp/MarkdownUtils.cpp b/android/src/main/cpp/MarkdownUtils.cpp deleted file mode 100644 index 9621e46f4..000000000 --- a/android/src/main/cpp/MarkdownUtils.cpp +++ /dev/null @@ -1,33 +0,0 @@ -#include "MarkdownUtils.h" -#include "MarkdownGlobal.h" - -#include - -using namespace facebook; - -namespace expensify { -namespace livemarkdown { - jni::local_ref MarkdownUtils::nativeParseMarkdown( - jni::alias_ref jThis, - jni::alias_ref input, - int parserId) { - // This method is synchronized (see MarkdownUtils.java) so we don't need a mutex here. - const auto markdownRuntime = expensify::livemarkdown::getMarkdownRuntime(); - jsi::Runtime &rt = markdownRuntime->getJSIRuntime(); - - const auto markdownWorklet = expensify::livemarkdown::getMarkdownWorklet(parserId); - - const auto text = jsi::String::createFromUtf8(rt, input->toStdString()); - const auto result = markdownRuntime->runGuarded(markdownWorklet, text); - - const auto json = rt.global().getPropertyAsObject(rt, "JSON").getPropertyAsFunction(rt, "stringify").call(rt, result).asString(rt).utf8(rt); - return jni::make_jstring(json); - } - - void MarkdownUtils::registerNatives() { - registerHybrid({ - makeNativeMethod("nativeParseMarkdown", MarkdownUtils::nativeParseMarkdown)}); - } - -} // namespace livemarkdown -} // namespace expensify diff --git a/android/src/main/cpp/OnLoad.cpp b/android/src/main/cpp/OnLoad.cpp index 470863409..7daa4a33b 100644 --- a/android/src/main/cpp/OnLoad.cpp +++ b/android/src/main/cpp/OnLoad.cpp @@ -1,11 +1,11 @@ #include -#include "MarkdownUtils.h" +#include "MarkdownParser.h" #include "RuntimeDecorator.h" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) { return facebook::jni::initialize( - vm, [] { expensify::livemarkdown::MarkdownUtils::registerNatives(); }); + vm, [] { expensify::livemarkdown::MarkdownParser::registerNatives(); }); } extern "C" JNIEXPORT void JNICALL Java_com_expensify_livemarkdown_LiveMarkdownModule_injectJSIBindings(JNIEnv *env, jobject thiz, jlong jsiRuntime) { diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownFormatter.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownFormatter.java new file mode 100644 index 000000000..2ef8ccf97 --- /dev/null +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownFormatter.java @@ -0,0 +1,132 @@ +package com.expensify.livemarkdown; + +import android.content.res.AssetManager; +import android.text.SpannableStringBuilder; +import android.text.Spanned; + +import androidx.annotation.NonNull; + +import com.expensify.livemarkdown.spans.*; +import com.facebook.react.views.text.internal.span.CustomLineHeightSpan; +import com.facebook.systrace.Systrace; + +import java.util.List; +import java.util.Objects; + +public class MarkdownFormatter { + private final @NonNull AssetManager mAssetManager; + + public MarkdownFormatter(@NonNull AssetManager assetManager) { + mAssetManager = assetManager; + } + + public void format(@NonNull SpannableStringBuilder ssb, @NonNull List markdownRanges, @NonNull MarkdownStyle markdownStyle) { + try { + Systrace.beginSection(0, "format"); + Objects.requireNonNull(markdownStyle, "mMarkdownStyle is null"); + removeSpans(ssb); + applyRanges(ssb, markdownRanges, markdownStyle); + } finally { + Systrace.endSection(0); + } + } + + private void removeSpans(@NonNull SpannableStringBuilder ssb) { + try { + Systrace.beginSection(0, "removeSpans"); + // We shouldn't use `removeSpans()` because it also removes SpellcheckSpan, SuggestionSpan etc. + MarkdownSpan[] spans = ssb.getSpans(0, ssb.length(), MarkdownSpan.class); + for (MarkdownSpan span : spans) { + ssb.removeSpan(span); + } + } finally { + Systrace.endSection(0); + } + } + + private void applyRanges(@NonNull SpannableStringBuilder ssb, @NonNull List markdownRanges, @NonNull MarkdownStyle markdownStyle) { + try { + Systrace.beginSection(0, "applyRanges"); + for (MarkdownRange markdownRange : markdownRanges) { + applyRange(ssb, markdownRange, markdownStyle); + } + } finally { + Systrace.endSection(0); + } + } + + private void applyRange(@NonNull SpannableStringBuilder ssb, @NonNull MarkdownRange markdownRange, @NonNull MarkdownStyle markdownStyle) { + String type = markdownRange.getType(); + int start = markdownRange.getStart(); + int end = markdownRange.getEnd(); + switch (type) { + case "bold": + setSpan(ssb, new MarkdownBoldSpan(), start, end); + break; + case "italic": + setSpan(ssb, new MarkdownItalicSpan(), start, end); + break; + case "strikethrough": + setSpan(ssb, new MarkdownStrikethroughSpan(), start, end); + break; + case "emoji": + setSpan(ssb, new MarkdownEmojiSpan(markdownStyle.getEmojiFontSize()), start, end); + break; + case "mention-here": + setSpan(ssb, new MarkdownForegroundColorSpan(markdownStyle.getMentionHereColor()), start, end); + setSpan(ssb, new MarkdownBackgroundColorSpan(markdownStyle.getMentionHereBackgroundColor()), start, end); + break; + case "mention-user": + // TODO: change mention color when it mentions current user + setSpan(ssb, new MarkdownForegroundColorSpan(markdownStyle.getMentionUserColor()), start, end); + setSpan(ssb, new MarkdownBackgroundColorSpan(markdownStyle.getMentionUserBackgroundColor()), start, end); + break; + case "mention-report": + setSpan(ssb, new MarkdownForegroundColorSpan(markdownStyle.getMentionReportColor()), start, end); + setSpan(ssb, new MarkdownBackgroundColorSpan(markdownStyle.getMentionReportBackgroundColor()), start, end); + break; + case "syntax": + setSpan(ssb, new MarkdownForegroundColorSpan(markdownStyle.getSyntaxColor()), start, end); + break; + case "link": + setSpan(ssb, new MarkdownUnderlineSpan(), start, end); + setSpan(ssb, new MarkdownForegroundColorSpan(markdownStyle.getLinkColor()), start, end); + break; + case "code": + setSpan(ssb, new MarkdownFontFamilySpan(markdownStyle.getCodeFontFamily(), mAssetManager), start, end); + setSpan(ssb, new MarkdownFontSizeSpan(markdownStyle.getCodeFontSize()), start, end); + setSpan(ssb, new MarkdownForegroundColorSpan(markdownStyle.getCodeColor()), start, end); + setSpan(ssb, new MarkdownBackgroundColorSpan(markdownStyle.getCodeBackgroundColor()), start, end); + break; + case "pre": + setSpan(ssb, new MarkdownFontFamilySpan(markdownStyle.getPreFontFamily(), mAssetManager), start, end); + setSpan(ssb, new MarkdownFontSizeSpan(markdownStyle.getPreFontSize()), start, end); + setSpan(ssb, new MarkdownForegroundColorSpan(markdownStyle.getPreColor()), start, end); + setSpan(ssb, new MarkdownBackgroundColorSpan(markdownStyle.getPreBackgroundColor()), start, end); + break; + case "h1": + setSpan(ssb, new MarkdownBoldSpan(), start, end); + CustomLineHeightSpan[] spans = ssb.getSpans(0, ssb.length(), CustomLineHeightSpan.class); + if (spans.length >= 1) { + int lineHeight = spans[0].getLineHeight(); + setSpan(ssb, new MarkdownLineHeightSpan(lineHeight * 1.5f), start, end); + } + // NOTE: size span must be set after line height span to avoid height jumps + setSpan(ssb, new MarkdownFontSizeSpan(markdownStyle.getH1FontSize()), start, end); + break; + case "blockquote": + MarkdownBlockquoteSpan span = new MarkdownBlockquoteSpan( + markdownStyle.getBlockquoteBorderColor(), + markdownStyle.getBlockquoteBorderWidth(), + markdownStyle.getBlockquoteMarginLeft(), + markdownStyle.getBlockquotePaddingLeft(), + markdownRange.getDepth()); + setSpan(ssb, span, start, end); + break; + } + } + + private void setSpan(@NonNull SpannableStringBuilder ssb, @NonNull MarkdownSpan span, int start, int end) { + ssb.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } +} diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java new file mode 100644 index 000000000..3e108db70 --- /dev/null +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java @@ -0,0 +1,89 @@ +package com.expensify.livemarkdown; + +import androidx.annotation.NonNull; + +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.util.RNLog; +import com.facebook.soloader.SoLoader; +import com.facebook.systrace.Systrace; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +public class MarkdownParser { + static { + SoLoader.loadLibrary("livemarkdown"); + } + + private final @NonNull ReactContext mReactContext; + private String mPrevText; + private int mPrevParserId; + private List mPrevMarkdownRanges; + + public MarkdownParser(@NonNull ReactContext reactContext) { + mReactContext = reactContext; + } + + private native String nativeParse(@NonNull String text, int parserId); + + public synchronized List parse(@NonNull String text, int parserId) { + try { + Systrace.beginSection(0, "parse"); + + if (text.equals(mPrevText) && parserId == mPrevParserId) { + return mPrevMarkdownRanges; + } + + String json; + try { + Systrace.beginSection(0, "nativeParse"); + json = nativeParse(text, parserId); + } catch (Exception e) { + // Skip formatting, runGuarded will show the error in LogBox + mPrevText = text; + mPrevParserId = parserId; + mPrevMarkdownRanges = Collections.emptyList(); + return mPrevMarkdownRanges; + } finally { + Systrace.endSection(0); + } + + List markdownRanges = new LinkedList<>(); + try { + Systrace.beginSection(0, "markdownRanges"); + JSONArray ranges = new JSONArray(json); + for (int i = 0; i < ranges.length(); i++) { + JSONObject range = ranges.getJSONObject(i); + String type = range.getString("type"); + int start = range.getInt("start"); + int length = range.getInt("length"); + int depth = range.optInt("depth", 1); + if (length == 0 || start + length > text.length()) { + continue; + } + markdownRanges.add(new MarkdownRange(type, start, length, depth)); + } + } catch (JSONException e) { + RNLog.w(mReactContext, "[react-native-live-markdown] Incorrect schema of worklet parser output: " + e.getMessage()); + mPrevText = text; + mPrevParserId = parserId; + mPrevMarkdownRanges = Collections.emptyList(); + return mPrevMarkdownRanges; + } finally { + Systrace.endSection(0); + } + + mPrevText = text; + mPrevParserId = parserId; + mPrevMarkdownRanges = markdownRanges; + return mPrevMarkdownRanges; + } finally { + Systrace.endSection(0); + } + } +} diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownRange.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownRange.java index 9c3aedd3d..1fda115b9 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownRange.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownRange.java @@ -1,14 +1,18 @@ package com.expensify.livemarkdown; +import androidx.annotation.NonNull; + public class MarkdownRange { - private final String mType; + private final @NonNull String mType; private final int mStart; + private final int mEnd; private final int mLength; private final int mDepth; - public MarkdownRange(String type, int start, int length, int depth) { + public MarkdownRange(@NonNull String type, int start, int length, int depth) { mType = type; mStart = start; + mEnd = start + length; mLength = length; mDepth = depth; } @@ -21,6 +25,10 @@ public int getStart() { return mStart; } + public int getEnd() { + return mEnd; + } + public int getLength() { return mLength; } diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownStyle.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownStyle.java index cf691e62a..50606f982 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownStyle.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownStyle.java @@ -32,6 +32,7 @@ public class MarkdownStyle { private final float mBlockquotePaddingLeft; + @NonNull private final String mCodeFontFamily; private final float mCodeFontSize; @@ -42,6 +43,7 @@ public class MarkdownStyle { @ColorInt private final int mCodeBackgroundColor; + @NonNull private final String mPreFontFamily; private final float mPreFontSize; diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownTextInputDecoratorView.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownTextInputDecoratorView.java index 6bd1a68fe..b7ab2aa03 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownTextInputDecoratorView.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownTextInputDecoratorView.java @@ -61,6 +61,7 @@ protected void onAttachedToWindow() { mReactEditText = (ReactEditText) previousSibling; mTextWatcher = new MarkdownTextWatcher(mMarkdownUtils); mReactEditText.addTextChangedListener(mTextWatcher); + applyNewStyles(); } } diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownTextWatcher.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownTextWatcher.java index 23b215a23..d182be1fd 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownTextWatcher.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownTextWatcher.java @@ -7,7 +7,7 @@ import androidx.annotation.NonNull; public class MarkdownTextWatcher implements TextWatcher { - private final MarkdownUtils mMarkdownUtils; + private final @NonNull MarkdownUtils mMarkdownUtils; public MarkdownTextWatcher(@NonNull MarkdownUtils markdownUtils) { mMarkdownUtils = markdownUtils; @@ -25,8 +25,8 @@ public void onTextChanged(CharSequence s, int start, int before, int count) { @Override public void afterTextChanged(Editable editable) { - if (editable instanceof SpannableStringBuilder) { - mMarkdownUtils.applyMarkdownFormatting((SpannableStringBuilder) editable); + if (editable instanceof SpannableStringBuilder ssb) { + mMarkdownUtils.applyMarkdownFormatting(ssb); } } } diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java index 3451fdb45..6877e46f9 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java @@ -1,43 +1,22 @@ package com.expensify.livemarkdown; -import android.content.res.AssetManager; import android.text.SpannableStringBuilder; -import android.text.Spanned; import androidx.annotation.NonNull; -import com.expensify.livemarkdown.spans.*; import com.facebook.react.bridge.ReactContext; -import com.facebook.react.util.RNLog; -import com.facebook.react.views.text.internal.span.CustomLineHeightSpan; -import com.facebook.soloader.SoLoader; +import com.facebook.systrace.Systrace; -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -import java.util.LinkedList; import java.util.List; -import java.util.Objects; public class MarkdownUtils { - static { - SoLoader.loadLibrary("livemarkdown"); - } - - private static synchronized native String nativeParseMarkdown(String input, int parserId); - public MarkdownUtils(@NonNull ReactContext reactContext) { - mReactContext = reactContext; - mAssetManager = reactContext.getAssets(); + mMarkdownParser = new MarkdownParser(reactContext); + mMarkdownFormatter = new MarkdownFormatter(reactContext.getAssets()); } - private final @NonNull ReactContext mReactContext; - private final @NonNull AssetManager mAssetManager; - - private String mPrevInput; - private String mPrevOutput; - private int mPrevParserId; + private final @NonNull MarkdownParser mMarkdownParser; + private final @NonNull MarkdownFormatter mMarkdownFormatter; private MarkdownStyle mMarkdownStyle; private int mParserId; @@ -51,129 +30,13 @@ public void setParserId(int parserId) { } public void applyMarkdownFormatting(SpannableStringBuilder ssb) { - Objects.requireNonNull(mMarkdownStyle, "mMarkdownStyle is null"); - - removeSpans(ssb); - - String input = ssb.toString(); - String output; - if (input.equals(mPrevInput) && mParserId == mPrevParserId) { - output = mPrevOutput; - } else { - try { - output = nativeParseMarkdown(input, mParserId); - } catch (Exception e) { - output = "[]"; - } - mPrevInput = input; - mPrevOutput = output; - mPrevParserId = mParserId; - } - - List markdownRanges = new LinkedList<>(); try { - JSONArray ranges = new JSONArray(output); - for (int i = 0; i < ranges.length(); i++) { - JSONObject range = ranges.getJSONObject(i); - String type = range.getString("type"); - int start = range.getInt("start"); - int length = range.getInt("length"); - int depth = range.optInt("depth", 1); - int end = start + length; - if (length == 0 || end > input.length()) { - continue; - } - markdownRanges.add(new MarkdownRange(type, start, length, depth)); - } - } catch (JSONException e) { - RNLog.w(mReactContext, "[react-native-live-markdown] Incorrect schema of worklet parser output: " + e.getMessage()); - } - - for (MarkdownRange markdownRange : markdownRanges) { - applyRange(ssb, markdownRange); - } - } - - private void applyRange(SpannableStringBuilder ssb, MarkdownRange markdownRange) { - String type = markdownRange.getType(); - int start = markdownRange.getStart(); - int end = start + markdownRange.getLength(); - switch (type) { - case "bold": - setSpan(ssb, new MarkdownBoldSpan(), start, end); - break; - case "italic": - setSpan(ssb, new MarkdownItalicSpan(), start, end); - break; - case "strikethrough": - setSpan(ssb, new MarkdownStrikethroughSpan(), start, end); - break; - case "emoji": - setSpan(ssb, new MarkdownEmojiSpan(mMarkdownStyle.getEmojiFontSize()), start, end); - break; - case "mention-here": - setSpan(ssb, new MarkdownForegroundColorSpan(mMarkdownStyle.getMentionHereColor()), start, end); - setSpan(ssb, new MarkdownBackgroundColorSpan(mMarkdownStyle.getMentionHereBackgroundColor()), start, end); - break; - case "mention-user": - // TODO: change mention color when it mentions current user - setSpan(ssb, new MarkdownForegroundColorSpan(mMarkdownStyle.getMentionUserColor()), start, end); - setSpan(ssb, new MarkdownBackgroundColorSpan(mMarkdownStyle.getMentionUserBackgroundColor()), start, end); - break; - case "mention-report": - setSpan(ssb, new MarkdownForegroundColorSpan(mMarkdownStyle.getMentionReportColor()), start, end); - setSpan(ssb, new MarkdownBackgroundColorSpan(mMarkdownStyle.getMentionReportBackgroundColor()), start, end); - break; - case "syntax": - setSpan(ssb, new MarkdownForegroundColorSpan(mMarkdownStyle.getSyntaxColor()), start, end); - break; - case "link": - setSpan(ssb, new MarkdownUnderlineSpan(), start, end); - setSpan(ssb, new MarkdownForegroundColorSpan(mMarkdownStyle.getLinkColor()), start, end); - break; - case "code": - setSpan(ssb, new MarkdownFontFamilySpan(mMarkdownStyle.getCodeFontFamily(), mAssetManager), start, end); - setSpan(ssb, new MarkdownFontSizeSpan(mMarkdownStyle.getCodeFontSize()), start, end); - setSpan(ssb, new MarkdownForegroundColorSpan(mMarkdownStyle.getCodeColor()), start, end); - setSpan(ssb, new MarkdownBackgroundColorSpan(mMarkdownStyle.getCodeBackgroundColor()), start, end); - break; - case "pre": - setSpan(ssb, new MarkdownFontFamilySpan(mMarkdownStyle.getPreFontFamily(), mAssetManager), start, end); - setSpan(ssb, new MarkdownFontSizeSpan(mMarkdownStyle.getPreFontSize()), start, end); - setSpan(ssb, new MarkdownForegroundColorSpan(mMarkdownStyle.getPreColor()), start, end); - setSpan(ssb, new MarkdownBackgroundColorSpan(mMarkdownStyle.getPreBackgroundColor()), start, end); - break; - case "h1": - setSpan(ssb, new MarkdownBoldSpan(), start, end); - CustomLineHeightSpan[] spans = ssb.getSpans(0, ssb.length(), CustomLineHeightSpan.class); - if (spans.length >= 1) { - int lineHeight = spans[0].getLineHeight(); - setSpan(ssb, new MarkdownLineHeightSpan(lineHeight * 1.5f), start, end); - } - // NOTE: size span must be set after line height span to avoid height jumps - setSpan(ssb, new MarkdownFontSizeSpan(mMarkdownStyle.getH1FontSize()), start, end); - break; - case "blockquote": - MarkdownBlockquoteSpan span = new MarkdownBlockquoteSpan( - mMarkdownStyle.getBlockquoteBorderColor(), - mMarkdownStyle.getBlockquoteBorderWidth(), - mMarkdownStyle.getBlockquoteMarginLeft(), - mMarkdownStyle.getBlockquotePaddingLeft(), - markdownRange.getDepth()); - setSpan(ssb, span, start, end); - break; - } - } - - private void setSpan(SpannableStringBuilder ssb, MarkdownSpan span, int start, int end) { - ssb.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - } - - private void removeSpans(SpannableStringBuilder ssb) { - // We shouldn't use `removeSpans()` because it also removes SpellcheckSpan, SuggestionSpan etc. - MarkdownSpan[] spans = ssb.getSpans(0, ssb.length(), MarkdownSpan.class); - for (MarkdownSpan span : spans) { - ssb.removeSpan(span); + Systrace.beginSection(0, "applyMarkdownFormatting"); + String text = ssb.toString(); + List markdownRanges = mMarkdownParser.parse(text, mParserId); + mMarkdownFormatter.format(ssb, markdownRanges, mMarkdownStyle); + } finally { + Systrace.endSection(0); } } } diff --git a/apple/MarkdownCommitHook.mm b/apple/MarkdownCommitHook.mm index f98f12194..9ff5be224 100644 --- a/apple/MarkdownCommitHook.mm +++ b/apple/MarkdownCommitHook.mm @@ -198,7 +198,7 @@ // apply markdown auto newString = [usedUtils parseMarkdown:nsAttributedString - withAttributes:defaultNSTextAttributes]; + withDefaultTextAttributes:defaultNSTextAttributes]; // create a clone of the old TextInputState and update the // attributed string box to point to the string with markdown @@ -247,7 +247,7 @@ // apply markdown auto newString = [usedUtils parseMarkdown:nsAttributedString - withAttributes:defaultNSTextAttributes]; + withDefaultTextAttributes:defaultNSTextAttributes]; // create a clone of the old TextInputState and update the // attributed string box to point to the string with markdown diff --git a/apple/MarkdownFormatter.h b/apple/MarkdownFormatter.h new file mode 100644 index 000000000..1cf86c851 --- /dev/null +++ b/apple/MarkdownFormatter.h @@ -0,0 +1,18 @@ +#import +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +const NSAttributedStringKey RCTLiveMarkdownBlockquoteDepthAttributeName = @"RCTLiveMarkdownBlockquoteDepth"; + +@interface MarkdownFormatter : NSObject + +- (nonnull NSAttributedString *)format:(nonnull NSString *)text + withDefaultTextAttributes:(nonnull NSDictionary *)defaultTextAttributes + withMarkdownRanges:(nonnull NSArray *)markdownRanges + withMarkdownStyle:(nonnull RCTMarkdownStyle *)markdownStyle; + +NS_ASSUME_NONNULL_END + +@end diff --git a/apple/MarkdownFormatter.mm b/apple/MarkdownFormatter.mm new file mode 100644 index 000000000..fdb4116f6 --- /dev/null +++ b/apple/MarkdownFormatter.mm @@ -0,0 +1,166 @@ +#import "MarkdownFormatter.h" +#import + +@implementation MarkdownFormatter + +- (nonnull NSAttributedString *)format:(nonnull NSString *)text + withDefaultTextAttributes:(nonnull NSDictionary *)defaultTextAttributes + withMarkdownRanges:(nonnull NSArray *)markdownRanges + withMarkdownStyle:(nonnull RCTMarkdownStyle *)markdownStyle +{ + NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:text attributes: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)]; + + for (MarkdownRange *markdownRange in markdownRanges) { + [self applyRangeToAttributedString:attributedString + type:std::string([markdownRange.type UTF8String]) + range:markdownRange.range + depth:markdownRange.depth + markdownStyle:markdownStyle + defaultTextAttributes:defaultTextAttributes]; + } + + [attributedString.string enumerateSubstringsInRange:NSMakeRange(0, attributedString.length) + options:NSStringEnumerationByLines | NSStringEnumerationSubstringNotRequired + usingBlock:^(NSString * _Nullable substring, NSRange substringRange, NSRange enclosingRange, BOOL * _Nonnull stop) { + RCTApplyBaselineOffset(attributedString, enclosingRange); + }]; + + [attributedString endEditing]; + + return attributedString; +} + +- (void)applyRangeToAttributedString:(NSMutableAttributedString *)attributedString + type:(const std::string)type + range:(const NSRange)range + depth:(const int)depth + markdownStyle:(nonnull RCTMarkdownStyle *)markdownStyle + defaultTextAttributes:(nonnull NSDictionary *)defaultTextAttributes +{ + if (type == "bold" || type == "italic" || type == "code" || type == "pre" || type == "h1" || type == "emoji") { + UIFont *font = [attributedString attribute:NSFontAttributeName atIndex:range.location effectiveRange:NULL]; + if (type == "bold") { + font = [RCTFont updateFont:font withWeight:@"bold"]; + } else if (type == "italic") { + font = [RCTFont updateFont:font withStyle:@"italic"]; + } else if (type == "code") { + font = [RCTFont updateFont:font withFamily:markdownStyle.codeFontFamily + size:[NSNumber numberWithFloat:markdownStyle.codeFontSize] + weight:nil + style:nil + variant:nil + scaleMultiplier:0]; + } else if (type == "pre") { + font = [RCTFont updateFont:font withFamily:markdownStyle.preFontFamily + size:[NSNumber numberWithFloat:markdownStyle.preFontSize] + weight:nil + style:nil + variant:nil + scaleMultiplier:0]; + } else if (type == "h1") { + font = [RCTFont updateFont:font withFamily:nil + size:[NSNumber numberWithFloat:markdownStyle.h1FontSize] + weight:@"bold" + style:nil + variant:nil + scaleMultiplier:0]; + } else if (type == "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 == "syntax") { + [attributedString addAttribute:NSForegroundColorAttributeName value:markdownStyle.syntaxColor range:range]; + } else if (type == "strikethrough") { + [attributedString addAttribute:NSStrikethroughStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleSingle] range:range]; + } else if (type == "code") { + [attributedString addAttribute:NSForegroundColorAttributeName value:markdownStyle.codeColor range:range]; + [attributedString addAttribute:NSBackgroundColorAttributeName value:markdownStyle.codeBackgroundColor range:range]; + } else if (type == "mention-here") { + [attributedString addAttribute:NSForegroundColorAttributeName value:markdownStyle.mentionHereColor range:range]; + [attributedString addAttribute:NSBackgroundColorAttributeName value:markdownStyle.mentionHereBackgroundColor range:range]; + } else if (type == "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 == "mention-report") { + [attributedString addAttribute:NSForegroundColorAttributeName value:markdownStyle.mentionReportColor range:range]; + [attributedString addAttribute:NSBackgroundColorAttributeName value:markdownStyle.mentionReportBackgroundColor range:range]; + } else if (type == "link") { + [attributedString addAttribute:NSUnderlineStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleSingle] range:range]; + [attributedString addAttribute:NSForegroundColorAttributeName value:markdownStyle.linkColor range:range]; + } else if (type == "blockquote") { + CGFloat indent = (markdownStyle.blockquoteMarginLeft + markdownStyle.blockquoteBorderWidth + markdownStyle.blockquotePaddingLeft) * depth; + NSParagraphStyle *defaultParagraphStyle = defaultTextAttributes[NSParagraphStyleAttributeName]; + NSMutableParagraphStyle *paragraphStyle = defaultParagraphStyle != nil ? [defaultParagraphStyle mutableCopy] : [NSMutableParagraphStyle new]; + paragraphStyle.firstLineHeadIndent = indent; + paragraphStyle.headIndent = indent; + [attributedString addAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:range]; + [attributedString addAttribute:RCTLiveMarkdownBlockquoteDepthAttributeName value:@(depth) range:range]; + } else if (type == "pre") { + [attributedString addAttribute:NSForegroundColorAttributeName value:markdownStyle.preColor range:range]; + NSRange rangeForBackground = [[attributedString string] 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 + } +} + +static void RCTApplyBaselineOffset(NSMutableAttributedString *attributedText, NSRange attributedTextRange) +{ + __block CGFloat maximumLineHeight = 0; + + [attributedText enumerateAttribute:NSParagraphStyleAttributeName + inRange:attributedTextRange + options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired + usingBlock:^(NSParagraphStyle *paragraphStyle, __unused NSRange range, __unused BOOL *stop) { + if (!paragraphStyle) { + return; + } + + maximumLineHeight = MAX(paragraphStyle.maximumLineHeight, maximumLineHeight); + }]; + + if (maximumLineHeight == 0) { + // `lineHeight` was not specified, nothing to do. + return; + } + + __block CGFloat maximumFontLineHeight = 0; + + [attributedText enumerateAttribute:NSFontAttributeName + inRange:attributedTextRange + options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired + usingBlock:^(UIFont *font, NSRange range, __unused BOOL *stop) { + if (!font) { + return; + } + + maximumFontLineHeight = MAX(font.lineHeight, maximumFontLineHeight); + }]; + + if (maximumLineHeight < maximumFontLineHeight) { + return; + } + + CGFloat baseLineOffset = (maximumLineHeight - maximumFontLineHeight) / 2.0; + [attributedText addAttribute:NSBaselineOffsetAttributeName + value:@(baseLineOffset) + range:attributedTextRange]; +} + +@end diff --git a/apple/MarkdownLayoutManager.h b/apple/MarkdownLayoutManager.h index 29be3508d..9e965e113 100644 --- a/apple/MarkdownLayoutManager.h +++ b/apple/MarkdownLayoutManager.h @@ -1,5 +1,6 @@ #import #import +#import NS_ASSUME_NONNULL_BEGIN diff --git a/apple/MarkdownLayoutManager.mm b/apple/MarkdownLayoutManager.mm index 3974ba98c..4ea7da64d 100644 --- a/apple/MarkdownLayoutManager.mm +++ b/apple/MarkdownLayoutManager.mm @@ -5,34 +5,27 @@ @implementation MarkdownLayoutManager - (void)drawBackgroundForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin { [super drawBackgroundForGlyphRange:glyphsToShow atPoint:origin]; + NSTextStorage *textStorage = self.textStorage; + [self enumerateLineFragmentsForGlyphRange:glyphsToShow usingBlock:^(CGRect rect, CGRect usedRect, NSTextContainer * _Nonnull textContainer, NSRange glyphRange, BOOL * _Nonnull stop) { - __block BOOL isBlockquote = NO; - __block int currentDepth = 0; + NSNumber *depth = [textStorage attribute:RCTLiveMarkdownBlockquoteDepthAttributeName atIndex:glyphRange.location effectiveRange:nil]; + if (depth == nil) { + return; // not a blockquote + } + RCTMarkdownUtils *markdownUtils = [self valueForKey:@"markdownUtils"]; - [markdownUtils.blockquoteRangesAndLevels enumerateObjectsUsingBlock:^(NSDictionary *item, NSUInteger idx, BOOL * _Nonnull stop) { - NSRange range = [[item valueForKey:@"range"] rangeValue]; - currentDepth = [[item valueForKey:@"depth"] unsignedIntegerValue]; - NSUInteger start = range.location; - NSUInteger end = start + range.length; - NSUInteger location = glyphRange.location; - if (location >= start && location < end) { - isBlockquote = YES; - *stop = YES; - } - }]; - if (isBlockquote) { - CGFloat paddingLeft = origin.x; - CGFloat paddingTop = origin.y; - CGFloat y = paddingTop + rect.origin.y; - CGFloat width = markdownUtils.markdownStyle.blockquoteBorderWidth; - CGFloat height = rect.size.height; - CGFloat shift = markdownUtils.markdownStyle.blockquoteMarginLeft + markdownUtils.markdownStyle.blockquoteBorderWidth + markdownUtils.markdownStyle.blockquotePaddingLeft; - for (int level = 0; level < currentDepth; level++) { - CGFloat x = paddingLeft + (level * shift) + markdownUtils.markdownStyle.blockquoteMarginLeft; - CGRect lineRect = CGRectMake(x, y, width, height); - [markdownUtils.markdownStyle.blockquoteBorderColor setFill]; - UIRectFill(lineRect); - } + CGFloat paddingLeft = origin.x; + CGFloat paddingTop = origin.y; + CGFloat y = paddingTop + rect.origin.y; + CGFloat width = markdownUtils.markdownStyle.blockquoteBorderWidth; + CGFloat height = rect.size.height; + CGFloat shift = markdownUtils.markdownStyle.blockquoteMarginLeft + markdownUtils.markdownStyle.blockquoteBorderWidth + markdownUtils.markdownStyle.blockquotePaddingLeft; + + for (NSUInteger level = 0; level < [depth unsignedIntValue]; level++) { + CGFloat x = paddingLeft + (level * shift) + markdownUtils.markdownStyle.blockquoteMarginLeft; + CGRect lineRect = CGRectMake(x, y, width, height); + [markdownUtils.markdownStyle.blockquoteBorderColor setFill]; + UIRectFill(lineRect); } }]; } diff --git a/apple/MarkdownParser.h b/apple/MarkdownParser.h new file mode 100644 index 000000000..407e49b07 --- /dev/null +++ b/apple/MarkdownParser.h @@ -0,0 +1,13 @@ +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface MarkdownParser : NSObject + +- (NSArray *)parse:(nonnull NSString *)text + withParserId:(nonnull NSNumber *)parserId; + +NS_ASSUME_NONNULL_END + +@end diff --git a/apple/MarkdownParser.mm b/apple/MarkdownParser.mm new file mode 100644 index 000000000..4e96050a1 --- /dev/null +++ b/apple/MarkdownParser.mm @@ -0,0 +1,74 @@ +#import "MarkdownParser.h" +#import +#import +#import + +@implementation MarkdownParser { + NSString *_prevText; + NSNumber *_prevParserId; + NSArray *_prevMarkdownRanges; +} + +- (NSArray *)parse:(nonnull NSString *)text + withParserId:(nonnull NSNumber *)parserId +{ + @synchronized (self) { + if ([text isEqualToString:_prevText] && [parserId isEqualToNumber:_prevParserId]) { + return _prevMarkdownRanges; + } + + static std::mutex workletRuntimeMutex; // this needs to be global since the worklet runtime is also global + const auto lock = std::lock_guard(workletRuntimeMutex); + + const auto &markdownRuntime = expensify::livemarkdown::getMarkdownRuntime(); + jsi::Runtime &rt = markdownRuntime->getJSIRuntime(); + + const auto &markdownWorklet = expensify::livemarkdown::getMarkdownWorklet([parserId intValue]); + + const auto &input = jsi::String::createFromUtf8(rt, [text UTF8String]); + + jsi::Value output; + try { + output = markdownRuntime->runGuarded(markdownWorklet, input); + } catch (const jsi::JSError &error) { + // Skip formatting, runGuarded will show the error in LogBox + _prevText = text; + _prevParserId = parserId; + _prevMarkdownRanges = @[]; + return _prevMarkdownRanges; + } + + NSMutableArray *markdownRanges = [[NSMutableArray alloc] init]; + try { + const auto &ranges = output.asObject(rt).asArray(rt); + for (size_t i = 0, n = ranges.size(rt); i < n; ++i) { + const auto &item = ranges.getValueAtIndex(rt, i).asObject(rt); + const auto &type = item.getProperty(rt, "type").asString(rt).utf8(rt); + const auto &start = static_cast(item.getProperty(rt, "start").asNumber()); + const auto &length = static_cast(item.getProperty(rt, "length").asNumber()); + const auto &depth = item.hasProperty(rt, "depth") ? static_cast(item.getProperty(rt, "depth").asNumber()) : 1; + + if (length == 0 || start + length > text.length) { + continue; + } + + NSRange range = NSMakeRange(start, length); + MarkdownRange *markdownRange = [[MarkdownRange alloc] initWithType:@(type.c_str()) range:range depth:depth]; + [markdownRanges addObject:markdownRange]; + } + } catch (const jsi::JSError &error) { + RCTLogWarn(@"[react-native-live-markdown] Incorrect schema of worklet parser output: %s", error.getMessage().c_str()); + _prevText = text; + _prevParserId = parserId; + _prevMarkdownRanges = @[]; + return _prevMarkdownRanges; + } + + _prevText = text; + _prevParserId = parserId; + _prevMarkdownRanges = markdownRanges; + return _prevMarkdownRanges; + } +} + +@end diff --git a/apple/RCTBackedTextFieldDelegateAdapter+Markdown.mm b/apple/RCTBackedTextFieldDelegateAdapter+Markdown.mm index 11c3baf8b..d4d8f8214 100644 --- a/apple/RCTBackedTextFieldDelegateAdapter+Markdown.mm +++ b/apple/RCTBackedTextFieldDelegateAdapter+Markdown.mm @@ -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 withAttributes:backedTextInputView.defaultTextAttributes]; + backedTextInputView.attributedText = [markdownUtils parseMarkdown:backedTextInputView.attributedText withDefaultTextAttributes:backedTextInputView.defaultTextAttributes]; [backedTextInputView setSelectedTextRange:range notifyDelegate:YES]; } diff --git a/apple/RCTBaseTextInputView+Markdown.mm b/apple/RCTBaseTextInputView+Markdown.mm index 7662d5455..ec4200682 100644 --- a/apple/RCTBaseTextInputView+Markdown.mm +++ b/apple/RCTBaseTextInputView+Markdown.mm @@ -16,7 +16,7 @@ - (void)markdown_setAttributedText:(NSAttributedString *)attributedText { RCTMarkdownUtils *markdownUtils = [self getMarkdownUtils]; if (markdownUtils != nil) { - attributedText = [markdownUtils parseMarkdown:attributedText withAttributes:self.backedTextInputView.defaultTextAttributes]; + attributedText = [markdownUtils parseMarkdown:attributedText withDefaultTextAttributes:self.backedTextInputView.defaultTextAttributes]; } // Call the original method @@ -46,7 +46,7 @@ - (void)markdown_updateLocalData if (markdownUtils != nil) { id backedTextInputView = self.backedTextInputView; NSAttributedString *oldAttributedText = backedTextInputView.attributedText; - NSAttributedString *newAttributedText = [markdownUtils parseMarkdown:oldAttributedText withAttributes:backedTextInputView.defaultTextAttributes]; + NSAttributedString *newAttributedText = [markdownUtils parseMarkdown:oldAttributedText withDefaultTextAttributes:backedTextInputView.defaultTextAttributes]; UITextRange *range = backedTextInputView.selectedTextRange; // update attributed text without emitting onSelectionChange event diff --git a/apple/RCTMarkdownUtils.h b/apple/RCTMarkdownUtils.h index e942139d9..fed14596d 100644 --- a/apple/RCTMarkdownUtils.h +++ b/apple/RCTMarkdownUtils.h @@ -7,9 +7,9 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic) RCTMarkdownStyle *markdownStyle; @property (nonatomic) NSNumber *parserId; -@property (nonatomic) NSMutableArray *blockquoteRangesAndLevels; -- (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input withAttributes:(nullable NSDictionary*)attributes; +- (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input + withDefaultTextAttributes:(nonnull NSDictionary *)defaultTextAttributes; @end diff --git a/apple/RCTMarkdownUtils.mm b/apple/RCTMarkdownUtils.mm index c9ceed1c1..a932e572c 100644 --- a/apple/RCTMarkdownUtils.mm +++ b/apple/RCTMarkdownUtils.mm @@ -1,221 +1,54 @@ #import -#import -#import -#import "react_native_assert.h" -#import -#import -#include +#import +#import @implementation RCTMarkdownUtils { + MarkdownParser *_markdownParser; + MarkdownFormatter *_markdownFormatter; NSString *_prevInputString; NSAttributedString *_prevAttributedString; - NSDictionary *_prevTextAttributes; + NSDictionary *_prevDefaultTextAttributes; __weak RCTMarkdownStyle *_prevMarkdownStyle; __weak NSNumber *_prevParserId; } -- (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input withAttributes:(nullable NSDictionary *)attributes +- (instancetype)init { - @synchronized (self) { - if (input == nil) { - return nil; - } - - NSString *inputString = [input string]; - if ([inputString isEqualToString:_prevInputString] && [attributes isEqualToDictionary:_prevTextAttributes] && [_markdownStyle isEqual:_prevMarkdownStyle] && [_parserId isEqualToNumber:_prevParserId]) { - return _prevAttributedString; - } - - static std::mutex runtimeMutex; - auto lock = std::lock_guard(runtimeMutex); - - auto markdownRuntime = expensify::livemarkdown::getMarkdownRuntime(); - jsi::Runtime &rt = markdownRuntime->getJSIRuntime(); - - auto markdownWorklet = expensify::livemarkdown::getMarkdownWorklet([_parserId intValue]); - - NSMutableArray *markdownRanges = [[NSMutableArray alloc] init]; - - try { - const auto &text = jsi::String::createFromUtf8(rt, [inputString UTF8String]); - const auto &output = markdownRuntime->runGuarded(markdownWorklet, text); - const auto &ranges = output.asObject(rt).asArray(rt); - - for (size_t i = 0, n = ranges.size(rt); i < n; ++i) { - const auto &item = ranges.getValueAtIndex(rt, i).asObject(rt); - const auto &type = item.getProperty(rt, "type").asString(rt).utf8(rt); - const auto &start = static_cast(item.getProperty(rt, "start").asNumber()); - const auto &length = static_cast(item.getProperty(rt, "length").asNumber()); - const auto &depth = item.hasProperty(rt, "depth") ? static_cast(item.getProperty(rt, "depth").asNumber()) : 1; - - NSRange range = NSMakeRange(start, length); - MarkdownRange *markdownRange = [[MarkdownRange alloc] initWithType:@(type.c_str()) range:range depth:depth]; - [markdownRanges addObject:markdownRange]; - } - } catch (const jsi::JSError &error) { - RCTLogWarn(@"[react-native-live-markdown] Incorrect schema of worklet parser output: %s", error.getMessage().c_str()); - NSAttributedString *attributedString = [[NSAttributedString alloc] initWithString:inputString attributes:attributes]; - _prevInputString = inputString; - _prevAttributedString = attributedString; - _prevTextAttributes = attributes; - _prevMarkdownStyle = _markdownStyle; - _prevParserId = _parserId; - return attributedString; - } - - 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]; - - for (MarkdownRange *markdownRange in markdownRanges) { - [self applyRangeToAttributedString:attributedString - type:std::string([markdownRange.type UTF8String]) - range:markdownRange.range - depth:markdownRange.depth]; - } - - RCTApplyBaselineOffset(attributedString); - - [attributedString endEditing]; - - _prevInputString = inputString; - _prevAttributedString = attributedString; - _prevTextAttributes = attributes; - _prevMarkdownStyle = _markdownStyle; - _prevParserId = _parserId; - - return attributedString; - } -} - -- (void)applyRangeToAttributedString:(NSMutableAttributedString *)attributedString type:(const std::string)type range:(NSRange)range depth:(const int)depth { - if (range.length == 0 || range.location + range.length > attributedString.length) { - return; - } - - if (type == "bold" || type == "italic" || type == "code" || type == "pre" || type == "h1" || type == "emoji") { - UIFont *font = [attributedString attribute:NSFontAttributeName atIndex:range.location effectiveRange:NULL]; - if (type == "bold") { - font = [RCTFont updateFont:font withWeight:@"bold"]; - } else if (type == "italic") { - font = [RCTFont updateFont:font withStyle:@"italic"]; - } else if (type == "code") { - font = [RCTFont updateFont:font withFamily:_markdownStyle.codeFontFamily - size:[NSNumber numberWithFloat:_markdownStyle.codeFontSize] - weight:nil - style:nil - variant:nil - scaleMultiplier:0]; - } else if (type == "pre") { - font = [RCTFont updateFont:font withFamily:_markdownStyle.preFontFamily - size:[NSNumber numberWithFloat:_markdownStyle.preFontSize] - weight:nil - style:nil - variant:nil - scaleMultiplier:0]; - } else if (type == "h1") { - font = [RCTFont updateFont:font withFamily:nil - size:[NSNumber numberWithFloat:_markdownStyle.h1FontSize] - weight:@"bold" - style:nil - variant:nil - scaleMultiplier:0]; - } else if (type == "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 (self = [super init]) { + _markdownParser = [MarkdownParser new]; + _markdownFormatter = [MarkdownFormatter new]; + } - if (type == "syntax") { - [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.syntaxColor range:range]; - } else if (type == "strikethrough") { - [attributedString addAttribute:NSStrikethroughStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleSingle] range:range]; - } else if (type == "code") { - [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.codeColor range:range]; - [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.codeBackgroundColor range:range]; - } else if (type == "mention-here") { - [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.mentionHereColor range:range]; - [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.mentionHereBackgroundColor range:range]; - } else if (type == "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 == "mention-report") { - [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.mentionReportColor range:range]; - [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.mentionReportBackgroundColor range:range]; - } else if (type == "link") { - [attributedString addAttribute:NSUnderlineStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleSingle] range:range]; - [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.linkColor range:range]; - } else if (type == "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 == "pre") { - [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.preColor range:range]; - NSRange rangeForBackground = [[attributedString string] 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 - } + return self; } -static void RCTApplyBaselineOffset(NSMutableAttributedString *attributedText) +- (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input + withDefaultTextAttributes:(nonnull NSDictionary *)defaultTextAttributes { - __block CGFloat maximumLineHeight = 0; - - [attributedText enumerateAttribute:NSParagraphStyleAttributeName - inRange:NSMakeRange(0, attributedText.length) - options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired - usingBlock:^(NSParagraphStyle *paragraphStyle, __unused NSRange range, __unused BOOL *stop) { - if (!paragraphStyle) { - return; + @synchronized (self) { + if (input == nil) { + return nil; } - maximumLineHeight = MAX(paragraphStyle.maximumLineHeight, maximumLineHeight); - }]; - - if (maximumLineHeight == 0) { - // `lineHeight` was not specified, nothing to do. - return; - } - - __block CGFloat maximumFontLineHeight = 0; - - [attributedText enumerateAttribute:NSFontAttributeName - inRange:NSMakeRange(0, attributedText.length) - options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired - usingBlock:^(UIFont *font, NSRange range, __unused BOOL *stop) { - if (!font) { - return; + NSString *inputString = [input string]; + if ([inputString isEqualToString:_prevInputString] && [defaultTextAttributes isEqualToDictionary:_prevDefaultTextAttributes] && [_markdownStyle isEqual:_prevMarkdownStyle] && [_parserId isEqualToNumber:_prevParserId]) { + return _prevAttributedString; } - maximumFontLineHeight = MAX(font.lineHeight, maximumFontLineHeight); - }]; + NSArray *markdownRanges = [_markdownParser parse:inputString withParserId:_parserId]; - if (maximumLineHeight < maximumFontLineHeight) { - return; - } + NSAttributedString *attributedString = [_markdownFormatter format:inputString + withDefaultTextAttributes:defaultTextAttributes + withMarkdownRanges:markdownRanges + withMarkdownStyle:_markdownStyle]; + _prevInputString = inputString; + _prevAttributedString = attributedString; + _prevDefaultTextAttributes = defaultTextAttributes; + _prevMarkdownStyle = _markdownStyle; + _prevParserId = _parserId; - CGFloat baseLineOffset = (maximumLineHeight - maximumFontLineHeight) / 2.0; - [attributedText addAttribute:NSBaselineOffsetAttributeName - value:@(baseLineOffset) - range:NSMakeRange(0, attributedText.length)]; + return attributedString; + } } @end diff --git a/apple/RCTTextInputComponentView+Markdown.mm b/apple/RCTTextInputComponentView+Markdown.mm index 5ce1e63e0..6570097c1 100644 --- a/apple/RCTTextInputComponentView+Markdown.mm +++ b/apple/RCTTextInputComponentView+Markdown.mm @@ -18,7 +18,7 @@ - (void)setMarkdownUtils:(RCTMarkdownUtils *)markdownUtils { if (markdownUtils != nil) { // force Markdown formatting on first render because `_setAttributedText` is called before `setMarkdownUtils` RCTUITextField *backedTextInputView = [self getBackedTextInputView]; - backedTextInputView.attributedText = [markdownUtils parseMarkdown:backedTextInputView.attributedText withAttributes:backedTextInputView.defaultTextAttributes]; + backedTextInputView.attributedText = [markdownUtils parseMarkdown:backedTextInputView.attributedText withDefaultTextAttributes:backedTextInputView.defaultTextAttributes]; } } @@ -36,7 +36,7 @@ - (void)markdown__setAttributedString:(NSAttributedString *)attributedString RCTMarkdownUtils *markdownUtils = [self getMarkdownUtils]; RCTUITextField *backedTextInputView = [self getBackedTextInputView]; if (markdownUtils != nil && backedTextInputView != nil) { - attributedString = [markdownUtils parseMarkdown:attributedString withAttributes:backedTextInputView.defaultTextAttributes]; + attributedString = [markdownUtils parseMarkdown:attributedString withDefaultTextAttributes: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 diff --git a/apple/RCTUITextView+Markdown.mm b/apple/RCTUITextView+Markdown.mm index 70f2d8820..5a49abe95 100644 --- a/apple/RCTUITextView+Markdown.mm +++ b/apple/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 withAttributes:self.defaultTextAttributes]; + super.attributedText = [markdownUtils parseMarkdown:self.attributedText withDefaultTextAttributes: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/example/ios/Podfile.lock b/example/ios/Podfile.lock index fecdc466b..766790689 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1509,7 +1509,7 @@ PODS: - React-logger (= 0.76.2) - React-perflogger (= 0.76.2) - React-utils (= 0.76.2) - - RNLiveMarkdown (0.1.184): + - RNLiveMarkdown (0.1.216): - DoubleConversion - glog - hermes-engine @@ -1529,9 +1529,98 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNLiveMarkdown/newarch (= 0.1.184) + - RNLiveMarkdown/newarch (= 0.1.216) + - RNReanimated/worklets - Yoga - - RNLiveMarkdown/newarch (0.1.184): + - RNLiveMarkdown/newarch (0.1.216): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.01.01.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - RNReanimated/worklets + - Yoga + - RNReanimated (3.16.4): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.01.01.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - RNReanimated/reanimated (= 3.16.4) + - RNReanimated/worklets (= 3.16.4) + - Yoga + - RNReanimated/reanimated (3.16.4): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.01.01.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - RNReanimated/reanimated/apple (= 3.16.4) + - Yoga + - RNReanimated/reanimated/apple (3.16.4): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.01.01.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - RNReanimated/worklets (3.16.4): - DoubleConversion - glog - hermes-engine @@ -1556,68 +1645,68 @@ PODS: - Yoga (0.0.0) DEPENDENCIES: - - boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`) - - DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) - - fmt (from `../node_modules/react-native/third-party-podspecs/fmt.podspec`) - - glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`) - - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - - RCT-Folly/Fabric (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - - RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) - - RCTRequired (from `../node_modules/react-native/Libraries/Required`) - - RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`) - - React (from `../node_modules/react-native/`) - - React-callinvoker (from `../node_modules/react-native/ReactCommon/callinvoker`) - - React-Core (from `../node_modules/react-native/`) - - React-Core/RCTWebSocket (from `../node_modules/react-native/`) - - React-CoreModules (from `../node_modules/react-native/React/CoreModules`) - - React-cxxreact (from `../node_modules/react-native/ReactCommon/cxxreact`) - - React-debug (from `../node_modules/react-native/ReactCommon/react/debug`) - - React-defaultsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/defaults`) - - React-domnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/dom`) - - React-Fabric (from `../node_modules/react-native/ReactCommon`) - - React-FabricComponents (from `../node_modules/react-native/ReactCommon`) - - React-FabricImage (from `../node_modules/react-native/ReactCommon`) - - React-featureflags (from `../node_modules/react-native/ReactCommon/react/featureflags`) - - React-featureflagsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`) - - React-graphics (from `../node_modules/react-native/ReactCommon/react/renderer/graphics`) - - React-hermes (from `../node_modules/react-native/ReactCommon/hermes`) - - React-idlecallbacksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`) - - React-ImageManager (from `../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`) - - React-jserrorhandler (from `../node_modules/react-native/ReactCommon/jserrorhandler`) - - React-jsi (from `../node_modules/react-native/ReactCommon/jsi`) - - React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`) - - React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector-modern`) - - React-jsitracing (from `../node_modules/react-native/ReactCommon/hermes/executor/`) - - React-logger (from `../node_modules/react-native/ReactCommon/logger`) - - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) - - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - - React-nativeconfig (from `../node_modules/react-native/ReactCommon`) - - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - - React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`) - - React-performancetimeline (from `../node_modules/react-native/ReactCommon/react/performance/timeline`) - - React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`) - - React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`) - - React-RCTAppDelegate (from `../node_modules/react-native/Libraries/AppDelegate`) - - React-RCTBlob (from `../node_modules/react-native/Libraries/Blob`) - - React-RCTFabric (from `../node_modules/react-native/React`) - - React-RCTImage (from `../node_modules/react-native/Libraries/Image`) - - React-RCTLinking (from `../node_modules/react-native/Libraries/LinkingIOS`) - - React-RCTNetwork (from `../node_modules/react-native/Libraries/Network`) - - React-RCTSettings (from `../node_modules/react-native/Libraries/Settings`) - - React-RCTText (from `../node_modules/react-native/Libraries/Text`) - - React-RCTVibration (from `../node_modules/react-native/Libraries/Vibration`) - - React-rendererconsistency (from `../node_modules/react-native/ReactCommon/react/renderer/consistency`) - - React-rendererdebug (from `../node_modules/react-native/ReactCommon/react/renderer/debug`) - - React-rncore (from `../node_modules/react-native/ReactCommon`) - - React-RuntimeApple (from `../node_modules/react-native/ReactCommon/react/runtime/platform/ios`) - - React-RuntimeCore (from `../node_modules/react-native/ReactCommon/react/runtime`) - - React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`) - - React-RuntimeHermes (from `../node_modules/react-native/ReactCommon/react/runtime`) - - React-runtimescheduler (from `../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`) - - React-timing (from `../node_modules/react-native/ReactCommon/react/timing`) - - React-utils (from `../node_modules/react-native/ReactCommon/react/utils`) + - boost (from `../../node_modules/react-native/third-party-podspecs/boost.podspec`) + - DoubleConversion (from `../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) + - FBLazyVector (from `../../node_modules/react-native/Libraries/FBLazyVector`) + - fmt (from `../../node_modules/react-native/third-party-podspecs/fmt.podspec`) + - glog (from `../../node_modules/react-native/third-party-podspecs/glog.podspec`) + - hermes-engine (from `../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) + - RCT-Folly (from `../../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) + - RCT-Folly/Fabric (from `../../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) + - RCTDeprecation (from `../../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) + - RCTRequired (from `../../node_modules/react-native/Libraries/Required`) + - RCTTypeSafety (from `../../node_modules/react-native/Libraries/TypeSafety`) + - React (from `../../node_modules/react-native/`) + - React-callinvoker (from `../../node_modules/react-native/ReactCommon/callinvoker`) + - React-Core (from `../../node_modules/react-native/`) + - React-Core/RCTWebSocket (from `../../node_modules/react-native/`) + - React-CoreModules (from `../../node_modules/react-native/React/CoreModules`) + - React-cxxreact (from `../../node_modules/react-native/ReactCommon/cxxreact`) + - React-debug (from `../../node_modules/react-native/ReactCommon/react/debug`) + - React-defaultsnativemodule (from `../../node_modules/react-native/ReactCommon/react/nativemodule/defaults`) + - React-domnativemodule (from `../../node_modules/react-native/ReactCommon/react/nativemodule/dom`) + - React-Fabric (from `../../node_modules/react-native/ReactCommon`) + - React-FabricComponents (from `../../node_modules/react-native/ReactCommon`) + - React-FabricImage (from `../../node_modules/react-native/ReactCommon`) + - React-featureflags (from `../../node_modules/react-native/ReactCommon/react/featureflags`) + - React-featureflagsnativemodule (from `../../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`) + - React-graphics (from `../../node_modules/react-native/ReactCommon/react/renderer/graphics`) + - React-hermes (from `../../node_modules/react-native/ReactCommon/hermes`) + - React-idlecallbacksnativemodule (from `../../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`) + - React-ImageManager (from `../../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`) + - React-jserrorhandler (from `../../node_modules/react-native/ReactCommon/jserrorhandler`) + - React-jsi (from `../../node_modules/react-native/ReactCommon/jsi`) + - React-jsiexecutor (from `../../node_modules/react-native/ReactCommon/jsiexecutor`) + - React-jsinspector (from `../../node_modules/react-native/ReactCommon/jsinspector-modern`) + - React-jsitracing (from `../../node_modules/react-native/ReactCommon/hermes/executor/`) + - React-logger (from `../../node_modules/react-native/ReactCommon/logger`) + - React-Mapbuffer (from `../../node_modules/react-native/ReactCommon`) + - React-microtasksnativemodule (from `../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) + - React-nativeconfig (from `../../node_modules/react-native/ReactCommon`) + - React-NativeModulesApple (from `../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) + - React-perflogger (from `../../node_modules/react-native/ReactCommon/reactperflogger`) + - React-performancetimeline (from `../../node_modules/react-native/ReactCommon/react/performance/timeline`) + - React-RCTActionSheet (from `../../node_modules/react-native/Libraries/ActionSheetIOS`) + - React-RCTAnimation (from `../../node_modules/react-native/Libraries/NativeAnimation`) + - React-RCTAppDelegate (from `../../node_modules/react-native/Libraries/AppDelegate`) + - React-RCTBlob (from `../../node_modules/react-native/Libraries/Blob`) + - React-RCTFabric (from `../../node_modules/react-native/React`) + - React-RCTImage (from `../../node_modules/react-native/Libraries/Image`) + - React-RCTLinking (from `../../node_modules/react-native/Libraries/LinkingIOS`) + - React-RCTNetwork (from `../../node_modules/react-native/Libraries/Network`) + - React-RCTSettings (from `../../node_modules/react-native/Libraries/Settings`) + - React-RCTText (from `../../node_modules/react-native/Libraries/Text`) + - React-RCTVibration (from `../../node_modules/react-native/Libraries/Vibration`) + - React-rendererconsistency (from `../../node_modules/react-native/ReactCommon/react/renderer/consistency`) + - React-rendererdebug (from `../../node_modules/react-native/ReactCommon/react/renderer/debug`) + - React-rncore (from `../../node_modules/react-native/ReactCommon`) + - React-RuntimeApple (from `../../node_modules/react-native/ReactCommon/react/runtime/platform/ios`) + - React-RuntimeCore (from `../../node_modules/react-native/ReactCommon/react/runtime`) + - React-runtimeexecutor (from `../../node_modules/react-native/ReactCommon/runtimeexecutor`) + - React-RuntimeHermes (from `../../node_modules/react-native/ReactCommon/react/runtime`) + - React-runtimescheduler (from `../../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`) + - React-timing (from `../../node_modules/react-native/ReactCommon/react/timing`) + - React-utils (from `../../node_modules/react-native/ReactCommon/react/utils`) - ReactCodegen (from `build/generated/ios`) - ReactCommon/turbomodule/core (from `../../node_modules/react-native/ReactCommon`) - RNLiveMarkdown (from `../..`) @@ -1640,7 +1729,7 @@ EXTERNAL SOURCES: glog: :podspec: "../../node_modules/react-native/third-party-podspecs/glog.podspec" hermes-engine: - :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" + :podspec: "../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" :tag: hermes-2024-11-12-RNv0.76.2-5b4aa20c719830dcf5684832b89a6edb95ac3d64 RCT-Folly: :podspec: "../../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec" @@ -1745,9 +1834,9 @@ EXTERNAL SOURCES: React-RuntimeHermes: :path: "../../node_modules/react-native/ReactCommon/react/runtime" React-runtimescheduler: - :path: "../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler" + :path: "../../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler" React-timing: - :path: "../node_modules/react-native/ReactCommon/react/timing" + :path: "../../node_modules/react-native/ReactCommon/react/timing" React-utils: :path: "../../node_modules/react-native/ReactCommon/react/utils" ReactCodegen: @@ -1822,9 +1911,10 @@ SPEC CHECKSUMS: React-runtimescheduler: 95b7087f459699756c1b7feb3f4637066c337b62 React-timing: 97673939f96f79031d2a5a0a92285618475149ec React-utils: ed6cb7ba089ac0856aa104df12691e99abbf14e1 - ReactCodegen: 93b271af49774429f34d7fd561197020d86436e2 + ReactCodegen: 88beea4320ac19f2c8836a6e8644685bb4905e8e ReactCommon: 208cb02e3c0bb8a727b3e1a1782202bcfa5d9631 - RNLiveMarkdown: 02101566762bd45b57d46d8e8a531d047b858dfa + RNLiveMarkdown: 832e5ce7d3896c8352a859497a2465336105ea32 + RNReanimated: 97d6090ccdf33859f28cc6d394fb4fd799e75d29 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: c5b0e913b5b3b4cde588227c1402e747797061f3 diff --git a/example/package.json b/example/package.json index bc717d5e3..c91f5b10b 100644 --- a/example/package.json +++ b/example/package.json @@ -9,10 +9,10 @@ "start": "react-native start" }, "dependencies": { - "expensify-common": "2.0.106", + "expensify-common": "2.0.108", "react": "18.3.1", "react-native": "0.76.2", - "react-native-reanimated": "3.16.3" + "react-native-reanimated": "3.16.4" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/example/src/App.tsx b/example/src/App.tsx index 77d5e421d..2d48a2bd6 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -15,6 +15,17 @@ import {PlatformInfo} from './PlatformInfo'; // We don't need this workaround in New Expensify App since Reanimated is imported before Live Markdown. console.log(Animated); +function handleFormatSelection(selectedText: string, formatCommand: string) { + switch (formatCommand) { + case 'formatBold': + return `*${selectedText}*`; + case 'formatItalic': + return `_${selectedText}_`; + default: + return selectedText; + } +} + export default function App() { const [value, setValue] = React.useState(TEST_CONST.EXAMPLE_CONTENT); const [textColorState, setTextColorState] = React.useState(false); @@ -48,6 +59,7 @@ export default function App() { { + 'worklet'; + + return []; +}; + +export {MarkdownTextInput, parseExpensiMarkMock as parseExpensiMark}; diff --git a/package-lock.json b/package-lock.json index 108a7b9f7..026035c90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@expensify/react-native-live-markdown", - "version": "0.1.191", + "version": "0.1.216", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@expensify/react-native-live-markdown", - "version": "0.1.191", + "version": "0.1.216", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -42,7 +42,7 @@ "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-promise": "^6.1.1", "eslint-plugin-tsdoc": "^0.2.17", - "expensify-common": "2.0.106", + "expensify-common": "2.0.108", "jest": "^29.6.3", "jest-environment-jsdom": "^29.7.0", "nodemon": "^3.1.3", @@ -51,7 +51,7 @@ "react": "18.3.1", "react-native": "0.76.2", "react-native-builder-bob": "^0.20.0", - "react-native-reanimated": "3.16.3", + "react-native-reanimated": "3.16.4", "react-native-web": "^0.19.10", "release-it": "^15.0.0", "turbo": "^1.10.7", @@ -61,20 +61,20 @@ "node": ">= 18.0.0" }, "peerDependencies": { - "expensify-common": ">=2.0.106", + "expensify-common": ">=2.0.108", "react": "*", "react-native": "*", - "react-native-reanimated": ">=3.16.3" + "react-native-reanimated": ">=3.16.4" } }, "example": { "name": "@expensify/react-native-live-markdown-example", "version": "0.0.1", "dependencies": { - "expensify-common": "2.0.106", + "expensify-common": "2.0.108", "react": "18.3.1", "react-native": "0.76.2", - "react-native-reanimated": "3.16.3" + "react-native-reanimated": "3.16.4" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -15601,9 +15601,10 @@ } }, "node_modules/expensify-common": { - "version": "2.0.106", - "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.106.tgz", - "integrity": "sha512-KmxKvglbIUJb0sAcmNxb/AXYAqa3GIZfu3MbmtlYDNJx24mjDjtbGkKhm+16TICDoPj2PDRNogIqgUGWmSSZFQ==", + "version": "2.0.108", + "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.108.tgz", + "integrity": "sha512-q4chHq1dxHJP/5CRTmTiIRrQKZik0Ms2yM6kjCMlCuJT/3aTlFOHCChb24TB/7TgjbSw0utj1cnFhRDfL87RWg==", + "license": "MIT", "dependencies": { "awesome-phonenumber": "^5.4.0", "classnames": "2.5.0", @@ -25733,9 +25734,9 @@ } }, "node_modules/react-native-reanimated": { - "version": "3.16.3", - "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.16.3.tgz", - "integrity": "sha512-OWlA6e1oHhytTpc7WiSZ7Tmb8OYwLKYZz29Sz6d6WAg60Hm5GuAiKIWUG7Ako7FLcYhFkA0pEQ2xPMEYUo9vlw==", + "version": "3.16.4", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.16.4.tgz", + "integrity": "sha512-dF1Vvu8gG+p0+DmBhKMTx5X9iw/rH1ZF9WaIn2nW0c5rxsVFf00axmDgaAdPxNWblmtLnroaKwrV7SjMUyOx+g==", "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.0.0-0", "@babel/plugin-transform-class-properties": "^7.0.0-0", diff --git a/package.json b/package.json index 63604bb87..4333078b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@expensify/react-native-live-markdown", - "version": "0.1.191", + "version": "0.1.216", "description": "Drop-in replacement for React Native's TextInput component with Markdown formatting.", "main": "lib/commonjs/index", "module": "lib/module/index", @@ -10,6 +10,7 @@ "files": [ "src", "lib", + "mock", "android", "apple", "cpp", @@ -87,7 +88,7 @@ "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-promise": "^6.1.1", "eslint-plugin-tsdoc": "^0.2.17", - "expensify-common": "2.0.106", + "expensify-common": "2.0.108", "jest": "^29.6.3", "jest-environment-jsdom": "^29.7.0", "nodemon": "^3.1.3", @@ -96,17 +97,17 @@ "react": "18.3.1", "react-native": "0.76.2", "react-native-builder-bob": "^0.20.0", - "react-native-reanimated": "3.16.3", + "react-native-reanimated": "3.16.4", "react-native-web": "^0.19.10", "release-it": "^15.0.0", "turbo": "^1.10.7", "typescript": "~5.3.3" }, "peerDependencies": { - "expensify-common": ">=2.0.106", + "expensify-common": ">=2.0.108", "react": "*", "react-native": "*", - "react-native-reanimated": ">=3.16.3" + "react-native-reanimated": ">=3.16.4" }, "workspaces": [ "./example", diff --git a/src/MarkdownTextInput.tsx b/src/MarkdownTextInput.tsx index c4ec2467a..efd1a57e2 100644 --- a/src/MarkdownTextInput.tsx +++ b/src/MarkdownTextInput.tsx @@ -28,10 +28,9 @@ function initializeLiveMarkdownIfNeeded() { if (initialized) { return; } - if (!NativeLiveMarkdownModule) { - throw new Error('[react-native-live-markdown] NativeLiveMarkdownModule is not available'); + if (NativeLiveMarkdownModule) { + NativeLiveMarkdownModule.install(); } - NativeLiveMarkdownModule.install(); if (!global.jsi_setMarkdownRuntime) { throw new Error('[react-native-live-markdown] global.jsi_setMarkdownRuntime is not available'); } @@ -53,6 +52,7 @@ function unregisterParser(parserId: number) { interface MarkdownTextInputProps extends TextInputProps, InlineImagesInputProps { markdownStyle?: PartialMarkdownStyle; + formatSelection?: (selectedText: string, formatCommand: string) => string; parser: (value: string) => MarkdownRange[]; } diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index b69028012..05316223d 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -30,6 +30,7 @@ const useClientEffect = typeof window === 'undefined' ? useEffect : useLayoutEff interface MarkdownTextInputProps extends TextInputProps, InlineImagesInputProps { markdownStyle?: MarkdownStyle; parser: (text: string) => MarkdownRange[]; + formatSelection?: (selectedText: string, formatCommand: string) => string; onClick?: (e: MouseEvent) => void; dir?: string; disabled?: boolean; @@ -85,6 +86,7 @@ const MarkdownTextInput = React.forwardRef { + if (!contentSelection.current || contentSelection.current.end - contentSelection.current.start < 1) { + throw new Error('[react-native-live-markdown] Trying to apply format command on empty selection'); + } + + if (!formatSelection) { + return parseText(parser, target, parsedText, processedMarkdownStyle, cursorPosition); + } + + const selectedText = parsedText.slice(contentSelection.current.start, contentSelection.current.end); + const formattedText = formatSelection(selectedText, formatCommand); + + if (selectedText === formattedText) { + return parseText(parser, target, parsedText, processedMarkdownStyle, cursorPosition); + } + + const prefix = parsedText.slice(0, contentSelection.current.start); + const suffix = parsedText.slice(contentSelection.current.end); + const diffLength = formattedText.length - selectedText.length; + const text = `${prefix}${formattedText}${suffix}`; + + return parseText(parser, target, text, processedMarkdownStyle, cursorPosition + diffLength, true); + }, + [parser, parseText, formatSelection, processedMarkdownStyle], + ); + // Placeholder text color logic const updateTextColor = useCallback( (node: HTMLDivElement, text: string) => { @@ -361,6 +390,11 @@ const MarkdownTextInput = React.forwardRef { expect('Hello, world!').toBeParsedAs([]); }); +describe('parsing error', () => { + expect(`> [exa\nmple.com](https://example.com)`).toBeParsedAs([]); +}); + test('bold', () => { expect('Hello, *world*!').toBeParsedAs([ {type: 'syntax', start: 7, length: 1}, diff --git a/src/__tests__/splitRangesOnEmojis.test.ts b/src/__tests__/splitRangesOnEmojis.test.ts new file mode 100644 index 000000000..a9eb11c32 --- /dev/null +++ b/src/__tests__/splitRangesOnEmojis.test.ts @@ -0,0 +1,163 @@ +import type {MarkdownRange} from '../commonTypes'; +import {splitRangesOnEmojis} from '../rangeUtils'; + +const sortRanges = (ranges: MarkdownRange[]) => { + return ranges.sort((a, b) => a.start - b.start); +}; + +test('no overlap', () => { + const markdownRanges: MarkdownRange[] = [ + {type: 'strikethrough', start: 0, length: 10}, + {type: 'emoji', start: 12, length: 2}, + ]; + + const splittedRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); + expect(splittedRanges).toEqual([ + {type: 'strikethrough', start: 0, length: 10}, + {type: 'emoji', start: 12, length: 2}, + ]); +}); + +test('overlap different type', () => { + const markdownRanges: MarkdownRange[] = [ + {type: 'strikethrough', start: 0, length: 10}, + {type: 'emoji', start: 3, length: 4}, + ]; + + const splittedRanges = splitRangesOnEmojis(markdownRanges, 'italic'); + expect(splittedRanges).toEqual(markdownRanges); +}); + +describe('single overlap', () => { + test('emoji at the beginning', () => { + let markdownRanges: MarkdownRange[] = [ + {type: 'strikethrough', start: 0, length: 10}, + {type: 'emoji', start: 0, length: 2}, + ]; + + markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); + sortRanges(markdownRanges); + + expect(markdownRanges).toEqual([ + {type: 'emoji', start: 0, length: 2}, + {type: 'strikethrough', start: 2, length: 8}, + ]); + }); + + test('emoji in the middle', () => { + let markdownRanges: MarkdownRange[] = [ + {type: 'strikethrough', start: 0, length: 10}, + {type: 'emoji', start: 3, length: 4}, + ]; + + markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); + sortRanges(markdownRanges); + + expect(markdownRanges).toEqual([ + {type: 'strikethrough', start: 0, length: 3}, + {type: 'emoji', start: 3, length: 4}, + {type: 'strikethrough', start: 7, length: 3}, + ]); + }); + + test('emoji at the end', () => { + let markdownRanges: MarkdownRange[] = [ + {type: 'strikethrough', start: 0, length: 10}, + {type: 'emoji', start: 8, length: 2}, + ]; + + markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); + sortRanges(markdownRanges); + + expect(markdownRanges).toEqual([ + {type: 'strikethrough', start: 0, length: 8}, + {type: 'emoji', start: 8, length: 2}, + ]); + }); + + test('multiple emojis in the middle', () => { + let markdownRanges: MarkdownRange[] = [ + {type: 'strikethrough', start: 0, length: 10}, + {type: 'emoji', start: 3, length: 2}, + {type: 'emoji', start: 5, length: 2}, + ]; + + markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); + sortRanges(markdownRanges); + + expect(markdownRanges).toEqual([ + {type: 'strikethrough', start: 0, length: 3}, + {type: 'emoji', start: 3, length: 2}, + {type: 'emoji', start: 5, length: 2}, + {type: 'strikethrough', start: 7, length: 3}, + ]); + }); + + test('just emojis', () => { + let markdownRanges: MarkdownRange[] = [ + {type: 'strikethrough', start: 0, length: 6}, + {type: 'emoji', start: 0, length: 2}, + {type: 'emoji', start: 2, length: 2}, + {type: 'emoji', start: 4, length: 2}, + ]; + + markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); + + expect(markdownRanges).toEqual([ + {type: 'emoji', start: 0, length: 2}, + {type: 'emoji', start: 2, length: 2}, + {type: 'emoji', start: 4, length: 2}, + ]); + }); +}); + +describe('multiple overlaps', () => { + test('splitting on one type', () => { + let markdownRanges: MarkdownRange[] = [ + {type: 'italic', start: 0, length: 20}, + {type: 'strikethrough', start: 2, length: 12}, + {type: 'emoji', start: 3, length: 1}, + {type: 'emoji', start: 8, length: 2}, + {type: 'strikethrough', start: 22, length: 5}, + ]; + + markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); + sortRanges(markdownRanges); + + expect(markdownRanges).toEqual([ + {type: 'italic', start: 0, length: 20}, + {type: 'strikethrough', start: 2, length: 1}, + {type: 'emoji', start: 3, length: 1}, + {type: 'strikethrough', start: 4, length: 4}, + {type: 'emoji', start: 8, length: 2}, + {type: 'strikethrough', start: 10, length: 4}, + {type: 'strikethrough', start: 22, length: 5}, + ]); + }); + + test('splitting on two types', () => { + let markdownRanges: MarkdownRange[] = [ + {type: 'italic', start: 0, length: 20}, + {type: 'strikethrough', start: 2, length: 12}, + {type: 'emoji', start: 3, length: 1}, + {type: 'emoji', start: 8, length: 2}, + {type: 'strikethrough', start: 22, length: 5}, + ]; + + markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); + markdownRanges = splitRangesOnEmojis(markdownRanges, 'italic'); + sortRanges(markdownRanges); + + expect(markdownRanges).toEqual([ + {type: 'italic', start: 0, length: 3}, + {type: 'strikethrough', start: 2, length: 1}, + {type: 'emoji', start: 3, length: 1}, + {type: 'italic', start: 4, length: 4}, + {type: 'strikethrough', start: 4, length: 4}, + {type: 'emoji', start: 8, length: 2}, + {type: 'italic', start: 10, length: 10}, + {type: 'strikethrough', start: 10, length: 4}, + {type: 'strikethrough', start: 22, length: 5}, + ]); + }); +}); diff --git a/src/commonTypes.ts b/src/commonTypes.ts index 73edbf39b..a7cb9e018 100644 --- a/src/commonTypes.ts +++ b/src/commonTypes.ts @@ -5,6 +5,7 @@ type MarkdownType = | 'emoji' | 'mention-here' | 'mention-user' + | 'mention-short' | 'mention-report' | 'link' | 'code' diff --git a/src/parseExpensiMark.ts b/src/parseExpensiMark.ts index 4162eb6c1..37216d115 100644 --- a/src/parseExpensiMark.ts +++ b/src/parseExpensiMark.ts @@ -1,8 +1,27 @@ 'worklet'; +import {Platform} from 'react-native'; import {ExpensiMark} from 'expensify-common'; import {unescapeText} from 'expensify-common/dist/utils'; +import {decode} from 'html-entities'; +import type {WorkletFunction} from 'react-native-reanimated/lib/typescript/commonTypes'; import type {MarkdownType, MarkdownRange} from './commonTypes'; +import {groupRanges, sortRanges, splitRangesOnEmojis} from './rangeUtils'; + +function isWeb() { + return Platform.OS === 'web'; +} + +function isJest() { + return !!global.process.env.JEST_WORKER_ID; +} + +// eslint-disable-next-line no-underscore-dangle +if (__DEV__ && !isWeb() && !isJest() && (decode as WorkletFunction).__workletHash === undefined) { + throw new Error( + "[react-native-live-markdown] `parseExpensiMark` requires `html-entities` package to be workletized. Please add `'worklet';` directive at the top of `node_modules/html-entities/lib/index.js` using patch-package.", + ); +} const MAX_PARSABLE_LENGTH = 4000; @@ -136,6 +155,8 @@ function parseTreeToTextAndRanges(tree: StackItem): [string, MarkdownRange[]] { addChildrenWithStyle(node, 'mention-here'); } else if (node.tag === '') { addChildrenWithStyle(node, 'mention-user'); + } else if (node.tag === '') { + addChildrenWithStyle(node, 'mention-short'); } else if (node.tag === '') { addChildrenWithStyle(node, 'mention-report'); } else if (node.tag === '
') { @@ -154,7 +175,7 @@ function parseTreeToTextAndRanges(tree: StackItem): [string, MarkdownRange[]] { text += '\n'; } else if (node.tag.startsWith('