diff --git a/markdown/core/api/core.api b/markdown/core/api/core.api index 14376ac741..f79f859206 100644 --- a/markdown/core/api/core.api +++ b/markdown/core/api/core.api @@ -222,6 +222,29 @@ public final class org/jetbrains/jewel/markdown/MarkdownKt { public static final fun Markdown (Ljava/util/List;Ljava/lang/String;Landroidx/compose/ui/Modifier;ZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling;Lorg/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer;Landroidx/compose/runtime/Composer;II)V } +public abstract interface class org/jetbrains/jewel/markdown/MarkdownMode { + public abstract fun getScrollingSynchronizer ()Lorg/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer; + public abstract fun getWithEditor ()Z +} + +public final class org/jetbrains/jewel/markdown/MarkdownMode$PreviewOnly : org/jetbrains/jewel/markdown/MarkdownMode { + public static final field $stable I + public static final field INSTANCE Lorg/jetbrains/jewel/markdown/MarkdownMode$PreviewOnly; + public fun getScrollingSynchronizer ()Lorg/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer; + public fun getWithEditor ()Z +} + +public final class org/jetbrains/jewel/markdown/MarkdownMode$WithEditor : org/jetbrains/jewel/markdown/MarkdownMode { + public static final field $stable I + public fun (Lorg/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer;)V + public fun getScrollingSynchronizer ()Lorg/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer; + public fun getWithEditor ()Z +} + +public final class org/jetbrains/jewel/markdown/MarkdownModeKt { + public static final fun WithMarkdownMode (Lorg/jetbrains/jewel/markdown/MarkdownMode;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V +} + public final class org/jetbrains/jewel/markdown/SemanticsKt { public static final fun getRawMarkdown ()Landroidx/compose/ui/semantics/SemanticsPropertyKey; public static final fun getRawMarkdown (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;)Ljava/lang/String; @@ -258,9 +281,11 @@ public abstract interface class org/jetbrains/jewel/markdown/extensions/Markdown public final class org/jetbrains/jewel/markdown/extensions/MarkdownKt { public static final fun getLocalMarkdownBlockRenderer ()Landroidx/compose/runtime/ProvidableCompositionLocal; + public static final fun getLocalMarkdownMode ()Landroidx/compose/runtime/ProvidableCompositionLocal; public static final fun getLocalMarkdownProcessor ()Landroidx/compose/runtime/ProvidableCompositionLocal; public static final fun getLocalMarkdownStyling ()Landroidx/compose/runtime/ProvidableCompositionLocal; public static final fun getMarkdownBlockRenderer (Lorg/jetbrains/jewel/foundation/theme/JewelTheme$Companion;Landroidx/compose/runtime/Composer;I)Lorg/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer; + public static final fun getMarkdownMode (Lorg/jetbrains/jewel/foundation/theme/JewelTheme$Companion;Landroidx/compose/runtime/Composer;I)Lorg/jetbrains/jewel/markdown/MarkdownMode; public static final fun getMarkdownProcessor (Lorg/jetbrains/jewel/foundation/theme/JewelTheme$Companion;Landroidx/compose/runtime/Composer;I)Lorg/jetbrains/jewel/markdown/processing/MarkdownProcessor; public static final fun getMarkdownStyling (Lorg/jetbrains/jewel/foundation/theme/JewelTheme$Companion;Landroidx/compose/runtime/Composer;I)Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling; } @@ -299,12 +324,17 @@ public final class org/jetbrains/jewel/markdown/processing/MarkdownParserFactory public final class org/jetbrains/jewel/markdown/processing/MarkdownProcessor { public static final field $stable I public fun ()V - public fun (Ljava/util/List;ZLorg/commonmark/parser/Parser;)V - public synthetic fun (Ljava/util/List;ZLorg/commonmark/parser/Parser;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/util/List;Lorg/jetbrains/jewel/markdown/MarkdownMode;Lorg/commonmark/parser/Parser;)V + public synthetic fun (Ljava/util/List;Lorg/jetbrains/jewel/markdown/MarkdownMode;Lorg/commonmark/parser/Parser;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun processChildren (Lorg/commonmark/node/Node;)Ljava/util/List; public final fun processMarkdownDocument (Ljava/lang/String;)Ljava/util/List; } +public final class org/jetbrains/jewel/markdown/rendering/AutoScrollingUtilKt { + public static final fun AutoScrollableBlock (Lorg/jetbrains/jewel/markdown/MarkdownBlock;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun AutoScrollableText-JAgEBs0 (Lorg/jetbrains/jewel/markdown/MarkdownBlock;Landroidx/compose/ui/text/AnnotatedString;Landroidx/compose/ui/Modifier;JJLandroidx/compose/ui/text/font/FontStyle;Landroidx/compose/ui/text/font/FontWeight;Landroidx/compose/ui/text/font/FontFamily;JLandroidx/compose/ui/text/style/TextDecoration;IJIZILjava/util/Map;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/runtime/Composer;III)V +} + public class org/jetbrains/jewel/markdown/rendering/DefaultInlineMarkdownRenderer : org/jetbrains/jewel/markdown/rendering/InlineMarkdownRenderer { public static final field $stable I public static final field Companion Lorg/jetbrains/jewel/markdown/rendering/DefaultInlineMarkdownRenderer$Companion; @@ -751,3 +781,19 @@ public abstract interface class org/jetbrains/jewel/markdown/rendering/WithUnder public abstract fun getUnderlineWidth-D9Ej5fM ()F } +public abstract class org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer { + public static final field $stable I + public static final field Companion Lorg/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer$Companion; + public fun ()V + public abstract fun acceptBlockSpans (Lorg/jetbrains/jewel/markdown/MarkdownBlock;Lkotlin/ranges/IntRange;)V + public abstract fun acceptGlobalPosition (Lorg/jetbrains/jewel/markdown/MarkdownBlock;Landroidx/compose/ui/layout/LayoutCoordinates;)V + public abstract fun acceptTextLayout (Lorg/jetbrains/jewel/markdown/MarkdownBlock;Landroidx/compose/ui/text/TextLayoutResult;)V + public abstract fun afterProcessing ()V + public abstract fun beforeProcessing ()V + public abstract fun scrollToLine (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer$Companion { + public final fun create (Landroidx/compose/foundation/gestures/ScrollableState;)Lorg/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer; +} + diff --git a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/MarkdownMode.kt b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/MarkdownMode.kt new file mode 100644 index 0000000000..424c87dfbb --- /dev/null +++ b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/MarkdownMode.kt @@ -0,0 +1,28 @@ +package org.jetbrains.jewel.markdown + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import org.jetbrains.jewel.foundation.ExperimentalJewelApi +import org.jetbrains.jewel.markdown.extensions.LocalMarkdownMode +import org.jetbrains.jewel.markdown.scrolling.ScrollingSynchronizer + +@ExperimentalJewelApi +public sealed interface MarkdownMode { + public val withEditor: Boolean + public val scrollingSynchronizer: ScrollingSynchronizer? + + public object PreviewOnly : MarkdownMode { + override val withEditor: Boolean = false + override val scrollingSynchronizer: ScrollingSynchronizer? = null + } + + public class WithEditor(public override val scrollingSynchronizer: ScrollingSynchronizer?) : MarkdownMode { + override val withEditor: Boolean = true + } +} + +@ExperimentalJewelApi +@Composable +public fun WithMarkdownMode(mode: MarkdownMode, content: @Composable () -> Unit) { + CompositionLocalProvider(LocalMarkdownMode provides mode) { content() } +} diff --git a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/Markdown.kt b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/Markdown.kt index e42195c241..12d3c33d14 100644 --- a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/Markdown.kt +++ b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/Markdown.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.staticCompositionLocalOf import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.markdown.MarkdownMode import org.jetbrains.jewel.markdown.processing.MarkdownProcessor import org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer import org.jetbrains.jewel.markdown.rendering.MarkdownStyling @@ -28,3 +29,10 @@ public val LocalMarkdownBlockRenderer: ProvidableCompositionLocal = staticCompositionLocalOf { + MarkdownMode.PreviewOnly +} + +public val JewelTheme.Companion.markdownMode: MarkdownMode + @Composable get() = LocalMarkdownMode.current 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..d119fe0020 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 @@ -14,6 +14,7 @@ import org.commonmark.node.ListItem import org.commonmark.node.Node import org.commonmark.node.OrderedList import org.commonmark.node.Paragraph +import org.commonmark.node.SourceSpan import org.commonmark.node.ThematicBreak import org.commonmark.parser.Parser import org.intellij.lang.annotations.Language @@ -26,40 +27,44 @@ import org.jetbrains.jewel.markdown.InlineMarkdown import org.jetbrains.jewel.markdown.MarkdownBlock import org.jetbrains.jewel.markdown.MarkdownBlock.CodeBlock import org.jetbrains.jewel.markdown.MarkdownBlock.ListBlock +import org.jetbrains.jewel.markdown.MarkdownMode import org.jetbrains.jewel.markdown.extensions.MarkdownProcessorExtension import org.jetbrains.jewel.markdown.rendering.DefaultInlineMarkdownRenderer +import org.jetbrains.jewel.markdown.scrolling.ScrollingSynchronizer /** * Reads raw Markdown strings and processes them into a list of [MarkdownBlock]. * * @param extensions Extensions to use when processing the Markdown (e.g., to support parsing custom block-level * Markdown). - * @param editorMode Indicates whether the processor should be optimized for an editor/preview scenario, where it + * @param markdownMode Indicates whether the processor should be optimized for an editor/preview scenario, where it * assumes small incremental changes as performed by a user typing. This means it will only update the changed blocks * by keeping state in memory. * * Default is `false`; set this to `true` if this parser will be used in an editor scenario, where the raw Markdown is * only ever going to change slightly but frequently (e.g., as the user types). * - * **Attention:** do **not** reuse or share an instance of [MarkdownProcessor] that is in [editorMode]. Processing + * **Attention:** do **not** reuse or share an instance of [MarkdownProcessor] that is in [markdownMode]. Processing * entirely different Markdown strings will defeat the purpose of the optimization. When in editor mode, the instance * of [MarkdownProcessor] is **not** thread-safe! * * @param commonMarkParser The CommonMark [Parser] used to parse the Markdown. By default it's a vanilla instance * provided by the [MarkdownParserFactory], but you can provide your own if you need to customize the parser — e.g., - * to ignore certain tags. If [optimizeEdits] is `true`, make sure you set + * to ignore certain tags. If [markdownMode] is `MarkdownMode.WithEditor`, make sure you set * `includeSourceSpans(IncludeSourceSpans.BLOCKS)` on the parser. */ @ExperimentalJewelApi public class MarkdownProcessor( private val extensions: List = emptyList(), - private val editorMode: Boolean = false, - private val commonMarkParser: Parser = MarkdownParserFactory.create(editorMode, extensions), + private val markdownMode: MarkdownMode = MarkdownMode.PreviewOnly, + private val commonMarkParser: Parser = MarkdownParserFactory.create(markdownMode.withEditor, extensions), ) { private var currentState = State(emptyList(), emptyList(), emptyList()) @TestOnly internal fun getCurrentIndexesInTest() = currentState.indexes + private val scrollingSynchronizer: ScrollingSynchronizer? = markdownMode.scrollingSynchronizer + /** * Parses a Markdown document, translating from CommonMark 0.31.2 to a list of [MarkdownBlock]. Inline Markdown in * leaf nodes is contained in [InlineMarkdown], which can be rendered to an @@ -69,14 +74,19 @@ public class MarkdownProcessor( * @see DefaultInlineMarkdownRenderer */ public fun processMarkdownDocument(@Language("Markdown") rawMarkdown: String): List { - val blocks = - if (editorMode) { - processWithQuickEdits(rawMarkdown) - } else { - parseRawMarkdown(rawMarkdown) - } + scrollingSynchronizer?.beforeProcessing() + return try { + val blocks = + if (markdownMode.withEditor) { + processWithQuickEdits(rawMarkdown) + } else { + parseRawMarkdown(rawMarkdown) + } - return blocks.mapNotNull { child -> child.tryProcessMarkdownBlock() } + blocks.mapNotNull { child -> child.tryProcessMarkdownBlock() } + } finally { + scrollingSynchronizer?.afterProcessing() + } } @VisibleForTesting @@ -154,6 +164,60 @@ public class MarkdownProcessor( previousBlocks.subList(lastBlock, previousBlocks.size) val newIndexes = previousIndexes.subList(0, firstBlock) + updatedIndexes + suffixIndexes + + // Processor only re-parses the changed part of the document, which has two outcomes: + // 1. sourceSpans in updatedBlocks start from line index 0, not from the actual line + // the update part starts in the document; + // 2. sourceSpans in blocks after the changed part remain unchanged + // (therefore irrelevant too). + // + // Addressing the second outcome is easy, as all the lines there were just shifted by + // nLinesDelta. + + for (i in lastBlock until newBlocks.size) { + newBlocks[i].traverseAll { node -> + node.sourceSpans = + node.sourceSpans.map { span -> + SourceSpan.of(span.lineIndex + nLinesDelta, span.columnIndex, span.inputIndex, span.length) + } + } + } + + // The first outcome is a bit trickier. Consider a fresh new block with the following + // structure: + // + // indexes spans + // Block A [10-20] (0-10) + // block A1 [ n/a ] (0-2) + // block A2 [ n/a ] (3-10) + // Block B [21-30] (11-20) + // block B1 [ n/a ] (11-16) + // block B2 [ n/a ] (17-20) + // + // There are two updated blocks with two children each. + // Note that at this point the indexes are updated, yet they only exist for the topmost + // blocks. + // So, to calculate actual spans for, for example, block B2 (B2s), we need to also take into + // account + // the first index of the block B (Bi) and the first span of the block B (Bs) and use the + // formula + // B2s = (B2s - Bs) + Bi + for ((block, indexes) in updatedBlocks.zip(updatedIndexes)) { + val firstSpanLineIndex = block.sourceSpans.firstOrNull()?.lineIndex ?: continue + val firstIndex = indexes.first + block.traverseAll { node -> + node.sourceSpans = + node.sourceSpans.map { span -> + SourceSpan.of( + span.lineIndex - firstSpanLineIndex + firstIndex, + span.columnIndex, + span.inputIndex, + span.length, + ) + } + } + } + currentState = State(newLines, newBlocks, newIndexes) return newBlocks @@ -186,8 +250,20 @@ public class MarkdownProcessor( } else -> null + }.also { block -> + if (scrollingSynchronizer != null && this is Block && block != null) { + postProcess(scrollingSynchronizer, this, block) + } } + private fun postProcess(scrollingSynchronizer: ScrollingSynchronizer, block: Block, mdBlock: MarkdownBlock) { + val spans = + block.sourceSpans.ifEmpty { + return + } + scrollingSynchronizer.acceptBlockSpans(mdBlock, spans.first().lineIndex..spans.last().lineIndex) + } + private fun Paragraph.toMarkdownParagraph(): MarkdownBlock.Paragraph = MarkdownBlock.Paragraph(readInlineContent().toList()) @@ -257,6 +333,11 @@ public class MarkdownProcessor( } } + private fun Node.traverseAll(action: (Node) -> Unit) { + action(this) + forEachChild { child -> child.traverseAll(action) } + } + private fun HtmlBlock.toMarkdownHtmlBlockOrNull(): MarkdownBlock.HtmlBlock? { if (literal.isBlank()) return null return MarkdownBlock.HtmlBlock(literal.trimEnd('\n')) diff --git a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/AutoScrollingUtil.kt b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/AutoScrollingUtil.kt new file mode 100644 index 0000000000..b974a5198b --- /dev/null +++ b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/AutoScrollingUtil.kt @@ -0,0 +1,110 @@ +package org.jetbrains.jewel.markdown.rendering + +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit +import kotlin.math.abs +import org.jetbrains.jewel.foundation.ExperimentalJewelApi +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.markdown.MarkdownBlock +import org.jetbrains.jewel.markdown.extensions.LocalMarkdownMode +import org.jetbrains.jewel.markdown.extensions.markdownMode +import org.jetbrains.jewel.ui.component.Text + +/** + * Use this composable as a wrapper to an actual block composable to enable scrolling to the block in an editor+preview + * combined mode with scrolling synchronization. + * + * @see [DefaultMarkdownBlockRenderer] + */ +@ExperimentalJewelApi +@Composable +public fun AutoScrollableBlock( + block: MarkdownBlock, + modifier: Modifier = Modifier, + content: @Composable (Modifier) -> Unit, +) { + val synchronizer = JewelTheme.markdownMode.scrollingSynchronizer + if (synchronizer == null) { + return content(modifier) + } + + var previousPosition by remember { mutableStateOf(Offset.Zero) } + + content( + modifier.onGloballyPositioned { coordinates -> + val newPosition = coordinates.positionInRoot() + if (abs(previousPosition.y - newPosition.y) > 1.0) { + previousPosition = newPosition + synchronizer.acceptGlobalPosition(block, coordinates) + } + } + ) +} + +/** + * Use this composable if you want to have auto-scrolling within atomic text blocks (such as code blocks) in an + * editor+preview combined mode with scrolling synchronization. + * + * @see [DefaultMarkdownBlockRenderer.CodeText] + */ +@ExperimentalJewelApi +@Composable +public fun AutoScrollableText( + block: MarkdownBlock, + text: AnnotatedString, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign = TextAlign.Unspecified, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + inlineContent: Map = emptyMap(), + style: TextStyle = JewelTheme.defaultTextStyle, +) { + with(LocalMarkdownMode.current) { + Text( + text = text, + modifier = modifier, + color = color, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign, + lineHeight = lineHeight, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + inlineContent = inlineContent, + onTextLayout = { layout -> scrollingSynchronizer?.acceptTextLayout(block, layout) }, + style = style, + ) + } +} 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..f4177feaef 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 @@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -119,13 +120,16 @@ public open class DefaultMarkdownBlockRenderer( val mergedStyle = styling.inlinesStyling.textStyle.merge(TextStyle(color = textColor)) val interactionSource = remember { MutableInteractionSource() } - Text( - modifier = - Modifier.focusProperties { canFocus = false } - .clickable(interactionSource = interactionSource, indication = null, onClick = onTextClick), - text = renderedContent, - style = mergedStyle, - ) + AutoScrollableBlock(block) { modifier -> + Text( + modifier = + modifier + .focusProperties { canFocus = false } + .clickable(interactionSource = interactionSource, indication = null, onClick = onTextClick), + text = renderedContent, + style = mergedStyle, + ) + } } @Composable @@ -156,18 +160,22 @@ public open class DefaultMarkdownBlockRenderer( onTextClick: () -> Unit, ) { val renderedContent = rememberRenderedContent(block, styling.inlinesStyling, enabled, onUrlClick) - Heading( - renderedContent, - styling.inlinesStyling.textStyle, - styling.padding, - styling.underlineWidth, - styling.underlineColor, - styling.underlineGap, - ) + AutoScrollableBlock(block) { modifier -> + Heading( + modifier, + renderedContent, + styling.inlinesStyling.textStyle, + styling.padding, + styling.underlineWidth, + styling.underlineColor, + styling.underlineGap, + ) + } } @Composable private fun Heading( + modifier: Modifier, renderedContent: AnnotatedString, textStyle: TextStyle, paddingValues: PaddingValues, @@ -175,7 +183,7 @@ public open class DefaultMarkdownBlockRenderer( underlineColor: Color, underlineGap: Dp, ) { - Column(modifier = Modifier.padding(paddingValues)) { + Column(modifier = modifier.padding(paddingValues)) { val textColor = textStyle.color.takeOrElse { LocalContentColor.current.takeOrElse { textStyle.color } } val mergedStyle = textStyle.merge(TextStyle(color = textColor)) Text(text = renderedContent, style = mergedStyle, modifier = Modifier.focusProperties { canFocus = false }) @@ -252,16 +260,19 @@ public open class DefaultMarkdownBlockRenderer( for ((index, item) in block.children.withIndex()) { Row { val number = block.startFrom + index - Text( - text = "$number${block.delimiter}", - style = styling.numberStyle, - color = styling.numberStyle.color.takeOrElse { LocalContentColor.current }, - modifier = - Modifier.focusProperties { canFocus = false } - .widthIn(min = styling.numberMinWidth) - .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), - textAlign = styling.numberTextAlign, - ) + AutoScrollableBlock(block) { modifier -> + Text( + text = "$number${block.delimiter}", + style = styling.numberStyle, + color = styling.numberStyle.color.takeOrElse { LocalContentColor.current }, + modifier = + modifier + .focusProperties { canFocus = false } + .widthIn(min = styling.numberMinWidth) + .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), + textAlign = styling.numberTextAlign, + ) + } Spacer(Modifier.width(styling.numberContentGap)) @@ -289,14 +300,17 @@ public open class DefaultMarkdownBlockRenderer( Column(modifier = Modifier.padding(styling.padding), verticalArrangement = Arrangement.spacedBy(itemSpacing)) { for (item in block.children) { Row { - Text( - text = styling.bullet.toString(), - style = styling.bulletStyle, - color = styling.bulletStyle.color.takeOrElse { LocalContentColor.current }, - modifier = - Modifier.focusProperties { canFocus = false } - .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), - ) + AutoScrollableBlock(block) { modifier -> + Text( + text = styling.bullet.toString(), + style = styling.bulletStyle, + color = styling.bulletStyle.color.takeOrElse { LocalContentColor.current }, + modifier = + modifier + .focusProperties { canFocus = false } + .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), + ) + } Spacer(Modifier.width(styling.bulletContentGap)) @@ -329,15 +343,19 @@ public open class DefaultMarkdownBlockRenderer( .border(styling.borderWidth, styling.borderColor, styling.shape) .then(if (styling.fillWidth) Modifier.fillMaxWidth() else Modifier), ) { - Text( - text = block.content, - style = styling.editorTextStyle, - color = styling.editorTextStyle.color.takeOrElse { LocalContentColor.current }, - modifier = - Modifier.focusProperties { canFocus = false } - .padding(styling.padding) - .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), - ) + AutoScrollableBlock(block) { modifier -> + AutoScrollableText( + block = block, + text = AnnotatedString(block.content), + style = styling.editorTextStyle, + color = styling.editorTextStyle.color.takeOrElse { LocalContentColor.current }, + modifier = + modifier + .focusProperties { canFocus = false } + .padding(styling.padding) + .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), + ) + } } } @@ -361,7 +379,7 @@ public open class DefaultMarkdownBlockRenderer( ) } - Code(block.content, mimeType, styling) + Code(block, mimeType, styling) if (styling.infoPosition.verticalAlignment == Alignment.Bottom) { FencedBlockInfo( @@ -377,21 +395,29 @@ public open class DefaultMarkdownBlockRenderer( } @Composable - private fun Code(content: String, mimeType: MimeType, styling: MarkdownStyling.Code.Fenced) { - val annotatedCode by - LocalCodeHighlighter.current.highlight(content, mimeType).collectAsState(AnnotatedString(content)) - CodeText(annotatedCode, styling) + private fun Code(block: FencedCodeBlock, mimeType: MimeType, styling: MarkdownStyling.Code.Fenced) { + key(block) { + val annotatedCode by + LocalCodeHighlighter.current + .highlight(block.content, mimeType) + .collectAsState(AnnotatedString(block.content)) + CodeText(block, annotatedCode, styling) + } } @Composable - private fun CodeText(annotatedCode: AnnotatedString, styling: MarkdownStyling.Code.Fenced) { - Text( - text = annotatedCode, - style = styling.editorTextStyle, - modifier = - Modifier.focusProperties { canFocus = false } - .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), - ) + private fun CodeText(block: MarkdownBlock, annotatedCode: AnnotatedString, styling: MarkdownStyling.Code.Fenced) { + AutoScrollableBlock(block) { modifier -> + AutoScrollableText( + block = block, + text = annotatedCode, + style = styling.editorTextStyle, + modifier = + modifier + .focusProperties { canFocus = false } + .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), + ) + } } @Composable diff --git a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer.kt b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer.kt new file mode 100644 index 0000000000..a38f44d10c --- /dev/null +++ b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer.kt @@ -0,0 +1,263 @@ +package org.jetbrains.jewel.markdown.scrolling + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.text.TextLayoutResult +import java.util.TreeMap +import org.jetbrains.jewel.foundation.ExperimentalJewelApi +import org.jetbrains.jewel.foundation.util.myLogger +import org.jetbrains.jewel.markdown.MarkdownBlock +import org.jetbrains.jewel.markdown.processing.MarkdownProcessor +import org.jetbrains.jewel.markdown.rendering.AutoScrollableBlock +import org.jetbrains.jewel.markdown.rendering.AutoScrollableText + +/** + * To support synchronized scrolling between source and preview, we need to establish a mapping between source lines and + * coordinates of their presentation. + * + * For simplicity, let's suppose that the source code is immutable. [MarkdownProcessor] parses it and yields a list of + * [MarkdownBlock]s. Unfortunately, it doesn't contain any information about the source lines, as the need to keep them + * and reserve more heap is not strong enough (the hypothesis is that most users just need to read the .md file and not + * to edit it). + * + * However, [MarkdownProcessor] uses commonmark inside and takes the blocks this library returns to build + * [MarkdownBlock]s, and in the editor mode, commonmark blocks still hold the information about source lines. + * [acceptBlockSpans] can be implemented the way that remembers mappings between [MarkdownBlock]s and source lines these + * blocks span over. + * + * Next, Compose provides the callback [onGloballyPositioned] with precalculated global layout. [acceptGlobalPosition] + * can be implemented to remember mappings between [MarkdownBlock]s and global coordinates these blocks are rendered on. + * + * These two mappings are enough to make the synchronizer work. When a source code is scrolled to a line, an + * implementation can find a block containing the line (or the next one if there are no blocks on the line), then find + * this block's global layout and, finally, tell Compose to scroll to the topmost coordinate of the layout. This way, a + * user can observe the whole block in the preview, even if only a part of it is visible in the source view. + * + * For some blocks, however, it makes sense to scroll within their content. Code blocks make for a perfect example of + * it. They can contain a lot of lines, and at the same time, they're not soft-wrapped in a preview, every source line + * is mapped 1:1 to the preview, so scrolling inside a code block would be preferable (and natural) to support. + * [acceptTextLayout] serves the purpose of calculation every line's position within the composable. This information + * may, in turn, be used together with global positioning of the composable to compute the absolute position of a + * certain line in the preview. + * + * # Editing + * + * [MarkdownProcessor] always yields all the blocks that are present in the source, even in optimized mode, so + * [acceptBlockSpans] is not really affected by editing. [acceptGlobalPosition] is trickier, as it is not triggered on + * blocks preceding the change. [acceptTextLayout] is even more intricate, as it may or may not be triggered on blocks + * following the change. It implies that mappings should be adjusted accordingly. [beforeProcessing] and + * [afterProcessing] can help with that, as they're invoked before and after every re-parse, i.e. every change in the + * file. See [PerLine] as one of the possible implementations for [ScrollState]. + * + * # Keep in mind + * - [acceptBlockSpans] accepts blocks in the **depth-first order**. + * - Between [beforeProcessing] and [afterProcessing] every single block is processed, [acceptBlockSpans] is triggered + * for every one of them. + * - [acceptGlobalPosition] is **always** triggered on the changed block and the blocks that follow the change. + * - [acceptTextLayout] is **always** triggered on the changed block, but **not always** on those located below the + * changed block. It's **not triggered** on blocks located above the change. + * - [acceptTextLayout] is triggered **before** [acceptGlobalPosition] for the same block. + * + * @see [MarkdownProcessor] + * @see [AutoScrollableBlock] + * @see [AutoScrollableText] + * @see [PerLine] + */ +@ExperimentalJewelApi +public abstract class ScrollingSynchronizer { + /** Scroll the preview to the position that match the given [sourceLine] the best. */ + public abstract suspend fun scrollToLine(sourceLine: Int) + + /** Called before [MarkdownProcessor] starts processing the raw markdown text. */ + public abstract fun beforeProcessing() + + /** Called after [MarkdownProcessor] starts processing the raw markdown text. */ + public abstract fun afterProcessing() + + /** + * Accept mapping between the markdown [block] and the [sourceRange] of lines containing this block. Called on every + * block after it was (re)parsed. + */ + public abstract fun acceptBlockSpans(block: MarkdownBlock, sourceRange: IntRange) + + /** + * Accept mapping between the markdown [block] and the global [coordinates] of lines containing this block. Called + * on all blocks that require (re)positioning: on first composition, on a changed block, on unchanged blocks that + * are positioned below the changed block. + */ + public abstract fun acceptGlobalPosition(block: MarkdownBlock, coordinates: LayoutCoordinates) + + /** + * Accept mapping between the markdown [block] and the [textLayout] of the text this block comprises. Called on all + * blocks that require adjusting text layout: on first composition, on a block with the changed text, and may be + * called on unchanged blocks that are positioned below the changed block. + */ + public abstract fun acceptTextLayout(block: MarkdownBlock, textLayout: TextLayoutResult) + + public companion object { + public fun create(scrollState: ScrollableState): ScrollingSynchronizer? = + when (scrollState) { + is ScrollState -> PerLine(scrollState) + is LazyListState -> { + myLogger().warn("Synchronization for LazyListState is not supported yet") + null + } + + else -> null + } + } + + private class PerLine(private val scrollState: ScrollState) : ScrollingSynchronizer() { + private val lines2Blocks = TreeMap() + private var blocks2LineRanges = mutableMapOf() + private val blocks2Top = mutableMapOf() + private val previousPositions = mutableMapOf() + + // Only used to clean up obsolete keys in the maps above; + // otherwise stale MarkdownBlocks will keep piling up on each typed key + private val actualBlocks = mutableSetOf() + + // It'd be a bit more performant if there were a map mapping lines to offsets, + // and that was the initial approach, + // but this structure would be hard to maintain because of optimizations in Compose. + // Namely, text offsets may not be recalculated even if the block was repositioned. + // For example, if contents of one item in a Column change, it only causes relayout + // of the changed item, and not the items that follow, even though they are to be + // repositioned globally. + // Thus, even if lines that a block occupies change, + // relative offsets within the block can remain the same. + // But here, given there's guaranteed 1:1 source to preview lines mapping, + // the rules holds that, if a block hasn't changed, text offsets remain unchanged too, + // so this map always keeps relevant information. + private val blocks2TextOffsets = mutableMapOf>() + + override suspend fun scrollToLine(sourceLine: Int) { + val block = findBestBlockForLine(sourceLine) ?: return + val y = blocks2Top[block] ?: return + if (y < 0) return + val lineRange = blocks2LineRanges[block] ?: return + val textOffsets = blocks2TextOffsets[block] + // The line may be empty and represent no block, + // in this case scroll to the first line of the first block positioned after the line + val lineIndexInBlock = maxOf(0, sourceLine - lineRange.start) + val lineOffset = textOffsets?.get(lineIndexInBlock) ?: 0 + scrollState.animateScrollTo(y + lineOffset) + } + + private fun findBestBlockForLine(line: Int): MarkdownBlock? { + // The best block is the one **below** the line if there is no block that covers the + // line. + // Otherwise, when scrolling down the source, on empty lines preview will scroll in the + // opposite direction + val sm = lines2Blocks.subMap(line, Int.MAX_VALUE) + if (sm.isEmpty()) return null + // TODO use firstEntry() after switching to JDK 21 + return sm.getValue(sm.firstKey()) + } + + override fun beforeProcessing() { + // acceptBlockSpans works on ALL the nodes, including those unchanged, + // so it will be fully rebuilt during processing anyway + lines2Blocks.clear() + blocks2LineRanges.clear() + } + + override fun afterProcessing() { + blocks2LineRanges.keys.retainAll(actualBlocks) + blocks2Top.keys.retainAll(actualBlocks) + blocks2TextOffsets.keys.retainAll(actualBlocks) + previousPositions.keys.retainAll(actualBlocks) + actualBlocks.clear() + } + + override fun acceptBlockSpans(block: MarkdownBlock, sourceRange: IntRange) { + for (line in sourceRange) { + // DFS -- keep the innermost block for the given line + lines2Blocks.putIfAbsent(line, block) + } + blocks2LineRanges[block] = sourceRange + actualBlocks += block + } + + override fun acceptGlobalPosition(block: MarkdownBlock, coordinates: LayoutCoordinates) { + // coordinates are relative to the current viewport + // (which also means onPositionedGlobally is triggered when scrolling); + // to get the real absolute coordinates we need to consider scroll state + val y = coordinates.positionInRoot().y.toInt() + scrollState.value + + // let's not recalculate internal structures on the preview scrolling -- more safety + val oldY = previousPositions[block] + if (oldY == null || y != oldY) { + blocks2Top[block] = y + previousPositions[block] = y + } + } + + override fun acceptTextLayout(block: MarkdownBlock, textLayout: TextLayoutResult) { + if (block !is MarkdownBlock.CodeBlock) return + val sourceLines = blocks2LineRanges[block] ?: return + + var y = 0 + val list = mutableListOf() + + if (block is MarkdownBlock.CodeBlock.FencedCodeBlock) { + // All source lines in the fenced code block, + // beside the first and the last ones, are mapped 1:1 onto preview + // code block: + // + // | source: | preview: + // __________________________________|_________________ + // (first line) | ```language | + // | | + // | | + // | | + // | | + // | | + // (last line) | ``` | + // + // Some of the lines might be empty, and thus there are no spans for them. + // However, every empty line follows the 1:1 mapping rule, + // which means all of the lines in the range [first line + 1; last line - 1] + // have their counterparts in the preview, regardless of the content. + + val openingLine = sourceLines.first() + val firstSourceLine = openingLine + 1 + val closingLine = sourceLines.last() + // map the line with opening triple backticks + // to the topmost point of the block in the preview + list += y + for (i in firstSourceLine.. | + // | | + // | | + // | | + // (last line) | | + for (i in sourceLines) { + list += y + val lineHeight = + textLayout.getLineBottom(i - sourceLines.first) - textLayout.getLineTop(i - sourceLines.first) + y += lineHeight.toInt() + } + } + blocks2TextOffsets[block] = list + } + } +} diff --git a/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessorOptimizeEditsTest.kt b/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessorOptimizeEditsTest.kt index 3bbf7236fa..030adb1daa 100644 --- a/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessorOptimizeEditsTest.kt +++ b/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessorOptimizeEditsTest.kt @@ -7,6 +7,7 @@ import org.commonmark.parser.IncludeSourceSpans import org.commonmark.parser.Parser import org.commonmark.renderer.html.HtmlRenderer import org.intellij.lang.annotations.Language +import org.jetbrains.jewel.markdown.MarkdownMode import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertNotSame @@ -46,7 +47,7 @@ class MarkdownProcessorOptimizeEditsTest { @Test fun `first blocks stay the same`() { - val processor = MarkdownProcessor(editorMode = true) + val processor = MarkdownProcessor(markdownMode = MarkdownMode.WithEditor(null)) val firstRun = processor.processWithQuickEdits(rawMarkdown) val secondRun = processor.processWithQuickEdits( @@ -83,7 +84,7 @@ class MarkdownProcessorOptimizeEditsTest { @Test fun `first block edited`() { - val processor = MarkdownProcessor(editorMode = true) + val processor = MarkdownProcessor(markdownMode = MarkdownMode.WithEditor(null)) val firstRun = processor.processWithQuickEdits(rawMarkdown) val secondRun = processor.processWithQuickEdits( @@ -141,7 +142,7 @@ class MarkdownProcessorOptimizeEditsTest { @Test fun `last block edited`() { - val processor = MarkdownProcessor(editorMode = true) + val processor = MarkdownProcessor(markdownMode = MarkdownMode.WithEditor(null)) val firstRun = processor.processWithQuickEdits(rawMarkdown) val secondRun = processor.processWithQuickEdits( @@ -202,7 +203,7 @@ class MarkdownProcessorOptimizeEditsTest { @Test fun `middle block edited`() { - val processor = MarkdownProcessor(editorMode = true) + val processor = MarkdownProcessor(markdownMode = MarkdownMode.WithEditor(null)) val firstRun = processor.processWithQuickEdits(rawMarkdown) val secondRun = processor.processWithQuickEdits( @@ -265,7 +266,7 @@ class MarkdownProcessorOptimizeEditsTest { @Test fun `blocks merged`() { - val processor = MarkdownProcessor(editorMode = true) + val processor = MarkdownProcessor(markdownMode = MarkdownMode.WithEditor(null)) val firstRun = processor.processWithQuickEdits(rawMarkdown) val secondRun = processor.processWithQuickEdits( @@ -324,7 +325,7 @@ class MarkdownProcessorOptimizeEditsTest { @Test fun `blocks split`() { - val processor = MarkdownProcessor(editorMode = true) + val processor = MarkdownProcessor(markdownMode = MarkdownMode.WithEditor(null)) val firstRun = processor.processWithQuickEdits(rawMarkdown) val secondRun = processor.processWithQuickEdits( @@ -384,7 +385,7 @@ class MarkdownProcessorOptimizeEditsTest { @Test fun `blocks deleted`() { - val processor = MarkdownProcessor(editorMode = true) + val processor = MarkdownProcessor(markdownMode = MarkdownMode.WithEditor(null)) val firstRun = processor.processWithQuickEdits(rawMarkdown) val secondRun = processor.processWithQuickEdits( @@ -438,7 +439,7 @@ class MarkdownProcessorOptimizeEditsTest { @Test fun `blocks added`() { - val processor = MarkdownProcessor(editorMode = true) + val processor = MarkdownProcessor(markdownMode = MarkdownMode.WithEditor(null)) val firstRun = processor.processWithQuickEdits(rawMarkdown) val secondDocument = """ @@ -505,7 +506,7 @@ class MarkdownProcessorOptimizeEditsTest { @Test fun `no changes`() { - val processor = MarkdownProcessor(editorMode = true) + val processor = MarkdownProcessor(markdownMode = MarkdownMode.WithEditor(null)) val firstRun = processor.processWithQuickEdits(rawMarkdown) val secondRun = processor.processWithQuickEdits(rawMarkdown) assertHtmlEquals( @@ -538,7 +539,7 @@ class MarkdownProcessorOptimizeEditsTest { @Test fun `empty line added`() { - val processor = MarkdownProcessor(editorMode = true) + val processor = MarkdownProcessor(markdownMode = MarkdownMode.WithEditor(null)) val firstRun = processor.processWithQuickEdits(rawMarkdown) val secondRun = processor.processWithQuickEdits("\n" + rawMarkdown) assertHtmlEquals( @@ -573,7 +574,7 @@ class MarkdownProcessorOptimizeEditsTest { /** Regression https://github.com/JetBrains/jewel/issues/344 */ @Test fun `content if empty`() { - val processor = MarkdownProcessor(editorMode = true) + val processor = MarkdownProcessor(markdownMode = MarkdownMode.WithEditor(null)) processor.processWithQuickEdits(rawMarkdown) val secondRun = processor.processWithQuickEdits("") assertHtmlEquals( @@ -587,7 +588,7 @@ class MarkdownProcessorOptimizeEditsTest { @Test fun `chained changes`() { - val processor = MarkdownProcessor(editorMode = true) + val processor = MarkdownProcessor(markdownMode = MarkdownMode.WithEditor(null)) processor.processWithQuickEdits( """ # Header 0 diff --git a/markdown/extension/gfm-alerts/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/alerts/GitHubAlertBlockRenderer.kt b/markdown/extension/gfm-alerts/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/alerts/GitHubAlertBlockRenderer.kt index 51638ae6c8..6a9f5a458f 100644 --- a/markdown/extension/gfm-alerts/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/alerts/GitHubAlertBlockRenderer.kt +++ b/markdown/extension/gfm-alerts/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/alerts/GitHubAlertBlockRenderer.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.takeOrElse @@ -23,6 +22,7 @@ import org.jetbrains.jewel.markdown.extensions.github.alerts.Alert.Important import org.jetbrains.jewel.markdown.extensions.github.alerts.Alert.Note import org.jetbrains.jewel.markdown.extensions.github.alerts.Alert.Tip import org.jetbrains.jewel.markdown.extensions.github.alerts.Alert.Warning +import org.jetbrains.jewel.markdown.rendering.AutoScrollableBlock import org.jetbrains.jewel.markdown.rendering.InlineMarkdownRenderer import org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer import org.jetbrains.jewel.markdown.rendering.MarkdownStyling @@ -66,50 +66,56 @@ public class GitHubAlertBlockRenderer(private val styling: AlertStyling, private onUrlClick: (String) -> Unit, onTextClick: () -> Unit, ) { - Column( - Modifier.drawBehind { - val isLtr = layoutDirection == Ltr - val lineWidthPx = styling.lineWidth.toPx() - val x = if (isLtr) lineWidthPx / 2 else size.width - lineWidthPx / 2 + AutoScrollableBlock(block) { modifier -> + Column( + modifier + .drawBehind { + val isLtr = layoutDirection == Ltr + val lineWidthPx = styling.lineWidth.toPx() + val x = if (isLtr) lineWidthPx / 2 else size.width - lineWidthPx / 2 - drawLine( - color = styling.lineColor, - start = Offset(x, 0f), - end = Offset(x, size.height), - strokeWidth = lineWidthPx, - cap = styling.strokeCap, - pathEffect = styling.pathEffect, - ) - } - .padding(styling.padding), - verticalArrangement = Arrangement.spacedBy(rootStyling.blockVerticalSpacing), - ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - val titleIconKey = styling.titleIconKey - if (titleIconKey != null) { - Icon( - key = titleIconKey, - contentDescription = null, - iconClass = AlertStyling::class.java, - tint = styling.titleIconTint, - ) - } + drawLine( + color = styling.lineColor, + start = Offset(x, 0f), + end = Offset(x, size.height), + strokeWidth = lineWidthPx, + cap = styling.strokeCap, + pathEffect = styling.pathEffect, + ) + } + .padding(styling.padding), + verticalArrangement = Arrangement.spacedBy(rootStyling.blockVerticalSpacing), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val titleIconKey = styling.titleIconKey + if (titleIconKey != null) { + Icon( + key = titleIconKey, + contentDescription = null, + iconClass = AlertStyling::class.java, + tint = styling.titleIconTint, + ) + } + CompositionLocalProvider( + LocalContentColor provides styling.titleTextStyle.color.takeOrElse { LocalContentColor.current } + ) { + Text( + text = block.javaClass.simpleName, + style = styling.titleTextStyle, + modifier = modifier.pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), + ) + } + } CompositionLocalProvider( - LocalContentColor provides styling.titleTextStyle.color.takeOrElse { LocalContentColor.current } + LocalContentColor provides styling.textColor.takeOrElse { LocalContentColor.current } ) { - Text( - text = block.javaClass.simpleName, - style = styling.titleTextStyle, - modifier = Modifier.pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), - ) + blockRenderer.render(block.content, enabled, onUrlClick, onTextClick) } } - CompositionLocalProvider( - LocalContentColor provides styling.textColor.takeOrElse { LocalContentColor.current } - ) { - blockRenderer.render(block.content, enabled, onUrlClick, onTextClick) - } } } } diff --git a/markdown/ide-laf-bridge-styling/api/ide-laf-bridge-styling.api b/markdown/ide-laf-bridge-styling/api/ide-laf-bridge-styling.api index c139b91712..f6d7cfbc72 100644 --- a/markdown/ide-laf-bridge-styling/api/ide-laf-bridge-styling.api +++ b/markdown/ide-laf-bridge-styling/api/ide-laf-bridge-styling.api @@ -4,8 +4,8 @@ public final class org/jetbrains/jewel/intui/markdown/bridge/BridgeMarkdownBlock } public final class org/jetbrains/jewel/intui/markdown/bridge/BridgeProvideMarkdownStylingKt { - public static final fun ProvideMarkdownStyling (Lcom/intellij/openapi/project/Project;Ljava/lang/String;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling;Lorg/jetbrains/jewel/markdown/processing/MarkdownProcessor;Lorg/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V - public static final fun ProvideMarkdownStyling (Ljava/lang/String;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling;Lorg/jetbrains/jewel/markdown/processing/MarkdownProcessor;Lorg/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer;Lorg/jetbrains/jewel/foundation/code/highlighting/CodeHighlighter;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun ProvideMarkdownStyling (Lcom/intellij/openapi/project/Project;Ljava/lang/String;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling;Lorg/jetbrains/jewel/markdown/MarkdownMode;Lorg/jetbrains/jewel/markdown/processing/MarkdownProcessor;Lorg/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun ProvideMarkdownStyling (Ljava/lang/String;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling;Lorg/jetbrains/jewel/markdown/MarkdownMode;Lorg/jetbrains/jewel/markdown/processing/MarkdownProcessor;Lorg/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer;Lorg/jetbrains/jewel/foundation/code/highlighting/CodeHighlighter;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V } public final class org/jetbrains/jewel/intui/markdown/bridge/styling/BridgeMarkdownStylingKt { diff --git a/markdown/ide-laf-bridge-styling/src/main/kotlin/org/jetbrains/jewel/intui/markdown/bridge/BridgeProvideMarkdownStyling.kt b/markdown/ide-laf-bridge-styling/src/main/kotlin/org/jetbrains/jewel/intui/markdown/bridge/BridgeProvideMarkdownStyling.kt index 31d04c9b7a..815aba45d8 100644 --- a/markdown/ide-laf-bridge-styling/src/main/kotlin/org/jetbrains/jewel/intui/markdown/bridge/BridgeProvideMarkdownStyling.kt +++ b/markdown/ide-laf-bridge-styling/src/main/kotlin/org/jetbrains/jewel/intui/markdown/bridge/BridgeProvideMarkdownStyling.kt @@ -12,7 +12,9 @@ import org.jetbrains.jewel.foundation.code.highlighting.LocalCodeHighlighter import org.jetbrains.jewel.foundation.code.highlighting.NoOpCodeHighlighter import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.intui.markdown.bridge.styling.create +import org.jetbrains.jewel.markdown.MarkdownMode import org.jetbrains.jewel.markdown.extensions.LocalMarkdownBlockRenderer +import org.jetbrains.jewel.markdown.extensions.LocalMarkdownMode import org.jetbrains.jewel.markdown.extensions.LocalMarkdownProcessor import org.jetbrains.jewel.markdown.extensions.LocalMarkdownStyling import org.jetbrains.jewel.markdown.processing.MarkdownProcessor @@ -24,7 +26,8 @@ import org.jetbrains.jewel.markdown.rendering.MarkdownStyling public fun ProvideMarkdownStyling( themeName: String = JewelTheme.name, markdownStyling: MarkdownStyling = remember(themeName) { MarkdownStyling.create() }, - markdownProcessor: MarkdownProcessor = remember { MarkdownProcessor() }, + markdownMode: MarkdownMode = remember { MarkdownMode.PreviewOnly }, + markdownProcessor: MarkdownProcessor = remember { MarkdownProcessor(markdownMode = markdownMode) }, markdownBlockRenderer: MarkdownBlockRenderer = remember(markdownStyling) { MarkdownBlockRenderer.create(markdownStyling) }, codeHighlighter: CodeHighlighter = remember { NoOpCodeHighlighter }, @@ -32,6 +35,7 @@ public fun ProvideMarkdownStyling( ) { CompositionLocalProvider( LocalMarkdownStyling provides markdownStyling, + LocalMarkdownMode provides markdownMode, LocalMarkdownProcessor provides markdownProcessor, LocalMarkdownBlockRenderer provides markdownBlockRenderer, LocalCodeHighlighter provides codeHighlighter, @@ -46,7 +50,8 @@ public fun ProvideMarkdownStyling( project: Project, themeName: String = JewelTheme.name, markdownStyling: MarkdownStyling = remember(themeName) { MarkdownStyling.create() }, - markdownProcessor: MarkdownProcessor = remember { MarkdownProcessor() }, + markdownMode: MarkdownMode = remember { MarkdownMode.PreviewOnly }, + markdownProcessor: MarkdownProcessor = remember { MarkdownProcessor(markdownMode = markdownMode) }, markdownBlockRenderer: MarkdownBlockRenderer = remember(markdownStyling) { MarkdownBlockRenderer.create(markdownStyling) }, content: @Composable () -> Unit, @@ -56,6 +61,7 @@ public fun ProvideMarkdownStyling( ProvideMarkdownStyling( themeName = themeName, markdownStyling = markdownStyling, + markdownMode = markdownMode, markdownProcessor = markdownProcessor, markdownBlockRenderer = markdownBlockRenderer, codeHighlighter = codeHighlighter, diff --git a/markdown/int-ui-standalone-styling/api/int-ui-standalone-styling.api b/markdown/int-ui-standalone-styling/api/int-ui-standalone-styling.api index d1453f58fa..4e4693c68c 100644 --- a/markdown/int-ui-standalone-styling/api/int-ui-standalone-styling.api +++ b/markdown/int-ui-standalone-styling/api/int-ui-standalone-styling.api @@ -6,8 +6,8 @@ public final class org/jetbrains/jewel/intui/markdown/standalone/IntUiMarkdownBl } public final class org/jetbrains/jewel/intui/markdown/standalone/IntUiProvideMarkdownStylingKt { - public static final fun ProvideMarkdownStyling (Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling;Lorg/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer;Lorg/jetbrains/jewel/foundation/code/highlighting/CodeHighlighter;Lorg/jetbrains/jewel/markdown/processing/MarkdownProcessor;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V - public static final fun ProvideMarkdownStyling (ZLorg/jetbrains/jewel/markdown/rendering/MarkdownStyling;Lorg/jetbrains/jewel/markdown/processing/MarkdownProcessor;Lorg/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer;Lorg/jetbrains/jewel/foundation/code/highlighting/CodeHighlighter;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun ProvideMarkdownStyling (Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling;Lorg/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer;Lorg/jetbrains/jewel/foundation/code/highlighting/CodeHighlighter;Lorg/jetbrains/jewel/markdown/MarkdownMode;Lorg/jetbrains/jewel/markdown/processing/MarkdownProcessor;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun ProvideMarkdownStyling (ZLorg/jetbrains/jewel/markdown/rendering/MarkdownStyling;Lorg/jetbrains/jewel/markdown/MarkdownMode;Lorg/jetbrains/jewel/markdown/processing/MarkdownProcessor;Lorg/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer;Lorg/jetbrains/jewel/foundation/code/highlighting/CodeHighlighter;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V } public final class org/jetbrains/jewel/intui/markdown/standalone/styling/IntUiMarkdownStylingKt { diff --git a/markdown/int-ui-standalone-styling/src/main/kotlin/org/jetbrains/jewel/intui/markdown/standalone/IntUiProvideMarkdownStyling.kt b/markdown/int-ui-standalone-styling/src/main/kotlin/org/jetbrains/jewel/intui/markdown/standalone/IntUiProvideMarkdownStyling.kt index c62e396671..66dd206a44 100644 --- a/markdown/int-ui-standalone-styling/src/main/kotlin/org/jetbrains/jewel/intui/markdown/standalone/IntUiProvideMarkdownStyling.kt +++ b/markdown/int-ui-standalone-styling/src/main/kotlin/org/jetbrains/jewel/intui/markdown/standalone/IntUiProvideMarkdownStyling.kt @@ -10,7 +10,9 @@ import org.jetbrains.jewel.foundation.code.highlighting.NoOpCodeHighlighter import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.intui.markdown.standalone.styling.dark import org.jetbrains.jewel.intui.markdown.standalone.styling.light +import org.jetbrains.jewel.markdown.MarkdownMode import org.jetbrains.jewel.markdown.extensions.LocalMarkdownBlockRenderer +import org.jetbrains.jewel.markdown.extensions.LocalMarkdownMode import org.jetbrains.jewel.markdown.extensions.LocalMarkdownProcessor import org.jetbrains.jewel.markdown.extensions.LocalMarkdownStyling import org.jetbrains.jewel.markdown.processing.MarkdownProcessor @@ -29,6 +31,7 @@ public fun ProvideMarkdownStyling( MarkdownStyling.light() } }, + markdownMode: MarkdownMode = remember { MarkdownMode.PreviewOnly }, markdownProcessor: MarkdownProcessor = remember { MarkdownProcessor() }, markdownBlockRenderer: MarkdownBlockRenderer = remember(markdownStyling) { @@ -43,6 +46,7 @@ public fun ProvideMarkdownStyling( ) { CompositionLocalProvider( LocalMarkdownStyling provides markdownStyling, + LocalMarkdownMode provides markdownMode, LocalMarkdownProcessor provides markdownProcessor, LocalMarkdownBlockRenderer provides markdownBlockRenderer, LocalCodeHighlighter provides codeHighlighter, @@ -57,11 +61,13 @@ public fun ProvideMarkdownStyling( markdownStyling: MarkdownStyling, markdownBlockRenderer: MarkdownBlockRenderer, codeHighlighter: CodeHighlighter, - markdownProcessor: MarkdownProcessor = remember { MarkdownProcessor() }, + markdownMode: MarkdownMode = remember { MarkdownMode.PreviewOnly }, + markdownProcessor: MarkdownProcessor = remember { MarkdownProcessor(markdownMode = markdownMode) }, content: @Composable () -> Unit, ) { CompositionLocalProvider( LocalMarkdownStyling provides markdownStyling, + LocalMarkdownMode provides markdownMode, LocalMarkdownProcessor provides markdownProcessor, LocalMarkdownBlockRenderer provides markdownBlockRenderer, LocalCodeHighlighter provides codeHighlighter, diff --git a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/MarkdownView.kt b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/MarkdownView.kt index e35cfa48b4..49d045a26d 100644 --- a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/MarkdownView.kt +++ b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/MarkdownView.kt @@ -9,6 +9,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import org.jetbrains.jewel.foundation.modifier.trackActivation import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.markdown.MarkdownMode +import org.jetbrains.jewel.markdown.WithMarkdownMode import org.jetbrains.jewel.samples.standalone.view.markdown.JewelReadme import org.jetbrains.jewel.samples.standalone.view.markdown.MarkdownEditor import org.jetbrains.jewel.samples.standalone.view.markdown.MarkdownPreview @@ -18,11 +20,13 @@ import org.jetbrains.jewel.ui.component.Divider @Composable fun MarkdownDemo() { Row(Modifier.trackActivation().fillMaxSize().background(JewelTheme.globalColors.panelBackground)) { - val editorState = rememberTextFieldState(JewelReadme) - MarkdownEditor(state = editorState, modifier = Modifier.fillMaxHeight().weight(1f)) + WithMarkdownMode(MarkdownMode.WithEditor(scrollingSynchronizer = null)) { + val editorState = rememberTextFieldState(JewelReadme) + MarkdownEditor(state = editorState, modifier = Modifier.fillMaxHeight().weight(1f)) - Divider(Orientation.Vertical, Modifier.fillMaxHeight()) + Divider(Orientation.Vertical, Modifier.fillMaxHeight()) - MarkdownPreview(modifier = Modifier.fillMaxHeight().weight(1f), rawMarkdown = editorState.text) + MarkdownPreview(modifier = Modifier.fillMaxHeight().weight(1f), rawMarkdown = editorState.text) + } } } diff --git a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/markdown/MarkdownPreview.kt b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/markdown/MarkdownPreview.kt index 40a796138b..ce782d8a2c 100644 --- a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/markdown/MarkdownPreview.kt +++ b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/markdown/MarkdownPreview.kt @@ -28,6 +28,7 @@ import org.jetbrains.jewel.intui.markdown.standalone.styling.light import org.jetbrains.jewel.markdown.LazyMarkdown import org.jetbrains.jewel.markdown.MarkdownBlock import org.jetbrains.jewel.markdown.extension.autolink.AutolinkProcessorExtension +import org.jetbrains.jewel.markdown.extensions.LocalMarkdownMode import org.jetbrains.jewel.markdown.extensions.github.alerts.AlertStyling import org.jetbrains.jewel.markdown.extensions.github.alerts.GitHubAlertProcessorExtension import org.jetbrains.jewel.markdown.extensions.github.alerts.GitHubAlertRendererExtension @@ -45,11 +46,11 @@ internal fun MarkdownPreview(modifier: Modifier = Modifier, rawMarkdown: CharSeq var markdownBlocks by remember { mutableStateOf(emptyList()) } val extensions = remember { listOf(GitHubAlertProcessorExtension, AutolinkProcessorExtension) } - + val markdownMode = LocalMarkdownMode.current // We are doing this here for the sake of simplicity. // In a real-world scenario you would be doing this outside your Composables, // potentially involving ViewModels, dependency injection, etc. - val processor = remember { MarkdownProcessor(extensions, editorMode = true) } + val processor = remember { MarkdownProcessor(extensions, markdownMode = markdownMode) } LaunchedEffect(rawMarkdown) { // TODO you may want to debounce or drop on backpressure, in real usages. You should also