From 78a3bc908559f2919a5be7559b298038636de9ee 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 both block and inline images --- gradle/libs.versions.toml | 6 ++ markdown/core/api/core.api | 12 ++++ markdown/core/build.gradle.kts | 4 ++ .../org/jetbrains/jewel/markdown/Markdown.kt | 20 ++++++ .../jetbrains/jewel/markdown/MarkdownBlock.kt | 2 + .../markdown/processing/MarkdownProcessor.kt | 17 ++++- .../DefaultInlineMarkdownRenderer.kt | 12 +--- .../rendering/DefaultMarkdownBlockRenderer.kt | 70 +++++++++++++++++++ .../rendering/MarkdownBlockRenderer.kt | 2 + .../samples/ideplugin/ComponentShowcaseTab.kt | 2 + .../standalone/view/markdown/JewelReadme.kt | 2 + 11 files changed, 137 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/api/core.api b/markdown/core/api/core.api index 14376ac741..f26fff469a 100644 --- a/markdown/core/api/core.api +++ b/markdown/core/api/core.api @@ -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 (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 @@ -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 @@ -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 diff --git a/markdown/core/build.gradle.kts b/markdown/core/build.gradle.kts index 151e991cb7..48bc19b5fb 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) + api(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..da9b725a2d 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,7 @@ public fun Markdown( markdownStyling: MarkdownStyling = JewelTheme.markdownStyling, blockRenderer: MarkdownBlockRenderer = DefaultMarkdownBlockRenderer(markdownStyling), ) { + setSingletonImageLoaderFactory(::createImageLoader) if (selectable) { SelectionContainer(modifier.semantics { rawMarkdown = markdown }) { Column(verticalArrangement = Arrangement.spacedBy(markdownStyling.blockVerticalSpacing)) { @@ -110,6 +115,7 @@ public fun LazyMarkdown( markdownStyling: MarkdownStyling = JewelTheme.markdownStyling, blockRenderer: MarkdownBlockRenderer = JewelTheme.markdownBlockRenderer, ) { + setSingletonImageLoaderFactory(::createImageLoader) if (selectable) { SelectionContainer(modifier) { LazyColumn( @@ -131,3 +137,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/MarkdownBlock.kt b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/MarkdownBlock.kt index 892f760113..e002053bed 100644 --- a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/MarkdownBlock.kt +++ b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/MarkdownBlock.kt @@ -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 public val isTight: Boolean 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..3605e557f5 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 @@ -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 @@ -169,7 +170,15 @@ public class MarkdownProcessor( private fun Node.tryProcessMarkdownBlock(): MarkdownBlock? = // Non-Block children are ignored when (this) { - is Paragraph -> toMarkdownParagraph() + is Paragraph -> { + val child = firstChild + if (child is Image && child === lastChild) { + // only render standalone images as blocks + child.toMarkdownImageOrNull() + } else { + toMarkdownParagraph() + } + } is Heading -> toMarkdownHeadingOrNull() is BulletList -> toMarkdownListOrNull() is OrderedList -> toMarkdownListOrNull() @@ -208,6 +217,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 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..c0ac2c158d 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,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 @@ -32,14 +35,23 @@ import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon 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.TextUnit 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 @@ -89,6 +101,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 MarkdownBlock.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) @@ -103,6 +116,8 @@ public open class DefaultMarkdownBlockRenderer( } } + private data class ImageSize(val w: TextUnit, val h: TextUnit) + @Composable override fun render( block: Paragraph, @@ -112,6 +127,7 @@ public open class DefaultMarkdownBlockRenderer( onTextClick: () -> Unit, ) { val renderedContent = rememberRenderedContent(block, styling.inlinesStyling, enabled, onUrlClick) + val knownSizes = remember { mutableStateMapOf() } val textColor = styling.inlinesStyling.textStyle.color .takeOrElse { LocalContentColor.current } @@ -125,9 +141,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) = + buildList { + fun dfs(items: List) { + for (item in items) { + when (item) { + is InlineMarkdown.Image -> add(item) + is WithInlineMarkdown -> { + dfs(item.inlineContent) + } + + else -> {} + } + } + } + dfs(input) + } + @Composable override fun render( block: Heading, @@ -423,6 +457,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, @@ -434,6 +473,37 @@ public open class DefaultMarkdownBlockRenderer( inlineRenderer.renderAsAnnotatedString(block.inlineContent, styling, enabled, onUrlClick) } + @Composable + private fun renderedImages( + blockInlineContent: List, + knownSizes: SnapshotStateMap, + ): Map = + getImages(blockInlineContent).associate { image -> image.source to imageContent(image, knownSizes) } + + @Composable + private fun imageContent( + image: InlineMarkdown.Image, + knownSizes: SnapshotStateMap, + ): InlineTextContent { + return InlineTextContent( + Placeholder( + width = knownSizes[image.source]?.w ?: 100.sp, + height = knownSizes[image.source]?.h ?: 100.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Bottom, + ) + ) { + AsyncImage( + model = + ImageRequest.Builder(LocalPlatformContext.current).data(image.source).size(Size.ORIGINAL).build(), + contentDescription = image.title, + // TODO: figure out what to use instead of *.sp + onSuccess = { state -> + knownSizes[image.source] = state.result.image.let { ImageSize(it.width.sp, it.height.sp) } + }, + ) + } + } + @Composable private fun MaybeScrollingContainer( isScrollable: Boolean, diff --git a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer.kt b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer.kt index b66ebc4979..72bc4640e4 100644 --- a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer.kt +++ b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer.kt @@ -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 } 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..029c0c2dd5 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=42) + | |```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..03c05aa25c 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=42) + 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.