Skip to content

Commit a0c1521

Browse files
committed
Merge branch 'main' into @ksienkiewicz/feat-use-html-normalizer
2 parents 1be4b76 + 12b0730 commit a0c1521

35 files changed

Lines changed: 1155 additions & 262 deletions

android/src/main/java/com/swmansion/enriched/text/EnrichedTextStyle.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ data class EnrichedTextStyle(
155155
opacity: Int,
156156
): Int {
157157
val color = parseColor(context, map, key)
158-
if (Color.alpha(color) == 0) return color
158+
if (Color.alpha(color) != 255) return color
159159
return (color and 0x00FFFFFF) or (opacity.coerceIn(0, 255) shl 24)
160160
}
161161

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import com.swmansion.enriched.textinput.utils.EnrichedEditableFactory
6767
import com.swmansion.enriched.textinput.utils.EnrichedSelection
6868
import com.swmansion.enriched.textinput.utils.EnrichedSpanState
6969
import com.swmansion.enriched.textinput.utils.RichContentReceiver
70+
import com.swmansion.enriched.textinput.utils.ShortcutsHandler
7071
import com.swmansion.enriched.textinput.utils.mergeSpannables
7172
import com.swmansion.enriched.textinput.utils.setCheckboxClickListener
7273
import com.swmansion.enriched.textinput.utils.zwsCountBefore
@@ -85,6 +86,7 @@ class EnrichedTextInputView :
8586
val inlineStyles: InlineStyles? = InlineStyles(this)
8687
val paragraphStyles: ParagraphStyles? = ParagraphStyles(this)
8788
val listStyles: ListStyles? = ListStyles(this)
89+
val shortcutsHandler: ShortcutsHandler? = ShortcutsHandler(this)
8890
val parametrizedStyles: ParametrizedStyles? = ParametrizedStyles(this)
8991
val alignmentStyles: AlignmentStyles? = AlignmentStyles(this)
9092
var isDuringTransaction: Boolean = false
@@ -124,6 +126,9 @@ class EnrichedTextInputView :
124126
var experimentalSynchronousEvents: Boolean = false
125127
var useHtmlNormalizer: Boolean = false
126128

129+
// Pair: (trigger, style)
130+
var textShortcuts: List<Pair<String, String>> = emptyList()
131+
127132
private var fontSizeRaw: Float? = null
128133
var fontSize: Float? = null
129134
private var lineHeight: Float? = null
@@ -794,7 +799,7 @@ class EnrichedTextInputView :
794799
layoutManager.invalidateLayout()
795800
}
796801

