From 46f29ca820fa5d81c95b1b5d55e9d7b539d98988 Mon Sep 17 00:00:00 2001 From: Oleg Bask Date: Sun, 10 Nov 2024 21:55:23 +0000 Subject: [PATCH] Load markdown images using Coil3 It supports every image as inline --- gradle/libs.versions.toml | 6 ++ markdown/core/build.gradle.kts | 4 ++ .../org/jetbrains/jewel/markdown/Markdown.kt | 22 ++++++ .../markdown/processing/MarkdownProcessor.kt | 2 +- .../DefaultInlineMarkdownRenderer.kt | 12 +--- .../rendering/DefaultMarkdownBlockRenderer.kt | 70 +++++++++++++++++++ .../samples/ideplugin/ComponentShowcaseTab.kt | 2 + .../standalone/view/markdown/JewelReadme.kt | 2 + 8 files changed, 108 insertions(+), 12 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d4089fe4a0..30969437ca 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +coil = "3.0.2" commonmark = "0.24.0" composeDesktop = "1.7.0" detekt = "1.23.6" @@ -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" } @@ -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" } diff --git a/markdown/core/build.gradle.kts b/markdown/core/build.gradle.kts index 151e991cb7..3c6fd8eb08 100644 --- a/markdown/core/build.gradle.kts +++ b/markdown/core/build.gradle.kts @@ -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) diff --git a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/Markdown.kt b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/Markdown.kt index 03f253dadd..daf9bc18ba 100644 --- a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/Markdown.kt +++ b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/Markdown.kt @@ -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 @@ -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)) { @@ -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( @@ -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() diff --git a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessor.kt b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessor.kt index cd526d0ae9..68fca1cf13 100644 --- a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessor.kt +++ b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessor.kt @@ -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() diff --git a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultInlineMarkdownRenderer.kt b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultInlineMarkdownRenderer.kt index 60fc292715..8acc07a78d 100644 --- a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultInlineMarkdownRenderer.kt +++ b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultInlineMarkdownRenderer.kt @@ -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 -> diff --git a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt index ecb8f107b4..21b2d0dcf1 100644 --- a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt +++ b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt @@ -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 @@ -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 @@ -103,6 +114,8 @@ public open class DefaultMarkdownBlockRenderer( } } + private data class ImageSize(val width: Int, val height: Int) + @Composable override fun render( block: Paragraph, @@ -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) = + buildList { + fun collectImagesRecursively(items: List) { + 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, @@ -434,6 +465,45 @@ public open class DefaultMarkdownBlockRenderer( inlineRenderer.renderAsAnnotatedString(block.inlineContent, styling, enabled, onUrlClick) } + @Composable + private fun renderedImages(blockInlineContent: List): Map = + getImages(blockInlineContent).associate { image -> image.source to imageContent(image) } + + @Composable + private fun imageContent(image: InlineMarkdown.Image): InlineTextContent { + val knownSize = remember(image.source) { mutableStateOf(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, diff --git a/samples/ide-plugin/src/main/kotlin/org/jetbrains/jewel/samples/ideplugin/ComponentShowcaseTab.kt b/samples/ide-plugin/src/main/kotlin/org/jetbrains/jewel/samples/ideplugin/ComponentShowcaseTab.kt index 62f0193114..ee30cc0467 100644 --- a/samples/ide-plugin/src/main/kotlin/org/jetbrains/jewel/samples/ideplugin/ComponentShowcaseTab.kt +++ b/samples/ide-plugin/src/main/kotlin/org/jetbrains/jewel/samples/ideplugin/ComponentShowcaseTab.kt @@ -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" |``` diff --git a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/markdown/JewelReadme.kt b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/markdown/JewelReadme.kt index 5ddc0d580d..a0b9823fcb 100644 --- a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/markdown/JewelReadme.kt +++ b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/markdown/JewelReadme.kt @@ -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.