[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
This commit is contained in:
jonnyandrew 2023-01-30 17:35:29 +00:00 committed by GitHub
parent 156f4f71f9
commit 00f9c362da
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 339 additions and 110 deletions

1
changelog.d/7975.bugfix Normal file
View file

@ -0,0 +1 @@
Fix extra new lines added to inline code

1
changelog.d/8011.feature Normal file
View file

@ -0,0 +1 @@
[Rich text editor] Add inline code to rich text editor

View file

@ -3502,6 +3502,7 @@
<string name="rich_text_editor_link">Set link</string> <string name="rich_text_editor_link">Set link</string>
<string name="rich_text_editor_numbered_list">Toggle numbered list</string> <string name="rich_text_editor_numbered_list">Toggle numbered list</string>
<string name="rich_text_editor_bullet_list">Toggle bullet list</string> <string name="rich_text_editor_bullet_list">Toggle bullet list</string>
<string name="rich_text_editor_inline_code">Apply inline code format</string>
<string name="rich_text_editor_full_screen_toggle">Toggle full screen mode</string> <string name="rich_text_editor_full_screen_toggle">Toggle full screen mode</string>
<string name="set_link_text">Text</string> <string name="set_link_text">Text</string>

View file

@ -19,9 +19,10 @@ package im.vector.app.core.utils
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Paint import android.graphics.Paint
import android.text.Layout import android.text.Layout
import android.text.Spannable import android.text.Spanned
import androidx.core.text.getSpans import androidx.core.text.getSpans
import im.vector.app.features.html.HtmlCodeSpan import im.vector.app.features.html.HtmlCodeSpan
import io.element.android.wysiwyg.spans.InlineCodeSpan
import io.mockk.justRun import io.mockk.justRun
import io.mockk.mockk import io.mockk.mockk
import io.mockk.slot import io.mockk.slot
@ -31,9 +32,9 @@ import io.noties.markwon.core.spans.OrderedListItemSpan
import io.noties.markwon.core.spans.StrongEmphasisSpan import io.noties.markwon.core.spans.StrongEmphasisSpan
import me.gujun.android.span.style.CustomTypefaceSpan import me.gujun.android.span.style.CustomTypefaceSpan
fun Spannable.toTestSpan(): String { fun Spanned.toTestSpan(): String {
var output = toString() var output = toString()
readSpansWithContent().forEach { readSpansWithContent().reversed().forEach {
val tags = it.span.readTags() val tags = it.span.readTags()
val remappedContent = it.span.remapContent(source = this, originalContent = it.content) val remappedContent = it.span.remapContent(source = this, originalContent = it.content)
output = output.replace(it.content, "${tags.open}$remappedContent${tags.close}") output = output.replace(it.content, "${tags.open}$remappedContent${tags.close}")
@ -41,7 +42,7 @@ fun Spannable.toTestSpan(): String {
return output return output
} }
private fun Spannable.readSpansWithContent() = getSpans<Any>().map { span -> private fun Spanned.readSpansWithContent() = getSpans<Any>().map { span ->
val start = getSpanStart(span) val start = getSpanStart(span)
val end = getSpanEnd(span) val end = getSpanEnd(span)
SpanWithContent( SpanWithContent(
@ -51,12 +52,24 @@ private fun Spannable.readSpansWithContent() = getSpans<Any>().map { span ->
}.reversed() }.reversed()
private fun Any.readTags(): SpanTags { private fun Any.readTags(): SpanTags {
return when (this::class) { val tagName = when (this::class) {
OrderedListItemSpan::class -> SpanTags("[list item]", "[/list item]") OrderedListItemSpan::class -> "list item"
HtmlCodeSpan::class -> SpanTags("[code]", "[/code]") HtmlCodeSpan::class ->
StrongEmphasisSpan::class -> SpanTags("[bold]", "[/bold]") if ((this as HtmlCodeSpan).isBlock) "code block" else "inline code"
EmphasisSpan::class, CustomTypefaceSpan::class -> SpanTags("[italic]", "[/italic]") StrongEmphasisSpan::class -> "bold"
else -> throw IllegalArgumentException("Unknown ${this::class}") 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]")
} }
} }

View file

@ -16,7 +16,8 @@
package im.vector.app.features.html 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 androidx.test.platform.app.InstrumentationRegistry
import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.ColorProvider
@ -36,16 +37,19 @@ class EventHtmlRendererTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val fakeVectorPreferences = mockk<VectorPreferences>().also { private val fakeVectorPreferences = mockk<VectorPreferences>().also {
every { it.latexMathsIsEnabled() } returns false every { it.latexMathsIsEnabled() } returns false
every { it.isRichTextEditorEnabled() } returns false
} }
private val fakeSessionHolder = mockk<ActiveSessionHolder>() private val fakeSessionHolder = mockk<ActiveSessionHolder>()
private val renderer = EventHtmlRenderer( private val renderer = EventHtmlRenderer(
MatrixHtmlPluginConfigure(ColorProvider(context), context.resources), MatrixHtmlPluginConfigure(ColorProvider(context), context.resources, fakeVectorPreferences),
context, context,
fakeVectorPreferences, fakeVectorPreferences,
fakeSessionHolder, fakeSessionHolder,
) )
private val textView: TextView = TextView(context)
@Test @Test
fun takesInitialListPositionIntoAccount() { fun takesInitialListPositionIntoAccount() {
val result = """<ol start="5"><li>first entry<li></ol>""".renderAsTestSpan() val result = """<ol start="5"><li>first entry<li></ol>""".renderAsTestSpan()
@ -57,7 +61,7 @@ class EventHtmlRendererTest {
fun doesNotProcessMarkdownWithinCodeBlocks() { fun doesNotProcessMarkdownWithinCodeBlocks() {
val result = """<code>__italic__ **bold**</code>""".renderAsTestSpan() val result = """<code>__italic__ **bold**</code>""".renderAsTestSpan()
result shouldBeEqualTo "[code]__italic__ **bold**[/code]" result shouldBeEqualTo "[inline code]__italic__ **bold**[/inline code]"
} }
@Test @Test
@ -71,7 +75,15 @@ class EventHtmlRendererTest {
fun processesHtmlWithinCodeBlocks() { fun processesHtmlWithinCodeBlocks() {
val result = """<code><i>italic</i> <b>bold</b></code>""".renderAsTestSpan() val result = """<code><i>italic</i> <b>bold</b></code>""".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 = """<code><i>italic</i> <b>bold</b></code>""".renderAsTestSpan()
result shouldBeEqualTo "[inline code][italic]italic[/italic] [bold]bold[/bold][/inline code]"
} }
@Test @Test
@ -81,5 +93,9 @@ class EventHtmlRendererTest {
result shouldBeEqualTo """& < > ' """" 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()
}
} }

View file

@ -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) { addRichTextMenuItem(R.drawable.ic_composer_numbered_list, R.string.rich_text_editor_numbered_list, ComposerAction.ORDERED_LIST) {
views.richTextComposerEditText.toggleList(ordered = true) 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?) = fun setLink(link: String?) =

View file

@ -160,6 +160,9 @@ class MessageItemFactory @Inject constructor(
textRendererFactory.create(roomId) textRendererFactory.create(roomId)
} }
private val useRichTextEditorStyle: Boolean get() =
vectorPreferences.isRichTextEditorEnabled()
fun create(params: TimelineItemFactoryParams): VectorEpoxyModel<*>? { fun create(params: TimelineItemFactoryParams): VectorEpoxyModel<*>? {
val event = params.event val event = params.event
val highlight = params.isHighlighted val highlight = params.isHighlighted
@ -480,6 +483,7 @@ class MessageItemFactory @Inject constructor(
highlight, highlight,
callback, callback,
attributes, attributes,
useRichTextEditorStyle = vectorPreferences.isRichTextEditorEnabled(),
) )
} }
@ -586,7 +590,7 @@ class MessageItemFactory @Inject constructor(
val replyToContent = messageContent.relatesTo?.inReplyTo val replyToContent = messageContent.relatesTo?.inReplyTo
buildFormattedTextItem(matrixFormattedBody, informationData, highlight, callback, attributes, replyToContent) buildFormattedTextItem(matrixFormattedBody, informationData, highlight, callback, attributes, replyToContent)
} else { } 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, highlight,
callback, callback,
attributes, attributes,
useRichTextEditorStyle,
) )
} }
@ -620,6 +625,7 @@ class MessageItemFactory @Inject constructor(
highlight: Boolean, highlight: Boolean,
callback: TimelineEventController.Callback?, callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes, attributes: AbsMessageItem.Attributes,
useRichTextEditorStyle: Boolean,
): MessageTextItem? { ): MessageTextItem? {
val renderedBody = textRenderer.render(body) val renderedBody = textRenderer.render(body)
val bindingOptions = spanUtils.getBindingOptions(renderedBody) val bindingOptions = spanUtils.getBindingOptions(renderedBody)
@ -640,6 +646,7 @@ class MessageItemFactory @Inject constructor(
.previewUrlRetriever(callback?.getPreviewUrlRetriever()) .previewUrlRetriever(callback?.getPreviewUrlRetriever())
.imageContentRenderer(imageContentRenderer) .imageContentRenderer(imageContentRenderer)
.previewUrlCallback(callback) .previewUrlCallback(callback)
.useRichTextEditorStyle(useRichTextEditorStyle)
.leftGuideline(avatarSizeProvider.leftGuideline) .leftGuideline(avatarSizeProvider.leftGuideline)
.attributes(attributes) .attributes(attributes)
.highlighted(highlight) .highlighted(highlight)

View file

@ -18,6 +18,7 @@ package im.vector.app.features.home.room.detail.timeline.item
import android.text.Spanned import android.text.Spanned
import android.text.method.MovementMethod import android.text.method.MovementMethod
import android.view.ViewStub
import androidx.appcompat.widget.AppCompatTextView import androidx.appcompat.widget.AppCompatTextView
import androidx.core.text.PrecomputedTextCompat import androidx.core.text.PrecomputedTextCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
@ -67,6 +68,9 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var markwonPlugins: (List<MarkwonPlugin>)? = null var markwonPlugins: (List<MarkwonPlugin>)? = null
@EpoxyAttribute
var useRichTextEditorStyle: Boolean = false
private val previewUrlViewUpdater = PreviewUrlViewUpdater() private val previewUrlViewUpdater = PreviewUrlViewUpdater()
override fun bind(holder: Holder) { override fun bind(holder: Holder) {
@ -82,27 +86,28 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
holder.previewUrlView.delegate = previewUrlCallback holder.previewUrlView.delegate = previewUrlCallback
holder.previewUrlView.renderMessageLayout(attributes.informationData.messageLayout) holder.previewUrlView.renderMessageLayout(attributes.informationData.messageLayout)
val messageView: AppCompatTextView = if (useRichTextEditorStyle) holder.richMessageView else holder.plainMessageView
if (useBigFont) { if (useBigFont) {
holder.messageView.textSize = 44F messageView.textSize = 44F
} else { } else {
holder.messageView.textSize = 15.5F messageView.textSize = 15.5F
} }
if (searchForPills) { if (searchForPills) {
message?.charSequence?.findPillsAndProcess(coroutineScope) { message?.charSequence?.findPillsAndProcess(coroutineScope) {
// mmm.. not sure this is so safe in regards to cell reuse // mmm.. not sure this is so safe in regards to cell reuse
it.bind(holder.messageView) it.bind(messageView)
} }
} }
message?.charSequence.let { charSequence -> 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) super.bind(holder)
holder.messageView.movementMethod = movementMethod messageView.movementMethod = movementMethod
renderSendState(holder.messageView, holder.messageView) renderSendState(messageView, messageView)
holder.messageView.onClick(attributes.itemClickListener) messageView.onClick(attributes.itemClickListener)
holder.messageView.onLongClickIgnoringLinks(attributes.itemLongClickListener) messageView.onLongClickIgnoringLinks(attributes.itemLongClickListener)
holder.messageView.setTextWithEmojiSupport(message?.charSequence, bindingOptions) messageView.setTextWithEmojiSupport(message?.charSequence, bindingOptions)
markwonPlugins?.forEach { plugin -> plugin.afterSetText(holder.messageView) } markwonPlugins?.forEach { plugin -> plugin.afterSetText(messageView) }
} }
private fun AppCompatTextView.setTextWithEmojiSupport(message: CharSequence?, bindingOptions: BindingOptions?) { private fun AppCompatTextView.setTextWithEmojiSupport(message: CharSequence?, bindingOptions: BindingOptions?) {
@ -125,8 +130,15 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
override fun getViewStubId() = STUB_ID override fun getViewStubId() = STUB_ID
class Holder : AbsMessageItem.Holder(STUB_ID) { class Holder : AbsMessageItem.Holder(STUB_ID) {
val messageView by bind<AppCompatTextView>(R.id.messageTextView)
val previewUrlView by bind<PreviewUrlView>(R.id.messageUrlPreview) val previewUrlView by bind<PreviewUrlView>(R.id.messageUrlPreview)
private val richMessageStub by bind<ViewStub>(R.id.richMessageTextViewStub)
private val plainMessageStub by bind<ViewStub>(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 { inner class PreviewUrlViewUpdater : PreviewUrlRetriever.PreviewUrlRetrieverListener {

View file

@ -30,6 +30,8 @@ import android.content.res.Resources
import android.graphics.Typeface import android.graphics.Typeface
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.text.Spannable import android.text.Spannable
import android.text.SpannableStringBuilder
import android.widget.TextView
import androidx.core.text.toSpannable import androidx.core.text.toSpannable
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.RequestBuilder 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.resources.ColorProvider
import im.vector.app.core.utils.DimensionConverter import im.vector.app.core.utils.DimensionConverter
import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorPreferences
import io.element.android.wysiwyg.spans.InlineCodeSpan
import io.noties.markwon.AbstractMarkwonPlugin import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.Markwon import io.noties.markwon.Markwon
import io.noties.markwon.MarkwonPlugin import io.noties.markwon.MarkwonPlugin
@ -64,8 +67,8 @@ import javax.inject.Singleton
@Singleton @Singleton
class EventHtmlRenderer @Inject constructor( class EventHtmlRenderer @Inject constructor(
htmlConfigure: MatrixHtmlPluginConfigure, htmlConfigure: MatrixHtmlPluginConfigure,
context: Context, private val context: Context,
vectorPreferences: VectorPreferences, private val vectorPreferences: VectorPreferences,
private val activeSessionHolder: ActiveSessionHolder private val activeSessionHolder: ActiveSessionHolder
) { ) {
@ -73,73 +76,121 @@ class EventHtmlRenderer @Inject constructor(
fun afterRender(renderedText: Spannable) fun afterRender(renderedText: Spannable)
} }
private val builder = Markwon.builder(context) private val glidePlugin = GlideImagesPlugin.create(object : GlideImagesPlugin.GlideStore {
.usePlugin(HtmlPlugin.create(htmlConfigure)) override fun load(drawable: AsyncDrawable): RequestBuilder<Drawable> {
.usePlugin(GlideImagesPlugin.create(object : GlideImagesPlugin.GlideStore { val url = drawable.destination
override fun load(drawable: AsyncDrawable): RequestBuilder<Drawable> { if (url.isMxcUrl()) {
val url = drawable.destination val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver()
if (url.isMxcUrl()) { val imageUrl = contentUrlResolver.resolveFullSize(url)
val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver() // Override size to avoid crashes for huge pictures
val imageUrl = contentUrlResolver.resolveFullSize(url) return Glide.with(context).load(imageUrl).override(500)
// 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?)
// 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<*>) { override fun cancel(target: Target<*>) {
Glide.with(context).clear(target) Glide.with(context).clear(target)
} }
})) })
private val markwon = if (vectorPreferences.latexMathsIsEnabled()) { private val latexPlugins = listOf(
// If latex maths is enabled in app preferences, refomat it so Markwon recognises it object : AbstractMarkwonPlugin() {
// It needs to be in this specific format: https://noties.io/Markwon/docs/v4/ext-latex override fun processMarkdown(markdown: String): String {
builder return markdown
.usePlugin(object : AbstractMarkwonPlugin() { .replace(Regex("""<span\s+data-mx-maths="([^"]*)">.*?</span>""")) { matchResult ->
override fun processMarkdown(markdown: String): String { "$$" + matchResult.groupValues[1] + "$$"
return markdown }
.replace(Regex("""<span\s+data-mx-maths="([^"]*)">.*?</span>""")) { matchResult -> .replace(Regex("""<div\s+data-mx-maths="([^"]*)">.*?</div>""")) { matchResult ->
"$$" + matchResult.groupValues[1] + "$$" "\n$$\n" + matchResult.groupValues[1] + "\n$$\n"
} }
.replace(Regex("""<div\s+data-mx-maths="([^"]*)">.*?</div>""")) { 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)) }
} }
},
JLatexMathPlugin.create(44F) { builder ->
builder.inlinesEnabled(true)
builder.theme().inlinePadding(JLatexMathTheme.Padding.symmetric(24, 8))
}
)
override fun configureParser(builder: Parser.Builder) { private val markwonInlineParserPlugin =
/* Configuring the Markwon block formatting processor. MarkwonInlineParserPlugin.create(
* Default settings are all Markdown blocks. Turn those off. /* 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()) .textSetter(PrecomputedFutureTextSetterCompat.create())
.build() .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) { override fun configureHtml(plugin: HtmlPlugin) {
plugin plugin
@ -193,6 +248,7 @@ class MatrixHtmlPluginConfigure @Inject constructor(private val colorProvider: C
.addHandler(FontTagHandler()) .addHandler(FontTagHandler())
.addHandler(ParagraphHandler(DimensionConverter(resources))) .addHandler(ParagraphHandler(DimensionConverter(resources)))
.addHandler(MxReplyTagHandler()) .addHandler(MxReplyTagHandler())
.addHandler(CodePostProcessorTagHandler(vectorPreferences))
.addHandler(CodePreTagHandler()) .addHandler(CodePreTagHandler())
.addHandler(CodeTagHandler()) .addHandler(CodeTagHandler())
.addHandler(SpanHandler(colorProvider)) .addHandler(SpanHandler(colorProvider))

View file

@ -16,20 +16,29 @@
package im.vector.app.features.html 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.MarkwonVisitor
import io.noties.markwon.SpannableBuilder import io.noties.markwon.SpannableBuilder
import io.noties.markwon.core.MarkwonTheme
import io.noties.markwon.html.HtmlTag import io.noties.markwon.html.HtmlTag
import io.noties.markwon.html.MarkwonHtmlRenderer import io.noties.markwon.html.MarkwonHtmlRenderer
import io.noties.markwon.html.TagHandler import io.noties.markwon.html.TagHandler
class CodeTagHandler : TagHandler() { /**
* Span to be added to any <code> 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) { override fun handle(visitor: MarkwonVisitor, renderer: MarkwonHtmlRenderer, tag: HtmlTag) {
SpannableBuilder.setSpans( SpannableBuilder.setSpans(
visitor.builder(), visitor.builder(), IntermediateCodeSpan(isBlock = false), tag.start(), tag.end()
HtmlCodeSpan(visitor.configuration().theme(), false),
tag.start(),
tag.end()
) )
} }
@ -42,15 +51,13 @@ class CodeTagHandler : TagHandler() {
* Pre tag are already handled by HtmlPlugin to keep the formatting. * Pre tag are already handled by HtmlPlugin to keep the formatting.
* We are only using it to check for <pre><code>*</code></pre> tags. * We are only using it to check for <pre><code>*</code></pre> tags.
*/ */
class CodePreTagHandler : TagHandler() { internal class CodePreTagHandler : TagHandler() {
override fun handle(visitor: MarkwonVisitor, renderer: MarkwonHtmlRenderer, tag: HtmlTag) { override fun handle(visitor: MarkwonVisitor, renderer: MarkwonHtmlRenderer, tag: HtmlTag) {
val htmlCodeSpan = visitor.builder() val codeSpan = visitor.builder().getSpans(tag.start(), tag.end()).firstOrNull {
.getSpans(tag.start(), tag.end()) it.what is IntermediateCodeSpan
.firstOrNull { }
it.what is HtmlCodeSpan if (codeSpan != null) {
} (codeSpan.what as IntermediateCodeSpan).isBlock = true
if (htmlCodeSpan != null) {
(htmlCodeSpan.what as HtmlCodeSpan).isBlock = true
} }
} }
@ -58,3 +65,42 @@ class CodePreTagHandler : TagHandler() {
return listOf("pre") 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)
}
}

View file

@ -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</$ROOT_TAG_NAME>"
}
}

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="44dp"
android:height="44dp"
android:viewportWidth="44"
android:viewportHeight="44">
<path
android:pathData="M24.958,15.621C25.117,15.092 24.816,14.534 24.287,14.375C23.758,14.217 23.201,14.517 23.042,15.046L19.042,28.379C18.883,28.908 19.184,29.466 19.713,29.624C20.242,29.783 20.799,29.483 20.958,28.954L24.958,15.621Z"
android:fillColor="#8D97A5"/>
<path
android:pathData="M15.974,17.232C15.549,16.878 14.919,16.936 14.565,17.36L11.232,21.36C10.923,21.731 10.923,22.269 11.232,22.64L14.565,26.64C14.919,27.065 15.549,27.122 15.974,26.768C16.398,26.415 16.455,25.784 16.102,25.36L13.302,22L16.102,18.64C16.455,18.216 16.398,17.585 15.974,17.232Z"
android:fillColor="#8D97A5"/>
<path
android:pathData="M28.027,17.232C28.451,16.878 29.081,16.936 29.435,17.36L32.768,21.36C33.077,21.731 33.077,22.269 32.768,22.64L29.435,26.64C29.081,27.065 28.451,27.122 28.027,26.768C27.602,26.415 27.545,25.784 27.898,25.36L30.698,22L27.898,18.64C27.545,18.216 27.602,17.585 28.027,17.232Z"
android:fillColor="#8D97A5"/>
</vector>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Message view to be used when the rich text editor is not enabled -->
<androidx.appcompat.widget.AppCompatTextView android:id="@+id/messageTextView"
style="@style/Widget.Vector.TextView.Body"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="viewStart"
android:textColor="?vctr_content_primary"
tools:text="@sample/messages.json/data/message"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" />

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Message view to be used when the rich text editor is enabled -->
<io.element.android.wysiwyg.EditorStyledTextView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/messageTextView"
style="@style/Widget.Vector.TextView.Body"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="viewStart"
android:textColor="?vctr_content_primary"
tools:text="@sample/messages.json/data/message" />

View file

@ -7,14 +7,17 @@
android:orientation="vertical" android:orientation="vertical"
tools:viewBindingIgnore="true"> tools:viewBindingIgnore="true">
<TextView <ViewStub
android:id="@+id/messageTextView" android:id="@+id/plainMessageTextViewStub"
style="@style/Widget.Vector.TextView.Body"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAlignment="viewStart" android:layout="@layout/item_timeline_event_text_message_plain_stub" />
android:textColor="?vctr_content_primary"
tools:text="@sample/messages.json/data/message" /> <ViewStub
android:id="@+id/richMessageTextViewStub"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout="@layout/item_timeline_event_text_message_rich_stub" />
<im.vector.app.features.home.room.detail.timeline.url.PreviewUrlView <im.vector.app.features.home.room.detail.timeline.url.PreviewUrlView
android:id="@+id/messageUrlPreview" android:id="@+id/messageUrlPreview"