Skip to content

Commit a415d3d

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 e85862e commit a415d3d

File tree

8 files changed

+241
-4
lines changed

8 files changed

+241
-4
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.1.0"
23
commonmark = "0.24.0"
34
composeDesktop = "1.7.1"
45
detekt = "1.23.6"
@@ -16,6 +17,11 @@ kotlinxBinaryCompat = "0.16.3"
1617
ktfmtGradlePlugin = "0.20.1"
1718

1819
[libraries]
20+
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
21+
# network is only needed in a standalone non-ide version
22+
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
23+
coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" }
24+
1925
commonmark-core = { module = "org.commonmark:commonmark", version.ref = "commonmark" }
2026
commonmark-ext-autolink = { module = "org.commonmark:commonmark-ext-autolink", version.ref = "commonmark" }
2127
commonmark-ext-gfm-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" }
@@ -26,6 +32,7 @@ filePicker = { module = "com.darkrockstudios:mpfilepicker", version = "3.1.0" }
2632
kotlinSarif = { module = "io.github.detekt.sarif4k:sarif4k", version.ref = "kotlinSarif" }
2733
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
2834
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
35+
ktor-client-java = { module = "io.ktor:ktor-client-java", version = "3.0.3" }
2936

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

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.ktor3)
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

+127-3
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,7 @@
1414
</stringArguments>
1515
<arrayArguments>
1616
<arrayArg name="pluginClasspaths">
17-
<args>
18-
<arg>$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-compose-compiler-plugin/2.1.10/kotlin-compose-compiler-plugin-2.1.10.jar</arg>
19-
</args>
17+
<args>$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-compose-compiler-plugin/2.1.10/kotlin-compose-compiler-plugin-2.1.10.jar</args>
2018
</arrayArg>
2119
</arrayArguments>
2220
</compilerArguments>
@@ -55,6 +53,132 @@
5553
</SOURCES>
5654
</library>
5755
</orderEntry>
56+
<orderEntry type="module-library">
57+
<library name="io.coil.kt.coil3.core.jvm" type="repository">
58+
<properties include-transitive-deps="false" maven-id="io.coil-kt.coil3:coil-core-jvm:3.1.0">
59+
<verification>
60+
<artifact url="file://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-core-jvm/3.1.0/coil-core-jvm-3.1.0.jar">
61+
<sha256sum>6d44a11188c53f4eea2e87ebadc6aad90069132c9b029fc1b35aeb0added5ef7</sha256sum>
62+
</artifact>
63+
</verification>
64+
</properties>
65+
<CLASSES>
66+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-core-jvm/3.1.0/coil-core-jvm-3.1.0.jar!/" />
67+
</CLASSES>
68+
<JAVADOC />
69+
<SOURCES>
70+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-core-jvm/3.1.0/coil-core-jvm-3.1.0-sources.jar!/" />
71+
</SOURCES>
72+
</library>
73+
</orderEntry>
74+
<orderEntry type="module-library">
75+
<library name="io.coil.kt.coil3.jvm" type="repository">
76+
<properties include-transitive-deps="false" maven-id="io.coil-kt.coil3:coil-jvm:3.1.0">
77+
<verification>
78+
<artifact url="file://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-jvm/3.1.0/coil-jvm-3.1.0.jar">
79+
<sha256sum>7d0182c987052d6502135a6a6cc7cc68288ef48f990d75ad4c4ae03e25be786e</sha256sum>
80+
</artifact>
81+
</verification>
82+
</properties>
83+
<CLASSES>
84+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-jvm/3.1.0/coil-jvm-3.1.0.jar!/" />
85+
</CLASSES>
86+
<JAVADOC />
87+
<SOURCES>
88+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-jvm/3.1.0/coil-jvm-3.1.0-sources.jar!/" />
89+
</SOURCES>
90+
</library>
91+
</orderEntry>
92+
<orderEntry type="module-library" exported="">
93+
<library name="io.coil.kt.coil3.compose.core.jvm" type="repository">
94+
<properties include-transitive-deps="false" maven-id="io.coil-kt.coil3:coil-compose-core-jvm:3.1.0">
95+
<verification>
96+
<artifact url="file://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-compose-core-jvm/3.1.0/coil-compose-core-jvm-3.1.0.jar">
97+
<sha256sum>8aa1d7ae1d11f969e8cdcc8fee42b7ee6a036e21f70567e3c3486edd9d7dc594</sha256sum>
98+
</artifact>
99+
</verification>
100+
</properties>
101+
<CLASSES>
102+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-compose-core-jvm/3.1.0/coil-compose-core-jvm-3.1.0.jar!/" />
103+
</CLASSES>
104+
<JAVADOC />
105+
<SOURCES>
106+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-compose-core-jvm/3.1.0/coil-compose-core-jvm-3.1.0-sources.jar!/" />
107+
</SOURCES>
108+
</library>
109+
</orderEntry>
110+
<orderEntry type="module-library" exported="">
111+
<library name="io.coil.kt.coil3.compose.jvm" type="repository">
112+
<properties include-transitive-deps="false" maven-id="io.coil-kt.coil3:coil-compose-jvm:3.1.0">
113+
<verification>
114+
<artifact url="file://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-compose-jvm/3.1.0/coil-compose-jvm-3.1.0.jar">
115+
<sha256sum>d43e0ed4566d30f8ce8e023539240b494be6fa69a1b8c81b4c32f5f465a7cc06</sha256sum>
116+
</artifact>
117+
</verification>
118+
</properties>
119+
<CLASSES>
120+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-compose-jvm/3.1.0/coil-compose-jvm-3.1.0.jar!/" />
121+
</CLASSES>
122+
<JAVADOC />
123+
<SOURCES>
124+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-compose-jvm/3.1.0/coil-compose-jvm-3.1.0-sources.jar!/" />
125+
</SOURCES>
126+
</library>
127+
</orderEntry>
128+
<orderEntry type="module-library">
129+
<library name="io.coil.kt.coil3.network.core.jvm" type="repository">
130+
<properties include-transitive-deps="false" maven-id="io.coil-kt.coil3:coil-network-core-jvm:3.1.0">
131+
<verification>
132+
<artifact url="file://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-network-core-jvm/3.1.0/coil-network-core-jvm-3.1.0.jar">
133+
<sha256sum>8332e45cf792cd24d9814744db84b0e6d33ec6eaf0724bd07ddc1fce7c55591f</sha256sum>
134+
</artifact>
135+
</verification>
136+
</properties>
137+
<CLASSES>
138+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-network-core-jvm/3.1.0/coil-network-core-jvm-3.1.0.jar!/" />
139+
</CLASSES>
140+
<JAVADOC />
141+
<SOURCES>
142+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-network-core-jvm/3.1.0/coil-network-core-jvm-3.1.0-sources.jar!/" />
143+
</SOURCES>
144+
</library>
145+
</orderEntry>
146+
<orderEntry type="module-library">
147+
<library name="io.coil.kt.coil3.network.ktor3.jvm" type="repository">
148+
<properties include-transitive-deps="false" maven-id="io.coil-kt.coil3:coil-network-ktor3-jvm:3.1.0">
149+
<verification>
150+
<artifact url="file://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-network-ktor3-jvm/3.1.0/coil-network-ktor3-jvm-3.1.0.jar">
151+
<sha256sum>cc4f9f8d6d447e7559cb9717c3a45dfbd351ddd06a34724001322512160b0215</sha256sum>
152+
</artifact>
153+
</verification>
154+
</properties>
155+
<CLASSES>
156+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-network-ktor3-jvm/3.1.0/coil-network-ktor3-jvm-3.1.0.jar!/" />
157+
</CLASSES>
158+
<JAVADOC />
159+
<SOURCES>
160+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-network-ktor3-jvm/3.1.0/coil-network-ktor3-jvm-3.1.0-sources.jar!/" />
161+
</SOURCES>
162+
</library>
163+
</orderEntry>
164+
<orderEntry type="module-library" exported="">
165+
<library name="io.coil.kt.coil3.svg.jvm" type="repository">
166+
<properties include-transitive-deps="false" maven-id="io.coil-kt.coil3:coil-svg-jvm:3.1.0">
167+
<verification>
168+
<artifact url="file://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-svg-jvm/3.1.0/coil-svg-jvm-3.1.0.jar">
169+
<sha256sum>413a53b4b6e0a40b851d1a99a01a4cd91ac0dc68facefddd7adfdb2d5456588f</sha256sum>
170+
</artifact>
171+
</verification>
172+
</properties>
173+
<CLASSES>
174+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-svg-jvm/3.1.0/coil-svg-jvm-3.1.0.jar!/" />
175+
</CLASSES>
176+
<JAVADOC />
177+
<SOURCES>
178+
<root url="jar://$MAVEN_REPOSITORY$/io/coil-kt/coil3/coil-svg-jvm/3.1.0/coil-svg-jvm-3.1.0-sources.jar!/" />
179+
</SOURCES>
180+
</library>
181+
</orderEntry>
58182
<orderEntry type="module-library" scope="TEST">
59183
<library name="org.jetbrains.compose.ui.ui.test.junit4" type="repository">
60184
<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/rendering/DefaultInlineMarkdownRenderer.kt

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package org.jetbrains.jewel.markdown.rendering
22

