diff --git a/.maestro/enrichedInput/screenshots/android/custom_style_colors.png b/.maestro/enrichedInput/screenshots/android/custom_style_colors.png new file mode 100644 index 00000000..cf9f1fe1 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/custom_style_colors.png differ diff --git a/.maestro/enrichedText/screenshots/android/custom_style_colors_visual.png b/.maestro/enrichedText/screenshots/android/custom_style_colors_visual.png new file mode 100644 index 00000000..e9e95d24 Binary files /dev/null and b/.maestro/enrichedText/screenshots/android/custom_style_colors_visual.png differ diff --git a/android/src/main/java/com/swmansion/enriched/common/CustomStyle.kt b/android/src/main/java/com/swmansion/enriched/common/CustomStyle.kt new file mode 100644 index 00000000..ef8ced02 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/common/CustomStyle.kt @@ -0,0 +1,6 @@ +package com.swmansion.enriched.common + +data class CustomStyle( + val foregroundColor: Int? = null, + val backgroundColor: Int? = null, +) diff --git a/android/src/main/java/com/swmansion/enriched/common/EnrichedSpanFlags.kt b/android/src/main/java/com/swmansion/enriched/common/EnrichedSpanFlags.kt index d9a0ddb5..0466bbb0 100644 --- a/android/src/main/java/com/swmansion/enriched/common/EnrichedSpanFlags.kt +++ b/android/src/main/java/com/swmansion/enriched/common/EnrichedSpanFlags.kt @@ -2,6 +2,7 @@ package com.swmansion.enriched.common import android.text.Spannable import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan +import com.swmansion.enriched.common.spans.EnrichedCustomStyleSpan import com.swmansion.enriched.common.spans.interfaces.EnrichedInlineSpan // Higher priority spans are processed first, so styles with lower priorities are painted on top of previously applied styles. @@ -10,7 +11,8 @@ import com.swmansion.enriched.common.spans.interfaces.EnrichedInlineSpan object EnrichedSpanFlags { private const val ALIGNMENT_SPAN_PRIORITY = 0 private const val INLINE_SPAN_PRIORITY = 1 - private const val PARAGRAPH_SPAN_PRIORITY = 2 + private const val CUSTOM_STYLE_SPAN_PRIORITY = 2 + private const val PARAGRAPH_SPAN_PRIORITY = 3 @JvmStatic @JvmOverloads @@ -21,6 +23,7 @@ object EnrichedSpanFlags { val priority = when (span) { is EnrichedAlignmentSpan -> ALIGNMENT_SPAN_PRIORITY + is EnrichedCustomStyleSpan -> CUSTOM_STYLE_SPAN_PRIORITY is EnrichedInlineSpan -> INLINE_SPAN_PRIORITY else -> PARAGRAPH_SPAN_PRIORITY } diff --git a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedColorParser.kt b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedColorParser.kt new file mode 100644 index 00000000..fdac22e8 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedColorParser.kt @@ -0,0 +1,273 @@ +package com.swmansion.enriched.common.parser + +import android.graphics.Color +import androidx.core.graphics.toColorInt +import kotlin.math.roundToInt + +object EnrichedColorParser { + private val CSS_NAMED_COLORS = + mapOf( + "aliceblue" to "#F0F8FFFF", + "antiquewhite" to "#FAEBD7FF", + "aqua" to "#00FFFFFF", + "aquamarine" to "#7FFFD4FF", + "azure" to "#F0FFFFFF", + "beige" to "#F5F5DCFF", + "bisque" to "#FFE4C4FF", + "black" to "#000000FF", + "blanchedalmond" to "#FFEBCDFF", + "blue" to "#0000FFFF", + "blueviolet" to "#8A2BE2FF", + "brown" to "#A52A2AFF", + "burlywood" to "#DEB887FF", + "cadetblue" to "#5F9EA0FF", + "chartreuse" to "#7FFF00FF", + "chocolate" to "#D2691EFF", + "coral" to "#FF7F50FF", + "cornflowerblue" to "#6495EDFF", + "cornsilk" to "#FFF8DCFF", + "crimson" to "#DC143CFF", + "cyan" to "#00FFFFFF", + "darkblue" to "#00008BFF", + "darkcyan" to "#008B8BFF", + "darkgoldenrod" to "#B8860BFF", + "darkgray" to "#A9A9A9FF", + "darkgrey" to "#A9A9A9FF", + "darkgreen" to "#006400FF", + "darkkhaki" to "#BDB76BFF", + "darkmagenta" to "#8B008BFF", + "darkolivegreen" to "#556B2FFF", + "darkorange" to "#FF8C00FF", + "darkorchid" to "#9932CCFF", + "darkred" to "#8B0000FF", + "darksalmon" to "#E9967AFF", + "darkseagreen" to "#8FBC8FFF", + "darkslateblue" to "#483D8BFF", + "darkslategray" to "#2F4F4FFF", + "darkslategrey" to "#2F4F4FFF", + "darkturquoise" to "#00CED1FF", + "darkviolet" to "#9400D3FF", + "deeppink" to "#FF1493FF", + "deepskyblue" to "#00BFFFFF", + "dimgray" to "#696969FF", + "dimgrey" to "#696969FF", + "dodgerblue" to "#1E90FFFF", + "firebrick" to "#B22222FF", + "floralwhite" to "#FFFAF0FF", + "forestgreen" to "#228B22FF", + "fuchsia" to "#FF00FFFF", + "gainsboro" to "#DCDCDCFF", + "ghostwhite" to "#F8F8FFFF", + "gold" to "#FFD700FF", + "goldenrod" to "#DAA520FF", + "gray" to "#808080FF", + "grey" to "#808080FF", + "green" to "#008000FF", + "greenyellow" to "#ADFF2FFF", + "honeydew" to "#F0FFF0FF", + "hotpink" to "#FF69B4FF", + "indianred" to "#CD5C5CFF", + "indigo" to "#4B0082FF", + "ivory" to "#FFFFF0FF", + "khaki" to "#F0E68CFF", + "lavender" to "#E6E6FAFF", + "lavenderblush" to "#FFF0F5FF", + "lawngreen" to "#7CFC00FF", + "lemonchiffon" to "#FFFACDFF", + "lightblue" to "#ADD8E6FF", + "lightcoral" to "#F08080FF", + "lightcyan" to "#E0FFFFFF", + "lightgoldenrodyellow" to "#FAFAD2FF", + "lightgray" to "#D3D3D3FF", + "lightgrey" to "#D3D3D3FF", + "lightgreen" to "#90EE90FF", + "lightpink" to "#FFB6C1FF", + "lightsalmon" to "#FFA07AFF", + "lightseagreen" to "#20B2AAFF", + "lightskyblue" to "#87CEFAFF", + "lightslategray" to "#778899FF", + "lightslategrey" to "#778899FF", + "lightsteelblue" to "#B0C4DEFF", + "lightyellow" to "#FFFFE0FF", + "lime" to "#00FF00FF", + "limegreen" to "#32CD32FF", + "linen" to "#FAF0E6FF", + "magenta" to "#FF00FFFF", + "maroon" to "#800000FF", + "mediumaquamarine" to "#66CDAAFF", + "mediumblue" to "#0000CDFF", + "mediumorchid" to "#BA55D3FF", + "mediumpurple" to "#9370D8FF", + "mediumseagreen" to "#3CB371FF", + "mediumslateblue" to "#7B68EEFF", + "mediumspringgreen" to "#00FA9AFF", + "mediumturquoise" to "#48D1CCFF", + "mediumvioletred" to "#C71585FF", + "midnightblue" to "#191970FF", + "mintcream" to "#F5FFFAFF", + "mistyrose" to "#FFE4E1FF", + "moccasin" to "#FFE4B5FF", + "navajowhite" to "#FFDEADFF", + "navy" to "#000080FF", + "oldlace" to "#FDF5E6FF", + "olive" to "#808000FF", + "olivedrab" to "#6B8E23FF", + "orange" to "#FFA500FF", + "orangered" to "#FF4500FF", + "orchid" to "#DA70D6FF", + "palegoldenrod" to "#EEE8AAFF", + "palegreen" to "#98FB98FF", + "paleturquoise" to "#AFEEEEFF", + "palevioletred" to "#D87093FF", + "papayawhip" to "#FFEFD5FF", + "peachpuff" to "#FFDAB9FF", + "peru" to "#CD853FFF", + "pink" to "#FFC0CBFF", + "plum" to "#DDA0DDFF", + "powderblue" to "#B0E0E6FF", + "purple" to "#800080FF", + "rebeccapurple" to "#663399FF", + "red" to "#FF0000FF", + "rosybrown" to "#BC8F8FFF", + "royalblue" to "#4169E1FF", + "saddlebrown" to "#8B4513FF", + "salmon" to "#FA8072FF", + "sandybrown" to "#F4A460FF", + "seagreen" to "#2E8B57FF", + "seashell" to "#FFF5EEFF", + "sienna" to "#A0522DFF", + "silver" to "#C0C0C0FF", + "skyblue" to "#87CEEBFF", + "slateblue" to "#6A5ACDFF", + "slategray" to "#708090FF", + "slategrey" to "#708090FF", + "snow" to "#FFFAFAFF", + "springgreen" to "#00FF7FFF", + "steelblue" to "#4682B4FF", + "tan" to "#D2B48CFF", + "teal" to "#008080FF", + "thistle" to "#D8BFD8FF", + "tomato" to "#FF6347FF", + "turquoise" to "#40E0D0FF", + "violet" to "#EE82EEFF", + "wheat" to "#F5DEB3FF", + "white" to "#FFFFFFFF", + "whitesmoke" to "#F5F5F5FF", + "yellow" to "#FFFF00FF", + "yellowgreen" to "#9ACD32FF", + ) + + @JvmStatic + fun parseCssColor(value: String?): Int? { + if (value.isNullOrBlank()) return null + + var str = value.trim().lowercase() + + // Handle Named Colors + if (CSS_NAMED_COLORS.containsKey(str)) { + str = CSS_NAMED_COLORS[str]!! + } + + // Handle Hex (#FFF, #FFFFFF, #FFFFFFFF CSS format) + if (str.startsWith("#")) { + val hex = str.substring(1) + return try { + when (hex.length) { + 3 -> { + // #RGB -> #RRGGBB + val expanded = "${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}" + "#$expanded".toColorInt() + } + + 4 -> { + // #RGBA -> #AARRGGBB + val expanded = "${hex[3]}${hex[3]}${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}" + "#$expanded".toColorInt() + } + + 6 -> { + // #RRGGBB + str.toColorInt() + } + + 8 -> { + // #RRGGBBAA -> #AARRGGBB + val aarrggbb = "#${hex.substring(6, 8)}${hex.substring(0, 6)}" + aarrggbb.toColorInt() + } + + else -> { + null + } + } + } catch (_: IllegalArgumentException) { + null + } + } + + // Handle rgb() and rgba() + if (str.startsWith("rgb")) { + return try { + val start = str.indexOf('(') + 1 + val end = str.indexOf(')') + if (start <= 0 || end <= start) return null + + val parts = str.substring(start, end).split(",") + if (parts.size >= 3) { + val r = + parts[0] + .trim() + .toFloat() + .roundToInt() + .coerceIn(0, 255) + val g = + parts[1] + .trim() + .toFloat() + .roundToInt() + .coerceIn(0, 255) + val b = + parts[2] + .trim() + .toFloat() + .roundToInt() + .coerceIn(0, 255) + + val a = + if (parts.size == 4) { + (parts[3].trim().toFloat() * 255f).roundToInt().coerceIn(0, 255) + } else { + 255 + } + + Color.argb(a, r, g, b) + } else { + null + } + } catch (_: Exception) { + null + } + } + + // Catch any native Android colors if missed + return try { + str.toColorInt() + } catch (_: IllegalArgumentException) { + null + } + } + + @JvmStatic + fun colorToHex(color: Int): String { + val alpha = (color ushr 24) and 0xFF + val red = (color shr 16) and 0xFF + val green = (color shr 8) and 0xFF + val blue = color and 0xFF + + return if (alpha == 255) { + String.format("#%02X%02X%02X", red, green, blue) + } else { + String.format("#%02X%02X%02X%02X", red, green, blue, alpha) + } + } +} diff --git a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java index 08198b3c..553c89c6 100644 --- a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java +++ b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java @@ -12,6 +12,7 @@ import com.swmansion.enriched.common.spans.EnrichedBoldSpan; import com.swmansion.enriched.common.spans.EnrichedCheckboxListSpan; import com.swmansion.enriched.common.spans.EnrichedCodeBlockSpan; +import com.swmansion.enriched.common.spans.EnrichedCustomStyleSpan; import com.swmansion.enriched.common.spans.EnrichedH1Span; import com.swmansion.enriched.common.spans.EnrichedH2Span; import com.swmansion.enriched.common.spans.EnrichedH3Span; @@ -339,6 +340,30 @@ private static void withinParagraph(StringBuilder out, Spanned text, int start, // Don't output the placeholder character underlying the image. i = next; } + if (style[j] instanceof EnrichedCustomStyleSpan) { + EnrichedCustomStyleSpan cs = (EnrichedCustomStyleSpan) style[j]; + Integer fgColor = cs.getForegroundColor(); + Integer bgColor = cs.getBackgroundColor(); + if (fgColor != null || bgColor != null) { + StringBuilder cssProps = new StringBuilder(); + if (fgColor != null) { + cssProps + .append("color: ") + .append(EnrichedColorParser.colorToHex(fgColor)) + .append(";"); + } + if (bgColor != null) { + if (cssProps.length() > 0) cssProps.append(" "); + cssProps + .append("background-color: ") + .append(EnrichedColorParser.colorToHex(bgColor)) + .append(";"); + } + out.append(""); + } else { + out.append(""); + } + } } withinStyle(out, text, i, next); for (int j = style.length - 1; j >= 0; j--) { @@ -363,6 +388,9 @@ private static void withinParagraph(StringBuilder out, Spanned text, int start, if (style[j] instanceof EnrichedItalicSpan) { out.append(""); } + if (style[j] instanceof EnrichedCustomStyleSpan) { + out.append(""); + } } } } @@ -418,6 +446,12 @@ class HtmlToSpannedConverter implements ContentHandler { private static final Pattern CSS_ALIGNMENT_PATTERN = Pattern.compile("text-align\\s*:\\s*(left|center|right)", Pattern.CASE_INSENSITIVE); + private static final Pattern CSS_FG_PATTERN = + Pattern.compile("(?:^|;)\\s*(? void endMention(Editable text, T style, EnrichedSpanFactory void endSpan(Editable text, T style, EnrichedSpanFactory spanFactory) { + CustomStyleMark mark = getLast(text, CustomStyleMark.class); + if (mark == null) return; + setSpanFromMark(text, mark, spanFactory.createCustomStyleSpan(mark.mFg, mark.mBg)); + } + public void setDocumentLocator(Locator locator) {} public void startDocument() {} @@ -1020,6 +1086,16 @@ public Newline(int numNewlines) { } } + private static class CustomStyleMark { + public final Integer mFg; + public final Integer mBg; + + public CustomStyleMark(Integer fg, Integer bg) { + mFg = fg; + mBg = bg; + } + } + private static class Alignment { final String mCssValue; diff --git a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt index f61ef8d6..9272c6cc 100644 --- a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt +++ b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt @@ -5,6 +5,7 @@ import com.swmansion.enriched.common.spans.EnrichedBlockQuoteSpan import com.swmansion.enriched.common.spans.EnrichedBoldSpan import com.swmansion.enriched.common.spans.EnrichedCheckboxListSpan import com.swmansion.enriched.common.spans.EnrichedCodeBlockSpan +import com.swmansion.enriched.common.spans.EnrichedCustomStyleSpan import com.swmansion.enriched.common.spans.EnrichedH1Span import com.swmansion.enriched.common.spans.EnrichedH2Span import com.swmansion.enriched.common.spans.EnrichedH3Span @@ -79,4 +80,9 @@ interface EnrichedSpanFactory { fun createBlockQuoteSpan(style: T): EnrichedBlockQuoteSpan fun createCodeBlockSpan(style: T): EnrichedCodeBlockSpan + + fun createCustomStyleSpan( + foregroundColor: Int?, + backgroundColor: Int?, + ): EnrichedCustomStyleSpan } diff --git a/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedCustomStyleSpan.kt b/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedCustomStyleSpan.kt new file mode 100644 index 00000000..4e8e63d0 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedCustomStyleSpan.kt @@ -0,0 +1,20 @@ +package com.swmansion.enriched.common.spans + +import android.text.TextPaint +import android.text.style.CharacterStyle +import com.swmansion.enriched.common.spans.interfaces.EnrichedInlineSpan + +open class EnrichedCustomStyleSpan( + private val foregroundColor: Int?, + private val backgroundColor: Int?, +) : CharacterStyle(), + EnrichedInlineSpan { + fun getForegroundColor(): Int? = foregroundColor + + fun getBackgroundColor(): Int? = backgroundColor + + override fun updateDrawState(textPaint: TextPaint) { + foregroundColor?.let { textPaint.color = it } + backgroundColor?.let { textPaint.bgColor = it } + } +} diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt index c2e1db3d..e44d20d6 100644 --- a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt +++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt @@ -1,11 +1,13 @@ package com.swmansion.enriched.text import com.swmansion.enriched.common.parser.EnrichedSpanFactory +import com.swmansion.enriched.common.spans.EnrichedCustomStyleSpan import com.swmansion.enriched.text.spans.EnrichedTextAlignmentSpan import com.swmansion.enriched.text.spans.EnrichedTextBlockQuoteSpan import com.swmansion.enriched.text.spans.EnrichedTextBoldSpan import com.swmansion.enriched.text.spans.EnrichedTextCheckboxListSpan import com.swmansion.enriched.text.spans.EnrichedTextCodeBlockSpan +import com.swmansion.enriched.text.spans.EnrichedTextCustomStyleSpan import com.swmansion.enriched.text.spans.EnrichedTextH1Span import com.swmansion.enriched.text.spans.EnrichedTextH2Span import com.swmansion.enriched.text.spans.EnrichedTextH3Span @@ -80,4 +82,9 @@ class EnrichedTextSpanFactory : EnrichedSpanFactory { override fun createBlockQuoteSpan(style: EnrichedTextStyle) = EnrichedTextBlockQuoteSpan(style) override fun createCodeBlockSpan(style: EnrichedTextStyle) = EnrichedTextCodeBlockSpan(style) + + override fun createCustomStyleSpan( + foregroundColor: Int?, + backgroundColor: Int?, + ): EnrichedCustomStyleSpan = EnrichedTextCustomStyleSpan(foregroundColor, backgroundColor) } diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextCustomStyleSpan.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextCustomStyleSpan.kt new file mode 100644 index 00000000..f948be2d --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextCustomStyleSpan.kt @@ -0,0 +1,16 @@ +package com.swmansion.enriched.text.spans + +import com.swmansion.enriched.common.spans.EnrichedCustomStyleSpan +import com.swmansion.enriched.text.EnrichedTextStyle +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan + +class EnrichedTextCustomStyleSpan( + foregroundColor: Int?, + backgroundColor: Int?, +) : EnrichedCustomStyleSpan(foregroundColor, backgroundColor), + EnrichedTextSpan { + override val dependsOnHtmlStyle: Boolean = false + + override fun rebuildWithStyle(style: EnrichedTextStyle): EnrichedTextCustomStyleSpan = + EnrichedTextCustomStyleSpan(getForegroundColor(), getBackgroundColor()) +} diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt index d18e16c0..c5fae89b 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt @@ -7,6 +7,7 @@ import com.swmansion.enriched.textinput.spans.EnrichedInputBlockQuoteSpan import com.swmansion.enriched.textinput.spans.EnrichedInputBoldSpan import com.swmansion.enriched.textinput.spans.EnrichedInputCheckboxListSpan import com.swmansion.enriched.textinput.spans.EnrichedInputCodeBlockSpan +import com.swmansion.enriched.textinput.spans.EnrichedInputCustomStyleSpan import com.swmansion.enriched.textinput.spans.EnrichedInputH1Span import com.swmansion.enriched.textinput.spans.EnrichedInputH2Span import com.swmansion.enriched.textinput.spans.EnrichedInputH3Span @@ -82,4 +83,9 @@ class EnrichedTextInputSpannableFactory : EnrichedSpanFactory { override fun createBlockQuoteSpan(style: HtmlStyle) = EnrichedInputBlockQuoteSpan(style) override fun createCodeBlockSpan(style: HtmlStyle) = EnrichedInputCodeBlockSpan(style) + + override fun createCustomStyleSpan( + foregroundColor: Int?, + backgroundColor: Int?, + ) = EnrichedInputCustomStyleSpan(foregroundColor, backgroundColor) } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt index b2a0c16c..2144c8c9 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -59,6 +59,7 @@ import com.swmansion.enriched.textinput.spans.EnrichedLineHeightSpan import com.swmansion.enriched.textinput.spans.EnrichedSpans import com.swmansion.enriched.textinput.spans.interfaces.EnrichedInputSpan import com.swmansion.enriched.textinput.styles.AlignmentStyles +import com.swmansion.enriched.textinput.styles.CustomStyles import com.swmansion.enriched.textinput.styles.HtmlStyle import com.swmansion.enriched.textinput.styles.InlineStyles import com.swmansion.enriched.textinput.styles.ListStyles @@ -90,6 +91,7 @@ class EnrichedTextInputView : val shortcutsHandler: ShortcutsHandler? = ShortcutsHandler(this) val parametrizedStyles: ParametrizedStyles? = ParametrizedStyles(this) val alignmentStyles: AlignmentStyles? = AlignmentStyles(this) + val customStyles: CustomStyles? = CustomStyles(this) var isDuringTransaction: Boolean = false var isRemovingMany: Boolean = false var scrollEnabled: Boolean = true @@ -1003,6 +1005,15 @@ class EnrichedTextInputView : selection?.validateStyles() } + fun setStyle(styleJSON: String) { + val isValid = verifyStyle(EnrichedSpans.CUSTOM_STYLE) + if (!isValid) return + + runAsATransaction { + customStyles?.setStyle(styleJSON) + } + } + fun requestHTML(requestId: Int) { val html = try { diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt index 3cb4a5e8..ad5fcd34 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -483,7 +483,7 @@ class EnrichedTextInputViewManager : view: EnrichedTextInputView?, styleJSON: String, ) { - // TODO: Implement + view?.setStyle(styleJSON) } override fun measure( diff --git a/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedInputCustomStyleSpan.kt b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedInputCustomStyleSpan.kt new file mode 100644 index 00000000..5257a40b --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedInputCustomStyleSpan.kt @@ -0,0 +1,16 @@ +package com.swmansion.enriched.textinput.spans + +import com.swmansion.enriched.common.spans.EnrichedCustomStyleSpan +import com.swmansion.enriched.textinput.spans.interfaces.EnrichedInputSpan +import com.swmansion.enriched.textinput.styles.HtmlStyle + +class EnrichedInputCustomStyleSpan( + foregroundColor: Int?, + backgroundColor: Int?, +) : EnrichedCustomStyleSpan(foregroundColor, backgroundColor), + EnrichedInputSpan { + override val dependsOnHtmlStyle: Boolean = false + + override fun rebuildWithStyle(htmlStyle: HtmlStyle): EnrichedInputCustomStyleSpan = + EnrichedInputCustomStyleSpan(getForegroundColor(), getBackgroundColor()) +} diff --git a/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedSpans.kt b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedSpans.kt index 0e40e9e8..d164b7db 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedSpans.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedSpans.kt @@ -50,6 +50,9 @@ object EnrichedSpans { const val IMAGE = "image" const val MENTION = "mention" + // custom style + const val CUSTOM_STYLE = "custom_style" + val inlineSpans: Map = mapOf( BOLD to BaseSpanConfig(EnrichedInputBoldSpan::class.java), @@ -85,7 +88,12 @@ object EnrichedSpans { MENTION to BaseSpanConfig(EnrichedInputMentionSpan::class.java), ) - val allSpans: Map = inlineSpans + paragraphSpans + listSpans + parametrizedStyles + val customStyles: Map = + mapOf( + CUSTOM_STYLE to BaseSpanConfig(EnrichedInputCustomStyleSpan::class.java), + ) + + val allSpans: Map = inlineSpans + paragraphSpans + listSpans + parametrizedStyles + customStyles fun getMergingConfigForStyle( style: String, @@ -231,6 +239,10 @@ object EnrichedSpans { ) } + CUSTOM_STYLE -> { + StylesMergingConfig() + } + else -> { null } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/CustomStyles.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/CustomStyles.kt new file mode 100644 index 00000000..3c94f158 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/CustomStyles.kt @@ -0,0 +1,213 @@ +package com.swmansion.enriched.textinput.styles + +import android.text.Editable +import android.text.Spannable +import com.swmansion.enriched.common.EnrichedSpanFlags +import com.swmansion.enriched.textinput.EnrichedTextInputView +import com.swmansion.enriched.textinput.spans.EnrichedInputCustomStyleSpan +import org.json.JSONObject + +class CustomStyles( + private val view: EnrichedTextInputView, +) { + fun setStyle(styleJSON: String) { + val selection = view.selection ?: return + val (start, end) = selection.getInlineSelection() + + val json = runCatching { JSONObject(styleJSON) }.getOrNull() ?: return + + val hasFg = json.has("foregroundColor") + val hasBg = json.has("backgroundColor") + + val fgColor = if (hasFg && !json.isNull("foregroundColor")) json.getInt("foregroundColor") else null + val bgColor = if (hasBg && !json.isNull("backgroundColor")) json.getInt("backgroundColor") else null + + if (start == end) { + val currentStyle = view.spanState?.customStyle + val finalFg = if (hasFg) fgColor else currentStyle?.foregroundColor + val finalBg = if (hasBg) bgColor else currentStyle?.backgroundColor + + view.spanState?.setCustomStyle(finalFg, finalBg) + } else { + val spannable = view.text as Spannable + applyCustomStyleSpan(spannable, start, end, hasFg, fgColor, hasBg, bgColor) + view.selection.validateStyles() + } + } + + private fun applyCustomStyleSpan( + spannable: Spannable, + start: Int, + end: Int, + hasFg: Boolean, + fgColor: Int?, + hasBg: Boolean, + bgColor: Int?, + ) { + val existingSpans = spannable.getSpans(start, end, EnrichedInputCustomStyleSpan::class.java) + val boundaries = mutableSetOf(start, end) + + // Snapshot boundaries and spans before any modifications + val oldSpans = + existingSpans.mapNotNull { span -> + val spanStart = spannable.getSpanStart(span) + val spanEnd = spannable.getSpanEnd(span) + if (spanStart == -1 || spanEnd == -1) null else Triple(span, spanStart, spanEnd) + } + + // Remove old spans, restore outer edges, and collect internal boundaries + for ((span, spanStart, spanEnd) in oldSpans) { + val spanFg = span.getForegroundColor() + val spanBg = span.getBackgroundColor() + + spannable.removeSpan(span) + + if (spanStart < start) setCustomSpan(spannable, spanStart, start, spanFg, spanBg) + if (spanEnd > end) setCustomSpan(spannable, end, spanEnd, spanFg, spanBg) + + if (spanStart in start..end) boundaries.add(spanStart) + if (spanEnd in start..end) boundaries.add(spanEnd) + } + + // Build the new merged spans chunk-by-chunk + val sortedBoundaries = boundaries.sorted() + + for (i in 0 until sortedBoundaries.size - 1) { + val chunkStart = sortedBoundaries[i] + val chunkEnd = sortedBoundaries[i + 1] + + // Find the old span that fully covers this specific chunk + val oldSpan = oldSpans.firstOrNull { it.second <= chunkStart && it.third >= chunkEnd }?.first + + val finalFg = if (hasFg) fgColor else oldSpan?.getForegroundColor() + val finalBg = if (hasBg) bgColor else oldSpan?.getBackgroundColor() + + setCustomSpan(spannable, chunkStart, chunkEnd, finalFg, finalBg) + } + } + + fun afterTextChanged( + s: Editable, + startCursorPosition: Int, + endCursorPosition: Int, + ) { + val isInsertion = endCursorPosition > startCursorPosition + val activeStyle = view.spanState?.customStyle + + if (isInsertion) { + val activeFg = activeStyle?.foregroundColor + val activeBg = activeStyle?.backgroundColor + + // Split existing spans if they don't match the current active colors + splitCustomSpanOnInsertion(s, startCursorPosition, endCursorPosition, activeFg, activeBg) + + setCustomSpan(s, startCursorPosition, endCursorPosition, activeFg, activeBg) + } + + // Merge any adjacent spans that have the exact same colors + collapseAdjacentCustomSpans(s, startCursorPosition, endCursorPosition) + } + + private fun splitCustomSpanOnInsertion( + spannable: Spannable, + insertStart: Int, + insertEnd: Int, + activeFg: Int?, + activeBg: Int?, + ) { + val spans = spannable.getSpans(insertStart, insertEnd, EnrichedInputCustomStyleSpan::class.java) + + for (span in spans) { + val spanStart = spannable.getSpanStart(span) + val spanEnd = spannable.getSpanEnd(span) + if (spanStart < 0 || spanEnd < 0) continue + + val spanFg = span.getForegroundColor() + val spanBg = span.getBackgroundColor() + + // If the existing span perfectly matches the active state, leave it + if (spanFg == activeFg && spanBg == activeBg) continue + + // Colors differ. We must split the old span so it doesn't cover the new text + spannable.removeSpan(span) + + if (spanStart < insertStart) { + setCustomSpan(spannable, spanStart, insertStart, spanFg, spanBg) + } + if (spanEnd > insertEnd) { + setCustomSpan(spannable, insertEnd, spanEnd, spanFg, spanBg) + } + } + } + + private fun collapseAdjacentCustomSpans( + spannable: Spannable, + start: Int, + end: Int, + ) { + // Look slightly outside the typed area to catch adjacent spans + val searchStart = (start - 1).coerceAtLeast(0) + val searchEnd = (end + 1).coerceAtMost(spannable.length) + + val spans = spannable.getSpans(searchStart, searchEnd, EnrichedInputCustomStyleSpan::class.java) + if (spans.isEmpty()) return + + // Sort spans and extract their boundaries simultaneously + val sortedSpans = + spans + .mapNotNull { span -> + val spanStart = spannable.getSpanStart(span) + val spanEnd = spannable.getSpanEnd(span) + if (spanStart == -1 || spanEnd == -1) null else Triple(span, spanStart, spanEnd) + }.sortedBy { it.second } + + // Wipe all spans in this region immediately (we safely hold their data in sortedSpans) + sortedSpans.forEach { spannable.removeSpan(it.first) } + + var (_, currentStart, currentEnd) = sortedSpans[0] + var currentFg = sortedSpans[0].first.getForegroundColor() + var currentBg = sortedSpans[0].first.getBackgroundColor() + + // Iterate and merge + for (i in 1 until sortedSpans.size) { + val (span, spanStart, spanEnd) = sortedSpans[i] + val spanFg = span.getForegroundColor() + val spanBg = span.getBackgroundColor() + + // If spans are touching/overlapping AND their colors match perfectly extend the span + if (spanStart <= currentEnd && spanFg == currentFg && spanBg == currentBg) { + currentEnd = maxOf(currentEnd, spanEnd) + } else { + // Colors changed or there is a gap. Commit the current merged block. + setCustomSpan(spannable, currentStart, currentEnd, currentFg, currentBg) + + // Start a new tracking block + currentStart = spanStart + currentEnd = spanEnd + currentFg = spanFg + currentBg = spanBg + } + } + + // Commit the final block + setCustomSpan(spannable, currentStart, currentEnd, currentFg, currentBg) + } + + private fun setCustomSpan( + spannable: Spannable, + start: Int, + end: Int, + fg: Int?, + bg: Int?, + ) { + if (start >= end || (fg == null && bg == null)) return + + val span = EnrichedInputCustomStyleSpan(fg, bg) + spannable.setSpan( + span, + start, + end, + EnrichedSpanFlags.forSpan(span), + ) + } +} diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSelection.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSelection.kt index 002e1954..d2ab6d7c 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSelection.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSelection.kt @@ -9,6 +9,7 @@ import com.swmansion.enriched.textinput.EnrichedTextInputView import com.swmansion.enriched.textinput.events.OnChangeSelectionEvent import com.swmansion.enriched.textinput.events.OnLinkDetectedEvent import com.swmansion.enriched.textinput.events.OnMentionDetectedEvent +import com.swmansion.enriched.textinput.spans.EnrichedInputCustomStyleSpan import com.swmansion.enriched.textinput.spans.EnrichedInputLinkSpan import com.swmansion.enriched.textinput.spans.EnrichedInputMentionSpan import com.swmansion.enriched.textinput.spans.EnrichedSpans @@ -89,6 +90,7 @@ class EnrichedSelection( for ((style, config) in EnrichedSpans.inlineSpans) { state.setStart(style, getInlineStyleStart(config.clazz)) } + validateCustomStyles() } else { view.isRemovingMany = false } @@ -137,6 +139,31 @@ class EnrichedSelection( return styleStart } + private fun validateCustomStyles() { + val state = view.spanState ?: return + val (start, end) = getInlineSelection() + val spannable = view.text as Spannable + val spans = spannable.getSpans(start, end, EnrichedInputCustomStyleSpan::class.java) + + var foundFg: Int? = null + var foundBg: Int? = null + + for (span in spans) { + val spanStart = spannable.getSpanStart(span) + val spanEnd = spannable.getSpanEnd(span) + + if (start == end && start == spanStart) { + continue + } else if (start >= spanStart && end <= spanEnd) { + foundFg = span.getForegroundColor() + foundBg = span.getBackgroundColor() + break + } + } + + state.setCustomStyle(foundFg, foundBg) + } + fun getParagraphSelection(): Pair { val (currentStart, currentEnd) = getInlineSelection() val spannable = view.text as Spannable diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt index ec4e2728..c58832b9 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt @@ -5,6 +5,8 @@ import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.WritableMap import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.uimanager.events.EventDispatcher +import com.swmansion.enriched.common.CustomStyle +import com.swmansion.enriched.common.parser.EnrichedColorParser import com.swmansion.enriched.textinput.EnrichedTextInputView import com.swmansion.enriched.textinput.events.OnChangeStateEvent import com.swmansion.enriched.textinput.spans.EnrichedSpans @@ -54,6 +56,8 @@ class EnrichedSpanState( private set var currentAlignment: String = "auto" private set + var customStyle: CustomStyle? = null + private set fun setBoldStart(start: Int?) { this.boldStart = start @@ -155,6 +159,14 @@ class EnrichedSpanState( emitStateChangeEvent() } + fun setCustomStyle( + fgColor: Int?, + bgColor: Int?, + ) { + this.customStyle = CustomStyle(fgColor, bgColor) + emitStateChangeEvent() + } + fun getStart(name: String): Int? { val start = when (name) { @@ -254,6 +266,7 @@ class EnrichedSpanState( payload.putMap("mention", getStyleState(activeStyles, EnrichedSpans.MENTION)) payload.putMap("checkboxList", getStyleState(activeStyles, EnrichedSpans.CHECKBOX_LIST)) payload.putString("alignment", currentAlignment) + payload.putMap("customStyle", getCustomStylePayload()) return payload } @@ -312,6 +325,25 @@ class EnrichedSpanState( return state } + private fun getCustomStylePayload(): WritableMap { + val customStyleMap = Arguments.createMap() + val customStyle = view.spanState?.customStyle + + if (customStyle?.foregroundColor != null) { + customStyleMap.putString("foregroundColor", EnrichedColorParser.colorToHex(customStyle.foregroundColor)) + } else { + customStyleMap.putString("foregroundColor", "") + } + + if (customStyle?.backgroundColor != null) { + customStyleMap.putString("backgroundColor", EnrichedColorParser.colorToHex(customStyle.backgroundColor)) + } else { + customStyleMap.putString("backgroundColor", "") + } + + return customStyleMap + } + companion object { const val NAME = "ReactNativeEnrichedView" } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt b/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt index bcc41294..c283321b 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt @@ -75,6 +75,7 @@ class EnrichedTextWatcher( private fun applyStyles(s: Editable) { view.inlineStyles?.afterTextChanged(s, startCursorPosition, endCursorPosition) + view.customStyles?.afterTextChanged(s, startCursorPosition, endCursorPosition) view.paragraphStyles?.afterTextChanged(s, endCursorPosition, previousTextLength) view.listStyles?.afterTextChanged(s, endCursorPosition, previousTextLength) view.alignmentStyles?.afterTextChanged(s, endCursorPosition, deletedText, anchorAlignmentToRestore)