package com.swmansion.enriched.textinput.styles import android.text.Editable import android.text.Spannable import android.text.SpannableStringBuilder import android.text.Spanned import com.swmansion.enriched.common.EnrichedConstants import com.swmansion.enriched.textinput.EnrichedTextInputView import com.swmansion.enriched.textinput.spans.EnrichedInputCheckboxListSpan import com.swmansion.enriched.textinput.spans.EnrichedInputOrderedListSpan import com.swmansion.enriched.textinput.spans.EnrichedInputUnorderedListSpan import com.swmansion.enriched.textinput.spans.EnrichedSpans import com.swmansion.enriched.textinput.utils.getParagraphBounds import com.swmansion.enriched.textinput.utils.getSafeSpanBoundaries import com.swmansion.enriched.textinput.utils.safelyInsertZWS import com.swmansion.enriched.textinput.utils.safelyRemoveZWS class ListStyles( private val view: EnrichedTextInputView, ) { private fun getPreviousParagraphSpan( spannable: Spannable, s: Int, type: Class, ): T? { if (s <= 0) return null val (previousParagraphStart, previousParagraphEnd) = spannable.getParagraphBounds(s - 1) val spans = spannable.getSpans(previousParagraphStart, previousParagraphEnd, type) if (spans.isNotEmpty()) { return spans.last() } return null } private fun isPreviousParagraphList( spannable: Spannable, s: Int, type: Class, ): Boolean { val previousSpan = getPreviousParagraphSpan(spannable, s, type) return previousSpan != null } private fun getOrderedListIndex( spannable: Spannable, s: Int, ): Int { val span = getPreviousParagraphSpan(spannable, s, EnrichedInputOrderedListSpan::class.java) val index = span?.getListIndex() ?: 0 return index + 1 } private fun setSpan( spannable: Spannable, name: String, start: Int, end: Int, isChecked: Boolean? = false, ) { val (safeStart, safeEnd) = spannable.getSafeSpanBoundaries(start, end) when (name) { EnrichedSpans.UNORDERED_LIST -> { val span = EnrichedInputUnorderedListSpan(view.htmlStyle) spannable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) } EnrichedSpans.ORDERED_LIST -> { val index = getOrderedListIndex(spannable, safeStart) val span = EnrichedInputOrderedListSpan(index, view.htmlStyle) spannable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) } EnrichedSpans.CHECKBOX_LIST -> { val span = EnrichedInputCheckboxListSpan(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() } } } private fun removeSpansForRange( spannable: Spannable, start: Int, end: Int, clazz: Class, ): Boolean { val ssb = spannable as SpannableStringBuilder val spans = ssb.getSpans(start, end, clazz) if (spans.isEmpty()) return false for (span in spans) { ssb.removeSpan(span) } ssb.safelyRemoveZWS(start, end) return true } fun updateOrderedListIndexes( text: Spannable, position: Int, ) { val spans = text.getSpans(position + 1, text.length, EnrichedInputOrderedListSpan::class.java) val sortedSpans = spans.sortedBy { text.getSpanStart(it) } for (span in sortedSpans) { val spanStart = text.getSpanStart(span) val index = getOrderedListIndex(text, spanStart) span.setListIndex(index) } } private fun toggleStyle( name: String, checkboxState: Boolean?, ) { if (view.selection == null) return val config = EnrichedSpans.listSpans[name] ?: return val spannable = view.text as SpannableStringBuilder val (start, end) = view.selection.getParagraphSelection() val styleStart = view.spanState?.getStart(name) if (styleStart != null) { view.spanState.setStart(name, null) removeSpansForRange(spannable, start, end, config.clazz) view.selection.validateStyles() return } if (start == end) { val wasInserted = spannable.safelyInsertZWS(start) val shift = if (wasInserted) 1 else 0 view.spanState?.setStart(name, start + shift) removeSpansForRange(spannable, start, end, config.clazz) setSpan(spannable, name, start, end + shift, checkboxState) return } var currentStart = start val paragraphs = spannable.substring(start, end).split("\n") removeSpansForRange(spannable, start, end, config.clazz) for (paragraph in paragraphs) { val wasInserted = spannable.safelyInsertZWS(currentStart) val shift = if (wasInserted) 1 else 0 val currentEnd = currentStart + paragraph.length + shift setSpan(spannable, name, currentStart, currentEnd, checkboxState) // Safely jump exactly 1 character over the '\n' to the next line currentStart = currentEnd + 1 } 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, endCursorPosition: Int, previousTextLength: Int, ) { val config = EnrichedSpans.listSpans[name] ?: return val cursorPosition = endCursorPosition.coerceAtMost(s.length) val (start, end) = s.getParagraphBounds(cursorPosition) val isBackspace = previousTextLength > s.length val isNewLine = cursorPosition > 0 && s[cursorPosition - 1] == '\n' val spans = s.getSpans(start, end, config.clazz) // Remove spans if cursor is at the start of the paragraph and spans exist if (isBackspace && start == cursorPosition && spans.isNotEmpty()) { removeSpansForRange(s, start, end, config.clazz) return } if (!isBackspace && isNewLine && isPreviousParagraphList(s, start, config.clazz)) { // Check if the span from the previous line "leaked" into this one if (spans.isNotEmpty()) { val existingSpan = spans[0] val spanStart = s.getSpanStart(existingSpan) // If the span started before the current paragraph (belongs to the previous item) // update it to end at the newline (start - 1) if (spanStart < start) { val spanFlags = s.getSpanFlags(existingSpan) s.setSpan(existingSpan, spanStart, start - 1, spanFlags) } } val wasInserted = (s as SpannableStringBuilder).safelyInsertZWS(cursorPosition) val shift = if (wasInserted) 1 else 0 setSpan(s, name, start, end + shift) // Inform that new span has been added view.selection?.validateStyles() return } if (name === EnrichedSpans.CHECKBOX_LIST) { if (spans.isNotEmpty()) { val previousSpan = spans[0] as EnrichedInputCheckboxListSpan 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) } setSpan(s, name, start, end) } } fun afterTextChanged( s: Editable, endCursorPosition: Int, previousTextLength: Int, ) { 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) fun removeStyle( name: String, start: Int, end: Int, ): Boolean { val config = EnrichedSpans.listSpans[name] ?: return false val spannable = view.text as Spannable return removeSpansForRange(spannable, start, end, config.clazz) } }