diff --git a/android/src/main/cpp/MarkdownParser.cpp b/android/src/main/cpp/MarkdownParser.cpp new file mode 100644 index 00000000..b645eb48 --- /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 4a509b87..fc3313b5 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 9621e46f..00000000 --- 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 47086340..7daa4a33 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 00000000..c7f683bf --- /dev/null +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownFormatter.java @@ -0,0 +1,116 @@ +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 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(SpannableStringBuilder ssb, List markdownRanges, @NonNull MarkdownStyle markdownStyle) { + Objects.requireNonNull(markdownStyle, "mMarkdownStyle is null"); + removeSpans(ssb); + applyRanges(ssb, markdownRanges, markdownStyle); + } + + 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); + } + } + + private void applyRanges(SpannableStringBuilder ssb, List markdownRanges, @NonNull MarkdownStyle markdownStyle) { + for (MarkdownRange markdownRange : markdownRanges) { + applyRange(ssb, markdownRange, markdownStyle); + } + } + + private void applyRange(SpannableStringBuilder ssb, MarkdownRange markdownRange, MarkdownStyle markdownStyle) { + 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(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(SpannableStringBuilder ssb, 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 00000000..f1ae4404 --- /dev/null +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java @@ -0,0 +1,114 @@ +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 org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +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(String text, int parserId); + + private void splitRangesOnEmojis(List markdownRanges, String type) { + List emojiRanges = new ArrayList<>(); + for (MarkdownRange range : markdownRanges) { + if (range.getType().equals("emoji")) { + emojiRanges.add(range); + } + } + + int i = 0; + int j = 0; + while (i < markdownRanges.size() && j < emojiRanges.size()) { + MarkdownRange currentRange = markdownRanges.get(i); + MarkdownRange emojiRange = emojiRanges.get(j); + + if (!currentRange.getType().equals(type) || currentRange.getEnd() < emojiRange.getStart()) { + i += 1; + continue; + } else if (emojiRange.getStart() >= currentRange.getStart() && emojiRange.getEnd() <= currentRange.getEnd()) { + // Split range + MarkdownRange startRange = new MarkdownRange(currentRange.getType(), currentRange.getStart(), emojiRange.getStart() - currentRange.getStart(), currentRange.getDepth()); + MarkdownRange endRange = new MarkdownRange(currentRange.getType(), emojiRange.getEnd(), currentRange.getEnd() - emojiRange.getEnd(), currentRange.getDepth()); + + markdownRanges.add(i + 1, startRange); + markdownRanges.add(i + 2, endRange); + markdownRanges.remove(i); + i = i + 1; + } + j += 1; + } + } + + + private List parseRanges(String rangesJSON, String innerText) { + List markdownRanges = new ArrayList<>(); + try { + JSONArray ranges = new JSONArray(rangesJSON); + 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); + + MarkdownRange markdownRange = new MarkdownRange(type, start, length, depth); + if (markdownRange.getLength() == 0 || markdownRange.getEnd() > innerText.length()) { + continue; + } + markdownRanges.add(markdownRange); + } + } catch (JSONException e) { + return Collections.emptyList(); + } + splitRangesOnEmojis(markdownRanges, "italic"); + splitRangesOnEmojis(markdownRanges, "strikethrough"); + return markdownRanges; + } + + public synchronized List parse(String text, int parserId) { + if (text.equals(mPrevText) && parserId == mPrevParserId) { + return mPrevMarkdownRanges; + } + + String json; + try { + 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; + } + + List markdownRanges = parseRanges(json, text); + + mPrevText = text; + mPrevParserId = parserId; + mPrevMarkdownRanges = markdownRanges; + return mPrevMarkdownRanges; + } +} diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java index 180d9752..be72ec4a 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java @@ -1,46 +1,21 @@ 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 org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -import java.io.IOException; -import java.io.InputStream; -import java.util.ArrayList; -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; @@ -49,175 +24,13 @@ public void setMarkdownStyle(@NonNull MarkdownStyle markdownStyle) { mMarkdownStyle = markdownStyle; } - private void splitRangesOnEmojis(List markdownRanges, String type) { - List emojiRanges = new ArrayList<>(); - for (MarkdownRange range : markdownRanges) { - if (range.getType().equals("emoji")) { - emojiRanges.add(range); - } - } - - int i = 0; - int j = 0; - while (i < markdownRanges.size() && j < emojiRanges.size()) { - MarkdownRange currentRange = markdownRanges.get(i); - MarkdownRange emojiRange = emojiRanges.get(j); - - if (!currentRange.getType().equals(type) || currentRange.getEnd() < emojiRange.getStart()) { - i += 1; - continue; - } else if (emojiRange.getStart() >= currentRange.getStart() && emojiRange.getEnd() <= currentRange.getEnd()) { - // Split range - MarkdownRange startRange = new MarkdownRange(currentRange.getType(), currentRange.getStart(), emojiRange.getStart() - currentRange.getStart(), currentRange.getDepth()); - MarkdownRange endRange = new MarkdownRange(currentRange.getType(), emojiRange.getEnd(), currentRange.getEnd() - emojiRange.getEnd(), currentRange.getDepth()); - - markdownRanges.add(i + 1, startRange); - markdownRanges.add(i + 2, endRange); - markdownRanges.remove(i); - i = i + 1; - } - j += 1; - } - } - - - private List parseRanges(String rangesJSON, String innerText) { - List markdownRanges = new ArrayList<>(); - try { - JSONArray ranges = new JSONArray(rangesJSON); - 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); - - MarkdownRange markdownRange = new MarkdownRange(type, start, length, depth); - if (markdownRange.getLength() == 0 || markdownRange.getEnd() > innerText.length()) { - continue; - } - markdownRanges.add(markdownRange); - } - } catch (JSONException e) { - return new ArrayList<>(); - } - splitRangesOnEmojis(markdownRanges, "italic"); - splitRangesOnEmojis(markdownRanges, "strikethrough"); - return markdownRanges; - } - - - public void setParserId(int parserId) { mParserId = 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 = parseRanges(output, input); - 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); - } + String text = ssb.toString(); + List markdownRanges = mMarkdownParser.parse(text, mParserId); + mMarkdownFormatter.format(ssb, markdownRanges, mMarkdownStyle); } } diff --git a/apple/MarkdownParser.h b/apple/MarkdownParser.h new file mode 100644 index 00000000..7ec8d195 --- /dev/null +++ b/apple/MarkdownParser.h @@ -0,0 +1,12 @@ +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface MarkdownParser : NSObject + +- (NSArray *)parse:(NSString *)text withParserId:(NSNumber *)parserId; + +NS_ASSUME_NONNULL_END + +@end diff --git a/apple/MarkdownParser.mm b/apple/MarkdownParser.mm new file mode 100644 index 00000000..739f106d --- /dev/null +++ b/apple/MarkdownParser.mm @@ -0,0 +1,72 @@ +#import "MarkdownParser.h" +#import +#import +#import + +@implementation MarkdownParser { + NSString *_prevText; + NSNumber *_prevParserId; + NSArray *_prevMarkdownRanges; +} + +- (NSArray *)parse:(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/RCTMarkdownUtils.mm b/apple/RCTMarkdownUtils.mm index c9ceed1c..57d90774 100644 --- a/apple/RCTMarkdownUtils.mm +++ b/apple/RCTMarkdownUtils.mm @@ -1,12 +1,13 @@ #import #import -#import +#import #import "react_native_assert.h" #import #import #include @implementation RCTMarkdownUtils { + MarkdownParser *_markdownParser; NSString *_prevInputString; NSAttributedString *_prevAttributedString; NSDictionary *_prevTextAttributes; @@ -14,6 +15,15 @@ @implementation RCTMarkdownUtils { __weak NSNumber *_prevParserId; } +- (instancetype)init +{ + if (self = [super init]) { + _markdownParser = [MarkdownParser new]; + } + + return self; +} + - (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input withAttributes:(nullable NSDictionary *)attributes { @synchronized (self) { @@ -26,43 +36,8 @@ - (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input withA 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; - } - + NSArray *markdownRanges = [_markdownParser parse:inputString withParserId:_parserId]; + NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:inputString attributes:attributes]; [attributedString beginEditing]; @@ -95,10 +70,6 @@ - (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input withA } - (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") { diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 3a061a0a..d9e49e6f 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1497,7 +1497,7 @@ PODS: - React-logger (= 0.75.3) - React-perflogger (= 0.75.3) - React-utils (= 0.75.3) - - RNLiveMarkdown (0.1.188): + - RNLiveMarkdown (0.1.190): - DoubleConversion - glog - hermes-engine @@ -1517,10 +1517,10 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNLiveMarkdown/newarch (= 0.1.188) + - RNLiveMarkdown/newarch (= 0.1.190) - RNReanimated/worklets - Yoga - - RNLiveMarkdown/newarch (0.1.188): + - RNLiveMarkdown/newarch (0.1.190): - DoubleConversion - glog - hermes-engine @@ -1897,7 +1897,7 @@ SPEC CHECKSUMS: React-utils: f2afa6acd905ca2ce7bb8ffb4a22f7f8a12534e8 ReactCodegen: e35c23cdd36922f6d2990c6c1f1b022ade7ad74d ReactCommon: 289214026502e6a93484f4a46bcc0efa4f3f2864 - RNLiveMarkdown: c0d3ebfa32b4a6a33f1dbfc76ab9a06e516bfb1a + RNLiveMarkdown: a210cbb45b6cb9db0b28ef09aafdc9c77424dd38 RNReanimated: ab6c33a61e90c4cbe5dbcbe65bd6c7cb3be167e6 SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d Yoga: 1354c027ab07c7736f99a3bef16172d6f1b12b47 diff --git a/package-lock.json b/package-lock.json index f46aedb1..0756d1d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@expensify/react-native-live-markdown", - "version": "0.1.192", + "version": "0.1.197", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@expensify/react-native-live-markdown", - "version": "0.1.192", + "version": "0.1.197", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 15b4058e..bf6bb60c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@expensify/react-native-live-markdown", - "version": "0.1.192", + "version": "0.1.197", "description": "Drop-in replacement for React Native's TextInput component with Markdown formatting.", "main": "lib/commonjs/index", "module": "lib/module/index",