/* * Episteme Reader - A native Android document reader. * Copyright (C) 2026 Episteme * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * mail: epistemereader@gmail.com */ package com.aryan.reader.pdf import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.ui.zIndex import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.aryan.reader.R import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.changedToUp import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.positionChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.aryan.reader.pdf.data.PdfTextBox import timber.log.Timber import kotlin.math.roundToInt private enum class ResizeHandle { TOP_LEFT, TOP_CENTER, TOP_RIGHT, RIGHT_CENTER, BOTTOM_RIGHT, BOTTOM_CENTER, BOTTOM_LEFT, LEFT_CENTER, NONE } enum class HandlePosition { TOP, BOTTOM, AUTO } // Eagerly consumes pointer events so parent scaled pan/zoom gestures don't intercept it suspend fun PointerInputScope.detectEagerDragGestures( onDragStart: (Offset) -> Unit, onDragEnd: () -> Unit, onDragCancel: () -> Unit, onDrag: (PointerInputChange, Offset) -> Unit ) { awaitEachGesture { var dragStarted = false try { val down = awaitFirstDown(requireUnconsumed = false) down.consume() // Consume immediately onDragStart(down.position) dragStarted = true val pointerId = down.id var canceled = false while (true) { val event = awaitPointerEvent() val change = event.changes.firstOrNull { it.id == pointerId } if (change == null) { canceled = true break } if (change.changedToUp()) { change.consume() break } if (change.positionChanged()) { val dragAmount = change.position - change.previousPosition change.consume() onDrag(change, dragAmount) } } if (canceled) onDragCancel() else onDragEnd() dragStarted = false } finally { if (dragStarted) { onDragCancel() } } } } @Composable fun ResizableTextBox( box: PdfTextBox, isSelected: Boolean, isEditMode: Boolean, isDarkMode: Boolean, pageWidthPx: Float, pageHeightPx: Float, scale: Float = 1f, onBoundsChanged: (Rect) -> Unit, onTextChanged: (String) -> Unit, onSelect: () -> Unit, onDragStart: (Offset) -> Unit, onDrag: (Offset, Rect) -> Unit, onDragEnd: () -> Unit, modifier: Modifier = Modifier, onDragCancel: () -> Unit = {}, handlePosition: HandlePosition = HandlePosition.AUTO ) { if (pageWidthPx <= 0 || pageHeightPx <= 0) return val density = LocalDensity.current val focusRequester = remember { FocusRequester() } // Counter-scale fixed sizes so they render proportionally regardless of the zoom level val handleSize = (10f / scale).dp val handleTouchSize = (40f / scale).dp val handleSizePx = with(density) { handleSize.toPx() } val halfHandlePx = handleSizePx / 2f val handleTouchSizePx = with(density) { handleTouchSize.toPx() } val borderColor = if (isDarkMode) Color.White else Color.Black val handleColor = if (isDarkMode) Color.White else Color.Black var isDraggingOrResizing by remember { mutableStateOf(false) } val fontFamily = remember(box.fontPath, box.fontName) { Timber.tag("PdfFontDebug").d("Rendering Box ${box.id}: FontPath=${box.fontPath}, FontName=${box.fontName}") if (box.fontPath != null) { PdfFontCache.getFontFamily(box.fontPath) } else { when (box.fontName) { "Serif" -> FontFamily.Serif "Sans" -> FontFamily.SansSerif "Monospace" -> FontFamily.Monospace "Cursive" -> FontFamily.Cursive else -> FontFamily.Default } } } androidx.compose.runtime.SideEffect { Timber.tag("PdfTextBoxDebug").v("ResizableTextBox Recompose [ID: ${box.id}] | isSelected=$isSelected | scale=$scale | pagePx=${pageWidthPx}x${pageHeightPx} | bounds=${box.relativeBounds}") } var currentRectPx by remember { mutableStateOf( Rect( left = box.relativeBounds.left * pageWidthPx, top = box.relativeBounds.top * pageHeightPx, right = box.relativeBounds.right * pageWidthPx, bottom = box.relativeBounds.bottom * pageHeightPx ) ) } LaunchedEffect(isSelected) { if (isSelected && isEditMode) { focusRequester.requestFocus() } } LaunchedEffect(box.relativeBounds, pageWidthPx, pageHeightPx) { if (!isDraggingOrResizing) { val newPx = Rect( left = box.relativeBounds.left * pageWidthPx, top = box.relativeBounds.top * pageHeightPx, right = box.relativeBounds.right * pageWidthPx, bottom = box.relativeBounds.bottom * pageHeightPx ) Timber.tag("PdfTextBoxDebug").d("LaunchedEffect bounds recalculation [ID: ${box.id}] | currentRectPx=$newPx") if (kotlin.math.abs(newPx.left - currentRectPx.left) > 1f || kotlin.math.abs(newPx.top - currentRectPx.top) > 1f || kotlin.math.abs(newPx.width - currentRectPx.width) > 1f || kotlin.math.abs(newPx.height - currentRectPx.height) > 1f ) { currentRectPx = newPx } } } val requiredBottomSpacePx = with(density) { 60.dp.toPx() } / scale // Freeze handle position while dragging to prevent UI jumping var isHandleAtTop by remember { mutableStateOf(false) } LaunchedEffect(currentRectPx, pageHeightPx, handlePosition, requiredBottomSpacePx, isDraggingOrResizing) { if (!isDraggingOrResizing) { isHandleAtTop = when (handlePosition) { HandlePosition.TOP -> true HandlePosition.BOTTOM -> false HandlePosition.AUTO -> { if (pageHeightPx <= 0f) false else (pageHeightPx - currentRectPx.bottom) < requiredBottomSpacePx } } } } Box( modifier = modifier .zIndex(if (isSelected) 10f else 0f) .graphicsLayer { translationX = currentRectPx.left - halfHandlePx translationY = currentRectPx.top - halfHandlePx } .size( width = with(density) { (currentRectPx.width + handleSizePx).toDp() }, height = with(density) { (currentRectPx.height + handleSizePx).toDp() } ) ) { // --- 1. Content Body --- Box( modifier = Modifier .fillMaxSize() .padding(handleSize / 2) .pointerInput(Unit) { detectTapGestures { Timber.tag("PdfTextBoxDebug").d("TextBox Tapped[ID: ${box.id}]") onSelect() } } .then( if (isSelected) Modifier.border((1.5f / scale).dp, borderColor) else Modifier ) ) { BasicTextField( value = box.text, onValueChange = onTextChanged, modifier = Modifier .fillMaxSize() .padding(8.dp) .verticalScroll(rememberScrollState()) .focusRequester(focusRequester), textStyle = TextStyle( color = box.color, background = box.backgroundColor, fontFamily = fontFamily, fontSize = with(LocalDensity.current) { (box.fontSize * pageHeightPx).toSp() }, fontWeight = if (box.isBold) FontWeight.Bold else FontWeight.Normal, fontStyle = if (box.isItalic) FontStyle.Italic else FontStyle.Normal, textDecoration = run { val decs = mutableListOf() if (box.isUnderline) decs.add(TextDecoration.Underline) if (box.isStrikeThrough) decs.add(TextDecoration.LineThrough) if (decs.isEmpty()) TextDecoration.None else TextDecoration.combine(decs) } ), cursorBrush = SolidColor(if (isDarkMode) Color.White else MaterialTheme.colorScheme.primary), enabled = isEditMode && isSelected, readOnly = !isEditMode ) } if (isSelected) { val handles = ResizeHandle.entries.filter { it != ResizeHandle.NONE } fun getHandleCenter(handle: ResizeHandle, w: Float, h: Float): Offset { return when (handle) { ResizeHandle.TOP_LEFT -> Offset(halfHandlePx, halfHandlePx) ResizeHandle.TOP_CENTER -> Offset(halfHandlePx + w / 2, halfHandlePx) ResizeHandle.TOP_RIGHT -> Offset(halfHandlePx + w, halfHandlePx) ResizeHandle.RIGHT_CENTER -> Offset(halfHandlePx + w, halfHandlePx + h / 2) ResizeHandle.BOTTOM_RIGHT -> Offset(halfHandlePx + w, halfHandlePx + h) ResizeHandle.BOTTOM_CENTER -> Offset(halfHandlePx + w / 2, halfHandlePx + h) ResizeHandle.BOTTOM_LEFT -> Offset(halfHandlePx, halfHandlePx + h) ResizeHandle.LEFT_CENTER -> Offset(halfHandlePx, halfHandlePx + h / 2) else -> Offset.Zero } } handles.forEach { handle -> val center = getHandleCenter(handle, currentRectPx.width, currentRectPx.height) Box( modifier = Modifier .offset { IntOffset( (center.x - handleTouchSizePx / 2).roundToInt(), (center.y - handleTouchSizePx / 2).roundToInt() ) } .size(handleTouchSize) .pointerInput(onBoundsChanged) { detectEagerDragGestures( onDragStart = { Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragStart[ID: ${box.id}] Handle=$handle") isDraggingOrResizing = true }, onDragEnd = { isDraggingOrResizing = false val normalized = Rect( left = currentRectPx.left / pageWidthPx, top = currentRectPx.top / pageHeightPx, right = currentRectPx.right / pageWidthPx, bottom = currentRectPx.bottom / pageHeightPx ) Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragEnd [ID: ${box.id}] finalNormalized=$normalized") onBoundsChanged(normalized) }, onDragCancel = { isDraggingOrResizing = false } ) { change, dragAmount -> Timber.tag("PdfTextBoxDebug").v("ResizeHandle Drag [ID: ${box.id}] Handle=$handle | dragAmount=$dragAmount") var l = currentRectPx.left var t = currentRectPx.top var r = currentRectPx.right var b = currentRectPx.bottom val dx = dragAmount.x val dy = dragAmount.y val minSize = 50f / scale when (handle) { ResizeHandle.TOP_LEFT -> { l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize)) t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize)) } ResizeHandle.TOP_CENTER -> t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize)) ResizeHandle.TOP_RIGHT -> { r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx)) t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize)) } ResizeHandle.RIGHT_CENTER -> r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx)) ResizeHandle.BOTTOM_RIGHT -> { r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx)) b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx)) } ResizeHandle.BOTTOM_CENTER -> b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx)) ResizeHandle.BOTTOM_LEFT -> { l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize)) b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx)) } ResizeHandle.LEFT_CENTER -> l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize)) else -> {} } currentRectPx = Rect(l, t, r, b) } } ) { Box( modifier = Modifier .size(handleSize) .background(handleColor, CircleShape) .align(Alignment.Center) ) } } DragPill( isDarkMode = isDarkMode, scale = scale, modifier = Modifier .align(if (isHandleAtTop) Alignment.TopCenter else Alignment.BottomCenter) .offset(y = if (isHandleAtTop) (-32f / scale).dp else (32f / scale).dp) .zIndex(20f) .pointerInput(pageWidthPx, pageHeightPx, onDragStart, onDragEnd, onDragCancel) { detectEagerDragGestures( onDragStart = { offset -> Timber.tag("PdfTextBoxDebug").d("DragPill DragStart [ID: ${box.id}] at offset=$offset") isDraggingOrResizing = true onDragStart(offset) }, onDragEnd = { isDraggingOrResizing = false val normalized = Rect( left = currentRectPx.left / pageWidthPx, top = currentRectPx.top / pageHeightPx, right = currentRectPx.right / pageWidthPx, bottom = currentRectPx.bottom / pageHeightPx ) Timber.tag("PdfTextBoxDebug").d("DragPill DragEnd[ID: ${box.id}] finalNormalized=$normalized") onBoundsChanged(normalized) onDragEnd() }, onDragCancel = { isDraggingOrResizing = false onDragCancel() } ) { change, dragAmount -> val w = currentRectPx.width val h = currentRectPx.height val rawLeft = currentRectPx.left + dragAmount.x val rawTop = currentRectPx.top + dragAmount.y val newLeft = rawLeft.coerceIn(0f, maxOf(0f, pageWidthPx - w)) val newTop = rawTop.coerceIn(0f, maxOf(0f, pageHeightPx - h)) val newRect = Rect(newLeft, newTop, newLeft + w, newTop + h) currentRectPx = newRect onDrag(dragAmount, newRect) } } ) } } } @Composable private fun DragPill( modifier: Modifier = Modifier, isDarkMode: Boolean, scale: Float = 1f ) { Surface( modifier = modifier .size(width = (48f / scale).dp, height = (24f / scale).dp), shape = CircleShape, color = if (isDarkMode) Color.White else Color.Black, contentColor = if (isDarkMode) Color.Black else Color.White, shadowElevation = (4f / scale).dp ) { Box(contentAlignment = Alignment.Center) { Icon( painter = painterResource(id = R.drawable.drag_handle), contentDescription = "Drag to move text box", modifier = Modifier.size((20f / scale).dp) ) } } }