Continue working on html rendering

This commit is contained in:
ganfra
2022-11-30 17:39:26 +01:00
parent 20fcf51e1e
commit 42ecee8d52
4 changed files with 319 additions and 277 deletions

View File

@@ -4,7 +4,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.x.features.messages.html.HtmlDocument
import io.element.android.x.features.messages.components.html.HtmlDocument
import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent
@Composable
@@ -12,10 +12,11 @@ fun MessagesTimelineItemTextView(
content: MessagesTimelineItemTextBasedContent,
modifier: Modifier = Modifier
) {
Box(modifier) {
if (content.htmlDocument != null) {
HtmlDocument(document = content.htmlDocument!!)
} else {
val htmlDocument = content.htmlDocument
if (htmlDocument != null) {
HtmlDocument(document = htmlDocument, modifier)
} else {
Box(modifier) {
Text(text = content.body)
}
}

View File

@@ -0,0 +1,310 @@
package io.element.android.x.features.messages.components.html
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
@Composable
fun HtmlDocument(document: Document, modifier: Modifier = Modifier) {
HtmlBody(body = document.body(), modifier = modifier)
}
@Composable
private fun HtmlBody(body: Element, modifier: Modifier = Modifier) {
Column(
modifier = modifier
) {
for (node in body.childNodes()) {
when (node) {
is TextNode -> {
if (!node.isBlank) {
Text(text = node.text())
}
}
is Element -> {
HtmlBlock(element = node)
}
else -> {
continue
}
}
}
}
}
@Composable
private fun HtmlBlock(element: Element, modifier: Modifier = Modifier) {
val blockModifier = modifier
.padding(top = 4.dp)
when (element.normalName()) {
"p" -> HtmlParagraph(element, blockModifier)
"h1", "h2", "h3", "h4", "h5", "h6" -> HtmlHeading(element, blockModifier)
"ol" -> HtmlOrderedList(element, blockModifier)
"ul" -> HtmlUnorderedList(element, blockModifier)
"blockquote" -> HtmlBlockquote(element, blockModifier)
"pre" -> HtmlPreformatted(element, blockModifier)
"mx-reply" -> HtmlMxReply(element, blockModifier)
// fallback to html inline
else -> HtmlInline(element, modifier)
}
}
@Composable
private fun HtmlInline(element: Element, modifier: Modifier = Modifier) {
Box(modifier.padding(start = 8.dp)) {
val styledText = buildAnnotatedString {
appendInlineElement(element, MaterialTheme.colorScheme)
}
Text(styledText)
}
}
@Composable
private fun HtmlPreformatted(pre: Element, modifier: Modifier = Modifier) {
val isCode = pre.firstElementChild()?.normalName() == "code"
val backgroundColor =
if (isCode) MaterialTheme.colorScheme.codeBackground() else Color.Unspecified
Box(modifier.background(color = backgroundColor)) {
Text(
text = pre.wholeText(),
style = TextStyle(fontFamily = FontFamily.Monospace),
)
}
}
@Composable
private fun HtmlParagraph(paragraph: Element, modifier: Modifier = Modifier) {
Box(modifier) {
val styledText = buildAnnotatedString {
appendInlineChildrenElements(paragraph.childNodes(), MaterialTheme.colorScheme)
}
Text(styledText)
}
}
@Composable
private fun HtmlBlockquote(blockquote: Element, modifier: Modifier = Modifier) {
val color = MaterialTheme.colorScheme.onBackground
Box(
modifier = modifier
.drawBehind {
drawLine(
color = color,
strokeWidth = 2f,
start = Offset(12.dp.value, 0f),
end = Offset(12.dp.value, size.height)
)
}
.padding(start = 8.dp, top = 4.dp, bottom = 4.dp)
) {
val text = buildAnnotatedString {
withStyle(style = SpanStyle(fontStyle = FontStyle.Italic)) {
appendInlineChildrenElements(blockquote.childNodes(), MaterialTheme.colorScheme)
}
}
Text(text)
}
}
@Composable
private fun HtmlHeading(heading: Element, modifier: Modifier = Modifier) {
val style = when (heading.normalName()) {
"h1" -> MaterialTheme.typography.headlineLarge.copy(fontSize = 30.sp)
"h2" -> MaterialTheme.typography.headlineLarge.copy(fontSize = 26.sp)
"h3" -> MaterialTheme.typography.headlineMedium.copy(fontSize = 22.sp)
"h4" -> MaterialTheme.typography.headlineMedium.copy(fontSize = 18.sp)
"h5" -> MaterialTheme.typography.headlineSmall.copy(fontSize = 14.sp)
"h6" -> MaterialTheme.typography.headlineSmall.copy(fontSize = 12.sp)
else -> {
return
}
}
Box(modifier) {
val text = buildAnnotatedString {
appendInlineChildrenElements(heading.childNodes(), MaterialTheme.colorScheme)
}
Text(text, style = style)
}
}
@Composable
private fun HtmlMxReply(mxReply: Element, modifier: Modifier = Modifier) {
val blockquote = mxReply.childNodes().firstOrNull() ?: return
val shape = RoundedCornerShape(12.dp)
Surface(
modifier = modifier.offset(x = -(8.dp)),
color = MaterialTheme.colorScheme.background,
shape = shape,
) {
val text = buildAnnotatedString {
for (blockquoteNode in blockquote.childNodes()) {
when (blockquoteNode) {
is TextNode -> {
withStyle(
style = SpanStyle(
fontSize = 12.sp,
color = MaterialTheme.colorScheme.secondary
)
) {
append(blockquoteNode.text())
}
}
is Element -> {
when (blockquoteNode.normalName()) {
"br" -> {
append('\n')
}
"a" -> {
append(blockquoteNode.ownText())
}
}
}
}
}
}
Text(text, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp))
}
}
@Composable
private fun HtmlOrderedList(unorderedList: Element, modifier: Modifier = Modifier) {
var number = 1
val delimiter = "."
HtmlListItems(unorderedList, modifier = modifier) {
val text = buildAnnotatedString {
append("${number++}$delimiter ${it.text()}")
}
Text(text)
}
}
@Composable
private fun HtmlUnorderedList(unorderedList: Element, modifier: Modifier = Modifier) {
val marker = ""
HtmlListItems(unorderedList, modifier = modifier) {
val text = buildAnnotatedString {
append("$marker ${it.text()}")
}
Text(text)
}
}
@Composable
private fun HtmlListItems(
list: Element,
modifier: Modifier = Modifier,
content: @Composable (node: TextNode) -> Unit
) {
Column(modifier = modifier) {
for (node in list.children()) {
for (innerNode in node.childNodes()) {
when (innerNode) {
is TextNode -> {
if (!innerNode.isBlank) content(innerNode)
}
is Element -> HtmlBlock(
element = innerNode,
modifier = modifier.padding(start = 4.dp)
)
}
}
}
}
}
private fun ColorScheme.codeBackground(): Color {
return background.copy(alpha = 0.3f)
}
private fun AnnotatedString.Builder.appendInlineChildrenElements(
childNodes: List<Node>,
colors: ColorScheme
) {
for (node in childNodes) {
when (node) {
is TextNode -> {
append(node.text())
}
is Element -> {
appendInlineElement(node, colors)
}
}
}
}
private fun AnnotatedString.Builder.appendInlineElement(element: Element, colors: ColorScheme) {
when (element.normalName()) {
"br" -> {
append('\n')
}
"code" -> {
withStyle(
style = TextStyle(
fontFamily = FontFamily.Monospace,
background = colors.codeBackground()
).toSpanStyle()
) {
appendInlineChildrenElements(element.childNodes(), colors)
}
}
"del" -> {
withStyle(style = SpanStyle(textDecoration = TextDecoration.LineThrough)) {
appendInlineChildrenElements(element.childNodes(), colors)
}
}
"em" -> {
withStyle(style = SpanStyle(fontStyle = FontStyle.Italic)) {
appendInlineChildrenElements(element.childNodes(), colors)
}
}
"strong" -> {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
appendInlineChildrenElements(element.childNodes(), colors)
}
}
"a" -> {
val href = element.attr("href")
pushStringAnnotation(tag = "url", annotation = href)
withStyle(
style = SpanStyle(
color = Color.Blue,
textDecoration = TextDecoration.Underline
)
) {
append(element.ownText())
}
pop()
}
else -> {
appendInlineChildrenElements(element.childNodes(), colors)
}
}
}

View File

@@ -1,269 +0,0 @@
package io.element.android.x.features.messages.html
import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import coil.compose.rememberImagePainter
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
@Composable
fun HtmlDocument(document: Document, modifier: Modifier = Modifier) {
HtmlBody(body = document.body(), modifier = modifier)
}
@Composable
fun HtmlBody(body: Element, modifier: Modifier = Modifier) {
Column(
modifier = modifier
) {
for (node in body.childNodes()) {
when (node) {
is TextNode -> {
if (!node.isBlank) {
Text(
text = node.text(),
style = MaterialTheme.typography.body1
)
}
}
is Element -> {
HtmlBlock(node)
}
else -> {
return
}
}
}
}
}
@Composable
fun HtmlBlock(element: Element, modifier: Modifier = Modifier) {
val blockModifier = modifier
.fillMaxWidth()
.padding(top = 4.dp)
when (element.normalName()) {
"p" -> HtmlParagraph(element, blockModifier)
"h1", "h2", "h3", "h4", "h5", "h6" -> HtmlHeading(heading = element, blockModifier)
"ol" -> HtmlOrderedList(element, blockModifier)
"ul" -> HtmlUnorderedList(element, blockModifier)
"blockquote" -> Column {
for (e in element.children()) {
HtmlBlock(element = e)
}
}
}
}
@Composable
fun HtmlHeading(heading: Element, modifier: Modifier = Modifier) {
val style = when (heading.normalName()) {
"h1" -> MaterialTheme.typography.h1
"h2" -> MaterialTheme.typography.h2
"h3" -> MaterialTheme.typography.h3
"h4" -> MaterialTheme.typography.h4
"h5" -> MaterialTheme.typography.h5
"h6" -> MaterialTheme.typography.h6
else -> {
return
}
}
Box(modifier) {
val text = buildAnnotatedString {
appendInlineChildrenElements(heading.childNodes())
}
HtmlText(text, style)
}
}
@Composable
private fun HtmlOrderedList(unorderedList: Element, modifier: Modifier = Modifier) {
var number = 0
val delimiter = "."
HtmlListItems(unorderedList, modifier = modifier) {
val text = buildAnnotatedString {
pushStyle(MaterialTheme.typography.body1.toSpanStyle())
append("${number++}$delimiter ")
appendInlineElements(it)
pop()
}
HtmlText(text, MaterialTheme.typography.body1, modifier)
}
}
@Composable
private fun HtmlUnorderedList(unorderedList: Element, modifier: Modifier = Modifier) {
val marker = "-"
HtmlListItems(unorderedList, modifier = modifier) {
val text = buildAnnotatedString {
pushStyle(MaterialTheme.typography.body1.toSpanStyle())
append("$marker ")
appendInlineElements(it)
pop()
}
HtmlText(text, MaterialTheme.typography.body1, modifier)
}
}
@Composable
fun HtmlListItems(
list: Element,
modifier: Modifier = Modifier,
content: @Composable (node: Element) -> Unit
) {
if (list.children().isEmpty()) return
Column(modifier = modifier) {
val children = list.children().iterator()
var listItem = children.next()
while (listItem != null) {
val innerChildren = listItem.children().iterator()
var child = if (innerChildren.hasNext()) {
innerChildren.next()
} else {
null
}
while (child != null) {
when (child.normalName()) {
"ul" -> HtmlUnorderedList(child, modifier)
"ol" -> HtmlOrderedList(child, modifier)
else -> content(child)
}
child = if (innerChildren.hasNext()) {
innerChildren.next()
} else {
null
}
}
listItem = if (children.hasNext()) {
children.next()
} else {
null
}
}
}
}
private fun AnnotatedString.Builder.appendInlineChildrenElements(childNodes: List<Node>) {
for (node in childNodes) {
when (node) {
is TextNode -> {
append(node.text())
}
is Element -> {
appendInlineElements(node)
}
}
}
}
private fun AnnotatedString.Builder.appendInlineElements(element: Element) {
when (element.normalName()) {
"br" -> {
append('\n')
}
"del" -> {
withStyle(style = SpanStyle(textDecoration = TextDecoration.LineThrough)) {
appendInlineChildrenElements(element.childNodes())
}
}
"em" -> {
withStyle(style = SpanStyle(fontStyle = FontStyle.Italic)) {
appendInlineChildrenElements(element.childNodes())
}
}
"strong" -> {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
appendInlineChildrenElements(element.childNodes())
}
}
"a" -> {
val href = element.attr("href")
pushStringAnnotation(tag = "url", annotation = href)
withStyle(
style = SpanStyle(
color = Color.Blue,
textDecoration = TextDecoration.Underline
)
) {
append(element.ownText())
}
pop()
}
}
}
@Composable
fun HtmlText(text: AnnotatedString, style: TextStyle, modifier: Modifier = Modifier) {
val uriHandler = LocalUriHandler.current
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
Text(text = text,
modifier.pointerInput(Unit) {
detectTapGestures { offset ->
layoutResult.value?.let { layoutResult ->
val position = layoutResult.getOffsetForPosition(offset)
text.getStringAnnotations(position, position)
.firstOrNull()
?.let { sa ->
if (sa.tag == "url") {
uriHandler.openUri(sa.item)
}
}
}
}
},
style = style,
inlineContent = mapOf(
"imageUrl" to InlineTextContent(
Placeholder(style.fontSize, style.fontSize, PlaceholderVerticalAlign.Bottom)
) {
Image(
painter = rememberImagePainter(
data = it,
),
contentDescription = null,
modifier = modifier,
alignment = Alignment.Center
)
}
),
onTextLayout = { layoutResult.value = it }
)
}
@Composable
private fun HtmlParagraph(paragraph: Element, modifier: Modifier = Modifier) {
Box(modifier) {
val styledText = buildAnnotatedString {
pushStyle(MaterialTheme.typography.body1.toSpanStyle())
appendInlineChildrenElements(paragraph.childNodes())
pop()
}
HtmlText(styledText, MaterialTheme.typography.body1)
}
}

View File

@@ -11,7 +11,7 @@ class MessagesTimelineItemContentProvider : PreviewParameterProvider<MessagesTim
override val values = sequenceOf(
MessagesTimelineItemEmoteContent(
body = "Emote",
formattedBody = FormattedBody(MessageFormat.HTML, "Formatted emote")
htmlDocument = null
),
MessagesTimelineItemEncryptedContent(
encryptedMessage = EncryptedMessage.Unknown
@@ -19,12 +19,12 @@ class MessagesTimelineItemContentProvider : PreviewParameterProvider<MessagesTim
// TODO MessagesTimelineItemImageContent(),
MessagesTimelineItemNoticeContent(
body = "Notice",
formattedBody = FormattedBody(MessageFormat.HTML, "Formatted notice")
htmlDocument = null
),
MessagesTimelineItemRedactedContent,
MessagesTimelineItemTextContent(
body = "Text",
formattedBody = FormattedBody(MessageFormat.HTML, "Formatted text")
htmlDocument = null
),
MessagesTimelineItemUnknownContent,
)