Skip to content

Commit 300f1e1

Browse files
committed
feat: add colored style on android
1 parent 1b9b225 commit 300f1e1

11 files changed

Lines changed: 408 additions & 18 deletions

File tree

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,15 @@ class EnrichedTextInputView : AppCompatEditText {
659659
dispatcher?.dispatchEvent(OnRequestHtmlResultEvent(surfaceId, id, requestId, html, experimentalSynchronousEvents))
660660
}
661661

662+
fun setColor(color: Int) {
663+
val isValid = verifyStyle(EnrichedSpans.COLOR)
664+
if (!isValid) return
665+
666+
inlineStyles?.setColorStyle(color)
667+
}
668+
669+
fun removeColor() = inlineStyles?.removeColorSpan()
670+
662671
// Sometimes setting up style triggers many changes in sequence
663672
// Eg. removing conflicting styles -> changing text -> applying spans
664673
// In such scenario we want to prevent from handling side effects (eg. onTextChanged)

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

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

33
import android.content.Context
4+
import androidx.core.graphics.toColorInt
45
import com.facebook.react.bridge.ReadableArray
56
import com.facebook.react.bridge.ReadableMap
67
import com.facebook.react.module.annotations.ReactModule
@@ -329,15 +330,24 @@ class EnrichedTextInputViewManager :
329330
view?.verifyAndToggleStyle(EnrichedSpans.UNORDERED_LIST)
330331
}
331332

