From 6a60aec0efa75e7d73c179715681cbd9ae6e2102 Mon Sep 17 00:00:00 2001 From: Aryan Date: Fri, 10 Apr 2026 17:30:48 +0530 Subject: [PATCH] Epub improvements (#160) * Improved navigation and search result highlighting in the EPUB reader * refactor: implement JIT chunk restoration for robust navigation - Fixes search and bookmark navigation failing in same-chapter transitions. - Introduced JIT (Just-in-Time) HTML restoration in `epub_reader.js` to re-populate virtualized chunks before CFI resolution or search scrolling. - Synchronized search highlighting and user highlights within newly-restored chunk segments. - Improved search navigation accuracy by mapping occurrences to relative chunk indices. * Implemented a custom text selection engine for the paginated EPUB reader to support cross-page selection and improved handle interaction. * Implemented management of external file behavior and improved library filtering. * Added support for toolbar customization in the EPUB reader. * Added support for toolbar customization in the PDF reader. * fix: rendering during auto-scroll and navigation on long pages * Added a "Scroll to Top" feature to the auto-scroll controls in both EPUB and PDF readers. --- app/src/main/assets/epub_reader.js | 66 +- .../main/java/com/aryan/reader/HomeScreen.kt | 102 +- .../java/com/aryan/reader/LibraryScreen.kt | 38 +- .../java/com/aryan/reader/MainActivity.kt | 2 +- .../java/com/aryan/reader/MainViewModel.kt | 66 +- .../aryan/reader/epubreader/ChapterWebView.kt | 21 +- .../reader/epubreader/EpubReaderControls.kt | 619 ++-- .../reader/epubreader/EpubReaderScreen.kt | 126 +- .../java/com/aryan/reader/opds/OpdsParser.kt | 2 +- .../com/aryan/reader/opds/OpdsRepository.kt | 12 +- .../reader/paginatedreader/PaginatedReader.kt | 3118 +++++++++-------- .../com/aryan/reader/pdf/PdfPageComposable.kt | 10 +- .../com/aryan/reader/pdf/PdfVerticalReader.kt | 29 + .../com/aryan/reader/pdf/PdfViewerScreen.kt | 874 +++-- .../com/aryan/reader/pdf/RichTextSystem.kt | 2 +- .../reader/pdf/data/PdfAnnotationData.kt | 6 +- app/src/main/res/values/strings.xml | 12 + 17 files changed, 3045 insertions(+), 2060 deletions(-) diff --git a/app/src/main/assets/epub_reader.js b/app/src/main/assets/epub_reader.js index 64d39de..e7b7be1 100644 --- a/app/src/main/assets/epub_reader.js +++ b/app/src/main/assets/epub_reader.js @@ -774,15 +774,16 @@ document.addEventListener("DOMContentLoaded", initializeReaderContent); } + window.CURRENT_SEARCH_QUERY = ""; + window.clearSearchHighlights = function () { + window.CURRENT_SEARCH_QUERY = ""; document.querySelectorAll("mark.search-highlight").forEach(function (el) { var parent = el.parentNode; - if (parent) { while (el.firstChild) { parent.insertBefore(el.firstChild, el); } - parent.removeChild(el); parent.normalize(); } @@ -792,10 +793,12 @@ window.highlightAllOccurrences = function (query) { window.clearSearchHighlights(); + window.CURRENT_SEARCH_QUERY = query; + if (!query || query.length < 2) return "JS: Query too short for highlighting."; var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); - var nodesToModify = []; + var nodesToModify =[]; while ((node = walker.nextNode())) { if (node.nodeValue.toLowerCase().includes(query.toLowerCase())) { @@ -811,11 +814,9 @@ tempDiv.innerHTML = textNode.nodeValue.replace(regex, '$1'); var parent = textNode.parentNode; - while (tempDiv.firstChild) { parent.insertBefore(tempDiv.firstChild, textNode); } - parent.removeChild(textNode); } }); @@ -823,17 +824,37 @@ return "JS: Highlighted " + document.querySelectorAll("mark.search-highlight").length + " occurrences."; }; - window.scrollToOccurrence = function (index) { - var highlights = document.querySelectorAll("mark.search-highlight"); + window.scrollToChunkOccurrence = function (chunkIndex, relativeIndex) { + console.log("NavDiag: scrollToChunkOccurrence chunk=" + chunkIndex + ", relativeIdx=" + relativeIndex); + var chunkDiv = document.querySelector(`.chunk-container[data-chunk-index='${chunkIndex}']`); - if (highlights && index >= 0 && index < highlights.length) { - var element = highlights[index]; + if (chunkDiv) { + let wasEmpty = false; - element.scrollIntoView({ behavior: "auto", block: "center", inline: "nearest" }); - return "JS: Scrolled to occurrence " + index; + if (chunkDiv.innerHTML === "" && window.virtualization && window.virtualization.chunksData[chunkIndex]) { + console.log("NavDiag: Chunk was empty, restoring content before scrolling."); + chunkDiv.innerHTML = window.virtualization.chunksData[chunkIndex]; + chunkDiv.style.height = ""; + wasEmpty = true; + } + + var highlights = chunkDiv.querySelectorAll("mark.search-highlight"); + + if ((wasEmpty || highlights.length === 0) && window.CURRENT_SEARCH_QUERY) { + window.highlightAllOccurrences(window.CURRENT_SEARCH_QUERY); + highlights = chunkDiv.querySelectorAll("mark.search-highlight"); + } + + if (highlights && highlights.length > 0) { + var targetIdx = (relativeIndex >= 0 && relativeIndex < highlights.length) ? relativeIndex : 0; + highlights[targetIdx].scrollIntoView({ behavior: "auto", block: "center", inline: "nearest" }); + return "JS: Scrolled to relative occurrence " + targetIdx + " in chunk " + chunkIndex; + } else { + chunkDiv.scrollIntoView({ behavior: "auto", block: "center" }); + return "JS: No highlights in chunk, scrolled to chunk center."; + } } - - return "JS: Occurrence " + index + " not found."; + return "JS: Chunk " + chunkIndex + " not found."; }; window.removeHighlight = function () { @@ -1429,6 +1450,15 @@ let chunkElement = currentNode.querySelector(`.chunk-container[data-chunk-index="${chunkIndex}"]`); if (chunkElement) { + if (chunkElement.innerHTML === "" && window.virtualization && window.virtualization.chunksData[chunkIndex]) { + console.log("CFI_DIAGNOSIS: Chunk " + chunkIndex + " was empty, restoring content for CFI resolution."); + chunkElement.innerHTML = window.virtualization.chunksData[chunkIndex]; + chunkElement.style.height = ""; + if (window.CURRENT_HIGHLIGHTS) { + window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS); + } + } + let elementsInChunk = Array.from(chunkElement.childNodes).filter(n => n.nodeType === Node.ELEMENT_NODE); if (indexInChunk >= 0 && indexInChunk < elementsInChunk.length) { currentNode = elementsInChunk[indexInChunk]; @@ -1649,7 +1679,7 @@ cleanCfi = cfi.substring(cfi.indexOf('@') + 1); } - console.log("PosSaveDiag: JS scrollToCfi called with cleanCfi=" + cleanCfi); + console.log("NavDiag: JS scrollToCfi called with cleanCfi=" + cleanCfi); if (!cleanCfi || !cleanCfi.startsWith('/')) { if (window.CfiBridge && window.CfiBridge.onScrollFinished) { @@ -1719,6 +1749,7 @@ } if (Math.abs(window.scrollY - targetScrollY) > 1) { + console.log("NavDiag: Scrolling to targetY=" + targetScrollY); window.scrollTo({ top: targetScrollY, behavior: 'auto' }); } @@ -1947,6 +1978,9 @@ if (window.CURRENT_HIGHLIGHTS) { window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS); } + if (window.CURRENT_SEARCH_QUERY) { + window.highlightAllOccurrences(window.CURRENT_SEARCH_QUERY); + } } } else { if (div.innerHTML !== "") { @@ -2005,6 +2039,10 @@ if (window.CURRENT_HIGHLIGHTS) { window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS); } + + if (window.CURRENT_SEARCH_QUERY) { + window.highlightAllOccurrences(window.CURRENT_SEARCH_QUERY); + } } if (window.checkImagesForDiagnosis) { diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt index 4ac97ca..f623e88 100644 --- a/app/src/main/java/com/aryan/reader/HomeScreen.kt +++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt @@ -70,6 +70,7 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DrawerValue import androidx.compose.material3.DropdownMenu @@ -83,6 +84,7 @@ import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -163,6 +165,7 @@ fun HomeScreen( var showAboutDialog by remember { mutableStateOf(false) } var showInfoDialog by remember { mutableStateOf(false) } var itemForInfoDialog by remember { mutableStateOf(null) } + var showBehaviorDialog by remember { mutableStateOf(false) } var showClearBookCacheDialog by remember { mutableStateOf(false) } var showClearReflowCacheDialog by remember { mutableStateOf(false) } @@ -308,7 +311,8 @@ fun HomeScreen( onFolderSyncToggle = viewModel::setFolderSyncEnabled, onClearReflowCache = { showClearReflowCacheDialog = true }, onRecentFilesLimitChange = viewModel::setRecentFilesLimit, - onTabsToggle = viewModel::setTabsEnabled + onTabsToggle = viewModel::setTabsEnabled, + onExternalFileBehaviorClick = { showBehaviorDialog = true } ) } else { ContextualTopAppBar( @@ -443,6 +447,20 @@ fun HomeScreen( onDismiss = { showClearReflowCacheDialog = false } ) } + if (uiState.showExternalFileSavePromptFor != null) { + ExternalFileSaveDialog( + onConfirm = { keep, dontAskAgain -> + viewModel.handleExternalFilePrompt(uiState.showExternalFileSavePromptFor!!, keep, dontAskAgain) + } + ) + } + if (showBehaviorDialog) { + ExternalFileBehaviorDialog( + currentBehavior = uiState.externalFileBehavior, + onDismiss = { showBehaviorDialog = false }, + onSelect = { viewModel.setExternalFileBehavior(it) } + ) + } } } if (showAboutDialog) { @@ -758,7 +776,8 @@ fun DefaultTopAppBar( onShowDeviceManagement: () -> Unit, onFolderSyncToggle: (Boolean) -> Unit, onRecentFilesLimitChange: (Int) -> Unit, - onTabsToggle: (Boolean) -> Unit + onTabsToggle: (Boolean) -> Unit, + onExternalFileBehaviorClick: () -> Unit ) { var showOptionsMenu by remember { mutableStateOf(false) } var showLimitMenu by remember { mutableStateOf(false) } @@ -822,6 +841,11 @@ fun DefaultTopAppBar( } }) + DropdownMenuItem(text = { Text(stringResource(R.string.options_external_file_behavior)) }, onClick = { + onExternalFileBehaviorClick() + showOptionsMenu = false + }) + HorizontalDivider() DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = { onClearCache() @@ -1284,4 +1308,78 @@ fun DangerousFolderActionDialog( } } ) +} + +@Composable +fun ExternalFileSaveDialog( + onConfirm: (keep: Boolean, dontAskAgain: Boolean) -> Unit +) { + var dontAsk by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = { }, + properties = androidx.compose.ui.window.DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false), + title = { Text(stringResource(R.string.external_file_prompt_title)) }, + text = { + Column { + Text(stringResource(R.string.external_file_prompt_desc)) + Spacer(modifier = Modifier.height(16.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { dontAsk = !dontAsk } + ) { + Checkbox(checked = dontAsk, onCheckedChange = { dontAsk = it }) + Text(stringResource(R.string.external_file_dont_ask)) + } + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(true, dontAsk) }) { + Text(stringResource(R.string.external_file_keep)) + } + }, + dismissButton = { + TextButton( + onClick = { onConfirm(false, dontAsk) }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { + Text(stringResource(R.string.external_file_delete)) + } + } + ) +} + +@Composable +fun ExternalFileBehaviorDialog( + currentBehavior: String, + onDismiss: () -> Unit, + onSelect: (String) -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.options_external_file_behavior)) }, + text = { + Column { + val options = listOf("ASK" to R.string.external_file_behavior_ask, "KEEP" to R.string.external_file_behavior_keep, "DELETE" to R.string.external_file_behavior_delete) + options.forEach { (value, labelRes) -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(value); onDismiss() } + .padding(vertical = 12.dp) + ) { + RadioButton(selected = currentBehavior == value, onClick = null) + Spacer(modifier = Modifier.width(16.dp)) + Text(stringResource(labelRes)) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } + } + ) } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt index ed8a89b..d5990cc 100644 --- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt +++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt @@ -1878,22 +1878,28 @@ fun LibraryFilterSheet( } } - if (syncedFolders.isNotEmpty()) { - Text(stringResource(R.string.filter_source_folder), style = MaterialTheme.typography.titleMedium) - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - syncedFolders.forEach { folder -> - FilterChip( - selected = folder.uriString in currentFilters.sourceFolders, - onClick = { - val newSet = if (folder.uriString in currentFilters.sourceFolders) currentFilters.sourceFolders - folder.uriString else currentFilters.sourceFolders + folder.uriString - currentFilters = currentFilters.copy(sourceFolders = newSet) - }, - label = { Text(folder.name) } - ) - } + Text(stringResource(R.string.filter_source_folder), style = MaterialTheme.typography.titleMedium) + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterChip( + selected = "IN_APP_STORAGE" in currentFilters.sourceFolders, + onClick = { + val newSet = if ("IN_APP_STORAGE" in currentFilters.sourceFolders) currentFilters.sourceFolders - "IN_APP_STORAGE" else currentFilters.sourceFolders + "IN_APP_STORAGE" + currentFilters = currentFilters.copy(sourceFolders = newSet) + }, + label = { Text(stringResource(R.string.filter_in_app_storage)) } + ) + syncedFolders.forEach { folder -> + FilterChip( + selected = folder.uriString in currentFilters.sourceFolders, + onClick = { + val newSet = if (folder.uriString in currentFilters.sourceFolders) currentFilters.sourceFolders - folder.uriString else currentFilters.sourceFolders + folder.uriString + currentFilters = currentFilters.copy(sourceFolders = newSet) + }, + label = { Text(folder.name) } + ) } } diff --git a/app/src/main/java/com/aryan/reader/MainActivity.kt b/app/src/main/java/com/aryan/reader/MainActivity.kt index cb69b53..488a1d4 100644 --- a/app/src/main/java/com/aryan/reader/MainActivity.kt +++ b/app/src/main/java/com/aryan/reader/MainActivity.kt @@ -105,7 +105,7 @@ class MainActivity : ComponentActivity() { if (intent?.action == Intent.ACTION_VIEW && intent.data != null) { Timber.d("Received VIEW intent with URI: ${intent.data}") val uri = intent.data!! - viewModel.onFileSelected(uri) + viewModel.onFileSelected(uri, isFromRecent = false, isExternalIntent = true) } } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index a3e6e56..88f481a 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -240,6 +240,8 @@ data class ReaderScreenState( val openTabIds: List = emptyList(), val openTabs: List = emptyList(), val activeTabBookId: String? = null, + val showExternalFileSavePromptFor: String? = null, + val externalFileBehavior: String = "ASK", ) open class MainViewModel(application: Application) : AndroidViewModel(application) { @@ -279,6 +281,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio @Suppress("unused") val navigationEvent = _navigationEvent.receiveAsFlow() private var pendingSwitchDeferred: CompletableDeferred? = null + private var externalOpenedBookId: String? = null data class PageModificationResult( val layout: List, @@ -348,6 +351,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } catch(_: Exception) { emptyList() } } ?: emptyList(), activeTabBookId = prefs.getString(KEY_ACTIVE_TAB, null), + externalFileBehavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK" ) ) @@ -370,7 +374,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val filters = internalState.libraryFilters val libraryFiltered = baseVisibleFiles.filter { item -> val matchType = if (filters.fileTypes.isNotEmpty()) item.type in filters.fileTypes else true - val matchFolder = if (filters.sourceFolders.isNotEmpty()) item.sourceFolderUri in filters.sourceFolders else true + val matchFolder = if (filters.sourceFolders.isNotEmpty()) { + val matchesInApp = filters.sourceFolders.contains("IN_APP_STORAGE") && item.sourceFolderUri == null && item.uriString?.startsWith("opds-pse") != true + val matchesSynced = item.sourceFolderUri in filters.sourceFolders + matchesInApp || matchesSynced + } else true val progress = item.progressPercentage ?: 0f val matchStatus = when (filters.readStatus) { ReadStatusFilter.ALL -> true @@ -963,7 +971,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio withContext(Dispatchers.Main) { onDeleted() - showBanner(appContext.getString(R.string.banner_text_view_deleted)) } } } @@ -1487,6 +1494,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } + val closingBookId = _internalState.value.selectedBookId val uriString = _internalState.value.selectedPdfUri?.toString() ?: _internalState.value.selectedEpubUri?.toString() @@ -1504,6 +1512,16 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) } + if (closingBookId != null && closingBookId == externalOpenedBookId) { + val behavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK" + if (behavior == "ASK") { + _internalState.update { it.copy(showExternalFileSavePromptFor = closingBookId) } + } else if (behavior == "DELETE") { + deleteBookPermanently(closingBookId) + } + externalOpenedBookId = null + } + if (uriString != null) { viewModelScope.launch { val freshBook = recentFilesRepository.getFileByUri(uriString) @@ -2819,7 +2837,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } - fun onFileSelected(uri: Uri, isFromRecent: Boolean = false) { + fun onFileSelected(uri: Uri, isFromRecent: Boolean = false, isExternalIntent: Boolean = false) { if (isFromRecent) { Timber.i("Opening recent file: $uri") viewModelScope.launch { @@ -2832,11 +2850,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } else { Timber.i("Importing new file: $uri") - importExternalFile(uri) + importExternalFile(uri, isExternalIntent) } } - private fun importExternalFile(externalUri: Uri) { + private fun importExternalFile(externalUri: Uri, isExternalIntent: Boolean = false) { _internalState.update { it.copy(isLoading = true, errorMessage = null, contextualActionItems = emptySet()) } @@ -2846,6 +2864,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio if (importResult != null) { val (internalUri, bookId, type) = importResult + if (isExternalIntent) { + externalOpenedBookId = bookId + } val displayName = getFileNameFromUri(externalUri, appContext) ?: "Unknown File" openBook( internalUri, bookId = bookId, type = type, originalDisplayName = displayName @@ -3734,12 +3755,25 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } fun selectAllRecentFiles() { - val recentFilesForHome = uiState.value.recentFiles.filter { it.isRecent } - _internalState.update { it.copy(contextualActionItems = recentFilesForHome.toSet()) } + val currentVisible = uiState.value.recentFiles.filter { it.isRecent }.toSet() + _internalState.update { state -> + if (state.contextualActionItems.containsAll(currentVisible) && currentVisible.isNotEmpty()) { + state.copy(contextualActionItems = emptySet()) + } else { + state.copy(contextualActionItems = currentVisible) + } + } } fun selectAllLibraryFiles() { - _internalState.update { it.copy(contextualActionItems = uiState.value.recentFiles.toSet()) } + val currentVisible = uiState.value.allRecentFiles.toSet() + _internalState.update { state -> + if (state.contextualActionItems.containsAll(currentVisible) && currentVisible.isNotEmpty()) { + state.copy(contextualActionItems = emptySet()) + } else { + state.copy(contextualActionItems = currentVisible) + } + } } fun clearContextualAction() { @@ -3753,6 +3787,21 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio _internalState.update { it.copy(showCreateShelfDialog = true) } } + fun handleExternalFilePrompt(bookId: String, keep: Boolean, dontAskAgain: Boolean) { + if (dontAskAgain) { + val newBehavior = if (keep) "KEEP" else "DELETE" + setExternalFileBehavior(newBehavior) + } + if (!keep) { + deleteBookPermanently(bookId) + } + _internalState.update { it.copy(showExternalFileSavePromptFor = null) } + } + fun setExternalFileBehavior(behavior: String) { + prefs.edit { putString(KEY_EXTERNAL_FILE_BEHAVIOR, behavior) } + _internalState.update { it.copy(externalFileBehavior = behavior) } + } + fun dismissCreateShelfDialog() { _internalState.update { it.copy(showCreateShelfDialog = false) } } @@ -4361,5 +4410,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private const val KEY_TABS_ENABLED = "tabs_enabled" private const val KEY_OPEN_TAB_IDS = "open_tab_ids" private const val KEY_ACTIVE_TAB = "active_tab_book_id" + private const val KEY_EXTERNAL_FILE_BEHAVIOR = "external_file_behavior" } } 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 05beb3b..00f42c0 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt @@ -577,6 +577,11 @@ fun ChapterWebView( ) } + message.startsWith("NavDiag:") -> { + Timber.tag("NavDiag") + .d("JS -> ${message.substringAfter("NavDiag: ")}") + } + message.startsWith("AutoScrollDiagnosis") -> { Timber.d( "JS -> ${message.substringAfter("AutoScrollDiagnosis: ")}" @@ -726,13 +731,13 @@ fun ChapterWebView( if (!initialCfi.isNullOrBlank()) { val cfiJsCommand = "javascript:window.scrollToCfi('$initialCfi');" - Timber.tag("POS_DIAG").d("WebView onPageFinished: Triggering scroll to initialCfi: $initialCfi") + Timber.tag("NavDiag").d("WebView onPageFinished: Triggering scroll to initialCfi: $initialCfi") view?.evaluateJavascript(cfiJsCommand) { onChapterInitiallyScrolled() scrollActionTaken = true } } else if (!initialFragmentId.isNullOrBlank()) { - Timber.d("WebView onPageFinished: Scrolling to Element ID: $initialFragmentId") + Timber.tag("NavDiag").d("WebView onPageFinished: Scrolling to Element ID: $initialFragmentId") view?.evaluateJavascript( "javascript:var el = document.getElementById('$initialFragmentId'); if(el) { el.scrollIntoView(); } else { console.log('Element not found: $initialFragmentId'); }", null @@ -744,9 +749,7 @@ fun ChapterWebView( ChapterScrollPosition.END -> "javascript:window.scrollToChapterEnd();" else -> "javascript:window.scrollToChapterStart();" } - Timber.d( - "WebView onPageFinished: Executing initial scroll to target: $initialScrollTarget" - ) + Timber.tag("NavDiag").d("WebView onPageFinished: Executing initial scroll to target: $initialScrollTarget") view?.evaluateJavascript(scrollJsCommand) { onChapterInitiallyScrolled() scrollActionTaken = true @@ -754,17 +757,13 @@ fun ChapterWebView( } else if (initialPageScrollY != null && initialPageScrollY > 0) { val scrollJsCommand = "javascript:window.scrollToSpecificY($initialPageScrollY);" - Timber.d( - "WebView onPageFinished: Executing initial scroll to Y: $initialPageScrollY" - ) + Timber.tag("NavDiag").d("WebView onPageFinished: Executing initial scroll to Y: $initialPageScrollY") view?.evaluateJavascript(scrollJsCommand) { onChapterInitiallyScrolled() scrollActionTaken = true } } else { - Timber.d( - "WebView onPageFinished: No specific scroll, defaulting to start." - ) + Timber.tag("NavDiag").d("WebView onPageFinished: No specific scroll, defaulting to start.") view?.evaluateJavascript("javascript:window.scrollToChapterStart();") { onChapterInitiallyScrolled() scrollActionTaken = true diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt index 6e25c63..db462e5 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt @@ -30,6 +30,11 @@ import androidx.annotation.RequiresApi import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Switch +import androidx.compose.material3.ModalBottomSheet import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -69,6 +74,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronRight @@ -131,6 +137,26 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlin.math.roundToInt +enum class ReaderTool(val title: String, val category: String) { + DICTIONARY("External Apps", "Top Bar"), + THEME("Theme Settings", "Top Bar"), + SLIDER("Navigation Slider", "Bottom Bar"), + TOC("Sidebar", "Bottom Bar"), + FORMAT("Text Formatting", "Bottom Bar"), + SEARCH("Search", "Bottom Bar"), + AI_FEATURES("AI Features", "Bottom Bar"), + TTS_CONTROLS("TTS Controls", "Bottom Bar"), + READING_MODE("Reading Mode", "Overflow Menu"), + BOOKMARK("Bookmark", "Overflow Menu"), + TAP_TO_TURN("Tap to Turn Pages", "Overflow Menu"), + VOLUME_SCROLL("Volume Button Scrolling", "Overflow Menu"), + PAGE_TURN_ANIM("Realistic Page Turns", "Overflow Menu"), + KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"), + VISUAL_OPTIONS("Visual Options", "Overflow Menu"), + AUTO_SCROLL("Auto Scroll", "Overflow Menu"), + TTS_SETTINGS("TTS Voice Settings", "Overflow Menu") +} + @Composable fun EpubReaderTopBar( isVisible: Boolean, @@ -158,6 +184,8 @@ fun EpubReaderTopBar( onOpenThemeSettings: () -> Unit, onOpenVisualOptions: () -> Unit, searchFocusRequester: androidx.compose.ui.focus.FocusRequester, + hiddenTools: Set, + onCustomizeTools: () -> Unit, modifier: Modifier = Modifier, onToggleReflow: (() -> Unit)? = null, onDeleteReflow: (() -> Unit)? = null, @@ -204,22 +232,26 @@ fun EpubReaderTopBar( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) ) - TooltipIconButton( - text = stringResource(R.string.tooltip_dictionary), - description = stringResource(R.string.tooltip_dictionary_desc), - onClick = onOpenDictionarySettings - ) { - Icon( - painter = painterResource(id = R.drawable.dictionary), - contentDescription = stringResource(R.string.content_desc_dictionary_settings) - ) + if (!hiddenTools.contains(ReaderTool.DICTIONARY.name)) { + TooltipIconButton( + text = stringResource(R.string.tooltip_dictionary), + description = stringResource(R.string.tooltip_dictionary_desc), + onClick = onOpenDictionarySettings + ) { + Icon( + painter = painterResource(id = R.drawable.dictionary), + contentDescription = stringResource(R.string.content_desc_dictionary_settings) + ) + } } - TooltipIconButton( - text = stringResource(R.string.tooltip_theme), - description = stringResource(R.string.tooltip_theme_desc), - onClick = onOpenThemeSettings - ) { - Icon(painter = painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc)) + if (!hiddenTools.contains(ReaderTool.THEME.name)) { + TooltipIconButton( + text = stringResource(R.string.tooltip_theme), + description = stringResource(R.string.tooltip_theme_desc), + onClick = onOpenThemeSettings + ) { + Icon(painter = painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc)) + } } Box { var showMoreMenu by remember { mutableStateOf(false) } @@ -235,6 +267,18 @@ fun EpubReaderTopBar( expanded = showMoreMenu, onDismissRequest = { showMoreMenu = false } ) { + DropdownMenuItem( + text = { Text("Customize Toolbar") }, + onClick = { + showMoreMenu = false + onCustomizeTools() + }, + leadingIcon = { + Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(20.dp)) + } + ) + HorizontalDivider() + if (onToggleReflow != null) { DropdownMenuItem( text = { Text(stringResource(R.string.menu_view_original_pdf)) }, @@ -274,125 +318,175 @@ fun EpubReaderTopBar( ) } - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_reading_mode_vertical)) }, - enabled = !isTtsActive, - onClick = { - showMoreMenu = false - onChangeRenderMode(RenderMode.VERTICAL_SCROLL) - }, - trailingIcon = { if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_selected)) } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_reading_mode_paginated)) }, - enabled = !isTtsActive, - onClick = { - showMoreMenu = false - onChangeRenderMode(RenderMode.PAGINATED) - }, - trailingIcon = { if (currentRenderMode == RenderMode.PAGINATED) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_selected)) } - ) - HorizontalDivider() - DropdownMenuItem( - text = { Text(if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(R.string.menu_bookmark_this_page)) }, - onClick = { + if (!hiddenTools.contains(ReaderTool.READING_MODE.name)) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_reading_mode_vertical)) }, + enabled = !isTtsActive, + onClick = { + showMoreMenu = false + onChangeRenderMode(RenderMode.VERTICAL_SCROLL) + }, + trailingIcon = { + if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon( + Icons.Default.Check, + contentDescription = stringResource(R.string.content_desc_selected) + ) + }) + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_reading_mode_paginated)) }, + enabled = !isTtsActive, + onClick = { + showMoreMenu = false + onChangeRenderMode(RenderMode.PAGINATED) + }, + trailingIcon = { + if (currentRenderMode == RenderMode.PAGINATED) Icon( + Icons.Default.Check, + contentDescription = stringResource(R.string.content_desc_selected) + ) + }) + HorizontalDivider() + } + if (!hiddenTools.contains(ReaderTool.BOOKMARK.name)) { + DropdownMenuItem(text = { + Text( + if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource( + R.string.menu_bookmark_this_page + ) + ) + }, onClick = { showMoreMenu = false onToggleBookmark() - } - ) - HorizontalDivider() - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) }, - enabled = currentRenderMode == RenderMode.PAGINATED, - onClick = { - onToggleTapToNavigate(!tapToNavigateEnabled) - showMoreMenu = false - }, - trailingIcon = { if (tapToNavigateEnabled) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) } - ) - HorizontalDivider() - DropdownMenuItem( - text = { + }) + HorizontalDivider() + } + if (!hiddenTools.contains(ReaderTool.TAP_TO_TURN.name)) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) }, + enabled = currentRenderMode == RenderMode.PAGINATED, + onClick = { + onToggleTapToNavigate(!tapToNavigateEnabled) + showMoreMenu = false + }, + trailingIcon = { + if (tapToNavigateEnabled) Icon( + Icons.Default.Check, + contentDescription = stringResource(R.string.content_desc_enabled) + ) + }) + HorizontalDivider() + } + if (!hiddenTools.contains(ReaderTool.VOLUME_SCROLL.name)) { + DropdownMenuItem( + text = { Text( - if (currentRenderMode == RenderMode.VERTICAL_SCROLL) stringResource(R.string.menu_volume_button_scrolling) + if (currentRenderMode == RenderMode.VERTICAL_SCROLL) stringResource( + R.string.menu_volume_button_scrolling + ) else stringResource(R.string.menu_volume_button_page_turn) ) }, - enabled = true, - onClick = { - onToggleVolumeScroll(!volumeScrollEnabled) - showMoreMenu = false - }, - trailingIcon = { if (volumeScrollEnabled) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) } - ) - HorizontalDivider() - - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_realistic_page_turns)) }, - enabled = currentRenderMode == RenderMode.PAGINATED, - onClick = { - onTogglePageTurnAnimation(!isPageTurnAnimationEnabled) - showMoreMenu = false - }, - trailingIcon = { if (isPageTurnAnimationEnabled) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) } - ) - HorizontalDivider() - - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_keep_screen_on)) }, - onClick = { - onToggleKeepScreenOn(!isKeepScreenOn) - showMoreMenu = false - }, - trailingIcon = { if (isKeepScreenOn) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) } - ) - HorizontalDivider() - - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_visual_options)) }, - onClick = { - showMoreMenu = false - onOpenVisualOptions() - }, - leadingIcon = { - Icon(Icons.Default.Visibility, contentDescription = null, modifier = Modifier.size(20.dp)) - } - ) - HorizontalDivider() - - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_auto_scroll)) }, - enabled = !isTtsActive && currentRenderMode == RenderMode.VERTICAL_SCROLL, - onClick = { - showMoreMenu = false - onStartAutoScroll() - } - ) - - HorizontalDivider() - - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_tts_voice_settings)) }, - onClick = { - showMoreMenu = false - onOpenDeviceVoiceSettings() - }, - leadingIcon = { - Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) - } - ) - - if (BuildConfig.DEBUG) { + enabled = true, + onClick = { + onToggleVolumeScroll(!volumeScrollEnabled) + showMoreMenu = false + }, + trailingIcon = { + if (volumeScrollEnabled) Icon( + Icons.Default.Check, + contentDescription = stringResource(R.string.content_desc_enabled) + ) + }) + HorizontalDivider() + } + if (!hiddenTools.contains(ReaderTool.PAGE_TURN_ANIM.name)) { DropdownMenuItem( - text = { Text(stringResource(R.string.menu_tts_settings_debug)) }, + text = { Text(stringResource(R.string.menu_realistic_page_turns)) }, + enabled = currentRenderMode == RenderMode.PAGINATED, + onClick = { + onTogglePageTurnAnimation(!isPageTurnAnimationEnabled) + showMoreMenu = false + }, + trailingIcon = { + if (isPageTurnAnimationEnabled) Icon( + Icons.Default.Check, + contentDescription = stringResource(R.string.content_desc_enabled) + ) + }) + HorizontalDivider() + } + if (!hiddenTools.contains(ReaderTool.KEEP_SCREEN_ON.name)) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_keep_screen_on)) }, + onClick = { + onToggleKeepScreenOn(!isKeepScreenOn) + showMoreMenu = false + }, + trailingIcon = { + if (isKeepScreenOn) Icon( + Icons.Default.Check, + contentDescription = stringResource(R.string.content_desc_enabled) + ) + }) + HorizontalDivider() + } + if (!hiddenTools.contains(ReaderTool.VISUAL_OPTIONS.name)) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_visual_options)) }, onClick = { showMoreMenu = false - onOpenTtsSettings() + onOpenVisualOptions() }, leadingIcon = { - Icon(painter = painterResource(id = R.drawable.text_to_speech), contentDescription = null, modifier = Modifier.size(20.dp)) - } - ) + Icon( + Icons.Default.Visibility, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + }) + HorizontalDivider() + } + if (!hiddenTools.contains(ReaderTool.AUTO_SCROLL.name)) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_auto_scroll)) }, + enabled = !isTtsActive && currentRenderMode == RenderMode.VERTICAL_SCROLL, + onClick = { + showMoreMenu = false + onStartAutoScroll() + }) + HorizontalDivider() + } + if (!hiddenTools.contains(ReaderTool.TTS_SETTINGS.name)) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_tts_voice_settings)) }, + onClick = { + showMoreMenu = false + onOpenDeviceVoiceSettings() + }, + leadingIcon = { + Icon( + Icons.Default.GraphicEq, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + }) + + if (BuildConfig.DEBUG) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_tts_settings_debug)) }, + onClick = { + showMoreMenu = false + onOpenTtsSettings() + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.text_to_speech), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + ) + } } } } @@ -418,6 +512,7 @@ fun EpubReaderBottomBar( onRecap: () -> Unit, onToggleTts: () -> Unit, onPlayPauseTts: () -> Unit, + hiddenTools: Set, modifier: Modifier = Modifier ) { AnimatedVisibility( @@ -439,107 +534,131 @@ fun EpubReaderBottomBar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceAround ) { - TooltipIconButton( - text = stringResource(R.string.tooltip_slider), - description = stringResource(R.string.tooltip_slider_desc), - onClick = onOpenSlider, - enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL - ) { - Icon(painter = painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider)) + if (!hiddenTools.contains(ReaderTool.SLIDER.name)) { + TooltipIconButton( + text = stringResource(R.string.tooltip_slider), + description = stringResource(R.string.tooltip_slider_desc), + onClick = onOpenSlider, + enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL + ) { + Icon( + painter = painterResource(id = R.drawable.slider), + contentDescription = stringResource(R.string.content_desc_navigate_slider) + ) + } } - TooltipIconButton( - text = stringResource(R.string.tooltip_toc), - description = stringResource(R.string.tooltip_toc_desc), - onClick = onOpenDrawer - ) { - Icon(imageVector = Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_chapters_menu)) + if (!hiddenTools.contains(ReaderTool.TOC.name)) { + TooltipIconButton( + text = stringResource(R.string.tooltip_toc), + description = stringResource(R.string.tooltip_toc_desc), + onClick = onOpenDrawer + ) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = stringResource(R.string.content_desc_chapters_menu) + ) + } } - TooltipIconButton( - text = stringResource(R.string.tooltip_format), - description = stringResource(R.string.tooltip_format_desc), - onClick = onToggleFormat - ) { - Icon(painter = painterResource(id = R.drawable.format_size), contentDescription = stringResource(R.string.content_desc_text_formatting)) + if (!hiddenTools.contains(ReaderTool.FORMAT.name)) { + TooltipIconButton( + text = stringResource(R.string.tooltip_format), + description = stringResource(R.string.tooltip_format_desc), + onClick = onToggleFormat + ) { + Icon( + painter = painterResource(id = R.drawable.format_size), + contentDescription = stringResource(R.string.content_desc_text_formatting) + ) + } } - TooltipIconButton( - text = stringResource(R.string.tooltip_search), - description = stringResource(R.string.tooltip_search_desc), - onClick = onToggleSearch - ) { - Icon(imageVector = Icons.Default.Search, contentDescription = stringResource(R.string.tooltip_search)) + if (!hiddenTools.contains(ReaderTool.SEARCH.name)) { + TooltipIconButton( + text = stringResource(R.string.tooltip_search), + description = stringResource(R.string.tooltip_search_desc), + onClick = onToggleSearch + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.tooltip_search) + ) + } } - @Suppress("KotlinConstantConditions", "SimplifyBooleanWithConstants") - if (BuildConfig.FLAVOR != "oss") { - Box { - var showAiFeaturesMenu by remember { mutableStateOf(false) } - TooltipIconButton( - text = stringResource(R.string.tooltip_ai), - description = stringResource(R.string.tooltip_ai_desc), - onClick = { showAiFeaturesMenu = true } - ) { - Icon(painter = painterResource(id = R.drawable.ai), contentDescription = "AI Features") - } - DropdownMenu( - expanded = showAiFeaturesMenu, - onDismissRequest = { showAiFeaturesMenu = false } - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_chapter_summarization)) }, - onClick = { - showAiFeaturesMenu = false - onSummarize() - } - ) - if (BuildConfig.DEBUG && isProUser) { - HorizontalDivider() + if (!hiddenTools.contains(ReaderTool.AI_FEATURES.name)) { + @Suppress( + "KotlinConstantConditions", + "SimplifyBooleanWithConstants" + ) if (BuildConfig.FLAVOR != "oss") { + Box { + var showAiFeaturesMenu by remember { mutableStateOf(false) } + TooltipIconButton( + text = stringResource(R.string.tooltip_ai), + description = stringResource(R.string.tooltip_ai_desc), + onClick = { showAiFeaturesMenu = true }) { + Icon( + painter = painterResource(id = R.drawable.ai), + contentDescription = "AI Features" + ) + } + DropdownMenu( + expanded = showAiFeaturesMenu, + onDismissRequest = { showAiFeaturesMenu = false }) { DropdownMenuItem( - text = { Text(stringResource(R.string.menu_recap_beta)) }, + text = { Text(stringResource(R.string.menu_chapter_summarization)) }, onClick = { showAiFeaturesMenu = false - onRecap() - } - ) + onSummarize() + }) + if (BuildConfig.DEBUG && isProUser) { + HorizontalDivider() + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_recap_beta)) }, + onClick = { + showAiFeaturesMenu = false + onRecap() + }) + } } } } } - Box { - Row(verticalAlignment = Alignment.CenterVertically) { - TooltipIconButton( - text = if (isTtsSessionActive) - stringResource(R.string.tooltip_tts_stop) - else - stringResource(R.string.tooltip_tts_start), - description = if (isTtsSessionActive) - stringResource(R.string.tooltip_tts_stop_desc) - else - stringResource(R.string.tooltip_tts_start_desc), - onClick = onToggleTts - ) { - Icon( - painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), - contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts) - ) - } - if (isTtsSessionActive) { + + if (!hiddenTools.contains(ReaderTool.TTS_CONTROLS.name)) { + Box { + Row(verticalAlignment = Alignment.CenterVertically) { TooltipIconButton( - text = if (ttsState.isPlaying) - stringResource(R.string.tooltip_tts_pause) - else - stringResource(R.string.tooltip_tts_resume), - description = if (ttsState.isPlaying) - stringResource(R.string.tooltip_tts_pause_desc) - else - stringResource(R.string.tooltip_tts_resume_desc), - onClick = onPlayPauseTts, - enabled = !ttsState.isLoading + text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) + else stringResource(R.string.tooltip_tts_start), + description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) + else stringResource(R.string.tooltip_tts_start_desc), + onClick = onToggleTts ) { Icon( - painter = painterResource(id = if (ttsState.isPlaying) R.drawable.pause else R.drawable.play), - contentDescription = if (ttsState.isPlaying) stringResource(R.string.content_desc_pause_tts) else stringResource(R.string.content_desc_resume_tts) + painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource( + id = R.drawable.text_to_speech + ), + contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource( + R.string.content_desc_start_tts + ) ) } + if (isTtsSessionActive) { + TooltipIconButton( + text = if (ttsState.isPlaying) stringResource(R.string.tooltip_tts_pause) + else stringResource(R.string.tooltip_tts_resume), + description = if (ttsState.isPlaying) stringResource(R.string.tooltip_tts_pause_desc) + else stringResource(R.string.tooltip_tts_resume_desc), + onClick = onPlayPauseTts, + enabled = !ttsState.isLoading + ) { + Icon( + painter = painterResource(id = if (ttsState.isPlaying) R.drawable.pause else R.drawable.play), + contentDescription = if (ttsState.isPlaying) stringResource( + R.string.content_desc_pause_tts + ) else stringResource(R.string.content_desc_resume_tts) + ) + } + } } } } @@ -885,6 +1004,7 @@ fun AutoScrollControls( onLocalModeToggle: (Boolean) -> Unit, modifier: Modifier = Modifier, isTempPaused: Boolean = false, + onScrollToTop: (() -> Unit)? = null ) { val backgroundAlpha = 0.6f @@ -1018,6 +1138,19 @@ fun AutoScrollControls( } Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + if (onScrollToTop != null) { + IconButton( + onClick = onScrollToTop, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.ArrowUpward, + contentDescription = "Scroll to Top", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } IconButton( onClick = onMusicianModeToggle, modifier = Modifier.size(32.dp) @@ -1216,4 +1349,76 @@ fun AutoScrollControls( } } } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomizeToolsSheet( + hiddenTools: Set, + onUpdate: (Set) -> Unit, + onDismiss: () -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + contentWindowInsets = { WindowInsets.navigationBars } + ) { + Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 24.dp)) { + Text( + text = "Customize Toolbar", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Select the tools you want to keep visible. Unchecking a tool hides it from the UI to give you a distraction-free reading space.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + + LazyColumn(modifier = Modifier.fillMaxWidth()) { + ReaderTool.entries.groupBy { it.category }.forEach { (category, tools) -> + item { + Text( + text = category, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) + ) + } + items(tools) { tool -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { + val newSet = hiddenTools.toMutableSet() + if (newSet.contains(tool.name)) newSet.remove(tool.name) + else newSet.add(tool.name) + onUpdate(newSet) + } + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = tool.title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Switch( + checked = !hiddenTools.contains(tool.name), + onCheckedChange = { isVisible -> + val newSet = hiddenTools.toMutableSet() + if (isVisible) newSet.remove(tool.name) else newSet.add(tool.name) + onUpdate(newSet) + } + ) + } + } + } + } + } + } } \ No newline at end of file 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 c6b605e..4d08e5d 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -214,6 +214,17 @@ private const val AUTO_SCROLL_LOCAL_MIN_PREFIX = "auto_scroll_local_min_" private const val AUTO_SCROLL_LOCAL_MAX_PREFIX = "auto_scroll_local_max_" private const val MUSICIAN_MODE_KEY = "musician_mode_enabled" private const val KEEP_SCREEN_ON_KEY = "keep_screen_on_enabled" +private const val HIDDEN_TOOLS_KEY = "hidden_reader_tools" + +private fun saveHiddenTools(context: Context, hiddenTools: Set) { + val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) + prefs.edit { putStringSet(HIDDEN_TOOLS_KEY, hiddenTools) } +} + +private fun loadHiddenTools(context: Context): Set { + val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) + return prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()) ?: emptySet() +} private fun saveKeepScreenOn(context: Context, isEnabled: Boolean) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) @@ -655,6 +666,9 @@ fun EpubReaderHost( mutableStateOf(loadExternalSearchPackage(context)) } + var hiddenTools by remember { mutableStateOf(loadHiddenTools(context)) } + var showCustomizeToolsSheet by remember { mutableStateOf(false) } + var showDictionaryUpsellDialog by remember { mutableStateOf(false) } var showSummarizationUpsellDialog by remember { mutableStateOf(false) } @@ -1603,6 +1617,7 @@ fun EpubReaderHost( } fun navigateToSearchResult(index: Int) { + Timber.tag("NavDiag").d("navigateToSearchResult index: $index") performSearchResultNavigation( index = index, searchState = searchState, @@ -1613,17 +1628,36 @@ fun EpubReaderHost( paginator = paginator, coroutineScope = scope, onVerticalChapterChange = { chapterIdx, chunkIdx, result -> - initialScrollTargetForChapter = ChapterScrollPosition.START + Timber.tag("NavDiag").d("onVerticalChapterChange chapterIdx=$chapterIdx, chunkIdx=$chunkIdx, query=${result.query}") + initialScrollTargetForChapter = null + chunkTargetOverride = chunkIdx currentScrollYPosition = 0 currentScrollHeightValue = 0 currentChapterIndex = chapterIdx searchHighlightTarget = result - loadUpToChunkIndex = chunkIdx }, - onVerticalScrollToResult = { _ -> - searchHighlightTarget = null + onVerticalScrollToResult = { result -> + Timber.tag("NavDiag").d("onVerticalScrollToResult query=${result.query}, chunk=${result.chunkIndex}") + val targetChunk = result.chunkIndex + if (targetChunk >= loadedChunkCount) { + val chunksToInject = (loadedChunkCount..targetChunk) + chunksToInject.forEach { idx -> + val content = chapterChunks.getOrNull(idx) + if (content != null) { + val escaped = escapeJsString(content) + webViewRefForTts?.evaluateJavascript( + "javascript:window.virtualization.appendChunk($idx, '$escaped');", + null + ) + } + } + loadUpToChunkIndex = targetChunk + loadedChunkCount = max(loadedChunkCount, targetChunk + 1) + } + searchHighlightTarget = result }, onPaginatedScrollToPage = { pageIdx -> + Timber.tag("NavDiag").d("onPaginatedScrollToPage pageIdx=$pageIdx") paginatedPagerState.scrollToPage(pageIdx) } ) @@ -1763,23 +1797,8 @@ fun EpubReaderHost( Timber.tag("BookmarkDiagnosis").d("Navigating to ${bookmark.cfi}") cfiToLoad = bookmark.cfi - val directChunkIndex = try { - val parts = bookmark.cfi.split('/').mapNotNull { it.toIntOrNull() } - if (parts.isNotEmpty()) { - val firstIndex = parts[0] - (firstIndex - 2) / 2 - } else null - } catch (_: Exception) { - null - } - - val locator = if (directChunkIndex == null) { - locatorConverter.getLocatorFromCfi(epubBook, bookmark.chapterIndex, bookmark.cfi) - } else { - null - } - - val targetChunk = directChunkIndex ?: locator?.let { it.blockIndex / 20 } + val locator = locatorConverter.getLocatorFromCfi(epubBook, bookmark.chapterIndex, bookmark.cfi) + val targetChunk = locator?.let { it.blockIndex / 20 } if (bookmark.chapterIndex != currentChapterIndex) { chunkTargetOverride = if (targetChunk != null && targetChunk >= 0) { @@ -2168,7 +2187,7 @@ fun EpubReaderHost( } else if (chapterChunks.isNotEmpty()) { val initialContentToLoad = remember(loadUpToChunkIndex, chapterChunks) { val targetIdx = loadUpToChunkIndex - val startIdx = maxOf(0, targetIdx - 1) + val startIdx = 0 val endIdx = minOf(chapterChunks.lastIndex, targetIdx + 1) chapterChunks.indices.joinToString(separator = "\n") { index -> @@ -2221,30 +2240,31 @@ fun EpubReaderHost( ) } - LaunchedEffect(isWebViewReady) { + LaunchedEffect(isWebViewReady, searchHighlightTarget) { val target = searchHighlightTarget - Timber.d("Effect(isWebViewReady=$isWebViewReady) triggered for chapter $targetChapterIndex. Target is: $target" - ) + Timber.tag("NavDiag").d("Effect(isWebViewReady=$isWebViewReady, target=$target) triggered for chapter $targetChapterIndex.") if (isWebViewReady && target != null && target.locationInSource == targetChapterIndex) { - Timber.d("Highlighting condition met. Highlighting now." - ) + Timber.tag("NavDiag").d("Highlighting condition met. Highlighting now.") delay(200) val webView = webViewRefForTts if (webView != null) { val escapedQuery = escapeJsString(target.query) - val js = - "javascript:window.highlightAllOccurrences('${escapedQuery}'); window.scrollToOccurrence(${target.occurrenceIndexInLocation});" - Timber.d("Executing search highlight/scroll JS: $js" - ) + val targetChunk = target.chunkIndex + + val relativeIdx = searchState.searchResults + .filter { it.locationInSource == target.locationInSource && it.chunkIndex == targetChunk } + .indexOf(target) + .coerceAtLeast(0) + + val js = "javascript:console.log('NavDiag: Executing robust search highlight JS'); window.CURRENT_SEARCH_QUERY = '${escapedQuery}'; window.highlightAllOccurrences('${escapedQuery}'); window.scrollToChunkOccurrence($targetChunk, $relativeIdx);" + Timber.tag("NavDiag").d("Executing search highlight/scroll JS: $js") webView.evaluateJavascript(js) { result -> - Timber.d("JS highlight/scroll result: $result" - ) + Timber.tag("NavDiag").d("JS highlight/scroll result: $result") } searchHighlightTarget = null } else { - Timber.w("Highlight failed: WebView was null even after ready signal." - ) + Timber.tag("NavDiag").w("Highlight failed: WebView was null even after ready signal.") searchHighlightTarget = null } } @@ -2305,6 +2325,7 @@ fun EpubReaderHost( }, onChapterInitiallyScrolled = { val wasCfiScroll = cfiToLoad != null + Timber.tag("NavDiag").d("onChapterInitiallyScrolled for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll") initialScrollTargetForChapter = null cfiToLoad = null fragmentToLoad = null @@ -3485,6 +3506,8 @@ fun EpubReaderHost( tapToNavigateEnabled = tapToNavigateEnabled, volumeScrollEnabled = volumeScrollEnabled, isPageTurnAnimationEnabled = isPageTurnAnimationEnabled, + hiddenTools = hiddenTools, + onCustomizeTools = { showCustomizeToolsSheet = true }, onNavigateBack = { triggerSaveAndExit() }, isKeepScreenOn = isKeepScreenOn, onToggleKeepScreenOn = { enabled -> @@ -3497,23 +3520,34 @@ fun EpubReaderHost( keyboardController?.hide() focusManager.clearFocus() containerFocusRequester.requestFocus() + webViewRefForTts?.evaluateJavascript("javascript:window.clearSearchHighlights();", null) }, onChangeRenderMode = { newMode -> + Timber.tag("NavDiag").d("onChangeRenderMode to $newMode") if (newMode != currentRenderMode) { if (newMode == RenderMode.PAGINATED) { isSwitchingToPaginated = true webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null) } else { scope.launch { + Timber.tag("NavDiag").d("Mode changing to VERTICAL. lastKnownLocator=$lastKnownLocator") lastKnownLocator?.let { locator -> val cfi = locatorConverter.getCfiFromLocator(epubBook, locator) + Timber.tag("NavDiag").d("Converted locator to CFI: $cfi") if (cfi != null) { val targetChunk = locator.blockIndex / 20 chunkTargetOverride = targetChunk if (currentChapterIndex != locator.chapterIndex) { + initialScrollTargetForChapter = null currentScrollYPosition = 0 currentScrollHeightValue = 0 currentChapterIndex = locator.chapterIndex + } else { + if (targetChunk > loadUpToChunkIndex) { + loadUpToChunkIndex = targetChunk + loadedChunkCount = max(loadedChunkCount, targetChunk + 1) + } + initialScrollTargetForChapter = null } cfiToLoad = cfi } else { @@ -3661,7 +3695,15 @@ fun EpubReaderHost( saveAutoScrollUseSlider(context, autoScrollUseSlider) }, isLocalMode = isAutoScrollLocal, - onLocalModeToggle = onToggleAutoScrollMode + onLocalModeToggle = onToggleAutoScrollMode, + onScrollToTop = { + if (isAutoScrollPlaying) { + triggerAutoScrollTempPause(1000L) + } + scope.launch { + webViewRefForTts?.evaluateJavascript("window.scrollTo({ top: 0, behavior: 'smooth' });", null) + } + } ) } @@ -3672,6 +3714,7 @@ fun EpubReaderHost( isTtsSessionActive = isTtsSessionActive, ttsState = ttsState, isProUser = isProUser, + hiddenTools = hiddenTools, onOpenSlider = { when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { @@ -4123,6 +4166,17 @@ fun EpubReaderHost( ) } + if (showCustomizeToolsSheet) { + CustomizeToolsSheet( + hiddenTools = hiddenTools, + onUpdate = { newHiddenSet -> + hiddenTools = newHiddenSet + saveHiddenTools(context, newHiddenSet) + }, + onDismiss = { showCustomizeToolsSheet = false } + ) + } + if (showDictionarySettingsSheet) { DictionarySettingsDialog( isVisible = true, diff --git a/app/src/main/java/com/aryan/reader/opds/OpdsParser.kt b/app/src/main/java/com/aryan/reader/opds/OpdsParser.kt index 00b2c13..fbd3b9f 100644 --- a/app/src/main/java/com/aryan/reader/opds/OpdsParser.kt +++ b/app/src/main/java/com/aryan/reader/opds/OpdsParser.kt @@ -294,7 +294,7 @@ class OpdsParser { private fun parseOpds2Navigation(nav: JSONObject, baseUrl: String): OpdsEntry { val title = nav.optString("title", "Unknown") val href = nav.optString("href") - val summary = nav.optString("description", null) + val summary = nav.optString("description") val navigationUrl = if (href.isNotEmpty()) resolveUrl(baseUrl, href) else null return OpdsEntry( diff --git a/app/src/main/java/com/aryan/reader/opds/OpdsRepository.kt b/app/src/main/java/com/aryan/reader/opds/OpdsRepository.kt index 26e8ec6..0475147 100644 --- a/app/src/main/java/com/aryan/reader/opds/OpdsRepository.kt +++ b/app/src/main/java/com/aryan/reader/opds/OpdsRepository.kt @@ -21,7 +21,15 @@ class OpdsRepository(context: Context) { private const val KEY_CATALOGS_JSON = "opds_catalogs_json" val sharedHttpClient: OkHttpClient by lazy { - OkHttpClient.Builder().build() + OkHttpClient.Builder() + .addInterceptor { chain -> + val originalRequest = chain.request() + val requestWithUserAgent = originalRequest.newBuilder() + .header("User-Agent", "EpistemeReader/1.0 (Android)") + .build() + chain.proceed(requestWithUserAgent) + } + .build() } } @@ -54,6 +62,8 @@ class OpdsRepository(context: Context) { if (catalogs.isEmpty()) { catalogs.add(OpdsCatalog(UUID.randomUUID().toString(), "Project Gutenberg", "https://m.gutenberg.org/ebooks.opds/", isDefault = true)) + catalogs.add(OpdsCatalog(UUID.randomUUID().toString(), "Standard Ebooks", "https://standardebooks.org/feeds/opds", isDefault = true)) + saveCatalogs(catalogs) } 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 f98a6db..73aa349 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt @@ -36,17 +36,17 @@ import androidx.compose.foundation.layout.widthIn 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.magnifier import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -64,6 +64,7 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.isSpecified import androidx.compose.ui.geometry.toRect import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush @@ -78,6 +79,7 @@ import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale @@ -85,16 +87,12 @@ import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow -import androidx.compose.ui.platform.ClipEntry -import androidx.compose.ui.platform.Clipboard -import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalTextToolbar +import androidx.compose.ui.platform.LocalHapticFeedback 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 @@ -113,6 +111,7 @@ import androidx.compose.ui.text.style.TextIndent import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize @@ -129,6 +128,7 @@ 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 @@ -154,14 +154,44 @@ import kotlin.math.abs import kotlin.math.roundToInt data class PaginatedSelection( - val blockIndex: Int, - val baseCfi: String, + val startBlockIndex: Int, + val endBlockIndex: Int, + val startBaseCfi: String, + val endBaseCfi: String, val startOffset: Int, val endOffset: Int, val text: String, - val rect: Rect + val rect: Rect, + val startBlockCharOffset: Int = 0, + val endBlockCharOffset: Int = 0, + val textPerBlock: Map = emptyMap() ) +class ReactiveBlockMap( + private val delegate: MutableMap> = androidx.compose.runtime.mutableStateMapOf() +) : MutableMap> by delegate { + var tick by mutableIntStateOf(0) + + override fun put(key: String, value: Triple): Triple? { + tick++ + return delegate.put(key, value) + } + + override fun remove(key: String): Triple? { + tick++ + return delegate.remove(key) + } + + override fun clear() { + tick++ + delegate.clear() + } +} + +data class PendingCrossPageSelection(val fromPageIndex: Int) + +enum class SelectionHandle { START, END } + private class SmartPopupPositionProvider( private val contentRect: Rect, private val density: Density ) : PopupPositionProvider { @@ -832,32 +862,6 @@ fun PaginatedReaderScreen( } } -private data class PaginatedMenuState( - val rect: Rect, val onCopy: () -> Unit, val onHide: () -> Unit, val onSelectAll: (() -> Unit)? -) - -private class CustomPaginatedTextToolbar( - private val onShow: (Rect, () -> Unit, (() -> Unit)?) -> Unit, private val onHide: () -> Unit -) : TextToolbar { - override fun showMenu( - rect: Rect, - onCopyRequested: (() -> Unit)?, - onPasteRequested: (() -> Unit)?, - onCutRequested: (() -> Unit)?, - onSelectAllRequested: (() -> Unit)? - ) { - if (onCopyRequested != null) { - onShow(rect, onCopyRequested, onSelectAllRequested) - } - } - - override val status: TextToolbarStatus = TextToolbarStatus.Hidden - - override fun hide() { - onHide() - } -} - private fun parseEmphasisAnnotation(annotation: String, defaultColor: Color): TextEmphasis { Timber.d("Parsing annotation string: '$annotation'") val map = annotation.split(';').filter { it.isNotBlank() }.associate { @@ -1160,35 +1164,52 @@ private fun TextWithEmphasis( val customDrawer = Modifier.drawBehind { textLayoutResult?.let { layoutResult -> - if (activeSelection != null && activeSelection.blockIndex == block.blockIndex) { - val path = layoutResult.getPathForRange( - activeSelection.startOffset, activeSelection.endOffset - ) - drawPath(path, Color(0xFF1976D2).copy(alpha = 0.3f)) + if (activeSelection != null) { + // ADD absolute offset helper: + val currentBlockAbs = when (block) { + is ParagraphBlock -> block.startCharOffsetInSource + is HeaderBlock -> block.startCharOffsetInSource + is QuoteBlock -> block.startCharOffsetInSource + is ListItemBlock -> block.startCharOffsetInSource + else -> 0 + } + + val isStart = block.blockIndex == activeSelection.startBlockIndex && currentBlockAbs == activeSelection.startBlockCharOffset + val isEnd = block.blockIndex == activeSelection.endBlockIndex && currentBlockAbs == activeSelection.endBlockCharOffset + + val isBetween = when { + block.blockIndex > activeSelection.startBlockIndex && block.blockIndex < activeSelection.endBlockIndex -> true + block.blockIndex == activeSelection.startBlockIndex && block.blockIndex == activeSelection.endBlockIndex -> + currentBlockAbs > activeSelection.startBlockCharOffset && currentBlockAbs < activeSelection.endBlockCharOffset + block.blockIndex == activeSelection.startBlockIndex -> currentBlockAbs > activeSelection.startBlockCharOffset + block.blockIndex == activeSelection.endBlockIndex -> currentBlockAbs < activeSelection.endBlockCharOffset + else -> false + } + + if (isStart || isEnd || isBetween) { + val sOffset = if (isStart) activeSelection.startOffset else 0 + val eOffset = if (isEnd) activeSelection.endOffset else layoutResult.layoutInput.text.length + + if (sOffset < eOffset) { + try { + val path = layoutResult.getPathForRange(sOffset, eOffset) + drawPath(path, Color(0xFF1976D2).copy(alpha = 0.3f)) + } catch (e: Exception) { + Timber.e(e, "Highlight path out of bounds") + } + } + } } if (block.cfi != null && userHighlights.isNotEmpty()) { userHighlights.forEach { highlight -> val range = getHighlightOffsetsInBlock(block, highlight) - if (range != null) { try { - val path = layoutResult.getPathForRange( - range.first, range.last + 1 - ) - - drawPath( - path, - highlight.color.color.copy(alpha = 0.4f), - blendMode = BlendMode.SrcOver - ) - + val path = layoutResult.getPathForRange(range.first, range.last + 1) + drawPath(path, highlight.color.color.copy(alpha = 0.4f), blendMode = BlendMode.SrcOver) if (highlight.cfi == pressedHighlightCfi) { - drawPath( - path, - Color.Black.copy(alpha = 0.1f), - blendMode = BlendMode.SrcOver - ) + drawPath(path, Color.Black.copy(alpha = 0.1f), blendMode = BlendMode.SrcOver) } } catch (_: Exception) { } } @@ -1211,8 +1232,7 @@ private fun TextWithEmphasis( else boundingBox.top - markSize * 0.1f ) drawCircle(markColor, markSize / 2, center, style = Stroke(1f)) - } catch (_: Exception) { - } + } catch (_: Exception) { } } } } @@ -1327,6 +1347,59 @@ private fun TextWithEmphasis( } .pointerInput(text) { detectTapGestures( + onLongPress = { offset -> + textLayoutResult?.let { layout -> + val charOffset = layout.getOffsetForPosition(offset) + val wordBoundary = layout.getWordBoundary(charOffset) + + var start = wordBoundary.start + var end = wordBoundary.end + + // Trim trailing/leading punctuations for a cleaner word selection + val textStr = text.text + while (start < end && start < textStr.length && !textStr[start].isLetterOrDigit()) start++ + while (end > start && end <= textStr.length && !textStr[end - 1].isLetterOrDigit()) end-- + + if (start < end && block.cfi != null) { + layoutCoordinates?.let { coords -> + if (coords.isAttached) { + val maxIdx = maxOf(0, textStr.length - 1) + val startBox = layout.getBoundingBox(start.coerceIn(0, maxIdx)) + val endBox = layout.getBoundingBox((end - 1).coerceIn(0, maxIdx)) + + val topLeftWin = coords.localToWindow(startBox.topLeft) + val bottomRightWin = coords.localToWindow(endBox.bottomRight) + + val selText = textStr.substring(start, end) + + val startBlockAbs = when (block) { + is ParagraphBlock -> block.startCharOffsetInSource + is HeaderBlock -> block.startCharOffsetInSource + is QuoteBlock -> block.startCharOffsetInSource + is ListItemBlock -> block.startCharOffsetInSource + else -> 0 + } + + onSelectionChange( + PaginatedSelection( + startBlockIndex = block.blockIndex, + endBlockIndex = block.blockIndex, + startBaseCfi = block.cfi!!, + endBaseCfi = block.cfi!!, + startOffset = start, + endOffset = end, + text = selText, + rect = Rect(topLeftWin, bottomRightWin), + startBlockCharOffset = startBlockAbs, + endBlockCharOffset = startBlockAbs, + textPerBlock = mapOf("${block.blockIndex}_${startBlockAbs}" to selText) + ) + ) + } + } + } + } + }, onTap = { offset -> textLayoutResult?.let { layout -> val charOffset = layout.getOffsetForPosition(offset) @@ -1371,6 +1444,7 @@ private fun checkLayoutMismatch( } } +@Suppress("unused") @SuppressLint("UnusedBoxWithConstraintsScope") @OptIn(ExperimentalFoundationApi::class) @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) @@ -1408,17 +1482,11 @@ internal fun PaginatedReaderContent( ) { val coroutineScope = rememberCoroutineScope() val density = LocalDensity.current - var menuState by remember { mutableStateOf(null) } var showExternalLinkDialog by remember { mutableStateOf(null) } val context = LocalContext.current val imageLoader = context.imageLoader val textMeasurer = rememberTextMeasurer() var activeSelection by remember { mutableStateOf(null) } - val blockLayoutMap = remember { - androidx.compose.runtime.mutableStateMapOf>() - } - var showColorPickerDialog by remember { mutableStateOf(null) } - var showPaletteManager by remember { mutableStateOf(false) } if (showExternalLinkDialog != null) { val urlToShow = showExternalLinkDialog!! @@ -1462,21 +1530,42 @@ internal fun PaginatedReaderContent( }) } - val textToolbar = remember { - CustomPaginatedTextToolbar( - onShow = { rect, onCopy, onSelectAll -> - menuState = PaginatedMenuState( - rect = rect, - onCopy = onCopy, - onHide = { menuState = null }, - onSelectAll = onSelectAll - ) - }, - onHide = { menuState = null } - ) + var pageTurnTouchY by remember { mutableStateOf(null) } + var lastKnownSelectionRect by remember { mutableStateOf?>(null) } + + LaunchedEffect(activeSelection) { + if (activeSelection != null && activeSelection!!.rect != Rect.Zero) { + lastKnownSelectionRect = activeSelection!!.rect to pagerState.currentPage + } } - var pageTurnTouchY by remember { mutableStateOf(null) } + val blockLayoutMap = remember { + ReactiveBlockMap() + } + var showColorPickerDialog by remember { mutableStateOf(null) } + + var showPaletteManager by remember { mutableStateOf(false) } + var pagerWindowBounds by remember { mutableStateOf(Rect.Zero) } + val hapticFeedback = LocalHapticFeedback.current + var isDraggingHandle by remember { mutableStateOf(false) } + var pendingCrossPageSelection by remember { mutableStateOf(null) } + var crossPageTriggerInfo by remember { mutableStateOf?>(null) } + + LaunchedEffect(pagerState) { + var previousPage = pagerState.currentPage + snapshotFlow { pagerState.currentPage }.collect { newPage -> + if (newPage == previousPage + 1) { + if (crossPageTriggerInfo != null && crossPageTriggerInfo!!.first == previousPage) { + pendingCrossPageSelection = PendingCrossPageSelection(fromPageIndex = previousPage) + Timber.d("CrossPageSelection: Strict bottom trigger activated, queued for page $newPage") + } + } else if (newPage != previousPage) { + pendingCrossPageSelection = null + } + crossPageTriggerInfo = null + previousPage = newPage + } + } if (uiState.isLoading) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -1487,46 +1576,27 @@ internal fun PaginatedReaderContent( if (uiState.totalPageCount > 0) { uiState.generation - val realClipboard: Clipboard = LocalClipboard.current - var isForDictionary by remember { mutableStateOf(false) } - var isForHighlight by remember { mutableStateOf(false) } - var capturedTextForAction by remember { mutableStateOf(null) } + var rootCoords by androidx.compose.runtime.remember { mutableStateOf(null) } + var magnifierCenter by remember { mutableStateOf(Offset.Unspecified) } - val dictionaryClipboard = remember(realClipboard) { - object : Clipboard { - override val nativeClipboard: ClipboardManager - get() = realClipboard.nativeClipboard + val magnifierModifier = if (magnifierCenter.isSpecified) { + Modifier.magnifier( + sourceCenter = { magnifierCenter }, + zoom = 1.5f, + size = DpSize(140.dp, 48.dp), + cornerRadius = 24.dp, + elevation = 4.dp + ) + } else Modifier - override suspend fun getClipEntry(): ClipEntry? = realClipboard.getClipEntry() - - override suspend fun setClipEntry(clipEntry: ClipEntry?) { - val text = clipEntry?.clipData?.getItemAt(0)?.text?.toString() - Timber.d( - "Clipboard intercept: setClipEntry called. Text: '$text', isForHighlight: $isForHighlight" - ) - capturedTextForAction = text - - if (isForDictionary) { - if (!text.isNullOrBlank()) { - onWordSelectedForAiDefinition(text) - } - } else if (isForHighlight) { - // Do not copy to real clipboard - } else { - realClipboard.setClipEntry(clipEntry) - } - } - } - } - - CompositionLocalProvider( - LocalTextToolbar provides textToolbar, LocalClipboard provides dictionaryClipboard - ) { - HorizontalPager( - state = pagerState, - modifier = Modifier - .fillMaxSize() - .pointerInput(Unit) { + Box(modifier = Modifier.fillMaxSize().onGloballyPositioned { rootCoords = it }.then(magnifierModifier)) { + run { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize().onGloballyPositioned { coords -> + pagerWindowBounds = + Rect(coords.positionInWindow(), coords.size.toSize()) + }.pointerInput(Unit) { awaitPointerEventScope { while (true) { val event = awaitPointerEvent(PointerEventPass.Initial) @@ -1537,432 +1607,400 @@ internal fun PaginatedReaderContent( } } }, - beyondViewportPageCount = 1 - ) { pageIndex -> - val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction - val zIndex = -pageOffset + beyondViewportPageCount = 1 + ) { pageIndex -> + val pageOffset = + (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction + val zIndex = -pageOffset - val pageModifier = if (isPageTurnAnimationEnabled) { - Modifier - .zIndex(zIndex) - .realisticBookPage(pagerState, pageIndex, effectiveBg, isDarkTheme, pageTurnTouchY) - } else { - Modifier - } + val pageModifier = if (isPageTurnAnimationEnabled) { + Modifier.zIndex(zIndex).realisticBookPage( + pagerState, + pageIndex, + effectiveBg, + isDarkTheme, + pageTurnTouchY + ) + } else Modifier - var pageContent by remember { mutableStateOf(null) } - var currentChapterPath by remember { mutableStateOf(null) } + var pageContent by remember { mutableStateOf(null) } + var currentChapterPath by remember { mutableStateOf(null) } - LaunchedEffect(pageIndex, uiState.generation) { - Timber.tag("ReflowPaginationDiag").d("PaginatedReaderContent: Fetching page $pageIndex content. generation=${uiState.generation}") - pageContent = onGetPage(pageIndex) - Timber.tag("ReflowPaginationDiag").d("PaginatedReaderContent: Fetched page $pageIndex content. isNull=${pageContent == null}, blocks=${pageContent?.content?.size}") - onGetChapterPath(pageIndex)?.let { currentChapterPath = it } - } + LaunchedEffect(pageIndex, uiState.generation) { + pageContent = onGetPage(pageIndex) + onGetChapterPath(pageIndex)?.let { currentChapterPath = it } + } - Box(modifier = Modifier.fillMaxSize().then(pageModifier)) { - SelectionContainer(modifier = Modifier.fillMaxSize()) { - Box(modifier = Modifier - .fillMaxSize() - .pointerInput(Unit) { + val textBlocksOnPage = + pageContent?.content?.filterIsInstance() + ?.filter { it.cfi != null } ?: emptyList() + val lastTextBlock = textBlocksOnPage.lastOrNull() + + // Strict Trigger Check - Custom Selection + LaunchedEffect(activeSelection, lastTextBlock, isDraggingHandle) { + if (isDraggingHandle && activeSelection != null && lastTextBlock != null && activeSelection!!.endBlockIndex == lastTextBlock.blockIndex) { + if (activeSelection!!.endOffset >= lastTextBlock.content.text.length - 3) { + if (crossPageTriggerInfo?.first != pageIndex) { + Timber.tag("TextSelectionDiag") + .d("Cross-page trigger ACTIVATED. Selection at bottom-right of page $pageIndex.") + crossPageTriggerInfo = pageIndex to lastTextBlock.cfi!! + } + return@LaunchedEffect + } + } + if (!isDraggingHandle && activeSelection == null && crossPageTriggerInfo?.first == pageIndex) { + Timber.tag("TextSelectionDiag") + .d("Cross-page trigger CLEARED on page $pageIndex (Custom).") + crossPageTriggerInfo = null + } + } + + // Smart Cross-page selection logic + LaunchedEffect(pendingCrossPageSelection, pageContent) { + val pending = pendingCrossPageSelection ?: return@LaunchedEffect + if (pageIndex != pending.fromPageIndex + 1) return@LaunchedEffect + val content = pageContent ?: return@LaunchedEffect + + val firstTextBlock = + content.content.filterIsInstance() + .firstOrNull { it.cfi != null } ?: run { + pendingCrossPageSelection = null + return@LaunchedEffect + } + + var layoutInfo: Triple? = + null + for (i in 0 until 20) { + layoutInfo = blockLayoutMap["${firstTextBlock.cfi}_$pageIndex"] + if (layoutInfo != null && layoutInfo.second.isAttached) break + delay(50) + } + + if (layoutInfo == null || !layoutInfo.second.isAttached) { + pendingCrossPageSelection = null + return@LaunchedEffect + } + + val text = firstTextBlock.content.text + if (text.isEmpty()) { + pendingCrossPageSelection = null + return@LaunchedEffect + } + + // Smart boundary logic (>10 chars & ends at a word) + var endIndex = minOf(text.length, 10) + if (text.length > 10) { + for (i in 10 until text.length) { + if (text[i].isWhitespace() || !text[i].isLetterOrDigit()) { + endIndex = i + break + } + } + } + + try { + val path = layoutInfo.first.getPathForRange(0, endIndex) + val localRect = path.getBounds() + val windowTopLeft = + layoutInfo.second.localToWindow(localRect.topLeft) + val windowBottomRight = + layoutInfo.second.localToWindow(localRect.bottomRight) + + val previousSel = activeSelection + + val firstTextBlockAbs = when (firstTextBlock) { + is ParagraphBlock -> firstTextBlock.startCharOffsetInSource + is HeaderBlock -> firstTextBlock.startCharOffsetInSource + is QuoteBlock -> firstTextBlock.startCharOffsetInSource + is ListItemBlock -> firstTextBlock.startCharOffsetInSource + else -> 0 + } + + val newTextPerBlock = (previousSel?.textPerBlock ?: emptyMap()).toMutableMap() + newTextPerBlock["${firstTextBlock.blockIndex}_${firstTextBlockAbs}"] = text.substring(0, endIndex) + + val newText = newTextPerBlock.entries.sortedBy { + val parts = it.key.split("_") + val idx = parts[0].toIntOrNull() ?: 0 + val abs = parts.getOrNull(1)?.toIntOrNull() ?: 0 + idx * 1000000L + abs + }.joinToString(" ") { it.value } + + activeSelection = PaginatedSelection( + startBlockIndex = previousSel?.startBlockIndex ?: firstTextBlock.blockIndex, + endBlockIndex = firstTextBlock.blockIndex, + startBaseCfi = previousSel?.startBaseCfi ?: firstTextBlock.cfi!!, + endBaseCfi = firstTextBlock.cfi!!, + startOffset = previousSel?.startOffset ?: 0, + endOffset = endIndex, + text = newText, + rect = Rect(windowTopLeft, windowBottomRight), + startBlockCharOffset = previousSel?.startBlockCharOffset ?: firstTextBlockAbs, + endBlockCharOffset = firstTextBlockAbs, + textPerBlock = newTextPerBlock + ) + } catch (e: Exception) { + Timber.e(e, "CrossPageSelection: Failed to create selection") + } + + pendingCrossPageSelection = null + } + + Box(modifier = Modifier.fillMaxSize().then(pageModifier)) { + Box(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) { detectTapGestures( onTap = { offset -> - Timber.d( - "Tap detected on empty page area." - ) - menuState?.onHide?.invoke() + Timber.d("Tap detected on empty page area.") + activeSelection = null onTap(offset) }) - } - .padding( - horizontal = horizontalPadding, vertical = verticalPadding + }.padding( + horizontal = horizontalPadding, + vertical = verticalPadding ), contentAlignment = Alignment.TopStart) { - if (pageContent != null) { - val onGeneralTapCallback: (Offset) -> Unit = { offset -> - menuState?.onHide?.invoke() - onTap(offset) - } - val onLinkClickCallback: (String) -> Unit = { href -> - Timber.d("Link clicked: $href") - if (href.startsWith("http://") || href.startsWith("https://")) { - showExternalLinkDialog = href - } else { - currentChapterPath?.let { path -> - onLinkClick(path, href) { targetPageIndex -> - coroutineScope.launch { - pagerState.scrollToPage(targetPageIndex) + if (pageContent != null) { + val onGeneralTapCallback: (Offset) -> Unit = { offset -> + activeSelection = null + onTap(offset) + } + val onLinkClickCallback: (String) -> Unit = { href -> + Timber.d("Link clicked: $href") + if (href.startsWith("http://") || href.startsWith("https://")) { + showExternalLinkDialog = href + } else { + currentChapterPath?.let { path -> + onLinkClick(path, href) { targetPageIndex -> + coroutineScope.launch { + pagerState.scrollToPage(targetPageIndex) + } } } } } - } - Column(modifier = Modifier.fillMaxSize()) { - val searchHighlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.4f) - val ttsHighlightColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f) + Column(modifier = Modifier.fillMaxSize()) { + val searchHighlightColor = + MaterialTheme.colorScheme.primary.copy(alpha = 0.4f) + val ttsHighlightColor = + MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f) - pageContent!!.content.forEach { block -> - val marginModifier = Modifier.padding( - top = block.style.margin.top.coerceAtLeast(0.dp), - bottom = block.style.margin.bottom.coerceAtLeast(0.dp) - ) - - val alignModifier = if (block.style.horizontalAlign == "center") { - Modifier.align(Alignment.CenterHorizontally) - } else { - Modifier.padding( - start = block.style.margin.left.coerceAtLeast(0.dp), - end = block.style.margin.right.coerceAtLeast(0.dp) - ) - } - - val widthModifier = if (block.style.width != Dp.Unspecified) { - Modifier.width(block.style.width) - } else { - Modifier.fillMaxWidth() - } - - val styleModifier = alignModifier - .then(if (block.style.horizontalAlign == "center") widthModifier else Modifier) - .drawCssBorders( - blockStyle = block.style, - density = density + pageContent!!.content.forEach { block -> + val marginModifier = Modifier.padding( + top = block.style.margin.top.coerceAtLeast(0.dp), + bottom = block.style.margin.bottom.coerceAtLeast( + 0.dp + ) ) - val diagnosticModifier = Modifier - .onGloballyPositioned { coordinates -> - val actualHeight = coordinates.size.height - if (block.expectedHeight > 0) { - val snippet = when(block) { - is ParagraphBlock -> block.content.text.take(50) - is HeaderBlock -> block.content.text.take(50) - is QuoteBlock -> block.content.text.take(50) - is ListItemBlock -> block.content.text.take(50) - is TextContentBlock -> block.content.text.take(50) - else -> "Non-text content" - } - - checkLayoutMismatch( - blockIndex = block.blockIndex, - blockType = block::class.simpleName ?: "Block", - expectedHeight = block.expectedHeight, - actualHeight = actualHeight, - textSnippet = snippet, - tolerance = 2 + val alignModifier = + if (block.style.horizontalAlign == "center") { + Modifier.align(Alignment.CenterHorizontally) + } else { + Modifier.padding( + start = block.style.margin.left.coerceAtLeast( + 0.dp + ), + end = block.style.margin.right.coerceAtLeast( + 0.dp + ) ) } - } - .then(marginModifier) - .then(styleModifier) - Box(modifier = diagnosticModifier) { - val paddingModifier = Modifier.padding( - start = block.style.padding.left.coerceAtLeast(0.dp) + (block.style.borderLeft?.width ?: 0.dp), - top = block.style.padding.top.coerceAtLeast(0.dp) + (block.style.borderTop?.width ?: 0.dp), - end = block.style.padding.right.coerceAtLeast(0.dp) + (block.style.borderRight?.width ?: 0.dp), - bottom = block.style.padding.bottom.coerceAtLeast(0.dp) + (block.style.borderBottom?.width ?: 0.dp) - ).then( - if (block.style.horizontalAlign != "center") widthModifier else Modifier.fillMaxWidth() - ) + val widthModifier = + if (block.style.width != Dp.Unspecified) { + Modifier.width(block.style.width) + } else { + Modifier.fillMaxWidth() + } - @Suppress("DEPRECATION") when (block) { - is ParagraphBlock -> { - val paragraphStyle = textStyle.copy( - textAlign = block.textAlign ?: textStyle.textAlign + val styleModifier = + alignModifier.then(if (block.style.horizontalAlign == "center") widthModifier else Modifier) + .drawCssBorders( + blockStyle = block.style, + density = density ) - val searchHighlighted = highlightQueryInText( - block.content, - searchQuery, - searchHighlightColor - ) - val finalContent = - if (ttsHighlightInfo != null && block.cfi == ttsHighlightInfo.cfi) { - buildAnnotatedString { - append(searchHighlighted) - // Define absolute ranges - val blockStartAbs = - block.startCharOffsetInSource - val blockEndAbs = - block.startCharOffsetInSource + searchHighlighted.length - val highlightStartAbs = - ttsHighlightInfo.offset - val highlightEndAbs = - ttsHighlightInfo.offset + ttsHighlightInfo.text.length + val diagnosticModifier = + Modifier.onGloballyPositioned { coordinates -> + val actualHeight = + coordinates.size.height + if (block.expectedHeight > 0) { + val snippet = when (block) { + is ParagraphBlock -> block.content.text.take( + 50 + ) - // Calculate intersection - val intersectionStartAbs = maxOf( - blockStartAbs, highlightStartAbs - ) - val intersectionEndAbs = minOf( - blockEndAbs, highlightEndAbs - ) + is HeaderBlock -> block.content.text.take( + 50 + ) - // Check for overlap and apply - // style - if (intersectionStartAbs < intersectionEndAbs) { - val highlightStartRelative = - intersectionStartAbs - blockStartAbs - val highlightEndRelative = - intersectionEndAbs - blockStartAbs - addStyle( - style = SpanStyle( - background = ttsHighlightColor - ), - start = highlightStartRelative, - end = highlightEndRelative - ) - } - } - } else { - searchHighlighted + is QuoteBlock -> block.content.text.take( + 50 + ) + + is ListItemBlock -> block.content.text.take( + 50 + ) + + is TextContentBlock -> block.content.text.take( + 50 + ) + + else -> "Non-text content" } - @Suppress("UnusedVariable", "Unused") val diagnosticModifier = - if (block.textAlign == TextAlign.Justify) { - Modifier.onGloballyPositioned { coordinates -> - val width = coordinates.size.width - Timber.d( - """ + checkLayoutMismatch( + blockIndex = block.blockIndex, + blockType = block::class.simpleName + ?: "Block", + expectedHeight = block.expectedHeight, + actualHeight = actualHeight, + textSnippet = snippet, + tolerance = 2 + ) + } + }.then(marginModifier).then(styleModifier) + + Box(modifier = diagnosticModifier) { + val paddingModifier = Modifier.padding( + start = block.style.padding.left.coerceAtLeast( + 0.dp + ) + (block.style.borderLeft?.width ?: 0.dp), + top = block.style.padding.top.coerceAtLeast( + 0.dp + ) + (block.style.borderTop?.width ?: 0.dp), + end = block.style.padding.right.coerceAtLeast( + 0.dp + ) + (block.style.borderRight?.width + ?: 0.dp), + bottom = block.style.padding.bottom.coerceAtLeast( + 0.dp + ) + (block.style.borderBottom?.width + ?: 0.dp) + ).then( + if (block.style.horizontalAlign != "center") widthModifier else Modifier.fillMaxWidth() + ) + + @Suppress("DEPRECATION") when (block) { + is ParagraphBlock -> { + val paragraphStyle = textStyle.copy( + textAlign = block.textAlign + ?: textStyle.textAlign + ) + val searchHighlighted = + highlightQueryInText( + block.content, + searchQuery, + searchHighlightColor + ) + val finalContent = + if (ttsHighlightInfo != null && block.cfi == ttsHighlightInfo.cfi) { + buildAnnotatedString { + append(searchHighlighted) + + // Define absolute ranges + val blockStartAbs = + block.startCharOffsetInSource + val blockEndAbs = + block.startCharOffsetInSource + searchHighlighted.length + val highlightStartAbs = + ttsHighlightInfo.offset + val highlightEndAbs = + ttsHighlightInfo.offset + ttsHighlightInfo.text.length + + // Calculate intersection + val intersectionStartAbs = + maxOf( + blockStartAbs, + highlightStartAbs + ) + val intersectionEndAbs = + minOf( + blockEndAbs, + highlightEndAbs + ) + + // Check for overlap and apply + // style + if (intersectionStartAbs < intersectionEndAbs) { + val highlightStartRelative = + intersectionStartAbs - blockStartAbs + val highlightEndRelative = + intersectionEndAbs - blockStartAbs + addStyle( + style = SpanStyle( + background = ttsHighlightColor + ), + start = highlightStartRelative, + end = highlightEndRelative + ) + } + } + } else { + searchHighlighted + } + + @Suppress( + "UnusedVariable", + "Unused" + ) val diagnosticModifier = + if (block.textAlign == TextAlign.Justify) { + Modifier.onGloballyPositioned { coordinates -> + val width = + coordinates.size.width + Timber.d( + """ [UI Render] Block Index: ${block.blockIndex} Text Start: ${ - block.content.text.take( - 20 - ) - }... + block.content.text.take( + 20 + ) + }... Actual Render Width Px: $width ------------------------------------------------ """.trimIndent() - ) - } - } else { - Modifier - } - - TextWithEmphasis( - text = finalContent, - style = paragraphStyle, - modifier = paddingModifier, - textMeasurer = textMeasurer, - onLinkClick = onLinkClickCallback, - onGeneralTap = onGeneralTapCallback, - block = block, - userHighlights = userHighlights, - activeSelection = activeSelection, - onSelectionChange = { sel -> - activeSelection = sel - }, - onHighlightClick = { highlight, _ -> - onNoteRequested(highlight.cfi) - activeSelection = null - menuState = null - }, - isDarkTheme = isDarkTheme, - onRegisterLayout = { layout, coords -> - if (block.cfi != null) blockLayoutMap[block.cfi] = - Triple( - layout, - coords, - block.startCharOffsetInSource - ) - }) - } - - is HeaderBlock -> { - val style = textStyle.copy( - fontWeight = FontWeight.Bold, - textAlign = block.textAlign ?: textStyle.textAlign - ) - val searchHighlighted = highlightQueryInText( - block.content, - searchQuery, - searchHighlightColor - ) - val finalContent = - if (ttsHighlightInfo != null && block.cfi == ttsHighlightInfo.cfi) { - buildAnnotatedString { - append(searchHighlighted) - - val blockStartAbs = - block.startCharOffsetInSource - val blockEndAbs = - block.startCharOffsetInSource + searchHighlighted.length - val highlightStartAbs = - ttsHighlightInfo.offset - val highlightEndAbs = - ttsHighlightInfo.offset + ttsHighlightInfo.text.length - - val intersectionStartAbs = maxOf( - blockStartAbs, highlightStartAbs - ) - val intersectionEndAbs = minOf( - blockEndAbs, highlightEndAbs - ) - - if (intersectionStartAbs < intersectionEndAbs) { - val highlightStartRelative = - intersectionStartAbs - blockStartAbs - val highlightEndRelative = - intersectionEndAbs - blockStartAbs - addStyle( - style = SpanStyle( - background = ttsHighlightColor - ), - start = highlightStartRelative, - end = highlightEndRelative ) } + } else { + Modifier } - } else { - searchHighlighted - } - TextWithEmphasis( - text = finalContent, - style = style, - modifier = paddingModifier, - textMeasurer = textMeasurer, - onLinkClick = onLinkClickCallback, - onGeneralTap = onGeneralTapCallback, - block = block, - userHighlights = userHighlights, - activeSelection = activeSelection, - onSelectionChange = { sel -> - activeSelection = sel - }, - onHighlightClick = { highlight, _ -> - onNoteRequested(highlight.cfi) - activeSelection = null - menuState = null - }, - isDarkTheme = isDarkTheme, - onRegisterLayout = { layout, coords -> - if (block.cfi != null) blockLayoutMap[block.cfi] = - Triple( - layout, - coords, - block.startCharOffsetInSource - ) - }) - } - is QuoteBlock -> { - val quoteStyle = textStyle.copy( - textAlign = block.textAlign ?: textStyle.textAlign - ) - val quoteModifier = - paddingModifier.padding(start = 16.dp) - val searchHighlighted = highlightQueryInText( - block.content, - searchQuery, - searchHighlightColor - ) - val finalContent = - if (ttsHighlightInfo != null && block.cfi == ttsHighlightInfo.cfi) { - buildAnnotatedString { - append(searchHighlighted) - - val blockStartAbs = - block.startCharOffsetInSource - val blockEndAbs = - block.startCharOffsetInSource + searchHighlighted.length - val highlightStartAbs = - ttsHighlightInfo.offset - val highlightEndAbs = - ttsHighlightInfo.offset + ttsHighlightInfo.text.length - - val intersectionStartAbs = maxOf( - blockStartAbs, highlightStartAbs - ) - val intersectionEndAbs = minOf( - blockEndAbs, highlightEndAbs - ) - - if (intersectionStartAbs < intersectionEndAbs) { - val highlightStartRelative = - intersectionStartAbs - blockStartAbs - val highlightEndRelative = - intersectionEndAbs - blockStartAbs - addStyle( - style = SpanStyle( - background = ttsHighlightColor - ), - start = highlightStartRelative, - end = highlightEndRelative + TextWithEmphasis( + text = finalContent, + style = paragraphStyle, + modifier = paddingModifier, + textMeasurer = textMeasurer, + onLinkClick = onLinkClickCallback, + onGeneralTap = onGeneralTapCallback, + block = block, + userHighlights = userHighlights, + activeSelection = activeSelection, + onSelectionChange = { sel -> + activeSelection = sel + }, + onHighlightClick = { highlight, _ -> + onNoteRequested(highlight.cfi) + activeSelection = null + }, + isDarkTheme = isDarkTheme, + onRegisterLayout = { layout, coords -> + if (block.cfi != null) blockLayoutMap["${block.cfi}_$pageIndex"] = + Triple( + layout, + coords, + block ) - } - } - } else { - searchHighlighted - } - TextWithEmphasis( - text = finalContent, - style = quoteStyle, - modifier = quoteModifier, - textMeasurer = textMeasurer, - onLinkClick = onLinkClickCallback, - onGeneralTap = onGeneralTapCallback, - block = block, - userHighlights = userHighlights, - activeSelection = activeSelection, - onSelectionChange = { sel -> - activeSelection = sel - }, - onHighlightClick = { highlight, _ -> - onNoteRequested(highlight.cfi) - activeSelection = null - menuState = null - }, - isDarkTheme = isDarkTheme, - onRegisterLayout = { layout, coords -> - if (block.cfi != null) blockLayoutMap[block.cfi] = - Triple( - layout, - coords, - block.startCharOffsetInSource - ) - }) - } + }) + } - is ListItemBlock -> { - Row( - modifier = paddingModifier, - verticalAlignment = Alignment.Top - ) { - val markerAreaModifier = - Modifier - .width(32.dp) - .padding(end = 8.dp) - - if (block.itemMarkerImage != null) { - val imageRequest = - Builder(LocalContext.current).data( - File( - block.itemMarkerImage - ) - ).crossfade(true).build() - val imageSize = with(density) { - (textStyle.fontSize.value * 0.8f).sp.toDp() - } - - AsyncImage( - model = imageRequest, - contentDescription = "List item marker", - modifier = markerAreaModifier.height( - imageSize - ), - alignment = Alignment.CenterEnd, - contentScale = ContentScale.FillHeight - ) - } else if (block.itemMarker != null) { - Text( - text = block.itemMarker, - style = textStyle.copy( - textAlign = TextAlign.End - ), - modifier = markerAreaModifier - ) - } + is HeaderBlock -> { + val style = textStyle.copy( + fontWeight = FontWeight.Bold, + textAlign = block.textAlign + ?: textStyle.textAlign + ) val searchHighlighted = highlightQueryInText( block.content, @@ -1988,9 +2026,11 @@ internal fun PaginatedReaderContent( blockStartAbs, highlightStartAbs ) - val intersectionEndAbs = minOf( - blockEndAbs, highlightEndAbs - ) + val intersectionEndAbs = + minOf( + blockEndAbs, + highlightEndAbs + ) if (intersectionStartAbs < intersectionEndAbs) { val highlightStartRelative = @@ -2011,8 +2051,93 @@ internal fun PaginatedReaderContent( } TextWithEmphasis( text = finalContent, - style = textStyle, - modifier = Modifier.weight(1f), + style = style, + modifier = paddingModifier, + textMeasurer = textMeasurer, + onLinkClick = onLinkClickCallback, + onGeneralTap = onGeneralTapCallback, + block = block, + userHighlights = userHighlights, + activeSelection = activeSelection, + onSelectionChange = { sel -> + activeSelection = sel + }, + onHighlightClick = { highlight, _ -> + onNoteRequested( + highlight.cfi + ) + activeSelection = null + }, + isDarkTheme = isDarkTheme, + onRegisterLayout = { layout, coords -> + if (block.cfi != null) blockLayoutMap["${block.cfi}_$pageIndex"] = + Triple( + layout, + coords, + block + ) + }) + } + + is QuoteBlock -> { + val quoteStyle = textStyle.copy( + textAlign = block.textAlign + ?: textStyle.textAlign + ) + val quoteModifier = + paddingModifier.padding(start = 16.dp) + val searchHighlighted = + highlightQueryInText( + block.content, + searchQuery, + searchHighlightColor + ) + val finalContent = + if (ttsHighlightInfo != null && block.cfi == ttsHighlightInfo.cfi) { + buildAnnotatedString { + append(searchHighlighted) + + val blockStartAbs = + block.startCharOffsetInSource + val blockEndAbs = + block.startCharOffsetInSource + searchHighlighted.length + val highlightStartAbs = + ttsHighlightInfo.offset + val highlightEndAbs = + ttsHighlightInfo.offset + ttsHighlightInfo.text.length + + val intersectionStartAbs = + maxOf( + blockStartAbs, + highlightStartAbs + ) + val intersectionEndAbs = + minOf( + blockEndAbs, + highlightEndAbs + ) + + if (intersectionStartAbs < intersectionEndAbs) { + val highlightStartRelative = + intersectionStartAbs - blockStartAbs + val highlightEndRelative = + intersectionEndAbs - blockStartAbs + addStyle( + style = SpanStyle( + background = ttsHighlightColor + ), + start = highlightStartRelative, + end = highlightEndRelative + ) + } + } + } else { + searchHighlighted + } + TextWithEmphasis( + text = finalContent, + style = quoteStyle, + modifier = quoteModifier, textMeasurer = textMeasurer, onLinkClick = onLinkClickCallback, onGeneralTap = onGeneralTapCallback, @@ -2025,490 +2150,658 @@ internal fun PaginatedReaderContent( onHighlightClick = { highlight, _ -> onNoteRequested(highlight.cfi) activeSelection = null - menuState = null }, isDarkTheme = isDarkTheme, onRegisterLayout = { layout, coords -> - if (block.cfi != null) blockLayoutMap[block.cfi] = + if (block.cfi != null) blockLayoutMap["${block.cfi}_$pageIndex"] = Triple( layout, coords, - block.startCharOffsetInSource + block ) }) } - } - is WrappingContentBlock -> { - WrappingContentLayout( - block = block, - textStyle = textStyle, - modifier = paddingModifier, - searchQuery = searchQuery, - ttsHighlightInfo = ttsHighlightInfo, - searchHighlightColor = searchHighlightColor, - ttsHighlightColor = ttsHighlightColor - ) - } - - is FlexContainerBlock -> { - // Background, border, and padding are - // already applied by the outer Box wrapper. - // Only apply padding + width here. - val containerModifier = paddingModifier - - if (block.style.flexDirection == "row") { - val horizontalArrangement = - when (block.style.justifyContent) { - "center" -> Arrangement.Center - "flex-end" -> Arrangement.End - "space-between" -> Arrangement.SpaceBetween - "space-around" -> Arrangement.SpaceAround - else -> Arrangement.Start - } - val verticalAlignment = - when (block.style.alignItems) { - "center" -> Alignment.CenterVertically - "flex-end" -> Alignment.Bottom - else -> Alignment.Top - } + is ListItemBlock -> { Row( - modifier = containerModifier.fillMaxWidth(), - horizontalArrangement = horizontalArrangement, - verticalAlignment = verticalAlignment + modifier = paddingModifier, + verticalAlignment = Alignment.Top ) { - block.children.forEach { childBlock -> - RenderFlexChildBlock( - childBlock = childBlock, - textStyle = textStyle, - searchQuery = searchQuery, - searchHighlightColor = searchHighlightColor, - ttsHighlightInfo = ttsHighlightInfo, - ttsHighlightColor = ttsHighlightColor, - textMeasurer = textMeasurer, - onLinkClickCallback = onLinkClickCallback, - onGeneralTapCallback = onGeneralTapCallback, - userHighlights = userHighlights, - activeSelection = activeSelection, - onSelectionChange = { sel -> - activeSelection = sel - }, - onHighlightClick = { highlight, _ -> - onNoteRequested(highlight.cfi) - activeSelection = null - menuState = null - }, - isDarkTheme = isDarkTheme, - blockLayoutMap = blockLayoutMap, - density = density, - imageLoader = imageLoader - ) - } - } - } else { - val verticalArrangement = - when (block.style.justifyContent) { - "center" -> Arrangement.Center - "flex-end" -> Arrangement.Bottom - "space-between" -> Arrangement.SpaceBetween - "space-around" -> Arrangement.SpaceAround - else -> Arrangement.Top - } - val horizontalAlignment = - when (block.style.alignItems) { - "center" -> Alignment.CenterHorizontally - "flex-end" -> Alignment.End - else -> Alignment.Start - } - Column( - modifier = containerModifier.fillMaxWidth(), - verticalArrangement = verticalArrangement, - horizontalAlignment = horizontalAlignment - ) { - block.children.forEach { childBlock -> - RenderFlexChildBlock( - childBlock = childBlock, - textStyle = textStyle, - searchQuery = searchQuery, - searchHighlightColor = searchHighlightColor, - ttsHighlightInfo = ttsHighlightInfo, - ttsHighlightColor = ttsHighlightColor, - textMeasurer = textMeasurer, - onLinkClickCallback = onLinkClickCallback, - onGeneralTapCallback = onGeneralTapCallback, - userHighlights = userHighlights, - activeSelection = activeSelection, - onSelectionChange = { sel -> - activeSelection = sel - }, - onHighlightClick = { highlight, _ -> - onNoteRequested(highlight.cfi) - activeSelection = null - menuState = null - }, - isDarkTheme = isDarkTheme, - blockLayoutMap = blockLayoutMap, - density = density, - imageLoader = imageLoader - ) - } - } - } - } + val markerAreaModifier = + Modifier.width(32.dp) + .padding(end = 8.dp) - is MathBlock -> { - Timber.d( - "PaginatedReader: Rendering MathBlock. Alt: '${block.altText}', Has SVG: ${!block.svgContent.isNullOrBlank()}" - ) - if (!block.svgContent.isNullOrBlank()) { - BoxWithConstraints( - modifier = paddingModifier - ) { - val localDensity = LocalDensity.current - val fontSizePx = with(localDensity) { - textStyle.fontSize.toPx() - } - val containerWidthPx = - with(localDensity) { - maxWidth.roundToPx() - } - val widthPx = parseSvgDimension( - block.svgWidth, - fontSizePx, - containerWidthPx, - localDensity - ) - val heightPx = parseSvgDimension( - block.svgHeight, - fontSizePx, - containerWidthPx, - localDensity - ) - - var imageModifier: Modifier = Modifier - if (widthPx != null) { - val finalWidthDp = with(localDensity) { widthPx.toDp() } - Timber.d("Applying calculated width to MathBlock image: $finalWidthDp") - imageModifier = imageModifier.width(finalWidthDp) - } else { - Timber.w("Could not calculate a specific width for MathBlock. It will fill available space.") - imageModifier = imageModifier.fillMaxWidth() - } - - if (heightPx != null) { - val finalHeightDp = with(localDensity) { heightPx.toDp() } - Timber.d("Applying calculated height to MathBlock image: $finalHeightDp") - imageModifier = imageModifier.height(finalHeightDp) - } else { - val viewBoxParts = block.svgViewBox?.split(' ', ',')?.mapNotNull { it.toFloatOrNull() } - if (viewBoxParts != null && viewBoxParts.size == 4 && viewBoxParts[2] > 0) { - val aspectRatio = viewBoxParts[3] / viewBoxParts[2] - val effectiveWidth = widthPx ?: containerWidthPx.toFloat() - val finalHeightDp = with(localDensity) { (effectiveWidth * aspectRatio).toDp() } - imageModifier = imageModifier.height(finalHeightDp) - } else { - val fallbackHeightDp = with(localDensity) { (textStyle.fontSize.value * 3).sp.toDp() } - imageModifier = imageModifier.height(fallbackHeightDp) - } - } - - val imageRequest = - Builder(LocalContext.current).data( - SvgData( - block.svgContent - ) - ).listener( - onError = { _, result -> - Timber.e( - result.throwable, - "Coil failed to load SVG for MathBlock." + if (block.itemMarkerImage != null) { + val imageRequest = + Builder(LocalContext.current).data( + File( + block.itemMarkerImage ) - }).build() + ).crossfade(true).build() + val imageSize = with(density) { + (textStyle.fontSize.value * 0.8f).sp.toDp() + } - val colorFilter = - if (block.isFromMathJax) ColorFilter.tint( - textStyle.color + AsyncImage( + model = imageRequest, + contentDescription = "List item marker", + modifier = markerAreaModifier.height( + imageSize + ), + alignment = Alignment.CenterEnd, + contentScale = ContentScale.FillHeight ) - else null + } else if (block.itemMarker != null) { + Text( + text = block.itemMarker, + style = textStyle.copy( + textAlign = TextAlign.End + ), + modifier = markerAreaModifier + ) + } + val searchHighlighted = + highlightQueryInText( + block.content, + searchQuery, + searchHighlightColor + ) + val finalContent = + if (ttsHighlightInfo != null && block.cfi == ttsHighlightInfo.cfi) { + buildAnnotatedString { + append(searchHighlighted) - AsyncImage( - model = imageRequest, - contentDescription = block.altText - ?: "Equation", - modifier = imageModifier, - contentScale = ContentScale.Fit, - colorFilter = colorFilter, - imageLoader = imageLoader - ) + val blockStartAbs = + block.startCharOffsetInSource + val blockEndAbs = + block.startCharOffsetInSource + searchHighlighted.length + val highlightStartAbs = + ttsHighlightInfo.offset + val highlightEndAbs = + ttsHighlightInfo.offset + ttsHighlightInfo.text.length + + val intersectionStartAbs = + maxOf( + blockStartAbs, + highlightStartAbs + ) + val intersectionEndAbs = + minOf( + blockEndAbs, + highlightEndAbs + ) + + if (intersectionStartAbs < intersectionEndAbs) { + val highlightStartRelative = + intersectionStartAbs - blockStartAbs + val highlightEndRelative = + intersectionEndAbs - blockStartAbs + addStyle( + style = SpanStyle( + background = ttsHighlightColor + ), + start = highlightStartRelative, + end = highlightEndRelative + ) + } + } + } else { + searchHighlighted + } + TextWithEmphasis( + text = finalContent, + style = textStyle, + modifier = Modifier.weight(1f), + textMeasurer = textMeasurer, + onLinkClick = onLinkClickCallback, + onGeneralTap = onGeneralTapCallback, + block = block, + userHighlights = userHighlights, + activeSelection = activeSelection, + onSelectionChange = { sel -> + activeSelection = sel + }, + onHighlightClick = { highlight, _ -> + onNoteRequested(highlight.cfi) + activeSelection = null + }, + isDarkTheme = isDarkTheme, + onRegisterLayout = { layout, coords -> + if (block.cfi != null) blockLayoutMap["${block.cfi}_$pageIndex"] = + Triple( + layout, + coords, + block + ) + }) } - } else { - Timber.w( - "PaginatedReader: MathBlock has no SVG content, rendering alt text." - ) - Text( - text = block.altText - ?: "[Equation not available]", - style = textStyle, - modifier = paddingModifier + } + + is WrappingContentBlock -> { + WrappingContentLayout( + block = block, + textStyle = textStyle, + modifier = paddingModifier, + searchQuery = searchQuery, + ttsHighlightInfo = ttsHighlightInfo, + searchHighlightColor = searchHighlightColor, + ttsHighlightColor = ttsHighlightColor ) } - } - is ImageBlock -> { - val style = block.style - val finalImageModifier = Modifier - .then( + is FlexContainerBlock -> { + // Background, border, and padding are + // already applied by the outer Box wrapper. + // Only apply padding + width here. + val containerModifier = paddingModifier + + if (block.style.flexDirection == "row") { + val horizontalArrangement = + when (block.style.justifyContent) { + "center" -> Arrangement.Center + "flex-end" -> Arrangement.End + "space-between" -> Arrangement.SpaceBetween + "space-around" -> Arrangement.SpaceAround + else -> Arrangement.Start + } + val verticalAlignment = + when (block.style.alignItems) { + "center" -> Alignment.CenterVertically + "flex-end" -> Alignment.Bottom + else -> Alignment.Top + } + Row( + modifier = containerModifier.fillMaxWidth(), + horizontalArrangement = horizontalArrangement, + verticalAlignment = verticalAlignment + ) { + block.children.forEach { childBlock -> + RenderFlexChildBlock( + childBlock = childBlock, + textStyle = textStyle, + searchQuery = searchQuery, + searchHighlightColor = searchHighlightColor, + ttsHighlightInfo = ttsHighlightInfo, + ttsHighlightColor = ttsHighlightColor, + textMeasurer = textMeasurer, + onLinkClickCallback = onLinkClickCallback, + onGeneralTapCallback = onGeneralTapCallback, + userHighlights = userHighlights, + activeSelection = activeSelection, + onSelectionChange = { sel -> + activeSelection = + sel + }, + onHighlightClick = { highlight, _ -> + onNoteRequested( + highlight.cfi + ) + activeSelection = + null + }, + isDarkTheme = isDarkTheme, + blockLayoutMap = blockLayoutMap, + density = density, + imageLoader = imageLoader, + pageIndex = pageIndex + ) + } + } + } else { + val verticalArrangement = + when (block.style.justifyContent) { + "center" -> Arrangement.Center + "flex-end" -> Arrangement.Bottom + "space-between" -> Arrangement.SpaceBetween + "space-around" -> Arrangement.SpaceAround + else -> Arrangement.Top + } + val horizontalAlignment = + when (block.style.alignItems) { + "center" -> Alignment.CenterHorizontally + "flex-end" -> Alignment.End + else -> Alignment.Start + } + Column( + modifier = containerModifier.fillMaxWidth(), + verticalArrangement = verticalArrangement, + horizontalAlignment = horizontalAlignment + ) { + block.children.forEach { childBlock -> + RenderFlexChildBlock( + childBlock = childBlock, + textStyle = textStyle, + searchQuery = searchQuery, + searchHighlightColor = searchHighlightColor, + ttsHighlightInfo = ttsHighlightInfo, + ttsHighlightColor = ttsHighlightColor, + textMeasurer = textMeasurer, + onLinkClickCallback = onLinkClickCallback, + onGeneralTapCallback = onGeneralTapCallback, + userHighlights = userHighlights, + activeSelection = activeSelection, + onSelectionChange = { sel -> + activeSelection = + sel + }, + onHighlightClick = { highlight, _ -> + onNoteRequested( + highlight.cfi + ) + activeSelection = + null + }, + isDarkTheme = isDarkTheme, + blockLayoutMap = blockLayoutMap, + density = density, + imageLoader = imageLoader, + pageIndex = pageIndex + ) + } + } + } + } + + is MathBlock -> { + Timber.d( + "PaginatedReader: Rendering MathBlock. Alt: '${block.altText}', Has SVG: ${!block.svgContent.isNullOrBlank()}" + ) + if (!block.svgContent.isNullOrBlank()) { + BoxWithConstraints( + modifier = paddingModifier + ) { + val localDensity = + LocalDensity.current + val fontSizePx = + with(localDensity) { + textStyle.fontSize.toPx() + } + val containerWidthPx = + with(localDensity) { + maxWidth.roundToPx() + } + val widthPx = parseSvgDimension( + block.svgWidth, + fontSizePx, + containerWidthPx, + localDensity + ) + val heightPx = + parseSvgDimension( + block.svgHeight, + fontSizePx, + containerWidthPx, + localDensity + ) + + var imageModifier: Modifier = + Modifier + if (widthPx != null) { + val finalWidthDp = + with(localDensity) { widthPx.toDp() } + Timber.d("Applying calculated width to MathBlock image: $finalWidthDp") + imageModifier = + imageModifier.width( + finalWidthDp + ) + } else { + Timber.w("Could not calculate a specific width for MathBlock. It will fill available space.") + imageModifier = + imageModifier.fillMaxWidth() + } + + if (heightPx != null) { + val finalHeightDp = + with(localDensity) { heightPx.toDp() } + Timber.d("Applying calculated height to MathBlock image: $finalHeightDp") + imageModifier = + imageModifier.height( + finalHeightDp + ) + } else { + val viewBoxParts = + block.svgViewBox?.split( + ' ', + ',' + ) + ?.mapNotNull { it.toFloatOrNull() } + if (viewBoxParts != null && viewBoxParts.size == 4 && viewBoxParts[2] > 0) { + val aspectRatio = + viewBoxParts[3] / viewBoxParts[2] + val effectiveWidth = + widthPx + ?: containerWidthPx.toFloat() + val finalHeightDp = + with(localDensity) { (effectiveWidth * aspectRatio).toDp() } + imageModifier = + imageModifier.height( + finalHeightDp + ) + } else { + val fallbackHeightDp = + with(localDensity) { (textStyle.fontSize.value * 3).sp.toDp() } + imageModifier = + imageModifier.height( + fallbackHeightDp + ) + } + } + + val imageRequest = + Builder(LocalContext.current).data( + SvgData( + block.svgContent + ) + ).listener( + onError = { _, result -> + Timber.e( + result.throwable, + "Coil failed to load SVG for MathBlock." + ) + }).build() + + val colorFilter = + if (block.isFromMathJax) ColorFilter.tint( + textStyle.color + ) + else null + + AsyncImage( + model = imageRequest, + contentDescription = block.altText + ?: "Equation", + modifier = imageModifier, + contentScale = ContentScale.Fit, + colorFilter = colorFilter, + imageLoader = imageLoader + ) + } + } else { + Timber.w( + "PaginatedReader: MathBlock has no SVG content, rendering alt text." + ) + Text( + text = block.altText + ?: "[Equation not available]", + style = textStyle, + modifier = paddingModifier + ) + } + } + + is ImageBlock -> { + val style = block.style + val finalImageModifier = Modifier.then( if (style.width != Dp.Unspecified) Modifier.width( style.width ) else Modifier - ) - .then( + ).then( if (style.maxWidth != Dp.Unspecified) Modifier.widthIn( max = style.maxWidth ) else Modifier - ) - .then( + ).then( if (block.intrinsicWidth != null && block.intrinsicHeight != null && block.intrinsicWidth > 0f && block.intrinsicHeight > 0f) { - Modifier.aspectRatio(block.intrinsicWidth / block.intrinsicHeight, matchHeightConstraintsFirst = false) + Modifier.aspectRatio( + block.intrinsicWidth / block.intrinsicHeight, + matchHeightConstraintsFirst = false + ) } else if (style.height != Dp.Unspecified) { Modifier.height(style.height) } else { Modifier.height(250.dp) } - ) - .then(paddingModifier) + ).then(paddingModifier) - val colorFilter = - if (block.style.filter == "invert(100%)") { - val matrix = floatArrayOf( - -1f, - 0f, - 0f, - 0f, - 255f, - 0f, - -1f, - 0f, - 0f, - 255f, - 0f, - 0f, - -1f, - 0f, - 255f, - 0f, - 0f, - 0f, - 1f, - 0f - ) - ColorFilter.colorMatrix( - ColorMatrix(matrix) - ) - } else { - null - } - val context = LocalContext.current - val imageRequest = - Builder(context).data(File(block.path)) - .listener(onSuccess = { _, _ -> - Timber.d( - "Coil successfully loaded image: ${block.path}" + val colorFilter = + if (block.style.filter == "invert(100%)") { + val matrix = floatArrayOf( + -1f, + 0f, + 0f, + 0f, + 255f, + 0f, + -1f, + 0f, + 0f, + 255f, + 0f, + 0f, + -1f, + 0f, + 255f, + 0f, + 0f, + 0f, + 1f, + 0f ) - }, onError = { _, result -> - Timber.e( - result.throwable, - "Coil FAILED to load image: ${block.path}" + ColorFilter.colorMatrix( + ColorMatrix(matrix) ) - }).crossfade(true).build() - - AsyncImage( - model = imageRequest, - contentDescription = block.altText - ?: "Image from EPUB", - modifier = finalImageModifier, - contentScale = ContentScale.Fit, - colorFilter = colorFilter - ) - } - - is SpacerBlock -> { - Box( - modifier = Modifier - .fillMaxWidth() - .height(block.height) - .drawCssBorders(block.style, density) - ) - } - - is TableBlock -> { - Column(modifier = paddingModifier) { - block.rows.forEach { tableRow -> - Row( - Modifier - .fillMaxWidth() - .height( - IntrinsicSize.Min + } else { + null + } + val context = LocalContext.current + val imageRequest = + Builder(context).data(File(block.path)) + .listener(onSuccess = { _, _ -> + Timber.d( + "Coil successfully loaded image: ${block.path}" ) - ) { - val hasFixedWidths = tableRow.any { - it.style.blockStyle.width != Dp.Unspecified - } + }, onError = { _, result -> + Timber.e( + result.throwable, + "Coil FAILED to load image: ${block.path}" + ) + }).crossfade(true).build() - tableRow.forEach { cell -> - val cellStyle = - cell.style.blockStyle + AsyncImage( + model = imageRequest, + contentDescription = block.altText + ?: "Image from EPUB", + modifier = finalImageModifier, + contentScale = ContentScale.Fit, + colorFilter = colorFilter + ) + } - val cellContainerModifier = - if (hasFixedWidths) { - if (cellStyle.width != Dp.Unspecified) Modifier.width( - cellStyle.width - ) - else Modifier.weight( - cell.colspan.toFloat(), - fill = true - ) - } else { - Modifier.weight( - cell.colspan.toFloat(), - fill = true - ) + is SpacerBlock -> { + Box( + modifier = Modifier.fillMaxWidth() + .height(block.height) + .drawCssBorders( + block.style, + density + ) + ) + } + + is TableBlock -> { + Column(modifier = paddingModifier) { + block.rows.forEach { tableRow -> + Row( + Modifier.fillMaxWidth() + .height( + IntrinsicSize.Min + ) + ) { + val hasFixedWidths = + tableRow.any { + it.style.blockStyle.width != Dp.Unspecified } - val alignment = - when (cell.style.paragraphStyle.textAlign) { - TextAlign.Center -> Alignment.CenterHorizontally - TextAlign.End -> Alignment.End - else -> Alignment.Start - } + tableRow.forEach { cell -> + val cellStyle = + cell.style.blockStyle - val cellModifier = - cellContainerModifier - .fillMaxHeight() - .then( - if (cellStyle.backgroundColor.isSpecified) { - Modifier.background( - cellStyle.backgroundColor - ) - } else { - Modifier - } - ) - .drawCssBorders(cellStyle, density) - .padding( - start = cellStyle.padding.left.coerceAtLeast( - 0.dp - ), - top = cellStyle.padding.top.coerceAtLeast( - 0.dp - ), - end = cellStyle.padding.right.coerceAtLeast( - 0.dp - ), - bottom = cellStyle.padding.bottom.coerceAtLeast( - 0.dp + val cellContainerModifier = + if (hasFixedWidths) { + if (cellStyle.width != Dp.Unspecified) Modifier.width( + cellStyle.width ) - ) - - Column( - modifier = cellModifier, - horizontalAlignment = alignment - ) { - val cellTextStyle = - if (cell.isHeader) { - textStyle.copy( - fontWeight = FontWeight.Bold + else Modifier.weight( + cell.colspan.toFloat(), + fill = true ) } else { - textStyle + Modifier.weight( + cell.colspan.toFloat(), + fill = true + ) } - cell.content.forEach { blockInCell -> - when (blockInCell) { - is ParagraphBlock -> { - Text( - text = blockInCell.content, - style = cellTextStyle, - modifier = Modifier.fillMaxWidth() + val alignment = + when (cell.style.paragraphStyle.textAlign) { + TextAlign.Center -> Alignment.CenterHorizontally + TextAlign.End -> Alignment.End + else -> Alignment.Start + } + + val cellModifier = + cellContainerModifier.fillMaxHeight() + .then( + if (cellStyle.backgroundColor.isSpecified) { + Modifier.background( + cellStyle.backgroundColor + ) + } else { + Modifier + } + ) + .drawCssBorders( + cellStyle, + density + ).padding( + start = cellStyle.padding.left.coerceAtLeast( + 0.dp + ), + top = cellStyle.padding.top.coerceAtLeast( + 0.dp + ), + end = cellStyle.padding.right.coerceAtLeast( + 0.dp + ), + bottom = cellStyle.padding.bottom.coerceAtLeast( + 0.dp ) + ) + + Column( + modifier = cellModifier, + horizontalAlignment = alignment + ) { + val cellTextStyle = + if (cell.isHeader) { + textStyle.copy( + fontWeight = FontWeight.Bold + ) + } else { + textStyle } - is HeaderBlock -> { - Text( - text = blockInCell.content, - style = cellTextStyle.copy( - fontWeight = FontWeight.Bold - ), - modifier = Modifier.fillMaxWidth() - ) - } - - is ListItemBlock -> { - Row( - verticalAlignment = Alignment.Top - ) { - if (blockInCell.itemMarker != null) { - Text( - text = blockInCell.itemMarker, - style = cellTextStyle, - modifier = Modifier.padding( - end = 4.dp - ) - ) - } + cell.content.forEach { blockInCell -> + when (blockInCell) { + is ParagraphBlock -> { Text( text = blockInCell.content, style = cellTextStyle, - modifier = Modifier.weight( - 1f - ) + modifier = Modifier.fillMaxWidth() ) } - } - is SpacerBlock -> { - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(blockInCell.height) - .drawCssBorders(blockInCell.style, density) - ) - } + is HeaderBlock -> { + Text( + text = blockInCell.content, + style = cellTextStyle.copy( + fontWeight = FontWeight.Bold + ), + modifier = Modifier.fillMaxWidth() + ) + } - is ImageBlock -> { - val imageModifier = Modifier.fillMaxWidth().then( - if (blockInCell.intrinsicWidth != null && blockInCell.intrinsicHeight != null && blockInCell.intrinsicWidth > 0f && blockInCell.intrinsicHeight > 0f) { - Modifier.aspectRatio(blockInCell.intrinsicWidth / blockInCell.intrinsicHeight, matchHeightConstraintsFirst = false) - } else if (blockInCell.style.height != Dp.Unspecified) { - Modifier.height(blockInCell.style.height) - } else { - Modifier.height(250.dp) - } - ) - AsyncImage( - model = Builder( - LocalContext.current - ).data( - File( - blockInCell.path + is ListItemBlock -> { + Row( + verticalAlignment = Alignment.Top + ) { + if (blockInCell.itemMarker != null) { + Text( + text = blockInCell.itemMarker, + style = cellTextStyle, + modifier = Modifier.padding( + end = 4.dp + ) + ) + } + Text( + text = blockInCell.content, + style = cellTextStyle, + modifier = Modifier.weight( + 1f + ) ) - ).build(), - contentDescription = blockInCell.altText, - contentScale = ContentScale.Fit, - modifier = imageModifier - ) - } - is TextContentBlock -> { - Text( - text = blockInCell.content, - style = cellTextStyle, - modifier = Modifier.fillMaxWidth() - ) - } + } + } - else -> {} + is SpacerBlock -> { + Spacer( + modifier = Modifier.fillMaxWidth() + .height( + blockInCell.height + ) + .drawCssBorders( + blockInCell.style, + density + ) + ) + } + + is ImageBlock -> { + val imageModifier = + Modifier.fillMaxWidth() + .then( + if (blockInCell.intrinsicWidth != null && blockInCell.intrinsicHeight != null && blockInCell.intrinsicWidth > 0f && blockInCell.intrinsicHeight > 0f) { + Modifier.aspectRatio( + blockInCell.intrinsicWidth / blockInCell.intrinsicHeight, + matchHeightConstraintsFirst = false + ) + } else if (blockInCell.style.height != Dp.Unspecified) { + Modifier.height( + blockInCell.style.height + ) + } else { + Modifier.height( + 250.dp + ) + } + ) + AsyncImage( + model = Builder( + LocalContext.current + ).data( + File( + blockInCell.path + ) + ) + .build(), + contentDescription = blockInCell.altText, + contentScale = ContentScale.Fit, + modifier = imageModifier + ) + } + + is TextContentBlock -> { + Text( + text = blockInCell.content, + style = cellTextStyle, + modifier = Modifier.fillMaxWidth() + ) + } + + else -> {} + } } } } @@ -2520,492 +2813,512 @@ internal fun PaginatedReaderContent( } } } - } - } else { - var chapterInfo by remember { - mutableStateOf?>(null) - } - LaunchedEffect(pageIndex) { - chapterInfo = onGetChapterInfo(pageIndex) - } + } else { + var chapterInfo by remember { + mutableStateOf?>(null) + } + LaunchedEffect(pageIndex) { + chapterInfo = onGetChapterInfo(pageIndex) + } - ChapterLoadingPlaceholder(title = chapterInfo?.first) + ChapterLoadingPlaceholder(title = chapterInfo?.first) + } } } } } } - } - menuState?.let { state -> - Popup(popupPositionProvider = remember(state.rect, density) { - SmartPopupPositionProvider(state.rect, density) - }, 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 - 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 + if (activeSelection != null) { + val sel = activeSelection!! + val currentPageSuffix = "_${pagerState.currentPage}" - val startLayout: TextLayoutResult = startTriple.first - val startCoords: LayoutCoordinates = startTriple.second - val startAbsOffset: Int = startTriple.third + val currentPageBlocks = + blockLayoutMap.filterKeys { it.endsWith(currentPageSuffix) }.values.filter { it.second.isAttached } + val visibleSelectedBlocks = + currentPageBlocks.filter { it.third.blockIndex in sel.startBlockIndex..sel.endBlockIndex } - val localStart = startCoords.windowToLocal(selectionRect.topLeft) - val finalStartOffset = startLayout.getOffsetForPosition(localStart) - val absStart: Int = finalStartOffset + startAbsOffset + if (!isDraggingHandle && visibleSelectedBlocks.isNotEmpty()) { + val menuAnchorRect = run { + var minLeft = Float.MAX_VALUE + var minTop = Float.MAX_VALUE + var maxRight = Float.MIN_VALUE + var maxBottom = Float.MIN_VALUE - onStartTtsFromSelection(startCfi, absStart) - geometricSuccess = true - } catch(e: Exception) { - Timber.e(e, "TTS Selection error") + visibleSelectedBlocks.forEach { triple -> + val (textLayout, coords, block) = triple + + val currentBlockAbs = when (block) { + is ParagraphBlock -> block.startCharOffsetInSource + is HeaderBlock -> block.startCharOffsetInSource + is QuoteBlock -> block.startCharOffsetInSource + is ListItemBlock -> block.startCharOffsetInSource + else -> 0 + } + val isStartBlockPart = block.blockIndex == sel.startBlockIndex && currentBlockAbs == sel.startBlockCharOffset + val isEndBlockPart = block.blockIndex == sel.endBlockIndex && currentBlockAbs == sel.endBlockCharOffset + + val blockStartOffset = if (isStartBlockPart) sel.startOffset else 0 + val blockEndOffset = if (isEndBlockPart) sel.endOffset else textLayout.layoutInput.text.length + + val textLen = textLayout.layoutInput.text.length + val maxIdx = maxOf(0, textLen - 1) + + val safeStart = blockStartOffset.coerceIn(0, textLen) + val safeEnd = blockEndOffset.coerceIn(safeStart, textLen) + + if (safeStart < safeEnd) { + try { + val startBox = textLayout.getBoundingBox(safeStart.coerceIn(0, maxIdx)) + val endBox = textLayout.getBoundingBox((safeEnd - 1).coerceIn(0, maxIdx)) + + val topWin = + coords.localToWindow(Offset(0f, startBox.top)).y + val bottomWin = + coords.localToWindow(Offset(0f, endBox.bottom)).y + val leftWin1 = + coords.localToWindow(Offset(startBox.left, 0f)).x + val rightWin1 = + coords.localToWindow(Offset(startBox.right, 0f)).x + val leftWin2 = + coords.localToWindow(Offset(endBox.left, 0f)).x + val rightWin2 = + coords.localToWindow(Offset(endBox.right, 0f)).x + + minTop = minOf(minTop, topWin) + maxBottom = maxOf(maxBottom, bottomWin) + minLeft = minOf(minLeft, leftWin1, leftWin2) + maxRight = maxOf(maxRight, rightWin1, rightWin2) + } catch (e: Exception) { + Timber.e(e, "Error calculating exact selection bounds") + } } } - 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 - 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(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 } - ) - } - } - - if (activeSelection != null) { - val sel = activeSelection!! - Popup(popupPositionProvider = remember(sel.rect, density) { - SmartPopupPositionProvider(sel.rect, density) - }, 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 - }, 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 } - ) - } - } - - if (showColorPickerDialog != null) { - AlertDialog( - onDismissRequest = { showColorPickerDialog = null }, - title = { Text("Select Color") }, - text = { - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 48.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - items(HighlightColor.entries) { colorOption -> - Box( - modifier = Modifier - .size(48.dp) - .background(colorOption.color, CircleShape) - .border(1.dp, MaterialTheme.colorScheme.outline, CircleShape) - .clickable { - onUpdatePalette(showColorPickerDialog!!, colorOption) - showColorPickerDialog = null - } + val handleSizePx = with(density) { 36.dp.toPx() } + if (minTop != Float.MAX_VALUE && maxBottom != Float.MIN_VALUE) { + Rect(minLeft, minTop, maxRight, maxBottom + handleSizePx) + } else { + Rect( + sel.rect.left, + sel.rect.top, + sel.rect.right, + sel.rect.bottom + handleSizePx ) } } - }, - confirmButton = { TextButton(onClick = { showColorPickerDialog = null }) { Text("Close") } } - ) - } - if (showPaletteManager) { - PaletteManagerDialog( - currentPalette = activeHighlightPalette, - onDismiss = { showPaletteManager = false }, - onSave = { newPalette -> - newPalette.forEachIndexed { index, color -> - onUpdatePalette(index, color) + Popup( + popupPositionProvider = remember( + menuAnchorRect, + density + ) { SmartPopupPositionProvider(menuAnchorRect, density) }, + onDismissRequest = { activeSelection = null }, + properties = androidx.compose.ui.window.PopupProperties( + dismissOnClickOutside = false + ) + ) { + 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 + }, + onHighlight = { color -> + val finalCfi = + "${sel.startBaseCfi}:${sel.startOffset}|${sel.endBaseCfi}:${sel.endOffset}" + onHighlightCreated(finalCfi, sel.text, color.id) + activeSelection = null + }, + onNote = { + onNoteRequested(null) + val finalCfi = + "${sel.startBaseCfi}:${sel.startOffset}|${sel.endBaseCfi}:${sel.endOffset}" + onHighlightCreated(finalCfi, sel.text, HighlightColor.YELLOW.id) + activeSelection = null + }, + onTts = { + val startAbs = sel.startOffset + sel.startBlockCharOffset + onStartTtsFromSelection(sel.startBaseCfi, startAbs) + activeSelection = null + }, + onDelete = null, + isProUser = isProUser, + isOss = isOss, + activeHighlightPalette = activeHighlightPalette, + onOpenPaletteManager = { showPaletteManager = true }) } - showPaletteManager = false } - ) + + val updateSelection: (Offset, SelectionHandle) -> SelectionHandle = + { windowPos, currentDragHandle -> + var activeDragHandle = currentDragHandle + + val attachedBlocks = + blockLayoutMap.filterKeys { it.endsWith(currentPageSuffix) }.values.filter { it.second.isAttached } + .sortedBy { it.second.positionInWindow().y } + + if (attachedBlocks.isNotEmpty()) { + val targetTriple = attachedBlocks.minByOrNull { + val coords = it.second + val rect = Rect(coords.positionInWindow(), coords.size.toSize()) + val dx = + maxOf(rect.left - windowPos.x, 0f, windowPos.x - rect.right) + val dy = + maxOf(rect.top - windowPos.y, 0f, windowPos.y - rect.bottom) + dx * dx + dy * dy + } ?: attachedBlocks.last() + + val (textLayout, coords, block) = targetTriple + val localPos = coords.windowToLocal(windowPos) + val offset = textLayout.getOffsetForPosition(localPos) + .coerceIn(0, textLayout.layoutInput.text.length) + + val isStartHandle = activeDragHandle == SelectionHandle.START + var newStartIdx = + if (isStartHandle) block.blockIndex else sel.startBlockIndex + var newEndIdx = + if (isStartHandle) sel.endBlockIndex else block.blockIndex + var newStartOffset = if (isStartHandle) offset else sel.startOffset + var newEndOffset = if (isStartHandle) sel.endOffset else offset + var newStartCfi = if (isStartHandle) block.cfi!! else sel.startBaseCfi + var newEndCfi = if (isStartHandle) sel.endBaseCfi else block.cfi!! + + val currentBlockAbs = when (block) { + is ParagraphBlock -> block.startCharOffsetInSource + is HeaderBlock -> block.startCharOffsetInSource + is QuoteBlock -> block.startCharOffsetInSource + is ListItemBlock -> block.startCharOffsetInSource + else -> 0 + } + var newStartBlockAbs = if (isStartHandle) currentBlockAbs else sel.startBlockCharOffset + var newEndBlockAbs = if (!isStartHandle) currentBlockAbs else sel.endBlockCharOffset + + // UPDATE Swap conditions completely: + val isReversed = when { + newStartIdx > newEndIdx -> true + newStartIdx < newEndIdx -> false + else -> { + when { + newStartBlockAbs > newEndBlockAbs -> true + newStartBlockAbs < newEndBlockAbs -> false + else -> newStartOffset > newEndOffset + } + } + } + + if (isReversed) { + newStartIdx = newEndIdx.also { newEndIdx = newStartIdx } + newStartOffset = newEndOffset.also { newEndOffset = newStartOffset } + newStartCfi = newEndCfi.also { newEndCfi = newStartCfi } + newStartBlockAbs = newEndBlockAbs.also { newEndBlockAbs = newStartBlockAbs } + activeDragHandle = if (activeDragHandle == SelectionHandle.START) SelectionHandle.END else SelectionHandle.START + } + + if (newStartIdx != sel.startBlockIndex || newEndIdx != sel.endBlockIndex || newStartOffset != sel.startOffset || newEndOffset != sel.endOffset) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + + val relevantBlocks = attachedBlocks.filter { it.third.blockIndex in newStartIdx..newEndIdx } + .sortedWith(compareBy({ it.third.blockIndex }, { b -> + when(b.third) { + is ParagraphBlock -> (b.third as ParagraphBlock).startCharOffsetInSource + is HeaderBlock -> (b.third as HeaderBlock).startCharOffsetInSource + is QuoteBlock -> (b.third as QuoteBlock).startCharOffsetInSource + is ListItemBlock -> (b.third as ListItemBlock).startCharOffsetInSource + else -> 0 + } + })) + + val newTextPerBlock = sel.textPerBlock.toMutableMap() + newTextPerBlock.keys.removeAll { keyStr -> + val bIdx = keyStr.split("_").firstOrNull()?.toIntOrNull() ?: -1 + bIdx !in newStartIdx..newEndIdx + } + + for (b in relevantBlocks) { + val txt = b.third.content.text + val bAbs = when(b.third) { + is ParagraphBlock -> b.third.startCharOffsetInSource + is HeaderBlock -> b.third.startCharOffsetInSource + is QuoteBlock -> b.third.startCharOffsetInSource + is ListItemBlock -> b.third.startCharOffsetInSource + else -> 0 + } + val isStartBlockPart = b.third.blockIndex == newStartIdx && bAbs == newStartBlockAbs + val isEndBlockPart = b.third.blockIndex == newEndIdx && bAbs == newEndBlockAbs + + val s = if (isStartBlockPart) newStartOffset else 0 + val e = if (isEndBlockPart) newEndOffset else txt.length + + val safeS = s.coerceIn(0, txt.length) + val safeE = e.coerceIn(safeS, txt.length) + + if (safeS < safeE) { + newTextPerBlock["${b.third.blockIndex}_${bAbs}"] = txt.substring(safeS, safeE) + } else { + newTextPerBlock.remove("${b.third.blockIndex}_${bAbs}") + } + } + + val newText = newTextPerBlock.entries.sortedBy { + val parts = it.key.split("_") + val idx = parts[0].toIntOrNull() ?: 0 + val abs = parts.getOrNull(1)?.toIntOrNull() ?: 0 + idx * 1000000L + abs + }.joinToString(" ") { it.value } + + val sLayout = blockLayoutMap["${newStartCfi}$currentPageSuffix"]?.takeIf { + val abs = when(it.third) { + is ParagraphBlock -> it.third.startCharOffsetInSource + is HeaderBlock -> it.third.startCharOffsetInSource + is QuoteBlock -> it.third.startCharOffsetInSource + is ListItemBlock -> it.third.startCharOffsetInSource + else -> 0 + } + abs == newStartBlockAbs + } + + val eLayout = blockLayoutMap["${newEndCfi}$currentPageSuffix"] + var newRect = sel.rect + + if (sLayout != null && eLayout != null && sLayout.second.isAttached && eLayout.second.isAttached) { + val sMaxIdx = maxOf(0, sLayout.first.layoutInput.text.length - 1) + val eMaxIdx = maxOf(0, eLayout.first.layoutInput.text.length - 1) + + val sRectLocal = sLayout.first.getBoundingBox( + newStartOffset.coerceIn(0, sMaxIdx) + ) + val sRectWin = Rect( + sLayout.second.localToWindow(sRectLocal.topLeft), + sLayout.second.localToWindow(sRectLocal.bottomRight) + ) + val eRectLocal = eLayout.first.getBoundingBox( + (newEndOffset - 1).coerceIn(0, eMaxIdx) + ) + val eRectWin = Rect( + eLayout.second.localToWindow(eRectLocal.topLeft), + eLayout.second.localToWindow(eRectLocal.bottomRight) + ) + newRect = Rect( + minOf(sRectWin.left, eRectWin.left), + sRectWin.top, + maxOf(sRectWin.right, eRectWin.right), + eRectWin.bottom + ) + } else { + var minLeft = Float.MAX_VALUE + var minTop = Float.MAX_VALUE + var maxRight = Float.MIN_VALUE + var maxBottom = Float.MIN_VALUE + relevantBlocks.forEach { b -> + if (b.second.isAttached) { + val r = Rect( + b.second.positionInWindow(), + b.second.size.toSize() + ) + minLeft = minOf(minLeft, r.left) + minTop = minOf(minTop, r.top) + maxRight = maxOf(maxRight, r.right) + maxBottom = maxOf(maxBottom, r.bottom) + } + } + if (minLeft != Float.MAX_VALUE) { + newRect = Rect(minLeft, minTop, maxRight, maxBottom) + } + } + + activeSelection = PaginatedSelection( + startBlockIndex = newStartIdx, + endBlockIndex = newEndIdx, + startBaseCfi = newStartCfi, + endBaseCfi = newEndCfi, + startOffset = newStartOffset, + endOffset = newEndOffset, + text = newText, + rect = newRect, + startBlockCharOffset = newStartBlockAbs, + endBlockCharOffset = newEndBlockAbs, + textPerBlock = newTextPerBlock + ) + } + } + activeDragHandle + } + + val latestUpdateSelection by androidx.compose.runtime.rememberUpdatedState(updateSelection) + + listOf(SelectionHandle.START, SelectionHandle.END).forEach { handleType -> + val isStart = handleType == SelectionHandle.START + var handleCoords by remember { mutableStateOf(null) } + + Box( + modifier = Modifier + .graphicsLayer { + @Suppress("UNUSED_VARIABLE") val animOffset = pagerState.currentPageOffsetFraction + @Suppress("UNUSED_VARIABLE") val currPage = pagerState.currentPage + @Suppress("UNUSED_VARIABLE") val isScrolling = pagerState.isScrollInProgress + @Suppress("UNUSED_VARIABLE") val tick = blockLayoutMap.tick + + val selCfi = if (isStart) sel.startBaseCfi else sel.endBaseCfi + val selOffset = if (isStart) sel.startOffset else sel.endOffset + val targetBlockAbs = if (isStart) sel.startBlockCharOffset else sel.endBlockCharOffset + val layoutInfo = blockLayoutMap["${selCfi}$currentPageSuffix"]?.takeIf { + val blockAbs = when (val block = it.third) { + is ParagraphBlock -> block.startCharOffsetInSource + is HeaderBlock -> block.startCharOffsetInSource + is QuoteBlock -> block.startCharOffsetInSource + is ListItemBlock -> block.startCharOffsetInSource + else -> 0 + } + blockAbs == targetBlockAbs + } + + val pos = if (layoutInfo != null && layoutInfo.second.isAttached && rootCoords != null && rootCoords!!.isAttached) { + val textLayout = layoutInfo.first + val coords = layoutInfo.second + val maxIdx = maxOf(0, textLayout.layoutInput.text.length - 1) + val safeOffset = selOffset.coerceIn(0, textLayout.layoutInput.text.length) + val safeOffsetForLine = safeOffset.coerceIn(0, maxIdx) + + val line = textLayout.getLineForOffset(safeOffsetForLine) + val x = textLayout.getHorizontalPosition(safeOffset, usePrimaryDirection = true) + val y = textLayout.getLineBottom(line) + + try { + val windowPos = coords.localToWindow(Offset(x, y)) + rootCoords!!.windowToLocal(windowPos) + } catch (e: Exception) { + Offset.Unspecified + } + } else { + Offset.Unspecified + } + + if (pos.isSpecified) { + translationX = pos.x - 18.dp.toPx() + translationY = pos.y + alpha = 1f + } else { + alpha = 0f + } + } + .size(36.dp) + .onGloballyPositioned { handleCoords = it } + .pointerInput(handleType) { + awaitEachGesture { + val down = awaitFirstDown() + down.consume() + isDraggingHandle = true + var currentDragHandle = handleType + + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) { + change.consume() + break + } + change.consume() + + if (handleCoords != null && rootCoords != null && handleCoords!!.isAttached && rootCoords!!.isAttached) { + try { + val pointerWindow = handleCoords!!.localToWindow(change.position) + val pointerRoot = rootCoords!!.windowToLocal(pointerWindow) + val targetRootY = pointerRoot.y - 36.dp.toPx() + val targetRootPos = Offset(pointerRoot.x, targetRootY) + + magnifierCenter = targetRootPos + + val targetWindowPos = rootCoords!!.localToWindow(targetRootPos) + currentDragHandle = latestUpdateSelection(targetWindowPos, currentDragHandle) + } catch (e: Exception) { + // Ignore detachment crashes during fast scrolls + } + } + } + isDraggingHandle = false + magnifierCenter = Offset.Unspecified + } + }, + contentAlignment = Alignment.TopCenter + ) { + Icon( + painter = painterResource(R.drawable.teardrop), + contentDescription = if (isStart) "Start handle" else "End handle", + modifier = Modifier.size(36.dp).graphicsLayer { + rotationZ = if (isStart) 30f else -30f + transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0f) + }, + tint = Color(0xFF1976D2) + ) + } + } + } + + if (showColorPickerDialog != null) { + AlertDialog( + onDismissRequest = { showColorPickerDialog = null }, + title = { Text("Select Color") }, + text = { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 48.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(HighlightColor.entries) { colorOption -> + Box( + modifier = Modifier.size(48.dp) + .background(colorOption.color, CircleShape).border( + 1.dp, + MaterialTheme.colorScheme.outline, + CircleShape + ).clickable { + onUpdatePalette( + showColorPickerDialog!!, + colorOption + ) + showColorPickerDialog = null + }) + } + } + }, + confirmButton = { + TextButton(onClick = { + showColorPickerDialog = null + }) { Text("Close") } + }) + } + + if (showPaletteManager) { + PaletteManagerDialog( + currentPalette = activeHighlightPalette, + onDismiss = { showPaletteManager = false }, + onSave = { newPalette -> + newPalette.forEachIndexed { index, color -> + onUpdatePalette(index, color) + } + showPaletteManager = false + }) + } } } else { Timber.w("Book has no pages to display.") @@ -3065,9 +3378,10 @@ private fun RenderFlexChildBlock( onSelectionChange: (PaginatedSelection?) -> Unit, onHighlightClick: (UserHighlight, Rect) -> Unit, isDarkTheme: Boolean, - blockLayoutMap: MutableMap>, + blockLayoutMap: MutableMap>, density: Density, - imageLoader: ImageLoader + imageLoader: ImageLoader, + pageIndex: Int ) { @Composable fun renderTextBlock(block: TextContentBlock) { @@ -3130,7 +3444,7 @@ private fun RenderFlexChildBlock( isDarkTheme = isDarkTheme, onRegisterLayout = { layout, coords -> block.cfi?.let { cfi -> - blockLayoutMap[cfi] = Triple(layout, coords, block.startCharOffsetInSource) + blockLayoutMap["${cfi}_$pageIndex"] = Triple(layout, coords, block) } }) } 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 5ce8874..c14a97e 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt @@ -123,7 +123,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -1104,12 +1104,12 @@ internal fun PdfPageComposable( try { page = withContext(Dispatchers.IO) { pdfDocumentItem.openPage(pdfPageIndex) } - snapshotFlow { visibleScreenRect() }.collectLatest { currentVisibleRect -> + snapshotFlow { visibleScreenRect() }.conflate().collect { currentVisibleRect -> val tileCalcStart = System.nanoTime() - if (!isActive) return@collectLatest + if (!isActive) return@collect if (isScrolling && effectiveScale > 1f) { - return@collectLatest + return@collect } val pxTl: Float @@ -1131,7 +1131,7 @@ internal fun PdfPageComposable( oldTiles.forEach { PdfBitmapPool.recycle(it.bitmap) } } } - return@collectLatest + return@collect } } else { val pivotX = screenWidth / 2f 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 4112bc1..9e2a53d 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt @@ -139,6 +139,8 @@ class VerticalPdfReaderState { internal var scrollToPageHandler: (suspend (Int) -> Unit)? = null internal var snapToPageHandler: (suspend (Int) -> Unit)? = null internal var scrollByHandler: (suspend (Float) -> Unit)? = null + internal var scrollToTopHandler: (suspend () -> Unit)? = null + internal var scrollToBottomHandler: (suspend () -> Unit)? = null suspend fun scrollToPage(pageIndex: Int) { scrollToPageHandler?.invoke(pageIndex) @@ -151,6 +153,14 @@ class VerticalPdfReaderState { suspend fun scrollBy(delta: Float) { scrollByHandler?.invoke(delta) } + + suspend fun scrollToTop() { + scrollToTopHandler?.invoke() + } + + suspend fun scrollToBottom() { + scrollToBottomHandler?.invoke() + } } @Composable @@ -244,6 +254,8 @@ internal fun PdfVerticalReader( state.scrollToPageHandler = null state.snapToPageHandler = null state.scrollByHandler = null + state.scrollToTopHandler = null + state.scrollToBottomHandler = null } } var globalEraserPosition by remember { mutableStateOf(null) } @@ -527,6 +539,23 @@ internal fun PdfVerticalReader( ) } } + + state.scrollToTopHandler = { + panYAnimatable.animateTo( + targetValue = headerHeightPx, + animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing) + ) + } + + state.scrollToBottomHandler = { + val currentZoom = zoomAnimatable.value + val zoomedDocHeight = totalDocHeight * currentZoom + val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx) + panYAnimatable.animateTo( + targetValue = minPanY, + animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing) + ) + } } var selectionClearTrigger by remember { mutableLongStateOf(0L) } 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 0c459fe..171aa31 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -20,7 +20,7 @@ // PdfViewerScreen.kt @file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH", "Unused", "UnusedVariable", "SimplifyBooleanWithConstants" -) +) @file:kotlin.OptIn(ExperimentalMaterial3Api::class) package com.aryan.reader.pdf @@ -30,6 +30,8 @@ import android.app.Activity import android.content.ClipData import android.content.Context import android.content.pm.PackageManager +import androidx.compose.material3.Switch +import androidx.compose.material.icons.filled.Settings import android.graphics.Bitmap import android.graphics.RectF import android.net.Uri @@ -91,6 +93,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -364,6 +367,42 @@ private const val PREF_EXTERNAL_TRANSLATE_PKG = "external_translate_package" private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package" private const val PDF_THEME_KEY = "pdf_reader_theme" private const val PDF_KEEP_SCREEN_ON_KEY = "pdf_keep_screen_on_enabled" +private const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools" + +private fun loadPdfHiddenTools(context: Context): Set { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()) ?: emptySet() +} + +private fun savePdfHiddenTools(context: Context, hiddenTools: Set) { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { putStringSet(PDF_HIDDEN_TOOLS_KEY, hiddenTools) } +} + +enum class PdfReaderTool(val title: String, val category: String) { + DICTIONARY("External Apps", "Top Bar"), + THEME("Theme Settings", "Top Bar"), + LOCK_PANNING("Lock Panning", "Top Bar"), + FULL_SCREEN("Full Screen", "Top Bar"), + SLIDER("Navigation Slider", "Bottom Bar"), + TOC("Sidebar", "Bottom Bar"), + SEARCH("Search", "Bottom Bar"), + HIGHLIGHT_ALL("Highlight selectable text", "Bottom Bar"), + AI_FEATURES("AI Features", "Bottom Bar"), + EDIT_MODE("Edit Mode", "Bottom Bar"), + TTS_CONTROLS("TTS Controls", "Bottom Bar"), + OCR_LANGUAGE("OCR Language", "Overflow Menu"), + READING_MODE("Reading Mode", "Overflow Menu"), + KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"), + AUTO_SCROLL("Auto Scroll", "Overflow Menu"), + TTS_SETTINGS("TTS Voice Settings", "Overflow Menu"), + BOOKMARK("Bookmark", "Overflow Menu"), + PAGE_MANAGEMENT("Page Management", "Overflow Menu"), + REFLOW("Text View (Reflow)", "Overflow Menu"), + SHARE("Share", "Overflow Menu"), + SAVE_COPY("Save Copy", "Overflow Menu"), + PRINT("Print", "Overflow Menu") +} private fun loadCustomHighlightColors(context: Context): Map { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) @@ -1213,6 +1252,14 @@ fun PdfViewerScreen( var showReindexDialog by remember { mutableStateOf(null) } var pendingActionAfterOcrSelection by remember { mutableStateOf<(() -> Unit)?>(null) } + var showCustomizeToolsSheet by remember { mutableStateOf(false) } + var hiddenTools by remember { mutableStateOf(loadPdfHiddenTools(context)) } + + val onUpdateHiddenTools = { newSet: Set -> + hiddenTools = newSet + savePdfHiddenTools(context, newSet) + } + val executeWithOcrCheck = remember(hasSelectedOcrLanguage) { { action: () -> Unit -> if (hasSelectedOcrLanguage) { @@ -3815,6 +3862,7 @@ fun PdfViewerScreen( showSummarizationUpsellDialog -> showSummarizationUpsellDialog = false showAiDefinitionPopup -> showAiDefinitionPopup = false showDictionaryUpsellDialog -> showDictionaryUpsellDialog = false + showCustomizeToolsSheet -> showCustomizeToolsSheet = false isPageSliderVisible -> { isPageSliderVisible = false showBars = true @@ -5143,7 +5191,7 @@ fun PdfViewerScreen( triggerAutoScrollTempPause(1000L) coroutineScope.launch { - verticalReaderState.scrollToPage(0) + verticalReaderState.scrollToTop() } break } @@ -5210,7 +5258,7 @@ fun PdfViewerScreen( triggerAutoScrollTempPause(1000L) coroutineScope.launch { - verticalReaderState.scrollToPage(totalPages - 1) + verticalReaderState.scrollToBottom() } break } @@ -5555,60 +5603,68 @@ fun PdfViewerScreen( .testTag("PageNumberIndicator") ) - TooltipIconButton( - text = "Theme", - description = "Theme Settings", - onClick = { showThemePanel = true }) { - Icon( - painter = painterResource(id = R.drawable.palette), - contentDescription = "Theme Settings", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + if (!hiddenTools.contains(PdfReaderTool.THEME.name)) { + TooltipIconButton( + text = "Theme", + description = "Theme Settings", + onClick = { showThemePanel = true }) { + Icon( + painter = painterResource(id = R.drawable.palette), + contentDescription = "Theme Settings", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } - TooltipIconButton( - text = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) - else stringResource(R.string.tooltip_lock_pan), - description = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan_desc) - else stringResource(R.string.tooltip_lock_pan_desc), - onClick = { - isScrollLocked = !isScrollLocked - savePdfScrollLocked(context, bookId, isScrollLocked) - if (isScrollLocked) { - savePdfLockedState(context, bookId, currentActiveScale, currentActiveOffset.x, currentActiveOffset.y) - lockedState = Triple(currentActiveScale, currentActiveOffset.x, currentActiveOffset.y) - } - }) { - Icon( - imageVector = if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, - contentDescription = if (isScrollLocked) "Unlock Panning" else "Lock Panning", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + if (!hiddenTools.contains(PdfReaderTool.LOCK_PANNING.name)) { + TooltipIconButton( + text = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) + else stringResource(R.string.tooltip_lock_pan), + description = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan_desc) + else stringResource(R.string.tooltip_lock_pan_desc), + onClick = { + isScrollLocked = !isScrollLocked + savePdfScrollLocked(context, bookId, isScrollLocked) + if (isScrollLocked) { + savePdfLockedState(context, bookId, currentActiveScale, currentActiveOffset.x, currentActiveOffset.y) + lockedState = Triple(currentActiveScale, currentActiveOffset.x, currentActiveOffset.y) + } + }) { + Icon( + imageVector = if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, + contentDescription = if (isScrollLocked) "Unlock Panning" else "Lock Panning", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } - TooltipIconButton( - text = stringResource(R.string.tooltip_fullscreen), - description = stringResource(R.string.tooltip_fullscreen_desc), - onClick = { - isFullScreen = true - savePdfFullScreen(context, bookId, true) - }) { - Icon( - imageVector = Icons.Default.Fullscreen, - contentDescription = "Enter Full Screen", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + if (!hiddenTools.contains(PdfReaderTool.FULL_SCREEN.name)) { + TooltipIconButton( + text = stringResource(R.string.tooltip_fullscreen), + description = stringResource(R.string.tooltip_fullscreen_desc), + onClick = { + isFullScreen = true + savePdfFullScreen(context, bookId, true) + }) { + Icon( + imageVector = Icons.Default.Fullscreen, + contentDescription = "Enter Full Screen", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } - TooltipIconButton( - text = stringResource(R.string.tooltip_dictionary), - description = stringResource(R.string.tooltip_dictionary_desc), - onClick = { showDictionarySettingsSheet = true }) { - Icon( - painter = painterResource(id = R.drawable.dictionary), - contentDescription = "Dictionary Settings", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + if (!hiddenTools.contains(PdfReaderTool.DICTIONARY.name)) { + TooltipIconButton( + text = stringResource(R.string.tooltip_dictionary), + description = stringResource(R.string.tooltip_dictionary_desc), + onClick = { showDictionarySettingsSheet = true }) { + Icon( + painter = painterResource(id = R.drawable.dictionary), + contentDescription = "Dictionary Settings", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } if (BuildConfig.DEBUG) { @@ -5682,7 +5738,18 @@ fun PdfViewerScreen( DropdownMenu( expanded = showMoreMenu, onDismissRequest = { showMoreMenu = false }) { - if (BuildConfig.IS_PRO) { + DropdownMenuItem( + text = { Text("Customize Toolbar") }, + onClick = { + showMoreMenu = false + showCustomizeToolsSheet = true + }, + leadingIcon = { + Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(20.dp)) + } + ) + HorizontalDivider() + if (BuildConfig.IS_PRO && !hiddenTools.contains(PdfReaderTool.OCR_LANGUAGE.name)) { DropdownMenuItem( text = { Text("OCR Language") }, onClick = { @@ -5693,166 +5760,184 @@ fun PdfViewerScreen( HorizontalDivider() } - DropdownMenuItem( - text = { Text("Reading Mode: Vertical scroll") }, - enabled = !isTtsSessionActive, - onClick = { - displayMode = DisplayMode.VERTICAL_SCROLL - showMoreMenu = false - }, - trailingIcon = { - if (displayMode == DisplayMode.VERTICAL_SCROLL) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = "Selected" - ) - } - }) - HorizontalDivider() - DropdownMenuItem( - text = { Text("Reading Mode: Paginated") }, - enabled = !isTtsSessionActive, - onClick = { - displayMode = DisplayMode.PAGINATION - showMoreMenu = false - }, - trailingIcon = { - if (displayMode == DisplayMode.PAGINATION) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = "Selected" - ) - } - }) - HorizontalDivider() - DropdownMenuItem( - text = { Text("Keep Screen On") }, - onClick = { - isKeepScreenOn = !isKeepScreenOn - saveKeepScreenOn(context, isKeepScreenOn) - showMoreMenu = false - }, - trailingIcon = { - if (isKeepScreenOn) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = "Selected" - ) - } - }) - HorizontalDivider() - DropdownMenuItem( - text = { Text("Auto Scroll") }, - enabled = !isTtsSessionActive && displayMode == DisplayMode.VERTICAL_SCROLL, - onClick = { - showMoreMenu = false - isAutoScrollModeActive = true - isAutoScrollPlaying = true - showBars = !isMusicianMode - }) - - HorizontalDivider() - DropdownMenuItem( - text = { Text("TTS Voice Settings") }, - onClick = { - showMoreMenu = false - showDeviceVoiceSettingsSheet = true - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.GraphicEq, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - }) - - if (BuildConfig.DEBUG) { + if (!hiddenTools.contains(PdfReaderTool.READING_MODE.name)) { DropdownMenuItem( - text = { Text("TTS Settings (Debug)") }, + text = { Text("Reading Mode: Vertical scroll") }, + enabled = !isTtsSessionActive, + onClick = { + displayMode = DisplayMode.VERTICAL_SCROLL + showMoreMenu = false + }, + trailingIcon = { + if (displayMode == DisplayMode.VERTICAL_SCROLL) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = "Selected" + ) + } + }) + HorizontalDivider() + DropdownMenuItem( + text = { Text("Reading Mode: Paginated") }, + enabled = !isTtsSessionActive, + onClick = { + displayMode = DisplayMode.PAGINATION + showMoreMenu = false + }, + trailingIcon = { + if (displayMode == DisplayMode.PAGINATION) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = "Selected" + ) + } + }) + HorizontalDivider() + } + if (!hiddenTools.contains(PdfReaderTool.KEEP_SCREEN_ON.name)) { + DropdownMenuItem( + text = { Text("Keep Screen On") }, + onClick = { + isKeepScreenOn = !isKeepScreenOn + saveKeepScreenOn(context, isKeepScreenOn) + showMoreMenu = false + }, + trailingIcon = { + if (isKeepScreenOn) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = "Selected" + ) + } + }) + HorizontalDivider() + } + if (!hiddenTools.contains(PdfReaderTool.AUTO_SCROLL.name)) { + DropdownMenuItem( + text = { Text("Auto Scroll") }, + enabled = !isTtsSessionActive && displayMode == DisplayMode.VERTICAL_SCROLL, onClick = { showMoreMenu = false - showTtsSettingsSheet = true + isAutoScrollModeActive = true + isAutoScrollPlaying = true + showBars = !isMusicianMode + }) + + HorizontalDivider() + } + if (!hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name)) { + DropdownMenuItem( + text = { Text("TTS Voice Settings") }, + onClick = { + showMoreMenu = false + showDeviceVoiceSettingsSheet = true }, leadingIcon = { Icon( - painter = painterResource(id = R.drawable.text_to_speech), + imageVector = Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp) ) - }) - } - - HorizontalDivider() - DropdownMenuItem(text = { - Text( - if (isBookmarked) "Remove bookmark" - else "Bookmark this page" + } ) - }, onClick = { - showMoreMenu = false - onBookmarkClick() - }) - HorizontalDivider() - DropdownMenuItem( - text = { Text("Insert Blank Page") }, - onClick = { + if (BuildConfig.DEBUG) { + DropdownMenuItem( + text = { Text("TTS Settings (Debug)") }, + onClick = { + showMoreMenu = false + showTtsSettingsSheet = true + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.text_to_speech), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + ) + } + HorizontalDivider() + } + if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) { + DropdownMenuItem(text = { + Text( + if (isBookmarked) "Remove bookmark" + else "Bookmark this page" + ) + }, onClick = { showMoreMenu = false - onInsertPage() + onBookmarkClick() }) - - val canDelete = - virtualPages.getOrNull(currentPage) is VirtualPage.BlankPage - if (canDelete) { + HorizontalDivider() + } + if (!hiddenTools.contains(PdfReaderTool.PAGE_MANAGEMENT.name)) { DropdownMenuItem( - text = { Text("Delete Page") }, + text = { Text("Insert Blank Page") }, onClick = { showMoreMenu = false - onDeletePage() - }, - colors = MenuDefaults.itemColors( - textColor = MaterialTheme.colorScheme.error + onInsertPage() + }) + + val canDelete = + virtualPages.getOrNull(currentPage) is VirtualPage.BlankPage + if (canDelete) { + DropdownMenuItem( + text = { Text("Delete Page") }, + onClick = { + showMoreMenu = false + onDeletePage() + }, + colors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error + ) ) - ) + } + HorizontalDivider() } - - HorizontalDivider() - - DropdownMenuItem( - text = { - Text( - when { - isReflowingThisBook -> "Generating... ${(reflowProgressValue * 100).toInt()}%" - hasReflowFile -> "Open Text View" - else -> "Generate Text View" - } - ) - }, - enabled = pdfDocument != null && !isReflowingThisBook, - onClick = { - showMoreMenu = false - - coroutineScope.launch { - if (richTextController != null) { - withContext(NonCancellable) { richTextController.saveImmediate() } - } - saveAllData(true).join() - - val resolvedPage = - if (!initialScrollDone && currentPage == 0) { - pendingRestorePage ?: 0 - } else { - currentPage + if (!hiddenTools.contains(PdfReaderTool.REFLOW.name)) { + DropdownMenuItem( + text = { + Text( + when { + isReflowingThisBook -> "Generating... ${(reflowProgressValue * 100).toInt()}%" + hasReflowFile -> "Open Text View" + else -> "Generate Text View" } + ) + }, + enabled = pdfDocument != null && !isReflowingThisBook, + onClick = { + showMoreMenu = false - if (hasReflowFile) { - val item = - uiState.allRecentFiles.find { it.bookId == reflowBookId } - if (item != null) { - viewModel.switchToFileSeamlessly( - item, - resolvedPage - ) + coroutineScope.launch { + if (richTextController != null) { + withContext(NonCancellable) { richTextController.saveImmediate() } + } + saveAllData(true).join() + + val resolvedPage = + if (!initialScrollDone && currentPage == 0) { + pendingRestorePage ?: 0 + } else { + currentPage + } + + if (hasReflowFile) { + val item = + uiState.allRecentFiles.find { it.bookId == reflowBookId } + if (item != null) { + viewModel.switchToFileSeamlessly( + item, resolvedPage + ) + } else { + viewModel.generateAndImportReflowFile( + pdfBookId = bookId, + pdfUri = effectivePdfUri, + originalTitle = originalFileName, + autoOpenPage = resolvedPage + ) + } } else { viewModel.generateAndImportReflowFile( pdfBookId = bookId, @@ -5861,35 +5946,33 @@ fun PdfViewerScreen( autoOpenPage = resolvedPage ) } - } else { - viewModel.generateAndImportReflowFile( - pdfBookId = bookId, - pdfUri = effectivePdfUri, - originalTitle = originalFileName, - autoOpenPage = resolvedPage - ) } + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.format_size), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + }) + HorizontalDivider() + } + if (!hiddenTools.contains(PdfReaderTool.SHARE.name)) { + DropdownMenuItem( + text = { Text("Share") }, + onClick = { + showMoreMenu = false + showShareDialog = true + }, + leadingIcon = { + Icon( + Icons.Default.Share, + contentDescription = null + ) } - }, - leadingIcon = { - Icon( - painter = painterResource(id = R.drawable.format_size), - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - }) - - HorizontalDivider() - - DropdownMenuItem(text = { Text("Share") }, onClick = { - showMoreMenu = false - showShareDialog = true - }, leadingIcon = { - Icon( - Icons.Default.Share, contentDescription = null ) - }) - if (uiState.selectedFileType == FileType.PDF) { + } + if (uiState.selectedFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)) { DropdownMenuItem( text = { Text("Save copy to device") }, onClick = { @@ -5903,7 +5986,7 @@ fun PdfViewerScreen( ) }) } - if (uiState.selectedFileType == FileType.PDF) { + if (uiState.selectedFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name)) { DropdownMenuItem( text = { Text("Print") }, onClick = { @@ -6247,93 +6330,97 @@ fun PdfViewerScreen( horizontalArrangement = Arrangement.SpaceAround ) { // Slider Navigation Trigger - TooltipIconButton( - text = stringResource(R.string.tooltip_slider), - description = stringResource(R.string.tooltip_slider_desc), - onClick = { - val currentPage = if (displayMode == DisplayMode.PAGINATION) { - pagerState.currentPage - } else { - verticalReaderState.currentPage - } - sliderStartPage = currentPage - sliderCurrentPage = currentPage.toFloat() - isPageSliderVisible = true - showBars = false - }, enabled = !(ttsState.isPlaying || ttsState.isLoading) - ) { - Icon( - painter = painterResource(id = R.drawable.slider), - contentDescription = "Navigate with slider" - ) - } - - TooltipIconButton( - text = stringResource(R.string.tooltip_toc), - description = stringResource(R.string.tooltip_toc_desc), - onClick = { coroutineScope.launch { drawerState.open() } }, - enabled = !(ttsState.isPlaying || ttsState.isLoading), - modifier = Modifier.testTag("TocButton") - ) { - Icon( - imageVector = Icons.Default.Menu, - contentDescription = "Table of Contents" - ) - } - - // Search Button - TooltipIconButton( - text = stringResource(R.string.tooltip_search), - description = stringResource(R.string.tooltip_search_desc), - onClick = { - executeWithOcrCheck { - searchState.isSearchActive = true - showBars = true - } - }, - enabled = !(ttsState.isPlaying || ttsState.isLoading), - modifier = Modifier.testTag("SearchButton") - ) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = "Search" - ) - } - - TooltipIconButton( - text = if (showAllTextHighlights) - stringResource(R.string.tooltip_highlights_off) - else - stringResource(R.string.tooltip_highlights), - description = if (showAllTextHighlights) - stringResource(R.string.tooltip_highlights_off_desc) - else - stringResource(R.string.tooltip_highlights_desc), - onClick = { - val newState = !showAllTextHighlights - if (newState) { - if (isHighlightingLoading) return@TooltipIconButton - showAllTextHighlights = true - isHighlightingLoading = true - } else { - showAllTextHighlights = false - isHighlightingLoading = false - } - }) { - if (isHighlightingLoading) { - CircularProgressIndicator(Modifier.size(24.dp)) - } else { + if (!hiddenTools.contains(PdfReaderTool.SLIDER.name)) { + TooltipIconButton( + text = stringResource(R.string.tooltip_slider), + description = stringResource(R.string.tooltip_slider_desc), + onClick = { + val currentPage = + if (displayMode == DisplayMode.PAGINATION) { + pagerState.currentPage + } else { + verticalReaderState.currentPage + } + sliderStartPage = currentPage + sliderCurrentPage = currentPage.toFloat() + isPageSliderVisible = true + showBars = false + }, + enabled = !(ttsState.isPlaying || ttsState.isLoading) + ) { Icon( - painter = painterResource(id = R.drawable.highlight_text), - contentDescription = "Highlight all text", - tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant + painter = painterResource(id = R.drawable.slider), + contentDescription = "Navigate with slider" + ) + } + } + if (!hiddenTools.contains(PdfReaderTool.TOC.name)) { + TooltipIconButton( + text = stringResource(R.string.tooltip_toc), + description = stringResource(R.string.tooltip_toc_desc), + onClick = { coroutineScope.launch { drawerState.open() } }, + enabled = !(ttsState.isPlaying || ttsState.isLoading), + modifier = Modifier.testTag("TocButton") + ) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = "Table of Contents" ) } } + // Search Button + if (!hiddenTools.contains(PdfReaderTool.SEARCH.name)) { + TooltipIconButton( + text = stringResource(R.string.tooltip_search), + description = stringResource(R.string.tooltip_search_desc), + onClick = { + executeWithOcrCheck { + searchState.isSearchActive = true + showBars = true + } + }, + enabled = !(ttsState.isPlaying || ttsState.isLoading), + modifier = Modifier.testTag("SearchButton") + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search" + ) + } + } + if (!hiddenTools.contains(PdfReaderTool.HIGHLIGHT_ALL.name)) { + TooltipIconButton( + text = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off) + else stringResource(R.string.tooltip_highlights), + description = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off_desc) + else stringResource(R.string.tooltip_highlights_desc), + onClick = { + val newState = !showAllTextHighlights + if (newState) { + if (isHighlightingLoading) return@TooltipIconButton + showAllTextHighlights = true + isHighlightingLoading = true + } else { + showAllTextHighlights = false + isHighlightingLoading = false + } + }) { + if (isHighlightingLoading) { + CircularProgressIndicator(Modifier.size(24.dp)) + } else { + Icon( + painter = painterResource(id = R.drawable.highlight_text), + contentDescription = "Highlight all text", + tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + // AI feat - if (BuildConfig.FLAVOR != "oss") { + if (BuildConfig.FLAVOR != "oss" && !hiddenTools.contains(PdfReaderTool.AI_FEATURES.name)) { Box { var showAiFeaturesMenu by remember { mutableStateOf(false) } TooltipIconButton( @@ -6375,66 +6462,63 @@ fun PdfViewerScreen( } // Edit Button - TooltipIconButton( - text = if (isEditMode) - stringResource(R.string.tooltip_edit_mode_exit) - else - stringResource(R.string.tooltip_edit_mode), - description = if (isEditMode) - stringResource(R.string.tooltip_edit_mode_exit_desc) - else - stringResource(R.string.tooltip_edit_mode_desc), - onClick = { - val newEditMode = !isEditMode - val currentActivePage = richTextController?.activePageIndex ?: -1 + if (!hiddenTools.contains(PdfReaderTool.EDIT_MODE.name)) { + TooltipIconButton( + text = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit) + else stringResource(R.string.tooltip_edit_mode), + description = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit_desc) + else stringResource(R.string.tooltip_edit_mode_desc), + onClick = { + val newEditMode = !isEditMode + val currentActivePage = + richTextController?.activePageIndex ?: -1 - Timber.tag("RichTextMigration").i("Edit Toggle: $isEditMode -> $newEditMode (ActivePage: $currentActivePage)") + Timber.tag("RichTextMigration") + .i("Edit Toggle: $isEditMode -> $newEditMode (ActivePage: $currentActivePage)") - if (!newEditMode && richTextController != null) { - coroutineScope.launch { - richTextController.saveImmediate() - withContext(Dispatchers.Main) { - keyboardController?.hide() + if (!newEditMode && richTextController != null) { + coroutineScope.launch { + richTextController.saveImmediate() + withContext(Dispatchers.Main) { + keyboardController?.hide() + } } } - } - isEditMode = newEditMode - if (!newEditMode) showBars = true + isEditMode = newEditMode + if (!newEditMode) showBars = true + }) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Toggle Editing Mode", + tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) } - ) { - Icon( - imageVector = Icons.Default.Edit, - contentDescription = "Toggle Editing Mode", - tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant - ) } // TTS - TooltipIconButton( - text = if (isTtsSessionActive) - stringResource(R.string.tooltip_tts_stop) - else - stringResource(R.string.tooltip_tts_start), - description = if (isTtsSessionActive) - stringResource(R.string.tooltip_tts_stop_desc) - else - stringResource(R.string.tooltip_tts_start_desc), - onClick = { - if (isTtsSessionActive) { - Timber.d("TTS button clicked: Stopping TTS") - ttsController.stop() - } else { - startTtsWithPermissionCheck(null, null) - } - }) { - Icon( - painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) - else painterResource( - id = R.drawable.text_to_speech - ), - contentDescription = if (isTtsSessionActive) "Stop TTS" else "Start TTS" - ) + if (!hiddenTools.contains(PdfReaderTool.TTS_CONTROLS.name)) { + TooltipIconButton( + text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) + else stringResource(R.string.tooltip_tts_start), + description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) + else stringResource(R.string.tooltip_tts_start_desc), + onClick = { + if (isTtsSessionActive) { + Timber.d("TTS button clicked: Stopping TTS") + ttsController.stop() + } else { + startTtsWithPermissionCheck(null, null) + } + }) { + Icon( + painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) + else painterResource( + id = R.drawable.text_to_speech + ), + contentDescription = if (isTtsSessionActive) "Stop TTS" else "Start TTS" + ) + } } // TTS Pause/Resume Button @@ -7759,7 +7843,22 @@ fun PdfViewerScreen( savePdfAutoScrollUseSlider(context, autoScrollUseSlider) }, isLocalMode = isAutoScrollLocal, - onLocalModeToggle = onToggleAutoScrollMode + onLocalModeToggle = onToggleAutoScrollMode, + onScrollToTop = { + if (isAutoScrollPlaying) { + triggerAutoScrollTempPause(1000L) + } + coroutineScope.launch { + verticalReaderState.scrollToTop() + } + } + ) + } + if (showCustomizeToolsSheet) { + PdfCustomizeToolsSheet( + hiddenTools = hiddenTools, + onUpdate = onUpdateHiddenTools, + onDismiss = { showCustomizeToolsSheet = false } ) } } @@ -8411,4 +8510,75 @@ fun PdfSearchResultsList( } } } +} + +@Composable +fun PdfCustomizeToolsSheet( + hiddenTools: Set, + onUpdate: (Set) -> Unit, + onDismiss: () -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + contentWindowInsets = { WindowInsets.navigationBars } + ) { + Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 24.dp)) { + Text( + text = "Customize Toolbar", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Select the tools you want to keep visible. Unchecking a tool hides it from the UI to give you a distraction-free reading space.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + + LazyColumn(modifier = Modifier.fillMaxWidth()) { + PdfReaderTool.entries.groupBy { it.category }.forEach { (category, tools) -> + item { + Text( + text = category, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) + ) + } + items(tools) { tool -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { + val newSet = hiddenTools.toMutableSet() + if (newSet.contains(tool.name)) newSet.remove(tool.name) + else newSet.add(tool.name) + onUpdate(newSet) + } + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = tool.title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Switch( + checked = !hiddenTools.contains(tool.name), + onCheckedChange = { isVisible -> + val newSet = hiddenTools.toMutableSet() + if (isVisible) newSet.remove(tool.name) else newSet.add(tool.name) + onUpdate(newSet) + } + ) + } + } + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/RichTextSystem.kt b/app/src/main/java/com/aryan/reader/pdf/RichTextSystem.kt index 118037d..27ab141 100644 --- a/app/src/main/java/com/aryan/reader/pdf/RichTextSystem.kt +++ b/app/src/main/java/com/aryan/reader/pdf/RichTextSystem.kt @@ -420,7 +420,7 @@ class PdfRichTextRepository(private val context: Context) { isItalic = sObj.optBoolean("i"), isUnderline = sObj.optBoolean("u"), isStrikethrough = sObj.optBoolean("st"), - fontPath = sObj.optString("fp", null), + fontPath = sObj.optString("fp"), ) ) } 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 f295b4c..5795c11 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 @@ -200,8 +200,8 @@ object TextBoxSerializer { isItalic = obj.optBoolean("isItalic", false), isUnderline = obj.optBoolean("isUnderline", false), isStrikeThrough = obj.optBoolean("isStrikeThrough", false), - fontPath = obj.optString("fontPath", null).takeIf { !it.isNullOrBlank() }, - fontName = obj.optString("fontName", null).takeIf { !it.isNullOrBlank() } + fontPath = obj.optString("fontPath").takeIf { !it.isNullOrBlank() }, + fontName = obj.optString("fontName").takeIf { !it.isNullOrBlank() } ) ) } @@ -269,7 +269,7 @@ object HighlightSerializer { 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)), - note = obj.optString("note", null).takeIf { !it.isNullOrBlank() } + note = obj.optString("note").takeIf { !it.isNullOrBlank() } ) ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5062c69..19b9482 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -139,6 +139,18 @@ Source Folder Read Status Clear All + In-App Storage + + + Save File? + Do you want to save this external file in the app\'s library? If not, it will be removed.\n\n(You can change this default behavior anytime from the Home Screen > More Options > External File Behavior). + Don\'t ask again + Keep in Library + Remove + Ask Every Time + Always Keep + Always Remove + External File Behavior Add Catalog