Skip to content

Commit

Permalink
Load markdown images using Coil3
Browse files Browse the repository at this point in the history
It supports both block and inline images
  • Loading branch information
obask committed Nov 18, 2024
1 parent a3525ea commit 78a3bc9
Show file tree
Hide file tree
Showing 11 changed files with 137 additions and 12 deletions.
6 changes: 6 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
[versions]
coil = "3.0.2"
commonmark = "0.24.0"
composeDesktop = "1.7.0"
detekt = "1.23.6"
Expand All @@ -17,6 +18,10 @@ ktfmtGradlePlugin = "0.20.1"
poko = "0.17.1"

[libraries]
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-network-ktor2 = { module = "io.coil-kt.coil3:coil-network-ktor2", version.ref = "coil" }
coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" }

commonmark-core = { module = "org.commonmark:commonmark", version.ref = "commonmark" }
commonmark-ext-autolink = { module = "org.commonmark:commonmark-ext-autolink", version.ref = "commonmark" }

Expand All @@ -25,6 +30,7 @@ filePicker = { module = "com.darkrockstudios:mpfilepicker", version = "3.1.0" }
kotlinSarif = { module = "io.github.detekt.sarif4k:sarif4k", version.ref = "kotlinSarif" }
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
ktor-client-java = { module = "io.ktor:ktor-client-java", version = "2.3.12" }

jna-core = { module = "net.java.dev.jna:jna", version.ref = "jna" }

Expand Down
12 changes: 12 additions & 0 deletions markdown/core/api/core.api
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,16 @@ public final class org/jetbrains/jewel/markdown/MarkdownBlock$HtmlBlock : org/je
public fun toString ()Ljava/lang/String;
}

