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

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