3+
import androidx.compose.foundation.text.appendInlineContent
34
import androidx.compose.ui.graphics.Color
45
import androidx.compose.ui.text.AnnotatedString
56
import androidx.compose.ui.text.AnnotatedString.Builder
@@ -80,7 +81,7 @@ public open class DefaultInlineMarkdownRenderer(rendererExtensions: List<Markdow
8081
}
8182

8283
is InlineMarkdown.Image -> {
83-
// TODO not supported yet — see JEWEL-746
84+
appendInlineContent(child.source, "![${child.title}](${child.source})")
8485
}
8586

8687
is InlineMarkdown.CustomDelimitedNode -> {

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

+77
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,25 @@ 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.foundation.util.JewelLogger
53+
import org.jetbrains.jewel.markdown.InlineMarkdown
4254
import org.jetbrains.jewel.markdown.MarkdownBlock
4355
import org.jetbrains.jewel.markdown.MarkdownBlock.BlockQuote
4456
import org.jetbrains.jewel.markdown.MarkdownBlock.CodeBlock
@@ -106,6 +118,8 @@ public open class DefaultMarkdownBlockRenderer(
106118
}
107119
}
108120

121+
private data class ImageSize(val width: Int, val height: Int)
122+
109123
@Composable
110124
override fun render(
111125
block: Paragraph,
@@ -128,6 +142,7 @@ public open class DefaultMarkdownBlockRenderer(
128142
.clickable(interactionSource = interactionSource, indication = null, onClick = onTextClick),
129143
text = renderedContent,
130144
style = mergedStyle,
145+
inlineContent = renderedImages(block),
131146
)
132147
}
133148

