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
|
|
@ -26,10 +26,8 @@ import com.aryan.reader.pdf.data.PdfAnnotation
|
|||
|
||||
object DemoAnnotationGenerator {
|
||||
|
||||
// --- SVG Configuration ---
|
||||
private const val SVG_WIDTH = 800f
|
||||
|
||||
// Extracted from your Figma SVG
|
||||
private val DECORATIVE_DOTS = listOf(
|
||||
DotData(120f, 90f, 5f, Color(0xFFF59E0B), 0.7f),
|
||||
DotData(680f, 210f, 6f, Color(0xFFEC4899), 0.7f),
|
||||
|
|
@ -72,47 +70,36 @@ object DemoAnnotationGenerator {
|
|||
fun generateDemoAnnotations(pageIndex: Int): List<PdfAnnotation> {
|
||||
val annotations = mutableListOf<PdfAnnotation>()
|
||||
|
||||
// --- Layout Calculation ---
|
||||
// We want the SVG to occupy 80% of the page width, centered.
|
||||
// PDF coordinates are 0..1.
|
||||
val targetWidthPercent = 0.8f
|
||||
// SVG aspect ratio 300 / 800 = 0.375
|
||||
|
||||
// Calculate scale factor relative to normalized page coordinates
|
||||
val scaleX = targetWidthPercent / SVG_WIDTH
|
||||
val scaleY = scaleX // Keep uniform scale in abstract space
|
||||
|
||||
// Center offsets (0.5 is middle of page)
|
||||
val startX = (1f - targetWidthPercent) / 2f
|
||||
val startY = 0.4f // Position slightly above center vertically
|
||||
val startY = 0.2f
|
||||
|
||||
var currentTime = System.currentTimeMillis()
|
||||
|
||||
// Helper to transform SVG points to PDF Page Points
|
||||
fun transformPoint(x: Float, y: Float): PdfPoint {
|
||||
val pdfX = startX + (x * scaleX)
|
||||
val pdfY = startY + (y * scaleY)
|
||||
val pdfY = startY + (y * scaleX)
|
||||
return PdfPoint(pdfX, pdfY, currentTime)
|
||||
}
|
||||
|
||||
// 1. Render Decorative Dots
|
||||
DECORATIVE_DOTS.forEach { dot ->
|
||||
val pdfPoint = transformPoint(dot.cx, dot.cy)
|
||||
|
||||
// To make a "Dot" with the pen, we need at least 2 points very close together
|
||||
// or a single point might not render depending on the implementation.
|
||||
val points = listOf(
|
||||
pdfPoint,
|
||||
pdfPoint.copy(x = pdfPoint.x + 0.0001f, timestamp = currentTime + 10)
|
||||
)
|
||||
|
||||
// Convert SVG radius to stroke width
|
||||
val relativeThickness = (dot.r / SVG_WIDTH) * 2.5f
|
||||
|
||||
annotations.add(
|
||||
PdfAnnotation(
|
||||
type = AnnotationType.INK,
|
||||
inkType = InkType.PEN, // Standard pen for dots
|
||||
inkType = InkType.PEN,
|
||||
pageIndex = pageIndex,
|
||||
points = points,
|
||||
color = dot.color.copy(alpha = dot.alpha),
|
||||
|
|
@ -122,7 +109,6 @@ object DemoAnnotationGenerator {
|
|||
currentTime += 50
|
||||
}
|
||||
|
||||
// 2. Render Text ("Try Episteme!")
|
||||
val textPaths = splitSvgPaths(TEXT_STROKES_DATA)
|
||||
textPaths.forEach { pathString ->
|
||||
val path = PathParser.createPathFromPathData(pathString)
|
||||
|
|
@ -130,7 +116,6 @@ object DemoAnnotationGenerator {
|
|||
|
||||
if (flattenedPoints.isNotEmpty()) {
|
||||
val pdfPoints = flattenedPoints.mapIndexed { _, p ->
|
||||
// Increment time to simulate drawing speed for Fountain Pen physics
|
||||
currentTime += 8
|
||||
transformPoint(p.x, p.y).copy(timestamp = currentTime)
|
||||
}
|
||||
|
|
@ -138,18 +123,17 @@ object DemoAnnotationGenerator {
|
|||
annotations.add(
|
||||
PdfAnnotation(
|
||||
type = AnnotationType.INK,
|
||||
inkType = InkType.FOUNTAIN_PEN, // Handwriting looks best with this
|
||||
inkType = InkType.FOUNTAIN_PEN,
|
||||
pageIndex = pageIndex,
|
||||
points = pdfPoints,
|
||||
color = Color(0xFF418377), // Updated Green
|
||||
strokeWidth = 0.004f // Fine tip
|
||||
color = Color(0xFF418377),
|
||||
strokeWidth = 0.004f
|
||||
)
|
||||
)
|
||||
currentTime += 150 // Pen lift delay
|
||||
currentTime += 150
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Render Underline
|
||||
val underlinePath = PathParser.createPathFromPathData(UNDERLINE_DATA)
|
||||
val underlinePointsRaw = flattenPath(underlinePath)
|
||||
val underlinePdfPoints = underlinePointsRaw.map { p ->
|
||||
|
|
@ -160,10 +144,10 @@ object DemoAnnotationGenerator {
|
|||
annotations.add(
|
||||
PdfAnnotation(
|
||||
type = AnnotationType.INK,
|
||||
inkType = InkType.PEN, // Consistent width for underline
|
||||
inkType = InkType.PEN,
|
||||
pageIndex = pageIndex,
|
||||
points = underlinePdfPoints,
|
||||
color = Color(0xFFEC4899).copy(alpha = 0.6f), // Pink
|
||||
color = Color(0xFFEC4899).copy(alpha = 0.6f),
|
||||
strokeWidth = 0.005f
|
||||
)
|
||||
)
|
||||
|
|
@ -171,8 +155,6 @@ object DemoAnnotationGenerator {
|
|||
return annotations
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
private data class DotData(val cx: Float, val cy: Float, val r: Float, val color: Color, val alpha: Float)
|
||||
private data class PointF(val x: Float, val y: Float)
|
||||
|
||||
|
|
@ -180,11 +162,9 @@ object DemoAnnotationGenerator {
|
|||
* Android's Path doesn't give us points directly. We use approximate().
|
||||
*/
|
||||
private fun flattenPath(path: Path): List<PointF> {
|
||||
// Approximate the path with error tolerance 0.5 (pixels in SVG space)
|
||||
val approximation = path.approximate(0.5f)
|
||||
val points = mutableListOf<PointF>()
|
||||
|
||||
// approximation array format: [t0, x0, y0, t1, x1, y1, ...]
|
||||
var i = 0
|
||||
while (i < approximation.size) {
|
||||
val x = approximation[i + 1]
|
||||
|
|
@ -204,15 +184,12 @@ object DemoAnnotationGenerator {
|
|||
val result = mutableListOf<String>()
|
||||
|
||||
rawPaths.forEach { fullPathString ->
|
||||
// Clean up and standardize
|
||||
val cleanStr = fullPathString.trim()
|
||||
|
||||
// Split by "M" (Move command).
|
||||
val parts = cleanStr.split("M")
|
||||
|
||||
parts.forEach { part ->
|
||||
if (part.isNotBlank()) {
|
||||
// Re-prepend M because split removed it
|
||||
result.add("M ${part.trim()}")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,24 @@
|
|||
// PdfNavigationDrawerContent.kt
|
||||
package com.aryan.reader.pdf
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
|
|
@ -13,29 +28,260 @@ import androidx.compose.foundation.pager.rememberPagerState
|
|||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.legere.pdfiumandroid.api.Bookmark
|
||||
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import timber.log.Timber
|
||||
import androidx.core.graphics.createBitmap
|
||||
|
||||
private const val MAX_FIXED_RECURSION = 128
|
||||
|
||||
internal data class PdfBookmark(val pageIndex: Int, val title: String, val totalPages: Int)
|
||||
|
||||
internal data class TocEntry(val title: String, val pageIndex: Int, val nestLevel: Int)
|
||||
|
||||
/**
|
||||
* Patches the library bug where siblings are truncated due to depth-state leakage.
|
||||
*/
|
||||
suspend fun PdfDocumentKt.getFixedTableOfContents(): List<Bookmark> {
|
||||
val tag = "PdfTocFix"
|
||||
Timber.tag(tag).i("Starting Pure Reflection Traversal...")
|
||||
|
||||
return try {
|
||||
// 1. Get the 'document' field (PdfDocumentU) from PdfDocumentKt
|
||||
val documentField = PdfDocumentKt::class.java.getDeclaredField("document").apply { isAccessible = true }
|
||||
val docUInstance = documentField.get(this) ?: return getTableOfContents()
|
||||
|
||||
// 2. Get the 'nativeDocument' field from PdfDocumentU
|
||||
val nativeDocField = docUInstance.javaClass.getDeclaredField("nativeDocument").apply { isAccessible = true }
|
||||
val nativeDocInstance = nativeDocField.get(docUInstance) ?: return getTableOfContents()
|
||||
|
||||
// 3. Get the native pointer (long) from PdfDocumentU
|
||||
val ptrField = docUInstance.javaClass.getDeclaredField("mNativeDocPtr").apply { isAccessible = true }
|
||||
val mNativeDocPtr = ptrField.get(docUInstance) as Long
|
||||
|
||||
// 4. Look up native methods using primitive 'long' types (mandatory for JNI)
|
||||
val nClass = nativeDocInstance.javaClass
|
||||
val lp = Long::class.javaPrimitiveType!! // Shorthand for 'long'
|
||||
|
||||
val getTitleM = nClass.getMethod("getBookmarkTitle", lp)
|
||||
val getDestIdxM = nClass.getMethod("getBookmarkDestIndex", lp, lp)
|
||||
val getFirstChildM = nClass.getMethod("getFirstChildBookmark", lp, lp)
|
||||
val getSiblingM = nClass.getMethod("getSiblingBookmark", lp, lp)
|
||||
|
||||
val topLevel = mutableListOf<Bookmark>()
|
||||
val visited = mutableSetOf<Long>()
|
||||
|
||||
/**
|
||||
* Corrected traversal: Iterative for siblings, recursive for children.
|
||||
*/
|
||||
fun walk(parentList: MutableList<Bookmark>, startPtr: Long, level: Int) {
|
||||
var currentPtr = startPtr
|
||||
var itemIndex = 0
|
||||
|
||||
while (currentPtr != 0L) {
|
||||
if (visited.contains(currentPtr)) break
|
||||
visited.add(currentPtr)
|
||||
|
||||
val title = getTitleM.invoke(nativeDocInstance, currentPtr) as? String ?: "Untitled"
|
||||
val pageIdx = getDestIdxM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
|
||||
|
||||
Timber.tag(tag).v("Lvl $level | Item $itemIndex | Ptr: 0x${java.lang.Long.toHexString(currentPtr)} | $title")
|
||||
|
||||
val bookmark = Bookmark().apply {
|
||||
this.mNativePtr = currentPtr
|
||||
this.title = title
|
||||
this.pageIdx = pageIdx
|
||||
}
|
||||
parentList.add(bookmark)
|
||||
|
||||
// Recursive dive into children
|
||||
val firstChild = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
|
||||
if (firstChild != 0L && level < MAX_FIXED_RECURSION) {
|
||||
walk(bookmark.children, firstChild, level + 1)
|
||||
}
|
||||
|
||||
// Iterative move to next sibling
|
||||
currentPtr = getSiblingM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
|
||||
itemIndex++
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Start from the root (Pass 0L as primitive long)
|
||||
val firstRoot = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, 0L) as Long
|
||||
if (firstRoot != 0L) {
|
||||
walk(topLevel, firstRoot, 0)
|
||||
}
|
||||
|
||||
if (topLevel.isEmpty()) {
|
||||
Timber.tag(tag).w("No items found, falling back to library.")
|
||||
getTableOfContents()
|
||||
} else {
|
||||
Timber.tag(tag).i("TOC Successfully Patched! Nodes: ${visited.size}")
|
||||
topLevel
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(tag).e(e, "Reflection traversal critical error.")
|
||||
this.getTableOfContents()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun flattenToc(bookmarks: List<Bookmark>, level: Int = 0): List<TocEntry> {
|
||||
Timber.tag("PdfTocDebug").d("Processing level $level with ${bookmarks.size} items")
|
||||
val entries = mutableListOf<TocEntry>()
|
||||
for ((index, bookmark) in bookmarks.withIndex()) {
|
||||
val title = bookmark.title ?: "Untitled Chapter"
|
||||
val childCount = bookmark.children.size
|
||||
|
||||
Timber.tag("PdfTocDebug").d(
|
||||
"Lvl $level | Item $index: \"$title\" (Page: ${bookmark.pageIdx}) | Children: $childCount"
|
||||
)
|
||||
|
||||
entries.add(
|
||||
TocEntry(
|
||||
title = title,
|
||||
pageIndex = bookmark.pageIdx.toInt(),
|
||||
nestLevel = level
|
||||
)
|
||||
)
|
||||
|
||||
if (childCount > 0) {
|
||||
Timber.tag("PdfTocDebug").v("Entering children of \"$title\"")
|
||||
entries.addAll(flattenToc(bookmark.children, level + 1))
|
||||
Timber.tag("PdfTocDebug").v("Returned to Lvl $level from \"$title\"")
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
internal fun loadPdfBookmarksFromJson(bookmarksJson: String?): Set<PdfBookmark> {
|
||||
if (bookmarksJson.isNullOrBlank()) return emptySet()
|
||||
return try {
|
||||
val jsonArray = JSONArray(bookmarksJson)
|
||||
(0 until jsonArray.length()).mapNotNull { i ->
|
||||
try {
|
||||
val json = jsonArray.getJSONObject(i)
|
||||
PdfBookmark(
|
||||
pageIndex = json.getInt("pageIndex"),
|
||||
title = json.getString("title"),
|
||||
totalPages = json.getInt("totalPages")
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to parse bookmark from JSON object")
|
||||
null
|
||||
}
|
||||
}.toSet()
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to parse bookmarks from JSON string: $bookmarksJson")
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PdfTocTreeItem(
|
||||
label: String,
|
||||
nestLevel: Int,
|
||||
isExpanded: Boolean,
|
||||
hasChildren: Boolean,
|
||||
isCurrent: Boolean,
|
||||
onToggleExpand: () -> Unit,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = if (isCurrent) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else Color.Transparent,
|
||||
label = "TocItemBackground"
|
||||
)
|
||||
|
||||
val contentColor = if (isCurrent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.background(backgroundColor)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Spacer(modifier = Modifier.width((16 * nestLevel).dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clickable(enabled = hasChildren, onClick = onToggleExpand),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (hasChildren) {
|
||||
Icon(
|
||||
imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = if (isExpanded) "Collapse" else "Expand",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = label,
|
||||
style = if (nestLevel == 0) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (isCurrent) FontWeight.Bold else if (nestLevel == 0) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = contentColor,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f).padding(end = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun PdfNavigationDrawerContent(
|
||||
pdfDocument: ReaderDocument?,
|
||||
flatTableOfContents: List<TocEntry>,
|
||||
bookmarks: Set<PdfBookmark>,
|
||||
userHighlights: List<PdfUserHighlight>,
|
||||
currentPage: Int,
|
||||
totalPages: Int,
|
||||
customHighlightColors: Map<PdfHighlightColor, Color>,
|
||||
onPageSelected: (Int) -> Unit,
|
||||
onRenameBookmark: (PdfBookmark, String) -> Unit,
|
||||
|
|
@ -44,11 +290,15 @@ internal fun PdfNavigationDrawerContent(
|
|||
onNoteRequested: (String?) -> Unit,
|
||||
onCloseDrawer: () -> Unit
|
||||
) {
|
||||
val drawerPagerState = rememberPagerState(pageCount = { 3 })
|
||||
val drawerPagerState = rememberPagerState(pageCount = { 4 })
|
||||
val drawerScope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
TabRow(selectedTabIndex = drawerPagerState.currentPage) {
|
||||
ScrollableTabRow(
|
||||
selectedTabIndex = drawerPagerState.currentPage,
|
||||
edgePadding = 8.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Tab(selected = drawerPagerState.currentPage == 0, onClick = {
|
||||
drawerScope.launch { drawerPagerState.animateScrollToPage(0) }
|
||||
}, text = { Text("Chapters") })
|
||||
|
|
@ -68,6 +318,14 @@ internal fun PdfNavigationDrawerContent(
|
|||
text = { Text("Highlights") },
|
||||
modifier = Modifier.testTag("HighlightsTab")
|
||||
)
|
||||
Tab(
|
||||
selected = drawerPagerState.currentPage == 3,
|
||||
onClick = {
|
||||
drawerScope.launch { drawerPagerState.animateScrollToPage(3) }
|
||||
},
|
||||
text = { Text("Pages") },
|
||||
modifier = Modifier.testTag("PagesTab")
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalPager(
|
||||
|
|
@ -531,6 +789,130 @@ internal fun PdfNavigationDrawerContent(
|
|||
}
|
||||
}
|
||||
}
|
||||
3 -> { // Pages Page
|
||||
val listState = rememberLazyListState()
|
||||
val pageRows = remember(totalPages) { (0 until totalPages).chunked(3) }
|
||||
|
||||
val currentRowIndex = currentPage / 3
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
drawerScope.launch {
|
||||
if (currentRowIndex in pageRows.indices) {
|
||||
listState.animateScrollToItem(currentRowIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Locate")
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(end = 12.dp)
|
||||
) {
|
||||
items(pageRows, key = { it.firstOrNull() ?: 0 }) { row ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
row.forEach { pageIdx ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.aspectRatio(0.707f)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant,
|
||||
RoundedCornerShape(4.dp)
|
||||
)
|
||||
.border(
|
||||
width = if (currentPage == pageIdx) 2.dp else 1.dp,
|
||||
color = if (currentPage == pageIdx) MaterialTheme.colorScheme.primary else Color.Black.copy(alpha = 0.1f),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
)
|
||||
.clickable {
|
||||
onCloseDrawer()
|
||||
onPageSelected(pageIdx)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
var thumb by remember { mutableStateOf(PdfThumbnailCache.get(pageIdx)) }
|
||||
|
||||
LaunchedEffect(pageIdx, pdfDocument) {
|
||||
if (thumb == null && pdfDocument != null) {
|
||||
withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
try {
|
||||
val cached = PdfThumbnailCache.get(pageIdx)
|
||||
if (cached != null) {
|
||||
thumb = cached
|
||||
} else {
|
||||
pdfDocument.openPage(pageIdx)?.use { p ->
|
||||
val w = p.getPageWidthPoint()
|
||||
val h = p.getPageHeightPoint()
|
||||
val ratio = if (h > 0) w.toFloat() / h.toFloat() else 1f
|
||||
val thumbW = 200
|
||||
val thumbH = (thumbW / ratio).toInt().coerceAtLeast(1)
|
||||
val bmp = createBitmap(thumbW, thumbH)
|
||||
bmp.eraseColor(android.graphics.Color.WHITE)
|
||||
p.renderPageBitmap(bmp, 0, 0, thumbW, thumbH, false)
|
||||
PdfThumbnailCache.put(pageIdx, bmp)
|
||||
thumb = bmp
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (thumb != null) {
|
||||
Image(
|
||||
bitmap = thumb!!.asImageBitmap(),
|
||||
contentDescription = "Page ${pageIdx + 1}",
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${pageIdx + 1}",
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(4.dp)
|
||||
.background(
|
||||
Color.Black.copy(alpha = 0.5f),
|
||||
RoundedCornerShape(6.dp)
|
||||
)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
repeat(3 - row.size) { Spacer(modifier = Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalScrollbar(
|
||||
listState = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -239,15 +239,45 @@ internal fun BookmarkButton(
|
|||
}
|
||||
|
||||
@Composable
|
||||
internal fun ZoomPercentageIndicator(percentage: Int) {
|
||||
internal fun ZoomPercentageIndicator(
|
||||
percentage: Int,
|
||||
onResetZoomClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.8f)
|
||||
) {
|
||||
Text(
|
||||
text = "$percentage%",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
androidx.compose.foundation.layout.Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "$percentage%",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// Divider
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(1.dp)
|
||||
.height(16.dp)
|
||||
.background(Color.White.copy(alpha = 0.5f))
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// Reset Zoom Button
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.zoom_out),
|
||||
contentDescription = "Reset Zoom",
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onResetZoomClick)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ enum class PdfReaderTool(val title: String, val category: String) {
|
|||
THEME("Theme Settings", "Top Bar"),
|
||||
LOCK_PANNING("Lock Panning", "Top Bar"),
|
||||
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
|
||||
TAP_TO_TURN("Tap to Turn Pages", "Overflow Menu"),
|
||||
FULL_SCREEN("Full Screen", "Top Bar"),
|
||||
SLIDER("Navigation Slider", "Bottom Bar"),
|
||||
TOC("Sidebar", "Bottom Bar"),
|
||||
|
|
@ -370,4 +371,4 @@ internal fun savePdfDarkMode(context: Context, isDark: Boolean) {
|
|||
internal fun loadPdfDarkMode(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getBoolean(PDF_DARK_MODE_KEY, false)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,229 +0,0 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.legere.pdfiumandroid.api.Bookmark
|
||||
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
|
||||
import org.json.JSONArray
|
||||
import timber.log.Timber
|
||||
|
||||
private const val MAX_FIXED_RECURSION = 128
|
||||
|
||||
internal data class PdfBookmark(val pageIndex: Int, val title: String, val totalPages: Int)
|
||||
|
||||
internal data class TocEntry(val title: String, val pageIndex: Int, val nestLevel: Int)
|
||||
|
||||
/**
|
||||
* Patches the library bug where siblings are truncated due to depth-state leakage.
|
||||
*/
|
||||
suspend fun PdfDocumentKt.getFixedTableOfContents(): List<Bookmark> {
|
||||
val tag = "PdfTocFix"
|
||||
Timber.tag(tag).i("Starting Pure Reflection Traversal...")
|
||||
|
||||
return try {
|
||||
// 1. Get the 'document' field (PdfDocumentU) from PdfDocumentKt
|
||||
val documentField = PdfDocumentKt::class.java.getDeclaredField("document").apply { isAccessible = true }
|
||||
val docUInstance = documentField.get(this) ?: return getTableOfContents()
|
||||
|
||||
// 2. Get the 'nativeDocument' field from PdfDocumentU
|
||||
val nativeDocField = docUInstance.javaClass.getDeclaredField("nativeDocument").apply { isAccessible = true }
|
||||
val nativeDocInstance = nativeDocField.get(docUInstance) ?: return getTableOfContents()
|
||||
|
||||
// 3. Get the native pointer (long) from PdfDocumentU
|
||||
val ptrField = docUInstance.javaClass.getDeclaredField("mNativeDocPtr").apply { isAccessible = true }
|
||||
val mNativeDocPtr = ptrField.get(docUInstance) as Long
|
||||
|
||||
// 4. Look up native methods using primitive 'long' types (mandatory for JNI)
|
||||
val nClass = nativeDocInstance.javaClass
|
||||
val lp = Long::class.javaPrimitiveType!! // Shorthand for 'long'
|
||||
|
||||
val getTitleM = nClass.getMethod("getBookmarkTitle", lp)
|
||||
val getDestIdxM = nClass.getMethod("getBookmarkDestIndex", lp, lp)
|
||||
val getFirstChildM = nClass.getMethod("getFirstChildBookmark", lp, lp)
|
||||
val getSiblingM = nClass.getMethod("getSiblingBookmark", lp, lp)
|
||||
|
||||
val topLevel = mutableListOf<Bookmark>()
|
||||
val visited = mutableSetOf<Long>()
|
||||
|
||||
/**
|
||||
* Corrected traversal: Iterative for siblings, recursive for children.
|
||||
*/
|
||||
fun walk(parentList: MutableList<Bookmark>, startPtr: Long, level: Int) {
|
||||
var currentPtr = startPtr
|
||||
var itemIndex = 0
|
||||
|
||||
while (currentPtr != 0L) {
|
||||
if (visited.contains(currentPtr)) break
|
||||
visited.add(currentPtr)
|
||||
|
||||
val title = getTitleM.invoke(nativeDocInstance, currentPtr) as? String ?: "Untitled"
|
||||
val pageIdx = getDestIdxM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
|
||||
|
||||
Timber.tag(tag).v("Lvl $level | Item $itemIndex | Ptr: 0x${java.lang.Long.toHexString(currentPtr)} | $title")
|
||||
|
||||
val bookmark = Bookmark().apply {
|
||||
this.mNativePtr = currentPtr
|
||||
this.title = title
|
||||
this.pageIdx = pageIdx
|
||||
}
|
||||
parentList.add(bookmark)
|
||||
|
||||
// Recursive dive into children
|
||||
val firstChild = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
|
||||
if (firstChild != 0L && level < MAX_FIXED_RECURSION) {
|
||||
walk(bookmark.children, firstChild, level + 1)
|
||||
}
|
||||
|
||||
// Iterative move to next sibling
|
||||
currentPtr = getSiblingM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
|
||||
itemIndex++
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Start from the root (Pass 0L as primitive long)
|
||||
val firstRoot = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, 0L) as Long
|
||||
if (firstRoot != 0L) {
|
||||
walk(topLevel, firstRoot, 0)
|
||||
}
|
||||
|
||||
if (topLevel.isEmpty()) {
|
||||
Timber.tag(tag).w("No items found, falling back to library.")
|
||||
getTableOfContents()
|
||||
} else {
|
||||
Timber.tag(tag).i("TOC Successfully Patched! Nodes: ${visited.size}")
|
||||
topLevel
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(tag).e(e, "Reflection traversal critical error.")
|
||||
this.getTableOfContents()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun flattenToc(bookmarks: List<Bookmark>, level: Int = 0): List<TocEntry> {
|
||||
Timber.tag("PdfTocDebug").d("Processing level $level with ${bookmarks.size} items")
|
||||
val entries = mutableListOf<TocEntry>()
|
||||
for ((index, bookmark) in bookmarks.withIndex()) {
|
||||
val title = bookmark.title ?: "Untitled Chapter"
|
||||
val childCount = bookmark.children.size
|
||||
|
||||
Timber.tag("PdfTocDebug").d(
|
||||
"Lvl $level | Item $index: \"$title\" (Page: ${bookmark.pageIdx}) | Children: $childCount"
|
||||
)
|
||||
|
||||
entries.add(
|
||||
TocEntry(
|
||||
title = title,
|
||||
pageIndex = bookmark.pageIdx.toInt(),
|
||||
nestLevel = level
|
||||
)
|
||||
)
|
||||
|
||||
if (childCount > 0) {
|
||||
Timber.tag("PdfTocDebug").v("Entering children of \"$title\"")
|
||||
entries.addAll(flattenToc(bookmark.children, level + 1))
|
||||
Timber.tag("PdfTocDebug").v("Returned to Lvl $level from \"$title\"")
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
internal fun loadPdfBookmarksFromJson(bookmarksJson: String?): Set<PdfBookmark> {
|
||||
if (bookmarksJson.isNullOrBlank()) return emptySet()
|
||||
return try {
|
||||
val jsonArray = JSONArray(bookmarksJson)
|
||||
(0 until jsonArray.length()).mapNotNull { i ->
|
||||
try {
|
||||
val json = jsonArray.getJSONObject(i)
|
||||
PdfBookmark(
|
||||
pageIndex = json.getInt("pageIndex"),
|
||||
title = json.getString("title"),
|
||||
totalPages = json.getInt("totalPages")
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to parse bookmark from JSON object")
|
||||
null
|
||||
}
|
||||
}.toSet()
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to parse bookmarks from JSON string: $bookmarksJson")
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PdfTocTreeItem(
|
||||
label: String,
|
||||
nestLevel: Int,
|
||||
isExpanded: Boolean,
|
||||
hasChildren: Boolean,
|
||||
isCurrent: Boolean,
|
||||
onToggleExpand: () -> Unit,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = if (isCurrent) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else Color.Transparent,
|
||||
label = "TocItemBackground"
|
||||
)
|
||||
|
||||
val contentColor = if (isCurrent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.background(backgroundColor)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Spacer(modifier = Modifier.width((16 * nestLevel).dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clickable(enabled = hasChildren, onClick = onToggleExpand),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (hasChildren) {
|
||||
Icon(
|
||||
imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = if (isExpanded) "Collapse" else "Expand",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = label,
|
||||
style = if (nestLevel == 0) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (isCurrent) FontWeight.Bold else if (nestLevel == 0) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = contentColor,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f).padding(end = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import androidx.compose.foundation.rememberScrollState
|
|||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Undo
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
|
|
@ -33,6 +34,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aryan.reader.BuildConfig
|
||||
import com.aryan.reader.FileType
|
||||
import com.aryan.reader.R
|
||||
|
|
@ -81,6 +83,8 @@ internal fun PdfTopBar(
|
|||
onShowCustomizeTools: () -> Unit,
|
||||
onShowOcrLanguage: () -> Unit,
|
||||
onShowVisualOptions: () -> Unit,
|
||||
tapToNavigateEnabled: Boolean,
|
||||
onToggleTapToNavigate: () -> Unit,
|
||||
onChangeDisplayMode: (DisplayMode) -> Unit,
|
||||
onToggleKeepScreenOn: () -> Unit,
|
||||
onStartAutoScroll: () -> Unit,
|
||||
|
|
@ -94,7 +98,8 @@ internal fun PdfTopBar(
|
|||
onPrint: () -> Unit,
|
||||
onTabClick: (String) -> Unit,
|
||||
onTabClose: (String) -> Unit,
|
||||
onNewTabClick: () -> Unit
|
||||
onNewTabClick: () -> Unit,
|
||||
onGenerateDemoAnnotations: () -> Unit
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = showStandardBars,
|
||||
|
|
@ -179,6 +184,9 @@ internal fun PdfTopBar(
|
|||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
TooltipIconButton(text = "Demo Annotations", onClick = onGenerateDemoAnnotations) {
|
||||
Icon(Icons.Default.BugReport, contentDescription = "Generate Demo Annotations", tint = MaterialTheme.colorScheme.secondary)
|
||||
}
|
||||
TooltipIconButton(text = stringResource(R.string.pen_playground), onClick = onShowPenPlayground) {
|
||||
Icon(Icons.Default.Star, contentDescription = "Open Pen Playground", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
|
|
@ -238,6 +246,26 @@ internal fun PdfTopBar(
|
|||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.TAP_TO_TURN.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) },
|
||||
enabled = displayMode == DisplayMode.PAGINATION,
|
||||
onClick = {
|
||||
onToggleTapToNavigate()
|
||||
showMoreMenu = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (tapToNavigateEnabled) {
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
contentDescription = stringResource(R.string.content_desc_enabled)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.KEEP_SCREEN_ON.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_keep_screen_on)) },
|
||||
|
|
@ -433,13 +461,17 @@ fun PdfBottomBar(
|
|||
isEditMode: Boolean,
|
||||
isTtsSessionActive: Boolean,
|
||||
ttsErrorMessage: String?,
|
||||
jumpBackPage: Int?,
|
||||
onJumpBack: () -> Unit,
|
||||
onShowSlider: () -> Unit,
|
||||
onShowToc: () -> Unit,
|
||||
onSearchClick: () -> Unit,
|
||||
onToggleHighlights: () -> Unit,
|
||||
onShowAiHub: () -> Unit,
|
||||
onToggleEditMode: () -> Unit,
|
||||
onToggleTts: () -> Unit
|
||||
onToggleTts: () -> Unit,
|
||||
isBubbleZoomModeActive: Boolean,
|
||||
onToggleBubbleZoom: () -> Unit
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = showStandardBars && !searchStateActive,
|
||||
|
|
@ -456,8 +488,35 @@ fun PdfBottomBar(
|
|||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = bottomBarPadding).height(56.dp).padding(horizontal = 8.dp).horizontalScroll(bottomBarScrollState),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
if (jumpBackPage != null) {
|
||||
TooltipIconButton(
|
||||
text = "Jump Back to Page ${jumpBackPage + 1}",
|
||||
description = "Return to previous page",
|
||||
onClick = onJumpBack
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Undo,
|
||||
contentDescription = "Jump Back",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
text = "${jumpBackPage + 1}",
|
||||
fontSize = 10.sp,
|
||||
lineHeight = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.SLIDER.name)) {
|
||||
TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_slider),
|
||||
|
|
@ -532,10 +591,24 @@ fun PdfBottomBar(
|
|||
}
|
||||
}
|
||||
|
||||
if (BuildConfig.FLAVOR != "oss") {
|
||||
TooltipIconButton(
|
||||
text = if (isBubbleZoomModeActive) "Exit Smart Zoom" else "Smart Comic Zoom",
|
||||
description = "Toggle Smart Comic Zoom",
|
||||
onClick = onToggleBubbleZoom
|
||||
) {
|
||||
Icon(
|
||||
painterResource(R.drawable.comic_bubble),
|
||||
contentDescription = "Smart Comic Zoom",
|
||||
tint = if (isBubbleZoomModeActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ttsErrorMessage?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f).padding(start = 8.dp), maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
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
|
||||
|
|
@ -90,6 +91,9 @@ 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
|
||||
|
|
@ -108,6 +112,7 @@ 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
|
||||
|
|
@ -217,8 +222,8 @@ internal fun PdfVerticalReader(
|
|||
isEditMode: Boolean = false,
|
||||
allAnnotations: () -> Map<Int, List<PdfAnnotation>> = { emptyMap() },
|
||||
drawingState: PdfDrawingState,
|
||||
onDrawStart: (Int, PdfPoint) -> Unit,
|
||||
onDraw: (Int, PdfPoint) -> Unit,
|
||||
onDrawStart: (Int, PdfPoint, Boolean) -> Unit,
|
||||
onDraw: (Int, PdfPoint, Boolean) -> Unit,
|
||||
onDrawEnd: () -> Unit,
|
||||
onOcrModelDownloading: () -> Unit = {},
|
||||
selectedTool: InkType,
|
||||
|
|
@ -247,7 +252,10 @@ internal fun PdfVerticalReader(
|
|||
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
|
||||
onPaletteClick: () -> Unit = {},
|
||||
lockedState: Triple<Float, Float, Float>? = null,
|
||||
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = 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) {
|
||||
|
|
@ -260,6 +268,7 @@ internal fun PdfVerticalReader(
|
|||
}
|
||||
}
|
||||
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
|
||||
|
|
@ -393,18 +402,17 @@ internal fun PdfVerticalReader(
|
|||
|
||||
val zoomedDocHeight = totalDocHeight * savedScale
|
||||
val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
|
||||
val maxPanY = headerHeightPx
|
||||
|
||||
zoomAnimatable.stop()
|
||||
panXAnimatable.stop()
|
||||
panYAnimatable.stop()
|
||||
|
||||
panXAnimatable.updateBounds(minPanX, maxPanX)
|
||||
panYAnimatable.updateBounds(minPanY, maxPanY)
|
||||
panYAnimatable.updateBounds(minPanY, headerHeightPx)
|
||||
|
||||
zoomAnimatable.snapTo(savedScale)
|
||||
panXAnimatable.snapTo(savedPanX)
|
||||
panYAnimatable.snapTo(savedPanY.coerceIn(minPanY, maxPanY))
|
||||
panYAnimatable.snapTo(savedPanY.coerceIn(minPanY, headerHeightPx))
|
||||
|
||||
Timber.tag("PdfLockDiagnostic").d("RESTORE SNAP COMPLETE: Scale=${zoomAnimatable.value}, X=${panXAnimatable.value}, Y=${panYAnimatable.value}")
|
||||
|
||||
|
|
@ -492,6 +500,65 @@ internal fun PdfVerticalReader(
|
|||
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
|
||||
) {
|
||||
|
|
@ -920,6 +987,15 @@ internal fun PdfVerticalReader(
|
|||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
val buttons = currentEvent.buttons
|
||||
Timber.tag("StylusEraserDiagnostic").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
|
||||
|
|
@ -943,14 +1019,14 @@ internal fun PdfVerticalReader(
|
|||
var isCanceled = false
|
||||
|
||||
try {
|
||||
if (selectedTool == InkType.ERASER) {
|
||||
if (selectedTool == InkType.ERASER || isEraserOverride) {
|
||||
globalEraserPosition = down.position
|
||||
}
|
||||
|
||||
val startData = getPageAndPoint(down.position)
|
||||
if (startData != null) {
|
||||
val (pageIndex, point) = startData
|
||||
onDrawStart(pageIndex, point)
|
||||
onDrawStart(pageIndex, point, isEraserOverride)
|
||||
down.consume()
|
||||
}
|
||||
|
||||
|
|
@ -969,7 +1045,7 @@ internal fun PdfVerticalReader(
|
|||
if (change == null || !change.pressed) break
|
||||
|
||||
if (change.positionChanged()) {
|
||||
if (selectedTool == InkType.ERASER) {
|
||||
if (selectedTool == InkType.ERASER || isEraserOverride) {
|
||||
globalEraserPosition = change.position
|
||||
}
|
||||
|
||||
|
|
@ -977,11 +1053,11 @@ internal fun PdfVerticalReader(
|
|||
if (dragData != null) {
|
||||
val (pageIndex, point) = dragData
|
||||
|
||||
if (pageIndex != lastPageIndex && selectedTool != InkType.ERASER) {
|
||||
if (pageIndex != lastPageIndex && selectedTool != InkType.ERASER && !isEraserOverride) {
|
||||
onDrawEnd()
|
||||
onDrawStart(pageIndex, point)
|
||||
onDrawStart(pageIndex, point, isEraserOverride)
|
||||
} else {
|
||||
onDraw(pageIndex, point)
|
||||
onDraw(pageIndex, point, isEraserOverride)
|
||||
}
|
||||
lastPageIndex = pageIndex
|
||||
}
|
||||
|
|
@ -993,6 +1069,7 @@ internal fun PdfVerticalReader(
|
|||
onDrawEnd()
|
||||
}
|
||||
globalEraserPosition = null
|
||||
isStylusEraserOverride = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1472,16 +1549,20 @@ internal fun PdfVerticalReader(
|
|||
}
|
||||
|
||||
val onDrawStartLambda = remember(page.index, onDrawStart) {
|
||||
{ point: PdfPoint -> onDrawStart(page.index, point) }
|
||||
{ point: PdfPoint, isEraserOverride: Boolean ->
|
||||
onDrawStart(page.index, point, isEraserOverride)
|
||||
}
|
||||
}
|
||||
|
||||
val currentOnDraw by rememberUpdatedState(onDraw)
|
||||
val onDrawLambda = remember(page.index) {
|
||||
{ point: PdfPoint -> currentOnDraw(page.index, point) }
|
||||
{ point: PdfPoint, isEraserOverride: Boolean ->
|
||||
currentOnDraw(page.index, point, isEraserOverride)
|
||||
}
|
||||
}
|
||||
|
||||
val onSingleTapLambda = remember(onPageClick) {
|
||||
{
|
||||
{ _: Offset? ->
|
||||
selectionClearTrigger++
|
||||
onPageClick()
|
||||
}
|
||||
|
|
@ -1759,7 +1840,9 @@ internal fun PdfVerticalReader(
|
|||
draggingBoxId = null
|
||||
}
|
||||
},
|
||||
draggingBoxId = draggingBoxId
|
||||
draggingBoxId = draggingBoxId,
|
||||
isBubbleZoomModeActive = isBubbleZoomModeActive,
|
||||
onDetectBubbles = onDetectBubbles
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1875,6 +1958,7 @@ internal fun PdfVerticalReader(
|
|||
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)
|
||||
|
|
@ -1934,7 +2018,7 @@ internal fun PdfVerticalReader(
|
|||
.alpha(scrollbarAlpha)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
AnimatedVisibility(
|
||||
visible = isDraggingScrollbar,
|
||||
visible = isDraggingScrollbar && totalPages > 0,
|
||||
enter = fadeIn() + androidx.compose.animation.slideInHorizontally {
|
||||
it / 2
|
||||
},
|
||||
|
|
@ -1948,7 +2032,7 @@ internal fun PdfVerticalReader(
|
|||
modifier = Modifier.padding(end = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${state.currentPage + 1}/${totalPages}",
|
||||
text = "${safeCurrentPage + 1}/$totalPages",
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontSize = 16.sp, fontWeight = FontWeight.Bold
|
||||
),
|
||||
|
|
@ -2060,7 +2144,7 @@ internal fun PdfVerticalReader(
|
|||
}
|
||||
}
|
||||
|
||||
if (isEditMode && selectedTool == InkType.ERASER && globalEraserPosition != null) {
|
||||
if (isEditMode && (selectedTool == InkType.ERASER || isStylusEraserOverride) && globalEraserPosition != null) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val pos = globalEraserPosition!!
|
||||
val radiusPx = if (activeToolThickness > 0f) {
|
||||
|
|
@ -2127,4 +2211,4 @@ internal fun PdfVerticalReader(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Undo
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
|
|
@ -209,11 +210,14 @@ import com.aryan.reader.SearchResult
|
|||
import com.aryan.reader.SummarizationResult
|
||||
import com.aryan.reader.SummaryCacheManager
|
||||
import com.aryan.reader.TtsSettingsSheet
|
||||
import com.aryan.reader.ml.SpeechBubble
|
||||
import com.aryan.reader.epubreader.AutoScrollControls
|
||||
import com.aryan.reader.epubreader.DictionarySettingsDialog
|
||||
import com.aryan.reader.epubreader.ExternalDictionaryHelper
|
||||
import com.aryan.reader.epubreader.SystemUiMode
|
||||
import com.aryan.reader.epubreader.TtsOverlayControls
|
||||
import com.aryan.reader.epubreader.loadTapToNavigateSetting
|
||||
import com.aryan.reader.epubreader.saveTapToNavigateSetting
|
||||
import com.aryan.reader.fetchAiDefinition
|
||||
import com.aryan.reader.loadCustomThemes
|
||||
import com.aryan.reader.paginatedreader.TtsChunk
|
||||
|
|
@ -252,6 +256,7 @@ import org.json.JSONObject
|
|||
import timber.log.Timber
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.util.LinkedHashSet
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlin.math.PI
|
||||
|
|
@ -290,6 +295,7 @@ fun PdfViewerScreen(
|
|||
val focusManager = LocalFocusManager.current
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
var displayMode by remember { mutableStateOf(loadDisplayMode(context)) }
|
||||
var tapToNavigateEnabled by remember { mutableStateOf(loadTapToNavigateSetting(context)) }
|
||||
var showThemePanel by remember { mutableStateOf(false) }
|
||||
var currentThemeId by remember { mutableStateOf(loadPdfThemeId(context)) }
|
||||
var excludeImages by remember { mutableStateOf(com.aryan.reader.loadExcludeImages(context)) }
|
||||
|
|
@ -335,9 +341,11 @@ fun PdfViewerScreen(
|
|||
savePdfHiddenTools(context, newSet)
|
||||
}
|
||||
|
||||
val isOss = BuildConfig.FLAVOR == "oss"
|
||||
|
||||
val executeWithOcrCheck = remember(hasSelectedOcrLanguage) {
|
||||
{ action: () -> Unit ->
|
||||
if (hasSelectedOcrLanguage) {
|
||||
if (isOss || hasSelectedOcrLanguage) {
|
||||
action()
|
||||
} else {
|
||||
pendingActionAfterOcrSelection = action
|
||||
|
|
@ -584,6 +592,9 @@ fun PdfViewerScreen(
|
|||
var customHighlightColors by remember { mutableStateOf(loadCustomHighlightColors(context)) }
|
||||
var showHighlightColorPicker by remember { mutableStateOf(false) }
|
||||
var highlightColorPickerInitialSlot by remember { mutableStateOf(PdfHighlightColor.YELLOW) }
|
||||
var isBubbleZoomModeActive by remember { mutableStateOf(false) }
|
||||
var showBubbleZoomDownloadDialog by remember { mutableStateOf(false) }
|
||||
val bubbleZoomDownloadProgress by viewModel.speechBubbleModelDownloadProgress.collectAsState()
|
||||
|
||||
var dockLocation by remember { mutableStateOf(initialDockLocation) }
|
||||
var dockOffset by remember { mutableStateOf(initialDockOffset) }
|
||||
|
|
@ -655,22 +666,11 @@ fun PdfViewerScreen(
|
|||
snapPreviewLocation,
|
||||
isEditMode,
|
||||
isDockDragging,
|
||||
showStandardBars,
|
||||
systemUiMode,
|
||||
statusBarHeightDp
|
||||
) {
|
||||
if (!isEditMode) {
|
||||
var h = 0.dp
|
||||
if (showStandardBars) {
|
||||
h += 56.dp
|
||||
}
|
||||
|
||||
val isStatusBarVisible = systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)
|
||||
|
||||
if (isStatusBarVisible) {
|
||||
h += statusBarHeightDp
|
||||
}
|
||||
h
|
||||
0.dp
|
||||
} else {
|
||||
val isStickyTop = dockLocation == DockLocation.TOP && !isDockDragging
|
||||
val isPreviewingTop = snapPreviewLocation == DockLocation.TOP
|
||||
|
|
@ -686,6 +686,31 @@ fun PdfViewerScreen(
|
|||
label = "verticalHeaderHeight"
|
||||
)
|
||||
|
||||
val targetTopOverlayInset = remember(
|
||||
showStandardBars,
|
||||
systemUiMode,
|
||||
statusBarHeightDp
|
||||
) {
|
||||
if (!showStandardBars) {
|
||||
0.dp
|
||||
} else {
|
||||
var inset = 56.dp
|
||||
val isStatusBarVisible =
|
||||
systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)
|
||||
|
||||
if (isStatusBarVisible) {
|
||||
inset += statusBarHeightDp
|
||||
}
|
||||
inset
|
||||
}
|
||||
}
|
||||
|
||||
val topOverlayInset by animateDpAsState(
|
||||
targetValue = targetTopOverlayInset,
|
||||
animationSpec = tween(durationMillis = 200),
|
||||
label = "topOverlayInset"
|
||||
)
|
||||
|
||||
val verticalFooterHeight by remember(
|
||||
dockLocation,
|
||||
snapPreviewLocation,
|
||||
|
|
@ -820,9 +845,155 @@ fun PdfViewerScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
var isDocumentReady by remember { mutableStateOf(false) }
|
||||
|
||||
suspend fun renderSpeechBubblePrefetchBitmap(
|
||||
document: ReaderDocument,
|
||||
sourcePageIndex: Int
|
||||
): Bitmap? = withContext(Dispatchers.IO) {
|
||||
document.openPage(sourcePageIndex)?.use { page ->
|
||||
val pageWidth = page.getPageWidthPoint()
|
||||
val pageHeight = page.getPageHeightPoint()
|
||||
if (pageWidth <= 0 || pageHeight <= 0) {
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val longEdge = max(pageWidth, pageHeight).toFloat()
|
||||
val targetLongEdge = when (document) {
|
||||
is PdfDocumentWrapper -> 1600f.coerceAtLeast(longEdge)
|
||||
else -> min(longEdge, 1600f)
|
||||
}
|
||||
val renderScale = (targetLongEdge / longEdge).coerceAtLeast(1f)
|
||||
val renderWidth = (pageWidth * renderScale).roundToInt().coerceAtLeast(1)
|
||||
val renderHeight = (pageHeight * renderScale).roundToInt().coerceAtLeast(1)
|
||||
val renderBitmap = Bitmap.createBitmap(renderWidth, renderHeight, Bitmap.Config.ARGB_8888)
|
||||
|
||||
try {
|
||||
page.renderPageBitmap(
|
||||
bitmap = renderBitmap,
|
||||
startX = 0,
|
||||
startY = 0,
|
||||
drawSizeX = renderWidth,
|
||||
drawSizeY = renderHeight,
|
||||
renderAnnot = true
|
||||
)
|
||||
renderBitmap
|
||||
} catch (t: Throwable) {
|
||||
renderBitmap.recycle()
|
||||
Timber.tag("BubbleZoom").w(t, "Failed to render bubble prefetch bitmap for page $sourcePageIndex")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun buildSpeechBubblePrefetchOrder(): List<Int> {
|
||||
if (totalDisplayPages <= 0) return emptyList()
|
||||
val ordered = LinkedHashSet<Int>()
|
||||
ordered += currentPage.coerceIn(0, totalDisplayPages - 1)
|
||||
for (distance in 1 until totalDisplayPages) {
|
||||
val next = currentPage + distance
|
||||
val previous = currentPage - distance
|
||||
if (next in 0 until totalDisplayPages) ordered += next
|
||||
if (previous in 0 until totalDisplayPages) ordered += previous
|
||||
}
|
||||
return ordered.toList()
|
||||
}
|
||||
|
||||
suspend fun detectSpeechBubblesForPage(
|
||||
sourcePageIndex: Int,
|
||||
fallbackBitmap: Bitmap,
|
||||
allowHighQualityFallback: Boolean = true
|
||||
): List<SpeechBubble> {
|
||||
val document = pdfDocument
|
||||
val shouldUsePrefetchBitmap =
|
||||
allowHighQualityFallback &&
|
||||
document != null &&
|
||||
!viewModel.hasCachedSpeechBubbles(bookId, sourcePageIndex)
|
||||
val detectionBitmap = if (shouldUsePrefetchBitmap) {
|
||||
renderSpeechBubblePrefetchBitmap(document!!, sourcePageIndex) ?: fallbackBitmap
|
||||
} else {
|
||||
fallbackBitmap
|
||||
}
|
||||
val ownsBitmap = detectionBitmap !== fallbackBitmap
|
||||
|
||||
return try {
|
||||
val detected = viewModel.detectSpeechBubblesCached(
|
||||
documentId = bookId,
|
||||
pageIndex = sourcePageIndex,
|
||||
bitmap = detectionBitmap,
|
||||
context = context
|
||||
)
|
||||
if (ownsBitmap) {
|
||||
viewModel.detectSpeechBubblesCached(
|
||||
documentId = bookId,
|
||||
pageIndex = sourcePageIndex,
|
||||
bitmap = fallbackBitmap,
|
||||
context = context
|
||||
)
|
||||
} else {
|
||||
detected
|
||||
}
|
||||
} finally {
|
||||
if (ownsBitmap && !detectionBitmap.isRecycled) {
|
||||
detectionBitmap.recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
isBubbleZoomModeActive,
|
||||
isDocumentReady,
|
||||
pdfDocument,
|
||||
bookId,
|
||||
currentPage,
|
||||
totalDisplayPages,
|
||||
virtualPages
|
||||
) {
|
||||
val document = pdfDocument ?: return@LaunchedEffect
|
||||
if (!isBubbleZoomModeActive || !isDocumentReady || totalDisplayPages <= 0) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
for (displayPageIndex in buildSpeechBubblePrefetchOrder()) {
|
||||
if (!isActive) break
|
||||
|
||||
val sourcePageIndex = when (val virtualPage = virtualPages.getOrNull(displayPageIndex)) {
|
||||
is VirtualPage.PdfPage -> virtualPage.pdfIndex
|
||||
null -> displayPageIndex
|
||||
else -> continue
|
||||
}
|
||||
|
||||
if (viewModel.hasCachedSpeechBubbles(bookId, sourcePageIndex)) {
|
||||
continue
|
||||
}
|
||||
|
||||
val prefetchBitmap = renderSpeechBubblePrefetchBitmap(document, sourcePageIndex) ?: continue
|
||||
try {
|
||||
detectSpeechBubblesForPage(
|
||||
sourcePageIndex = sourcePageIndex,
|
||||
fallbackBitmap = prefetchBitmap,
|
||||
allowHighQualityFallback = false
|
||||
)
|
||||
} finally {
|
||||
if (!prefetchBitmap.isRecycled) {
|
||||
prefetchBitmap.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
kotlinx.coroutines.yield()
|
||||
}
|
||||
}
|
||||
|
||||
val jumpHistory = remember { mutableStateListOf<Int>() }
|
||||
var showJumpPill by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(showJumpPill, jumpHistory.size) {
|
||||
if (showJumpPill && jumpHistory.isNotEmpty()) {
|
||||
delay(4000)
|
||||
showJumpPill = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(currentPage, isDocumentReady, totalPages, initialScrollDone) {
|
||||
if (isDocumentReady && totalPages > 0) {
|
||||
if (initialScrollDone) {
|
||||
|
|
@ -989,6 +1160,7 @@ fun PdfViewerScreen(
|
|||
var isLoadingDocument by remember { mutableStateOf(true) }
|
||||
|
||||
var selectionClearTrigger by remember { mutableLongStateOf(0L) }
|
||||
var resetZoomTrigger by remember { mutableLongStateOf(0L) }
|
||||
|
||||
val displayPageRatios by remember(pageAspectRatios, virtualPages) {
|
||||
derivedStateOf {
|
||||
|
|
@ -1879,7 +2051,6 @@ fun PdfViewerScreen(
|
|||
val onDictionaryLookupStable = remember(executeWithOcrCheck, useOnlineDictionary, selectedDictPackage, uiState.credits, isProUser) {
|
||||
{ text: String ->
|
||||
executeWithOcrCheck {
|
||||
val isOss = BuildConfig.FLAVOR == "oss"
|
||||
val effectiveUseOnline = !isOss && useOnlineDictionary
|
||||
|
||||
if (effectiveUseOnline) {
|
||||
|
|
@ -1954,6 +2125,14 @@ fun PdfViewerScreen(
|
|||
{ targetPage: Int ->
|
||||
coroutineScope.launch {
|
||||
if (targetPage in 0 until totalPages) {
|
||||
val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
|
||||
|
||||
if (current != targetPage) {
|
||||
if (jumpHistory.size > 20) jumpHistory.removeAt(0)
|
||||
jumpHistory.add(current)
|
||||
showJumpPill = true
|
||||
}
|
||||
|
||||
if (displayMode == DisplayMode.PAGINATION) {
|
||||
pagerState.animateScrollToPage(targetPage)
|
||||
} else {
|
||||
|
|
@ -2700,19 +2879,8 @@ fun PdfViewerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(pagerState.isScrollInProgress) {
|
||||
if (pagerState.isScrollInProgress && showBars) {
|
||||
showBars = false
|
||||
Timber.d("Pager scroll detected, hiding bars.")
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(pagerState.isScrollInProgress) {
|
||||
if (pagerState.isScrollInProgress) {
|
||||
if (showBars) {
|
||||
showBars = false
|
||||
Timber.d("Pager scroll detected, hiding bars.")
|
||||
}
|
||||
if (displayMode == DisplayMode.PAGINATION && !isAutoPagingForTts && (ttsState.isPlaying || ttsState.isLoading)) {
|
||||
ttsController.stop()
|
||||
}
|
||||
|
|
@ -2986,6 +3154,14 @@ fun PdfViewerScreen(
|
|||
val onInternalLinkNav: (Int) -> Unit = { targetPage ->
|
||||
coroutineScope.launch {
|
||||
if (targetPage in 0 until totalPages) {
|
||||
val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
|
||||
|
||||
if (current != targetPage) {
|
||||
if (jumpHistory.size > 20) jumpHistory.removeAt(0)
|
||||
jumpHistory.add(current)
|
||||
showJumpPill = true
|
||||
}
|
||||
|
||||
if (displayMode == DisplayMode.PAGINATION) {
|
||||
pagerState.animateScrollToPage(targetPage)
|
||||
} else {
|
||||
|
|
@ -3014,16 +3190,24 @@ fun PdfViewerScreen(
|
|||
|
||||
fun navigateToPdfSearchResult(result: SearchResult) {
|
||||
currentPdfSearchResult = result
|
||||
|
||||
searchHighlightTarget = result
|
||||
|
||||
coroutineScope.launch {
|
||||
val targetPage = result.locationInSource
|
||||
val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
|
||||
|
||||
if (current != targetPage) {
|
||||
if (jumpHistory.size > 20) jumpHistory.removeAt(0)
|
||||
jumpHistory.add(current)
|
||||
showJumpPill = true
|
||||
}
|
||||
|
||||
if (displayMode == DisplayMode.PAGINATION) {
|
||||
if (pagerState.currentPage != result.locationInSource) {
|
||||
pagerState.scrollToPage(result.locationInSource)
|
||||
if (pagerState.currentPage != targetPage) {
|
||||
pagerState.scrollToPage(targetPage)
|
||||
}
|
||||
} else {
|
||||
verticalReaderState.scrollToPage(result.locationInSource)
|
||||
verticalReaderState.scrollToPage(targetPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3092,13 +3276,23 @@ fun PdfViewerScreen(
|
|||
drawerState = drawerState, gesturesEnabled = drawerState.isOpen, drawerContent = {
|
||||
ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) {
|
||||
PdfNavigationDrawerContent(
|
||||
pdfDocument = pdfDocument,
|
||||
flatTableOfContents = flatTableOfContents,
|
||||
bookmarks = bookmarks,
|
||||
userHighlights = userHighlights,
|
||||
currentPage = currentPage,
|
||||
totalPages = totalDisplayPages,
|
||||
customHighlightColors = customHighlightColors,
|
||||
onPageSelected = { targetPage ->
|
||||
coroutineScope.launch {
|
||||
val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
|
||||
|
||||
if (current != targetPage) {
|
||||
if (jumpHistory.size > 20) jumpHistory.removeAt(0)
|
||||
jumpHistory.add(current)
|
||||
showJumpPill = true
|
||||
}
|
||||
|
||||
if (displayMode == DisplayMode.PAGINATION) {
|
||||
pagerState.scrollToPage(targetPage)
|
||||
} else {
|
||||
|
|
@ -3203,6 +3397,44 @@ fun PdfViewerScreen(
|
|||
val stablePdfDocument = remember(pdfDocument) { StableHolder(pdfDocument!!) }
|
||||
when (displayMode) {
|
||||
DisplayMode.PAGINATION -> {
|
||||
val onPaginationPreSingleTap: (Offset) -> Boolean = { tapOffset ->
|
||||
val canTurnPagesByTap = tapToNavigateEnabled &&
|
||||
(currentPageScale <= 1.02f || isScrollLocked)
|
||||
|
||||
if (!canTurnPagesByTap) {
|
||||
false
|
||||
} else {
|
||||
val oneQuarterWidthPx = boxMaxWidthFloat / 4f
|
||||
when {
|
||||
tapOffset.x < oneQuarterWidthPx -> {
|
||||
coroutineScope.launch {
|
||||
val targetPage =
|
||||
(pagerState.currentPage - 1).coerceAtLeast(0)
|
||||
if (targetPage != pagerState.currentPage) {
|
||||
pagerState.scrollToPage(targetPage)
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
tapOffset.x > (boxMaxWidthFloat - oneQuarterWidthPx) -> {
|
||||
coroutineScope.launch {
|
||||
val targetPage =
|
||||
(pagerState.currentPage + 1).coerceAtMost(
|
||||
pagerState.pageCount - 1
|
||||
)
|
||||
if (targetPage != pagerState.currentPage) {
|
||||
pagerState.scrollToPage(targetPage)
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
|
|
@ -3291,9 +3523,10 @@ fun PdfViewerScreen(
|
|||
|
||||
@Suppress("ControlFlowWithEmptyBody") val onDrawPagination =
|
||||
remember(pageIndex) {
|
||||
{ point: PdfPoint ->
|
||||
if (currentSelectedTool == InkType.TEXT) {
|
||||
} else if (currentSelectedTool == InkType.ERASER) {
|
||||
{ point: PdfPoint, isEraserOverride: Boolean ->
|
||||
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
|
||||
if (effectiveTool == InkType.TEXT) {
|
||||
} else if (effectiveTool == InkType.ERASER) {
|
||||
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
|
||||
val existing = allAnnotations[pageIndex] ?: emptyList()
|
||||
val toRemove = existing.filter {
|
||||
|
|
@ -3328,12 +3561,13 @@ fun PdfViewerScreen(
|
|||
|
||||
@Suppress("ControlFlowWithEmptyBody") val onDrawStartPagination =
|
||||
remember(pageIndex) {
|
||||
{ point: PdfPoint ->
|
||||
{ point: PdfPoint, isEraserOverride: Boolean ->
|
||||
if (showToolSettings) {
|
||||
showToolSettings = false
|
||||
} else {
|
||||
if (currentSelectedTool == InkType.TEXT) {
|
||||
} else if (currentSelectedTool == InkType.ERASER) {
|
||||
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
|
||||
if (effectiveTool == InkType.TEXT) {
|
||||
} else if (effectiveTool == InkType.ERASER) {
|
||||
lastEraserPoint = point
|
||||
erasedAnnotationsFromStroke.clear()
|
||||
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
|
||||
|
|
@ -3362,7 +3596,7 @@ fun PdfViewerScreen(
|
|||
drawingState.onDrawStart(
|
||||
pageIndex,
|
||||
pointWithTime,
|
||||
currentSelectedTool,
|
||||
effectiveTool,
|
||||
currentStrokeColorState,
|
||||
currentStrokeWidthState
|
||||
)
|
||||
|
|
@ -3403,7 +3637,8 @@ fun PdfViewerScreen(
|
|||
modifier = Modifier.fillMaxSize(),
|
||||
showAllTextHighlights = showAllTextHighlights,
|
||||
onHighlightLoading = { /* no-op for paginated mode */ },
|
||||
onSingleTap = onSingleTapStable,
|
||||
onPreSingleTap = onPaginationPreSingleTap,
|
||||
onSingleTap = { _ -> onSingleTapStable() },
|
||||
isProUser = isProUser,
|
||||
onShowDictionaryUpsellDialog = {
|
||||
if (useOnlineDictionary) {
|
||||
|
|
@ -3420,6 +3655,7 @@ fun PdfViewerScreen(
|
|||
onBookmarkClick = { onToggleBookmark(pageIndex) },
|
||||
isZoomEnabled = true,
|
||||
clearSelectionTrigger = selectionClearTrigger,
|
||||
resetZoomTrigger = resetZoomTrigger,
|
||||
pageAnnotations = pageAnnotationsProvider,
|
||||
drawingState = drawingState,
|
||||
onDrawStart = onDrawStartPagination,
|
||||
|
|
@ -3470,12 +3706,11 @@ fun PdfViewerScreen(
|
|||
currentActiveOffset = newOffset
|
||||
}
|
||||
},
|
||||
onDetectPanels = { bitmap ->
|
||||
Toast.makeText(context, "Scanning for panels...", Toast.LENGTH_SHORT).show()
|
||||
viewModel.detectComicPanels(bitmap, context)
|
||||
onDetectBubbles = { sourcePageIndex, bitmap ->
|
||||
detectSpeechBubblesForPage(sourcePageIndex, bitmap)
|
||||
},
|
||||
onShowPanelPopup = { croppedBitmap ->
|
||||
poppedUpPanelBitmap = croppedBitmap
|
||||
onShowPanelPopup = { bitmapWithRects ->
|
||||
poppedUpPanelBitmap = bitmapWithRects
|
||||
},
|
||||
onTwoFingerSwipe = { direction ->
|
||||
coroutineScope.launch {
|
||||
|
|
@ -3647,7 +3882,15 @@ fun PdfViewerScreen(
|
|||
paginationDraggingBoxId = null
|
||||
}
|
||||
},
|
||||
onDragPageTurn = { /* Handled in onTextBoxDrag */ },
|
||||
onDragPageTurn = { direction ->
|
||||
coroutineScope.launch {
|
||||
val targetPage = pagerState.currentPage + direction
|
||||
if (targetPage in 0 until totalDisplayPages) {
|
||||
pagerState.animateScrollToPage(targetPage)
|
||||
}
|
||||
}
|
||||
},
|
||||
isBubbleZoomModeActive = isBubbleZoomModeActive,
|
||||
isVisible = isVisiblePage,
|
||||
isActivePage = pagerState.currentPage == pageIndex,
|
||||
isScrolling = pagerState.isScrollInProgress
|
||||
|
|
@ -3718,12 +3961,13 @@ fun PdfViewerScreen(
|
|||
|
||||
@Suppress("ControlFlowWithEmptyBody") val onDrawStartStable =
|
||||
remember {
|
||||
{ pageIndex: Int, point: PdfPoint ->
|
||||
{ pageIndex: Int, point: PdfPoint, isEraserOverride: Boolean ->
|
||||
if (showToolSettings) {
|
||||
showToolSettings = false
|
||||
} else {
|
||||
if (currentSelectedTool == InkType.TEXT) {
|
||||
} else if (currentSelectedTool == InkType.ERASER) {
|
||||
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
|
||||
if (effectiveTool == InkType.TEXT) {
|
||||
} else if (effectiveTool == InkType.ERASER) {
|
||||
lastEraserPoint = point
|
||||
erasedAnnotationsFromStroke.clear()
|
||||
|
||||
|
|
@ -3753,7 +3997,7 @@ fun PdfViewerScreen(
|
|||
drawingState.onDrawStart(
|
||||
pageIndex,
|
||||
pointWithTime,
|
||||
currentSelectedTool,
|
||||
effectiveTool,
|
||||
currentStrokeColorState,
|
||||
currentStrokeWidthState
|
||||
)
|
||||
|
|
@ -3763,8 +4007,9 @@ fun PdfViewerScreen(
|
|||
}
|
||||
|
||||
val onDrawStable = remember(isHighlighterSnapEnabled, isCurrentToolHighlighter, calculateSnappedPoint) {
|
||||
{ pageIndex: Int, point: PdfPoint ->
|
||||
if (currentSelectedTool == InkType.ERASER) {
|
||||
{ pageIndex: Int, point: PdfPoint, isEraserOverride: Boolean ->
|
||||
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
|
||||
if (effectiveTool == InkType.ERASER) {
|
||||
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
|
||||
val existing = allAnnotations[pageIndex] ?: emptyList()
|
||||
val toRemove = existing.filter {
|
||||
|
|
@ -3915,6 +4160,11 @@ fun PdfViewerScreen(
|
|||
onZoomAndPanChanged = { newScale, newOffset ->
|
||||
currentActiveScale = newScale
|
||||
currentActiveOffset = newOffset
|
||||
},
|
||||
resetZoomTrigger = resetZoomTrigger,
|
||||
isBubbleZoomModeActive = isBubbleZoomModeActive,
|
||||
onDetectBubbles = { sourcePageIndex, bitmap ->
|
||||
detectSpeechBubblesForPage(sourcePageIndex, bitmap)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -4108,7 +4358,7 @@ fun PdfViewerScreen(
|
|||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(top = if (showBars) verticalHeaderHeight else 0.dp)
|
||||
.padding(top = topOverlayInset)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Surface(
|
||||
|
|
@ -4138,6 +4388,55 @@ fun PdfViewerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = bubbleZoomDownloadProgress != null,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
// shift down slightly if the OCR indicator is also showing
|
||||
.padding(top = topOverlayInset + if (isOcrModelDownloading) 64.dp else 0.dp)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
shadowElevation = 4.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
val progress = bubbleZoomDownloadProgress ?: 0f
|
||||
if (progress > 0f) {
|
||||
CircularProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
trackColor = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.2f)
|
||||
)
|
||||
} else {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "Downloading Bubble Zoom model... ${(progress * 100).toInt()}%",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Slider UI Overlay ---
|
||||
AnimatedVisibility(
|
||||
visible = isPageSliderVisible,
|
||||
|
|
@ -4209,6 +4508,15 @@ fun PdfViewerScreen(
|
|||
scrubDebounceJob.value = coroutineScope.launch {
|
||||
delay(200)
|
||||
if (isActive) {
|
||||
val targetPage = newValue.roundToInt()
|
||||
|
||||
if (targetPage != sliderStartPage) {
|
||||
if (jumpHistory.lastOrNull() != sliderStartPage) {
|
||||
if (jumpHistory.size > 20) jumpHistory.removeAt(0)
|
||||
jumpHistory.add(sliderStartPage)
|
||||
}
|
||||
showJumpPill = true
|
||||
}
|
||||
if (displayMode == DisplayMode.PAGINATION) {
|
||||
pagerState.scrollToPage(
|
||||
newValue.roundToInt()
|
||||
|
|
@ -4418,10 +4726,17 @@ fun PdfViewerScreen(
|
|||
},
|
||||
onShowCustomizeTools = { showCustomizeToolsSheet = true },
|
||||
onShowOcrLanguage = {
|
||||
hasSelectedOcrLanguage = true
|
||||
showOcrLanguageDialog = true
|
||||
if (!isOss) {
|
||||
hasSelectedOcrLanguage = true
|
||||
showOcrLanguageDialog = true
|
||||
}
|
||||
},
|
||||
onShowVisualOptions = { showVisualOptionsSheet = true },
|
||||
tapToNavigateEnabled = tapToNavigateEnabled,
|
||||
onToggleTapToNavigate = {
|
||||
tapToNavigateEnabled = !tapToNavigateEnabled
|
||||
saveTapToNavigateSetting(context, tapToNavigateEnabled)
|
||||
},
|
||||
onChangeDisplayMode = { displayMode = it },
|
||||
onToggleKeepScreenOn = {
|
||||
isKeepScreenOn = !isKeepScreenOn
|
||||
|
|
@ -4481,13 +4796,28 @@ fun PdfViewerScreen(
|
|||
}
|
||||
}
|
||||
},
|
||||
onNewTabClick = { showNewTabSheet = true }
|
||||
onNewTabClick = { showNewTabSheet = true },
|
||||
onGenerateDemoAnnotations = {
|
||||
val page = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
|
||||
val demoAnnots = DemoAnnotationGenerator.generateDemoAnnotations(page)
|
||||
|
||||
if (demoAnnots.isNotEmpty()) {
|
||||
Timber.d("Debug: Generating ${demoAnnots.size} demo annotations for page $page")
|
||||
val existing = allAnnotations[page] ?: emptyList()
|
||||
allAnnotations = allAnnotations + (page to (existing + demoAnnots))
|
||||
|
||||
demoAnnots.forEach { annot ->
|
||||
undoStack.add(HistoryAction.Add(page, annot))
|
||||
}
|
||||
redoStack.clear()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ReflowProgressOverlay(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = verticalHeaderHeight)
|
||||
.padding(top = topOverlayInset)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
showStandardBars = showStandardBars,
|
||||
|
|
@ -4502,7 +4832,7 @@ fun PdfViewerScreen(
|
|||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = verticalHeaderHeight)
|
||||
.padding(top = topOverlayInset)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
if (isBackgroundIndexing) {
|
||||
|
|
@ -4673,6 +5003,56 @@ fun PdfViewerScreen(
|
|||
)
|
||||
}
|
||||
|
||||
val effectiveNavBarForPill = if (systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)) with(density) { navBarHeight.toDp() } else 0.dp
|
||||
|
||||
val isBottomBarVisibleForPill = showStandardBars && !searchState.isSearchActive
|
||||
val targetPillBottomPadding = if (isBottomBarVisibleForPill) 56.dp + 16.dp + effectiveNavBarForPill else 16.dp + effectiveNavBarForPill
|
||||
|
||||
val pillBottomPadding by animateDpAsState(
|
||||
targetValue = targetPillBottomPadding,
|
||||
label = "PillBottomPadding"
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showJumpPill && jumpHistory.isNotEmpty(),
|
||||
enter = fadeIn() + slideInVertically { it },
|
||||
exit = fadeOut() + slideOutVertically { it },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(bottom = pillBottomPadding)
|
||||
.padding(start = 16.dp)
|
||||
) {
|
||||
val lastPage = jumpHistory.lastOrNull() ?: 0
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
shadowElevation = 6.dp,
|
||||
onClick = {
|
||||
val target = jumpHistory.removeLastOrNull()
|
||||
if (target != null) {
|
||||
showJumpPill = false
|
||||
coroutineScope.launch {
|
||||
if (displayMode == DisplayMode.PAGINATION) {
|
||||
pagerState.animateScrollToPage(target)
|
||||
} else {
|
||||
verticalReaderState.scrollToPage(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.Undo, contentDescription = "Jump Back", modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Back to Pg ${lastPage + 1}", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom Bar
|
||||
PdfBottomBar(
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
|
|
@ -4687,6 +5067,20 @@ fun PdfViewerScreen(
|
|||
isEditMode = isEditMode,
|
||||
isTtsSessionActive = isTtsSessionActive,
|
||||
ttsErrorMessage = ttsState.errorMessage,
|
||||
jumpBackPage = jumpHistory.lastOrNull(),
|
||||
onJumpBack = {
|
||||
val target = jumpHistory.removeLastOrNull()
|
||||
if (target != null) {
|
||||
showJumpPill = false
|
||||
coroutineScope.launch {
|
||||
if (displayMode == DisplayMode.PAGINATION) {
|
||||
pagerState.animateScrollToPage(target)
|
||||
} else {
|
||||
verticalReaderState.scrollToPage(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onShowSlider = {
|
||||
val currentPageForSlider = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
|
||||
sliderStartPage = currentPageForSlider
|
||||
|
|
@ -4736,6 +5130,16 @@ fun PdfViewerScreen(
|
|||
} else {
|
||||
startTtsWithPermissionCheck(null, null)
|
||||
}
|
||||
},
|
||||
isBubbleZoomModeActive = isBubbleZoomModeActive,
|
||||
onToggleBubbleZoom = {
|
||||
if (isOss) {
|
||||
coroutineScope.launch { snackbarHostState.showSnackbar("Bubble Zoom is only available in Playstore version of Episteme") }
|
||||
} else if (!isBubbleZoomModeActive && !viewModel.isSpeechBubbleModelAvailable(context)) {
|
||||
showBubbleZoomDownloadDialog = true
|
||||
} else {
|
||||
isBubbleZoomModeActive = !isBubbleZoomModeActive
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -5145,7 +5549,12 @@ fun PdfViewerScreen(
|
|||
exit = fadeOut()
|
||||
) {
|
||||
val percentage = (currentPageScale * 100).roundToInt()
|
||||
ZoomPercentageIndicator(percentage = percentage)
|
||||
ZoomPercentageIndicator(
|
||||
percentage = percentage,
|
||||
onResetZoomClick = {
|
||||
resetZoomTrigger = System.currentTimeMillis()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val isImeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0
|
||||
|
|
@ -5465,7 +5874,7 @@ fun PdfViewerScreen(
|
|||
) {
|
||||
Image(
|
||||
bitmap = poppedUpPanelBitmap!!.asImageBitmap(),
|
||||
contentDescription = "Zoomed Panel",
|
||||
contentDescription = "Annotated Page",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
|
|
@ -5485,7 +5894,7 @@ fun PdfViewerScreen(
|
|||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Close Panel",
|
||||
contentDescription = "Close Image",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
|
@ -5500,6 +5909,30 @@ fun PdfViewerScreen(
|
|||
onConfirm = { password -> documentPassword = password })
|
||||
}
|
||||
|
||||
if (showBubbleZoomDownloadDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showBubbleZoomDownloadDialog = false },
|
||||
icon = { Icon(Icons.Default.Info, contentDescription = null) },
|
||||
title = { Text("Download Bubble Zoom Model") },
|
||||
text = {
|
||||
Text("To use the Bubble Zoom feature, an AI model needs to be downloaded (~134 MB). Do you want to download it now?")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showBubbleZoomDownloadDialog = false
|
||||
viewModel.downloadSpeechBubbleModel(context)
|
||||
}) {
|
||||
Text("Download")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showBubbleZoomDownloadDialog = false }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showNewTabSheet) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showNewTabSheet = false },
|
||||
|
|
@ -5656,7 +6089,7 @@ fun PdfViewerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
if (showOcrLanguageDialog) {
|
||||
if (showOcrLanguageDialog && !isOss) {
|
||||
OcrLanguageSelectionDialog(
|
||||
currentLanguage = ocrLanguage,
|
||||
isFirstRun = !hasSelectedOcrLanguage,
|
||||
|
|
@ -6037,6 +6470,17 @@ fun PdfViewerScreen(
|
|||
currentTtsMode = currentTtsMode,
|
||||
isCollapsed = isTtsCollapsed,
|
||||
onCollapseChange = { isTtsCollapsed = it },
|
||||
onLocateCurrentChunk = {
|
||||
ttsPageData?.pageIndex?.let { targetPage ->
|
||||
coroutineScope.launch {
|
||||
if (displayMode == DisplayMode.PAGINATION) {
|
||||
pagerState.scrollToPage(targetPage)
|
||||
} else {
|
||||
verticalReaderState.scrollToPage(targetPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpenTtsSettings = { showTtsSettingsSheet = true },
|
||||
onClose = {
|
||||
ttsController.stop()
|
||||
|
|
@ -6165,4 +6609,4 @@ fun PdfViewerScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue