Support notes with highlights (#150)

* Added support for highlight notes in EPUB reader

* fix(epub annots): ensure persistent highlights and preserve notes on color change

- refactor: unified processAndAddHighlight across scroll and paginated modes
- fix: re-apply highlights to virtualized DOM elements on chunk load
- fix: replaced '|' with ';;' in JS bridge to prevent CFI parsing errors

* feat(epub): redesign annotation UX with theme-aware Bottom Sheet

* Updated highlight handling to use full CFI strings instead of splitting into parts during deletion and style updates. Adjusted `processAndAddHighlight` to return the final CFI and updated the delete button UI to use a contained button style.

* Added support for notes in PDF highlights.

* Implemented support for custom highlight colors in the PDF reader.

* Improved PDF annotation notes and theme handling in the bottom sheet.
This commit is contained in:
Aryan 2026-04-05 09:52:05 +05:30 committed by GitHub
parent c8f361376f
commit 65e0570d0e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 2026 additions and 744 deletions

View file

@ -24,6 +24,7 @@ import android.graphics.Rect
import android.graphics.RectF
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -31,10 +32,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@ -45,23 +48,40 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CopyAll
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
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.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
@ -72,7 +92,6 @@ import com.aryan.reader.pdf.ocr.OcrElement
import com.aryan.reader.pdf.ocr.OcrLine
import com.aryan.reader.pdf.ocr.OcrResult
import com.aryan.reader.pdf.ocr.OcrSymbol
import io.legere.pdfiumandroid.suspend.PdfTextPageKt
import timber.log.Timber
import java.util.UUID
@ -111,7 +130,8 @@ data class PdfUserHighlight(
val bounds: List<RectF>,
val color: PdfHighlightColor,
val text: String,
val range: Pair<Int, Int>
val range: Pair<Int, Int>,
val note: String? = null
)
internal data class CustomPdfMenuState(
@ -122,7 +142,9 @@ internal data class CustomPdfMenuState(
val highlightId: String? = null,
val isComment: Boolean = false,
val author: String? = null,
val annotation: EmbeddedAnnotation? = null
val annotation: EmbeddedAnnotation? = null,
val note: String? = null,
val selectedColor: PdfHighlightColor? = null
)
internal enum class PdfSelectionMethod {
@ -197,6 +219,8 @@ private fun CommentThread(replies: List<EmbeddedAnnotation>, depth: Int) {
internal fun PdfSelectionMenuPopup(
menuState: CustomPdfMenuState,
popupPositionProvider: PopupPositionProvider,
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
onPaletteClick: (() -> Unit)? = null,
onDismiss: () -> Unit,
onCopy: (String) -> Unit,
onAiDefine: (String) -> Unit,
@ -205,7 +229,8 @@ internal fun PdfSelectionMenuPopup(
onSelectAll: () -> Unit,
onColorSelected: (PdfHighlightColor) -> Unit,
onDelete: () -> Unit,
onTts: (() -> Unit)? = null
onTts: (() -> Unit)? = null,
onNote: (() -> Unit)? = null
) {
Popup(
popupPositionProvider = popupPositionProvider,
@ -224,6 +249,48 @@ internal fun PdfSelectionMenuPopup(
modifier = Modifier.widthIn(max = 300.dp)
) {
Column(modifier = if (menuState.isComment) Modifier.fillMaxWidth() else Modifier.width(IntrinsicSize.Max)) {
if (!menuState.note.isNullOrBlank()) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
shape = RoundedCornerShape(8.dp),
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 8.dp)
.fillMaxWidth()
) {
Column(
modifier = Modifier
.heightIn(max = 140.dp)
.verticalScroll(rememberScrollState())
.padding(12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = "Note",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(14.dp)
)
Spacer(Modifier.width(6.dp))
Text(
"Note",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
}
Spacer(Modifier.height(4.dp))
Text(
text = menuState.note,
style = MaterialTheme.typography.bodyMedium.copy(
fontStyle = FontStyle.Italic
),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
HorizontalDivider()
}
if (menuState.isComment) {
Column(
modifier = Modifier
@ -264,15 +331,30 @@ internal fun PdfSelectionMenuPopup(
verticalAlignment = Alignment.CenterVertically
) {
PdfHighlightColor.entries.forEach { colorEnum ->
val displayColor = customHighlightColors[colorEnum] ?: colorEnum.color
Box(
modifier = Modifier.padding(horizontal = 6.dp).size(32.dp)
.background(colorEnum.color, CircleShape).clip(CircleShape)
.background(displayColor, CircleShape).clip(CircleShape)
.clickable {
Timber.tag("PdfHighlightDebug")
.d("Color box clicked: $colorEnum")
onColorSelected(colorEnum)
})
}
if (onPaletteClick != null) {
val rainbowColors = listOf(
Color.Red, Color.Magenta, Color.Blue, Color.Cyan, Color.Green, Color.Yellow, Color.Red
)
Box(
modifier = Modifier
.padding(horizontal = 6.dp)
.size(32.dp)
.clip(CircleShape)
.background(Brush.sweepGradient(rainbowColors))
.clickable { onPaletteClick() },
contentAlignment = Alignment.Center
) {}
}
}
HorizontalDivider()
@ -288,6 +370,11 @@ internal fun PdfSelectionMenuPopup(
actions.add(MenuActionItem(imageVector = Icons.Default.Search, label = "Search", onClick = { onSearch(menuState.selectedText) }))
}
if (onNote != null) {
val noteLabel = if (menuState.note.isNullOrBlank()) "Note" else "Edit"
actions.add(MenuActionItem(imageVector = Icons.Default.Edit, label = noteLabel, onClick = onNote))
}
if (!menuState.isExistingHighlight) {
actions.add(MenuActionItem(iconRes = R.drawable.select_all, label = "Select All", onClick = { onSelectAll() }))
}
@ -536,4 +623,214 @@ internal fun mergePdfRectsIntoLines(rects: List<RectF>): List<RectF> {
return merged.map { m ->
RectF(m[0], m[3], m[2], m[1])
}
}
@Composable
fun PdfHighlightColorRow(
modifier: Modifier = Modifier,
selectedColor: PdfHighlightColor? = null,
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
onColorSelect: (PdfHighlightColor) -> Unit,
onPaletteClick: (() -> Unit)? = null
) {
Row(
modifier = modifier
.padding(vertical = 12.dp, horizontal = 12.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
PdfHighlightColor.entries.forEach { colorEnum ->
val displayColor = customHighlightColors[colorEnum] ?: colorEnum.color
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.padding(horizontal = 6.dp)
.size(32.dp)
.clip(CircleShape)
.background(displayColor)
.clickable { onColorSelect(colorEnum) }
.border(
width = if (selectedColor == colorEnum) 3.dp else 1.dp,
color = if (selectedColor == colorEnum) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
shape = CircleShape
)
) {
if (selectedColor == colorEnum) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = if (displayColor.luminance() > 0.5f) Color.Black else Color.White,
modifier = Modifier.size(18.dp)
)
}
}
}
if (onPaletteClick != null) {
val rainbowColors = listOf(
Color.Red, Color.Magenta, Color.Blue, Color.Cyan, Color.Green, Color.Yellow, Color.Red
)
Box(
modifier = Modifier
.padding(horizontal = 6.dp)
.size(32.dp)
.clip(CircleShape)
.background(Brush.sweepGradient(rainbowColors))
.clickable { onPaletteClick() },
contentAlignment = Alignment.Center
) {}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PdfAnnotationBottomSheet(
highlight: PdfUserHighlight,
effectiveBg: Color,
effectiveText: Color,
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
onPaletteClick: (() -> Unit)? = null,
onColorChange: (PdfHighlightColor) -> Unit,
onDismiss: () -> Unit,
onSave: (String) -> Unit,
onDelete: () -> Unit,
onCopy: () -> Unit,
onDictionary: () -> Unit,
onTranslate: () -> Unit,
onSearch: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var noteText by remember { mutableStateOf(highlight.note ?: "") }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = effectiveBg,
contentColor = effectiveText,
contentWindowInsets = { WindowInsets.navigationBars }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 24.dp)
) {
val displayColor = customHighlightColors[highlight.color] ?: highlight.color.color
PdfHighlightColorRow(
selectedColor = highlight.color,
customHighlightColors = customHighlightColors,
onColorSelect = onColorChange,
onPaletteClick = onPaletteClick,
modifier = Modifier.padding(bottom = 16.dp)
)
Surface(
color = displayColor.copy(alpha = 0.1f),
shape = RoundedCornerShape(12.dp),
border = BorderStroke(1.dp, displayColor.copy(alpha = 0.3f)),
modifier = Modifier.fillMaxWidth()
) {
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
Box(modifier = Modifier.width(6.dp).fillMaxHeight().background(displayColor))
Text(
text = "\"${highlight.text}\"",
style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
maxLines = 4,
overflow = TextOverflow.Ellipsis,
color = effectiveText.copy(alpha = 0.9f),
modifier = Modifier.padding(16.dp)
)
}
}
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
PdfBottomSheetToolButton(icon = R.drawable.copy, label = "Copy", effectiveText = effectiveText, onClick = onCopy)
PdfBottomSheetToolButton(icon = R.drawable.dictionary, label = "Dict", effectiveText = effectiveText, onClick = onDictionary)
PdfBottomSheetToolButton(icon = R.drawable.translate, label = "Translate", effectiveText = effectiveText, onClick = onTranslate)
PdfBottomSheetToolButton(icon = R.drawable.search, label = "Search", effectiveText = effectiveText, onClick = onSearch)
}
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = noteText,
onValueChange = { noteText = it },
placeholder = { Text("Add a note...", color = effectiveText.copy(alpha = 0.5f)) },
modifier = Modifier.fillMaxWidth().heightIn(min = 100.dp),
maxLines = 5,
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = effectiveText.copy(alpha = 0.3f),
focusedTextColor = effectiveText,
unfocusedTextColor = effectiveText
),
shape = RoundedCornerShape(12.dp)
)
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = onDelete,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
)
) {
Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Delete")
}
Button(
onClick = { onSave(noteText) },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Text("Save Note")
}
}
}
}
}
@Composable
private fun PdfBottomSheetToolButton(
icon: Int,
label: String,
effectiveText: Color,
onClick: () -> Unit
) {
Column(
modifier = Modifier.clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
painter = painterResource(id = icon),
contentDescription = label,
tint = effectiveText.copy(alpha = 0.8f),
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = effectiveText.copy(alpha = 0.8f)
)
}
}

View file

@ -368,6 +368,7 @@ data class PageSelectionData(
val selectionHighlightColor: Color,
val pageIndex: Int,
val userHighlightScreenRects: StableHolder<List<Pair<PdfUserHighlight, List<Rect>>>>,
val customHighlightColors: StableHolder<Map<PdfHighlightColor, Color>>
)
@Suppress("unused")
@ -437,8 +438,11 @@ internal fun PdfPageComposable(
onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> },
onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> },
onHighlightDelete: (String) -> Unit = {},
onNoteRequested: (String?) -> Unit = {},
onTts: (Int, Int) -> Unit = { _, _ -> },
activeToolThickness: Float = 0f
activeToolThickness: Float = 0f,
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
onPaletteClick: (() -> Unit)? = null
) {
val pdfDocumentItem = pdfDocument.item
var bitmapState by remember { mutableStateOf(PdfThumbnailCache.get(pageIndex)) }
@ -2552,16 +2556,7 @@ internal fun PdfPageComposable(
if (hitHighlightPair != null && tappedRect != null) {
val hitHighlight = hitHighlightPair.first
val combinedRect = Rect(hitHighlightPair.second.first())
hitHighlightPair.second.forEach { combinedRect.union(it) }
customMenuState = CustomPdfMenuState(
selectedText = hitHighlight.text,
anchorRect = combinedRect,
charRange = hitHighlight.range,
isExistingHighlight = true,
highlightId = hitHighlight.id
)
onNoteRequested(hitHighlight.id)
return@launch
}
@ -3568,7 +3563,8 @@ internal fun PdfPageComposable(
searchHighlightMode,
searchFocusedColor,
searchAllColor,
userHighlightScreenRects
userHighlightScreenRects,
customHighlightColors
) {
PageSelectionData(
pageLinks = StableHolder(pageLinks),
@ -3593,6 +3589,7 @@ internal fun PdfPageComposable(
mergedSearchAllRects = StableHolder(mergedSearchAllRects),
searchHighlightMode = searchHighlightMode,
userHighlightScreenRects = StableHolder(userHighlightScreenRects),
customHighlightColors = StableHolder(customHighlightColors)
)
}
@ -3622,6 +3619,7 @@ internal fun PdfPageComposable(
onHighlightUpdate = onHighlightUpdate,
onHighlightDelete = onHighlightDelete,
onTts = onTts,
onNote = onNoteRequested,
teardropHeightPx = teardropHeightPxState.value,
activeDraggingHandle = activeDraggingHandle,
showMagnifier = showMagnifier,
@ -3833,7 +3831,9 @@ internal fun PdfPageComposable(
onTextBoxDrag = onTextBoxDrag,
onTextBoxDragEnd = onTextBoxDragEnd,
onDragPageTurn = onDragPageTurn,
draggingBoxId = draggingBoxId
draggingBoxId = draggingBoxId,
customHighlightColors = customHighlightColors,
onPaletteClick = onPaletteClick
)
}
@ -3955,7 +3955,8 @@ private fun PdfHighlightsLayer(
scrimColorForTextHighlight: Color,
allTextPageHighlightColor: Color,
ttsHighlightColor: Color,
selectionHighlightColor: Color
selectionHighlightColor: Color,
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap()
) {
Timber.d("PdfHighlightsLayer Recompose")
Canvas(modifier = Modifier
@ -4098,10 +4099,11 @@ private fun PdfHighlightsLayer(
// 9. Persistent User Highlights
userHighlightScreenRects.forEach { (highlight, screenRects) ->
val displayColor = customHighlightColors[highlight.color] ?: highlight.color.color
screenRects.forEach { r ->
if (isVisible(r)) {
drawRect(
color = highlight.color.color.copy(alpha = 0.4f),
color = displayColor.copy(alpha = 0.4f),
topLeft = Offset(r.left.toFloat(), r.top.toFloat()),
size = Size(r.width().toFloat(), r.height().toFloat())
)
@ -4471,7 +4473,8 @@ private fun PdfPageSelectionsLayer(
scrimColorForTextHighlight: Color,
allTextPageHighlightColor: Color,
ttsHighlightColor: Color,
selectionHighlightColor: Color
selectionHighlightColor: Color,
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap()
) {
SideEffect {
Timber.tag("PdfDrawPerf").v("SELECTIONS LAYER: Recomposing")
@ -4498,7 +4501,8 @@ private fun PdfPageSelectionsLayer(
scrimColorForTextHighlight = scrimColorForTextHighlight,
allTextPageHighlightColor = allTextPageHighlightColor,
ttsHighlightColor = ttsHighlightColor,
selectionHighlightColor = selectionHighlightColor
selectionHighlightColor = selectionHighlightColor,
customHighlightColors = customHighlightColors
)
val highlightTime = (System.nanoTime() - highlightStart) / 1_000_000f
@ -4561,12 +4565,14 @@ private fun PdfPageRenderer(
onTextBoxDragEnd: () -> Unit,
onDragPageTurn: (Int) -> Unit,
draggingBoxId: String? = null,
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
onPaletteClick: (() -> Unit)? = null,
onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit,
onHighlightUpdate: (String, PdfHighlightColor) -> Unit,
onHighlightDelete: (String) -> Unit,
onTts: (Int, Int) -> Unit,
activeToolThickness: Float
activeToolThickness: Float,
onNote: (String?) -> Unit,
) {
Box(modifier = Modifier.fillMaxSize()) {
Box(
@ -4604,7 +4610,8 @@ private fun PdfPageRenderer(
scrimColorForTextHighlight = selectionData.scrimColorForTextHighlight,
allTextPageHighlightColor = selectionData.allTextPageHighlightColor,
ttsHighlightColor = selectionData.ttsHighlightColor,
selectionHighlightColor = selectionData.selectionHighlightColor
selectionHighlightColor = selectionData.selectionHighlightColor,
customHighlightColors = selectionData.customHighlightColors.item
)
// Layer 3: Annotations & Text
@ -4950,7 +4957,21 @@ private fun PdfPageRenderer(
onTts = {
onTts(selectionData.pageIndex, menuState.charRange.first)
onMenuDismiss()
}
},
onNote = {
if (menuState.isExistingHighlight && menuState.highlightId != null) {
onNote(menuState.highlightId)
} else {
onNote(null)
onHighlightAdd(
selectionData.pageIndex, menuState.charRange, menuState.selectedText,
PdfHighlightColor.YELLOW
)
}
onMenuDismiss()
},
customHighlightColors = selectionData.customHighlightColors.item,
onPaletteClick = onPaletteClick
)
}
}

View file

@ -230,8 +230,11 @@ internal fun PdfVerticalReader(
onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> },
onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> },
onHighlightDelete: (String) -> Unit = {},
onNoteRequested: (String?) -> Unit = {},
onTts: (Int, Int) -> Unit = { _, _ -> },
activeToolThickness: Float = 0f
activeToolThickness: Float = 0f,
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
onPaletteClick: () -> Unit = {}
) {
SideEffect { Timber.tag("PdfDrawPerf").v("LIST: PdfVerticalReader Recomposing.") }
DisposableEffect(state) {
@ -1576,8 +1579,11 @@ internal fun PdfVerticalReader(
onHighlightAdd = onHighlightAdd,
onHighlightUpdate = onHighlightUpdate,
onHighlightDelete = onHighlightDelete,
onNoteRequested = onNoteRequested,
onTts = onTts,
activeToolThickness = activeToolThickness,
customHighlightColors = customHighlightColors,
onPaletteClick = onPaletteClick,
onTextBoxDragStart = { box, localTopLeft, touchOffset ->
val currentZoom = zoomAnimatable.value
val panX = panXAnimatable.value

View file

@ -27,6 +27,7 @@ package com.aryan.reader.pdf
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ClipData
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.Bitmap
@ -43,6 +44,7 @@ import android.print.PrintDocumentInfo
import android.print.PrintManager
import android.provider.OpenableColumns
import android.util.Base64
import android.util.LruCache
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
@ -119,7 +121,6 @@ import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Brush
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.FullscreenExit
@ -144,6 +145,7 @@ import androidx.compose.material3.DrawerValue
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.HorizontalDivider
@ -153,6 +155,7 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.OutlinedTextField
@ -167,6 +170,7 @@ import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@ -262,6 +266,7 @@ import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.BuildConfig
import com.aryan.reader.DeviceVoiceSettingsSheet
import com.aryan.reader.FileType
import com.aryan.reader.HighlightColorPickerDialog
import com.aryan.reader.MainViewModel
import com.aryan.reader.R
import com.aryan.reader.ReaderTheme
@ -360,6 +365,24 @@ 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 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)
}
}
private 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())
}
}
}
private 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) }
@ -1091,7 +1114,7 @@ private data class DocumentCacheItem(
)
private class DocumentCache(val maxSize: Int = 3) {
val cache = object : android.util.LruCache<String, DocumentCacheItem>(maxSize) {
val cache = object : LruCache<String, DocumentCacheItem>(maxSize) {
override fun entryRemoved(
evicted: Boolean,
key: String,
@ -1189,7 +1212,7 @@ fun PdfViewerScreen(
val effectiveFileType = uiState.selectedFileType ?: FileType.PDF
var showNewTabSheet by remember { mutableStateOf(false) }
val sheetState = androidx.compose.material3.rememberModalBottomSheetState(skipPartiallyExpanded = false)
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
val isTabsEnabled = uiState.isTabsEnabled
val openTabs = uiState.openTabs
@ -1280,6 +1303,16 @@ fun PdfViewerScreen(
var isEditMode by rememberSaveable { mutableStateOf(false) }
var isDockMinimized by rememberSaveable { mutableStateOf(false) }
var pendingNoteForNewHighlight by remember { mutableStateOf(false) }
var highlightToNoteId by remember { mutableStateOf<String?>(null) }
val onNoteRequested: (String?) -> Unit = { id ->
if (id != null) {
highlightToNoteId = id
} else {
pendingNoteForNewHighlight = true
}
}
val isDrawingActive by remember(isEditMode, isDockMinimized) {
derivedStateOf { isEditMode && !isDockMinimized }
}
@ -1394,6 +1427,10 @@ fun PdfViewerScreen(
val (initialDockLocation, initialDockOffset) = remember(context) { loadDockState(context) }
var customHighlightColors by remember { mutableStateOf(loadCustomHighlightColors(context)) }
var showHighlightColorPicker by remember { mutableStateOf(false) }
var highlightColorPickerInitialSlot by remember { mutableStateOf(PdfHighlightColor.YELLOW) }
var dockLocation by remember { mutableStateOf(initialDockLocation) }
var dockOffset by remember { mutableStateOf(initialDockOffset) }
var snapPreviewLocation by remember { mutableStateOf<DockLocation?>(null) }
@ -1834,6 +1871,10 @@ fun PdfViewerScreen(
withContext(Dispatchers.Main) {
userHighlights.add(newHighlight)
Timber.tag("PdfExportDebug").d("userHighlights now contains ${userHighlights.size} items.")
if (pendingNoteForNewHighlight) {
pendingNoteForNewHighlight = false
highlightToNoteId = newHighlight.id
}
}
}
}
@ -4094,61 +4135,125 @@ fun PdfViewerScreen(
)
}
} else {
var showDeleteConfirmDialogFor by remember {
mutableStateOf<PdfUserHighlight?>(null)
}
val sortedHighlights = remember(userHighlights.toList()) {
userHighlights.sortedBy { it.pageIndex }
}
var showDeleteConfirmDialogFor by remember { mutableStateOf<PdfUserHighlight?>(null) }
var filterWithNotesOnly by remember { mutableStateOf(false) }
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,
modifier = Modifier
.background(
color = highlight.color.color.copy(alpha = 0.3f),
shape = RoundedCornerShape(4.dp)
)
.padding(horizontal = 4.dp, vertical = 2.dp)
)
},
supportingContent = {
Text(
"Page ${highlight.pageIndex + 1}",
style = MaterialTheme.typography.bodySmall
)
},
trailingContent = {
IconButton(
onClick = { showDeleteConfirmDialogFor = highlight }
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete highlight",
tint = MaterialTheme.colorScheme.error
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
)
}
},
modifier = Modifier.clickable {
coroutineScope.launch {
drawerState.close()
if (displayMode == DisplayMode.PAGINATION) {
pagerState.scrollToPage(highlight.pageIndex)
} else {
verticalReaderState.scrollToPage(highlight.pageIndex)
},
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
coroutineScope.launch { drawerState.close() }
}
)
DropdownMenuItem(
text = { Text("Delete") },
onClick = {
showDeleteConfirmDialogFor = highlight
highlightMenuExpanded = false
}
)
}
}
},
modifier = Modifier.clickable {
coroutineScope.launch {
drawerState.close()
if (displayMode == DisplayMode.PAGINATION) {
pagerState.scrollToPage(highlight.pageIndex)
} else {
verticalReaderState.scrollToPage(highlight.pageIndex)
}
}
}
}
)
HorizontalDivider()
)
HorizontalDivider()
}
}
}
@ -4436,6 +4541,11 @@ fun PdfViewerScreen(
totalPages = totalDisplayPages,
activeTheme = activeTheme,
isScrollLocked = isScrollLocked,
customHighlightColors = customHighlightColors,
onPaletteClick = {
highlightColorPickerInitialSlot = PdfHighlightColor.YELLOW
showHighlightColorPicker = true
},
onScaleChanged = { newScale ->
if (pagerState.currentPage == pageIndex) {
currentPageScale = newScale
@ -4506,6 +4616,7 @@ fun PdfViewerScreen(
onHighlightAdd = onHighlightAdd,
onHighlightUpdate = onHighlightUpdate,
onHighlightDelete = onHighlightDelete,
onNoteRequested = onNoteRequested,
onTts = { pageIdx, charIdx -> startTtsWithPermissionCheck(pageIdx, charIdx) },
activeToolThickness = currentStrokeWidthState,
onTwoFingerSwipe = { direction ->
@ -4817,6 +4928,8 @@ fun PdfViewerScreen(
pdfDocument = docHolder,
activeTheme = activeTheme,
isScrollLocked = isScrollLocked,
customHighlightColors = customHighlightColors,
onPaletteClick = { showHighlightColorPicker = true },
totalPages = totalDisplayPages,
pageAspectRatios = ratiosHolder,
virtualPages = virtualPages,
@ -4841,6 +4954,7 @@ fun PdfViewerScreen(
onHighlightAdd = onHighlightAdd,
onHighlightUpdate = onHighlightUpdate,
onHighlightDelete = onHighlightDelete,
onNoteRequested = onNoteRequested,
onTts = { pageIdx, charIdx -> startTtsWithPermissionCheck(pageIdx, charIdx) },
activeToolThickness = currentStrokeWidthState,
onLinkClicked = onLinkClickedStable,
@ -6999,7 +7113,7 @@ fun PdfViewerScreen(
}
if (showNewTabSheet) {
androidx.compose.material3.ModalBottomSheet(
ModalBottomSheet(
onDismissRequest = { showNewTabSheet = false },
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface
@ -7246,6 +7360,84 @@ fun PdfViewerScreen(
)
}
if (highlightToNoteId != null) {
val targetHighlight = userHighlights.find { it.id == highlightToNoteId }
if (targetHighlight != null) {
val effectiveBg = if (activeTheme.backgroundColor == Color.Unspecified) MaterialTheme.colorScheme.surface else activeTheme.backgroundColor
val effectiveText = if (activeTheme.textColor == Color.Unspecified) MaterialTheme.colorScheme.onSurface else activeTheme.textColor
PdfAnnotationBottomSheet(
highlight = targetHighlight,
effectiveBg = effectiveBg,
effectiveText = effectiveText,
customHighlightColors = customHighlightColors,
onPaletteClick = {
highlightColorPickerInitialSlot = targetHighlight.color
showHighlightColorPicker = true
},
onColorChange = { newColor ->
onHighlightUpdate(
targetHighlight.id,
newColor
)
},
onDismiss = { highlightToNoteId = null },
onSave = { noteText ->
val index =
userHighlights.indexOfFirst { it.id == targetHighlight.id }
if (index != -1) {
userHighlights[index] =
targetHighlight.copy(note = noteText.takeIf { it.isNotBlank() })
}
highlightToNoteId = null
},
onDelete = {
onHighlightDelete(targetHighlight.id)
highlightToNoteId = null
},
onCopy = {
val clip = ClipData.newPlainText(
"Copied Text",
targetHighlight.text
)
clipboardManager.setText(
androidx.compose.ui.text.AnnotatedString(
targetHighlight.text
)
)
highlightToNoteId = null
},
onDictionary = {
onDictionaryLookupStable(targetHighlight.text)
highlightToNoteId = null
},
onTranslate = {
onTranslateTextStable(targetHighlight.text)
highlightToNoteId = null
},
onSearch = {
onSearchTextStable(targetHighlight.text)
highlightToNoteId = null
}
)
} else {
highlightToNoteId = null
}
}
if (showHighlightColorPicker) {
HighlightColorPickerDialog(
initialColors = customHighlightColors,
initialSelection = highlightColorPickerInitialSlot,
onDismiss = { showHighlightColorPicker = false },
onSave = { newColors ->
customHighlightColors = newColors
saveCustomHighlightColors(context, newColors)
showHighlightColorPicker = false
}
)
}
if (showThemePanel) {
ReaderThemePanel(
isVisible = true,

View file

@ -224,6 +224,10 @@ object HighlightSerializer {
obj.put("rangeStart", h.range.first)
obj.put("rangeEnd", h.range.second)
if (!h.note.isNullOrBlank()) {
obj.put("note", h.note)
}
val boundsArray = JSONArray()
h.bounds.forEach { r ->
val rObj = JSONObject()
@ -264,7 +268,8 @@ object HighlightSerializer {
bounds = bounds,
color = try { PdfHighlightColor.valueOf(obj.getString("color")) } catch(_: Exception) { PdfHighlightColor.YELLOW },
text = obj.optString("text", ""),
range = Pair(obj.optInt("rangeStart", 0), obj.optInt("rangeEnd", 0))
range = Pair(obj.optInt("rangeStart", 0), obj.optInt("rangeEnd", 0)),
note = obj.optString("note", null).takeIf { !it.isNullOrBlank() }
)
)
}