From 0e36c06321aa35cb1dca4984070132e924eef904 Mon Sep 17 00:00:00 2001 From: Alexander Kuznetsov Date: Fri, 31 Jan 2025 14:03:38 +0100 Subject: [PATCH] [markdown][compose] IJPL-175966 Provide smoother animation (cherry picked from commit 12880ed9794077b5f6ad185fe38c25113f1d53a2) (cherry picked from commit 14208dba7b101b9760f25156ce7253d9984453dd) IJ-MR-155570 GitOrigin-RevId: e1451e4e94dba8c97aaa46e50cca401919a2d357 --- .../markdown/core/api-dump-unreviewed.txt | 3 +- .../scrolling/ScrollingSynchronizer.kt | 8 +- .../scrolling/ScrollingSynchronizerTest.kt | 2 +- .../compose/preview/MarkdownComposePanel.kt | 75 ++++++++++++++++--- 4 files changed, 71 insertions(+), 17 deletions(-) diff --git a/platform/jewel/markdown/core/api-dump-unreviewed.txt b/platform/jewel/markdown/core/api-dump-unreviewed.txt index c4d26f118a41..5130d60e19d5 100644 --- a/platform/jewel/markdown/core/api-dump-unreviewed.txt +++ b/platform/jewel/markdown/core/api-dump-unreviewed.txt @@ -581,6 +581,7 @@ a:org.jetbrains.jewel.markdown.scrolling.ScrollingSynchronizer - pa:afterProcessing():V - pa:beforeProcessing():V - f:process(kotlin.jvm.functions.Function0):java.lang.Object -- a:scrollToLine(I,kotlin.coroutines.Continuation):java.lang.Object +- a:scrollToLine(I,androidx.compose.animation.core.AnimationSpec,kotlin.coroutines.Continuation):java.lang.Object +- bs:scrollToLine$default(org.jetbrains.jewel.markdown.scrolling.ScrollingSynchronizer,I,androidx.compose.animation.core.AnimationSpec,kotlin.coroutines.Continuation,I,java.lang.Object):java.lang.Object f:org.jetbrains.jewel.markdown.scrolling.ScrollingSynchronizer$Companion - f:create(androidx.compose.foundation.gestures.ScrollableState):org.jetbrains.jewel.markdown.scrolling.ScrollingSynchronizer diff --git a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer.kt b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer.kt index 5bf2c1771095..b80a0fbdcdf6 100644 --- a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer.kt +++ b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer.kt @@ -2,6 +2,8 @@ // Apache 2.0 license. package org.jetbrains.jewel.markdown.scrolling +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.SpringSpec import androidx.compose.foundation.ScrollState import androidx.compose.foundation.gestures.ScrollableState import androidx.compose.foundation.lazy.LazyListState @@ -69,7 +71,7 @@ import org.jetbrains.jewel.markdown.processing.MarkdownProcessor @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) + public abstract suspend fun scrollToLine(sourceLine: Int, animationSpec: AnimationSpec = SpringSpec()) /** * Called when [MarkdownProcessor] processes the raw markdown text. The processing itself is passed as an [action]. @@ -146,7 +148,7 @@ public abstract class ScrollingSynchronizer { // so this map always keeps relevant information. private val blocks2TextOffsets = mutableMapOf>() - override suspend fun scrollToLine(sourceLine: Int) { + override suspend fun scrollToLine(sourceLine: Int, animationSpec: AnimationSpec) { val block = findBestBlockForLine(sourceLine) ?: return val y = blocks2Top[block] ?: return if (y < 0) return @@ -156,7 +158,7 @@ public abstract class ScrollingSynchronizer { // 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) + scrollState.animateScrollTo(y + lineOffset, animationSpec) } private fun findBestBlockForLine(line: Int): MarkdownBlock? { diff --git a/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizerTest.kt b/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizerTest.kt index 05f6a8c683cb..94d1eb08a199 100644 --- a/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizerTest.kt +++ b/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizerTest.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import java.util.Arrays import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -59,7 +60,6 @@ import org.jetbrains.jewel.ui.component.styling.TrackClickBehavior import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test -import java.util.Arrays @Suppress("LargeClass") class ScrollingSynchronizerTest { diff --git a/plugins/markdown/compose/src/main/kotlin/com/intellij/markdown/compose/preview/MarkdownComposePanel.kt b/plugins/markdown/compose/src/main/kotlin/com/intellij/markdown/compose/preview/MarkdownComposePanel.kt index 07dc201af7de..b310c64091fc 100644 --- a/plugins/markdown/compose/src/main/kotlin/com/intellij/markdown/compose/preview/MarkdownComposePanel.kt +++ b/plugins/markdown/compose/src/main/kotlin/com/intellij/markdown/compose/preview/MarkdownComposePanel.kt @@ -1,38 +1,50 @@ -// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.intellij.markdown.compose.preview +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.TweenSpec import androidx.compose.foundation.* import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.intellij.ide.BrowserUtil +import com.intellij.openapi.editor.Editor import com.intellij.openapi.project.Project import com.intellij.openapi.util.UserDataHolder import com.intellij.openapi.util.UserDataHolderBase import com.intellij.openapi.vfs.VirtualFile import com.intellij.platform.compose.JBComposePanel +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch import org.intellij.plugins.markdown.ui.preview.MarkdownHtmlPanel import org.intellij.plugins.markdown.ui.preview.MarkdownHtmlPanelEx import org.intellij.plugins.markdown.ui.preview.MarkdownUpdateHandler import org.intellij.plugins.markdown.ui.preview.MarkdownUpdateHandler.PreviewRequest import org.intellij.plugins.markdown.ui.preview.PreviewStyleScheme import org.jetbrains.annotations.ApiStatus +import org.jetbrains.jewel.bridge.code.highlighting.CodeHighlighterFactory import org.jetbrains.jewel.bridge.toComposeColor import org.jetbrains.jewel.foundation.ExperimentalJewelApi +import org.jetbrains.jewel.foundation.code.highlighting.NoOpCodeHighlighter import org.jetbrains.jewel.intui.markdown.bridge.ProvideMarkdownStyling import org.jetbrains.jewel.markdown.Markdown -import org.jetbrains.jewel.ui.component.VerticalScrollbar +import org.jetbrains.jewel.markdown.MarkdownMode +import org.jetbrains.jewel.markdown.rendering.DefaultInlineMarkdownRenderer +import org.jetbrains.jewel.markdown.scrolling.ScrollSyncMarkdownBlockRenderer +import org.jetbrains.jewel.markdown.scrolling.ScrollingSynchronizer import javax.swing.JComponent +import kotlin.time.Duration.Companion.milliseconds @ExperimentalJewelApi -class MarkdownComposePanel( +internal class MarkdownComposePanel( private val project: Project?, private val virtualFile: VirtualFile?, private val updateHandler: MarkdownUpdateHandler = MarkdownUpdateHandler.Debounced() @@ -40,6 +52,8 @@ class MarkdownComposePanel( constructor() : this(null, null) + private val scrollToLineFlow = MutableSharedFlow(replay = 1) + private val panelComponent by lazy { JBComposePanel { // TODO temporary styling, we will likely need our own in the future for JCEF-like rendering @@ -52,16 +66,26 @@ class MarkdownComposePanel( private fun MarkdownPanel() { val scheme = PreviewStyleScheme.fromCurrentTheme() val fontSize = scheme.fontSize.sp / scheme.scale + val scrollState = rememberScrollState(0) + val scrollingSynchronizer = remember(scrollState) { ScrollingSynchronizer.create(scrollState) } + val markdownStyling = JcefLikeMarkdownStyling(scheme, fontSize) + val blockRenderer = ScrollSyncMarkdownBlockRenderer(markdownStyling, emptyList(), DefaultInlineMarkdownRenderer(emptyList())) ProvideMarkdownStyling( - markdownStyling = JcefLikeMarkdownStyling(scheme, fontSize), + markdownMode = MarkdownMode.EditorPreview(scrollingSynchronizer), + markdownStyling = markdownStyling, + codeHighlighter = remember(project) { + project?.let { + CodeHighlighterFactory.getInstance(project).createHighlighter() + } ?: NoOpCodeHighlighter + }, + markdownBlockRenderer = blockRenderer ) { Box( modifier = Modifier .background(scheme.backgroundColor.toComposeColor()) .padding(horizontal = fontSize.value.dp * 2) ) { - val scrollState = rememberScrollState(0) - MarkdownPreviewPanel(scrollState) + MarkdownPreviewPanel(scrollState, scrollingSynchronizer, blockRenderer) VerticalScrollbar( modifier = Modifier .align(Alignment.CenterEnd), @@ -71,11 +95,33 @@ class MarkdownComposePanel( } } + @OptIn(FlowPreview::class) @Suppress("FunctionName") @Composable - private fun MarkdownPreviewPanel(scrollState: ScrollState) { + private fun MarkdownPreviewPanel(scrollState: ScrollState, + scrollingSynchronizer: ScrollingSynchronizer?, + blockRenderer: ScrollSyncMarkdownBlockRenderer, + animationSpec: AnimationSpec = TweenSpec(easing = LinearEasing) + ) { val request by updateHandler.requests.collectAsState(null) (request as? PreviewRequest.Update)?.let { + if (scrollingSynchronizer != null) { + val coroutineScope = rememberCoroutineScope() + LaunchedEffect(Unit) { + coroutineScope.launch { + scrollToLineFlow.debounce(16.milliseconds).collect { scrollToLine -> + scrollingSynchronizer.scrollToLine(scrollToLine, animationSpec) + } + } + } + LaunchedEffect(it.initialScrollOffset) { + coroutineScope.launch { + if (it.initialScrollOffset != 0) { + scrollToLineFlow.emit(it.initialScrollOffset) + } + } + } + } Markdown( it.content, modifier = Modifier @@ -84,12 +130,16 @@ class MarkdownComposePanel( enabled = true, selectable = true, onUrlClick = { url -> BrowserUtil.open(url) }, + blockRenderer = blockRenderer, ) } } override fun setHtml(html: String, initialScrollOffset: Int, document: VirtualFile?) { - updateHandler.setContent(html, initialScrollOffset, document) + } + + override fun setHtml(html: String, initialScrollOffset: Int, initialScrollLineNumber: Int, document: VirtualFile?) { + updateHandler.setContent(html, initialScrollLineNumber, document) } override fun reloadWithOffset(offset: Int) { @@ -115,7 +165,8 @@ class MarkdownComposePanel( override fun removeScrollListener(listener: MarkdownHtmlPanel.ScrollListener) { } - override fun scrollToMarkdownSrcOffset(offset: Int, smooth: Boolean) { + override suspend fun scrollTo(editor: Editor, line: Int) { + scrollToLineFlow.emit(line) } override fun scrollBy(horizontalUnits: Int, verticalUnits: Int) {