@@ -447,6 +462,50 @@ public open class DefaultMarkdownBlockRenderer(
447462
inlineRenderer.renderAsAnnotatedString(block.inlineContent, styling, enabled, onUrlClick)
448463
}
449464

465+
@Composable
466+
private fun renderedImages(blockInlineContent: WithInlineMarkdown): Map<String, InlineTextContent> =
467+
getImages(blockInlineContent).associate { image -> image.source to imageContent(image) }
468+
469+
@Composable
470+
private fun imageContent(image: InlineMarkdown.Image): InlineTextContent {
471+
val knownSize = remember(image.source) { mutableStateOf<ImageSize?>(null) }
472+
return InlineTextContent(
473+
with(LocalDensity.current) {
474+
// `toSp` ensures that the placeholder size matches the original image size in
475+
// pixels.
476+
// This approach doesn't allow images from appearing larger with different screen
477+
// scaling,
478+
// but simply maintains behavior consistent with standalone AsyncImage rendering.
479+
Placeholder(
480+
width = knownSize.value?.width?.dp?.toSp() ?: 0.sp,
481+
height = knownSize.value?.height?.dp?.toSp() ?: 1.sp,
482+
placeholderVerticalAlign = PlaceholderVerticalAlign.Bottom,
483+
)
484+
}
485+
) {
486+
AsyncImage(
487+
model =
488+
ImageRequest.Builder(LocalPlatformContext.current)
489+
.data(image.source)
490+
// make sure image doesn't get downscaled to the placeholder size
491+
.size(Size.ORIGINAL)
492+
.build(),
493+
contentDescription = image.title,
494+
onSuccess = { state ->
495+
// onSuccess should only be called once, but adding additional protection from
496+
// unnecessary rerender
497+
if (knownSize.value == null) {
498+
knownSize.value = state.result.image.let { ImageSize(it.width, it.height) }
499+
}
500+
},
501+
onError = { error ->
502+
JewelLogger.getInstance("Jewel").warn("AsyncImage loading: ${error.result.throwable}")
503+
},
504+
modifier = knownSize.value?.let { Modifier.height(it.height.dp).width(it.width.dp) } ?: Modifier,
505+
)
506+
}
507+
}
508+
450509
@Composable
451510
protected fun MaybeScrollingContainer(
452511
isScrollable: Boolean,
@@ -475,3 +534,21 @@ public open class DefaultMarkdownBlockRenderer(
475534
override operator fun plus(extension: MarkdownRendererExtension): MarkdownBlockRenderer =
476535
DefaultMarkdownBlockRenderer(rootStyling, rendererExtensions = rendererExtensions + extension, inlineRenderer)
477536
}
537+
538+
private fun getImages(input: WithInlineMarkdown): List<InlineMarkdown.Image> = buildList {
539+
fun collectImagesRecursively(items: List<InlineMarkdown>) {
540+
for (item in items) {
541+
when (item) {
542+
is InlineMarkdown.Image -> {
543+
if (item.source.isNotBlank()) add(item)
544+
}
545+
is WithInlineMarkdown -> {
546+
collectImagesRecursively(item.inlineContent)
547+
}
548+
549+
else -> {}
550+
}
551+
}
552+
}
553+
collectImagesRecursively(input.inlineContent)
554+
}

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
|```

platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/markdown/JewelReadme.kt

+2
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ desktop-optimized theme and set of components.
2121
>
2222
> Use at your own risk!
2323
24+
![logo](https://avatars.githubusercontent.com/u/878437?s=48)
25+
2426
Jewel provides an implementation of the IntelliJ Platform themes that can be used in any Compose for Desktop
2527
application. Additionally, it has a Swing LaF Bridge that only works in the IntelliJ Platform (i.e., used to create IDE
2628
plugins), but automatically mirrors the current Swing LaF into Compose for a native-looking, consistent UI.

0 commit comments

Comments
 (0)