question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

[Feature request] Support <details> tag

See original GitHub issue

Reddit, GitHub and GitLab all support <details> tag which looks like spoiler tag:

Click me! That's how spoiler tag works, but the little surprise is that
It can actually be nested

Would be very helpful if we could implement this as well. I tried doing that using custom HTML plugin as described in the doc, but the problem is it doesn’t support proper nesting, you lose start and end positions of the tag as you go from child details to parent ones as this is actually text-changing span.

Implementing the actual span in TextView is as simple as:

        val innerText = spanned.subSequence(innerRange.first, innerRange.first + innerText.length) // contains all spans there

        spanned.replace(outerRange.first, outerRange.last + 1, summaryText) // replace it just with spoiler text

       // replace inner text with clickable span
        val wrapper = object : ClickableSpan() {

            override fun onClick(widget: View) {
                // replace wrappers with real previous spans

                val start = spanned.getSpanStart(this)
                val end = spanned.getSpanEnd(this)

                spanned.removeSpan(this)
                spanned.replace(start, end, innerText)

                view.text = spanned
                AsyncDrawableScheduler.schedule(view)
            }
        }
        spanned.setSpan(wrapper, outerRange.first, outerRange.first + summaryText.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)

Issue Analytics

  • State:closed
  • Created 4 years ago
  • Comments:7 (2 by maintainers)

github_iconTop GitHub Comments

4reactions
Kaned1ascommented, Jan 6, 2020

@wax911 @noties

Here it is. It’s heavily modified version of the previous one. It also puts hidden text inside the blockquote to separate it from other content.

Tag parser

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, CLOSED, OPENED }

data class DetailsParsingSpan(
    val summary: DetailsSummarySpan,
    var state: DetailsSpanState = DetailsSpanState.CLOSED
)
Post-processor
/**
 * 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
 */
fun postProcessDetails(spanned: SpannableStringBuilder, view: TextView) {
    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
        }

        // replace text inside spoiler tag with just spoiler summary that is clickable
        val summaryText = when (span.state) {
            DetailsSpanState.CLOSED -> "${span.summary.text} ▼\n\n"
            DetailsSpanState.OPENED  -> "${span.summary.text} ▲\n\n"
            else -> ""
        }

        when (span.state) {

            DetailsSpanState.CLOSED -> {
                span.state = DetailsSpanState.DORMANT
                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)
                            }

                        postProcessMore(spanned, view)

                        view.text = spanned
                        AsyncDrawableScheduler.schedule(view)
                    }

                    override fun updateDrawState(ds: TextPaint) {
                        ds.color = ds.linkColor
                    }
                }
                spanned.setSpan(wrapper, startIdx, startIdx + summaryText.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
            }

            DetailsSpanState.OPENED -> {
                span.state = DetailsSpanState.DORMANT

                // 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(mdThemeFrom(view))
                    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)

                        postProcessMore(spanned, view)

                        view.text = spanned
                    }

                    override fun updateDrawState(ds: TextPaint) {
                        ds.color = ds.linkColor
                    }
                }
                spanned.setSpan(wrapper, startIdx, startIdx + summaryText.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
            }

            DetailsSpanState.DORMANT -> {
                // this state is present so that details spans that were already processed won't be processed again
                // nothing should be done
            }
        }
    }
}
2reactions
notiescommented, Dec 23, 2019

Hello @Adonai !

This is an extremely hard feature to add seamlessly on the library level. And the reason is why, is exactly as you said it, during rendering a visitor cannot modify content, otherwise all subsequent spans will lose positions. So, we have 2 options:

  1. extract <details> tag during parsing
  2. post-process rendered result
there is always another option...

3. some regex solution 😄

So — #1 although logically would be the perfect place to implement won’t help us. Because commonmark specification is created with interoperability with HTML in mind, so any valid HTML is also valid commonmark/markdown. This comes with HTML being treated as first-class citizen and placed in parsed markdown according to the commonmark parsing strategy and not actual HTML content (there is no entry <details> anywhere in parsed markdown document). This complicates things for us as we do not have a browser engine at our disposal. So, we will have to rule out the parsing stage for now and try to do something with rendered result instead.

BTW, why would having a <details> node in parsed markdown would be a good thing? Because it would allow us seamlessly display that node in a different TextView widget (or any other actually). So, we could split parsed markdown and display some nodes in different widgets which could give us flexibility. For example, this approach is used in markwon-recycler-table module to display markdown tables in TableLayout instead of a TextView. But in the end we don’t have to use RecyclerView and can inflate our layout programmatically which can be OK for relatively small content.

I’m mentioning different widget because it seems that in order to implement a proper <details> tag handler we must use a different from other content widget. As we cannot freely append/remove text without messing with other spans.

At first I considered using a ReplacementSpan which theoretically could help us out - simply track expanded state and draw a summary or (summary + content) based on it. But it implies that all the text must be drawn directly on a Canvas. Markdown tables, for example, that are displayed in a TextView are actually doing it this way and have some limitations - they cannot display images and cannot be acted upon. So, in case of an image or a nested <details> this way we won’t be able to achieve anything.

So, what are other options? We could still proceed as usual:

  • register HTML <details> tag handler which sets some DetailsHtmlSpan
  • parse markdown via markwon.toMarkdown(String)
  • inspect parsed markdown for presence of DetailsHtmlSpan
    • if we have found none -> apply markdown to a TextView and forget about anything
    • ???

There is new sample screen with solution I’ve hacked through. It’s not perfect, but I hope it does show a possible way of action - sample

So, after we have found DetailsHtmlSpan we split our rendered markdown by biggest DetailsHtmlSpan and additionally look for nested <details> tags. So our rendered markdown in a form similar to:

[
  Spanned{start:0, end:10},
  Details{
    start: 10, end: 30,
    children: [
       Spanned{start:20, end:30}
    ]
  }
  Spanned{start:30, end: 45}
]

Based on that structure we will create 3 TextViews - 1 for each root element. A <details> will have a dedicated TextView for itself and all its children (both Spanned and nested <details>). It will also have additional spans to handle clicks and draw an expand/collapse indicator.

Please note that this is just a proof-of-concept and I do not consider it to be production ready. Still it is a valuable point of reference as it will cover if this feature worth the cost of its implementation. From my point of view this feature is an overkill to be added to the project, but if someone want to tinker with it, polish or improve that someone is really welcome 🙌

Read more comments on GitHub >

github_iconTop Results From Across the Web

support MD equivalent of <details> tag · Issue #445 ... - GitHub
The easiest way to have a lot of those on one page is to use the details tag, which allows for automatic hide/show...
Read more >
How To Manage Feature Requests [Template included]
This guide will teach you everything about feature requests – how to process them, manage them, respond to them, prioritize them – so...
Read more >
The Details disclosure element - HTML - MDN Web Docs
The HTML element creates a disclosure widget in which information is visible only when the widget is toggled into an "open" state.
Read more >
Intercom Tags vs. Savio for Tracking Feature Requests
Can I just track feature requests using Intercom conversation tags?” Yes, but it's not ideal. Here's how to do it, together with a...
Read more >
Support for Content Tagging | Feature requests - GatherContent
Currently I create a tab on EVERY content template that has a list of our tags and categories. Keeping this updated is a...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found