/*
* Episteme Reader - A native Android document reader.
* Copyright (C) 2026 Episteme
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*
* mail: epistemereader@gmail.com
*/
// PdfVerticalReader.kt
@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH", "VariableNeverRead")
package com.aryan.reader.pdf
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.RectF
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.exponentialDecay
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculateCentroid
import androidx.compose.foundation.gestures.calculateCentroidSize
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.lerp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.isPrimaryPressed
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.input.pointer.isTertiaryPressed
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import com.aryan.reader.SearchResult
import com.aryan.reader.ml.SpeechBubble
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.VirtualPage
import com.aryan.reader.shared.pdf.calculatePdfVerticalPageLayoutPx
import com.aryan.reader.shared.pdf.pdfVerticalPageGapDp
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
private const val SCROLL_BOUNDS_TAG = "PdfScrollBounds"
@Stable
class VerticalPdfReaderState {
var currentPage by mutableIntStateOf(0)
internal set
var firstVisiblePage by mutableIntStateOf(0)
internal set
var lastVisiblePage by mutableIntStateOf(0)
internal set
internal var scrollToPageHandler: (suspend (Int) -> Unit)? = null
internal var snapToPageHandler: (suspend (Int) -> Unit)? = null
internal var scrollByHandler: (suspend (Float) -> Unit)? = null
internal var scrollToTopHandler: (suspend () -> Unit)? = null
internal var scrollToBottomHandler: (suspend () -> Unit)? = null
suspend fun scrollToPage(pageIndex: Int) {
scrollToPageHandler?.invoke(pageIndex)
}
suspend fun snapToPage(pageIndex: Int) {
snapToPageHandler?.invoke(pageIndex)
}
suspend fun scrollBy(delta: Float) {
scrollByHandler?.invoke(delta)
}
suspend fun scrollToTop() {
scrollToTopHandler?.invoke()
}
suspend fun scrollToBottom() {
scrollToBottomHandler?.invoke()
}
}
@Composable
fun rememberVerticalPdfReaderState(): VerticalPdfReaderState {
return remember { VerticalPdfReaderState() }
}
private data class PdfPageLayout(
val index: Int,
val yPx: Int,
val heightPx: Int,
val widthPx: Int,
val widthDp: Dp,
val heightDp: Dp
) {
val y: Float
get() = yPx.toFloat()
val height: Float
get() = heightPx.toFloat()
val width: Float
get() = widthPx.toFloat()
}
private data class DividerLayout(val yPx: Int, val widthPx: Int, val heightPx: Int) {
val y: Float
get() = yPx.toFloat()
val width: Float
get() = widthPx.toFloat()
val height: Float
get() = heightPx.toFloat()
}
internal data class PdfLockedOrientationResetCamera(
val zoom: Float,
val panX: Float,
val panY: Float
)
internal fun calculateLockedOrientationResetCamera(
pageTopY: Float,
totalDocHeight: Float,
screenWidth: Float,
screenHeight: Float,
headerHeightPx: Float,
footerHeightPx: Float,
fitZoom: Float
): PdfLockedOrientationResetCamera {
val targetPanY = headerHeightPx - (pageTopY * fitZoom)
val zoomedDocHeight = totalDocHeight * fitZoom
val minPanY = if (zoomedDocHeight < (screenHeight - headerHeightPx - footerHeightPx)) {
headerHeightPx
} else {
(screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
}
val finalPanY = targetPanY.coerceIn(minPanY, headerHeightPx)
val zoomedDocWidth = screenWidth * fitZoom
val targetPanX = if (zoomedDocWidth < screenWidth) {
(screenWidth - zoomedDocWidth) / 2f
} else {
0f
}
return PdfLockedOrientationResetCamera(
zoom = fitZoom,
panX = targetPanX,
panY = finalPanY
)
}
@Suppress("UnusedVariable")
@SuppressLint("UnusedBoxWithConstraintsScope", "BinaryOperationInTimber")
@OptIn(FlowPreview::class)
@Composable
internal fun PdfVerticalReader(
modifier: Modifier = Modifier,
state: VerticalPdfReaderState,
pdfDocument: StableHolder,
documentKey: String,
activeTheme: com.aryan.reader.ReaderTheme,
activeTextureAlpha: Float = 0.55f,
excludeImages: Boolean = false,
totalPages: Int,
virtualPages: List = emptyList(),
pageAspectRatios: StableHolder>,
headerHeight: Dp,
footerHeight: Dp,
onZoomChange: (Float) -> Unit,
onPageClick: () -> Unit,
showAllTextHighlights: Boolean,
onHighlightLoading: (Boolean) -> Unit,
searchQuery: String,
searchHighlightMode: SearchHighlightMode,
searchResultToHighlight: SearchResult?,
isProUser: Boolean,
onShowDictionaryUpsellDialog: () -> Unit,
onWordSelectedForAiDefinition: (String) -> Unit,
onTranslateText: (String) -> Unit,
onSearchText: (String) -> Unit,
ttsHighlightData: TtsHighlightData?,
ttsReadingPage: Int?,
onLinkClicked: (String) -> Unit,
onInternalLinkClicked: (Int) -> Unit,
bookmarks: StableHolder>,
onBookmarkClick: (Int) -> Unit,
onOcrStateChange: (Boolean) -> Unit,
onGetOcrSearchRects: suspend (Int, String) -> List,
isEditMode: Boolean = false,
allAnnotations: () -> Map> = { emptyMap() },
drawingState: PdfDrawingState,
onDrawStart: (Int, PdfPoint, Boolean) -> Unit,
onDraw: (Int, PdfPoint, Boolean) -> Unit,
onDrawEnd: () -> Unit,
onOcrModelDownloading: () -> Unit = {},
selectedTool: InkType,
richTextController: RichTextController? = null,
textBoxes: List = emptyList(),
textBoxesByPage: Map> = emptyMap(),
selectedTextBoxId: String? = null,
onTextBoxChange: (PdfTextBox) -> Unit = {},
onTextBoxSelect: (String) -> Unit = {},
bottomContentPaddingPx: Float = 0f,
topContentPaddingPx: Float = 0f,
onTextBoxMoved: (String, Int, Rect) -> Unit = { _, _, _ -> },
isAutoScrollPlaying: Boolean = false,
isAutoScrollTempPaused: Boolean = false,
isScrollLocked: Boolean = false,
autoScrollSpeed: Float = 1.0f,
onInteractionListener: () -> Unit = {},
isStylusOnlyMode: Boolean = false,
stylusButtonHovering: Boolean = false,
isHighlighterSnapEnabled: Boolean = false,
userHighlights: List = emptyList(),
userHighlightsByPage: Map> = emptyMap(),
onHighlightAdd: (Int, Pair, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> },
onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> },
onHighlightDelete: (String) -> Unit = {},
onNoteRequested: (String?) -> Unit = {},
onTts: (Int, Int) -> Unit = { _, _ -> },
activeToolThickness: Float = 0f,
eraserToolThickness: Float = 0f,
customHighlightColors: Map = emptyMap(),
onPaletteClick: () -> Unit = {},
lockedState: Triple? = null,
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null,
resetZoomTrigger: Long = 0L,
isBubbleZoomModeActive: Boolean = false,
showPageGap: Boolean = true,
showPageNumberOverlay: Boolean = true,
onDetectBubbles: suspend (Int, Bitmap) -> List = { _, _ -> emptyList() }
) {
DisposableEffect(state) {
onDispose {
state.scrollToPageHandler = null
state.snapToPageHandler = null
state.scrollByHandler = null
state.scrollToTopHandler = null
state.scrollToBottomHandler = null
}
}
var globalEraserPosition by remember { mutableStateOf(null) }
var isStylusEraserOverride by remember { mutableStateOf(false) }
val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse"
val verticalPageBackgroundColor = remember(activeTheme) {
when (activeTheme.id) {
"no_theme", "system" -> Color.White
"reverse" -> Color.Black
else -> activeTheme.backgroundColor
}
}
BoxWithConstraints(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) {
val imeInsets = WindowInsets.ime
val density = LocalDensity.current
val viewConfiguration = LocalViewConfiguration.current
val screenWidth = constraints.maxWidth.toFloat()
val screenHeight = constraints.maxHeight.toFloat()
val ratios = pageAspectRatios.item
val bookmarkSet = bookmarks.item
val effectiveTextBoxesByPage = remember(textBoxes, textBoxesByPage) {
textBoxesByPage.ifEmpty { textBoxes.groupBy { it.pageIndex } }
}
val effectiveUserHighlightsByPage = remember(userHighlights, userHighlightsByPage) {
userHighlightsByPage.ifEmpty { userHighlights.groupBy { it.pageIndex } }
}
val scope = rememberCoroutineScope()
LaunchedEffect(constraints, density) {
Timber.d(
"Screen Dims: width=$screenWidth, height=$screenHeight, constraints=$constraints"
)
}
val headerHeightPx = with(density) { headerHeight.toPx() }
val footerHeightPx = with(density) { footerHeight.toPx() }
val dividerHeightDp = pdfVerticalPageGapDp(showPageGap, 8.dp)
val dividerHeightPx = with(density) { dividerHeightDp.toPx() }
val dividerHeightPxInt = dividerHeightPx.roundToInt().coerceAtLeast(0)
var isFlinging by remember { mutableStateOf(false) }
var isFastFlinging by remember { mutableStateOf(false) }
var isInteracting by remember { mutableStateOf(false) }
var isDragging by remember { mutableStateOf(false) }
val layoutState = remember(ratios, constraints.maxWidth, constraints.maxHeight, density, showPageGap, dividerHeightPxInt) {
data class LayoutResult(val pages: List, val totalHeight: Float)
val verticalLayout = calculatePdfVerticalPageLayoutPx(
pageAspectRatios = ratios,
viewportWidthPx = constraints.maxWidth,
viewportHeightPx = constraints.maxHeight,
pageGapPx = dividerHeightPxInt
)
val pages = verticalLayout.pages.map { page ->
PdfPageLayout(
index = page.pageIndex,
yPx = page.topPx,
heightPx = page.heightPx,
widthPx = page.widthPx,
widthDp = with(density) { page.widthPx.toDp() },
heightDp = with(density) { page.heightPx.toDp() }
)
}
LayoutResult(pages, verticalLayout.totalHeightPx.toFloat())
}
val layoutInfo = layoutState.pages
val totalDocHeight = layoutState.totalHeight
Timber.tag(SCROLL_BOUNDS_TAG)
.d("Layout Recalculated. Page Count: ${layoutInfo.size}, TotalDocHeight: $totalDocHeight")
val fitZoom = remember(ratios, screenWidth, screenHeight) {
if (ratios.isEmpty() || screenWidth == 0f || screenHeight == 0f) 1f
else {
val firstRatio = ratios.firstOrNull { it > 0f } ?: 1f
val baseHeight = screenWidth / firstRatio
if (screenWidth > screenHeight) {
((screenHeight - 32f) / baseHeight).coerceAtMost(1f)
} else {
1f
}
}
}
val zoomAnimatable = remember { Animatable(fitZoom) }
val panXAnimatable = remember { Animatable(if ((screenWidth * fitZoom) < screenWidth) (screenWidth - (screenWidth * fitZoom)) / 2f else 0f) }
val panYAnimatable = remember { Animatable(0f) }
LaunchedEffect(zoomAnimatable.value, panXAnimatable.value, panYAnimatable.value) {
onZoomAndPanChanged?.invoke(zoomAnimatable.value, Offset(panXAnimatable.value, panYAnimatable.value))
}
var isResizing by remember { mutableStateOf(false) }
var previousScreenWidth by remember { mutableFloatStateOf(0f) }
var previousScreenHeight by remember { mutableFloatStateOf(0f) }
var lockedOrientationChangedDuringResize by remember { mutableStateOf(false) }
val targetPageDuringResize = remember { mutableIntStateOf(-1) }
if (previousScreenWidth != screenWidth || previousScreenHeight != screenHeight) {
if (previousScreenWidth > 0f) {
val previousWasLandscape = previousScreenWidth > previousScreenHeight
val currentIsLandscape = screenWidth > screenHeight
isResizing = true
if (isScrollLocked && previousWasLandscape != currentIsLandscape) {
lockedOrientationChangedDuringResize = true
}
if (targetPageDuringResize.intValue == -1) {
targetPageDuringResize.intValue = state.currentPage
}
}
previousScreenWidth = screenWidth
previousScreenHeight = screenHeight
}
var isInitialLayout by remember { mutableStateOf(true) }
val currentScaleProvider = remember(zoomAnimatable) { { zoomAnimatable.value } }
var hasRestoredLockedState by remember { mutableStateOf(false) }
LaunchedEffect(isScrollLocked, lockedState, totalDocHeight, screenWidth, isInteracting) {
if (!hasRestoredLockedState && isScrollLocked && lockedState != null && totalDocHeight > 0f && screenWidth > 0f && !isInteracting) {
val (savedScale, savedPanX, savedPanY) = lockedState
Timber.tag("PdfLockDiagnostic").i("RESTORING: Scale=$savedScale, X=$savedPanX, Y=$savedPanY")
val zoomedDocWidth = screenWidth * savedScale
val minPanX = if (zoomedDocWidth < screenWidth) (screenWidth - zoomedDocWidth) / 2f else -(zoomedDocWidth - screenWidth)
val maxPanX = if (zoomedDocWidth < screenWidth) minPanX else 0f
val zoomedDocHeight = totalDocHeight * savedScale
val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
zoomAnimatable.stop()
panXAnimatable.stop()
panYAnimatable.stop()
panXAnimatable.updateBounds(minPanX, maxPanX)
panYAnimatable.updateBounds(minPanY, headerHeightPx)
zoomAnimatable.snapTo(savedScale)
panXAnimatable.snapTo(savedPanX)
panYAnimatable.snapTo(savedPanY.coerceIn(minPanY, headerHeightPx))
Timber.tag("PdfLockDiagnostic").d("RESTORE SNAP COMPLETE: Scale=${zoomAnimatable.value}, X=${panXAnimatable.value}, Y=${panYAnimatable.value}")
hasRestoredLockedState = true
}
}
LaunchedEffect(layoutState.pages) {
if (!isInitialLayout && isScrollLocked && lockedOrientationChangedDuringResize) {
val targetPageIdx = if (targetPageDuringResize.intValue != -1) {
targetPageDuringResize.intValue
} else {
state.currentPage
}
val newLayout = layoutState.pages
val pageLayout = newLayout.getOrNull(targetPageIdx)
if (pageLayout != null) {
val resetCamera = calculateLockedOrientationResetCamera(
pageTopY = pageLayout.y,
totalDocHeight = layoutState.totalHeight,
screenWidth = screenWidth,
screenHeight = screenHeight,
headerHeightPx = headerHeightPx,
footerHeightPx = footerHeightPx,
fitZoom = fitZoom
)
panXAnimatable.updateBounds(null, null)
panYAnimatable.updateBounds(null, null)
coroutineScope {
launch { zoomAnimatable.snapTo(resetCamera.zoom) }
launch { panXAnimatable.snapTo(resetCamera.panX) }
launch { panYAnimatable.snapTo(resetCamera.panY) }
}
state.currentPage = targetPageIdx
hasRestoredLockedState = true
onZoomChange(resetCamera.zoom)
onZoomAndPanChanged?.invoke(resetCamera.zoom, Offset(resetCamera.panX, resetCamera.panY))
Timber.tag("PdfLockDiagnostic").i(
"Orientation changed while locked; reset zoom to fit and kept page $targetPageIdx"
)
}
} else if (!isInitialLayout && !isScrollLocked) {
val targetPageIdx = if (targetPageDuringResize.intValue != -1) {
targetPageDuringResize.intValue
} else {
state.currentPage
}
val newLayout = layoutState.pages
val pageLayout = newLayout.getOrNull(targetPageIdx)
if (pageLayout != null) {
val currentZoom = zoomAnimatable.value
val isFit = currentZoom <= 1.1f
val targetZoom = if (isFit) fitZoom else currentZoom
val targetPanY = headerHeightPx - (pageLayout.y * targetZoom)
val zoomedDocHeight = layoutState.totalHeight * targetZoom
val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
val finalPanY = targetPanY.coerceIn(minPanY, headerHeightPx)
val targetPanX = if (isFit) {
if ((screenWidth * targetZoom) < screenWidth) {
(screenWidth - (screenWidth * targetZoom)) / 2f
} else 0f
} else {
panXAnimatable.value
}
panXAnimatable.updateBounds(null, null)
panYAnimatable.updateBounds(null, null)
coroutineScope {
launch { zoomAnimatable.snapTo(targetZoom) }
launch { panXAnimatable.snapTo(targetPanX) }
launch { panYAnimatable.snapTo(finalPanY) }
}
}
}
if (!isInitialLayout) {
delay(50)
isResizing = false
lockedOrientationChangedDuringResize = false
targetPageDuringResize.intValue = -1
}
isInitialLayout = false
}
fun clampValues(
targetZoom: Float, targetPanX: Float, targetPanY: Float
): Triple {
val constrainedZoom = targetZoom.coerceIn(fitZoom, 5f)
val zoomedDocWidth = screenWidth * constrainedZoom
val zoomedDocHeight = totalDocHeight * constrainedZoom
val constrainedX = if (zoomedDocWidth < screenWidth) {
(screenWidth - zoomedDocWidth) / 2f
} else {
val maxPanX = 0f
val minPanX = -(zoomedDocWidth - screenWidth)
targetPanX.coerceIn(minPanX, maxPanX)
}
val minPanY = if (zoomedDocHeight < (screenHeight - headerHeightPx - footerHeightPx)) {
headerHeightPx
} else {
(screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
}
val constrainedY = targetPanY.coerceIn(minPanY, headerHeightPx)
return Triple(constrainedZoom, constrainedX, constrainedY)
}
fun clampCamera(
targetZoom: Float, targetPanX: Float, targetPanY: Float
): Triple {
return clampValues(targetZoom, targetPanX, targetPanY)
}
LaunchedEffect(resetZoomTrigger) {
if (resetZoomTrigger != 0L && zoomAnimatable.value > fitZoom && !isScrollLocked) {
scope.launch {
zoomAnimatable.stop()
panXAnimatable.stop()
panYAnimatable.stop()
val startZoom = zoomAnimatable.value
val startPanX = panXAnimatable.value
val startPanY = panYAnimatable.value
val pivotScreenX = screenWidth / 2f
val pivotScreenY = screenHeight / 2f
val pivotContentX = (pivotScreenX - startPanX) / startZoom
val pivotContentY = (pivotScreenY - startPanY) / startZoom
val rawNextPanX = pivotScreenX - (pivotContentX * fitZoom)
val rawNextPanY = pivotScreenY - (pivotContentY * fitZoom)
val (finalZoom, finalX, finalY) = clampCamera(fitZoom, rawNextPanX, rawNextPanY)
panXAnimatable.updateBounds(
lowerBound = minOf(panXAnimatable.lowerBound ?: finalX, finalX, startPanX),
upperBound = maxOf(panXAnimatable.upperBound ?: finalX, finalX, startPanX)
)
panYAnimatable.updateBounds(
lowerBound = minOf(panYAnimatable.lowerBound ?: finalY, finalY, startPanY),
upperBound = maxOf(panYAnimatable.upperBound ?: finalY, finalY, startPanY)
)
coroutineScope {
launch { zoomAnimatable.animateTo(finalZoom, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
launch { panXAnimatable.animateTo(finalX, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
launch { panYAnimatable.animateTo(finalY, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
}
onZoomChange(zoomAnimatable.value)
val zoomedDocWidth = screenWidth * finalZoom
val finalMinX: Float
val finalMaxX: Float
if (zoomedDocWidth < screenWidth) {
val centeredX = (screenWidth - zoomedDocWidth) / 2f
finalMinX = centeredX
finalMaxX = centeredX
} else {
finalMinX = -(zoomedDocWidth - screenWidth)
finalMaxX = 0f
}
panXAnimatable.updateBounds(lowerBound = finalMinX, upperBound = finalMaxX)
val zDocH = totalDocHeight * finalZoom
val minScrollY = (screenHeight - footerHeightPx - zDocH).coerceAtMost(headerHeightPx)
panYAnimatable.updateBounds(lowerBound = minScrollY, upperBound = headerHeightPx)
}
}
}
LaunchedEffect(
totalDocHeight, screenHeight, headerHeightPx, footerHeightPx, zoomAnimatable.value, isInteracting, isFlinging, isResizing
) {
if (zoomAnimatable.isRunning || panXAnimatable.isRunning || panYAnimatable.isRunning || isInteracting || isFlinging || isResizing) {
return@LaunchedEffect
}
val currentPanY = panYAnimatable.value
val currentPanX = panXAnimatable.value
val currentZoom = zoomAnimatable.value
val (z, x, y) = clampValues(currentZoom, currentPanX, currentPanY)
if (y != currentPanY) {
panYAnimatable.snapTo(y)
}
if (x != currentPanX) {
if (isScrollLocked) {
Timber.tag("PdfLockDiagnostic").d("FORCED SNAP: X=$currentPanX to $x")
}
panXAnimatable.snapTo(x)
}
}
LaunchedEffect(layoutInfo, totalDocHeight, screenHeight, headerHeightPx) {
val calculateTargetPanY = { index: Int ->
if (index in layoutInfo.indices) {
val targetPage = layoutInfo[index]
val currentZoom = zoomAnimatable.value
val screenCenterY = screenHeight / 2f
val pageCenterY = (targetPage.y + targetPage.height / 2f) * currentZoom
val targetPanY = screenCenterY - pageCenterY
val zoomedDocHeight = totalDocHeight * currentZoom
val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(
headerHeightPx
)
targetPanY.coerceIn(minPanY, headerHeightPx)
} else {
null
}
}
state.scrollToPageHandler = { index ->
val clampedPanY = calculateTargetPanY(index)
if (clampedPanY != null) {
panYAnimatable.animateTo(clampedPanY, animationSpec = tween(500))
}
}
state.snapToPageHandler = { index ->
val clampedPanY = calculateTargetPanY(index)
Timber.tag("PdfPositionDebug").d("VerticalReader: snapToPage($index) called. ClampedPanY: $clampedPanY")
if (clampedPanY != null) {
panYAnimatable.snapTo(clampedPanY)
}
}
state.scrollByHandler = { delta ->
val currentZoom = zoomAnimatable.value
val zoomedDocHeight = totalDocHeight * currentZoom
val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
val targetPanY = (panYAnimatable.value - delta).coerceIn(minPanY, headerHeightPx)
if (abs(targetPanY - panYAnimatable.value) > 0.5f) {
panYAnimatable.animateTo(
targetValue = targetPanY,
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
)
}
}
state.scrollToTopHandler = {
panYAnimatable.animateTo(
targetValue = headerHeightPx,
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
)
}
state.scrollToBottomHandler = {
val currentZoom = zoomAnimatable.value
val zoomedDocHeight = totalDocHeight * currentZoom
val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
panYAnimatable.animateTo(
targetValue = minPanY,
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
)
}
}
var selectionClearTrigger by remember { mutableLongStateOf(0L) }
var draggingBoxId by remember { mutableStateOf(null) }
var draggingBoxOffset by remember { mutableStateOf(Offset.Zero) }
var draggingBoxSize by remember { mutableStateOf(Size.Zero) }
var draggingBoxTouchDelta by remember { mutableStateOf(Offset.Zero) }
var draggingBoxPageHeight by remember { mutableFloatStateOf(0f) }
// Auto-scroll logic
LaunchedEffect(
draggingBoxId,
draggingBoxOffset,
screenHeight,
bottomContentPaddingPx,
topContentPaddingPx
) {
if (draggingBoxId != null) {
while (isActive) {
val scrollZone = 50f
val topEdge = headerHeightPx + topContentPaddingPx
val bottomEdge = screenHeight - bottomContentPaddingPx
var scrollDelta = 0f
if (draggingBoxOffset.y < topEdge + scrollZone) {
val dist = (draggingBoxOffset.y - topEdge).coerceAtMost(scrollZone)
val ratio = 1f - (dist / scrollZone).coerceIn(0f, 1f)
scrollDelta = -15f * ratio
} else if (draggingBoxOffset.y + (draggingBoxSize.height * zoomAnimatable.value) > bottomEdge - scrollZone) {
val boxBottom = draggingBoxOffset.y + (draggingBoxSize.height * zoomAnimatable.value)
val dist = (bottomEdge - boxBottom).coerceAtMost(scrollZone)
val ratio = 1f - (dist / scrollZone).coerceIn(0f, 1f)
scrollDelta = 15f * ratio
}
if (abs(scrollDelta) > 0.1f) {
val currentY = panYAnimatable.value
val newPanY = currentY - scrollDelta
val zoomedDocHeight = totalDocHeight * zoomAnimatable.value
val minPanY =
(screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(
headerHeightPx
)
val finalPanY = newPanY.coerceIn(minPanY, headerHeightPx)
panYAnimatable.snapTo(finalPanY)
}
delay(16)
}
}
}
LaunchedEffect(isFlinging) {
if (isFlinging) {
isFastFlinging = true
while (isActive && isFlinging) {
val velX = abs(panXAnimatable.velocity)
val velY = abs(panYAnimatable.velocity)
val totalVelocity = max(velX, velY)
isFastFlinging = totalVelocity > 500f
delay(50)
}
} else {
isFastFlinging = false
}
}
LaunchedEffect(isAutoScrollPlaying, isAutoScrollTempPaused, autoScrollSpeed, totalDocHeight, screenHeight) {
if (isAutoScrollPlaying && !isAutoScrollTempPaused) {
val baseSpeedPxPerSec = 80f
var lastFrameTime = withFrameNanos { it }
while (isActive) {
val frameTime = withFrameNanos { it }
val deltaSeconds = (frameTime - lastFrameTime) / 1_000_000_000f
lastFrameTime = frameTime
if (deltaSeconds > 0.1f) continue
val pixelMove = (baseSpeedPxPerSec * autoScrollSpeed) * deltaSeconds
val currentPanY = panYAnimatable.value
val currentZoom = zoomAnimatable.value
val zoomedDocHeight = totalDocHeight * currentZoom
val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
val newPanY = (currentPanY - pixelMove).coerceIn(minPanY, headerHeightPx)
panYAnimatable.snapTo(newPanY)
@Suppress("ControlFlowWithEmptyBody") if (newPanY <= minPanY + 0.1f) {
// Reached end
}
}
}
}
var highResScale by remember { mutableFloatStateOf(1f) }
LaunchedEffect(isInteracting) {
if (isInteracting && isAutoScrollPlaying) {
onInteractionListener()
}
}
LaunchedEffect(isInteracting) {
Timber.tag("PdfTouchDebug").i("VerticalReader: isInteracting changed to $isInteracting")
}
LaunchedEffect(highResScale) {
Timber.tag("PdfPerformance").i("VerticalReader HighResScale changed to: $highResScale")
}
LaunchedEffect(Unit) {
snapshotFlow { isInteracting || (isFlinging && isFastFlinging) }.collectLatest { isBusy ->
Timber.tag("PdfDrawPerf").d(
"VerticalReader Interaction State: isBusy=$isBusy (Interacting=$isInteracting, Flinging=$isFlinging, Fast=$isFastFlinging)"
)
if (!isBusy) {
delay(50)
val target = zoomAnimatable.value
if (highResScale != target) {
Timber.tag("PdfDrawPerf").v("VerticalReader: Updating highResScale to $target")
highResScale = target
}
}
}
}
LaunchedEffect(highResScale, zoomAnimatable.value) {
Timber.tag("PdfDrawPerf")
.v("VerticalReader Scale: HighRes=$highResScale, Anim=${zoomAnimatable.value}")
}
LaunchedEffect(zoomAnimatable.value) {
if (!isInteracting && !(isFlinging && isFastFlinging)) {
if (highResScale != zoomAnimatable.value) {
highResScale = zoomAnimatable.value
}
}
}
val imeBottom = imeInsets.getBottom(density)
LaunchedEffect(
headerHeightPx,
footerHeightPx,
totalDocHeight,
screenHeight,
imeBottom,
isEditMode,
selectedTool,
zoomAnimatable.value,
isInteracting,
isFlinging,
isResizing
) {
if (isInteracting || isFlinging || isResizing) return@LaunchedEffect
val currentZoom = zoomAnimatable.value
val zoomedDocHeight = totalDocHeight * currentZoom
val zoomedDocWidth = screenWidth * currentZoom
val isAnimating = zoomAnimatable.isRunning || panYAnimatable.isRunning || panXAnimatable.isRunning
val isTextEditing = isEditMode && selectedTool == InkType.TEXT && imeBottom > 0
val effectiveFooterPx = if (isTextEditing) 0f else footerHeightPx
val extraScrollForIme = if (isTextEditing) imeBottom.toFloat() else 0f
val minPanY = (screenHeight - effectiveFooterPx - zoomedDocHeight - extraScrollForIme).coerceAtMost(headerHeightPx)
val minPanX: Float
val maxPanX: Float
if (zoomedDocWidth < screenWidth) {
val centeredX = (screenWidth - zoomedDocWidth) / 2f
minPanX = centeredX
maxPanX = centeredX
} else {
minPanX = -(zoomedDocWidth - screenWidth)
maxPanX = 0f
}
if (!isAnimating) {
if (isScrollLocked) {
Timber.tag("PdfLockDiagnostic").v("CLAMP CHECK: X=${panXAnimatable.value} | Allowed Range=[$minPanX, $maxPanX]")
}
panYAnimatable.updateBounds(lowerBound = minPanY, upperBound = headerHeightPx)
panXAnimatable.updateBounds(lowerBound = minPanX, upperBound = maxPanX)
} else {
panYAnimatable.updateBounds(
lowerBound = minOf(panYAnimatable.lowerBound ?: minPanY, minPanY),
upperBound = maxOf(panYAnimatable.upperBound ?: headerHeightPx, headerHeightPx)
)
panXAnimatable.updateBounds(
lowerBound = minOf(panXAnimatable.lowerBound ?: minPanX, minPanX),
upperBound = maxOf(panXAnimatable.upperBound ?: maxPanX, maxPanX)
)
}
}
LaunchedEffect(
richTextController?.cursorPageIndex,
richTextController?.cursorRectInPage,
imeBottom,
density,
isEditMode,
selectedTool,
layoutInfo
) {
val controller = richTextController ?: return@LaunchedEffect
if (imeBottom == 0 || !isEditMode || selectedTool != InkType.TEXT) {
return@LaunchedEffect
}
val pageIndex = controller.cursorPageIndex
val cursorRect = controller.cursorRectInPage
if (pageIndex >= 0 && cursorRect != null) {
val pageLayout = layoutInfo.find { it.index == pageIndex }
if (pageLayout != null) {
val currentPanY = panYAnimatable.value
val currentZoom = zoomAnimatable.value
val cursorGlobalTopY =
(pageLayout.y + cursorRect.top) * currentZoom + currentPanY
val cursorGlobalBottomY =
(pageLayout.y + cursorRect.bottom) * currentZoom + currentPanY
val topSafeBuffer = with(density) { 80.dp.toPx() }
val visibleBottom = screenHeight - imeBottom
var requiredShift = 0f
if (cursorGlobalBottomY > (visibleBottom)) {
requiredShift = visibleBottom - cursorGlobalBottomY
} else if (cursorGlobalTopY < topSafeBuffer) {
requiredShift = topSafeBuffer - cursorGlobalTopY
}
if (abs(requiredShift) > 10f) {
val targetPanY = currentPanY + requiredShift
panYAnimatable.snapTo(targetPanY)
}
}
}
}
val onDoubleTapToZoom: (Offset) -> Unit = { tapScreenOffset ->
if (!isScrollLocked) {
val currentZoom = zoomAnimatable.value
val targetZoom = when {
currentZoom < 0.95f -> 1f
currentZoom < 2.45f -> 2.5f
else -> fitZoom
}
val startPanX = panXAnimatable.value
val startPanY = panYAnimatable.value
scope.launch {
zoomAnimatable.stop()
panXAnimatable.stop()
panYAnimatable.stop()
val pivotContentX = (tapScreenOffset.x - startPanX) / currentZoom
val pivotContentY = (tapScreenOffset.y - startPanY) / currentZoom
val rawNextPanX = tapScreenOffset.x - (pivotContentX * targetZoom)
val rawNextPanY = tapScreenOffset.y - (pivotContentY * targetZoom)
val (finalZoom, finalX, finalY) = clampCamera(targetZoom, rawNextPanX, rawNextPanY)
panXAnimatable.updateBounds(
lowerBound = minOf(panXAnimatable.lowerBound ?: finalX, finalX, startPanX),
upperBound = maxOf(panXAnimatable.upperBound ?: finalX, finalX, startPanX)
)
panYAnimatable.updateBounds(
lowerBound = minOf(panYAnimatable.lowerBound ?: finalY, finalY, startPanY),
upperBound = maxOf(panYAnimatable.upperBound ?: finalY, finalY, startPanY)
)
coroutineScope {
launch { zoomAnimatable.animateTo(finalZoom, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
launch { panXAnimatable.animateTo(finalX, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
launch { panYAnimatable.animateTo(finalY, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
}
onZoomChange(zoomAnimatable.value)
val zoomedDocWidth = screenWidth * finalZoom
val finalMinX: Float
val finalMaxX: Float
if (zoomedDocWidth < screenWidth) {
val centeredX = (screenWidth - zoomedDocWidth) / 2f
finalMinX = centeredX
finalMaxX = centeredX
} else {
finalMinX = -(zoomedDocWidth - screenWidth)
finalMaxX = 0f
}
panXAnimatable.updateBounds(lowerBound = finalMinX, upperBound = finalMaxX)
val zDocH = totalDocHeight * finalZoom
val minScrollY = (screenHeight - footerHeightPx - zDocH).coerceAtMost(headerHeightPx)
panYAnimatable.updateBounds(lowerBound = minScrollY, upperBound = headerHeightPx)
}
}
}
val globalDrawingModifier = Modifier.pointerInput(
isEditMode,
layoutInfo,
selectedTool,
isStylusOnlyMode,
isHighlighterSnapEnabled
) {
if (!isEditMode) return@pointerInput
if (selectedTool == InkType.TEXT) return@pointerInput
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
if (isStylusOnlyMode && down.type == PointerType.Touch) {
return@awaitEachGesture
}
val buttons = currentEvent.buttons
Timber.tag("StylusDebug").d(
"VerticalReader | Type: ${down.type} | isPrimary: ${buttons.isPrimaryPressed} | isSecondary: ${buttons.isSecondaryPressed} | isTertiary: ${buttons.isTertiaryPressed} | buttonsString: $buttons"
)
val isEraserOverride = down.type == PointerType.Eraser ||
(down.type == PointerType.Stylus && (currentEvent.buttons.isSecondaryPressed || currentEvent.buttons.isPrimaryPressed || stylusButtonHovering))
isStylusEraserOverride = isEraserOverride
fun getPageAndPoint(screenOffset: Offset): Pair? {
val zoom = zoomAnimatable.value
val panX = panXAnimatable.value
val panY = panYAnimatable.value
val docX = (screenOffset.x - panX) / zoom
val docY = (screenOffset.y - panY) / zoom
val pageLayout = layoutInfo.firstOrNull { page ->
docY >= page.y && docY <= (page.y + page.height)
} ?: return null
val localY = docY - pageLayout.y
val normX = (docX / pageLayout.width).coerceIn(0f, 1f)
val normY = (localY / pageLayout.height).coerceIn(0f, 1f)
return pageLayout.index to PdfPoint(normX, normY)
}
var isCanceled = false
try {
if (selectedTool == InkType.ERASER || isEraserOverride) {
globalEraserPosition = down.position
}
val startData = getPageAndPoint(down.position)
if (startData != null) {
val (pageIndex, point) = startData
onDrawStart(pageIndex, point, isEraserOverride)
down.consume()
}
var lastPageIndex = startData?.first
do {
val event = awaitPointerEvent()
if (event.changes.size > 1) {
isCanceled = true
drawingState.onDrawCancel()
break
}
val change = event.changes.firstOrNull { it.id == down.id }
if (change == null || !change.pressed) break
if (change.positionChanged()) {
if (selectedTool == InkType.ERASER || isEraserOverride) {
globalEraserPosition = change.position
}
val dragData = getPageAndPoint(change.position)
if (dragData != null) {
val (pageIndex, point) = dragData
if (pageIndex != lastPageIndex && selectedTool != InkType.ERASER && !isEraserOverride) {
onDrawEnd()
onDrawStart(pageIndex, point, isEraserOverride)
} else {
onDraw(pageIndex, point, isEraserOverride)
}
lastPageIndex = pageIndex
}
change.consume()
}
} while (true)
} finally {
if (!isCanceled) {
onDrawEnd()
}
globalEraserPosition = null
isStylusEraserOverride = false
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(if (showPageGap) Color.Transparent else verticalPageBackgroundColor)
.then(globalDrawingModifier)
.pointerInput(isEditMode, selectedTool, isStylusOnlyMode, isScrollLocked) {
Timber.tag("PdfTouchDebug").v(
"VerticalReader: TapPointerInput init. isEditMode=$isEditMode"
)
val isTapDetectionAllowed = !isEditMode ||
selectedTool == InkType.TEXT ||
isStylusOnlyMode
if (!isTapDetectionAllowed) return@pointerInput
detectTapGestures(onTap = {
if (!isEditMode) {
Timber.tag("PdfTouchDebug").d("VerticalReader: Tap detected")
selectionClearTrigger++
onPageClick()
} else if (selectedTool == InkType.TEXT) {
onPageClick()
}
}, onDoubleTap = { offset ->
if (!isScrollLocked) {
Timber.tag("PdfTouchDebug").d("VerticalReader: DoubleTap detected")
onDoubleTapToZoom(offset)
}
})
}
.pointerInput(
totalDocHeight,
isEditMode,
selectedTool,
isScrollLocked,
isStylusOnlyMode,
isHighlighterSnapEnabled
) {
val tracker = VelocityTracker()
val decay = exponentialDecay()
val touchSlop = viewConfiguration.touchSlop
awaitEachGesture {
Timber.tag("PdfTouchDebug").v(
"VerticalReader: Gesture Loop Start. isEditMode=$isEditMode"
)
val down = awaitFirstDown(requireUnconsumed = false)
isInteracting = true
isDragging = false
Timber.tag("PointerTypeDebug").d("VerticalReader: Input Type detected: ${down.type}")
val isDrawingGesture = isEditMode &&
selectedTool != InkType.TEXT &&
(!isStylusOnlyMode || down.type != PointerType.Touch)
if (isDrawingGesture && down.pressed) {
val event = awaitPointerEvent()
Timber.tag("PdfTouchDebug").v(
"VerticalReader: EditMode check. Event changes: ${event.changes.size}"
)
if (event.changes.size == 1) {
Timber.tag("PdfTouchDebug").v(
"VerticalReader: Ignoring single touch in Edit Mode, waiting for gesture end..."
)
val originalPointerId = down.id
do {
val followUp = awaitPointerEvent()
val originalPointer = followUp.changes.firstOrNull {
it.id == originalPointerId
}
} while (originalPointer != null && originalPointer.pressed)
Timber.tag("PdfTouchDebug").v(
"VerticalReader: Single touch gesture ended, loop will restart."
)
return@awaitEachGesture
}
}
Timber.tag("PdfTouchDebug").v(
"VerticalReader: Proceeding with gesture logic..."
)
scope.launch {
zoomAnimatable.stop()
panXAnimatable.stop()
panYAnimatable.stop()
panXAnimatable.updateBounds(null, null)
panYAnimatable.updateBounds(null, null)
}
tracker.resetTracking()
var velocityTrackerAccumulator = Offset.Zero
var accumulatedZoom = zoomAnimatable.value
var accumulatedPanX = panXAnimatable.value
var accumulatedPanY = panYAnimatable.value
var totalPanDistance = 0f
var panLocked = false
var gestureDisambiguationMode = 0
var gestureZoomAccumulator = 1f
do {
val event = awaitPointerEvent()
val isMultiTouch = event.changes.size > 1
val canceled = event.changes.any { it.isConsumed } && !isMultiTouch
if (canceled) {
Timber.tag("PdfTouchDebug").v(
"VerticalReader: Event Canceled (Child consumed?)."
)
}
if (!canceled) {
if (isEditMode && isMultiTouch) {
drawingState.onDrawCancel()
}
val zoomChange = event.calculateZoom()
val rawPanChange = event.calculatePan()
val panChange = if (isScrollLocked && !isMultiTouch) Offset(0f, rawPanChange.y) else rawPanChange
val centroid = event.calculateCentroid(useCurrent = false)
val panMagnitude = panChange.getDistance()
val currentCentroidSize = event.calculateCentroidSize(
useCurrent = true
)
val previousCentroidSize = currentCentroidSize / zoomChange
val spanMagnitude = abs(
currentCentroidSize - previousCentroidSize
)
totalPanDistance += panMagnitude
gestureZoomAccumulator *= zoomChange
val isZoomPastSlop = abs(gestureZoomAccumulator - 1f) > 0.05f
val isPanPastSlop = totalPanDistance > touchSlop
if (gestureDisambiguationMode == 0) {
if (isPanPastSlop || isZoomPastSlop) {
if (spanMagnitude > panMagnitude * 1.5f) {
gestureDisambiguationMode = 2
Timber.tag("PdfTouchDebug").d(
"Locked to ZOOM (Span > Pan * 1.5)"
)
} else {
gestureDisambiguationMode = 1
Timber.tag("PdfTouchDebug").d(
"Locked to PAN (Pan Dominant)"
)
}
}
} else if (gestureDisambiguationMode == 1) {
if (spanMagnitude > (panMagnitude * 3f) && spanMagnitude > 4f) {
gestureDisambiguationMode = 2
Timber.tag("PdfTouchDebug").d(
"Breakout: Switching PAN -> ZOOM"
)
}
}
val isTouchInput = event.changes.all { it.type == PointerType.Touch }
val shouldScroll = (panLocked || gestureDisambiguationMode != 0) &&
(!isEditMode || selectedTool == InkType.TEXT || isMultiTouch || (isStylusOnlyMode && isTouchInput))
if (shouldScroll) {
panLocked = true
isDragging = true
if (zoomChange != 1f || panChange != Offset.Zero) {
var effectiveZoomChange = zoomChange
if (gestureDisambiguationMode == 1) effectiveZoomChange = 1f
val oldZoom = accumulatedZoom
val rawTargetZoom = oldZoom * effectiveZoomChange
val constrainedZoom = rawTargetZoom.coerceIn(fitZoom, 5f)
val prevCentroid = centroid - panChange
val contentPivotX = (prevCentroid.x - accumulatedPanX) / oldZoom
val contentPivotY = (prevCentroid.y - accumulatedPanY) / oldZoom
Timber.tag("PdfZoomIssue").v(
"PivotCalc: ScreenCentroidY=${centroid.y}, DocumentPanY=$accumulatedPanY, " +
"CalculatedContentPivotY=$contentPivotY"
)
val rawNewPanX = centroid.x - (contentPivotX * constrainedZoom)
val rawNewPanY = centroid.y - (contentPivotY * constrainedZoom)
val (finalZoom, finalX, finalY) = clampCamera(
constrainedZoom, rawNewPanX, rawNewPanY
)
accumulatedZoom = finalZoom
accumulatedPanX = finalX
accumulatedPanY = finalY
if (accumulatedZoom != zoomAnimatable.value) {
onZoomChange(accumulatedZoom)
}
scope.launch {
zoomAnimatable.snapTo(accumulatedZoom)
panXAnimatable.snapTo(accumulatedPanX)
panYAnimatable.snapTo(accumulatedPanY)
}
event.changes.forEach {
if (it.positionChanged()) it.consume()
}
if (event.changes.isNotEmpty()) {
velocityTrackerAccumulator += panChange
val time = event.changes[0].uptimeMillis
tracker.addPosition(time, velocityTrackerAccumulator)
}
}
}
}
} while (!canceled && event.changes.any { it.pressed })
if (isInteracting) {
Timber.tag("PdfTouchDebug").v("VerticalReader: Interaction ended")
isInteracting = false
}
isDragging = false
val validFlingCondition = panLocked
if (validFlingCondition) {
val velocity = tracker.calculateVelocity()
val flingSensitivity = 2.0f
val minFlingVelocity = 250f
val (finalZoom, finalX, finalY) = clampCamera(
accumulatedZoom, accumulatedPanX, accumulatedPanY
)
scope.launch {
isFlinging = true
try {
if (accumulatedZoom !in fitZoom..5f) {
zoomAnimatable.animateTo(
finalZoom, animationSpec = tween(300)
)
}
onZoomChange(zoomAnimatable.targetValue)
val zoomedDocWidth = screenWidth * finalZoom
val zoomedDocHeight = totalDocHeight * finalZoom
val flingMinX: Float
val flingMaxX: Float
if (zoomedDocWidth < screenWidth) {
val centeredX = (screenWidth - zoomedDocWidth) / 2f
flingMinX = centeredX
flingMaxX = centeredX
} else {
flingMinX = -(zoomedDocWidth - screenWidth)
flingMaxX = 0f
}
val minPanY =
(screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(
headerHeightPx
)
Timber.tag(SCROLL_BOUNDS_TAG).i("Fling Logic:")
Timber.tag(SCROLL_BOUNDS_TAG)
.d("- totalDocHeight: $totalDocHeight, zoom: $finalZoom -> zoomedDocHeight: $zoomedDocHeight")
Timber.tag(SCROLL_BOUNDS_TAG)
.d("- Fling bounds set to Y:[$minPanY, $headerHeightPx]")
panXAnimatable.updateBounds(flingMinX, flingMaxX)
panYAnimatable.updateBounds(minPanY, headerHeightPx)
coroutineScope {
launch {
val rawX = velocity.x * flingSensitivity
val flingX = if (abs(rawX) > minFlingVelocity && !isScrollLocked) rawX
else 0f
if (flingX != 0f) panXAnimatable.animateDecay(
flingX, decay
)
}
launch {
val rawY = velocity.y * flingSensitivity
val flingY = if (abs(rawY) > minFlingVelocity) rawY
else 0f
if (flingY != 0f) panYAnimatable.animateDecay(
flingY, decay
)
}
}
} finally {
isFlinging = false
}
}
}
}
}) {
val cachedVisiblePages = remember { mutableStateOf>(emptyList()) }
val visiblePages by remember(layoutInfo, screenHeight, textBoxes, draggingBoxId) {
derivedStateOf {
val zoom = zoomAnimatable.value
val panY = panYAnimatable.value
val viewportTop = -panY / zoom
val viewportBottom = (-panY + screenHeight) / zoom
val buffer = screenHeight * 0.5f
val searchTop = viewportTop - buffer
val searchBottom = viewportBottom + buffer
// Standard visibility logic
val baseVisiblePages = if (layoutInfo.isEmpty()) {
emptyList()
} else {
val searchIndex = layoutInfo.binarySearch { page ->
if (page.y + page.height < searchTop) -1
else if (page.y > searchBottom) 1 else 0
}
var startIndex = if (searchIndex < 0) -searchIndex - 1
else searchIndex
startIndex = startIndex.coerceIn(layoutInfo.indices)
while (startIndex > 0 && layoutInfo[startIndex - 1].y + layoutInfo[startIndex - 1].height >= searchTop) {
startIndex--
}
val result = mutableListOf()
for (i in startIndex until layoutInfo.size) {
val page = layoutInfo[i]
if (page.y > searchBottom) break
result.add(page)
}
result
}
val draggedBox = textBoxes.find { it.id == draggingBoxId }
val originPage = if (draggedBox != null) {
layoutInfo.find { it.index == draggedBox.pageIndex }
} else null
val finalPages =
if (originPage != null && baseVisiblePages.none { it.index == originPage.index }) {
(baseVisiblePages + originPage).sortedBy { it.index }
} else {
baseVisiblePages
}
val cached = cachedVisiblePages.value
val layoutMatches = cached.size == finalPages.size && cached.indices.all {
val cachedPage = cached[it]
val newPage = finalPages[it]
cachedPage.index == newPage.index &&
cachedPage.yPx == newPage.yPx &&
cachedPage.heightPx == newPage.heightPx &&
cachedPage.widthPx == newPage.widthPx
}
if (!layoutMatches) {
cachedVisiblePages.value = finalPages
Timber.tag("PdfDrawPerf").d(
"Vertical Visible Pages Changed: ${finalPages.map { it.index }} (Dragging: ${draggedBox != null})"
)
finalPages
} else {
cached
}
}
}
LaunchedEffect(visiblePages, screenHeight, isResizing) {
snapshotFlow {
Pair(panYAnimatable.value, zoomAnimatable.value)
}.collectLatest { (panY, zoom) ->
if (!isResizing && visiblePages.isNotEmpty()) {
state.firstVisiblePage = visiblePages.first().index
state.lastVisiblePage = visiblePages.last().index
val realViewportTop = -panY / zoom
val realViewportBottom = (-panY + screenHeight) / zoom
val mostVisible = visiblePages.maxByOrNull { page ->
val top = max(page.y, realViewportTop)
val bottom = min(page.y + page.height, realViewportBottom)
max(0f, bottom - top)
}
if (mostVisible != null && mostVisible.index != state.currentPage) {
Timber.tag("PdfPositionDebug").v("VerticalReader: Page changed to ${mostVisible.index} (PanY: $panY)")
state.currentPage = mostVisible.index
}
}
}
}
Layout(
content = {
visiblePages.forEach { page ->
key(documentKey, page.index) {
val isBookmarked by remember(bookmarkSet, page.index) {
derivedStateOf {
bookmarkSet.any { it.pageIndex == page.index }
}
}
val visibleScreenRectLambda = remember(page, screenWidth, screenHeight) {
{
val zoom = zoomAnimatable.value
val panX = panXAnimatable.value
val panY = panYAnimatable.value
val viewportLeft = -panX / zoom
val viewportTop = -panY / zoom
val viewportRight = (-panX + screenWidth) / zoom
val viewportBottom = (-panY + screenHeight) / zoom
val pageLeft = 0f
val pageRight = page.width
val pageTop = page.y
val pageBottom = page.y + page.height
val visibleLeft = max(viewportLeft, pageLeft)
val visibleTop = max(viewportTop, pageTop)
val visibleRight = min(viewportRight, pageRight)
val visibleBottom = min(viewportBottom, pageBottom)
if (visibleLeft < visibleRight && visibleTop < visibleBottom) {
val localLeft = (visibleLeft - pageLeft).toInt()
val localTop = (visibleTop - pageTop).toInt()
val localRight = (visibleRight - pageLeft).toInt()
val localBottom = (visibleBottom - pageTop).toInt()
val result = androidx.compose.ui.unit.IntRect(
left = localLeft,
top = localTop,
right = localRight,
bottom = localBottom
)
result
} else {
null
}
}
}
val pageTtsData =
if (ttsReadingPage == page.index) ttsHighlightData else null
var ocrHighlightRects by remember {
mutableStateOf>(emptyList())
}
val stableOcrHighlightRects = remember(ocrHighlightRects) {
StableHolder(ocrHighlightRects)
}
LaunchedEffect(searchResultToHighlight, page.index) {
ocrHighlightRects = emptyList()
if (searchResultToHighlight != null && searchResultToHighlight.locationInSource == page.index) {
ocrHighlightRects = onGetOcrSearchRects(
page.index, searchResultToHighlight.query
)
}
}
val searchResultForPage =
if (searchResultToHighlight?.locationInSource == page.index) {
searchResultToHighlight
} else {
null
}
val onDrawStartLambda = remember(page.index, onDrawStart) {
{ point: PdfPoint, isEraserOverride: Boolean ->
onDrawStart(page.index, point, isEraserOverride)
}
}
val currentOnDraw by rememberUpdatedState(onDraw)
val onDrawLambda = remember(page.index) {
{ point: PdfPoint, isEraserOverride: Boolean ->
currentOnDraw(page.index, point, isEraserOverride)
}
}
val onSingleTapLambda = remember(onPageClick) {
{ _: Offset? ->
selectionClearTrigger++
onPageClick()
}
}
val onTranslateTextLambda = remember(onTranslateText) {
{ text: String -> onTranslateText(text) }
}
val onSearchTextLambda = remember(onSearchText) {
{ text: String -> onSearchText(text) }
}
val currentOnDoubleTapToZoom by rememberUpdatedState(onDoubleTapToZoom)
val onDoubleTapLambda = remember(page, screenWidth, screenHeight) {
{ localOffset: Offset ->
Timber.tag("PdfZoomDebug").d(
"Page ${page.index} Double Tap: Local=$localOffset, PageY=${page.y}"
)
val contentX = localOffset.x
val contentY = localOffset.y + page.y
val currentZ = zoomAnimatable.value
val panX = panXAnimatable.value
val panY = panYAnimatable.value
val screenX = contentX * currentZ + panX
val screenY = contentY * currentZ + panY
Timber.tag("PdfZoomDebug").d("Mapped to Screen: ($screenX, $screenY)") // Added log
currentOnDoubleTapToZoom(Offset(screenX, screenY))
}
}
val onTtsHighlightCenter: (Float) -> Unit =
remember(page.index, ttsReadingPage) {
{ highlightCenterY ->
if (page.index == ttsReadingPage && !isInteracting) {
val currentZ = zoomAnimatable.value
val absoluteHighlightY = page.y + highlightCenterY
val targetPanY =
(screenHeight / 2) - (absoluteHighlightY * currentZ)
val zoomedDocHeight = totalDocHeight * currentZ
val minPanY =
(screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(
headerHeightPx
)
val clampedPanY = targetPanY.coerceIn(
minPanY, headerHeightPx
)
scope.launch {
panYAnimatable.animateTo(
clampedPanY, animationSpec = tween(500)
)
}
}
}
}
val onSearchHighlightCenter: (Float) -> Unit =
remember(page.index, searchResultToHighlight) {
{ highlightCenterY ->
if (searchResultToHighlight?.locationInSource == page.index && !isInteracting) {
val currentZ = zoomAnimatable.value
val absoluteHighlightY = page.y + highlightCenterY
val targetPanY =
(screenHeight / 2) - (absoluteHighlightY * currentZ)
val zoomedDocHeight = totalDocHeight * currentZ
val minPanY =
(screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(
headerHeightPx
)
val clampedPanY = targetPanY.coerceIn(
minPanY, headerHeightPx
)
scope.launch {
panYAnimatable.animateTo(
clampedPanY, animationSpec = tween(500)
)
}
}
}
}
val pageAnnotationsProvider = remember(page.index, allAnnotations) {
{ allAnnotations()[page.index] ?: emptyList() }
}
val virtualPage =
if (virtualPages.isNotEmpty()) virtualPages.getOrNull(page.index)
else VirtualPage.PdfPage(page.index)
Box(modifier = Modifier
.layoutId(page)
) {
PdfPageComposable(
pdfDocument = pdfDocument,
documentKey = documentKey,
pageIndex = page.index,
virtualPage = virtualPage,
totalPages = totalPages,
activeTheme = activeTheme,
activeTextureAlpha = activeTextureAlpha,
excludeImages = excludeImages,
externalScale = highResScale,
onScaleChanged = {},
showAllTextHighlights = showAllTextHighlights,
onHighlightLoading = onHighlightLoading,
searchQuery = searchQuery,
searchHighlightMode = searchHighlightMode,
searchResultToHighlight = searchResultForPage,
ocrHoverHighlights = stableOcrHighlightRects,
onSingleTap = onSingleTapLambda,
isProUser = isProUser,
onShowDictionaryUpsellDialog = onShowDictionaryUpsellDialog,
onWordSelectedForAiDefinition = onWordSelectedForAiDefinition,
onTranslateText = onTranslateTextLambda,
onSearchText = onSearchTextLambda,
ttsHighlightData = pageTtsData,
onLinkClicked = onLinkClicked,
onInternalLinkClicked = onInternalLinkClicked,
isBookmarked = isBookmarked,
onOcrStateChange = onOcrStateChange,
onBookmarkClick = { onBookmarkClick(page.index) },
isZoomEnabled = false,
isScrolling = isDragging || (isFlinging && isFastFlinging),
isVerticalScroll = true,
showPageNumberOverlay = showPageNumberOverlay,
isScrollLocked = isScrollLocked,
visualScaleProvider = currentScaleProvider,
onDoubleTap = onDoubleTapLambda,
clearSelectionTrigger = selectionClearTrigger,
onTtsHighlightCenterCalculated = onTtsHighlightCenter,
onSearchHighlightCenterCalculated = onSearchHighlightCenter,
isEditMode = isEditMode,
pageAnnotations = pageAnnotationsProvider,
drawingState = drawingState,
onDrawStart = onDrawStartLambda,
onDraw = onDrawLambda,
onDrawEnd = onDrawEnd,
visibleScreenRect = visibleScreenRectLambda,
onOcrModelDownloading = onOcrModelDownloading,
selectedTool = selectedTool,
richTextController = richTextController,
isStylusOnlyMode = isStylusOnlyMode,
stylusButtonHovering = stylusButtonHovering,
isAutoScrollPlaying = isAutoScrollPlaying,
textBoxes = effectiveTextBoxesByPage[page.index].orEmpty(),
selectedTextBoxId = selectedTextBoxId,
onTextBoxChange = onTextBoxChange,
onTextBoxSelect = onTextBoxSelect,
userHighlights = effectiveUserHighlightsByPage[page.index].orEmpty(),
onHighlightAdd = onHighlightAdd,
onHighlightUpdate = onHighlightUpdate,
onHighlightDelete = onHighlightDelete,
onNoteRequested = onNoteRequested,
onTts = onTts,
activeToolThickness = activeToolThickness,
eraserToolThickness = eraserToolThickness,
customHighlightColors = customHighlightColors,
onPaletteClick = onPaletteClick,
onTextBoxDragStart = { box, localTopLeft, touchOffset ->
val currentZoom = zoomAnimatable.value
val panX = panXAnimatable.value
val panY = panYAnimatable.value
val pageScreenY = page.y * currentZoom + panY
val boxScreenX = panX + (localTopLeft.x * currentZoom)
val boxScreenY = pageScreenY + (localTopLeft.y * currentZoom)
draggingBoxSize = Size(
box.relativeBounds.width * page.width,
box.relativeBounds.height * page.height
)
draggingBoxPageHeight = page.height
draggingBoxOffset = Offset(boxScreenX, boxScreenY)
draggingBoxTouchDelta = touchOffset * currentZoom
draggingBoxId = box.id
},
onTextBoxDrag = { dragDelta ->
val currentZoom = zoomAnimatable.value
val scaledDelta = dragDelta * currentZoom
Timber.tag("PdfTextBoxDebug").v("VerticalReader onTextBoxDrag dragDelta=$dragDelta zoom=$currentZoom scaledDelta=$scaledDelta")
draggingBoxOffset += scaledDelta
},
onTextBoxDragEnd = {
scope.launch {
val boxCenterY =
draggingBoxOffset.y + draggingBoxSize.height / 2f
val currentZoom = zoomAnimatable.value
val panY = panYAnimatable.value
val docY = (boxCenterY - panY) / currentZoom
val targetPage = layoutInfo.minByOrNull {
val pageCenter = it.y + (it.height / 2f)
abs(docY - pageCenter)
}
if (targetPage != null && draggingBoxId != null) {
val panX = panXAnimatable.value
val pageScreenY = targetPage.y * currentZoom + panY
val paddingPx = with(density) { 12.dp.toPx() }
val padRelW =
if (targetPage.width > 0) paddingPx / (targetPage.width * currentZoom) else 0f
val padRelH =
if (targetPage.height > 0) paddingPx / (targetPage.height * currentZoom) else 0f
val finalBoxX = draggingBoxOffset.x - panX
val finalBoxY = draggingBoxOffset.y - pageScreenY
val rawRelX =
(finalBoxX / currentZoom) / targetPage.width
val rawRelY =
(finalBoxY / currentZoom) / targetPage.height
val relW =
draggingBoxSize.width / targetPage.width
val relH =
draggingBoxSize.height / targetPage.height
val clampedW = relW.coerceAtMost(1f)
val clampedH = relH.coerceAtMost(1f)
val maxRelX =
(1f - clampedW - padRelW).coerceAtLeast(padRelW)
val maxRelY =
(1f - clampedH - padRelH).coerceAtLeast(padRelH)
val finalRelX = rawRelX.coerceIn(padRelW, maxRelX)
val finalRelY = rawRelY.coerceIn(padRelH, maxRelY)
val targetScreenX =
panX + (finalRelX * targetPage.width * currentZoom)
val targetScreenY =
pageScreenY + (finalRelY * targetPage.height * currentZoom)
val targetOffset = Offset(targetScreenX, targetScreenY)
val startOffset = draggingBoxOffset
Animatable(0f).animateTo(1f) {
draggingBoxOffset =
lerp(startOffset, targetOffset, value)
}
val newBounds = Rect(
finalRelX,
finalRelY,
finalRelX + clampedW,
finalRelY + clampedH
)
onTextBoxMoved(
draggingBoxId!!,
targetPage.index,
newBounds
)
}
draggingBoxId = null
}
},
draggingBoxId = draggingBoxId,
isBubbleZoomModeActive = isBubbleZoomModeActive,
onDetectBubbles = onDetectBubbles
)
}
if (page.index < totalPages - 1 && dividerHeightPxInt > 0) {
val dividerYPx = page.yPx + page.heightPx
Box(
modifier = Modifier
.layoutId(
DividerLayout(
yPx = dividerYPx,
widthPx = page.widthPx,
heightPx = dividerHeightPxInt
)
)
.background(
MaterialTheme.colorScheme.surfaceVariant
))
}
}
}
},
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
val z = zoomAnimatable.value
scaleX = z
scaleY = z
translationX = panXAnimatable.value
translationY = panYAnimatable.value
transformOrigin = TransformOrigin(0f, 0f)
}
.onGloballyPositioned { _ -> }) { measurables, constraints ->
val layoutStart = System.nanoTime()
Timber.tag("PdfDrawPerf")
.v("VERTICAL LAYOUT: Measure Pass (${measurables.size} items)")
val measureResult = layout(constraints.maxWidth, constraints.maxHeight) {
measurables.forEach { measurable ->
when (val id = measurable.layoutId) {
is PdfPageLayout -> {
val placeable = measurable.measure(
Constraints.fixed(
id.widthPx, id.heightPx
)
)
placeable.place(0, id.yPx)
}
is DividerLayout -> {
val placeable = measurable.measure(
Constraints.fixed(
id.widthPx, id.heightPx
)
)
placeable.place(0, id.yPx)
}
}
}
}
val layoutTime = (System.nanoTime() - layoutStart) / 1_000_000f
if (layoutTime > 2f) {
Timber.tag("PdfPerformance").d(
"VerticalReader Layout Measure/Place took ${layoutTime}ms for ${measurables.size} items"
)
}
measureResult
}
}
val currentZoom = zoomAnimatable.value
@Suppress("unused") val zoomedDocHeight = totalDocHeight * currentZoom
val currentZoomVal = zoomAnimatable.value
val zDocH = totalDocHeight * currentZoomVal
val minScrollY = (screenHeight - footerHeightPx - zDocH).coerceAtMost(headerHeightPx)
val totalScrollRange = headerHeightPx - minScrollY
val scrollProgress = if (abs(totalScrollRange) < 1f) 0f
else {
((headerHeightPx - panYAnimatable.value) / abs(totalScrollRange)).coerceIn(
0f, 1f
)
}
var isDraggingScrollbar by remember { mutableStateOf(false) }
var scrollbarVisible by remember { mutableStateOf(false) }
var lastScrollInteraction by remember { mutableLongStateOf(0L) }
LaunchedEffect(Unit) {
var previousValue = panYAnimatable.value
snapshotFlow { panYAnimatable.value }.collect { newValue ->
if (abs(newValue - previousValue) > 1f) {
lastScrollInteraction = System.currentTimeMillis()
}
previousValue = newValue
}
}
LaunchedEffect(lastScrollInteraction, isDraggingScrollbar) {
if (isDraggingScrollbar) {
scrollbarVisible = true
} else {
if (lastScrollInteraction > 0L) {
scrollbarVisible = true
delay(5000)
scrollbarVisible = false
}
}
}
val scrollbarAlpha by androidx.compose.animation.core.animateFloatAsState(
targetValue = if (scrollbarVisible) 1f else 0f,
animationSpec = tween(durationMillis = 300),
label = "scrollbarAlpha"
)
val safeCurrentPage = if (totalPages > 0) state.currentPage.coerceIn(0, totalPages - 1) else 0
val samsungBlue = Color(0xFF4285F4)
val samsungBlueDark = Color(0xFF1976D2)
val activeThemeColor = if (isDarkMode) samsungBlueDark else samsungBlue
val scrollbarIdleColor = if (isDarkMode) Color.Gray else Color.DarkGray
val barColor by animateColorAsState(
targetValue = if (isDraggingScrollbar) activeThemeColor else scrollbarIdleColor,
label = "barColor"
)
val scrollbarIdleWidth = 4.dp
val scrollbarActiveWidth = 8.dp
val barWidth by animateDpAsState(
targetValue = if (isDraggingScrollbar) scrollbarActiveWidth
else scrollbarIdleWidth, label = "barWidth"
)
val scrollbarIdleHeight = 40.dp
val scrollbarActiveHeight = 60.dp
val barHeight by animateDpAsState(
targetValue = if (isDraggingScrollbar) scrollbarActiveHeight
else scrollbarIdleHeight, label = "barHeight"
)
val barCornerRadius = 100.dp
var scrollbarTrackHeight by remember { mutableFloatStateOf(0f) }
@Suppress("unused") var thumbLayoutCoordinates by remember {
mutableStateOf(
null
)
}
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.fillMaxHeight()
.padding(top = headerHeight + 12.dp, bottom = footerHeight + 12.dp)
.width(48.dp)
.onGloballyPositioned { coordinates ->
scrollbarTrackHeight = coordinates.size.height.toFloat()
}) {
val thumbHeightPx = with(density) { barHeight.toPx() }
val effectiveTrackHeight = if (scrollbarTrackHeight > 0f) scrollbarTrackHeight
else (screenHeight - headerHeightPx - footerHeightPx)
val availableSpace = (effectiveTrackHeight - thumbHeightPx).coerceAtLeast(0f)
val thumbY = (availableSpace * scrollProgress).coerceIn(0f, availableSpace)
Box(modifier = Modifier
.offset { IntOffset(x = 0, y = thumbY.toInt()) }
.align(Alignment.TopEnd)
.wrapContentSize(align = Alignment.CenterEnd, unbounded = true)
.padding(end = 4.dp)
.alpha(scrollbarAlpha)) {
Row(verticalAlignment = Alignment.CenterVertically) {
AnimatedVisibility(
visible = isDraggingScrollbar && totalPages > 0,
enter = fadeIn() + androidx.compose.animation.slideInHorizontally {
it / 2
},
exit = fadeOut() + androidx.compose.animation.slideOutHorizontally {
it / 2
}) {
Surface(
shape = androidx.compose.foundation.shape.CircleShape,
color = activeThemeColor,
shadowElevation = 4.dp,
modifier = Modifier.padding(end = 12.dp)
) {
Text(
text = "${safeCurrentPage + 1}/$totalPages",
style = MaterialTheme.typography.titleMedium.copy(
fontSize = 16.sp, fontWeight = FontWeight.Bold
),
color = Color.White,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
}
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier
.height(barHeight)
.width(48.dp)
.onGloballyPositioned {}
.pointerInput(
scrollbarTrackHeight, totalDocHeight, screenHeight
) {
awaitEachGesture {
val down = awaitFirstDown(
requireUnconsumed = false
)
try {
isDraggingScrollbar = true
isInteracting = true
lastScrollInteraction = System.currentTimeMillis()
down.consume()
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull {
it.id == down.id
}
if (change == null || !change.pressed) {
break
}
val deltaY =
change.position.y - change.previousPosition.y
if (deltaY != 0f) {
change.consume()
val currentZoom = zoomAnimatable.value
val zoomedDocHeight = totalDocHeight * currentZoom
// Re-calculate bounds exactly as
// the main logic does
val maxS = headerHeightPx
val minS =
(screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(
maxS
)
// Calculate total scrollable track
// space
val trackH =
if (scrollbarTrackHeight > 0f) scrollbarTrackHeight
else (screenHeight - headerHeightPx - footerHeightPx)
val thumbH = scrollbarActiveHeight.toPx()
val trackSpace = (trackH - thumbH).coerceAtLeast(1f)
// Calculate raw delta
val dragFraction = deltaY / trackSpace
val totalRange = maxS - minS // This is
// negative,
// e.g. -5000
val scrollDelta = dragFraction * abs(totalRange)
// Apply delta
val rawTargetPanY =
(panYAnimatable.value - scrollDelta)
// FORCE CLAMP: Ensure we never set
// a value outside bounds
// This prevents the "Gap" where the
// scrollbar pushes past the footer
val clampedTargetPanY = rawTargetPanY.coerceIn(
minS, maxS
)
scope.launch {
panYAnimatable.snapTo(
clampedTargetPanY
)
}
lastScrollInteraction = System.currentTimeMillis()
}
}
} finally {
isDraggingScrollbar = false
isInteracting = false
lastScrollInteraction = System.currentTimeMillis()
}
}
}) {
Box(
modifier = Modifier
.size(width = barWidth, height = barHeight)
.background(
color = barColor, shape = RoundedCornerShape(barCornerRadius)
)
)
}
}
}
}
if (isEditMode && (selectedTool == InkType.ERASER || isStylusEraserOverride) && globalEraserPosition != null) {
Canvas(modifier = Modifier.fillMaxSize()) {
val pos = globalEraserPosition!!
val eraserStrokeWidth = resolveEraserStrokeWidth(
isStylusEraserOverride,
activeToolThickness,
eraserToolThickness
)
val radiusPx = if (eraserStrokeWidth > 0f) {
eraserStrokeWidth * screenWidth * zoomAnimatable.value
} else {
8.dp.toPx()
}
drawCircle(color = Color.White.copy(alpha = 0.3f), radius = radiusPx, center = pos)
drawCircle(
color = Color.Black,
radius = radiusPx,
center = pos,
style = Stroke(width = 1.dp.toPx())
)
}
}
if (draggingBoxId != null) {
val draggedBox = textBoxes.find { it.id == draggingBoxId }
if (draggedBox != null) {
val currentBoxHeight = draggingBoxSize.height
val fontScaleRatio =
if (currentBoxHeight > 0) draggingBoxPageHeight / currentBoxHeight else 1f
val currentZoom = zoomAnimatable.value
val boxBottomY = draggingBoxOffset.y + (draggingBoxSize.height * currentZoom)
val spaceBelow = screenHeight - boxBottomY
val overlayHandlePos =
if (spaceBelow < with(density) { 60.dp.toPx() }) HandlePosition.TOP else HandlePosition.BOTTOM
Box(modifier = Modifier
.offset {
IntOffset(
draggingBoxOffset.x.roundToInt(), draggingBoxOffset.y.roundToInt()
)
}
.graphicsLayer {
scaleX = currentZoom
scaleY = currentZoom
transformOrigin = TransformOrigin(0f, 0f)
}
.zIndex(100f)) {
ResizableTextBox(
box = draggedBox.copy(
relativeBounds = Rect(0f, 0f, 1f, 1f),
fontSize = draggedBox.fontSize * fontScaleRatio
),
isSelected = true,
isEditMode = false,
isDarkMode = isDarkMode,
pageWidthPx = draggingBoxSize.width,
pageHeightPx = draggingBoxSize.height,
scale = currentZoom,
handlePosition = overlayHandlePos,
onBoundsChanged = {},
onTextChanged = {},
onSelect = {},
onDragStart = {},
onDrag = { _, _ -> },
onDragEnd = {})
}
}
}
}
}