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 20, 2024
1 parent a3525ea commit f076393
Show file tree
Hide file tree
Showing 11 changed files with 156 additions and 13 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)
runtimeOnly(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,8 @@ public fun Markdown(
markdownStyling: MarkdownStyling = JewelTheme.markdownStyling,
blockRenderer: MarkdownBlockRenderer = DefaultMarkdownBlockRenderer(markdownStyling),
) {
// TODO: delete this once Jewel is moved to intellij-community repo
setSingletonImageLoaderFactory(::createImageLoader)
if (selectable) {
SelectionContainer(modifier.semantics { rawMarkdown = markdown }) {
Column(verticalArrangement = Arrangement.spacedBy(markdownStyling.blockVerticalSpacing)) {
Expand Down Expand Up @@ -110,6 +116,8 @@ public fun LazyMarkdown(
markdownStyling: MarkdownStyling = JewelTheme.markdownStyling,
blockRenderer: MarkdownBlockRenderer = JewelTheme.markdownBlockRenderer,
) {
// TODO: delete this once Jewel is moved to intellij-community repo
setSingletonImageLoaderFactory(::createImageLoader)
if (selectable) {
SelectionContainer(modifier) {
LazyColumn(
Expand All @@ -131,3 +139,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 @@ -167,9 +168,18 @@ public class MarkdownProcessor(
}

private fun Node.tryProcessMarkdownBlock(): MarkdownBlock? =
// Non-Block children are ignored
// Nodes that are not blocks or unsupported types are ignored
when (this) {
is Paragraph -> toMarkdownParagraph()
is Paragraph -> {
val child = firstChild
// Check if the paragraph contains only a single image without additional text
if (child is Image && child === lastChild) {
// Extract the standalone image from the paragraph and render it as a block
child.toMarkdownImageOrNull()
} else {
toMarkdownParagraph()
}
}
is Heading -> toMarkdownHeadingOrNull()
is BulletList -> toMarkdownListOrNull()
is OrderedList -> toMarkdownListOrNull()
Expand Down Expand Up @@ -208,6 +218,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 @@ -31,15 +34,24 @@ import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.platform.LocalDensity
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.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 All @@ -48,6 +60,7 @@ import org.jetbrains.jewel.markdown.MarkdownBlock.CodeBlock.IndentedCodeBlock
import org.jetbrains.jewel.markdown.MarkdownBlock.CustomBlock
import org.jetbrains.jewel.markdown.MarkdownBlock.Heading
import org.jetbrains.jewel.markdown.MarkdownBlock.HtmlBlock
import org.jetbrains.jewel.markdown.MarkdownBlock.Image
import org.jetbrains.jewel.markdown.MarkdownBlock.ListBlock
import org.jetbrains.jewel.markdown.MarkdownBlock.ListBlock.OrderedList
import org.jetbrains.jewel.markdown.MarkdownBlock.ListBlock.UnorderedList
Expand Down Expand Up @@ -89,6 +102,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 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 +117,8 @@ public open class DefaultMarkdownBlockRenderer(
}
}

private data class ImageSize(val width: Int, val height: Int)

@Composable
override fun render(
block: Paragraph,
Expand All @@ -112,6 +128,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 +142,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 collectImagesRecursively(items: List<InlineMarkdown>) {
for (item in items) {
when (item) {
is InlineMarkdown.Image -> add(item)
is WithInlineMarkdown -> {
collectImagesRecursively(item.inlineContent)
}

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

@Composable
override fun render(
block: Heading,
Expand Down Expand Up @@ -423,6 +458,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 +474,51 @@ 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(
with(LocalDensity.current) {
// `toSp` ensures that the placeholder size matches the original image size in
// pixels.
// This approach doesn't allow images from appearing larger with different screen
// scaling,
// but simply maintains behavior consistent with standalone AsyncImage rendering.
val imageSize = knownSizes[image.source]
Placeholder(
width = imageSize?.width?.toSp() ?: 20.sp,
height = imageSize?.height?.toSp() ?: 20.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Bottom,
)
}
) {
AsyncImage(
model =
ImageRequest.Builder(LocalPlatformContext.current)
.data(image.source)
// make sure image doesn't get downscaled to the placeholder size
.size(Size.ORIGINAL)
.build(),
contentDescription = image.title,
onSuccess = { state ->
if (image.source !in knownSizes) {
knownSizes[image.source] = state.result.image.let { ImageSize(it.width, it.height) }
}
},
)
}
}

@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=48)
|
|```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=48)
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 f076393

Please sign in to comment.