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

@ -148,6 +148,7 @@ import androidx.media3.common.util.UnstableApi
import com.aryan.reader.epubreader.PREF_CUSTOM_THEMES
import com.aryan.reader.epubreader.PREF_READER_THEME
import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.pdf.PdfHighlightColor
import com.aryan.reader.tts.GOOGLE_TTS_SPEAKERS
import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsPlaybackManager
@ -2616,4 +2617,207 @@ fun ColorSlider(color: Color, onColorChanged: (Color) -> Unit) {
Slider(value = color.green, onValueChange = { onColorChanged(color.copy(green = it)) }, colors = androidx.compose.material3.SliderDefaults.colors(thumbColor = Color.Green, activeTrackColor = Color.Green), modifier = Modifier.weight(1f))
Slider(value = color.blue, onValueChange = { onColorChanged(color.copy(blue = it)) }, colors = androidx.compose.material3.SliderDefaults.colors(thumbColor = Color.Blue, activeTrackColor = Color.Blue), modifier = Modifier.weight(1f))
}
}
@Composable
fun HighlightColorPickerDialog(
initialColors: Map<PdfHighlightColor, Color>,
initialSelection: PdfHighlightColor = PdfHighlightColor.YELLOW,
onDismiss: () -> Unit,
onSave: (Map<PdfHighlightColor, Color>) -> Unit
) {
var currentColors by remember { mutableStateOf(initialColors) }
var selectedSlot by remember { mutableStateOf(initialSelection) }
val initialActiveColor = currentColors[selectedSlot] ?: selectedSlot.color
val initialHsv = remember(initialActiveColor) {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(initialActiveColor.toArgb(), hsv)
hsv
}
var hue by remember { mutableFloatStateOf(initialHsv[0]) }
var saturation by remember { mutableFloatStateOf(initialHsv[1]) }
var value by remember { mutableFloatStateOf(initialHsv[2]) }
LaunchedEffect(selectedSlot) {
val color = currentColors[selectedSlot] ?: selectedSlot.color
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(color.toArgb(), hsv)
hue = hsv[0]
saturation = hsv[1]
value = hsv[2]
}
val currentColor by remember {
derivedStateOf {
val hsv = floatArrayOf(hue, saturation, value)
Color(android.graphics.Color.HSVToColor(255, hsv))
}
}
LaunchedEffect(currentColor) {
currentColors = currentColors + (selectedSlot to currentColor)
}
fun updateFromColor(color: Color) {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(color.toArgb(), hsv)
hue = hsv[0]
saturation = hsv[1]
value = hsv[2]
}
androidx.compose.ui.window.Dialog(
onDismissRequest = onDismiss,
properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(
shape = RoundedCornerShape(24.dp),
color = Color(0xFF2C2C2C),
modifier = Modifier
.fillMaxWidth(0.9f)
.padding(16.dp)
) {
Column(
modifier = Modifier
.padding(20.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.background(Color(0xFF3E3E3E), RoundedCornerShape(16.dp))
.padding(horizontal = 24.dp, vertical = 8.dp)
) {
Text(
text = "Customize Highlights",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
}
Spacer(Modifier.height(20.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
PdfHighlightColor.entries.forEach { slot ->
val slotColor = currentColors[slot] ?: slot.color
val isSelected = selectedSlot == slot
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(slotColor)
.clickable { selectedSlot = slot }
.border(
width = if (isSelected) 3.dp else 1.dp,
color = if (isSelected) Color.White else Color.Gray,
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = if (slotColor.luminance() > 0.5f) Color.Black else Color.White
)
}
}
}
}
Spacer(Modifier.height(20.dp))
SpectrumBox(
hue = hue,
saturation = saturation,
currentColor = currentColor,
onHueSatChanged = { h, s -> hue = h; saturation = s },
modifier = Modifier.fillMaxWidth().height(220.dp)
)
Spacer(Modifier.height(20.dp))
BrightnessSlider(
hue = hue,
saturation = saturation,
value = value,
onValueChanged = { value = it },
modifier = Modifier.fillMaxWidth().height(24.dp).clip(RoundedCornerShape(12.dp))
)
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
ColorComparePill(
oldColor = selectedSlot.color,
newColor = currentColor,
modifier = Modifier.width(64.dp).height(36.dp)
)
Column(
modifier = Modifier.weight(1.6f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("HEX", color = Color.Gray, fontSize = 12.sp, maxLines = 1)
Spacer(Modifier.height(4.dp))
HexInput(color = currentColor, onHexChanged = { updateFromColor(it) })
}
Row(
modifier = Modifier.weight(2.4f),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
RgbInputColumn(label = "R", value = currentColor.red,
onValueChange = { r -> updateFromColor(currentColor.copy(red = r)) },
modifier = Modifier.weight(1f)
)
RgbInputColumn(label = "G", value = currentColor.green,
onValueChange = { g -> updateFromColor(currentColor.copy(green = g)) },
modifier = Modifier.weight(1f)
)
RgbInputColumn(label = "B", value = currentColor.blue,
onValueChange = { b -> updateFromColor(currentColor.copy(blue = b)) },
modifier = Modifier.weight(1f)
)
}
}
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = { updateFromColor(selectedSlot.color) }) {
Text("Reset", color = Color(0xFFFF5252))
}
Row {
TextButton(onClick = onDismiss) {
Text("Cancel", color = Color.Gray)
}
Spacer(Modifier.width(8.dp))
Button(
onClick = { onSave(currentColors) },
colors = androidx.compose.material3.ButtonDefaults.buttonColors(
containerColor = Color.White
)
) {
Text("Save", color = Color.Black, fontWeight = FontWeight.Bold)
}
}
}
}
}
}
}

