[markdown][compose] IJPL-175966 Provide smoother animation

(cherry picked from commit 12880ed9794077b5f6ad185fe38c25113f1d53a2)


(cherry picked from commit 14208dba7b101b9760f25156ce7253d9984453dd)

IJ-MR-155570

GitOrigin-RevId: e1451e4e94dba8c97aaa46e50cca401919a2d357
This commit is contained in:
Alexander Kuznetsov
2025-01-31 14:03:38 +01:00
committed by intellij-monorepo-bot
parent 0eab3bb84a
commit 0e36c06321
4 changed files with 71 additions and 17 deletions

View File

@@ -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

View File

@@ -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<Float> = 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<MarkdownBlock, List<Int>>()
override suspend fun scrollToLine(sourceLine: Int) {
override suspend fun scrollToLine(sourceLine: Int, animationSpec: AnimationSpec<Float>) {
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? {

View File

@@ -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 {

View File

@@ -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<Int>(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<Float> = 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) {