332-
override fun setColor(view: EnrichedTextInputView?, color: String) {
333-
// no-op for now
333+
override fun setColor(
334+
view: EnrichedTextInputView?,
335+
color: String,
336+
) {
337+
view?.setColor(color.toColorInt())
334338
}
335339

336340
override fun removeColor(view: EnrichedTextInputView?) {
337-
// no-op for now
341+
view?.removeColor()
338342
}
339343

340-
override fun addLink(view: EnrichedTextInputView?, start: Int, end: Int, text: String, url: String) {
344+
override fun addLink(
345+
view: EnrichedTextInputView?,
346+
start: Int,
347+
end: Int,
348+
text: String,
349+
url: String,
350+
) {
341351
view?.addLink(start, end, text, url)
342352
}
343353

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.swmansion.enriched.events
2+
3+
import com.facebook.react.bridge.Arguments
4+
import com.facebook.react.bridge.WritableMap
5+
import com.facebook.react.uimanager.events.Event
6+
7+
class OnColorChangeEvent(
8+
surfaceId: Int,
9+
viewId: Int,
10+
private val experimentalSynchronousEvents: Boolean,
11+
private val color: String?,
12+
) : Event<OnColorChangeEvent>(surfaceId, viewId) {
13+
override fun getEventName(): String = EVENT_NAME
14+
15+
override fun getEventData(): WritableMap {
16+
val eventData: WritableMap = Arguments.createMap()
17+
18+
eventData.putString("color", color)
19+
20+
return eventData
21+
}
22+
23+
override fun experimental_isSynchronous(): Boolean = experimentalSynchronousEvents
24+
25+
companion object {
26+
const val EVENT_NAME: String = "onColorChangeInSelection"
27+
}
28+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.swmansion.enriched.spans
2+
3+
import android.text.style.ForegroundColorSpan
4+
import com.swmansion.enriched.spans.interfaces.EnrichedInlineSpan
5+
import com.swmansion.enriched.styles.HtmlStyle
6+
7+
class EnrichedColoredSpan(
8+
htmlStyle: HtmlStyle,
9+
val color: Int,
10+
) : ForegroundColorSpan(color),
11+
EnrichedInlineSpan {
12+
override val dependsOnHtmlStyle: Boolean = false
13+
14+
override fun rebuildWithStyle(htmlStyle: HtmlStyle): EnrichedColoredSpan = EnrichedColoredSpan(htmlStyle, color)
15+
16+
fun getHexColor(): String {
17+
val rgb = foregroundColor and 0x00FFFFFF
18+
return String.format("#%06X", rgb)
19+
}
20+
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ object EnrichedSpans {
3434
const val UNDERLINE = "underline"
3535
const val STRIKETHROUGH = "strikethrough"
3636
const val INLINE_CODE = "inline_code"
37+
const val COLOR = "color"
3738

3839
// paragraph styles
3940
const val H1 = "h1"
@@ -61,6 +62,7 @@ object EnrichedSpans {
6162
UNDERLINE to BaseSpanConfig(EnrichedUnderlineSpan::class.java),
6263
STRIKETHROUGH to BaseSpanConfig(EnrichedStrikeThroughSpan::class.java),
6364
INLINE_CODE to BaseSpanConfig(EnrichedInlineCodeSpan::class.java),
65+
COLOR to BaseSpanConfig(EnrichedColoredSpan::class.java),
6466
)
6567

6668
val paragraphSpans: Map<String, ParagraphSpanConfig> =
@@ -106,6 +108,13 @@ object EnrichedSpans {
106108
StylesMergingConfig(blockingStyles = blockingStyles.toTypedArray())
107109
}
108110

111+
COLOR -> {
112+
StylesMergingConfig(
113+
conflictingStyles = arrayOf(INLINE_CODE),
114+
blockingStyles = arrayOf(CODE_BLOCK, MENTION),
115+
)
116+
}
117+
109118
ITALIC -> {
110119
StylesMergingConfig(
111120
blockingStyles = arrayOf(CODE_BLOCK),

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

Lines changed: 188 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.swmansion.enriched.styles
33
import android.text.Editable
44
import android.text.Spannable
55
import com.swmansion.enriched.EnrichedTextInputView
6+
import com.swmansion.enriched.spans.EnrichedColoredSpan
67
import com.swmansion.enriched.spans.EnrichedSpans
78
import com.swmansion.enriched.utils.getSafeSpanBoundaries
89

@@ -101,21 +102,203 @@ class InlineStyles(
101102
}
102103
}
103104

105+
private fun applyColorSpan(
106+
spannable: Spannable,
107+
start: Int,
108+
end: Int,
109+
color: Int,
110+
) {
111+
val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(start, end)
112+
spannable.setSpan(
113+
EnrichedColoredSpan(view.htmlStyle, color),
114+
safeStart,
115+
safeEnd,
116+
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE,
117+
)
118+
}
119+
120+
private fun splitExistingColorSpans(
121+
spannable: Spannable,
122+
start: Int,
123+
end: Int,
124+
onRemain: (s: Int, e: Int, color: Int) -> Unit,
125+
) {
126+
val spans = spannable.getSpans(start, end, EnrichedColoredSpan::class.java)
127+
for (span in spans) {
128+
val spanStart = spannable.getSpanStart(span)
129+
val spanEnd = spannable.getSpanEnd(span)
130+
val color = span.color
131+
132+
spannable.removeSpan(span)
133+
134+
if (spanStart < start) {
135+
onRemain(spanStart, start, color)
136+
}
137+
138+
if (spanEnd > end) {
139+
onRemain(end, spanEnd, color)
140+
}
141+
}
142+
}
143+
144+
private fun mergeAdjacentColors(spannable: Spannable) {
145+
val colorSpans =
146+
spannable
147+
.getSpans(0, spannable.length, EnrichedColoredSpan::class.java)
148+
.sortedBy { spannable.getSpanStart(it) }
149+
150+
var index = 0
151+
while (index < colorSpans.size - 1) {
152+
val currentSpan = colorSpans[index]
153+
val nextSpan = colorSpans[index + 1]
154+
155+
val currentStart = spannable.getSpanStart(currentSpan)
156+
val currentEnd = spannable.getSpanEnd(currentSpan)
157+
val nextStart = spannable.getSpanStart(nextSpan)
158+
val nextEnd = spannable.getSpanEnd(nextSpan)
159+
160+
if (currentEnd == nextStart && currentSpan.color == nextSpan.color) {
161+
spannable.removeSpan(currentSpan)
162+
spannable.removeSpan(nextSpan)
163+
164+
applyColorSpan(spannable, currentStart, nextEnd, currentSpan.color)
165+
166+
return mergeAdjacentColors(spannable)
167+
}
168+
169+
index++
170+
}
171+
}
172+
173+
private fun isFullyColoredWith(
174+
spannable: Spannable,
175+
start: Int,
176+
end: Int,
177+
color: Int,
178+
): Boolean {
179+
val spans = spannable.getSpans(start, end, EnrichedColoredSpan::class.java)
180+
if (spans.isEmpty()) return false
181+
182+
val allSame = spans.all { it.color == color }
183+
184+
if (!allSame) {
185+
return false
186+
}
187+
188+
val minStart = spans.minOf { spannable.getSpanStart(it) }
189+
val maxEnd = spans.maxOf { spannable.getSpanEnd(it) }
190+
191+
return minStart <= start && maxEnd >= end
192+
}
193+
194+
fun setColorStyle(color: Int) {
195+
val (start, end) = view.selection?.getInlineSelection() ?: return
196+
val spannable = view.text as Spannable
197+
198+
if (start == end) {
199+
val spanState = view.spanState
200+
if (spanState?.colorStart != null && spanState.typingColor == color) {
201+
view.spanState.setColorStart(null, null)
202+
} else {
203+
view.spanState?.setColorStart(start, color)
204+
}
205+
return
206+
}
207+
208+
if (isFullyColoredWith(spannable, start, end, color)) {
209+
removeColorRange(start, end)
210+
view.spanState?.setColorStart(null, null)
211+
view.selection.validateStyles()
212+
return
213+
}
214+
215+
splitExistingColorSpans(spannable, start, end) { spanStart, spanEnd, existingColor ->
216+
applyColorSpan(spannable, spanStart, spanEnd, existingColor)
217+
}
218+
219+
applyColorSpan(spannable, start, end, color)
220+
221+
mergeAdjacentColors(spannable)
222+
223+
view.spanState?.setColorStart(null, null)
224+
view.selection.validateStyles()
225+
}
226+
227+
private fun removeColorRange(
228+
start: Int,
229+
end: Int,
230+
) {
231+
val spannable = view.text as Spannable
232+
233+
splitExistingColorSpans(spannable, start, end) { spanStart, spanEnd, color ->
234+
if (spanStart < start) applyColorSpan(spannable, spanStart, start, color)
235+
if (spanEnd > end) applyColorSpan(spannable, end, spanEnd, color)
236+
}
237+
}
238+
239+
fun removeColorSpan() {
240+
val (start, end) = view.selection?.getInlineSelection() ?: return
241+
242+
if (start == end) {
243+
view.spanState?.setColorStart(null, null)
244+
return
245+
}
246+
247+
removeColorRange(start, end)
248+
249+
view.spanState?.setColorStart(null, null)
250+
view.selection.validateStyles()
251+
}
252+
253+
private fun applyTypingColorIfActive(
254+
spannable: Spannable,
255+
cursor: Int,
256+
) {
257+
val state = view.spanState ?: return
258+
val colorStart = state.colorStart ?: return
259+
val color = state.typingColor ?: return
260+
261+
val existing =
262+
spannable
263+
.getSpans(colorStart, colorStart, EnrichedColoredSpan::class.java)
264+
.firstOrNull { it.color == color }
265+
266+
if (existing != null) {
267+
val spanStart = spannable.getSpanStart(existing)
268+
val spanEnd = spannable.getSpanEnd(existing)
269+
270+
if (cursor > spanEnd) {
271+
spannable.removeSpan(existing)
272+
applyColorSpan(spannable, spanStart, cursor, color)
273+
}
274+
275+
view.spanState.setColorStart(cursor, color)
276+
return
277+
}
278+
279+
applyColorSpan(spannable, colorStart, cursor, color)
280+
view.spanState.setColorStart(cursor, color)
281+
}
282+
104283
fun afterTextChanged(
105-
s: Editable,
284+
editable: Editable,
106285
endCursorPosition: Int,
107286
) {
108287
for ((style, config) in EnrichedSpans.inlineSpans) {
109288
val start = view.spanState?.getStart(style) ?: continue
110289
var end = endCursorPosition
111-
val spans = s.getSpans(start, end, config.clazz)
290+
if (config.clazz == EnrichedColoredSpan::class.java) {
291+
applyTypingColorIfActive(editable, end)
292+
continue
293+
}
294+
val spans = editable.getSpans(start, end, config.clazz)
112295

113296
for (span in spans) {
114-
end = s.getSpanEnd(span).coerceAtLeast(end)
115-
s.removeSpan(span)
297+
end = editable.getSpanEnd(span).coerceAtLeast(end)
298+
editable.removeSpan(span)
116299
}
117300

118-
setSpan(s, config.clazz, start, end)
301+
setSpan(editable, config.clazz, start, end)
119302
}
120303
}
121304

0 commit comments

Comments
 (0)