Skip to content

Commit

Permalink
Markdown: synchronize scrolling between editor and preview (JetBrains…
Browse files Browse the repository at this point in the history
…#690)

Only ScrollState is supported because of
its natural ability to scroll the view
to an arbitrary coordinate,

LazyListState doesn't allow this, and it
only gives an opportunity to scroll to
an item in a LazyColumn list and then
to a position within the item.
So, it requires a different approach
(which can hopefully be adjusted to the
proposed ScrollingSynchronizer API).

Note that the change only enables auto-scrolling
in a preview to match the position in the source,
it doesn't work the other way around.
  • Loading branch information
AlexVanGogen committed Nov 19, 2024
1 parent 547e4c2 commit 65e3f14
Show file tree
Hide file tree
Showing 15 changed files with 720 additions and 134 deletions.
50 changes: 48 additions & 2 deletions markdown/core/api/core.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> (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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 <init> ()V
public fun <init> (Ljava/util/List;ZLorg/commonmark/parser/Parser;)V
public synthetic fun <init> (Ljava/util/List;ZLorg/commonmark/parser/Parser;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (Ljava/util/List;Lorg/jetbrains/jewel/markdown/MarkdownMode;Lorg/commonmark/parser/Parser;)V
public synthetic fun <init> (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;
Expand Down Expand Up @@ -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 <init> ()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;
}

Original file line number Diff line number Diff line change
@@ -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() }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,3 +29,10 @@ public val LocalMarkdownBlockRenderer: ProvidableCompositionLocal<MarkdownBlockR

public val JewelTheme.Companion.markdownBlockRenderer: MarkdownBlockRenderer
@Composable get() = LocalMarkdownBlockRenderer.current

public val LocalMarkdownMode: ProvidableCompositionLocal<MarkdownMode> = staticCompositionLocalOf {
MarkdownMode.PreviewOnly
}

public val JewelTheme.Companion.markdownMode: MarkdownMode
@Composable get() = LocalMarkdownMode.current
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<MarkdownProcessorExtension> = 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
Expand All @@ -69,14 +74,19 @@ public class MarkdownProcessor(
* @see DefaultInlineMarkdownRenderer
*/
public fun processMarkdownDocument(@Language("Markdown") rawMarkdown: String): List<MarkdownBlock> {
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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())

Expand Down Expand Up @@ -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'))
Expand Down
Loading

0 comments on commit 65e3f14

Please sign in to comment.