Skip to content

Commit 74c71e0

Browse files
Merge branch 'main' into add-codeblock
2 parents 7fd08fd + 8fc6472 commit 74c71e0

18 files changed

Lines changed: 282 additions & 159 deletions

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<!-- Thanks for submitting a pull request! We appreciate you spending the time to work on these changes. Please follow the template so that the reviewers can easily understand what the code changes affect -->
2+
3+
# Summary
4+
5+
Explain the **motivation** for making this change: here are some points to help you:
6+
7+
- What issues does the pull request solve? Please tag them so that they will get automatically closed once PR is merged
8+
- What is the feature? (if applicable)
9+
- How did you implement the solution?
10+
- What areas of the library does it impact?
11+
12+
## Test Plan
13+
14+
Provide **clear steps so another contributor can reproduce the behavior or verify the feature works**.
15+
For example:
16+
17+
- Steps to reproduce the bug (if this is a bug fix)
18+
- Steps to verify the new feature
19+
- Expected vs actual results
20+
- Any special conditions or edge cases to test
21+
22+
## Screenshots / Videos
23+
24+
Include any visual proof that helps reviewers understand the change — UI updates, bug reproduction or the result of the fix.
25+
26+
## Compatibility
27+
28+
| OS | Implemented |
29+
| ------- | :---------: |
30+
| iOS | ✅❌ |
31+
| Android | ✅❌ |

