From 3576e943600ab4a3e7cd6c0c5089834cdd260925 Mon Sep 17 00:00:00 2001 From: Igor Furgala Date: Mon, 12 Jan 2026 10:36:00 +0100 Subject: [PATCH 01/10] feat: wip - android checklist --- .../enriched/EnrichedTextInputView.kt | 10 +++ .../enriched/EnrichedTextInputViewManager.kt | 7 ++ .../spans/EnrichedCheckboxListSpan.kt | 73 ++++++++++++++++++ .../swmansion/enriched/spans/EnrichedSpans.kt | 29 ++++--- .../swmansion/enriched/styles/HtmlStyle.kt | 17 +++++ .../swmansion/enriched/styles/ListStyles.kt | 55 +++++++++++++- .../enriched/utils/EnrichedParser.java | 56 ++++++++++++-- .../enriched/utils/EnrichedSpanState.kt | 10 +++ .../com/swmansion/enriched/utils/Utils.kt | 75 +++++++++++++++++++ apps/example/src/App.tsx | 6 ++ apps/example/src/components/Toolbar.tsx | 10 +++ src/EnrichedTextInput.tsx | 9 +++ src/EnrichedTextInputNativeComponent.ts | 11 +++ src/normalizeHtmlStyle.ts | 5 ++ 14 files changed, 351 insertions(+), 22 deletions(-) create mode 100644 android/src/main/java/com/swmansion/enriched/spans/EnrichedCheckboxListSpan.kt diff --git a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt index c3aff31f0..7135ba110 100644 --- a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt @@ -483,6 +483,7 @@ class EnrichedTextInputView : AppCompatEditText { EnrichedSpans.BLOCK_QUOTE -> paragraphStyles?.toggleStyle(EnrichedSpans.BLOCK_QUOTE) EnrichedSpans.ORDERED_LIST -> listStyles?.toggleStyle(EnrichedSpans.ORDERED_LIST) EnrichedSpans.UNORDERED_LIST -> listStyles?.toggleStyle(EnrichedSpans.UNORDERED_LIST) + EnrichedSpans.CHECKBOX_LIST -> listStyles?.toggleStyle(EnrichedSpans.CHECKBOX_LIST) else -> Log.w("EnrichedTextInputView", "Unknown style: $name") } @@ -511,6 +512,7 @@ class EnrichedTextInputView : AppCompatEditText { EnrichedSpans.BLOCK_QUOTE -> paragraphStyles?.removeStyle(EnrichedSpans.BLOCK_QUOTE, start, end) EnrichedSpans.ORDERED_LIST -> listStyles?.removeStyle(EnrichedSpans.ORDERED_LIST, start, end) EnrichedSpans.UNORDERED_LIST -> listStyles?.removeStyle(EnrichedSpans.UNORDERED_LIST, start, end) + EnrichedSpans.CHECKBOX_LIST -> listStyles?.removeStyle(EnrichedSpans.CHECKBOX_LIST, start, end) EnrichedSpans.LINK -> parametrizedStyles?.removeStyle(EnrichedSpans.LINK, start, end) EnrichedSpans.IMAGE -> parametrizedStyles?.removeStyle(EnrichedSpans.IMAGE, start, end) EnrichedSpans.MENTION -> parametrizedStyles?.removeStyle(EnrichedSpans.MENTION, start, end) @@ -538,6 +540,7 @@ class EnrichedTextInputView : AppCompatEditText { EnrichedSpans.BLOCK_QUOTE -> paragraphStyles?.getStyleRange() EnrichedSpans.ORDERED_LIST -> listStyles?.getStyleRange() EnrichedSpans.UNORDERED_LIST -> listStyles?.getStyleRange() + EnrichedSpans.CHECKBOX_LIST -> listStyles?.getStyleRange() EnrichedSpans.LINK -> parametrizedStyles?.getStyleRange() EnrichedSpans.IMAGE -> parametrizedStyles?.getStyleRange() EnrichedSpans.MENTION -> parametrizedStyles?.getStyleRange() @@ -603,6 +606,13 @@ class EnrichedTextInputView : AppCompatEditText { toggleStyle(name) } + fun toggleCheckboxListItem(checked: Boolean) { + val isValid = verifyStyle(EnrichedSpans.CHECKBOX_LIST) + if (!isValid) return + + listStyles?.toggleCheckboxListStyle(checked) + } + fun addLink( start: Int, end: Int, diff --git a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt index 8e6f85c0d..1ed62d067 100644 --- a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt @@ -329,6 +329,13 @@ class EnrichedTextInputViewManager : view?.verifyAndToggleStyle(EnrichedSpans.UNORDERED_LIST) } + override fun toggleCheckboxList( + view: EnrichedTextInputView?, + isChecked: Boolean, + ) { + view?.toggleCheckboxListItem(isChecked) + } + override fun addLink( view: EnrichedTextInputView?, start: Int, diff --git a/android/src/main/java/com/swmansion/enriched/spans/EnrichedCheckboxListSpan.kt b/android/src/main/java/com/swmansion/enriched/spans/EnrichedCheckboxListSpan.kt new file mode 100644 index 000000000..c8c2ff015 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/spans/EnrichedCheckboxListSpan.kt @@ -0,0 +1,73 @@ +package com.swmansion.enriched.spans + +import android.graphics.Canvas +import android.graphics.Paint +import android.text.Layout +import android.text.Spanned +import android.text.TextPaint +import android.text.style.LeadingMarginSpan +import android.text.style.MetricAffectingSpan +import androidx.core.graphics.withTranslation +import com.swmansion.enriched.spans.interfaces.EnrichedParagraphSpan +import com.swmansion.enriched.styles.HtmlStyle +import com.swmansion.enriched.utils.ResourceManager + +class EnrichedCheckboxListSpan( + var isChecked: Boolean, + private val htmlStyle: HtmlStyle, +) : MetricAffectingSpan(), + LeadingMarginSpan, + EnrichedParagraphSpan { + override val dependsOnHtmlStyle: Boolean = true + + // TODO: use custom drawables. Consider customizing color of them + private val checkedDrawable = ResourceManager.getDrawableResource(android.R.drawable.checkbox_on_background) + private val uncheckedDrawable = ResourceManager.getDrawableResource(android.R.drawable.checkbox_off_background) + + init { + checkedDrawable.setBounds(0, 0, htmlStyle.ulCheckboxBoxSize, htmlStyle.ulCheckboxBoxSize) + uncheckedDrawable.setBounds(0, 0, htmlStyle.ulCheckboxBoxSize, htmlStyle.ulCheckboxBoxSize) + } + + override fun updateMeasureState(p0: TextPaint) { + // Do nothing, but inform layout that this span affects text metrics + } + + override fun updateDrawState(p0: TextPaint?) { + // Do nothing, but inform layout that this span affects text metrics + } + + override fun getLeadingMargin(first: Boolean): Int = + htmlStyle.ulCheckboxBoxSize + htmlStyle.ulCheckboxMarginLeft + htmlStyle.ulCheckboxGapWidth + + override fun drawLeadingMargin( + canvas: Canvas, + paint: Paint, + x: Int, + dir: Int, + top: Int, + baseline: Int, + bottom: Int, + text: CharSequence, + start: Int, + end: Int, + first: Boolean, + layout: Layout?, + ) { + val spannedText = text as Spanned + + if (spannedText.getSpanStart(this) == start) { + val drawable = if (isChecked) checkedDrawable else uncheckedDrawable + + val fontMetrics = paint.fontMetrics + val lineCenter = baseline + (fontMetrics.ascent + fontMetrics.descent) / 2f + val drawableTop = lineCenter - (htmlStyle.ulCheckboxBoxSize / 2f) + + canvas.withTranslation(x.toFloat() + htmlStyle.ulCheckboxMarginLeft, drawableTop) { + drawable.draw(this) + } + } + } + + override fun rebuildWithStyle(htmlStyle: HtmlStyle): EnrichedCheckboxListSpan = EnrichedCheckboxListSpan(isChecked, htmlStyle) +} 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..81bae46fe 100644 --- a/android/src/main/java/com/swmansion/enriched/spans/EnrichedSpans.kt +++ b/android/src/main/java/com/swmansion/enriched/spans/EnrichedSpans.kt @@ -17,7 +17,7 @@ data class ParagraphSpanConfig( data class ListSpanConfig( override val clazz: Class<*>, - val shortcut: String, + val shortcut: String?, ) : ISpanConfig data class StylesMergingConfig( @@ -48,6 +48,7 @@ object EnrichedSpans { // list styles const val UNORDERED_LIST = "unordered_list" const val ORDERED_LIST = "ordered_list" + const val CHECKBOX_LIST = "checkbox_list" // parametrized styles const val LINK = "link" @@ -79,6 +80,7 @@ object EnrichedSpans { mapOf( UNORDERED_LIST to ListSpanConfig(EnrichedUnorderedListSpan::class.java, "- "), ORDERED_LIST to ListSpanConfig(EnrichedOrderedListSpan::class.java, "1. "), + CHECKBOX_LIST to ListSpanConfig(EnrichedCheckboxListSpan::class.java, null), ) val parametrizedStyles: Map = @@ -132,44 +134,44 @@ object EnrichedSpans { } H1 -> { - val conflictingStyles = mutableListOf(H2, H3, H4, H5, H6, ORDERED_LIST, UNORDERED_LIST, BLOCK_QUOTE, CODE_BLOCK) + val conflictingStyles = mutableListOf(H2, H3, H4, H5, H6, ORDERED_LIST, UNORDERED_LIST, CHECKBOX_LIST, BLOCK_QUOTE, CODE_BLOCK) if (htmlStyle.h1Bold) conflictingStyles.add(BOLD) StylesMergingConfig(conflictingStyles = conflictingStyles.toTypedArray()) } H2 -> { - val conflictingStyles = mutableListOf(H1, H3, H4, H5, H6, ORDERED_LIST, UNORDERED_LIST, BLOCK_QUOTE, CODE_BLOCK) + val conflictingStyles = mutableListOf(H1, H3, H4, H5, H6, ORDERED_LIST, UNORDERED_LIST, CHECKBOX_LIST, BLOCK_QUOTE, CODE_BLOCK) if (htmlStyle.h2Bold) conflictingStyles.add(BOLD) StylesMergingConfig(conflictingStyles = conflictingStyles.toTypedArray()) } H3 -> { - val conflictingStyles = mutableListOf(H1, H2, H4, H5, H6, ORDERED_LIST, UNORDERED_LIST, BLOCK_QUOTE, CODE_BLOCK) + val conflictingStyles = mutableListOf(H1, H2, H4, H5, H6, ORDERED_LIST, UNORDERED_LIST, CHECKBOX_LIST, BLOCK_QUOTE, CODE_BLOCK) if (htmlStyle.h3Bold) conflictingStyles.add(BOLD) StylesMergingConfig(conflictingStyles = conflictingStyles.toTypedArray()) } H4 -> { - val conflictingStyles = mutableListOf(H1, H2, H3, H5, H6, ORDERED_LIST, UNORDERED_LIST, BLOCK_QUOTE, CODE_BLOCK) + val conflictingStyles = mutableListOf(H1, H2, H3, H5, H6, ORDERED_LIST, UNORDERED_LIST, CHECKBOX_LIST, BLOCK_QUOTE, CODE_BLOCK) if (htmlStyle.h4Bold) conflictingStyles.add(BOLD) StylesMergingConfig(conflictingStyles = conflictingStyles.toTypedArray()) } H5 -> { - val conflictingStyles = mutableListOf(H1, H2, H3, H4, H6, ORDERED_LIST, UNORDERED_LIST, BLOCK_QUOTE, CODE_BLOCK) + val conflictingStyles = mutableListOf(H1, H2, H3, H4, H6, ORDERED_LIST, UNORDERED_LIST, CHECKBOX_LIST, BLOCK_QUOTE, CODE_BLOCK) if (htmlStyle.h5Bold) conflictingStyles.add(BOLD) StylesMergingConfig(conflictingStyles = conflictingStyles.toTypedArray()) } H6 -> { - val conflictingStyles = mutableListOf(H1, H2, H3, H4, H5, ORDERED_LIST, UNORDERED_LIST, BLOCK_QUOTE, CODE_BLOCK) + val conflictingStyles = mutableListOf(H1, H2, H3, H4, H5, ORDERED_LIST, UNORDERED_LIST, CHECKBOX_LIST, BLOCK_QUOTE, CODE_BLOCK) if (htmlStyle.h6Bold) conflictingStyles.add(BOLD) StylesMergingConfig(conflictingStyles = conflictingStyles.toTypedArray()) } BLOCK_QUOTE -> { StylesMergingConfig( - conflictingStyles = arrayOf(H1, H2, H3, H4, H5, H6, CODE_BLOCK, ORDERED_LIST, UNORDERED_LIST), + conflictingStyles = arrayOf(H1, H2, H3, H4, H5, H6, CODE_BLOCK, ORDERED_LIST, UNORDERED_LIST, CHECKBOX_LIST), ) } @@ -189,6 +191,7 @@ object EnrichedSpans { STRIKETHROUGH, UNORDERED_LIST, ORDERED_LIST, + CHECKBOX_LIST, BLOCK_QUOTE, INLINE_CODE, ), @@ -197,13 +200,19 @@ object EnrichedSpans { UNORDERED_LIST -> { StylesMergingConfig( - conflictingStyles = arrayOf(H1, H2, H3, H4, H5, H6, ORDERED_LIST, CODE_BLOCK, BLOCK_QUOTE), + conflictingStyles = arrayOf(H1, H2, H3, H4, H5, H6, ORDERED_LIST, CHECKBOX_LIST, CODE_BLOCK, BLOCK_QUOTE), ) } ORDERED_LIST -> { StylesMergingConfig( - conflictingStyles = arrayOf(H1, H2, H3, H4, H5, H6, UNORDERED_LIST, CODE_BLOCK, BLOCK_QUOTE), + conflictingStyles = arrayOf(H1, H2, H3, H4, H5, H6, UNORDERED_LIST, CHECKBOX_LIST, CODE_BLOCK, BLOCK_QUOTE), + ) + } + + CHECKBOX_LIST -> { + StylesMergingConfig( + conflictingStyles = arrayOf(H1, H2, H3, H4, H5, H6, UNORDERED_LIST, ORDERED_LIST, CODE_BLOCK, BLOCK_QUOTE), ) } diff --git a/android/src/main/java/com/swmansion/enriched/styles/HtmlStyle.kt b/android/src/main/java/com/swmansion/enriched/styles/HtmlStyle.kt index cb36ad877..cac24f1f1 100644 --- a/android/src/main/java/com/swmansion/enriched/styles/HtmlStyle.kt +++ b/android/src/main/java/com/swmansion/enriched/styles/HtmlStyle.kt @@ -52,6 +52,10 @@ class HtmlStyle { var ulBulletSize: Int = 8 var ulBulletColor: Int = Color.BLACK + var ulCheckboxBoxSize: Int = 50 + var ulCheckboxGapWidth: Int = 16 + var ulCheckboxMarginLeft: Int = 24 + var aColor: Int = Color.BLACK var aUnderline: Boolean = true @@ -118,6 +122,11 @@ class HtmlStyle { ulMarginLeft = parseFloat(ulStyle, "marginLeft").toInt() ulBulletSize = parseFloat(ulStyle, "bulletSize").toInt() + val ulCheckboxStyle = style.getMap("ulCheckbox") + ulCheckboxBoxSize = parseFloat(ulCheckboxStyle, "boxSize").toInt() + ulCheckboxGapWidth = parseFloat(ulCheckboxStyle, "gapWidth").toInt() + ulCheckboxMarginLeft = parseFloat(ulCheckboxStyle, "marginLeft").toInt() + val aStyle = style.getMap("a") aColor = parseColor(aStyle, "color") aUnderline = parseIsUnderline(aStyle) @@ -290,6 +299,10 @@ class HtmlStyle { ulBulletSize == other.ulBulletSize && ulBulletColor == other.ulBulletColor && + ulCheckboxBoxSize == other.ulCheckboxBoxSize && + ulCheckboxGapWidth == other.ulCheckboxGapWidth && + ulCheckboxMarginLeft == other.ulCheckboxMarginLeft && + aColor == other.aColor && aUnderline == other.aUnderline && @@ -332,6 +345,10 @@ class HtmlStyle { result = 31 * result + ulBulletSize.hashCode() result = 31 * result + ulBulletColor.hashCode() + result = 31 * result + ulCheckboxBoxSize.hashCode() + result = 31 * result + ulCheckboxGapWidth.hashCode() + result = 31 * result + ulCheckboxMarginLeft.hashCode() + result = 31 * result + aColor.hashCode() result = 31 * result + aUnderline.hashCode() diff --git a/android/src/main/java/com/swmansion/enriched/styles/ListStyles.kt b/android/src/main/java/com/swmansion/enriched/styles/ListStyles.kt index 93cfe8d99..adf41bc00 100644 --- a/android/src/main/java/com/swmansion/enriched/styles/ListStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/styles/ListStyles.kt @@ -5,11 +5,13 @@ import android.text.Spannable import android.text.SpannableStringBuilder import android.text.Spanned import com.swmansion.enriched.EnrichedTextInputView +import com.swmansion.enriched.spans.EnrichedCheckboxListSpan import com.swmansion.enriched.spans.EnrichedOrderedListSpan import com.swmansion.enriched.spans.EnrichedSpans import com.swmansion.enriched.spans.EnrichedUnorderedListSpan import com.swmansion.enriched.utils.getParagraphBounds import com.swmansion.enriched.utils.getSafeSpanBoundaries +import com.swmansion.enriched.utils.setLeadingMarginCheckboxClickListener class ListStyles( private val view: EnrichedTextInputView, @@ -55,6 +57,7 @@ class ListStyles( name: String, start: Int, end: Int, + isChecked: Boolean?, ) { val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(start, end) @@ -68,7 +71,24 @@ class ListStyles( val index = getOrderedListIndex(spannable, safeStart) val span = EnrichedOrderedListSpan(index, view.htmlStyle) spannable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + return } + + if (name == EnrichedSpans.CHECKBOX_LIST) { + val span = EnrichedCheckboxListSpan(isChecked ?: false, view.htmlStyle) + spannable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + view.setLeadingMarginCheckboxClickListener() + return + } + } + + private fun setSpan( + spannable: Spannable, + name: String, + start: Int, + end: Int, + ) { + setSpan(spannable, name, start, end, null) } private fun removeSpansForRange( @@ -101,7 +121,10 @@ class ListStyles( } } - fun toggleStyle(name: String) { + private fun toggleStyle( + name: String, + checkboxState: Boolean?, + ) { if (view.selection == null) return val config = EnrichedSpans.listSpans[name] ?: return val spannable = view.text as SpannableStringBuilder @@ -120,7 +143,7 @@ class ListStyles( spannable.insert(start, "\u200B") view.spanState?.setStart(name, start + 1) removeSpansForRange(spannable, start, end, config.clazz) - setSpan(spannable, name, start, end + 1) + setSpan(spannable, name, start, end + 1, checkboxState) return } @@ -132,7 +155,7 @@ class ListStyles( for (paragraph in paragraphs) { spannable.insert(currentStart, "\u200B") val currentEnd = currentStart + paragraph.length + 1 - setSpan(spannable, name, currentStart, currentEnd) + setSpan(spannable, name, currentStart, currentEnd, checkboxState) currentStart = currentEnd + 1 } @@ -140,6 +163,14 @@ class ListStyles( view.spanState?.setStart(name, currentStart) } + fun toggleStyle(name: String) { + toggleStyle(name, false) + } + + fun toggleCheckboxListStyle(checked: Boolean) { + toggleStyle(EnrichedSpans.CHECKBOX_LIST, checked) + } + private fun handleAfterTextChanged( s: Editable, name: String, @@ -152,7 +183,7 @@ class ListStyles( val isBackspace = previousTextLength > s.length val isNewLine = cursorPosition > 0 && s[cursorPosition - 1] == '\n' - val isShortcut = s.substring(start, end).startsWith(config.shortcut) + val isShortcut = config.shortcut?.let { s.substring(start, end).startsWith(it) } ?: false val spans = s.getSpans(start, end, config.clazz) // Remove spans if cursor is at the start of the paragraph and spans exist @@ -177,6 +208,21 @@ class ListStyles( return } + if (name === EnrichedSpans.CHECKBOX_LIST) { + if (spans.isNotEmpty()) { + val previousSpan = spans[0] as EnrichedCheckboxListSpan + val isChecked = previousSpan.isChecked + + for (span in spans) { + s.removeSpan(span) + } + + setSpan(s, EnrichedSpans.CHECKBOX_LIST, start, end, isChecked) + } + + return + } + if (spans.isNotEmpty()) { for (span in spans) { s.removeSpan(span) @@ -193,6 +239,7 @@ class ListStyles( ) { handleAfterTextChanged(s, EnrichedSpans.ORDERED_LIST, endCursorPosition, previousTextLength) handleAfterTextChanged(s, EnrichedSpans.UNORDERED_LIST, endCursorPosition, previousTextLength) + handleAfterTextChanged(s, EnrichedSpans.CHECKBOX_LIST, endCursorPosition, previousTextLength) } fun getStyleRange(): Pair = view.selection?.getParagraphSelection() ?: Pair(0, 0) 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..bfe8358fa 100644 --- a/android/src/main/java/com/swmansion/enriched/utils/EnrichedParser.java +++ b/android/src/main/java/com/swmansion/enriched/utils/EnrichedParser.java @@ -11,6 +11,7 @@ import android.text.style.ParagraphStyle; import com.swmansion.enriched.spans.EnrichedBlockQuoteSpan; import com.swmansion.enriched.spans.EnrichedBoldSpan; +import com.swmansion.enriched.spans.EnrichedCheckboxListSpan; import com.swmansion.enriched.spans.EnrichedCodeBlockSpan; import com.swmansion.enriched.spans.EnrichedH1Span; import com.swmansion.enriched.spans.EnrichedH2Span; @@ -156,6 +157,8 @@ private static String getBlockTag(EnrichedParagraphSpan[] spans) { return "ul"; } else if (span instanceof EnrichedOrderedListSpan) { return "ol"; + } else if (span instanceof EnrichedCheckboxListSpan) { + return "ul data-type=\"checkbox\""; } else if (span instanceof EnrichedH1Span) { return "h1"; } else if (span instanceof EnrichedH2Span) { @@ -177,6 +180,8 @@ private static String getBlockTag(EnrichedParagraphSpan[] spans) { private static void withinBlock(StringBuilder out, Spanned text, int start, int end) { boolean isInUlList = false; boolean isInOlList = false; + boolean isInCheckboxList = false; + int next; for (int i = start; i <= end; i = next) { next = TextUtils.indexOf(text, '\n', i, end); @@ -192,6 +197,10 @@ private static void withinBlock(StringBuilder out, Spanned text, int start, int // Current paragraph is no longer a list item; close the previously opened list isInOlList = false; out.append("\n"); + } else if (isInCheckboxList) { + // Current paragraph is no longer a list item; close the previously opened list + isInCheckboxList = false; + out.append("\n"); } out.append("
\n"); } else { @@ -200,6 +209,7 @@ private static void withinBlock(StringBuilder out, Spanned text, int start, int String tag = getBlockTag(paragraphStyles); boolean isUlListItem = tag.equals("ul"); boolean isOlListItem = tag.equals("ol"); + boolean isCheckboxListItem = tag.equals("ul data-type=\"checkbox\""); if (isInUlList && !isUlListItem) { // Current paragraph is no longer a list item; close the previously opened list @@ -209,6 +219,10 @@ private static void withinBlock(StringBuilder out, Spanned text, int start, int // Current paragraph is no longer a list item; close the previously opened list isInOlList = false; out.append("\n"); + } else if (isInCheckboxList && !isCheckboxListItem) { + // Current paragraph is no longer a list item; close the previously opened list + isInCheckboxList = false; + out.append("\n"); } if (isUlListItem && !isInUlList) { @@ -219,14 +233,27 @@ private static void withinBlock(StringBuilder out, Spanned text, int start, int // Current paragraph is the first item in a list isInOlList = true; out.append("\n"); + } else if (isCheckboxListItem && !isInCheckboxList) { + // Current paragraph is the first item in a list + isInCheckboxList = true; + out.append("
    \n"); } - boolean isList = isUlListItem || isOlListItem; + boolean isList = isUlListItem || isOlListItem || isCheckboxListItem; String tagType = isList ? "li" : tag; - out.append("<"); + out.append("<"); out.append(tagType); + if (isCheckboxListItem) { + EnrichedCheckboxListSpan[] checkboxSpans = + text.getSpans(i, next, EnrichedCheckboxListSpan.class); + if (checkboxSpans.length > 0) { + boolean isChecked = checkboxSpans[0].isChecked(); + if (isChecked) out.append(" checked"); + } + } + out.append(">"); withinParagraph(out, text, i, next); out.append("\n"); + } else if (next == end && isInCheckboxList) { + isInCheckboxList = false; + out.append("
\n"); } } next++; @@ -378,6 +408,7 @@ class HtmlToSpannedConverter implements ContentHandler { private final EnrichedParser.ImageGetter mImageGetter; private static Integer currentOrderedListItemIndex = 0; private static Boolean isInOrderedList = false; + private static Boolean isInCheckboxList = false; private static Boolean isEmptyTag = false; public HtmlToSpannedConverter( @@ -454,6 +485,8 @@ private void handleStartTag(String tag, Attributes attributes) { startBlockElement(mSpannableStringBuilder); } else if (tag.equalsIgnoreCase("ul")) { isInOrderedList = false; + String dataType = attributes.getValue("", "data-type"); + isInCheckboxList = "checkbox".equals(dataType); startBlockElement(mSpannableStringBuilder); } else if (tag.equalsIgnoreCase("ol")) { isInOrderedList = true; @@ -461,7 +494,7 @@ private void handleStartTag(String tag, Attributes attributes) { startBlockElement(mSpannableStringBuilder); } else if (tag.equalsIgnoreCase("li")) { isEmptyTag = true; - startLi(mSpannableStringBuilder); + startLi(mSpannableStringBuilder, attributes); } else if (tag.equalsIgnoreCase("b")) { start(mSpannableStringBuilder, new Bold()); } else if (tag.equalsIgnoreCase("i")) { @@ -578,14 +611,17 @@ private static void handleBr(Editable text) { text.append('\n'); } - private void startLi(Editable text) { + private void startLi(Editable text, Attributes attributes) { startBlockElement(text); if (isInOrderedList) { currentOrderedListItemIndex++; - start(text, new List("ol", currentOrderedListItemIndex)); + start(text, new List("ordered", currentOrderedListItemIndex, false)); + } else if (isInCheckboxList) { + String isChecked = attributes.getValue("", "checked"); + start(text, new List("checked", 0, "checked".equals(isChecked))); } else { - start(text, new List("ul", 0)); + start(text, new List("unordered", 0, false)); } } @@ -594,8 +630,10 @@ private static void endLi(Editable text, HtmlStyle style) { List l = getLast(text, List.class); if (l != null) { - if (l.mType.equals("ol")) { + if (l.mType.equals("ordered")) { setParagraphSpanFromMark(text, l, new EnrichedOrderedListSpan(l.mIndex, style)); + } else if (l.mType.equals("checked")) { + setParagraphSpanFromMark(text, l, new EnrichedCheckboxListSpan(l.mChecked, style)); } else { setParagraphSpanFromMark(text, l, new EnrichedUnorderedListSpan(style)); } @@ -884,10 +922,12 @@ private static class Blockquote {} private static class List { public int mIndex; public String mType; + public boolean mChecked; - public List(String type, int index) { + public List(String type, int index, boolean checked) { mType = type; mIndex = index; + mChecked = checked; } } 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 f22acca8f..e4204b21c 100644 --- a/android/src/main/java/com/swmansion/enriched/utils/EnrichedSpanState.kt +++ b/android/src/main/java/com/swmansion/enriched/utils/EnrichedSpanState.kt @@ -43,6 +43,8 @@ class EnrichedSpanState( private set var unorderedListStart: Int? = null private set + var checkboxListStart: Int? = null + private set var linkStart: Int? = null private set var imageStart: Int? = null @@ -125,6 +127,11 @@ class EnrichedSpanState( emitStateChangeEvent() } + fun setCheckboxListStart(start: Int?) { + this.checkboxListStart = start + emitStateChangeEvent() + } + fun setLinkStart(start: Int?) { this.linkStart = start emitStateChangeEvent() @@ -158,6 +165,7 @@ class EnrichedSpanState( EnrichedSpans.BLOCK_QUOTE -> blockQuoteStart EnrichedSpans.ORDERED_LIST -> orderedListStart EnrichedSpans.UNORDERED_LIST -> unorderedListStart + EnrichedSpans.CHECKBOX_LIST -> checkboxListStart EnrichedSpans.LINK -> linkStart EnrichedSpans.IMAGE -> imageStart EnrichedSpans.MENTION -> mentionStart @@ -187,6 +195,7 @@ class EnrichedSpanState( EnrichedSpans.BLOCK_QUOTE -> setBlockQuoteStart(start) EnrichedSpans.ORDERED_LIST -> setOrderedListStart(start) EnrichedSpans.UNORDERED_LIST -> setUnorderedListStart(start) + EnrichedSpans.CHECKBOX_LIST -> setCheckboxListStart(start) EnrichedSpans.LINK -> setLinkStart(start) EnrichedSpans.IMAGE -> setImageStart(start) EnrichedSpans.MENTION -> setMentionStart(start) @@ -210,6 +219,7 @@ class EnrichedSpanState( payload.putBoolean("isBlockQuote", blockQuoteStart != null) payload.putBoolean("isOrderedList", orderedListStart != null) payload.putBoolean("isUnorderedList", unorderedListStart != null) + payload.putBoolean("isCheckboxList", checkboxListStart != null) payload.putBoolean("isLink", linkStart != null) payload.putBoolean("isImage", imageStart != null) payload.putBoolean("isMention", mentionStart != null) diff --git a/android/src/main/java/com/swmansion/enriched/utils/Utils.kt b/android/src/main/java/com/swmansion/enriched/utils/Utils.kt index 9814ae324..53cf4a70b 100644 --- a/android/src/main/java/com/swmansion/enriched/utils/Utils.kt +++ b/android/src/main/java/com/swmansion/enriched/utils/Utils.kt @@ -1,9 +1,14 @@ package com.swmansion.enriched.utils +import android.annotation.SuppressLint import android.text.Spannable import android.text.SpannableString import android.text.SpannableStringBuilder +import android.text.Spanned import android.util.Log +import android.view.MotionEvent +import android.widget.TextView +import com.swmansion.enriched.spans.EnrichedCheckboxListSpan import com.swmansion.enriched.spans.interfaces.EnrichedBlockSpan import com.swmansion.enriched.spans.interfaces.EnrichedParagraphSpan import org.json.JSONObject @@ -99,3 +104,73 @@ fun Spannable.mergeSpannables( return builder } + +// Sets a touch listener on TextView which is responsible for detecting touches on checkbox icons +// We don't use ClickableSpan because it works fine only when LinkMovementMethod is set on TextView +// Which breaks text selection and other features +@SuppressLint("ClickableViewAccessibility") +fun TextView.setLeadingMarginCheckboxClickListener() { + var isDownOnCheckbox = false + + setOnTouchListener { v, event -> + val tv = v as TextView + val layout = tv.layout ?: return@setOnTouchListener false + val spannable = tv.text as? Spanned ?: return@setOnTouchListener false + + // Get touch coordinates relative to the text content + val x = event.x.toInt() - tv.totalPaddingLeft + tv.scrollX + val y = event.y.toInt() - tv.totalPaddingTop + tv.scrollY + + // Identify the line and whether it's the first line of the span + val line = layout.getLineForVertical(y) + val lineStart = layout.getLineStart(line) + + // Find spans for specific line + val spans = spannable.getSpans(lineStart, lineStart, EnrichedCheckboxListSpan::class.java) + if (spans.isEmpty()) return@setOnTouchListener false + + // There should be only one span per line as we don't support nested lists + val span = spans[0] + val isFirstLine = spannable.getSpanStart(span) == lineStart + val marginWidth = span.getLeadingMargin(true) + + // Check if touch is on checkbox icon area (which is in the leading margin on the first line) + val isInHotZone = isFirstLine && x in 0..marginWidth + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + if (isInHotZone) { + isDownOnCheckbox = true + return@setOnTouchListener true + } + } + + MotionEvent.ACTION_UP -> { + if (isDownOnCheckbox && isInHotZone) { + val spannable = tv.text as? Spannable + if (spannable != null) { + val start = spannable.getSpanStart(span) + val end = spannable.getSpanEnd(span) + val flags = spannable.getSpanFlags(span) + span.isChecked = !span.isChecked + + // Reapply span so changes are visible without need to redraw entire TextView + spannable.removeSpan(span) + spannable.setSpan(span, start, end, flags) + } + + isDownOnCheckbox = false + return@setOnTouchListener true + } + isDownOnCheckbox = false + } + + MotionEvent.ACTION_CANCEL -> { + isDownOnCheckbox = false + } + } + + // Let TextView handle other touches (e.g., for selection) + false + } +} diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx index 81bdfeebf..d394bda24 100644 --- a/apps/example/src/App.tsx +++ b/apps/example/src/App.tsx @@ -53,6 +53,7 @@ const DEFAULT_STYLE: StylesState = { isCodeBlock: false, isOrderedList: false, isUnorderedList: false, + isCheckboxList: false, isLink: false, isImage: false, isMention: false, @@ -439,6 +440,11 @@ const htmlStyle: HtmlStyle = { marginLeft: 24, gapWidth: 16, }, + ulCheckbox: { + boxSize: 24, + gapWidth: 16, + marginLeft: 24, + }, }; const styles = StyleSheet.create({ diff --git a/apps/example/src/components/Toolbar.tsx b/apps/example/src/components/Toolbar.tsx index 6fad4c527..9d08c2784 100644 --- a/apps/example/src/components/Toolbar.tsx +++ b/apps/example/src/components/Toolbar.tsx @@ -79,6 +79,10 @@ const STYLE_ITEMS = [ name: 'ordered-list', icon: 'list-ol', }, + { + name: 'checkbox-list', + icon: 'check-square-o', + }, ] as const; type Item = (typeof STYLE_ITEMS)[number]; @@ -147,6 +151,10 @@ export const Toolbar: FC = ({ case 'ordered-list': editorRef.current?.toggleOrderedList(); break; + case 'checkbox-list': + // Make checkbox checked by default + editorRef.current?.toggleCheckboxList(true); + break; case 'link': onOpenLinkModal(); break; @@ -191,6 +199,8 @@ export const Toolbar: FC = ({ return stylesState.isUnorderedList; case 'ordered-list': return stylesState.isOrderedList; + case 'checkbox-list': + return stylesState.isCheckboxList; case 'link': return stylesState.isLink; case 'image': diff --git a/src/EnrichedTextInput.tsx b/src/EnrichedTextInput.tsx index e2bae893c..7ba97f1c6 100644 --- a/src/EnrichedTextInput.tsx +++ b/src/EnrichedTextInput.tsx @@ -58,6 +58,7 @@ export interface EnrichedTextInputInstance extends NativeMethods { toggleBlockQuote: () => void; toggleOrderedList: () => void; toggleUnorderedList: () => void; + toggleCheckboxList: (checked: boolean) => void; setLink: (start: number, end: number, text: string, url: string) => void; setImage: (src: string, width: number, height: number) => void; startMention: (indicator: string) => void; @@ -117,6 +118,11 @@ export interface HtmlStyle { marginLeft?: number; gapWidth?: number; }; + ulCheckbox?: { + boxSize?: number; + gapWidth?: number; + marginLeft?: number; + }; } export interface EnrichedTextInputProps extends Omit { @@ -305,6 +311,9 @@ export const EnrichedTextInput = ({ toggleUnorderedList: () => { Commands.toggleUnorderedList(nullthrows(nativeRef.current)); }, + toggleCheckboxList: (checked: boolean) => { + Commands.toggleCheckboxList(nullthrows(nativeRef.current), checked); + }, setLink: (start: number, end: number, text: string, url: string) => { Commands.addLink(nullthrows(nativeRef.current), start, end, text, url); }, diff --git a/src/EnrichedTextInputNativeComponent.ts b/src/EnrichedTextInputNativeComponent.ts index 1308c2fa8..2bfd4196a 100644 --- a/src/EnrichedTextInputNativeComponent.ts +++ b/src/EnrichedTextInputNativeComponent.ts @@ -32,6 +32,7 @@ export interface OnChangeStateEvent { isBlockQuote: boolean; isOrderedList: boolean; isUnorderedList: boolean; + isCheckboxList: boolean; isLink: boolean; isImage: boolean; isMention: boolean; @@ -124,6 +125,11 @@ export interface HtmlStyleInternal { marginLeft?: Float; gapWidth?: Float; }; + ulCheckbox?: { + gapWidth?: Float; + boxSize?: Float; + marginLeft?: Float; + }; } export interface NativeProps extends ViewProps { @@ -198,6 +204,10 @@ interface NativeCommands { toggleBlockQuote: (viewRef: React.ElementRef) => void; toggleOrderedList: (viewRef: React.ElementRef) => void; toggleUnorderedList: (viewRef: React.ElementRef) => void; + toggleCheckboxList: ( + viewRef: React.ElementRef, + checked: boolean + ) => void; addLink: ( viewRef: React.ElementRef, start: Int32, @@ -251,6 +261,7 @@ export const Commands: NativeCommands = codegenNativeCommands({ 'toggleBlockQuote', 'toggleOrderedList', 'toggleUnorderedList', + 'toggleCheckboxList', 'addLink', 'addImage', 'startMention', diff --git a/src/normalizeHtmlStyle.ts b/src/normalizeHtmlStyle.ts index 03a1d6b21..463edf52e 100644 --- a/src/normalizeHtmlStyle.ts +++ b/src/normalizeHtmlStyle.ts @@ -66,6 +66,11 @@ const defaultStyle: Required = { marginLeft: 16, gapWidth: 16, }, + ulCheckbox: { + boxSize: 24, + gapWidth: 16, + marginLeft: 16, + }, }; const isMentionStyleRecord = ( From 2893386f2b718f0ad680ced9c82a9cc5e02d0aed Mon Sep 17 00:00:00 2001 From: Igor Furgala Date: Mon, 12 Jan 2026 12:28:46 +0100 Subject: [PATCH 02/10] chore: cleanup --- .../swmansion/enriched/spans/EnrichedCheckboxListSpan.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/com/swmansion/enriched/spans/EnrichedCheckboxListSpan.kt b/android/src/main/java/com/swmansion/enriched/spans/EnrichedCheckboxListSpan.kt index c8c2ff015..2562f18e1 100644 --- a/android/src/main/java/com/swmansion/enriched/spans/EnrichedCheckboxListSpan.kt +++ b/android/src/main/java/com/swmansion/enriched/spans/EnrichedCheckboxListSpan.kt @@ -29,11 +29,11 @@ class EnrichedCheckboxListSpan( uncheckedDrawable.setBounds(0, 0, htmlStyle.ulCheckboxBoxSize, htmlStyle.ulCheckboxBoxSize) } - override fun updateMeasureState(p0: TextPaint) { + override fun updateMeasureState(tp: TextPaint) { // Do nothing, but inform layout that this span affects text metrics } - override fun updateDrawState(p0: TextPaint?) { + override fun updateDrawState(tp: TextPaint?) { // Do nothing, but inform layout that this span affects text metrics } @@ -59,8 +59,7 @@ class EnrichedCheckboxListSpan( if (spannedText.getSpanStart(this) == start) { val drawable = if (isChecked) checkedDrawable else uncheckedDrawable - val fontMetrics = paint.fontMetrics - val lineCenter = baseline + (fontMetrics.ascent + fontMetrics.descent) / 2f + val lineCenter = (top + bottom) / 2f val drawableTop = lineCenter - (htmlStyle.ulCheckboxBoxSize / 2f) canvas.withTranslation(x.toFloat() + htmlStyle.ulCheckboxMarginLeft, drawableTop) { From 364118f7f60db8061aa2512778f13adfadad8a81 Mon Sep 17 00:00:00 2001 From: Igor Furgala Date: Mon, 12 Jan 2026 13:23:55 +0100 Subject: [PATCH 03/10] fix: layout calculation for bigger checkbox size --- .../spans/EnrichedCheckboxListSpan.kt | 28 ++++++++++++++++++- .../swmansion/enriched/styles/ListStyles.kt | 4 +++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/com/swmansion/enriched/spans/EnrichedCheckboxListSpan.kt b/android/src/main/java/com/swmansion/enriched/spans/EnrichedCheckboxListSpan.kt index 2562f18e1..fd37b45fb 100644 --- a/android/src/main/java/com/swmansion/enriched/spans/EnrichedCheckboxListSpan.kt +++ b/android/src/main/java/com/swmansion/enriched/spans/EnrichedCheckboxListSpan.kt @@ -6,6 +6,7 @@ import android.text.Layout import android.text.Spanned import android.text.TextPaint import android.text.style.LeadingMarginSpan +import android.text.style.LineHeightSpan import android.text.style.MetricAffectingSpan import androidx.core.graphics.withTranslation import com.swmansion.enriched.spans.interfaces.EnrichedParagraphSpan @@ -16,6 +17,7 @@ class EnrichedCheckboxListSpan( var isChecked: Boolean, private val htmlStyle: HtmlStyle, ) : MetricAffectingSpan(), + LineHeightSpan, LeadingMarginSpan, EnrichedParagraphSpan { override val dependsOnHtmlStyle: Boolean = true @@ -33,10 +35,34 @@ class EnrichedCheckboxListSpan( // Do nothing, but inform layout that this span affects text metrics } - override fun updateDrawState(tp: TextPaint?) { + override fun updateDrawState(tp: TextPaint) { // Do nothing, but inform layout that this span affects text metrics } + // Include checkbox size in text measurements to avoid clipping + override fun chooseHeight( + text: CharSequence, + start: Int, + end: Int, + spanstartv: Int, + v: Int, + fm: Paint.FontMetricsInt, + ) { + val checkboxSize = htmlStyle.ulCheckboxBoxSize + val currentLineHeight = fm.descent - fm.ascent + + if (checkboxSize > currentLineHeight) { + val extraSpace = checkboxSize - currentLineHeight + val halfExtra = extraSpace / 2 + + fm.ascent -= halfExtra + fm.descent += (extraSpace - halfExtra) + + fm.top -= halfExtra + fm.bottom += (extraSpace - halfExtra) + } + } + override fun getLeadingMargin(first: Boolean): Int = htmlStyle.ulCheckboxBoxSize + htmlStyle.ulCheckboxMarginLeft + htmlStyle.ulCheckboxGapWidth diff --git a/android/src/main/java/com/swmansion/enriched/styles/ListStyles.kt b/android/src/main/java/com/swmansion/enriched/styles/ListStyles.kt index adf41bc00..e87264962 100644 --- a/android/src/main/java/com/swmansion/enriched/styles/ListStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/styles/ListStyles.kt @@ -77,7 +77,11 @@ class ListStyles( if (name == EnrichedSpans.CHECKBOX_LIST) { val span = EnrichedCheckboxListSpan(isChecked ?: false, view.htmlStyle) spannable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + + // Set click listener to handle checkbox toggling view.setLeadingMarginCheckboxClickListener() + // Invalidate layout to update checkbox drawing in case checkbox is bigger than line height + view.layoutManager.invalidateLayout() return } } From 383f3f6d20f85a39f8f641ee6370e812696a3036 Mon Sep 17 00:00:00 2001 From: Igor Furgala Date: Mon, 12 Jan 2026 13:47:34 +0100 Subject: [PATCH 04/10] feat: customize checbox color --- .../spans/EnrichedCheckboxListSpan.kt | 18 ++--- .../swmansion/enriched/styles/HtmlStyle.kt | 4 + .../enriched/utils/CheckboxDrawable.kt | 81 +++++++++++++++++++ apps/example/src/App.tsx | 1 + src/EnrichedTextInput.tsx | 1 + src/EnrichedTextInputNativeComponent.ts | 1 + src/normalizeHtmlStyle.ts | 1 + 7 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 android/src/main/java/com/swmansion/enriched/utils/CheckboxDrawable.kt diff --git a/android/src/main/java/com/swmansion/enriched/spans/EnrichedCheckboxListSpan.kt b/android/src/main/java/com/swmansion/enriched/spans/EnrichedCheckboxListSpan.kt index fd37b45fb..cc0c0cb3a 100644 --- a/android/src/main/java/com/swmansion/enriched/spans/EnrichedCheckboxListSpan.kt +++ b/android/src/main/java/com/swmansion/enriched/spans/EnrichedCheckboxListSpan.kt @@ -11,7 +11,7 @@ import android.text.style.MetricAffectingSpan import androidx.core.graphics.withTranslation import com.swmansion.enriched.spans.interfaces.EnrichedParagraphSpan import com.swmansion.enriched.styles.HtmlStyle -import com.swmansion.enriched.utils.ResourceManager +import com.swmansion.enriched.utils.CheckboxDrawable class EnrichedCheckboxListSpan( var isChecked: Boolean, @@ -22,14 +22,10 @@ class EnrichedCheckboxListSpan( EnrichedParagraphSpan { override val dependsOnHtmlStyle: Boolean = true - // TODO: use custom drawables. Consider customizing color of them - private val checkedDrawable = ResourceManager.getDrawableResource(android.R.drawable.checkbox_on_background) - private val uncheckedDrawable = ResourceManager.getDrawableResource(android.R.drawable.checkbox_off_background) - - init { - checkedDrawable.setBounds(0, 0, htmlStyle.ulCheckboxBoxSize, htmlStyle.ulCheckboxBoxSize) - uncheckedDrawable.setBounds(0, 0, htmlStyle.ulCheckboxBoxSize, htmlStyle.ulCheckboxBoxSize) - } + private val checkboxDrawable = + CheckboxDrawable(htmlStyle.ulCheckboxBoxSize, htmlStyle.ulCheckboxBoxColor, isChecked).apply { + setBounds(0, 0, htmlStyle.ulCheckboxBoxSize, htmlStyle.ulCheckboxBoxSize) + } override fun updateMeasureState(tp: TextPaint) { // Do nothing, but inform layout that this span affects text metrics @@ -83,13 +79,13 @@ class EnrichedCheckboxListSpan( val spannedText = text as Spanned if (spannedText.getSpanStart(this) == start) { - val drawable = if (isChecked) checkedDrawable else uncheckedDrawable + checkboxDrawable.update(isChecked) val lineCenter = (top + bottom) / 2f val drawableTop = lineCenter - (htmlStyle.ulCheckboxBoxSize / 2f) canvas.withTranslation(x.toFloat() + htmlStyle.ulCheckboxMarginLeft, drawableTop) { - drawable.draw(this) + checkboxDrawable.draw(this) } } } diff --git a/android/src/main/java/com/swmansion/enriched/styles/HtmlStyle.kt b/android/src/main/java/com/swmansion/enriched/styles/HtmlStyle.kt index cac24f1f1..f029d7d33 100644 --- a/android/src/main/java/com/swmansion/enriched/styles/HtmlStyle.kt +++ b/android/src/main/java/com/swmansion/enriched/styles/HtmlStyle.kt @@ -55,6 +55,7 @@ class HtmlStyle { var ulCheckboxBoxSize: Int = 50 var ulCheckboxGapWidth: Int = 16 var ulCheckboxMarginLeft: Int = 24 + var ulCheckboxBoxColor: Int = Color.BLACK var aColor: Int = Color.BLACK var aUnderline: Boolean = true @@ -126,6 +127,7 @@ class HtmlStyle { ulCheckboxBoxSize = parseFloat(ulCheckboxStyle, "boxSize").toInt() ulCheckboxGapWidth = parseFloat(ulCheckboxStyle, "gapWidth").toInt() ulCheckboxMarginLeft = parseFloat(ulCheckboxStyle, "marginLeft").toInt() + ulCheckboxBoxColor = parseColor(ulCheckboxStyle, "boxColor") val aStyle = style.getMap("a") aColor = parseColor(aStyle, "color") @@ -302,6 +304,7 @@ class HtmlStyle { ulCheckboxBoxSize == other.ulCheckboxBoxSize && ulCheckboxGapWidth == other.ulCheckboxGapWidth && ulCheckboxMarginLeft == other.ulCheckboxMarginLeft && + ulCheckboxBoxColor == other.ulCheckboxBoxColor && aColor == other.aColor && aUnderline == other.aUnderline && @@ -348,6 +351,7 @@ class HtmlStyle { result = 31 * result + ulCheckboxBoxSize.hashCode() result = 31 * result + ulCheckboxGapWidth.hashCode() result = 31 * result + ulCheckboxMarginLeft.hashCode() + result = 31 * result + ulCheckboxBoxColor.hashCode() result = 31 * result + aColor.hashCode() result = 31 * result + aUnderline.hashCode() diff --git a/android/src/main/java/com/swmansion/enriched/utils/CheckboxDrawable.kt b/android/src/main/java/com/swmansion/enriched/utils/CheckboxDrawable.kt new file mode 100644 index 000000000..5b7ff3869 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/utils/CheckboxDrawable.kt @@ -0,0 +1,81 @@ +package com.swmansion.enriched.utils + +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PixelFormat +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.drawable.Drawable + +class CheckboxDrawable( + private val size: Int, + private var color: Int, + private var isChecked: Boolean, +) : Drawable() { + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val path = Path() + + fun update(checked: Boolean) { + this.isChecked = checked + invalidateSelf() + } + + override fun draw(canvas: Canvas) { + val saveCount = canvas.saveLayer(0f, 0f, size.toFloat(), size.toFloat(), null) + + paint.color = color + paint.style = Paint.Style.FILL + + // Full square background with transparent checkmark + if (isChecked) { + val cornerRadius = size * 0.15f + canvas.drawRoundRect(0f, 0f, size.toFloat(), size.toFloat(), cornerRadius, cornerRadius, paint) + + paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.XOR) + paint.strokeWidth = size * 0.15f + paint.style = Paint.Style.STROKE + paint.strokeCap = Paint.Cap.ROUND + paint.strokeJoin = Paint.Join.ROUND + + path.reset() + path.moveTo(size * 0.25f, size * 0.5f) + path.lineTo(size * 0.45f, size * 0.7f) + path.lineTo(size * 0.75f, size * 0.3f) + canvas.drawPath(path, paint) + + paint.xfermode = null + canvas.restoreToCount(saveCount) + return + } + + // Border only square for unchecked state + paint.style = Paint.Style.STROKE + paint.strokeWidth = size * 0.1f + val margin = paint.strokeWidth / 2f + val cornerRadius = size * 0.15f + canvas.drawRoundRect( + margin, + margin, + size - margin, + size - margin, + cornerRadius, + cornerRadius, + paint, + ) + + canvas.restoreToCount(saveCount) + } + + override fun setAlpha(alpha: Int) { + paint.alpha = alpha + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + paint.colorFilter = colorFilter + } + + @Deprecated("Deprecated in Java") + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT +} diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx index d394bda24..43e2159c1 100644 --- a/apps/example/src/App.tsx +++ b/apps/example/src/App.tsx @@ -444,6 +444,7 @@ const htmlStyle: HtmlStyle = { boxSize: 24, gapWidth: 16, marginLeft: 24, + boxColor: 'rgb(0, 26, 114)', }, }; diff --git a/src/EnrichedTextInput.tsx b/src/EnrichedTextInput.tsx index 7ba97f1c6..d142e19a2 100644 --- a/src/EnrichedTextInput.tsx +++ b/src/EnrichedTextInput.tsx @@ -122,6 +122,7 @@ export interface HtmlStyle { boxSize?: number; gapWidth?: number; marginLeft?: number; + boxColor?: ColorValue; }; } diff --git a/src/EnrichedTextInputNativeComponent.ts b/src/EnrichedTextInputNativeComponent.ts index 2bfd4196a..d1a5b065e 100644 --- a/src/EnrichedTextInputNativeComponent.ts +++ b/src/EnrichedTextInputNativeComponent.ts @@ -129,6 +129,7 @@ export interface HtmlStyleInternal { gapWidth?: Float; boxSize?: Float; marginLeft?: Float; + boxColor?: ColorValue; }; } diff --git a/src/normalizeHtmlStyle.ts b/src/normalizeHtmlStyle.ts index 463edf52e..9a87d5672 100644 --- a/src/normalizeHtmlStyle.ts +++ b/src/normalizeHtmlStyle.ts @@ -70,6 +70,7 @@ const defaultStyle: Required = { boxSize: 24, gapWidth: 16, marginLeft: 16, + boxColor: 'blue', }, }; From 345edcddb959a145ae3a7bfa6d4c720c0740935e Mon Sep 17 00:00:00 2001 From: Igor Furgala Date: Mon, 12 Jan 2026 16:32:25 +0100 Subject: [PATCH 05/10] chore: cleanup --- .../java/com/swmansion/enriched/EnrichedTextInputView.kt | 6 +++++- .../main/java/com/swmansion/enriched/styles/ListStyles.kt | 3 --- android/src/main/java/com/swmansion/enriched/utils/Utils.kt | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt index 9e6929730..eed45d375 100644 --- a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt @@ -48,6 +48,7 @@ import com.swmansion.enriched.utils.EnrichedParser import com.swmansion.enriched.utils.EnrichedSelection import com.swmansion.enriched.utils.EnrichedSpanState import com.swmansion.enriched.utils.mergeSpannables +import com.swmansion.enriched.utils.setCheckboxClickListener import com.swmansion.enriched.watchers.EnrichedSpanWatcher import com.swmansion.enriched.watchers.EnrichedTextWatcher import java.util.regex.Pattern @@ -134,9 +135,12 @@ class EnrichedTextInputView : AppCompatEditText { // Ensure that every time new editable is created, it has EnrichedSpanWatcher attached val spanWatcher = EnrichedSpanWatcher(this) this.spanWatcher = spanWatcher - setEditableFactory(EnrichedEditableFactory(spanWatcher)) + setEditableFactory(EnrichedEditableFactory(spanWatcher)) addTextChangedListener(EnrichedTextWatcher(this)) + + // Handle checkbox list item clicks + this.setCheckboxClickListener() } // https://github.com/facebook/react-native/blob/36df97f500aa0aa8031098caf7526db358b6ddc1/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt#L295C1-L296C1 diff --git a/android/src/main/java/com/swmansion/enriched/styles/ListStyles.kt b/android/src/main/java/com/swmansion/enriched/styles/ListStyles.kt index 0049efc5f..36ab5fc1e 100644 --- a/android/src/main/java/com/swmansion/enriched/styles/ListStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/styles/ListStyles.kt @@ -11,7 +11,6 @@ import com.swmansion.enriched.spans.EnrichedSpans import com.swmansion.enriched.spans.EnrichedUnorderedListSpan import com.swmansion.enriched.utils.getParagraphBounds import com.swmansion.enriched.utils.getSafeSpanBoundaries -import com.swmansion.enriched.utils.setLeadingMarginCheckboxClickListener class ListStyles( private val view: EnrichedTextInputView, @@ -78,8 +77,6 @@ class ListStyles( val span = EnrichedCheckboxListSpan(isChecked ?: false, view.htmlStyle) spannable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - // Set click listener to handle checkbox toggling - view.setLeadingMarginCheckboxClickListener() // Invalidate layout to update checkbox drawing in case checkbox is bigger than line height view.layoutManager.invalidateLayout() return diff --git a/android/src/main/java/com/swmansion/enriched/utils/Utils.kt b/android/src/main/java/com/swmansion/enriched/utils/Utils.kt index 53cf4a70b..b3368c372 100644 --- a/android/src/main/java/com/swmansion/enriched/utils/Utils.kt +++ b/android/src/main/java/com/swmansion/enriched/utils/Utils.kt @@ -109,7 +109,7 @@ fun Spannable.mergeSpannables( // We don't use ClickableSpan because it works fine only when LinkMovementMethod is set on TextView // Which breaks text selection and other features @SuppressLint("ClickableViewAccessibility") -fun TextView.setLeadingMarginCheckboxClickListener() { +fun TextView.setCheckboxClickListener() { var isDownOnCheckbox = false setOnTouchListener { v, event -> From 7790f7a10095c873babb56d5ab8a182653d84148 Mon Sep 17 00:00:00 2001 From: Igor Furgala Date: Mon, 12 Jan 2026 16:38:47 +0100 Subject: [PATCH 06/10] chore: resolve conflicts --- .../java/com/swmansion/enriched/utils/EnrichedSpanState.kt | 3 +++ apps/example/src/App.tsx | 1 + apps/example/src/components/Toolbar.tsx | 4 ++++ src/EnrichedTextInputNativeComponent.ts | 5 +++++ 4 files changed, 13 insertions(+) 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 362a466ba..9b9f16cee 100644 --- a/android/src/main/java/com/swmansion/enriched/utils/EnrichedSpanState.kt +++ b/android/src/main/java/com/swmansion/enriched/utils/EnrichedSpanState.kt @@ -234,6 +234,7 @@ class EnrichedSpanState( deprecatedPayload.putBoolean("isBlockQuote", blockQuoteStart != null) deprecatedPayload.putBoolean("isOrderedList", orderedListStart != null) deprecatedPayload.putBoolean("isUnorderedList", unorderedListStart != null) + deprecatedPayload.putBoolean("isCheckboxList", checkboxListStart != null) deprecatedPayload.putBoolean("isLink", linkStart != null) deprecatedPayload.putBoolean("isImage", imageStart != null) deprecatedPayload.putBoolean("isMention", mentionStart != null) @@ -278,6 +279,7 @@ class EnrichedSpanState( if (blockQuoteStart != null) EnrichedSpans.BLOCK_QUOTE else null, if (orderedListStart != null) EnrichedSpans.ORDERED_LIST else null, if (unorderedListStart != null) EnrichedSpans.UNORDERED_LIST else null, + if (checkboxListStart != null) EnrichedSpans.CHECKBOX_LIST else null, if (linkStart != null) EnrichedSpans.LINK else null, if (imageStart != null) EnrichedSpans.IMAGE else null, if (mentionStart != null) EnrichedSpans.MENTION else null, @@ -301,6 +303,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("checkboxList", getStyleState(activeStyles, EnrichedSpans.CHECKBOX_LIST)) // 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 21d774a48..2f895325a 100644 --- a/apps/example/src/App.tsx +++ b/apps/example/src/App.tsx @@ -62,6 +62,7 @@ const DEFAULT_STYLES: StylesState = { link: DEFAULT_STYLE_STATE, image: DEFAULT_STYLE_STATE, mention: DEFAULT_STYLE_STATE, + checkboxList: DEFAULT_STYLE_STATE, }; const DEFAULT_LINK_STATE = { diff --git a/apps/example/src/components/Toolbar.tsx b/apps/example/src/components/Toolbar.tsx index 7b00a5d5a..9f46944d2 100644 --- a/apps/example/src/components/Toolbar.tsx +++ b/apps/example/src/components/Toolbar.tsx @@ -205,6 +205,8 @@ export const Toolbar: FC = ({ return stylesState.image.isBlocking; case 'mention': return stylesState.mention.isBlocking; + case 'checkbox-list': + return stylesState.checkboxList.isBlocking; default: return false; } @@ -248,6 +250,8 @@ export const Toolbar: FC = ({ return stylesState.image.isActive; case 'mention': return stylesState.mention.isActive; + case 'checkbox-list': + return stylesState.checkboxList.isActive; default: return false; } diff --git a/src/EnrichedTextInputNativeComponent.ts b/src/EnrichedTextInputNativeComponent.ts index df25591ba..f280ca01f 100644 --- a/src/EnrichedTextInputNativeComponent.ts +++ b/src/EnrichedTextInputNativeComponent.ts @@ -117,6 +117,11 @@ export interface OnChangeStateEvent { isConflicting: boolean; isBlocking: boolean; }; + checkboxList: { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; } export interface OnChangeStateDeprecatedEvent { From b7c1abc1de15e0264ac59732dcc0c5065299eb9c Mon Sep 17 00:00:00 2001 From: Igor Furgala Date: Mon, 12 Jan 2026 16:56:08 +0100 Subject: [PATCH 07/10] chore: document checkbox list --- README.md | 1 + docs/API_REFERENCE.md | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index aaa6b14ec..8677ebdc0 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,7 @@ Supported styles: - blockquote - ordered list - unordered list +- checkbox list Each of the styles can be toggled the same way as in the example from [usage section](#usage); call a proper `toggle` function on the component ref. diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 34ab9150f..e73d0bcc6 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -251,6 +251,11 @@ interface OnChangeStateEvent { isConflicting: boolean; isBlocking: boolean; }; + checkboxList: { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; } ``` @@ -264,7 +269,7 @@ interface OnChangeStateEvent { ### `onChangeStateDeprecated` -> [!WARNING] +> [!WARNING] > Callback is here just to provide easier migration to newest enriched versions and will be removed in future releases. Callback that gets called when any of the styles within the selection changes. @@ -288,6 +293,7 @@ interface OnChangeStateDeprecatedEvent { isBlockQuote: boolean; isOrderedList: boolean; isUnorderedList: boolean; + isCheckboxList: boolean; isLink: boolean; isImage: boolean; isMention: boolean; @@ -683,6 +689,17 @@ toggleUnorderedList: () => void; Converts current selection into an unordered list. +### `.toggleCheckboxList()` + +```ts +toggleCheckboxList: (checked: boolean) => void; +``` + +Converts current selection into an unordered list with checkboxes as items. Each checkbox can be either checked or unchecked. +User can later toggle each checkbox individually by tapping on it. + +- `checked: boolean` - defines whether the checkboxes should be checked or unchecked by default. + ## HtmlStyle type Allows customizing HTML styles. @@ -745,6 +762,12 @@ interface HtmlStyle { marginLeft?: number; gapWidth?: number; }; + ulCheckbox?: { + boxColor?: ColorValue; + boxSize?: number; + marginLeft?: number; + gapWidth?: number; + }; } interface MentionStyleProperties { @@ -810,3 +833,12 @@ By bullet we mean the dot that begins each line of the list. - `bulletSize` sets both the height and the width of the bullet, defaults to `8`. - `marginLeft` is the margin to the left of the bullet (between the bullet and input's left edge), defaults to `16`. - `gapWidth` sets the gap between the bullet and the list item's text, defaults to `16`. + +### ulCheckbox (checkbox list) + +Allows using unordered list with checkboxes instead of bullets. + +- `boxColor` defines the color of the checkbox, takes [color](https://reactnative.dev/docs/colors) value and defaults to `blue`. +- `boxSize` sets both the height and the width of the checkbox, defaults to `24`. +- `marginLeft` is the margin to the left of the checkbox (between the checkbox and input's left edge), defaults to `16`. +- `gapWidth` sets the gap between the checkbox and the list item's text, defaults to `16`. From 6300d6036618de7106bb682a7f4ade689d0b9161 Mon Sep 17 00:00:00 2001 From: Igor Furgala Date: Tue, 13 Jan 2026 09:21:42 +0100 Subject: [PATCH 08/10] feat: improve cursor handling for checkboxes --- .../src/main/java/com/swmansion/enriched/utils/Utils.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/android/src/main/java/com/swmansion/enriched/utils/Utils.kt b/android/src/main/java/com/swmansion/enriched/utils/Utils.kt index b3368c372..ab0c94521 100644 --- a/android/src/main/java/com/swmansion/enriched/utils/Utils.kt +++ b/android/src/main/java/com/swmansion/enriched/utils/Utils.kt @@ -1,6 +1,7 @@ package com.swmansion.enriched.utils import android.annotation.SuppressLint +import android.text.Selection import android.text.Spannable import android.text.SpannableString import android.text.SpannableStringBuilder @@ -157,6 +158,14 @@ fun TextView.setCheckboxClickListener() { // Reapply span so changes are visible without need to redraw entire TextView spannable.removeSpan(span) spannable.setSpan(span, start, end, flags) + + // For focused input, ensure cursor is active for affected paragraph + if (tv.isFocused) { + val currentCursor = Selection.getSelectionEnd(spannable) + if (currentCursor < start || currentCursor > end) { + Selection.setSelection(spannable, end) + } + } } isDownOnCheckbox = false From 4332f9ed4eef7ae78c2c3fd1cfc08ca9df22e8e5 Mon Sep 17 00:00:00 2001 From: Igor Furgala Date: Mon, 19 Jan 2026 12:06:18 +0100 Subject: [PATCH 09/10] chore: resolve conflicts --- .../src/main/java/com/swmansion/enriched/utils/Utils.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/android/src/main/java/com/swmansion/enriched/utils/Utils.kt b/android/src/main/java/com/swmansion/enriched/utils/Utils.kt index e59966fb7..0f44dc530 100644 --- a/android/src/main/java/com/swmansion/enriched/utils/Utils.kt +++ b/android/src/main/java/com/swmansion/enriched/utils/Utils.kt @@ -1,6 +1,13 @@ package com.swmansion.enriched.utils +import android.annotation.SuppressLint +import android.text.Selection +import android.text.Spannable +import android.text.Spanned import android.util.Log +import android.view.MotionEvent +import android.widget.TextView +import com.swmansion.enriched.spans.EnrichedCheckboxListSpan import org.json.JSONObject fun jsonStringToStringMap(json: String): Map { From f9ea2c6ef8a0d2e7c3fe597d406d7c97f9c931df Mon Sep 17 00:00:00 2001 From: Igor Furgala Date: Fri, 23 Jan 2026 12:38:06 +0100 Subject: [PATCH 10/10] chore: resolve cr comments --- .../enriched/textinput/styles/ListStyles.kt | 44 +++++++------------ 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt index bbd47b629..606b6b901 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt @@ -58,42 +58,32 @@ class ListStyles( name: String, start: Int, end: Int, - isChecked: Boolean?, + isChecked: Boolean? = false, ) { val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(start, end) - if (name == EnrichedSpans.UNORDERED_LIST) { - val span = EnrichedUnorderedListSpan(view.htmlStyle) - spannable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - return - } + when (name) { + EnrichedSpans.UNORDERED_LIST -> { + val span = EnrichedUnorderedListSpan(view.htmlStyle) + spannable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } - if (name == EnrichedSpans.ORDERED_LIST) { - val index = getOrderedListIndex(spannable, safeStart) - val span = EnrichedOrderedListSpan(index, view.htmlStyle) - spannable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - return - } + EnrichedSpans.ORDERED_LIST -> { + val index = getOrderedListIndex(spannable, safeStart) + val span = EnrichedOrderedListSpan(index, view.htmlStyle) + spannable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } - if (name == EnrichedSpans.CHECKBOX_LIST) { - val span = EnrichedCheckboxListSpan(isChecked ?: false, view.htmlStyle) - spannable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + EnrichedSpans.CHECKBOX_LIST -> { + val span = EnrichedCheckboxListSpan(isChecked ?: false, view.htmlStyle) + spannable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - // Invalidate layout to update checkbox drawing in case checkbox is bigger than line height - view.layoutManager.invalidateLayout() - return + // Invalidate layout to update checkbox drawing in case checkbox is bigger than line height + view.layoutManager.invalidateLayout() + } } } - private fun setSpan( - spannable: Spannable, - name: String, - start: Int, - end: Int, - ) { - setSpan(spannable, name, start, end, null) - } - private fun removeSpansForRange( spannable: Spannable, start: Int,