diff --git a/app/src/main/assets/epub_reader.js b/app/src/main/assets/epub_reader.js index a86c5a3..fc75007 100644 --- a/app/src/main/assets/epub_reader.js +++ b/app/src/main/assets/epub_reader.js @@ -423,8 +423,8 @@ var cfiToReport = rawCfi; - if (rawCfi && rawCfi.includes("|")) { - var cfiParts = rawCfi.split("|"); + if (rawCfi && rawCfi.includes(";;")) { + var cfiParts = rawCfi.split(";;"); cfiToReport = cfiParts[cfiParts.length - 1]; console.log("HandleInteraction: Multi-CFI detected on single span. Reporting top layer: " + cfiToReport); } @@ -2051,31 +2051,30 @@ let idx = parseInt(div.dataset.chunkIndex, 10); if (entry.isIntersecting) { - // Check if we have data for this chunk at specific index if (!this.chunksData[idx]) { if (window.ContentBridge && window.ContentBridge.requestChunk) { window.ContentBridge.requestChunk(idx); } } else if (div.innerHTML === "") { - // Restore content from cache let oldHeight = div.getBoundingClientRect().height; - div.innerHTML = this.chunksData[idx]; // FIX: Access by index - div.style.height = ""; // Allow auto height + div.innerHTML = this.chunksData[idx]; + div.style.height = ""; let newHeight = div.getBoundingClientRect().height; - this.chunkHeights[idx] = newHeight; // Update cached height + this.chunkHeights[idx] = newHeight; - // Adjust scroll if this expansion happened above our viewport if (div.getBoundingClientRect().top < 0) { scrollAdjust += (newHeight - oldHeight); } + if (window.CURRENT_HIGHLIGHTS) { + window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS); + } } } else { - // Unload content to save memory/DOM weight if (div.innerHTML !== "") { let oldHeight = div.getBoundingClientRect().height; this.chunkHeights[idx] = oldHeight; - div.style.height = oldHeight + "px"; // Fix height to placeholder + div.style.height = oldHeight + "px"; div.innerHTML = ""; } } @@ -2115,6 +2114,9 @@ if (div.getBoundingClientRect().bottom < 0) { window.scrollBy(0, newHeight - oldHeight); } + if (window.CURRENT_HIGHLIGHTS) { + window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS); + } } if (window.checkImagesForDiagnosis) { @@ -2133,7 +2135,7 @@ allSpans.forEach((span) => { var currentCfiAttr = span.getAttribute("data-cfi") || ""; - var cfis = currentCfiAttr.split("|"); + var cfis = currentCfiAttr.split(";;"); if (cfis.includes(cfi)) { var classesToRemove = []; @@ -2288,10 +2290,10 @@ if (parent && parent.tagName === "SPAN" && parent.classList.contains(className)) { var currentCfi = parent.getAttribute("data-cfi") || ""; - var cfiList = currentCfi.split("|"); + var cfiList = currentCfi.split(";;"); if (!cfiList.includes(newCfi)) { - parent.setAttribute("data-cfi", currentCfi + "|" + newCfi); + parent.setAttribute("data-cfi", currentCfi ? (currentCfi + ";;" + newCfi) : newCfi); } } else { if (node.nodeValue.trim().length === 0) return; @@ -2366,7 +2368,7 @@ allSpans.forEach((span) => { var currentCfiAttr = span.getAttribute("data-cfi") || ""; - var cfiList = currentCfiAttr.split("|"); + var cfiList = currentCfiAttr.split(";;"); // Detailed check for match if (cfiList.includes(cfiToRemove)) { @@ -2397,15 +2399,14 @@ parent.normalize(); removedCount++; } else { - // CASE 2: Overlapping highlight -> Update data-cfi console.log(`$ { HL_LOG_TAG } : -> Updating span (remaining CFIs: $ { - newCfiList.join('|') + newCfiList.join(';;') }).`); - span.setAttribute("data-cfi", newCfiList.join("|")); + span.setAttribute("data-cfi", newCfiList.join(";;")); if (optionalCssClass) { console.log(`$ { @@ -2497,9 +2498,16 @@ }, applyHighlight: function (cfi, text, cssClass) { - // "Healed" Apply Logic: Checks text equality before applying try { - if (document.querySelector(`span[data-cfi='${cfi}']`)) return; + var alreadyApplied = false; + var spans = document.querySelectorAll(`span[data-cfi]`); + for (var i = 0; i < spans.length; i++) { + if ((spans[i].getAttribute("data-cfi") || "").split(";;").includes(cfi)) { + alreadyApplied = true; + break; + } + } + if (alreadyApplied) return; const location = window.getNodeAndOffsetFromCfi(cfi); if (!location || !location.node) return; diff --git a/app/src/main/java/com/aryan/reader/Common.kt b/app/src/main/java/com/aryan/reader/Common.kt index ad14e8a..9206e28 100644 --- a/app/src/main/java/com/aryan/reader/Common.kt +++ b/app/src/main/java/com/aryan/reader/Common.kt @@ -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, + initialSelection: PdfHighlightColor = PdfHighlightColor.YELLOW, + onDismiss: () -> Unit, + onSave: (Map) -> 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) + } + } + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt index 4fd31f8..3b5d860 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt @@ -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 + ) } } } diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt index 194a4e4..d4d8695 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt @@ -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 { 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): 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 -) { +): 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, + 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 = 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() + 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, + 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 = 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() + 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)) + } + } + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt index f533ef8..d89c376 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt @@ -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, + 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, chapters: List, onNavigateToHighlight: (UserHighlight) -> Unit, - onDeleteHighlight: (UserHighlight) -> Unit + onDeleteHighlight: (UserHighlight) -> Unit, + onEditNote: (UserHighlight) -> Unit, + activeHighlightPalette: List, + 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(null) } var showHighlightDeleteDialogFor by remember { mutableStateOf(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 -> diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt index e9312d9..0ac6a33 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -477,6 +477,9 @@ fun EpubReaderHost( var sliderStartPage by remember { mutableIntStateOf(0) } var startPageThumbnail by remember { mutableStateOf(null) } + var pendingNoteForNewHighlight by remember { mutableStateOf(false) } + var highlightToNoteCfi by remember { mutableStateOf(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 + } + ) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt index 2feb3a5..f98a6db 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt @@ -1,22 +1,3 @@ -/* - * Episteme Reader - A native Android document reader. - * Copyright (C) 2026 Episteme - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * mail: epistemereader@gmail.com - */ // PaginatedReader.kt package com.aryan.reader.paginatedreader @@ -29,7 +10,6 @@ import android.content.Intent import android.os.Build import android.widget.Toast import androidx.annotation.RequiresApi -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -59,17 +39,10 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.VolumeUp -import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -122,7 +95,6 @@ import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.TextToolbar import androidx.compose.ui.platform.TextToolbarStatus import androidx.compose.ui.res.imageResource -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.SpanStyle @@ -157,14 +129,13 @@ import coil.ImageLoader import coil.compose.AsyncImage import coil.imageLoader import coil.request.ImageRequest.Builder -import com.aryan.reader.R import com.aryan.reader.ReaderTexture import com.aryan.reader.countWords import com.aryan.reader.epub.EpubBook import com.aryan.reader.epubreader.HighlightColor +import com.aryan.reader.epubreader.PaginatedTextSelectionMenu import com.aryan.reader.epubreader.PaletteManagerDialog import com.aryan.reader.epubreader.ReaderTextAlign -import com.aryan.reader.epubreader.SpectrumButton import com.aryan.reader.epubreader.TtsHighlightInfo import com.aryan.reader.epubreader.UserHighlight import com.aryan.reader.paginatedreader.data.BookCacheDatabase @@ -513,6 +484,7 @@ fun PaginatedReaderScreen( onTranslate: (String) -> Unit, onSearch: (String) -> Unit, onStartTtsFromSelection: (String, Int) -> Unit, + onNoteRequested: (String?) -> Unit, userHighlights: List, onHighlightCreated: (String, String, String) -> Unit, onHighlightDeleted: (String) -> Unit, @@ -848,6 +820,7 @@ fun PaginatedReaderScreen( onTranslate = onTranslate, onSearch = onSearch, onStartTtsFromSelection = onStartTtsFromSelection, + onNoteRequested = onNoteRequested, userHighlights = userHighlights, onHighlightCreated = onHighlightCreated, onHighlightDeleted = onHighlightDeleted, @@ -1424,6 +1397,7 @@ internal fun PaginatedReaderContent( onTranslate: (String) -> Unit, onSearch: (String) -> Unit, onStartTtsFromSelection: (String, Int) -> Unit, + onNoteRequested: (String?) -> Unit, onGetChapterInfo: (Int) -> Pair?, userHighlights: List, onHighlightCreated: (String, String, String) -> Unit, @@ -1440,7 +1414,6 @@ internal fun PaginatedReaderContent( val imageLoader = context.imageLoader val textMeasurer = rememberTextMeasurer() var activeSelection by remember { mutableStateOf(null) } - var activeHighlightForMenu by remember { mutableStateOf?>(null) } val blockLayoutMap = remember { androidx.compose.runtime.mutableStateMapOf>() } @@ -1490,13 +1463,17 @@ internal fun PaginatedReaderContent( } val textToolbar = remember { - CustomPaginatedTextToolbar(onShow = { rect, onCopy, onSelectAll -> - if (activeHighlightForMenu == null) { + CustomPaginatedTextToolbar( + onShow = { rect, onCopy, onSelectAll -> menuState = PaginatedMenuState( - rect, onCopy, onHide = { menuState = null }, onSelectAll + rect = rect, + onCopy = onCopy, + onHide = { menuState = null }, + onSelectAll = onSelectAll ) - } - }, onHide = { menuState = null }) + }, + onHide = { menuState = null } + ) } var pageTurnTouchY by remember { mutableStateOf(null) } @@ -1776,9 +1753,8 @@ internal fun PaginatedReaderContent( onSelectionChange = { sel -> activeSelection = sel }, - onHighlightClick = { highlight, rect -> - activeHighlightForMenu = - highlight to rect + onHighlightClick = { highlight, _ -> + onNoteRequested(highlight.cfi) activeSelection = null menuState = null }, @@ -1854,9 +1830,8 @@ internal fun PaginatedReaderContent( onSelectionChange = { sel -> activeSelection = sel }, - onHighlightClick = { highlight, rect -> - activeHighlightForMenu = - highlight to rect + onHighlightClick = { highlight, _ -> + onNoteRequested(highlight.cfi) activeSelection = null menuState = null }, @@ -1933,9 +1908,8 @@ internal fun PaginatedReaderContent( onSelectionChange = { sel -> activeSelection = sel }, - onHighlightClick = { highlight, rect -> - activeHighlightForMenu = - highlight to rect + onHighlightClick = { highlight, _ -> + onNoteRequested(highlight.cfi) activeSelection = null menuState = null }, @@ -2048,9 +2022,8 @@ internal fun PaginatedReaderContent( onSelectionChange = { sel -> activeSelection = sel }, - onHighlightClick = { highlight, rect -> - activeHighlightForMenu = - highlight to rect + onHighlightClick = { highlight, _ -> + onNoteRequested(highlight.cfi) activeSelection = null menuState = null }, @@ -2120,9 +2093,8 @@ internal fun PaginatedReaderContent( onSelectionChange = { sel -> activeSelection = sel }, - onHighlightClick = { highlight, rect -> - activeHighlightForMenu = - highlight to rect + onHighlightClick = { highlight, _ -> + onNoteRequested(highlight.cfi) activeSelection = null menuState = null }, @@ -2169,9 +2141,8 @@ internal fun PaginatedReaderContent( onSelectionChange = { sel -> activeSelection = sel }, - onHighlightClick = { highlight, rect -> - activeHighlightForMenu = - highlight to rect + onHighlightClick = { highlight, _ -> + onNoteRequested(highlight.cfi) activeSelection = null menuState = null }, @@ -2571,281 +2542,375 @@ internal fun PaginatedReaderContent( }, onDismissRequest = { state.onHide() }) { PaginatedTextSelectionMenu( onCopy = { - isForDictionary = false - isForHighlight = false - state.onCopy() - state.onHide() - }, onSelectAll = { - state.onSelectAll?.invoke() - state.onHide() - }, onTts = { - isForHighlight = true - state.onCopy() - isForHighlight = false + isForDictionary = false + isForHighlight = false + state.onCopy() + state.onHide() + }, onSelectAll = { + state.onSelectAll?.invoke() + state.onHide() + }, onTts = { + isForHighlight = true + state.onCopy() + isForHighlight = false - capturedTextForAction?.let { text -> - val selectionRect = state.rect - var geometricSuccess = false - val candidates = blockLayoutMap.filter { (_, triple) -> - val (_, coords, _) = triple - if (!coords.isAttached) return@filter false - val pos = coords.positionInWindow() - val size = coords.size.toSize() - Rect(pos, size).overlaps(selectionRect) - } - if (candidates.isNotEmpty()) { - try { - val sorted = candidates.entries.sortedBy { it.value.second.positionInWindow().y } - val firstEntry = sorted.first() - val startCfi: String = firstEntry.key - val startTriple = firstEntry.value + capturedTextForAction?.let { text -> + val selectionRect = state.rect + var geometricSuccess = false + val candidates = blockLayoutMap.filter { (_, triple) -> + val (_, coords, _) = triple + if (!coords.isAttached) return@filter false + val pos = coords.positionInWindow() + val size = coords.size.toSize() + Rect(pos, size).overlaps(selectionRect) + } + if (candidates.isNotEmpty()) { + try { + val sorted = candidates.entries.sortedBy { it.value.second.positionInWindow().y } + val firstEntry = sorted.first() + val startCfi: String = firstEntry.key + val startTriple = firstEntry.value - val startLayout: TextLayoutResult = startTriple.first - val startCoords: LayoutCoordinates = startTriple.second - val startAbsOffset: Int = startTriple.third + val startLayout: TextLayoutResult = startTriple.first + val startCoords: LayoutCoordinates = startTriple.second + val startAbsOffset: Int = startTriple.third - val localStart = startCoords.windowToLocal(selectionRect.topLeft) - val finalStartOffset = startLayout.getOffsetForPosition(localStart) - val absStart: Int = finalStartOffset + startAbsOffset + val localStart = startCoords.windowToLocal(selectionRect.topLeft) + val finalStartOffset = startLayout.getOffsetForPosition(localStart) + val absStart: Int = finalStartOffset + startAbsOffset - onStartTtsFromSelection(startCfi, absStart) - geometricSuccess = true - } catch(e: Exception) { - Timber.e(e, "TTS Selection error") - } - } - if (!geometricSuccess) { - val pageInfo = onGetPage(pagerState.currentPage) - val firstCfi = pageInfo?.content?.firstOrNull { it.cfi != null }?.cfi - if (firstCfi != null) { - onStartTtsFromSelection(firstCfi, 0) - } + onStartTtsFromSelection(startCfi, absStart) + geometricSuccess = true + } catch(e: Exception) { + Timber.e(e, "TTS Selection error") } } - state.onHide() - }, onDictionary = { - isForDictionary = true - state.onCopy() - isForDictionary = false - state.onHide() - }, onTranslate = { - state.onCopy() - onTranslate(capturedTextForAction ?: "") - state.onHide() - }, onSearch = { - onSearch(capturedTextForAction ?: "") - state.onHide() - }, onHighlight = { color -> - Timber.d("Menu: Highlight option clicked. Color: ${color.id}") - isForHighlight = true - state.onCopy() - isForHighlight = false - - capturedTextForAction?.let { text -> - val selectionRect = state.rect - Timber.d("Menu: Selection Rect: $selectionRect") - - var geometricSuccess = false - val candidates = blockLayoutMap.filter { (_, triple) -> - val (_, coords, _) = triple - if (!coords.isAttached) return@filter false - val pos = coords.positionInWindow() - val size = coords.size.toSize() - val blockRect = Rect(pos, size) - val overlaps = blockRect.overlaps(selectionRect) - overlaps + if (!geometricSuccess) { + val pageInfo = onGetPage(pagerState.currentPage) + val firstCfi = pageInfo?.content?.firstOrNull { it.cfi != null }?.cfi + if (firstCfi != null) { + onStartTtsFromSelection(firstCfi, 0) } + } + } + state.onHide() + }, onDictionary = { + isForDictionary = true + state.onCopy() + isForDictionary = false + state.onHide() + }, onTranslate = { + state.onCopy() + onTranslate(capturedTextForAction ?: "") + state.onHide() + }, onSearch = { + onSearch(capturedTextForAction ?: "") + state.onHide() + }, onHighlight = { color -> + val handleHighlightAction = { selectedColor: HighlightColor, isNote: Boolean -> + Timber.d("Menu: Highlight option clicked. Color: ${selectedColor.id}") + isForHighlight = true + state.onCopy() + isForHighlight = false - if (candidates.isNotEmpty()) { - Timber.d( - "Menu: Geometric candidates found: ${candidates.keys}" - ) - try { - val sorted = candidates.entries.sortedBy { - it.value.second.positionInWindow().y - } - val (startCfi, startTriple) = sorted.first() - val (endCfi, endTriple) = sorted.last() - val (startLayout, startCoords, startAbsOffset) = startTriple - val (endLayout, endCoords, endAbsOffset) = endTriple - val localStart = - startCoords.windowToLocal(selectionRect.topLeft) - val localEnd = endCoords.windowToLocal( - selectionRect.bottomRight - ) - var finalStartOffset = - startLayout.getOffsetForPosition(localStart) - var finalEndOffset = endLayout.getOffsetForPosition(localEnd) - var finalEndCfi = endCfi - val startText = startLayout.layoutInput.text.text + capturedTextForAction?.let { text -> + val selectionRect = state.rect + var geometricSuccess = false + val candidates = blockLayoutMap.filter { (_, triple) -> + val (_, coords, _) = triple + if (!coords.isAttached) return@filter false + val pos = coords.positionInWindow() + val size = coords.size.toSize() + val blockRect = Rect(pos, size) + blockRect.overlaps(selectionRect) + } - if (startCfi == endCfi) { - val matches = mutableListOf() - var idx = startText.indexOf(text) - while (idx != -1) { - matches.add(idx) - idx = startText.indexOf(text, idx + 1) - } - if (matches.isNotEmpty()) { - val bestMatch = matches.minBy { - abs(it - finalStartOffset) - } - finalStartOffset = bestMatch - finalEndOffset = bestMatch + text.length - Timber.d( - "Refined Single-Block Offset: $finalStartOffset" - ) - } - } else { - val endText = endLayout.layoutInput.text.text + if (candidates.isNotEmpty()) { + try { + val sorted = candidates.entries.sortedBy { it.value.second.positionInWindow().y } + val (startCfi, startTriple) = sorted.first() + val (endCfi, endTriple) = sorted.last() + val (startLayout, startCoords, startAbsOffset) = startTriple + val (endLayout, endCoords, endAbsOffset) = endTriple + val localStart = startCoords.windowToLocal(selectionRect.topLeft) + val localEnd = endCoords.windowToLocal(selectionRect.bottomRight) + var finalStartOffset = startLayout.getOffsetForPosition(localStart) + var finalEndOffset = endLayout.getOffsetForPosition(localEnd) + var finalEndCfi = endCfi + val startText = startLayout.layoutInput.text.text - fun findBestMatch( - source: String, - query: String, - targetOffset: Int, - isSuffix: Boolean - ): Int { - if (query.isEmpty()) return -1 - var bestIdx = -1 - var minDiff = Int.MAX_VALUE - var idx = source.indexOf(query) + if (startCfi == endCfi) { + val matches = mutableListOf() + var idx = startText.indexOf(text) while (idx != -1) { - val cmpPoint = if (isSuffix) idx + query.length - else idx - val diff = abs(cmpPoint - targetOffset) - if (diff < minDiff) { - minDiff = diff - bestIdx = idx - } - idx = source.indexOf(query, idx + 1) + matches.add(idx) + idx = startText.indexOf(text, idx + 1) } - return bestIdx - } + if (matches.isNotEmpty()) { + val bestMatch = matches.minBy { abs(it - finalStartOffset) } + finalStartOffset = bestMatch + finalEndOffset = bestMatch + text.length + } + } else { + val endText = endLayout.layoutInput.text.text - var sMatch = -1 - var eMatch = -1 - var usedSuffixLen = 0 + fun findBestMatch(source: String, query: String, targetOffset: Int, isSuffix: Boolean): Int { + if (query.isEmpty()) return -1 + var bestIdx = -1 + var minDiff = Int.MAX_VALUE + var idx = source.indexOf(query) + while (idx != -1) { + val cmpPoint = if (isSuffix) idx + query.length else idx + val diff = abs(cmpPoint - targetOffset) + if (diff < minDiff) { + minDiff = diff + bestIdx = idx + } + idx = source.indexOf(query, idx + 1) + } + return bestIdx + } - val maxChunk = minOf(text.length, 50) - for (len in maxChunk downTo 3) { - val prefix = text.take(len).trim() - if (prefix.isNotEmpty()) { - val idx = findBestMatch( - startText, - prefix, - finalStartOffset, - isSuffix = false - ) - if (idx != -1) { - sMatch = idx - Timber.d( - "Refined Start: Found prefix '$prefix' at $idx" - ) - break + var sMatch = -1 + var eMatch = -1 + var usedSuffixLen = 0 + + val maxChunk = minOf(text.length, 50) + for (len in maxChunk downTo 3) { + val prefix = text.take(len).trim() + if (prefix.isNotEmpty()) { + val idx = findBestMatch(startText, prefix, finalStartOffset, isSuffix = false) + if (idx != -1) { sMatch = idx; break } + } + } + for (len in maxChunk downTo 3) { + val suffix = text.takeLast(len).trim() + if (suffix.isNotEmpty()) { + val idx = findBestMatch(endText, suffix, finalEndOffset, isSuffix = true) + if (idx != -1) { eMatch = idx; usedSuffixLen = suffix.length; break } + } + } + + if (sMatch != -1 && eMatch != -1) { + finalStartOffset = sMatch + finalEndOffset = eMatch + usedSuffixLen + } else if (eMatch == -1) { + val fitIdx = startText.indexOf(text) + if (fitIdx != -1) { + finalEndCfi = startCfi + finalStartOffset = fitIdx + finalEndOffset = fitIdx + text.length } } } - for (len in maxChunk downTo 3) { - val suffix = text.takeLast(len).trim() - if (suffix.isNotEmpty()) { - val idx = findBestMatch( - endText, - suffix, - finalEndOffset, - isSuffix = true - ) - if (idx != -1) { - eMatch = idx - usedSuffixLen = suffix.length - Timber.d( - "Refined End: Found suffix '$suffix' at $idx" - ) - break - } - } + val absStart = finalStartOffset + startAbsOffset + val absEnd = finalEndOffset + if (startCfi == finalEndCfi) startAbsOffset else endAbsOffset + + val rangeCfi = if (startCfi == finalEndCfi) { + val actualStart = minOf(absStart, absEnd) + val actualEnd = maxOf(absStart, absEnd).coerceAtLeast(actualStart + 1) + "$startCfi:$actualStart|$finalEndCfi:$actualEnd" + } else { + "$startCfi:$absStart|$finalEndCfi:$absEnd" } - if (sMatch != -1 && eMatch != -1) { - finalStartOffset = sMatch - finalEndOffset = eMatch + usedSuffixLen - } else if (eMatch == -1) { - Timber.d( - "Refined: Suffix not found in end block. Checking single block fit." - ) - val fitIdx = startText.indexOf(text) - if (fitIdx != -1) { - finalEndCfi = startCfi - finalStartOffset = fitIdx - finalEndOffset = fitIdx + text.length - } + if (isNote) onNoteRequested(null) + onHighlightCreated(rangeCfi, text, selectedColor.id) + geometricSuccess = true + } catch (e: Exception) { + Timber.e(e, "Menu: Geometric calculation failed.") + } + } + + if (!geometricSuccess) { + val pageContent = onGetPage(pagerState.currentPage) + val textBlocks = pageContent?.content?.filterIsInstance()?.filter { it.cfi != null } ?: emptyList() + + var startBlock: TextContentBlock? = null + var endBlock: TextContentBlock? = null + var startOffsetRel = -1 + var endOffsetRel = -1 + + for (block in textBlocks) { + val content = block.content.text + val idx = content.indexOf(text) + if (idx != -1) { + startBlock = block + endBlock = block + startOffsetRel = idx + endOffsetRel = idx + text.length + break } } + endBlock = startBlock - val absStart = finalStartOffset + startAbsOffset - val absEnd = - finalEndOffset + if (startCfi == finalEndCfi) startAbsOffset - else endAbsOffset + if (startBlock != null) { + val startAbs = startBlock.startCharOffsetInSource + startOffsetRel + val endAbs = endBlock.startCharOffsetInSource + (if (endOffsetRel != -1) endOffsetRel else startOffsetRel + text.length) + val rangeCfi = "${startBlock.cfi}:$startAbs|${endBlock.cfi}:$endAbs" - val rangeCfi = if (startCfi == finalEndCfi) { - val actualStart = minOf(absStart, absEnd) - val actualEnd = maxOf(absStart, absEnd).coerceAtLeast( - actualStart + 1 - ) - "$startCfi:$actualStart|$finalEndCfi:$actualEnd" - } else { - "$startCfi:$absStart|$finalEndCfi:$absEnd" + if (isNote) onNoteRequested(null) + onHighlightCreated(rangeCfi, text, selectedColor.id) } - - Timber.d("Menu: Geometric Success. CFI: $rangeCfi") - onHighlightCreated(rangeCfi, text, color.id) - geometricSuccess = true - } catch (e: Exception) { - Timber.e(e, "Menu: Geometric calculation failed.") - } - } - - if (!geometricSuccess) { - Timber.w("Menu: Falling back to text search.") - val pageContent = onGetPage(pagerState.currentPage) - val textBlocks = - pageContent?.content?.filterIsInstance() - ?.filter { it.cfi != null } ?: emptyList() - - var startBlock: TextContentBlock? = null - var endBlock: TextContentBlock? = null - var startOffsetRel = -1 - var endOffsetRel = -1 - - for (block in textBlocks) { - val content = block.content.text - val idx = content.indexOf(text) - if (idx != -1) { - startBlock = block - endBlock = block - startOffsetRel = idx - endOffsetRel = idx + text.length - break - } - } - - endBlock = startBlock - - if (startBlock != null) { - val startAbs = - startBlock.startCharOffsetInSource + startOffsetRel - val endAbs = - endBlock.startCharOffsetInSource + (if (endOffsetRel != -1) endOffsetRel - else startOffsetRel + text.length) - onHighlightCreated( - "${startBlock.cfi}:$startAbs|${endBlock.cfi}:$endAbs", - text, - color.id - ) } } + state.onHide() } - state.onHide() + handleHighlightAction(color, false) + }, + onNote = { + // Note: triggers a yellow highlight by default, then asks for note text + val handleHighlightAction = { selectedColor: HighlightColor, isNote: Boolean -> + isForHighlight = true + state.onCopy() + isForHighlight = false + + capturedTextForAction?.let { text -> + val selectionRect = state.rect + var geometricSuccess = false + val candidates = blockLayoutMap.filter { (_, triple) -> + val (_, coords, _) = triple + if (!coords.isAttached) return@filter false + val pos = coords.positionInWindow() + val size = coords.size.toSize() + val blockRect = Rect(pos, size) + blockRect.overlaps(selectionRect) + } + + if (candidates.isNotEmpty()) { + try { + val sorted = candidates.entries.sortedBy { it.value.second.positionInWindow().y } + val (startCfi, startTriple) = sorted.first() + val (endCfi, endTriple) = sorted.last() + val (startLayout, startCoords, startAbsOffset) = startTriple + val (endLayout, endCoords, endAbsOffset) = endTriple + val localStart = startCoords.windowToLocal(selectionRect.topLeft) + val localEnd = endCoords.windowToLocal(selectionRect.bottomRight) + var finalStartOffset = startLayout.getOffsetForPosition(localStart) + var finalEndOffset = endLayout.getOffsetForPosition(localEnd) + var finalEndCfi = endCfi + val startText = startLayout.layoutInput.text.text + + if (startCfi == endCfi) { + val matches = mutableListOf() + var idx = startText.indexOf(text) + while (idx != -1) { + matches.add(idx) + idx = startText.indexOf(text, idx + 1) + } + if (matches.isNotEmpty()) { + val bestMatch = matches.minBy { abs(it - finalStartOffset) } + finalStartOffset = bestMatch + finalEndOffset = bestMatch + text.length + } + } else { + val endText = endLayout.layoutInput.text.text + + fun findBestMatch(source: String, query: String, targetOffset: Int, isSuffix: Boolean): Int { + if (query.isEmpty()) return -1 + var bestIdx = -1 + var minDiff = Int.MAX_VALUE + var idx = source.indexOf(query) + while (idx != -1) { + val cmpPoint = if (isSuffix) idx + query.length else idx + val diff = abs(cmpPoint - targetOffset) + if (diff < minDiff) { + minDiff = diff + bestIdx = idx + } + idx = source.indexOf(query, idx + 1) + } + return bestIdx + } + + var sMatch = -1 + var eMatch = -1 + var usedSuffixLen = 0 + + val maxChunk = minOf(text.length, 50) + for (len in maxChunk downTo 3) { + val prefix = text.take(len).trim() + if (prefix.isNotEmpty()) { + val idx = findBestMatch(startText, prefix, finalStartOffset, isSuffix = false) + if (idx != -1) { sMatch = idx; break } + } + } + for (len in maxChunk downTo 3) { + val suffix = text.takeLast(len).trim() + if (suffix.isNotEmpty()) { + val idx = findBestMatch(endText, suffix, finalEndOffset, isSuffix = true) + if (idx != -1) { eMatch = idx; usedSuffixLen = suffix.length; break } + } + } + + if (sMatch != -1 && eMatch != -1) { + finalStartOffset = sMatch + finalEndOffset = eMatch + usedSuffixLen + } else if (eMatch == -1) { + val fitIdx = startText.indexOf(text) + if (fitIdx != -1) { + finalEndCfi = startCfi + finalStartOffset = fitIdx + finalEndOffset = fitIdx + text.length + } + } + } + + val absStart = finalStartOffset + startAbsOffset + val absEnd = finalEndOffset + if (startCfi == finalEndCfi) startAbsOffset else endAbsOffset + + val rangeCfi = if (startCfi == finalEndCfi) { + val actualStart = minOf(absStart, absEnd) + val actualEnd = maxOf(absStart, absEnd).coerceAtLeast(actualStart + 1) + "$startCfi:$actualStart|$finalEndCfi:$actualEnd" + } else { + "$startCfi:$absStart|$finalEndCfi:$absEnd" + } + + if (isNote) onNoteRequested(null) + onHighlightCreated(rangeCfi, text, selectedColor.id) + geometricSuccess = true + } catch (e: Exception) { + Timber.e(e, "Menu: Geometric calculation failed.") + } + } + + if (!geometricSuccess) { + val pageContent = onGetPage(pagerState.currentPage) + val textBlocks = pageContent?.content?.filterIsInstance()?.filter { it.cfi != null } ?: emptyList() + + var startBlock: TextContentBlock? = null + var endBlock: TextContentBlock? = null + var startOffsetRel = -1 + var endOffsetRel = -1 + + for (block in textBlocks) { + val content = block.content.text + val idx = content.indexOf(text) + if (idx != -1) { + startBlock = block + endBlock = block + startOffsetRel = idx + endOffsetRel = idx + text.length + break + } + } + endBlock = startBlock + + if (startBlock != null) { + val startAbs = startBlock.startCharOffsetInSource + startOffsetRel + val endAbs = endBlock.startCharOffsetInSource + (if (endOffsetRel != -1) endOffsetRel else startOffsetRel + text.length) + val rangeCfi = "${startBlock.cfi}:$startAbs|${endBlock.cfi}:$endAbs" + + if (isNote) onNoteRequested(null) + onHighlightCreated(rangeCfi, text, selectedColor.id) + } + } + } + state.onHide() + } + handleHighlightAction(HighlightColor.YELLOW, true) }, onDelete = null, isProUser = isProUser, isOss = isOss, activeHighlightPalette = activeHighlightPalette, onOpenPaletteManager = { showPaletteManager = true } @@ -2860,36 +2925,42 @@ internal fun PaginatedReaderContent( }, onDismissRequest = { activeSelection = null }) { PaginatedTextSelectionMenu( onCopy = { - val clipboardManager = - context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Copied Text", sel.text) - clipboardManager.setPrimaryClip(clip) - activeSelection = null - }, onSelectAll = null, onDictionary = { - if (isProUser || countWords(sel.text) <= 1) { - onWordSelectedForAiDefinition(sel.text) - } else { - onShowDictionaryUpsellDialog() - } - activeSelection = null - }, onTranslate = { - onTranslate(sel.text) - activeSelection = null - }, onSearch = { - onSearch(sel.text) - activeSelection = null - }, onTts = { - onStartTtsFromSelection(sel.baseCfi, sel.startOffset) - activeSelection = null - }, onHighlight = { color -> - Timber.d( - "CustomSelection: Highlight clicked. Text: '${sel.text}', BaseCFI: ${sel.baseCfi}, StartOffset: ${sel.startOffset}" - ) - val finalCfi = if (sel.startOffset > 0) "${sel.baseCfi}:${sel.startOffset}" - else sel.baseCfi - onHighlightCreated(finalCfi, sel.text, color.id) - activeSelection = null - }, onDelete = null, isProUser = isProUser, isOss = isOss, + val clipboardManager = + context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Copied Text", sel.text) + clipboardManager.setPrimaryClip(clip) + activeSelection = null + }, onSelectAll = null, onDictionary = { + if (isProUser || countWords(sel.text) <= 1) { + onWordSelectedForAiDefinition(sel.text) + } else { + onShowDictionaryUpsellDialog() + } + activeSelection = null + }, onTranslate = { + onTranslate(sel.text) + activeSelection = null + }, onSearch = { + onSearch(sel.text) + activeSelection = null + }, onTts = { + onStartTtsFromSelection(sel.baseCfi, sel.startOffset) + activeSelection = null + }, onHighlight = { color -> + Timber.d( + "CustomSelection: Highlight clicked. Text: '${sel.text}', BaseCFI: ${sel.baseCfi}, StartOffset: ${sel.startOffset}" + ) + val finalCfi = if (sel.startOffset > 0) "${sel.baseCfi}:${sel.startOffset}" + else sel.baseCfi + onHighlightCreated(finalCfi, sel.text, color.id) + activeSelection = null + }, onNote = { + onNoteRequested(null) + val finalCfi = if (sel.startOffset > 0) "${sel.baseCfi}:${sel.startOffset}" else sel.baseCfi + onHighlightCreated(finalCfi, sel.text, HighlightColor.YELLOW.id) + activeSelection = null + }, + onDelete = null, isProUser = isProUser, isOss = isOss, activeHighlightPalette = activeHighlightPalette, onOpenPaletteManager = { showPaletteManager = true } ) @@ -2924,62 +2995,6 @@ internal fun PaginatedReaderContent( ) } - // Edit Menu (Delete) - if (activeHighlightForMenu != null) { - val (highlight, rect) = activeHighlightForMenu!! - Popup(popupPositionProvider = remember(rect, density) { - SmartPopupPositionProvider(rect, density) - }, onDismissRequest = { activeHighlightForMenu = null }) { - PaginatedTextSelectionMenu( - onCopy = { - val clipboardManager = - context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Copied Text", highlight.text) - clipboardManager.setPrimaryClip(clip) - activeHighlightForMenu = null - }, - onSelectAll = null, - onDictionary = { - if (isProUser || countWords(highlight.text) <= 1) { - onWordSelectedForAiDefinition(highlight.text) - } else { - onShowDictionaryUpsellDialog() - } - activeHighlightForMenu = null - }, - onTranslate = { - onTranslate(highlight.text) - activeHighlightForMenu = null - }, - onSearch = { - onSearch(highlight.text) - activeHighlightForMenu = null - }, - onTts = { - val firstPart = highlight.cfi.split("|").first() - val baseCfi = firstPart.substringBefore(":") - val offset = firstPart.substringAfter(":", "0").toIntOrNull() ?: 0 - onStartTtsFromSelection(baseCfi, offset) - activeHighlightForMenu = null - }, - onHighlight = { color -> - Timber.d("Menu: Updating highlight color to ${color.id}") - onHighlightDeleted(highlight.cfi) - onHighlightCreated(highlight.cfi, highlight.text, color.id) - activeHighlightForMenu = null - }, - onDelete = { - onHighlightDeleted(highlight.cfi) - activeHighlightForMenu = null - }, - isProUser = isProUser, - isOss = isOss, - activeHighlightPalette = activeHighlightPalette, - onOpenPaletteManager = { showPaletteManager = true } - ) - } - } - if (showPaletteManager) { PaletteManagerDialog( currentPalette = activeHighlightPalette, @@ -3034,119 +3049,6 @@ private fun ChapterLoadingPlaceholder(title: String?) { } } -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 PaginatedTextSelectionMenu( - onCopy: () -> Unit, - onSelectAll: (() -> Unit)?, - onDictionary: () -> Unit, - onTranslate: () -> Unit, - onSearch: () -> Unit, - onHighlight: ((HighlightColor) -> Unit)?, - onDelete: (() -> Unit)?, - onTts: (() -> Unit)?, - @Suppress("unused") isProUser: Boolean, - @Suppress("unused") isOss: Boolean, - activeHighlightPalette: List = 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)) { - // 1. Colors Row - if (onHighlight != null) { - Row( - modifier = Modifier - .padding(vertical = 12.dp, horizontal = 12.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - activeHighlightPalette.forEach { colorEnum -> - Box( - modifier = Modifier - .padding(horizontal = 6.dp) - .size(32.dp) - .background(colorEnum.color, CircleShape) - .clickable { onHighlight(colorEnum) } - ) - } - - if (onOpenPaletteManager != null) { - Spacer(modifier = Modifier.width(8.dp)) - SpectrumButton( - onClick = onOpenPaletteManager, - size = 32.dp - ) - } - } - HorizontalDivider() - } - - val actions = mutableListOf() - 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 (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)) - } - } - } - } - } - } -} - @Composable private fun RenderFlexChildBlock( childBlock: ContentBlock, diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt b/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt index 01cdcab..d0ead9b 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt @@ -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, val color: PdfHighlightColor, val text: String, - val range: Pair + val range: Pair, + 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, depth: Int) { internal fun PdfSelectionMenuPopup( menuState: CustomPdfMenuState, popupPositionProvider: PopupPositionProvider, + customHighlightColors: Map = 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): List { return merged.map { m -> RectF(m[0], m[3], m[2], m[1]) } +} + +@Composable +fun PdfHighlightColorRow( + modifier: Modifier = Modifier, + selectedColor: PdfHighlightColor? = null, + customHighlightColors: Map = 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 = 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) + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt index ecbe68a..3be70aa 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt @@ -368,6 +368,7 @@ data class PageSelectionData( val selectionHighlightColor: Color, val pageIndex: Int, val userHighlightScreenRects: StableHolder>>>, + val customHighlightColors: StableHolder> ) @Suppress("unused") @@ -437,8 +438,11 @@ internal fun PdfPageComposable( onHighlightAdd: (Int, Pair, 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 = 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 = 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 = 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 = emptyMap(), + onPaletteClick: (() -> Unit)? = null, onHighlightAdd: (Int, Pair, 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 ) } } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt index 8dc735e..e71c165 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt @@ -230,8 +230,11 @@ internal fun PdfVerticalReader( onHighlightAdd: (Int, Pair, 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 = 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 diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt index 5dbea84..ec83f40 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -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 { + 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) { + 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(maxSize) { + val cache = object : LruCache(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(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(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(null) - } - val sortedHighlights = remember(userHighlights.toList()) { - userHighlights.sortedBy { it.pageIndex } - } + var showDeleteConfirmDialogFor by remember { mutableStateOf(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, diff --git a/app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationData.kt b/app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationData.kt index 6a6e60b..f295b4c 100644 --- a/app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationData.kt +++ b/app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationData.kt @@ -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() } ) ) }