Add ability to clear or toggle formatting.

This commit is contained in:
Cody Henthorne
2023-06-21 13:05:46 -04:00
committed by GitHub
parent 59b2cc5f79
commit 1d0a87f52a
8 changed files with 554 additions and 116 deletions

View File

@@ -10,7 +10,10 @@ import android.text.style.StyleSpan
import android.text.style.TypefaceSpan
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList.BodyRange
import org.thoughtcrime.securesms.util.PlaceholderURLSpan
import java.lang.Integer.max
import java.lang.Integer.min
/**
* Helper for parsing and applying styles. Most notably with [BodyRangeList].
@@ -18,7 +21,7 @@ import org.thoughtcrime.securesms.util.PlaceholderURLSpan
object MessageStyler {
const val MONOSPACE = "monospace"
const val SPAN_FLAGS = Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
const val SPAN_FLAGS = Spanned.SPAN_EXCLUSIVE_INCLUSIVE
const val DRAFT_ID = "DRAFT"
const val COMPOSE_ID = "COMPOSE"
const val QUOTE_ID = "QUOTE"
@@ -57,7 +60,7 @@ object MessageStyler {
var appliedStyle = false
var hasLinks = false
var bottomButton: BodyRangeList.BodyRange.Button? = null
var bottomButton: BodyRange.Button? = null
messageRanges
.rangesList
@@ -68,17 +71,18 @@ object MessageStyler {
if (range.hasStyle()) {
val styleSpan: Any? = when (range.style) {
BodyRangeList.BodyRange.Style.BOLD -> boldStyle()
BodyRangeList.BodyRange.Style.ITALIC -> italicStyle()
BodyRangeList.BodyRange.Style.STRIKETHROUGH -> strikethroughStyle()
BodyRangeList.BodyRange.Style.MONOSPACE -> monoStyle()
BodyRangeList.BodyRange.Style.SPOILER -> {
BodyRange.Style.BOLD -> boldStyle()
BodyRange.Style.ITALIC -> italicStyle()
BodyRange.Style.STRIKETHROUGH -> strikethroughStyle()
BodyRange.Style.MONOSPACE -> monoStyle()
BodyRange.Style.SPOILER -> {
val spoiler = spoilerStyle(id, range.start, range.length)
if (hideSpoilerText) {
span.setSpan(SpoilerAnnotation.SpoilerClickableSpan(spoiler), start, end, SPAN_FLAGS)
}
spoiler
}
else -> null
}
@@ -102,9 +106,111 @@ object MessageStyler {
}
@JvmStatic
fun hasStyling(text: Spanned): Boolean {
fun toggleStyle(style: BodyRange.Style, text: Spannable, start: Int, end: Int) {
if (start >= end) {
return
}
val toggleRange = start..end
val spanAndRanges = text
.getSpans(start, end, Object::class.java)
.asSequence()
.filter { it.isStyle(style) }
.map { SpanAndRange(it, text.getSpanStart(it)..text.getSpanEnd(it)) }
.toMutableList()
val isForceAdditive = spanAndRanges.hasGapsBetween(toggleRange)
var shouldAddRange = true
val iterator = spanAndRanges.iterator()
while (iterator.hasNext()) {
val existingStyle = iterator.next()
if (toggleRange == existingStyle.range) {
text.removeSpan(existingStyle.span)
iterator.remove()
shouldAddRange = false
} else if (toggleRange.containedIn(existingStyle.range)) {
text.removeSpan(existingStyle.span)
iterator.remove()
text.setSpan(style.toStyleSpan(existingStyle.range.first, toggleRange.first), existingStyle.range.first, toggleRange.first, SPAN_FLAGS)
text.setSpan(style.toStyleSpan(toggleRange.last, existingStyle.range.last), toggleRange.last, existingStyle.range.last, SPAN_FLAGS)
shouldAddRange = false
break
} else if (toggleRange.covers(existingStyle.range) && isForceAdditive) {
text.removeSpan(existingStyle.span)
iterator.remove()
}
}
if (shouldAddRange) {
val styleSpan = style.toStyleSpan(start, end - start)
text.setSpan(styleSpan, start, end, SPAN_FLAGS)
spanAndRanges += SpanAndRange(styleSpan, start..end)
}
spanAndRanges.sortWith { (_, lhs), (_, rhs) ->
val compareStart = lhs.first.compareTo(rhs.first)
if (compareStart == 0) {
lhs.last.compareTo(rhs.last)
} else {
compareStart
}
}
var index = 0
while (index < spanAndRanges.size) {
val spanAndRange = spanAndRanges[index]
val nextSpanAndRange = if (index < spanAndRanges.lastIndex) spanAndRanges[index + 1] else null
if (spanAndRange.range.first == spanAndRange.range.last) {
text.removeSpan(spanAndRange.span)
spanAndRanges.removeAt(index)
} else if (nextSpanAndRange != null && spanAndRange.range.overlapsStart(nextSpanAndRange.range)) {
text.removeSpan(nextSpanAndRange.span)
spanAndRanges.removeAt(index + 1)
text.removeSpan(spanAndRange.span)
spanAndRanges.removeAt(index)
val mergedRange = min(nextSpanAndRange.range.first, spanAndRange.range.first)..max(nextSpanAndRange.range.last, spanAndRange.range.last)
val styleSpan = style.toStyleSpan(mergedRange.first, mergedRange.last - mergedRange.first)
text.setSpan(styleSpan, mergedRange.first, mergedRange.last, SPAN_FLAGS)
spanAndRanges.add(index, SpanAndRange(styleSpan, mergedRange))
} else {
index++
}
}
}
@JvmStatic
fun clearStyling(text: Spannable, start: Int, end: Int) {
val clearRange = start..end
text
.getSpans(start, end, Object::class.java)
.asSequence()
.filter { it.isSupportedStyle() }
.map { SpanAndRange(it, text.getSpanStart(it)..text.getSpanEnd(it)) }
.forEach { spanAndRange ->
if (clearRange.covers(spanAndRange.range)) {
text.removeSpan(spanAndRange.span)
} else if (clearRange.containedIn(spanAndRange.range)) {
text.removeSpan(spanAndRange.span)
text.setSpan(copyStyleSpan(spanAndRange.span, spanAndRange.range.first, clearRange.first), spanAndRange.range.first, clearRange.first, SPAN_FLAGS)
text.setSpan(copyStyleSpan(spanAndRange.span, clearRange.last, spanAndRange.range.last - clearRange.last), clearRange.last, spanAndRange.range.last, SPAN_FLAGS)
} else if (clearRange.overlapsStart(spanAndRange.range)) {
text.removeSpan(spanAndRange.span)
text.setSpan(copyStyleSpan(spanAndRange.span, clearRange.last, spanAndRange.range.last), clearRange.last, spanAndRange.range.last, SPAN_FLAGS)
} else if (clearRange.overlapsEnd(spanAndRange.range)) {
text.removeSpan(spanAndRange.span)
text.setSpan(copyStyleSpan(spanAndRange.span, spanAndRange.range.first, clearRange.first), spanAndRange.range.first, clearRange.first, SPAN_FLAGS)
}
}
}
@JvmStatic
@JvmOverloads
fun hasStyling(text: Spanned, start: Int = 0, end: Int = text.length): Boolean {
return text
.getSpans(0, text.length, Object::class.java)
.getSpans(start, end, Object::class.java)
.any { s -> s.isSupportedStyle() && text.getSpanEnd(s) - text.getSpanStart(s) > 0 }
}
@@ -118,19 +224,19 @@ object MessageStyler {
val spanStart = text.getSpanStart(span)
val spanLength = text.getSpanEnd(span) - spanStart
val style: BodyRangeList.BodyRange.Style? = when (span) {
val style: BodyRange.Style? = when (span) {
is StyleSpan -> {
when (span.style) {
Typeface.BOLD -> BodyRangeList.BodyRange.Style.BOLD
Typeface.ITALIC -> BodyRangeList.BodyRange.Style.ITALIC
Typeface.BOLD -> BodyRange.Style.BOLD
Typeface.ITALIC -> BodyRange.Style.ITALIC
else -> null
}
}
is StrikethroughSpan -> BodyRangeList.BodyRange.Style.STRIKETHROUGH
is TypefaceSpan -> BodyRangeList.BodyRange.Style.MONOSPACE
is StrikethroughSpan -> BodyRange.Style.STRIKETHROUGH
is TypefaceSpan -> BodyRange.Style.MONOSPACE
is Annotation -> {
if (SpoilerAnnotation.isSpoilerAnnotation(span)) {
BodyRangeList.BodyRange.Style.SPOILER
BodyRange.Style.SPOILER
} else {
null
}
@@ -139,7 +245,7 @@ object MessageStyler {
}
if (spanLength > 0 && style != null) {
BodyRangeList.BodyRange.newBuilder().setStart(spanStart).setLength(spanLength).setStyle(style).build()
BodyRange.newBuilder().setStart(spanStart).setLength(spanLength).setStyle(style).build()
} else {
null
}
@@ -157,22 +263,72 @@ object MessageStyler {
fun Any.isSupportedStyle(): Boolean {
return when (this) {
is CharacterStyle -> isSupportedCharacterStyle(this)
is CharacterStyle -> isSupportedCharacterStyle()
is Annotation -> SpoilerAnnotation.isSpoilerAnnotation(this)
else -> false
}
}
private fun isSupportedCharacterStyle(style: CharacterStyle): Boolean {
return when (style) {
is StyleSpan -> style.style == Typeface.ITALIC || style.style == Typeface.BOLD
private fun Any.isSupportedCharacterStyle(): Boolean {
return when (this) {
is StyleSpan -> style == Typeface.ITALIC || style == Typeface.BOLD
is StrikethroughSpan -> true
is TypefaceSpan -> style.family == MONOSPACE
is TypefaceSpan -> family == MONOSPACE
else -> false
}
}
data class Result(val hasStyleLinks: Boolean = false, val bottomButton: BodyRangeList.BodyRange.Button? = null) {
private fun Any.isStyle(style: BodyRange.Style): Boolean {
return when (this) {
is CharacterStyle -> isCharacterStyle(style)
is Annotation -> SpoilerAnnotation.isSpoilerAnnotation(this) && style == BodyRange.Style.SPOILER
else -> false
}
}
private fun CharacterStyle.isCharacterStyle(style: BodyRange.Style): Boolean {
return when (this) {
is StyleSpan -> (this.style == Typeface.ITALIC && style == BodyRange.Style.ITALIC) || (this.style == Typeface.BOLD && style == BodyRange.Style.BOLD)
is StrikethroughSpan -> style == BodyRange.Style.STRIKETHROUGH
is TypefaceSpan -> this.family == MONOSPACE && style == BodyRange.Style.MONOSPACE
else -> false
}
}
private fun copyStyleSpan(span: Any, start: Int, length: Int): Any? {
return when (span) {
is StyleSpan -> {
when (span.style) {
Typeface.BOLD -> boldStyle()
Typeface.ITALIC -> italicStyle()
else -> null
}
}
is StrikethroughSpan -> strikethroughStyle()
is TypefaceSpan -> monoStyle()
is Annotation -> {
if (SpoilerAnnotation.isSpoilerAnnotation(span)) {
spoilerStyle(COMPOSE_ID, start, length)
} else {
null
}
}
else -> throw IllegalArgumentException("Provided text contains unsupported spans")
}
}
private fun BodyRange.Style.toStyleSpan(start: Int, length: Int): Any {
return when (this) {
BodyRange.Style.BOLD -> boldStyle()
BodyRange.Style.ITALIC -> italicStyle()
BodyRange.Style.SPOILER -> spoilerStyle(COMPOSE_ID, start, length)
BodyRange.Style.STRIKETHROUGH -> strikethroughStyle()
BodyRange.Style.MONOSPACE -> monoStyle()
else -> throw IllegalArgumentException()
}
}
data class Result(val hasStyleLinks: Boolean = false, val bottomButton: BodyRange.Button? = null) {
companion object {
@JvmStatic
val NO_STYLE = Result()
@@ -181,4 +337,49 @@ object MessageStyler {
fun none(): Result = NO_STYLE
}
}
private data class SpanAndRange(val span: Any, val range: IntRange)
private fun IntRange.overlapsStart(other: IntRange): Boolean {
return this.first <= other.first && this.last > other.first
}
private fun IntRange.overlapsEnd(other: IntRange): Boolean {
return this.first < other.last && this.last >= other.last
}
private fun IntRange.containedIn(other: IntRange): Boolean {
return this.first >= other.first && this.last <= other.last
}
private fun IntRange.covers(other: IntRange): Boolean {
return this.first <= other.first && this.last >= other.last
}
/**
* Checks if a sorted, non-overlapping list of ranges does not cover the provided [toggleRange] completely. That is,
* there is a value that does not exists in any of the ranges in the list but does exist in [toggleRange]
*
* For example, a list of ranges [[0..5], [7..10]] and a toggle range of [0..10] has a gap for the value 6. If
* the list was [[0..5], [6..8], [9..10]] there would be no gaps.
*/
private fun List<SpanAndRange>.hasGapsBetween(toggleRange: IntRange): Boolean {
val startingRangeIndex = indexOfFirst { it.range.first <= toggleRange.first && it.range.last >= toggleRange.first }
if (startingRangeIndex == -1) {
return true
}
val endingRangeIndex = indexOfFirst { it.range.first <= toggleRange.last && it.range.last >= toggleRange.last }
if (endingRangeIndex == -1) {
return true
}
for (i in startingRangeIndex until endingRangeIndex) {
if (this[i].range.last != this[i + 1].range.first) {
return true
}
}
return false
}
}