View file

@ -82,7 +82,6 @@ import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.core.net.toUri
import com.aryan.reader.ReaderTexture
import com.aryan.reader.paginatedreader.PaginatedTextSelectionMenu
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.json.JSONObject
@ -280,7 +279,9 @@ private data class CustomMenuState(
val selectionBounds: Rect,
val finishActionModeCallback: () -> Unit,
val cfi: String? = null,
val isExistingHighlight: Boolean = false
val isExistingHighlight: Boolean = false,
val note: String? = null,
val selectedColor: HighlightColor? = null
)
@Suppress("unused")
@ -344,6 +345,7 @@ fun ChapterWebView(
onWordSelectedForAiDefinition: (String) -> Unit,
onTranslate: (String) -> Unit,
onSearch: (String) -> Unit,
onNoteRequested: (String?) -> Unit,
onContentReadyForSummarization: suspend (String) -> Unit,
currentFontFamily: ReaderFont,
customFontPath: String? = null,
@ -368,6 +370,7 @@ fun ChapterWebView(
val jsToInject = remember(context) { getJsToInject(context) }
var showPaletteManager by remember { mutableStateOf(false) }
val latestUserHighlights by rememberUpdatedState(userHighlights)
val textureBase64 by remember(activeTextureId) {
mutableStateOf(
@ -461,7 +464,7 @@ fun ChapterWebView(
Timber.d(
"InteractiveWebView factory for $chapterTitle (Key: $key), isDarkTheme: $isDarkTheme, initialScroll: $initialScrollTarget"
)
val webView = InteractiveWebView(
@Suppress("unused") val webView = InteractiveWebView(
context = ctx,
onSingleTap = onTap,
onPotentialScroll = onPotentialScroll,
@ -511,32 +514,11 @@ fun ChapterWebView(
onClickCallback = { cfi, text, left, top, right, bottom ->
this.post {
onHighlightClicked()
val densityValue = density.density
val locationOnScreen = IntArray(2)
this.getLocationOnScreen(locationOnScreen)
val xOffset = locationOnScreen[0]
val yOffset = locationOnScreen[1]
val rect = Rect(
(left * densityValue).toInt() + xOffset,
(top * densityValue).toInt() + yOffset,
(right * densityValue).toInt() + xOffset,
(bottom * densityValue).toInt() + yOffset
)
customMenuState = CustomMenuState(
selectedText = text,
selectionBounds = rect,
finishActionModeCallback = {
localWebViewRef?.evaluateJavascript(
"javascript:if(window.getSelection) window.getSelection().removeAllRanges();",
null
)
},
cfi = cfi,
isExistingHighlight = true
localWebViewRef?.evaluateJavascript(
"javascript:if(window.getSelection) window.getSelection().removeAllRanges();",
null
)
onNoteRequested(cfi)
}
}
), "HighlightBridge"
@ -846,7 +828,13 @@ fun ChapterWebView(
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}');",
null
)
}, modifier = Modifier.fillMaxSize()
val escapedHighlights = escapeJsString(highlightsJson)
webView.evaluateJavascript(
"javascript:window.CURRENT_HIGHLIGHTS = '${escapedHighlights}'; window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);",
null
)
}, modifier = Modifier.fillMaxSize()
)
}
@ -989,7 +977,19 @@ fun ChapterWebView(
}
customMenuState = null
},
onHighlight = null, // Highlight handles itself above in the Colors Row
onNote = {
if (state.isExistingHighlight && state.cfi != null) {
onNoteRequested(state.cfi)
} else {
onNoteRequested(null)
localWebViewRef?.evaluateJavascript(
"javascript:window.HighlightBridgeHelper.createUserHighlight('${HighlightColor.YELLOW.cssClass}', '${HighlightColor.YELLOW.id}');", null
)
}
state.finishActionModeCallback()
customMenuState = null
},
onHighlight = null,
onTts = {
localWebViewRef?.evaluateJavascript(
"javascript:window.TtsBridgeHelper.extractAndRelayTextFromSelection();",
@ -1006,21 +1006,13 @@ fun ChapterWebView(
onDelete = if (state.isExistingHighlight && state.cfi != null) {
{
val highlightToDelete = userHighlights.find { h ->
h.cfi == state.cfi || h.cfi.split("|")
.contains(state.cfi)
h.cfi == state.cfi || h.cfi.split("|").contains(state.cfi)
}
if (highlightToDelete != null) {
val cssClassToDelete = highlightToDelete.color.cssClass
val allCfiParts = highlightToDelete.cfi.split("|")
allCfiParts.forEach { partCfi ->
localWebViewRef?.evaluateJavascript(
"javascript:window.HighlightBridgeHelper.removeHighlightByCfi('${
escapeJsString(
partCfi
)
}', '$cssClassToDelete');", null
)
}
localWebViewRef?.evaluateJavascript(
"javascript:window.HighlightBridgeHelper.removeHighlightByCfi('${escapeJsString(highlightToDelete.cfi)}', '$cssClassToDelete');", null
)
onHighlightDeleted(highlightToDelete.cfi)
}
state.finishActionModeCallback()
@ -1028,7 +1020,10 @@ fun ChapterWebView(
}
} else null,
isProUser = isProUser,
isOss = isOss)
isOss = isOss,
existingNote = state.note,
selectedColor = state.selectedColor
)
}
}
}

