diff --git a/android/build.gradle b/android/build.gradle index 8dc5b763..3ba26f75 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -175,6 +175,8 @@ dependencies { implementation "com.facebook.react:react-android" // version substituted by RNGP implementation "com.facebook.react:hermes-android" // version substituted by RNGP implementation project(":react-native-reanimated") + + testImplementation "junit:junit:4.13.2" } if (isNewArchitectureEnabled()) { diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java index 3e108db7..791c4f2b 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java @@ -1,5 +1,7 @@ package com.expensify.livemarkdown; +import static com.expensify.livemarkdown.RangeSplitter.splitRangesOnEmojis; + import androidx.annotation.NonNull; import com.facebook.react.bridge.ReactContext; @@ -11,6 +13,7 @@ import org.json.JSONException; import org.json.JSONObject; +import java.util.ArrayList; import java.util.Collections; import java.util.LinkedList; import java.util.List; @@ -31,6 +34,30 @@ public MarkdownParser(@NonNull ReactContext reactContext) { private native String nativeParse(@NonNull String text, int parserId); + 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(); + } + markdownRanges = splitRangesOnEmojis(markdownRanges, "italic"); + markdownRanges = splitRangesOnEmojis(markdownRanges, "strikethrough"); + return markdownRanges; + } public synchronized List parse(@NonNull String text, int parserId) { try { Systrace.beginSection(0, "parse"); @@ -53,30 +80,8 @@ public synchronized List parse(@NonNull String text, int parserId 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); - } + List markdownRanges = parseRanges(json, text); + Systrace.endSection(0); mPrevText = text; mPrevParserId = parserId; diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownRange.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownRange.java index 1fda115b..479f5fa1 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownRange.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownRange.java @@ -36,4 +36,32 @@ public int getLength() { public int getDepth() { return mDepth; } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (o instanceof MarkdownRange other) { + return this.mType.equals(other.mType) + && this.mStart == other.mStart + && this.mEnd == other.mEnd + && this.mLength == other.mLength + && this.mDepth == other.mDepth; + } + return false; + } + + @NonNull + @Override + public String toString() { + return "MarkdownRange{" + + "type='" + mType + "'" + + ", start=" + mStart + + ", end=" + mEnd + + ", length=" + mLength + + ", depth=" + mDepth + + "}"; + } } diff --git a/android/src/main/java/com/expensify/livemarkdown/RangeSplitter.java b/android/src/main/java/com/expensify/livemarkdown/RangeSplitter.java new file mode 100644 index 00000000..cd544c24 --- /dev/null +++ b/android/src/main/java/com/expensify/livemarkdown/RangeSplitter.java @@ -0,0 +1,67 @@ +package com.expensify.livemarkdown; + +import androidx.annotation.NonNull; + +import com.facebook.systrace.Systrace; + +import java.util.ArrayList; +import java.util.List; + +public class RangeSplitter { + public static ArrayList splitRangesOnEmojis(@NonNull List markdownRanges, @NonNull String type) { + ArrayList emojiRanges = new ArrayList<>(); + ArrayList oldRanges = new ArrayList<>(markdownRanges); + ArrayList newRanges = new ArrayList<>(); + + try { + Systrace.beginSection(0, "splitRangesOnEmojis"); + for (MarkdownRange range : oldRanges) { + if (range.getType().equals("emoji")) { + emojiRanges.add(range); + } + } + + int i = 0; + int j = 0; + while (i < oldRanges.size()) { + MarkdownRange currentRange = oldRanges.get(i); + if (!currentRange.getType().equals(type)) { + newRanges.add(currentRange); + i += 1; + continue; + } + + // Iterate through all emoji ranges before the end of the current range, splitting the current range at each intersection. + while (j < emojiRanges.size()) { + MarkdownRange emojiRange = emojiRanges.get(j); + if (emojiRange.getStart() > currentRange.getEnd()) { + break; + } + + int currentStart = currentRange.getStart(); + int currentEnd = currentRange.getEnd(); + int emojiStart = emojiRange.getStart(); + int emojiEnd = emojiRange.getEnd(); + if (emojiStart >= currentStart && emojiEnd <= currentEnd) { // Intersection + MarkdownRange newRange = new MarkdownRange(currentRange.getType(), currentStart, emojiStart - currentStart, currentRange.getDepth()); + currentRange = new MarkdownRange(currentRange.getType(), emojiEnd, currentEnd - emojiEnd, currentRange.getDepth()); + + if (newRange.getLength() > 0) { + newRanges.add(newRange); + } + } + j += 1; + } + + if (currentRange.getLength() > 0) { + newRanges.add(currentRange); + } + i += 1; + } + } finally { + Systrace.endSection(0); + } + + return newRanges; + } +} diff --git a/android/src/test/java/com/expensify/livemarkdown/RangeSplitterTest.java b/android/src/test/java/com/expensify/livemarkdown/RangeSplitterTest.java new file mode 100644 index 00000000..48f0a318 --- /dev/null +++ b/android/src/test/java/com/expensify/livemarkdown/RangeSplitterTest.java @@ -0,0 +1,81 @@ +package com.expensify.livemarkdown; + +import static com.expensify.livemarkdown.RangeSplitter.splitRangesOnEmojis; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class RangeSplitterTest { + + @Test + public void testNoOverlap() { + List markdownRanges = new ArrayList<>(); + markdownRanges.add(new MarkdownRange("strikethrough", 0, 10, 1)); + markdownRanges.add(new MarkdownRange("emoji", 12, 2, 1)); + + markdownRanges = splitRangesOnEmojis(markdownRanges, "strikethrough"); + + assertEquals(2, markdownRanges.size()); + assertEquals(new MarkdownRange("strikethrough", 0, 10, 1), markdownRanges.get(0)); + assertEquals(new MarkdownRange("emoji", 12, 2, 1), markdownRanges.get(1)); + } + + @Test + public void testOverlapDifferentType() { + List markdownRanges = new ArrayList<>(); + markdownRanges.add(new MarkdownRange("strikethrough", 0, 10, 1)); + markdownRanges.add(new MarkdownRange("emoji", 3, 4, 1)); + + markdownRanges = splitRangesOnEmojis(markdownRanges, "italic"); + + assertEquals(2, markdownRanges.size()); + assertEquals(new MarkdownRange("strikethrough", 0, 10, 1), markdownRanges.get(0)); + assertEquals(new MarkdownRange("emoji", 3, 4, 1), markdownRanges.get(1)); + } + + @Test + public void testSingleOverlap() { + List markdownRanges = new ArrayList<>(); + markdownRanges.add(new MarkdownRange("strikethrough", 0, 10, 1)); + markdownRanges.add(new MarkdownRange("emoji", 3, 4, 1)); // This range should split the strikethrough range + + markdownRanges = splitRangesOnEmojis(markdownRanges, "strikethrough"); + + // Sort is needed because ranges may get mixed while splitting + Collections.sort(markdownRanges, (r1, r2) -> Integer.compare(r1.getStart(), r2.getStart())); + + assertEquals(3, markdownRanges.size()); + assertEquals(new MarkdownRange("strikethrough", 0, 3, 1), markdownRanges.get(0)); + assertEquals(new MarkdownRange("emoji", 3, 4, 1), markdownRanges.get(1)); + assertEquals(new MarkdownRange("strikethrough", 7, 3, 1), markdownRanges.get(2)); + } + + @Test + public void testMultipleOverlapsMultipleTypes() { + List markdownRanges = new ArrayList<>(); + markdownRanges.add(new MarkdownRange("italic", 0, 20, 1)); + markdownRanges.add(new MarkdownRange("strikethrough", 2, 12, 1)); + markdownRanges.add(new MarkdownRange("emoji", 3, 1, 1)); + markdownRanges.add(new MarkdownRange("emoji", 8, 2, 1)); + markdownRanges.add(new MarkdownRange("strikethrough", 22, 5, 1)); + + markdownRanges = splitRangesOnEmojis(markdownRanges, "strikethrough"); + + // Sort is needed because ranges may get mixed while splitting + Collections.sort(markdownRanges, (r1, r2) -> Integer.compare(r1.getStart(), r2.getStart())); + + assertEquals(7, markdownRanges.size()); + assertEquals(new MarkdownRange("italic", 0, 20, 1), markdownRanges.get(0)); + assertEquals(new MarkdownRange("strikethrough", 2, 1, 1), markdownRanges.get(1)); + assertEquals(new MarkdownRange("emoji", 3, 1, 1), markdownRanges.get(2)); + assertEquals(new MarkdownRange("strikethrough", 4, 4, 1), markdownRanges.get(3)); + assertEquals(new MarkdownRange("emoji", 8, 2, 1), markdownRanges.get(4)); + assertEquals(new MarkdownRange("strikethrough", 10, 4, 1), markdownRanges.get(5)); + assertEquals(new MarkdownRange("strikethrough", 22, 5, 1), markdownRanges.get(6)); + } +} diff --git a/src/parseExpensiMark.ts b/src/parseExpensiMark.ts index 4162eb6c..9d89350f 100644 --- a/src/parseExpensiMark.ts +++ b/src/parseExpensiMark.ts @@ -222,6 +222,8 @@ function getTagPriority(tag: string) { return 2; case 'h1': return 1; + case 'emoji': + return -1; default: return 0; } diff --git a/src/web/utils/blockUtils.ts b/src/web/utils/blockUtils.ts index e9837569..41972f8d 100644 --- a/src/web/utils/blockUtils.ts +++ b/src/web/utils/blockUtils.ts @@ -26,7 +26,13 @@ function addStyleToBlock(targetElement: HTMLElement, type: NodeType, markdownSty node.style.textDecoration = 'line-through'; break; case 'emoji': - Object.assign(node.style, {...markdownStyle.emoji, verticalAlign: 'middle'}); + Object.assign(node.style, { + ...markdownStyle.emoji, + verticalAlign: 'middle', + fontStyle: 'normal', + textDecoration: 'none', + display: 'inline-block', + }); break; case 'mention-here': Object.assign(node.style, markdownStyle.mentionHere); @@ -49,7 +55,6 @@ function addStyleToBlock(targetElement: HTMLElement, type: NodeType, markdownSty case 'pre': Object.assign(node.style, markdownStyle.pre); break; - case 'blockquote': Object.assign(node.style, { ...markdownStyle.blockquote,