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 adf4a0bb1..ceee27730 100644 --- a/android/src/main/java/com/swmansion/enriched/styles/ListStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/styles/ListStyles.kt @@ -18,6 +18,7 @@ import com.swmansion.enriched.utils.ParagraphUtils.applyParagraphSpan import com.swmansion.enriched.utils.ParagraphUtils.findPreviousAlignmentSpan import com.swmansion.enriched.utils.ParagraphUtils.getPreviousParagraphSpan import com.swmansion.enriched.utils.getParagraphBounds +import com.swmansion.enriched.utils.getParagraphRanges import com.swmansion.enriched.utils.getSafeSpanBoundaries import com.swmansion.enriched.utils.removeSpans import com.swmansion.enriched.utils.removeZWS @@ -26,6 +27,12 @@ import com.swmansion.enriched.watchers.TextChangedEvent class ListStyles( private val view: EnrichedTextInputView, ) { + private data class StyledListReplacement( + val text: SpannableStringBuilder, + val insertedCharacters: Int, + val styleStart: Int, + ) + private fun setSpan( editable: Editable, name: TextStyle, @@ -37,17 +44,17 @@ class ListStyles( when (name) { TextStyle.UNORDERED_LIST -> { val span = EnrichedUnorderedListSpan(view.htmlStyle) - editable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + applyParagraphSpan(editable, span, safeStart, safeEnd) } TextStyle.ORDERED_LIST -> { val span = EnrichedOrderedListSpan(view.htmlStyle) - editable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + applyParagraphSpan(editable, span, safeStart, safeEnd) } TextStyle.CHECK_LIST -> { val span = EnrichedChecklistSpan(view.htmlStyle) - editable.setSpan(span, safeStart, safeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + applyParagraphSpan(editable, span, safeStart, safeEnd) } else -> {} @@ -61,7 +68,9 @@ class ListStyles( clazz: Class, removeZWS: Boolean = true, ): Boolean { - val spans = editable.getSpans(start, end, clazz) + val safeStart = start.coerceAtMost(end).coerceAtLeast(0).coerceAtMost(editable.length) + val safeEnd = end.coerceAtLeast(start).coerceAtLeast(safeStart).coerceAtMost(editable.length) + val spans = editable.getSpans(safeStart, safeEnd, clazz) if (spans.isEmpty()) return false val flag = Spanned.SPAN_EXCLUSIVE_EXCLUSIVE @@ -72,19 +81,19 @@ class ListStyles( editable.removeSpan(span) - if (spanStart < start) { + if (spanStart < safeStart) { editable.setSpan( span.copy(), spanStart, - start, + safeStart, flag, ) } - if (spanEnd > end) { + if (spanEnd > safeEnd) { editable.setSpan( span.copy(), - end, + safeEnd, spanEnd, flag, ) @@ -92,12 +101,61 @@ class ListStyles( } if (removeZWS) { - editable.removeZWS(start, end) + val replacement = SpannableStringBuilder(editable.subSequence(safeStart, safeEnd)) + replacement.removeZWS(0, replacement.length) + val removedCharacters = safeEnd - safeStart - replacement.length + + if (removedCharacters > 0) { + editable.replace(safeStart, safeEnd, replacement) + } } return true } + private fun buildStyledListReplacement( + editable: Editable, + start: Int, + end: Int, + name: TextStyle, + ): StyledListReplacement { + val replacement = SpannableStringBuilder(editable.subSequence(start, end)) + var insertedCharacters = 0 + var styleStart = 0 + val paragraphs = replacement.getParagraphRanges() + + for (paragraph in paragraphs) { + val currentStart = paragraph.first + insertedCharacters + var currentEnd = paragraph.last + insertedCharacters + + if (!replacement.containsZWS(currentStart, currentEnd)) { + replacement.insert(currentStart, Strings.ZERO_WIDTH_SPACE_STRING) + currentEnd += 1 + insertedCharacters += 1 + } + + setSpan(replacement, name, currentStart, currentEnd) + reapplyAlignment(replacement, currentStart, currentEnd) + styleStart = currentEnd + 1 + } + + return StyledListReplacement(replacement, insertedCharacters, styleStart) + } + + private fun CharSequence.containsZWS( + start: Int, + end: Int, + ): Boolean { + val safeStart = start.coerceIn(0, length) + val safeEnd = end.coerceIn(safeStart, length) + + for (index in safeStart until safeEnd) { + if (this[index] == Strings.ZERO_WIDTH_SPACE_CHAR) return true + } + + return false + } + fun toggleStyle(name: TextStyle) { val config = EnrichedSpans.listSpans[name] ?: return val editable = view.editableText @@ -124,23 +182,9 @@ class ListStyles( return } - var currentStart = start - val paragraphs = editable.substring(start, end).split(Strings.NEWLINE_STRING) - removeSpansForRange(editable, start, end, config.clazz, false) - - for (paragraph in paragraphs) { - var currentEnd = currentStart + paragraph.length - - if (!paragraph.contains(Strings.ZERO_WIDTH_SPACE_CHAR)) { - editable.insert(currentStart, Strings.ZERO_WIDTH_SPACE_STRING) - currentEnd += 1 - } - setSpan(editable, name, currentStart, currentEnd) - reapplyAlignment(editable, currentStart, currentEnd) - currentStart = currentEnd + 1 - } - - spanState.setStartWithStateChangeEmitting(name, currentStart) + val replacement = buildStyledListReplacement(editable, start, end, name) + editable.replace(start, end, replacement.text) + spanState.setStartWithStateChangeEmitting(name, start + replacement.styleStart) } private fun handleAfterTextChanged( diff --git a/android/src/main/java/com/swmansion/enriched/styles/ParagraphStyles.kt b/android/src/main/java/com/swmansion/enriched/styles/ParagraphStyles.kt index 36ab1ada2..e4b3ae12b 100644 --- a/android/src/main/java/com/swmansion/enriched/styles/ParagraphStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/styles/ParagraphStyles.kt @@ -30,6 +30,7 @@ import com.swmansion.enriched.utils.ParagraphUtils.applyParagraphSpan import com.swmansion.enriched.utils.ParagraphUtils.getPreviousParagraphSpan import com.swmansion.enriched.utils.getListRange import com.swmansion.enriched.utils.getParagraphBounds +import com.swmansion.enriched.utils.getParagraphRanges import com.swmansion.enriched.utils.getParagraphsBounds import com.swmansion.enriched.utils.isTheSameParagraphInSelection import com.swmansion.enriched.utils.removeSpans @@ -39,6 +40,11 @@ import com.swmansion.enriched.watchers.TextChangedEvent class ParagraphStyles( private val view: EnrichedTextInputView, ) { + private data class StyledParagraphReplacement( + val text: SpannableStringBuilder, + val insertedCharacters: Int, + ) + private fun removeStyleForSelection( editable: Editable, start: Int, @@ -46,36 +52,51 @@ class ParagraphStyles( clazz: Class, removeZWS: Boolean = true, ): Boolean { - val paragraphRanges = editable.getParagraphsBounds(start, end) + val (paragraphStart, paragraphEnd) = editable.getParagraphBounds(start, end) + + val spans = editable.getSpans(paragraphStart, paragraphEnd, clazz).distinct() var removedAny = false - for (range in paragraphRanges) { - val paragraphStart = range.first - val paragraphEnd = range.last - if (removeZWS) { - editable.removeZWS(paragraphStart, paragraphEnd) + for (span in spans) { + val spanStart = editable.getSpanStart(span) + val spanEnd = editable.getSpanEnd(span) + + val intersects = spanStart < paragraphEnd && spanEnd > paragraphStart + if (!intersects) continue + + editable.removeSpan(span) + + if (spanStart < paragraphStart) { + editable.setSpan( + span.copy(), + spanStart, + paragraphStart, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + + if (spanEnd > paragraphEnd) { + editable.setSpan( + span.copy(), + paragraphEnd, + spanEnd, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) } - val spans = editable.getSpans(paragraphStart, paragraphEnd, clazz) - if (spans.isEmpty()) continue - for (span in spans) { - val spanStart = editable.getSpanStart(span) - val spanEnd = editable.getSpanEnd(span) - val intersects = spanStart <= paragraphEnd && spanEnd >= paragraphStart - if (!intersects) continue + removedAny = true + } - editable.removeSpan(span) + view.transactionManager.runSilently { + if (removedAny && removeZWS) { + val (paragraphStart, paragraphEnd) = editable.getParagraphBounds(start, end) + val replacement = SpannableStringBuilder(editable.subSequence(paragraphStart, paragraphEnd)) + replacement.removeZWS(0, replacement.length) + val removedCharacters = paragraphEnd - paragraphStart - replacement.length - if (spanStart < paragraphStart) { - val leftSpan = span.copy() - editable.setSpan(leftSpan, spanStart, paragraphStart, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - } - if (spanEnd > paragraphEnd) { - val rightSpan = span.copy() - editable.setSpan(rightSpan, paragraphEnd + 1, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + if (removedCharacters > 0) { + editable.replace(paragraphStart, paragraphEnd, replacement) } - - removedAny = true } } @@ -87,6 +108,37 @@ class ParagraphStyles( setSpan(span, 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } + private fun buildStyledParagraphReplacement( + editable: Editable, + start: Int, + end: Int, + name: TextStyle, + clazz: Class, + ): StyledParagraphReplacement? { + createSpan(name) ?: return null + + val replacement = SpannableStringBuilder(editable.subSequence(start, end)) + + replacement.removeSpans(0, replacement.length, clazz) + + var insertedCharacters = 0 + val paragraphs = replacement.getParagraphRanges() + for (paragraph in paragraphs) { + val span = createSpan(name) ?: return null + val currentStart = paragraph.first + insertedCharacters + val currentEnd = paragraph.last + insertedCharacters + + if (currentStart == currentEnd) { + replacement.insert(currentStart, buildZWSWithSpan(span)) + insertedCharacters += 1 + } else { + applyParagraphSpan(replacement, span, currentStart, currentEnd) + } + } + + return StyledParagraphReplacement(replacement, insertedCharacters) + } + fun afterTextChanged(event: TextChangedEvent) { val s = event.text var endCursorPosition = event.endCursorPosition @@ -183,21 +235,9 @@ class ParagraphStyles( return } - var currentStart = start - val paragraphs = ssb.substring(start, end).split(Strings.NEWLINE_STRING) - removeStyleForSelection(ssb, start, end, config.clazz, false) - - for (paragraph in paragraphs) { - val span = createSpan(name) ?: return - var currentEnd = currentStart + paragraph.length - - if (paragraph.isEmpty()) { - ssb.insert(currentStart, Strings.ZERO_WIDTH_SPACE_STRING) - currentEnd += 1 - } - applyParagraphSpan(ssb, span, currentStart, currentEnd) - currentStart = currentEnd + 1 - } + val replacement = + buildStyledParagraphReplacement(ssb, start, end, name, config.clazz) ?: return + ssb.replace(start, end, replacement.text) selection.validateStyles() } @@ -275,7 +315,7 @@ class ParagraphStyles( ) } - editable.replace(paragraphStart, paragraphEnd, zwsBuilder) + editable.insert(paragraphStart, zwsBuilder) return } @@ -294,14 +334,18 @@ class ParagraphStyles( val (listStart, listEnd) = editable.getListRange(paragraphStart, paragraphEnd, listSpan) + val paragraphsBounds = editable.getParagraphsBounds(listStart, listEnd) + editable.removeSpans(listStart, listEnd, EnrichedAlignmentSpan::class.java) - editable.setSpan( - EnrichedAlignmentSpan(alignment), - listStart, - listEnd, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, - ) + for (paragraphBound in paragraphsBounds) { + editable.setSpan( + EnrichedAlignmentSpan(alignment), + paragraphBound.start, + paragraphBound.endInclusive, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } } else { editable.removeSpans(paragraphStart, paragraphEnd, EnrichedAlignmentSpan::class.java) editable.setSpan(EnrichedAlignmentSpan(alignment), paragraphStart, paragraphEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) diff --git a/android/src/main/java/com/swmansion/enriched/utils/EnrichedSelection.kt b/android/src/main/java/com/swmansion/enriched/utils/EnrichedSelection.kt index 25b2accdd..3d9c4d308 100644 --- a/android/src/main/java/com/swmansion/enriched/utils/EnrichedSelection.kt +++ b/android/src/main/java/com/swmansion/enriched/utils/EnrichedSelection.kt @@ -1,6 +1,7 @@ package com.swmansion.enriched.utils import android.text.Editable +import android.text.Layout import android.text.Spannable import com.facebook.react.bridge.ReactContext import com.facebook.react.uimanager.UIManagerHelper @@ -15,7 +16,7 @@ import com.swmansion.enriched.spans.EnrichedLinkSpan import com.swmansion.enriched.spans.EnrichedMentionSpan import com.swmansion.enriched.spans.EnrichedSpans import com.swmansion.enriched.spans.interfaces.EnrichedInlineSpan -import com.swmansion.enriched.spans.interfaces.EnrichedParagraphSpan +import com.swmansion.enriched.spans.interfaces.EnrichedSpan import org.json.JSONObject class EnrichedSelection( @@ -108,7 +109,7 @@ class EnrichedSelection( handleParagraphStyleState(paragraphSelection) for ((style, config) in EnrichedSpans.listSpans) { - state.setStart(style, getListStyleStart(config.clazz, paragraphSelection)) + state.setStart(style, getCoveredParagraphStyleStart(config.clazz, paragraphSelection)) } state.emitStateChangeEvent() @@ -124,16 +125,16 @@ class EnrichedSelection( private fun handleInlineStyleState() { val spanState = view.spanState val (start, end) = getInlineSelection() - val spannable = view.text as? Spannable ?: return + val editable = view.editableText - val spans = spannable.getSpans(start, end, EnrichedInlineSpan::class.java) + val spans = editable.getSpans(start, end, EnrichedInlineSpan::class.java) if (spans.isEmpty()) { inlineStylesList.forEach { (type, _) -> spanState.setStart(type, null) } - emitLinkDetectedEvent(spannable, null, start, end) - emitMentionDetectedEvent(spannable, null, start, end) + emitLinkDetectedEvent(editable, null, start, end) + emitMentionDetectedEvent(editable, null, start, end) return } @@ -142,7 +143,7 @@ class EnrichedSelection( if (config.clazz == EnrichedColoredSpan::class.java) { spans .filterIsInstance() - .minByOrNull { spannable.getSpanStart(it) } + .minByOrNull { editable.getSpanStart(it) } } else { spans.firstOrNull { it.javaClass == config.clazz } } @@ -150,8 +151,8 @@ class EnrichedSelection( val isSingleSelection = start == end span?.let { - val spanStart = spannable.getSpanStart(it) - val spanEnd = spannable.getSpanEnd(it) + val spanStart = editable.getSpanStart(it) + val spanEnd = editable.getSpanEnd(it) val isSpanInSelection = if (isSingleSelection) start <= spanStart || end > spanEnd else start < spanStart || end > spanEnd @@ -162,11 +163,11 @@ class EnrichedSelection( when (it) { is EnrichedLinkSpan -> { - emitLinkDetectedEvent(spannable, it, spanStart, spanEnd) + emitLinkDetectedEvent(editable, it, spanStart, spanEnd) } is EnrichedMentionSpan -> { - emitMentionDetectedEvent(spannable, it, spanStart, spanEnd) + emitMentionDetectedEvent(editable, it, spanStart, spanEnd) } is EnrichedColoredSpan -> { @@ -185,45 +186,44 @@ class EnrichedSelection( return view.editableText.getParagraphBounds(currentStart, currentEnd) } - private fun handleParagraphStyleState(paragraphSelection: Pair) { - val spanState = view.spanState - val (start, end) = paragraphSelection - val spannable = view.editableText + private fun getParagraphStyleStart( + clazz: Class, + paragraphSelection: Pair, + ): Int? = + if (clazz == EnrichedAlignmentSpan::class.java) { + getAlignmentStyleStart(paragraphSelection) + } else { + getCoveredParagraphStyleStart(clazz, paragraphSelection) + } - val spans = - spannable - .getSpans(start, end, EnrichedParagraphSpan::class.java) + private fun getAlignmentStyleStart(paragraphSelection: Pair): Int? { + var alignment: Layout.Alignment? = null + val styleStart = + getCoveredParagraphStyleStart(EnrichedAlignmentSpan::class.java, paragraphSelection) { span -> + if (alignment != null && alignment != span.alignment) { + return@getCoveredParagraphStyleStart false + } - if (spans.isEmpty()) { - EnrichedSpans.paragraphSpans.keys.forEach { - spanState.setStart(it, null) + alignment = span.alignment + true } - return - } - spans - .filterIsInstance() - .firstOrNull() - ?.let { spanState.setAlignment(it.alignmentString) } - - for ((type, config) in EnrichedSpans.paragraphSpans) { - val matchedSpan = - spans.firstOrNull { span -> - span.javaClass == config.clazz && - start >= spannable.getSpanStart(span) && - end <= spannable.getSpanEnd(span) - } + view.spanState.setAlignment(alignment?.toStringName()) + return styleStart + } + + private fun handleParagraphStyleState(paragraphSelection: Pair) { + val spanState = view.spanState - spanState.setStart( - type, - matchedSpan?.let { spannable.getSpanStart(it) }, - ) + for ((style, config) in EnrichedSpans.paragraphSpans) { + spanState.setStart(style, getParagraphStyleStart(config.clazz, paragraphSelection)) } } - private fun getListStyleStart( + private fun getCoveredParagraphStyleStart( type: Class, paragraphSelection: Pair, + onCoveredSpan: (T) -> Boolean = { true }, ): Int? { val (start, end) = paragraphSelection val spannable = view.editableText @@ -231,22 +231,22 @@ class EnrichedSelection( var paragraphStart = start val paragraphs = spannable.getParagraphsBounds(start, end) - pi@ for (paragraph in paragraphs) { - val spans = spannable.getSpans(paragraphStart, paragraph.endInclusive, type) - - for (span in spans) { - val spanStart = spannable.getSpanStart(span) - val spanEnd = spannable.getSpanEnd(span) - - if (spanStart == paragraphStart && spanEnd >= paragraph.endInclusive) { - styleStart = spanStart - paragraphStart = paragraph.endInclusive + 1 - continue@pi - } + for (paragraph in paragraphs) { + val paragraphEnd = paragraph.endInclusive + val span = + spannable + .getSpans(paragraphStart, paragraphEnd, type) + .firstOrNull { + spannable.getSpanStart(it) == paragraphStart && + spannable.getSpanEnd(it) >= paragraphEnd + } ?: return null + + if (!onCoveredSpan(span)) { + return null } - styleStart = null - break + styleStart = spannable.getSpanStart(span) + paragraphStart = paragraphEnd + 1 } return styleStart 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 850ec2b01..0e1e90414 100644 --- a/android/src/main/java/com/swmansion/enriched/utils/Utils.kt +++ b/android/src/main/java/com/swmansion/enriched/utils/Utils.kt @@ -357,3 +357,14 @@ fun Spannable.getParagraphsBounds( return result } + +fun Spannable.getParagraphRanges(): List = + getParagraphsBounds(0, length) + .toMutableList() + .apply { + if (isEmpty()) { + add(0..0) + } else if (this@getParagraphRanges.last() == Strings.NEWLINE) { + add(length..length) + } + }