diff --git a/README.md b/README.md index de5aff1a2..5ac5e7025 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/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt index 9ea30753b..2ae606203 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -54,6 +54,7 @@ import com.swmansion.enriched.textinput.utils.EnrichedParser import com.swmansion.enriched.textinput.utils.EnrichedSelection import com.swmansion.enriched.textinput.utils.EnrichedSpanState import com.swmansion.enriched.textinput.utils.mergeSpannables +import com.swmansion.enriched.textinput.utils.setCheckboxClickListener import com.swmansion.enriched.textinput.watchers.EnrichedSpanWatcher import com.swmansion.enriched.textinput.watchers.EnrichedTextWatcher import java.util.regex.Pattern @@ -155,9 +156,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 @@ -546,6 +550,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") } @@ -574,6 +579,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) @@ -601,6 +607,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() @@ -660,6 +667,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/textinput/EnrichedTextInputViewManager.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt index d6012bef1..cd530f7e9 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -340,6 +340,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/textinput/spans/EnrichedCheckboxListSpan.kt b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedCheckboxListSpan.kt new file mode 100644 index 000000000..c3fef6948 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedCheckboxListSpan.kt @@ -0,0 +1,94 @@ +package com.swmansion.enriched.textinput.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.LineHeightSpan +import android.text.style.MetricAffectingSpan +import androidx.core.graphics.withTranslation +import com.swmansion.enriched.textinput.spans.interfaces.EnrichedParagraphSpan +import com.swmansion.enriched.textinput.styles.HtmlStyle +import com.swmansion.enriched.utils.CheckboxDrawable + +class EnrichedCheckboxListSpan( + var isChecked: Boolean, + private val htmlStyle: HtmlStyle, +) : MetricAffectingSpan(), + LineHeightSpan, + LeadingMarginSpan, + EnrichedParagraphSpan { + override val dependsOnHtmlStyle: Boolean = true + + 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 + } + + 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 + + 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) { + checkboxDrawable.update(isChecked) + + val lineCenter = (top + bottom) / 2f + val drawableTop = lineCenter - (htmlStyle.ulCheckboxBoxSize / 2f) + + canvas.withTranslation(x.toFloat() + htmlStyle.ulCheckboxMarginLeft, drawableTop) { + checkboxDrawable.draw(this) + } + } + } + + override fun rebuildWithStyle(htmlStyle: HtmlStyle): EnrichedCheckboxListSpan = EnrichedCheckboxListSpan(isChecked, htmlStyle) +} 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 56edb2bae..a23a7449d 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 @@ -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/textinput/styles/HtmlStyle.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt index 34dce9139..70ea06956 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt @@ -52,6 +52,11 @@ class HtmlStyle { var ulBulletSize: Int = 8 var ulBulletColor: Int = Color.BLACK + 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 @@ -118,6 +123,12 @@ 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() + ulCheckboxBoxColor = parseColor(ulCheckboxStyle, "boxColor") + val aStyle = style.getMap("a") aColor = parseColor(aStyle, "color") aUnderline = parseIsUnderline(aStyle) @@ -290,6 +301,11 @@ class HtmlStyle { ulBulletSize == other.ulBulletSize && ulBulletColor == other.ulBulletColor && + ulCheckboxBoxSize == other.ulCheckboxBoxSize && + ulCheckboxGapWidth == other.ulCheckboxGapWidth && + ulCheckboxMarginLeft == other.ulCheckboxMarginLeft && + ulCheckboxBoxColor == other.ulCheckboxBoxColor && + aColor == other.aColor && aUnderline == other.aUnderline && @@ -332,6 +348,11 @@ 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 + ulCheckboxBoxColor.hashCode() + result = 31 * result + aColor.hashCode() result = 31 * result + aUnderline.hashCode() 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 36cc24f11..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 @@ -5,6 +5,7 @@ import android.text.Spannable import android.text.SpannableStringBuilder import android.text.Spanned import com.swmansion.enriched.textinput.EnrichedTextInputView +import com.swmansion.enriched.textinput.spans.EnrichedCheckboxListSpan import com.swmansion.enriched.textinput.spans.EnrichedOrderedListSpan import com.swmansion.enriched.textinput.spans.EnrichedSpans import com.swmansion.enriched.textinput.spans.EnrichedUnorderedListSpan @@ -57,19 +58,29 @@ class ListStyles( name: String, start: Int, end: Int, + 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) + } + + EnrichedSpans.ORDERED_LIST -> { + val index = getOrderedListIndex(spannable, safeStart) + val span = EnrichedOrderedListSpan(index, 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) - 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) + // Invalidate layout to update checkbox drawing in case checkbox is bigger than line height + view.layoutManager.invalidateLayout() + } } } @@ -104,7 +115,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 @@ -123,7 +137,7 @@ class ListStyles( spannable.insert(start, EnrichedConstants.ZWS_STRING) 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 } @@ -135,7 +149,7 @@ class ListStyles( for (paragraph in paragraphs) { spannable.insert(currentStart, EnrichedConstants.ZWS_STRING) val currentEnd = currentStart + paragraph.length + 1 - setSpan(spannable, name, currentStart, currentEnd) + setSpan(spannable, name, currentStart, currentEnd, checkboxState) currentStart = currentEnd + 1 } @@ -143,6 +157,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, @@ -155,7 +177,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 @@ -180,6 +202,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) @@ -196,6 +233,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/textinput/utils/CheckboxDrawable.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/CheckboxDrawable.kt new file mode 100644 index 000000000..5b7ff3869 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/textinput/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/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedParser.java b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedParser.java index 6fceff93d..cdce99718 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedParser.java +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedParser.java @@ -11,6 +11,7 @@ import android.text.style.ParagraphStyle; import com.swmansion.enriched.textinput.spans.EnrichedBlockQuoteSpan; import com.swmansion.enriched.textinput.spans.EnrichedBoldSpan; +import com.swmansion.enriched.textinput.spans.EnrichedCheckboxListSpan; import com.swmansion.enriched.textinput.spans.EnrichedCodeBlockSpan; import com.swmansion.enriched.textinput.spans.EnrichedH1Span; import com.swmansion.enriched.textinput.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/textinput/utils/EnrichedSpanState.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt index a13151f94..9b6a639a6 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 @@ -46,6 +46,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 @@ -128,6 +130,11 @@ class EnrichedSpanState( emitStateChangeEvent() } + fun setCheckboxListStart(start: Int?) { + this.checkboxListStart = start + emitStateChangeEvent() + } + fun setLinkStart(start: Int?) { this.linkStart = start emitStateChangeEvent() @@ -161,6 +168,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 @@ -190,6 +198,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) @@ -225,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) @@ -269,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, @@ -292,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/android/src/main/java/com/swmansion/enriched/textinput/utils/Utils.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/Utils.kt index 192448d38..a922959ba 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/Utils.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/Utils.kt @@ -1,6 +1,13 @@ package com.swmansion.enriched.textinput.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.textinput.spans.EnrichedCheckboxListSpan import org.json.JSONObject fun jsonStringToStringMap(json: String): Map { @@ -19,3 +26,81 @@ fun jsonStringToStringMap(json: String): Map { return result } + +// 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.setCheckboxClickListener() { + 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) + + // 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 + 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 6edefc174..2d8a92db6 100644 --- a/apps/example/src/App.tsx +++ b/apps/example/src/App.tsx @@ -63,6 +63,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 = { @@ -455,6 +456,12 @@ const htmlStyle: HtmlStyle = { marginLeft: 24, gapWidth: 16, }, + ulCheckbox: { + boxSize: 24, + gapWidth: 16, + marginLeft: 24, + boxColor: 'rgb(0, 26, 114)', + }, }; const styles = StyleSheet.create({ diff --git a/apps/example/src/components/Toolbar.tsx b/apps/example/src/components/Toolbar.tsx index 78335499b..9f46944d2 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; @@ -197,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; } @@ -240,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/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 35e1c68aa..25f8a1705 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; + }; } ``` @@ -288,6 +293,7 @@ interface OnChangeStateDeprecatedEvent { isBlockQuote: boolean; isOrderedList: boolean; isUnorderedList: boolean; + isCheckboxList: boolean; isLink: boolean; isImage: boolean; isMention: boolean; @@ -697,6 +703,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. @@ -759,6 +776,12 @@ interface HtmlStyle { marginLeft?: number; gapWidth?: number; }; + ulCheckbox?: { + boxColor?: ColorValue; + boxSize?: number; + marginLeft?: number; + gapWidth?: number; + }; } interface MentionStyleProperties { @@ -824,3 +847,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`. diff --git a/src/EnrichedTextInput.tsx b/src/EnrichedTextInput.tsx index 018e8adab..1dccb9f16 100644 --- a/src/EnrichedTextInput.tsx +++ b/src/EnrichedTextInput.tsx @@ -61,6 +61,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; @@ -120,6 +121,12 @@ export interface HtmlStyle { marginLeft?: number; gapWidth?: number; }; + ulCheckbox?: { + boxSize?: number; + gapWidth?: number; + marginLeft?: number; + boxColor?: ColorValue; + }; } export interface EnrichedTextInputProps extends Omit { @@ -324,6 +331,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/spec/EnrichedTextInputNativeComponent.ts b/src/spec/EnrichedTextInputNativeComponent.ts index d03141f79..589459b40 100644 --- a/src/spec/EnrichedTextInputNativeComponent.ts +++ b/src/spec/EnrichedTextInputNativeComponent.ts @@ -117,6 +117,11 @@ export interface OnChangeStateEvent { isConflicting: boolean; isBlocking: boolean; }; + checkboxList: { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; } export interface OnChangeStateDeprecatedEvent { @@ -135,6 +140,7 @@ export interface OnChangeStateDeprecatedEvent { isBlockQuote: boolean; isOrderedList: boolean; isUnorderedList: boolean; + isCheckboxList: boolean; isLink: boolean; isImage: boolean; isMention: boolean; @@ -231,6 +237,12 @@ export interface HtmlStyleInternal { marginLeft?: Float; gapWidth?: Float; }; + ulCheckbox?: { + gapWidth?: Float; + boxSize?: Float; + marginLeft?: Float; + boxColor?: ColorValue; + }; } export interface NativeProps extends ViewProps { @@ -308,6 +320,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, @@ -361,6 +377,7 @@ export const Commands: NativeCommands = codegenNativeCommands({ 'toggleBlockQuote', 'toggleOrderedList', 'toggleUnorderedList', + 'toggleCheckboxList', 'addLink', 'addImage', 'startMention', diff --git a/src/utils/normalizeHtmlStyle.ts b/src/utils/normalizeHtmlStyle.ts index 4a35d4dc3..6c3b2d61c 100644 --- a/src/utils/normalizeHtmlStyle.ts +++ b/src/utils/normalizeHtmlStyle.ts @@ -66,6 +66,12 @@ const defaultStyle: Required = { marginLeft: 16, gapWidth: 16, }, + ulCheckbox: { + boxSize: 24, + gapWidth: 16, + marginLeft: 16, + boxColor: 'blue', + }, }; const isMentionStyleRecord = (