From 75f486a0f5bb42b5c78cf3a6f26e98cea14521e4 Mon Sep 17 00:00:00 2001 From: SpiritCroc <dev@spiritcroc.de> Date: Wed, 20 Jul 2022 18:19:06 +0200 Subject: [PATCH] Collapse details tags Change-Id: I545958f06e412819c0f1cdb9037abc69c1442808 --- .../app/features/html/DetailsTagHandler.kt | 58 +++++ .../features/html/DetailsTagPostProcessor.kt | 201 ++++++++++++++++++ .../app/features/html/EventHtmlRenderer.kt | 2 + 3 files changed, 261 insertions(+) create mode 100644 vector/src/main/java/im/vector/app/features/html/DetailsTagHandler.kt create mode 100644 vector/src/main/java/im/vector/app/features/html/DetailsTagPostProcessor.kt diff --git a/vector/src/main/java/im/vector/app/features/html/DetailsTagHandler.kt b/vector/src/main/java/im/vector/app/features/html/DetailsTagHandler.kt new file mode 100644 index 0000000000..cf656ef433 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/html/DetailsTagHandler.kt @@ -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 +) diff --git a/vector/src/main/java/im/vector/app/features/html/DetailsTagPostProcessor.kt b/vector/src/main/java/im/vector/app/features/html/DetailsTagPostProcessor.kt new file mode 100644 index 0000000000..a264e55204 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/html/DetailsTagPostProcessor.kt @@ -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) + } + } + } + +} diff --git a/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt b/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt index 70687dfe8b..b65d0059bc 100644 --- a/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt +++ b/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt @@ -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)))