diff --git a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt index e14f51389..e6666ce47 100644 --- a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt @@ -691,6 +691,15 @@ class EnrichedTextInputView : AppCompatEditText { dispatcher?.dispatchEvent(OnRequestHtmlResultEvent(surfaceId, id, requestId, html, experimentalSynchronousEvents)) } + fun setColor(color: Int) { + val isValid = verifyStyle(EnrichedSpans.COLOR) + if (!isValid) return + + inlineStyles?.setColorStyle(color) + } + + fun removeColor() = inlineStyles?.removeColorSpan() + // Sometimes setting up style triggers many changes in sequence // Eg. removing conflicting styles -> changing text -> applying spans // In such scenario we want to prevent from handling side effects (eg. onTextChanged) diff --git a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt index d74f08a9e..1c8e2ea11 100644 --- a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt @@ -1,6 +1,7 @@ package com.swmansion.enriched import android.content.Context +import androidx.core.graphics.toColorInt import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap import com.facebook.react.module.annotations.ReactModule @@ -338,6 +339,17 @@ class EnrichedTextInputViewManager : view?.verifyAndToggleStyle(EnrichedSpans.UNORDERED_LIST) } + override fun setColor( + view: EnrichedTextInputView?, + color: String, + ) { + view?.setColor(color.toColorInt()) + } + + override fun removeColor(view: EnrichedTextInputView?) { + view?.removeColor() + } + override fun addLink( view: EnrichedTextInputView?, start: Int, diff --git a/android/src/main/java/com/swmansion/enriched/events/OnColorChangeEvent.kt b/android/src/main/java/com/swmansion/enriched/events/OnColorChangeEvent.kt new file mode 100644 index 000000000..e8877b88d --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/events/OnColorChangeEvent.kt @@ -0,0 +1,28 @@ +package com.swmansion.enriched.events + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.events.Event + +class OnColorChangeEvent( + surfaceId: Int, + viewId: Int, + private val experimentalSynchronousEvents: Boolean, + private val color: String?, +) : Event(surfaceId, viewId) { + override fun getEventName(): String = EVENT_NAME + + override fun getEventData(): WritableMap { + val eventData: WritableMap = Arguments.createMap() + + eventData.putString("color", color) + + return eventData + } + + override fun experimental_isSynchronous(): Boolean = experimentalSynchronousEvents + + companion object { + const val EVENT_NAME: String = "onColorChangeInSelection" + } +} diff --git a/android/src/main/java/com/swmansion/enriched/spans/EnrichedColoredSpan.kt b/android/src/main/java/com/swmansion/enriched/spans/EnrichedColoredSpan.kt new file mode 100644 index 000000000..9b89a3546 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/spans/EnrichedColoredSpan.kt @@ -0,0 +1,20 @@ +package com.swmansion.enriched.spans + +import android.text.style.ForegroundColorSpan +import com.swmansion.enriched.spans.interfaces.EnrichedInlineSpan +import com.swmansion.enriched.styles.HtmlStyle + +class EnrichedColoredSpan( + htmlStyle: HtmlStyle, + val color: Int, +) : ForegroundColorSpan(color), + EnrichedInlineSpan { + override val dependsOnHtmlStyle: Boolean = false + + override fun rebuildWithStyle(htmlStyle: HtmlStyle): EnrichedColoredSpan = EnrichedColoredSpan(htmlStyle, color) + + fun getHexColor(): String { + val rgb = foregroundColor and 0x00FFFFFF + return String.format("#%06X", rgb) + } +} diff --git a/android/src/main/java/com/swmansion/enriched/spans/EnrichedSpans.kt b/android/src/main/java/com/swmansion/enriched/spans/EnrichedSpans.kt index 3589a73f7..d70720c1b 100644 --- a/android/src/main/java/com/swmansion/enriched/spans/EnrichedSpans.kt +++ b/android/src/main/java/com/swmansion/enriched/spans/EnrichedSpans.kt @@ -34,6 +34,7 @@ object EnrichedSpans { const val UNDERLINE = "underline" const val STRIKETHROUGH = "strikethrough" const val INLINE_CODE = "inline_code" + const val COLOR = "color" // paragraph styles const val H1 = "h1" @@ -61,6 +62,7 @@ object EnrichedSpans { UNDERLINE to BaseSpanConfig(EnrichedUnderlineSpan::class.java), STRIKETHROUGH to BaseSpanConfig(EnrichedStrikeThroughSpan::class.java), INLINE_CODE to BaseSpanConfig(EnrichedInlineCodeSpan::class.java), + COLOR to BaseSpanConfig(EnrichedColoredSpan::class.java), ) val paragraphSpans: Map = @@ -106,6 +108,13 @@ object EnrichedSpans { StylesMergingConfig(blockingStyles = blockingStyles.toTypedArray()) } + COLOR -> { + StylesMergingConfig( + conflictingStyles = arrayOf(INLINE_CODE), + blockingStyles = arrayOf(CODE_BLOCK, MENTION), + ) + } + ITALIC -> { StylesMergingConfig( blockingStyles = arrayOf(CODE_BLOCK), diff --git a/android/src/main/java/com/swmansion/enriched/styles/InlineStyles.kt b/android/src/main/java/com/swmansion/enriched/styles/InlineStyles.kt index 967f6a223..7de95afce 100644 --- a/android/src/main/java/com/swmansion/enriched/styles/InlineStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/styles/InlineStyles.kt @@ -3,6 +3,7 @@ package com.swmansion.enriched.styles import android.text.Editable import android.text.Spannable import com.swmansion.enriched.EnrichedTextInputView +import com.swmansion.enriched.spans.EnrichedColoredSpan import com.swmansion.enriched.spans.EnrichedSpans import com.swmansion.enriched.utils.getSafeSpanBoundaries @@ -101,21 +102,203 @@ class InlineStyles( } } + private fun applyColorSpan( + spannable: Spannable, + start: Int, + end: Int, + color: Int, + ) { + val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(start, end) + spannable.setSpan( + EnrichedColoredSpan(view.htmlStyle, color), + safeStart, + safeEnd, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + + private fun splitExistingColorSpans( + spannable: Spannable, + start: Int, + end: Int, + onRemain: (s: Int, e: Int, color: Int) -> Unit, + ) { + val spans = spannable.getSpans(start, end, EnrichedColoredSpan::class.java) + for (span in spans) { + val spanStart = spannable.getSpanStart(span) + val spanEnd = spannable.getSpanEnd(span) + val color = span.color + + spannable.removeSpan(span) + + if (spanStart < start) { + onRemain(spanStart, start, color) + } + + if (spanEnd > end) { + onRemain(end, spanEnd, color) + } + } + } + + private fun mergeAdjacentColors(spannable: Spannable) { + val colorSpans = + spannable + .getSpans(0, spannable.length, EnrichedColoredSpan::class.java) + .sortedBy { spannable.getSpanStart(it) } + + var index = 0 + while (index < colorSpans.size - 1) { + val currentSpan = colorSpans[index] + val nextSpan = colorSpans[index + 1] + + val currentStart = spannable.getSpanStart(currentSpan) + val currentEnd = spannable.getSpanEnd(currentSpan) + val nextStart = spannable.getSpanStart(nextSpan) + val nextEnd = spannable.getSpanEnd(nextSpan) + + if (currentEnd == nextStart && currentSpan.color == nextSpan.color) { + spannable.removeSpan(currentSpan) + spannable.removeSpan(nextSpan) + + applyColorSpan(spannable, currentStart, nextEnd, currentSpan.color) + + return mergeAdjacentColors(spannable) + } + + index++ + } + } + + private fun isFullyColoredWith( + spannable: Spannable, + start: Int, + end: Int, + color: Int, + ): Boolean { + val spans = spannable.getSpans(start, end, EnrichedColoredSpan::class.java) + if (spans.isEmpty()) return false + + val allSame = spans.all { it.color == color } + + if (!allSame) { + return false + } + + val minStart = spans.minOf { spannable.getSpanStart(it) } + val maxEnd = spans.maxOf { spannable.getSpanEnd(it) } + + return minStart <= start && maxEnd >= end + } + + fun setColorStyle(color: Int) { + val (start, end) = view.selection?.getInlineSelection() ?: return + val spannable = view.text as Spannable + + if (start == end) { + val spanState = view.spanState + if (spanState?.colorStart != null && spanState.typingColor == color) { + view.spanState.setColorStart(null, null) + } else { + view.spanState?.setColorStart(start, color) + } + return + } + + if (isFullyColoredWith(spannable, start, end, color)) { + removeColorRange(start, end) + view.spanState?.setColorStart(null, null) + view.selection.validateStyles() + return + } + + splitExistingColorSpans(spannable, start, end) { spanStart, spanEnd, existingColor -> + applyColorSpan(spannable, spanStart, spanEnd, existingColor) + } + + applyColorSpan(spannable, start, end, color) + + mergeAdjacentColors(spannable) + + view.spanState?.setColorStart(null, null) + view.selection.validateStyles() + } + + private fun removeColorRange( + start: Int, + end: Int, + ) { + val spannable = view.text as Spannable + + splitExistingColorSpans(spannable, start, end) { spanStart, spanEnd, color -> + if (spanStart < start) applyColorSpan(spannable, spanStart, start, color) + if (spanEnd > end) applyColorSpan(spannable, end, spanEnd, color) + } + } + + fun removeColorSpan() { + val (start, end) = view.selection?.getInlineSelection() ?: return + + if (start == end) { + view.spanState?.setColorStart(null, null) + return + } + + removeColorRange(start, end) + + view.spanState?.setColorStart(null, null) + view.selection.validateStyles() + } + + private fun applyTypingColorIfActive( + spannable: Spannable, + cursor: Int, + ) { + val state = view.spanState ?: return + val colorStart = state.colorStart ?: return + val color = state.typingColor ?: return + + val existing = + spannable + .getSpans(colorStart, colorStart, EnrichedColoredSpan::class.java) + .firstOrNull { it.color == color } + + if (existing != null) { + val spanStart = spannable.getSpanStart(existing) + val spanEnd = spannable.getSpanEnd(existing) + + if (cursor > spanEnd) { + spannable.removeSpan(existing) + applyColorSpan(spannable, spanStart, cursor, color) + } + + view.spanState.setColorStart(cursor, color) + return + } + + applyColorSpan(spannable, colorStart, cursor, color) + view.spanState.setColorStart(cursor, color) + } + fun afterTextChanged( - s: Editable, + editable: Editable, endCursorPosition: Int, ) { for ((style, config) in EnrichedSpans.inlineSpans) { val start = view.spanState?.getStart(style) ?: continue var end = endCursorPosition - val spans = s.getSpans(start, end, config.clazz) + if (config.clazz == EnrichedColoredSpan::class.java) { + applyTypingColorIfActive(editable, end) + continue + } + val spans = editable.getSpans(start, end, config.clazz) for (span in spans) { - end = s.getSpanEnd(span).coerceAtLeast(end) - s.removeSpan(span) + end = editable.getSpanEnd(span).coerceAtLeast(end) + editable.removeSpan(span) } - setSpan(s, config.clazz, start, end) + setSpan(editable, config.clazz, start, end) } } diff --git a/android/src/main/java/com/swmansion/enriched/utils/EnrichedParser.java b/android/src/main/java/com/swmansion/enriched/utils/EnrichedParser.java index 82c89efd6..572fbb933 100644 --- a/android/src/main/java/com/swmansion/enriched/utils/EnrichedParser.java +++ b/android/src/main/java/com/swmansion/enriched/utils/EnrichedParser.java @@ -1,5 +1,6 @@ package com.swmansion.enriched.utils; +import android.graphics.Color; import android.graphics.drawable.Drawable; import android.text.Editable; import android.text.Layout; @@ -9,9 +10,11 @@ import android.text.TextUtils; import android.text.style.AlignmentSpan; import android.text.style.ParagraphStyle; +import androidx.annotation.Nullable; import com.swmansion.enriched.spans.EnrichedBlockQuoteSpan; import com.swmansion.enriched.spans.EnrichedBoldSpan; import com.swmansion.enriched.spans.EnrichedCodeBlockSpan; +import com.swmansion.enriched.spans.EnrichedColoredSpan; import com.swmansion.enriched.spans.EnrichedH1Span; import com.swmansion.enriched.spans.EnrichedH2Span; import com.swmansion.enriched.spans.EnrichedH3Span; @@ -306,6 +309,10 @@ 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 EnrichedColoredSpan) { + String color = ((EnrichedColoredSpan) style[j]).getHexColor(); + out.append(""); + } } withinStyle(out, text, i, next); for (int j = style.length - 1; j >= 0; j--) { @@ -330,6 +337,9 @@ private static void withinParagraph(StringBuilder out, Spanned text, int start, if (style[j] instanceof EnrichedItalicSpan) { out.append(""); } + if (style[j] instanceof EnrichedColoredSpan) { + out.append(""); + } } } } @@ -498,6 +508,8 @@ private void handleStartTag(String tag, Attributes attributes) { start(mSpannableStringBuilder, new Code()); } else if (tag.equalsIgnoreCase("mention")) { startMention(mSpannableStringBuilder, attributes); + } else if (tag.equalsIgnoreCase("font")) { + startFont(mSpannableStringBuilder, attributes); } } @@ -540,6 +552,8 @@ private void handleEndTag(String tag) { end(mSpannableStringBuilder, Code.class, new EnrichedInlineCodeSpan(mStyle)); } else if (tag.equalsIgnoreCase("mention")) { endMention(mSpannableStringBuilder, mStyle); + } else if (tag.equalsIgnoreCase("font")) { + endFont(mSpannableStringBuilder, mStyle); } } @@ -926,4 +940,62 @@ public Alignment(Layout.Alignment alignment) { mAlignment = alignment; } } + + private static class Font { + public int color; + + public Font(int color) { + this.color = color; + } + } + + private static void startFont(Editable text, @Nullable Attributes attributes) { + if (attributes == null) { + return; + } + + int color = parseCssColor(attributes.getValue("", "color")); + + start(text, new Font(color)); + } + + private static void endFont(Editable text, HtmlStyle style) { + Font font = getLast(text, Font.class); + + if (font == null) { + return; + } + + setSpanFromMark(text, font, new EnrichedColoredSpan(style, font.color)); + } + + private static int parseCssColor(String css) { + if (css == null) return Color.BLACK; + + css = css.trim(); + + try { + return Color.parseColor(css); + } catch (Exception ignore) { + } + + if (css.startsWith("rgb(")) { + String[] parts = css.substring(4, css.length() - 1).split(","); + int r = Integer.parseInt(parts[0].trim()); + int g = Integer.parseInt(parts[1].trim()); + int b = Integer.parseInt(parts[2].trim()); + return Color.rgb(r, g, b); + } + + if (css.startsWith("rgba(")) { + String[] parts = css.substring(5, css.length() - 1).split(","); + int r = Integer.parseInt(parts[0].trim()); + int g = Integer.parseInt(parts[1].trim()); + int b = Integer.parseInt(parts[2].trim()); + float a = Float.parseFloat(parts[3].trim()); + return Color.argb((int) (a * 255), r, g, b); + } + + return Color.BLACK; + } } diff --git a/android/src/main/java/com/swmansion/enriched/utils/EnrichedSelection.kt b/android/src/main/java/com/swmansion/enriched/utils/EnrichedSelection.kt index 4d36459a4..f77771c4b 100644 --- a/android/src/main/java/com/swmansion/enriched/utils/EnrichedSelection.kt +++ b/android/src/main/java/com/swmansion/enriched/utils/EnrichedSelection.kt @@ -8,6 +8,7 @@ import com.swmansion.enriched.EnrichedTextInputView import com.swmansion.enriched.events.OnChangeSelectionEvent import com.swmansion.enriched.events.OnLinkDetectedEvent import com.swmansion.enriched.events.OnMentionDetectedEvent +import com.swmansion.enriched.spans.EnrichedColoredSpan import com.swmansion.enriched.spans.EnrichedLinkSpan import com.swmansion.enriched.spans.EnrichedMentionSpan import com.swmansion.enriched.spans.EnrichedSpans @@ -125,6 +126,9 @@ class EnrichedSelection( if (start == end && start == spanStart) { styleStart = null } else if (start >= spanStart && end <= spanEnd) { + if (span is EnrichedColoredSpan) { + view.spanState?.setTypingColor(span.color) + } styleStart = spanStart } } diff --git a/android/src/main/java/com/swmansion/enriched/utils/EnrichedSpanState.kt b/android/src/main/java/com/swmansion/enriched/utils/EnrichedSpanState.kt index ef72537b0..6cc7374ab 100644 --- a/android/src/main/java/com/swmansion/enriched/utils/EnrichedSpanState.kt +++ b/android/src/main/java/com/swmansion/enriched/utils/EnrichedSpanState.kt @@ -8,6 +8,7 @@ import com.facebook.react.uimanager.events.EventDispatcher import com.swmansion.enriched.EnrichedTextInputView import com.swmansion.enriched.events.OnChangeStateDeprecatedEvent import com.swmansion.enriched.events.OnChangeStateEvent +import com.swmansion.enriched.events.OnColorChangeEvent import com.swmansion.enriched.spans.EnrichedSpans class EnrichedSpanState( @@ -15,6 +16,7 @@ class EnrichedSpanState( ) { private var previousPayload: WritableMap? = null private var previousDeprecatedPayload: WritableMap? = null + private var previousDispatchedColor: Int? = null var boldStart: Int? = null private set @@ -52,6 +54,33 @@ class EnrichedSpanState( private set var mentionStart: Int? = null private set + var colorStart: Int? = null + private set + var typingColor: Int? = null + private set + + fun setTypingColor(color: Int?) { + typingColor = color + emitColorChangeEvent(color) + } + + fun setColorStart(start: Int?) { + if (start == null) { + setColorStart(null, null) + } else { + setColorStart(start, typingColor) + } + } + + fun setColorStart( + start: Int?, + color: Int?, + ) { + colorStart = start + typingColor = null + emitStateChangeEvent() + setTypingColor(color) + } fun setBoldStart(start: Int?) { this.boldStart = start @@ -147,6 +176,7 @@ class EnrichedSpanState( val start = when (name) { EnrichedSpans.BOLD -> boldStart + EnrichedSpans.COLOR -> colorStart EnrichedSpans.ITALIC -> italicStart EnrichedSpans.UNDERLINE -> underlineStart EnrichedSpans.STRIKETHROUGH -> strikethroughStart @@ -176,6 +206,7 @@ class EnrichedSpanState( ) { when (name) { EnrichedSpans.BOLD -> setBoldStart(start) + EnrichedSpans.COLOR -> setColorStart(start) EnrichedSpans.ITALIC -> setItalicStart(start) EnrichedSpans.UNDERLINE -> setUnderlineStart(start) EnrichedSpans.STRIKETHROUGH -> setStrikethroughStart(start) @@ -196,6 +227,31 @@ class EnrichedSpanState( } } + private fun emitColorChangeEvent(color: Int?) { + val resolvedColor = color ?: view.currentTextColor + + if (previousDispatchedColor == resolvedColor) { + return + } + + previousDispatchedColor = resolvedColor + + val colorToDispatch = String.format("#%06X", resolvedColor and 0x00FFFFFF) + + val context = view.context as ReactContext + val surfaceId = UIManagerHelper.getSurfaceId(context) + val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, view.id) + + dispatcher?.dispatchEvent( + OnColorChangeEvent( + surfaceId, + view.id, + view.experimentalSynchronousEvents, + colorToDispatch, + ), + ) + } + private fun emitStateChangeEvent() { val context = view.context as ReactContext val surfaceId = UIManagerHelper.getSurfaceId(context) @@ -255,6 +311,7 @@ class EnrichedSpanState( val activeStyles = listOfNotNull( if (boldStart != null) EnrichedSpans.BOLD else null, + if (colorStart != null) EnrichedSpans.COLOR else null, if (italicStart != null) EnrichedSpans.ITALIC else null, if (underlineStart != null) EnrichedSpans.UNDERLINE else null, if (strikethroughStart != null) EnrichedSpans.STRIKETHROUGH else null, @@ -292,6 +349,7 @@ class EnrichedSpanState( payload.putMap("link", getStyleState(activeStyles, EnrichedSpans.LINK)) payload.putMap("image", getStyleState(activeStyles, EnrichedSpans.IMAGE)) payload.putMap("mention", getStyleState(activeStyles, EnrichedSpans.MENTION)) + payload.putMap("colored", getStyleState(activeStyles, EnrichedSpans.COLOR)) // Do not emit event if payload is the same if (previousPayload == payload) { diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx index d91708992..60751a391 100644 --- a/apps/example/src/App.tsx +++ b/apps/example/src/App.tsx @@ -9,6 +9,7 @@ import { type OnChangeStateEvent, type OnChangeSelectionEvent, type HtmlStyle, + type OnChangeColorEvent, } from 'react-native-enriched'; import { useRef, useState } from 'react'; import { Button } from './components/Button'; @@ -26,6 +27,7 @@ import { DEFAULT_IMAGE_WIDTH, prepareImageDimensions, } from './utils/prepareImageDimensions'; +import { ColorPreview } from './components/ColorPreview'; type StylesState = OnChangeStateEvent; @@ -45,6 +47,7 @@ const DEFAULT_STYLE_STATE = { const DEFAULT_STYLES: StylesState = { bold: DEFAULT_STYLE_STATE, + colored: DEFAULT_STYLE_STATE, italic: DEFAULT_STYLE_STATE, underline: DEFAULT_STYLE_STATE, strikeThrough: DEFAULT_STYLE_STATE, @@ -64,6 +67,8 @@ const DEFAULT_STYLES: StylesState = { mention: DEFAULT_STYLE_STATE, }; +const PRIMARY_COLOR = '#000000'; + const DEFAULT_LINK_STATE = { text: '', url: '', @@ -94,6 +99,7 @@ export default function App() { const [stylesState, setStylesState] = useState(DEFAULT_STYLES); const [currentLink, setCurrentLink] = useState(DEFAULT_LINK_STATE); + const [selectionColor, setSelectionColor] = useState(PRIMARY_COLOR); const ref = useRef(null); @@ -293,6 +299,14 @@ export default function App() { setSelection(sel); }; + const handleSelectionColorChange = (e: OnChangeColorEvent) => { + setSelectionColor(e.color); + }; + + const handleRemoveColor = () => { + ref.current?.removeColor(); + }; + return ( <> handleChangeText(e.nativeEvent)} onChangeHtml={(e) => handleChangeHtml(e.nativeEvent)} onChangeState={(e) => handleChangeState(e.nativeEvent)} + onColorChangeInSelection={(e) => + handleSelectionColorChange(e.nativeEvent) + } onLinkDetected={handleLinkDetected} onMentionDetected={console.log} onStartMention={handleStartMention} @@ -330,6 +347,7 @@ export default function App() { @@ -343,8 +361,14 @@ export default function App() { onPress={openValueModal} style={styles.valueButton} /> +