v1.0.45-oss (#221)
* Implemented ML-based comic panel detection and a panel popup viewer. * Optimized `ComicPanelDetector` for performance and memory efficiency. * Optimized comic panel detection performance by introducing a dedicated single-thread dispatcher for ML tasks, replacing mutex-based synchronization. * Refactored system UI handling and layout padding in `PdfViewerScreen`. * Refactor `PdfViewerScreen.kt` by extracting components and logic into specialized files. * replace hardcoded padding with dynamic header height and adjust IME layout logic * Improve `PdfTextBox` interaction and visual consistency during zoom and pan. * improve PDF text box dragging and scaling behavior across zoom levels * optimize color scheme calculation using remember and expand text dimming coverage * Implement in-app language selection and per-app language preferences. * Improve PDF lock stability in `PdfVerticalReader` * Implement "Preserve Image Colors" option for PDF themes * Optimize TOC locate in ReaderDrawer * Update library and home screen UI components * smoother page turn animation in epub pagination mode * Implement automatic page skipping for TTS when no text is found in PDF viewer * Refine status bar handling and window insets across main screens * Optimize ViewModel initialization and integrate AndroidX SplashScreen * Move ComicPanelDetector to debug source set and introduce IPanelDetector interface * Optimize TOC scrolling in PdfNavigationDrawerContent * Bump version to 1.0.45(45)
This commit is contained in:
parent
71e3614ad6
commit
cb7de86224
33 changed files with 4795 additions and 3447 deletions
|
|
@ -19,13 +19,23 @@
|
|||
*/
|
||||
package com.aryan.reader.pdf
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
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.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
|
|
@ -35,19 +45,29 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Redo
|
||||
import androidx.compose.material.icons.automirrored.filled.Undo
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.DoNotTouch
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.TouchApp
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
|
|
@ -329,4 +349,160 @@ private fun DockIcon(
|
|||
modifier = Modifier.size(iconSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PenPlayground(onClose: () -> Unit) {
|
||||
var selectedPen by remember { mutableStateOf(PenType.FOUNTAIN_PEN) }
|
||||
var selectedColor by remember { mutableStateOf(Color(0xFF2196F3)) } // Default Blue
|
||||
val colors = listOf(
|
||||
Color(0xFFF44336), // Red
|
||||
Color(0xFFFFEB3B), // Yellow
|
||||
Color(0xFF2196F3), // Blue
|
||||
Color(0xFF4CAF50), // Green
|
||||
Color(0xFF9C27B0), // Purple
|
||||
Color.White, // White
|
||||
Color.Black // Black
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.95f)
|
||||
.padding(16.dp),
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = Color(0xFF1E1E1E),
|
||||
shadowElevation = 16.dp,
|
||||
tonalElevation = 0.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 24.dp, horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Star,
|
||||
contentDescription = null,
|
||||
tint = Color.Gray,
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
)
|
||||
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
id = R.drawable.close
|
||||
),
|
||||
contentDescription = "Close", tint = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(40.dp))
|
||||
|
||||
// --- The Pen Rack ---
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(140.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
PenType.entries.forEach { type ->
|
||||
val isSelected = selectedPen == type
|
||||
|
||||
val offsetY by animateDpAsState(
|
||||
targetValue = if (isSelected) (-20).dp else 0.dp, label = "offset"
|
||||
)
|
||||
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isSelected) 1.2f else 1.0f, label = "scale"
|
||||
)
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.offset(y = offsetY)
|
||||
.scale(scale)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) { selectedPen = type }) {
|
||||
// Drawing Area
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(40.dp)
|
||||
.height(120.dp),
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
PenIcon(
|
||||
color = selectedColor,
|
||||
type = type,
|
||||
isSelected = isSelected,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
color = Color.White.copy(alpha = 0.1f),
|
||||
thickness = 1.dp
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
// --- Color Palette ---
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
colors.forEach { color ->
|
||||
val isSelected = selectedColor == color
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clickable { selectedColor = color }) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
drawCircle(color = color)
|
||||
if (isSelected) {
|
||||
drawCircle(
|
||||
color = Color.White,
|
||||
radius = size.minDimension / 2,
|
||||
style = Stroke(width = 3.dp.toPx())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isSelected && color == Color.White) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = Color.Black,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
} else if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = if (color == Color.Black) Color.White
|
||||
else Color.Black.copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
149
app/src/main/java/com/aryan/reader/pdf/PdfDialogs.kt
Normal file
149
app/src/main/java/com/aryan/reader/pdf/PdfDialogs.kt
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.R
|
||||
|
||||
@Composable
|
||||
internal fun PasswordDialog(isError: Boolean, onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
|
||||
var password by remember { mutableStateOf("") }
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.title_password_protected)) }, text = {
|
||||
Column {
|
||||
Text(stringResource(R.string.desc_password_protected))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(stringResource(R.string.password)) },
|
||||
singleLine = true,
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None
|
||||
else PasswordVisualTransformation(),
|
||||
keyboardActions = KeyboardActions(onDone = { onConfirm(password) }),
|
||||
isError = isError,
|
||||
supportingText = if (isError) {
|
||||
{ Text(stringResource(R.string.error_incorrect_password)) }
|
||||
} else null,
|
||||
trailingIcon = {
|
||||
val image = if (passwordVisible) Icons.Filled.Visibility
|
||||
else Icons.Filled.VisibilityOff
|
||||
|
||||
val description = if (passwordVisible) stringResource(R.string.content_desc_hide_password) else stringResource(R.string.content_desc_show_password)
|
||||
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(imageVector = image, description)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}, confirmButton = {
|
||||
Button(onClick = { onConfirm(password) }, enabled = password.isNotBlank()) {
|
||||
Text(stringResource(R.string.action_open))
|
||||
}
|
||||
}, dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } })
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun OcrLanguageSelectionDialog(
|
||||
currentLanguage: OcrLanguage,
|
||||
isFirstRun: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onLanguageSelected: (OcrLanguage) -> Unit
|
||||
) {
|
||||
AlertDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.title_select_ocr_language)) }, text = {
|
||||
Column(Modifier.selectableGroup()) {
|
||||
Text(
|
||||
stringResource(R.string.desc_select_ocr_language),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
if (isFirstRun) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
stringResource(R.string.desc_ocr_language_change_later),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
OcrLanguage.entries.forEach { language ->
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.selectable(
|
||||
selected = (language == currentLanguage),
|
||||
onClick = { onLanguageSelected(language) },
|
||||
role = Role.RadioButton
|
||||
)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(selected = (language == currentLanguage), onClick = null)
|
||||
Text(
|
||||
text = language.displayName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } })
|
||||
}
|
||||
194
app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt
Normal file
194
app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.CancellationSignal
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.print.PageRange
|
||||
import android.print.PrintAttributes
|
||||
import android.print.PrintDocumentAdapter
|
||||
import android.print.PrintDocumentInfo
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.LruCache
|
||||
import androidx.core.graphics.createBitmap
|
||||
import io.legere.pdfiumandroid.suspend.PdfiumCoreKt
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import kotlin.random.Random
|
||||
|
||||
object PdfiumCoreProvider {
|
||||
val core: PdfiumCoreKt by lazy {
|
||||
PdfiumCoreKt(Dispatchers.Default)
|
||||
}
|
||||
}
|
||||
|
||||
internal data class DocumentCacheItem(
|
||||
val doc: ReaderDocument,
|
||||
val pfd: ParcelFileDescriptor,
|
||||
val totalPages: Int,
|
||||
val pageAspectRatios: List<Float>,
|
||||
val flatTableOfContents: List<TocEntry>
|
||||
)
|
||||
|
||||
internal class DocumentCache(val maxSize: Int = 3) {
|
||||
val cache = object : LruCache<String, DocumentCacheItem>(maxSize) {
|
||||
override fun entryRemoved(
|
||||
evicted: Boolean,
|
||||
key: String,
|
||||
oldValue: DocumentCacheItem,
|
||||
newValue: DocumentCacheItem?
|
||||
) {
|
||||
if (evicted) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try { oldValue.doc.close() } catch (e: Exception) { Timber.e(e) }
|
||||
try { oldValue.pfd.close() } catch (e: Exception) { Timber.e(e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fun put(key: String, item: DocumentCacheItem) { cache.put(key, item) }
|
||||
fun get(key: String): DocumentCacheItem? = cache.get(key)
|
||||
fun evictAll() { cache.evictAll() }
|
||||
}
|
||||
|
||||
class PdfPrintDocumentAdapter(
|
||||
private val context: Context,
|
||||
private val pdfUri: Uri,
|
||||
private val fileName: String
|
||||
) : PrintDocumentAdapter() {
|
||||
|
||||
override fun onLayout(
|
||||
oldAttributes: PrintAttributes?,
|
||||
newAttributes: PrintAttributes?,
|
||||
cancellationSignal: CancellationSignal?,
|
||||
callback: LayoutResultCallback?,
|
||||
extras: Bundle?
|
||||
) {
|
||||
if (cancellationSignal?.isCanceled == true) {
|
||||
callback?.onLayoutCancelled()
|
||||
return
|
||||
}
|
||||
|
||||
val info = PrintDocumentInfo.Builder(fileName)
|
||||
.setContentType(PrintDocumentInfo.CONTENT_TYPE_DOCUMENT)
|
||||
.build()
|
||||
|
||||
callback?.onLayoutFinished(info, true)
|
||||
}
|
||||
|
||||
override fun onWrite(
|
||||
pages: Array<out PageRange>?,
|
||||
destination: ParcelFileDescriptor?,
|
||||
cancellationSignal: CancellationSignal?,
|
||||
callback: WriteResultCallback?
|
||||
) {
|
||||
try {
|
||||
context.contentResolver.openFileDescriptor(pdfUri, "r")?.use { pfd ->
|
||||
FileInputStream(pfd.fileDescriptor).use { input ->
|
||||
FileOutputStream(destination?.fileDescriptor).use { output ->
|
||||
val buf = ByteArray(8192)
|
||||
var bytesRead: Int
|
||||
while (input.read(buf).also { bytesRead = it } > 0) {
|
||||
if (cancellationSignal?.isCanceled == true) {
|
||||
Timber.tag("PdfPrint").d("Print job cancelled during write")
|
||||
callback?.onWriteCancelled()
|
||||
return
|
||||
}
|
||||
output.write(buf, 0, bytesRead)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Timber.tag("PdfPrint").i("PDF successfully streamed to print spooler")
|
||||
callback?.onWriteFinished(arrayOf(PageRange.ALL_PAGES))
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("PdfPrint").e(e, "Error writing PDF to print spooler")
|
||||
callback?.onWriteFailed(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun generateShortId(): String {
|
||||
return Random.nextInt(1000, 9999).toString()
|
||||
}
|
||||
|
||||
internal fun getSuggestedFilename(originalName: String?, isAnnotated: Boolean): String {
|
||||
val base = originalName?.substringBeforeLast('.') ?: "Document"
|
||||
val safeBase = base.replace("[^a-zA-Z0-9._-]".toRegex(), "_").take(50)
|
||||
|
||||
val suffix = if (isAnnotated) "_annotated" else ""
|
||||
val shortId = generateShortId()
|
||||
|
||||
return "${safeBase}${suffix}_${shortId}.pdf"
|
||||
}
|
||||
|
||||
internal fun getFastFileId(context: Context, uri: Uri): String {
|
||||
var result = uri.toString()
|
||||
try {
|
||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
|
||||
val size = if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L
|
||||
val name = if (nameIndex != -1) cursor.getString(nameIndex) else "unknown"
|
||||
|
||||
result = "${name}_${size}"
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to generate fast file ID")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
internal suspend fun renderPageToBitmap(doc: ReaderDocument, pageIndex: Int): Bitmap? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
var page: ReaderPage? = null
|
||||
try {
|
||||
page = doc.openPage(pageIndex)
|
||||
if (page == null) return@withContext null
|
||||
|
||||
val bitmapWidth = 1080
|
||||
val aspectRatio =
|
||||
page.getPageWidthPoint().toFloat() / page.getPageHeightPoint().toFloat()
|
||||
if (aspectRatio.isNaN() || aspectRatio <= 0) {
|
||||
Timber.e("Invalid aspect ratio for page $pageIndex")
|
||||
return@withContext null
|
||||
}
|
||||
val bitmapHeight = (bitmapWidth / aspectRatio).toInt()
|
||||
|
||||
if (bitmapHeight <= 0) {
|
||||
Timber.e("Invalid calculated bitmap height for page $pageIndex")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val bitmap = createBitmap(bitmapWidth, bitmapHeight)
|
||||
page.renderPageBitmap(
|
||||
bitmap = bitmap,
|
||||
startX = 0,
|
||||
startY = 0,
|
||||
drawSizeX = bitmapWidth,
|
||||
drawSizeY = bitmapHeight,
|
||||
renderAnnot = true
|
||||
)
|
||||
bitmap
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error rendering page $pageIndex to bitmap for summarization")
|
||||
null
|
||||
} finally {
|
||||
try {
|
||||
page?.close()
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Error closing page in renderPageToBitmap")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
36
app/src/main/java/com/aryan/reader/pdf/PdfModels.kt
Normal file
36
app/src/main/java/com/aryan/reader/pdf/PdfModels.kt
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.content.edit
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.aryan.reader.pdf.data.PdfAnnotation
|
||||
import com.aryan.reader.tts.TtsPlaybackManager
|
||||
|
||||
internal enum class SaveMode {
|
||||
ORIGINAL, ANNOTATED
|
||||
}
|
||||
|
||||
enum class SearchHighlightMode {
|
||||
FOCUSED, ALL
|
||||
}
|
||||
|
||||
internal sealed interface HistoryAction {
|
||||
data class Add(val pageIndex: Int, val annotation: PdfAnnotation) : HistoryAction
|
||||
data class Remove(val items: Map<Int, List<PdfAnnotation>>) : HistoryAction
|
||||
}
|
||||
|
||||
internal enum class DockLocation {
|
||||
TOP, BOTTOM, FLOATING
|
||||
}
|
||||
|
||||
internal enum class DisplayMode {
|
||||
PAGINATION, VERTICAL_SCROLL
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Suppress("unused")
|
||||
internal fun saveTtsMode(context: Context, mode: TtsPlaybackManager.TtsMode) {
|
||||
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||
prefs.edit { putString(TTS_MODE_KEY, mode.name) }
|
||||
}
|
||||
|
|
@ -0,0 +1,537 @@
|
|||
// PdfNavigationDrawerContent.kt
|
||||
package com.aryan.reader.pdf
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
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.filled.MoreVert
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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 kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun PdfNavigationDrawerContent(
|
||||
flatTableOfContents: List<TocEntry>,
|
||||
bookmarks: Set<PdfBookmark>,
|
||||
userHighlights: List<PdfUserHighlight>,
|
||||
currentPage: Int,
|
||||
customHighlightColors: Map<PdfHighlightColor, Color>,
|
||||
onPageSelected: (Int) -> Unit,
|
||||
onRenameBookmark: (PdfBookmark, String) -> Unit,
|
||||
onDeleteBookmark: (PdfBookmark) -> Unit,
|
||||
onDeleteHighlight: (PdfUserHighlight) -> Unit,
|
||||
onNoteRequested: (String?) -> Unit,
|
||||
onCloseDrawer: () -> Unit
|
||||
) {
|
||||
val drawerPagerState = rememberPagerState(pageCount = { 3 })
|
||||
val drawerScope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
TabRow(selectedTabIndex = drawerPagerState.currentPage) {
|
||||
Tab(selected = drawerPagerState.currentPage == 0, onClick = {
|
||||
drawerScope.launch { drawerPagerState.animateScrollToPage(0) }
|
||||
}, text = { Text("Chapters") })
|
||||
Tab(
|
||||
selected = drawerPagerState.currentPage == 1,
|
||||
onClick = {
|
||||
drawerScope.launch { drawerPagerState.animateScrollToPage(1) }
|
||||
},
|
||||
text = { Text("Bookmarks") },
|
||||
modifier = Modifier.testTag("BookmarksTab")
|
||||
)
|
||||
Tab(
|
||||
selected = drawerPagerState.currentPage == 2,
|
||||
onClick = {
|
||||
drawerScope.launch { drawerPagerState.animateScrollToPage(2) }
|
||||
},
|
||||
text = { Text("Highlights") },
|
||||
modifier = Modifier.testTag("HighlightsTab")
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalPager(
|
||||
state = drawerPagerState,
|
||||
modifier = Modifier.fillMaxWidth().weight(1f)
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> { // Chapters Page
|
||||
if (flatTableOfContents.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
"Chapters are not available for this book.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val allParentIndices = remember(flatTableOfContents) {
|
||||
flatTableOfContents.indices.filter { i ->
|
||||
val next = flatTableOfContents.getOrNull(i + 1)
|
||||
next != null && next.nestLevel > flatTableOfContents[i].nestLevel
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
var expandedEntryIndices by rememberSaveable(flatTableOfContents) {
|
||||
mutableStateOf(allParentIndices)
|
||||
}
|
||||
|
||||
val visibleItemInfo by remember(flatTableOfContents) {
|
||||
derivedStateOf {
|
||||
val result = mutableListOf<Pair<Int, TocEntry>>()
|
||||
val visibilityStack = BooleanArray(20) { false }
|
||||
visibilityStack[0] = true
|
||||
|
||||
for (i in flatTableOfContents.indices) {
|
||||
val entry = flatTableOfContents[i]
|
||||
val level = entry.nestLevel.coerceIn(0, 19)
|
||||
|
||||
if (visibilityStack[level]) {
|
||||
result.add(i to entry)
|
||||
val isExpanded = expandedEntryIndices.contains(i)
|
||||
if (level + 1 < visibilityStack.size) {
|
||||
visibilityStack[level + 1] = isExpanded
|
||||
}
|
||||
} else {
|
||||
if (level + 1 < visibilityStack.size) {
|
||||
visibilityStack[level + 1] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
val currentTocEntry by remember(currentPage, flatTableOfContents) {
|
||||
derivedStateOf {
|
||||
flatTableOfContents.lastOrNull { it.pageIndex <= currentPage }
|
||||
}
|
||||
}
|
||||
|
||||
val onScrollToCurrent = {
|
||||
drawerScope.launch {
|
||||
val targetEntry = currentTocEntry ?: return@launch
|
||||
val targetOriginalIndex = flatTableOfContents.indexOf(targetEntry)
|
||||
if (targetOriginalIndex != -1) {
|
||||
var currentLevel = targetEntry.nestLevel
|
||||
val newExpanded = expandedEntryIndices.toMutableSet()
|
||||
|
||||
for (i in targetOriginalIndex downTo 0) {
|
||||
val entry = flatTableOfContents[i]
|
||||
if (entry.nestLevel < currentLevel) {
|
||||
newExpanded.add(i)
|
||||
currentLevel = entry.nestLevel
|
||||
}
|
||||
if (currentLevel == 0) break
|
||||
}
|
||||
|
||||
expandedEntryIndices = newExpanded
|
||||
|
||||
val visibleIdx = visibleItemInfo.indexOfFirst { it.second == targetEntry }
|
||||
|
||||
if (visibleIdx != -1) {
|
||||
var attempts = 0
|
||||
while (listState.layoutInfo.totalItemsCount <= visibleIdx && attempts < 10) {
|
||||
delay(30)
|
||||
attempts++
|
||||
}
|
||||
|
||||
listState.animateScrollToItem(visibleIdx)
|
||||
}
|
||||
}
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
TextButton(onClick = { expandedEntryIndices = flatTableOfContents.indices.toSet() }) {
|
||||
Text("Expand All")
|
||||
}
|
||||
TextButton(onClick = { expandedEntryIndices = emptySet() }) {
|
||||
Text("Collapse All")
|
||||
}
|
||||
TextButton(onClick = onScrollToCurrent) {
|
||||
Text("Locate")
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(end = 12.dp)
|
||||
) {
|
||||
items(
|
||||
items = visibleItemInfo,
|
||||
key = { it.second.title + it.first }
|
||||
) { item ->
|
||||
val (originalIndex, entry) = item
|
||||
|
||||
val nextItem = flatTableOfContents.getOrNull(originalIndex + 1)
|
||||
val hasChildren = nextItem != null && nextItem.nestLevel > entry.nestLevel
|
||||
val isExpanded = expandedEntryIndices.contains(originalIndex)
|
||||
val isCurrentChapter = entry == currentTocEntry
|
||||
|
||||
PdfTocTreeItem(
|
||||
label = entry.title,
|
||||
nestLevel = entry.nestLevel,
|
||||
isExpanded = isExpanded,
|
||||
hasChildren = hasChildren,
|
||||
isCurrent = isCurrentChapter,
|
||||
onToggleExpand = {
|
||||
expandedEntryIndices = if (isExpanded) {
|
||||
expandedEntryIndices - originalIndex
|
||||
} else {
|
||||
expandedEntryIndices + originalIndex
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
onCloseDrawer()
|
||||
onPageSelected(entry.pageIndex)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
VerticalScrollbar(
|
||||
listState = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1 -> { // Bookmarks Page
|
||||
if (bookmarks.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
"You haven't added any bookmarks yet.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
} else {
|
||||
var bookmarkMenuExpandedFor by remember { mutableStateOf<PdfBookmark?>(null) }
|
||||
var showDeleteConfirmDialogFor by remember { mutableStateOf<PdfBookmark?>(null) }
|
||||
var showRenameBookmarkDialog by remember { mutableStateOf<PdfBookmark?>(null) }
|
||||
|
||||
val sortedBookmarks = remember(bookmarks) {
|
||||
bookmarks.sortedBy { it.pageIndex }
|
||||
}
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
itemsIndexed(
|
||||
items = sortedBookmarks, key = { index, bookmark ->
|
||||
"bm_${index}_${bookmark.pageIndex}"
|
||||
}) { _, bookmark ->
|
||||
ListItem(headlineContent = {
|
||||
Text(
|
||||
bookmark.title,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}, supportingContent = {
|
||||
Text(
|
||||
"Page ${bookmark.pageIndex + 1} of ${bookmark.totalPages}",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}, trailingContent = {
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = {
|
||||
bookmarkMenuExpandedFor = bookmark
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = "More options for bookmark"
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = bookmarkMenuExpandedFor == bookmark,
|
||||
onDismissRequest = {
|
||||
bookmarkMenuExpandedFor = null
|
||||
}) {
|
||||
DropdownMenuItem(text = {
|
||||
Text("Rename")
|
||||
}, onClick = {
|
||||
showRenameBookmarkDialog = bookmark
|
||||
bookmarkMenuExpandedFor = null
|
||||
})
|
||||
DropdownMenuItem(text = {
|
||||
Text("Delete")
|
||||
}, onClick = {
|
||||
showDeleteConfirmDialogFor = bookmark
|
||||
bookmarkMenuExpandedFor = null
|
||||
})
|
||||
}
|
||||
}
|
||||
}, modifier = Modifier
|
||||
.clickable {
|
||||
onCloseDrawer()
|
||||
onPageSelected(bookmark.pageIndex)
|
||||
}
|
||||
.testTag(
|
||||
"BookmarkItem_${bookmark.pageIndex}"
|
||||
))
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
showRenameBookmarkDialog?.let { bookmarkToRename ->
|
||||
var newTitle by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(onDismissRequest = {
|
||||
showRenameBookmarkDialog = null
|
||||
}, title = { Text("Rename Bookmark") }, text = {
|
||||
OutlinedTextField(
|
||||
value = newTitle,
|
||||
onValueChange = { newTitle = it },
|
||||
label = { Text("New Title") },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = bookmarkToRename.title,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||
alpha = 0.6f
|
||||
)
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}, confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onRenameBookmark(bookmarkToRename, newTitle)
|
||||
showRenameBookmarkDialog = null
|
||||
}) { Text("Save") }
|
||||
}, dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showRenameBookmarkDialog = null
|
||||
}) { Text("Cancel") }
|
||||
})
|
||||
}
|
||||
|
||||
showDeleteConfirmDialogFor?.let { bookmarkToDelete ->
|
||||
AlertDialog(onDismissRequest = {
|
||||
showDeleteConfirmDialogFor = null
|
||||
}, title = { Text("Delete Bookmark?") }, text = {
|
||||
Text(
|
||||
"Are you sure you want to permanently delete this bookmark?"
|
||||
)
|
||||
}, confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDeleteBookmark(bookmarkToDelete)
|
||||
showDeleteConfirmDialogFor = null
|
||||
}) { Text("Delete") }
|
||||
}, dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDeleteConfirmDialogFor = null
|
||||
}) { Text("Cancel") }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
2 -> { // Highlights Page
|
||||
if (userHighlights.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
"You haven't added any highlights yet.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
} else {
|
||||
var showDeleteConfirmDialogFor by remember { mutableStateOf<PdfUserHighlight?>(null) }
|
||||
var filterWithNotesOnly by remember { mutableStateOf(false) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FilterChip(
|
||||
selected = !filterWithNotesOnly,
|
||||
onClick = { filterWithNotesOnly = false },
|
||||
label = { Text("All") }
|
||||
)
|
||||
FilterChip(
|
||||
selected = filterWithNotesOnly,
|
||||
onClick = { filterWithNotesOnly = true },
|
||||
label = { Text("With Notes") }
|
||||
)
|
||||
}
|
||||
|
||||
val filteredHighlights = if (filterWithNotesOnly) {
|
||||
userHighlights.filter { !it.note.isNullOrBlank() }
|
||||
} else {
|
||||
userHighlights.toList()
|
||||
}
|
||||
|
||||
val sortedHighlights = remember(filteredHighlights) {
|
||||
filteredHighlights.sortedBy { it.pageIndex }
|
||||
}
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
itemsIndexed(
|
||||
items = sortedHighlights,
|
||||
key = { _, highlight -> highlight.id }
|
||||
) { _, highlight ->
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = highlight.text.ifBlank { "Highlighted section" },
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val displayColor = customHighlightColors[highlight.color] ?: highlight.color.color
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.background(displayColor, CircleShape)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Page ${highlight.pageIndex + 1}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (!highlight.note.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = highlight.note,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
|
||||
modifier = Modifier.padding(12.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
Box {
|
||||
var highlightMenuExpanded by remember { mutableStateOf(false) }
|
||||
IconButton(onClick = { highlightMenuExpanded = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = "Options")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = highlightMenuExpanded,
|
||||
onDismissRequest = { highlightMenuExpanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (highlight.note.isNullOrBlank()) "Add Note" else "Edit Note") },
|
||||
onClick = {
|
||||
onNoteRequested(highlight.id)
|
||||
highlightMenuExpanded = false
|
||||
onCloseDrawer()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Delete") },
|
||||
onClick = {
|
||||
showDeleteConfirmDialogFor = highlight
|
||||
highlightMenuExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
onCloseDrawer()
|
||||
onPageSelected(highlight.pageIndex)
|
||||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showDeleteConfirmDialogFor?.let { highlightToDelete ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteConfirmDialogFor = null },
|
||||
title = { Text("Delete Highlight?") },
|
||||
text = { Text("Are you sure you want to permanently delete this highlight?") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDeleteHighlight(highlightToDelete)
|
||||
showDeleteConfirmDialogFor = null
|
||||
}
|
||||
) { Text("Delete") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { showDeleteConfirmDialogFor = null }
|
||||
) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
253
app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt
Normal file
253
app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.R
|
||||
|
||||
private data class ScrollbarCalculations(
|
||||
val thumbHeight: Float,
|
||||
val thumbOffset: Float,
|
||||
val contentHeight: Float,
|
||||
val viewportHeight: Float
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun VerticalScrollbar(
|
||||
listState: LazyListState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isDragged by interactionSource.collectIsDraggedAsState()
|
||||
|
||||
val scrollbarState by remember {
|
||||
derivedStateOf {
|
||||
val layoutInfo = listState.layoutInfo
|
||||
val totalItems = layoutInfo.totalItemsCount
|
||||
val visibleItemsInfo = layoutInfo.visibleItemsInfo
|
||||
val viewportHeight = layoutInfo.viewportSize.height.toFloat()
|
||||
|
||||
if (totalItems == 0 || visibleItemsInfo.isEmpty() || viewportHeight <= 0f) {
|
||||
return@derivedStateOf null
|
||||
}
|
||||
|
||||
// Estimate total height
|
||||
val averageItemHeight = visibleItemsInfo.sumOf { it.size } / visibleItemsInfo.size.toFloat()
|
||||
val estimatedContentHeight = (averageItemHeight * totalItems).coerceAtLeast(viewportHeight)
|
||||
val viewportRatio = viewportHeight / estimatedContentHeight
|
||||
|
||||
if (viewportRatio >= 1f) return@derivedStateOf null
|
||||
|
||||
val thumbHeight = (viewportHeight * viewportRatio).coerceIn(80f, viewportHeight / 2)
|
||||
|
||||
val firstItemIndex = listState.firstVisibleItemIndex
|
||||
val firstItemOffset = listState.firstVisibleItemScrollOffset
|
||||
val currentScrollPixels = (firstItemIndex * averageItemHeight) + firstItemOffset
|
||||
val maxScrollPixels = estimatedContentHeight - viewportHeight
|
||||
val scrollProgress = (currentScrollPixels / maxScrollPixels).coerceIn(0f, 1f)
|
||||
val trackHeight = viewportHeight - thumbHeight
|
||||
val thumbOffset = trackHeight * scrollProgress
|
||||
|
||||
ScrollbarCalculations(
|
||||
thumbHeight = thumbHeight,
|
||||
thumbOffset = thumbOffset,
|
||||
contentHeight = estimatedContentHeight,
|
||||
viewportHeight = viewportHeight
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val targetAlpha = if (listState.isScrollInProgress || isDragged) 1f else 0f
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = targetAlpha,
|
||||
animationSpec = tween(durationMillis = 200),
|
||||
label = "ScrollbarAlpha"
|
||||
)
|
||||
|
||||
if (scrollbarState != null) {
|
||||
val state = scrollbarState!!
|
||||
val draggableState = rememberDraggableState { delta ->
|
||||
val trackHeight = state.viewportHeight - state.thumbHeight
|
||||
if (trackHeight > 0) {
|
||||
val scrollRatio = delta / trackHeight
|
||||
val totalScrollableDistance = state.contentHeight - state.viewportHeight
|
||||
val scrollDelta = scrollRatio * totalScrollableDistance
|
||||
listState.dispatchRawDelta(scrollDelta)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.width(30.dp)
|
||||
.fillMaxHeight()
|
||||
.draggable(
|
||||
state = draggableState,
|
||||
orientation = Orientation.Vertical,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.graphicsLayer { translationY = state.thumbOffset }
|
||||
.padding(end = 4.dp)
|
||||
.width(6.dp)
|
||||
.height(with(LocalDensity.current) { state.thumbHeight.toDp() })
|
||||
.alpha(alpha)
|
||||
.background(
|
||||
color = if (isDragged) MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(100)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
indication = null, interactionSource = remember { MutableInteractionSource() }) {},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surface.copy(
|
||||
alpha = 0.9f
|
||||
), shape = RoundedCornerShape(16.dp)
|
||||
)
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.slider),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "Page $currentPage of $totalPages",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ThumbnailWithIndicator(
|
||||
thumbnail: Bitmap, modifier: Modifier = Modifier, onClick: () -> Unit
|
||||
) {
|
||||
val borderColor = MaterialTheme.colorScheme.primary
|
||||
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.width(45.dp)
|
||||
.height(64.dp)
|
||||
.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
border = BorderStroke(2.dp, borderColor)
|
||||
) {
|
||||
Image(
|
||||
bitmap = thumbnail.asImageBitmap(),
|
||||
contentDescription = "Start page thumbnail",
|
||||
contentScale = ContentScale.FillBounds,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
Box(modifier = Modifier
|
||||
.offset(y = (-4).dp)
|
||||
.size(8.dp)
|
||||
.rotate(45f)
|
||||
.background(borderColor))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun BookmarkButton(
|
||||
isBookmarked: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.width(48.dp)
|
||||
.height(48.dp)
|
||||
.clip(RectangleShape)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick
|
||||
), contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
AnimatedVisibility(visible = isBookmarked, enter = fadeIn(), exit = fadeOut()) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.bookmark),
|
||||
contentDescription = "Bookmark",
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ZoomPercentageIndicator(percentage: Int) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.8f)
|
||||
) {
|
||||
Text(
|
||||
text = "$percentage%",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -347,7 +347,9 @@ data class PageStaticData(
|
|||
val targetWidth: Int,
|
||||
val targetHeight: Int,
|
||||
val colorFilter: StableHolder<ColorFilter?>,
|
||||
val isDarkMode: Boolean
|
||||
val isDarkMode: Boolean,
|
||||
val excludeImages: Boolean,
|
||||
val imageRects: StableHolder<List<android.graphics.Rect>>
|
||||
)
|
||||
|
||||
@Stable
|
||||
|
|
@ -416,6 +418,7 @@ internal fun PdfPageComposable(
|
|||
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),
|
||||
excludeImages: Boolean = false,
|
||||
onDoubleTap: ((Offset) -> Unit)? = null,
|
||||
isEditMode: Boolean = false,
|
||||
drawingState: PdfDrawingState? = null,
|
||||
|
|
@ -451,7 +454,9 @@ internal fun PdfPageComposable(
|
|||
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
|
||||
onPaletteClick: (() -> Unit)? = null,
|
||||
lockedState: Triple<Float, Float, Float>? = null,
|
||||
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null
|
||||
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null,
|
||||
onDetectPanels: suspend (Bitmap) -> List<android.graphics.RectF> = { emptyList() },
|
||||
onShowPanelPopup: (Bitmap) -> Unit = {}
|
||||
) {
|
||||
val pdfDocumentItem = pdfDocument.item
|
||||
var bitmapState by remember { mutableStateOf(PdfThumbnailCache.get(pageIndex)) }
|
||||
|
|
@ -852,11 +857,13 @@ internal fun PdfPageComposable(
|
|||
|
||||
@Suppress("VariableNeverRead") var embeddedAnnotations by remember { mutableStateOf<List<EmbeddedAnnotation>>(emptyList()) }
|
||||
var standardAnnotScreenRects by remember { mutableStateOf<List<Pair<EmbeddedAnnotation, Rect>>>(emptyList()) }
|
||||
var imageScreenRects by remember { mutableStateOf<List<android.graphics.Rect>>(emptyList()) }
|
||||
|
||||
LaunchedEffect(pageIndex, pdfDocumentItem, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage) {
|
||||
if (!isPdfPage || actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0) {
|
||||
if (pageLinks.isNotEmpty()) pageLinks = emptyList()
|
||||
if (standardAnnotScreenRects.isNotEmpty()) standardAnnotScreenRects = emptyList()
|
||||
if (imageScreenRects.isNotEmpty()) imageScreenRects = emptyList()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
|
|
@ -864,6 +871,7 @@ internal fun PdfPageComposable(
|
|||
val allLinks = mutableListOf<PageLink>()
|
||||
var finalDisplayList = emptyList<EmbeddedAnnotation>()
|
||||
var mappedAnnots = emptyList<Pair<EmbeddedAnnotation, Rect>>()
|
||||
var mappedImageRects = emptyList<android.graphics.Rect>()
|
||||
val annotLink = 2
|
||||
|
||||
try {
|
||||
|
|
@ -931,6 +939,39 @@ internal fun PdfPageComposable(
|
|||
Timber.e(e, "Error fetching web links")
|
||||
}
|
||||
|
||||
// --- Extract Image Bounds ---
|
||||
try {
|
||||
val pagePtr = pageWrapper.getNativePointer()
|
||||
if (pagePtr != 0L) {
|
||||
val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr)
|
||||
val imgRects = mutableListOf<android.graphics.Rect>()
|
||||
val outRect = FloatArray(4)
|
||||
|
||||
for (i in 0 until objCount) {
|
||||
if (NativePdfiumBridge.getPageObjectType(pagePtr, i) == 3) { // 3 = FPDF_PAGEOBJ_IMAGE
|
||||
if (NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, i, outRect)) {
|
||||
val pdfRectF = android.graphics.RectF(
|
||||
min(outRect[0], outRect[2]),
|
||||
max(outRect[1], outRect[3]),
|
||||
max(outRect[0], outRect[2]),
|
||||
min(outRect[1], outRect[3])
|
||||
)
|
||||
val deviceRect = pageWrapper.mapRectToDevice(
|
||||
0, 0, actualBitmapWidthPx, actualBitmapHeightPx,
|
||||
currentPageRotation, pdfRectF
|
||||
)
|
||||
if (deviceRect.width() > 0 && deviceRect.height() > 0) {
|
||||
imgRects.add(android.graphics.Rect(deviceRect.left, deviceRect.top, deviceRect.right, deviceRect.bottom))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
mappedImageRects = imgRects
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("PdfImageDebug").e(e, "Error extracting image rects")
|
||||
}
|
||||
|
||||
// 3. Extract Embedded Annotations
|
||||
try {
|
||||
val pagePtr = pageWrapper.getNativePointer()
|
||||
|
|
@ -1035,6 +1076,7 @@ internal fun PdfPageComposable(
|
|||
pageLinks = allLinks
|
||||
embeddedAnnotations = finalDisplayList
|
||||
standardAnnotScreenRects = mappedAnnots
|
||||
imageScreenRects = mappedImageRects
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2627,7 +2669,38 @@ internal fun PdfPageComposable(
|
|||
coroutineScope.launch {
|
||||
val startScale = scale
|
||||
val targetScale = if (startScale > 1.1f) 1f else 2.5f
|
||||
Timber.tag("PdfZoomDebug").i("DoubleTap Triggered: CurrentScale=$startScale, Target=$targetScale")
|
||||
|
||||
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
|
||||
|
|
@ -2658,10 +2731,10 @@ internal fun PdfPageComposable(
|
|||
)
|
||||
) {
|
||||
val progress = value
|
||||
scale = lerp(
|
||||
scale = androidx.compose.ui.util.lerp(
|
||||
startScale, targetScale, progress
|
||||
)
|
||||
offset = lerp(
|
||||
offset = androidx.compose.ui.geometry.lerp(
|
||||
startOffset, targetOffset, progress
|
||||
)
|
||||
onScaleChanged(scale)
|
||||
|
|
@ -3555,6 +3628,7 @@ internal fun PdfPageComposable(
|
|||
val stableBitmapState = remember(bitmapState) { StableHolder(bitmapState) }
|
||||
val stableTiles = remember(tiles) { StableHolder(tiles) }
|
||||
val stableColorFilter = remember(colorFilter) { StableHolder(colorFilter) }
|
||||
val stableImageRects = remember(imageScreenRects) { StableHolder(imageScreenRects) }
|
||||
|
||||
val staticData = remember(
|
||||
stableBitmapState,
|
||||
|
|
@ -3567,7 +3641,9 @@ internal fun PdfPageComposable(
|
|||
actualBitmapWidthPx,
|
||||
actualBitmapHeightPx,
|
||||
stableColorFilter,
|
||||
isDarkMode
|
||||
isDarkMode,
|
||||
excludeImages,
|
||||
stableImageRects
|
||||
) {
|
||||
Timber.tag("PdfDrawPerf").v(
|
||||
"STATIC DATA GENERATED: Scale=$effectiveScale, Tiles=${stableTiles.item.size}"
|
||||
|
|
@ -3583,7 +3659,9 @@ internal fun PdfPageComposable(
|
|||
targetWidth = actualBitmapWidthPx,
|
||||
targetHeight = actualBitmapHeightPx,
|
||||
colorFilter = stableColorFilter,
|
||||
isDarkMode = isDarkMode
|
||||
isDarkMode = isDarkMode,
|
||||
excludeImages = excludeImages,
|
||||
imageRects = stableImageRects
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -3659,6 +3737,7 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
},
|
||||
scale = scale,
|
||||
uiScale = effectiveScale,
|
||||
offset = offset,
|
||||
startHandlePos = startHandleContentPosition.value,
|
||||
endHandlePos = endHandleContentPosition.value,
|
||||
|
|
@ -3936,11 +4015,11 @@ private fun PdfBitmapLayer(
|
|||
targetWidth: Int,
|
||||
targetHeight: Int,
|
||||
colorFilter: ColorFilter? = null,
|
||||
isDarkMode: Boolean = false
|
||||
isDarkMode: Boolean = false,
|
||||
excludeImages: Boolean = false,
|
||||
imageRects: List<android.graphics.Rect> = emptyList()
|
||||
) {
|
||||
Canvas(modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer()) {
|
||||
Canvas(modifier = Modifier.fillMaxSize().graphicsLayer()) {
|
||||
translate(left = centeringOffsetX, top = centeringOffsetY) {
|
||||
clipRect(left = 0f, top = 0f, right = targetWidth.toFloat(), bottom = targetHeight.toFloat()) {
|
||||
if (bitmapState != null && !bitmapState.isRecycled) {
|
||||
|
|
@ -3959,6 +4038,32 @@ private fun PdfBitmapLayer(
|
|||
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
||||
)
|
||||
|
||||
if (excludeImages && colorFilter != null && imageRects.isNotEmpty()) {
|
||||
imageRects.forEach { rect ->
|
||||
val scaleX = bitmapState.width.toFloat() / dstW.toFloat()
|
||||
val scaleY = bitmapState.height.toFloat() / dstH.toFloat()
|
||||
|
||||
val srcRectLeft = (rect.left * scaleX).roundToInt().coerceAtLeast(0)
|
||||
val srcRectTop = (rect.top * scaleY).roundToInt().coerceAtLeast(0)
|
||||
val srcRectRight = (rect.right * scaleX).roundToInt().coerceAtMost(bitmapState.width)
|
||||
val srcRectBottom = (rect.bottom * scaleY).roundToInt().coerceAtMost(bitmapState.height)
|
||||
|
||||
val w = srcRectRight - srcRectLeft
|
||||
val h = srcRectBottom - srcRectTop
|
||||
if (w > 0 && h > 0) {
|
||||
drawImage(
|
||||
image = bitmapState.asImageBitmap(),
|
||||
srcOffset = IntOffset(srcRectLeft, srcRectTop),
|
||||
srcSize = IntSize(w, h),
|
||||
dstOffset = IntOffset(rect.left, rect.top),
|
||||
dstSize = IntSize(rect.width(), rect.height()),
|
||||
colorFilter = null,
|
||||
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val needsTiling = effectiveScale > 1f || targetWidth > 3000 || targetHeight > 3000
|
||||
if (needsTiling) {
|
||||
tiles.forEach { tile ->
|
||||
|
|
@ -3968,12 +4073,52 @@ private fun PdfBitmapLayer(
|
|||
srcOffset = IntOffset.Zero,
|
||||
srcSize = IntSize(tile.bitmap.width, tile.bitmap.height),
|
||||
dstOffset = IntOffset(tile.renderRect.left, tile.renderRect.top),
|
||||
dstSize = IntSize(
|
||||
tile.renderRect.width(), tile.renderRect.height()
|
||||
),
|
||||
dstSize = IntSize(tile.renderRect.width(), tile.renderRect.height()),
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
||||
)
|
||||
|
||||
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 iw = intersectRight - intersectLeft
|
||||
val ih = intersectBottom - intersectTop
|
||||
|
||||
if (iw > 0 && ih > 0) {
|
||||
val scaleXBmp = tile.bitmap.width.toFloat() / tile.renderRect.width()
|
||||
val scaleYBmp = tile.bitmap.height.toFloat() / tile.renderRect.height()
|
||||
|
||||
val srcLeft = ((intersectLeft - tile.renderRect.left) * scaleXBmp).roundToInt()
|
||||
val srcTop = ((intersectTop - tile.renderRect.top) * scaleYBmp).roundToInt()
|
||||
val srcRight = ((intersectRight - tile.renderRect.left) * scaleXBmp).roundToInt()
|
||||
val srcBottom = ((intersectBottom - tile.renderRect.top) * scaleYBmp).roundToInt()
|
||||
|
||||
val srcW = srcRight - srcLeft
|
||||
val srcH = srcBottom - srcTop
|
||||
|
||||
if (srcW > 0 && srcH > 0) {
|
||||
drawImage(
|
||||
image = tile.bitmap.asImageBitmap(),
|
||||
srcOffset = IntOffset(srcLeft, srcTop),
|
||||
srcSize = IntSize(srcW, srcH),
|
||||
dstOffset = IntOffset(intersectLeft, intersectTop),
|
||||
dstSize = IntSize(iw, ih),
|
||||
colorFilter = null,
|
||||
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4497,7 +4642,9 @@ private fun PdfPageStaticLayer(data: PageStaticData) {
|
|||
targetWidth = data.targetWidth,
|
||||
targetHeight = data.targetHeight,
|
||||
colorFilter = data.colorFilter.item,
|
||||
isDarkMode = data.isDarkMode
|
||||
isDarkMode = data.isDarkMode,
|
||||
excludeImages = data.excludeImages,
|
||||
imageRects = data.imageRects.item
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -4571,6 +4718,7 @@ private fun PdfPageRenderer(
|
|||
drawingState: PdfDrawingState?,
|
||||
onCanvasSizeChanged: (Float, Float) -> Unit,
|
||||
scale: Float,
|
||||
uiScale: Float,
|
||||
offset: Offset,
|
||||
startHandlePos: Offset?,
|
||||
endHandlePos: Offset?,
|
||||
|
|
@ -4697,6 +4845,12 @@ private fun PdfPageRenderer(
|
|||
}
|
||||
}
|
||||
|
||||
if (textBoxes.isNotEmpty()) {
|
||||
androidx.compose.runtime.SideEffect {
|
||||
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer parent graphicsLayer applied | scale=$scale | offset=$offset | Centering: X=${staticData.centeringOffsetX}, Y=${staticData.centeringOffsetY}")
|
||||
}
|
||||
}
|
||||
|
||||
textBoxes.forEach { box ->
|
||||
val isDraggingThisBox = (box.id == draggingBoxId)
|
||||
val boxAlpha = if (isDraggingThisBox) 0f else 1f
|
||||
|
|
@ -4707,17 +4861,27 @@ private fun PdfPageRenderer(
|
|||
isSelected = (box.id == selectedTextBoxId),
|
||||
isEditMode = isEditMode,
|
||||
isDarkMode = staticData.isDarkMode,
|
||||
scale = uiScale,
|
||||
pageWidthPx = staticData.targetWidth.toFloat(),
|
||||
pageHeightPx = staticData.targetHeight.toFloat(),
|
||||
handlePosition = HandlePosition.AUTO,
|
||||
onBoundsChanged = { newBounds ->
|
||||
onTextBoxChange(box.copy(relativeBounds = newBounds))
|
||||
Timber.tag("PdfTextBoxDebug").v("PdfPageRenderer onBoundsChanged [ID: ${box.id}] bounds=$newBounds draggingBoxId=$draggingBoxId")
|
||||
if (draggingBoxId != box.id) {
|
||||
onTextBoxChange(box.copy(relativeBounds = newBounds))
|
||||
} else {
|
||||
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onBoundsChanged IGNORED because box[ID: ${box.id}] is being dragged globally")
|
||||
}
|
||||
},
|
||||
onTextChanged = { newText ->
|
||||
onTextBoxChange(box.copy(text = newText))
|
||||
},
|
||||
onSelect = { onTextBoxSelect(box.id) },
|
||||
onSelect = {
|
||||
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onSelect propagated[ID: ${box.id}]")
|
||||
onTextBoxSelect(box.id)
|
||||
},
|
||||
onDragStart = { touchOffset ->
|
||||
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onDragStart[ID: ${box.id}] isVerticalScroll=$isVerticalScroll | offset=$touchOffset")
|
||||
if (isVerticalScroll) {
|
||||
val topLeft = Offset(
|
||||
box.relativeBounds.left * staticData.targetWidth,
|
||||
|
|
@ -4729,10 +4893,12 @@ private fun PdfPageRenderer(
|
|||
}
|
||||
},
|
||||
onDrag = { delta, currentBounds ->
|
||||
Timber.tag("PdfTextBoxDebug").v("PdfPageRenderer onDrag [ID: ${box.id}] delta=$delta currentBounds=$currentBounds scale=$scale")
|
||||
if (isVerticalScroll) {
|
||||
onTextBoxDrag(delta)
|
||||
} else {
|
||||
onTextBoxDrag(delta)
|
||||
val scaledDelta = delta * scale
|
||||
onTextBoxDrag(scaledDelta)
|
||||
|
||||
val width = staticData.targetWidth
|
||||
val edgeThreshold = 60f
|
||||
|
|
@ -4746,9 +4912,11 @@ private fun PdfPageRenderer(
|
|||
}
|
||||
},
|
||||
onDragEnd = {
|
||||
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onDragEnd[ID: ${box.id}]")
|
||||
onTextBoxDragEnd()
|
||||
},
|
||||
onDragCancel = {
|
||||
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onDragCancel [ID: ${box.id}]")
|
||||
onTextBoxDragEnd()
|
||||
},
|
||||
modifier = Modifier
|
||||
|
|
|
|||
373
app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt
Normal file
373
app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.core.content.edit
|
||||
import com.aryan.reader.BuildConfig
|
||||
import com.aryan.reader.ReaderTheme
|
||||
import com.aryan.reader.epubreader.SystemUiMode
|
||||
|
||||
internal const val VERTICAL_SCROLL_TAG = "PdfVerticalScroll"
|
||||
internal const val SETTINGS_PREFS_NAME = "epub_reader_settings"
|
||||
internal const val TTS_MODE_KEY = "tts_mode"
|
||||
internal const val DISPLAY_MODE_KEY = "pdf_display_mode"
|
||||
internal const val PDF_DARK_MODE_KEY = "pdf_dark_mode"
|
||||
internal const val OCR_LANGUAGE_KEY = "ocr_language_key"
|
||||
internal const val OCR_LANGUAGE_SELECTED_KEY = "ocr_language_selected_key"
|
||||
internal const val DOCK_LOCATION_KEY = "dock_location"
|
||||
internal const val DOCK_OFFSET_X_KEY = "dock_offset_x"
|
||||
internal const val DOCK_OFFSET_Y_KEY = "dock_offset_y"
|
||||
internal const val PDF_AUTO_SCROLL_SPEED_KEY = "pdf_auto_scroll_speed"
|
||||
internal const val PDF_AUTO_SCROLL_USE_SLIDER_KEY = "pdf_auto_scroll_use_slider"
|
||||
internal const val PDF_AUTO_SCROLL_MIN_SPEED_KEY = "pdf_auto_scroll_min_speed"
|
||||
internal const val PDF_AUTO_SCROLL_MAX_SPEED_KEY = "pdf_auto_scroll_max_speed"
|
||||
internal const val STYLUS_ONLY_MODE_KEY = "stylus_only_mode"
|
||||
|
||||
private const val PDF_AUTO_SCROLL_IS_LOCAL_PREFIX = "pdf_as_local_"
|
||||
private const val PDF_AUTO_SCROLL_LOCAL_SPEED_PREFIX = "pdf_as_local_speed_"
|
||||
private const val PDF_AUTO_SCROLL_LOCAL_MIN_PREFIX = "pdf_as_local_min_"
|
||||
private const val PDF_AUTO_SCROLL_LOCAL_MAX_PREFIX = "pdf_as_local_max_"
|
||||
private const val PDF_SCROLL_LOCKED_PREFIX = "pdf_sl_local_"
|
||||
internal const val PDF_FULL_SCREEN_PREFIX = "pdf_fs_local_"
|
||||
private const val PDF_MUSICIAN_MODE_KEY = "pdf_musician_mode_enabled"
|
||||
private const val PREF_USE_ONLINE_DICT = "use_online_dictionary"
|
||||
private const val PREF_EXTERNAL_DICT_PKG = "external_dictionary_package"
|
||||
private const val PREF_EXTERNAL_TRANSLATE_PKG = "external_translate_package"
|
||||
private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package"
|
||||
private const val PDF_THEME_KEY = "pdf_reader_theme"
|
||||
private const val PDF_KEEP_SCREEN_ON_KEY = "pdf_keep_screen_on_enabled"
|
||||
private const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools"
|
||||
private const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode"
|
||||
internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug"
|
||||
|
||||
enum class PdfReaderTool(val title: String, val category: String) {
|
||||
DICTIONARY("External Apps", "Top Bar"),
|
||||
THEME("Theme Settings", "Top Bar"),
|
||||
LOCK_PANNING("Lock Panning", "Top Bar"),
|
||||
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
|
||||
FULL_SCREEN("Full Screen", "Top Bar"),
|
||||
SLIDER("Navigation Slider", "Bottom Bar"),
|
||||
TOC("Sidebar", "Bottom Bar"),
|
||||
SEARCH("Search", "Bottom Bar"),
|
||||
HIGHLIGHT_ALL("Highlight selectable text", "Bottom Bar"),
|
||||
AI_FEATURES("AI Features", "Bottom Bar"),
|
||||
EDIT_MODE("Edit Mode", "Bottom Bar"),
|
||||
TTS_CONTROLS("TTS Controls", "Bottom Bar"),
|
||||
OCR_LANGUAGE("OCR Language", "Overflow Menu"),
|
||||
READING_MODE("Reading Mode", "Overflow Menu"),
|
||||
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
|
||||
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
|
||||
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu"),
|
||||
BOOKMARK("Bookmark", "Overflow Menu"),
|
||||
PAGE_MANAGEMENT("Page Management", "Overflow Menu"),
|
||||
REFLOW("Text View (Reflow)", "Overflow Menu"),
|
||||
SHARE("Share", "Overflow Menu"),
|
||||
SAVE_COPY("Save Copy", "Overflow Menu"),
|
||||
PRINT("Print", "Overflow Menu")
|
||||
}
|
||||
|
||||
val PdfBuiltInThemes = listOf(
|
||||
ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
|
||||
ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true),
|
||||
ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false),
|
||||
ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
|
||||
ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
|
||||
ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
|
||||
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true)
|
||||
)
|
||||
|
||||
internal fun loadPdfHiddenTools(context: Context): Set<String> {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()) ?: emptySet()
|
||||
}
|
||||
|
||||
internal fun savePdfHiddenTools(context: Context, hiddenTools: Set<String>) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putStringSet(PDF_HIDDEN_TOOLS_KEY, hiddenTools) }
|
||||
}
|
||||
|
||||
internal fun loadCustomHighlightColors(context: Context): Map<PdfHighlightColor, Color> {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return PdfHighlightColor.entries.associateWith {
|
||||
val defaultArgb = it.color.toArgb()
|
||||
val savedArgb = prefs.getInt("custom_highlight_${it.name}", defaultArgb)
|
||||
Color(savedArgb)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun saveCustomHighlightColors(context: Context, colors: Map<PdfHighlightColor, Color>) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit {
|
||||
colors.forEach { (colorEnum, color) ->
|
||||
putInt("custom_highlight_${colorEnum.name}", color.toArgb())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun saveKeepScreenOn(context: Context, isEnabled: Boolean) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putBoolean(PDF_KEEP_SCREEN_ON_KEY, isEnabled) }
|
||||
}
|
||||
|
||||
internal fun loadKeepScreenOn(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getBoolean(PDF_KEEP_SCREEN_ON_KEY, false)
|
||||
}
|
||||
|
||||
internal fun savePdfSystemUiMode(context: Context, mode: SystemUiMode) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putInt(PDF_SYSTEM_UI_MODE_KEY, mode.id) }
|
||||
}
|
||||
|
||||
internal fun loadPdfSystemUiMode(context: Context): SystemUiMode {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val id = prefs.getInt(PDF_SYSTEM_UI_MODE_KEY, SystemUiMode.SYNC.id)
|
||||
return SystemUiMode.entries.find { it.id == id } ?: SystemUiMode.SYNC
|
||||
}
|
||||
|
||||
internal fun savePdfThemeId(context: Context, themeId: String) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putString(PDF_THEME_KEY, themeId) }
|
||||
}
|
||||
|
||||
internal fun loadPdfThemeId(context: Context): String {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getString(PDF_THEME_KEY, "no_theme") ?: "no_theme"
|
||||
}
|
||||
|
||||
internal fun loadUseOnlineDict(context: Context): Boolean {
|
||||
@Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss") return false
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getBoolean(PREF_USE_ONLINE_DICT, true)
|
||||
}
|
||||
|
||||
internal fun saveUseOnlineDict(context: Context, useOnline: Boolean) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putBoolean(PREF_USE_ONLINE_DICT, useOnline) }
|
||||
}
|
||||
|
||||
internal fun loadExternalDictPackage(context: Context): String? {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getString(PREF_EXTERNAL_DICT_PKG, null)
|
||||
}
|
||||
|
||||
internal fun saveExternalDictPackage(context: Context, packageName: String) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putString(PREF_EXTERNAL_DICT_PKG, packageName) }
|
||||
}
|
||||
|
||||
internal fun loadExternalTranslatePackage(context: Context): String? {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getString(PREF_EXTERNAL_TRANSLATE_PKG, null)
|
||||
}
|
||||
|
||||
internal fun saveExternalTranslatePackage(context: Context, packageName: String) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putString(PREF_EXTERNAL_TRANSLATE_PKG, packageName) }
|
||||
}
|
||||
|
||||
internal fun loadExternalSearchPackage(context: Context): String? {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getString(PREF_EXTERNAL_SEARCH_PKG, null)
|
||||
}
|
||||
|
||||
internal fun saveExternalSearchPackage(context: Context, packageName: String) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putString(PREF_EXTERNAL_SEARCH_PKG, packageName) }
|
||||
}
|
||||
|
||||
internal fun savePdfMusicianMode(context: Context, isEnabled: Boolean) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putBoolean(PDF_MUSICIAN_MODE_KEY, isEnabled) }
|
||||
}
|
||||
|
||||
internal fun loadPdfMusicianMode(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getBoolean(PDF_MUSICIAN_MODE_KEY, false)
|
||||
}
|
||||
|
||||
internal fun savePdfScrollLocked(context: Context, bookId: String, isLocked: Boolean) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putBoolean(PDF_SCROLL_LOCKED_PREFIX + bookId, isLocked) }
|
||||
}
|
||||
|
||||
internal fun loadPdfScrollLocked(context: Context, bookId: String): Boolean {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getBoolean(PDF_SCROLL_LOCKED_PREFIX + bookId, false)
|
||||
}
|
||||
|
||||
internal fun savePdfAutoScrollLocalMode(context: Context, bookId: String, isLocal: Boolean) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putBoolean(PDF_AUTO_SCROLL_IS_LOCAL_PREFIX + bookId, isLocal) }
|
||||
}
|
||||
|
||||
internal fun loadPdfAutoScrollLocalMode(context: Context, bookId: String): Boolean {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getBoolean(PDF_AUTO_SCROLL_IS_LOCAL_PREFIX + bookId, false)
|
||||
}
|
||||
|
||||
internal fun savePdfAutoScrollLocalSettings(context: Context, bookId: String, speed: Float, min: Float, max: Float) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit {
|
||||
putFloat(PDF_AUTO_SCROLL_LOCAL_SPEED_PREFIX + bookId, speed)
|
||||
putFloat(PDF_AUTO_SCROLL_LOCAL_MIN_PREFIX + bookId, min)
|
||||
putFloat(PDF_AUTO_SCROLL_LOCAL_MAX_PREFIX + bookId, max)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun loadPdfAutoScrollLocalSettings(context: Context, bookId: String): Triple<Float, Float, Float>? {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
if (!prefs.contains(PDF_AUTO_SCROLL_LOCAL_SPEED_PREFIX + bookId)) return null
|
||||
|
||||
val speed = prefs.getFloat(PDF_AUTO_SCROLL_LOCAL_SPEED_PREFIX + bookId, 3.0f)
|
||||
val min = prefs.getFloat(PDF_AUTO_SCROLL_LOCAL_MIN_PREFIX + bookId, 0.1f)
|
||||
val max = prefs.getFloat(PDF_AUTO_SCROLL_LOCAL_MAX_PREFIX + bookId, 10.0f)
|
||||
return Triple(speed, min, max)
|
||||
}
|
||||
|
||||
internal fun savePdfAutoScrollMinSpeed(context: Context, speed: Float) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putFloat(PDF_AUTO_SCROLL_MIN_SPEED_KEY, speed) }
|
||||
}
|
||||
|
||||
internal fun saveStylusOnlyMode(context: Context, isEnabled: Boolean) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putBoolean(STYLUS_ONLY_MODE_KEY, isEnabled) }
|
||||
}
|
||||
|
||||
internal fun loadStylusOnlyMode(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getBoolean(STYLUS_ONLY_MODE_KEY, false)
|
||||
}
|
||||
|
||||
internal fun loadPdfAutoScrollMinSpeed(context: Context): Float {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getFloat(PDF_AUTO_SCROLL_MIN_SPEED_KEY, 0.1f)
|
||||
}
|
||||
|
||||
internal fun savePdfAutoScrollMaxSpeed(context: Context, speed: Float) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putFloat(PDF_AUTO_SCROLL_MAX_SPEED_KEY, speed) }
|
||||
}
|
||||
|
||||
internal fun loadPdfAutoScrollMaxSpeed(context: Context): Float {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getFloat(PDF_AUTO_SCROLL_MAX_SPEED_KEY, 10.0f)
|
||||
}
|
||||
|
||||
internal fun savePdfAutoScrollUseSlider(context: Context, useSlider: Boolean) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putBoolean(PDF_AUTO_SCROLL_USE_SLIDER_KEY, useSlider) }
|
||||
}
|
||||
|
||||
internal fun loadPdfAutoScrollUseSlider(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getBoolean(PDF_AUTO_SCROLL_USE_SLIDER_KEY, false)
|
||||
}
|
||||
|
||||
internal fun savePdfAutoScrollSpeed(context: Context, speed: Float) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putFloat(PDF_AUTO_SCROLL_SPEED_KEY, speed) }
|
||||
}
|
||||
|
||||
internal fun loadPdfAutoScrollSpeed(context: Context): Float {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getFloat(PDF_AUTO_SCROLL_SPEED_KEY, 3.0f)
|
||||
}
|
||||
|
||||
internal fun savePdfLockedState(context: Context, bookId: String, scale: Float, offsetX: Float, offsetY: Float) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit {
|
||||
putFloat("pdf_locked_scale_$bookId", scale)
|
||||
putFloat("pdf_locked_offset_x_$bookId", offsetX)
|
||||
putFloat("pdf_locked_offset_y_$bookId", offsetY)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun loadPdfLockedState(context: Context, bookId: String): Triple<Float, Float, Float>? {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
if (!prefs.contains("pdf_locked_scale_$bookId")) return null
|
||||
return Triple(
|
||||
prefs.getFloat("pdf_locked_scale_$bookId", 1f),
|
||||
prefs.getFloat("pdf_locked_offset_x_$bookId", 0f),
|
||||
prefs.getFloat("pdf_locked_offset_y_$bookId", 0f)
|
||||
)
|
||||
}
|
||||
|
||||
internal fun saveOcrLanguage(context: Context, language: OcrLanguage) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit {
|
||||
putString(OCR_LANGUAGE_KEY, language.name)
|
||||
putBoolean(OCR_LANGUAGE_SELECTED_KEY, true)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun loadOcrLanguage(context: Context): OcrLanguage {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val name = prefs.getString(OCR_LANGUAGE_KEY, OcrLanguage.LATIN.name)
|
||||
return try {
|
||||
OcrLanguage.valueOf(name ?: OcrLanguage.LATIN.name)
|
||||
} catch (_: Exception) {
|
||||
OcrLanguage.LATIN
|
||||
}
|
||||
}
|
||||
|
||||
internal fun hasUserSelectedOcrLanguage(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getBoolean(OCR_LANGUAGE_SELECTED_KEY, false)
|
||||
}
|
||||
|
||||
internal fun saveDockState(context: Context, location: DockLocation, offset: Offset) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit {
|
||||
putString(DOCK_LOCATION_KEY, location.name)
|
||||
putFloat(DOCK_OFFSET_X_KEY, offset.x)
|
||||
putFloat(DOCK_OFFSET_Y_KEY, offset.y)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun loadDockState(context: Context): Pair<DockLocation, Offset> {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val locName = prefs.getString(DOCK_LOCATION_KEY, DockLocation.BOTTOM.name)
|
||||
val location = try {
|
||||
DockLocation.valueOf(locName ?: DockLocation.BOTTOM.name)
|
||||
} catch (_: Exception) {
|
||||
DockLocation.BOTTOM
|
||||
}
|
||||
|
||||
val x = prefs.getFloat(DOCK_OFFSET_X_KEY, 0f)
|
||||
val y = prefs.getFloat(DOCK_OFFSET_Y_KEY, 0f)
|
||||
|
||||
return location to Offset(x, y)
|
||||
}
|
||||
|
||||
internal fun saveDisplayMode(context: Context, mode: DisplayMode) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putString(DISPLAY_MODE_KEY, mode.name) }
|
||||
}
|
||||
|
||||
internal fun loadDisplayMode(context: Context): DisplayMode {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val modeName = prefs.getString(DISPLAY_MODE_KEY, DisplayMode.VERTICAL_SCROLL.name)
|
||||
return try {
|
||||
DisplayMode.valueOf(modeName ?: DisplayMode.VERTICAL_SCROLL.name)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
DisplayMode.VERTICAL_SCROLL
|
||||
}
|
||||
}
|
||||
|
||||
internal data class TtsPageData(
|
||||
val pageIndex: Int, val processedText: ProcessedText, val fromOcr: Boolean
|
||||
)
|
||||
|
||||
internal fun savePdfDarkMode(context: Context, isDark: Boolean) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putBoolean(PDF_DARK_MODE_KEY, isDark) }
|
||||
}
|
||||
|
||||
internal fun loadPdfDarkMode(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getBoolean(PDF_DARK_MODE_KEY, false)
|
||||
}
|
||||
236
app/src/main/java/com/aryan/reader/pdf/PdfSearchUI.kt
Normal file
236
app/src/main/java/com/aryan/reader/pdf/PdfSearchUI.kt
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
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.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.itemContentType
|
||||
import androidx.paging.compose.itemKey
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.SearchResult
|
||||
|
||||
@Composable
|
||||
internal fun SearchNavigationPill(
|
||||
text: String,
|
||||
mode: SearchHighlightMode,
|
||||
onToggleMode: () -> Unit,
|
||||
onPrev: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onTextClick: () -> Unit,
|
||||
isPrevEnabled: Boolean = true,
|
||||
isNextEnabled: Boolean = true
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.shadow(6.dp, RoundedCornerShape(50))
|
||||
.height(56.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
) {
|
||||
// Toggle Mode
|
||||
IconButton(onClick = onToggleMode) {
|
||||
Icon(
|
||||
imageVector = if (mode == SearchHighlightMode.ALL) Icons.Default.Visibility
|
||||
else Icons.Default.VisibilityOff,
|
||||
contentDescription = "Toggle Highlights",
|
||||
tint = if (mode == SearchHighlightMode.ALL) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// Vertical Divider
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(1.dp)
|
||||
.height(24.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||
alpha = 0.3f
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// Prev
|
||||
IconButton(onClick = onPrev, enabled = isPrevEnabled) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyboardArrowUp,
|
||||
contentDescription = "Previous",
|
||||
tint = if (isPrevEnabled) MaterialTheme.colorScheme.onSurface
|
||||
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
)
|
||||
}
|
||||
|
||||
// Counter/Text
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onTextClick
|
||||
)
|
||||
.padding(horizontal = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
// Next
|
||||
IconButton(onClick = onNext, enabled = isNextEnabled) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = "Next",
|
||||
tint = if (isNextEnabled) MaterialTheme.colorScheme.onSurface
|
||||
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PdfSearchResultsPanel(
|
||||
lazyResults: LazyPagingItems<SearchResult>,
|
||||
totalPageCount: Int,
|
||||
onResultClick: (SearchResult) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
if (lazyResults.itemCount == 0 && lazyResults.loadState.refresh !is LoadState.Loading) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("No results found.", style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
} else {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.msg_results_found_pages),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
)
|
||||
HorizontalDivider()
|
||||
|
||||
LazyColumn(modifier = Modifier.testTag("SearchResultsList")) {
|
||||
items(count = lazyResults.itemCount, key = lazyResults.itemKey {
|
||||
"${it.locationInSource}_${it.occurrenceIndexInLocation}"
|
||||
}, contentType = lazyResults.itemContentType { "SearchResult" }) { index ->
|
||||
val result = lazyResults[index]
|
||||
if (result != null) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
result.locationTitle,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}, supportingContent = {
|
||||
Text(
|
||||
result.snippet, style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}, modifier = Modifier
|
||||
.testTag(
|
||||
"SearchResultItem_${result.locationInSource}"
|
||||
)
|
||||
.clickable { onResultClick(result) })
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lazyResults.loadState.refresh is LoadState.Loading) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PdfSearchResultsList(
|
||||
results: List<SearchResult>,
|
||||
onResultClick: (SearchResult) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
if (results.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(stringResource(R.string.search_no_results_simple), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
} else {
|
||||
Column {
|
||||
Text(
|
||||
text = "${results.size} matches found",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
)
|
||||
HorizontalDivider()
|
||||
|
||||
LazyColumn(modifier = Modifier.testTag("SearchResultsList")) {
|
||||
itemsIndexed(results) { _, result ->
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
result.locationTitle,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}, supportingContent = {
|
||||
Text(
|
||||
result.snippet, style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}, modifier = Modifier
|
||||
.testTag(
|
||||
"SearchResultItem_${result.locationInSource}"
|
||||
)
|
||||
.clickable { onResultClick(result) })
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
153
app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt
Normal file
153
app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
@file:kotlin.OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package com.aryan.reader.pdf
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.epubreader.OptionSegmentedControl
|
||||
import com.aryan.reader.epubreader.SystemUiMode
|
||||
|
||||
@Composable
|
||||
fun PdfCustomizeToolsSheet(
|
||||
hiddenTools: Set<String>,
|
||||
onUpdate: (Set<String>) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
contentWindowInsets = { WindowInsets.navigationBars }
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 24.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.title_customize_toolbar),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.desc_customize_toolbar),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
||||
PdfReaderTool.entries.groupBy { it.category }.forEach { (category, tools) ->
|
||||
item {
|
||||
Text(
|
||||
text = category,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
items(tools) { tool ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable {
|
||||
val newSet = hiddenTools.toMutableSet()
|
||||
if (newSet.contains(tool.name)) newSet.remove(tool.name)
|
||||
else newSet.add(tool.name)
|
||||
onUpdate(newSet)
|
||||
}
|
||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = tool.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Switch(
|
||||
checked = !hiddenTools.contains(tool.name),
|
||||
onCheckedChange = { isVisible ->
|
||||
val newSet = hiddenTools.toMutableSet()
|
||||
if (isVisible) newSet.remove(tool.name) else newSet.add(tool.name)
|
||||
onUpdate(newSet)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PdfVisualOptionsSheet(
|
||||
systemUiMode: SystemUiMode,
|
||||
onSystemUiModeChange: (SystemUiMode) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentWindowInsets = { WindowInsets.navigationBars }
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||
.padding(bottom = 32.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(stringResource(R.string.menu_visual_options), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(stringResource(R.string.visual_options_system_ui), style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.visual_options_system_ui_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OptionSegmentedControl(
|
||||
options = SystemUiMode.entries,
|
||||
selectedOption = systemUiMode,
|
||||
onOptionSelected = onSystemUiModeChange,
|
||||
getLabel = { it.title }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -21,7 +21,8 @@ package com.aryan.reader.pdf
|
|||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
|
|
@ -52,7 +53,12 @@ import androidx.compose.ui.geometry.Offset
|
|||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.changedToUp
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
|
|
@ -78,6 +84,49 @@ enum class HandlePosition {
|
|||
TOP, BOTTOM, AUTO
|
||||
}
|
||||
|
||||
// Eagerly consumes pointer events so parent scaled pan/zoom gestures don't intercept it
|
||||
suspend fun PointerInputScope.detectEagerDragGestures(
|
||||
onDragStart: (Offset) -> Unit,
|
||||
onDragEnd: () -> Unit,
|
||||
onDragCancel: () -> Unit,
|
||||
onDrag: (PointerInputChange, Offset) -> Unit
|
||||
) {
|
||||
awaitEachGesture {
|
||||
var dragStarted = false
|
||||
try {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
down.consume() // Consume immediately
|
||||
onDragStart(down.position)
|
||||
dragStarted = true
|
||||
val pointerId = down.id
|
||||
var canceled = false
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == pointerId }
|
||||
if (change == null) {
|
||||
canceled = true
|
||||
break
|
||||
}
|
||||
if (change.changedToUp()) {
|
||||
change.consume()
|
||||
break
|
||||
}
|
||||
if (change.positionChanged()) {
|
||||
val dragAmount = change.position - change.previousPosition
|
||||
change.consume()
|
||||
onDrag(change, dragAmount)
|
||||
}
|
||||
}
|
||||
if (canceled) onDragCancel() else onDragEnd()
|
||||
dragStarted = false
|
||||
} finally {
|
||||
if (dragStarted) {
|
||||
onDragCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ResizableTextBox(
|
||||
box: PdfTextBox,
|
||||
|
|
@ -86,6 +135,7 @@ fun ResizableTextBox(
|
|||
isDarkMode: Boolean,
|
||||
pageWidthPx: Float,
|
||||
pageHeightPx: Float,
|
||||
scale: Float = 1f,
|
||||
onBoundsChanged: (Rect) -> Unit,
|
||||
onTextChanged: (String) -> Unit,
|
||||
onSelect: () -> Unit,
|
||||
|
|
@ -101,8 +151,9 @@ fun ResizableTextBox(
|
|||
val density = LocalDensity.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
val handleSize = 10.dp
|
||||
val handleTouchSize = 40.dp
|
||||
// Counter-scale fixed sizes so they render proportionally regardless of the zoom level
|
||||
val handleSize = (10f / scale).dp
|
||||
val handleTouchSize = (40f / scale).dp
|
||||
val handleSizePx = with(density) { handleSize.toPx() }
|
||||
val halfHandlePx = handleSizePx / 2f
|
||||
val handleTouchSizePx = with(density) { handleTouchSize.toPx() }
|
||||
|
|
@ -126,7 +177,9 @@ fun ResizableTextBox(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
androidx.compose.runtime.SideEffect {
|
||||
Timber.tag("PdfTextBoxDebug").v("ResizableTextBox Recompose [ID: ${box.id}] | isSelected=$isSelected | scale=$scale | pagePx=${pageWidthPx}x${pageHeightPx} | bounds=${box.relativeBounds}")
|
||||
}
|
||||
var currentRectPx by remember {
|
||||
mutableStateOf(
|
||||
Rect(
|
||||
|
|
@ -152,6 +205,8 @@ fun ResizableTextBox(
|
|||
right = box.relativeBounds.right * pageWidthPx,
|
||||
bottom = box.relativeBounds.bottom * pageHeightPx
|
||||
)
|
||||
Timber.tag("PdfTextBoxDebug").d("LaunchedEffect bounds recalculation [ID: ${box.id}] | currentRectPx=$newPx")
|
||||
|
||||
if (kotlin.math.abs(newPx.left - currentRectPx.left) > 1f ||
|
||||
kotlin.math.abs(newPx.top - currentRectPx.top) > 1f ||
|
||||
kotlin.math.abs(newPx.width - currentRectPx.width) > 1f ||
|
||||
|
|
@ -162,20 +217,19 @@ fun ResizableTextBox(
|
|||
}
|
||||
}
|
||||
|
||||
val requiredBottomSpacePx = with(density) { 60.dp.toPx() }
|
||||
val requiredBottomSpacePx = with(density) { 60.dp.toPx() } / scale
|
||||
|
||||
val isHandleAtTop by remember(currentRectPx, pageHeightPx, handlePosition) {
|
||||
derivedStateOf {
|
||||
when (handlePosition) {
|
||||
// Freeze handle position while dragging to prevent UI jumping
|
||||
var isHandleAtTop by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(currentRectPx, pageHeightPx, handlePosition, requiredBottomSpacePx, isDraggingOrResizing) {
|
||||
if (!isDraggingOrResizing) {
|
||||
isHandleAtTop = when (handlePosition) {
|
||||
HandlePosition.TOP -> true
|
||||
HandlePosition.BOTTOM -> false
|
||||
HandlePosition.AUTO -> {
|
||||
if (pageHeightPx <= 0f) {
|
||||
false
|
||||
} else {
|
||||
val spaceBelow = pageHeightPx - currentRectPx.bottom
|
||||
spaceBelow < requiredBottomSpacePx
|
||||
}
|
||||
if (pageHeightPx <= 0f) false
|
||||
else (pageHeightPx - currentRectPx.bottom) < requiredBottomSpacePx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -184,11 +238,9 @@ fun ResizableTextBox(
|
|||
Box(
|
||||
modifier = modifier
|
||||
.zIndex(if (isSelected) 10f else 0f)
|
||||
.offset {
|
||||
IntOffset(
|
||||
(currentRectPx.left - halfHandlePx).roundToInt(),
|
||||
(currentRectPx.top - halfHandlePx).roundToInt()
|
||||
)
|
||||
.graphicsLayer {
|
||||
translationX = currentRectPx.left - halfHandlePx
|
||||
translationY = currentRectPx.top - halfHandlePx
|
||||
}
|
||||
.size(
|
||||
width = with(density) { (currentRectPx.width + handleSizePx).toDp() },
|
||||
|
|
@ -201,10 +253,13 @@ fun ResizableTextBox(
|
|||
.fillMaxSize()
|
||||
.padding(handleSize / 2)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { onSelect() }
|
||||
detectTapGestures {
|
||||
Timber.tag("PdfTextBoxDebug").d("TextBox Tapped[ID: ${box.id}]")
|
||||
onSelect()
|
||||
}
|
||||
}
|
||||
.then(
|
||||
if (isSelected) Modifier.border(1.5.dp, borderColor) else Modifier
|
||||
if (isSelected) Modifier.border((1.5f / scale).dp, borderColor) else Modifier
|
||||
)
|
||||
) {
|
||||
BasicTextField(
|
||||
|
|
@ -220,7 +275,7 @@ fun ResizableTextBox(
|
|||
background = box.backgroundColor,
|
||||
fontFamily = fontFamily,
|
||||
fontSize = with(LocalDensity.current) {
|
||||
(box.fontSize * pageHeightPx).coerceAtLeast(10f).toSp()
|
||||
(box.fontSize * pageHeightPx).toSp()
|
||||
},
|
||||
fontWeight = if (box.isBold) FontWeight.Bold else FontWeight.Normal,
|
||||
fontStyle = if (box.isItalic) FontStyle.Italic else FontStyle.Normal,
|
||||
|
|
@ -267,8 +322,11 @@ fun ResizableTextBox(
|
|||
}
|
||||
.size(handleTouchSize)
|
||||
.pointerInput(onBoundsChanged) {
|
||||
detectDragGestures(
|
||||
onDragStart = { isDraggingOrResizing = true },
|
||||
detectEagerDragGestures(
|
||||
onDragStart = {
|
||||
Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragStart[ID: ${box.id}] Handle=$handle")
|
||||
isDraggingOrResizing = true
|
||||
},
|
||||
onDragEnd = {
|
||||
isDraggingOrResizing = false
|
||||
val normalized = Rect(
|
||||
|
|
@ -277,40 +335,42 @@ fun ResizableTextBox(
|
|||
right = currentRectPx.right / pageWidthPx,
|
||||
bottom = currentRectPx.bottom / pageHeightPx
|
||||
)
|
||||
Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragEnd [ID: ${box.id}] finalNormalized=$normalized")
|
||||
onBoundsChanged(normalized)
|
||||
},
|
||||
onDragCancel = { isDraggingOrResizing = false }
|
||||
) { change, dragAmount ->
|
||||
change.consume()
|
||||
Timber.tag("PdfTextBoxDebug").v("ResizeHandle Drag [ID: ${box.id}] Handle=$handle | dragAmount=$dragAmount")
|
||||
|
||||
var l = currentRectPx.left
|
||||
var t = currentRectPx.top
|
||||
var r = currentRectPx.right
|
||||
var b = currentRectPx.bottom
|
||||
val dx = dragAmount.x
|
||||
val dy = dragAmount.y
|
||||
val minSize = 50f
|
||||
val minSize = 50f / scale
|
||||
|
||||
when (handle) {
|
||||
ResizeHandle.TOP_LEFT -> {
|
||||
l = (l + dx).coerceIn(0f, r - minSize)
|
||||
t = (t + dy).coerceIn(0f, b - minSize)
|
||||
l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
|
||||
t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
|
||||
}
|
||||
ResizeHandle.TOP_CENTER -> t = (t + dy).coerceIn(0f, b - minSize)
|
||||
ResizeHandle.TOP_CENTER -> t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
|
||||
ResizeHandle.TOP_RIGHT -> {
|
||||
r = (r + dx).coerceIn(l + minSize, pageWidthPx)
|
||||
t = (t + dy).coerceIn(0f, b - minSize)
|
||||
r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
|
||||
t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
|
||||
}
|
||||
ResizeHandle.RIGHT_CENTER -> r = (r + dx).coerceIn(l + minSize, pageWidthPx)
|
||||
ResizeHandle.RIGHT_CENTER -> r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
|
||||
ResizeHandle.BOTTOM_RIGHT -> {
|
||||
r = (r + dx).coerceIn(l + minSize, pageWidthPx)
|
||||
b = (b + dy).coerceIn(t + minSize, pageHeightPx)
|
||||
r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
|
||||
b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
|
||||
}
|
||||
ResizeHandle.BOTTOM_CENTER -> b = (b + dy).coerceIn(t + minSize, pageHeightPx)
|
||||
ResizeHandle.BOTTOM_CENTER -> b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
|
||||
ResizeHandle.BOTTOM_LEFT -> {
|
||||
l = (l + dx).coerceIn(0f, r - minSize)
|
||||
b = (b + dy).coerceIn(t + minSize, pageHeightPx)
|
||||
l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
|
||||
b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
|
||||
}
|
||||
ResizeHandle.LEFT_CENTER -> l = (l + dx).coerceIn(0f, r - minSize)
|
||||
ResizeHandle.LEFT_CENTER -> l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
|
||||
else -> {}
|
||||
}
|
||||
currentRectPx = Rect(l, t, r, b)
|
||||
|
|
@ -328,13 +388,15 @@ fun ResizableTextBox(
|
|||
|
||||
DragPill(
|
||||
isDarkMode = isDarkMode,
|
||||
scale = scale,
|
||||
modifier = Modifier
|
||||
.align(if (isHandleAtTop) Alignment.TopCenter else Alignment.BottomCenter)
|
||||
.offset(y = if (isHandleAtTop) (-32).dp else 32.dp)
|
||||
.offset(y = if (isHandleAtTop) (-32f / scale).dp else (32f / scale).dp)
|
||||
.zIndex(20f)
|
||||
.pointerInput(pageWidthPx, pageHeightPx, onDragStart, onDragEnd, onDragCancel) {
|
||||
detectDragGestures(
|
||||
detectEagerDragGestures(
|
||||
onDragStart = { offset ->
|
||||
Timber.tag("PdfTextBoxDebug").d("DragPill DragStart [ID: ${box.id}] at offset=$offset")
|
||||
isDraggingOrResizing = true
|
||||
onDragStart(offset)
|
||||
},
|
||||
|
|
@ -346,6 +408,7 @@ fun ResizableTextBox(
|
|||
right = currentRectPx.right / pageWidthPx,
|
||||
bottom = currentRectPx.bottom / pageHeightPx
|
||||
)
|
||||
Timber.tag("PdfTextBoxDebug").d("DragPill DragEnd[ID: ${box.id}] finalNormalized=$normalized")
|
||||
onBoundsChanged(normalized)
|
||||
onDragEnd()
|
||||
},
|
||||
|
|
@ -354,13 +417,12 @@ fun ResizableTextBox(
|
|||
onDragCancel()
|
||||
}
|
||||
) { change, dragAmount ->
|
||||
change.consume()
|
||||
val w = currentRectPx.width
|
||||
val h = currentRectPx.height
|
||||
val rawLeft = currentRectPx.left + dragAmount.x
|
||||
val rawTop = currentRectPx.top + dragAmount.y
|
||||
val newLeft = rawLeft.coerceIn(0f, pageWidthPx - w)
|
||||
val newTop = rawTop.coerceIn(0f, pageHeightPx - h)
|
||||
val newLeft = rawLeft.coerceIn(0f, maxOf(0f, pageWidthPx - w))
|
||||
val newTop = rawTop.coerceIn(0f, maxOf(0f, pageHeightPx - h))
|
||||
val newRect = Rect(newLeft, newTop, newLeft + w, newTop + h)
|
||||
currentRectPx = newRect
|
||||
onDrag(dragAmount, newRect)
|
||||
|
|
@ -374,21 +436,22 @@ fun ResizableTextBox(
|
|||
@Composable
|
||||
private fun DragPill(
|
||||
modifier: Modifier = Modifier,
|
||||
isDarkMode: Boolean
|
||||
isDarkMode: Boolean,
|
||||
scale: Float = 1f
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.size(width = 48.dp, height = 24.dp),
|
||||
.size(width = (48f / scale).dp, height = (24f / scale).dp),
|
||||
shape = CircleShape,
|
||||
color = if (isDarkMode) Color.White else Color.Black,
|
||||
contentColor = if (isDarkMode) Color.Black else Color.White,
|
||||
shadowElevation = 4.dp
|
||||
shadowElevation = (4f / scale).dp
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.drag_handle),
|
||||
contentDescription = "Drag to move text box",
|
||||
modifier = Modifier.size(20.dp)
|
||||
modifier = Modifier.size((20f / scale).dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
229
app/src/main/java/com/aryan/reader/pdf/PdfTocAndBookmarks.kt
Normal file
229
app/src/main/java/com/aryan/reader/pdf/PdfTocAndBookmarks.kt
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
541
app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt
Normal file
541
app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt
Normal file
|
|
@ -0,0 +1,541 @@
|
|||
// PdfToolbars.kt
|
||||
package com.aryan.reader.pdf
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
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.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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 com.aryan.reader.BuildConfig
|
||||
import com.aryan.reader.FileType
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.SearchState
|
||||
import com.aryan.reader.SearchTopBar
|
||||
import com.aryan.reader.TooltipIconButton
|
||||
import com.aryan.reader.epubreader.SystemUiMode
|
||||
import kotlin.collections.isNotEmpty
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun PdfTopBar(
|
||||
modifier: Modifier = Modifier,
|
||||
showStandardBars: Boolean,
|
||||
systemUiMode: SystemUiMode,
|
||||
statusBarHeightDp: Dp,
|
||||
searchState: SearchState,
|
||||
focusRequester: FocusRequester,
|
||||
onCloseSearch: () -> Unit,
|
||||
isLoadingDocument: Boolean,
|
||||
errorMessage: String?,
|
||||
currentPageForDisplay: Int,
|
||||
totalPages: Int,
|
||||
pagerStatePageCount: Int,
|
||||
hiddenTools: Set<String>,
|
||||
isScrollLocked: Boolean,
|
||||
isEditMode: Boolean,
|
||||
displayMode: DisplayMode,
|
||||
isKeepScreenOn: Boolean,
|
||||
isTtsSessionActive: Boolean,
|
||||
isBookmarked: Boolean,
|
||||
canDeletePage: Boolean,
|
||||
isReflowingThisBook: Boolean,
|
||||
hasReflowFile: Boolean,
|
||||
isPdfDocumentLoaded: Boolean,
|
||||
isTabsEnabled: Boolean,
|
||||
openTabs: List<RecentFileItem>,
|
||||
activeTabBookId: String?,
|
||||
effectiveFileType: FileType,
|
||||
onNavigateBack: () -> Unit,
|
||||
onShowThemePanel: () -> Unit,
|
||||
onToggleScrollLock: () -> Unit,
|
||||
onShowDictionarySettings: () -> Unit,
|
||||
onShowPenPlayground: () -> Unit,
|
||||
onImportSvg: () -> Unit,
|
||||
onShowCustomizeTools: () -> Unit,
|
||||
onShowOcrLanguage: () -> Unit,
|
||||
onShowVisualOptions: () -> Unit,
|
||||
onChangeDisplayMode: (DisplayMode) -> Unit,
|
||||
onToggleKeepScreenOn: () -> Unit,
|
||||
onStartAutoScroll: () -> Unit,
|
||||
onShowTtsSettings: () -> Unit,
|
||||
onToggleBookmark: () -> Unit,
|
||||
onInsertPage: () -> Unit,
|
||||
onDeletePage: () -> Unit,
|
||||
onReflowAction: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
onSaveCopy: () -> Unit,
|
||||
onPrint: () -> Unit,
|
||||
onTabClick: (String) -> Unit,
|
||||
onTabClose: (String) -> Unit,
|
||||
onNewTabClick: () -> Unit
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = showStandardBars,
|
||||
enter = slideInVertically(animationSpec = tween(200)) { fullHeight -> -fullHeight } + fadeIn(animationSpec = tween(200)),
|
||||
exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> -fullHeight } + fadeOut(animationSpec = tween(200)),
|
||||
modifier = modifier
|
||||
) {
|
||||
val isStatusBarVisible = when (systemUiMode) {
|
||||
SystemUiMode.DEFAULT -> true
|
||||
SystemUiMode.SYNC -> showStandardBars
|
||||
SystemUiMode.HIDDEN -> false
|
||||
}
|
||||
val topBarPadding = if (isStatusBarVisible) statusBarHeightDp else 0.dp
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 4.dp
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(top = topBarPadding)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (searchState.isSearchActive) {
|
||||
SearchTopBar(
|
||||
searchState = searchState,
|
||||
focusRequester = focusRequester,
|
||||
onCloseSearch = onCloseSearch
|
||||
)
|
||||
} else {
|
||||
TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_back),
|
||||
description = stringResource(R.string.tooltip_back_desc),
|
||||
onClick = onNavigateBack
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
val titleText = when {
|
||||
isLoadingDocument -> stringResource(R.string.loading_pdf)
|
||||
errorMessage != null -> stringResource(R.string.error_loading_pdf)
|
||||
totalPages > 0 && pagerStatePageCount > 0 -> "Page ${currentPageForDisplay + 1} of $totalPages"
|
||||
totalPages > 0 && pagerStatePageCount == 0 -> stringResource(R.string.loading_page)
|
||||
else -> "PDF Viewer"
|
||||
}
|
||||
Text(
|
||||
text = titleText,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = 12.dp).weight(1f).testTag("PageNumberIndicator")
|
||||
)
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.THEME.name)) {
|
||||
TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_theme),
|
||||
description = stringResource(R.string.tooltip_theme_desc),
|
||||
onClick = onShowThemePanel
|
||||
) {
|
||||
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.LOCK_PANNING.name)) {
|
||||
TooltipIconButton(
|
||||
text = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan),
|
||||
description = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan_desc) else stringResource(R.string.tooltip_lock_pan_desc),
|
||||
onClick = onToggleScrollLock
|
||||
) {
|
||||
Icon(if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, contentDescription = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.DICTIONARY.name)) {
|
||||
TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_dictionary),
|
||||
description = stringResource(R.string.tooltip_dictionary_desc),
|
||||
onClick = onShowDictionarySettings
|
||||
) {
|
||||
Icon(painterResource(id = R.drawable.dictionary), contentDescription = "Dictionary Settings", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
TooltipIconButton(text = stringResource(R.string.pen_playground), onClick = onShowPenPlayground) {
|
||||
Icon(Icons.Default.Star, contentDescription = "Open Pen Playground", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
TooltipIconButton(text = stringResource(R.string.import_svg), onClick = onImportSvg) {
|
||||
Icon(Icons.Default.Brush, contentDescription = stringResource(R.string.import_svg), tint = Color(0xFFE91E63))
|
||||
}
|
||||
}
|
||||
|
||||
Box {
|
||||
var showMoreMenu by remember { mutableStateOf(false) }
|
||||
TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_more_options),
|
||||
description = stringResource(R.string.tooltip_more_options_desc),
|
||||
onClick = { showMoreMenu = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.tooltip_more_options))
|
||||
}
|
||||
|
||||
DropdownMenu(expanded = showMoreMenu, onDismissRequest = { showMoreMenu = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.title_customize_toolbar)) },
|
||||
onClick = { showMoreMenu = false; onShowCustomizeTools() },
|
||||
leadingIcon = { Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.title_customize_toolbar), modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
|
||||
if (BuildConfig.IS_PRO && !hiddenTools.contains(PdfReaderTool.OCR_LANGUAGE.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_ocr_language)) },
|
||||
onClick = { showMoreMenu = false; onShowOcrLanguage() }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.VISUAL_OPTIONS.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_visual_options)) },
|
||||
onClick = { showMoreMenu = false; onShowVisualOptions() },
|
||||
leadingIcon = { Icon(Icons.Default.Visibility, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.READING_MODE.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = { onChangeDisplayMode(DisplayMode.VERTICAL_SCROLL); showMoreMenu = false },
|
||||
trailingIcon = { if (displayMode == DisplayMode.VERTICAL_SCROLL) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = { onChangeDisplayMode(DisplayMode.PAGINATION); showMoreMenu = false },
|
||||
trailingIcon = { if (displayMode == DisplayMode.PAGINATION) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.KEEP_SCREEN_ON.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_keep_screen_on)) },
|
||||
onClick = { onToggleKeepScreenOn(); showMoreMenu = false },
|
||||
trailingIcon = { if (isKeepScreenOn) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.AUTO_SCROLL.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_auto_scroll)) },
|
||||
enabled = !isTtsSessionActive && displayMode == DisplayMode.VERTICAL_SCROLL,
|
||||
onClick = { showMoreMenu = false; onStartAutoScroll() }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = { showMoreMenu = false; onShowTtsSettings() },
|
||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(R.string.menu_bookmark_this_page)) },
|
||||
onClick = { showMoreMenu = false; onToggleBookmark() }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.PAGE_MANAGEMENT.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_insert_blank_page)) },
|
||||
onClick = { showMoreMenu = false; onInsertPage() }
|
||||
)
|
||||
if (canDeletePage) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_delete_page)) },
|
||||
onClick = { showMoreMenu = false; onDeletePage() },
|
||||
colors = MenuDefaults.itemColors(textColor = MaterialTheme.colorScheme.error)
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.REFLOW.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(when { isReflowingThisBook -> stringResource(R.string.generating_reflow_progress); hasReflowFile -> stringResource(R.string.action_open_text_view); else -> stringResource(R.string.action_generate_text_view) }) },
|
||||
enabled = isPdfDocumentLoaded && !isReflowingThisBook,
|
||||
onClick = { showMoreMenu = false; onReflowAction() },
|
||||
leadingIcon = { Icon(painterResource(id = R.drawable.format_size), contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.SHARE.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_share)) },
|
||||
onClick = { showMoreMenu = false; onShare() },
|
||||
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
|
||||
if (effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_save_copy_to_device)) },
|
||||
onClick = { showMoreMenu = false; onSaveCopy() },
|
||||
leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
|
||||
if (effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_print)) },
|
||||
onClick = { showMoreMenu = false; onPrint() },
|
||||
leadingIcon = { Icon(painterResource(id = R.drawable.print), contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isTabsEnabled && openTabs.isNotEmpty() && effectiveFileType == FileType.PDF) {
|
||||
LazyRow(
|
||||
modifier = Modifier.fillMaxWidth().height(44.dp).background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
items(openTabs, key = { it.bookId }) { tab ->
|
||||
val isSelected = tab.bookId == activeTabBookId
|
||||
val bgColor = if (isSelected) MaterialTheme.colorScheme.surface else Color.Transparent
|
||||
val contentColor = if (isSelected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(if (isSelected) 44.dp else 36.dp)
|
||||
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
||||
.background(bgColor)
|
||||
.clickable { onTabClick(tab.bookId) }
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = tab.customName ?: tab.title ?: tab.displayName,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.widthIn(max = 140.dp),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = contentColor
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
IconButton(
|
||||
onClick = { onTabClose(tab.bookId) },
|
||||
modifier = Modifier.size(20.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.close_tab), modifier = Modifier.size(16.dp), tint = contentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
IconButton(onClick = onNewTabClick, modifier = Modifier.padding(start = 8.dp, bottom = 4.dp).size(36.dp)) {
|
||||
Icon(Icons.Default.Add, contentDescription = "New Tab", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ReflowProgressOverlay(
|
||||
modifier: Modifier = Modifier,
|
||||
showStandardBars: Boolean,
|
||||
isReflowingThisBook: Boolean,
|
||||
reflowProgressValue: Float
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = showStandardBars && isReflowingThisBook,
|
||||
enter = fadeIn(animationSpec = tween(200)) + slideInVertically(animationSpec = tween(200)),
|
||||
exit = fadeOut(animationSpec = tween(200)) + slideOutVertically(animationSpec = tween(200)),
|
||||
modifier = modifier
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp),
|
||||
shadowElevation = 4.dp
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(R.string.generating_text_view),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = "${(reflowProgressValue * 100).toInt()}%",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { reflowProgressValue },
|
||||
modifier = Modifier.fillMaxWidth().height(6.dp),
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PdfBottomBar(
|
||||
modifier: Modifier = Modifier,
|
||||
showStandardBars: Boolean,
|
||||
searchStateActive: Boolean,
|
||||
systemUiMode: SystemUiMode,
|
||||
navBarHeightDp: Dp,
|
||||
hiddenTools: Set<String>,
|
||||
isTtsPlayingOrLoading: Boolean,
|
||||
showAllTextHighlights: Boolean,
|
||||
isHighlightingLoading: Boolean,
|
||||
isEditMode: Boolean,
|
||||
isTtsSessionActive: Boolean,
|
||||
ttsErrorMessage: String?,
|
||||
onShowSlider: () -> Unit,
|
||||
onShowToc: () -> Unit,
|
||||
onSearchClick: () -> Unit,
|
||||
onToggleHighlights: () -> Unit,
|
||||
onShowAiHub: () -> Unit,
|
||||
onToggleEditMode: () -> Unit,
|
||||
onToggleTts: () -> Unit
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = showStandardBars && !searchStateActive,
|
||||
enter = slideInVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeIn(animationSpec = tween(200)),
|
||||
exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeOut(animationSpec = tween(200)),
|
||||
modifier = modifier
|
||||
) {
|
||||
val isNavBarVisible = systemUiMode != SystemUiMode.HIDDEN
|
||||
val bottomBarPadding = if (isNavBarVisible) navBarHeightDp else 0.dp
|
||||
|
||||
Surface(modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surface, tonalElevation = 4.dp) {
|
||||
val bottomBarScrollState = rememberScrollState()
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = bottomBarPadding).height(56.dp).padding(horizontal = 8.dp).horizontalScroll(bottomBarScrollState),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (!hiddenTools.contains(PdfReaderTool.SLIDER.name)) {
|
||||
TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_slider),
|
||||
description = stringResource(R.string.tooltip_slider_desc),
|
||||
onClick = onShowSlider,
|
||||
enabled = !isTtsPlayingOrLoading
|
||||
) {
|
||||
Icon(painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider))
|
||||
}
|
||||
}
|
||||
if (!hiddenTools.contains(PdfReaderTool.TOC.name)) {
|
||||
TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_toc),
|
||||
description = stringResource(R.string.tooltip_toc_desc),
|
||||
onClick = onShowToc,
|
||||
enabled = !isTtsPlayingOrLoading,
|
||||
modifier = Modifier.testTag("TocButton")
|
||||
) {
|
||||
Icon(Icons.Default.Menu, contentDescription = "Table of Contents")
|
||||
}
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.SEARCH.name)) {
|
||||
TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_search),
|
||||
description = stringResource(R.string.tooltip_search_desc),
|
||||
onClick = onSearchClick,
|
||||
enabled = !isTtsPlayingOrLoading,
|
||||
modifier = Modifier.testTag("SearchButton")
|
||||
) {
|
||||
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
|
||||
}
|
||||
}
|
||||
if (!hiddenTools.contains(PdfReaderTool.HIGHLIGHT_ALL.name)) {
|
||||
TooltipIconButton(
|
||||
text = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off) else stringResource(R.string.tooltip_highlights),
|
||||
description = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off_desc) else stringResource(R.string.tooltip_highlights_desc),
|
||||
onClick = onToggleHighlights
|
||||
) {
|
||||
if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(24.dp))
|
||||
else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = "Highlight all text", tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
if (BuildConfig.FLAVOR != "oss" && !hiddenTools.contains(PdfReaderTool.AI_FEATURES.name)) {
|
||||
TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_ai),
|
||||
description = stringResource(R.string.tooltip_ai_desc),
|
||||
onClick = onShowAiHub
|
||||
) {
|
||||
Icon(painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.tooltip_ai))
|
||||
}
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.EDIT_MODE.name)) {
|
||||
TooltipIconButton(
|
||||
text = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit) else stringResource(R.string.tooltip_edit_mode),
|
||||
description = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit_desc) else stringResource(R.string.tooltip_edit_mode_desc),
|
||||
onClick = onToggleEditMode
|
||||
) {
|
||||
Icon(Icons.Default.Edit, contentDescription = "Toggle Editing Mode", tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.TTS_CONTROLS.name)) {
|
||||
TooltipIconButton(
|
||||
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start),
|
||||
description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc),
|
||||
onClick = onToggleTts
|
||||
) {
|
||||
Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) "Stop TTS" else "Start TTS", tint = if (isTtsSessionActive) 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -184,11 +184,12 @@ private data class DividerLayout(val y: Float, val width: Float, val height: Flo
|
|||
@OptIn(FlowPreview::class)
|
||||
@Composable
|
||||
internal fun PdfVerticalReader(
|
||||
modifier: Modifier = Modifier,
|
||||
state: VerticalPdfReaderState,
|
||||
pdfDocument: StableHolder<ReaderDocument>,
|
||||
activeTheme: com.aryan.reader.ReaderTheme,
|
||||
excludeImages: Boolean = false,
|
||||
totalPages: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
virtualPages: List<VirtualPage> = emptyList(),
|
||||
pageAspectRatios: StableHolder<List<Float>>,
|
||||
headerHeight: Dp,
|
||||
|
|
@ -284,6 +285,11 @@ internal fun PdfVerticalReader(
|
|||
val dividerHeightDp = 8.dp
|
||||
val dividerHeightPx = with(density) { dividerHeightDp.toPx() }
|
||||
|
||||
var isFlinging by remember { mutableStateOf(false) }
|
||||
var isFastFlinging by remember { mutableStateOf(false) }
|
||||
var isInteracting by remember { mutableStateOf(false) }
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
|
||||
val layoutState = remember(ratios, screenWidth, screenHeight, density) {
|
||||
data class LayoutResult(val pages: List<PdfPageLayout>, val totalHeight: Float)
|
||||
|
||||
|
|
@ -373,8 +379,41 @@ internal fun PdfVerticalReader(
|
|||
var isInitialLayout by remember { mutableStateOf(true) }
|
||||
val currentScaleProvider = remember(zoomAnimatable) { { zoomAnimatable.value } }
|
||||
|
||||
var hasRestoredLockedState by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(isScrollLocked, lockedState, totalDocHeight, screenWidth, isInteracting) {
|
||||
if (!hasRestoredLockedState && isScrollLocked && lockedState != null && totalDocHeight > 0f && screenWidth > 0f && !isInteracting) {
|
||||
val (savedScale, savedPanX, savedPanY) = lockedState
|
||||
|
||||
Timber.tag("PdfLockDiagnostic").i("RESTORING: Scale=$savedScale, X=$savedPanX, Y=$savedPanY")
|
||||
|
||||
val zoomedDocWidth = screenWidth * savedScale
|
||||
val minPanX = if (zoomedDocWidth < screenWidth) (screenWidth - zoomedDocWidth) / 2f else -(zoomedDocWidth - screenWidth)
|
||||
val maxPanX = if (zoomedDocWidth < screenWidth) minPanX else 0f
|
||||
|
||||
val zoomedDocHeight = totalDocHeight * savedScale
|
||||
val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
|
||||
val maxPanY = headerHeightPx
|
||||
|
||||
zoomAnimatable.stop()
|
||||
panXAnimatable.stop()
|
||||
panYAnimatable.stop()
|
||||
|
||||
panXAnimatable.updateBounds(minPanX, maxPanX)
|
||||
panYAnimatable.updateBounds(minPanY, maxPanY)
|
||||
|
||||
zoomAnimatable.snapTo(savedScale)
|
||||
panXAnimatable.snapTo(savedPanX)
|
||||
panYAnimatable.snapTo(savedPanY.coerceIn(minPanY, maxPanY))
|
||||
|
||||
Timber.tag("PdfLockDiagnostic").d("RESTORE SNAP COMPLETE: Scale=${zoomAnimatable.value}, X=${panXAnimatable.value}, Y=${panYAnimatable.value}")
|
||||
|
||||
hasRestoredLockedState = true
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(layoutState.pages) {
|
||||
if (!isInitialLayout) {
|
||||
if (!isInitialLayout && !isScrollLocked) {
|
||||
val targetPageIdx = if (targetPageDuringResize.intValue != -1) {
|
||||
targetPageDuringResize.intValue
|
||||
} else {
|
||||
|
|
@ -410,21 +449,13 @@ internal fun PdfVerticalReader(
|
|||
launch { panXAnimatable.snapTo(targetPanX) }
|
||||
launch { panYAnimatable.snapTo(finalPanY) }
|
||||
}
|
||||
|
||||
panYAnimatable.updateBounds(lowerBound = minPanY, upperBound = headerHeightPx)
|
||||
state.currentPage = targetPageIdx
|
||||
if (isFit) onZoomChange(targetZoom)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isInitialLayout) {
|
||||
delay(50)
|
||||
isResizing = false
|
||||
targetPageDuringResize.intValue = -1
|
||||
} else if (isScrollLocked && lockedState != null) {
|
||||
val (savedScale, savedPanX, _) = lockedState
|
||||
coroutineScope {
|
||||
launch { zoomAnimatable.snapTo(savedScale) }
|
||||
launch { panXAnimatable.snapTo(savedPanX) }
|
||||
}
|
||||
}
|
||||
isInitialLayout = false
|
||||
}
|
||||
|
|
@ -461,11 +492,6 @@ internal fun PdfVerticalReader(
|
|||
return clampValues(targetZoom, targetPanX, targetPanY)
|
||||
}
|
||||
|
||||
var isFlinging by remember { mutableStateOf(false) }
|
||||
var isFastFlinging by remember { mutableStateOf(false) }
|
||||
var isInteracting by remember { mutableStateOf(false) }
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(
|
||||
totalDocHeight, screenHeight, headerHeightPx, footerHeightPx, zoomAnimatable.value, isInteracting, isFlinging, isResizing
|
||||
) {
|
||||
|
|
@ -483,6 +509,9 @@ internal fun PdfVerticalReader(
|
|||
panYAnimatable.snapTo(y)
|
||||
}
|
||||
if (x != currentPanX) {
|
||||
if (isScrollLocked) {
|
||||
Timber.tag("PdfLockDiagnostic").d("FORCED SNAP: X=$currentPanX to $x")
|
||||
}
|
||||
panXAnimatable.snapTo(x)
|
||||
}
|
||||
}
|
||||
|
|
@ -585,8 +614,8 @@ internal fun PdfVerticalReader(
|
|||
val dist = (draggingBoxOffset.y - topEdge).coerceAtMost(scrollZone)
|
||||
val ratio = 1f - (dist / scrollZone).coerceIn(0f, 1f)
|
||||
scrollDelta = -15f * ratio
|
||||
} else if (draggingBoxOffset.y + draggingBoxSize.height > bottomEdge - scrollZone) {
|
||||
val boxBottom = draggingBoxOffset.y + draggingBoxSize.height
|
||||
} else if (draggingBoxOffset.y + (draggingBoxSize.height * zoomAnimatable.value) > bottomEdge - scrollZone) {
|
||||
val boxBottom = draggingBoxOffset.y + (draggingBoxSize.height * zoomAnimatable.value)
|
||||
val dist = (bottomEdge - boxBottom).coerceAtMost(scrollZone)
|
||||
val ratio = 1f - (dist / scrollZone).coerceIn(0f, 1f)
|
||||
scrollDelta = 15f * ratio
|
||||
|
|
@ -744,6 +773,9 @@ internal fun PdfVerticalReader(
|
|||
}
|
||||
|
||||
if (!isAnimating) {
|
||||
if (isScrollLocked) {
|
||||
Timber.tag("PdfLockDiagnostic").v("CLAMP CHECK: X=${panXAnimatable.value} | Allowed Range=[$minPanX, $maxPanX]")
|
||||
}
|
||||
panYAnimatable.updateBounds(lowerBound = minPanY, upperBound = headerHeightPx)
|
||||
panXAnimatable.updateBounds(lowerBound = minPanX, upperBound = maxPanX)
|
||||
} else {
|
||||
|
|
@ -1575,6 +1607,7 @@ internal fun PdfVerticalReader(
|
|||
virtualPage = virtualPage,
|
||||
totalPages = totalPages,
|
||||
activeTheme = activeTheme,
|
||||
excludeImages = excludeImages,
|
||||
externalScale = highResScale,
|
||||
onScaleChanged = {},
|
||||
showAllTextHighlights = showAllTextHighlights,
|
||||
|
|
@ -1638,17 +1671,20 @@ internal fun PdfVerticalReader(
|
|||
val boxScreenY = pageScreenY + (localTopLeft.y * currentZoom)
|
||||
|
||||
draggingBoxSize = Size(
|
||||
box.relativeBounds.width * page.width * currentZoom,
|
||||
box.relativeBounds.height * page.height * currentZoom
|
||||
box.relativeBounds.width * page.width,
|
||||
box.relativeBounds.height * page.height
|
||||
)
|
||||
draggingBoxPageHeight = page.height * currentZoom
|
||||
draggingBoxPageHeight = page.height
|
||||
|
||||
draggingBoxOffset = Offset(boxScreenX, boxScreenY)
|
||||
draggingBoxTouchDelta = touchOffset * currentZoom
|
||||
draggingBoxId = box.id
|
||||
},
|
||||
onTextBoxDrag = { dragDelta ->
|
||||
draggingBoxOffset += dragDelta
|
||||
val currentZoom = zoomAnimatable.value
|
||||
val scaledDelta = dragDelta * currentZoom
|
||||
Timber.tag("PdfTextBoxDebug").v("VerticalReader onTextBoxDrag dragDelta=$dragDelta zoom=$currentZoom scaledDelta=$scaledDelta")
|
||||
draggingBoxOffset += scaledDelta
|
||||
},
|
||||
onTextBoxDragEnd = {
|
||||
scope.launch {
|
||||
|
|
@ -1681,9 +1717,9 @@ internal fun PdfVerticalReader(
|
|||
val rawRelY =
|
||||
(finalBoxY / currentZoom) / targetPage.height
|
||||
val relW =
|
||||
(draggingBoxSize.width / currentZoom) / targetPage.width
|
||||
draggingBoxSize.width / targetPage.width
|
||||
val relH =
|
||||
(draggingBoxSize.height / currentZoom) / targetPage.height
|
||||
draggingBoxSize.height / targetPage.height
|
||||
|
||||
val clampedW = relW.coerceAtMost(1f)
|
||||
val clampedH = relH.coerceAtMost(1f)
|
||||
|
|
@ -2051,7 +2087,8 @@ internal fun PdfVerticalReader(
|
|||
val fontScaleRatio =
|
||||
if (currentBoxHeight > 0) draggingBoxPageHeight / currentBoxHeight else 1f
|
||||
|
||||
val boxBottomY = draggingBoxOffset.y + draggingBoxSize.height
|
||||
val currentZoom = zoomAnimatable.value
|
||||
val boxBottomY = draggingBoxOffset.y + (draggingBoxSize.height * currentZoom)
|
||||
val spaceBelow = screenHeight - boxBottomY
|
||||
val overlayHandlePos =
|
||||
if (spaceBelow < with(density) { 60.dp.toPx() }) HandlePosition.TOP else HandlePosition.BOTTOM
|
||||
|
|
@ -2062,6 +2099,11 @@ internal fun PdfVerticalReader(
|
|||
draggingBoxOffset.x.roundToInt(), draggingBoxOffset.y.roundToInt()
|
||||
)
|
||||
}
|
||||
.graphicsLayer {
|
||||
scaleX = currentZoom
|
||||
scaleY = currentZoom
|
||||
transformOrigin = TransformOrigin(0f, 0f)
|
||||
}
|
||||
.zIndex(100f)) {
|
||||
ResizableTextBox(
|
||||
box = draggedBox.copy(
|
||||
|
|
@ -2073,6 +2115,7 @@ internal fun PdfVerticalReader(
|
|||
isDarkMode = isDarkMode,
|
||||
pageWidthPx = draggingBoxSize.width,
|
||||
pageHeightPx = draggingBoxSize.height,
|
||||
scale = currentZoom,
|
||||
handlePosition = overlayHandlePos,
|
||||
onBoundsChanged = {},
|
||||
onTextChanged = {},
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -35,14 +35,11 @@ import androidx.compose.foundation.layout.Column
|
|||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
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.ime
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
|
|
@ -118,7 +115,6 @@ import com.aryan.reader.R
|
|||
import com.aryan.reader.data.CustomFontEntity
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private enum class ColorMenuMode {
|
||||
|
|
@ -176,18 +172,6 @@ fun TextAnnotationDock(
|
|||
val fontSizes = listOf(12.sp, 14.sp, 16.sp, 18.sp, 20.sp, 24.sp, 30.sp)
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
val density = LocalDensity.current
|
||||
val imeBottom = WindowInsets.ime.getBottom(density)
|
||||
val navBottom = WindowInsets.navigationBars.getBottom(density)
|
||||
val spacerHeightPx = max(0, imeBottom - navBottom)
|
||||
val spacerHeightDp = with(density) { spacerHeightPx.toDp() }
|
||||
|
||||
val effectiveSpacerHeight = if (spacerHeightDp > 0.dp) {
|
||||
spacerHeightDp
|
||||
} else {
|
||||
bottomDockPadding
|
||||
}
|
||||
|
||||
LaunchedEffect(activePopup) {
|
||||
if (activePopup == ActivePopup.NONE) {
|
||||
activeMenuMode = ColorMenuMode.PALETTE
|
||||
|
|
@ -200,7 +184,7 @@ fun TextAnnotationDock(
|
|||
|
||||
val dockBarHeight = 48.dp
|
||||
val margin = 8.dp
|
||||
val finalOffsetY = -(effectiveSpacerHeight + dockBarHeight + margin)
|
||||
val finalOffsetY = -(bottomDockPadding + dockBarHeight + margin)
|
||||
|
||||
val isFocusable = activeMenuMode == ColorMenuMode.SPECTRUM || activePopup == ActivePopup.FONT_FAMILY
|
||||
|
||||
|
|
@ -724,7 +708,7 @@ fun TextAnnotationDock(
|
|||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(effectiveSpacerHeight))
|
||||
Spacer(modifier = Modifier.height(bottomDockPadding))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue