mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 16:22:49 +00:00
Add headers (h1-h6) tag support for the new timeline renderer (#7754)
* Add headers (`h1-h6`) tag support for the new timeline renderer * Make H1 and H2 use the same (smaller) style * Update screenshots --------- Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
committed by
GitHub
parent
fb84c5b3c0
commit
b6cf293e2a
+12
@@ -54,6 +54,18 @@ data class ParagraphNode(
|
||||
val inlineContent: ImmutableMap<String, MentionNodeContent> = persistentMapOf(),
|
||||
) : BlockNode
|
||||
|
||||
/**
|
||||
* A heading (`<h1>`–`<h6>`). Like a [ParagraphNode] it carries inline content ([text] and
|
||||
* [inlineContent]), plus the heading [level] from 1 (largest) to 6 (smallest), used by the renderer
|
||||
* to pick the typography.
|
||||
*/
|
||||
@Immutable
|
||||
data class HeaderNode(
|
||||
val level: Int,
|
||||
val text: AnnotatedString,
|
||||
val inlineContent: ImmutableMap<String, MentionNodeContent> = persistentMapOf(),
|
||||
) : BlockNode
|
||||
|
||||
/**
|
||||
* A preformatted code block (`<pre>`). Rendered in a monospaced, horizontally
|
||||
* scrollable container. The text is kept raw: no inline styling is applied inside it.
|
||||
|
||||
+74
-14
@@ -62,6 +62,7 @@ import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
@@ -76,6 +77,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.htmlrenderer.api.BlockNode
|
||||
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
|
||||
import io.element.android.libraries.htmlrenderer.api.DocumentNode
|
||||
import io.element.android.libraries.htmlrenderer.api.HeaderNode
|
||||
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.INLINE_CODE_ANNOTATION_TAG
|
||||
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.LINK_ANNOTATION_TAG
|
||||
import io.element.android.libraries.htmlrenderer.api.ListNode
|
||||
@@ -158,6 +160,7 @@ internal fun BlockNode.lastBlockNode(): BlockNode? = when (this) {
|
||||
is ListNode -> this
|
||||
is QuoteNode -> this
|
||||
is CodeBlockNode -> this
|
||||
is HeaderNode -> this
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -180,7 +183,7 @@ private fun BlockNodeView(
|
||||
context: RenderContext,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (node === context.lastBlockNode && node !is ParagraphNode) {
|
||||
if (node === context.lastBlockNode && node !is ParagraphNode && node !is HeaderNode) {
|
||||
SideEffect { context.onContentLayoutChange(ContentAvoidingLayoutData.NotOverlapping) }
|
||||
}
|
||||
|
||||
@@ -189,6 +192,7 @@ private fun BlockNodeView(
|
||||
is ParagraphNode -> {
|
||||
ParagraphView(node = node, context = context, modifier = modifier)
|
||||
}
|
||||
is HeaderNode -> HeaderView(node = node, context = context, modifier = modifier)
|
||||
is CodeBlockNode -> CodeBlockView(node = node, modifier = modifier)
|
||||
is QuoteNode -> QuoteView(node = node, context = context, modifier = modifier)
|
||||
is ListNode -> ListView(node = node, context = context, modifier = modifier)
|
||||
@@ -201,16 +205,6 @@ private fun ParagraphView(
|
||||
context: RenderContext,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val linkColor = ElementTheme.colors.textLinkExternal
|
||||
val codeBackgroundColor = ElementTheme.colors.bgSubtleSecondary
|
||||
val codeBorderColor = ElementTheme.colors.borderInteractiveSecondary
|
||||
val styledText = remember(node.text, linkColor) {
|
||||
node.text
|
||||
.linkify(SpanStyle(color = linkColor))
|
||||
.applyLinkStyles(linkColor = linkColor)
|
||||
}
|
||||
val inlineContent = rememberMentionInlineContent(node.inlineContent, context)
|
||||
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
// Only the last rendered paragraph (and only when not inside a code block / quote) reports its
|
||||
// measured last line to the timeline.
|
||||
val measureLastLine = if (node === context.lastBlockNode) {
|
||||
@@ -218,21 +212,87 @@ private fun ParagraphView(
|
||||
} else {
|
||||
null
|
||||
}
|
||||
TextBlock(
|
||||
text = node.text,
|
||||
inlineContent = node.inlineContent,
|
||||
textStyle = ElementTheme.typography.fontBodyMdRegular,
|
||||
context = context,
|
||||
modifier = modifier,
|
||||
onTextLayout = { measureLastLine?.invoke(it) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderView(
|
||||
node: HeaderNode,
|
||||
context: RenderContext,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Only the last rendered paragraph (and only when not inside a code block / quote) reports its
|
||||
// measured last line to the timeline.
|
||||
val measureLastLine = if (node === context.lastBlockNode) {
|
||||
ContentAvoidingLayout.measureLastTextLine(onContentLayoutChange = context.onContentLayoutChange)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
TextBlock(
|
||||
text = node.text,
|
||||
inlineContent = node.inlineContent,
|
||||
textStyle = headerTextStyle(node.level),
|
||||
context = context,
|
||||
modifier = modifier,
|
||||
onTextLayout = { measureLastLine?.invoke(it) }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an inline-content block (a paragraph or a header) as a [Text] with [textStyle], applying
|
||||
* link styling, the inline-code background boxes, mention/image inline content and link taps.
|
||||
*/
|
||||
@Composable
|
||||
private fun TextBlock(
|
||||
text: AnnotatedString,
|
||||
inlineContent: ImmutableMap<String, MentionNodeContent>,
|
||||
textStyle: TextStyle,
|
||||
context: RenderContext,
|
||||
modifier: Modifier = Modifier,
|
||||
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||
) {
|
||||
val linkColor = ElementTheme.colors.textLinkExternal
|
||||
val codeBackgroundColor = ElementTheme.colors.bgSubtleSecondary
|
||||
val codeBorderColor = ElementTheme.colors.borderInteractiveSecondary
|
||||
val styledText = remember(text, linkColor) {
|
||||
text.linkify(SpanStyle(color = linkColor))
|
||||
.applyLinkStyles(linkColor = linkColor)
|
||||
}
|
||||
val resolvedInlineContent = rememberMentionInlineContent(inlineContent, context)
|
||||
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
Text(
|
||||
text = styledText,
|
||||
modifier = modifier
|
||||
.drawInlineCodeBackgrounds(styledText, layoutResult, codeBackgroundColor, codeBorderColor, LocalDensity.current)
|
||||
.linkTapHandler(styledText, layoutResult, context),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
style = textStyle,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
inlineContent = inlineContent,
|
||||
inlineContent = resolvedInlineContent,
|
||||
onTextLayout = { result ->
|
||||
layoutResult.value = result
|
||||
measureLastLine?.invoke(result)
|
||||
onTextLayout(result)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Maps an `<h1>`–`<h6>` [level] to a Compound typography style, largest first. */
|
||||
@Composable
|
||||
private fun headerTextStyle(level: Int): TextStyle = when (level) {
|
||||
// For safety reasons, use the same style for H1 and H2, to avoid people using H1 for 'shouting' spam that fills the timeline with huge text
|
||||
1, 2 -> ElementTheme.typography.fontHeadingLgBold
|
||||
3 -> ElementTheme.typography.fontHeadingMdBold
|
||||
4 -> ElementTheme.typography.fontHeadingSmMedium
|
||||
5 -> ElementTheme.typography.fontBodyLgMedium
|
||||
else -> ElementTheme.typography.fontBodyMdMedium
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CodeBlockView(
|
||||
node: CodeBlockNode,
|
||||
|
||||
+12
@@ -26,6 +26,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.htmlrenderer.api.BlockNode
|
||||
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
|
||||
import io.element.android.libraries.htmlrenderer.api.DocumentNode
|
||||
import io.element.android.libraries.htmlrenderer.api.HeaderNode
|
||||
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.INLINE_CODE_ANNOTATION_TAG
|
||||
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.LINK_ANNOTATION_TAG
|
||||
import io.element.android.libraries.htmlrenderer.api.ListItemNode
|
||||
@@ -55,6 +56,7 @@ internal fun HtmlMessageContentPreview(
|
||||
internal class HtmlMessageContentPreviewParam : PreviewParameterProvider<DocumentNode> {
|
||||
override val values: Sequence<DocumentNode>
|
||||
get() = sequenceOf(
|
||||
headers(),
|
||||
richParagraph(),
|
||||
inlineCode(),
|
||||
multilineInlineCode(),
|
||||
@@ -237,6 +239,16 @@ internal class HtmlMessageContentPreviewParam : PreviewParameterProvider<Documen
|
||||
),
|
||||
)
|
||||
|
||||
private fun headers() = document(
|
||||
HeaderNode(level = 1, text = AnnotatedString("Heading 1")),
|
||||
HeaderNode(level = 2, text = AnnotatedString("Heading 2")),
|
||||
HeaderNode(level = 3, text = AnnotatedString("Heading 3")),
|
||||
HeaderNode(level = 4, text = AnnotatedString("Heading 4")),
|
||||
HeaderNode(level = 5, text = AnnotatedString("Heading 5")),
|
||||
HeaderNode(level = 6, text = AnnotatedString("Heading 6")),
|
||||
paragraph(AnnotatedString("Body text under the headings.")),
|
||||
)
|
||||
|
||||
private fun document(vararg blocks: BlockNode) = DocumentNode(blocks.toList().toImmutableList())
|
||||
|
||||
private fun paragraph(
|
||||
|
||||
+26
-4
@@ -18,6 +18,7 @@ import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.htmlrenderer.api.BlockNode
|
||||
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
|
||||
import io.element.android.libraries.htmlrenderer.api.DocumentNode
|
||||
import io.element.android.libraries.htmlrenderer.api.HeaderNode
|
||||
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser
|
||||
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.INLINE_CODE_ANNOTATION_TAG
|
||||
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.LINK_ANNOTATION_TAG
|
||||
@@ -30,6 +31,7 @@ import io.element.android.libraries.htmlrenderer.api.spans.InlineCodeSpanStyle
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import org.jsoup.nodes.Document
|
||||
@@ -81,12 +83,13 @@ class DefaultHtmlMessageParser(
|
||||
return blocks.toImmutableList()
|
||||
}
|
||||
|
||||
private fun parseBlock(element: Element): BlockNode? = when (element.tagName()) {
|
||||
private fun parseBlock(element: Element): BlockNode? = when (val tag = element.tagName()) {
|
||||
TAG_P -> buildParagraph(element.childNodes())
|
||||
TAG_PRE -> CodeBlockNode(code = element.wholeText().trimEnd('\n'))
|
||||
TAG_BLOCKQUOTE -> QuoteNode(children = parseBlocks(element))
|
||||
TAG_UL -> buildList(element, ordered = false)
|
||||
TAG_OL -> buildList(element, ordered = true)
|
||||
in HEADER_TAGS -> buildHeader(element, level = tag.removePrefix("h").toInt())
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -102,6 +105,17 @@ class DefaultHtmlMessageParser(
|
||||
|
||||
/** Builds a [ParagraphNode] from a run of inline [nodes], or `null` if it holds no content. */
|
||||
private fun buildParagraph(nodes: List<Node>): ParagraphNode? {
|
||||
return buildInlineContent(nodes)?.let { ParagraphNode(text = it.text, inlineContent = it.inlineContent) }
|
||||
}
|
||||
|
||||
/** Builds a [HeaderNode] of the given [level] from the inline content of [element], or `null` if empty. */
|
||||
private fun buildHeader(element: Element, level: Int): HeaderNode? {
|
||||
return buildInlineContent(element.childNodes())?.let {
|
||||
HeaderNode(level = level, text = it.text, inlineContent = it.inlineContent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildInlineContent(nodes: List<Node>): InlineContentBuilder.Result? {
|
||||
val builder = InlineContentBuilder()
|
||||
nodes.forEach { builder.append(it) }
|
||||
return builder.build()
|
||||
@@ -199,21 +213,29 @@ class DefaultHtmlMessageParser(
|
||||
}
|
||||
}
|
||||
|
||||
fun build(): ParagraphNode? {
|
||||
fun build(): Result? {
|
||||
val text = builder.toAnnotatedString()
|
||||
if (text.isBlank() && mentions.isEmpty()) return null
|
||||
return ParagraphNode(
|
||||
return Result(
|
||||
text = text,
|
||||
inlineContent = mentions.toImmutableMap(),
|
||||
)
|
||||
}
|
||||
|
||||
/** The flattened inline content of a block: styled text plus its inline-content placeholders. */
|
||||
inner class Result(
|
||||
val text: AnnotatedString,
|
||||
val inlineContent: ImmutableMap<String, MentionNodeContent>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Tags that start a new block-level node. Anything else is treated as inline content. */
|
||||
private fun Element.isBlockLevel(): Boolean = tagName() in BLOCK_TAGS
|
||||
|
||||
private val BLOCK_TAGS = setOf(TAG_P, TAG_PRE, TAG_BLOCKQUOTE, TAG_UL, TAG_OL)
|
||||
private val HEADER_TAGS = setOf("h1", "h2", "h3", "h4", "h5", "h6")
|
||||
|
||||
private val BLOCK_TAGS = setOf(TAG_P, TAG_PRE, TAG_BLOCKQUOTE, TAG_UL, TAG_OL) + HEADER_TAGS
|
||||
|
||||
private const val TAG_P = "p"
|
||||
private const val TAG_PRE = "pre"
|
||||
|
||||
+19
@@ -12,6 +12,7 @@ import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
|
||||
import io.element.android.libraries.htmlrenderer.api.HeaderNode
|
||||
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser
|
||||
import io.element.android.libraries.htmlrenderer.api.ListNode
|
||||
import io.element.android.libraries.htmlrenderer.api.MentionNodeContent
|
||||
@@ -191,6 +192,24 @@ class DefaultHtmlMessageParserTest : RobolectricTest() {
|
||||
assertThat(paragraph.text.text).isEqualTo("Actual message")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `headers become HeaderNodes with their level and inline content`() {
|
||||
val document = parse("<h1>Title</h1><h3>Subtitle with <strong>bold</strong></h3><h6>Small</h6>")
|
||||
assertThat(document.children).hasSize(3)
|
||||
|
||||
val h1 = document.children[0] as HeaderNode
|
||||
assertThat(h1.level).isEqualTo(1)
|
||||
assertThat(h1.text.text).isEqualTo("Title")
|
||||
|
||||
val h3 = document.children[1] as HeaderNode
|
||||
assertThat(h3.level).isEqualTo(3)
|
||||
assertThat(h3.text.text).isEqualTo("Subtitle with bold")
|
||||
|
||||
val h6 = document.children[2] as HeaderNode
|
||||
assertThat(h6.level).isEqualTo(6)
|
||||
assertThat(h6.text.text).isEqualTo("Small")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank input produces an empty document`() {
|
||||
val document = parse(" ")
|
||||
|
||||
+6
@@ -89,6 +89,12 @@ private object CustomHtmlToDomParser {
|
||||
"blockquote",
|
||||
"p",
|
||||
"br",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
// Add custom `mx-reply` tag, even if it's just to remove its contents from the plain text version of the message
|
||||
"mx-reply"
|
||||
)
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Reference in New Issue
Block a user