Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Markdown: synchronize scrolling between editor and preview #694

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 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,8 +324,8 @@ 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;
}
Expand All @@ -319,6 +344,7 @@ public class org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer
public static final field $stable I
public fun <init> (Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling;Ljava/util/List;Lorg/jetbrains/jewel/markdown/rendering/InlineMarkdownRenderer;)V
public synthetic fun <init> (Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling;Ljava/util/List;Lorg/jetbrains/jewel/markdown/rendering/InlineMarkdownRenderer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
protected final fun MaybeScrollingContainer (ZLandroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V
public fun render (Ljava/util/List;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
public fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$BlockQuote;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$BlockQuote;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
public fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$CodeBlock$FencedCodeBlock;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$Code$Fenced;Landroidx/compose/runtime/Composer;I)V
Expand All @@ -333,6 +359,7 @@ public class org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer
public fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$ListItem;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
public fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$Paragraph;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$Paragraph;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
public fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
public fun render-EWr_ITI (Lorg/jetbrains/jewel/markdown/MarkdownBlock$CodeBlock$FencedCodeBlock;Ljava/lang/String;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$Code$Fenced;Landroidx/compose/runtime/Composer;I)V
public fun renderThematicBreak (Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$ThematicBreak;Landroidx/compose/runtime/Composer;I)V
}

Expand Down Expand Up @@ -751,3 +778,32 @@ public abstract interface class org/jetbrains/jewel/markdown/rendering/WithUnder
public abstract fun getUnderlineWidth-D9Ej5fM ()F
}

public final class org/jetbrains/jewel/markdown/scrolling/AutoScrollingUtilKt {
public static final fun AutoScrollableBlock (Lorg/jetbrains/jewel/markdown/MarkdownBlock;Lorg/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V
}

public class org/jetbrains/jewel/markdown/scrolling/ScrollSyncMarkdownBlockRenderer : org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer {
public static final field $stable I
public fun <init> (Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling;Ljava/util/List;Lorg/jetbrains/jewel/markdown/rendering/InlineMarkdownRenderer;)V
public fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$CodeBlock$IndentedCodeBlock;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$Code$Indented;Landroidx/compose/runtime/Composer;I)V
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$Paragraph;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$Paragraph;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
public fun render-EWr_ITI (Lorg/jetbrains/jewel/markdown/MarkdownBlock$CodeBlock$FencedCodeBlock;Ljava/lang/String;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$Code$Fenced;Landroidx/compose/runtime/Composer;I)V
}

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;
}

1 change: 1 addition & 0 deletions markdown/core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dependencies {

testImplementation(compose.desktop.uiTestJUnit4)
testImplementation(projects.ui)
testImplementation(compose.desktop.currentOs)
}

publicApiValidation {
Expand Down
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
Loading