Skip to content

Commit e75e946

Browse files
author
Oleg Baskakov
committed
[JEWEL-746] Load markdown images using Coil3
It supports every image as an inline node; Using built-in coroutine library and ktor2 from the platform; Added SVG support using a coil dependency.
1 parent 0d4af10 commit e75e946

File tree

11 files changed

+239
-14
lines changed

11 files changed

+239
-14
lines changed

platform/jewel/gradle/libs.versions.toml

+7
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
[versions]
2+
coil = "3.0.4"
23
commonmark = "0.24.0"
34
composeDesktop = "1.7.1"
45
detekt = "1.23.6"
@@ -17,6 +18,11 @@ ktfmtGradlePlugin = "0.20.1"
1718
poko = "0.17.1"
1819

1920
[libraries]
21+
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
22+
# network is only needed in a standalone non-ide version
23+
coil-network-ktor2 = { module = "io.coil-kt.coil3:coil-network-ktor2", version.ref = "coil" }
24+
coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" }
25+
2026
commonmark-core = { module = "org.commonmark:commonmark", version.ref = "commonmark" }
2127
commonmark-ext-autolink = { module = "org.commonmark:commonmark-ext-autolink", version.ref = "commonmark" }
2228

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

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

platform/jewel/markdown/core/build.gradle.kts

