Collapse details tags

Change-Id: I545958f06e412819c0f1cdb9037abc69c1442808
This commit is contained in:
SpiritCroc 2022-07-20 18:19:06 +02:00
parent 47fb27c822
commit 75f486a0f5
3 changed files with 261 additions and 0 deletions

View File

@ -0,0 +1,58 @@
package im.vector.app.features.html
import android.text.Spanned
import io.noties.markwon.MarkwonVisitor
import io.noties.markwon.html.HtmlTag
import io.noties.markwon.html.MarkwonHtmlRenderer
import io.noties.markwon.html.TagHandler
import java.util.Collections
/**
* https://github.com/noties/Markwon/issues/181#issuecomment-571296484
*/
class DetailsTagHandler: TagHandler() {
override fun handle(visitor: MarkwonVisitor, renderer: MarkwonHtmlRenderer, tag: HtmlTag) {
var summaryEnd = -1
var summaryStart = -1
for (child in tag.asBlock.children()) {
if (!child.isClosed) {
continue
}
if ("summary" == child.name()) {
summaryStart = child.start()
summaryEnd = child.end()
}
val tagHandler = renderer.tagHandler(child.name())
if (tagHandler != null) {
tagHandler.handle(visitor, renderer, child)
} else if (child.isBlock) {
visitChildren(visitor, renderer, child.asBlock)
}
}
if (summaryEnd > -1 && summaryStart > -1) {
val summary = visitor.builder().subSequence(summaryStart, summaryEnd)
val summarySpan = DetailsSummarySpan(summary)
visitor.builder().setSpan(summarySpan, summaryStart, summaryEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
visitor.builder().setSpan(DetailsParsingSpan(summarySpan), tag.start(), tag.end(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
override fun supportedTags(): Collection<String> {
return Collections.singleton("details")
}
}
data class DetailsSummarySpan(val text: CharSequence)
enum class DetailsSpanState { DORMANT_CLOSE, DORMANT_OPEN, CLOSED, OPENED }
data class DetailsParsingSpan(
val summary: DetailsSummarySpan,
var state: DetailsSpanState = DetailsSpanState.CLOSED
)

View File

@ -0,0 +1,201 @@
package im.vector.app.features.html
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.TextPaint
import android.text.style.ClickableSpan
import android.view.View
import android.widget.TextView
import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.image.AsyncDrawableScheduler
/**
* https://github.com/noties/Markwon/issues/181#issuecomment-571296484
*/
class DetailsTagPostProcessor constructor(
private val eventHtmlRenderer: EventHtmlRenderer
) : AbstractMarkwonPlugin() {
override fun afterSetText(textView: TextView) {
postProcessDetails(SpannableStringBuilder(textView.text), textView, true)
}
/**
* Post-process details statements in the text. They act like `<spoiler>` or `<cut>` tag in some websites
* @param spanned text to be modified to cut out details tags and insert replacements instead of them
* @param view resulting text view to accept the modified spanned string
* @param onBind whether we call this externally or internally
*/
private fun postProcessDetails(spanned: SpannableStringBuilder, view: TextView, onBind: Boolean) {
val spans = spanned.getSpans(0, spanned.length, DetailsParsingSpan::class.java)
spans.sortBy { spanned.getSpanStart(it) }
// if we have no details, proceed as usual (single text-view)
if (spans.isNullOrEmpty()) {
// no details
return
}
for (span in spans) {
val startIdx = spanned.getSpanStart(span)
val endIdx = spanned.getSpanEnd(span)
val summaryStartIdx = spanned.getSpanStart(span.summary)
val summaryEndIdx = spanned.getSpanEnd(span.summary)
// details tags can be nested, skip them if they were hidden
if (startIdx == -1 || endIdx == -1) {
continue
}
// On re-bind, reset span state
if (onBind) {
span.state = when (span.state) {
DetailsSpanState.DORMANT_CLOSE -> DetailsSpanState.CLOSED
DetailsSpanState.DORMANT_OPEN -> DetailsSpanState.OPENED
else -> span.state
}
}
// replace text inside spoiler tag with just spoiler summary that is clickable
val summaryText = when (span.state) {
// Make sure to not convert the summary to string by accident, to not lose existing spans (like clickable links)
DetailsSpanState.CLOSED -> {
SpannableStringBuilder(span.summary.text).apply {
insert(0, "")
if (endIdx < spanned.length-1) {
append("\n\n")
}
}
}
DetailsSpanState.OPENED -> {
SpannableStringBuilder(span.summary.text).apply {
insert(0, "")
if (endIdx < spanned.length-1) {
append("\n\n")
}
}
}
else -> ""
}
when (span.state) {
DetailsSpanState.CLOSED -> {
span.state = DetailsSpanState.DORMANT_CLOSE
spanned.removeSpan(span.summary) // will be added later
// spoiler tag must be closed, all the content under it must be hidden
// retrieve content under spoiler tag and hide it
// if it is shown, it should be put in blockquote to distinguish it from text before and after
val innerSpanned = spanned.subSequence(summaryEndIdx, endIdx) as SpannableStringBuilder
spanned.replace(summaryStartIdx, endIdx, summaryText)
spanned.setSpan(span.summary, startIdx, startIdx + summaryText.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
// expand text on click
val wrapper = object : ClickableSpan() {
// replace wrappers with real previous spans on click
override fun onClick(widget: View) {
span.state = DetailsSpanState.OPENED
val start = spanned.getSpanStart(this)
val end = spanned.getSpanEnd(this)
spanned.removeSpan(this)
spanned.insert(end, innerSpanned)
// make details span cover all expanded text
spanned.removeSpan(span)
spanned.setSpan(span, start, end + innerSpanned.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
// edge-case: if the span around this text is now too short, expand it as well
spanned.getSpans(end, end, Any::class.java)
.filter { spanned.getSpanEnd(it) == end }
.forEach {
if (it is DetailsSummarySpan) {
// don't expand summaries, they are meant to end there
return@forEach
}
val bqStart = spanned.getSpanStart(it)
spanned.removeSpan(it)
spanned.setSpan(it, bqStart, end + innerSpanned.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
postProcessAndSetText(spanned, view)
AsyncDrawableScheduler.schedule(view)
}
override fun updateDrawState(ds: TextPaint) {
// Override without setting any color to preserve original colors
//ds.color = ThemeUtils.getColor(view.context, R.attr.vctr_content_primary)
}
}
spanned.setSpan(wrapper, startIdx, startIdx + summaryText.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
view.text = spanned
}
DetailsSpanState.OPENED -> {
span.state = DetailsSpanState.DORMANT_OPEN
// put the hidden text into blockquote if needed
/*
var bq = spanned.getSpans(summaryEndIdx, endIdx, BlockQuoteSpan::class.java)
.firstOrNull { spanned.getSpanStart(it) == summaryEndIdx && spanned.getSpanEnd(it) == endIdx }
if (bq == null) {
bq = BlockQuoteSpan(eventHtmlRenderer.theme)
spanned.setSpan(bq, summaryEndIdx, endIdx, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
*/
// content under spoiler tag is shown, but should be hidden again on click
// change summary text to opened variant
spanned.replace(summaryStartIdx, summaryEndIdx, summaryText)
spanned.setSpan(span.summary, startIdx, startIdx + summaryText.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
val wrapper = object : ClickableSpan() {
// hide text again on click
override fun onClick(widget: View) {
span.state = DetailsSpanState.CLOSED
spanned.removeSpan(this)
postProcessAndSetText(spanned, view)
}
override fun updateDrawState(ds: TextPaint) {
// Override without setting any color to preserve original colors
//ds.color = ThemeUtils.getColor(view.context, R.attr.vctr_content_primary)
}
}
spanned.setSpan(wrapper, startIdx, startIdx + summaryText.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
view.text = spanned
}
DetailsSpanState.DORMANT_CLOSE,
DetailsSpanState.DORMANT_OPEN -> {
// this state is present so that details spans that were already processed won't be processed again
// nothing should be done
}
}
}
}
private fun postProcessAndSetText(spanned: SpannableStringBuilder, view: TextView) {
view.text = spanned
eventHtmlRenderer.plugins.forEach { plugin ->
if (plugin is DetailsTagPostProcessor) {
// Keep dormant state by not using the external interface that resets it
plugin.postProcessDetails(spanned, view, false)
} else {
plugin.afterSetText(view)
}
}
}
}

View File

@ -105,6 +105,7 @@ class EventHtmlRenderer @Inject constructor(
}
}
},
DetailsTagPostProcessor(this),
GlideImagesPlugin.create(object: GlideImagesPlugin.GlideStore {
override fun load(drawable: AsyncDrawable): RequestBuilder<Drawable> {
val url = drawable.destination
@ -235,6 +236,7 @@ class MatrixHtmlPluginConfigure @Inject constructor(private val colorProvider: C
override fun configureHtml(plugin: HtmlPlugin) {
plugin
.addHandler(DetailsTagHandler())
.addHandler(ListHandlerWithInitialStart())
.addHandler(FontTagHandler())
.addHandler(ParagraphHandler(DimensionConverter(resources)))