public final class org/jetbrains/jewel/markdown/MarkdownBlock$Image : org/jetbrains/jewel/markdown/MarkdownBlock {
public static final field $stable I
public fun <init> (Ljava/lang/String;Ljava/lang/String;)V
public fun equals (Ljava/lang/Object;)Z
public final fun getDestination ()Ljava/lang/String;
public final fun getTitle ()Ljava/lang/String;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public abstract interface class org/jetbrains/jewel/markdown/MarkdownBlock$ListBlock : org/jetbrains/jewel/markdown/MarkdownBlock {
public abstract fun getChildren ()Ljava/util/List;
public abstract fun isTight ()Z
Expand Down Expand Up @@ -327,6 +337,7 @@ public class org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer
public fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$Heading;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$Heading$HN;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
public fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$Heading;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$Heading;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
public fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$HtmlBlock;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$HtmlBlock;Landroidx/compose/runtime/Composer;I)V
public fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$Image;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$Image;Landroidx/compose/runtime/Composer;I)V
public fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$ListBlock$OrderedList;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$List$Ordered;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
public fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$ListBlock$UnorderedList;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$List$Unordered;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
public fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$ListBlock;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$List;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
Expand Down Expand Up @@ -379,6 +390,7 @@ public abstract interface class org/jetbrains/jewel/markdown/rendering/MarkdownB
public abstract fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$Heading;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$Heading$HN;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
public abstract fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$Heading;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$Heading;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
public abstract fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$HtmlBlock;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$HtmlBlock;Landroidx/compose/runtime/Composer;I)V
public abstract fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$Image;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$Image;Landroidx/compose/runtime/Composer;I)V
public abstract fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$ListBlock$OrderedList;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$List$Ordered;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
public abstract fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$ListBlock$UnorderedList;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$List$Unordered;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
public abstract fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$ListBlock;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$List;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
Expand Down
4 changes: 4 additions & 0 deletions markdown/core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ plugins {
dependencies {
api(projects.ui)
api(libs.commonmark.core)
api(libs.ktor.client.java)
implementation(libs.coil.compose)
implementation(libs.coil.network.ktor2)
implementation(libs.coil.svg)

testImplementation(compose.desktop.uiTestJUnit4)
testImplementation(projects.ui)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.compose.setSingletonImageLoaderFactory
import coil3.memory.MemoryCache
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
Expand Down Expand Up @@ -76,6 +80,7 @@ public fun Markdown(
markdownStyling: MarkdownStyling = JewelTheme.markdownStyling,
blockRenderer: MarkdownBlockRenderer = DefaultMarkdownBlockRenderer(markdownStyling),
) {
setSingletonImageLoaderFactory(::createImageLoader)
if (selectable) {
SelectionContainer(modifier.semantics { rawMarkdown = markdown }) {
Column(verticalArrangement = Arrangement.spacedBy(markdownStyling.blockVerticalSpacing)) {
Expand Down Expand Up @@ -110,6 +115,7 @@ public fun LazyMarkdown(
markdownStyling: MarkdownStyling = JewelTheme.markdownStyling,
blockRenderer: MarkdownBlockRenderer = JewelTheme.markdownBlockRenderer,
) {
setSingletonImageLoaderFactory(::createImageLoader)
if (selectable) {
SelectionContainer(modifier) {
LazyColumn(
Expand All @@ -131,3 +137,17 @@ public fun LazyMarkdown(
}
}
}

private const val IMAGES_MEMORY_CACHE_SIZE = 24L * 1024 * 1024 // 24mb

/**
* This method sets up an image loader with a memory cache but disables the disk cache. Disabling the disk cache is
* necessary because Coil crashes when attempting to use the file system cache with IDEA platform.
*
* Otherwise Coil3 will throw java.lang.NoSuchMethodError: kotlinx.coroutines.CoroutineDispatcher.limitedParallelism
*/
private fun createImageLoader(context: PlatformContext) =
ImageLoader.Builder(context)
.memoryCache { MemoryCache.Builder().maxSizeBytes(IMAGES_MEMORY_CACHE_SIZE).build() }
.diskCache(null)
.build()
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ public sealed interface MarkdownBlock {

@GenerateDataFunctions public class HtmlBlock(public val content: String) : MarkdownBlock

@GenerateDataFunctions public class Image(public val destination: String, public val title: String) : MarkdownBlock

public sealed interface ListBlock : MarkdownBlock {
public val children: List<ListItem>
public val isTight: Boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import org.commonmark.node.Document
import org.commonmark.node.FencedCodeBlock
import org.commonmark.node.Heading
import org.commonmark.node.HtmlBlock
import org.commonmark.node.Image
import org.commonmark.node.IndentedCodeBlock
import org.commonmark.node.ListBlock as CMListBlock
import org.commonmark.node.ListItem
Expand Down Expand Up @@ -169,7 +170,15 @@ public class MarkdownProcessor(
private fun Node.tryProcessMarkdownBlock(): MarkdownBlock? =
// Non-Block children are ignored
when (this) {
is Paragraph -> toMarkdownParagraph()
is Paragraph -> {
val child = firstChild
if (child is Image && child === lastChild) {
// only render standalone images as blocks
child.toMarkdownImageOrNull()
} else {
toMarkdownParagraph()
}
}
is Heading -> toMarkdownHeadingOrNull()
is BulletList -> toMarkdownListOrNull()
is OrderedList -> toMarkdownListOrNull()
Expand Down Expand Up @@ -208,6 +217,12 @@ public class MarkdownProcessor(
private fun IndentedCodeBlock.toMarkdownCodeBlockOrNull(): CodeBlock.IndentedCodeBlock =
CodeBlock.IndentedCodeBlock(literal.trimEnd('\n'))

private fun Image.toMarkdownImageOrNull(): MarkdownBlock.Image? {
if (destination.isBlank()) return null

return MarkdownBlock.Image(destination.trim(), (title ?: "").trim())
}

private fun BulletList.toMarkdownListOrNull(): ListBlock.UnorderedList? {
val children = processListItems()
if (children.isEmpty()) return null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,17 +77,7 @@ public open class DefaultInlineMarkdownRenderer(private val rendererExtensions:
}

is InlineMarkdown.Image -> {
appendInlineContent(
INLINE_IMAGE,
buildString {
appendLine(child.source)
append(child.alt)
if (!child.title.isNullOrBlank()) {
appendLine()
append(child.title)
}
},
)
appendInlineContent(child.source, "![${child.title}](...)")
}

is InlineMarkdown.CustomNode ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.selection.DisableSelection
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
Expand All @@ -32,14 +35,23 @@ import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection.Ltr
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import coil3.size.Size
import org.jetbrains.jewel.foundation.ExperimentalJewelApi
import org.jetbrains.jewel.foundation.code.MimeType
import org.jetbrains.jewel.foundation.code.highlighting.LocalCodeHighlighter
import org.jetbrains.jewel.foundation.theme.LocalContentColor
import org.jetbrains.jewel.markdown.InlineMarkdown
import org.jetbrains.jewel.markdown.MarkdownBlock
import org.jetbrains.jewel.markdown.MarkdownBlock.BlockQuote
import org.jetbrains.jewel.markdown.MarkdownBlock.CodeBlock
Expand Down Expand Up @@ -89,6 +101,7 @@ public open class DefaultMarkdownBlockRenderer(
is IndentedCodeBlock -> render(block, rootStyling.code.indented)
is Heading -> render(block, rootStyling.heading, enabled, onUrlClick, onTextClick)
is HtmlBlock -> render(block, rootStyling.htmlBlock)
is MarkdownBlock.Image -> render(block, rootStyling.image)
is OrderedList -> render(block, rootStyling.list.ordered, enabled, onUrlClick, onTextClick)
is UnorderedList -> render(block, rootStyling.list.unordered, enabled, onUrlClick, onTextClick)
is ListItem -> render(block, enabled, onUrlClick, onTextClick)
Expand All @@ -103,6 +116,8 @@ public open class DefaultMarkdownBlockRenderer(
}
}

private data class ImageSize(val w: TextUnit, val h: TextUnit)

@Composable
override fun render(
block: Paragraph,
Expand All @@ -112,6 +127,7 @@ public open class DefaultMarkdownBlockRenderer(
onTextClick: () -> Unit,
) {
val renderedContent = rememberRenderedContent(block, styling.inlinesStyling, enabled, onUrlClick)
val knownSizes = remember { mutableStateMapOf<String, ImageSize>() }
val textColor =
styling.inlinesStyling.textStyle.color
.takeOrElse { LocalContentColor.current }
Expand All @@ -125,9 +141,27 @@ public open class DefaultMarkdownBlockRenderer(
.clickable(interactionSource = interactionSource, indication = null, onClick = onTextClick),
text = renderedContent,
style = mergedStyle,
inlineContent = renderedImages(block.inlineContent, knownSizes),
)
}

private fun getImages(input: List<InlineMarkdown>) =
buildList<InlineMarkdown.Image> {
fun dfs(items: List<InlineMarkdown>) {
for (item in items) {
when (item) {
is InlineMarkdown.Image -> add(item)
is WithInlineMarkdown -> {
dfs(item.inlineContent)
}

else -> {}
}
}
}
dfs(input)
}

@Composable
override fun render(
block: Heading,
Expand Down Expand Up @@ -423,6 +457,11 @@ public open class DefaultMarkdownBlockRenderer(
// HTML blocks are intentionally not rendered
}

@Composable
override fun render(block: MarkdownBlock.Image, styling: MarkdownStyling.Image) {
AsyncImage(model = block.destination, contentDescription = block.title)
}

@Composable
private fun rememberRenderedContent(
block: WithInlineMarkdown,
Expand All @@ -434,6 +473,37 @@ public open class DefaultMarkdownBlockRenderer(
inlineRenderer.renderAsAnnotatedString(block.inlineContent, styling, enabled, onUrlClick)
}

@Composable
private fun renderedImages(
blockInlineContent: List<InlineMarkdown>,
knownSizes: SnapshotStateMap<String, ImageSize>,
): Map<String, InlineTextContent> =
getImages(blockInlineContent).associate { image -> image.source to imageContent(image, knownSizes) }

@Composable
private fun imageContent(
image: InlineMarkdown.Image,
knownSizes: SnapshotStateMap<String, ImageSize>,
): InlineTextContent {
return InlineTextContent(
Placeholder(
width = knownSizes[image.source]?.w ?: 100.sp,
height = knownSizes[image.source]?.h ?: 100.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Bottom,
)
) {
AsyncImage(
model =
ImageRequest.Builder(LocalPlatformContext.current).data(image.source).size(Size.ORIGINAL).build(),
contentDescription = image.title,
// TODO: figure out what to use instead of *.sp
onSuccess = { state ->
knownSizes[image.source] = state.result.image.let { ImageSize(it.width.sp, it.height.sp) }
},
)
}
}

@Composable
private fun MaybeScrollingContainer(
isScrollable: Boolean,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,5 +103,7 @@ public interface MarkdownBlockRenderer {

@Composable public fun render(block: HtmlBlock, styling: MarkdownStyling.HtmlBlock)

@Composable public fun render(block: MarkdownBlock.Image, styling: MarkdownStyling.Image)

public companion object
}
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,8 @@ private fun MarkdownExample(project: Project) {
| * Tables
| * And more — I am running out of random things to say 😆
|
|![logo](https://avatars.githubusercontent.com/u/878437?s=42)
|
|```kotlin
|fun hello() = "World"
|```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ desktop-optimized theme and set of components.
>
> Use at your own risk!
![logo](https://avatars.githubusercontent.com/u/878437?s=42)
Jewel provides an implementation of the IntelliJ Platform themes that can be used in any Compose for Desktop
application. Additionally, it has a Swing LaF Bridge that only works in the IntelliJ Platform (i.e., used to create IDE
plugins), but automatically mirrors the current Swing LaF into Compose for a native-looking, consistent UI.
Expand Down

0 comments on commit 78a3bc9

Please sign in to comment.