Skip to content

Commit

Permalink
Load markdown images using Coil3
Browse files Browse the repository at this point in the history
It supports every image as inline
  • Loading branch information
obask committed Nov 25, 2024
1 parent a3525ea commit 46f29ca
Show file tree
Hide file tree
Showing 8 changed files with 108 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
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 @@ -167,7 +167,7 @@ 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 Heading -> toMarkdownHeadingOrNull()
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,11 +15,13 @@ 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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
Expand All @@ -31,15 +33,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 Down Expand Up @@ -103,6 +114,8 @@ public open class DefaultMarkdownBlockRenderer(
}
}

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

@Composable
override fun render(
block: Paragraph,
Expand All @@ -125,9 +138,27 @@ public open class DefaultMarkdownBlockRenderer(
.clickable(interactionSource = interactionSource, indication = null, onClick = onTextClick),
text = renderedContent,
style = mergedStyle,
inlineContent = renderedImages(block.inlineContent),
)
}

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 @@ -434,6 +465,45 @@ public open class DefaultMarkdownBlockRenderer(
inlineRenderer.renderAsAnnotatedString(block.inlineContent, styling, enabled, onUrlClick)
}

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

@Composable
private fun imageContent(image: InlineMarkdown.Image): InlineTextContent {
val knownSize = remember(image.source) { mutableStateOf<ImageSize?>(null) }
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.
Placeholder(
width = knownSize.value?.width?.dp?.toSp() ?: 0.sp,
height = knownSize.value?.height?.dp?.toSp() ?: 1.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 (knownSize.value == null) {
knownSize.value = state.result.image.let { ImageSize(it.width, it.height) }
}
},
modifier = knownSize.value?.let { Modifier.height(it.height.dp).width(it.width.dp) } ?: Modifier,
)
}
}

@Composable
private fun MaybeScrollingContainer(
isScrollable: Boolean,
Expand Down
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 46f29ca

Please sign in to comment.