diff --git a/markymark/src/main/java/com/moveagency/markymark/annotator/DefaultMarkyMarkAnnotator.kt b/markymark/src/main/java/com/moveagency/markymark/annotator/DefaultMarkyMarkAnnotator.kt index 67290a5..0ff3771 100644 --- a/markymark/src/main/java/com/moveagency/markymark/annotator/DefaultMarkyMarkAnnotator.kt +++ b/markymark/src/main/java/com/moveagency/markymark/annotator/DefaultMarkyMarkAnnotator.kt @@ -23,7 +23,18 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withLink -import com.moveagency.markymark.model.annotated.* +import com.moveagency.markymark.model.annotated.AnnotatedStableNode +import com.moveagency.markymark.model.annotated.Bold +import com.moveagency.markymark.model.annotated.Code +import com.moveagency.markymark.model.annotated.EmailLink +import com.moveagency.markymark.model.annotated.Italic +import com.moveagency.markymark.model.annotated.Link +import com.moveagency.markymark.model.annotated.ParagraphText +import com.moveagency.markymark.model.annotated.SoftLineBreak +import com.moveagency.markymark.model.annotated.Strikethrough +import com.moveagency.markymark.model.annotated.Subscript +import com.moveagency.markymark.model.annotated.Superscript +import com.moveagency.markymark.model.annotated.Text import com.moveagency.markymark.theme.AnnotatedStyles import kotlinx.collections.immutable.ImmutableList @@ -88,7 +99,7 @@ open class DefaultMarkyMarkAnnotator : MarkyMarkAnnotator { protected open fun AnnotatedString.Builder.annotateLink(link: Link, styles: AnnotatedStyles) { pushStyle(styles.link) - withLink(LinkAnnotation.Url(link.url)) { + withLink(linkToAnnotation(link)) { annotateChildren(nodes = link.children, styles = styles) } pop() @@ -96,7 +107,7 @@ open class DefaultMarkyMarkAnnotator : MarkyMarkAnnotator { protected open fun AnnotatedString.Builder.annotateEmailLink(link: EmailLink, styles: AnnotatedStyles) { pushStyle(styles.link) - withLink(LinkAnnotation.Url("$MailToPrefix${link.email}")) { + withLink(emailLinkToAnnotation(link)) { append(link.email) } pop() @@ -114,6 +125,26 @@ open class DefaultMarkyMarkAnnotator : MarkyMarkAnnotator { pop() } + private fun linkToAnnotation(link: Link) = when (link) { + is Link.BrowserLink -> LinkAnnotation.Url(url = link.url) + is Link.CustomLink -> { + LinkAnnotation.Clickable( + tag = link.title.orEmpty(), + linkInteractionListener = { link.clickListener.onClick(link.url) }, + ) + } + } + + private fun emailLinkToAnnotation(link: EmailLink): LinkAnnotation { + val url = "$MailToPrefix${link.email}" + return link.linkInteractionListener?.let { listener -> + LinkAnnotation.Clickable( + tag = link.email, + linkInteractionListener = { listener.onClick(url) }, + ) + } ?: LinkAnnotation.Url(url) + } + companion object { private const val MailToPrefix = "mailto:" diff --git a/markymark/src/main/java/com/moveagency/markymark/composable/Markdown.kt b/markymark/src/main/java/com/moveagency/markymark/composable/Markdown.kt index ef96fbf..2666838 100644 --- a/markymark/src/main/java/com/moveagency/markymark/composable/Markdown.kt +++ b/markymark/src/main/java/com/moveagency/markymark/composable/Markdown.kt @@ -23,7 +23,13 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import com.moveagency.markymark.MarkyMark import com.moveagency.markymark.composer.paddingVertical @@ -45,7 +51,10 @@ fun Markdown( val document = remember(parser, markdown) { parser.parse(markdown) } var nodes by remember { mutableStateOf>(persistentListOf()) } - LaunchedEffect(parser, document) { nodes = convertToStableNodes(document) } + + LaunchedEffect(parser, document) { + nodes = convertToStableNodes(document = document) + } val composer = MarkyMark.options.composer diff --git a/markymark/src/main/java/com/moveagency/markymark/converter/AnnotatedStableNodeConverter.kt b/markymark/src/main/java/com/moveagency/markymark/converter/AnnotatedStableNodeConverter.kt index 779d4b4..2facb87 100644 --- a/markymark/src/main/java/com/moveagency/markymark/converter/AnnotatedStableNodeConverter.kt +++ b/markymark/src/main/java/com/moveagency/markymark/converter/AnnotatedStableNodeConverter.kt @@ -21,9 +21,25 @@ package com.moveagency.markymark.converter import android.util.Log import com.moveagency.markymark.converter.MarkyMarkConverter.ConverterTag import com.moveagency.markymark.converter.MarkyMarkConverter.convertToAnnotatedNodes +import com.moveagency.markymark.model.LinkInteractionListener import com.moveagency.markymark.model.NodeMetadata -import com.moveagency.markymark.model.annotated.* -import com.vladsch.flexmark.ast.* +import com.moveagency.markymark.model.annotated.Bold +import com.moveagency.markymark.model.annotated.Code +import com.moveagency.markymark.model.annotated.EmailLink +import com.moveagency.markymark.model.annotated.Italic +import com.moveagency.markymark.model.annotated.Link +import com.moveagency.markymark.model.annotated.ParagraphText +import com.moveagency.markymark.model.annotated.SoftLineBreak +import com.moveagency.markymark.model.annotated.Strikethrough +import com.moveagency.markymark.model.annotated.Subscript +import com.moveagency.markymark.model.annotated.Superscript +import com.moveagency.markymark.model.annotated.Text +import com.vladsch.flexmark.ast.AutoLink +import com.vladsch.flexmark.ast.Emphasis +import com.vladsch.flexmark.ast.LinkRef +import com.vladsch.flexmark.ast.MailLink +import com.vladsch.flexmark.ast.StrongEmphasis +import com.vladsch.flexmark.ast.TextBase import com.vladsch.flexmark.util.ast.Node import com.vladsch.flexmark.util.sequence.BasedSequence import com.vladsch.flexmark.util.sequence.Escaping @@ -40,20 +56,24 @@ import com.vladsch.flexmark.ext.superscript.Superscript as FlexSuperscript object AnnotatedStableNodeConverter { @Suppress("ComplexMethod") - internal suspend fun convertToAnnotatedNode(metadata: NodeMetadata, node: Node) = when (node) { + internal suspend fun convertToAnnotatedNode( + metadata: NodeMetadata, + node: Node, + linkInteractionListener: LinkInteractionListener?, + ) = when (node) { is FlexText -> convertTextNode(metadata, node) - is Emphasis -> convertEmphasisNode(metadata, node) - is StrongEmphasis -> convertStrongEmphasisNode(metadata, node) - is FlexStrikethrough -> convertStrikeThroughNode(metadata, node) + is Emphasis -> convertEmphasisNode(metadata, node, linkInteractionListener) + is StrongEmphasis -> convertStrongEmphasisNode(metadata, node, linkInteractionListener) + is FlexStrikethrough -> convertStrikeThroughNode(metadata, node, linkInteractionListener) is FlexCode -> convertCodeNode(metadata, node) - is FlexLink -> convertLinkNode(metadata, node) - is AutoLink -> convertAutoLinkNode(metadata, node) + is FlexLink -> convertLinkNode(metadata, node, linkInteractionListener) + is AutoLink -> convertAutoLinkNode(metadata, node, linkInteractionListener) is LinkRef -> convertLinkRefNode(metadata, node) - is MailLink -> convertMailLinkNode(metadata, node) + is MailLink -> convertMailLinkNode(metadata, node, linkInteractionListener) is FlexSoftLineBreak -> SoftLineBreak(metadata) is FlexSubscript -> convertSubscriptNode(metadata, node) is FlexSuperscript -> convertSuperscriptNode(metadata, node) - is TextBase -> convertTextBaseNode(metadata, node) + is TextBase -> convertTextBaseNode(metadata, node, linkInteractionListener) else -> { Log.w(ConverterTag, "Found unknown node, $node") null @@ -64,49 +84,106 @@ object AnnotatedStableNodeConverter { return Text(metadata = metadata, content = text.chars.unescapeHtml()) } - private suspend fun convertEmphasisNode(metadata: NodeMetadata, emphasis: Emphasis): Italic { + private suspend fun convertEmphasisNode( + metadata: NodeMetadata, + emphasis: Emphasis, + linkInteractionListener: LinkInteractionListener?, + ): Italic { return Italic( metadata = metadata, - children = convertToAnnotatedNodes(metadata = metadata, nodes = emphasis.children), + children = convertToAnnotatedNodes( + metadata = metadata, + nodes = emphasis.children, + linkInteractionListener = linkInteractionListener, + ), ) } - private suspend fun convertStrongEmphasisNode(metadata: NodeMetadata, strongEmphasis: StrongEmphasis): Bold { + private suspend fun convertStrongEmphasisNode( + metadata: NodeMetadata, + strongEmphasis: StrongEmphasis, + linkInteractionListener: LinkInteractionListener?, + ): Bold { return Bold( metadata = metadata, - children = convertToAnnotatedNodes(metadata = metadata, strongEmphasis.children), + children = convertToAnnotatedNodes( + metadata = metadata, + nodes = strongEmphasis.children, + linkInteractionListener = linkInteractionListener, + ), ) } private suspend fun convertStrikeThroughNode( metadata: NodeMetadata, strikethrough: FlexStrikethrough, + linkInteractionListener: LinkInteractionListener?, ): Strikethrough { return Strikethrough( metadata = metadata, - children = convertToAnnotatedNodes(metadata = metadata, nodes = strikethrough.children), + children = convertToAnnotatedNodes( + metadata = metadata, + nodes = strikethrough.children, + linkInteractionListener = linkInteractionListener, + ), ) } private suspend fun convertCodeNode(metadata: NodeMetadata, code: FlexCode): Code { return Code( metadata = metadata, - children = convertToAnnotatedNodes(metadata = metadata, nodes = code.children), + children = convertToAnnotatedNodes( + metadata = metadata, + nodes = code.children, + linkInteractionListener = null, + ), ) } - private suspend fun convertLinkNode(metadata: NodeMetadata, link: FlexLink): Link { - return Link( + private suspend fun convertLinkNode( + metadata: NodeMetadata, + link: FlexLink, + clickListener: LinkInteractionListener?, + ): Link { + return clickListener?.let { listener -> + Link.CustomLink( + metadata = metadata, + children = convertToAnnotatedNodes( + metadata = metadata, + nodes = link.children, + linkInteractionListener = null, + ), + url = link.url.unescapeHtml(), + title = link.title.unescapeHtml().takeUnless { it.isBlank() }, + clickListener = listener, + ) + } ?: Link.BrowserLink( metadata = metadata, - children = convertToAnnotatedNodes(metadata = metadata, nodes = link.children), + children = convertToAnnotatedNodes( + metadata = metadata, + nodes = link.children, + linkInteractionListener = null, + ), url = link.url.unescapeHtml(), title = link.title.unescapeHtml().takeUnless { it.isBlank() }, ) } - private fun convertAutoLinkNode(metadata: NodeMetadata, autoLink: AutoLink): Link { + private fun convertAutoLinkNode( + metadata: NodeMetadata, + autoLink: AutoLink, + clickListener: LinkInteractionListener?, + ): Link { val url = autoLink.url.unescapeHtml() - return Link( + return clickListener?.let { + Link.CustomLink( + metadata = metadata, + children = persistentListOf(Text(metadata = metadata, content = url)), + url = url, + title = null, + clickListener = it, + ) + } ?: Link.BrowserLink( metadata = metadata, children = persistentListOf(Text(metadata = metadata, content = url)), url = url, @@ -118,28 +195,52 @@ object AnnotatedStableNodeConverter { return Text(metadata = metadata, content = linkRef.chars.unescapeHtml()) } - private fun convertMailLinkNode(metadata: NodeMetadata, emailLink: MailLink): EmailLink { - return EmailLink(metadata = metadata, email = emailLink.text.unescapeHtml()) + private fun convertMailLinkNode( + metadata: NodeMetadata, + emailLink: MailLink, + linkInteractionListener: LinkInteractionListener?, + ): EmailLink { + return EmailLink( + metadata = metadata, + email = emailLink.text.unescapeHtml(), + linkInteractionListener = linkInteractionListener, + ) } private suspend fun convertSubscriptNode(metadata: NodeMetadata, subscript: FlexSubscript): Subscript { return Subscript( metadata = metadata, - children = convertToAnnotatedNodes(metadata = metadata, nodes = subscript.children), + children = convertToAnnotatedNodes( + metadata = metadata, + nodes = subscript.children, + linkInteractionListener = null, + ), ) } private suspend fun convertSuperscriptNode(metadata: NodeMetadata, superscript: FlexSuperscript): Superscript { return Superscript( metadata = metadata, - children = convertToAnnotatedNodes(metadata = metadata, nodes = superscript.children), + children = convertToAnnotatedNodes( + metadata = metadata, + nodes = superscript.children, + linkInteractionListener = null, + ), ) } - private suspend fun convertTextBaseNode(metadata: NodeMetadata, textBase: TextBase): ParagraphText { + private suspend fun convertTextBaseNode( + metadata: NodeMetadata, + textBase: TextBase, + linkInteractionListener: LinkInteractionListener?, + ): ParagraphText { return ParagraphText( metadata = metadata, - children = convertToAnnotatedNodes(metadata = metadata, nodes = textBase.children), + children = convertToAnnotatedNodes( + metadata = metadata, + nodes = textBase.children, + linkInteractionListener = linkInteractionListener, + ), ) } diff --git a/markymark/src/main/java/com/moveagency/markymark/converter/ComposableStableNodeConverter.kt b/markymark/src/main/java/com/moveagency/markymark/converter/ComposableStableNodeConverter.kt index 691d140..96bcde4 100644 --- a/markymark/src/main/java/com/moveagency/markymark/converter/ComposableStableNodeConverter.kt +++ b/markymark/src/main/java/com/moveagency/markymark/converter/ComposableStableNodeConverter.kt @@ -21,18 +21,28 @@ package com.moveagency.markymark.converter import com.moveagency.markymark.converter.AnnotatedStableNodeConverter.convertToAnnotatedNode import com.moveagency.markymark.converter.AnnotatedStableNodeConverter.unescapeHtml import com.moveagency.markymark.converter.MarkyMarkConverter.convertToAnnotatedNodes +import com.moveagency.markymark.model.LinkInteractionListener import com.moveagency.markymark.model.NodeMetadata import com.moveagency.markymark.model.annotated.AnnotatedStableNode import com.moveagency.markymark.model.annotated.ParagraphText -import com.moveagency.markymark.model.composable.* import com.moveagency.markymark.model.composable.BlockQuote import com.moveagency.markymark.model.composable.CodeBlock +import com.moveagency.markymark.model.composable.ComposableStableNode +import com.moveagency.markymark.model.composable.Headline import com.moveagency.markymark.model.composable.Image import com.moveagency.markymark.model.composable.ListBlock import com.moveagency.markymark.model.composable.Paragraph +import com.moveagency.markymark.model.composable.Rule +import com.moveagency.markymark.model.composable.TableBlock +import com.moveagency.markymark.model.composable.TextNode import com.moveagency.markymark.util.mapAsync import com.moveagency.markymark.util.mapAsyncIndexed -import com.vladsch.flexmark.ast.* +import com.vladsch.flexmark.ast.FencedCodeBlock +import com.vladsch.flexmark.ast.Heading +import com.vladsch.flexmark.ast.IndentedCodeBlock +import com.vladsch.flexmark.ast.ListItem +import com.vladsch.flexmark.ast.OrderedListItem +import com.vladsch.flexmark.ast.ThematicBreak import com.vladsch.flexmark.ext.gfm.tasklist.TaskListItem import com.vladsch.flexmark.ext.tables.TableBody import com.vladsch.flexmark.ext.tables.TableHead @@ -59,27 +69,71 @@ object ComposableStableNodeConverter { internal suspend fun convertToStableNode( metadata: NodeMetadata, node: Node, + linkInteractionListener: LinkInteractionListener?, ): List = when (node) { - is Heading -> listOf(convertHeadingNode(metadata = metadata, heading = node)) is ThematicBreak -> listOf(Rule(metadata = metadata)) - is FlexParagraph -> convertParagraphNode(metadata = metadata, paragraph = node) is FlexImage -> listOf(convertImageNode(metadata = metadata, image = node)) is FencedCodeBlock -> listOf(convertFencedCodeBlockNode(metadata = metadata, fencedCodeBlock = node)) is IndentedCodeBlock -> listOf(convertIndentedCodeBlockNode(metadata = metadata, indentedCodeBlock = node)) - is FlexBlockQuote -> listOf(convertBlockQuoteNode(metadata = metadata, blockQuote = node)) - is FlexTableBlock -> listOf(convertTableBlockNode(metadata = metadata, tableBlock = node)) - is FlexListBlock -> listOfNotNull(convertListBlockNode(metadata = metadata, listBlock = node)) - else -> listOfNotNull(convertTextNode(metadata = metadata, node = node)) + is FlexBlockQuote -> listOf( + convertBlockQuoteNode( + metadata = metadata, + blockQuote = node, + linkInteractionListener = linkInteractionListener, + ), + ) + is FlexTableBlock -> listOf( + convertTableBlockNode( + metadata = metadata, + tableBlock = node, + linkInteractionListener = linkInteractionListener, + ) + ) + is Heading -> listOf( + convertHeadingNode( + metadata = metadata, + heading = node, + linkInteractionListener = linkInteractionListener, + ), + ) + is FlexParagraph -> { + convertParagraphNode( + metadata = metadata, + paragraph = node, + linkInteractionListener = linkInteractionListener + ) + } + is FlexListBlock -> { + listOfNotNull( + convertListBlockNode( + metadata = metadata, + listBlock = node, + linkInteractionListener = linkInteractionListener + ) + ) + } + else -> listOfNotNull( + convertTextNode( + metadata = metadata, + node = node, + linkInteractionListener = linkInteractionListener, + ) + ) } @Suppress("MagicNumber") private suspend fun convertHeadingNode( metadata: NodeMetadata, heading: Heading, + linkInteractionListener: LinkInteractionListener?, ): Headline { return Headline( metadata = metadata, - children = convertToAnnotatedNodes(metadata = metadata, nodes = heading.children), + children = convertToAnnotatedNodes( + metadata = metadata, + nodes = heading.children, + linkInteractionListener = linkInteractionListener, + ), headingLevel = when (heading.level) { 1 -> Headline.Level.Heading1 2 -> Headline.Level.Heading2 @@ -94,6 +148,7 @@ object ComposableStableNodeConverter { private suspend fun convertParagraphNode( metadata: NodeMetadata, paragraph: FlexParagraph, + linkInteractionListener: LinkInteractionListener?, ): List { return paragraph.children .splitOnImage() @@ -107,6 +162,7 @@ object ComposableStableNodeConverter { children = convertParagraphChildren( metadata = metadata.incParagraphLevel(), children = it, + linkInteractionListener = linkInteractionListener, ) ) } @@ -138,8 +194,12 @@ object ComposableStableNodeConverter { private suspend fun convertParagraphChildren( metadata: NodeMetadata, children: Iterable, + linkInteractionListener: LinkInteractionListener?, ): ImmutableList { - return children.mapAsync { convertToStableNode(metadata = metadata, node = it) } + return children + .mapAsync { + convertToStableNode(metadata = metadata, node = it, linkInteractionListener = linkInteractionListener) + } .flatten() .bundleParagraphText(metadata) } @@ -226,12 +286,14 @@ object ComposableStableNodeConverter { private suspend fun convertBlockQuoteNode( metadata: NodeMetadata, blockQuote: FlexBlockQuote, + linkInteractionListener: LinkInteractionListener?, ): BlockQuote { return BlockQuote( metadata = metadata, children = MarkyMarkConverter.convertToStableNodes( nodes = blockQuote.children, metadata = metadata.incQuoteLevel(), + linkInteractionListener = linkInteractionListener, ) ) } @@ -239,12 +301,25 @@ object ComposableStableNodeConverter { private suspend fun convertTableBlockNode( metadata: NodeMetadata, tableBlock: FlexTableBlock, + linkInteractionListener: LinkInteractionListener?, ): ComposableStableNode { return withContext(Dispatchers.Default) { - val head = async { convertToTableRow(metadata = metadata, tableHead = tableBlock.firstChild as TableHead) } + val head = async { + convertToTableRow( + metadata = metadata, + tableHead = tableBlock.firstChild as TableHead, + linkInteractionListener = linkInteractionListener, + ) + } val body = async { (tableBlock.lastChild as? TableBody) - ?.let { convertToTableRows(metadata = metadata, tableBody = it) } + ?.let { + convertToTableRows( + metadata = metadata, + tableBody = it, + linkInteractionListener = linkInteractionListener, + ) + } .orEmpty() .toImmutableList() } @@ -259,34 +334,58 @@ object ComposableStableNodeConverter { private suspend fun convertToTableRow( metadata: NodeMetadata, tableHead: TableHead, + linkInteractionListener: LinkInteractionListener?, ): TableBlock.TableRow { - return convertToTableRow(metadata = metadata, tableRow = tableHead.firstChild as FlexTableRow) + return convertToTableRow( + metadata = metadata, + tableRow = tableHead.firstChild as FlexTableRow, + linkInteractionListener = linkInteractionListener, + ) } private suspend fun convertToTableRows( metadata: NodeMetadata, tableBody: TableBody, + linkInteractionListener: LinkInteractionListener?, ): ImmutableList { - return convertToTableRows(metadata = metadata, nodes = tableBody.children) + return convertToTableRows( + metadata = metadata, + nodes = tableBody.children, + linkInteractionListener = linkInteractionListener, + ) } private suspend fun convertToTableRows( metadata: NodeMetadata, nodes: Iterable, + linkInteractionListener: LinkInteractionListener? ): ImmutableList { return nodes.filterIsInstance(FlexTableRow::class.java) - .mapAsync { convertToTableRow(metadata = metadata, tableRow = it) } + .mapAsync { + convertToTableRow( + metadata = metadata, + tableRow = it, + linkInteractionListener = linkInteractionListener, + ) + } .toImmutableList() } private suspend fun convertToTableRow( metadata: NodeMetadata, tableRow: FlexTableRow, + linkInteractionListener: LinkInteractionListener? ): TableBlock.TableRow { return TableBlock.TableRow( tableRow.children .filterIsInstance(FlexTableCell::class.java) - .mapAsync { convertToTableCell(metadata = metadata, tableCell = it) } + .mapAsync { + convertToTableCell( + metadata = metadata, + tableCell = it, + linkInteractionListener = linkInteractionListener, + ) + } .toImmutableList() ) } @@ -294,9 +393,14 @@ object ComposableStableNodeConverter { private suspend fun convertToTableCell( metadata: NodeMetadata, tableCell: FlexTableCell, + linkInteractionListener: LinkInteractionListener?, ): TableBlock.TableCell { return TableBlock.TableCell( - children = convertToAnnotatedNodes(metadata = metadata, nodes = tableCell.children), + children = convertToAnnotatedNodes( + metadata = metadata, + nodes = tableCell.children, + linkInteractionListener = linkInteractionListener, + ), alignment = when (tableCell.alignment) { FlexTableCell.Alignment.LEFT -> TableBlock.TableCell.Alignment.Start FlexTableCell.Alignment.CENTER -> TableBlock.TableCell.Alignment.Center @@ -309,10 +413,12 @@ object ComposableStableNodeConverter { private suspend fun convertListBlockNode( metadata: NodeMetadata, listBlock: FlexListBlock, + linkInteractionListener: LinkInteractionListener?, ): ListBlock? { return convertListChildren( metadata = metadata, nodes = listBlock.children, + linkInteractionListener = linkInteractionListener, ).takeUnless { it.isEmpty() } ?.let { ListBlock(metadata = metadata, children = it) } } @@ -320,12 +426,14 @@ object ComposableStableNodeConverter { private suspend fun convertListChildren( metadata: NodeMetadata, nodes: Iterable, + linkInteractionListener: LinkInteractionListener?, ): ImmutableList { return nodes.mapAsyncIndexed { index, item -> convertListItem( metadata = metadata, index = index + 1, item = item as ListItem, + linkInteractionListener = linkInteractionListener, ) }.flatten().toImmutableList() } @@ -334,11 +442,16 @@ object ComposableStableNodeConverter { metadata: NodeMetadata, index: Int, item: ListItem, + linkInteractionListener: LinkInteractionListener?, ): ImmutableList { val firstChild = item.firstChild if (firstChild == null || !firstChild.hasChildren()) return persistentListOf() - val content = convertToAnnotatedNodes(metadata = metadata, nodes = firstChild.children) + val content = convertToAnnotatedNodes( + metadata = metadata, + nodes = firstChild.children, + linkInteractionListener = linkInteractionListener, + ) return buildList { // Enforce the first item to parsed as a list item. There is no way in the Markdown spec it won't be and // this greatly simplifies displaying lists later. @@ -351,7 +464,9 @@ object ComposableStableNodeConverter { item.children .drop(1) // Drop first child as we converted that to list item above - .mapAsync { convertListNode(metadata = metadata, node = it) } + .mapAsync { + convertListNode(metadata = metadata, node = it, linkInteractionListener = linkInteractionListener) + } .flatten() .let(::addAll) }.toPersistentList() @@ -363,17 +478,34 @@ object ComposableStableNodeConverter { else -> ListBlock.ListItemType.Unordered } - private suspend fun convertListNode(metadata: NodeMetadata, node: Node) = when (node) { + private suspend fun convertListNode( + metadata: NodeMetadata, + node: Node, + linkInteractionListener: LinkInteractionListener?, + ) = when (node) { is FlexListBlock -> listOfNotNull( - convertListBlockNode(listBlock = node, metadata = metadata.incListLevel()) - ?.let(ListBlock.ListEntry::ListNode) + convertListBlockNode( + listBlock = node, + metadata = metadata.incListLevel(), + linkInteractionListener = linkInteractionListener, + )?.let(ListBlock.ListEntry::ListNode) ) - else -> convertToStableNode(node = node, metadata = metadata.incLevel()) - .map(ListBlock.ListEntry::ListNode) + else -> convertToStableNode( + node = node, + metadata = metadata.incLevel(), + linkInteractionListener = linkInteractionListener, + ).map(ListBlock.ListEntry::ListNode) } - private suspend fun convertTextNode(node: Node, metadata: NodeMetadata): TextNode? { - return convertToAnnotatedNode(metadata = metadata, node = node) - ?.let { TextNode(metadata = metadata, text = it) } + private suspend fun convertTextNode( + node: Node, + metadata: NodeMetadata, + linkInteractionListener: LinkInteractionListener?, + ): TextNode? { + return convertToAnnotatedNode( + metadata = metadata, + node = node, + linkInteractionListener = linkInteractionListener, + )?.let { TextNode(metadata = metadata, text = it) } } } diff --git a/markymark/src/main/java/com/moveagency/markymark/converter/MarkyMarkConverter.kt b/markymark/src/main/java/com/moveagency/markymark/converter/MarkyMarkConverter.kt index 25c97ef..4a06a39 100644 --- a/markymark/src/main/java/com/moveagency/markymark/converter/MarkyMarkConverter.kt +++ b/markymark/src/main/java/com/moveagency/markymark/converter/MarkyMarkConverter.kt @@ -20,6 +20,7 @@ package com.moveagency.markymark.converter import com.moveagency.markymark.converter.AnnotatedStableNodeConverter.convertToAnnotatedNode import com.moveagency.markymark.converter.ComposableStableNodeConverter.convertToStableNode +import com.moveagency.markymark.model.LinkInteractionListener import com.moveagency.markymark.model.NodeMetadata import com.moveagency.markymark.model.annotated.AnnotatedStableNode import com.moveagency.markymark.model.composable.ComposableStableNode @@ -44,15 +45,26 @@ object MarkyMarkConverter { * Convert [document] child [Node]s to [ComposableStableNode]s. This mapping happens as asynchronously as possible * on the [Dispatchers.Default] dispatcher. See [mapAsync] & [mapAsyncIndexed] for more details. */ - suspend fun convertToStableNodes(document: Document): ImmutableList { - return convertToStableNodes(nodes = document.children, metadata = NodeMetadata.Root) + suspend fun convertToStableNodes( + document: Document, + linkInteractionListener: LinkInteractionListener? = null, + ): ImmutableList { + return convertToStableNodes( + nodes = document.children, + metadata = NodeMetadata.Root, + linkInteractionListener = linkInteractionListener, + ) } internal suspend fun convertToStableNodes( metadata: NodeMetadata, nodes: Iterable, + linkInteractionListener: LinkInteractionListener? = null, ): ImmutableList { - return nodes.mapAsync { convertToStableNode(metadata = metadata, node = it) } + return nodes + .mapAsync { + convertToStableNode(metadata = metadata, node = it, linkInteractionListener = linkInteractionListener) + } .flatten() .toImmutableList() } @@ -60,8 +72,16 @@ object MarkyMarkConverter { internal suspend fun convertToAnnotatedNodes( metadata: NodeMetadata, nodes: Iterable, + linkInteractionListener: LinkInteractionListener? = null, ): ImmutableList { - return nodes.mapAsync { convertToAnnotatedNode(metadata = metadata, node = it) } + return nodes + .mapAsync { + convertToAnnotatedNode( + metadata = metadata, + node = it, + linkInteractionListener = linkInteractionListener, + ) + } .filterNotNull() .toImmutableList() } diff --git a/markymark/src/main/java/com/moveagency/markymark/model/LinkInteractionListener.kt b/markymark/src/main/java/com/moveagency/markymark/model/LinkInteractionListener.kt new file mode 100644 index 0000000..01978f7 --- /dev/null +++ b/markymark/src/main/java/com/moveagency/markymark/model/LinkInteractionListener.kt @@ -0,0 +1,24 @@ +/* + * Copyright © 2025 Framna + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the “Software”), to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and + * to permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of + * the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO + * THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF + * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ + +package com.moveagency.markymark.model + +fun interface LinkInteractionListener { + + fun onClick(url: String) +} diff --git a/markymark/src/main/java/com/moveagency/markymark/model/annotated/EmailLink.kt b/markymark/src/main/java/com/moveagency/markymark/model/annotated/EmailLink.kt index b05a1e5..da6e280 100644 --- a/markymark/src/main/java/com/moveagency/markymark/model/annotated/EmailLink.kt +++ b/markymark/src/main/java/com/moveagency/markymark/model/annotated/EmailLink.kt @@ -19,6 +19,7 @@ package com.moveagency.markymark.model.annotated import androidx.compose.runtime.Immutable +import com.moveagency.markymark.model.LinkInteractionListener import com.moveagency.markymark.model.NodeMetadata /** @@ -37,4 +38,5 @@ import com.moveagency.markymark.model.NodeMetadata data class EmailLink( override val metadata: NodeMetadata, val email: String, + val linkInteractionListener: LinkInteractionListener? = null, ) : AnnotatedStableNode() diff --git a/markymark/src/main/java/com/moveagency/markymark/model/annotated/Link.kt b/markymark/src/main/java/com/moveagency/markymark/model/annotated/Link.kt index 27487be..8d0eea3 100644 --- a/markymark/src/main/java/com/moveagency/markymark/model/annotated/Link.kt +++ b/markymark/src/main/java/com/moveagency/markymark/model/annotated/Link.kt @@ -19,6 +19,7 @@ package com.moveagency.markymark.model.annotated import androidx.compose.runtime.Immutable +import com.moveagency.markymark.model.LinkInteractionListener import com.moveagency.markymark.model.NodeMetadata import kotlinx.collections.immutable.ImmutableList @@ -37,10 +38,28 @@ import kotlinx.collections.immutable.ImmutableList * * For more details see the [Markdown guide](https://www.markdownguide.org/basic-syntax#code). */ + @Immutable -data class Link( - override val metadata: NodeMetadata, - val children: ImmutableList, - val url: String, - val title: String?, -) : AnnotatedStableNode() +sealed class Link : AnnotatedStableNode() { + + abstract val children: ImmutableList + abstract val url: String + abstract val title: String? + + @Immutable + data class BrowserLink( + override val metadata: NodeMetadata, + override val children: ImmutableList, + override val url: String, + override val title: String?, + ) : Link() + + @Immutable + data class CustomLink( + override val metadata: NodeMetadata, + override val children: ImmutableList, + override val url: String, + override val title: String?, + val clickListener: LinkInteractionListener, + ) : Link() +} diff --git a/markymark/src/main/java/com/moveagency/markymark/theme/AnnotatedStyles.kt b/markymark/src/main/java/com/moveagency/markymark/theme/AnnotatedStyles.kt index d883311..b8be953 100644 --- a/markymark/src/main/java/com/moveagency/markymark/theme/AnnotatedStyles.kt +++ b/markymark/src/main/java/com/moveagency/markymark/theme/AnnotatedStyles.kt @@ -35,7 +35,7 @@ import com.moveagency.markymark.model.annotated.* * - [code] -> [Code] -> [annotateCode][DefaultMarkyMarkAnnotator.annotateCode] * - [italic] -> [Italic] -> [annotateItalic][DefaultMarkyMarkAnnotator.annotateItalic] * - [strikethrough] -> [Strikethrough] -> [annotateStrikethrough][DefaultMarkyMarkAnnotator.annotateStrikethrough] - * - [link] -> [Link] -> [annotateLink][DefaultMarkyMarkAnnotator.annotateLink] + * - [link] -> [BrowserLink] -> [annotateLink][DefaultMarkyMarkAnnotator.annotateLink] * - [subscript] -> [Subscript] -> [annotateSubscript][DefaultMarkyMarkAnnotator.annotateSubscript] * - [superscript] -> [Superscript] -> [annotateSuperScript][DefaultMarkyMarkAnnotator.annotateSuperscript] *