mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 21:32:34 +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(" ")
|
||||
|
||||
Reference in New Issue
Block a user