Skip to content

Commit 6762946

Browse files
Merge branch 'main' into fix/initial-hmtl-styles-are-not-applied
2 parents f93aff9 + 6aad08e commit 6762946

67 files changed

Lines changed: 2259 additions & 461 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.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 | ✅❌ |

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,11 @@ export default function App() {
101101
<View style={styles.container}>
102102
<EnrichedTextInput
103103
ref={ref}
104-
onChangeState={(e) => setStylesState(e.nativeEvent)}
104+
onChangeState={e => setStylesState(e.nativeEvent)}
105105
style={styles.input}
106106
/>
107107
<Button
108-
title="Toggle bold"
108+
title={stylesState?.isBold ? 'Unbold' : 'Bold'}
109109
color={stylesState?.isBold ? 'green' : 'gray'}
110110
onPress={() => ref.current?.toggleBold()}
111111
/>

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

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight
2929
import com.swmansion.enriched.events.MentionHandler
3030
import com.swmansion.enriched.events.OnInputBlurEvent
3131
import com.swmansion.enriched.events.OnInputFocusEvent
32+
import com.swmansion.enriched.spans.EnrichedImageSpan
3233
import com.swmansion.enriched.spans.EnrichedSpans
3334
import com.swmansion.enriched.styles.InlineStyles
3435
import com.swmansion.enriched.styles.ListStyles
@@ -54,6 +55,7 @@ class EnrichedTextInputView : AppCompatEditText {
5455
val parametrizedStyles: ParametrizedStyles? = ParametrizedStyles(this)
5556
var isDuringTransaction: Boolean = false
5657
var isRemovingMany: Boolean = false
58+
var scrollEnabled: Boolean = true
5759

5860
val mentionHandler: MentionHandler? = MentionHandler(this)
5961
var htmlStyle: HtmlStyle = HtmlStyle(this, null)
@@ -76,6 +78,8 @@ class EnrichedTextInputView : AppCompatEditText {
7678
private var fontFamily: String? = null
7779
private var fontStyle: Int = ReactConstants.UNSET
7880
private var fontWeight: Int = ReactConstants.UNSET
81+
private var defaultValue: CharSequence? = null
82+
private var defaultValueDirty: Boolean = false
7983

8084
private var inputMethodManager: InputMethodManager? = null
8185

@@ -143,6 +147,14 @@ class EnrichedTextInputView : AppCompatEditText {
143147
return super.onTouchEvent(ev)
144148
}
145149

150+
override fun canScrollVertically(direction: Int): Boolean {
151+
return scrollEnabled
152+
}
153+
154+
override fun canScrollHorizontally(direction: Int): Boolean {
155+
return scrollEnabled
156+
}
157+
146158
override fun onSelectionChanged(selStart: Int, selEnd: Int) {
147159
super.onSelectionChanged(selStart, selEnd)
148160
selection?.onSelection(selStart, selEnd)
@@ -249,6 +261,7 @@ class EnrichedTextInputView : AppCompatEditText {
249261
val newText = parseText(value)
250262
setText(newText)
251263

264+
observeAsyncImages()
252265
// Assign SpanWatcher one more time as our previous spannable has been replaced
253266
addSpanWatcher(EnrichedSpanWatcher(this))
254267

@@ -257,6 +270,20 @@ class EnrichedTextInputView : AppCompatEditText {
257270
}
258271
}
259272

273+
/**
274+
* Finds all async images in the current text and sets up listeners
275+
* to redraw the text layout when they finish downloading.
276+
*/
277+
private fun observeAsyncImages() {
278+
val liveText = text ?: return
279+
280+
val spans = liveText.getSpans(0, liveText.length, EnrichedImageSpan::class.java)
281+
282+
for (span in spans) {
283+
span.observeAsyncDrawableLoaded(liveText)
284+
}
285+
}
286+
260287
fun setAutoFocus(autoFocus: Boolean) {
261288
this.autoFocus = autoFocus
262289
}
@@ -366,7 +393,24 @@ class EnrichedTextInputView : AppCompatEditText {
366393
return false
367394
}
368395

369-
fun updateTypeface() {
396+
fun afterUpdateTransaction() {
397+
updateTypeface()
398+
updateDefaultValue()
399+
}
400+
401+
fun setDefaultValue(value: CharSequence?) {
402+
defaultValue = value
403+
defaultValueDirty = true
404+
}
405+
406+
private fun updateDefaultValue() {
407+
if (!defaultValueDirty) return
408+
409+
defaultValueDirty = false
410+
setValue(defaultValue ?: "")
411+
}
412+
413+
private fun updateTypeface() {
370414
if (!typefaceDirty) return
371415
typefaceDirty = false
372416

@@ -444,7 +488,7 @@ class EnrichedTextInputView : AppCompatEditText {
444488
}
445489

446490
private fun verifyStyle(name: String): Boolean {
447-
val mergingConfig = EnrichedSpans.mergingConfig[name] ?: return true
491+
val mergingConfig = EnrichedSpans.getMergingConfigForStyle(name, htmlStyle) ?: return true
448492
val conflictingStyles = mergingConfig.conflictingStyles
449493
val blockingStyles = mergingConfig.blockingStyles
450494
val isEnabling = spanState?.getStart(name) == null
@@ -505,11 +549,11 @@ class EnrichedTextInputView : AppCompatEditText {
505549
parametrizedStyles?.setLinkSpan(start, end, text, url)
506550
}
507551

508-
fun addImage(src: String) {
552+
fun addImage(src: String, width: Float, height: Float) {
509553
val isValid = verifyStyle(EnrichedSpans.IMAGE)
510554
if (!isValid) return
511555

512-
parametrizedStyles?.setImageSpan(src)
556+
parametrizedStyles?.setImageSpan(src, width, height)
513557
layoutManager.invalidateLayout()
514558
}
515559

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

Lines changed: 11 additions & 6 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(
@@ -251,8 +255,8 @@ class EnrichedTextInputViewManager : SimpleViewManager<EnrichedTextInputView>(),
251255
view?.addLink(start, end, text, url)
252256
}
253257

254-
override fun addImage(view: EnrichedTextInputView?, src: String) {
255-
view?.addImage(src)
258+
override fun addImage(view: EnrichedTextInputView?, src: String, width: Float, height: Float) {
259+
view?.addImage(src, width, height)
256260
}
257261

258262
override fun startMention(view: EnrichedTextInputView?, indicator: String) {
@@ -275,7 +279,8 @@ class EnrichedTextInputViewManager : SimpleViewManager<EnrichedTextInputView>(),
275279
heightMode: YogaMeasureMode?,
276280
attachmentsPositions: FloatArray?
277281
): Long {
278-
return MeasurementStore.getMeasureById(localData?.getInt("viewTag"), width)
282+
val id = localData?.getInt("viewTag")
283+
return MeasurementStore.getMeasureById(context, id, width, props)
279284
}
280285

281286
companion object {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import com.facebook.react.ReactPackage
44
import com.facebook.react.bridge.NativeModule
55
import com.facebook.react.bridge.ReactApplicationContext
66
import com.facebook.react.uimanager.ViewManager
7+
import com.swmansion.enriched.utils.ResourceManager
78
import java.util.ArrayList
89

910
class EnrichedTextInputViewPackage : ReactPackage {
1011
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
12+
ResourceManager.init(reactContext.applicationContext)
1113
val viewManagers: MutableList<ViewManager<*, *>> = ArrayList()
1214
viewManagers.add(EnrichedTextInputViewManager())
1315
return viewManagers

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

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
11
package com.swmansion.enriched
22

3+
import android.content.Context
34
import android.graphics.Typeface
45
import android.graphics.text.LineBreaker
56
import android.os.Build
67
import android.text.Spannable
78
import android.text.StaticLayout
89
import android.text.TextPaint
10+
import android.util.Log
11+
import com.facebook.react.bridge.ReadableMap
912
import com.facebook.react.uimanager.PixelUtil
13+
import com.facebook.react.views.text.ReactTypefaceUtils.applyStyles
14+
import com.facebook.react.views.text.ReactTypefaceUtils.parseFontStyle
15+
import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight
1016
import com.facebook.yoga.YogaMeasureOutput
17+
import com.swmansion.enriched.styles.HtmlStyle
18+
import com.swmansion.enriched.utils.EnrichedParser
1119
import java.util.concurrent.ConcurrentHashMap
20+
import kotlin.math.ceil
1221

1322
object MeasurementStore {
1423
data class PaintParams(
@@ -17,10 +26,12 @@ object MeasurementStore {
1726
)
1827

1928
data class MeasurementParams(
29+
val initialized: Boolean,
30+
2031
val cachedWidth: Float,
2132
val cachedSize: Long,
2233

23-
val spannable: Spannable?,
34+
val spannable: CharSequence?,
2435
val paintParams: PaintParams,
2536
)
2637

@@ -29,18 +40,20 @@ object MeasurementStore {
2940
fun store(id: Int, spannable: Spannable?, paint: TextPaint): Boolean {
3041
val cachedWidth = data[id]?.cachedWidth ?: 0f
3142
val cachedSize = data[id]?.cachedSize ?: 0L
43+
val initialized = data[id]?.initialized ?: true
44+
3245
val size = measure(cachedWidth, spannable, paint)
3346
val paintParams = PaintParams(paint.typeface, paint.textSize)
3447

35-
data[id] = MeasurementParams(cachedWidth, size, spannable, paintParams)
48+
data[id] = MeasurementParams(initialized, cachedWidth, size, spannable, paintParams)
3649
return cachedSize != size
3750
}
3851

3952
fun release(id: Int) {
4053
data.remove(id)
4154
}
4255

43-
fun measure(maxWidth: Float, spannable: Spannable?, paintParams: PaintParams): Long {
56+
private fun measure(maxWidth: Float, spannable: CharSequence?, paintParams: PaintParams): Long {
4457
val paint = TextPaint().apply {
4558
typeface = paintParams.typeface
4659
textSize = paintParams.fontSize
@@ -49,7 +62,7 @@ object MeasurementStore {
4962
return measure(maxWidth, spannable, paint)
5063
}
5164

52-
fun measure(maxWidth: Float, spannable: Spannable?, paint: TextPaint): Long {
65+
private fun measure(maxWidth: Float, spannable: CharSequence?, paint: TextPaint): Long {
5366
val text = spannable ?: ""
5467
val textLength = text.length
5568
val builder = StaticLayout.Builder
@@ -71,9 +84,63 @@ object MeasurementStore {
7184
return YogaMeasureOutput.make(widthInSP, heightInSP)
7285
}
7386

74-
fun getMeasureById(id: Int?, width: Float): Long {
75-
val id = id ?: return YogaMeasureOutput.make(0, 0)
76-
val value = data[id] ?: return YogaMeasureOutput.make(0, 0)
87+
// Returns either: Spannable parsed from HTML defaultValue, or plain text defaultValue, or "I" if no defaultValue
88+
private fun getInitialText(defaultView: EnrichedTextInputView, props: ReadableMap?): CharSequence {
89+
val defaultValue = props?.getString("defaultValue")
90+
91+
// If there is no default value, assume text is one line, "I" is a good approximation of height
92+
if (defaultValue == null) return "I"
93+
94+
val isHtml = defaultValue.startsWith("<html>") && defaultValue.endsWith("</html>")
95+
if (!isHtml) return defaultValue
96+
97+
try {
98+
val htmlStyle = HtmlStyle(defaultView, props.getMap("htmlStyle"))
99+
val parsed = EnrichedParser.fromHtml(defaultValue, htmlStyle, null)
100+
return parsed.trimEnd('\n')
101+
} catch (e: Exception) {
102+
Log.w("MeasurementStore", "Error parsing initial HTML text: ${e.message}")
103+
return defaultValue
104+
}
105+
}
106+
107+
private fun getInitialFontSize(defaultView: EnrichedTextInputView, props: ReadableMap?): Float {
108+
val propsFontSize = props?.getDouble("fontSize")?.toFloat()
109+
if (propsFontSize == null) return defaultView.textSize
110+
111+
return ceil(PixelUtil.toPixelFromSP(propsFontSize))
112+
}
113+
114+
// Called when view measurements are not available in the store
115+
// Most likely first measurement, we can use defaultValue, as no native state is set yet
116+
private fun initialMeasure(context: Context, id: Int?, width: Float, props: ReadableMap?): Long {
117+
val defaultView = EnrichedTextInputView(context)
118+
119+
val text = getInitialText(defaultView, props)
120+
val fontSize = getInitialFontSize(defaultView, props)
121+
122+
val fontFamily = props?.getString("fontFamily")
123+
val fontStyle = parseFontStyle(props?.getString("fontStyle"))
124+
val fontWeight = parseFontWeight(props?.getString("fontWeight"))
125+
126+
val typeface = applyStyles(defaultView.typeface, fontStyle, fontWeight, fontFamily, context.assets)
127+
val paintParams = PaintParams(typeface, fontSize)
128+
val size = measure(width, text, PaintParams(typeface, fontSize))
129+
130+
if (id != null) {
131+
data[id] = MeasurementParams(true, width, size, text, paintParams)
132+
}
133+
134+
return size
135+
}
136+
137+
fun getMeasureById(context: Context, id: Int?, width: Float, props: ReadableMap?): Long {
138+
val id = id ?: return initialMeasure(context, id, width, props)
139+
val value = data[id] ?: return initialMeasure(context, id, width, props)
140+
141+
// First measure has to be done using initialMeasure
142+
// That way it's free of any side effects and async initializations
143+
if (!value.initialized) return initialMeasure(context, id, width, props)
77144

78145
if (width == value.cachedWidth) {
79146
return value.cachedSize
@@ -83,8 +150,9 @@ object MeasurementStore {
83150
typeface = value.paintParams.typeface
84151
textSize = value.paintParams.fontSize
85152
}
153+
86154
val size = measure(width, value.spannable, paint)
87-
data[id] = MeasurementParams(width, size, value.spannable, value.paintParams)
155+
data[id] = MeasurementParams(true, width, size, value.spannable, value.paintParams)
88156
return size
89157
}
90158
}

0 commit comments

Comments
 (0)