View file

@ -24,6 +24,7 @@ import timber.log.Timber
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
@ -44,15 +45,22 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
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.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import com.aryan.reader.R
@ -96,7 +104,8 @@ data class UserHighlight(
val cfi: String,
val text: String,
val color: HighlightColor,
val chapterIndex: Int
val chapterIndex: Int,
val note: String? = null
)
fun escapeJsString(value: String): String {
@ -182,6 +191,7 @@ fun saveHighlightsToPrefs(context: Context, bookTitle: String, highlights: List<
put("text", h.text)
put("colorId", h.color.id)
put("chapterIndex", h.chapterIndex)
put("note", h.note ?: "")
}
jsonArray.put(obj)
}
@ -200,13 +210,15 @@ fun loadHighlightsFromPrefs(context: Context, bookTitle: String): List<UserHighl
val obj = jsonArray.getJSONObject(i)
val colorId = obj.getString("colorId")
val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW
val noteStr = obj.optString("note", "")
list.add(
UserHighlight(
id = obj.optString("id", UUID.randomUUID().toString()),
cfi = obj.getString("cfi"),
text = obj.getString("text"),
color = color,
chapterIndex = obj.getInt("chapterIndex")
chapterIndex = obj.getInt("chapterIndex"),
note = noteStr.takeIf { it.isNotBlank() }
)
)
}
@ -225,13 +237,15 @@ fun parseHighlightsJson(jsonString: String?): List<UserHighlight> {
val obj = jsonArray.getJSONObject(i)
val colorId = obj.getString("colorId")
val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW
val noteStr = obj.optString("note", "")
list.add(
UserHighlight(
id = obj.optString("id", java.util.UUID.randomUUID().toString()),
id = obj.optString("id", UUID.randomUUID().toString()),
cfi = obj.getString("cfi"),
text = obj.getString("text"),
color = color,
chapterIndex = obj.getInt("chapterIndex")
chapterIndex = obj.getInt("chapterIndex"),
note = noteStr.takeIf { it.isNotBlank() }
)
)
}
@ -250,6 +264,7 @@ fun highlightsToJson(highlights: List<UserHighlight>): String {
put("text", h.text)
put("colorId", h.color.id)
put("chapterIndex", h.chapterIndex)
put("note", h.note ?: "")
}
jsonArray.put(obj)
}
@ -271,7 +286,7 @@ fun processAndAddHighlight(
newColor: HighlightColor,
chapterIndex: Int,
currentList: MutableList<UserHighlight>
) {
): String {
val newParts = newCfi.split('|')
val newStartFull = newParts.first()
val newEndFull = newParts.last()
@ -286,10 +301,11 @@ fun processAndAddHighlight(
var finalEndPath = newEndPath
var finalEndOffset = newEndOffset
var finalText = newText
var finalNote: String? = null
while (iterator.hasNext()) {
val existing = iterator.next()
if (existing.chapterIndex != chapterIndex || existing.color != newColor) continue
if (existing.chapterIndex != chapterIndex) continue
val exParts = existing.cfi.split('|')
val exStartFull = exParts.first()
@ -317,6 +333,7 @@ fun processAndAddHighlight(
if (!isDisjoint) {
iterator.remove()
if (existing.note != null && finalNote == null) finalNote = existing.note
val unionStartCmp = comparePaths(finalStartPath, exStartPath)
if (unionStartCmp > 0 || (unionStartCmp == 0 && finalStartOffset > exStartOffset)) {
finalStartPath = exStartPath
@ -331,12 +348,15 @@ fun processAndAddHighlight(
}
}
val finalCfi = "$finalStartPath:$finalStartOffset|$finalEndPath:$finalEndOffset"
currentList.add(UserHighlight(
cfi = "$finalStartPath:$finalStartOffset|$finalEndPath:$finalEndOffset",
cfi = finalCfi,
text = finalText,
color = newColor,
chapterIndex = chapterIndex
chapterIndex = chapterIndex,
note = finalNote
))
return finalCfi
}
// --- UI Components ---
@ -479,4 +499,459 @@ fun PaletteManagerDialog(
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AnnotationBottomSheet(
highlight: UserHighlight,
effectiveBg: Color,
effectiveText: Color,
activeHighlightPalette: List<HighlightColor>,
onColorChange: (HighlightColor) -> Unit,
onOpenPaletteManager: () -> 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, // Matches user theme
contentColor = effectiveText,
contentWindowInsets = { WindowInsets.navigationBars }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 24.dp)
) {
// Top: Highlight Colors
HighlightColorRow(
activeHighlightPalette = activeHighlightPalette,
selectedColor = highlight.color,
onColorSelect = onColorChange,
onOpenPaletteManager = onOpenPaletteManager,
modifier = Modifier.padding(bottom = 16.dp)
)
// Middle: Elegant Highlight Snippet Card
Surface(
color = highlight.color.color.copy(alpha = 0.1f),
shape = RoundedCornerShape(12.dp),
border = BorderStroke(1.dp, highlight.color.color.copy(alpha = 0.3f)),
modifier = Modifier.fillMaxWidth()
) {
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
// Left colored accent bar
Box(
modifier = Modifier
.width(6.dp)
.fillMaxHeight()
.background(highlight.color.color)
)
Text(
text = "\"${highlight.text}\"",
style = MaterialTheme.typography.bodyMedium.copy(fontStyle = androidx.compose.ui.text.font.FontStyle.Italic),
maxLines = 4,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
color = effectiveText.copy(alpha = 0.9f),
modifier = Modifier.padding(16.dp)
)
}
}
Spacer(Modifier.height(16.dp))
// Action Tools Row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
BottomSheetToolButton(icon = R.drawable.copy, label = "Copy", onClick = onCopy, effectiveText = effectiveText)
BottomSheetToolButton(icon = R.drawable.dictionary, label = "Dict", onClick = onDictionary, effectiveText = effectiveText)
BottomSheetToolButton(icon = R.drawable.translate, label = "Translate", onClick = onTranslate, effectiveText = effectiveText)
BottomSheetToolButton(icon = R.drawable.search, label = "Search", onClick = onSearch, effectiveText = effectiveText)
}
Spacer(Modifier.height(16.dp))
// Note TextField
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))
// Bottom Actions
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 BottomSheetToolButton(
icon: Int,
label: String,
onClick: () -> Unit,
effectiveText: Color
) {
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)
)
}
}
@Composable
fun PaginatedTextSelectionMenu(
onCopy: () -> Unit,
onSelectAll: (() -> Unit)?,
onDictionary: () -> Unit,
onTranslate: () -> Unit,
onSearch: () -> Unit,
onHighlight: ((HighlightColor) -> Unit)?,
onNote: (() -> Unit)? = null,
onDelete: (() -> Unit)?,
onTts: (() -> Unit)?,
@Suppress("unused") isProUser: Boolean,
@Suppress("unused") isOss: Boolean,
activeHighlightPalette: List<HighlightColor> = emptyList(),
onOpenPaletteManager: (() -> Unit)? = null
) {
Surface(
shape = RoundedCornerShape(12.dp),
shadowElevation = 6.dp,
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Column(modifier = Modifier.width(IntrinsicSize.Max).widthIn(min = 200.dp)) {
if (onHighlight != null) {
HighlightColorRow(
activeHighlightPalette = activeHighlightPalette,
selectedColor = null,
onColorSelect = onHighlight,
onOpenPaletteManager = onOpenPaletteManager
)
HorizontalDivider()
}
val actions = mutableListOf<MenuActionItem>()
actions.add(MenuActionItem(iconRes = R.drawable.copy, label = "Copy", onClick = onCopy))
if (onTts != null) {
actions.add(MenuActionItem(imageVector = Icons.AutoMirrored.Filled.VolumeUp, label = "Speak", onClick = onTts))
}
actions.add(MenuActionItem(iconRes = R.drawable.dictionary, label = "Dict", onClick = onDictionary))
actions.add(MenuActionItem(iconRes = R.drawable.translate, label = "Translate", onClick = onTranslate))
actions.add(MenuActionItem(iconRes = R.drawable.search, label = "Search", onClick = onSearch))
if (onNote != null) {
actions.add(MenuActionItem(imageVector = Icons.Default.Edit, label = "Note", onClick = onNote))
}
if (onSelectAll != null) {
actions.add(MenuActionItem(iconRes = R.drawable.select_all, label = "Select All", onClick = onSelectAll))
}
if (onDelete != null) {
actions.add(MenuActionItem(imageVector = Icons.Default.Delete, label = "Remove", onClick = onDelete, isError = true))
}
Column(modifier = Modifier.padding(bottom = 4.dp)) {
actions.chunked(3).forEach { rowActions ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
rowActions.forEach { action ->
val tint = if (action.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
Column(
modifier = Modifier
.width(64.dp)
.clip(RoundedCornerShape(8.dp))
.clickable { action.onClick() }
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (action.imageVector != null) {
Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
} else if (action.iconRes != null) {
Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.height(4.dp))
Text(text = action.label, style = MaterialTheme.typography.labelSmall, color = tint, maxLines = 1)
}
}
repeat(3 - rowActions.size) {
Spacer(modifier = Modifier.width(64.dp))
}
}
}
}
}
}
}
private class MenuActionItem(
val iconRes: Int? = null,
val imageVector: androidx.compose.ui.graphics.vector.ImageVector? = null,
val label: String,
val onClick: () -> Unit,
val isError: Boolean = false
)
@Composable
fun HighlightColorRow(
modifier: Modifier = Modifier,
activeHighlightPalette: List<HighlightColor>,
selectedColor: HighlightColor? = null,
onColorSelect: (HighlightColor) -> Unit,
onOpenPaletteManager: (() -> Unit)? = null,
) {
Row(
modifier = modifier
.padding(vertical = 12.dp, horizontal = 12.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
activeHighlightPalette.forEach { colorEnum ->
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.padding(horizontal = 6.dp)
.size(32.dp)
.clip(CircleShape) // 1. Clip shape for ripple
.background(colorEnum.color) // 2. Apply background
.clickable {
Timber.d("HighlightColorRow: Color clicked -> ${colorEnum.name}")
onColorSelect(colorEnum)
} // 3. Add clickable (ripple)
.border( // 4. Add border on top
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 (colorEnum == HighlightColor.WHITE || colorEnum == HighlightColor.YELLOW) Color.Black else Color.White,
modifier = Modifier.size(18.dp)
)
}
}
}
if (onOpenPaletteManager != null) {
Spacer(modifier = Modifier.width(8.dp))
SpectrumButton(
onClick = onOpenPaletteManager,
size = 32.dp
)
}
}
}
@Composable
fun PaginatedTextSelectionMenu(
onCopy: () -> Unit,
onSelectAll: (() -> Unit)?,
onDictionary: () -> Unit,
onTranslate: () -> Unit,
onSearch: () -> Unit,
onHighlight: ((HighlightColor) -> Unit)?,
onNote: (() -> Unit)? = null,
onDelete: (() -> Unit)?,
onTts: (() -> Unit)?,
@Suppress("unused") isProUser: Boolean,
@Suppress("unused") isOss: Boolean,
activeHighlightPalette: List<HighlightColor> = emptyList(),
onOpenPaletteManager: (() -> Unit)? = null,
existingNote: String? = null,
selectedColor: HighlightColor? = null
) {
Surface(
shape = RoundedCornerShape(12.dp),
shadowElevation = 6.dp,
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Column(modifier = Modifier.width(IntrinsicSize.Max).widthIn(min = 200.dp)) {
// 1. Colors Row
if (onHighlight != null) {
HighlightColorRow(
activeHighlightPalette = activeHighlightPalette,
selectedColor = selectedColor,
onColorSelect = onHighlight,
onOpenPaletteManager = onOpenPaletteManager
)
HorizontalDivider()
}
// 2. Improved Comment/Note View
if (!existingNote.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 = existingNote,
style = MaterialTheme.typography.bodyMedium.copy(
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
HorizontalDivider()
}
val actions = mutableListOf<MenuActionItem>()
actions.add(MenuActionItem(iconRes = R.drawable.copy, label = "Copy", onClick = onCopy))
if (onTts != null) {
actions.add(MenuActionItem(imageVector = Icons.AutoMirrored.Filled.VolumeUp, label = "Speak", onClick = onTts))
}
actions.add(MenuActionItem(iconRes = R.drawable.dictionary, label = "Dict", onClick = onDictionary))
actions.add(MenuActionItem(iconRes = R.drawable.translate, label = "Translate", onClick = onTranslate))
actions.add(MenuActionItem(iconRes = R.drawable.search, label = "Search", onClick = onSearch))
if (onNote != null) {
val noteLabel = if (existingNote.isNullOrBlank()) "Note" else "Edit"
actions.add(MenuActionItem(imageVector = Icons.Default.Edit, label = noteLabel, onClick = onNote))
}
if (onSelectAll != null) {
actions.add(MenuActionItem(iconRes = R.drawable.select_all, label = "Select All", onClick = onSelectAll))
}
if (onDelete != null) {
actions.add(MenuActionItem(imageVector = Icons.Default.Delete, label = "Remove", onClick = onDelete, isError = true))
}
Column(modifier = Modifier.padding(bottom = 4.dp)) {
actions.chunked(3).forEach { rowActions ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
rowActions.forEach { action ->
val tint = if (action.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
Column(
modifier = Modifier
.width(64.dp)
.clickable { action.onClick() }
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (action.imageVector != null) {
Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
} else if (action.iconRes != null) {
Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.height(4.dp))
Text(text = action.label, style = MaterialTheme.typography.labelSmall, color = tint, maxLines = 1)
}
}
repeat(3 - rowActions.size) {
Spacer(modifier = Modifier.width(64.dp))
}
}
}
}
}
}
}

View file

@ -29,6 +29,7 @@ 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -65,6 +66,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
@ -213,7 +215,11 @@ fun EpubReaderDrawerSheet(
onNavigateToHighlight: (UserHighlight) -> Unit,
onDeleteBookmark: (Bookmark) -> Unit,
onRenameBookmark: (Bookmark, String) -> Unit,
onDeleteHighlight: (UserHighlight) -> Unit
onDeleteHighlight: (UserHighlight) -> Unit,
onEditNote: (UserHighlight) -> Unit,
activeHighlightPalette: List<HighlightColor>,
onOpenPaletteManager: () -> Unit,
onHighlightColorChange: (UserHighlight, HighlightColor) -> Unit
) {
ModalDrawerSheet(
modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)
@ -236,7 +242,7 @@ fun EpubReaderDrawerSheet(
Tab(
selected = drawerPagerState.currentPage == 2,
onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(2) } },
text = { Text(stringResource(R.string.tab_highlights)) }
text = { Text("Annotations") }
)
}
@ -267,7 +273,11 @@ fun EpubReaderDrawerSheet(
userHighlights = userHighlights,
chapters = chapters,
onNavigateToHighlight = onNavigateToHighlight,
onDeleteHighlight = onDeleteHighlight
onDeleteHighlight = onDeleteHighlight,
onEditNote = onEditNote,
activeHighlightPalette = activeHighlightPalette,
onOpenPaletteManager = onOpenPaletteManager,
onHighlightColorChange = onHighlightColorChange
)
}
}
@ -639,12 +649,17 @@ private fun BookmarksList(
}
}
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
private fun HighlightsList(
userHighlights: List<UserHighlight>,
chapters: List<EpubChapter>,
onNavigateToHighlight: (UserHighlight) -> Unit,
onDeleteHighlight: (UserHighlight) -> Unit
onDeleteHighlight: (UserHighlight) -> Unit,
onEditNote: (UserHighlight) -> Unit,
activeHighlightPalette: List<HighlightColor>,
onOpenPaletteManager: () -> Unit,
onHighlightColorChange: (UserHighlight, HighlightColor) -> Unit
) {
if (userHighlights.isEmpty()) {
Box(modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) {
@ -653,76 +668,138 @@ private fun HighlightsList(
} else {
var highlightMenuExpandedFor by remember { mutableStateOf<UserHighlight?>(null) }
var showHighlightDeleteDialogFor by remember { mutableStateOf<UserHighlight?>(null) }
var filterWithNotesOnly by remember { mutableStateOf(false) } // ADDED
val listState = rememberLazyListState()
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().padding(end = 4.dp)
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
items = userHighlights.sortedBy { it.chapterIndex },
key = { it.id }
) { highlight ->
val chapterTitle = chapters.getOrNull(highlight.chapterIndex)?.title ?: stringResource(R.string.unknown_chapter)
ListItem(
headlineContent = {
Text(
text = highlight.text,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.SemiBold
)
},
supportingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(12.dp)
.background(highlight.color.color, CircleShape)
)
Spacer(Modifier.width(8.dp))
Text(
text = chapterTitle,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
trailingContent = {
Box {
IconButton(onClick = { highlightMenuExpandedFor = highlight }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.content_desc_options)
)
}
DropdownMenu(
expanded = highlightMenuExpandedFor == highlight,
onDismissRequest = { highlightMenuExpandedFor = null }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.action_delete)) },
onClick = {
showHighlightDeleteDialogFor = highlight
highlightMenuExpandedFor = null
}
)
}
}
},
modifier = Modifier.clickable { onNavigateToHighlight(highlight) }
)
HorizontalDivider()
}
androidx.compose.material3.FilterChip(
selected = !filterWithNotesOnly,
onClick = { filterWithNotesOnly = false },
label = { Text("All") }
)
androidx.compose.material3.FilterChip(
selected = filterWithNotesOnly,
onClick = { filterWithNotesOnly = true },
label = { Text("With Notes") }
)
}
VerticalScrollbar(
listState = listState,
modifier = Modifier.align(Alignment.CenterEnd)
)
val filteredHighlights = if (filterWithNotesOnly) {
userHighlights.filter { !it.note.isNullOrBlank() }
} else {
userHighlights
}
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().padding(end = 4.dp)
) {
items(
items = filteredHighlights.sortedBy { it.chapterIndex },
key = { it.id }
) { highlight ->
val chapterTitle = chapters.getOrNull(highlight.chapterIndex)?.title ?: stringResource(R.string.unknown_chapter)
ListItem(
headlineContent = {
Text(
text = highlight.text,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.SemiBold
)
},
supportingContent = {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(12.dp)
.background(highlight.color.color, CircleShape)
)
Spacer(Modifier.width(8.dp))
Text(
text = chapterTitle,
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 = androidx.compose.ui.text.font.FontStyle.Italic),
modifier = Modifier.padding(12.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
},
trailingContent = {
Box {
IconButton(onClick = { highlightMenuExpandedFor = highlight }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.content_desc_options)
)
}
DropdownMenu(
expanded = highlightMenuExpandedFor == highlight,
onDismissRequest = { highlightMenuExpandedFor = null }
) {
HighlightColorRow(
activeHighlightPalette = activeHighlightPalette,
selectedColor = highlight.color,
onColorSelect = { color ->
onHighlightColorChange(highlight, color)
highlightMenuExpandedFor = null
},
onOpenPaletteManager = {
onOpenPaletteManager()
highlightMenuExpandedFor = null
}
)
HorizontalDivider()
DropdownMenuItem(
text = { Text(if (highlight.note.isNullOrBlank()) "Add Note" else "Edit Note") },
onClick = {
onEditNote(highlight)
highlightMenuExpandedFor = null
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.action_delete)) },
onClick = {
showHighlightDeleteDialogFor = highlight
highlightMenuExpandedFor = null
}
)
}
}
},
modifier = Modifier.clickable { onNavigateToHighlight(highlight) }
)
HorizontalDivider()
}
}
VerticalScrollbar(
listState = listState,
modifier = Modifier.align(Alignment.CenterEnd)
)
}
}
showHighlightDeleteDialogFor?.let { highlightToDelete ->

View file

@ -477,6 +477,9 @@ fun EpubReaderHost(
var sliderStartPage by remember { mutableIntStateOf(0) }
var startPageThumbnail by remember { mutableStateOf<Bitmap?>(null) }
var pendingNoteForNewHighlight by remember { mutableStateOf(false) }
var highlightToNoteCfi by remember { mutableStateOf<String?>(null) }
var showJustifyWarningDialog by remember { mutableStateOf(false) }
var isNavigatingByToc by remember { mutableStateOf(false) }
@ -1022,6 +1025,7 @@ fun EpubReaderHost(
var showTtsSettingsSheet by remember { mutableStateOf(false) }
var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) }
var showThemePanel by remember { mutableStateOf(false) }
var showPaletteManager by remember { mutableStateOf(false) }
var currentThemeId by remember { mutableStateOf(loadReaderThemeId(context)) }
var customThemes by remember { mutableStateOf(loadCustomThemes(context)) }
@ -1066,6 +1070,18 @@ fun EpubReaderHost(
}
}
val onHighlightColorChange: (UserHighlight, HighlightColor) -> Unit = { targetHighlight, newColor ->
val index = userHighlights.indexOfFirst { it.cfi == targetHighlight.cfi }
if (index != -1) {
userHighlights[index] = targetHighlight.copy(color = newColor)
if (currentRenderMode == RenderMode.VERTICAL_SCROLL && targetHighlight.chapterIndex == currentChapterIndex) {
val cssClass = newColor.cssClass
val jsCommand = "javascript:window.HighlightBridgeHelper.updateHighlightStyle('${escapeJsString(targetHighlight.cfi)}', '$cssClass', '${newColor.id}');"
webViewRefForTts?.evaluateJavascript(jsCommand, null)
}
}
}
fun startTts() {
if (isAutoScrollModeActive) {
isAutoScrollModeActive = false
@ -1637,13 +1653,16 @@ fun EpubReaderHost(
drawerContent = {
EpubReaderDrawerSheet(
chapters = chapters,
tableOfContents = epubBook.tableOfContents, // Pass TOC
tableOfContents = epubBook.tableOfContents,
activeFragmentId = activeFragmentId,
bookmarks = bookmarks,
userHighlights = userHighlights,
currentChapterIndex = currentChapterIndex,
currentChapterInPaginatedMode = currentChapterInPaginatedMode,
renderMode = currentRenderMode,
activeHighlightPalette = currentHighlightPalette,
onOpenPaletteManager = { showPaletteManager = true },
onHighlightColorChange = onHighlightColorChange,
onNavigateToTocEntry = { entry ->
scope.launch {
drawerState.close()
@ -1948,15 +1967,14 @@ fun EpubReaderHost(
highlightToDelete.chapterIndex == currentChapterIndex) {
val cssClass = highlightToDelete.color.cssClass
val cfiParts = highlightToDelete.cfi.split("|")
cfiParts.forEach { partCfi ->
val jsCommand = "javascript:window.HighlightBridgeHelper.removeHighlightByCfi('${escapeJsString(partCfi)}', '$cssClass');"
Timber.d("Executing JS removal for part: $partCfi")
webViewRefForTts?.evaluateJavascript(jsCommand, null)
}
val jsCommand = "javascript:window.HighlightBridgeHelper.removeHighlightByCfi('${escapeJsString(highlightToDelete.cfi)}', '$cssClass');"
Timber.d("Executing JS removal for highlight: ${highlightToDelete.cfi}")
webViewRefForTts?.evaluateJavascript(jsCommand, null)
}
}
},
onEditNote = { highlight ->
highlightToNoteCfi = highlight.cfi
},
)
}
) {
@ -2238,21 +2256,25 @@ fun EpubReaderHost(
Timber.d("Vertical Mode (Source): Creating Highlight. CFI: $cfi")
Timber.d("Vertical Mode (Source): Text Snippet: '${text.take(50)}...'")
val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW
val existingIndex = userHighlights.indexOfFirst { it.cfi == cfi }
if (existingIndex != -1) {
val existing = userHighlights[existingIndex]
userHighlights[existingIndex] = existing.copy(color = color, text = text)
Timber.d("Kotlin: Updated existing highlight at index $existingIndex")
val finalCfi = processAndAddHighlight(
newCfi = cfi,
newText = text,
newColor = color,
chapterIndex = currentChapterIndex,
currentList = userHighlights
)
if (pendingNoteForNewHighlight) {
pendingNoteForNewHighlight = false
highlightToNoteCfi = finalCfi
}
},
onNoteRequested = { cfi ->
if (cfi != null) {
highlightToNoteCfi = cfi
} else {
val highlight = UserHighlight(
cfi = cfi,
text = text,
color = color,
chapterIndex = currentChapterIndex
)
userHighlights.add(highlight)
Timber.d("Kotlin: Added new highlight")
pendingNoteForNewHighlight = true
}
},
onHighlightDeleted = { cfi ->
@ -2896,13 +2918,24 @@ fun EpubReaderHost(
onHighlightCreated = { cfi, text, colorId ->
Timber.d("EpubReaderScreen: onHighlightCreated. CFI: $cfi")
val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW
processAndAddHighlight(
val finalCfi = processAndAddHighlight(
newCfi = cfi,
newText = text,
newColor = color,
chapterIndex = currentChapterInPaginatedMode ?: 0,
currentList = userHighlights
)
if (pendingNoteForNewHighlight) {
pendingNoteForNewHighlight = false
highlightToNoteCfi = finalCfi
}
},
onNoteRequested = { cfi ->
if (cfi != null) {
highlightToNoteCfi = cfi
} else {
pendingNoteForNewHighlight = true
}
},
onHighlightDeleted = { cfi ->
val toRemove = userHighlights.find { it.cfi == cfi }
@ -3933,6 +3966,60 @@ fun EpubReaderHost(
}
}
}
if (highlightToNoteCfi != null) {
val targetHighlight = userHighlights.find {
it.cfi == highlightToNoteCfi || (highlightToNoteCfi != null && it.cfi.contains(highlightToNoteCfi!!))
}
if (targetHighlight != null) {
AnnotationBottomSheet(
highlight = targetHighlight,
effectiveBg = effectiveBg,
effectiveText = effectiveText,
activeHighlightPalette = currentHighlightPalette,
onColorChange = { newColor -> onHighlightColorChange(targetHighlight, newColor) },
onOpenPaletteManager = { showPaletteManager = true },
onDismiss = { highlightToNoteCfi = null },
onSave = { noteText ->
val index = userHighlights.indexOfFirst { it.cfi == targetHighlight.cfi }
if (index != -1) {
userHighlights[index] = targetHighlight.copy(note = noteText.takeIf { it.isNotBlank() })
}
highlightToNoteCfi = null
},
onDelete = {
userHighlights.remove(targetHighlight)
if (currentRenderMode == RenderMode.VERTICAL_SCROLL && targetHighlight.chapterIndex == currentChapterIndex) {
val cssClass = targetHighlight.color.cssClass
val jsCommand = "javascript:window.HighlightBridgeHelper.removeHighlightByCfi('${escapeJsString(
targetHighlight.cfi)}', '$cssClass');"
webViewRefForTts?.evaluateJavascript(jsCommand, null)
}
highlightToNoteCfi = null
},
onCopy = {
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
val clip = android.content.ClipData.newPlainText("Copied Text", targetHighlight.text)
clipboardManager.setPrimaryClip(clip)
highlightToNoteCfi = null
},
onDictionary = {
onDictionaryLookup(targetHighlight.text)
highlightToNoteCfi = null
},
onTranslate = {
onTranslateLookup(targetHighlight.text)
highlightToNoteCfi = null
},
onSearch = {
onSearchLookup(targetHighlight.text)
highlightToNoteCfi = null
}
)
}
}
CustomTopBanner(bannerMessage = bannerMessage)
}
}
@ -4097,5 +4184,18 @@ fun EpubReaderHost(
onCustomThemesUpdated = { customThemes = it; saveCustomThemes(context, it) }
)
}
if (showPaletteManager) {
PaletteManagerDialog(
currentPalette = currentHighlightPalette,
onDismiss = { showPaletteManager = false },
onSave = { newPalette ->
newPalette.forEachIndexed { index, color ->
onUpdateHighlightPalette(index, color)
}
showPaletteManager = false
}
)
}
}
}

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() }
)
)
}