book-reader/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt
Aryan b20ade9946
Desktop app (#308)
* Implement build profiles and feature policy for offline desktop builds

* Introduce unified cross-platform Settings Hub

* Refactor main settings into a hierarchical page-based navigation model

* Refactor library projection to use shared multiplatform logic

* Refactor UI state consumption by removing intermediate screen models

* Introduce AndroidSharedStateBridge to centralize state mapping and reduction logic

* Refactor state management for tabs, selection, and pinning to use shared bridge logic

* Refactor file type management and validation into a centralized shared module

* Centralize file type resolution and improve handling of unknown types

* Centralize book import logic with SharedImportPlanner

* Refactor magnifier geometry logic and coordinate mapping

* Properly handle orientation changes in scroll-locked PDF reader

* Add screen orientation controls to EPUB and PDF readers

* Implement right-to-left (RTL) pagination support and refactor reader menus

* Separate right-to-left pagination settings for PDF and EPUB

* Ensure PDF page data is scoped by document key for multi tab support

* Implement theme-aware link styling for the epub reader

* Implement jump history for back and forward navigation in the epub reader

* Improve locator handling and navigation logic in paginated reader mode

* Implement stable pagination navigation and location tracking

* Centralize banner message management and auto-dismiss logic in MainViewModel

* Implement zoom and pan state preservation for PDF pan lock mode

* Enhance reader navigation UI and workspace layout management in desktop app

* Refactor reader navigation sidebar and relocate search controls in desktop app

* Enhance reader UI with redesigned selection menus and bottom sheet overlays

* Implement custom highlight palettes and reader theme customization in desktop app

* Implement cross-platform modal layer and refine reader UI styling

* Improve highlight accuracy and implement metadata enrichment on book open in desktop app

* Implement two-page spread layout for paginated reader on desktop

* Implement persistent caching for book loading and pagination in desktop app

* Implement persistent caching for book loading and pagination in desktop app

* Optimize reader settings updates by separating layout and appearance changes in desktop app

* Improve desktop window branding and native Windows styling

* Enhance reader selection interactions and UI across EPUB and PDF viewers in desktop app

* Refine selection handle positioning and interaction logic

* Implement EPUB selection debug logging and improve handle targeting

* Optimize desktop book loading performance and UI responsiveness

* Implement anchored zoom gestures and rendering optimizations for the Desktop PDF viewer.

* Implement smooth zoom preview for the PDF reader in desktop app

* Optimize PDF rendering performance and responsiveness in the desktop reader

* Implement conditional diagnostic logging and update desktop build configuration

* Implemented hierarchical TOC, custom scrollbars, and improved desktop modal handling

* Added management options for annotations and highlights in the sidebar in desktop app

* Implemented `SharedStableOutlinedTextField` and updated text input fields to use `TextFieldValue` for improved cursor and selection stability.

* Refined library filters and enhanced OPDS functionality in desktop app

* Improved EPUB pagination measurement and implemented layout diagnostic logging for desktop app

* Added PPTX support including document parsing, rendering, and indexing

* Improved PPTX rendering and layout accuracy

* Implemented text autofit support for PPTX rendering

* Enhanced PPTX rendering with support for custom geometry, automatic numbering, table styles, and image opacity

* Improved EPUB pagination accuracy and added layout telemetry in desktop app

* Improved folder synchronization with metadata-only mode and hashed sidecar management in desktop app

* Implemented rich text font scaling and migrated desktop ink tools to custom pointer input handling

* Implemented billing account obfuscation

* Implemented hierarchical folder navigation and improved library selection functionality in desktop app

* Implemented platform-aware directory resolution and multi-platform native library support for desktop

* Added full-screen mode for the reader workspace

* Added PDF zoom indicator and interactive vertical scrollbar with page tooltips

* Refactored speech bubble prefetching to use a limited radius and improved ML detector initialization and lifecycle management

* Updated PDF indexing to replace existing page text and removed search result item keys

* Implemented "preparing" foreground notification for TTS service

* Optimized PDF rendering performance by pre-calculating page-specific annotations

* Refactored desktop packaging tasks and improved distribution configuration

* Optimized EPUB parser memory usage and added path traversal protection

* Refactored WorkManager monitoring logic and added work pruning

* Implemented comprehensive resource cleanup and memory management for WebView-based components to prevent memory leaks

* Implemented bitmap size limits and scaling to prevent canvas rendering errors

* Split long text paragraphs into multiple semantic blocks during HTML parsing

* Implemented local ActionMode for text selection to prevent platform crashes

* Refactored PPTX text layout, optimized HtmlParser block detection, and improved banner dismissal logic

* Added desktop startup splash screen and deferred WebView initialization

* Reorganized settings hub and added separate PDF reader defaults

* Implemented embedded cover extraction and metadata support for MOBI and FB2 formats

* Implemented batching for MetadataExtractionWorker and optimized EPUB metadata extraction performance.

* Implemented procedurally generated book covers and replaced static placeholders

* Redesigned search UI with a top bar and results overlay in desktop app

* Added PDF page gap and overlay visibility options and implemented DesktopBookImporter

* Refactored PDF reader UI with tabbed inspector and improved theme background handling in desktop

* Implemented PDF viewport persistence for zoom and scroll positions in desktop app

* Improved desktop fullscreen implementation and state restoration

* Implemented desktop window state persistence

* Implemented flavor-based branding and ProGuard configuration for desktop builds

* Implemented precise reader positioning and improved highlight rendering logic in desktop app

* Added support for user-editable book metadata

* Enhanced book metadata support and integrated info/edit dialogs

* Implemented embedded EPUB metadata editing

* Improved highlight mapping and added custom scrollbar styling for the reader.

* Reduced desktop WebView bundle size by excluding unused locales and runtime files

* Added neutral pan mode as the default PDF interaction state.

* Refactored library empty states and updated primary navigation tabs in desktop app

* Implemented native paginated reader and unified content rendering architecture in desktop epub reader

* Implemented native EPUB image rendering for desktop and improved block layout spacing with margin collapsing.

* Improved pagination overflow detection in desktop

* Implemented multi-block text selection with interactive handles and CFI support in desktop epub pagination
2026-05-15 22:36:51 +05:30

2305 lines
107 KiB
Kotlin

/*
* 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 <https://www.gnu.org/licenses/>.
*
* 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<ReaderDocument>,
documentKey: String,
activeTheme: com.aryan.reader.ReaderTheme,
activeTextureAlpha: Float = 0.55f,
excludeImages: Boolean = false,
totalPages: Int,
virtualPages: List<VirtualPage> = emptyList(),
pageAspectRatios: StableHolder<List<Float>>,
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<Set<PdfBookmark>>,
onBookmarkClick: (Int) -> Unit,
onOcrStateChange: (Boolean) -> Unit,
onGetOcrSearchRects: suspend (Int, String) -> List<RectF>,
isEditMode: Boolean = false,
allAnnotations: () -> Map<Int, List<PdfAnnotation>> = { emptyMap() },
drawingState: PdfDrawingState,
onDrawStart: (Int, PdfPoint, Boolean) -> Unit,
onDraw: (Int, PdfPoint, Boolean) -> Unit,
onDrawEnd: () -> Unit,
onOcrModelDownloading: () -> Unit = {},
selectedTool: InkType,
richTextController: RichTextController? = null,
textBoxes: List<PdfTextBox> = emptyList(),
textBoxesByPage: Map<Int, List<PdfTextBox>> = 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<PdfUserHighlight> = emptyList(),
userHighlightsByPage: Map<Int, List<PdfUserHighlight>> = emptyMap(),
onHighlightAdd: (Int, Pair<Int, Int>, 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<PdfHighlightColor, Color> = emptyMap(),
onPaletteClick: () -> Unit = {},
lockedState: Triple<Float, Float, Float>? = null,
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null,
resetZoomTrigger: Long = 0L,
isBubbleZoomModeActive: Boolean = false,
showPageGap: Boolean = true,
showPageNumberOverlay: Boolean = true,
onDetectBubbles: suspend (Int, Bitmap) -> List<SpeechBubble> = { _, _ -> emptyList() }
) {
DisposableEffect(state) {
onDispose {
state.scrollToPageHandler = null
state.snapToPageHandler = null
state.scrollByHandler = null
state.scrollToTopHandler = null
state.scrollToBottomHandler = null
}
}
var globalEraserPosition by remember { mutableStateOf<Offset?>(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<PdfPageLayout>, 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<Float, Float, Float> {
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<Float, Float, Float> {
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<String?>(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<Int, PdfPoint>? {
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<Float>()
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<List<PdfPageLayout>>(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<PdfPageLayout>()
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<List<RectF>>(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<LayoutCoordinates?>(
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 = {})
}
}
}
}
}