797-
private fun toggleStyle(name: String) {
802+
internal fun toggleStyle(name: String) {
798803
when (name) {
799804
EnrichedSpans.BOLD -> inlineStyles?.toggleStyle(EnrichedSpans.BOLD)
800805
EnrichedSpans.ITALIC -> inlineStyles?.toggleStyle(EnrichedSpans.ITALIC)

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,22 @@ class EnrichedTextInputViewManager :
315315
view?.useHtmlNormalizer = value
316316
}
317317

318+
override fun setTextShortcuts(
319+
view: EnrichedTextInputView?,
320+
value: ReadableArray?,
321+
) {
322+
val shortcuts = mutableListOf<Pair<String, String>>()
323+
if (value != null) {
324+
for (i in 0 until value.size()) {
325+
val map = value.getMap(i) ?: continue
326+
val trigger = map.getString("trigger") ?: continue
327+
val style = map.getString("style") ?: continue
328+
shortcuts.add(Pair(trigger, style))
329+
}
330+
}
331+
view?.textShortcuts = shortcuts
332+
}
333+
318334
override fun focus(view: EnrichedTextInputView?) {
319335
view?.requestFocusProgrammatically()
320336
}

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

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,6 @@ data class ParagraphSpanConfig(
1515
val isContinuous: Boolean,
1616
) : ISpanConfig
1717

18-
data class ListSpanConfig(
19-
override val clazz: Class<*>,
20-
val shortcut: String?,
21-
) : ISpanConfig
22-
2318
data class StylesMergingConfig(
2419
// styles that should be removed when we apply specific style
2520
val conflictingStyles: Array<String> = emptyArray(),
@@ -76,11 +71,11 @@ object EnrichedSpans {
7671
CODE_BLOCK to ParagraphSpanConfig(EnrichedInputCodeBlockSpan::class.java, true),
7772
)
7873

79-
val listSpans: Map<String, ListSpanConfig> =
74+
val listSpans: Map<String, BaseSpanConfig> =
8075
mapOf(
81-
UNORDERED_LIST to ListSpanConfig(EnrichedInputUnorderedListSpan::class.java, "- "),
82-
ORDERED_LIST to ListSpanConfig(EnrichedInputOrderedListSpan::class.java, "1. "),
83-
CHECKBOX_LIST to ListSpanConfig(EnrichedInputCheckboxListSpan::class.java, null),
76+
UNORDERED_LIST to BaseSpanConfig(EnrichedInputUnorderedListSpan::class.java),
77+
ORDERED_LIST to BaseSpanConfig(EnrichedInputOrderedListSpan::class.java),
78+
CHECKBOX_LIST to BaseSpanConfig(EnrichedInputCheckboxListSpan::class.java),
8479
)
8580

8681
val parametrizedStyles: Map<String, BaseSpanConfig> =

android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,8 +197,7 @@ class HtmlStyle : EnrichedStyle {
197197
color: Int,
198198
alpha: Int,
199199
): Int {
200-
// Do not apply opacity to transparent color
201-
if (Color.alpha(color) == 0) return color
200+
if (Color.alpha(color) != 255) return color
202201
val a = alpha.coerceIn(0, 255)
203202
return (color and 0x00FFFFFF) or (a shl 24)
204203
}

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,23 @@ class InlineStyles(
143143
}
144144
}
145145

146+
fun applyStyleOnRange(
147+
name: String,
148+
start: Int,
149+
end: Int,
150+
) {
151+
val config = EnrichedSpans.inlineSpans[name] ?: return
152+
val type = config.clazz
153+
val spannable = view.text as Spannable
154+
val spans = spannable.getSpans(start, end, type)
155+
156+
if (spans.any { spannable.getSpanStart(it) <= start && spannable.getSpanEnd(it) >= end }) {
157+
return
158+
}
159+
160+
setAndMergeSpans(spannable, type, start, end)
161+
}
162+
146163
fun toggleStyle(name: String) {
147164
if (view.selection == null) return
148165
val (start, end) = view.selection.getInlineSelection()

android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,6 @@ class ListStyles(
182182

183183
val isBackspace = previousTextLength > s.length
184184
val isNewLine = cursorPosition > 0 && s[cursorPosition - 1] == '\n'
185-
val isShortcut = config.shortcut?.let { s.substring(start, end).startsWith(it) } ?: false
186185
val spans = s.getSpans(start, end, config.clazz)
187186

188187
// Remove spans if cursor is at the start of the paragraph and spans exist
@@ -191,14 +190,6 @@ class ListStyles(
191190
return
192191
}
193192

194-
if (!isBackspace && isShortcut) {
195-
s.replace(start, cursorPosition, EnrichedConstants.ZWS_STRING)
196-
setSpan(s, name, start, start + 1)
197-
// Inform that new span has been added
198-
view.selection?.validateStyles()
199-
return
200-
}
201-
202193
if (!isBackspace && isNewLine && isPreviousParagraphList(s, start, config.clazz)) {
203194
// Check if the span from the previous line "leaked" into this one
204195
if (spans.isNotEmpty()) {
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package com.swmansion.enriched.textinput.utils
2+
3+
import android.text.Editable
4+
import com.swmansion.enriched.common.EnrichedConstants
5+
import com.swmansion.enriched.textinput.EnrichedTextInputView
6+
import com.swmansion.enriched.textinput.spans.EnrichedSpans
7+
8+
class ShortcutsHandler(
9+
private val view: EnrichedTextInputView,
10+
) {
11+
fun afterTextChanged(
12+
s: Editable,
13+
endCursorPosition: Int,
14+
previousTextLength: Int,
15+
) {
16+
handleParagraphShortcuts(s, endCursorPosition, previousTextLength)
17+
handleInlineShortcuts(s, endCursorPosition, previousTextLength)
18+
}
19+
20+
private fun handleParagraphShortcuts(
21+
s: Editable,
22+
endCursorPosition: Int,
23+
previousTextLength: Int,
24+
) {
25+
val shortcuts = view.textShortcuts
26+
if (shortcuts.isEmpty()) return
27+
if (previousTextLength >= s.length) return
28+
29+
val cursorPosition = endCursorPosition.coerceAtMost(s.length)
30+
val (start, end) = s.getParagraphBounds(cursorPosition)
31+
val paragraphText = s.substring(start, end)
32+
33+
val effectiveTriggerStart =
34+
if (paragraphText.startsWith(EnrichedConstants.ZWS_STRING)) {
35+
if (paragraphHasNonAlignmentSpan(s, start, end)) return
36+
start + 1
37+
} else {
38+
start
39+
}
40+
41+
for ((trigger, styleName) in shortcuts) {
42+
if (isInlineShortcutStyle(styleName)) continue
43+
if (trigger.isEmpty()) continue
44+
if (!s.substring(effectiveTriggerStart, end).startsWith(trigger)) continue
45+
46+
val resolvedStyle = resolveStyleName(styleName) ?: continue
47+
48+
s.replace(effectiveTriggerStart, effectiveTriggerStart + trigger.length, "")
49+
view.toggleStyle(resolvedStyle)
50+
return
51+
}
52+
}
53+
54+
private fun paragraphHasNonAlignmentSpan(
55+
s: Editable,
56+
start: Int,
57+
end: Int,
58+
): Boolean {
59+
val paragraphAndListClasses =
60+
(EnrichedSpans.paragraphSpans.values + EnrichedSpans.listSpans.values).map { it.clazz }
61+
return paragraphAndListClasses.any { clazz ->
62+
s.getSpans(start, end, clazz).isNotEmpty()
63+
}
64+
}
65+
66+
private fun inlineShortcutsSorted(): List<Pair<String, String>> =
67+
view.textShortcuts
68+
.filter { (trigger, styleName) ->
69+
isInlineShortcutStyle(styleName) && trigger.isNotEmpty()
70+
}.sortedByDescending { it.first.length }
71+
72+
// Delimiter at [delimStart] is part of a longer inline trigger (e.g. `*`
73+
// inside `**`).
74+
private fun isDelimiterPartOfLongerInlineTrigger(
75+
trigger: String,
76+
delimStart: Int,
77+
text: String,
78+
inlineShortcuts: List<Pair<String, String>>,
79+
): Boolean {
80+
val delimEnd = delimStart + trigger.length
81+
82+
for ((longerTrigger, _) in inlineShortcuts) {
83+
if (longerTrigger.length <= trigger.length) continue
84+
if (!longerTrigger.endsWith(trigger)) continue
85+
86+
val longerStart = delimEnd - longerTrigger.length
87+
if (longerStart < 0 || longerStart + longerTrigger.length > text.length) continue
88+
89+
if (text.substring(longerStart, longerStart + longerTrigger.length) == longerTrigger) {
90+
return true
91+
}
92+
}
93+
94+
return false
95+
}
96+
97+
private fun handleInlineShortcuts(
98+
s: Editable,
99+
endCursorPosition: Int,
100+
previousTextLength: Int,
101+
) {
102+
val shortcuts = view.textShortcuts
103+
if (shortcuts.isEmpty()) return
104+
if (previousTextLength >= s.length) return
105+
106+
val cursorPosition = endCursorPosition.coerceAtMost(s.length)
107+
val text = s.toString()
108+
val (paraStart, _) = s.getParagraphBounds(cursorPosition)
109+
val inlineShortcuts = inlineShortcutsSorted()
110+
111+
for ((trigger, styleName) in inlineShortcuts) {
112+
val resolvedStyle = resolveStyleName(styleName) ?: continue
113+
114+
if (cursorPosition < trigger.length) continue
115+
val closingDelim = text.substring(cursorPosition - trigger.length, cursorPosition)
116+
if (closingDelim != trigger) continue
117+
118+
val closeDelimStart = cursorPosition - trigger.length
119+
120+
val searchText = text.substring(paraStart, closeDelimStart)
121+
val openIdx = searchText.lastIndexOf(trigger)
122+
if (openIdx < 0) continue
123+
124+
val openAbsolute = paraStart + openIdx
125+
126+
if (isDelimiterPartOfLongerInlineTrigger(trigger, openAbsolute, text, inlineShortcuts)) {
127+
continue
128+
}
129+
130+
val contentStart = openAbsolute + trigger.length
131+
val contentEnd = closeDelimStart
132+
if (contentEnd <= contentStart) continue
133+
134+
if (isStyleBlockedOnRange(resolvedStyle, contentStart, contentEnd, s, view.htmlStyle)) {
135+
continue
136+
}
137+
138+
s.delete(closeDelimStart, cursorPosition)
139+
s.delete(openAbsolute, openAbsolute + trigger.length)
140+
141+
val adjustedStart = openAbsolute
142+
val adjustedEnd = contentEnd - trigger.length
143+
144+
view.inlineStyles?.applyStyleOnRange(resolvedStyle, adjustedStart, adjustedEnd)
145+
view.setSelection(adjustedEnd, adjustedEnd)
146+
view.spanState?.setStart(resolvedStyle, null)
147+
return
148+
}
149+
}
150+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.swmansion.enriched.textinput.utils
2+
3+
import android.text.Spannable
4+
import com.swmansion.enriched.textinput.spans.EnrichedSpans
5+
import com.swmansion.enriched.textinput.styles.HtmlStyle
6+
7+
fun resolveStyleName(name: String): String? =
8+
when (name) {
9+
"h1" -> EnrichedSpans.H1
10+
"h2" -> EnrichedSpans.H2
11+
"h3" -> EnrichedSpans.H3
12+
"h4" -> EnrichedSpans.H4
13+
"h5" -> EnrichedSpans.H5
14+
"h6" -> EnrichedSpans.H6
15+
"blockquote" -> EnrichedSpans.BLOCK_QUOTE
16+
"codeblock" -> EnrichedSpans.CODE_BLOCK
17+
"unordered_list" -> EnrichedSpans.UNORDERED_LIST
18+
"ordered_list" -> EnrichedSpans.ORDERED_LIST
19+
"checkbox_list" -> EnrichedSpans.CHECKBOX_LIST
20+
"bold" -> EnrichedSpans.BOLD
21+
"italic" -> EnrichedSpans.ITALIC
22+
"underline" -> EnrichedSpans.UNDERLINE
23+
"strikethrough" -> EnrichedSpans.STRIKETHROUGH
24+
"inline_code" -> EnrichedSpans.INLINE_CODE
25+
else -> null
26+
}
27+
28+
fun isInlineShortcutStyle(styleName: String): Boolean {
29+
val resolvedStyle = resolveStyleName(styleName) ?: return false
30+
return EnrichedSpans.inlineSpans.containsKey(resolvedStyle)
31+
}
32+
33+
fun isStyleBlockedOnRange(
34+
styleName: String,
35+
start: Int,
36+
end: Int,
37+
spannable: Spannable,
38+
htmlStyle: HtmlStyle,
39+
): Boolean {
40+
val mergingConfig =
41+
EnrichedSpans.getMergingConfigForStyle(styleName, htmlStyle) ?: return false
42+
43+
for (blockingStyleName in mergingConfig.blockingStyles) {
44+
val spanClass = EnrichedSpans.allSpans[blockingStyleName]?.clazz ?: continue
45+
if (spannable.getSpans(start, end, spanClass).isNotEmpty()) {
46+
return true
47+
}
48+
}
49+
50+
return false
51+
}

android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ class EnrichedTextWatcher(
7878
view.paragraphStyles?.afterTextChanged(s, endCursorPosition, previousTextLength)
7979
view.listStyles?.afterTextChanged(s, endCursorPosition, previousTextLength)
8080
view.alignmentStyles?.afterTextChanged(s, endCursorPosition, deletedText, anchorAlignmentToRestore)
81+
view.shortcutsHandler?.afterTextChanged(s, endCursorPosition, previousTextLength)
8182
view.parametrizedStyles?.afterTextChanged(s, startCursorPosition, endCursorPosition)
8283
}
8384

0 commit comments

Comments
 (0)