From 00f9c362da7bcf5be9c24e63b31718a58ee49dce Mon Sep 17 00:00:00 2001 From: jonnyandrew Date: Mon, 30 Jan 2023 17:35:29 +0000 Subject: [PATCH] [Rich text editor] Add inline code to rich text editor (#8011) Also: - Fixes https://github.com/vector-im/element-android/issues/7975 - See https://github.com/noties/Markwon/issues/423 --- changelog.d/7975.bugfix | 1 + changelog.d/8011.feature | 1 + .../src/main/res/values/strings.xml | 1 + .../java/im/vector/app/core/utils/TestSpan.kt | 33 +++- .../features/html/EventHtmlRendererTest.kt | 26 ++- .../detail/composer/RichTextComposerLayout.kt | 3 + .../timeline/factory/MessageItemFactory.kt | 9 +- .../detail/timeline/item/MessageTextItem.kt | 34 ++-- .../app/features/html/EventHtmlRenderer.kt | 184 ++++++++++++------ .../app/features/html/HtmlCodeHandlers.kt | 72 +++++-- .../app/features/html/HtmlRootTagPlugin.kt | 33 ++++ .../res/drawable/ic_composer_inline_code.xml | 15 ++ ...timeline_event_text_message_plain_stub.xml | 11 ++ ..._timeline_event_text_message_rich_stub.xml | 11 ++ .../item_timeline_event_text_message_stub.xml | 15 +- 15 files changed, 339 insertions(+), 110 deletions(-) create mode 100644 changelog.d/7975.bugfix create mode 100644 changelog.d/8011.feature create mode 100644 vector/src/main/java/im/vector/app/features/html/HtmlRootTagPlugin.kt create mode 100644 vector/src/main/res/drawable/ic_composer_inline_code.xml create mode 100644 vector/src/main/res/layout/item_timeline_event_text_message_plain_stub.xml create mode 100644 vector/src/main/res/layout/item_timeline_event_text_message_rich_stub.xml diff --git a/changelog.d/7975.bugfix b/changelog.d/7975.bugfix new file mode 100644 index 0000000000..b34c784b27 --- /dev/null +++ b/changelog.d/7975.bugfix @@ -0,0 +1 @@ +Fix extra new lines added to inline code diff --git a/changelog.d/8011.feature b/changelog.d/8011.feature new file mode 100644 index 0000000000..700a528fc1 --- /dev/null +++ b/changelog.d/8011.feature @@ -0,0 +1 @@ +[Rich text editor] Add inline code to rich text editor \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index 46c175437a..e690f06bbb 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -3502,6 +3502,7 @@ Set link Toggle numbered list Toggle bullet list + Apply inline code format Toggle full screen mode Text diff --git a/vector/src/androidTest/java/im/vector/app/core/utils/TestSpan.kt b/vector/src/androidTest/java/im/vector/app/core/utils/TestSpan.kt index e31dc6942c..1d0d6548e1 100644 --- a/vector/src/androidTest/java/im/vector/app/core/utils/TestSpan.kt +++ b/vector/src/androidTest/java/im/vector/app/core/utils/TestSpan.kt @@ -19,9 +19,10 @@ package im.vector.app.core.utils import android.graphics.Canvas import android.graphics.Paint import android.text.Layout -import android.text.Spannable +import android.text.Spanned import androidx.core.text.getSpans import im.vector.app.features.html.HtmlCodeSpan +import io.element.android.wysiwyg.spans.InlineCodeSpan import io.mockk.justRun import io.mockk.mockk import io.mockk.slot @@ -31,9 +32,9 @@ import io.noties.markwon.core.spans.OrderedListItemSpan import io.noties.markwon.core.spans.StrongEmphasisSpan import me.gujun.android.span.style.CustomTypefaceSpan -fun Spannable.toTestSpan(): String { +fun Spanned.toTestSpan(): String { var output = toString() - readSpansWithContent().forEach { + readSpansWithContent().reversed().forEach { val tags = it.span.readTags() val remappedContent = it.span.remapContent(source = this, originalContent = it.content) output = output.replace(it.content, "${tags.open}$remappedContent${tags.close}") @@ -41,7 +42,7 @@ fun Spannable.toTestSpan(): String { return output } -private fun Spannable.readSpansWithContent() = getSpans().map { span -> +private fun Spanned.readSpansWithContent() = getSpans().map { span -> val start = getSpanStart(span) val end = getSpanEnd(span) SpanWithContent( @@ -51,12 +52,24 @@ private fun Spannable.readSpansWithContent() = getSpans().map { span -> }.reversed() private fun Any.readTags(): SpanTags { - return when (this::class) { - OrderedListItemSpan::class -> SpanTags("[list item]", "[/list item]") - HtmlCodeSpan::class -> SpanTags("[code]", "[/code]") - StrongEmphasisSpan::class -> SpanTags("[bold]", "[/bold]") - EmphasisSpan::class, CustomTypefaceSpan::class -> SpanTags("[italic]", "[/italic]") - else -> throw IllegalArgumentException("Unknown ${this::class}") + val tagName = when (this::class) { + OrderedListItemSpan::class -> "list item" + HtmlCodeSpan::class -> + if ((this as HtmlCodeSpan).isBlock) "code block" else "inline code" + StrongEmphasisSpan::class -> "bold" + EmphasisSpan::class, CustomTypefaceSpan::class -> "italic" + InlineCodeSpan::class -> "inline code" + else -> if (this::class.qualifiedName!!.startsWith("android.widget")) { + null + } else { + throw IllegalArgumentException("Unknown ${this::class}") + } + } + + return if (tagName == null) { + SpanTags("", "") + } else { + SpanTags("[$tagName]", "[/$tagName]") } } diff --git a/vector/src/androidTest/java/im/vector/app/features/html/EventHtmlRendererTest.kt b/vector/src/androidTest/java/im/vector/app/features/html/EventHtmlRendererTest.kt index a2e489dd70..7f3293e7d1 100644 --- a/vector/src/androidTest/java/im/vector/app/features/html/EventHtmlRendererTest.kt +++ b/vector/src/androidTest/java/im/vector/app/features/html/EventHtmlRendererTest.kt @@ -16,7 +16,8 @@ package im.vector.app.features.html -import androidx.core.text.toSpannable +import android.widget.TextView +import androidx.core.text.toSpanned import androidx.test.platform.app.InstrumentationRegistry import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.resources.ColorProvider @@ -36,16 +37,19 @@ class EventHtmlRendererTest { private val context = InstrumentationRegistry.getInstrumentation().targetContext private val fakeVectorPreferences = mockk().also { every { it.latexMathsIsEnabled() } returns false + every { it.isRichTextEditorEnabled() } returns false } private val fakeSessionHolder = mockk() private val renderer = EventHtmlRenderer( - MatrixHtmlPluginConfigure(ColorProvider(context), context.resources), + MatrixHtmlPluginConfigure(ColorProvider(context), context.resources, fakeVectorPreferences), context, fakeVectorPreferences, fakeSessionHolder, ) + private val textView: TextView = TextView(context) + @Test fun takesInitialListPositionIntoAccount() { val result = """
  1. first entry
""".renderAsTestSpan() @@ -57,7 +61,7 @@ class EventHtmlRendererTest { fun doesNotProcessMarkdownWithinCodeBlocks() { val result = """__italic__ **bold**""".renderAsTestSpan() - result shouldBeEqualTo "[code]__italic__ **bold**[/code]" + result shouldBeEqualTo "[inline code]__italic__ **bold**[/inline code]" } @Test @@ -71,7 +75,15 @@ class EventHtmlRendererTest { fun processesHtmlWithinCodeBlocks() { val result = """italic bold""".renderAsTestSpan() - result shouldBeEqualTo "[code][italic]italic[/italic] [bold]bold[/bold][/code]" + result shouldBeEqualTo "[inline code][italic]italic[/italic] [bold]bold[/bold][/inline code]" + } + + @Test + fun processesHtmlWithinCodeBlocks_givenRichTextEditorEnabled() { + every { fakeVectorPreferences.isRichTextEditorEnabled() } returns true + val result = """italic bold""".renderAsTestSpan() + + result shouldBeEqualTo "[inline code][italic]italic[/italic] [bold]bold[/bold][/inline code]" } @Test @@ -81,5 +93,9 @@ class EventHtmlRendererTest { result shouldBeEqualTo """& < > ' """" } - private fun String.renderAsTestSpan() = renderer.render(this).toSpannable().toTestSpan() + private fun String.renderAsTestSpan(): String { + textView.text = renderer.render(this).toSpanned() + renderer.plugins.forEach { markwonPlugin -> markwonPlugin.afterSetText(textView) } + return textView.text.toSpanned().toTestSpan() + } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt index 1bb82b41fe..2c0d77045e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt @@ -246,6 +246,9 @@ internal class RichTextComposerLayout @JvmOverloads constructor( addRichTextMenuItem(R.drawable.ic_composer_numbered_list, R.string.rich_text_editor_numbered_list, ComposerAction.ORDERED_LIST) { views.richTextComposerEditText.toggleList(ordered = true) } + addRichTextMenuItem(R.drawable.ic_composer_inline_code, R.string.rich_text_editor_inline_code, ComposerAction.INLINE_CODE) { + views.richTextComposerEditText.toggleInlineFormat(InlineFormat.InlineCode) + } } fun setLink(link: String?) = diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt index 219ccbe11c..9cb1608415 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -160,6 +160,9 @@ class MessageItemFactory @Inject constructor( textRendererFactory.create(roomId) } + private val useRichTextEditorStyle: Boolean get() = + vectorPreferences.isRichTextEditorEnabled() + fun create(params: TimelineItemFactoryParams): VectorEpoxyModel<*>? { val event = params.event val highlight = params.isHighlighted @@ -480,6 +483,7 @@ class MessageItemFactory @Inject constructor( highlight, callback, attributes, + useRichTextEditorStyle = vectorPreferences.isRichTextEditorEnabled(), ) } @@ -586,7 +590,7 @@ class MessageItemFactory @Inject constructor( val replyToContent = messageContent.relatesTo?.inReplyTo buildFormattedTextItem(matrixFormattedBody, informationData, highlight, callback, attributes, replyToContent) } else { - buildMessageTextItem(messageContent.body, false, informationData, highlight, callback, attributes) + buildMessageTextItem(messageContent.body, false, informationData, highlight, callback, attributes, useRichTextEditorStyle) } } @@ -610,6 +614,7 @@ class MessageItemFactory @Inject constructor( highlight, callback, attributes, + useRichTextEditorStyle, ) } @@ -620,6 +625,7 @@ class MessageItemFactory @Inject constructor( highlight: Boolean, callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes, + useRichTextEditorStyle: Boolean, ): MessageTextItem? { val renderedBody = textRenderer.render(body) val bindingOptions = spanUtils.getBindingOptions(renderedBody) @@ -640,6 +646,7 @@ class MessageItemFactory @Inject constructor( .previewUrlRetriever(callback?.getPreviewUrlRetriever()) .imageContentRenderer(imageContentRenderer) .previewUrlCallback(callback) + .useRichTextEditorStyle(useRichTextEditorStyle) .leftGuideline(avatarSizeProvider.leftGuideline) .attributes(attributes) .highlighted(highlight) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageTextItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageTextItem.kt index 072c3dcd27..a9cd25ae19 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageTextItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageTextItem.kt @@ -18,6 +18,7 @@ package im.vector.app.features.home.room.detail.timeline.item import android.text.Spanned import android.text.method.MovementMethod +import android.view.ViewStub import androidx.appcompat.widget.AppCompatTextView import androidx.core.text.PrecomputedTextCompat import androidx.core.view.isVisible @@ -67,6 +68,9 @@ abstract class MessageTextItem : AbsMessageItem() { @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var markwonPlugins: (List)? = null + @EpoxyAttribute + var useRichTextEditorStyle: Boolean = false + private val previewUrlViewUpdater = PreviewUrlViewUpdater() override fun bind(holder: Holder) { @@ -82,27 +86,28 @@ abstract class MessageTextItem : AbsMessageItem() { holder.previewUrlView.delegate = previewUrlCallback holder.previewUrlView.renderMessageLayout(attributes.informationData.messageLayout) + val messageView: AppCompatTextView = if (useRichTextEditorStyle) holder.richMessageView else holder.plainMessageView if (useBigFont) { - holder.messageView.textSize = 44F + messageView.textSize = 44F } else { - holder.messageView.textSize = 15.5F + messageView.textSize = 15.5F } if (searchForPills) { message?.charSequence?.findPillsAndProcess(coroutineScope) { // mmm.. not sure this is so safe in regards to cell reuse - it.bind(holder.messageView) + it.bind(messageView) } } message?.charSequence.let { charSequence -> - markwonPlugins?.forEach { plugin -> plugin.beforeSetText(holder.messageView, charSequence as Spanned) } + markwonPlugins?.forEach { plugin -> plugin.beforeSetText(messageView, charSequence as Spanned) } } super.bind(holder) - holder.messageView.movementMethod = movementMethod - renderSendState(holder.messageView, holder.messageView) - holder.messageView.onClick(attributes.itemClickListener) - holder.messageView.onLongClickIgnoringLinks(attributes.itemLongClickListener) - holder.messageView.setTextWithEmojiSupport(message?.charSequence, bindingOptions) - markwonPlugins?.forEach { plugin -> plugin.afterSetText(holder.messageView) } + messageView.movementMethod = movementMethod + renderSendState(messageView, messageView) + messageView.onClick(attributes.itemClickListener) + messageView.onLongClickIgnoringLinks(attributes.itemLongClickListener) + messageView.setTextWithEmojiSupport(message?.charSequence, bindingOptions) + markwonPlugins?.forEach { plugin -> plugin.afterSetText(messageView) } } private fun AppCompatTextView.setTextWithEmojiSupport(message: CharSequence?, bindingOptions: BindingOptions?) { @@ -125,8 +130,15 @@ abstract class MessageTextItem : AbsMessageItem() { override fun getViewStubId() = STUB_ID class Holder : AbsMessageItem.Holder(STUB_ID) { - val messageView by bind(R.id.messageTextView) val previewUrlView by bind(R.id.messageUrlPreview) + private val richMessageStub by bind(R.id.richMessageTextViewStub) + private val plainMessageStub by bind(R.id.plainMessageTextViewStub) + val richMessageView: AppCompatTextView by lazy { + richMessageStub.inflate().findViewById(R.id.messageTextView) + } + val plainMessageView: AppCompatTextView by lazy { + plainMessageStub.inflate().findViewById(R.id.messageTextView) + } } inner class PreviewUrlViewUpdater : PreviewUrlRetriever.PreviewUrlRetrieverListener { 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 21fcbffb03..bc9ba0b85a 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 @@ -30,6 +30,8 @@ import android.content.res.Resources import android.graphics.Typeface import android.graphics.drawable.Drawable import android.text.Spannable +import android.text.SpannableStringBuilder +import android.widget.TextView import androidx.core.text.toSpannable import com.bumptech.glide.Glide import com.bumptech.glide.RequestBuilder @@ -38,6 +40,7 @@ import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.resources.ColorProvider import im.vector.app.core.utils.DimensionConverter import im.vector.app.features.settings.VectorPreferences +import io.element.android.wysiwyg.spans.InlineCodeSpan import io.noties.markwon.AbstractMarkwonPlugin import io.noties.markwon.Markwon import io.noties.markwon.MarkwonPlugin @@ -64,8 +67,8 @@ import javax.inject.Singleton @Singleton class EventHtmlRenderer @Inject constructor( htmlConfigure: MatrixHtmlPluginConfigure, - context: Context, - vectorPreferences: VectorPreferences, + private val context: Context, + private val vectorPreferences: VectorPreferences, private val activeSessionHolder: ActiveSessionHolder ) { @@ -73,73 +76,121 @@ class EventHtmlRenderer @Inject constructor( fun afterRender(renderedText: Spannable) } - private val builder = Markwon.builder(context) - .usePlugin(HtmlPlugin.create(htmlConfigure)) - .usePlugin(GlideImagesPlugin.create(object : GlideImagesPlugin.GlideStore { - override fun load(drawable: AsyncDrawable): RequestBuilder { - val url = drawable.destination - if (url.isMxcUrl()) { - val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver() - val imageUrl = contentUrlResolver.resolveFullSize(url) - // Override size to avoid crashes for huge pictures - return Glide.with(context).load(imageUrl).override(500) - } - // We don't want to support other url schemes here, so just return a request for null - return Glide.with(context).load(null as String?) - } + private val glidePlugin = GlideImagesPlugin.create(object : GlideImagesPlugin.GlideStore { + override fun load(drawable: AsyncDrawable): RequestBuilder { + val url = drawable.destination + if (url.isMxcUrl()) { + val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver() + val imageUrl = contentUrlResolver.resolveFullSize(url) + // Override size to avoid crashes for huge pictures + return Glide.with(context).load(imageUrl).override(500) + } + // We don't want to support other url schemes here, so just return a request for null + return Glide.with(context).load(null as String?) + } - override fun cancel(target: Target<*>) { - Glide.with(context).clear(target) - } - })) + override fun cancel(target: Target<*>) { + Glide.with(context).clear(target) + } + }) - private val markwon = if (vectorPreferences.latexMathsIsEnabled()) { - // If latex maths is enabled in app preferences, refomat it so Markwon recognises it - // It needs to be in this specific format: https://noties.io/Markwon/docs/v4/ext-latex - builder - .usePlugin(object : AbstractMarkwonPlugin() { - override fun processMarkdown(markdown: String): String { - return markdown - .replace(Regex(""".*?""")) { matchResult -> - "$$" + matchResult.groupValues[1] + "$$" - } - .replace(Regex(""".*?""")) { matchResult -> - "\n$$\n" + matchResult.groupValues[1] + "\n$$\n" - } - } - }) - .usePlugin(JLatexMathPlugin.create(44F) { builder -> - builder.inlinesEnabled(true) - builder.theme().inlinePadding(JLatexMathTheme.Padding.symmetric(24, 8)) - }) - } else { - builder - } - .usePlugin( - MarkwonInlineParserPlugin.create( - /* Configuring the Markwon inline formatting processor. - * Default settings are all Markdown features. Turn those off, only using the - * inline HTML processor and HTML entities processor. - */ - MarkwonInlineParser.factoryBuilderNoDefaults() - .addInlineProcessor(HtmlInlineProcessor()) // use inline HTML processor - .addInlineProcessor(EntityInlineProcessor()) // use HTML entities processor - ) - ) - .usePlugin(object : AbstractMarkwonPlugin() { - override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) { - builder.setFactory( - Emphasis::class.java - ) { _, _ -> CustomTypefaceSpan(Typeface.create(Typeface.DEFAULT, Typeface.ITALIC)) } + private val latexPlugins = listOf( + object : AbstractMarkwonPlugin() { + override fun processMarkdown(markdown: String): String { + return markdown + .replace(Regex(""".*?""")) { matchResult -> + "$$" + matchResult.groupValues[1] + "$$" + } + .replace(Regex(""".*?""")) { matchResult -> + "\n$$\n" + matchResult.groupValues[1] + "\n$$\n" + } } + }, + JLatexMathPlugin.create(44F) { builder -> + builder.inlinesEnabled(true) + builder.theme().inlinePadding(JLatexMathTheme.Padding.symmetric(24, 8)) + } + ) - override fun configureParser(builder: Parser.Builder) { - /* Configuring the Markwon block formatting processor. - * Default settings are all Markdown blocks. Turn those off. + private val markwonInlineParserPlugin = + MarkwonInlineParserPlugin.create( + /* Configuring the Markwon inline formatting processor. + * Default settings are all Markdown features. Turn those off, only using the + * inline HTML processor and HTML entities processor. */ - builder.enabledBlockTypes(kotlin.collections.emptySet()) + MarkwonInlineParser.factoryBuilderNoDefaults() + .addInlineProcessor(HtmlInlineProcessor()) // use inline HTML processor + .addInlineProcessor(EntityInlineProcessor()) // use HTML entities processor + ) + + private val italicPlugin = object : AbstractMarkwonPlugin() { + override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) { + builder.setFactory( + Emphasis::class.java + ) { _, _ -> CustomTypefaceSpan(Typeface.create(Typeface.DEFAULT, Typeface.ITALIC)) } + } + + override fun configureParser(builder: Parser.Builder) { + /* Configuring the Markwon block formatting processor. + * Default settings are all Markdown blocks. Turn those off. + */ + builder.enabledBlockTypes(emptySet()) + } + } + + private val cleanUpIntermediateCodePlugin = object : AbstractMarkwonPlugin() { + override fun afterSetText(textView: TextView) { + super.afterSetText(textView) + + // Remove any intermediate spans + val text = textView.text.toSpannable() + text.getSpans(0, text.length, IntermediateCodeSpan::class.java) + .forEach { span -> + text.removeSpan(span) + } + } + } + + /** + * Workaround for https://github.com/noties/Markwon/issues/423 + */ + private val removeLeadingNewlineForInlineCode = object : AbstractMarkwonPlugin() { + override fun afterSetText(textView: TextView) { + super.afterSetText(textView) + + val text = SpannableStringBuilder(textView.text.toSpannable()) + val inlineCodeSpans = text.getSpans(0, textView.length(), InlineCodeSpan::class.java).toList() + val legacyInlineCodeSpans = text.getSpans(0, textView.length(), HtmlCodeSpan::class.java).filter { !it.isBlock } + val spans = inlineCodeSpans + legacyInlineCodeSpans + + if (spans.isEmpty()) return + + spans.forEach { span -> + val start = text.getSpanStart(span) + if (text[start] == '\n') { + text.replace(start, start + 1, "") } - }) + } + + textView.text = text + } + } + + private val markwon = Markwon.builder(context) + .usePlugin(HtmlRootTagPlugin()) + .usePlugin(HtmlPlugin.create(htmlConfigure)) + .usePlugin(removeLeadingNewlineForInlineCode) + .usePlugin(glidePlugin) + .apply { + if (vectorPreferences.latexMathsIsEnabled()) { + // If latex maths is enabled in app preferences, refomat it so Markwon recognises it + // It needs to be in this specific format: https://noties.io/Markwon/docs/v4/ext-latex + latexPlugins.forEach(::usePlugin) + } + } + .usePlugin(markwonInlineParserPlugin) + .usePlugin(italicPlugin) + .usePlugin(cleanUpIntermediateCodePlugin) .textSetter(PrecomputedFutureTextSetterCompat.create()) .build() @@ -185,7 +236,11 @@ class EventHtmlRenderer @Inject constructor( } } -class MatrixHtmlPluginConfigure @Inject constructor(private val colorProvider: ColorProvider, private val resources: Resources) : HtmlPlugin.HtmlConfigure { +class MatrixHtmlPluginConfigure @Inject constructor( + private val colorProvider: ColorProvider, + private val resources: Resources, + private val vectorPreferences: VectorPreferences, +) : HtmlPlugin.HtmlConfigure { override fun configureHtml(plugin: HtmlPlugin) { plugin @@ -193,6 +248,7 @@ class MatrixHtmlPluginConfigure @Inject constructor(private val colorProvider: C .addHandler(FontTagHandler()) .addHandler(ParagraphHandler(DimensionConverter(resources))) .addHandler(MxReplyTagHandler()) + .addHandler(CodePostProcessorTagHandler(vectorPreferences)) .addHandler(CodePreTagHandler()) .addHandler(CodeTagHandler()) .addHandler(SpanHandler(colorProvider)) diff --git a/vector/src/main/java/im/vector/app/features/html/HtmlCodeHandlers.kt b/vector/src/main/java/im/vector/app/features/html/HtmlCodeHandlers.kt index 1010625370..295b74c7a9 100644 --- a/vector/src/main/java/im/vector/app/features/html/HtmlCodeHandlers.kt +++ b/vector/src/main/java/im/vector/app/features/html/HtmlCodeHandlers.kt @@ -16,20 +16,29 @@ package im.vector.app.features.html +import im.vector.app.features.settings.VectorPreferences +import io.element.android.wysiwyg.spans.InlineCodeSpan import io.noties.markwon.MarkwonVisitor import io.noties.markwon.SpannableBuilder +import io.noties.markwon.core.MarkwonTheme import io.noties.markwon.html.HtmlTag import io.noties.markwon.html.MarkwonHtmlRenderer import io.noties.markwon.html.TagHandler -class CodeTagHandler : TagHandler() { +/** + * Span to be added to any found during initial pass. + * The actual code spans can then be added on a second pass using this + * span as a reference. + */ +internal class IntermediateCodeSpan( + var isBlock: Boolean +) + +internal class CodeTagHandler : TagHandler() { override fun handle(visitor: MarkwonVisitor, renderer: MarkwonHtmlRenderer, tag: HtmlTag) { SpannableBuilder.setSpans( - visitor.builder(), - HtmlCodeSpan(visitor.configuration().theme(), false), - tag.start(), - tag.end() + visitor.builder(), IntermediateCodeSpan(isBlock = false), tag.start(), tag.end() ) } @@ -42,15 +51,13 @@ class CodeTagHandler : TagHandler() { * Pre tag are already handled by HtmlPlugin to keep the formatting. * We are only using it to check for
*
tags. */ -class CodePreTagHandler : TagHandler() { +internal class CodePreTagHandler : TagHandler() { override fun handle(visitor: MarkwonVisitor, renderer: MarkwonHtmlRenderer, tag: HtmlTag) { - val htmlCodeSpan = visitor.builder() - .getSpans(tag.start(), tag.end()) - .firstOrNull { - it.what is HtmlCodeSpan - } - if (htmlCodeSpan != null) { - (htmlCodeSpan.what as HtmlCodeSpan).isBlock = true + val codeSpan = visitor.builder().getSpans(tag.start(), tag.end()).firstOrNull { + it.what is IntermediateCodeSpan + } + if (codeSpan != null) { + (codeSpan.what as IntermediateCodeSpan).isBlock = true } } @@ -58,3 +65,42 @@ class CodePreTagHandler : TagHandler() { return listOf("pre") } } + +internal class CodePostProcessorTagHandler( + private val vectorPreferences: VectorPreferences, +) : TagHandler() { + + override fun supportedTags() = listOf(HtmlRootTagPlugin.ROOT_TAG_NAME) + + override fun handle(visitor: MarkwonVisitor, renderer: MarkwonHtmlRenderer, tag: HtmlTag) { + if (tag.attributes()[HtmlRootTagPlugin.ROOT_ATTRIBUTE] == null) { + return + } + + if (tag.isBlock) { + visitChildren(visitor, renderer, tag.asBlock) + } + + // Replace any intermediate code spans with the real formatting spans + visitor.builder() + .getSpans(tag.start(), tag.end()) + .filter { + it.what is IntermediateCodeSpan + }.forEach { code -> + val intermediateCodeSpan = code.what as IntermediateCodeSpan + val theme = visitor.configuration().theme() + val span = intermediateCodeSpan.toFinalCodeSpan(theme) + SpannableBuilder.setSpans( + visitor.builder(), span, code.start, code.end + ) + } + } + + private fun IntermediateCodeSpan.toFinalCodeSpan( + markwonTheme: MarkwonTheme + ): Any = if (vectorPreferences.isRichTextEditorEnabled() && !isBlock) { + InlineCodeSpan() + } else { + HtmlCodeSpan(markwonTheme, isBlock) + } +} diff --git a/vector/src/main/java/im/vector/app/features/html/HtmlRootTagPlugin.kt b/vector/src/main/java/im/vector/app/features/html/HtmlRootTagPlugin.kt new file mode 100644 index 0000000000..59f2cda00b --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/html/HtmlRootTagPlugin.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.html + +import io.noties.markwon.AbstractMarkwonPlugin + +/** + * A root node enables post-processing of optionally nested tags. + * See: [im.vector.app.features.html.CodePostProcessorTagHandler] + */ +internal class HtmlRootTagPlugin : AbstractMarkwonPlugin() { + companion object { + const val ROOT_ATTRIBUTE = "data-root" + const val ROOT_TAG_NAME = "div" + } + override fun processMarkdown(html: String): String { + return "<$ROOT_TAG_NAME $ROOT_ATTRIBUTE>$html" + } +} diff --git a/vector/src/main/res/drawable/ic_composer_inline_code.xml b/vector/src/main/res/drawable/ic_composer_inline_code.xml new file mode 100644 index 0000000000..1743b757af --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_inline_code.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/vector/src/main/res/layout/item_timeline_event_text_message_plain_stub.xml b/vector/src/main/res/layout/item_timeline_event_text_message_plain_stub.xml new file mode 100644 index 0000000000..1d94632686 --- /dev/null +++ b/vector/src/main/res/layout/item_timeline_event_text_message_plain_stub.xml @@ -0,0 +1,11 @@ + + + diff --git a/vector/src/main/res/layout/item_timeline_event_text_message_rich_stub.xml b/vector/src/main/res/layout/item_timeline_event_text_message_rich_stub.xml new file mode 100644 index 0000000000..bedff8bd4a --- /dev/null +++ b/vector/src/main/res/layout/item_timeline_event_text_message_rich_stub.xml @@ -0,0 +1,11 @@ + + + diff --git a/vector/src/main/res/layout/item_timeline_event_text_message_stub.xml b/vector/src/main/res/layout/item_timeline_event_text_message_stub.xml index 5c5280ad4e..32785a41af 100644 --- a/vector/src/main/res/layout/item_timeline_event_text_message_stub.xml +++ b/vector/src/main/res/layout/item_timeline_event_text_message_stub.xml @@ -7,14 +7,17 @@ android:orientation="vertical" tools:viewBindingIgnore="true"> - + android:layout="@layout/item_timeline_event_text_message_plain_stub" /> + +