From 767e75b7989768d0349fc8f5ea6384505a7ea795 Mon Sep 17 00:00:00 2001 From: Bazsalanszky Date: Fri, 9 Aug 2024 12:45:15 +0200 Subject: [PATCH] Better markdown handling This commit fixes some of the issues with markdown rendering: - Switch to Textview instead of RecyclerView to render Markdown - Import Spoiler renderer from Jebora - Import Script renderer from Jebora - Clean out markwon plugins that were specific to reddit Closes #130 #172 #217 and #273 --- app/build.gradle | 1 + .../PostDetailRecyclerViewAdapter.java | 15 +- .../markdown/MarkdownUtils.java | 47 ++---- .../markdown/MarkwonSpoilerPlugin.kt | 158 ++++++++++++++++++ .../markdown/ScriptRewriteSupportPlugin.kt | 26 +++ .../res/layout/item_post_detail_gallery.xml | 6 +- ...tem_post_detail_image_and_gif_autoplay.xml | 6 +- .../main/res/layout/item_post_detail_link.xml | 6 +- .../layout/item_post_detail_no_preview.xml | 8 +- .../main/res/layout/item_post_detail_text.xml | 8 +- ...item_post_detail_video_and_gif_preview.xml | 6 +- .../item_post_detail_video_autoplay.xml | 6 +- ...etail_video_autoplay_legacy_controller.xml | 6 +- 13 files changed, 235 insertions(+), 64 deletions(-) create mode 100644 app/src/main/kotlin/eu/toldi/infinityforlemmy/markdown/MarkwonSpoilerPlugin.kt create mode 100644 app/src/main/kotlin/eu/toldi/infinityforlemmy/markdown/ScriptRewriteSupportPlugin.kt diff --git a/app/build.gradle b/app/build.gradle index c171853b..35c5485f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -245,6 +245,7 @@ dependencies { implementation "io.noties.markwon:simple-ext:$markwonVersion" implementation "io.noties.markwon:inline-parser:$markwonVersion" implementation "io.noties.markwon:image-glide:$markwonVersion" + implementation "io.noties.markwon:html:$markwonVersion" implementation 'com.atlassian.commonmark:commonmark-ext-gfm-tables:0.14.0' implementation 'me.saket:better-link-movement-method:2.2.0' diff --git a/app/src/main/java/eu/toldi/infinityforlemmy/adapters/PostDetailRecyclerViewAdapter.java b/app/src/main/java/eu/toldi/infinityforlemmy/adapters/PostDetailRecyclerViewAdapter.java index 628a4b7e..5fb655ed 100644 --- a/app/src/main/java/eu/toldi/infinityforlemmy/adapters/PostDetailRecyclerViewAdapter.java +++ b/app/src/main/java/eu/toldi/infinityforlemmy/adapters/PostDetailRecyclerViewAdapter.java @@ -681,10 +681,7 @@ public class PostDetailRecyclerViewAdapter extends RecyclerView.Adapter { if (mPost.isArchived()) { @@ -1827,7 +1824,7 @@ public class PostDetailRecyclerViewAdapter extends RecyclerView.Adapter { - plugin.excludeInlineProcessor(HtmlInlineProcessor.class); - })) .usePlugin(miscPlugin) - .usePlugin(SuperscriptPlugin.create()) - .usePlugin(SpoilerParserPlugin.create(markdownColor, spoilerBackgroundColor)) - .usePlugin(RedditHeadingPlugin.create()) + .usePlugin(new ScriptRewriteSupportPlugin()) + .usePlugin(new MarkwonSpoilerPlugin(true)) + .usePlugin(HtmlPlugin.create()) .usePlugin(StrikethroughPlugin.create()) - .usePlugin(MovementMethodPlugin.create(new SpoilerAwareMovementMethod() - .setOnLinkLongClickListener(onLinkLongClickListener))) + .usePlugin(TablePlugin.create(context)) .usePlugin(LinkifyPlugin.create(Linkify.WEB_URLS)) - .usePlugin(TableEntryPlugin.create(context)) .usePlugin(new MarkwonLemmyLinkPlugin()) .build(); } else { result = Markwon.builder(context) .usePlugin(GlideImagesPlugin.create(new GlideMarkdownLoader(mGlide))) - .usePlugin(MarkwonInlineParserPlugin.create(plugin -> { - plugin.excludeInlineProcessor(HtmlInlineProcessor.class); - })) .usePlugin(miscPlugin) - .usePlugin(SuperscriptPlugin.create()) - .usePlugin(SpoilerParserPlugin.create(markdownColor, spoilerBackgroundColor)) - .usePlugin(RedditHeadingPlugin.create()) + .usePlugin(new ScriptRewriteSupportPlugin()) + .usePlugin(new MarkwonSpoilerPlugin(true)) + .usePlugin(HtmlPlugin.create()) .usePlugin(StrikethroughPlugin.create()) .usePlugin(MovementMethodPlugin.create(new SpoilerAwareMovementMethod() .setOnLinkLongClickListener(onLinkLongClickListener))) .usePlugin(LinkifyPlugin.create(Linkify.WEB_URLS)) - .usePlugin(TableEntryPlugin.create(context)) + .usePlugin(TablePlugin.create(context)) .usePlugin(ClickableGlideImagesPlugin.create(context)) .usePlugin(new MarkwonLemmyLinkPlugin()) .build(); @@ -87,32 +81,27 @@ public class MarkdownUtils { Markwon result; if (dataSaverEnabled) { result = Markwon.builder(context) - .usePlugin(MarkwonInlineParserPlugin.create(plugin -> { - plugin.excludeInlineProcessor(HtmlInlineProcessor.class); - })) .usePlugin(miscPlugin) - .usePlugin(SuperscriptPlugin.create()) - .usePlugin(RedditHeadingPlugin.create()) + .usePlugin(new ScriptRewriteSupportPlugin()) + .usePlugin(new MarkwonSpoilerPlugin(true)) + .usePlugin(HtmlPlugin.create()) .usePlugin(StrikethroughPlugin.create()) .usePlugin(MovementMethodPlugin.create(new SpoilerAwareMovementMethod() .setOnLinkLongClickListener(onLinkLongClickListener))) .usePlugin(LinkifyPlugin.create(Linkify.WEB_URLS)) - .usePlugin(TableEntryPlugin.create(context)) + .usePlugin(TablePlugin.create(context)) .usePlugin(new MarkwonLemmyLinkPlugin()) .build(); } else { result = Markwon.builder(context) - .usePlugin(MarkwonInlineParserPlugin.create(plugin -> { - plugin.excludeInlineProcessor(HtmlInlineProcessor.class); - })) .usePlugin(miscPlugin) - .usePlugin(SuperscriptPlugin.create()) - .usePlugin(RedditHeadingPlugin.create()) - .usePlugin(StrikethroughPlugin.create()) + .usePlugin(new ScriptRewriteSupportPlugin()) + .usePlugin(new MarkwonSpoilerPlugin(true)) + .usePlugin(HtmlPlugin.create()) .usePlugin(MovementMethodPlugin.create(new SpoilerAwareMovementMethod() .setOnLinkLongClickListener(onLinkLongClickListener))) .usePlugin(LinkifyPlugin.create(Linkify.WEB_URLS)) - .usePlugin(TableEntryPlugin.create(context)) + .usePlugin(TablePlugin.create(context)) .usePlugin(GlideImagesPlugin.create(context.getApplicationContext())) .usePlugin(new MarkwonLemmyLinkPlugin()) .build(); diff --git a/app/src/main/kotlin/eu/toldi/infinityforlemmy/markdown/MarkwonSpoilerPlugin.kt b/app/src/main/kotlin/eu/toldi/infinityforlemmy/markdown/MarkwonSpoilerPlugin.kt new file mode 100644 index 00000000..cbf6b938 --- /dev/null +++ b/app/src/main/kotlin/eu/toldi/infinityforlemmy/markdown/MarkwonSpoilerPlugin.kt @@ -0,0 +1,158 @@ +package eu.toldi.infinityforlemmy.markdown + +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.TextPaint +import android.text.style.ClickableSpan +import android.util.Log +import android.view.View +import android.widget.TextView +import io.noties.markwon.AbstractMarkwonPlugin +import io.noties.markwon.MarkwonPlugin +import io.noties.markwon.MarkwonVisitor +import io.noties.markwon.core.CorePlugin +import io.noties.markwon.image.AsyncDrawableScheduler + +// Source copied from https://github.com/LemmyNet/jerboa/blob/main/app/src/main/java/com/jerboa/util/markwon/MarkwonSpoilerPlugin.kt + +data class SpoilerTitleSpan( + val title: CharSequence, +) + +class SpoilerCloseSpan + +class MarkwonSpoilerPlugin( + val enableInteraction: Boolean, +) : AbstractMarkwonPlugin() { + override fun configure(registry: MarkwonPlugin.Registry) { + registry.require(CorePlugin::class.java) { + it.addOnTextAddedListener( + SpoilerTextAddedListener(), + ) + } + } + + private class SpoilerTextAddedListener : CorePlugin.OnTextAddedListener { + override fun onTextAdded( + visitor: MarkwonVisitor, + text: String, + start: Int, + ) { + val spoilerTitleRegex = Regex("(:::\\s+spoiler\\s+)(.*)") + // Find all spoiler "start" lines + val spoilerTitles = spoilerTitleRegex.findAll(text) + + for (match in spoilerTitles) { + val spoilerTitle = match.groups[2]!!.value + visitor.builder().setSpan( + SpoilerTitleSpan(spoilerTitle), + start, + start + match.groups[2]!!.range.last, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + + val spoilerCloseRegex = Regex("^(?!.*spoiler).*:::") + // Find all spoiler "end" lines + val spoilerCloses = spoilerCloseRegex.findAll(text) + for (match in spoilerCloses) { + visitor + .builder() + .setSpan(SpoilerCloseSpan(), start, start + 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + } + + override fun afterSetText(textView: TextView) { + try { + val spanned = SpannableStringBuilder(textView.text) + val spoilerTitleSpans = + spanned.getSpans(0, spanned.length, SpoilerTitleSpan::class.java) + val spoilerCloseSpans = + spanned.getSpans(0, spanned.length, SpoilerCloseSpan::class.java) + + spoilerTitleSpans.sortBy { spanned.getSpanStart(it) } + spoilerCloseSpans.sortBy { spanned.getSpanStart(it) } + + spoilerTitleSpans.forEachIndexed { index, spoilerTitleSpan -> + val spoilerStart = spanned.getSpanStart(spoilerTitleSpan) + + var spoilerEnd = spanned.length + if (index < spoilerCloseSpans.size) { + val spoilerCloseSpan = spoilerCloseSpans[index] + spoilerEnd = spanned.getSpanEnd(spoilerCloseSpan) + } + + var open = false + // The space at the end is necessary for the lengths to be the same + // This reduces complexity as else it would need complex logic to determine the replacement length + val getSpoilerTitle = { openParam: Boolean -> + if (openParam) "▼ ${spoilerTitleSpan.title}\n" else "▶ ${spoilerTitleSpan.title}\u200B" + } + + val spoilerTitle = getSpoilerTitle(false) + + val spoilerContent = + spanned.subSequence( + spanned.getSpanEnd(spoilerTitleSpan) + 1, + spoilerEnd - 3, + ) as SpannableStringBuilder + + // Remove spoiler content from span + spanned.replace(spoilerStart, spoilerEnd, spoilerTitle) + // Set span block title + spanned.setSpan( + spoilerTitle, + spoilerStart, + spoilerStart + spoilerTitle.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + + val wrapper = + object : ClickableSpan() { + override fun onClick(p0: View) { + if (enableInteraction) { + textView.cancelPendingInputEvents() + open = !open + + val spoilerStartCurrent = spanned.getSpanStart(spoilerTitle) + + spanned.replace( + spoilerStartCurrent, + spoilerStartCurrent + spoilerTitle.length, + getSpoilerTitle(open), + ) + if (open) { + spanned.insert(spoilerStartCurrent + spoilerTitle.length, spoilerContent) + } else { + spanned.replace( + spoilerStartCurrent + spoilerTitle.length, + spoilerStartCurrent + spoilerTitle.length + spoilerContent.length, + "", + ) + } + + textView.text = spanned + AsyncDrawableScheduler.schedule(textView) + } + } + + override fun updateDrawState(ds: TextPaint) { + } + } + + // Set spoiler block type as ClickableSpan + spanned.setSpan( + wrapper, + spoilerStart, + spoilerStart + spoilerTitle.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + + textView.text = spanned + } + } catch (e: Exception) { + Log.w("jerboa", "Failed to parse spoiler tag. Format incorrect") + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/eu/toldi/infinityforlemmy/markdown/ScriptRewriteSupportPlugin.kt b/app/src/main/kotlin/eu/toldi/infinityforlemmy/markdown/ScriptRewriteSupportPlugin.kt new file mode 100644 index 00000000..82eaa9d6 --- /dev/null +++ b/app/src/main/kotlin/eu/toldi/infinityforlemmy/markdown/ScriptRewriteSupportPlugin.kt @@ -0,0 +1,26 @@ +package eu.toldi.infinityforlemmy.markdown + +import io.noties.markwon.AbstractMarkwonPlugin + +// Source copied from https://github.com/LemmyNet/jerboa/blob/main/app/src/main/java/com/jerboa/util/markwon/ScriptRewriteSupportPlugin.kt + +class ScriptRewriteSupportPlugin : AbstractMarkwonPlugin() { + override fun processMarkdown(markdown: String): String = + super.processMarkdown( + if (markdown.contains("^") || markdown.contains("~")) { + rewriteLemmyScriptToMarkwonScript(markdown) + } else { // Fast path: if there are no markdown characters, we don't need to do anything + markdown + }, + ) + + companion object { + val SUPERSCRIPT_RGX = Regex("""\^([^\n^]+)\^""") + val SUBSCRIPT_RGX = Regex("""(?$1") + .replace(SUBSCRIPT_RGX, "$1") + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/item_post_detail_gallery.xml b/app/src/main/res/layout/item_post_detail_gallery.xml index d1672d3a..4c4219bd 100644 --- a/app/src/main/res/layout/item_post_detail_gallery.xml +++ b/app/src/main/res/layout/item_post_detail_gallery.xml @@ -256,13 +256,13 @@ android:src="@drawable/ic_link" android:visibility="gone" /> - diff --git a/app/src/main/res/layout/item_post_detail_image_and_gif_autoplay.xml b/app/src/main/res/layout/item_post_detail_image_and_gif_autoplay.xml index 260d712a..b204dbdf 100644 --- a/app/src/main/res/layout/item_post_detail_image_and_gif_autoplay.xml +++ b/app/src/main/res/layout/item_post_detail_image_and_gif_autoplay.xml @@ -262,13 +262,13 @@ - diff --git a/app/src/main/res/layout/item_post_detail_link.xml b/app/src/main/res/layout/item_post_detail_link.xml index 4dc95ebf..2493ad14 100644 --- a/app/src/main/res/layout/item_post_detail_link.xml +++ b/app/src/main/res/layout/item_post_detail_link.xml @@ -271,13 +271,13 @@ - diff --git a/app/src/main/res/layout/item_post_detail_no_preview.xml b/app/src/main/res/layout/item_post_detail_no_preview.xml index d954ea55..7375099e 100644 --- a/app/src/main/res/layout/item_post_detail_no_preview.xml +++ b/app/src/main/res/layout/item_post_detail_no_preview.xml @@ -106,17 +106,17 @@ android:focusable="true" android:longClickable="true" /> - - + - - + - diff --git a/app/src/main/res/layout/item_post_detail_video_autoplay.xml b/app/src/main/res/layout/item_post_detail_video_autoplay.xml index 609d8453..632d371f 100644 --- a/app/src/main/res/layout/item_post_detail_video_autoplay.xml +++ b/app/src/main/res/layout/item_post_detail_video_autoplay.xml @@ -255,13 +255,13 @@ - diff --git a/app/src/main/res/layout/item_post_detail_video_autoplay_legacy_controller.xml b/app/src/main/res/layout/item_post_detail_video_autoplay_legacy_controller.xml index 8d7f8396..7209d21e 100644 --- a/app/src/main/res/layout/item_post_detail_video_autoplay_legacy_controller.xml +++ b/app/src/main/res/layout/item_post_detail_video_autoplay_legacy_controller.xml @@ -258,13 +258,13 @@ -