@@ -85,8 +85,17 @@ import org.jetbrains.jewel.markdown.processing.MarkdownProcessor
8585@ApiStatus.Experimental
8686@ExperimentalJewelApi
8787public abstract class ScrollingSynchronizer {
88- /* * Scroll the preview to the position that match the given [sourceLine] the best. */
89- public abstract suspend fun scrollToLine (sourceLine : Int , animationSpec : AnimationSpec <Float > = SpringSpec ())
88+ /* * Scroll the preview to the position that matches the given [sourceLine] the best. */
89+ @ApiStatus.NonExtendable
90+ public open suspend fun scrollToLine (sourceLine : Int , animationSpec : AnimationSpec <Float > = SpringSpec ()) {
91+ scrollToCoordinate(findYCoordinateToScroll(sourceLine), animationSpec)
92+ }
93+
94+ /* * Scroll the preview to the given vertical position [y] using given [animationSpec]. */
95+ protected abstract suspend fun scrollToCoordinate (y : Int , animationSpec : AnimationSpec <Float > = SpringSpec ())
96+
97+ /* * Find the vertical position in the preview that matches the given [sourceLine] the best. */
98+ protected abstract suspend fun findYCoordinateToScroll (sourceLine : Int ): Int
9099
91100 /* *
92101 * Called when [MarkdownProcessor] processes the raw markdown text. The processing itself is passed as an [action].
@@ -185,28 +194,56 @@ public abstract class ScrollingSynchronizer {
185194 // so this map always keeps relevant information.
186195 private val blocks2TextOffsets = mutableMapOf<MarkdownBlock , List <Int >>()
187196
188- override suspend fun scrollToLine (sourceLine : Int , animationSpec : AnimationSpec <Float >) {
189- val block = findBestBlockForLine(sourceLine) ? : return
190- val y = blocks2Top[block] ? : return
191- if (y < 0 ) return
192- val lineRange = (block as ? LocatableMarkdownBlock )?.lines ? : return
193- val textOffsets = blocks2TextOffsets[block]
197+ override suspend fun scrollToCoordinate (y : Int , animationSpec : AnimationSpec <Float >) {
198+ scrollState.animateScrollTo(y, animationSpec)
199+ }
200+
201+ override suspend fun findYCoordinateToScroll (sourceLine : Int ): Int {
202+ blocksSortedByPreference(sourceLine).forEach { block ->
203+ val positionToScroll = block.positionToScroll(sourceLine)
204+ if (positionToScroll != null ) {
205+ return positionToScroll
206+ }
207+ }
208+ return 0
209+ }
210+
211+ private fun blocksSortedByPreference (sourceLine : Int ) = iterator {
212+ val blockOnLine = lines2Blocks[sourceLine]
213+ if (blockOnLine != null ) {
214+ yield (blockOnLine)
215+ } else {
216+ // If there is no block that covers the line,
217+ // the next best block is the one **after** the line.
218+ // Otherwise, when scrolling down the source,
219+ // on empty lines the preview will scroll
220+ // in the opposite direction
221+ val firstBlockAfterLine = lines2Blocks.higherEntry(sourceLine)?.value
222+ if (firstBlockAfterLine != null ) {
223+ yield (firstBlockAfterLine)
224+ }
225+ }
226+ // Otherwise, look for the closest block positioned before the line.
227+ // This way, the corresponding preview line will be located
228+ // below the viewport's top point (and the user still has a chance
229+ // to see it in the visible area)
230+ val blocksBeforeLine = lines2Blocks.headMap(sourceLine)
231+ val blocksBeforeLineClosestFirst = blocksBeforeLine.values.reversed()
232+ for (block in blocksBeforeLineClosestFirst) {
233+ yield (block)
234+ }
235+ }
236+
237+ private fun MarkdownBlock.positionToScroll (sourceLine : Int ): Int? {
238+ val y = blocks2Top[this ] ? : return null
239+ val lineRange = (this as ? LocatableMarkdownBlock )?.lines ? : return y
240+
194241 // The line may be empty and represent no block,
195242 // in this case scroll to the first line of the first block positioned after the line
196243 val lineIndexInBlock = maxOf(0 , sourceLine - lineRange.first)
197- val lineOffset = textOffsets?.get(lineIndexInBlock) ? : 0
198- scrollState.animateScrollTo(y + lineOffset, animationSpec)
199- }
244+ val textOffsets = blocks2TextOffsets[this ]
200245
201- private fun findBestBlockForLine (line : Int ): MarkdownBlock ? {
202- // The best block is the one **below** the line if there is no block that covers the
203- // line.
204- // Otherwise, when scrolling down the source, on empty lines preview will scroll in the
205- // opposite direction
206- val sm = lines2Blocks.subMap(line, Int .MAX_VALUE )
207- if (sm.isEmpty()) return null
208- // TODO use firstEntry() after switching to JDK 21
209- return sm.getValue(sm.firstKey())
246+ return y + (textOffsets?.getOrNull(lineIndexInBlock) ? : 0 )
210247 }
211248
212249 override fun beforeProcessing () {
0 commit comments