android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ class EnrichedTextInputView : AppCompatEditText {
5454
val parametrizedStyles: ParametrizedStyles? = ParametrizedStyles(this)
5555
var isDuringTransaction: Boolean = false
5656
var isRemovingMany: Boolean = false
57+
var scrollEnabled: Boolean = true
5758

5859
val mentionHandler: MentionHandler? = MentionHandler(this)
5960
var htmlStyle: HtmlStyle = HtmlStyle(this, null)
@@ -70,6 +71,8 @@ class EnrichedTextInputView : AppCompatEditText {
7071
private var fontFamily: String? = null
7172
private var fontStyle: Int = ReactConstants.UNSET
7273
private var fontWeight: Int = ReactConstants.UNSET
74+
private var defaultValue: CharSequence? = null
75+
private var defaultValueDirty: Boolean = false
7376

7477
private var inputMethodManager: InputMethodManager? = null
7578

@@ -137,6 +140,14 @@ class EnrichedTextInputView : AppCompatEditText {
137140
return super.onTouchEvent(ev)
138141
}
139142

143+
override fun canScrollVertically(direction: Int): Boolean {
144+
return scrollEnabled
145+
}
146+
147+
override fun canScrollHorizontally(direction: Int): Boolean {
148+
return scrollEnabled
149+
}
150+
140151
override fun onSelectionChanged(selStart: Int, selEnd: Int) {
141152
super.onSelectionChanged(selStart, selEnd)
142153
selection?.onSelection(selStart, selEnd)
@@ -360,7 +371,24 @@ class EnrichedTextInputView : AppCompatEditText {
360371
return false
361372
}
362373

363-
fun updateTypeface() {
374+
fun afterUpdateTransaction() {
375+
updateTypeface()
376+
updateDefaultValue()
377+
}
378+
379+
fun setDefaultValue(value: CharSequence?) {
380+
defaultValue = value
381+
defaultValueDirty = true
382+
}
383+
384+
private fun updateDefaultValue() {
385+
if (!defaultValueDirty) return
386+
387+
defaultValueDirty = false
388+
setValue(defaultValue ?: "")
389+
}
390+
391+
private fun updateTypeface() {
364392
if (!typefaceDirty) return
365393
typefaceDirty = false
366394

@@ -438,7 +466,7 @@ class EnrichedTextInputView : AppCompatEditText {
438466
}
439467

440468
private fun verifyStyle(name: String): Boolean {
441-
val mergingConfig = EnrichedSpans.mergingConfig[name] ?: return true
469+
val mergingConfig = EnrichedSpans.getMergingConfigForStyle(name, htmlStyle) ?: return true
442470
val conflictingStyles = mergingConfig.conflictingStyles
443471
val blockingStyles = mergingConfig.blockingStyles
444472
val isEnabling = spanState?.getStart(name) == null

android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package com.swmansion.enriched
22

33
import android.content.Context
4-
import android.util.Log
54
import com.facebook.react.bridge.ReadableArray
65
import com.facebook.react.bridge.ReadableMap
76
import com.facebook.react.module.annotations.ReactModule
@@ -78,7 +77,7 @@ class EnrichedTextInputViewManager : SimpleViewManager<EnrichedTextInputView>(),
7877

7978
@ReactProp(name = "defaultValue")
8079
override fun setDefaultValue(view: EnrichedTextInputView?, value: String?) {
81-
view?.setValue(value)
80+
view?.setDefaultValue(value)
8281
}
8382

8483
@ReactProp(name = "placeholder")
@@ -155,9 +154,14 @@ class EnrichedTextInputViewManager : SimpleViewManager<EnrichedTextInputView>(),
155154
view?.setFontStyle(style)
156155
}
157156

157+
@ReactProp(name = "scrollEnabled")
158+
override fun setScrollEnabled(view: EnrichedTextInputView, scrollEnabled: Boolean) {
159+
view.scrollEnabled = scrollEnabled
160+
}
161+
158162
override fun onAfterUpdateTransaction(view: EnrichedTextInputView) {
159163
super.onAfterUpdateTransaction(view)
160-
view.updateTypeface()
164+
view.afterUpdateTransaction()
161165
}
162166

163167
override fun setPadding(

android/src/main/java/com/swmansion/enriched/spans/EnrichedSpans.kt

Lines changed: 61 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.swmansion.enriched.spans
22

3+
import com.swmansion.enriched.styles.HtmlStyle
4+
35
data class BaseSpanConfig(val clazz: Class<*>)
46
data class ParagraphSpanConfig(val clazz: Class<*>, val isContinuous: Boolean)
57
data class ListSpanConfig(val clazz: Class<*>, val shortcut: String)
@@ -62,50 +64,63 @@ object EnrichedSpans {
6264
MENTION to BaseSpanConfig(EnrichedMentionSpan::class.java),
6365
)
6466

65-
val mergingConfig: Map<String, StylesMergingConfig> = mapOf(
66-
BOLD to StylesMergingConfig(
67-
blockingStyles = arrayOf(CODE_BLOCK)
68-
),
69-
ITALIC to StylesMergingConfig(
70-
blockingStyles = arrayOf(CODE_BLOCK)
71-
),
72-
UNDERLINE to StylesMergingConfig(
73-
blockingStyles = arrayOf(CODE_BLOCK)
74-
),
75-
STRIKETHROUGH to StylesMergingConfig(
76-
blockingStyles = arrayOf(CODE_BLOCK)
77-
),
78-
INLINE_CODE to StylesMergingConfig(
79-
conflictingStyles = arrayOf(MENTION, LINK),
80-
blockingStyles = arrayOf(CODE_BLOCK)
81-
),
82-
H1 to StylesMergingConfig(
83-
conflictingStyles = arrayOf(H2, H3, ORDERED_LIST, UNORDERED_LIST, BLOCK_QUOTE, CODE_BLOCK),
84-
),
85-
H2 to StylesMergingConfig(
86-
conflictingStyles = arrayOf(H1, H3, ORDERED_LIST, UNORDERED_LIST, BLOCK_QUOTE, CODE_BLOCK),
87-
),
88-
H3 to StylesMergingConfig(
89-
conflictingStyles = arrayOf(H1, H2, ORDERED_LIST, UNORDERED_LIST, BLOCK_QUOTE, CODE_BLOCK),
90-
),
91-
BLOCK_QUOTE to StylesMergingConfig(
92-
conflictingStyles = arrayOf(H1, H2, H3, CODE_BLOCK, ORDERED_LIST, UNORDERED_LIST),
93-
),
94-
CODE_BLOCK to StylesMergingConfig(
95-
conflictingStyles = arrayOf(H1, H2, H3, BOLD, ITALIC, UNDERLINE, STRIKETHROUGH, UNORDERED_LIST, ORDERED_LIST, BLOCK_QUOTE, INLINE_CODE),
96-
),
97-
UNORDERED_LIST to StylesMergingConfig(
98-
conflictingStyles = arrayOf(H1, H2, H3, ORDERED_LIST, CODE_BLOCK, BLOCK_QUOTE),
99-
),
100-
ORDERED_LIST to StylesMergingConfig(
101-
conflictingStyles = arrayOf(H1, H2, H3, UNORDERED_LIST, CODE_BLOCK, BLOCK_QUOTE),
102-
),
103-
LINK to StylesMergingConfig(
104-
blockingStyles = arrayOf(INLINE_CODE, CODE_BLOCK, MENTION)
105-
),
106-
IMAGE to StylesMergingConfig(),
107-
MENTION to StylesMergingConfig(
108-
blockingStyles = arrayOf(INLINE_CODE, CODE_BLOCK, LINK)
109-
),
110-
)
67+
fun getMergingConfigForStyle(style: String, htmlStyle: HtmlStyle): StylesMergingConfig? {
68+
return when (style) {
69+
BOLD -> {
70+
val blockingStyles = mutableListOf(CODE_BLOCK)
71+
if (htmlStyle.h1Bold) blockingStyles.add(H1)
72+
if (htmlStyle.h2Bold) blockingStyles.add(H2)
73+
if (htmlStyle.h3Bold) blockingStyles.add(H3)
74+
StylesMergingConfig(blockingStyles = blockingStyles.toTypedArray())
75+
}
76+
ITALIC -> StylesMergingConfig(
77+
blockingStyles = arrayOf(CODE_BLOCK)
78+
)
79+
UNDERLINE -> StylesMergingConfig(
80+
blockingStyles = arrayOf(CODE_BLOCK)
81+
)
82+
STRIKETHROUGH -> StylesMergingConfig(
83+
blockingStyles = arrayOf(CODE_BLOCK)
84+
)
85+
INLINE_CODE -> StylesMergingConfig(
86+
conflictingStyles = arrayOf(MENTION, LINK),
87+
blockingStyles = arrayOf(CODE_BLOCK)
88+
)
89+
H1 -> {
90+
val conflictingStyles = mutableListOf(H2, H3, ORDERED_LIST, UNORDERED_LIST, BLOCK_QUOTE, CODE_BLOCK)
91+
if (htmlStyle.h1Bold) conflictingStyles.add(BOLD)
92+
StylesMergingConfig(conflictingStyles = conflictingStyles.toTypedArray())
93+
}
94+
H2 -> {
95+
val conflictingStyles = mutableListOf(H1, H3, ORDERED_LIST, UNORDERED_LIST, BLOCK_QUOTE, CODE_BLOCK)
96+
if (htmlStyle.h2Bold) conflictingStyles.add(BOLD)
97+
StylesMergingConfig(conflictingStyles = conflictingStyles.toTypedArray())
98+
}
99+
H3 -> {
100+
val conflictingStyles = mutableListOf(H1, H2, ORDERED_LIST, UNORDERED_LIST, BLOCK_QUOTE, CODE_BLOCK)
101+
if (htmlStyle.h3Bold) conflictingStyles.add(BOLD)
102+
StylesMergingConfig(conflictingStyles = conflictingStyles.toTypedArray())
103+
}
104+
BLOCK_QUOTE -> StylesMergingConfig(
105+
conflictingStyles = arrayOf(H1, H2, H3, CODE_BLOCK, ORDERED_LIST, UNORDERED_LIST)
106+
)
107+
CODE_BLOCK -> StylesMergingConfig(
108+
conflictingStyles = arrayOf(H1, H2, H3, BOLD, ITALIC, UNDERLINE, STRIKETHROUGH, UNORDERED_LIST, ORDERED_LIST, BLOCK_QUOTE, INLINE_CODE)
109+
)
110+
UNORDERED_LIST -> StylesMergingConfig(
111+
conflictingStyles = arrayOf(H1, H2, H3, ORDERED_LIST, CODE_BLOCK, BLOCK_QUOTE)
112+
)
113+
ORDERED_LIST -> StylesMergingConfig(
114+
conflictingStyles = arrayOf(H1, H2, H3, UNORDERED_LIST, CODE_BLOCK, BLOCK_QUOTE),
115+
)
116+
LINK -> StylesMergingConfig(
117+
blockingStyles = arrayOf(INLINE_CODE, CODE_BLOCK, MENTION)
118+
)
119+
IMAGE -> StylesMergingConfig()
120+
MENTION -> StylesMergingConfig(
121+
blockingStyles = arrayOf(INLINE_CODE, CODE_BLOCK, LINK)
122+
)
123+
else -> null
124+
}
125+
}
111126
}

android/src/main/java/com/swmansion/enriched/styles/ParametrizedStyles.kt

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,15 +104,31 @@ class ParametrizedStyles(private val view: EnrichedTextInputView) {
104104
return Triple(result, start, end)
105105
}
106106

107+
private fun canLinkBeApplied(): Boolean {
108+
val mergingConfig = EnrichedSpans.getMergingConfigForStyle(EnrichedSpans.LINK, view.htmlStyle)?: return true
109+
val conflictingStyles = mergingConfig.conflictingStyles
110+
val blockingStyles = mergingConfig.blockingStyles
111+
112+
for (style in blockingStyles) {
113+
if (view.spanState?.getStart(style) != null) return false
114+
}
115+
116+
for (style in conflictingStyles) {
117+
if (view.spanState?.getStart(style) != null) return false
118+
}
119+
120+
return true
121+
}
122+
107123
private fun afterTextChangedLinks(result: Triple<String, Int, Int>) {
108124
// Do not detect link if it's applied manually
109-
if (isSettingLinkSpan) return
125+
if (isSettingLinkSpan || !canLinkBeApplied()) return
126+
110127
val spannable = view.text as Spannable
111128
val (word, start, end) = result
112129

113130
// TODO: Consider using more reliable regex, this one matches almost anything
114131
val urlPattern = android.util.Patterns.WEB_URL.matcher(word)
115-
116132
val spans = spannable.getSpans(start, end, EnrichedLinkSpan::class.java)
117133
for (span in spans) {
118134
spannable.removeSpan(span)

android/src/main/java/com/swmansion/enriched/utils/EnrichedParser.java

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,7 @@ class HtmlToSpannedConverter implements ContentHandler {
350350
private final EnrichedParser.ImageGetter mImageGetter;
351351
private static Integer currentOrderedListItemIndex = 0;
352352
private static Boolean isInOrderedList = false;
353+
private static Boolean isEmptyTag = false;
353354

354355
public HtmlToSpannedConverter(String source, HtmlStyle style, EnrichedParser.ImageGetter imageGetter, Parser parser) {
355356
mStyle = style;
@@ -396,19 +397,27 @@ public Spanned convert() {
396397
for (EnrichedZeroWidthSpaceSpan zeroWidthSpaceSpan : zeroWidthSpaceSpans) {
397398
int start = mSpannableStringBuilder.getSpanStart(zeroWidthSpaceSpan);
398399
int end = mSpannableStringBuilder.getSpanEnd(zeroWidthSpaceSpan);
399-
mSpannableStringBuilder.insert(start, "\u200B");
400+
401+
if (mSpannableStringBuilder.charAt(start) != '\u200B') {
402+
// Insert zero-width space character at the start if it's not already present.
403+
mSpannableStringBuilder.insert(start, "\u200B");
404+
end++; // Adjust end position due to insertion.
405+
}
406+
400407
mSpannableStringBuilder.removeSpan(zeroWidthSpaceSpan);
401-
mSpannableStringBuilder.setSpan(zeroWidthSpaceSpan, start, end + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
408+
mSpannableStringBuilder.setSpan(zeroWidthSpaceSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
402409
}
403410

404411
return mSpannableStringBuilder;
405412
}
406413

407414
private void handleStartTag(String tag, Attributes attributes) {
415+
isEmptyTag = false;
408416
if (tag.equalsIgnoreCase("br")) {
409417
// We don't need to handle this. TagSoup will ensure that there's a </br> for each <br>
410418
// so we can safely emit the linebreaks when we handle the close tag.
411419
} else if (tag.equalsIgnoreCase("p")) {
420+
isEmptyTag = true;
412421
startBlockElement(mSpannableStringBuilder);
413422
} else if (tag.equalsIgnoreCase("ul")) {
414423
isInOrderedList = false;
@@ -418,14 +427,17 @@ private void handleStartTag(String tag, Attributes attributes) {
418427
currentOrderedListItemIndex = 0;
419428
startBlockElement(mSpannableStringBuilder);
420429
} else if (tag.equalsIgnoreCase("li")) {
430+
isEmptyTag = true;
421431
startLi(mSpannableStringBuilder);
422432
} else if (tag.equalsIgnoreCase("b")) {
423433
start(mSpannableStringBuilder, new Bold());
424434
} else if (tag.equalsIgnoreCase("i")) {
425435
start(mSpannableStringBuilder, new Italic());
426436
} else if (tag.equalsIgnoreCase("blockquote")) {
437+
isEmptyTag = true;
427438
startBlockquote(mSpannableStringBuilder);
428439
} else if (tag.equalsIgnoreCase("codeblock")) {
440+
isEmptyTag = true;
429441
startCodeBlock(mSpannableStringBuilder);
430442
} else if (tag.equalsIgnoreCase("a")) {
431443
startA(mSpannableStringBuilder, attributes);
@@ -632,11 +644,17 @@ private static void setSpanFromMark(Spannable text, Object mark, Object... spans
632644
}
633645
}
634646

635-
private static void setParagraphSpanFromMark(Spannable text, Object mark, Object... spans) {
647+
private static void setParagraphSpanFromMark(Editable text, Object mark, Object... spans) {
636648
int where = text.getSpanStart(mark);
637649
text.removeSpan(mark);
638650
int len = text.length();
639651

652+
// Block spans require at least one character to be applied.
653+
if (isEmptyTag) {
654+
text.append("\u200B");
655+
len++;
656+
}
657+
640658
// Adjust the end position to exclude the newline character, if present
641659
if (len > 0 && text.charAt(len - 1) == '\n') {
642660
len--;
@@ -741,6 +759,8 @@ public void endElement(String uri, String localName, String qName) {
741759

742760
public void characters(char[] ch, int start, int length) {
743761
StringBuilder sb = new StringBuilder();
762+
if (length > 0) isEmptyTag = false;
763+
744764
/*
745765
* Ignore whitespace that immediately follows other whitespace;
746766
* newlines count as spaces.

0 commit comments

Comments
 (0)