* Add performance and stylus debugging logs * Refactor and decouple UI models from `MainViewModel` * Refactor library state management and projection logic * Implement desktop shell using Compose Multiplatform * Implement desktop shell using Compose Multiplatform * Implement desktop shell using Compose Multiplatform * Introduce ReaderEngine and enhance EPUB reader features in windows app * Move core paginated reader logic to a Kotlin Multiplatform `shared` module and introduce experimental desktop support. * Implement PDF rendering and text extraction for desktop using Pdfium * Add `NonReaderScreens.kt` and UI dependencies * Refactor and centralize library state management and models to improve cross-platform consistency * Implement JSON persistence for desktop library and enhance library management features including shelf CRUD, tagging, and metadata editing * Implement PDF annotation system and enhanced zoom controls for the desktop viewer * Implement WebView-based EPUB rendering for desktop using CEF and embedded resources * Optimize UI state projection, navigation state handling, and main screen pager performance * Implement Bring Your Own Key (BYOK) support for AI features in OSS version * Support Gemini-based Cloud TTS with BYOK support for OSS builds * Refactor table cell image sizing in `PaginatedReader` and improve `MobiParser` native library loading and error handling. * crash fixes * Enhance navigation stability with lifecycle-aware safety checks and update `navigation-compose` to 2.9.6 * Implement dynamic bottom padding for the page info bar to account for device rounded corners * Implement bidirectional jump history navigation and replace the jump-back pill with a dedicated `PdfJumpHistoryBar` * Optimize PDF tiling performance and refine pan-and-fling gesture handling * Implement customizable toolbars with drag-and-drop reordering and placement for PDF and EPUB readers * Updated UI for customize toolbar * Refine drag-and-drop reordering and section assignment for PDF and EPUB reader controls * restructure PDF viewer UI component hierarchy to fix verifier crash * Implement separate text dimming factors for light and dark themes * Synchronize Pdfium access and improve resource lifecycle safety across Kotlin and native layers * Enhance image alignment in paginated and EPUB readers through anchor detection and style-based positioning * Centralize file type resolution logic and implement HTML sanitization during import * Introduce vertical margin customization and configurable progress bar positioning * texture support in epub reader * Enhance TTS session management, progress tracking, and diagnostic logging * Optimize library state projection and folder synchronization performance by refactoring collection lookups and refining metadata extraction logic. * Refine TTS page mapping for PDF and overhaul TTS control UI * Implement natural session completion logic in `TtsPlaybackManager` for cloud tts * Replace Snackbar with `CustomTopBanner` for notifications in `PdfViewerScreen` * Refine TTS playback continuity across PDF pages and improve state management for session transitions * Implement global texture transparency and enhance textured theme support across PDF and EPUB readers. * Update reader themes and improve texture rendering in page animations, EPUB UI, and immersive mode * Add Support Project screen * Optimize library performance via projection caching, batch database updates, and scoped folder synchronization. * Enhance folder synchronization with fallback query mechanisms and refactor annotation sidecar importing logic * Bump version to 1.0.47 (51)
2216 lines
104 KiB
Kotlin
2216 lines
104 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.SideEffect
|
|
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.draw.clipToBounds
|
|
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.layout.positionInWindow
|
|
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 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 y: Float,
|
|
val height: Float,
|
|
val width: Float,
|
|
val widthDp: Dp,
|
|
val heightDp: Dp
|
|
)
|
|
|
|
private data class DividerLayout(val y: Float, val width: Float, val height: Float)
|
|
|
|
@Suppress("UnusedVariable")
|
|
@SuppressLint("UnusedBoxWithConstraintsScope", "BinaryOperationInTimber")
|
|
@OptIn(FlowPreview::class)
|
|
@Composable
|
|
internal fun PdfVerticalReader(
|
|
modifier: Modifier = Modifier,
|
|
state: VerticalPdfReaderState,
|
|
pdfDocument: StableHolder<ReaderDocument>,
|
|
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(),
|
|
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,
|
|
isHighlighterSnapEnabled: Boolean = false,
|
|
userHighlights: List<PdfUserHighlight> = emptyList(),
|
|
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,
|
|
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
|
|
onPaletteClick: () -> Unit = {},
|
|
lockedState: Triple<Float, Float, Float>? = null,
|
|
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null,
|
|
resetZoomTrigger: Long = 0L,
|
|
isBubbleZoomModeActive: Boolean = false,
|
|
onDetectBubbles: suspend (Int, Bitmap) -> List<SpeechBubble> = { _, _ -> emptyList() }
|
|
) {
|
|
SideEffect { Timber.tag("PdfDrawPerf").v("LIST: PdfVerticalReader Recomposing.") }
|
|
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"
|
|
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 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 = 8.dp
|
|
val dividerHeightPx = with(density) { dividerHeightDp.toPx() }
|
|
|
|
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, screenWidth, screenHeight, density) {
|
|
data class LayoutResult(val pages: List<PdfPageLayout>, val totalHeight: Float)
|
|
|
|
var currentY = 0.0
|
|
|
|
if (ratios.size == 1) {
|
|
val ratio = ratios[0]
|
|
val safeRatio = if (ratio <= 0f) 1f else ratio
|
|
val pageHeight = screenWidth / safeRatio
|
|
if (pageHeight < screenHeight) {
|
|
currentY = ((screenHeight - pageHeight) / 2f).toDouble()
|
|
}
|
|
}
|
|
|
|
val pages = ratios.mapIndexed { index, ratio ->
|
|
val safeRatio = if (ratio <= 0f) 1f else ratio
|
|
val pageHeightDouble = screenWidth.toDouble() / safeRatio.toDouble()
|
|
val pageHeight = pageHeightDouble.toFloat()
|
|
|
|
val info = PdfPageLayout(
|
|
index = index,
|
|
y = currentY.toFloat(),
|
|
height = pageHeight,
|
|
width = screenWidth,
|
|
widthDp = with(density) { screenWidth.toDp() },
|
|
heightDp = with(density) { pageHeight.toDp() })
|
|
|
|
currentY += pageHeightDouble
|
|
if (index < ratios.lastIndex) {
|
|
currentY += dividerHeightPx
|
|
}
|
|
info
|
|
}
|
|
|
|
val totalH = if (pages.isNotEmpty()) {
|
|
val last = pages.last()
|
|
last.y + last.height
|
|
} else {
|
|
0f
|
|
}
|
|
|
|
LayoutResult(pages, totalH)
|
|
}
|
|
|
|
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) }
|
|
val targetPageDuringResize = remember { mutableIntStateOf(-1) }
|
|
|
|
if (previousScreenWidth != screenWidth || previousScreenHeight != screenHeight) {
|
|
if (previousScreenWidth > 0f) {
|
|
isResizing = 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) {
|
|
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
|
|
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)
|
|
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()
|
|
.then(globalDrawingModifier)
|
|
.pointerInput(isEditMode, selectedTool, isStylusOnlyMode) {
|
|
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 ->
|
|
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 indicesMatch = cached.size == finalPages.size && cached.indices.all {
|
|
cached[it].index == finalPages[it].index
|
|
}
|
|
|
|
if (!indicesMatch) {
|
|
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(page.index) {
|
|
val isBookmarked by remember(bookmarkSet, page.index) {
|
|
derivedStateOf {
|
|
bookmarkSet.any { it.pageIndex == page.index }
|
|
}
|
|
}
|
|
|
|
SideEffect {
|
|
if (page.index == state.currentPage) {
|
|
Timber.tag("PdfDrawPerf")
|
|
.v("VERTICAL READER: Emitting Page ${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 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
|
|
onDoubleTapToZoom(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)
|
|
.graphicsLayer {
|
|
val z = zoomAnimatable.value
|
|
val px = panXAnimatable.value
|
|
val py = panYAnimatable.value
|
|
|
|
scaleX = z
|
|
scaleY = z
|
|
translationX = px
|
|
translationY = page.y * (z - 1f) + py
|
|
transformOrigin = TransformOrigin(0f, 0f)
|
|
|
|
if (page.index < 2 && z > 1.1f) {
|
|
Timber.tag("PdfZoomDebug").v("Page ${page.index} Render: TransY=$translationY (PageY=${page.y}, GlobalY=${page.y + translationY})")
|
|
}
|
|
}
|
|
.clipToBounds()
|
|
.onGloballyPositioned { coordinates ->
|
|
if (page.index == 0) {
|
|
val pos = coordinates.positionInWindow()
|
|
Timber.d(
|
|
"Page 0 Box | GlobalPos: $pos | Size: ${coordinates.size} | PageY: ${page.y}"
|
|
)
|
|
}
|
|
}) {
|
|
PdfPageComposable(
|
|
pdfDocument = pdfDocument,
|
|
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,
|
|
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,
|
|
isAutoScrollPlaying = isAutoScrollPlaying,
|
|
textBoxes = textBoxes.filter { it.pageIndex == page.index },
|
|
selectedTextBoxId = selectedTextBoxId,
|
|
onTextBoxChange = onTextBoxChange,
|
|
onTextBoxSelect = onTextBoxSelect,
|
|
userHighlights = userHighlights.filter { it.pageIndex == page.index },
|
|
onHighlightAdd = onHighlightAdd,
|
|
onHighlightUpdate = onHighlightUpdate,
|
|
onHighlightDelete = onHighlightDelete,
|
|
onNoteRequested = onNoteRequested,
|
|
onTts = onTts,
|
|
activeToolThickness = activeToolThickness,
|
|
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) {
|
|
val dividerY = page.y + page.height
|
|
Box(
|
|
modifier = Modifier
|
|
.layoutId(
|
|
DividerLayout(
|
|
dividerY, page.width, dividerHeightPx
|
|
)
|
|
)
|
|
.graphicsLayer {
|
|
val z = zoomAnimatable.value
|
|
val px = panXAnimatable.value
|
|
val py = panYAnimatable.value
|
|
|
|
scaleX = z
|
|
scaleY = z
|
|
translationX = px
|
|
translationY = dividerY * (z - 1f) + py
|
|
transformOrigin = TransformOrigin(0f, 0f)
|
|
}
|
|
.background(
|
|
MaterialTheme.colorScheme.surfaceVariant
|
|
))
|
|
}
|
|
}
|
|
}
|
|
},
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.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.width.roundToInt(), id.height.roundToInt()
|
|
)
|
|
)
|
|
placeable.place(0, id.y.roundToInt())
|
|
}
|
|
|
|
is DividerLayout -> {
|
|
val placeable = measurable.measure(
|
|
Constraints.fixed(
|
|
id.width.roundToInt(), id.height.roundToInt()
|
|
)
|
|
)
|
|
placeable.place(0, id.y.roundToInt())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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 radiusPx = if (activeToolThickness > 0f) {
|
|
activeToolThickness * 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 = {})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|