V1.0.46 oss (#253)
* add a UI trigger for demo annotations * Implement internal link navigation * Simplify bottom padding logic in `EpubReaderScreen` for vertical scroll mode * Add support for Calibre metadata * Implement advanced library management with Room-backed collections, tags, and smart rules. * Display book series information in book details and remove tags from home screen * Implement zoom reset functionality in PDF viewer * Add a "Pages" thumbnail view to the PDF navigation drawer * Implement jump-back navigation in PDF viewer * MainViewModel: disable FolderSyncWorker, add log export, and enhance PDF position logging * Replace Gson with Kotlin Serialization in SmartCollectionEngine * Implement ONNX-based speech bubble detection * Implement speech bubble detection in PDF viewer * Implement "Smart Comic Zoom" feature for PDF manga/comic reading * Implement reader session persistence and restoration in `MainViewModel` * Add tap-to-turn page feature to PDF viewer * Improve book cache management and recovery * Refactor top overlay padding logic in `PdfViewerScreen` * Implement stylus eraser support in PDF reader * Refactor ReaderTextFormatPanel to use ModalBottomSheet and add new formatting controls * Introduce a customizable horizontal margin setting for the EPUB reader * Refine folder sync and metadata handling for better conflict resolution and stability. * Introduce user-adjustable image scaling for the EPUB reader * Use maxWidthPx as default width fallback for blocks * Improve cross-page text selection and header styling in the paginated reader * Refactor speech bubble detection and UI to support segmentation masks and interactive scaling * Improve speech bubble detection masking and rendering quality * Update ONNX speech bubble detector to use `.ort` model and optimize inference * Improve pagination accuracy * Implement hierarchical folder navigation for the library. * Refactor library item layout for improved space efficiency * Add support for browsing and downloading Google Fonts * Persist library landing state and add shelf search functionality * Fix base tts sample. * Move SpeechBubbleDetector to main source set and update ONNX dependency * Implement a "locate" feature and improve synchronization for TTS (Text-to-Speech) playback across EPUB and PDF readers. * Refine TTS synchronization and voice management in the EPUB reader * Adjust OCR checks for OSS flavor and optimize external dictionary intent flags * Add "Locate" button to PDF drawer's pages tab and move the page number to bottom right of thumbnail * fix various crashes * Refactor SpeechBubbleDetector into product flavors * Implement speech bubble detection caching and background prefetching * Implement on-demand download for Bubble Zoom ML model * Add smooth animation for PDF speech bubble expansion * fixes #252 * hide Google Fonts option, in FontsScreen, for offline variant * Bump version to 1.0.46(46)
This commit is contained in:
parent
579b6f25bf
commit
d16bdd70d4
67 changed files with 9724 additions and 1890 deletions
|
|
@ -13,6 +13,7 @@ import android.graphics.RectF
|
|||
import android.graphics.Shader
|
||||
import android.util.LruCache
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.ui.graphics.drawscope.withTransform
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
|
|
@ -83,6 +84,9 @@ import androidx.compose.ui.graphics.toArgb
|
|||
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
|
||||
import androidx.compose.ui.input.pointer.PointerType
|
||||
import androidx.compose.ui.input.pointer.changedToUp
|
||||
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
|
||||
|
|
@ -115,6 +119,7 @@ import androidx.core.graphics.scale
|
|||
import androidx.core.graphics.set
|
||||
import com.aryan.reader.R
|
||||
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
|
||||
|
|
@ -187,6 +192,78 @@ data class PageLink(
|
|||
val source: LinkSource
|
||||
)
|
||||
|
||||
private data class ExpandedBubbleRender(
|
||||
val bitmap: Bitmap,
|
||||
val zoomFactor: Float
|
||||
)
|
||||
|
||||
private fun computeDynamicBubbleZoomFactor(
|
||||
bubbleBounds: RectF,
|
||||
viewportWidth: Float,
|
||||
viewportHeight: Float
|
||||
): Float {
|
||||
if (bubbleBounds.width() <= 0f || bubbleBounds.height() <= 0f) return 1.5f
|
||||
val targetWidth = viewportWidth * 0.6f
|
||||
val targetHeight = viewportHeight * 0.32f
|
||||
return min(targetWidth / bubbleBounds.width(), targetHeight / bubbleBounds.height())
|
||||
.coerceIn(1.35f, 4.25f)
|
||||
}
|
||||
|
||||
private fun isTapInsideBubble(
|
||||
bubble: SpeechBubble,
|
||||
tapX: Float,
|
||||
tapY: Float,
|
||||
hitSlopPx: Float
|
||||
): Boolean {
|
||||
val expandedBounds = RectF(bubble.bounds)
|
||||
expandedBounds.inset(-hitSlopPx, -hitSlopPx)
|
||||
if (!expandedBounds.contains(tapX, tapY)) return false
|
||||
|
||||
val mask = bubble.maskBitmap ?: return true
|
||||
if (!bubble.bounds.contains(tapX, tapY)) return true
|
||||
|
||||
val normalizedX = ((tapX - bubble.bounds.left) / bubble.bounds.width()).coerceIn(0f, 0.999f)
|
||||
val normalizedY = ((tapY - bubble.bounds.top) / bubble.bounds.height()).coerceIn(0f, 0.999f)
|
||||
val maskX = (normalizedX * mask.width).toInt().coerceIn(0, mask.width - 1)
|
||||
val maskY = (normalizedY * mask.height).toInt().coerceIn(0, mask.height - 1)
|
||||
return AndroidColor.alpha(mask.getPixel(maskX, maskY)) > 24
|
||||
}
|
||||
|
||||
private suspend fun renderExpandedBubbleBitmap(
|
||||
document: ReaderDocument,
|
||||
pageIndex: Int,
|
||||
bubbleBounds: RectF,
|
||||
pageWidth: Int,
|
||||
pageHeight: Int,
|
||||
renderScale: Float
|
||||
): Bitmap? = withContext(Dispatchers.IO) {
|
||||
if (pageWidth <= 0 || pageHeight <= 0 || bubbleBounds.width() <= 0f || bubbleBounds.height() <= 0f) {
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
document.openPage(pageIndex)?.use { page ->
|
||||
val cropWidth = (bubbleBounds.width() * renderScale).roundToInt().coerceAtLeast(1)
|
||||
val cropHeight = (bubbleBounds.height() * renderScale).roundToInt().coerceAtLeast(1)
|
||||
val bitmap = createBitmap(cropWidth, cropHeight)
|
||||
|
||||
try {
|
||||
page.renderPageBitmap(
|
||||
bitmap = bitmap,
|
||||
startX = (-bubbleBounds.left * renderScale).roundToInt(),
|
||||
startY = (-bubbleBounds.top * renderScale).roundToInt(),
|
||||
drawSizeX = (pageWidth * renderScale).roundToInt().coerceAtLeast(cropWidth),
|
||||
drawSizeY = (pageHeight * renderScale).roundToInt().coerceAtLeast(cropHeight),
|
||||
renderAnnot = true
|
||||
)
|
||||
bitmap
|
||||
} catch (t: Throwable) {
|
||||
bitmap.recycle()
|
||||
Timber.tag("BubbleZoom").w(t, "Failed to render expanded bubble bitmap for page $pageIndex")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object PdfInkGeometry {
|
||||
fun calculateFountainPenPoints(
|
||||
points: List<PdfPoint>, baseWidth: Float, pageWidth: Float, pageHeight: Float
|
||||
|
|
@ -394,7 +471,8 @@ internal fun PdfPageComposable(
|
|||
searchHighlightMode: SearchHighlightMode = SearchHighlightMode.ALL,
|
||||
searchResultToHighlight: SearchResult?,
|
||||
ocrHoverHighlights: StableHolder<List<RectF>> = StableHolder(emptyList()),
|
||||
onSingleTap: () -> Unit,
|
||||
onPreSingleTap: ((Offset) -> Boolean)? = null,
|
||||
onSingleTap: (Offset?) -> Unit,
|
||||
isProUser: Boolean,
|
||||
onShowDictionaryUpsellDialog: () -> Unit,
|
||||
onWordSelectedForAiDefinition: (String) -> Unit,
|
||||
|
|
@ -415,6 +493,7 @@ internal fun PdfPageComposable(
|
|||
isVerticalScroll: Boolean = false,
|
||||
visualScaleProvider: () -> Float = { 1f },
|
||||
clearSelectionTrigger: Long = 0L,
|
||||
resetZoomTrigger: Long = 0L,
|
||||
onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null,
|
||||
onSearchHighlightCenterCalculated: ((Float) -> Unit)? = null,
|
||||
activeTheme: com.aryan.reader.ReaderTheme = com.aryan.reader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
|
||||
|
|
@ -423,8 +502,8 @@ internal fun PdfPageComposable(
|
|||
isEditMode: Boolean = false,
|
||||
drawingState: PdfDrawingState? = null,
|
||||
pageAnnotations: () -> List<PdfAnnotation> = { emptyList() },
|
||||
onDrawStart: (PdfPoint) -> Unit = {},
|
||||
onDraw: (PdfPoint) -> Unit = {},
|
||||
onDrawStart: (PdfPoint, Boolean) -> Unit = { _, _ -> },
|
||||
onDraw: (PdfPoint, Boolean) -> Unit = { _, _ -> },
|
||||
onDrawEnd: () -> Unit = {},
|
||||
visibleScreenRect: () -> IntRect? = { null },
|
||||
selectedTool: InkType = InkType.PEN,
|
||||
|
|
@ -441,6 +520,7 @@ internal fun PdfPageComposable(
|
|||
isScrollLocked: Boolean = false,
|
||||
isVisible: Boolean = true,
|
||||
isActivePage: Boolean = true,
|
||||
isBubbleZoomModeActive: Boolean = false,
|
||||
isStylusOnlyMode: Boolean = false,
|
||||
isAutoScrollPlaying: Boolean = false,
|
||||
isHighlighterSnapEnabled: Boolean = false,
|
||||
|
|
@ -455,7 +535,7 @@ internal fun PdfPageComposable(
|
|||
onPaletteClick: (() -> Unit)? = null,
|
||||
lockedState: Triple<Float, Float, Float>? = null,
|
||||
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null,
|
||||
onDetectPanels: suspend (Bitmap) -> List<android.graphics.RectF> = { emptyList() },
|
||||
onDetectBubbles: suspend (Int, Bitmap) -> List<SpeechBubble> = { _, _ -> emptyList() },
|
||||
onShowPanelPopup: (Bitmap) -> Unit = {}
|
||||
) {
|
||||
val pdfDocumentItem = pdfDocument.item
|
||||
|
|
@ -475,10 +555,7 @@ internal fun PdfPageComposable(
|
|||
LocalContext.current
|
||||
val viewConfiguration = LocalViewConfiguration.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Timber.d(
|
||||
"PdfPageComposable recompose: page=$pageIndex, isScrolling=$isScrolling, visualScale=$visualScaleProvider"
|
||||
)
|
||||
var isStylusEraserOverride by remember { mutableStateOf(false) }
|
||||
|
||||
var layoutCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||
|
||||
|
|
@ -493,6 +570,7 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
|
||||
val currentOnSingleTap by rememberUpdatedState(onSingleTap)
|
||||
val currentOnPreSingleTap by rememberUpdatedState(onPreSingleTap)
|
||||
val currentOnDoubleTap by rememberUpdatedState(onDoubleTap)
|
||||
|
||||
val effectiveScale = if (isZoomEnabled && !isVerticalScroll) scale else externalScale
|
||||
|
|
@ -618,9 +696,7 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
|
||||
LaunchedEffect(centeringOffsetX, centeringOffsetY, pageIndex) {
|
||||
Timber.d(
|
||||
"PdfPageComposable Page $pageIndex | Centering Offset: x=$centeringOffsetX, y=$centeringOffsetY"
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
var showMagnifier by remember { mutableStateOf(false) }
|
||||
|
|
@ -646,6 +722,132 @@ internal fun PdfPageComposable(
|
|||
screenOffset
|
||||
}
|
||||
|
||||
var detectedBubbles by remember(targetPageId) { mutableStateOf<List<SpeechBubble>>(emptyList()) }
|
||||
var expandedBubbleIndex by remember(targetPageId) { mutableIntStateOf(-1) }
|
||||
var animatingBubbleIndex by remember(targetPageId) { mutableIntStateOf(-1) }
|
||||
val bubbleExpansionProgress = remember(targetPageId) { Animatable(0f) }
|
||||
var isDetectingBubbles by remember(targetPageId) { mutableStateOf(false) }
|
||||
var expandedBubbleRender by remember(targetPageId) { mutableStateOf<ExpandedBubbleRender?>(null) }
|
||||
val currentDetectedBubbles by rememberUpdatedState(detectedBubbles)
|
||||
val currentExpandedBubbleIndex by rememberUpdatedState(expandedBubbleIndex)
|
||||
val currentBubbleZoomModeActive by rememberUpdatedState(isBubbleZoomModeActive)
|
||||
val bubbleTapSlopPx = with(density) { 18.dp.toPx() }
|
||||
|
||||
LaunchedEffect(expandedBubbleIndex) {
|
||||
if (expandedBubbleIndex != -1) {
|
||||
if (animatingBubbleIndex != -1 && animatingBubbleIndex != expandedBubbleIndex) {
|
||||
bubbleExpansionProgress.animateTo(0f, tween(150))
|
||||
}
|
||||
animatingBubbleIndex = expandedBubbleIndex
|
||||
bubbleExpansionProgress.animateTo(1f, tween(250, easing = androidx.compose.animation.core.FastOutSlowInEasing))
|
||||
} else {
|
||||
bubbleExpansionProgress.animateTo(0f, tween(200, easing = androidx.compose.animation.core.FastOutLinearInEasing))
|
||||
animatingBubbleIndex = -1
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
isBubbleZoomModeActive,
|
||||
isActivePage,
|
||||
isPdfPage,
|
||||
pdfPageIndex,
|
||||
bitmapState,
|
||||
actualBitmapWidthPx,
|
||||
actualBitmapHeightPx
|
||||
) {
|
||||
Timber.tag("BubbleZoom").d("LaunchedEffect triggered. modeActive=$isBubbleZoomModeActive, activePage=$isActivePage, hasBitmap=${bitmapState != null}, dims=${actualBitmapWidthPx}x${actualBitmapHeightPx}")
|
||||
|
||||
if (isBubbleZoomModeActive && isActivePage && isPdfPage && bitmapState != null && actualBitmapWidthPx > 0 && actualBitmapHeightPx > 0) {
|
||||
Timber.tag("BubbleZoom").d("Conditions met. Starting detection...")
|
||||
isDetectingBubbles = true
|
||||
try {
|
||||
val rawBubbles = onDetectBubbles(pdfPageIndex, bitmapState!!)
|
||||
Timber.tag("BubbleZoom").d("Detection complete. Found ${rawBubbles.size} raw bubbles.")
|
||||
|
||||
// NEW: Scale bubbles down from render bitmap space to logical screen space
|
||||
val scaleX = actualBitmapWidthPx.toFloat() / bitmapState!!.width.toFloat()
|
||||
val scaleY = actualBitmapHeightPx.toFloat() / bitmapState!!.height.toFloat()
|
||||
|
||||
val logicalBubbles = rawBubbles.map { b ->
|
||||
b.copy(bounds = android.graphics.RectF(
|
||||
b.bounds.left * scaleX,
|
||||
b.bounds.top * scaleY,
|
||||
b.bounds.right * scaleX,
|
||||
b.bounds.bottom * scaleY
|
||||
))
|
||||
}
|
||||
|
||||
val rowHeight = actualBitmapHeightPx * 0.1f
|
||||
detectedBubbles = logicalBubbles.sortedWith(compareBy<SpeechBubble> { (it.bounds.centerY() / rowHeight).roundToInt() }.thenBy { it.bounds.centerX() })
|
||||
expandedBubbleIndex = -1
|
||||
|
||||
Timber.tag("BubbleZoom").d("Sorted logical bubbles count: ${detectedBubbles.size}")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("BubbleZoom").e(e, "Bubble detection failed with exception")
|
||||
} finally {
|
||||
isDetectingBubbles = false
|
||||
}
|
||||
} else {
|
||||
Timber.tag("BubbleZoom").d("Conditions NOT met or mode disabled. Clearing bubbles.")
|
||||
detectedBubbles = emptyList()
|
||||
expandedBubbleIndex = -1
|
||||
expandedBubbleRender?.bitmap?.takeUnless { it.isRecycled }?.recycle()
|
||||
expandedBubbleRender = null
|
||||
if (!isBubbleZoomModeActive && scale > 1f && !isVerticalScroll && isZoomEnabled) {
|
||||
coroutineScope.launch {
|
||||
Animatable(scale).animateTo(1f, tween(300)) {
|
||||
scale = this.value
|
||||
offset = Offset.Zero
|
||||
onScaleChanged(scale)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
animatingBubbleIndex,
|
||||
detectedBubbles,
|
||||
actualBitmapWidthPx,
|
||||
actualBitmapHeightPx,
|
||||
canvasWidthPx.floatValue,
|
||||
canvasHeightPx.floatValue,
|
||||
isBubbleZoomModeActive,
|
||||
isPdfPage,
|
||||
pdfPageIndex
|
||||
) {
|
||||
val previousRender = expandedBubbleRender
|
||||
expandedBubbleRender = null
|
||||
previousRender?.bitmap?.takeUnless { it.isRecycled }?.recycle()
|
||||
|
||||
if (!isBubbleZoomModeActive || !isPdfPage || animatingBubbleIndex !in detectedBubbles.indices) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val bubble = detectedBubbles[animatingBubbleIndex]
|
||||
val zoomFactor = computeDynamicBubbleZoomFactor(
|
||||
bubbleBounds = bubble.bounds,
|
||||
viewportWidth = canvasWidthPx.floatValue.coerceAtLeast(actualBitmapWidthPx.toFloat()),
|
||||
viewportHeight = canvasHeightPx.floatValue.coerceAtLeast(actualBitmapHeightPx.toFloat())
|
||||
)
|
||||
val renderScale = (zoomFactor * 1.2f).coerceAtLeast(1.6f)
|
||||
val renderedBubble = renderExpandedBubbleBitmap(
|
||||
document = pdfDocumentItem,
|
||||
pageIndex = pdfPageIndex,
|
||||
bubbleBounds = bubble.bounds,
|
||||
pageWidth = actualBitmapWidthPx,
|
||||
pageHeight = actualBitmapHeightPx,
|
||||
renderScale = renderScale
|
||||
)
|
||||
|
||||
if (renderedBubble != null) {
|
||||
expandedBubbleRender = ExpandedBubbleRender(
|
||||
bitmap = renderedBubble,
|
||||
zoomFactor = zoomFactor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
val currentBitmap = bitmapState
|
||||
|
|
@ -653,6 +855,7 @@ internal fun PdfPageComposable(
|
|||
if (currentBitmap != null && !currentBitmap.isRecycled && currentBitmap !== cachedBitmap) {
|
||||
currentBitmap.recycle()
|
||||
}
|
||||
expandedBubbleRender?.bitmap?.takeUnless { it.isRecycled }?.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1646,6 +1849,32 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(resetZoomTrigger) {
|
||||
if (resetZoomTrigger != 0L && scale > 1f && isZoomEnabled && !isVerticalScroll && !isScrollLocked) {
|
||||
coroutineScope.launch {
|
||||
val startScale = scale
|
||||
val startOffset = offset
|
||||
Animatable(0f).animateTo(
|
||||
1f, animationSpec = tween(durationMillis = 300)
|
||||
) {
|
||||
val progress = value
|
||||
scale = androidx.compose.ui.util.lerp(
|
||||
startScale, 1f, progress
|
||||
)
|
||||
offset = androidx.compose.ui.geometry.lerp(
|
||||
startOffset, Offset.Zero, progress
|
||||
)
|
||||
onScaleChanged(scale)
|
||||
}
|
||||
if (scale <= 1.05f) {
|
||||
scale = 1f
|
||||
offset = Offset.Zero
|
||||
onScaleChanged(scale)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val errorSelection = stringResource(R.string.error_selection)
|
||||
val errorOcrSelection = stringResource(R.string.error_ocr_selection)
|
||||
val errorProcessingPage = stringResource(R.string.error_processing_page)
|
||||
|
|
@ -2487,7 +2716,8 @@ internal fun PdfPageComposable(
|
|||
isEditMode,
|
||||
selectedTool,
|
||||
isStylusOnlyMode,
|
||||
userHighlightScreenRects
|
||||
userHighlightScreenRects,
|
||||
bubbleTapSlopPx
|
||||
) {
|
||||
val isTapDetectionAllowed = !isEditMode ||
|
||||
selectedTool == InkType.TEXT ||
|
||||
|
|
@ -2496,9 +2726,48 @@ internal fun PdfPageComposable(
|
|||
if (!isTapDetectionAllowed) return@pointerInput
|
||||
|
||||
detectTapGestures(onTap = { tapOffset ->
|
||||
if (currentOnPreSingleTap?.invoke(tapOffset) == true) {
|
||||
return@detectTapGestures
|
||||
}
|
||||
|
||||
val tapInContentCoords = screenToContentCoordinates(tapOffset)
|
||||
val tapXInBitmap = tapInContentCoords.x
|
||||
val tapYInBitmap = tapInContentCoords.y
|
||||
val isWithinContentBounds =
|
||||
tapXInBitmap in 0f..actualBitmapWidthPx.toFloat() &&
|
||||
tapYInBitmap in 0f..actualBitmapHeightPx.toFloat()
|
||||
|
||||
if (!isWithinContentBounds) {
|
||||
currentOnSingleTap(tapOffset)
|
||||
return@detectTapGestures
|
||||
}
|
||||
|
||||
Timber.tag("BubbleZoom").d("Tap inside bounds. modeActive=$currentBubbleZoomModeActive, detectedBubbles=${currentDetectedBubbles.size}, tapPos=($tapXInBitmap, $tapYInBitmap)")
|
||||
|
||||
if (currentBubbleZoomModeActive && currentDetectedBubbles.isNotEmpty()) {
|
||||
val tappedBubbleIndex = currentDetectedBubbles.indexOfFirst { bubble ->
|
||||
isTapInsideBubble(
|
||||
bubble = bubble,
|
||||
tapX = tapXInBitmap,
|
||||
tapY = tapYInBitmap,
|
||||
hitSlopPx = bubbleTapSlopPx
|
||||
)
|
||||
}
|
||||
|
||||
Timber.tag("BubbleZoom").d("Tapped bubble index: $tappedBubbleIndex (expandedIndex=$currentExpandedBubbleIndex)")
|
||||
|
||||
if (tappedBubbleIndex != -1) {
|
||||
expandedBubbleIndex = if (currentExpandedBubbleIndex == tappedBubbleIndex) {
|
||||
-1
|
||||
} else {
|
||||
tappedBubbleIndex
|
||||
}
|
||||
return@detectTapGestures
|
||||
} else if (currentExpandedBubbleIndex != -1) {
|
||||
expandedBubbleIndex = -1
|
||||
return@detectTapGestures
|
||||
}
|
||||
}
|
||||
|
||||
coroutineScope.launch {
|
||||
val nativeResult = withContext(Dispatchers.IO) {
|
||||
|
|
@ -2660,7 +2929,7 @@ internal fun PdfPageComposable(
|
|||
currentPageRotation,
|
||||
)
|
||||
} else {
|
||||
currentOnSingleTap()
|
||||
currentOnSingleTap(tapOffset)
|
||||
}
|
||||
}
|
||||
}, onDoubleTap = { tapOffset ->
|
||||
|
|
@ -2670,37 +2939,6 @@ internal fun PdfPageComposable(
|
|||
val startScale = scale
|
||||
val targetScale = if (startScale > 1.1f) 1f else 2.5f
|
||||
|
||||
if (com.aryan.reader.BuildConfig.DEBUG && startScale <= 1.1f && bitmapState != null) {
|
||||
val tapInContentCoords = screenToContentCoordinates(tapOffset)
|
||||
|
||||
val ratioX = bitmapState!!.width.toFloat() / actualBitmapWidthPx.toFloat()
|
||||
val ratioY = bitmapState!!.height.toFloat() / actualBitmapHeightPx.toFloat()
|
||||
val tapXInBitmap = tapInContentCoords.x * ratioX
|
||||
val tapYInBitmap = tapInContentCoords.y * ratioY
|
||||
|
||||
val panels = onDetectPanels(bitmapState!!)
|
||||
|
||||
val tappedPanel = panels.firstOrNull {
|
||||
it.contains(tapXInBitmap, tapYInBitmap)
|
||||
}
|
||||
|
||||
if (tappedPanel != null) {
|
||||
Timber.d("Popup: Cropping panel $tappedPanel")
|
||||
val left = tappedPanel.left.coerceAtLeast(0f).toInt()
|
||||
val top = tappedPanel.top.coerceAtLeast(0f).toInt()
|
||||
val right = tappedPanel.right.coerceAtMost(bitmapState!!.width.toFloat()).toInt()
|
||||
val bottom = tappedPanel.bottom.coerceAtMost(bitmapState!!.height.toFloat()).toInt()
|
||||
val width = right - left
|
||||
val height = bottom - top
|
||||
|
||||
if (width > 0 && height > 0) {
|
||||
val cropped = android.graphics.Bitmap.createBitmap(bitmapState!!, left, top, width, height)
|
||||
onShowPanelPopup(cropped)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val startOffset = offset
|
||||
val targetOffsetUnbounded = if (targetScale <= 1.1f) {
|
||||
Offset.Zero
|
||||
|
|
@ -3017,12 +3255,20 @@ internal fun PdfPageComposable(
|
|||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
val buttons = currentEvent.buttons
|
||||
Timber.tag("StylusEraserDiagnostic").d(
|
||||
"Page $pageIndex | 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
|
||||
|
||||
val dragPointerId = down.id
|
||||
val startPos = down.position
|
||||
var dragStarted = false
|
||||
val touchSlop = viewConfiguration.touchSlop
|
||||
|
||||
if (selectedTool == InkType.ERASER) {
|
||||
if (selectedTool == InkType.ERASER || isEraserOverride) {
|
||||
eraserPosition = down.position
|
||||
}
|
||||
|
||||
|
|
@ -3034,6 +3280,7 @@ internal fun PdfPageComposable(
|
|||
drawingState?.onDrawCancel()
|
||||
}
|
||||
eraserPosition = null
|
||||
isStylusEraserOverride = false
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
|
|
@ -3051,12 +3298,13 @@ internal fun PdfPageComposable(
|
|||
val normY =
|
||||
(contentPos.y / actualBitmapHeightPx).coerceIn(0f, 1f)
|
||||
|
||||
onDrawStart(PdfPoint(normX, normY))
|
||||
onDrawStart(PdfPoint(normX, normY), isEraserOverride)
|
||||
onDrawEnd()
|
||||
} else {
|
||||
onDrawEnd()
|
||||
}
|
||||
eraserPosition = null
|
||||
isStylusEraserOverride = false
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
|
|
@ -3077,7 +3325,7 @@ internal fun PdfPageComposable(
|
|||
0f, 1f
|
||||
)
|
||||
onDrawStart(
|
||||
PdfPoint(startNormX, startNormY)
|
||||
PdfPoint(startNormX, startNormY), isEraserOverride
|
||||
)
|
||||
|
||||
val currContentPos = screenToContentCoordinates(
|
||||
|
|
@ -3091,9 +3339,9 @@ internal fun PdfPageComposable(
|
|||
(currContentPos.y / actualBitmapHeightPx).coerceIn(
|
||||
0f, 1f
|
||||
)
|
||||
onDraw(PdfPoint(currNormX, currNormY))
|
||||
onDraw(PdfPoint(currNormX, currNormY), isEraserOverride)
|
||||
|
||||
if (selectedTool == InkType.ERASER) {
|
||||
if (selectedTool == InkType.ERASER || isEraserOverride) {
|
||||
eraserPosition = change.position
|
||||
}
|
||||
change.consume()
|
||||
|
|
@ -3106,9 +3354,9 @@ internal fun PdfPageComposable(
|
|||
(currContentPos.x / actualBitmapWidthPx).coerceIn(0f, 1f)
|
||||
val currNormY =
|
||||
(currContentPos.y / actualBitmapHeightPx).coerceIn(0f, 1f)
|
||||
onDraw(PdfPoint(currNormX, currNormY))
|
||||
onDraw(PdfPoint(currNormX, currNormY), isEraserOverride)
|
||||
|
||||
if (selectedTool == InkType.ERASER) {
|
||||
if (selectedTool == InkType.ERASER || isEraserOverride) {
|
||||
eraserPosition = change.position
|
||||
}
|
||||
change.consume()
|
||||
|
|
@ -3118,6 +3366,7 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
} finally {
|
||||
eraserPosition = null
|
||||
isStylusEraserOverride = false
|
||||
}
|
||||
}, contentAlignment = Alignment.Center
|
||||
) {
|
||||
|
|
@ -3230,10 +3479,6 @@ internal fun PdfPageComposable(
|
|||
offset = Offset.Zero
|
||||
onScaleChanged(1f)
|
||||
}
|
||||
|
||||
Timber.d(
|
||||
"PdfPageComposable Page $pageIndex initialized/resized/locked. scale=$scale, offset=$offset"
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
|
|
@ -3386,15 +3631,8 @@ internal fun PdfPageComposable(
|
|||
val viewContainerHeightPx =
|
||||
with(density) { currentContainerMaxHeight.toPx().toInt() }
|
||||
|
||||
Timber.d(
|
||||
"PdfPageComposable Page $pageIndex | viewContainerPx: ${viewContainerWidthPx}x${viewContainerHeightPx}"
|
||||
)
|
||||
|
||||
if (viewContainerWidthPx <= 0 || viewContainerHeightPx <= 0) {
|
||||
if (bitmapState == null) isLoadingPage = true
|
||||
Timber.d(
|
||||
"PdfPageComposable: viewContainer dimensions invalid ($viewContainerWidthPx x $viewContainerHeightPx), waiting."
|
||||
)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
|
|
@ -3948,6 +4186,7 @@ internal fun PdfPageComposable(
|
|||
isEditMode = isEditMode,
|
||||
selectedTool = selectedTool,
|
||||
eraserPosition = eraserPosition,
|
||||
isStylusEraserOverride = isStylusEraserOverride,
|
||||
activeToolThickness = activeToolThickness,
|
||||
richTextController = richTextController,
|
||||
textBoxes = textBoxes,
|
||||
|
|
@ -3960,7 +4199,14 @@ internal fun PdfPageComposable(
|
|||
onDragPageTurn = onDragPageTurn,
|
||||
draggingBoxId = draggingBoxId,
|
||||
customHighlightColors = customHighlightColors,
|
||||
onPaletteClick = onPaletteClick
|
||||
onPaletteClick = onPaletteClick,
|
||||
isBubbleZoomModeActive = isBubbleZoomModeActive,
|
||||
isActivePage = isActivePage,
|
||||
isDetectingBubbles = isDetectingBubbles,
|
||||
detectedBubbles = detectedBubbles,
|
||||
animatingBubbleIndex = animatingBubbleIndex,
|
||||
bubbleExpansionProgress = bubbleExpansionProgress.value,
|
||||
expandedBubbleRender = expandedBubbleRender
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -4080,15 +4326,10 @@ private fun PdfBitmapLayer(
|
|||
|
||||
if (excludeImages && colorFilter != null && imageRects.isNotEmpty()) {
|
||||
imageRects.forEach { imgRect ->
|
||||
val scaledImgRectLeft = (imgRect.left * effectiveScale).roundToInt()
|
||||
val scaledImgRectTop = (imgRect.top * effectiveScale).roundToInt()
|
||||
val scaledImgRectRight = (imgRect.right * effectiveScale).roundToInt()
|
||||
val scaledImgRectBottom = (imgRect.bottom * effectiveScale).roundToInt()
|
||||
|
||||
val intersectLeft = max(scaledImgRectLeft, tile.renderRect.left)
|
||||
val intersectTop = max(scaledImgRectTop, tile.renderRect.top)
|
||||
val intersectRight = min(scaledImgRectRight, tile.renderRect.right)
|
||||
val intersectBottom = min(scaledImgRectBottom, tile.renderRect.bottom)
|
||||
val intersectLeft = max(imgRect.left, tile.renderRect.left)
|
||||
val intersectTop = max(imgRect.top, tile.renderRect.top)
|
||||
val intersectRight = min(imgRect.right, tile.renderRect.right)
|
||||
val intersectBottom = min(imgRect.bottom, tile.renderRect.bottom)
|
||||
|
||||
val iw = intersectRight - intersectLeft
|
||||
val ih = intersectBottom - intersectTop
|
||||
|
|
@ -4151,7 +4392,6 @@ private fun PdfHighlightsLayer(
|
|||
selectionHighlightColor: Color,
|
||||
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap()
|
||||
) {
|
||||
Timber.d("PdfHighlightsLayer Recompose")
|
||||
Canvas(modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer()) {
|
||||
|
|
@ -4751,6 +4991,7 @@ private fun PdfPageRenderer(
|
|||
isEditMode: Boolean,
|
||||
selectedTool: InkType,
|
||||
eraserPosition: Offset?,
|
||||
isStylusEraserOverride: Boolean,
|
||||
richTextController: RichTextController?,
|
||||
textBoxes: List<PdfTextBox>,
|
||||
selectedTextBoxId: String?,
|
||||
|
|
@ -4769,6 +5010,13 @@ private fun PdfPageRenderer(
|
|||
onTts: (Int, Int) -> Unit,
|
||||
activeToolThickness: Float,
|
||||
onNote: (String?) -> Unit,
|
||||
isBubbleZoomModeActive: Boolean = false,
|
||||
isActivePage: Boolean = true,
|
||||
isDetectingBubbles: Boolean = false,
|
||||
detectedBubbles: List<SpeechBubble> = emptyList(),
|
||||
animatingBubbleIndex: Int = -1,
|
||||
bubbleExpansionProgress: Float = 0f,
|
||||
expandedBubbleRender: ExpandedBubbleRender? = null
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
|
|
@ -4976,7 +5224,7 @@ private fun PdfPageRenderer(
|
|||
|
||||
val teardropPainter = painterResource(id = R.drawable.teardrop)
|
||||
|
||||
if (isEditMode && selectedTool == InkType.ERASER && eraserPosition != null) {
|
||||
if (isEditMode && (selectedTool == InkType.ERASER || isStylusEraserOverride) && eraserPosition != null) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val radiusPx = if (activeToolThickness > 0f && staticData.targetWidth > 0) {
|
||||
activeToolThickness * staticData.targetWidth * scale // Calculate dynamic size based on tool settings scale
|
||||
|
|
@ -5203,6 +5451,150 @@ private fun PdfPageRenderer(
|
|||
if (isPerformingOcr && ocrRipplePos != null) {
|
||||
OcrProcessingIndicator(position = ocrRipplePos)
|
||||
}
|
||||
|
||||
if (isBubbleZoomModeActive && isActivePage) {
|
||||
if (isDetectingBubbles) {
|
||||
androidx.compose.material3.CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
} else if (detectedBubbles.isNotEmpty()) {
|
||||
Canvas(modifier = Modifier.fillMaxSize().zIndex(20f)) {
|
||||
// Draw shadow-like hints for unexpanded bubbles
|
||||
detectedBubbles.forEachIndexed { index, bubble ->
|
||||
val hintAlpha = if (index == animatingBubbleIndex) 0.35f * (1f - bubbleExpansionProgress) else 0.35f
|
||||
if (hintAlpha > 0f) {
|
||||
val left = bubble.bounds.left + staticData.centeringOffsetX
|
||||
val top = bubble.bounds.top + staticData.centeringOffsetY
|
||||
val width = bubble.bounds.width()
|
||||
val height = bubble.bounds.height()
|
||||
|
||||
if (bubble.maskBitmap != null) {
|
||||
drawImage(
|
||||
image = bubble.maskBitmap.asImageBitmap(),
|
||||
dstOffset = IntOffset(left.toInt(), top.toInt()),
|
||||
dstSize = IntSize(width.toInt(), height.toInt()),
|
||||
colorFilter = ColorFilter.tint(Color.Black.copy(alpha = hintAlpha)),
|
||||
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
||||
)
|
||||
} else {
|
||||
drawRoundRect(
|
||||
color = Color.Black.copy(alpha = hintAlpha),
|
||||
topLeft = Offset(left, top),
|
||||
size = Size(width, height),
|
||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(24f, 24f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (animatingBubbleIndex in detectedBubbles.indices && staticData.bitmap.item != null && bubbleExpansionProgress > 0f) {
|
||||
val bubble = detectedBubbles[animatingBubbleIndex]
|
||||
val left = bubble.bounds.left + staticData.centeringOffsetX
|
||||
val top = bubble.bounds.top + staticData.centeringOffsetY
|
||||
val logicalWidth = bubble.bounds.width()
|
||||
val logicalHeight = bubble.bounds.height()
|
||||
val pivotX = left + logicalWidth / 2f
|
||||
val pivotY = top + logicalHeight / 2f
|
||||
val targetZoomFactor = expandedBubbleRender?.zoomFactor ?: computeDynamicBubbleZoomFactor(
|
||||
bubbleBounds = bubble.bounds,
|
||||
viewportWidth = staticData.canvasWidth,
|
||||
viewportHeight = staticData.canvasHeight
|
||||
)
|
||||
val zoomFactor = androidx.compose.ui.util.lerp(1f, targetZoomFactor, bubbleExpansionProgress)
|
||||
|
||||
withTransform({
|
||||
scale(zoomFactor, zoomFactor, Offset(pivotX, pivotY))
|
||||
}) {
|
||||
val dstOffset = IntOffset(left.toInt(), top.toInt())
|
||||
val dstSize = IntSize(logicalWidth.toInt(), logicalHeight.toInt())
|
||||
|
||||
val renderScaleX = staticData.bitmap.item.width.toFloat() / staticData.targetWidth.toFloat()
|
||||
val renderScaleY = staticData.bitmap.item.height.toFloat() / staticData.targetHeight.toFloat()
|
||||
|
||||
val srcOffset = IntOffset(
|
||||
(bubble.bounds.left * renderScaleX).toInt(),
|
||||
(bubble.bounds.top * renderScaleY).toInt()
|
||||
)
|
||||
val srcSize = IntSize(
|
||||
(logicalWidth * renderScaleX).toInt(),
|
||||
(logicalHeight * renderScaleY).toInt()
|
||||
)
|
||||
|
||||
if (bubble.maskBitmap != null) {
|
||||
drawImage(
|
||||
image = bubble.maskBitmap.asImageBitmap(),
|
||||
dstOffset = IntOffset(left.toInt() + 12, top.toInt() + 12),
|
||||
dstSize = dstSize,
|
||||
colorFilter = ColorFilter.tint(Color.Black.copy(alpha = 0.5f * bubbleExpansionProgress)),
|
||||
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
||||
)
|
||||
} else {
|
||||
drawRoundRect(
|
||||
color = Color.Black.copy(alpha = 0.5f * bubbleExpansionProgress),
|
||||
topLeft = Offset(left + 12f, top + 12f),
|
||||
size = Size(logicalWidth, logicalHeight),
|
||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(24f, 24f)
|
||||
)
|
||||
}
|
||||
|
||||
if (bubble.maskBitmap != null) {
|
||||
val rect = androidx.compose.ui.geometry.Rect(
|
||||
dstOffset.x.toFloat(),
|
||||
dstOffset.y.toFloat(),
|
||||
dstOffset.x.toFloat() + dstSize.width,
|
||||
dstOffset.y.toFloat() + dstSize.height
|
||||
)
|
||||
drawContext.canvas.saveLayer(rect, androidx.compose.ui.graphics.Paint())
|
||||
drawImage(
|
||||
image = (expandedBubbleRender?.bitmap ?: staticData.bitmap.item).asImageBitmap(),
|
||||
srcOffset = if (expandedBubbleRender != null) IntOffset.Zero else srcOffset,
|
||||
srcSize = if (expandedBubbleRender != null) {
|
||||
IntSize(
|
||||
expandedBubbleRender.bitmap.width,
|
||||
expandedBubbleRender.bitmap.height)
|
||||
} else {
|
||||
srcSize
|
||||
},
|
||||
dstOffset = dstOffset,
|
||||
dstSize = dstSize,
|
||||
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
||||
)
|
||||
drawImage(
|
||||
image = bubble.maskBitmap.asImageBitmap(),
|
||||
dstOffset = dstOffset,
|
||||
dstSize = dstSize,
|
||||
blendMode = BlendMode.DstIn,
|
||||
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
||||
)
|
||||
drawContext.canvas.restore()
|
||||
} else {
|
||||
clipRect(left, top, left + logicalWidth, top + logicalHeight) {
|
||||
drawImage(
|
||||
image = (expandedBubbleRender?.bitmap ?: staticData.bitmap.item).asImageBitmap(),
|
||||
srcOffset = if (expandedBubbleRender != null) IntOffset.Zero else srcOffset,
|
||||
srcSize = if (expandedBubbleRender != null) {
|
||||
IntSize(
|
||||
expandedBubbleRender.bitmap.width,
|
||||
expandedBubbleRender.bitmap.height)
|
||||
} else {
|
||||
srcSize
|
||||
},
|
||||
dstOffset = dstOffset,
|
||||
dstSize = dstSize
|
||||
)
|
||||
}
|
||||
drawRect(
|
||||
color = Color.White.copy(alpha = 0.5f * bubbleExpansionProgress),
|
||||
topLeft = Offset(left, top),
|
||||
size = Size(logicalWidth, logicalHeight),
|
||||
style = Stroke(width = 4f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -5409,4 +5801,4 @@ private fun getNativePointer(obj: Any): Long {
|
|||
} catch (_: Exception) {}
|
||||
|
||||
return 0L
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue