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