+4
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ plugins {
99
dependencies {
1010
api(projects.ui)
1111
api(libs.commonmark.core)
12+
runtimeOnly(libs.ktor.client.java)
13+
implementation(libs.coil.compose)
14+
implementation(libs.coil.network.ktor2)
15+
implementation(libs.coil.svg)
1216

1317
testImplementation(compose.desktop.uiTestJUnit4)
1418
testImplementation(projects.ui)

platform/jewel/markdown/core/intellij.platform.jewel.markdown.core.iml

+126
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,132 @@
5656
</SOURCES>
5757
</library>
5858
</orderEntry>
59+
<orderEntry type="module-library">
60+
<library name="io.coil.kt.coil3.core.jvm" type="repository">
61+
<properties include-transitive-deps="false" maven-id="io.coil-kt.coil3:coil-core-jvm:3.0.4">
62+
<verification>
63+
<artifact url="file://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-core-jvm/3.0.4/coil-core-jvm-3.0.4.jar">
64+
<sha256sum>635860bca98d3709b5714e3356100cb20af825a28d2f930bfcb448d7f78cfbdf</sha256sum>
65+
</artifact>
66+
</verification>
67+
</properties>
68+
<CLASSES>
69+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-core-jvm/3.0.4/coil-core-jvm-3.0.4.jar!/" />
70+
</CLASSES>
71+
<JAVADOC />
72+
<SOURCES>
73+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-core-jvm/3.0.4/coil-core-jvm-3.0.4-sources.jar!/" />
74+
</SOURCES>
75+
</library>
76+
</orderEntry>
77+
<orderEntry type="module-library">
78+
<library name="io.coil.kt.coil3.jvm" type="repository">
79+
<properties include-transitive-deps="false" maven-id="io.coil-kt.coil3:coil-jvm:3.0.4">
80+
<verification>
81+
<artifact url="file://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-jvm/3.0.4/coil-jvm-3.0.4.jar">
82+
<sha256sum>cca25e168e69956d01c5581a6523582f1809805d74c478e91802a15e321906e4</sha256sum>
83+
</artifact>
84+
</verification>
85+
</properties>
86+
<CLASSES>
87+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-jvm/3.0.4/coil-jvm-3.0.4.jar!/" />
88+
</CLASSES>
89+
<JAVADOC />
90+
<SOURCES>
91+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-jvm/3.0.4/coil-jvm-3.0.4-sources.jar!/" />
92+
</SOURCES>
93+
</library>
94+
</orderEntry>
95+
<orderEntry type="module-library" exported="">
96+
<library name="io.coil.kt.coil3.compose.core.jvm" type="repository">
97+
<properties include-transitive-deps="false" maven-id="io.coil-kt.coil3:coil-compose-core-jvm:3.0.4">
98+
<verification>
99+
<artifact url="file://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-compose-core-jvm/3.0.4/coil-compose-core-jvm-3.0.4.jar">
100+
<sha256sum>a0c8585e76bf426025d509e57fa09a7f9cc030327d641b93555714646b28721b</sha256sum>
101+
</artifact>
102+
</verification>
103+
</properties>
104+
<CLASSES>
105+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-compose-core-jvm/3.0.4/coil-compose-core-jvm-3.0.4.jar!/" />
106+
</CLASSES>
107+
<JAVADOC />
108+
<SOURCES>
109+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-compose-core-jvm/3.0.4/coil-compose-core-jvm-3.0.4-sources.jar!/" />
110+
</SOURCES>
111+
</library>
112+
</orderEntry>
113+
<orderEntry type="module-library" exported="">
114+
<library name="io.coil.kt.coil3.compose.jvm" type="repository">
115+
<properties include-transitive-deps="false" maven-id="io.coil-kt.coil3:coil-compose-jvm:3.0.4">
116+
<verification>
117+
<artifact url="file://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-compose-jvm/3.0.4/coil-compose-jvm-3.0.4.jar">
118+
<sha256sum>fdff25fb0065a29130ef4fc2276c91c11fd69ea8df8b1c241f648ce1b161b5a3</sha256sum>
119+
</artifact>
120+
</verification>
121+
</properties>
122+
<CLASSES>
123+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-compose-jvm/3.0.4/coil-compose-jvm-3.0.4.jar!/" />
124+
</CLASSES>
125+
<JAVADOC />
126+
<SOURCES>
127+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-compose-jvm/3.0.4/coil-compose-jvm-3.0.4-sources.jar!/" />
128+
</SOURCES>
129+
</library>
130+
</orderEntry>
131+
<orderEntry type="module-library">
132+
<library name="io.coil.kt.coil3.network.core.jvm" type="repository">
133+
<properties include-transitive-deps="false" maven-id="io.coil-kt.coil3:coil-network-core-jvm:3.0.4">
134+
<verification>
135+
<artifact url="file://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-network-core-jvm/3.0.4/coil-network-core-jvm-3.0.4.jar">
136+
<sha256sum>3268b588b18708b91bd9db300579d3fec9970570aeda8a34a5ee5b8a94fb43f8</sha256sum>
137+
</artifact>
138+
</verification>
139+
</properties>
140+
<CLASSES>
141+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-network-core-jvm/3.0.4/coil-network-core-jvm-3.0.4.jar!/" />
142+
</CLASSES>
143+
<JAVADOC />
144+
<SOURCES>
145+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-network-core-jvm/3.0.4/coil-network-core-jvm-3.0.4-sources.jar!/" />
146+
</SOURCES>
147+
</library>
148+
</orderEntry>
149+
<orderEntry type="module-library">
150+
<library name="io.coil.kt.coil3.network.ktor2.jvm" type="repository">
151+
<properties include-transitive-deps="false" maven-id="io.coil-kt.coil3:coil-network-ktor2-jvm:3.0.4">
152+
<verification>
153+
<artifact url="file://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-network-ktor2-jvm/3.0.4/coil-network-ktor2-jvm-3.0.4.jar">
154+
<sha256sum>1b5b67d6cc0ae0272e285cabe37e270637780f2465eeef0fbb883b6f89311124</sha256sum>
155+
</artifact>
156+
</verification>
157+
</properties>
158+
<CLASSES>
159+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-network-ktor2-jvm/3.0.4/coil-network-ktor2-jvm-3.0.4.jar!/" />
160+
</CLASSES>
161+
<JAVADOC />
162+
<SOURCES>
163+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-network-ktor2-jvm/3.0.4/coil-network-ktor2-jvm-3.0.4-sources.jar!/" />
164+
</SOURCES>
165+
</library>
166+
</orderEntry>
167+
<orderEntry type="module-library" exported="">
168+
<library name="io.coil.kt.coil3.svg.jvm" type="repository">
169+
<properties include-transitive-deps="false" maven-id="io.coil-kt.coil3:coil-svg-jvm:3.0.4">
170+
<verification>
171+
<artifact url="file://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-svg-jvm/3.0.4/coil-svg-jvm-3.0.4.jar">
172+
<sha256sum>ce0c4898911033bc28eb1483ce48b6f87ec76d01fc04574b79a8e9dfd56aa311</sha256sum>
173+
</artifact>
174+
</verification>
175+
</properties>
176+
<CLASSES>
177+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-svg-jvm/3.0.4/coil-svg-jvm-3.0.4.jar!/" />
178+
</CLASSES>
179+
<JAVADOC />
180+
<SOURCES>
181+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-svg-jvm/3.0.4/coil-svg-jvm-3.0.4-sources.jar!/" />
182+
</SOURCES>
183+
</library>
184+
</orderEntry>
59185
<orderEntry type="module-library" scope="TEST">
60186
<library name="org.jetbrains.compose.ui.ui.test.junit4" type="repository">
61187
<properties include-transitive-deps="false" maven-id="org.jetbrains.compose.ui:ui-test-junit4:1.7.1">

platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/Markdown.kt

+20
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ import androidx.compose.runtime.setValue
1717
import androidx.compose.ui.Modifier
1818
import androidx.compose.ui.semantics.semantics
1919
import androidx.compose.ui.unit.dp
20+
import coil3.ImageLoader
21+
import coil3.PlatformContext
22+
import coil3.compose.setSingletonImageLoaderFactory
23+
import coil3.memory.MemoryCache
2024
import kotlinx.coroutines.CoroutineDispatcher
2125
import kotlinx.coroutines.Dispatchers
2226
import kotlinx.coroutines.withContext
@@ -76,6 +80,7 @@ public fun Markdown(
7680
markdownStyling: MarkdownStyling = JewelTheme.markdownStyling,
7781
blockRenderer: MarkdownBlockRenderer = DefaultMarkdownBlockRenderer(markdownStyling),
7882
) {
83+
setSingletonImageLoaderFactory(::createImageLoader)
7984
if (selectable) {
8085
SelectionContainer(modifier.semantics { rawMarkdown = markdown }) {
8186
Column(verticalArrangement = Arrangement.spacedBy(markdownStyling.blockVerticalSpacing)) {
@@ -110,6 +115,7 @@ public fun LazyMarkdown(
110115
markdownStyling: MarkdownStyling = JewelTheme.markdownStyling,
111116
blockRenderer: MarkdownBlockRenderer = JewelTheme.markdownBlockRenderer,
112117
) {
118+
setSingletonImageLoaderFactory(::createImageLoader)
113119
if (selectable) {
114120
SelectionContainer(modifier) {
115121
LazyColumn(
@@ -131,3 +137,17 @@ public fun LazyMarkdown(
131137
}
132138
}
133139
}
140+
141+
private const val IMAGES_MEMORY_CACHE_SIZE = 24L * 1024 * 1024 // 24mb
142+
143+
/**
144+
* This method sets up an image loader with a memory cache but disables the disk cache. Disabling the disk cache is
145+
* necessary because Coil crashes when attempting to use the file system cache with IDEA platform.
146+
*
147+
* Otherwise, Coil3 will throw java.lang.NoSuchMethodError: kotlinx.coroutines.CoroutineDispatcher.limitedParallelism
148+
*/
149+
private fun createImageLoader(context: PlatformContext) =
150+
ImageLoader.Builder(context)
151+
.memoryCache { MemoryCache.Builder().maxSizeBytes(IMAGES_MEMORY_CACHE_SIZE).build() }
152+
.diskCache(null)
153+
.build()

platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessor.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ public class MarkdownProcessor(
236236
}
237237

238238
private fun Node.tryProcessMarkdownBlock(): MarkdownBlock? =
239-
// Non-Block children are ignored
239+
// Nodes that are not blocks or unsupported types are ignored
240240
when (this) {
241241
is Paragraph -> toMarkdownParagraph()
242242
is Heading -> toMarkdownHeadingOrNull()

platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultInlineMarkdownRenderer.kt

+1-11
Original file line numberDiff line numberDiff line change
@@ -77,17 +77,7 @@ public open class DefaultInlineMarkdownRenderer(private val rendererExtensions:
7777
}
7878

7979
is InlineMarkdown.Image -> {
80-
appendInlineContent(
81-
INLINE_IMAGE,
82-
buildString {
83-
appendLine(child.source)
84-
append(child.alt)
85-
if (!child.title.isNullOrBlank()) {
86-
appendLine()
87-
append(child.title)
88-
}
89-
},
90-
)
80+
appendInlineContent(child.source, "![${child.title}](...)")
9181
}
9282

9383
is InlineMarkdown.CustomNode ->

platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt

+73
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ import androidx.compose.foundation.layout.height
1414
import androidx.compose.foundation.layout.padding
1515
import androidx.compose.foundation.layout.width
1616
import androidx.compose.foundation.layout.widthIn
17+
import androidx.compose.foundation.text.InlineTextContent
1718
import androidx.compose.foundation.text.selection.DisableSelection
1819
import androidx.compose.runtime.Composable
1920
import androidx.compose.runtime.CompositionLocalProvider
2021
import androidx.compose.runtime.collectAsState
2122
import androidx.compose.runtime.getValue
23+
import androidx.compose.runtime.mutableStateOf
2224
import androidx.compose.runtime.remember
2325
import androidx.compose.ui.Alignment
2426
import androidx.compose.ui.Modifier
@@ -30,15 +32,24 @@ import androidx.compose.ui.graphics.isSpecified
3032
import androidx.compose.ui.graphics.takeOrElse
3133
import androidx.compose.ui.input.pointer.PointerIcon
3234
import androidx.compose.ui.input.pointer.pointerHoverIcon
35+
import androidx.compose.ui.platform.LocalDensity
3336
import androidx.compose.ui.text.AnnotatedString
37+
import androidx.compose.ui.text.Placeholder
38+
import androidx.compose.ui.text.PlaceholderVerticalAlign
3439
import androidx.compose.ui.text.TextStyle
3540
import androidx.compose.ui.unit.Dp
3641
import androidx.compose.ui.unit.LayoutDirection.Ltr
3742
import androidx.compose.ui.unit.dp
43+
import androidx.compose.ui.unit.sp
44+
import coil3.compose.AsyncImage
45+
import coil3.compose.LocalPlatformContext
46+
import coil3.request.ImageRequest
47+
import coil3.size.Size
3848
import org.jetbrains.jewel.foundation.ExperimentalJewelApi
3949
import org.jetbrains.jewel.foundation.code.MimeType
4050
import org.jetbrains.jewel.foundation.code.highlighting.LocalCodeHighlighter
4151
import org.jetbrains.jewel.foundation.theme.LocalContentColor
52+
import org.jetbrains.jewel.markdown.InlineMarkdown
4253
import org.jetbrains.jewel.markdown.MarkdownBlock
4354
import org.jetbrains.jewel.markdown.MarkdownBlock.BlockQuote
4455
import org.jetbrains.jewel.markdown.MarkdownBlock.CodeBlock
@@ -102,6 +113,8 @@ public open class DefaultMarkdownBlockRenderer(
102113
}
103114
}
104115

116+
private data class ImageSize(val width: Int, val height: Int)
117+
105118
@Composable
106119
override fun render(
107120
block: Paragraph,
@@ -124,9 +137,29 @@ public open class DefaultMarkdownBlockRenderer(
124137
.clickable(interactionSource = interactionSource, indication = null, onClick = onTextClick),
125138
text = renderedContent,
126139
style = mergedStyle,
140+
inlineContent = renderedImages(block.inlineContent),
127141
)
128142
}
129143

144+
private fun getImages(input: List<InlineMarkdown>) =
145+
buildList<InlineMarkdown.Image> {
146+
fun collectImagesRecursively(items: List<InlineMarkdown>) {
147+
for (item in items) {
148+
when (item) {
149+
is InlineMarkdown.Image -> {
150+
if (item.source.isNotBlank()) add(item)
151+
}
152+
is WithInlineMarkdown -> {
153+
collectImagesRecursively(item.inlineContent)
154+
}
155+
156+
else -> {}
157+
}
158+
}
159+
}
160+
collectImagesRecursively(input)
161+
}
162+
130163
@Composable
131164
override fun render(
132165
block: Heading,
@@ -439,6 +472,46 @@ public open class DefaultMarkdownBlockRenderer(
439472
inlineRenderer.renderAsAnnotatedString(block.inlineContent, styling, enabled, onUrlClick)
440473
}
441474

475+
@Composable
476+
private fun renderedImages(blockInlineContent: List<InlineMarkdown>): Map<String, InlineTextContent> =
477+
getImages(blockInlineContent).associate { image -> image.source to imageContent(image) }
478+
479+
@Composable
480+
private fun imageContent(image: InlineMarkdown.Image): InlineTextContent {
481+
val knownSize = remember(image.source) { mutableStateOf<ImageSize?>(null) }
482+
return InlineTextContent(
483+
with(LocalDensity.current) {
484+
// `toSp` ensures that the placeholder size matches the original image size in
485+
// pixels.
486+
// This approach doesn't allow images from appearing larger with different screen
487+
// scaling,
488+
// but simply maintains behavior consistent with standalone AsyncImage rendering.
489+
Placeholder(
490+
width = knownSize.value?.width?.dp?.toSp() ?: 0.sp,
491+
height = knownSize.value?.height?.dp?.toSp() ?: 1.sp,
492+
placeholderVerticalAlign = PlaceholderVerticalAlign.Bottom,
493+
)
494+
}
495+
) {
496+
AsyncImage(
497+
model =
498+
ImageRequest.Builder(LocalPlatformContext.current)
499+
.data(image.source)
500+
// make sure image doesn't get downscaled to the placeholder size
501+
.size(Size.ORIGINAL)
502+
.build(),
503+
contentDescription = image.title,
504+
onSuccess = { state ->
505+
if (knownSize.value == null) {
506+
knownSize.value = state.result.image.let { ImageSize(it.width, it.height) }
507+
}
508+
},
509+
// TODO: log onError as warning
510+
modifier = knownSize.value?.let { Modifier.height(it.height.dp).width(it.width.dp) } ?: Modifier,
511+
)
512+
}
513+
}
514+
442515
@Composable
443516
protected fun MaybeScrollingContainer(
444517
isScrollable: Boolean,

platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizerTest.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import androidx.compose.ui.text.style.TextAlign
2121
import androidx.compose.ui.unit.Density
2222
import androidx.compose.ui.unit.dp
2323
import androidx.compose.ui.unit.sp
24+
import java.util.Arrays
2425
import kotlin.time.Duration.Companion.milliseconds
2526
import kotlinx.coroutines.Dispatchers
2627
import kotlinx.coroutines.launch
@@ -59,7 +60,6 @@ import org.jetbrains.jewel.ui.component.styling.TrackClickBehavior
5960
import org.junit.Assert.assertEquals
6061
import org.junit.Assert.assertTrue
6162
import org.junit.Test
62-
import java.util.Arrays
6363

6464
@Suppress("LargeClass")
6565
class ScrollingSynchronizerTest {

platform/jewel/samples/ide-plugin/src/main/kotlin/org/jetbrains/jewel/samples/ideplugin/ComponentShowcaseTab.kt

+2
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,8 @@ private fun MarkdownExample(project: Project) {
421421
| * Tables
422422
| * And more — I am running out of random things to say 😆
423423
|
424+
|![logo](https://avatars.githubusercontent.com/u/878437?s=48)
425+
|
424426
|```kotlin
425427
|fun hello() = "World"
426428
|```

0 commit comments

Comments
 (0)