mirror of
https://github.com/SchildiChat/SchildiChat-android.git
synced 2024-11-25 10:55:55 +03:00
[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:
parent
156f4f71f9
commit
00f9c362da
15 changed files with 339 additions and 110 deletions
1
changelog.d/7975.bugfix
Normal file
1
changelog.d/7975.bugfix
Normal file
|
@ -0,0 +1 @@
|
|||
Fix extra new lines added to inline code
|
1
changelog.d/8011.feature
Normal file
1
changelog.d/8011.feature
Normal file
|
@ -0,0 +1 @@
|
|||
[Rich text editor] Add inline code to rich text editor
|
|
@ -3502,6 +3502,7 @@
|
|||
<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_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="set_link_text">Text</string>
|
||||
|
|
|
@ -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<Any>().map { span ->
|
||||
private fun Spanned.readSpansWithContent() = getSpans<Any>().map { span ->
|
||||
val start = getSpanStart(span)
|
||||
val end = getSpanEnd(span)
|
||||
SpanWithContent(
|
||||
|
@ -51,12 +52,24 @@ private fun Spannable.readSpansWithContent() = getSpans<Any>().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]")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<VectorPreferences>().also {
|
||||
every { it.latexMathsIsEnabled() } returns false
|
||||
every { it.isRichTextEditorEnabled() } returns false
|
||||
}
|
||||
private val fakeSessionHolder = mockk<ActiveSessionHolder>()
|
||||
|
||||
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 = """<ol start="5"><li>first entry<li></ol>""".renderAsTestSpan()
|
||||
|
@ -57,7 +61,7 @@ class EventHtmlRendererTest {
|
|||
fun doesNotProcessMarkdownWithinCodeBlocks() {
|
||||
val result = """<code>__italic__ **bold**</code>""".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 = """<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
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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?) =
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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<MessageTextItem.Holder>() {
|
|||
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
|
||||
var markwonPlugins: (List<MarkwonPlugin>)? = null
|
||||
|
||||
@EpoxyAttribute
|
||||
var useRichTextEditorStyle: Boolean = false
|
||||
|
||||
private val previewUrlViewUpdater = PreviewUrlViewUpdater()
|
||||
|
||||
override fun bind(holder: Holder) {
|
||||
|
@ -82,27 +86,28 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
|
|||
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<MessageTextItem.Holder>() {
|
|||
override fun getViewStubId() = STUB_ID
|
||||
|
||||
class Holder : AbsMessageItem.Holder(STUB_ID) {
|
||||
val messageView by bind<AppCompatTextView>(R.id.messageTextView)
|
||||
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 {
|
||||
|
|
|
@ -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<Drawable> {
|
||||
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<Drawable> {
|
||||
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("""<span\s+data-mx-maths="([^"]*)">.*?</span>""")) { matchResult ->
|
||||
"$$" + matchResult.groupValues[1] + "$$"
|
||||
}
|
||||
.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)) }
|
||||
private val latexPlugins = listOf(
|
||||
object : AbstractMarkwonPlugin() {
|
||||
override fun processMarkdown(markdown: String): String {
|
||||
return markdown
|
||||
.replace(Regex("""<span\s+data-mx-maths="([^"]*)">.*?</span>""")) { matchResult ->
|
||||
"$$" + matchResult.groupValues[1] + "$$"
|
||||
}
|
||||
.replace(Regex("""<div\s+data-mx-maths="([^"]*)">.*?</div>""")) { 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))
|
||||
|
|
|
@ -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 <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) {
|
||||
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 <pre><code>*</code></pre> 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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>"
|
||||
}
|
||||
}
|
15
vector/src/main/res/drawable/ic_composer_inline_code.xml
Normal file
15
vector/src/main/res/drawable/ic_composer_inline_code.xml
Normal 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>
|
|
@ -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" />
|
|
@ -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" />
|
|
@ -7,14 +7,17 @@
|
|||
android:orientation="vertical"
|
||||
tools:viewBindingIgnore="true">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/messageTextView"
|
||||
style="@style/Widget.Vector.TextView.Body"
|
||||
<ViewStub
|
||||
android:id="@+id/plainMessageTextViewStub"
|
||||
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" />
|
||||
android:layout="@layout/item_timeline_event_text_message_plain_stub" />
|
||||
|
||||
<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
|
||||
android:id="@+id/messageUrlPreview"
|
||||
|
|
Loading…
Reference in a new issue