From 056485a1403c21275026e07ba2d0ff377f355b42 Mon Sep 17 00:00:00 2001 From: Aryan Date: Sat, 16 May 2026 16:19:31 +0530 Subject: [PATCH] General improvements (#316) * Added support for right text alignment in epub reader * Added tabs management to PDF navigation drawer * Implemented unified selection menu placement logic * Added reset functionality for toolbar customization * Improved highlight filtering in epub pagination * Migrated hardcoded UI strings to string resources * Extracted desktop application logic from Main.kt into modular files * Refactored Main.kt by extracting PDF and EPUB logic into specialized files * Added Spanish language support * Added system default option to app language selection --- app/build.gradle.kts | 24 +- app/src/main/assets/epub_reader.js | 127 + .../java/com/aryan/reader/AiSettingsScreen.kt | 80 +- app/src/main/java/com/aryan/reader/Common.kt | 80 +- .../main/java/com/aryan/reader/HomeScreen.kt | 34 +- .../java/com/aryan/reader/LibraryScreen.kt | 2 +- .../java/com/aryan/reader/SettingsScreen.kt | 23 +- .../com/aryan/reader/SharedComposables.kt | 48 +- .../aryan/reader/TtsWordReplacementsSheet.kt | 99 +- .../java/com/aryan/reader/UiLabelResources.kt | 19 + .../aryan/reader/epubreader/ChapterWebView.kt | 60 +- .../aryan/reader/epubreader/EpubReaderAi.kt | 12 +- .../epubreader/EpubReaderAnnotations.kt | 44 +- .../reader/epubreader/EpubReaderControls.kt | 205 +- .../reader/epubreader/EpubReaderScreen.kt | 80 +- .../reader/epubreader/EpubReaderSettings.kt | 45 +- .../reader/epubreader/InteractiveWebView.kt | 239 +- .../reader/feedback/FeedbackViewModel.kt | 22 +- .../com/aryan/reader/opds/OpdsViewModel.kt | 15 +- .../reader/paginatedreader/PaginatedReader.kt | 213 +- .../java/com/aryan/reader/pdf/PdfDrawer.kt | 342 +- .../java/com/aryan/reader/pdf/PdfHelper.kt | 36 +- .../com/aryan/reader/pdf/PdfPageComposable.kt | 199 +- .../com/aryan/reader/pdf/PdfPreferences.kt | 78 +- .../com/aryan/reader/pdf/PdfSettingsSheets.kt | 177 +- .../java/com/aryan/reader/pdf/PdfToolbars.kt | 4 +- .../com/aryan/reader/pdf/PdfViewerScreen.kt | 69 +- .../aryan/reader/tts/TtsPlaybackManager.kt | 15 +- .../java/com/aryan/reader/tts/TtsService.kt | 3 +- .../res/drawable-nodpi/format_align_right.xml | 11 + app/src/main/res/values-ar/strings.xml | 3 + app/src/main/res/values-de/strings.xml | 3 + app/src/main/res/values-es/plurals.xml | 44 +- app/src/main/res/values-es/strings.xml | 1073 +++- app/src/main/res/values-fr/strings.xml | 3 + app/src/main/res/values-ru/strings.xml | 3 + app/src/main/res/values-tr/strings.xml | 3 + app/src/main/res/values/strings.xml | 223 + app/src/main/res/xml/locales_config.xml | 3 +- .../com/aryan/reader/BillingClientWrapper.kt | 3 +- .../aryan/reader/AppLanguageOptionsTest.kt | 60 + .../EpubReaderBridgeAndControlsTest.kt | 25 + ...EpubReaderPreferencesAndAnnotationsTest.kt | 4 +- .../PaginatedHighlightMappingTest.kt | 34 +- .../PdfReaderSettingsAndSharedModelsTest.kt | 32 + .../aryan/reader/desktop/DesktopAppHost.kt | 532 ++ .../desktop/DesktopEpubBridgeParsing.kt | 335 ++ .../reader/desktop/DesktopEpubPagination.kt | 65 + .../reader/desktop/DesktopExternalLinks.kt | 158 + .../reader/desktop/DesktopFileDialogs.kt | 95 + .../reader/desktop/DesktopFileDropTarget.kt | 228 + .../aryan/reader/desktop/DesktopLibraryUi.kt | 506 ++ .../reader/desktop/DesktopLogFormatting.kt | 8 + .../reader/desktop/DesktopPdfAnnotationUi.kt | 441 ++ .../reader/desktop/DesktopPdfAppearance.kt | 202 + .../reader/desktop/DesktopPdfChromeUi.kt | 422 ++ .../reader/desktop/DesktopPdfNavigationUi.kt | 338 ++ .../desktop/DesktopPdfPageInteractions.kt | 328 + .../reader/desktop/DesktopPdfSelectionUi.kt | 503 ++ .../reader/desktop/DesktopPdfSidecars.kt | 83 + .../aryan/reader/desktop/DesktopPdfZoom.kt | 290 + .../desktop/DesktopReaderDiagnostics.kt | 68 + .../reader/desktop/DesktopReaderOpening.kt | 36 + .../reader/desktop/DesktopReaderPanels.kt | 535 ++ .../reader/desktop/DesktopReaderTextures.kt | 107 + .../reader/desktop/DesktopReaderTypography.kt | 42 + .../kotlin/com/aryan/reader/desktop/Main.kt | 5347 +---------------- .../reader/shared/ReaderAppearanceModels.kt | 2 + .../reader/ReaderHtmlDocumentBuilder.kt | 143 +- .../reader/shared/reader/ReaderModels.kt | 1 + .../shared/ui/SharedNativePaginatedReader.kt | 88 +- .../reader/shared/ui/SharedReaderChrome.kt | 7 + .../shared/ui/SharedSelectionMenuPlacement.kt | 182 + .../reader/shared/ReaderActionReducerTest.kt | 4 +- .../reader/ReaderHtmlDocumentBuilderTest.kt | 14 + .../ui/SharedSelectionMenuPlacementTest.kt | 99 + .../reader/SharedMeasuredEpubPaginator.kt | 1 + 77 files changed, 9184 insertions(+), 5947 deletions(-) create mode 100644 app/src/main/res/drawable-nodpi/format_align_right.xml create mode 100644 app/src/test/java/com/aryan/reader/AppLanguageOptionsTest.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAppHost.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubBridgeParsing.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubPagination.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopExternalLinks.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFileDialogs.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFileDropTarget.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryUi.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLogFormatting.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfAnnotationUi.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfAppearance.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfChromeUi.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfNavigationUi.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfPageInteractions.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSelectionUi.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSidecars.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfZoom.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderDiagnostics.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderOpening.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderPanels.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTextures.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTypography.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedSelectionMenuPlacement.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedSelectionMenuPlacementTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a4c5992..e8b9811 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,7 @@ @file:Suppress("UnstableApiUsage") import java.util.Properties +import javax.xml.parsers.DocumentBuilderFactory plugins { alias(libs.plugins.android.application) @@ -18,6 +19,27 @@ if (localPropertiesFile.exists()) { localPropertiesFile.inputStream().use { localProperties.load(it) } } +fun configuredAppLocaleTags(): Set { + val localesConfig = file("src/main/res/xml/locales_config.xml") + val androidNamespace = "http://schemas.android.com/apk/res/android" + val document = DocumentBuilderFactory.newInstance() + .apply { isNamespaceAware = true } + .newDocumentBuilder() + .parse(localesConfig) + val localeNodes = document.getElementsByTagName("locale") + + return buildSet { + for (index in 0 until localeNodes.length) { + val name = localeNodes.item(index) + .attributes + ?.getNamedItemNS(androidNamespace, "name") + ?.nodeValue + ?.takeIf { it.isNotBlank() } + if (name != null) add(name) + } + } +} + kotlin { jvmToolchain(21) } @@ -33,7 +55,7 @@ android { versionCode = 51 versionName = "1.0.47" - resourceConfigurations += setOf("en", "ar", "de", "tr", "fr", "ru") + resourceConfigurations += configuredAppLocaleTags() testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" externalNativeBuild { diff --git a/app/src/main/assets/epub_reader.js b/app/src/main/assets/epub_reader.js index 8c7d2e1..3b6f716 100644 --- a/app/src/main/assets/epub_reader.js +++ b/app/src/main/assets/epub_reader.js @@ -207,6 +207,131 @@ }; window.setTextSelectionEnabled(true); + installReaderSelectionBridge(); + } + + function installReaderSelectionBridge() { + if (window.__readerSelectionBridgeInstalled) { + return; + } + window.__readerSelectionBridgeInstalled = true; + + var pointerActive = false; + var lastPayloadJson = ""; + + function buildSelectionPayload() { + var selection = window.getSelection && window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return null; + } + + var selectedText = selection.toString().trim(); + if (!selectedText) { + return null; + } + + var range = selection.getRangeAt(0); + var viewportLeft = 0; + var viewportTop = 0; + var viewportRight = window.innerWidth || document.documentElement.clientWidth || 0; + var viewportBottom = window.innerHeight || document.documentElement.clientHeight || 0; + var rects = Array.prototype.slice.call(range.getClientRects ? range.getClientRects() : []); + rects = rects.filter(function (rect) { + if (!rect || rect.width <= 0 || rect.height <= 0) return false; + return rect.right >= viewportLeft && + rect.left <= viewportRight && + rect.bottom >= viewportTop && + rect.top <= viewportBottom; + }); + + var rect = null; + if (rects.length > 0) { + var firstRect = rects[0]; + rect = rects.reduce(function (acc, item) { + return { + left: Math.min(acc.left, item.left), + top: Math.min(acc.top, item.top), + right: Math.max(acc.right, item.right), + bottom: Math.max(acc.bottom, item.bottom) + }; + }, { + left: firstRect.left, + top: firstRect.top, + right: firstRect.right, + bottom: firstRect.bottom + }); + rect.width = rect.right - rect.left; + rect.height = rect.bottom - rect.top; + } else { + rect = range.getBoundingClientRect ? range.getBoundingClientRect() : null; + if (!rect || rect.width <= 0 || rect.height <= 0) { + return null; + } + } + + return { + text: selectedText, + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + width: rect.width, + height: rect.height + }; + } + + function reportSelectionToBridge() { + if (pointerActive) { + return; + } + if (!window.ReaderSelectionBridge || !window.ReaderSelectionBridge.onSelectionChanged) { + return; + } + + var payload = buildSelectionPayload(); + if (!payload) { + lastPayloadJson = ""; + return; + } + + var payloadJson = JSON.stringify(payload); + if (payloadJson === lastPayloadJson) { + return; + } + lastPayloadJson = payloadJson; + window.ReaderSelectionBridge.onSelectionChanged(payloadJson); + } + + function scheduleSelectionReport(delay) { + setTimeout(reportSelectionToBridge, delay == null ? 160 : delay); + } + + function markPointerActive() { + pointerActive = true; + } + + function markPointerReleased() { + pointerActive = false; + scheduleSelectionReport(80); + scheduleSelectionReport(220); + scheduleSelectionReport(520); + } + + document.addEventListener("selectionchange", function () { + if (!pointerActive) { + scheduleSelectionReport(160); + } + }); + document.addEventListener("pointerdown", markPointerActive, true); + document.addEventListener("pointerup", markPointerReleased, true); + document.addEventListener("pointercancel", markPointerReleased, true); + document.addEventListener("touchstart", markPointerActive, true); + document.addEventListener("touchend", markPointerReleased, true); + document.addEventListener("touchcancel", markPointerReleased, true); + document.addEventListener("mousedown", markPointerActive, true); + document.addEventListener("mouseup", markPointerReleased, true); + document.addEventListener("keyup", function () { scheduleSelectionReport(80); }); + window.addEventListener("scroll", function () { scheduleSelectionReport(80); }, false); } window.VIEWPORT_PADDING_TOP = 0; @@ -766,6 +891,8 @@ var alignSelector = "body, p, li, div, h1, h2, h3, h4, h5, h6"; if (textAlign === "left") { alignCss = alignSelector + " { text-align: left !important; }"; + } else if (textAlign === "right") { + alignCss = alignSelector + " { text-align: right !important; }"; } else if (textAlign === "justify") { alignCss = alignSelector + " { text-align: justify !important; -webkit-hyphens: auto !important; hyphens: auto !important; }"; } diff --git a/app/src/main/java/com/aryan/reader/AiSettingsScreen.kt b/app/src/main/java/com/aryan/reader/AiSettingsScreen.kt index 7e2cb11..ecf9c61 100644 --- a/app/src/main/java/com/aryan/reader/AiSettingsScreen.kt +++ b/app/src/main/java/com/aryan/reader/AiSettingsScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -53,6 +54,10 @@ fun AiSettingsScreen( var pendingKey by remember { mutableStateOf("") } var showSaveConfirm by remember { mutableStateOf(false) } var providerToDelete by remember { mutableStateOf(null) } + val providerLabels = mapOf( + "gemini" to stringResource(R.string.provider_gemini), + "groq" to stringResource(R.string.provider_groq), + ) fun refresh() { settings = loadAiByokSettings(context) @@ -67,10 +72,10 @@ fun AiSettingsScreen( modifier = Modifier.statusBarsPadding(), topBar = { CustomTopAppBar( - title = { Text("AI keys and models") }, + title = { Text(stringResource(R.string.ai_settings_title)) }, navigationIcon = { IconButton(onClick = onBackClick) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) } } ) @@ -84,23 +89,23 @@ fun AiSettingsScreen( .padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text("Saved keys", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - SavedKeyRow("Gemini", maskedAiByokKey(context, "gemini"), onDelete = { providerToDelete = "gemini" }) - SavedKeyRow("Groq", maskedAiByokKey(context, "groq"), onDelete = { providerToDelete = "groq" }) + Text(stringResource(R.string.ai_settings_saved_keys), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + SavedKeyRow(providerLabels.getValue("gemini"), maskedAiByokKey(context, "gemini"), onDelete = { providerToDelete = "gemini" }) + SavedKeyRow(providerLabels.getValue("groq"), maskedAiByokKey(context, "groq"), onDelete = { providerToDelete = "groq" }) HorizontalDivider() - Text("Add or replace key", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text(stringResource(R.string.ai_settings_add_or_replace_key), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) ExposedDropdownMenuBox( expanded = providerMenuExpanded, onExpandedChange = { providerMenuExpanded = it }, modifier = Modifier.fillMaxWidth() ) { OutlinedTextField( - value = selectedProvider.replaceFirstChar { it.titlecase() }, + value = providerLabels[selectedProvider].orEmpty(), onValueChange = {}, readOnly = true, - label = { Text("Provider") }, + label = { Text(stringResource(R.string.label_provider)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = providerMenuExpanded) }, modifier = Modifier.fillMaxWidth().menuAnchor() ) @@ -110,7 +115,7 @@ fun AiSettingsScreen( ) { listOf("gemini", "groq").forEach { provider -> DropdownMenuItem( - text = { Text(provider.replaceFirstChar { it.titlecase() }) }, + text = { Text(providerLabels[provider].orEmpty()) }, onClick = { selectedProvider = provider providerMenuExpanded = false @@ -125,7 +130,7 @@ fun AiSettingsScreen( OutlinedTextField( value = pendingKey, onValueChange = { pendingKey = it }, - label = { Text("API key") }, + label = { Text(stringResource(R.string.label_api_key)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth() @@ -135,7 +140,7 @@ fun AiSettingsScreen( enabled = pendingKey.isNotBlank(), modifier = Modifier.align(Alignment.End) ) { - Text("Save key") + Text(stringResource(R.string.ai_settings_save_key)) } HorizontalDivider() @@ -146,9 +151,9 @@ fun AiSettingsScreen( horizontalArrangement = Arrangement.SpaceBetween ) { Column(modifier = Modifier.weight(1f)) { - Text("Use one model for all features", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.ai_settings_use_one_model), style = MaterialTheme.typography.titleMedium) Text( - "When off, each reader AI feature uses its own selected model.", + stringResource(R.string.ai_settings_use_one_model_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -161,35 +166,35 @@ fun AiSettingsScreen( if (settings.useOneModel) { ModelSelector( - title = "All AI features", - description = "Smart dictionary, summaries, and recaps all use this model.", + title = stringResource(R.string.ai_settings_all_features), + description = stringResource(R.string.ai_settings_all_features_desc), selectedId = settings.modelForAll, onSelected = { updateModels(settings.copy(modelForAll = it)) } ) } else { ModelSelector( - title = "Smart dictionary", - description = "Used when defining selected words or phrases.", + title = stringResource(R.string.ai_settings_smart_dictionary), + description = stringResource(R.string.ai_settings_smart_dictionary_desc), selectedId = settings.defineModel, onSelected = { updateModels(settings.copy(defineModel = it)) } ) ModelSelector( - title = "Summaries", - description = "Used for EPUB summaries and PDF page summaries. PDF/image summaries need Gemini.", + title = stringResource(R.string.ai_settings_summaries), + description = stringResource(R.string.ai_settings_summaries_desc), selectedId = settings.summarizeModel, onSelected = { updateModels(settings.copy(summarizeModel = it)) } ) ModelSelector( - title = "Recaps", - description = "Used for story recap generation.", + title = stringResource(R.string.ai_settings_recaps), + description = stringResource(R.string.ai_settings_recaps_desc), selectedId = settings.recapModel, onSelected = { updateModels(settings.copy(recapModel = it)) } ) } ModelSelector( - title = "Cloud TTS", - description = "Uses the saved Gemini key. Only $GEMINI_CLOUD_TTS_MODEL is supported for now.", + title = stringResource(R.string.credits_cloud_tts_title), + description = stringResource(R.string.ai_settings_cloud_tts_desc, GEMINI_CLOUD_TTS_MODEL), selectedId = settings.ttsModel, options = listOf(AiModelOption("gemini", GEMINI_CLOUD_TTS_MODEL)), onSelected = { updateModels(settings.copy(ttsModel = it)) } @@ -198,38 +203,40 @@ fun AiSettingsScreen( } if (showSaveConfirm) { + val providerLabel = providerLabels[selectedProvider].orEmpty() AlertDialog( onDismissRequest = { showSaveConfirm = false }, - title = { Text("Save ${selectedProvider.replaceFirstChar { it.titlecase() }} key?") }, - text = { Text("After saving, only the first 3 and last 3 characters will be visible. To change it later, replace or delete it.") }, + title = { Text(stringResource(R.string.dialog_save_provider_key, providerLabel)) }, + text = { Text(stringResource(R.string.dialog_save_key_desc)) }, confirmButton = { TextButton(onClick = { saveAiByokKey(context, selectedProvider, pendingKey) pendingKey = "" showSaveConfirm = false refresh() - }) { Text("Save") } + }) { Text(stringResource(R.string.action_save)) } }, dismissButton = { - TextButton(onClick = { showSaveConfirm = false }) { Text("Cancel") } + TextButton(onClick = { showSaveConfirm = false }) { Text(stringResource(R.string.action_cancel)) } } ) } providerToDelete?.let { provider -> + val providerLabel = providerLabels[provider].orEmpty() AlertDialog( onDismissRequest = { providerToDelete = null }, - title = { Text("Delete ${provider.replaceFirstChar { it.titlecase() }} key?") }, - text = { Text("Features using this provider will stop working until a new key is saved.") }, + title = { Text(stringResource(R.string.dialog_delete_provider_key, providerLabel)) }, + text = { Text(stringResource(R.string.dialog_delete_key_desc)) }, confirmButton = { TextButton(onClick = { deleteAiByokKey(context, provider) providerToDelete = null refresh() - }) { Text("Delete") } + }) { Text(stringResource(R.string.action_delete)) } }, dismissButton = { - TextButton(onClick = { providerToDelete = null }) { Text("Cancel") } + TextButton(onClick = { providerToDelete = null }) { Text(stringResource(R.string.action_cancel)) } } ) } @@ -241,14 +248,15 @@ private fun SavedKeyRow( maskedKey: String, onDelete: () -> Unit ) { + val noKeySaved = stringResource(R.string.ai_settings_no_key_saved) ListItem( headlineContent = { Text(label) }, supportingContent = { - Text(maskedKey.ifBlank { "No key saved" }) + Text(maskedKey.ifBlank { noKeySaved }) }, trailingContent = { IconButton(onClick = onDelete, enabled = maskedKey.isNotBlank()) { - Icon(Icons.Default.Delete, contentDescription = "Delete $label key") + Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.content_desc_delete_provider_key, label)) } } ) @@ -275,10 +283,10 @@ private fun ModelSelector( modifier = Modifier.fillMaxWidth() ) { OutlinedTextField( - value = selected?.label ?: "No model selected", + value = selected?.label ?: stringResource(R.string.ai_settings_no_model_selected), onValueChange = {}, readOnly = true, - label = { Text("Model") }, + label = { Text(stringResource(R.string.label_model)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, modifier = Modifier.fillMaxWidth().menuAnchor() ) @@ -287,7 +295,7 @@ private fun ModelSelector( onDismissRequest = { expanded = false } ) { DropdownMenuItem( - text = { Text("No model selected") }, + text = { Text(stringResource(R.string.ai_settings_no_model_selected)) }, onClick = { onSelected("") expanded = false diff --git a/app/src/main/java/com/aryan/reader/Common.kt b/app/src/main/java/com/aryan/reader/Common.kt index df292c2..ce71fcc 100644 --- a/app/src/main/java/com/aryan/reader/Common.kt +++ b/app/src/main/java/com/aryan/reader/Common.kt @@ -239,11 +239,19 @@ const val GEMINI_CLOUD_TTS_MODEL_ID = "gemini:$GEMINI_CLOUD_TTS_MODEL" enum class AiFeature { DEFINE, SUMMARIZE, RECAP } -private fun AiFeature.displayName(): String { +private fun AiFeature.displayName(context: Context): String { return when (this) { - AiFeature.DEFINE -> "Smart dictionary" - AiFeature.SUMMARIZE -> "Summaries" - AiFeature.RECAP -> "Recaps" + AiFeature.DEFINE -> context.getString(R.string.ai_settings_smart_dictionary) + AiFeature.SUMMARIZE -> context.getString(R.string.ai_settings_summaries) + AiFeature.RECAP -> context.getString(R.string.ai_settings_recaps) + } +} + +private fun aiProviderDisplayName(context: Context, provider: String): String { + return when (provider) { + "gemini" -> context.getString(R.string.provider_gemini) + "groq" -> context.getString(R.string.provider_groq) + else -> provider.replaceFirstChar { it.titlecase(Locale.ROOT) } } } @@ -478,7 +486,8 @@ data class CachedSummaryItem( ) class SummaryCacheManager(context: Context) { - private val cacheDir = File(context.cacheDir, "chapter_summaries") + private val appContext = context.applicationContext + private val cacheDir = File(appContext.cacheDir, "chapter_summaries") init { if (!cacheDir.exists()) { @@ -529,7 +538,7 @@ class SummaryCacheManager(context: Context) { val fullText = file.readText() val lines = fullText.lines() - val title = lines.firstOrNull()?.trim() ?: "Chapter ${index + 1}" + val title = lines.firstOrNull()?.trim() ?: appContext.getString(R.string.chapter_number_format, index + 1) val summaryText = if (lines.size > 1) lines.drop(1).joinToString("\n") else "" Timber.d("Cache Load: Ch $index, Title: $title") @@ -914,6 +923,7 @@ fun AiDefinitionPopup( } val textToUse = styledContent.text + val aiDefinitionTitle = stringResource(R.string.ai_definition_title) if (textToUse.isNotBlank()) { Row( @@ -937,7 +947,7 @@ fun AiDefinitionPopup( val token = getAuthToken() ttsController.start( chunks = chunks, - bookTitle = "AI Definition", + bookTitle = aiDefinitionTitle, chapterTitle = word, coverImageUri = null, ttsMode = loadTtsMode(context), @@ -1178,8 +1188,8 @@ suspend fun fetchAiDefinition( } } else { val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null } - val errorDetail = try { errorBody?.let { JSONObject(it).getString("detail") } } catch (_: Exception) { "Could not get definition." } - onError("${responseCode}. ${errorDetail ?: context.getString(R.string.error_unknown_server)}") + val errorDetail = try { errorBody?.let { JSONObject(it).getString("detail") } } catch (_: Exception) { context.getString(R.string.error_could_not_get_definition) } + onError(context.getString(R.string.error_response_code_with_detail, responseCode, errorDetail ?: context.getString(R.string.error_unknown_server))) } } catch (e: Exception) { Timber.e(e, "Network error fetching AI definition: ${e.message}") @@ -1198,7 +1208,8 @@ fun countWords(text: String): Int { private fun streamGeminiAiResponse( connection: HttpURLConnection, onUpdate: (String) -> Unit, - onError: (String) -> Unit + onError: (String) -> Unit, + safetyError: String ): Boolean { var hasReceivedData = false connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader -> @@ -1243,7 +1254,7 @@ private fun streamGeminiAiResponse( hasReceivedData = true } if (jsonResponse.optJSONArray("candidates")?.optJSONObject(0)?.optString("finishReason") == "SAFETY") { - onError("Blocked for safety reasons.") + onError(safetyError) } } catch (e: Exception) { Timber.w(e, "Could not parse Gemini BYOK stream object") @@ -1363,12 +1374,12 @@ suspend fun callByokTextAi( val settings = loadAiByokSettings(context) val model = aiModelById(settings.modelIdFor(feature)) if (model == null) { - onError("Choose a model for ${feature.displayName()} in AI key and model settings.") + onError(context.getString(R.string.ai_error_choose_model, feature.displayName(context))) return@withContext false } val apiKey = settings.apiKeyFor(model.provider) if (apiKey.isBlank()) { - onError("Add a ${model.provider.replaceFirstChar { it.titlecase(Locale.ROOT) }} API key in AI key and model settings.") + onError(context.getString(R.string.ai_error_add_provider_key, aiProviderDisplayName(context, model.provider))) return@withContext false } @@ -1422,13 +1433,13 @@ suspend fun callByokTextAi( val hasData = if (model.provider == "groq") { streamGroqAiResponse(connection, onUpdate, onError) } else { - streamGeminiAiResponse(connection, onUpdate, onError) + streamGeminiAiResponse(connection, onUpdate, onError, context.getString(R.string.ai_error_blocked_safety)) } - if (!hasData) onError("The AI provider returned an empty response.") + if (!hasData) onError(context.getString(R.string.ai_error_provider_empty_response)) hasData } else { val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null } - onError("AI provider error: $responseCode. ${errorBody.orEmpty().take(300)}") + onError(context.getString(R.string.ai_error_provider_error, responseCode, errorBody.orEmpty().take(300))) false } } catch (e: Exception) { @@ -1454,16 +1465,16 @@ suspend fun callByokGeminiInlineAi( val settings = loadAiByokSettings(context) val model = aiModelById(settings.modelIdFor(feature)) if (model == null) { - onError("Choose a model for ${feature.displayName()} in AI key and model settings.") + onError(context.getString(R.string.ai_error_choose_model, feature.displayName(context))) return@withContext false } if (model.provider != "gemini") { - onError("This summary needs a Gemini model because the selected Groq models do not support PDF/image input.") + onError(context.getString(R.string.ai_error_gemini_required_for_image_summary)) return@withContext false } val apiKey = settings.geminiKey.trim() if (apiKey.isBlank()) { - onError("Add a Gemini API key in AI key and model settings.") + onError(context.getString(R.string.ai_error_add_provider_key, context.getString(R.string.provider_gemini))) return@withContext false } @@ -1502,12 +1513,12 @@ suspend fun callByokGeminiInlineAi( connection.outputStream.use { it.write(payload.toString().toByteArray(Charsets.UTF_8)) } val responseCode = connection.responseCode if (responseCode == HttpURLConnection.HTTP_OK) { - val hasData = streamGeminiAiResponse(connection, onUpdate, onError) - if (!hasData) onError("The AI provider returned an empty response.") + val hasData = streamGeminiAiResponse(connection, onUpdate, onError, context.getString(R.string.ai_error_blocked_safety)) + if (!hasData) onError(context.getString(R.string.ai_error_provider_empty_response)) hasData } else { val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null } - onError("AI provider error: $responseCode. ${errorBody.orEmpty().take(300)}") + onError(context.getString(R.string.ai_error_provider_error, responseCode, errorBody.orEmpty().take(300))) false } } catch (e: Exception) { @@ -1692,7 +1703,7 @@ suspend fun fetchRecap( if (!hasReceivedData) onError(context.getString(R.string.error_parse_recap)) } else { val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null } - onError("${responseCode}. ${errorBody ?: ""}") + onError(context.getString(R.string.error_response_code_with_detail, responseCode, errorBody.orEmpty())) } } catch (e: Exception) { Timber.e(e, "Recap error: ${e.message}") @@ -2824,10 +2835,10 @@ fun ReaderThemePanel( TabRow(selectedTabIndex = selectedTabIndex, containerColor = Color.Transparent, divider = {}) { Tab(selected = selectedTabIndex == 0, onClick = { selectedTabIndex = 0 }) { - Text("Solid Colors", modifier = Modifier.padding(12.dp)) + Text(stringResource(R.string.theme_solid_colors), modifier = Modifier.padding(12.dp)) } Tab(selected = selectedTabIndex == 1, onClick = { selectedTabIndex = 1 }) { - Text("Textured", modifier = Modifier.padding(12.dp)) + Text(stringResource(R.string.theme_textured), modifier = Modifier.padding(12.dp)) } } @@ -2836,7 +2847,7 @@ fun ReaderThemePanel( if (selectedTabIndex == 1) { Column(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text("Texture Transparency", style = MaterialTheme.typography.labelMedium) + Text(stringResource(R.string.theme_texture_transparency), style = MaterialTheme.typography.labelMedium) Text("${(globalTextureTransparency * 100).roundToInt()}%", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) } Slider( @@ -2894,7 +2905,7 @@ fun ReaderThemePanel( Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Text(stringResource(R.string.theme_my_themes), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) IconButton(onClick = { editingTheme = null; builderIsTextured = selectedTabIndex == 1; showBuilder = true }) { - Icon(Icons.Default.Add, contentDescription = "New Theme", tint = MaterialTheme.colorScheme.primary) + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.theme_new), tint = MaterialTheme.colorScheme.primary) } } } @@ -2958,7 +2969,7 @@ private fun ThemeGridItem( .clickable { onThemeSelected(theme.id) }, contentAlignment = Alignment.Center ) { - Text(text = "Aa", color = textColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) + Text(text = stringResource(R.string.label_aa_preview), color = textColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) } Spacer(modifier = Modifier.height(8.dp)) Text(text = theme.name, style = MaterialTheme.typography.labelSmall, color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis) @@ -2967,9 +2978,9 @@ private fun ThemeGridItem( Spacer(modifier = Modifier.height(6.dp)) Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surfaceVariant) { Row(modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Default.Edit, "Edit", Modifier.size(28.dp).clip(CircleShape).clickable { onEdit(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.primary) + Icon(Icons.Default.Edit, stringResource(R.string.action_edit), Modifier.size(28.dp).clip(CircleShape).clickable { onEdit(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.primary) Spacer(Modifier.width(4.dp)) - Icon(Icons.Default.Delete, "Delete", Modifier.size(28.dp).clip(CircleShape).clickable { onDelete(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.error) + Icon(Icons.Default.Delete, stringResource(R.string.action_delete), Modifier.size(28.dp).clip(CircleShape).clickable { onDelete(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.error) } } } @@ -2985,7 +2996,8 @@ fun ThemeBuilderView( onCancel: () -> Unit ) { val context = LocalContext.current - var name by remember { mutableStateOf(initialTheme?.name ?: if (isTexturedMode) "Custom Textured" else "Custom Solid") } + val defaultThemeName = stringResource(if (isTexturedMode) R.string.theme_custom_textured_default else R.string.theme_custom_solid_default) + var name by remember(initialTheme?.id, isTexturedMode, defaultThemeName) { mutableStateOf(initialTheme?.name ?: defaultThemeName) } var bgColor by remember { mutableStateOf(initialTheme?.backgroundColor ?: Color(0xFFF5F5F5)) } var txtColor by remember { mutableStateOf(initialTheme?.textColor ?: Color(0xFF111111)) } var editingColorType by remember { mutableStateOf(null) } @@ -3106,7 +3118,7 @@ private fun CustomTexturePickerSection( onImportTexture: () -> Unit ) { Column(modifier = Modifier.fillMaxWidth()) { - Text("Select Custom Texture", style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(bottom = 8.dp)) + Text(stringResource(R.string.theme_select_custom_texture), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(bottom = 8.dp)) LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { item { @@ -3118,8 +3130,8 @@ private fun CustomTexturePickerSection( border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) ) { Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { - Icon(Icons.Default.Add, contentDescription = "Import", tint = MaterialTheme.colorScheme.primary) - Text("Import", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary) + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.action_import), tint = MaterialTheme.colorScheme.primary) + Text(stringResource(R.string.action_import), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary) } } } diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt index b3aeb74..bdc5511 100644 --- a/app/src/main/java/com/aryan/reader/HomeScreen.kt +++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt @@ -1049,7 +1049,7 @@ fun DefaultTopAppBar( } }, actions = { IconButton(onClick = onSettingsClick) { - Icon(Icons.Default.Settings, contentDescription = "Settings") + Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings)) } Box { IconButton(onClick = onAppThemeClick) { @@ -1139,7 +1139,7 @@ fun DefaultTopAppBar( if (!BuildConfig.IS_OFFLINE) { DropdownMenuItem( - text = { Text(if (hideReaderAiFeatures) "Show AI in reader" else "Hide AI in reader") }, + text = { Text(stringResource(if (hideReaderAiFeatures) R.string.options_show_ai_in_reader else R.string.options_hide_ai_in_reader)) }, onClick = { onToggleHideReaderAi() hideReaderAiFeatures = !hideReaderAiFeatures @@ -1374,7 +1374,7 @@ private fun AppDrawerContent( NavigationDrawerItem( icon = { Icon(Icons.Default.Settings, contentDescription = null) }, - label = { Text("Settings") }, + label = { Text(stringResource(R.string.settings)) }, selected = false, onClick = onSettingsClick, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) @@ -1391,7 +1391,7 @@ private fun AppDrawerContent( if (isOss && !BuildConfig.IS_OFFLINE) { NavigationDrawerItem( icon = { Icon(painterResource(id = R.drawable.ai), contentDescription = null) }, - label = { Text("AI keys and models") }, + label = { Text(stringResource(R.string.ai_settings_title)) }, selected = false, onClick = onAiSettingsClick, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) @@ -1855,7 +1855,7 @@ fun AppThemeBottomSheet( Spacer(Modifier.height(24.dp)) if (uiState.appThemeMode == AppThemeMode.SYSTEM) { - Text("${stringResource(R.string.app_theme_text_brightness)} (Light)", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + Text(stringResource(R.string.app_theme_text_brightness_light), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) Spacer(Modifier.height(8.dp)) Row( modifier = Modifier @@ -1877,7 +1877,7 @@ fun AppThemeBottomSheet( Spacer(Modifier.height(16.dp)) - Text("${stringResource(R.string.app_theme_text_brightness)} (Dark)", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + Text(stringResource(R.string.app_theme_text_brightness_dark), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) Spacer(Modifier.height(8.dp)) Row( modifier = Modifier @@ -2184,38 +2184,30 @@ fun CreateAppThemeDialog( @Composable fun LanguageSelectionDialog(onDismiss: () -> Unit) { val currentLocales = AppCompatDelegate.getApplicationLocales() - val currentTag = if (!currentLocales.isEmpty) currentLocales.get(0)?.language ?: "en" else "en" - - val languages = listOf( - "en" to R.string.language_english_default, - "ar" to R.string.language_arabic, - "de" to R.string.language_german, - "tr" to R.string.language_turkish, - "fr" to R.string.language_french, - "ru" to R.string.language_russian - ) + val currentTag = if (!currentLocales.isEmpty) currentLocales.get(0)?.toLanguageTag() else null AlertDialog( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.options_language)) }, text = { Column { - languages.forEach { (tag, nameRes) -> + appLanguageSelectionOptions.forEach { language -> Row( modifier = Modifier .fillMaxWidth() .clickable { - AppCompatDelegate.setApplicationLocales( + val locales = language.tag?.let { tag -> LocaleListCompat.forLanguageTags(tag) - ) + } ?: LocaleListCompat.getEmptyLocaleList() + AppCompatDelegate.setApplicationLocales(locales) onDismiss() } .padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { - RadioButton(selected = currentTag == tag, onClick = null) + RadioButton(selected = currentTag == language.tag, onClick = null) Spacer(modifier = Modifier.width(16.dp)) - Text(stringResource(nameRes)) + Text(stringResource(language.labelRes)) } } } diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt index 5559eff..47b7856 100644 --- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt +++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt @@ -723,7 +723,7 @@ fun LibraryScreenContent( } } IconButton(onClick = onSettingsClick) { - Icon(Icons.Default.Settings, contentDescription = "Settings") + Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings)) } } ) diff --git a/app/src/main/java/com/aryan/reader/SettingsScreen.kt b/app/src/main/java/com/aryan/reader/SettingsScreen.kt index 991530a..70dcd58 100644 --- a/app/src/main/java/com/aryan/reader/SettingsScreen.kt +++ b/app/src/main/java/com/aryan/reader/SettingsScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.util.UnstableApi import androidx.navigation.NavHostController @@ -156,7 +157,7 @@ fun SettingsScreen( title = { Text(settingsPage.title) }, navigationIcon = { IconButton(onClick = ::navigateBackFromSettings) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) } } ) @@ -242,11 +243,11 @@ fun SettingsScreen( SharedSettingsAction.TEST_PANEL_DETECTION -> viewModel.testPanelDetection(context) SharedSettingsAction.TEST_SPEECH_BUBBLE_DETECTION -> viewModel.testSpeechBubbleDetection(context) SharedSettingsAction.EXPORT_LOGS -> viewModel.exportLogsToFile(context) - SharedSettingsAction.DEBUG_ACTIONS -> viewModel.showBanner("Debug actions remain in their existing menus.") + SharedSettingsAction.DEBUG_ACTIONS -> viewModel.showBanner(context.getString(R.string.debug_actions_existing_menus)) SharedSettingsAction.HELP_FEEDBACK -> navController.navigate(AppDestinations.FEEDBACK_SCREEN_ROUTE) SharedSettingsAction.SUPPORT -> navController.navigate(AppDestinations.SUPPORT_PROJECT_SCREEN_ROUTE) SharedSettingsAction.ABOUT -> showAboutDialog = true - SharedSettingsAction.PDF_READER_DEFAULTS -> viewModel.showBanner("PDF-specific OCR, annotation, and tool settings remain in the PDF reader.") + SharedSettingsAction.PDF_READER_DEFAULTS -> viewModel.showBanner(context.getString(R.string.pdf_specific_settings_existing_reader)) SharedSettingsAction.TEXT_READER_DEFAULTS, SharedSettingsAction.READER_TOOLBAR, SharedSettingsAction.TTS_REPLACEMENTS, @@ -374,7 +375,7 @@ fun SettingsScreen( onSpeakerChange = viewModel.ttsController::changeSpeaker, isTtsActive = ttsState.isPlaying, getAuthToken = { viewModel.getAuthToken() }, - bookTitle = "Reader defaults" + bookTitle = context.getString(R.string.reader_defaults) ) } @@ -395,19 +396,23 @@ private fun RecentLimitDialog( ) { AlertDialog( onDismissRequest = onDismiss, - title = { Text("Recent files limit") }, + title = { Text(stringResource(R.string.options_recent_limit)) }, text = { androidx.compose.foundation.layout.Column { listOf(0, 10, 20, 50, 100).forEach { limit -> TextButton(onClick = { onSelect(limit) }) { - val label = if (limit == 0) "No limit" else "$limit files" - Text(if (currentLimit == limit) "$label selected" else label) + val label = if (limit == 0) { + stringResource(R.string.options_no_limit) + } else { + stringResource(R.string.options_files_limit, limit) + } + Text(if (currentLimit == limit) stringResource(R.string.option_selected_format, label) else label) } } } }, confirmButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } + TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } } ) } @@ -522,6 +527,7 @@ private fun AndroidFormatSettings.toSharedFontFamilyName(): String { private fun AndroidReaderTextAlign.toSharedReaderTextAlign(): SharedReaderTextAlign { return when (this) { AndroidReaderTextAlign.JUSTIFY -> SharedReaderTextAlign.JUSTIFY + AndroidReaderTextAlign.RIGHT -> SharedReaderTextAlign.RIGHT AndroidReaderTextAlign.DEFAULT, AndroidReaderTextAlign.LEFT -> SharedReaderTextAlign.START } @@ -539,6 +545,7 @@ private fun ReaderSettings.toAndroidReaderFont(): AndroidReaderFont { private fun SharedReaderTextAlign.toAndroidTextAlign(): AndroidReaderTextAlign { return when (this) { SharedReaderTextAlign.JUSTIFY -> AndroidReaderTextAlign.JUSTIFY + SharedReaderTextAlign.RIGHT -> AndroidReaderTextAlign.RIGHT SharedReaderTextAlign.CENTER, SharedReaderTextAlign.START -> AndroidReaderTextAlign.LEFT } diff --git a/app/src/main/java/com/aryan/reader/SharedComposables.kt b/app/src/main/java/com/aryan/reader/SharedComposables.kt index 961b42d..878bdf8 100644 --- a/app/src/main/java/com/aryan/reader/SharedComposables.kt +++ b/app/src/main/java/com/aryan/reader/SharedComposables.kt @@ -550,10 +550,10 @@ fun FileInfoDialog( AlertDialog( onDismissRequest = { showRestoreConfirmation = false }, icon = { Icon(Icons.Default.Restore, contentDescription = null) }, - title = { Text("Restore original metadata?") }, + title = { Text(stringResource(R.string.dialog_restore_original_metadata)) }, text = { Text( - "This will write the original title, author, series, and summary back into the EPUB file. Reading progress, tags, and notes will not change." + stringResource(R.string.dialog_restore_original_metadata_desc) ) }, confirmButton = { @@ -564,7 +564,7 @@ fun FileInfoDialog( onDismiss() } ) { - Text("Restore") + Text(stringResource(R.string.action_restore)) } }, dismissButton = { @@ -642,10 +642,10 @@ private fun BookMetadataInfoContent( ) } val provenance = when { - item.type == FileType.EPUB && hasMetadataChanges -> "EPUB metadata edited" - item.type == FileType.EPUB -> "Metadata from EPUB file" - !item.customName.isNullOrBlank() -> "Display name changed in app" - else -> "Metadata from file" + item.type == FileType.EPUB && hasMetadataChanges -> stringResource(R.string.metadata_provenance_epub_edited) + item.type == FileType.EPUB -> stringResource(R.string.metadata_provenance_from_epub) + !item.customName.isNullOrBlank() -> stringResource(R.string.metadata_provenance_display_name_changed) + else -> stringResource(R.string.metadata_provenance_from_file) } Text( provenance, @@ -655,23 +655,23 @@ private fun BookMetadataInfoContent( } } - FileInfoSection(title = "Metadata") { - InfoRowDetailed("Title", item.title?.takeIf { it.isNotBlank() } ?: item.displayName, maxLines = 3) + FileInfoSection(title = stringResource(R.string.section_metadata)) { + InfoRowDetailed(stringResource(R.string.label_title), item.title?.takeIf { it.isNotBlank() } ?: item.displayName, maxLines = 3) item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }?.let { InfoRowDetailed(stringResource(R.string.author), it, maxLines = 2) } item.seriesLabel()?.let { - InfoRowDetailed("Series", it, maxLines = 2) + InfoRowDetailed(stringResource(R.string.label_series), it, maxLines = 2) } InfoRowDetailed(stringResource(R.string.format), item.type.name) InfoRowDetailed(stringResource(R.string.size), formatFileSize(item.fileSize)) - InfoRowDetailed("Reading", item.readingProgressText(), maxLines = 2) + InfoRowDetailed(stringResource(R.string.label_reading), item.readingProgressText(), maxLines = 2) } - FileInfoSection(title = "File") { - InfoRowDetailed("File name", item.displayName, maxLines = 2) + FileInfoSection(title = stringResource(R.string.section_file)) { + InfoRowDetailed(stringResource(R.string.label_file_name_simple), item.displayName, maxLines = 2) InfoRowDetailed(stringResource(R.string.added), formattedDate) - lastModifiedDate?.let { InfoRowDetailed("Modified", it) } + lastModifiedDate?.let { InfoRowDetailed(stringResource(R.string.label_modified), it) } InfoRowDetailed( label = stringResource(R.string.location), value = pathText, @@ -686,7 +686,7 @@ private fun BookMetadataInfoContent( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text("Summary", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text(stringResource(R.string.label_summary), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) ExpandableSummaryText(summary, collapsedMaxLines = 4) } } @@ -698,7 +698,7 @@ private fun BookMetadataInfoContent( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text("Library tags", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(stringResource(R.string.label_library_tags), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) TextButton(onClick = onOpenTags) { Text(stringResource(R.string.action_add_edit)) } } @@ -732,11 +732,11 @@ private fun BookMetadataEditContent( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Text("Editable metadata", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text(stringResource(R.string.label_editable_metadata), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) OutlinedTextField( value = titleInput, onValueChange = onTitleChange, - label = { Text("Title") }, + label = { Text(stringResource(R.string.label_title)) }, modifier = Modifier.fillMaxWidth(), maxLines = 3 ) @@ -751,7 +751,7 @@ private fun BookMetadataEditContent( OutlinedTextField( value = seriesInput, onValueChange = onSeriesChange, - label = { Text("Series") }, + label = { Text(stringResource(R.string.label_series)) }, modifier = Modifier.weight(1f), maxLines = 2 ) @@ -767,7 +767,7 @@ private fun BookMetadataEditContent( OutlinedTextField( value = descriptionInput, onValueChange = onDescriptionChange, - label = { Text("Summary") }, + label = { Text(stringResource(R.string.label_summary)) }, modifier = Modifier .fillMaxWidth() .heightIn(min = 128.dp), @@ -789,16 +789,16 @@ private fun BookDisplayNameEditContent( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Text("Display name", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text(stringResource(R.string.label_display_name), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) OutlinedTextField( value = displayNameInput, onValueChange = onDisplayNameChange, - label = { Text("Name shown in Reader") }, + label = { Text(stringResource(R.string.label_name_shown_in_reader)) }, modifier = Modifier.fillMaxWidth(), maxLines = 3 ) Text( - "Original file: $originalFileName", + stringResource(R.string.original_file_format, originalFileName), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, @@ -833,7 +833,7 @@ private fun FileInfoBottomBar( ) { Icon(Icons.Default.Restore, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(modifier = Modifier.width(8.dp)) - Text("Restore") + Text(stringResource(R.string.action_restore)) } } TextButton(onClick = onCancel) { diff --git a/app/src/main/java/com/aryan/reader/TtsWordReplacementsSheet.kt b/app/src/main/java/com/aryan/reader/TtsWordReplacementsSheet.kt index e415335..75d2c7e 100644 --- a/app/src/main/java/com/aryan/reader/TtsWordReplacementsSheet.kt +++ b/app/src/main/java/com/aryan/reader/TtsWordReplacementsSheet.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions +import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check @@ -49,6 +50,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType @@ -104,12 +106,12 @@ fun TtsWordReplacementsSheet( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = "TTS Word Replacements", + text = stringResource(R.string.menu_tts_word_replacements), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, ) Text( - text = bookTitle?.takeIf { it.isNotBlank() } ?: "Current book", + text = bookTitle?.takeIf { it.isNotBlank() } ?: stringResource(R.string.tts_replacements_current_book), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -117,7 +119,7 @@ fun TtsWordReplacementsSheet( ) } IconButton(onClick = onDismiss) { - Icon(Icons.Default.Close, contentDescription = "Close") + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close)) } } @@ -130,7 +132,7 @@ fun TtsWordReplacementsSheet( selectedTab = 0 editTarget = null }, - text = { Text("Global") }, + text = { Text(stringResource(R.string.tts_replacements_tab_global)) }, ) Tab( selected = selectedTab == 1, @@ -138,7 +140,7 @@ fun TtsWordReplacementsSheet( selectedTab = 1 editTarget = null }, - text = { Text("This book") }, + text = { Text(stringResource(R.string.tts_replacements_tab_this_book)) }, ) } @@ -179,8 +181,8 @@ private fun GlobalReplacementTab( ) { item { ListItem( - headlineContent = { Text("Enable replacements") }, - supportingContent = { Text("Rules here apply to every book unless disabled for a specific title.") }, + headlineContent = { Text(stringResource(R.string.tts_replacements_enable)) }, + supportingContent = { Text(stringResource(R.string.tts_replacements_enable_desc)) }, trailingContent = { Switch( checked = preferences.isEnabled, @@ -206,7 +208,7 @@ private fun GlobalReplacementTab( ) { Icon(Icons.Default.Add, contentDescription = null) Spacer(modifier = Modifier.width(8.dp)) - Text("Add rule") + Text(stringResource(R.string.tts_replacements_add_rule)) } } if (editTarget != null) { @@ -229,7 +231,7 @@ private fun GlobalReplacementTab( item { ReplacementRuleList( rules = preferences.globalRules, - emptyText = "No global replacement rules yet.", + emptyTextRes = R.string.tts_replacements_empty_global, onToggle = { rule, enabled -> onPreferencesChange( preferences.copy( @@ -297,7 +299,7 @@ private fun BookReplacementTab( ) { Icon(Icons.Default.Add, contentDescription = null) Spacer(modifier = Modifier.width(8.dp)) - Text("Add book rule") + Text(stringResource(R.string.tts_replacements_add_book_rule)) } } if (editTarget != null) { @@ -320,7 +322,7 @@ private fun BookReplacementTab( item { ReplacementRuleList( rules = localRules, - emptyText = "No book-specific rules yet.", + emptyTextRes = R.string.tts_replacements_empty_book, onToggle = { rule, enabled -> onPreferencesChange( preferences.withBookRules( @@ -349,8 +351,8 @@ private fun BookSettingsSwitches( ) { Column(modifier = Modifier.fillMaxWidth()) { ListItem( - headlineContent = { Text("Use global rules here") }, - supportingContent = { Text("Turn this off when a book needs its own pronunciation choices.") }, + headlineContent = { Text(stringResource(R.string.tts_replacements_use_global_here)) }, + supportingContent = { Text(stringResource(R.string.tts_replacements_use_global_here_desc)) }, trailingContent = { Switch( checked = settings.globalRulesEnabled, @@ -360,8 +362,8 @@ private fun BookSettingsSwitches( ) HorizontalDivider() ListItem( - headlineContent = { Text("Enable book rules") }, - supportingContent = { Text("Local rules run after global rules.") }, + headlineContent = { Text(stringResource(R.string.tts_replacements_enable_book_rules)) }, + supportingContent = { Text(stringResource(R.string.tts_replacements_enable_book_rules_desc)) }, trailingContent = { Switch( checked = settings.localRulesEnabled, @@ -381,13 +383,13 @@ private fun InheritedGlobalRules( ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - text = "Inherited global rules", + text = stringResource(R.string.tts_replacements_inherited_global_rules), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, ) if (globalRules.isEmpty()) { Text( - text = "No global rules to inherit.", + text = stringResource(R.string.tts_replacements_no_global_rules), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -395,9 +397,10 @@ private fun InheritedGlobalRules( } globalRules.forEach { rule -> val enabledHere = rule.id !in settings.disabledGlobalRuleIds + val silenceLabel = stringResource(R.string.tts_replacements_silence) ListItem( - headlineContent = { Text(rule.summaryText()) }, - supportingContent = { Text(if (enabledHere) "Allowed in this book" else "Disabled for this book") }, + headlineContent = { Text(rule.summaryText(silenceLabel)) }, + supportingContent = { Text(stringResource(if (enabledHere) R.string.tts_replacements_allowed_in_book else R.string.tts_replacements_disabled_for_book)) }, trailingContent = { Switch( checked = enabledHere, @@ -422,15 +425,16 @@ private fun SuggestionChips( ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - text = "Suggestions", + text = stringResource(R.string.tts_replacements_suggestions), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, ) LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { items(ReaderTtsReplacementSuggestions.presets) { suggestion -> + val silenceLabel = stringResource(R.string.tts_replacements_silence) AssistChip( onClick = { onSuggestionClick(suggestion) }, - label = { Text(suggestion.summaryText(), maxLines = 1, overflow = TextOverflow.Ellipsis) }, + label = { Text(suggestion.summaryText(silenceLabel), maxLines = 1, overflow = TextOverflow.Ellipsis) }, leadingIcon = { Icon(Icons.Default.Add, contentDescription = null) }, ) } @@ -456,8 +460,9 @@ private fun RuleEditorCard( var isRegex by remember(initial.id) { mutableStateOf(initial.isRegex) } var wholeWord by remember(initial.id) { mutableStateOf(initial.wholeWord) } var matchCase by remember(initial.id) { mutableStateOf(initial.matchCase) } - var previewInput by remember(initial.id) { - mutableStateOf(initial.from.takeIf { it.isNotBlank() } ?: "Dr. Smith met NASA at 5 p.m.") + val defaultPreviewInput = stringResource(R.string.tts_replacements_preview_default) + var previewInput by remember(initial.id, defaultPreviewInput) { + mutableStateOf(initial.from.takeIf { it.isNotBlank() } ?: defaultPreviewInput) } val draft = ReaderTtsReplacementRule( @@ -490,7 +495,7 @@ private fun RuleEditorCard( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = if (seedRule == null) "New replacement" else "Edit replacement", + text = stringResource(if (seedRule == null) R.string.tts_replacements_new_replacement else R.string.tts_replacements_edit_replacement), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, ) @@ -498,7 +503,7 @@ private fun RuleEditorCard( value = from, onValueChange = { from = it }, modifier = Modifier.fillMaxWidth(), - label = { Text("Replace") }, + label = { Text(stringResource(R.string.tts_replacements_label_replace)) }, singleLine = !isRegex, isError = !validation.isValid, supportingText = if (validation.message != null) { @@ -515,7 +520,7 @@ private fun RuleEditorCard( value = to, onValueChange = { to = it }, modifier = Modifier.fillMaxWidth(), - label = { Text("Speak as") }, + label = { Text(stringResource(R.string.tts_replacements_label_speak_as)) }, singleLine = !isRegex, ) LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -523,7 +528,7 @@ private fun RuleEditorCard( FilterChip( selected = enabled, onClick = { enabled = !enabled }, - label = { Text("Enabled") }, + label = { Text(stringResource(R.string.tts_replacements_chip_enabled)) }, leadingIcon = if (enabled) { { Icon(Icons.Default.Check, contentDescription = null) } } else { @@ -535,21 +540,21 @@ private fun RuleEditorCard( FilterChip( selected = isRegex, onClick = { isRegex = !isRegex }, - label = { Text("Regex") }, + label = { Text(stringResource(R.string.tts_replacements_chip_regex)) }, ) } item { FilterChip( selected = wholeWord, onClick = { wholeWord = !wholeWord }, - label = { Text("Whole word") }, + label = { Text(stringResource(R.string.tts_replacements_chip_whole_word)) }, ) } item { FilterChip( selected = matchCase, onClick = { matchCase = !matchCase }, - label = { Text("Match case") }, + label = { Text(stringResource(R.string.tts_replacements_chip_match_case)) }, ) } } @@ -557,7 +562,7 @@ private fun RuleEditorCard( value = previewInput, onValueChange = { previewInput = it }, modifier = Modifier.fillMaxWidth(), - label = { Text("Preview input") }, + label = { Text(stringResource(R.string.tts_replacements_label_preview_input)) }, minLines = 2, ) Text( @@ -570,14 +575,14 @@ private fun RuleEditorCard( horizontalArrangement = Arrangement.End, ) { TextButton(onClick = onCancel) { - Text("Cancel") + Text(stringResource(R.string.action_cancel)) } Spacer(modifier = Modifier.width(8.dp)) Button( onClick = { onSave(draft) }, enabled = validation.isValid, ) { - Text("Save") + Text(stringResource(R.string.action_save)) } } } @@ -587,14 +592,14 @@ private fun RuleEditorCard( @Composable private fun ReplacementRuleList( rules: List, - emptyText: String, + @StringRes emptyTextRes: Int, onToggle: (ReaderTtsReplacementRule, Boolean) -> Unit, onEdit: (ReaderTtsReplacementRule) -> Unit, onDelete: (ReaderTtsReplacementRule) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - text = "Rules", + text = stringResource(R.string.tts_replacements_rules), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, ) @@ -606,7 +611,7 @@ private fun ReplacementRuleList( contentAlignment = Alignment.Center, ) { Text( - text = emptyText, + text = stringResource(emptyTextRes), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -614,10 +619,11 @@ private fun ReplacementRuleList( return } rules.forEach { rule -> + val silenceLabel = stringResource(R.string.tts_replacements_silence) ListItem( headlineContent = { Text( - text = rule.summaryText(), + text = rule.summaryText(silenceLabel), maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -632,10 +638,10 @@ private fun ReplacementRuleList( onCheckedChange = { onToggle(rule, it) }, ) IconButton(onClick = { onEdit(rule) }) { - Icon(Icons.Default.Edit, contentDescription = "Edit") + Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.action_edit)) } IconButton(onClick = { onDelete(rule) }) { - Icon(Icons.Default.Delete, contentDescription = "Delete") + Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.action_delete)) } } }, @@ -648,16 +654,21 @@ private fun ReaderTtsReplacementRule.asEditableRule(scope: String): ReaderTtsRep return copy(id = "${scope}_${System.currentTimeMillis()}_${id}", enabled = true) } -private fun ReaderTtsReplacementRule.summaryText(): String { - val replacement = to.ifBlank { "silence" } +private fun ReaderTtsReplacementRule.summaryText(silenceLabel: String): String { + val replacement = to.ifBlank { silenceLabel } return "$from -> $replacement" } +@Composable private fun ReaderTtsReplacementRule.optionSummary(): String { + val regexLabel = stringResource(R.string.tts_replacements_chip_regex) + val plainTextLabel = stringResource(R.string.tts_replacements_plain_text) + val wholeWordLabel = stringResource(R.string.tts_replacements_chip_whole_word) + val caseSensitiveLabel = stringResource(R.string.tts_replacements_case_sensitive) val parts = buildList { - add(if (isRegex) "Regex" else "Plain text") - if (wholeWord) add("whole word") - if (matchCase) add("case-sensitive") + add(if (isRegex) regexLabel else plainTextLabel) + if (wholeWord) add(wholeWordLabel) + if (matchCase) add(caseSensitiveLabel) } return parts.joinToString(" - ") } diff --git a/app/src/main/java/com/aryan/reader/UiLabelResources.kt b/app/src/main/java/com/aryan/reader/UiLabelResources.kt index ccbb0ff..3405110 100644 --- a/app/src/main/java/com/aryan/reader/UiLabelResources.kt +++ b/app/src/main/java/com/aryan/reader/UiLabelResources.kt @@ -2,6 +2,25 @@ package com.aryan.reader import androidx.annotation.StringRes +data class AppLanguageOption( + val tag: String?, + @StringRes val labelRes: Int +) + +val systemAppLanguageOption = AppLanguageOption(null, R.string.language_system_default) + +val supportedAppLanguageOptions = listOf( + AppLanguageOption("en", R.string.language_english), + AppLanguageOption("ar", R.string.language_arabic), + AppLanguageOption("de", R.string.language_german), + AppLanguageOption("tr", R.string.language_turkish), + AppLanguageOption("fr", R.string.language_french), + AppLanguageOption("ru", R.string.language_russian), + AppLanguageOption("es", R.string.language_spanish) +) + +val appLanguageSelectionOptions = listOf(systemAppLanguageOption) + supportedAppLanguageOptions + val AddBooksSource.labelRes: Int @StringRes get() = when (this) { AddBooksSource.UNSHELVED -> R.string.add_books_source_unshelved 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 9ea4bb9..be66ca3 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt @@ -38,6 +38,8 @@ import android.widget.Toast import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -46,6 +48,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -69,6 +72,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource @@ -83,6 +87,10 @@ import androidx.compose.ui.window.PopupPositionProvider import androidx.core.net.toUri import com.aryan.reader.R import com.aryan.reader.getReaderTextureDataUri +import com.aryan.reader.shared.ui.SharedSelectionMenuRect +import com.aryan.reader.shared.ui.SharedSelectionMenuSize +import com.aryan.reader.shared.ui.SharedSelectionMenuViewport +import com.aryan.reader.shared.ui.sharedSelectionMenuPlacement import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.json.JSONObject @@ -1026,6 +1034,8 @@ fun ChapterWebView( // Custom Selection Menu Popup customMenuState?.let { state -> + val configuration = LocalConfiguration.current + val selectionMenuMaxHeight = (configuration.screenHeightDp.dp - 32.dp).coerceAtLeast(160.dp) val popupPositionProvider = remember(state.selectionBounds, density, state.isExistingHighlight) { object : PopupPositionProvider { @@ -1035,30 +1045,23 @@ fun ChapterWebView( layoutDirection: LayoutDirection, popupContentSize: IntSize ): IntOffset { - val topMargin = with(density) { 16.dp.toPx() }.toInt() - val bottomMargin = with(density) { + val marginPx = with(density) { 16.dp.toPx() } + val gapPx = with(density) { if (state.isExistingHighlight) 16.dp.toPx() else 60.dp.toPx() - }.toInt() - - var x = state.selectionBounds.centerX() - popupContentSize.width / 2 - - var y = state.selectionBounds.top - popupContentSize.height - topMargin - if (y < with(density) { 24.dp.toPx() }.toInt()) { - y = state.selectionBounds.bottom + bottomMargin } - if (x < 0) x = 0 - if (x + popupContentSize.width > windowSize.width) { - x = windowSize.width - popupContentSize.width - } - if (y + popupContentSize.height > windowSize.height) { - y = windowSize.height - popupContentSize.height - } - if (y < 0) y = 0 - - return IntOffset( - x.coerceIn(0, windowSize.width - popupContentSize.width), - y.coerceIn(0, windowSize.height - popupContentSize.height) + val placement = sharedSelectionMenuPlacement( + viewport = SharedSelectionMenuViewport(windowSize.width, windowSize.height), + popup = SharedSelectionMenuSize(popupContentSize.width, popupContentSize.height), + selection = SharedSelectionMenuRect( + left = state.selectionBounds.left.toFloat(), + top = state.selectionBounds.top.toFloat(), + right = state.selectionBounds.right.toFloat(), + bottom = state.selectionBounds.bottom.toFloat() + ), + marginPx = marginPx, + gapPx = gapPx ) + return IntOffset(placement.x, placement.y) } } } @@ -1075,11 +1078,14 @@ fun ChapterWebView( border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) ) { Column( - modifier = Modifier.width(IntrinsicSize.Max) + modifier = Modifier + .width(IntrinsicSize.Max) + .heightIn(max = selectionMenuMaxHeight) + .verticalScroll(rememberScrollState()) ) { Row( modifier = Modifier - .padding(vertical = 12.dp, horizontal = 12.dp) + .padding(vertical = 8.dp, horizontal = 10.dp) .fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically @@ -1087,8 +1093,8 @@ fun ChapterWebView( activeHighlightPalette.forEachIndexed { index, colorEnum -> Box( modifier = Modifier - .padding(horizontal = 6.dp) - .size(32.dp) + .padding(horizontal = 4.dp) + .size(28.dp) .background(colorEnum.color, CircleShape) .pointerInput(colorEnum) { detectTapGestures(onTap = { @@ -1115,7 +1121,7 @@ fun ChapterWebView( Spacer(modifier = Modifier.width(8.dp)) SpectrumButton( - onClick = { showPaletteManager = true }, size = 32.dp + onClick = { showPaletteManager = true }, size = 28.dp ) } @@ -1123,7 +1129,7 @@ fun ChapterWebView( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 8.dp), + .padding(horizontal = 6.dp, vertical = 6.dp), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically ) { diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt index c8e776d..672a4e4 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt @@ -66,7 +66,7 @@ suspend fun summarizeBookContent( onFinish: () -> Unit ) { if (content.isBlank()) { - onError("The book content is empty.") + onError(context.getString(R.string.ai_error_book_content_empty)) onFinish() return } @@ -75,7 +75,7 @@ suspend fun summarizeBookContent( @Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss") { if (BuildConfig.IS_OFFLINE) { - onError("AI features are unavailable in the offline OSS build.") + onError(context.getString(R.string.ai_error_offline_oss)) onFinish() return } @@ -154,7 +154,7 @@ suspend fun summarizeBookContent( } } if (!hasReceivedData) { - onError("Failed to parse summary from server response.") + onError(context.getString(R.string.ai_error_parse_summary)) } } else { val errorBody = try { @@ -162,12 +162,12 @@ suspend fun summarizeBookContent( } catch (_: Exception) { null } val errorDetail = try { JSONObject(errorBody.toString()).getString("detail") - } catch (_: Exception) { "Could not fetch summary." } - onError("Error: $responseCode. $errorDetail") + } catch (_: Exception) { context.getString(R.string.ai_error_fetch_summary) } + onError(context.getString(R.string.ai_error_with_code, responseCode, errorDetail)) } } catch (e: Exception) { Timber.e(e, "Network error during summarization: ${e.message}") - onError("Network error. Please check connection and server status.") + onError(context.getString(R.string.ai_error_network_server)) } finally { connection?.disconnect() onFinish() diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt index 564ed28..56fead4 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt @@ -495,7 +495,7 @@ fun PaginatedTextSelectionMenu( color = MaterialTheme.colorScheme.surface, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) ) { - Column(modifier = Modifier.width(IntrinsicSize.Max).widthIn(min = 200.dp)) { + Column(modifier = Modifier.width(IntrinsicSize.Max).widthIn(min = 180.dp)) { if (onHighlight != null) { HighlightColorRow( activeHighlightPalette = activeHighlightPalette, @@ -531,7 +531,7 @@ fun PaginatedTextSelectionMenu( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), + .padding(horizontal = 6.dp, vertical = 3.dp), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically ) { @@ -539,23 +539,23 @@ fun PaginatedTextSelectionMenu( val tint = if (action.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface Column( modifier = Modifier - .width(64.dp) + .width(56.dp) .clip(RoundedCornerShape(8.dp)) .clickable { action.onClick() } - .padding(vertical = 8.dp), + .padding(vertical = 6.dp), horizontalAlignment = Alignment.CenterHorizontally ) { if (action.imageVector != null) { - Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp)) + Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(22.dp)) } else if (action.iconRes != null) { - Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp)) + Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(22.dp)) } - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(2.dp)) Text(text = action.label, style = MaterialTheme.typography.labelSmall, color = tint, maxLines = 1) } } repeat(3 - rowActions.size) { - Spacer(modifier = Modifier.width(64.dp)) + Spacer(modifier = Modifier.width(56.dp)) } } } @@ -582,7 +582,7 @@ fun HighlightColorRow( ) { Row( modifier = modifier - .padding(vertical = 12.dp, horizontal = 12.dp) + .padding(vertical = 8.dp, horizontal = 10.dp) .fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically @@ -591,8 +591,8 @@ fun HighlightColorRow( Box( contentAlignment = Alignment.Center, modifier = Modifier - .padding(horizontal = 6.dp) - .size(32.dp) + .padding(horizontal = 4.dp) + .size(28.dp) .clip(CircleShape) // 1. Clip shape for ripple .background(colorEnum.color) // 2. Apply background .clickable { @@ -610,17 +610,17 @@ fun HighlightColorRow( imageVector = Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_selected), tint = if (colorEnum == HighlightColor.WHITE || colorEnum == HighlightColor.YELLOW) Color.Black else Color.White, - modifier = Modifier.size(18.dp) + modifier = Modifier.size(16.dp) ) } } } if (onOpenPaletteManager != null) { - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(6.dp)) SpectrumButton( onClick = onOpenPaletteManager, - size = 32.dp + size = 28.dp ) } } @@ -650,7 +650,7 @@ fun PaginatedTextSelectionMenu( color = MaterialTheme.colorScheme.surface, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) ) { - Column(modifier = Modifier.width(IntrinsicSize.Max).widthIn(min = 200.dp)) { + Column(modifier = Modifier.width(IntrinsicSize.Max).widthIn(min = 180.dp)) { // 1. Colors Row if (onHighlight != null) { HighlightColorRow( @@ -731,7 +731,7 @@ fun PaginatedTextSelectionMenu( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), + .padding(horizontal = 6.dp, vertical = 3.dp), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically ) { @@ -739,22 +739,22 @@ fun PaginatedTextSelectionMenu( val tint = if (action.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface Column( modifier = Modifier - .width(64.dp) + .width(56.dp) .clickable { action.onClick() } - .padding(vertical = 8.dp), + .padding(vertical = 6.dp), horizontalAlignment = Alignment.CenterHorizontally ) { if (action.imageVector != null) { - Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp)) + Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(22.dp)) } else if (action.iconRes != null) { - Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp)) + Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(22.dp)) } - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(2.dp)) Text(text = action.label, style = MaterialTheme.typography.labelSmall, color = tint, maxLines = 1) } } repeat(3 - rowActions.size) { - Spacer(modifier = Modifier.width(64.dp)) + Spacer(modifier = Modifier.width(56.dp)) } } } 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 22c3877..a99801d 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt @@ -26,6 +26,7 @@ import android.graphics.Canvas import android.os.Build import android.webkit.WebView import androidx.annotation.RequiresApi +import androidx.annotation.StringRes import androidx.compose.foundation.lazy.LazyListState import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility @@ -153,26 +154,26 @@ import kotlinx.coroutines.withContext import timber.log.Timber 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"), - SCREEN_ORIENTATION("Screen Orientation", "Top Bar"), - AUTO_SCROLL("Auto Scroll", "Overflow Menu"), - TTS_SETTINGS("TTS Settings", "Overflow Menu"), - TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu") +enum class ReaderTool(@StringRes val titleRes: Int, val category: String) { + DICTIONARY(R.string.tool_external_apps, "Top Bar"), + THEME(R.string.tooltip_theme_desc, "Top Bar"), + SLIDER(R.string.tool_navigation_slider, "Bottom Bar"), + TOC(R.string.tool_sidebar, "Bottom Bar"), + FORMAT(R.string.content_desc_text_formatting, "Bottom Bar"), + SEARCH(R.string.action_search, "Bottom Bar"), + AI_FEATURES(R.string.ai_features_title, "Bottom Bar"), + TTS_CONTROLS(R.string.tool_tts_controls, "Bottom Bar"), + READING_MODE(R.string.tool_reading_mode, "Overflow Menu"), + BOOKMARK(R.string.content_desc_bookmark, "Overflow Menu"), + TAP_TO_TURN(R.string.menu_tap_to_turn_pages, "Overflow Menu"), + VOLUME_SCROLL(R.string.menu_volume_button_scrolling, "Overflow Menu"), + PAGE_TURN_ANIM(R.string.menu_realistic_page_turns, "Overflow Menu"), + KEEP_SCREEN_ON(R.string.menu_keep_screen_on, "Overflow Menu"), + VISUAL_OPTIONS(R.string.menu_visual_options, "Overflow Menu"), + SCREEN_ORIENTATION(R.string.menu_screen_orientation, "Top Bar"), + AUTO_SCROLL(R.string.menu_auto_scroll, "Overflow Menu"), + TTS_SETTINGS(R.string.menu_tts_settings, "Overflow Menu"), + TTS_REPLACEMENTS(R.string.menu_tts_word_replacements, "Overflow Menu") } enum class FlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL } @@ -182,7 +183,8 @@ data class FlatToolItem( val type: FlatItemType, val tool: ReaderTool? = null, val section: ToolbarSection? = null, - val title: String? = null + val title: String? = null, + @StringRes val titleRes: Int? = null ) fun sanitizePlaceholders(list: List): List { @@ -197,7 +199,7 @@ fun sanitizePlaceholders(list: List): List { } ToolbarSection.entries.forEach { section -> - result.add(FlatToolItem("header_${section.name}", FlatItemType.SECTION_HEADER, section = section, title = section.title)) + result.add(FlatToolItem("header_${section.name}", FlatItemType.SECTION_HEADER, section = section, titleRes = section.titleRes)) val tools = sectionMap[section] ?: emptyList() if (tools.isEmpty()) { @@ -266,6 +268,51 @@ private val epubToolbarTools = setOf( ReaderTool.SCREEN_ORIENTATION ) +internal fun defaultReaderHiddenTools(): Set = setOf(ReaderTool.SCREEN_ORIENTATION.name) + +internal fun defaultReaderToolOrder(): List = ReaderTool.entries.toList() + +internal fun defaultReaderBottomTools(): Set { + return ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet() +} + +internal fun buildReaderToolbarItems( + hiddenTools: Set, + toolOrder: List, + bottomTools: Set +): List { + val toolbarTools = toolOrder.filter { it in epubToolbarTools } + val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) } + val bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) } + val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) } + val moreTools = toolOrder.filter { it !in epubToolbarTools } + + val list = mutableListOf() + + ToolbarSection.entries.forEach { section -> + val tools = when (section) { + ToolbarSection.TOP -> topTools + ToolbarSection.BOTTOM -> bottomToolsList + ToolbarSection.HIDDEN -> hiddenToolsList + } + list.add(FlatToolItem("header_${section.name}", FlatItemType.SECTION_HEADER, section = section, titleRes = section.titleRes)) + if (tools.isEmpty()) { + list.add(FlatToolItem("empty_${section.name}", FlatItemType.EMPTY_PLACEHOLDER, section = section)) + } else { + tools.forEach { tool -> + list.add(FlatToolItem("tool_${tool.name}", FlatItemType.TOOL, tool = tool, section = section)) + } + } + } + + list.add(FlatToolItem("more_header", FlatItemType.MORE_HEADER, titleRes = R.string.toolbar_more_menu)) + moreTools.forEach { tool -> + list.add(FlatToolItem("more_${tool.name}", FlatItemType.MORE_TOOL, tool = tool)) + } + + return list +} + @Composable fun EpubReaderTopBar( isVisible: Boolean, @@ -478,7 +525,7 @@ fun EpubReaderTopBar( if (hiddenToolbarTools.isNotEmpty()) { DropdownMenuItem( - text = { Text("Hidden tools") }, + text = { Text(stringResource(R.string.toolbar_hidden_tools_menu)) }, onClick = { showHiddenToolsExpanded = !showHiddenToolsExpanded }, trailingIcon = { Icon( @@ -1717,37 +1764,11 @@ fun CustomizeToolsSheet( var flatItems by remember { mutableStateOf( - run { - val toolbarTools = toolOrder.filter { it in epubToolbarTools } - val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) } - val bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) } - val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) } - val moreTools = toolOrder.filter { it !in epubToolbarTools } - - val list = mutableListOf() - - ToolbarSection.entries.forEach { section -> - val tools = when(section) { - ToolbarSection.TOP -> topTools - ToolbarSection.BOTTOM -> bottomToolsList - ToolbarSection.HIDDEN -> hiddenToolsList - } - list.add(FlatToolItem("header_${section.name}", FlatItemType.SECTION_HEADER, section = section, title = section.title)) - if (tools.isEmpty()) { - list.add(FlatToolItem("empty_${section.name}", FlatItemType.EMPTY_PLACEHOLDER, section = section)) - } else { - tools.forEach { tool -> - list.add(FlatToolItem("tool_${tool.name}", FlatItemType.TOOL, tool = tool, section = section)) - } - } - } - - list.add(FlatToolItem("more_header", FlatItemType.MORE_HEADER, title = "More menu")) - moreTools.forEach { tool -> - list.add(FlatToolItem("more_${tool.name}", FlatItemType.MORE_TOOL, tool = tool)) - } - list - } + buildReaderToolbarItems( + hiddenTools = hiddenTools, + toolOrder = toolOrder, + bottomTools = bottomTools + ) ) } @@ -1812,6 +1833,22 @@ fun CustomizeToolsSheet( } } + val resetToDefault = { + val defaultHiddenTools = defaultReaderHiddenTools() + val defaultToolOrder = defaultReaderToolOrder() + val defaultBottomTools = defaultReaderBottomTools() + + localHiddenTools = defaultHiddenTools + flatItems = buildReaderToolbarItems( + hiddenTools = defaultHiddenTools, + toolOrder = defaultToolOrder, + bottomTools = defaultBottomTools + ) + onUpdate(defaultHiddenTools) + onPlacementUpdate(defaultBottomTools) + onOrderUpdate(defaultToolOrder) + } + Dialog( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false) @@ -1828,12 +1865,17 @@ fun CustomizeToolsSheet( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Customize Toolbar", + text = stringResource(R.string.title_customize_toolbar), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f) ) + TextButton(onClick = resetToDefault) { + Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.action_reset)) + } IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close)) } @@ -1866,8 +1908,9 @@ fun CustomizeToolsSheet( ) { when (item.type) { FlatItemType.SECTION_HEADER -> { + val titleRes = item.titleRes Text( - text = item.title ?: "", + text = if (titleRes != null) stringResource(titleRes) else item.title.orEmpty(), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, @@ -1883,7 +1926,7 @@ fun CustomizeToolsSheet( .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), RoundedCornerShape(12.dp)), contentAlignment = Alignment.Center ) { - Text("Drop tools here", color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(stringResource(R.string.toolbar_drop_tools_here), color = MaterialTheme.colorScheme.onSurfaceVariant) } } FlatItemType.TOOL -> { @@ -1900,8 +1943,9 @@ fun CustomizeToolsSheet( ) } FlatItemType.MORE_HEADER -> { + val titleRes = item.titleRes Text( - text = item.title ?: "More menu", + text = if (titleRes != null) stringResource(titleRes) else item.title ?: stringResource(R.string.toolbar_more_menu), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(top = 24.dp, bottom = 8.dp, start = 4.dp) @@ -1909,7 +1953,7 @@ fun CustomizeToolsSheet( } FlatItemType.MORE_TOOL -> { MoreToolVisibilityRow( - title = item.tool!!.title, + title = stringResource(item.tool!!.titleRes), visible = !localHiddenTools.contains(item.tool.name), onToggle = { localHiddenTools = if (localHiddenTools.contains(item.tool.name)) { @@ -1952,14 +1996,14 @@ private fun ToolbarDragRow( ToolPreviewIcon(tool) Spacer(Modifier.width(16.dp)) Text( - text = tool.title, + text = stringResource(tool.titleRes), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f) ) Icon( Icons.Default.Menu, - contentDescription = "Drag to reorder", + contentDescription = stringResource(R.string.content_desc_drag_to_reorder), tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier .size(32.dp) @@ -2007,25 +2051,26 @@ private fun MoreToolVisibilityRow( } } -enum class ToolbarSection(val title: String) { - TOP("Top Bar"), - BOTTOM("Bottom Bar"), - HIDDEN("Hidden Tools") +enum class ToolbarSection(@StringRes val titleRes: Int) { + TOP(R.string.toolbar_top_bar), + BOTTOM(R.string.toolbar_bottom_bar), + HIDDEN(R.string.toolbar_hidden_tools) } @Composable private fun ToolPreviewIcon(tool: ReaderTool) { + val title = stringResource(tool.titleRes) when (tool) { - ReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = tool.title, modifier = Modifier.size(20.dp)) - ReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = tool.title, modifier = Modifier.size(20.dp)) - ReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = tool.title, modifier = Modifier.size(20.dp)) - ReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = tool.title, modifier = Modifier.size(20.dp)) - ReaderTool.FORMAT -> Icon(painterResource(id = R.drawable.format_size), contentDescription = tool.title, modifier = Modifier.size(20.dp)) - ReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = tool.title, modifier = Modifier.size(20.dp)) - ReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp)) - ReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp)) - ReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = tool.title, modifier = Modifier.size(20.dp)) - else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp)) + ReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = title, modifier = Modifier.size(20.dp)) + ReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = title, modifier = Modifier.size(20.dp)) + ReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = title, modifier = Modifier.size(20.dp)) + ReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = title, modifier = Modifier.size(20.dp)) + ReaderTool.FORMAT -> Icon(painterResource(id = R.drawable.format_size), contentDescription = title, modifier = Modifier.size(20.dp)) + ReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = title, modifier = Modifier.size(20.dp)) + ReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = title, modifier = Modifier.size(20.dp)) + ReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = title, modifier = Modifier.size(20.dp)) + ReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = title, modifier = Modifier.size(20.dp)) + else -> Icon(Icons.Default.MoreVert, contentDescription = title, modifier = Modifier.size(20.dp)) } } @@ -2050,7 +2095,7 @@ private fun HiddenEpubToolMenuItem( else -> true } DropdownMenuItem( - text = { Text(tool.title) }, + text = { Text(stringResource(tool.titleRes)) }, enabled = enabled, onClick = { showMoreMenu() @@ -2198,7 +2243,11 @@ fun TtsOverlayControls( shape = RoundedCornerShape(8.dp) ) { Text( - if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) "✨ Cloud" else "📱 Device", + if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) { + stringResource(R.string.tts_mode_cloud_ai) + } else { + stringResource(R.string.tts_mode_device_native) + }, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) @@ -2211,7 +2260,7 @@ fun TtsOverlayControls( ) { val voiceName = if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) { GEMINI_TTS_SPEAKERS.find { it.id == ttsState.speakerId }?.name ?: ttsState.speakerId - } else loadNativeVoice(context)?.split("-")?.lastOrNull() ?: "Default" + } else loadNativeVoice(context)?.split("-")?.lastOrNull() ?: stringResource(R.string.label_default) Text( voiceName, 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 1a4d11d..5e98b71 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -253,6 +253,15 @@ private const val TTS_LOCATE_REASON_OVERLAY = "overlay" private const val TAG_LINK_NAV = "LINK_NAV" private const val TAG_STABLE_PAGE_NAV = "StablePageNav" +private const val TAG_PAGINATED_HIGHLIGHT_DIAG = "PaginatedHighlightDiag" + +private fun epubHighlightDiagSnippet(text: String, maxLength: Int = 80): String { + return text + .replace('\n', ' ') + .replace('\r', ' ') + .replace('\t', ' ') + .take(maxLength) +} private fun View.bottomRoundedCornerRadiusPx(): Int { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return 0 @@ -303,7 +312,7 @@ private fun loadHiddenTools(context: Context): Set { val savedHiddenTools = prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()).orEmpty() val defaultsVersion = prefs.getInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0) if (defaultsVersion < HIDDEN_TOOLS_DEFAULTS_VERSION) { - val migratedHiddenTools = savedHiddenTools + ReaderTool.SCREEN_ORIENTATION.name + val migratedHiddenTools = savedHiddenTools + defaultReaderHiddenTools() prefs.edit { putStringSet(HIDDEN_TOOLS_KEY, migratedHiddenTools) putInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, HIDDEN_TOOLS_DEFAULTS_VERSION) @@ -325,7 +334,7 @@ private fun loadToolOrder(context: Context): List { ?.filter { it.isNotBlank() } ?.mapNotNull { name -> ReaderTool.entries.firstOrNull { it.name == name } } .orEmpty() - return (savedTools + ReaderTool.entries.filterNot { it in savedTools }).distinct() + return (savedTools + defaultReaderToolOrder().filterNot { it in savedTools }).distinct() } private fun saveBottomTools(context: Context, bottomTools: Set) { @@ -337,8 +346,8 @@ private fun loadBottomTools(context: Context): Set { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) return prefs.getStringSet( BOTTOM_TOOLS_KEY, - ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet() - ) ?: ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet() + defaultReaderBottomTools() + ) ?: defaultReaderBottomTools() } private fun saveKeepScreenOn(context: Context, isEnabled: Boolean) { @@ -2413,13 +2422,13 @@ fun EpubReaderHost( val targetPageIndex = pageIndex val targetCfi = cfi.orEmpty() if (targetPageIndex != null && (targetCfi.isBlank() || targetCfi.startsWith("android-page:"))) { - return "Page ${targetPageIndex + 1}" + return context.getString(R.string.pdf_page_short, targetPageIndex + 1) } val chapter = chapterIndex return if (chapter != null) { - chapters.getOrNull(chapter)?.title?.takeIf { it.isNotBlank() } ?: "Chapter ${chapter + 1}" + chapters.getOrNull(chapter)?.title?.takeIf { it.isNotBlank() } ?: context.getString(R.string.chapter_number_format, chapter + 1) } else { - "Location" + context.getString(R.string.location_generic) } } @@ -3262,7 +3271,7 @@ fun EpubReaderHost( } ?: run { isSummarizationLoading = false summarizationResult = - SummarizationResult(error = "WebView not available.") + SummarizationResult(error = context.getString(R.string.error_webview_not_available)) } } } @@ -3333,7 +3342,7 @@ fun EpubReaderHost( if (fullSummary.isNotBlank()) { val chapterTitle = chapters.getOrNull(chapterIndex)?.title - ?: "Chapter ${chapterIndex + 1}" + ?: context.getString(R.string.chapter_number_format, chapterIndex + 1) summaryCacheManager.saveSummary( epubBook.title, chapterIndex, @@ -3344,12 +3353,12 @@ fun EpubReaderHost( }) } else { summarizationResult = - SummarizationResult(error = "Could not get chapter content.") + SummarizationResult(error = context.getString(R.string.error_could_not_get_chapter_content)) isSummarizationLoading = false } } else { summarizationResult = - SummarizationResult(error = "Could not determine current chapter.") + SummarizationResult(error = context.getString(R.string.error_could_not_determine_chapter)) isSummarizationLoading = false } } @@ -4207,7 +4216,7 @@ fun EpubReaderHost( isSummarizationLoading = false val fullSummary = finalSummaryBuilder.toString() if (fullSummary.isNotBlank()) { - val chapterTitle = chapters.getOrNull(chapterIndexToSave)?.title ?: "Chapter ${chapterIndexToSave + 1}" + val chapterTitle = chapters.getOrNull(chapterIndexToSave)?.title ?: context.getString(R.string.chapter_number_format, chapterIndexToSave + 1) summaryCacheManager.saveSummary(bookTitleToSave, chapterIndexToSave, chapterTitle, fullSummary) } } @@ -4362,7 +4371,7 @@ fun EpubReaderHost( ) val chapterTitle = epubBook.chapters.getOrNull(currentChapterIndex)?.title - ?: "Unknown Chapter" + ?: context.getString(R.string.unknown_chapter) val newBookmark = Bookmark( cfi = cfi, chapterTitle = chapterTitle, @@ -4571,15 +4580,33 @@ fun EpubReaderHost( highlight.chapterIndex in (currentChapter - 1)..(currentChapter + 1) }, onHighlightCreated = { cfi, text, colorId -> + val chapterIndex = currentChapterInPaginatedMode ?: 0 + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "persist_request cfi=$cfi colorId=$colorId chapter=$chapterIndex " + + "existingCount=${userHighlights.size} textLen=${text.length} " + + "text='${epubHighlightDiagSnippet(text)}'" + ) Timber.d("EpubReaderScreen: onHighlightCreated. CFI: $cfi") val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW val finalCfi = processAndAddHighlight( newCfi = cfi, newText = text, newColor = color, - chapterIndex = currentChapterInPaginatedMode ?: 0, + chapterIndex = chapterIndex, currentList = userHighlights ) + val savedHighlight = userHighlights.find { + it.chapterIndex == chapterIndex && it.cfi == finalCfi + } + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "persist_result finalCfi=$finalCfi chapter=$chapterIndex " + + "savedId=${savedHighlight?.id} totalCount=${userHighlights.size} " + + "matchingCfiCount=${userHighlights.count { it.chapterIndex == chapterIndex && it.cfi == finalCfi }} " + + "locatorStart=${savedHighlight?.locator?.startOffset} " + + "locatorEnd=${savedHighlight?.locator?.endOffset} " + + "locatorPage=${savedHighlight?.locator?.pageIndex} " + + "locatorCfi=${savedHighlight?.locator?.cfi}" + ) if (pendingNoteForNewHighlight) { pendingNoteForNewHighlight = false highlightToNoteCfi = finalCfi @@ -4616,9 +4643,24 @@ fun EpubReaderHost( )?.let { recordEpubJump(it) } }, onHighlightDeleted = { cfi -> + val beforeCount = userHighlights.size val toRemove = userHighlights.find { it.cfi == cfi } if (toRemove != null) { + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "delete_request cfi=$cfi matchedId=${toRemove.id} " + + "matchedChapter=${toRemove.chapterIndex} beforeCount=$beforeCount " + + "locatorStart=${toRemove.locator.startOffset} " + + "locatorEnd=${toRemove.locator.endOffset}" + ) userHighlights.remove(toRemove) + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "delete_result cfi=$cfi removedId=${toRemove.id} " + + "afterCount=${userHighlights.size}" + ) + } else { + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).w( + "delete_request cfi=$cfi matchedId=null beforeCount=$beforeCount" + ) } } ) @@ -4745,7 +4787,7 @@ fun EpubReaderHost( val finalCfi = if (offset > 0) "$baseCfi:$offset" else baseCfi val chapterIndex = paginator?.findChapterIndexForPage(paginatedPagerState.currentPage) - val chapterTitle = chapterIndex?.let { epubBook.chapters.getOrNull(it)?.title } ?: "Unknown Chapter" + val chapterTitle = chapterIndex?.let { epubBook.chapters.getOrNull(it)?.title } ?: context.getString(R.string.unknown_chapter) val snippet = (targetBlockForBookmark as? TextContentBlock)?.content?.text?.take(150) ?: "" val pageInChapter: Int? @@ -4862,7 +4904,7 @@ fun EpubReaderHost( val textToShow = if (bookPaginator != null && chapterIndex != null) { val chapterTitle = chapters.getOrNull(chapterIndex)?.title?.take(30)?.trim() - ?: "Chapter" + ?: stringResource(R.string.chapter) val totalPagesInChapter = bookPaginator.chapterPageCounts[chapterIndex] val chapterStartPage = bookPaginator.chapterStartPageIndices[chapterIndex] @@ -4874,7 +4916,7 @@ fun EpubReaderHost( chapterTitle } } else { - "Page ${paginatedPagerState.currentPage + 1}/${paginatedPagerState.pageCount}" + stringResource(R.string.page_number_of_total, paginatedPagerState.currentPage + 1, paginatedPagerState.pageCount) } Text( @@ -5568,7 +5610,7 @@ fun EpubReaderHost( onVerticalMarginChange = { currentVerticalMargin = it }, currentFont = currentFontFamily, currentCustomFontName = if(currentCustomFontPath != null) { - customFonts.find { it.path == currentCustomFontPath }?.displayName ?: "Custom Font" + customFonts.find { it.path == currentCustomFontPath }?.displayName ?: stringResource(R.string.custom_font_fallback) } else null, onFontOptionClick = { showFontSelectionSheet = true }, currentTextAlign = currentTextAlign, @@ -5649,7 +5691,7 @@ fun EpubReaderHost( credits = credits, isProUser = isProUser, currentChapterIndex = effectiveCurrentChapterIndex, - chapterTitle = chapters.getOrNull(effectiveCurrentChapterIndex)?.title ?: "Chapter ${effectiveCurrentChapterIndex + 1}", + chapterTitle = chapters.getOrNull(effectiveCurrentChapterIndex)?.title ?: context.getString(R.string.chapter_number_format, effectiveCurrentChapterIndex + 1), showAiHubSheet = showAiHubSheet, onGenerateSummary = handleGenerateSummary, onGenerateRecap = handleGenerateRecap, diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt index bad04ba..82c7790 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt @@ -21,6 +21,7 @@ package com.aryan.reader.epubreader import android.content.Context import android.net.Uri +import androidx.annotation.StringRes import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility @@ -161,27 +162,28 @@ enum class ReaderFont(val id: String, val displayName: String, val fontFamilyNam LEXEND("lexend", "Lexend", "Lexend") } -enum class ReaderTextAlign(val id: String, val cssValue: String, val iconResId: Int, val displayName: String) { - DEFAULT("default", "", R.drawable.format_align_left, "Default"), - LEFT("left", "left", R.drawable.format_align_left, "Left"), - JUSTIFY("justify", "justify", R.drawable.format_align_justify, "Justify") +enum class ReaderTextAlign(val id: String, val cssValue: String, val iconResId: Int, @StringRes val displayNameRes: Int) { + DEFAULT("default", "", R.drawable.format_align_left, R.string.label_default), + LEFT("left", "left", R.drawable.format_align_left, R.string.label_left), + RIGHT("right", "right", R.drawable.format_align_right, R.string.label_right), + JUSTIFY("justify", "justify", R.drawable.format_align_justify, R.string.label_justify) } -enum class SystemUiMode(val id: Int, val title: String) { - DEFAULT(0, "Always Show"), - SYNC(1, "Sync with Menus"), - HIDDEN(2, "Always Hide") +enum class SystemUiMode(val id: Int, @StringRes val titleRes: Int) { + DEFAULT(0, R.string.label_always_show), + SYNC(1, R.string.label_sync_with_menus), + HIDDEN(2, R.string.label_always_hide) } -enum class PageInfoMode(val id: Int, val title: String) { - DEFAULT(0, "Always Show"), - SYNC(1, "Sync with Menus"), - HIDDEN(2, "Always Hide") +enum class PageInfoMode(val id: Int, @StringRes val titleRes: Int) { + DEFAULT(0, R.string.label_always_show), + SYNC(1, R.string.label_sync_with_menus), + HIDDEN(2, R.string.label_always_hide) } -enum class PageInfoPosition(val id: Int, val title: String) { - BOTTOM(0, "Bottom"), - TOP(1, "Top") +enum class PageInfoPosition(val id: Int, @StringRes val titleRes: Int) { + BOTTOM(0, R.string.label_bottom), + TOP(1, R.string.label_top) } data class FormatSettings( @@ -649,6 +651,7 @@ fun ReaderTextFormatPanel( Row { ReaderTextAlign.entries.forEach { align -> val isSelected = currentTextAlign == align + val alignDisplayName = stringResource(align.displayNameRes) Column( modifier = Modifier .fillMaxHeight() @@ -661,12 +664,12 @@ fun ReaderTextFormatPanel( ) { Icon( painter = painterResource(id = align.iconResId), - contentDescription = align.displayName, + contentDescription = alignDisplayName, tint = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp) ) Text( - text = align.displayName, + text = alignDisplayName, style = MaterialTheme.typography.labelSmall, fontSize = 11.sp, color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant @@ -923,7 +926,7 @@ fun VisualOptionsSheet( options = SystemUiMode.entries, selectedOption = systemUiMode, onOptionSelected = onSystemUiModeChange, - getLabel = { it.title } + getLabel = { stringResource(it.titleRes) } ) Spacer(modifier = Modifier.height(24.dp)) @@ -936,7 +939,7 @@ fun VisualOptionsSheet( options = PageInfoMode.entries, selectedOption = pageInfoMode, onOptionSelected = onPageInfoModeChange, - getLabel = { it.title } + getLabel = { stringResource(it.titleRes) } ) Spacer(modifier = Modifier.height(16.dp)) @@ -946,7 +949,7 @@ fun VisualOptionsSheet( options = PageInfoPosition.entries, selectedOption = pageInfoPosition, onOptionSelected = onPageInfoPositionChange, - getLabel = { it.title } + getLabel = { stringResource(it.titleRes) } ) Spacer(modifier = Modifier.height(24.dp)) @@ -1003,7 +1006,7 @@ fun OptionSegmentedControl( options: List, selectedOption: T, onOptionSelected: (T) -> Unit, - getLabel: (T) -> String + getLabel: @Composable (T) -> String ) { Row( modifier = Modifier diff --git a/app/src/main/java/com/aryan/reader/epubreader/InteractiveWebView.kt b/app/src/main/java/com/aryan/reader/epubreader/InteractiveWebView.kt index 918089d..b2c353f 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/InteractiveWebView.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/InteractiveWebView.kt @@ -33,6 +33,7 @@ import android.os.Handler import android.os.Looper import android.view.View import android.widget.PopupMenu +import android.webkit.JavascriptInterface import org.json.JSONObject enum class DragOperation { NONE, PULLING_DOWN_FROM_TOP, PULLING_UP_FROM_BOTTOM } @@ -52,6 +53,9 @@ class InteractiveWebView( companion object { private const val DRAG_SENSITIVITY_PX = 20f + private const val SELECTION_MENU_INITIAL_DELAY_MS = 120L + private const val SELECTION_MENU_RETRY_DELAY_MS = 140L + private const val SELECTION_MENU_RETRY_COUNT = 8 } private var startY: Float = 0f @@ -60,16 +64,26 @@ class InteractiveWebView( private val scrollStopHandler = Handler(Looper.getMainLooper()) private var scrollStopRunnable: Runnable? = null + private var selectionMenuRunnable: Runnable? = null private var activeSelectionActionMode: ActionMode? = null + private var selectionMenuShownForActiveMode = false + + init { + addJavascriptInterface(ReaderSelectionBridge(), "ReaderSelectionBridge") + } private fun clearPendingSelectionWork() { scrollStopRunnable?.let { scrollStopHandler.removeCallbacks(it) } scrollStopRunnable = null + selectionMenuRunnable?.let { scrollStopHandler.removeCallbacks(it) } + selectionMenuRunnable = null } - private fun startLocalSelectionActionMode(): ActionMode { + private fun startLocalSelectionActionMode(scheduleMenu: Boolean = true): ActionMode { activeSelectionActionMode?.let { existingMode -> - showCustomSelectionMenuFromCurrentSelection(existingMode) + if (scheduleMenu) { + scheduleCustomSelectionMenuFromCurrentSelection(existingMode) + } return existingMode } @@ -78,10 +92,14 @@ class InteractiveWebView( if (activeSelectionActionMode === localMode) { activeSelectionActionMode = null } + selectionMenuShownForActiveMode = false onHideCustomSelectionMenu() } + selectionMenuShownForActiveMode = false activeSelectionActionMode = localMode - showCustomSelectionMenuFromCurrentSelection(localMode) + if (scheduleMenu) { + scheduleCustomSelectionMenuFromCurrentSelection(localMode) + } return localMode } @@ -90,7 +108,56 @@ class InteractiveWebView( activeSelectionActionMode = null } - private fun showCustomSelectionMenuFromCurrentSelection(mode: ActionMode) { + private fun scheduleCustomSelectionMenuFromCurrentSelection( + mode: ActionMode, + delayMs: Long = SELECTION_MENU_INITIAL_DELAY_MS, + remainingRetries: Int = SELECTION_MENU_RETRY_COUNT + ) { + selectionMenuRunnable?.let { scrollStopHandler.removeCallbacks(it) } + selectionMenuRunnable = Runnable { + selectionMenuRunnable = null + showCustomSelectionMenuFromCurrentSelection(mode, remainingRetries) + } + scrollStopHandler.postDelayed(selectionMenuRunnable!!, delayMs) + } + + private fun scheduleSelectionActionModeRefreshAfterTouch( + delayMs: Long = SELECTION_MENU_INITIAL_DELAY_MS, + remainingRetries: Int = SELECTION_MENU_RETRY_COUNT + ) { + selectionMenuRunnable?.let { scrollStopHandler.removeCallbacks(it) } + selectionMenuRunnable = Runnable { + selectionMenuRunnable = null + activeSelectionActionMode?.let { mode -> + showCustomSelectionMenuFromCurrentSelection(mode, SELECTION_MENU_RETRY_COUNT) + return@Runnable + } + evaluateJavascript("(function() { var s = window.getSelection && window.getSelection(); return s ? s.toString().trim() : ''; })();") { result -> + val selectedText = result?.removeSurrounding("\"") + if (!selectedText.isNullOrBlank() && activeSelectionActionMode == null) { + Timber.d("CustomSelection: selection exists after touch-up. Starting local action mode.") + startLocalSelectionActionMode() + } else { + activeSelectionActionMode?.let { mode -> + showCustomSelectionMenuFromCurrentSelection(mode, SELECTION_MENU_RETRY_COUNT) + return@evaluateJavascript + } + if (remainingRetries > 0) { + scheduleSelectionActionModeRefreshAfterTouch( + delayMs = SELECTION_MENU_RETRY_DELAY_MS, + remainingRetries = remainingRetries - 1 + ) + } + } + } + } + scrollStopHandler.postDelayed(selectionMenuRunnable!!, delayMs) + } + + private fun showCustomSelectionMenuFromCurrentSelection( + mode: ActionMode, + remainingRetries: Int + ) { val jsToGetSelectionDetails = """ (function() { var selection = window.getSelection(); @@ -99,20 +166,44 @@ class InteractiveWebView( return null; } var range = selection.getRangeAt(0); - var rect = range.getBoundingClientRect(); - - // If getBoundingClientRect returns all zeros, try getClientRects() - if (rect.width === 0 && rect.height === 0 && rect.top === 0 && rect.left === 0) { - var clientRects = range.getClientRects(); - if (clientRects.length > 0) { - rect = clientRects[0]; // Use the first rect - } else { - return null; // No valid rect found + var viewportLeft = 0; + var viewportTop = 0; + var viewportRight = window.innerWidth || document.documentElement.clientWidth || 0; + var viewportBottom = window.innerHeight || document.documentElement.clientHeight || 0; + var rects = Array.prototype.slice.call(range.getClientRects ? range.getClientRects() : []); + rects = rects.filter(function(rect) { + if (!rect || rect.width <= 0 || rect.height <= 0) return false; + return rect.right >= viewportLeft && + rect.left <= viewportRight && + rect.bottom >= viewportTop && + rect.top <= viewportBottom; + }); + var rect = null; + if (rects.length > 0) { + var firstRect = rects[0]; + rect = rects.reduce(function(acc, item) { + return { + left: Math.min(acc.left, item.left), + top: Math.min(acc.top, item.top), + right: Math.max(acc.right, item.right), + bottom: Math.max(acc.bottom, item.bottom) + }; + }, { + left: firstRect.left, + top: firstRect.top, + right: firstRect.right, + bottom: firstRect.bottom + }); + rect.width = rect.right - rect.left; + rect.height = rect.bottom - rect.top; + } else { + rect = range.getBoundingClientRect ? range.getBoundingClientRect() : null; + if (!rect || rect.width <= 0 || rect.height <= 0) { + return null; } } - // Ensure the rect has some dimension - if (rect.width === 0 && rect.height === 0) { + if (rect.width <= 0 || rect.height <= 0) { return null; } @@ -129,13 +220,28 @@ class InteractiveWebView( """.trimIndent() evaluateJavascript(jsToGetSelectionDetails) { jsonResult -> + fun retryOrFinish(message: String) { + Timber.d(message) + if (selectionMenuShownForActiveMode) { + return + } + if (activeSelectionActionMode === mode && remainingRetries > 0) { + scheduleCustomSelectionMenuFromCurrentSelection( + mode = mode, + delayMs = SELECTION_MENU_RETRY_DELAY_MS, + remainingRetries = remainingRetries - 1 + ) + } else { + mode.finish() + } + } + if (activeSelectionActionMode !== mode) { return@evaluateJavascript } if (jsonResult == null || jsonResult == "null" || jsonResult.equals("\"null\"", ignoreCase = true)) { - Timber.d("CustomSelection: JS returned null or invalid for selection details.") - mode.finish() + retryOrFinish("CustomSelection: JS returned null or invalid for selection details. Retries left: $remainingRetries") return@evaluateJavascript } @@ -148,8 +254,7 @@ class InteractiveWebView( val selectedText = selectionDetails.getString("text") if (selectedText.isBlank()) { - Timber.d("CustomSelection: Selected text is blank after JS processing.") - mode.finish() + retryOrFinish("CustomSelection: Selected text is blank after JS processing. Retries left: $remainingRetries") return@evaluateJavascript } @@ -161,8 +266,7 @@ class InteractiveWebView( val jsHeight = selectionDetails.getDouble("height") if (jsWidth == 0.0 && jsHeight == 0.0) { - Timber.d("CustomSelection: JS returned a zero-area rect (width=0, height=0). Left: $jsLeft, Top: $jsTop") - mode.finish() + retryOrFinish("CustomSelection: JS returned a zero-area rect. Retries left: $remainingRetries. Left: $jsLeft, Top: $jsTop") return@evaluateJavascript } @@ -181,20 +285,85 @@ class InteractiveWebView( ) if (selectionRectScreen.isEmpty || selectionRectScreen.width() <= 0 || selectionRectScreen.height() <= 0) { - Timber.d("CustomSelection: Calculated selectionRectScreen is empty or invalid: $selectionRectScreen. JS LTRB: $jsLeft, $jsTop, $jsRight, $jsBottom. WebViewLoc: $webViewX, $webViewY") - mode.finish() + retryOrFinish("CustomSelection: Calculated selectionRectScreen is empty or invalid: $selectionRectScreen. Retries left: $remainingRetries. JS LTRB: $jsLeft, $jsTop, $jsRight, $jsBottom. WebViewLoc: $webViewX, $webViewY") return@evaluateJavascript } Timber.d("CustomSelection: Selected text: '$selectedText', JS Rect: {L:$jsLeft, T:$jsTop, R:$jsRight, B:$jsBottom}, Screen Rect: $selectionRectScreen") + selectionMenuShownForActiveMode = true onShowCustomSelectionMenu(selectedText, selectionRectScreen) { mode.finish() } } catch (e: Exception) { Timber.e(e, "CustomSelection: Error parsing selection details from JS: '$jsonResult', raw: '$jsonResult'") + if (selectionMenuShownForActiveMode) { + return@evaluateJavascript + } + if (remainingRetries > 0) { + scheduleCustomSelectionMenuFromCurrentSelection( + mode = mode, + delayMs = SELECTION_MENU_RETRY_DELAY_MS, + remainingRetries = remainingRetries - 1 + ) + } else { + mode.finish() + } + } + } + } + + private fun showCustomSelectionMenuFromSelectionDetailsJson( + mode: ActionMode, + rawJson: String + ) { + if (activeSelectionActionMode !== mode) return + selectionMenuRunnable?.let { scrollStopHandler.removeCallbacks(it) } + selectionMenuRunnable = null + + try { + val selectionDetails = JSONObject(rawJson) + val selectedText = selectionDetails.getString("text") + if (selectedText.isBlank()) { + return + } + + val jsLeft = selectionDetails.getDouble("left") + val jsTop = selectionDetails.getDouble("top") + val jsRight = selectionDetails.getDouble("right") + val jsBottom = selectionDetails.getDouble("bottom") + val jsWidth = selectionDetails.getDouble("width") + val jsHeight = selectionDetails.getDouble("height") + + if (jsWidth <= 0.0 || jsHeight <= 0.0) { + return + } + + val density = context.resources.displayMetrics.density + val webViewLocation = IntArray(2) + getLocationOnScreen(webViewLocation) + val webViewX = webViewLocation[0] + val webViewY = webViewLocation[1] + + val selectionRectScreen = Rect( + (webViewX + jsLeft * density).toInt(), + (webViewY + jsTop * density).toInt(), + (webViewX + jsRight * density).toInt(), + (webViewY + jsBottom * density).toInt() + ) + + if (selectionRectScreen.isEmpty || selectionRectScreen.width() <= 0 || selectionRectScreen.height() <= 0) { + Timber.d("CustomSelection: Bridge supplied invalid rect: $selectionRectScreen. JS LTRB: $jsLeft, $jsTop, $jsRight, $jsBottom") + return + } + + Timber.d("CustomSelection: Bridge selected text '$selectedText', Screen Rect: $selectionRectScreen") + selectionMenuShownForActiveMode = true + onShowCustomSelectionMenu(selectedText, selectionRectScreen) { mode.finish() } + } catch (e: Exception) { + Timber.e(e, "CustomSelection: Error parsing bridge selection details: '$rawJson'") } } @@ -292,6 +461,8 @@ class InteractiveWebView( if (wasDragging) { Timber.d("Drag operation ended, enabling text selection.") evaluateJavascript("javascript:if(window.setTextSelectionEnabled) window.setTextSelectionEnabled(true);", null) + } else if (event.actionMasked == MotionEvent.ACTION_UP) { + scheduleSelectionActionModeRefreshAfterTouch() } } } @@ -306,12 +477,14 @@ class InteractiveWebView( // MIUI can crash inside FloatingToolbar when WindowInsets are null, so WebView // selections use the app's Compose popup without starting the platform toolbar. + override fun startActionMode(originalCallback: ActionMode.Callback): ActionMode? { + Timber.d("CustomSelection: handling primary action mode locally.") + return startLocalSelectionActionMode() + } + override fun startActionMode(originalCallback: ActionMode.Callback, type: Int): ActionMode? { - if (type == ActionMode.TYPE_FLOATING) { - Timber.d("CustomSelection: handling floating action mode locally.") - return startLocalSelectionActionMode() - } - return super.startActionMode(originalCallback, type) + Timber.d("CustomSelection: handling action mode locally. Type: $type") + return startLocalSelectionActionMode() } override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) { @@ -392,4 +565,14 @@ class InteractiveWebView( override fun getMenuInflater(): MenuInflater = menuInflater } + + private inner class ReaderSelectionBridge { + @JavascriptInterface + fun onSelectionChanged(selectionJson: String) { + post { + val mode = startLocalSelectionActionMode(scheduleMenu = false) + showCustomSelectionMenuFromSelectionDetailsJson(mode, selectionJson) + } + } + } } diff --git a/app/src/main/java/com/aryan/reader/feedback/FeedbackViewModel.kt b/app/src/main/java/com/aryan/reader/feedback/FeedbackViewModel.kt index c61a147..00d5541 100644 --- a/app/src/main/java/com/aryan/reader/feedback/FeedbackViewModel.kt +++ b/app/src/main/java/com/aryan/reader/feedback/FeedbackViewModel.kt @@ -22,10 +22,12 @@ package com.aryan.reader.feedback import android.app.Application import android.content.Context import android.net.Uri +import androidx.annotation.StringRes import timber.log.Timber import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.aryan.reader.AuthRepository +import com.aryan.reader.R import com.aryan.reader.data.FeedbackMessage import com.aryan.reader.data.FeedbackRepository import com.aryan.reader.data.FeedbackThread @@ -61,11 +63,15 @@ class FeedbackViewModel(application: Application) : AndroidViewModel(application private var messagesListener: Any? = null private val currentUser = authRepository.getSignedInUser() + private fun string(@StringRes resId: Int, vararg args: Any?): String { + return getApplication().getString(resId, *args) + } + init { if (currentUser != null) { startListeningToThreads(currentUser.uid) } else { - _uiState.update { it.copy(errorMessage = "You must be signed in to use feedback.") } + _uiState.update { it.copy(errorMessage = string(R.string.feedback_error_sign_in_required)) } } } @@ -127,7 +133,7 @@ class FeedbackViewModel(application: Application) : AndroidViewModel(application fun onStartCreateTicket() { if (currentUser == null) { - _uiState.update { it.copy(errorMessage = "You must be signed in to submit feedback.") } + _uiState.update { it.copy(errorMessage = string(R.string.feedback_error_sign_in_submit)) } return } _uiState.update { it.copy(isCreatingTicket = true) } @@ -148,7 +154,7 @@ class FeedbackViewModel(application: Application) : AndroidViewModel(application fun onNewTicketImagesSelected(uris: List) { val current = _uiState.value.newTicketAttachments if (current.size + uris.size > 3) { - _uiState.update { it.copy(errorMessage = "Max 3 images allowed for tickets.") } + _uiState.update { it.copy(errorMessage = string(R.string.feedback_error_ticket_image_limit)) } return } validateAndAddImages(uris) { validUris -> @@ -167,7 +173,7 @@ class FeedbackViewModel(application: Application) : AndroidViewModel(application fun onChatImagesSelected(uris: List) { val current = _uiState.value.chatInputAttachments if (current.size + uris.size > 5) { - _uiState.update { it.copy(errorMessage = "Max 5 images allowed per message.") } + _uiState.update { it.copy(errorMessage = string(R.string.feedback_error_message_image_limit)) } return } validateAndAddImages(uris) { validUris -> @@ -186,7 +192,7 @@ class FeedbackViewModel(application: Application) : AndroidViewModel(application for (uri in uris) { val fileSize = getFileSize(context, uri) if (fileSize > maxFileSize) { - _uiState.update { it.copy(errorMessage = "One or more images exceed the 5MB limit.") } + _uiState.update { it.copy(errorMessage = string(R.string.feedback_error_images_size_limit)) } return } validUris.add(uri) @@ -219,7 +225,7 @@ class FeedbackViewModel(application: Application) : AndroidViewModel(application onThreadSelected(threadId) } catch (e: Exception) { Timber.e(e, "ViewModel: Error submitting ticket") - _uiState.update { it.copy(errorMessage = "Failed to create ticket: ${e.message}") } + _uiState.update { it.copy(errorMessage = string(R.string.feedback_error_create_ticket, e.message.orEmpty())) } } finally { _uiState.update { it.copy(isLoading = false) } } @@ -271,7 +277,7 @@ class FeedbackViewModel(application: Application) : AndroidViewModel(application Timber.e(e, "ViewModel: Error sending message") _uiState.update { it.copy( - errorMessage = "Failed to send: ${e.message}", + errorMessage = string(R.string.feedback_error_send, e.message.orEmpty()), // Updated reference to id pendingMessages = it.pendingMessages.filterNot { msg -> msg.id == messageId }, chatInputMessage = textToSend @@ -290,4 +296,4 @@ class FeedbackViewModel(application: Application) : AndroidViewModel(application repository.removeListener(messagesListener) repository.removeListener(threadsListener) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt b/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt index 9024233..c4fbb80 100644 --- a/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt +++ b/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt @@ -5,6 +5,7 @@ import android.content.Context import android.net.Uri import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.aryan.reader.R import com.aryan.reader.shared.opds.SharedOpdsDownloadNamer import com.aryan.reader.shared.opds.SharedOpdsSearch import kotlinx.coroutines.Dispatchers @@ -54,7 +55,12 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) { } } }.onFailure { e -> - _uiState.update { it.copy(isLoading = false, errorMessage = "Failed to load feed: ${e.message}") } + _uiState.update { + it.copy( + isLoading = false, + errorMessage = getApplication().getString(R.string.opds_error_load_feed, e.message.orEmpty()) + ) + } } } } @@ -83,7 +89,8 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) { val response = client.newCall(request).execute() if (response.isSuccessful) { - val body = response.body ?: throw Exception("Empty body") + val body = response.body + ?: throw IllegalStateException(context.getString(R.string.opds_error_empty_response)) val contentLength = body.contentLength() val ext = resolveOpdsDownloadExtension(acquisition, response) @@ -121,11 +128,11 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) { } } else { Timber.e("Download failed: ${response.code}") - _uiState.update { it.copy(errorMessage = "Download failed: ${response.message}") } + _uiState.update { it.copy(errorMessage = context.getString(R.string.opds_error_download_failed, response.message)) } } } catch (e: Exception) { Timber.e(e, "Download error") - _uiState.update { it.copy(errorMessage = "Download error: ${e.message}") } + _uiState.update { it.copy(errorMessage = context.getString(R.string.opds_error_download_error, e.message.orEmpty())) } } finally { _downloadingState.update { it - entry.id } } 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 30d0293..074509f 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt @@ -255,8 +255,17 @@ private fun headerFontScale(level: Int): Float = when (level) { private const val WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER = 1.2f private const val TAG_STABLE_PAGE_NAV = "StablePageNav" +private const val TAG_PAGINATED_HIGHLIGHT_DIAG = "PaginatedHighlightDiag" private const val EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS = 10_000L +private fun highlightDiagSnippet(text: String, maxLength: Int = 80): String { + return text + .replace('\n', ' ') + .replace('\r', ' ') + .replace('\t', ' ') + .take(maxLength) +} + private fun paginationLineHeightMultiplierForWebViewSetting(multiplier: Float): Float { return if (abs(multiplier - 1.0f) < 0.001f) WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER else multiplier } @@ -325,6 +334,14 @@ private fun isBlockSelectedOnPage( return afterStart && beforeEnd } +internal fun highlightsForPaginatedPage( + pageChapterIndex: Int?, + userHighlights: List +): List { + if (pageChapterIndex == null) return emptyList() + return userHighlights.filter { it.chapterIndex == pageChapterIndex } +} + class ReactiveBlockMap( private val delegate: MutableMap> = mutableStateMapOf() ) : MutableMap> by delegate { @@ -939,6 +956,7 @@ fun PaginatedReaderScreen( when (debouncedTextAlign) { ReaderTextAlign.JUSTIFY -> TextAlign.Justify ReaderTextAlign.LEFT -> TextAlign.Left + ReaderTextAlign.RIGHT -> TextAlign.Right ReaderTextAlign.DEFAULT -> null } } @@ -1266,6 +1284,7 @@ fun PaginatedReaderScreen( } result }, + onGetChapterIndex = { pageIndex -> paginator.findChapterIndexForPage(pageIndex) }, onGetChapterPath = { pageIndex -> paginator.getChapterPathForPage(pageIndex) }, onGetChapterInfo = { pageIndex -> paginator.findChapterIndexForPage(pageIndex)?.let { chapterIndex -> @@ -1522,8 +1541,12 @@ internal fun getHighlightOffsetsInBlock( .takeIf { it > blockStartAbs } ?: (blockStartAbs + block.content.text.length) - Timber.d( - "getHighlightOffsetsInBlock: Checking Block=${block.cfi} (AbsStart=$blockStartAbs) against Highlight=${highlight.cfi}" + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "map_check blockCfi=${block.cfi} blockPath=$blockPath " + + "blockAbs=$blockStartAbs..$blockEndAbs blockLen=${block.content.text.length} " + + "highlightId=${highlight.id} highlightChapter=${highlight.chapterIndex} " + + "highlightCfi=${highlight.cfi} startCfi=$startCfi endCfi=$endCfi " + + "highlightTextLen=${highlight.text.length} highlightText='${highlightDiagSnippet(highlight.text)}'" ) val relevantPart = parts.find { cfiPart -> @@ -1559,8 +1582,8 @@ internal fun getHighlightOffsetsInBlock( } if (relevantPart != null) { - Timber.d( - " -> Block ${block.cfi} matches specific part of multipart highlight: $relevantPart" + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "map_relevant_part blockCfi=${block.cfi} highlightId=${highlight.id} part=$relevantPart" ) } @@ -1573,16 +1596,38 @@ internal fun getHighlightOffsetsInBlock( isMultipartHighlight && CfiUtils.isPathStrictlyBetween(block.cfi!!, startCfi, endCfi!!) - Timber.d(" -> relevantPart=$relevantPart, isIntermediateBlock=$isIntermediateBlock") + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "map_decision blockCfi=${block.cfi} highlightId=${highlight.id} " + + "relevantPart=$relevantPart isIntermediateBlock=$isIntermediateBlock" + ) if (relevantPart == null) { if (!isIntermediateBlock) return null - if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length - if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length + if (highlightText.contains(blockText, ignoreCase = false)) { + val range = 0 until blockText.length + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "map_result reason=intermediate_exact blockCfi=${block.cfi} " + + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range" + ) + return range + } + if (highlightText.contains(blockText, ignoreCase = true)) { + val range = 0 until blockText.length + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "map_result reason=intermediate_exact_ignore_case blockCfi=${block.cfi} " + + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range" + ) + return range + } val normBlock = blockText.filter { !it.isWhitespace() } val normHighlight = highlightText.filter { !it.isWhitespace() } return if (normBlock.isNotBlank() && normHighlight.contains(normBlock, ignoreCase = true)) { - 0 until blockText.length + val range = 0 until blockText.length + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "map_result reason=intermediate_normalized blockCfi=${block.cfi} " + + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range" + ) + range } else { null } @@ -1604,18 +1649,26 @@ internal fun getHighlightOffsetsInBlock( val startMatches = arePathsEquivalent(startCfi, block.cfi!!) val endMatches = if (endCfi != null) arePathsEquivalent(endCfi, block.cfi!!) else false - Timber.d(" -> Path Equivalence: StartMatches=$startMatches, EndMatches=$endMatches") + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "map_path_equivalence blockCfi=${block.cfi} highlightId=${highlight.id} " + + "startMatches=$startMatches endMatches=$endMatches" + ) if (startMatches || endMatches) { val startAbs = CfiUtils.getOffsetOrNull(startCfi) val endAbs = endCfi?.let { CfiUtils.getOffsetOrNull(it) } + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "map_offset_inputs blockCfi=${block.cfi} highlightId=${highlight.id} " + + "blockAbs=$blockStartAbs..$blockEndAbs cfiOffsets=$startAbs..$endAbs" + ) if (startMatches && endMatches && startAbs != null && endAbs != null) { val rangeStartAbs = minOf(startAbs, endAbs) val rangeEndAbs = maxOf(startAbs, endAbs) if (rangeEndAbs <= blockStartAbs || rangeStartAbs >= blockEndAbs) { - Timber.d( - " -> Skipping same-path split block outside highlight offsets. " + - "highlight=$rangeStartAbs..$rangeEndAbs block=$blockStartAbs..$blockEndAbs" + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "map_skip reason=same_path_split_outside_offsets blockCfi=${block.cfi} " + + "highlightId=${highlight.id} highlightAbs=$rangeStartAbs..$rangeEndAbs " + + "blockAbs=$blockStartAbs..$blockEndAbs" ) return null } @@ -1653,8 +1706,9 @@ internal fun getHighlightOffsetsInBlock( val targetRel = relOffset - safeStart val bestRel = matches.minByOrNull { abs(it - targetRel) }!! val newS = safeStart + bestRel - Timber.d( - "Snapped start offset from rel $relOffset to $newS based on prefix '$prefix'" + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "map_snap_start blockCfi=${block.cfi} highlightId=${highlight.id} " + + "fromRel=$relOffset toRel=$newS prefix='$prefix'" ) s = newS snapped = true @@ -1674,8 +1728,9 @@ internal fun getHighlightOffsetsInBlock( val absOffset = endAbs ?: CfiUtils.getOffset(endCfi!!) val relOffset = absOffset - blockStartAbs - Timber.d( - " -> EndCFI Match. AbsOffset: $absOffset. RelOffset: $relOffset. Block Length: ${blockText.length}" + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "map_end_match blockCfi=${block.cfi} highlightId=${highlight.id} " + + "absOffset=$absOffset relOffset=$relOffset blockLen=${blockText.length}" ) e = if (relOffset > blockText.length) { @@ -1689,19 +1744,38 @@ internal fun getHighlightOffsetsInBlock( e = e.coerceIn(0, blockText.length) if (s < e) { - Timber.d("Fallback to CFI offsets for block ${block.cfi}. Range: $s..$e") - return s until e + val range = s until e + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "map_result reason=cfi_offsets blockCfi=${block.cfi} " + + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range" + ) + return range } else { - Timber.w( - " -> Invalid Range detected (likely highlight is on other split part): $s..$e" + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).w( + "map_skip reason=invalid_range blockCfi=${block.cfi} " + + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$s..$e" ) return null } } } - if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length - if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length + if (highlightText.contains(blockText, ignoreCase = false)) { + val range = 0 until blockText.length + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "map_result reason=block_inside_highlight_text blockCfi=${block.cfi} " + + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range" + ) + return range + } + if (highlightText.contains(blockText, ignoreCase = true)) { + val range = 0 until blockText.length + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "map_result reason=block_inside_highlight_text_ignore_case blockCfi=${block.cfi} " + + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range" + ) + return range + } var startIndex = blockText.indexOf(highlightText, ignoreCase = false) if (startIndex == -1) { @@ -1709,15 +1783,27 @@ internal fun getHighlightOffsetsInBlock( } if (startIndex >= 0) { - return startIndex until (startIndex + highlightText.length) + val range = startIndex until (startIndex + highlightText.length) + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "map_result reason=highlight_text_inside_block blockCfi=${block.cfi} " + + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range" + ) + return range } val match = findFuzzyMatch(blockText, highlightText) - if (match != null) return match + if (match != null) { + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "map_result reason=fuzzy_text blockCfi=${block.cfi} " + + "blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$match" + ) + return match + } if (relevantPart != null) { - Timber.d( - "Failed to match highlight text in block despite CFI match. " + "BlockCfi=${block.cfi}, HighlightCfi=${highlight.cfi}. " + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "map_skip reason=cfi_match_text_miss blockCfi=${block.cfi} " + + "highlightId=${highlight.id} highlightCfi=${highlight.cfi}" ) } @@ -1781,6 +1867,17 @@ private fun TextWithEmphasis( val range = getHighlightOffsetsInBlock(block, highlight) if (range != null) { try { + val blockStartAbs = getTextBlockCharOffset(block) + val blockEndAbs = block.endCharOffsetInSource + .takeIf { it > blockStartAbs } + ?: (blockStartAbs + block.content.text.length) + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "draw_highlight page=$pageIndex blockCfi=${block.cfi} " + + "blockIndex=${block.blockIndex} blockAbs=$blockStartAbs..$blockEndAbs " + + "highlightId=${highlight.id} highlightChapter=${highlight.chapterIndex} " + + "highlightCfi=${highlight.cfi} range=$range " + + "blockText='${highlightDiagSnippet(block.content.text)}'" + ) val path = layout.getPathForRange(range.first, range.last + 1) paths.add(path to highlight.color.color.copy(alpha = 0.4f)) if (highlight.cfi == pressedHighlightCfi) { @@ -2064,6 +2161,13 @@ private fun TextWithEmphasis( val range = getHighlightOffsetsInBlock(block, highlight) ?: continue if (charOffset in range) { + val blockStartAbs = getTextBlockCharOffset(block) + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "tap_highlight page=$pageIndex blockCfi=${block.cfi} " + + "blockIndex=${block.blockIndex} blockAbsStart=$blockStartAbs " + + "charOffset=$charOffset absoluteCharOffset=${blockStartAbs + charOffset} " + + "highlightId=${highlight.id} highlightCfi=${highlight.cfi} range=$range" + ) val path = layout.getPathForRange(range.first, range.last) val bounds = path.getBounds() return highlight to bounds @@ -2222,6 +2326,7 @@ internal fun PaginatedReaderContent( horizontalPadding: Dp, verticalPadding: Dp, onGetPage: (Int) -> Page?, + onGetChapterIndex: (Int) -> Int?, onGetChapterPath: (Int) -> String?, onLinkClick: (currentChapterPath: String, href: String, onNavComplete: (Int) -> Unit) -> Unit, onInternalLinkNavigated: (Int) -> Unit, @@ -2393,6 +2498,11 @@ internal fun PaginatedReaderContent( var pageContent by remember { mutableStateOf(null) } var currentChapterPath by remember { mutableStateOf(null) } + val pageChapterIndex = onGetChapterIndex(pageIndex) + val pageUserHighlights = highlightsForPaginatedPage( + pageChapterIndex = pageChapterIndex, + userHighlights = userHighlights + ) val themedPageContent = remember(pageContent, isDarkTheme, effectiveBg, effectiveText) { pageContent?.applyReaderThemeForDisplay( isDarkTheme = isDarkTheme, @@ -2401,6 +2511,15 @@ internal fun PaginatedReaderContent( ) } + if (pageUserHighlights.size != userHighlights.size) { + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "page_scope page=$pageIndex pageChapter=$pageChapterIndex " + + "inputHighlightCount=${userHighlights.size} " + + "pageHighlightCount=${pageUserHighlights.size} " + + "inputHighlightChapters=${userHighlights.map { it.chapterIndex }.distinct()}" + ) + } + LaunchedEffect(pageIndex, uiState.generation) { val fetchStartTime = System.currentTimeMillis() Timber.tag("PageTurnDiag").d("Page $pageIndex: Starting content fetch") @@ -2856,7 +2975,7 @@ internal fun PaginatedReaderContent( onLinkClick = onLinkClickCallback, onGeneralTap = onGeneralTapCallback, block = block, - userHighlights = userHighlights, + userHighlights = pageUserHighlights, activeSelection = activeSelection, onSelectionChange = { sel -> activeSelection = sel @@ -2939,7 +3058,7 @@ internal fun PaginatedReaderContent( onLinkClick = onLinkClickCallback, onGeneralTap = onGeneralTapCallback, block = block, - userHighlights = userHighlights, + userHighlights = pageUserHighlights, activeSelection = activeSelection, onSelectionChange = { sel -> activeSelection = sel @@ -3025,7 +3144,7 @@ internal fun PaginatedReaderContent( onLinkClick = onLinkClickCallback, onGeneralTap = onGeneralTapCallback, block = block, - userHighlights = userHighlights, + userHighlights = pageUserHighlights, activeSelection = activeSelection, onSelectionChange = { sel -> activeSelection = sel @@ -3142,7 +3261,7 @@ internal fun PaginatedReaderContent( onLinkClick = onLinkClickCallback, onGeneralTap = onGeneralTapCallback, block = block, - userHighlights = userHighlights, + userHighlights = pageUserHighlights, activeSelection = activeSelection, onSelectionChange = { sel -> activeSelection = sel @@ -3210,7 +3329,7 @@ internal fun PaginatedReaderContent( textMeasurer = textMeasurer, onLinkClickCallback = onLinkClickCallback, onGeneralTapCallback = onGeneralTapCallback, - userHighlights = userHighlights, + userHighlights = pageUserHighlights, activeSelection = activeSelection, onSelectionChange = { sel -> activeSelection = @@ -3263,7 +3382,7 @@ internal fun PaginatedReaderContent( textMeasurer = textMeasurer, onLinkClickCallback = onLinkClickCallback, onGeneralTapCallback = onGeneralTapCallback, - userHighlights = userHighlights, + userHighlights = pageUserHighlights, activeSelection = activeSelection, onSelectionChange = { sel -> activeSelection = @@ -3838,15 +3957,45 @@ internal fun PaginatedReaderContent( activeSelection = null }, onHighlight = { color -> + val startAbsoluteOffset = sel.startBlockCharOffset + sel.startOffset + val endAbsoluteOffset = sel.endBlockCharOffset + sel.endOffset val finalCfi = "${sel.startBaseCfi}:${sel.startOffset}|${sel.endBaseCfi}:${sel.endOffset}" + val absoluteCandidateCfi = + "${sel.startBaseCfi}:$startAbsoluteOffset|${sel.endBaseCfi}:$endAbsoluteOffset" + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "create_request source=highlight_menu color=${color.id} " + + "savedCfi=$finalCfi absoluteCandidateCfi=$absoluteCandidateCfi " + + "startPage=${sel.startPageIndex} endPage=${sel.endPageIndex} " + + "startBlockIndex=${sel.startBlockIndex} endBlockIndex=${sel.endBlockIndex} " + + "startBaseCfi=${sel.startBaseCfi} endBaseCfi=${sel.endBaseCfi} " + + "localOffsets=${sel.startOffset}..${sel.endOffset} " + + "blockAbsStarts=${sel.startBlockCharOffset}..${sel.endBlockCharOffset} " + + "absoluteOffsets=$startAbsoluteOffset..$endAbsoluteOffset " + + "textLen=${sel.text.length} text='${highlightDiagSnippet(sel.text)}'" + ) onHighlightCreated(finalCfi, sel.text, color.id) activeSelection = null }, onNote = { onNoteRequested(null) + val startAbsoluteOffset = sel.startBlockCharOffset + sel.startOffset + val endAbsoluteOffset = sel.endBlockCharOffset + sel.endOffset val finalCfi = "${sel.startBaseCfi}:${sel.startOffset}|${sel.endBaseCfi}:${sel.endOffset}" + val absoluteCandidateCfi = + "${sel.startBaseCfi}:$startAbsoluteOffset|${sel.endBaseCfi}:$endAbsoluteOffset" + Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( + "create_request source=note_menu color=${HighlightColor.YELLOW.id} " + + "savedCfi=$finalCfi absoluteCandidateCfi=$absoluteCandidateCfi " + + "startPage=${sel.startPageIndex} endPage=${sel.endPageIndex} " + + "startBlockIndex=${sel.startBlockIndex} endBlockIndex=${sel.endBlockIndex} " + + "startBaseCfi=${sel.startBaseCfi} endBaseCfi=${sel.endBaseCfi} " + + "localOffsets=${sel.startOffset}..${sel.endOffset} " + + "blockAbsStarts=${sel.startBlockCharOffset}..${sel.endBlockCharOffset} " + + "absoluteOffsets=$startAbsoluteOffset..$endAbsoluteOffset " + + "textLen=${sel.text.length} text='${highlightDiagSnippet(sel.text)}'" + ) onHighlightCreated(finalCfi, sel.text, HighlightColor.YELLOW.id) activeSelection = null }, diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfDrawer.kt b/app/src/main/java/com/aryan/reader/pdf/PdfDrawer.kt index a6f6a3d..e7265e6 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfDrawer.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfDrawer.kt @@ -29,6 +29,9 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.AlertDialog @@ -40,6 +43,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.ScrollableTabRow @@ -58,6 +62,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.res.stringResource @@ -76,6 +81,7 @@ import kotlinx.coroutines.withContext import org.json.JSONArray import timber.log.Timber import androidx.core.graphics.createBitmap +import com.aryan.reader.data.RecentFileItem import com.aryan.reader.pdf.data.VirtualPage private const val MAX_FIXED_RECURSION = 128 @@ -84,6 +90,32 @@ internal data class PdfBookmark(val pageIndex: Int, val title: String, val total internal data class TocEntry(val title: String, val pageIndex: Int, val nestLevel: Int) +private enum class PdfDrawerSection { + TABS, + CHAPTERS, + BOOKMARKS, + HIGHLIGHTS, + PAGES +} + +private val PdfDrawerSection.titleResId: Int + get() = when (this) { + PdfDrawerSection.TABS -> R.string.tab_tabs + PdfDrawerSection.CHAPTERS -> R.string.tab_chapters + PdfDrawerSection.BOOKMARKS -> R.string.tab_bookmarks + PdfDrawerSection.HIGHLIGHTS -> R.string.tab_highlights + PdfDrawerSection.PAGES -> R.string.tab_pages + } + +private val PdfDrawerSection.testTag: String? + get() = when (this) { + PdfDrawerSection.TABS -> "TabsTab" + PdfDrawerSection.BOOKMARKS -> "BookmarksTab" + PdfDrawerSection.HIGHLIGHTS -> "HighlightsTab" + PdfDrawerSection.PAGES -> "PagesTab" + PdfDrawerSection.CHAPTERS -> null + } + /** * Patches the library bug where siblings are truncated due to depth-state leakage. */ @@ -278,6 +310,225 @@ internal fun PdfTocTreeItem( } } +@Composable +private fun PdfTabsDrawerPage( + openTabs: List, + activeTabBookId: String?, + currentPage: Int, + totalPages: Int, + onTabSelected: (String) -> Unit, + onTabClosed: (String) -> Unit, + onNewTabClick: () -> Unit +) { + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 10.dp, end = 8.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.active_tabs), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) { + Text( + text = openTabs.size.toString(), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp) + ) + } + IconButton( + onClick = onNewTabClick, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.content_desc_new_tab) + ) + } + } + + HorizontalDivider() + + if (openTabs.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize().padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.msg_no_other_pdfs_found), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(vertical = 8.dp) + ) { + items(openTabs, key = { it.bookId }) { tab -> + PdfDrawerTabItem( + tab = tab, + isSelected = tab.bookId == activeTabBookId, + currentPage = currentPage, + totalPages = totalPages, + onTabSelected = onTabSelected, + onTabClosed = onTabClosed + ) + } + } + } + } +} + +@Composable +private fun PdfDrawerTabItem( + tab: RecentFileItem, + isSelected: Boolean, + currentPage: Int, + totalPages: Int, + onTabSelected: (String) -> Unit, + onTabClosed: (String) -> Unit +) { + val shape = RoundedCornerShape(8.dp) + val containerColor by animateColorAsState( + targetValue = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f) + } else { + MaterialTheme.colorScheme.surface + }, + label = "PdfDrawerTabContainer" + ) + val borderColor by animateColorAsState( + targetValue = if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.45f) + } else { + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f) + }, + label = "PdfDrawerTabBorder" + ) + val contentColor = if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } + val progressPercent = remember(isSelected, currentPage, totalPages, tab.progressPercentage) { + when { + isSelected && totalPages > 0 -> (((currentPage + 1).toFloat() / totalPages.toFloat()) * 100f) + .coerceIn(0f, 100f) + .toInt() + else -> tab.progressPercentage + ?.coerceIn(0f, 100f) + ?.toInt() + } + } + val pageLabel = when { + isSelected && totalPages > 0 -> stringResource(R.string.page_of_pages, currentPage + 1, totalPages) + tab.lastPage != null -> stringResource(R.string.pdf_page_short, tab.lastPage + 1) + else -> null + } + val progressLabel = progressPercent + ?.takeIf { it > 0 } + ?.let { stringResource(R.string.progress_complete, it) } + val supportingText = remember(pageLabel, progressLabel, tab.author) { + listOfNotNull(pageLabel, progressLabel, tab.author) + .distinct() + .joinToString(" - ") + } + + Column( + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 4.dp) + .clip(shape) + .background(containerColor) + .border(1.dp, borderColor, shape) + .clickable { onTabSelected(tab.bookId) } + .testTag("PdfDrawerTab_${tab.bookId}") + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 68.dp) + .padding(start = 12.dp, end = 6.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(8.dp)) + .background( + if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.16f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f) + } + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Description, + contentDescription = null, + tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = tab.customName ?: tab.title ?: tab.displayName, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, + color = contentColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + if (supportingText.isNotBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = supportingText, + style = MaterialTheme.typography.bodySmall, + color = contentColor.copy(alpha = 0.72f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + IconButton( + onClick = { onTabClosed(tab.bookId) }, + modifier = Modifier.size(36.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.close_tab), + tint = contentColor.copy(alpha = 0.8f) + ) + } + } + + progressPercent + ?.takeIf { it > 0 } + ?.let { percent -> + LinearProgressIndicator( + progress = { percent / 100f }, + modifier = Modifier.fillMaxWidth().height(3.dp), + color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary, + trackColor = Color.Transparent + ) + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun PdfNavigationDrawerContent( @@ -288,58 +539,81 @@ internal fun PdfNavigationDrawerContent( userHighlights: List, currentPage: Int, totalPages: Int, + isTabsEnabled: Boolean = false, + openTabs: List = emptyList(), + activeTabBookId: String? = null, customHighlightColors: Map, onPageSelected: (Int) -> Unit, + onTabSelected: (String) -> Unit = {}, + onTabClosed: (String) -> Unit = {}, + onNewTabClick: () -> Unit = {}, onRenameBookmark: (PdfBookmark, String) -> Unit, onDeleteBookmark: (PdfBookmark) -> Unit, onDeleteHighlight: (PdfUserHighlight) -> Unit, onNoteRequested: (String?) -> Unit, onCloseDrawer: () -> Unit ) { - val drawerPagerState = rememberPagerState(pageCount = { 4 }) + val showTabsPane = isTabsEnabled && openTabs.isNotEmpty() + val drawerSections = remember(showTabsPane) { + buildList { + if (showTabsPane) add(PdfDrawerSection.TABS) + add(PdfDrawerSection.CHAPTERS) + add(PdfDrawerSection.BOOKMARKS) + add(PdfDrawerSection.HIGHLIGHTS) + add(PdfDrawerSection.PAGES) + } + } + val drawerPagerState = rememberPagerState(pageCount = { drawerSections.size }) val drawerScope = rememberCoroutineScope() + LaunchedEffect(drawerSections.size) { + if (drawerPagerState.currentPage >= drawerSections.size) { + drawerPagerState.scrollToPage(drawerSections.lastIndex.coerceAtLeast(0)) + } + } + Column(modifier = Modifier.fillMaxSize()) { + val selectedDrawerTabIndex = drawerPagerState.currentPage.coerceIn(0, drawerSections.lastIndex) ScrollableTabRow( - selectedTabIndex = drawerPagerState.currentPage, + selectedTabIndex = selectedDrawerTabIndex, edgePadding = 8.dp, modifier = Modifier.fillMaxWidth() ) { - Tab(selected = drawerPagerState.currentPage == 0, onClick = { - drawerScope.launch { drawerPagerState.animateScrollToPage(0) } - }, text = { Text(stringResource(R.string.tab_chapters)) }) - Tab( - selected = drawerPagerState.currentPage == 1, - onClick = { - drawerScope.launch { drawerPagerState.animateScrollToPage(1) } - }, - text = { Text(stringResource(R.string.tab_bookmarks)) }, - modifier = Modifier.testTag("BookmarksTab") - ) - Tab( - selected = drawerPagerState.currentPage == 2, - onClick = { - drawerScope.launch { drawerPagerState.animateScrollToPage(2) } - }, - text = { Text(stringResource(R.string.tab_highlights)) }, - modifier = Modifier.testTag("HighlightsTab") - ) - Tab( - selected = drawerPagerState.currentPage == 3, - onClick = { - drawerScope.launch { drawerPagerState.animateScrollToPage(3) } - }, - text = { Text(stringResource(R.string.tab_pages)) }, - modifier = Modifier.testTag("PagesTab") - ) + drawerSections.forEachIndexed { index, section -> + Tab( + selected = selectedDrawerTabIndex == index, + onClick = { + drawerScope.launch { drawerPagerState.animateScrollToPage(index) } + }, + text = { Text(stringResource(section.titleResId)) }, + modifier = section.testTag?.let { Modifier.testTag(it) } ?: Modifier + ) + } } HorizontalPager( state = drawerPagerState, modifier = Modifier.fillMaxWidth().weight(1f) ) { page -> - when (page) { - 0 -> { // Chapters Page + when (drawerSections[page]) { + PdfDrawerSection.TABS -> PdfTabsDrawerPage( + openTabs = openTabs, + activeTabBookId = activeTabBookId, + currentPage = currentPage, + totalPages = totalPages, + onTabSelected = { bookId -> + if (bookId == activeTabBookId) { + onCloseDrawer() + } else { + onCloseDrawer() + onTabSelected(bookId) + } + }, + onTabClosed = onTabClosed, + onNewTabClick = onNewTabClick + ) + + PdfDrawerSection.CHAPTERS -> { // Chapters Page if (flatTableOfContents.isEmpty()) { Box( modifier = Modifier.fillMaxSize().padding(16.dp), @@ -500,7 +774,7 @@ internal fun PdfNavigationDrawerContent( } } - 1 -> { // Bookmarks Page + PdfDrawerSection.BOOKMARKS -> { // Bookmarks Page if (bookmarks.isEmpty()) { Box( modifier = Modifier @@ -642,7 +916,7 @@ internal fun PdfNavigationDrawerContent( } } } - 2 -> { // Highlights Page + PdfDrawerSection.HIGHLIGHTS -> { // Highlights Page if (userHighlights.isEmpty()) { Box( modifier = Modifier @@ -801,7 +1075,7 @@ internal fun PdfNavigationDrawerContent( } } } - 3 -> { // Pages Page + PdfDrawerSection.PAGES -> { // Pages Page val listState = rememberLazyListState() val pageRows = remember(totalPages) { (0 until totalPages).chunked(3) } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt b/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt index 97e1dde..c68c492 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt @@ -79,6 +79,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -236,6 +237,9 @@ internal fun PdfSelectionMenuPopup( onNote: (() -> Unit)? = null ) { val context = LocalContext.current + val configuration = LocalConfiguration.current + val selectionMenuMaxHeight = (configuration.screenHeightDp.dp - 32.dp).coerceAtLeast(160.dp) + val menuScrollState = rememberScrollState() Popup( popupPositionProvider = popupPositionProvider, @@ -251,9 +255,15 @@ internal fun PdfSelectionMenuPopup( shadowElevation = 8.dp, color = MaterialTheme.colorScheme.surface, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), - modifier = Modifier.widthIn(max = 300.dp) + modifier = Modifier + .widthIn(max = 280.dp) + .heightIn(max = selectionMenuMaxHeight) ) { - Column(modifier = if (menuState.isComment) Modifier.fillMaxWidth() else Modifier.width(IntrinsicSize.Max)) { + Column( + modifier = (if (menuState.isComment) Modifier.fillMaxWidth() else Modifier.width(IntrinsicSize.Max)) + .heightIn(max = selectionMenuMaxHeight) + .verticalScroll(menuScrollState) + ) { if (!menuState.note.isNullOrBlank()) { Surface( color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), @@ -330,7 +340,7 @@ internal fun PdfSelectionMenuPopup( } } else { Row( - modifier = Modifier.padding(vertical = 12.dp, horizontal = 12.dp) + modifier = Modifier.padding(vertical = 8.dp, horizontal = 10.dp) .fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically @@ -338,7 +348,7 @@ internal fun PdfSelectionMenuPopup( PdfHighlightColor.entries.forEach { colorEnum -> val displayColor = customHighlightColors[colorEnum] ?: colorEnum.color Box( - modifier = Modifier.padding(horizontal = 6.dp).size(32.dp) + modifier = Modifier.padding(horizontal = 4.dp).size(28.dp) .background(displayColor, CircleShape).clip(CircleShape) .clickable { Timber.tag("PdfHighlightDebug") @@ -352,8 +362,8 @@ internal fun PdfSelectionMenuPopup( ) Box( modifier = Modifier - .padding(horizontal = 6.dp) - .size(32.dp) + .padding(horizontal = 4.dp) + .size(28.dp) .clip(CircleShape) .background(Brush.sweepGradient(rainbowColors)) .clickable { onPaletteClick() }, @@ -392,7 +402,7 @@ internal fun PdfSelectionMenuPopup( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), + .padding(horizontal = 6.dp, vertical = 3.dp), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically ) { @@ -400,22 +410,22 @@ internal fun PdfSelectionMenuPopup( val tint = if (action.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface Column( modifier = Modifier - .width(64.dp) + .width(56.dp) .clickable { action.onClick() } - .padding(vertical = 8.dp), + .padding(vertical = 6.dp), horizontalAlignment = Alignment.CenterHorizontally ) { if (action.imageVector != null) { - Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp)) + Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(22.dp)) } else if (action.iconRes != null) { - Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp)) + Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(22.dp)) } - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(2.dp)) Text(text = action.label, style = MaterialTheme.typography.labelSmall, color = tint, maxLines = 1) } } repeat(3 - rowActions.size) { - Spacer(modifier = Modifier.width(64.dp)) + Spacer(modifier = Modifier.width(56.dp)) } } } 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 def8f77..7bd4f57 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt @@ -131,6 +131,10 @@ import com.aryan.reader.pdf.data.PdfTextBox import com.aryan.reader.pdf.data.VirtualPage import com.aryan.reader.pdf.ocr.OcrElement import com.aryan.reader.pdf.ocr.OcrResult +import com.aryan.reader.shared.ui.SharedSelectionMenuRect +import com.aryan.reader.shared.ui.SharedSelectionMenuSize +import com.aryan.reader.shared.ui.SharedSelectionMenuViewport +import com.aryan.reader.shared.ui.sharedSelectionMenuPlacement import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -2139,7 +2143,7 @@ internal fun PdfPageComposable( val dragEventChannel = Channel(Channel.CONFLATED) - coroutineScope.launch(Dispatchers.IO) { + val dragWorker = coroutineScope.launch(Dispatchers.IO) { var pageForDrag: ReaderPage? = null var textPageForDrag: ReaderTextPage? = null @@ -2473,106 +2477,107 @@ internal fun PdfPageComposable( } finally { dragEventChannel.close() } + dragWorker.invokeOnCompletion { + coroutineScope.launch { + if (selectionMethodUsed == PdfSelectionMethod.PDFIUM) { + if (selectionCharRange.value != null && selectedWordScreenRects.isNotEmpty()) { + val currentRange = selectionCharRange.value!! + var pageForMenu: ReaderPage? = null + var textPageForMenu: ReaderTextPage? = null + try { + val text = withContext(Dispatchers.IO) { + pageForMenu = pdfDocumentItem.openPage(pdfPageIndex) + textPageForMenu = pageForMenu?.openTextPage() + textPageForMenu?.textPageGetText( + currentRange.first, + currentRange.second - currentRange.first + ) + } + if (!text.isNullOrBlank()) { + val combinedRect = Rect(selectedWordScreenRects.first()) + selectedWordScreenRects.forEach { combinedRect.union(it) } - if (selectionMethodUsed == PdfSelectionMethod.PDFIUM) { - if (selectionCharRange.value != null && selectedWordScreenRects.isNotEmpty()) { - val currentRange = selectionCharRange.value!! - coroutineScope.launch { - var pageForMenu: ReaderPage? = null - var textPageForMenu: ReaderTextPage? = null - try { - val text = withContext(Dispatchers.IO) { - pageForMenu = pdfDocumentItem.openPage(pdfPageIndex) - textPageForMenu = pageForMenu?.openTextPage() - textPageForMenu?.textPageGetText( - currentRange.first, - currentRange.second - currentRange.first + customMenuState = CustomPdfMenuState( + selectedText = text, + anchorRect = combinedRect, + charRange = currentRange + ) + Timber.d( + "Menu shown after drag. Anchor: ${customMenuState?.anchorRect}" + ) + } else { + customMenuState = null + } + } catch (e: Exception) { + Timber.e( + e, "Error fetching text for menu after drag" ) + customMenuState = null + } finally { + withContext(NonCancellable) { + withContext(Dispatchers.IO) { + try { + textPageForMenu?.close() + } catch (_: Exception) { + } + try { + pageForMenu?.close() + } catch (_: Exception) { + } + } + } } - if (!text.isNullOrBlank()) { - val combinedRect = Rect(selectedWordScreenRects.first()) - selectedWordScreenRects.forEach { combinedRect.union(it) } + } else { + customMenuState = null + } + } else { + if (ocrSelectionSymbolIndices != null && selectedWordScreenRects.isNotEmpty()) { + val indices = ocrSelectionSymbolIndices!! + val selectedSymbolInfos = allOcrSymbolsForSelection.subList( + indices.first, indices.second + ) + if (selectedSymbolInfos.isNotEmpty()) { + val selectedText = buildString { + selectedSymbolInfos.forEachIndexed { index, info -> + append(info.symbol.text) + + if (index < selectedSymbolInfos.size - 1) { + val nextInfo = selectedSymbolInfos[index + 1] + + if (info.parentLine !== nextInfo.parentLine) { + append('\n') + } else if (info.parentElement !== nextInfo.parentElement) { + append(' ') + } + } + } + } + val firstRect = selectedSymbolInfos.first().symbol.boundingBox!! + val combinedRect = Rect(firstRect) + selectedSymbolInfos.forEach { info -> info.symbol.boundingBox?.let { combinedRect.union(it) } } customMenuState = CustomPdfMenuState( - selectedText = text, + selectedText = selectedText, anchorRect = combinedRect, - charRange = currentRange + charRange = Pair(indices.first, indices.second) ) Timber.d( - "Menu shown after drag. Anchor: ${customMenuState?.anchorRect}" + "Menu shown after OCR drag. Anchor: ${customMenuState?.anchorRect}" ) } else { customMenuState = null } - } catch (e: Exception) { - Timber.e( - e, "Error fetching text for menu after drag" - ) + } else { customMenuState = null - } finally { - withContext(NonCancellable) { - withContext(Dispatchers.IO) { - try { - textPageForMenu?.close() - } catch (_: Exception) { - } - try { - pageForMenu?.close() - } catch (_: Exception) { - } - } - } } } - } else { - customMenuState = null - } - } else { - if (ocrSelectionSymbolIndices != null && selectedWordScreenRects.isNotEmpty()) { - val indices = ocrSelectionSymbolIndices!! - val selectedSymbolInfos = allOcrSymbolsForSelection.subList( - indices.first, indices.second + activeDraggingHandle = null + showMagnifier = false + Timber.d( + "PointerInput: Drag on handle completed/cancelled. Menu state: $customMenuState" ) - if (selectedSymbolInfos.isNotEmpty()) { - val selectedText = buildString { - selectedSymbolInfos.forEachIndexed { index, info -> - append(info.symbol.text) - - if (index < selectedSymbolInfos.size - 1) { - val nextInfo = selectedSymbolInfos[index + 1] - - if (info.parentLine !== nextInfo.parentLine) { - append('\n') - } else if (info.parentElement !== nextInfo.parentElement) { - append(' ') - } - } - } - } - val firstRect = selectedSymbolInfos.first().symbol.boundingBox!! - val combinedRect = Rect(firstRect) - selectedSymbolInfos.forEach { info -> info.symbol.boundingBox?.let { combinedRect.union(it) } } - - customMenuState = CustomPdfMenuState( - selectedText = selectedText, - anchorRect = combinedRect, - charRange = Pair(indices.first, indices.second) - ) - Timber.d( - "Menu shown after OCR drag. Anchor: ${customMenuState?.anchorRect}" - ) - } else { - customMenuState = null - } - } else { - customMenuState = null } } - activeDraggingHandle = null - showMagnifier = false - Timber.d( - "PointerInput: Drag on handle completed/cancelled. Menu state: $customMenuState" - ) } else { val longPressTimeout = viewConfiguration.longPressTimeoutMillis try { @@ -5632,22 +5637,20 @@ private fun PdfPageRenderer( val topLeftWindow = coords.localToWindow(topLeftLocal) val bottomRightWindow = coords.localToWindow(bottomRightLocal) - val windowCenterX = (topLeftWindow.x + bottomRightWindow.x) / 2 val gapPx = with(density) { 16.dp.toPx() } - - var yInWindow = (topLeftWindow.y - popupContentSize.height - gapPx).toInt() - - if (yInWindow < 0) { - yInWindow = (bottomRightWindow.y + gapPx).toInt() - if (yInWindow + popupContentSize.height > windowSize.height) { - yInWindow = windowSize.height - popupContentSize.height - gapPx.toInt() - } - } - - val xInWindow = (windowCenterX - popupContentSize.width / 2).toInt() - .coerceIn(0, windowSize.width - popupContentSize.width) - - return IntOffset(xInWindow, yInWindow) + val placement = sharedSelectionMenuPlacement( + viewport = SharedSelectionMenuViewport(windowSize.width, windowSize.height), + popup = SharedSelectionMenuSize(popupContentSize.width, popupContentSize.height), + selection = SharedSelectionMenuRect( + left = topLeftWindow.x, + top = topLeftWindow.y, + right = bottomRightWindow.x, + bottom = bottomRightWindow.y + ), + marginPx = gapPx, + gapPx = gapPx + ) + return IntOffset(placement.x, placement.y) } } } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt index 85c0939..b1094fd 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt @@ -1,11 +1,13 @@ package com.aryan.reader.pdf import android.content.Context +import androidx.annotation.StringRes import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.core.content.edit import com.aryan.reader.BuildConfig +import com.aryan.reader.R import com.aryan.reader.ReaderTheme import com.aryan.reader.ReaderTexture import com.aryan.reader.epubreader.SystemUiMode @@ -49,33 +51,46 @@ internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug" private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY = "pdf_hidden_tools_defaults_version" private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION = 2 -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"), - VISUAL_OPTIONS("Visual Options", "Overflow Menu"), - TAP_TO_TURN("Tap to Turn Pages", "Overflow Menu"), - 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"), - SCREEN_ORIENTATION("Screen Orientation", "Top Bar"), - AUTO_SCROLL("Auto Scroll", "Overflow Menu"), - TTS_SETTINGS("TTS Settings", "Overflow Menu"), - TTS_REPLACEMENTS("TTS Word Replacements", "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") +enum class PdfReaderTool(@StringRes val titleRes: Int, val category: String) { + DICTIONARY(R.string.tool_external_apps, "Top Bar"), + THEME(R.string.tooltip_theme_desc, "Top Bar"), + LOCK_PANNING(R.string.tooltip_lock_pan, "Top Bar"), + VISUAL_OPTIONS(R.string.menu_visual_options, "Overflow Menu"), + TAP_TO_TURN(R.string.menu_tap_to_turn_pages, "Overflow Menu"), + FULL_SCREEN(R.string.tooltip_fullscreen, "Top Bar"), + SLIDER(R.string.tool_navigation_slider, "Bottom Bar"), + TOC(R.string.tool_sidebar, "Bottom Bar"), + SEARCH(R.string.action_search, "Bottom Bar"), + HIGHLIGHT_ALL(R.string.tool_highlight_selectable_text, "Bottom Bar"), + AI_FEATURES(R.string.ai_features_title, "Bottom Bar"), + EDIT_MODE(R.string.tool_edit_mode, "Bottom Bar"), + TTS_CONTROLS(R.string.tool_tts_controls, "Bottom Bar"), + OCR_LANGUAGE(R.string.menu_ocr_language, "Overflow Menu"), + READING_MODE(R.string.tool_reading_mode, "Overflow Menu"), + KEEP_SCREEN_ON(R.string.menu_keep_screen_on, "Overflow Menu"), + SCREEN_ORIENTATION(R.string.menu_screen_orientation, "Top Bar"), + AUTO_SCROLL(R.string.menu_auto_scroll, "Overflow Menu"), + TTS_SETTINGS(R.string.menu_tts_settings, "Overflow Menu"), + TTS_REPLACEMENTS(R.string.menu_tts_word_replacements, "Overflow Menu"), + BOOKMARK(R.string.content_desc_bookmark, "Overflow Menu"), + PAGE_MANAGEMENT(R.string.tool_page_management, "Overflow Menu"), + REFLOW(R.string.tool_text_view_reflow, "Overflow Menu"), + SHARE(R.string.action_share, "Overflow Menu"), + SAVE_COPY(R.string.action_save_copy_to_device, "Overflow Menu"), + PRINT(R.string.action_print, "Overflow Menu") +} + +internal fun defaultPdfHiddenTools(): Set { + return setOf( + PdfReaderTool.SCREEN_ORIENTATION.name, + PdfReaderTool.HIGHLIGHT_ALL.name + ) +} + +internal fun defaultPdfToolOrder(): List = PdfReaderTool.entries.toList() + +internal fun defaultPdfBottomTools(): Set { + return PdfReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet() } val PdfBuiltInThemes = listOf( @@ -99,10 +114,7 @@ internal fun loadPdfHiddenTools(context: Context): Set { val savedHiddenTools = prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()).orEmpty() val defaultsVersion = prefs.getInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0) if (defaultsVersion < PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) { - val migratedHiddenTools = savedHiddenTools + setOf( - PdfReaderTool.SCREEN_ORIENTATION.name, - PdfReaderTool.HIGHLIGHT_ALL.name - ) + val migratedHiddenTools = savedHiddenTools + defaultPdfHiddenTools() prefs.edit { putStringSet(PDF_HIDDEN_TOOLS_KEY, migratedHiddenTools) putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) @@ -127,7 +139,7 @@ internal fun loadPdfToolOrder(context: Context): List { ?.filter { it.isNotBlank() } ?.mapNotNull { name -> PdfReaderTool.entries.firstOrNull { it.name == name } } .orEmpty() - return (savedTools + PdfReaderTool.entries.filterNot { it in savedTools }).distinct() + return (savedTools + defaultPdfToolOrder().filterNot { it in savedTools }).distinct() } internal fun savePdfToolOrder(context: Context, toolOrder: List) { @@ -137,7 +149,7 @@ internal fun savePdfToolOrder(context: Context, toolOrder: List) internal fun loadPdfBottomTools(context: Context): Set { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - val defaultBottomTools = PdfReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet() + val defaultBottomTools = defaultPdfBottomTools() return prefs.getStringSet(PDF_BOTTOM_TOOLS_KEY, defaultBottomTools) ?: defaultBottomTools } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt b/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt index 0d90671..490c57b 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt @@ -3,6 +3,7 @@ package com.aryan.reader.pdf import androidx.compose.foundation.clickable +import androidx.annotation.StringRes import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -27,6 +28,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close @@ -34,6 +36,7 @@ import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ScreenRotation @@ -45,6 +48,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -77,7 +81,8 @@ data class PdfFlatToolItem( val type: PdfFlatItemType, val tool: PdfReaderTool? = null, val section: PdfToolbarSection? = null, - val title: String? = null + val title: String? = null, + @StringRes val titleRes: Int? = null ) fun sanitizePdfPlaceholders(list: List): List { @@ -92,7 +97,7 @@ fun sanitizePdfPlaceholders(list: List): List } PdfToolbarSection.entries.forEach { section -> - result.add(PdfFlatToolItem("header_${section.name}", PdfFlatItemType.SECTION_HEADER, section = section, title = section.title)) + result.add(PdfFlatToolItem("header_${section.name}", PdfFlatItemType.SECTION_HEADER, section = section, titleRes = section.titleRes)) val tools = sectionMap[section] ?: emptyList() if (tools.isEmpty()) { result.add(PdfFlatToolItem("empty_${section.name}", PdfFlatItemType.EMPTY_PLACEHOLDER, section = section)) @@ -108,6 +113,51 @@ fun sanitizePdfPlaceholders(list: List): List return result } +private val pdfReorderableToolbarTools = setOf( + PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.LOCK_PANNING, + PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH, + PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES, + PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS, + PdfReaderTool.SCREEN_ORIENTATION +) + +internal fun buildPdfToolbarItems( + hiddenTools: Set, + toolOrder: List, + bottomTools: Set +): List { + val toolbarTools = toolOrder.filter { it in pdfReorderableToolbarTools } + val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) } + val bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) } + val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) } + val moreTools = toolOrder.filter { it !in pdfReorderableToolbarTools } + + val list = mutableListOf() + + PdfToolbarSection.entries.forEach { section -> + val tools = when (section) { + PdfToolbarSection.TOP -> topTools + PdfToolbarSection.BOTTOM -> bottomToolsList + PdfToolbarSection.HIDDEN -> hiddenToolsList + } + list.add(PdfFlatToolItem("header_${section.name}", PdfFlatItemType.SECTION_HEADER, section = section, titleRes = section.titleRes)) + if (tools.isEmpty()) { + list.add(PdfFlatToolItem("empty_${section.name}", PdfFlatItemType.EMPTY_PLACEHOLDER, section = section)) + } else { + tools.forEach { tool -> + list.add(PdfFlatToolItem("tool_${tool.name}", PdfFlatItemType.TOOL, tool = tool, section = section)) + } + } + } + + list.add(PdfFlatToolItem("more_header", PdfFlatItemType.MORE_HEADER, titleRes = R.string.toolbar_more_menu)) + moreTools.forEach { tool -> + list.add(PdfFlatToolItem("more_${tool.name}", PdfFlatItemType.MORE_TOOL, tool = tool)) + } + + return list +} + class PdfDragDropState( val lazyListState: LazyListState, val onMove: (String, String) -> Unit @@ -142,54 +192,20 @@ fun PdfCustomizeToolsSheet( onPlacementUpdate: (Set) -> Unit, onDismiss: () -> Unit ) { - val reorderableToolbarTools = setOf( - PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.LOCK_PANNING, - PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH, - PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES, - PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS, - PdfReaderTool.SCREEN_ORIENTATION - ) - var localHiddenTools by remember { mutableStateOf(hiddenTools) } var flatItems by remember { mutableStateOf>( - run { - val toolbarTools = toolOrder.filter { it in reorderableToolbarTools } - val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) } - val bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) } - val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) } - val moreTools = toolOrder.filter { it !in reorderableToolbarTools } - - val list = mutableListOf() - - PdfToolbarSection.entries.forEach { section -> - val tools = when(section) { - PdfToolbarSection.TOP -> topTools - PdfToolbarSection.BOTTOM -> bottomToolsList - PdfToolbarSection.HIDDEN -> hiddenToolsList - } - list.add(PdfFlatToolItem("header_${section.name}", PdfFlatItemType.SECTION_HEADER, section = section, title = section.title)) - if (tools.isEmpty()) { - list.add(PdfFlatToolItem("empty_${section.name}", PdfFlatItemType.EMPTY_PLACEHOLDER, section = section)) - } else { - tools.forEach { tool -> - list.add(PdfFlatToolItem("tool_${tool.name}", PdfFlatItemType.TOOL, tool = tool, section = section)) - } - } - } - - list.add(PdfFlatToolItem("more_header", PdfFlatItemType.MORE_HEADER, title = "More menu")) - moreTools.forEach { tool -> - list.add(PdfFlatToolItem("more_${tool.name}", PdfFlatItemType.MORE_TOOL, tool = tool)) - } - list - } + buildPdfToolbarItems( + hiddenTools = hiddenTools, + toolOrder = toolOrder, + bottomTools = bottomTools + ) ) } val commitDragDrop = { val newHidden = localHiddenTools.filter { toolName -> - toolOrder.find { it.name == toolName } !in reorderableToolbarTools + toolOrder.find { it.name == toolName } !in pdfReorderableToolbarTools }.toMutableSet() val newBottom = mutableSetOf() @@ -247,6 +263,22 @@ fun PdfCustomizeToolsSheet( } } + val resetToDefault = { + val defaultHiddenTools = defaultPdfHiddenTools() + val defaultToolOrder = defaultPdfToolOrder() + val defaultBottomTools = defaultPdfBottomTools() + + localHiddenTools = defaultHiddenTools + flatItems = buildPdfToolbarItems( + hiddenTools = defaultHiddenTools, + toolOrder = defaultToolOrder, + bottomTools = defaultBottomTools + ) + onUpdate(defaultHiddenTools) + onPlacementUpdate(defaultBottomTools) + onOrderUpdate(defaultToolOrder) + } + Dialog( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false) @@ -269,6 +301,11 @@ fun PdfCustomizeToolsSheet( color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f) ) + TextButton(onClick = resetToDefault) { + Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.action_reset)) + } IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close)) } @@ -300,8 +337,9 @@ fun PdfCustomizeToolsSheet( ) { when (item.type) { PdfFlatItemType.SECTION_HEADER -> { + val titleRes = item.titleRes Text( - text = item.title ?: "", + text = if (titleRes != null) stringResource(titleRes) else item.title.orEmpty(), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, @@ -317,7 +355,7 @@ fun PdfCustomizeToolsSheet( .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), RoundedCornerShape(12.dp)), contentAlignment = Alignment.Center ) { - Text("Drop tools here", color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(stringResource(R.string.toolbar_drop_tools_here), color = MaterialTheme.colorScheme.onSurfaceVariant) } } PdfFlatItemType.TOOL -> { @@ -334,8 +372,9 @@ fun PdfCustomizeToolsSheet( ) } PdfFlatItemType.MORE_HEADER -> { + val titleRes = item.titleRes Text( - text = item.title ?: "More menu", + text = if (titleRes != null) stringResource(titleRes) else item.title ?: stringResource(R.string.toolbar_more_menu), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(top = 24.dp, bottom = 8.dp, start = 4.dp) @@ -343,7 +382,7 @@ fun PdfCustomizeToolsSheet( } PdfFlatItemType.MORE_TOOL -> { PdfMoreToolVisibilityRow( - title = item.tool!!.title, + title = stringResource(item.tool!!.titleRes), visible = !localHiddenTools.contains(item.tool.name), onToggle = { localHiddenTools = if (localHiddenTools.contains(item.tool.name)) { @@ -386,18 +425,19 @@ private fun PdfToolbarDragRow( PdfToolPreviewIcon(tool) Spacer(Modifier.width(16.dp)) Text( - text = tool.title, + text = stringResource(tool.titleRes), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f) ) Icon( Icons.Default.Menu, - contentDescription = "Drag to reorder", + contentDescription = stringResource(R.string.content_desc_drag_to_reorder), tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier - .size(48.dp) - .padding(12.dp) + .size(32.dp) + .padding(6.dp) + .clip(CircleShape) .pointerInput(tool) { detectDragGestures( onDragStart = { onDragStart() }, @@ -453,7 +493,7 @@ private fun PdfToolbarDragRow( PdfToolPreviewIcon(tool) Spacer(Modifier.width(12.dp)) Text( - text = tool.title, + text = stringResource(tool.titleRes), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f) @@ -489,27 +529,28 @@ private fun PdfMoreToolVisibilityRow( } } -enum class PdfToolbarSection(val title: String) { - TOP("Top Bar"), - BOTTOM("Bottom Bar"), - HIDDEN("Hidden Tools") +enum class PdfToolbarSection(@StringRes val titleRes: Int) { + TOP(R.string.toolbar_top_bar), + BOTTOM(R.string.toolbar_bottom_bar), + HIDDEN(R.string.toolbar_hidden_tools) } @Composable private fun PdfToolPreviewIcon(tool: PdfReaderTool) { + val title = stringResource(tool.titleRes) when (tool) { - PdfReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = tool.title, modifier = Modifier.size(20.dp)) - PdfReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = tool.title, modifier = Modifier.size(20.dp)) - PdfReaderTool.LOCK_PANNING -> Icon(Icons.Default.LockOpen, contentDescription = tool.title, modifier = Modifier.size(20.dp)) - PdfReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = tool.title, modifier = Modifier.size(20.dp)) - PdfReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = tool.title, modifier = Modifier.size(20.dp)) - PdfReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = tool.title, modifier = Modifier.size(20.dp)) - PdfReaderTool.HIGHLIGHT_ALL -> Icon(painterResource(id = R.drawable.highlight_text), contentDescription = tool.title, modifier = Modifier.size(20.dp)) - PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp)) - PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = tool.title, modifier = Modifier.size(20.dp)) - PdfReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp)) - PdfReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = tool.title, modifier = Modifier.size(20.dp)) - else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp)) + PdfReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = title, modifier = Modifier.size(20.dp)) + PdfReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = title, modifier = Modifier.size(20.dp)) + PdfReaderTool.LOCK_PANNING -> Icon(Icons.Default.LockOpen, contentDescription = title, modifier = Modifier.size(20.dp)) + PdfReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = title, modifier = Modifier.size(20.dp)) + PdfReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = title, modifier = Modifier.size(20.dp)) + PdfReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = title, modifier = Modifier.size(20.dp)) + PdfReaderTool.HIGHLIGHT_ALL -> Icon(painterResource(id = R.drawable.highlight_text), contentDescription = title, modifier = Modifier.size(20.dp)) + PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = title, modifier = Modifier.size(20.dp)) + PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = title, modifier = Modifier.size(20.dp)) + PdfReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = title, modifier = Modifier.size(20.dp)) + PdfReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = title, modifier = Modifier.size(20.dp)) + else -> Icon(Icons.Default.MoreVert, contentDescription = title, modifier = Modifier.size(20.dp)) } } @@ -556,7 +597,7 @@ fun PdfVisualOptionsSheet( options = SystemUiMode.entries, selectedOption = systemUiMode, onOptionSelected = onSystemUiModeChange, - getLabel = { it.title } + getLabel = { stringResource(it.titleRes) } ) Spacer(modifier = Modifier.height(20.dp)) diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt b/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt index 3bd9bb7..cc8597d 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt @@ -330,7 +330,7 @@ internal fun PdfTopBar( if (hiddenToolbarTools.isNotEmpty()) { DropdownMenuItem( - text = { Text("Hidden tools") }, + text = { Text(stringResource(R.string.toolbar_hidden_tools_menu)) }, onClick = { showHiddenToolsExpanded = !showHiddenToolsExpanded }, trailingIcon = { Icon( @@ -665,7 +665,7 @@ private fun HiddenPdfToolMenuItem( else -> true } DropdownMenuItem( - text = { Text(tool.title) }, + text = { Text(stringResource(tool.titleRes)) }, enabled = enabled, onClick = { closeMenu() 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 43e3995..320a0c9 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -2118,7 +2118,7 @@ fun PdfViewerScreen( words.take(6).joinToString(" ") + "..." } else { Timber.d("No words found. Falling back to 'Page X' title.") - "Page ${pageIndex + 1}" + context.getString(R.string.pdf_page_short, pageIndex + 1) } val chapterTitle = @@ -2472,7 +2472,7 @@ fun PdfViewerScreen( } if (virtualPage is VirtualPage.BlankPage) { - onUpdate(SummarizationResult(error = "Cannot summarize a blank page.")) + onUpdate(SummarizationResult(error = context.getString(R.string.pdf_error_blank_page_summary))) onFinish() return } @@ -2480,7 +2480,7 @@ fun PdfViewerScreen( val pdfPageIndex = (virtualPage as? VirtualPage.PdfPage)?.pdfIndex ?: currentPageIndex val doc = pdfDocument ?: run { - onUpdate(SummarizationResult(error = "Document not loaded.")) + onUpdate(SummarizationResult(error = context.getString(R.string.pdf_error_document_not_loaded))) onFinish() return } @@ -2593,7 +2593,7 @@ fun PdfViewerScreen( if (fullText.isEmpty() && lastResult?.error == null) { onUpdate( SummarizationResult( - error = "Failed to parse summary from server response." + error = context.getString(R.string.ai_error_parse_summary) ) ) } @@ -2607,17 +2607,21 @@ fun PdfViewerScreen( val errorDetail = try { errorBody?.let { JSONObject(it).getString("detail") } } catch (_: Exception) { - "Could not fetch summary." + context.getString(R.string.ai_error_fetch_summary) } onUpdate( SummarizationResult( - error = "Error: $responseCode. ${errorDetail ?: "An unknown server error occurred."}" + error = context.getString( + R.string.ai_error_with_code, + responseCode, + errorDetail ?: context.getString(R.string.error_unknown_server) + ) ) ) } } catch (e: Exception) { Timber.e(e, "Exception during PDF page summarization: ${e.message}") - onUpdate(SummarizationResult(error = "An error occurred: ${e.localizedMessage}")) + onUpdate(SummarizationResult(error = context.getString(R.string.error_occurred_format, e.localizedMessage))) } finally { pageBitmap?.recycle() connection?.disconnect() @@ -2783,8 +2787,8 @@ fun PdfViewerScreen( val chunks = splitTextIntoChunks(textToChunk) val bookTitle = (pdfDocument as? PdfDocumentWrapper)?.pdfDocument?.getDocumentMeta()?.title?.takeIf { it.isNotBlank() } - ?: effectivePdfUri.lastPathSegment ?: "Document" - val pageTitle = "Page ${pageToRead + 1}" + ?: effectivePdfUri.lastPathSegment ?: context.getString(R.string.default_document_title) + val pageTitle = context.getString(R.string.pdf_page_short, pageToRead + 1) val ttsChunks = chunks.mapIndexed { index, text -> TtsChunk(text, "", index) } @@ -2805,8 +2809,8 @@ fun PdfViewerScreen( } } else { val finalError = when { - ocrAttempted -> "OCR found no text on this page." - else -> "Page seems empty or text not extractable." + ocrAttempted -> context.getString(R.string.error_no_text_on_page_after_ocr) + else -> context.getString(R.string.error_page_text_not_extractable) } val nextPage = pageToRead + 1 @@ -3216,7 +3220,7 @@ fun PdfViewerScreen( } } else { Timber.e(e, "Error loading fixed-layout document") - errorMessage = "Error loading document: ${e.localizedMessage}" + errorMessage = context.getString(R.string.error_loading_document_format, e.localizedMessage) isLoadingDocument = false } if (pdfDocument == null) { @@ -3516,7 +3520,7 @@ fun PdfViewerScreen( results.add( SearchResult( locationInSource = match.pageIndex, - locationTitle = "Page ${match.pageIndex + 1}", + locationTitle = context.getString(R.string.pdf_page_short, match.pageIndex + 1), snippet = parseSnippet(match.snippet), query = query, occurrenceIndexInLocation = occurrenceIndex, @@ -3529,7 +3533,7 @@ fun PdfViewerScreen( results.add( SearchResult( locationInSource = match.pageIndex, - locationTitle = "Page ${match.pageIndex + 1}", + locationTitle = context.getString(R.string.pdf_page_short, match.pageIndex + 1), snippet = parseSnippet(match.snippet), query = query, occurrenceIndexInLocation = 0, @@ -3678,6 +3682,9 @@ fun PdfViewerScreen( userHighlights = visibleUserHighlights, currentPage = currentPage, totalPages = totalDisplayPages, + isTabsEnabled = isPdfTabStripVisible, + openTabs = openTabs, + activeTabBookId = activeTabBookId, customHighlightColors = customHighlightColors, onPageSelected = { targetPage -> coroutineScope.launch { @@ -3694,6 +3701,29 @@ fun PdfViewerScreen( } } }, + onTabSelected = { tabBookId -> + coroutineScope.launch { + currentBookId?.let { tabStateMap[it] = currentPage } + saveAllData(true).join() + viewModel.switchTab(tabBookId) + } + }, + onTabClosed = { tabBookId -> + coroutineScope.launch { + val isSelected = tabBookId == activeTabBookId + if (isSelected) saveAllData(true).join() + viewModel.closeTab(tabBookId) + if (isSelected && openTabs.size == 1) { + onNavigateBack() + } + } + }, + onNewTabClick = { + coroutineScope.launch { + drawerState.close() + showNewTabSheet = true + } + }, onRenameBookmark = { bookmarkToRename, newTitle -> if (newTitle.isNotBlank()) { val updatedBookmark = bookmarkToRename.copy(title = newTitle) @@ -6376,7 +6406,7 @@ fun PdfViewerScreen( AiHubBottomSheet( bookTitle = bookTitle, currentChapterIndex = currentPageForDisplay, - chapterTitle = "Page ${currentPageForDisplay + 1}", + chapterTitle = stringResource(R.string.pdf_page_short, currentPageForDisplay + 1), summaryCacheManager = summaryCacheManager, summarizationResult = summarizationResult, isSummarizationLoading = isSummarizationLoading, @@ -6412,7 +6442,12 @@ fun PdfViewerScreen( isSummarizationLoading = false val finalSummary = summarizationResult?.summary if (!finalSummary.isNullOrBlank() && summarizationResult?.error == null) { - summaryCacheManager.saveSummary(bookTitle, currentPageForDisplay, "Page ${currentPageForDisplay + 1}", finalSummary) + summaryCacheManager.saveSummary( + bookTitle, + currentPageForDisplay, + context.getString(R.string.pdf_page_short, currentPageForDisplay + 1), + finalSummary + ) } } ) @@ -6959,7 +6994,7 @@ fun PdfViewerScreen( AlertDialog( onDismissRequest = { clickedLinkUrl = null }, title = { Text(stringResource(R.string.dialog_external_link_title)) }, - text = { Text(stringResource(R.string.desc_external_link_warning)) }, + text = { Text(stringResource(R.string.desc_external_link_warning, url)) }, confirmButton = { TextButton( onClick = { diff --git a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt index 907731a..fa58d1c 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt @@ -19,6 +19,7 @@ */ package com.aryan.reader.tts +import android.content.Context import android.net.Uri import android.os.Bundle import timber.log.Timber @@ -81,6 +82,7 @@ private const val PREFETCH_LOOKAHEAD = 3 @UnstableApi class TtsPlaybackManager( + context: Context, private val player: Player, private val generateAudioChunk: suspend (bookTitle: String, chapterTitle: String?, chunkIndex: Int, totalChunks: Int, textChunk: String, speakerId: String, mode: TtsMode, authToken: String?) -> TtsAudioData, private val onResetContext: () -> Unit, @@ -88,6 +90,7 @@ class TtsPlaybackManager( private val onPlaybackSessionStopped: () -> Unit = {} ) : MediaSession.Callback, Player.Listener { + private val appContext = context.applicationContext private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var mediaSession: MediaSession? = null private val prefetchingJobs = java.util.concurrent.ConcurrentHashMap() @@ -388,7 +391,7 @@ class TtsPlaybackManager( args: Bundle // Added this parameter ) { if (chunks.isEmpty()) { - _ttsState.value = _ttsState.value.copy(errorMessage = "No text to read.") + _ttsState.value = _ttsState.value.copy(errorMessage = appContext.getString(R.string.tts_error_no_text)) Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("handleStartTts aborted because chunks is empty.") return } @@ -553,7 +556,7 @@ class TtsPlaybackManager( private suspend fun prepareAndPlayFirstChunk(startAtIndex: Int = 0, playWhenReady: Boolean = true, startAtPosition: Long = 0L) { val firstChunk = textChunks.getOrNull(startAtIndex) if (firstChunk == null) { - _ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Error starting playback.") + _ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = appContext.getString(R.string.tts_error_starting_playback)) onPlaybackSessionStopped() return } @@ -565,7 +568,7 @@ class TtsPlaybackManager( ) val spokenText = firstChunk.spokenText.ifBlank { firstChunk.text } - val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, startAtIndex, textChunks.size, spokenText, currentSpeakerId, currentTtsMode, currentAuthToken) + val ttsAudioData = generateAudioChunk(bookTitle ?: appContext.getString(R.string.tts_unknown_book), chapterTitle, startAtIndex, textChunks.size, spokenText, currentSpeakerId, currentTtsMode, currentAuthToken) Timber.tag("TTS_CLOUD_DIAG").i("generateAudioChunk returned in ${System.currentTimeMillis() - chunkStartTime}ms") if (ttsAudioData.error == "INSUFFICIENT_CREDITS") { @@ -630,7 +633,7 @@ class TtsPlaybackManager( } else { _ttsState.value = _ttsState.value.copy( isLoading = false, - errorMessage = ttsAudioData.error ?: "Failed to load audio." + errorMessage = ttsAudioData.error ?: appContext.getString(R.string.tts_error_load_audio) ) onPlaybackSessionStopped() } @@ -827,7 +830,7 @@ class TtsPlaybackManager( override fun onPlayerError(error: androidx.media3.common.PlaybackException) { Timber.tag("TTS_CLOUD_DIAG").e(error, "Player error: [${error.errorCodeName}] ${error.message}") Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e(error, "Player error. code=${error.errorCodeName}, message=${error.message}") - _ttsState.value = _ttsState.value.copy(errorMessage = "Playback error: ${error.message}") + _ttsState.value = _ttsState.value.copy(errorMessage = appContext.getString(R.string.tts_error_playback, error.message.orEmpty())) handleStopTts(userInitiated = true) } @@ -854,7 +857,7 @@ class TtsPlaybackManager( Timber.tag("TTS_CLOUD_DIAG").i("Starting prefetch generation for chunk $targetIndex") val spokenText = nextChunk.spokenText.ifBlank { nextChunk.text } - val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, targetIndex, textChunks.size, spokenText, currentSpeakerId, currentTtsMode, currentAuthToken) + val ttsAudioData = generateAudioChunk(bookTitle ?: appContext.getString(R.string.tts_unknown_book), chapterTitle, targetIndex, textChunks.size, spokenText, currentSpeakerId, currentTtsMode, currentAuthToken) Timber.tag("TTS_CLOUD_DIAG").i("Prefetch audio setup for chunk $targetIndex took ${System.currentTimeMillis() - prefetchStartTime}ms") diff --git a/app/src/main/java/com/aryan/reader/tts/TtsService.kt b/app/src/main/java/com/aryan/reader/tts/TtsService.kt index 22b9a99..5b0cba8 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsService.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsService.kt @@ -729,7 +729,7 @@ class TtsService : MediaSessionService() { null } if (directGeminiApiKey.isNullOrBlank() && googleCloudWorkerTtsUrl.isBlank()) { - TtsAudioData(audioFile = null, serverText = null, wordTimings = null, error = "Cloud TTS is not configured.") + TtsAudioData(audioFile = null, serverText = null, wordTimings = null, error = getString(R.string.tts_error_cloud_not_configured)) } else { liveClient.ensureConnected(googleCloudWorkerTtsUrl, speaker, authToken, directGeminiApiKey) liveClient.generateChunk(text, cachedFile) @@ -810,6 +810,7 @@ class TtsService : MediaSessionService() { Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("ExoPlayer created for TTS service.") playbackManager = TtsPlaybackManager( + context = this, player = player, generateAudioChunk = audioGenerator, onResetContext = { liveClient.close() }, diff --git a/app/src/main/res/drawable-nodpi/format_align_right.xml b/app/src/main/res/drawable-nodpi/format_align_right.xml new file mode 100644 index 0000000..584cc91 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/format_align_right.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 097f26e..c5bdc35 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -642,4 +642,7 @@ مُظلِل الصفحة غير متوفرة يرجى تسجيل الدخول إلى حساب Google الخاص بك لشراء رصيد. + لغة النظام + English (الإنجليزية) + Español (الإسبانية) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 4ad4979..e359bbc 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -732,12 +732,15 @@ Protokolle exportieren (Letzte %1$d Zeilen) Strengen Dateifilter aktivieren Wenn Sie diese Option aktivieren, werden einige unterstützte Dateiformate wie AZW3, CB7 und FB2 je nach Dateimanager möglicherweise nicht angezeigt.\n\nSind sie sicher, dass Sie diesen Filter aktivieren möchten? + Systemstandard + Englisch Englisch (Standard) العربية (Arabisch) Deutsch (Deutsch) Türkçe (Türkisch) Français (Französisch) Русский (Russisch) + Español (Spanisch) App-Thema Text-Helligkeit Farbschema diff --git a/app/src/main/res/values-es/plurals.xml b/app/src/main/res/values-es/plurals.xml index ba1aa32..fbd9e9a 100644 --- a/app/src/main/res/values-es/plurals.xml +++ b/app/src/main/res/values-es/plurals.xml @@ -2,22 +2,54 @@ %1$d libro - %1$d libros %1$d libros libro - libros libros - %1$d estantería - %1$d estanterías - %1$d estanterías + %1$d estante + %1$d estantes %1$d resultado encontrado - %1$d resultados encontrados %1$d resultados encontrados + + %1$d coincidencia encontrada + %1$d coincidencias encontradas + + + Eliminar archivo permanentemente + Eliminar archivos permanentemente + + + ¿Quieres eliminar permanentemente %1$d archivo seleccionado de tu dispositivo? Esta acción no se puede deshacer. + ¿Quieres eliminar permanentemente %1$d archivos seleccionados de tu dispositivo? Esta acción no se puede deshacer. + + + ¿Quieres quitar %1$d archivo seleccionado de la lista de archivos recientes? Volverá a aparecer si lo abres de nuevo desde la biblioteca. + ¿Quieres quitar %1$d archivos seleccionados de la lista de archivos recientes? Volverán a aparecer si los abres de nuevo desde la biblioteca. + + + ¿Seguro que quieres quitar %1$d libro del estante \'%2$s\'? El libro seguirá en tu biblioteca y aparecerá en Sin estante. + ¿Seguro que quieres quitar %1$d libros del estante \'%2$s\'? Los libros seguirán en tu biblioteca y aparecerán en Sin estante. + + + %1$d libro quitado de la biblioteca. + %1$d libros quitados de la biblioteca. + + + %1$d carpeta + %1$d carpetas + + + %1$d etiqueta + %1$d etiquetas + + + (%1$d fragmento) + (%1$d fragmentos) + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b55ed48..bf1d052 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,18 +1,1077 @@ Cancelar - Salvar + Guardar Eliminar + Quitar + Aceptar Cerrar Añadir + Renombrar Atrás Buscar - Retirar + Borrar Aplicar + Activar + Error: %1$s + Volver Libre - Cerrar Pestaña - Pestañas Activas - Cerrar todas las pastañas - ¿Quieres cerrar todas las pastañas? - Quitar + Pestañas activas + Cerrar pestaña + Cerrar todas las pestañas + ¿Cerrar todas las pestañas? + ¿Seguro que quieres cerrar todas las pestañas activas? + %1$s aceptas nuestros %2$s y confirmas que has leído nuestra %3$s. + Términos del servicio + Política de privacidad + Licencias + %1$d seleccionados + Borrar selección + Fijar/Desfijar + Información + Seleccionar todo + Quitar de recientes + Advertencia: algunos elementos seleccionados están sincronizados desde una carpeta local. Si continúas, se eliminarán los archivos reales del almacenamiento del dispositivo.\n\nEsta acción no se puede deshacer. + Información del archivo + Nombre del libro + Copiar nombre + Nombre original: %1$s + Restaurar original + Nombre de archivo: %1$s + Autor + Formato + Tamaño + Añadido + Ubicación + Origen: flujo OPDS + Almacenamiento en la app + Almacenamiento interno + Acerca de Episteme + Versión: %1$s (compilación: %2$d) + Selecciona un archivo + ¿Borrar todos los datos sincronizados? + ¿Seguro que quieres eliminar permanentemente todos los datos de tus libros de la nube? También se borrará tu biblioteca local para evitar que se vuelva a sincronizar. Esta acción no se puede deshacer. + ELIMINAR TODOS LOS DATOS + Inicio + Biblioteca + Archivos recientes + Tu biblioteca está vacía + Selecciona un archivo para leer o sincroniza una carpeta local para importar libros automáticamente. + No hay archivos recientes + Abre un archivo de tu biblioteca para verlo aquí. + Configurar sincronización de carpetas + Sincronizar carpeta + Carpeta local + Fijado + %1$d%% completado + No disponible localmente + Iniciar sesión con Google + Al iniciar sesión, + Episteme Pro + Actualizar a Episteme Pro + Sincronizar biblioteca + Sincronización en la nube para carpetas locales + Sube libros de tus carpetas sincronizadas a Google Drive. + Fuentes personalizadas + Apoyar el proyecto + Ayuda y comentarios + Cerrar sesión + Límite de archivos recientes + Sin límite + %1$d archivos + Borrar caché de libros + Borrar caché de reflujo + Biblioteca + Buscar título o autor… + Tipos: %1$s + Carpetas: %1$d + Estado: %1$s + Todos los libros + Estantes + Carpetas + Catálogos + No se encontraron resultados para \"%1$s\" + Selecciona un archivo PDF, EPUB, MOBI o AZW3 del dispositivo para empezar. + Añadir archivo + Nuevo estante + Crear nuevo estante + Nombre del estante + Crear + Renombrar estante + Eliminar estante + Añadir libros + Este estante está vacío + Añadir a %1$s + AÑADIR (%1$d) + No hay libros sin estante para añadir + Todos los libros ya están en este estante + Renombrar estante + ¿Eliminar estante? + ¿Seguro que quieres eliminar el estante \'%1$s\'? Todos los libros se moverán a Sin estante. + ¿Quitar del estante? + ¿Eliminar %1$s? + ¿Seguro que quieres eliminar los %1$d %2$s seleccionados? Todos los libros que contengan se moverán a Sin estante. + Sincronizar carpetas locales + Conecta carpetas locales para crear una biblioteca activa. Episteme supervisará archivos y sincronizará el progreso. + Añadir carpeta + Escanear todo + Escaneando… + Sincronizar metadatos + ÚLTIMA SINCRONIZACIÓN + LIBROS + Editar filtros + Quitar carpeta + Filtrar tipos de archivo + Selecciona los tipos de archivo que quieres sincronizar desde esta carpeta: + Filtrar biblioteca + Tipo de archivo + Carpeta de origen + Estado de lectura + Borrar todo + Almacenamiento en la app + ¿Guardar archivo? + ¿Quieres guardar este archivo externo en la biblioteca de la app? Si no, se quitará.\n\n(Puedes cambiar este comportamiento predeterminado cuando quieras desde la pantalla de inicio > Más opciones > Comportamiento de archivos externos). + No volver a preguntar + Mantener en la biblioteca + Quitar + Preguntar siempre + Mantener siempre + Quitar siempre + Comportamiento de archivos externos + Añadir catálogo + Buscar catálogo… + Este feed está vacío. + Descargando… + Cargando… + Transmitir + No disponible + Descargar + Formato de descarga + Transmitir ahora + Leer + No hay formatos compatibles disponibles. + EDITORIAL + PUBLICADO + IDIOMA + Sinopsis + Editar catálogo + Añadir catálogo OPDS + Nombre del catálogo + URL + Autenticación (opcional) + Nombre de usuario + Contraseña + Eliminar catálogo + ¿Seguro que quieres eliminar \'%1$s\'? + Al eliminar este catálogo también se quitarán permanentemente de tu biblioteca %1$d libros transmitidos asociados a él. + Preajuste + Plan gratuito + Gratis para siempre + Múltiples formatos + Compatible con PDF, EPUB, MOBI, AZW3 + Texto a voz de Android + Escucha tus libros con TTS integrado + Diccionario básico + Busca palabras sueltas rápidamente + Plan actual + Cargando precio… + Pago único + Acceso de por vida + Oferta de acceso anticipado + Funciones: + Sincronización en la nube entre dispositivos + Mantén toda tu biblioteca, incluidos archivos de libros y progreso de lectura, sincronizada en hasta 4 dispositivos. + Resúmenes + Obtén 10 resúmenes gratis de capítulos o páginas al día + Diccionario inteligente + Busca frases e incluso párrafos, no solo palabras sueltas + Solicitudes de funciones prioritarias + Tus sugerencias reciben prioridad + ¡Funciones Pro desbloqueadas! + Inicio de sesión requerido + Verificando compra… + Compra existente encontrada + Obtener acceso de por vida + La actualización no está disponible ahora. Revisa tu conexión a internet e inténtalo de nuevo. + Inicia sesión con tu cuenta de Google para comprar Episteme Pro. + Inicia sesión con tu cuenta de Google para comprar créditos. + Esto puede tardar unos momentos. Tu estado Pro se actualizará automáticamente. + Este dispositivo ya tiene una compra Pro, pero está vinculada a otra cuenta. Inicia sesión con la cuenta que se usó para la compra original para restaurar tus funciones Pro. + ¡Estás obteniendo Episteme Pro con un precio especial durante nuestro periodo de acceso anticipado! Es una oferta por tiempo limitado. + Inicia sesión con tu cuenta de Google para comprar Episteme Pro y desbloquear todas las funciones premium. + Ahora no + ¡Entendido! + Fuentes personalizadas + Importar fuente + Fuentes de Google + Explorar Google Fonts + Buscar entre más de 1900 fuentes… + Opciones populares + No se encontraron fuentes que coincidan con \'%1$s\' + Ya descargado + No hay fuentes personalizadas + Importa archivos TTF u OTF para usarlos en tus libros. + Vista previa no disponible (archivo de fuente no válido) + ¿Eliminar fuente? + ¿Seguro que quieres eliminar \'%1$s\'? Se quitará de todos tus dispositivos si la sincronización está activada. + Ponte en contacto + ¿Encontraste un error, tienes una solicitud de función o solo quieres saludar? Cuéntanos en GitHub o por correo electrónico. + Incidencias de GitHub + Informa errores, solicita funciones y sigue el progreso del desarrollo. + Soporte por correo + Contáctanos directamente por correo para cualquier otra consulta. + Apoyar el proyecto + Ayuda a que Episteme siga avanzando + Tu apoyo me ayuda a seguir manteniendo y mejorando Episteme para todos. + Patrocinar en GitHub + Apoya el desarrollo directamente mediante GitHub Sponsors. Como agradecimiento, recibirás una mención en el README del repositorio del proyecto. + Unirse en Patreon + Como agradecimiento por apoyar la app, quienes apoyan en Patreon reciben contenido y ventajas extra: avances de lo que estoy preparando, capturas y novedades tempranas, votaciones para ayudar a definir nuevas funciones y una mención en el README del repositorio. + Desbloquear Episteme Pro + La sincronización entre dispositivos es una función Pro. Desbloquea todas las funciones Pro con una sola compra. + Actualizar + Confirmar cierre de sesión + ¿Seguro que quieres cerrar sesión? + Límite de dispositivos alcanzado + Para usar Episteme Pro en este dispositivo, quita uno de tus dispositivos registrados. + Visto por última vez: %1$s + Confirmar acción destructiva + Esto eliminará permanentemente todos tus libros y el progreso de lectura de este dispositivo Y de tu cuenta de Google Drive. Esta acción no se puede deshacer. ¿Seguro? + Borrar caché de libros + Esto borrará todas las páginas procesadas en modo paginado. Ayuda a corregir problemas de diseño, pero los libros deberán procesarse de nuevo la próxima vez que los abras. + Confirmar y borrar + Borrar caché de reflujo + Esto eliminará todas las versiones de \'Vista de texto\' generadas de tus PDF y borrará su caché de imágenes/HTML asociada. Tus PDF originales no se tocarán. + Atrás + Diccionario + Más opciones + Control de páginas + Tabla de contenido + Formato de texto + Buscar + Funciones de IA + Iniciar texto a voz + Detener texto a voz + Pausar + Reanudar + Activar modo oscuro + Desactivar modo oscuro + Bloquear desplazamiento + Desbloquear desplazamiento + Pantalla completa + Mostrar resaltados + Ocultar resaltados + Modo de anotación + Salir del modo de anotación + Cerrar búsqueda + Borrar búsqueda + Mostrar resultados + Ocultar resultados + Resultado anterior + Resultado siguiente + Salir del lector y volver a la pantalla de inicio + Elige tu app preferida para buscar palabras + Accede al modo de lectura, marcadores y ajustes avanzados + Arrastra para saltar rápidamente a cualquier página del documento + Explora capítulos y navega a cualquier sección + Ajusta fuente, tamaño, interlineado, alineación y fuentes personalizadas + Encuentra cualquier palabra o frase en este libro + Resume el capítulo o la página actual con IA + Lee el libro en voz alta con el motor de voz del dispositivo + Detén la sesión de lectura en voz alta actual + Pausa la reproducción de lectura en voz alta actual + Reanuda la lectura en voz alta pausada + Invierte los colores del PDF para el modo oscuro + Desactiva el modo oscuro y restaura los colores originales del PDF + Bloquea el desplazamiento horizontal en la página + Desbloquea el desplazamiento para volver a activar pellizcar para ampliar y gestos de arrastre + Oculta todos los controles de la interfaz para una lectura inmersiva y sin distracciones + Marca visualmente las regiones de texto seleccionable en la página actual + Quita la superposición de texto seleccionable de la página + Añade anotaciones de tinta o texto + Termina la edición y vuelve a la vista de lectura normal + Salir de la búsqueda y volver al lector + Borra tu búsqueda actual y empieza de nuevo + Expande el panel para ver todas las coincidencias + Contrae el panel de resultados de búsqueda + Salta a la coincidencia anterior en el documento + Salta a la siguiente coincidencia en el documento + Iniciar sesión + Seleccionar carpeta + Seleccionar + Política de privacidad • Términos del servicio • Licencias + Tu dispositivo no permite seleccionar carpetas. Aun así puedes importar archivos individualmente. + No se encontró ningún administrador de archivos. Instala una app de administración de archivos. + %1$s descargado + %1$s: %2$s + Nunca + %1$s: %2$d + Se quitaron %1$d libros transmitidos. + No se pudo importar la fuente: %1$s + Vista de texto eliminada. + La sincronización requiere permiso de Google Drive. + Ocurrió un error con la compra. + ¡Actualización completada! Bienvenido a Pro. + No se pudo verificar la compra. Contacta con soporte si se te cobró. + Este dispositivo se quitó de tu cuenta. + No se pudo verificar este dispositivo. Revisa tu conexión. + No se pudieron actualizar los dispositivos. Inténtalo de nuevo. + Guardando PDF… + PDF guardado correctamente. + No se pudo abrir el archivo para guardar. + Error al guardar PDF: %1$s + Guardando PDF original… + PDF original guardado correctamente. + Compartiendo: %1$s + Compartir PDF + No se pudo compartir: %1$s + Límite alcanzado: máximo de %1$d carpetas permitidas. + Esta carpeta ya está sincronizada. + Carpeta añadida: %1$s + No se pudo acceder a los permisos de la carpeta. + Carpeta quitada. + Sincronización de carpeta: actualizando metadatos… + Escaneando carpeta en busca de libros nuevos… + Sincronización de carpeta: escaneo completado. + La sincronización falló. + Activa la sincronización para descargar archivos. + No se pudo descargar %1$s. + Activa la sincronización para borrar datos en la nube. + No has iniciado sesión; no se pueden borrar los datos en la nube. + Borrando todos los datos locales y de la nube… + Todos los datos locales y de la nube se borraron correctamente. + Error: no se pudieron borrar todos los datos. + Todos los datos locales borrados. + No se pudo iniciar sesión. Inténtalo de nuevo. + No se pudo encontrar una cuenta de Google. Esto puede ocurrir en una instalación nueva; inténtalo de nuevo en un momento. + Ocurrió un error durante el inicio de sesión. Revisa tu conexión a internet. + Inicia sesión para probar la gestión de dispositivos. + La sincronización es una función de Episteme Pro. + No has iniciado sesión; no se puede sincronizar. + Sincronización en la nube: buscando actualizaciones… + Sincronización en la nube: completada. + No se pudo sincronizar la biblioteca. + No se pudo encontrar el elemento reciente. + No se pudo importar el archivo. + No se pudo encontrar la ubicación del archivo. + Este tipo de archivo no es compatible. + No se pudo cargar la vista de texto generada. + Falló la generación de la vista de texto. + No se pudo cargar FB2: %1$s + No se pudo cargar el archivo: %1$s + No se pudo cargar MOBI: %1$s + No se pudo cargar EPUB: %1$s + Archivo eliminado de la carpeta. Se quitó de la biblioteca. + Ya existe un estante con ese nombre. + Eliminando de todos los dispositivos… + Eliminación completada. + Falló la sincronización en la nube; se eliminó localmente. + Caché de reflujo y vistas de texto generadas borradas. + Buscar en el libro… + No se encontraron resultados. + Generando resumen… + Detener + Leer en voz alta + Copiar + Copiar hilo + No se pudo generar un resumen. + Pensando… + Abrir en la app de diccionario + La IA no pudo proporcionar una definición. + Preguntando a la IA sobre \'%1$s\'… + Ajustes de texto a voz + Detén la reproducción para cambiar los ajustes. + Modo de síntesis + En el dispositivo + Nube (alta calidad) + Selección de voz + Reproducir muestra + Ajustes de voz en el dispositivo + Cerrar ajustes + Predeterminado del sistema + Coincide con los ajustes del sistema Android + Seleccionado + Cargando voces… + No hay voces disponibles en este dispositivo. + Voces específicas + Voces disponibles (%1$d) + No se encontraron voces para este idioma. + Variante: %1$s + Esta es una muestra de %1$s. + Temas de lectura + Preajustes + Preajustes con textura + Mis temas + Mis temas con textura + Aún no hay temas personalizados. Toca \'+\' para crear uno. + Aún no hay temas con textura personalizados. + Nuevo tema + Nuevo tema con textura + Editar tema + Nombre del tema + Tantos libros y tan poco tiempo. + ⚠️ ¡Bajo contraste! Puede causar fatiga visual. + Color de página + Color del texto + Textura + Ninguna + Subir + Transparencia de la textura + Vista previa en vivo + Leer es soñar. + El texto está vacío. + La IA devolvió una definición vacía. + No se pudo obtener la definición. + Ocurrió un error desconocido del servidor. + Error de red. Revisa la conexión. + No hay suficiente contexto para un recap. + No se pudo analizar el recap. + Error de red durante la generación del recap. + Ajustes de búsqueda + Motor de diccionario + Inteligente (IA) + App externa + Usa IA para definiciones. Recurrirá a la app externa de abajo si estás sin conexión o si la frase seleccionada es demasiado larga. + Usa la app seleccionada para búsquedas en el diccionario. + App alternativa + App de diccionario + Seleccionar una app + Traducir + App usada para traducir el texto seleccionado. + App de búsqueda + App usada para búsquedas web. + Ninguna + El contenido del libro está vacío. + No se pudo analizar el resumen de la respuesta del servidor. + No se pudo obtener el resumen. + Error: %1$d. %2$s + Error de red. Revisa la conexión y el estado del servidor. + Analizando capítulo %1$d… + Leyendo posición actual… + Generando recap… + Resumen del capítulo + Recap de la historia (beta) + Desbloquear resumen de capítulos + Obtén resúmenes concisos de cualquier capítulo con Episteme Pro. Actualiza para empezar a usar esta función. + Más información + Desbloquear diccionario inteligente + Definir frases y párrafos completos de hasta 2000 caracteres es una función Pro. Actualiza para obtener definiciones instantáneas de cualquier texto seleccionado. + Marcador + Ranura seleccionada + Personalizar paleta + Toca una ranura para editar: + Selecciona un color para la ranura: + Este capítulo está vacío. + Capítulo no encontrado + Error al cargar el capítulo + Ajustes de diccionario + Tema + Ajustes de tema + Más opciones + Ver PDF original + Eliminar vista de texto + Vertical + Paginado (izquierda a derecha) + Activado + Quitar marcador + Marcar esta página + Tocar para pasar páginas + Desplazamiento con botones de volumen + Pasar página con botones de volumen + Pasos de página realistas + Mantener pantalla encendida + Opciones visuales + Orientación de pantalla + Cambiar modo de lectura + Paginado (derecha a izquierda) + Desplazamiento automático + Ajustes de TTS + Ajustes de voz TTS + Reemplazos de palabras TTS + Compartir, guardar o imprimir + Ajustes de TTS (depuración) + Navegar con control deslizante + Menú de capítulos + Formato de texto + Resumen de capítulo + Recap (beta) + Detener TTS + Iniciar TTS + Pausar TTS + Reanudar TTS + Salir de navegación con control deslizante + Miniatura de página inicial + Expandir + Contraer + Reproducir + Velocidad local + Velocidad global + Modo de selección + Se aplica a todos los archivos + Guardado solo para este archivo + Desactivar modo músico + Activar modo músico + Intercambiar controles + Mín. + Máx. + Más lento + Más rápido + Disminuir + Aumentar + Página %1$d de %2$d + Capítulos + Pestañas + Marcadores + Resaltados + Páginas + Expandir todo + Contraer todo + Localizar + Aún no has añadido marcadores. + Más opciones para marcador + Renombrar marcador + Nuevo nombre + Nuevo título + ¿Eliminar marcador? + ¿Seguro que quieres eliminar permanentemente este marcador? + Aún no hay resaltados. + Capítulo desconocido + Opciones + ¿Eliminar resaltado? + ¿Seguro que quieres eliminar permanentemente este resaltado? + No se encontró el PDF original. + Error: no se encontró el contenido del libro. Ruta: %1$s + Selecciona primero una app de diccionario. + Selecciona primero una app de traducción. + Selecciona primero una app de búsqueda. + No hay capítulos disponibles para este libro. + Navegando a la posición… + Permiso requerido + Para mostrar controles de reproducción mientras la app está en segundo plano, concede el permiso de notificaciones. + Continuar + Limitación del texto justificado + Usar alineación justificada en modo paginado puede hacer que la selección de texto y los resaltados sean imprecisos debido a limitaciones de diseño. + Entiendo + Navegando al capítulo… + Selecciona primero un diccionario sin conexión. + El libro aún no está paginado. + Espera a que el libro se cargue por completo. + Suelta para ir al capítulo anterior + Suelta para ir al capítulo siguiente + Tira un poco más… (%1$d%%) + Capítulo + No se pudo obtener el contenido del capítulo. + No se pudo determinar el capítulo actual. + WebView no está disponible. + Página %1$d/%2$d + Formato local + Formato global + Restablecer + Tamaño + Espaciado + Seleccionar fuente + Preajustes + Importadas + Importar desde archivos + Aún no hay fuentes importadas. + Opciones visuales + Diseño de página + Quitar espacio entre páginas + Se aplica al modo de lectura vertical. + Ocultar superposición de número de página + Quita la pequeña etiqueta de recuento de páginas de cada página. + Interfaz del sistema (barras de estado y navegación) + Controla la visibilidad de las barras del sistema del dispositivo. + Orientación de pantalla + Elige si el lector sigue la orientación del sistema o prefiere vertical u horizontal cuando Android lo permite. + Barra de progreso + El progreso de lectura y el indicador de capítulo en la pantalla de lectura. + Posición + Transición fluida entre capítulos + Carga al instante el capítulo siguiente/anterior al desplazarte más allá del final, sin la animación de tirar para actualizar. + Quitar margen de bordes + Quita el espacio horizontal en los bordes izquierdo y derecho. + Buscar + Error al abrir el diccionario + Error al abrir la app de traducción + Error al abrir la app de búsqueda + Versión de código abierto + Versión de Play Store + Versión %1$s + Compilación %1$s + Explora el código fuente, marca con estrella, haz fork e informa incidencias. + Cómo manejamos tus datos. + Términos y condiciones de uso. + Bibliotecas de código abierto usadas. + Importando %1$d libros… Aparecerán pronto en tu biblioteca. + Enlace externo + Tocaste un enlace externo:\n\n%1$s\n\n¿Qué quieres hacer? + Abrir + No se encontró ningún navegador para abrir el enlace. + Guardar nota + Añadir una nota… + Dicc. + Hablar + Nota + Editar + FUENTE Y ALINEACIÓN + DISEÑO Y ESPACIADO + Tamaño de fuente + Interlineado + Espacio entre párrafos + Tamaño de imagen + Margen horizontal + Margen vertical + Ninguno + Orig. + Ajustes de voz + Velocidad (%1$sx) + Tono (%1$sx) + Así suena tu configuración de voz actual. + Pausar libro + Reanudar libro + Ajustes de voz / motor del sistema + Velocidad global + Velocidad local + Se aplica a todos los archivos + Guardado solo para este archivo + Desplazarse arriba + Personalizar barra de herramientas + Selecciona las herramientas que quieres mantener visibles. Desmarcar una herramienta la oculta de la interfaz para ofrecerte un espacio de lectura sin distracciones. + Anotaciones + Todo + Con notas + Añadir nota + Editar nota + %1$d / %2$d + Página %1$d de %2$d + Cerrar modo de edición + Alternar visibilidad + Modo solo lápiz + Pluma + Resaltador + Texto + Borrador + Deshacer + Rehacer + Mostrar panel + Seleccionar familia de fuente + Seleccionar tamaño de fuente + Fondo de fuente + Negrita + Cursiva + Subrayado + Tachado + Insertar cuadro de texto + Error de selección OCR: %1$s + Error de selección: %1$s + Error al procesar la página: %1$s + No se puede mostrar la página %1$d. + No se pudieron abrir los ajustes de impresión + Cargando PDF… + Error al cargar PDF + Cargando página… + Visor de PDF + Zona de pruebas de pluma + Importar SVG + ¡Se importaron %1$d trazos SVG! + No se pudo importar el SVG o está vacío. + Idioma de OCR + Insertar página en blanco + Eliminar página + Generando… %1$d%% + Abrir vista de texto + Generar vista de texto + Compartir + Guardar copia en el dispositivo + Imprimir + Generando vista de texto… + Indexando páginas… %1$d%% listo. Los resultados de búsqueda se actualizarán automáticamente. + Resultados encontrados en %1$d+ páginas + Resultado %1$d / %2$d + %1$d+ páginas + Resumir página (página %1$d) + Descargando paquete de idioma %1$s… + Seleccionar idioma de OCR + Elige el idioma o escritura principal de este documento para obtener mejores resultados de reconocimiento de texto. + Puedes cambiarlo más tarde en Más opciones > Idioma de OCR. + ¿Volver a indexar documento? + Estás cambiando la escritura de OCR a %1$s.\n\nPara garantizar la precisión de la búsqueda, necesitamos borrar el índice existente y volver a escanear las páginas que requieran OCR con este nuevo idioma.\n\nEsto se hará en segundo plano. + Volver a indexar + Protegido con contraseña + Este documento está cifrado. Introduce la contraseña para verlo. + Contraseña + Contraseña incorrecta + Ocultar contraseña + Mostrar contraseña + Estás a punto de navegar a:\n%1$s + Visitar + Guardar en el dispositivo + Elige el formato para guardar: + Con anotaciones + Original + Elige el formato para compartir: + Preparando PDF… + Añadir PDF a pestaña + No se encontraron otros PDF en tu biblioteca. + El PDF está vacío o no se pudo mostrar. + Página añadida en %1$d + Página eliminada + Página extra quitada + Los capítulos no están disponibles para este libro. + Sección resaltada + Línea recta + Espectro + Listo + Rojo + Verde + Azul + Fuente predeterminada del sistema + Importar + No hay fuentes importadas + Color de fuente + Resaltado + Activar lectura con varias pestañas + Usar filtro estricto de archivos + Idioma + Probar detección ML de paneles + Probar detección ML de globos de texto + Exportar registros (últimas %1$d líneas) + Activar filtro estricto de archivos + Si activas esto, algunos tipos de archivo compatibles como AZW3, CB7 y FB2 podrían no aparecer según tu administrador de archivos.\n\n¿Seguro que quieres activar este filtro? + Predeterminado del sistema + English (inglés) + English (inglés; predeterminado) + العربية (árabe) + Deutsch (alemán) + Türkçe (turco) + Français (francés) + Русский (ruso) + Español + Tema de la app + Apariencia + Contraste + Brillo del texto + Esquema de color + Dinámico + Crear tema de la app + Tema personalizado + Océano + Menta + Rosa + Sepia + Amatista + Ámbar + Zafiro + Añadir tema personalizado + Tema de la app + Icono de la app + Dispositivo + Abrir panel lateral + Foto de perfil + Perfil + Función Pro + Filtro + Ordenar + Cerrar búsqueda + Borrar consulta + Buscar estante + Portada del estante %1$s + Conservar colores de imagen + Mantener los colores originales de las imágenes cuando cambie el tema + Sin estante + Todos los libros + Sistema + Claro + Oscuro + Estándar + Medio + Alto + Recientes + Título A-Z + Autor A-Z + Porcentaje completado 0-100 + Porcentaje completado 100-0 + Tamaño (menor) + Tamaño (mayor) + Todos + Sin leer + En progreso + Completados + Etiquetas: %1$s + Explorar por etiqueta + Etiquetas + Carpetas + Archivos + Texto a voz + Controles de reproducción para texto a voz. + Preparando texto a voz + Preparando: %1$s + Motor TTS activo + IA en la nube + Nativo del dispositivo + Voces en la nube + Voces del dispositivo + Caché de la nube + Seleccionar voz en la nube de alta calidad + Borrar muestras + Voz predeterminada del sistema + Usa los ajustes del dispositivo + Filtro de idioma + En línea + Sin conexión + Filtro de voz + No hay audio en caché para esta voz. + Borrar caché de %1$s + Esta es una muestra de voz. + Funciones de IA + Resumen + Recap + Caché + Aún no hay resumen para %1$s. + Generar resumen para %1$s + Obtén un recap de la historia hasta tu posición actual. + Generar recap de la historia + Recap de la historia + Resultado en caché • Gratis + Generado • Gratis (%1$d/10 restantes) + Generado • Coste: %1$s créditos + Generando… • Coste: calculando + Salida de IA + Regenerar + No hay resúmenes en caché para este libro. + Créditos + Créditos de IA y nube + Créditos disponibles + %1$d créditos + Desglose de coste estimado + TTS en la nube + Coste: ~3-4 créditos por minuto de audio generado.\nPara activar: pantalla del lector > Más > Ajustes de voz TTS. + Resúmenes de IA y recap + Coste: ~1-4 créditos por solicitud según la longitud del capítulo.\nLos usuarios Pro reciben 10 resúmenes gratis al día. + Al comprar, + Sin créditos + No tienes créditos suficientes. Obtén Episteme Pro para 10 resúmenes gratis al día o añade más créditos para usar resúmenes, TTS en la nube y recap de la historia. + Obtener Pro / añadir créditos + Desbloquear resumen de páginas + Obtén resúmenes concisos de cualquier página con Episteme Pro. Actualiza para empezar a usar esta función. + Descargar modelo de Bubble Zoom + Para usar Bubble Zoom, se debe descargar un modelo de IA (~134 MB). ¿Quieres descargarlo ahora? + Traducir + Volver a pág. %1$d + Página %1$d + Resultado %1$d / %2$d + No se pudo cargar el PDF. + Descargando modelo de Bubble Zoom… %1$d%% + Salir de navegación con control deslizante + Saltar atrás + Saltar adelante + Desplazarse a la página de lectura + Página anotada + Cerrar imagen + Alternar resaltados de búsqueda + Arrastrar para mover cuadro de texto + Icono sin archivos + Copiar %1$s + Etiqueta + Marcador de elemento de lista + Restablecer zoom + Generar anotaciones de demostración + Anotaciones de demostración + Abrir zona de pruebas de pluma + Nueva pestaña + Resaltar todo el texto + Alternar modo de edición + Zoom inteligente de cómic + Personalizar resaltados + Inglés, español, francés, etc. + Hindi, maratí, sánscrito + inglés + Chino + inglés + Japonés + inglés + Coreano + inglés + Página no disponible + Documento + Generado + %1$s (Vista de texto) + %1$s (Reflujo) + Añadir / editar + No hay etiquetas asignadas. + Aplicar etiquetas + Buscar o crear etiqueta… + Crear \"%1$s\" + Distancia de tirón para cambiar de capítulo + Corto + Largo + Velocidad: %1$sx + Tono: %1$sx + Reproducir/Pausar + Restablecer velocidad + Restablecer tono + Seleccionar color + Este libro no tiene contenido para mostrar. + Enlace copiado + Texto copiado + Tabla de contenido + Marcador + Volver a la página %1$d + Volver a la página anterior + Salir del zoom inteligente + Zoom inteligente de cómic + Alternar zoom inteligente de cómic + Protección contra capturas de pantalla + La protección contra capturas de pantalla está activada + La protección contra capturas de pantalla está desactivada + Ajustes + Editar + Restaurar + %1$s seleccionado + Valores predeterminados del lector + Los ajustes específicos de PDF para OCR, anotaciones y herramientas permanecen en el lector de PDF. + Definición de IA + Capítulo %1$d + Ubicación + Fuente personalizada + Ocurrió un error: %1$s + Error al cargar documento: %1$s + OCR no encontró texto en esta página. + La página parece vacía o el texto no se puede extraer. + No se puede resumir una página en blanco. + Documento no cargado. + Las funciones de IA no están disponibles en la compilación OSS sin conexión. + Bloqueado por motivos de seguridad. + Elige un modelo para %1$s en los ajustes de claves y modelos de IA. + Añade una clave de API de %1$s en los ajustes de claves y modelos de IA. + El proveedor de IA devolvió una respuesta vacía. + Error del proveedor de IA: %1$d. %2$s + Este resumen necesita un modelo Gemini porque los modelos Groq seleccionados no admiten entradas PDF/imagen. + Debes iniciar sesión para usar comentarios. + Debes iniciar sesión para enviar comentarios. + Máximo 3 imágenes permitidas para tickets. + Máximo 5 imágenes permitidas por mensaje. + Una o más imágenes superan el límite de 5 MB. + No se pudo crear el ticket: %1$s + No se pudo enviar: %1$s + No se pudo cargar el feed: %1$s + Cuerpo vacío + Descarga fallida: %1$s + Error de descarga: %1$s + Compra fallida: %1$s + No se pudo conectar al servicio de facturación. + Productos no encontrados. + No se pudieron consultar los productos + No disponible en la versión de código abierto + No hay texto para leer. + Error al iniciar la reproducción. + No se pudo cargar el audio. + Error de reproducción: %1$s + TTS en la nube no está configurado. + Libro desconocido + Claves y modelos de IA + Claves guardadas + Añadir o reemplazar clave + Proveedor + Clave de API + Guardar clave + Usar un modelo para todas las funciones + Si está desactivado, cada función de IA del lector usa su propio modelo seleccionado. + Todas las funciones de IA + Diccionario inteligente, resúmenes y recaps usan este modelo. + Diccionario inteligente + Usado al definir palabras o frases seleccionadas. + Resúmenes + Usado para resúmenes EPUB y resúmenes de páginas PDF. Los resúmenes de PDF/imagen necesitan Gemini. + Recaps + Usado para generar recaps de la historia. + Usa la clave Gemini guardada. Por ahora solo se admite %1$s. + ¿Guardar clave de %1$s? + Después de guardarla, solo se verán los primeros 3 y los últimos 3 caracteres. Para cambiarla más tarde, reemplázala o elimínala. + ¿Eliminar clave de %1$s? + Las funciones que usen este proveedor dejarán de funcionar hasta que se guarde una nueva clave. + No hay clave guardada + Eliminar clave de %1$s + Modelo + No se seleccionó ningún modelo + Mostrar IA en el lector + Ocultar IA en el lector + Colores sólidos + Con textura + Sólido personalizado + Textura personalizada + Seleccionar textura personalizada + Brillo del texto (claro) + Brillo del texto (oscuro) + Predeterminado + Izquierda + Derecha + Justificar + Mostrar siempre + Sincronizar con menús + Ocultar siempre + Arriba + Abajo + ¿Restaurar metadatos originales? + Esto escribirá el título, autor, serie y resumen originales de vuelta en el archivo EPUB. El progreso de lectura, etiquetas y notas no cambiarán. + Metadatos EPUB editados + Metadatos del archivo EPUB + Nombre visible cambiado en la app + Metadatos del archivo + Metadatos + Archivo + Título + Serie + Lectura + Nombre de archivo + Modificado + Resumen + Etiquetas de biblioteca + Metadatos editables + Nombre visible + Nombre mostrado en el lector + Archivo original: %1$s + Barra superior + Barra inferior + Herramientas ocultas + Menú Más + Herramientas ocultas + Suelta herramientas aquí + Arrastrar para reordenar + Apps externas + Control de navegación + Barra lateral + Resaltar texto seleccionable + Modo de edición + Controles TTS + Modo de lectura + Gestión de páginas + Vista de texto (reflujo) + Libro actual + Global + Este libro + Activar reemplazos + Las reglas aquí se aplican a todos los libros salvo que se desactiven para un título específico. + Añadir regla + Añadir regla del libro + Aún no hay reglas globales de reemplazo. + Aún no hay reglas específicas de este libro. + Usar reglas globales aquí + Desactívalo cuando un libro necesite sus propias opciones de pronunciación. + Activar reglas del libro + Las reglas locales se ejecutan después de las globales. + Reglas globales heredadas + No hay reglas globales para heredar. + Permitido en este libro + Desactivado para este libro + Sugerencias + Nuevo reemplazo + Editar reemplazo + Reemplazar + Pronunciar como + Activado + Palabra completa + Coincidir mayúsculas + Entrada de vista previa + Reglas + silencio + Texto sin formato + distingue mayúsculas diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 895de19..52e0268 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -705,12 +705,15 @@ Langue Exporter les logs (dernières %1$d lignes) Si vous activez cette option, certains formats supportés comme AZW3, CB7 et FB2 pourraient ne pas apparaitre, selon votre gestionnaire de fichiers.\n\nÊtes-vous sûr de vouloir activer ce filtre ? + Langue du système + English (Anglais) English (Anglais ; par défaut) العربية (Arabe) Deutsch (Allemand) Türkçe (Turque) Français Русский (Russe) + Español (Espagnol) Contraste Luminosité du texte Océan diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index a1e0e36..95118f8 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -707,12 +707,15 @@ Экспорт %1$d последних строк журнала Строгий фильтр файлов Если вы включите эту функцию, некоторые поддерживаемые типы файлов, такие как AZW3, CB7 и FB2, могут не отображаться в зависимости от используемого вами файлового менеджера.\n\nВы уверены, что хотите включить этот фильтр? + Системный язык + Английский Английский (по умолчанию) Арабский Немецкий Турецкий Французский Русский + Испанский Тема приложения Внешний вид Контрастность diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 3c73a23..a8f2e5d 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -707,12 +707,15 @@ Günlükleri Dışarı Aktar (Son %1$d satır) Katı Dosya Filtresini Aç Bunu etkinleştirirseniz, dosya yöneticinize bağlı olarak AZW3, CB7 ve FB2 gibi bazı desteklenen dosya türleri görünmeyebilir.\n\nBu filtreyi etkinleştirmek istediğinizden emin misiniz? + Sistem varsayılanı + İngilizce İngilizce (Varsayılan) العربية (Arapça) Deutsch (Almanca) Türkçe Français (Fransızca) Русский (Rusça) + Español (İspanyolca) Uygulama Teması Görünüm Karşıtlık diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f0ac387..a9fea89 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -765,6 +765,7 @@ Chapters + Tabs Bookmarks Highlights Pages @@ -1108,12 +1109,15 @@ If you enable this, some supported file types like AZW3, CB7, and FB2 might not show up depending on your file manager.\n\nAre you sure you want to enable this filter? + System default + English English (Default) العربية (Arabic) Deutsch (German) Türkçe (Turkish) Français (French) Русский (Russian) + Español (Spanish) App Theme @@ -1326,4 +1330,223 @@ Screen capture protection Screen capture protection is on Screen capture protection is off + + + Settings + Edit + Restore + + %1$s selected + Reader defaults + + Debug actions remain in their existing menus. + + PDF-specific OCR, annotation, and tool settings remain in the PDF reader. + + Gemini + + Groq + + AI Definition + + Chapter %1$d + Location + Custom Font + + %1$d. %2$s + + An error occurred: %1$s + + Error loading document: %1$s + + OCR found no text on this page. + Page seems empty or text not extractable. + + Cannot summarize a blank page. + + Document not loaded. + + AI features are unavailable in the offline OSS build. + Blocked for safety reasons. + + Choose a model for %1$s in AI key and model settings. + + Add a %1$s API key in AI key and model settings. + + The AI provider returned an empty response. + + AI provider error: %1$d. %2$s + + This summary needs a Gemini model because the selected Groq models do not support PDF/image input. + You must be signed in to use feedback. + You must be signed in to submit feedback. + Max 3 images allowed for tickets. + Max 5 images allowed per message. + + One or more images exceed the 5MB limit. + + Failed to create ticket: %1$s + + Failed to send: %1$s + + Failed to load feed: %1$s + Empty body + + Download failed: %1$s + + Download error: %1$s + + Purchase failed: %1$s + Could not connect to billing service. + Products not found. + Failed to query products + + Not available in Open Source version + + No text to read. + Error starting playback. + Failed to load audio. + + Playback error: %1$s + + Cloud TTS is not configured. + + Unknown Book + + + AI keys and models + Saved keys + Add or replace key + Provider + + API key + Save key + Use one model for all features + When off, each reader AI feature uses its own selected model. + + All AI features + Smart dictionary, summaries, and recaps all use this model. + Smart dictionary + Used when defining selected words or phrases. + Summaries + + Used for EPUB summaries and PDF page summaries. PDF/image summaries need Gemini. + Recaps + Used for story recap generation. + + Uses the saved Gemini key. Only %1$s is supported for now. + + Save %1$s key? + After saving, only the first 3 and last 3 characters will be visible. To change it later, replace or delete it. + + Delete %1$s key? + Features using this provider will stop working until a new key is saved. + No key saved + + Delete %1$s key + Model + No model selected + Show AI in reader + Hide AI in reader + + + Solid Colors + Textured + Custom Solid + Custom Textured + Select Custom Texture + Text Brightness (Light) + Text Brightness (Dark) + Default + Left + Right + Justify + Always Show + Sync with Menus + Always Hide + Top + Bottom + + + Restore original metadata? + + This will write the original title, author, series, and summary back into the EPUB file. Reading progress, tags, and notes will not change. + + EPUB metadata edited + + Metadata from EPUB file + Display name changed in app + Metadata from file + Metadata + File + Title + Series + Reading + File name + Modified + Summary + Library tags + Editable metadata + Display name + Name shown in Reader + + Original file: %1$s + + + Top Bar + Bottom Bar + Hidden Tools + More menu + Hidden tools + Drop tools here + Drag to reorder + External Apps + Navigation Slider + Sidebar + Highlight selectable text + Edit Mode + + TTS Controls + Reading Mode + Page Management + + Text View (Reflow) + + + Current book + Global + This book + Enable replacements + Rules here apply to every book unless disabled for a specific title. + Add rule + Add book rule + No global replacement rules yet. + No book-specific rules yet. + Use global rules here + Turn this off when a book needs its own pronunciation choices. + Enable book rules + Local rules run after global rules. + Inherited global rules + No global rules to inherit. + Allowed in this book + Disabled for this book + Suggestions + + Dr. Smith met NASA at 5 p.m. + New replacement + Edit replacement + Replace + Speak as + Enabled + + Regex + Whole word + Match case + Preview input + Rules + + silence + Plain text + + case-sensitive diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index b8efb26..8baaf88 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -3,7 +3,8 @@ + - + diff --git a/app/src/oss/java/com/aryan/reader/BillingClientWrapper.kt b/app/src/oss/java/com/aryan/reader/BillingClientWrapper.kt index 6f3c9d8..638d535 100644 --- a/app/src/oss/java/com/aryan/reader/BillingClientWrapper.kt +++ b/app/src/oss/java/com/aryan/reader/BillingClientWrapper.kt @@ -25,6 +25,7 @@ class BillingClientWrapper( private val externalScope: CoroutineScope, private val onPurchaseVerified: (PurchaseEntity) -> Unit ) { + private val appContext = context.applicationContext private val _proUpgradeState = MutableStateFlow(ProUpgradeState()) val proUpgradeState = _proUpgradeState.asStateFlow() @@ -41,7 +42,7 @@ class BillingClientWrapper( productId: String = PRO_LIFETIME_PRODUCT_ID, obfuscatedAccountId: String? = null ) { - _proUpgradeState.value = _proUpgradeState.value.copy(error = "Not available in Open Source version") + _proUpgradeState.value = _proUpgradeState.value.copy(error = appContext.getString(R.string.billing_error_not_available_oss)) } fun consumePurchase(purchaseToken: String) {} diff --git a/app/src/test/java/com/aryan/reader/AppLanguageOptionsTest.kt b/app/src/test/java/com/aryan/reader/AppLanguageOptionsTest.kt new file mode 100644 index 0000000..322f528 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/AppLanguageOptionsTest.kt @@ -0,0 +1,60 @@ +package com.aryan.reader + +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory +import org.junit.Assert.assertEquals +import org.junit.Test + +class AppLanguageOptionsTest { + + @Test + fun `supported app languages include Spanish`() { + assertEquals( + listOf("en", "ar", "de", "tr", "fr", "ru", "es"), + supportedAppLanguageOptions.mapNotNull { it.tag } + ) + assertEquals(R.string.language_spanish, supportedAppLanguageOptions.last().labelRes) + } + + @Test + fun `app language selection defaults to system before explicit overrides`() { + assertEquals(null, appLanguageSelectionOptions.first().tag) + assertEquals(R.string.language_system_default, appLanguageSelectionOptions.first().labelRes) + assertEquals(supportedAppLanguageOptions, appLanguageSelectionOptions.drop(1)) + } + + @Test + fun `supported app language tags are unique`() { + val tags = supportedAppLanguageOptions.mapNotNull { it.tag } + + assertEquals(tags.distinct(), tags) + } + + @Test + fun `supported app languages match Android locale config`() { + assertEquals(readLocaleConfigTags(), supportedAppLanguageOptions.map { it.tag }) + } + + private fun readLocaleConfigTags(): List { + val localeConfig = listOf( + File("src/main/res/xml/locales_config.xml"), + File("app/src/main/res/xml/locales_config.xml") + ).first { it.isFile } + val document = DocumentBuilderFactory.newInstance() + .apply { isNamespaceAware = true } + .newDocumentBuilder() + .parse(localeConfig) + val localeNodes = document.getElementsByTagName("locale") + + return buildList { + for (index in 0 until localeNodes.length) { + add( + localeNodes.item(index) + .attributes + .getNamedItemNS("http://schemas.android.com/apk/res/android", "name") + .nodeValue + ) + } + } + } +} diff --git a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderBridgeAndControlsTest.kt b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderBridgeAndControlsTest.kt index cacfccf..9a51d7c 100644 --- a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderBridgeAndControlsTest.kt +++ b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderBridgeAndControlsTest.kt @@ -198,4 +198,29 @@ class EpubReaderBridgeAndControlsTest { assertTrue(ReaderTool.entries.any { it.category == "Overflow Menu" }) assertEquals("Top Bar", ReaderTool.SCREEN_ORIENTATION.category) } + + @Test + fun `reader toolbar reset defaults match first-run toolbar defaults`() { + assertEquals(setOf(ReaderTool.SCREEN_ORIENTATION.name), defaultReaderHiddenTools()) + assertEquals(ReaderTool.entries.toList(), defaultReaderToolOrder()) + assertEquals( + ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet(), + defaultReaderBottomTools() + ) + + val defaultItems = buildReaderToolbarItems( + hiddenTools = defaultReaderHiddenTools(), + toolOrder = defaultReaderToolOrder(), + bottomTools = defaultReaderBottomTools() + ) + + assertEquals( + ToolbarSection.HIDDEN, + defaultItems.single { it.tool == ReaderTool.SCREEN_ORIENTATION }.section + ) + assertEquals( + ToolbarSection.BOTTOM, + defaultItems.single { it.tool == ReaderTool.SLIDER }.section + ) + } } diff --git a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderPreferencesAndAnnotationsTest.kt b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderPreferencesAndAnnotationsTest.kt index 8fb9675..60bfc0f 100644 --- a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderPreferencesAndAnnotationsTest.kt +++ b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderPreferencesAndAnnotationsTest.kt @@ -57,7 +57,7 @@ class EpubReaderPreferencesAndAnnotationsTest { verticalMargin = 0.4f, fontFamily = ReaderFont.LORA, customFontPath = null, - textAlign = ReaderTextAlign.JUSTIFY + textAlign = ReaderTextAlign.RIGHT ) saveLocalReaderSettings( context = context, @@ -78,7 +78,7 @@ class EpubReaderPreferencesAndAnnotationsTest { assertEquals(1.4f, global.fontSize, 0.0001f) assertEquals(ReaderFont.LORA, global.font) - assertEquals(ReaderTextAlign.JUSTIFY, global.textAlign) + assertEquals(ReaderTextAlign.RIGHT, global.textAlign) assertNull(global.customPath) assertEquals(0.9f, local.fontSize, 0.0001f) assertEquals(1.1f, local.lineHeight, 0.0001f) diff --git a/app/src/test/java/com/aryan/reader/paginatedreader/PaginatedHighlightMappingTest.kt b/app/src/test/java/com/aryan/reader/paginatedreader/PaginatedHighlightMappingTest.kt index c76e782..89559da 100644 --- a/app/src/test/java/com/aryan/reader/paginatedreader/PaginatedHighlightMappingTest.kt +++ b/app/src/test/java/com/aryan/reader/paginatedreader/PaginatedHighlightMappingTest.kt @@ -54,6 +54,35 @@ class PaginatedHighlightMappingTest { assertNull(getHighlightOffsetsInBlock(block, highlight)) } + @Test + fun `paginated page highlights are scoped to page chapter`() { + val chapterFourHighlight = highlight( + cfi = "/4/10:11|/4/12:79", + text = "Original chapter text", + chapterIndex = 4 + ) + val chapterFiveHighlight = highlight( + cfi = "/4/10:11|/4/12:79", + text = "Different chapter text", + chapterIndex = 5 + ) + + assertEquals( + listOf(chapterFiveHighlight), + highlightsForPaginatedPage( + pageChapterIndex = 5, + userHighlights = listOf(chapterFourHighlight, chapterFiveHighlight) + ) + ) + assertEquals( + emptyList(), + highlightsForPaginatedPage( + pageChapterIndex = null, + userHighlights = listOf(chapterFourHighlight) + ) + ) + } + private fun paragraph( text: String, cfi: String, @@ -70,14 +99,15 @@ class PaginatedHighlightMappingTest { private fun highlight( cfi: String, - text: String + text: String, + chapterIndex: Int = 0 ): UserHighlight { return UserHighlight( id = "highlight", cfi = cfi, text = text, color = HighlightColor.YELLOW, - chapterIndex = 0 + chapterIndex = chapterIndex ) } } diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfReaderSettingsAndSharedModelsTest.kt b/app/src/test/java/com/aryan/reader/pdf/PdfReaderSettingsAndSharedModelsTest.kt index 5f3c89b..4860e92 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfReaderSettingsAndSharedModelsTest.kt +++ b/app/src/test/java/com/aryan/reader/pdf/PdfReaderSettingsAndSharedModelsTest.kt @@ -160,4 +160,36 @@ class PdfReaderSettingsAndSharedModelsTest { assertTrue(width >= 1) assertTrue(height >= 1) } + + @Test + fun `pdf toolbar reset defaults match first-run toolbar defaults`() { + assertEquals( + setOf(PdfReaderTool.SCREEN_ORIENTATION.name, PdfReaderTool.HIGHLIGHT_ALL.name), + defaultPdfHiddenTools() + ) + assertEquals(PdfReaderTool.entries.toList(), defaultPdfToolOrder()) + assertEquals( + PdfReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet(), + defaultPdfBottomTools() + ) + + val defaultItems = buildPdfToolbarItems( + hiddenTools = defaultPdfHiddenTools(), + toolOrder = defaultPdfToolOrder(), + bottomTools = defaultPdfBottomTools() + ) + + assertEquals( + PdfToolbarSection.HIDDEN, + defaultItems.single { it.tool == PdfReaderTool.SCREEN_ORIENTATION }.section + ) + assertEquals( + PdfToolbarSection.HIDDEN, + defaultItems.single { it.tool == PdfReaderTool.HIGHLIGHT_ALL }.section + ) + assertEquals( + PdfToolbarSection.BOTTOM, + defaultItems.single { it.tool == PdfReaderTool.SLIDER }.section + ) + } } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAppHost.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAppHost.kt new file mode 100644 index 0000000..6feac01 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAppHost.kt @@ -0,0 +1,532 @@ +package com.aryan.reader.desktop + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.WindowState +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import com.aryan.reader.shared.AppContrastOption +import com.aryan.reader.shared.AppThemeMode +import com.aryan.reader.shared.ReaderFeatureSurface +import com.aryan.reader.shared.ui.SharedAppTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.withContext +import java.awt.Component +import java.awt.EventQueue +import java.awt.Frame +import java.awt.GraphicsDevice +import java.awt.KeyboardFocusManager +import java.awt.Rectangle +import java.awt.Toolkit +import java.awt.event.KeyEvent as AwtKeyEvent +import java.util.concurrent.atomic.AtomicReference + +internal val DesktopDefaultAppSeedColor = Color(0xFFFFB300) + +internal fun launchEpistemeDesktopApplication(startupSplash: DesktopStartupSplash? = null) { + configureComposeSwingInterop() + application { + val windowDefaults = remember { epistemeDesktopWindowDefaults() } + val windowStateStore = remember { DesktopWindowStateStore() } + val restoredWindowState = remember { windowStateStore.load() } + val windowState = rememberWindowState( + placement = restoredWindowState?.toWindowPlacement() + ?: DesktopWindowStateSnapshot.default().toWindowPlacement(), + position = restoredWindowState?.toWindowPosition() ?: WindowPosition(Alignment.Center), + size = restoredWindowState?.toWindowSize(windowDefaults.defaultSize) ?: windowDefaults.defaultSize + ) + var readerFullscreen by remember { mutableStateOf(false) } + DesktopWindowStatePersistenceEffect( + windowState = windowState, + store = windowStateStore, + enabled = !readerFullscreen + ) + Window( + onCloseRequest = ::exitApplication, + title = windowDefaults.title, + state = windowState, + icon = painterResource(windowDefaults.iconResourcePath) + ) { + DisposableEffect(window, windowDefaults.minimumSize) { + window.minimumSize = windowDefaults.minimumSize + onDispose { + startupSplash?.close() + } + } + EpistemeDesktopStartupGate( + window = window, + startupSplash = startupSplash, + appWindowPlacement = windowState.placement, + readerFullscreen = readerFullscreen, + onReaderFullscreenChange = { readerFullscreen = it } + ) + } + } +} + +@Composable +private fun EpistemeDesktopStartupGate( + window: Component?, + startupSplash: DesktopStartupSplash?, + appWindowPlacement: WindowPlacement, + readerFullscreen: Boolean, + onReaderFullscreenChange: (Boolean) -> Unit +) { + var showApp by remember { mutableStateOf(false) } + + DisposableEffect(startupSplash) { + onDispose { + startupSplash?.close() + } + } + + if (showApp) { + EpistemeDesktopApp( + window = window, + appWindowPlacement = appWindowPlacement, + readerFullscreen = readerFullscreen, + onReaderFullscreenChange = onReaderFullscreenChange + ) + } else { + EpistemeDesktopStartupScreen(window = window) + } + + LaunchedEffect(Unit) { + withFrameNanos { } + startupSplash?.close() + delay(80L) + showApp = true + } +} + +@Composable +private fun EpistemeDesktopStartupScreen(window: Component?) { + val appTitle = remember { epistemeDesktopWindowDefaults().title } + SharedAppTheme( + appThemeMode = AppThemeMode.SYSTEM, + appContrastOption = AppContrastOption.STANDARD, + appTextDimFactorLight = 1.0f, + appTextDimFactorDark = 1.0f, + appSeedColor = DesktopDefaultAppSeedColor + ) { + EpistemeDesktopWindowChromeEffect( + window = window, + captionColor = MaterialTheme.colorScheme.surface, + textColor = MaterialTheme.colorScheme.onSurface, + borderColor = MaterialTheme.colorScheme.background + ) + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.padding(32.dp) + ) { + Image( + painter = painterResource(EpistemeDesktopWindowIconResource), + contentDescription = null, + modifier = Modifier.size(64.dp) + ) + Text( + text = appTitle, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) + Text( + text = "Opening your library", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + strokeWidth = 3.dp + ) + } + } + } +} + +internal const val ComposeInteropBlendingProperty = "compose.interop.blending" +internal const val ComposeInteropBlendingEnabled = "true" +private const val DesktopWindowStatePersistDebounceMillis = 450L + +internal fun configureComposeSwingInterop() { + // Must run before Compose creates the desktop window. Vertical EPUB embeds a Swing-backed + // JCEF WebView, and current Compose interop can leave a stale black native rectangle after + // that reader surface is removed unless interop blending is enabled. + if (System.getProperty(ComposeInteropBlendingProperty).isNullOrBlank()) { + System.setProperty(ComposeInteropBlendingProperty, ComposeInteropBlendingEnabled) + } +} + +@Composable +private fun DesktopWindowStatePersistenceEffect( + windowState: WindowState, + store: DesktopWindowStateStore, + enabled: Boolean +) { + val persistenceEnabled by rememberUpdatedState(enabled) + LaunchedEffect(windowState, store) { + snapshotFlow { DesktopWindowStateSnapshot.fromWindowState(windowState) } + .distinctUntilChanged() + .collectLatest { snapshot -> + if (!persistenceEnabled || snapshot == null) return@collectLatest + delay(DesktopWindowStatePersistDebounceMillis) + if (persistenceEnabled) { + withContext(Dispatchers.IO) { + store.save(snapshot) + } + } + } + } +} + +@Composable +internal fun DesktopReaderFullscreenEffect( + window: Component?, + enabled: Boolean +) { + val awtWindow = window as? java.awt.Window ?: return + val fullscreenSnapshot = remember(awtWindow) { + AtomicReference() + } + val pendingExitSnapshot = remember(awtWindow) { + AtomicReference() + } + + LaunchedEffect(awtWindow, enabled) { + if (!enabled && fullscreenSnapshot.get() == null) { + return@LaunchedEffect + } + if (enabled) { + EventQueue.invokeLater { + awtWindow.captureDesktopReaderFullscreenSnapshot(fullscreenSnapshot) + } + } + delay(if (enabled) 180L else 80L) + EventQueue.invokeLater { + if (enabled) { + pendingExitSnapshot.set(null) + awtWindow.enterDesktopReaderFullscreen(fullscreenSnapshot) + } else { + awtWindow.beginDesktopReaderFullscreenExit(fullscreenSnapshot, pendingExitSnapshot) + } + } + delay(120L) + EventQueue.invokeLater { + if (!enabled) { + awtWindow.restoreDesktopReaderFullscreenExitBounds(pendingExitSnapshot.getAndSet(null)) + } + awtWindow.refreshDesktopReaderWindowFocus() + } + } + + DisposableEffect(awtWindow) { + onDispose { + EventQueue.invokeLater { + awtWindow.beginDesktopReaderFullscreenExit(fullscreenSnapshot) + } + } + } +} + +private data class DesktopReaderFullscreenSnapshot( + val device: GraphicsDevice?, + val frameState: Int?, + val frameBounds: Rectangle?, + val alwaysOnTop: Boolean +) + +private fun java.awt.Window.enterDesktopReaderFullscreen( + snapshotRef: AtomicReference +) { + if (!isDisplayable) return + focusableWindowState = true + captureDesktopReaderFullscreenSnapshot(snapshotRef) + applyDesktopReaderBorderlessFullscreen(snapshotRef.get()) + refreshDesktopReaderWindowFocus() +} + +private fun java.awt.Window.captureDesktopReaderFullscreenSnapshot( + snapshotRef: AtomicReference +) { + snapshotRef.compareAndSet( + null, + DesktopReaderFullscreenSnapshot( + device = graphicsConfiguration?.device, + frameState = (this as? Frame)?.extendedState, + frameBounds = bounds.desktopReaderCopy(), + alwaysOnTop = isAlwaysOnTop + ) + ) +} + +private fun java.awt.Window.applyDesktopReaderBorderlessFullscreen(snapshot: DesktopReaderFullscreenSnapshot?) { + if (!isDisplayable) return + val device = snapshot?.device ?: graphicsConfiguration?.device + val frame = this as? Frame + focusableWindowState = true + if (frame != null) { + frame.extendedState = frame.extendedState and Frame.ICONIFIED.inv() and Frame.MAXIMIZED_BOTH.inv() + frame.state = Frame.NORMAL + } + device?.let { fullscreenDevice -> + runCatching { + if (fullscreenDevice.fullScreenWindow == this) { + fullscreenDevice.fullScreenWindow = null + } + } + } + runCatching { + bounds = device?.desktopReaderScreenBounds() ?: graphicsConfiguration?.bounds?.desktopReaderCopy() ?: bounds + } + runCatching { + isAlwaysOnTop = true + } +} + +private fun java.awt.Window.beginDesktopReaderFullscreenExit( + snapshotRef: AtomicReference, + pendingExitSnapshotRef: AtomicReference? = null +) { + val snapshot = snapshotRef.getAndSet(null) + if (snapshot == null) return + pendingExitSnapshotRef?.set(snapshot) + val device = snapshot.device ?: graphicsConfiguration?.device + device?.let { fullscreenDevice -> + runCatching { + if (fullscreenDevice.fullScreenWindow == this) { + fullscreenDevice.fullScreenWindow = null + } + } + } + runCatching { + isAlwaysOnTop = snapshot.alwaysOnTop + } + if (!isVisible) { + isVisible = true + } + (this as? Frame)?.let { frame -> + frame.state = Frame.NORMAL + frame.extendedState = Frame.NORMAL + } +} + +private fun java.awt.Window.restoreDesktopReaderFullscreenExitBounds(snapshot: DesktopReaderFullscreenSnapshot?) { + if (snapshot == null) return + runCatching { + isAlwaysOnTop = snapshot.alwaysOnTop + } + if (!isVisible) { + isVisible = true + } + val frame = this as? Frame + if (frame == null) { + bounds = snapshot.frameBounds.desktopReaderRestoreBounds(snapshot.device) + return + } + val restoreMaximized = snapshot.frameState?.let { state -> + state and Frame.MAXIMIZED_BOTH == Frame.MAXIMIZED_BOTH + } == true + frame.extendedState = Frame.NORMAL + frame.state = Frame.NORMAL + frame.bounds = snapshot.frameBounds.desktopReaderRestoreBounds(snapshot.device) + if (restoreMaximized) { + frame.maximizedBounds = snapshot.device?.desktopReaderUsableBounds() + EventQueue.invokeLater { + if (frame.isDisplayable && frame.isShowing) { + frame.extendedState = Frame.MAXIMIZED_BOTH + } + } + } + frame.toFront() + frame.requestFocus() + frame.validate() +} + +private fun GraphicsDevice.desktopReaderScreenBounds(): Rectangle { + return defaultConfiguration.bounds.desktopReaderCopy() +} + +private fun GraphicsDevice.desktopReaderUsableBounds(): Rectangle? { + val configuration = defaultConfiguration ?: return null + return runCatching { + val bounds = configuration.bounds + val insets = Toolkit.getDefaultToolkit().getScreenInsets(configuration) + Rectangle( + bounds.x + insets.left, + bounds.y + insets.top, + (bounds.width - insets.left - insets.right).coerceAtLeast(1), + (bounds.height - insets.top - insets.bottom).coerceAtLeast(1) + ) + }.getOrNull() +} + +private fun Rectangle?.desktopReaderRestoreBounds(device: GraphicsDevice?): Rectangle { + val usableBounds = device?.desktopReaderUsableBounds() + ?: return this?.desktopReaderCopy() ?: Rectangle(80, 80, 1280, 820) + val source = this ?: usableBounds + val width = source.width.coerceIn(640, usableBounds.width.coerceAtLeast(640)) + val height = source.height.coerceIn(480, usableBounds.height.coerceAtLeast(480)) + val looksFullscreen = source.x <= usableBounds.x && + source.y <= usableBounds.y && + source.width >= usableBounds.width && + source.height >= usableBounds.height + if (looksFullscreen) { + return usableBounds.desktopReaderCopy() + } + val maxX = (usableBounds.x + usableBounds.width - width).coerceAtLeast(usableBounds.x) + val maxY = (usableBounds.y + usableBounds.height - height).coerceAtLeast(usableBounds.y) + return Rectangle( + source.x.coerceIn(usableBounds.x, maxX), + source.y.coerceIn(usableBounds.y, maxY), + width, + height + ) +} + +private fun Rectangle.desktopReaderCopy(): Rectangle { + return Rectangle(x, y, width, height) +} + +private fun java.awt.Window.refreshDesktopReaderWindowFocus() { + if (!isDisplayable) return + if (this is Frame && extendedState and Frame.ICONIFIED != 0) { + extendedState = extendedState and Frame.ICONIFIED.inv() + } + toFront() + requestFocus() + requestFocusInWindow() + focusOwner?.requestFocus() +} + +@Composable +internal fun DesktopReaderFullscreenKeyEffect( + enabled: Boolean, + onKeyPressed: (AwtKeyEvent) -> Boolean +) { + val currentOnKeyPressed by rememberUpdatedState(onKeyPressed) + DisposableEffect(enabled) { + if (!enabled) { + onDispose {} + } else { + val focusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager() + val dispatcher = java.awt.KeyEventDispatcher { event -> + val modalWindowActive = focusManager.activeWindow?.isDesktopReaderModalWindow() == true + !modalWindowActive && event.id == AwtKeyEvent.KEY_PRESSED && currentOnKeyPressed(event) + } + focusManager.addKeyEventDispatcher(dispatcher) + onDispose { + focusManager.removeKeyEventDispatcher(dispatcher) + } + } + } +} + +private fun java.awt.Window.isDesktopReaderModalWindow(): Boolean { + val windowTitle = when (this) { + is java.awt.Dialog -> title + is Frame -> title + else -> "" + } + return name?.startsWith(DesktopReaderModalWindowNamePrefix) == true || + windowTitle.startsWith("Reader Panel") || + windowTitle.startsWith("Reader Popup") +} + +private const val DesktopReaderModalWindowNamePrefix = "shared-reader-modal:" + +internal data class DesktopWebViewRuntimeState( + val initialized: Boolean = false, + val restartRequired: Boolean = false, + val downloadProgress: Float = -1f, + val errorMessage: String? = null +) + +internal fun shouldRequestDesktopWebViewRuntime(readerSurface: ReaderFeatureSurface?): Boolean { + return readerSurface == ReaderFeatureSurface.TEXT_READER +} + +internal fun shouldStartDesktopWebViewRuntime( + requested: Boolean, + state: DesktopWebViewRuntimeState +): Boolean { + return requested && !state.initialized && !state.restartRequired && state.errorMessage == null +} + +@Composable +internal fun DesktopWebViewRuntimeIndicator( + state: DesktopWebViewRuntimeState, + modifier: Modifier = Modifier +) { + val message = when { + state.errorMessage != null -> "Embedded webview could not start: ${state.errorMessage}" + state.restartRequired -> "Embedded webview installed. Restart Episteme to finish setup." + state.downloadProgress >= 0f -> "Preparing bundled embedded webview ${state.downloadProgress.toInt()}%" + else -> "Preparing embedded webview..." + } + + Box( + modifier = modifier.padding(32.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (state.errorMessage == null && !state.restartRequired) { + CircularProgressIndicator() + } + Text( + text = message, + color = if (state.errorMessage == null) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + if (state.downloadProgress in 0f..100f) { + LinearProgressIndicator( + progress = { state.downloadProgress / 100f }, + modifier = Modifier.width(260.dp) + ) + } + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubBridgeParsing.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubBridgeParsing.kt new file mode 100644 index 0000000..cff8f9f --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubBridgeParsing.kt @@ -0,0 +1,335 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.ReaderLocator +import com.aryan.reader.shared.ui.SharedNativeReaderLinkClick +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.awt.event.KeyEvent as AwtKeyEvent +import java.net.URLDecoder + +internal data class DesktopReaderPosition( + val pageIndex: Int, + val locator: ReaderLocator? +) + +internal data class DesktopReaderHighlightClick( + val highlightId: String +) + +internal data class DesktopEpubLinkClick( + val href: String, + val chapterIndex: Int?, + val text: String? = null, + val chapterId: String? = null, + val chapterHref: String? = null, + val source: String = "bridge" +) + +internal fun SharedNativeReaderLinkClick.toDesktopEpubLinkClick(): DesktopEpubLinkClick { + return DesktopEpubLinkClick( + href = href, + chapterIndex = chapterIndex, + text = text, + source = "native" + ) +} + +internal data class DesktopEpubHandledLink( + val href: String, + val handledAtMs: Long +) + +internal enum class DesktopReaderSelectionAction { + DEFINE, + SPEAK, + SEARCH +} + +internal enum class DesktopReaderKeyNavigation { + NEXT, + PREVIOUS, + FIRST, + LAST, + SEARCH, + NEXT_SEARCH, + EXIT_FULLSCREEN +} + +internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull(fullscreen: Boolean): DesktopReaderKeyNavigation? { + if (id != AwtKeyEvent.KEY_PRESSED) return null + if (fullscreen && keyCode == AwtKeyEvent.VK_ESCAPE) { + return DesktopReaderKeyNavigation.EXIT_FULLSCREEN + } + if (isControlDown && keyCode == AwtKeyEvent.VK_F) { + return DesktopReaderKeyNavigation.SEARCH + } + if (isControlDown && keyCode == AwtKeyEvent.VK_G) { + return DesktopReaderKeyNavigation.NEXT_SEARCH + } + return when (keyCode) { + AwtKeyEvent.VK_RIGHT, + AwtKeyEvent.VK_PAGE_DOWN -> DesktopReaderKeyNavigation.NEXT + AwtKeyEvent.VK_LEFT, + AwtKeyEvent.VK_PAGE_UP -> DesktopReaderKeyNavigation.PREVIOUS + AwtKeyEvent.VK_HOME -> DesktopReaderKeyNavigation.FIRST + AwtKeyEvent.VK_END -> DesktopReaderKeyNavigation.LAST + else -> null + } +} + +internal data class DesktopReaderSelectionActionPayload( + val action: DesktopReaderSelectionAction, + val text: String +) + +internal fun String.readerHighlightClickOrNull(): DesktopReaderHighlightClick? { + fun parse(rawJson: String): DesktopReaderHighlightClick? = runCatching { + val obj = Json.parseToJsonElement(rawJson).jsonObject + val highlightId = obj["id"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?.takeIf { it.isNotBlank() } + ?: obj["highlightId"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?.takeIf { it.isNotBlank() } + ?: return@runCatching null + DesktopReaderHighlightClick(highlightId) + }.getOrNull() + + parse(this)?.let { return it } + return runCatching { + Json.parseToJsonElement(this).jsonPrimitive.contentOrNull + }.getOrNull()?.let { parse(it) } +} + +internal fun String.readerSelectionActionOrNull(): DesktopReaderSelectionActionPayload? { + fun parse(rawJson: String): DesktopReaderSelectionActionPayload? = runCatching { + val obj = Json.parseToJsonElement(rawJson).jsonObject + val text = obj["text"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?.takeIf { it.isNotBlank() } + ?: return@runCatching null + val action = when ( + obj["action"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?.lowercase() + ) { + "define" -> DesktopReaderSelectionAction.DEFINE + "speak" -> DesktopReaderSelectionAction.SPEAK + "web-search", "search" -> DesktopReaderSelectionAction.SEARCH + else -> return@runCatching null + } + DesktopReaderSelectionActionPayload(action, text) + }.getOrNull() + + parse(this)?.let { return it } + return runCatching { + Json.parseToJsonElement(this).jsonPrimitive.contentOrNull + }.getOrNull()?.let { parse(it) } +} + +internal fun String.readerSelectionDebugMessageOrNull(): String? { + fun parse(rawJson: String): String? = runCatching { + Json.parseToJsonElement(rawJson) + .jsonObject["message"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?.takeIf { it.isNotBlank() } + }.getOrNull() + + parse(this)?.let { return it } + return runCatching { + Json.parseToJsonElement(this).jsonPrimitive.contentOrNull + }.getOrNull()?.let { parse(it) } +} + +internal fun String.readerPaginationLogMessageOrNull(): String? { + fun parse(rawJson: String): String? = runCatching { + Json.parseToJsonElement(rawJson) + .jsonObject["message"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?.takeIf { it.isNotBlank() } + }.getOrNull() + + parse(this)?.let { return it } + return runCatching { + Json.parseToJsonElement(this).jsonPrimitive.contentOrNull + }.getOrNull()?.let { parse(it) } +} + +internal fun String.readerPositionOrNull(): DesktopReaderPosition? { + fun parse(rawJson: String): DesktopReaderPosition? = runCatching { + val obj = Json.parseToJsonElement(rawJson).jsonObject + val pageIndex = obj["pageIndex"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.intOrNull + ?: return@runCatching null + val locator = ReaderLocator( + chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + pageIndex = pageIndex, + startOffset = obj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + endOffset = obj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + textQuote = obj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, + cfi = obj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull + ) + DesktopReaderPosition(pageIndex, locator) + }.getOrNull() + + parse(this)?.let { return it } + return runCatching { + Json.parseToJsonElement(this).jsonPrimitive.contentOrNull + }.getOrNull()?.let { parse(it) } +} + +internal fun String.readerKeyNavigationOrNull(): DesktopReaderKeyNavigation? { + fun parse(rawJson: String): DesktopReaderKeyNavigation? = runCatching { + val action = Json.parseToJsonElement(rawJson) + .jsonObject["action"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?: return@runCatching null + when (action) { + "next" -> DesktopReaderKeyNavigation.NEXT + "previous" -> DesktopReaderKeyNavigation.PREVIOUS + "first" -> DesktopReaderKeyNavigation.FIRST + "last" -> DesktopReaderKeyNavigation.LAST + "search" -> DesktopReaderKeyNavigation.SEARCH + "nextSearch" -> DesktopReaderKeyNavigation.NEXT_SEARCH + "exitFullscreen" -> DesktopReaderKeyNavigation.EXIT_FULLSCREEN + else -> null + } + }.getOrNull() + + parse(this)?.let { return it } + return runCatching { + Json.parseToJsonElement(this).jsonPrimitive.contentOrNull + }.getOrNull()?.let { parse(it) } +} + +internal fun String.readerLinkClickOrNull(): DesktopEpubLinkClick? { + fun parse(rawJson: String): DesktopEpubLinkClick? = runCatching { + val obj = Json.parseToJsonElement(rawJson).jsonObject + val href = obj["href"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?.takeIf { it.isNotBlank() } + ?: return@runCatching null + DesktopEpubLinkClick( + href = href, + chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + text = obj["text"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, + chapterId = obj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, + chapterHref = obj["chapterHref"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull + ) + }.getOrNull() + + parse(this)?.let { return it } + return runCatching { + Json.parseToJsonElement(this).jsonPrimitive.contentOrNull + }.getOrNull()?.let { parse(it) } +} + +internal fun String.readerLinkClickFromIntercept(): DesktopEpubLinkClick? { + val trimmed = trim() + if (trimmed.startsWith("readerlink:", ignoreCase = true)) { + logEpubLink("request_intercept_readerlink raw=\"${trimmed.logPreview()}\"") + val payload = trimmed.substringAfter("?", missingDelimiterValue = "") + .split('&') + .firstOrNull { it.substringBefore("=").equals("payload", ignoreCase = true) } + ?.substringAfter("=", missingDelimiterValue = "") + ?.takeIf { it.isNotBlank() } + if (payload == null) { + logEpubLink("request_intercept_readerlink_ignored reason=missing_payload") + return null + } + val decoded = runCatching { + URLDecoder.decode(payload, Charsets.UTF_8.name()) + }.getOrElse { + logEpubLink("request_intercept_payload_decode_failed error=\"${it.message.orEmpty().logPreview()}\"") + return null + } + val link = decoded.readerLinkClickOrNull()?.copy(source = "request") + if (link == null) { + logEpubLink("request_intercept_readerlink_ignored reason=parse_failed payload=\"${decoded.logPreview()}\"") + } + return link + } + return readerHrefFromIntercept()?.let { href -> + DesktopEpubLinkClick( + href = href, + chapterIndex = null, + source = "request" + ) + } +} + +private fun String.readerHrefFromIntercept(): String? { + val trimmed = trim() + if (trimmed.isBlank()) return null + if (trimmed.equals("about:blank", ignoreCase = true)) return null + if (trimmed.startsWith("file:///kcefbrowser/", ignoreCase = true)) return null + if (trimmed.startsWith("file:/kcefbrowser/", ignoreCase = true)) return null + if (trimmed.startsWith("file://", ignoreCase = true)) return null + if (trimmed.startsWith("about:blank#", ignoreCase = true)) return "#${trimmed.substringAfter('#')}" + if (trimmed.startsWith("data:", ignoreCase = true)) return null + if (trimmed.startsWith("blob:", ignoreCase = true)) return null + return trimmed +} + +internal fun ReaderLocator.toReaderLocatorJson(): String { + return buildString { + append("{") + val values = buildList { + chapterIndex?.let { add("\"chapterIndex\":$it") } + pageIndex?.let { add("\"pageIndex\":$it") } + startOffset?.let { add("\"startOffset\":$it") } + endOffset?.let { add("\"endOffset\":$it") } + cfi?.let { add("\"cfi\":${it.toJsonStringLiteral()}") } + textQuote?.let { add("\"textQuote\":${it.toJsonStringLiteral()}") } + } + append(values.joinToString(",")) + append("}") + } +} + +private fun String.toJsonStringLiteral(): String { + val builder = StringBuilder("\"") + forEach { char -> + when (char) { + '\\' -> builder.append("\\\\") + '"' -> builder.append("\\\"") + '\n' -> builder.append("\\n") + '\r' -> builder.append("\\r") + '\t' -> builder.append("\\t") + '\b' -> builder.append("\\b") + '\u000C' -> builder.append("\\f") + else -> { + if (char.code < 0x20) { + builder.append("\\u") + builder.append(char.code.toString(16).padStart(4, '0')) + } else { + builder.append(char) + } + } + } + } + builder.append('"') + return builder.toString() +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubPagination.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubPagination.kt new file mode 100644 index 0000000..dc0be71 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubPagination.kt @@ -0,0 +1,65 @@ +package com.aryan.reader.desktop + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.aryan.reader.shared.reader.ReaderLayoutSignature +import com.aryan.reader.shared.reader.ReaderViewportSpec +import com.aryan.reader.shared.reader.SharedEpubBook + +internal data class DesktopEpubPaginationRequest( + val bookId: String, + val chapterSignature: Int, + val layoutSignature: ReaderLayoutSignature, + val viewport: ReaderViewportSpec, + val density: DesktopEpubPaginationDensity, + val cacheGeneration: Int +) + +internal data class DesktopEpubPaginationDensity( + val density: Float, + val fontScale: Float +) + +internal fun SharedEpubBook.desktopPaginationContentSignature(): Int { + return chapters.fold(31 * id.hashCode() + css.hashCode()) { acc, chapter -> + 31 * acc + + chapter.id.hashCode() + + chapter.plainText.length + + chapter.plainText.hashCode() + + chapter.semanticBlocks.hashCode() + + chapter.htmlContent.length + + chapter.htmlContent.hashCode() + + chapter.baseHref.orEmpty().hashCode() + } +} + +@Composable +internal fun DesktopEpubPaginationPreparing( + active: Boolean, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + CircularProgressIndicator() + Text( + if (active) "Preparing pages" else "Measuring reader layout", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopExternalLinks.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopExternalLinks.kt new file mode 100644 index 0000000..e5e2c83 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopExternalLinks.kt @@ -0,0 +1,158 @@ +package com.aryan.reader.desktop + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import java.awt.Desktop +import java.net.URI +import java.net.URLEncoder + +internal const val EpistemeSourceUrl = "https://github.com/Aryan-Raj3112/episteme" +internal const val EpistemeIssuesUrl = "https://github.com/Aryan-Raj3112/episteme/issues" +internal const val EpistemeGitHubSponsorsUrl = "https://github.com/sponsors/Aryan-Raj3112" +internal const val EpistemePatreonUrl = "https://www.patreon.com/c/epistemereader" +internal const val EpistemeSupportEmail = "epistemereader@gmail.com" + +private const val ExternalLinkLogTag = "EpistemeExternalLink" + +internal fun desktopFeedbackSubject(profile: DesktopBuildProfile): String { + return "Feedback: ${profile.appName}" +} + +internal fun desktopAppVersionName(): String { + val version = System.getProperty(DesktopVersionProperty) + ?.takeIf { it.isNotBlank() } + ?: EpistemeDesktopAppVersion::class.java.getPackage()?.implementationVersion + ?.takeIf { it.isNotBlank() } + return version?.let { "Version $it" } ?: "Version unavailable" +} + +private object EpistemeDesktopAppVersion + +@Composable +internal fun DesktopExternalLinkDialog( + url: String?, + onDismiss: () -> Unit +) { + if (url == null) return + val clipboardManager = LocalClipboardManager.current + LaunchedEffect(url) { + logExternalLink("dialog_show url=\"${url.logPreview()}\"") + } + fun dismiss() { + logExternalLink("dialog_dismiss url=\"${url.logPreview()}\"") + onDismiss() + } + DesktopReaderBottomSheet( + title = "External link", + onDismiss = ::dismiss + ) { + Text( + "You clicked an external link.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Text( + url, + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = ::dismiss) { + Text("Cancel") + } + TextButton( + onClick = { + logExternalLink("dialog_copy url=\"${url.logPreview()}\"") + clipboardManager.setText(AnnotatedString(url)) + onDismiss() + } + ) { + Text("Copy") + } + TextButton( + onClick = { + logExternalLink("dialog_open url=\"${url.logPreview()}\"") + openExternalUrl(url) + onDismiss() + } + ) { + Text("Open") + } + } + } +} + +internal fun openExternalUrl(url: String) { + if (!currentDesktopBuildProfile().featurePolicy.projectLinks) { + logExternalLink("open_blocked_offline url=\"${url.logPreview()}\"") + return + } + val normalizedUrl = url.normalizedExternalUrl() + runCatching { + if (Desktop.isDesktopSupported()) { + val desktop = Desktop.getDesktop() + if (normalizedUrl.startsWith("mailto:", ignoreCase = true)) { + desktop.mail(URI(normalizedUrl)) + } else { + desktop.browse(URI(normalizedUrl)) + } + logExternalLink("open_system_browser_success url=\"${normalizedUrl.logPreview()}\"") + } else { + logExternalLink("open_system_browser_unavailable url=\"${normalizedUrl.logPreview()}\"") + } + }.onFailure { throwable -> + logExternalLink("open_system_browser_failed url=\"${normalizedUrl.logPreview()}\" error=\"${throwable.message.orEmpty().logPreview()}\"") + } +} + +internal fun String.normalizedExternalUrl(): String { + val trimmed = trim() + return if (trimmed.startsWith("www.", ignoreCase = true)) { + "https://$trimmed" + } else { + trimmed + } +} + +internal fun String.isRemoteNetworkUrl(): Boolean { + val trimmed = trim() + return trimmed.startsWith("http://", ignoreCase = true) || + trimmed.startsWith("https://", ignoreCase = true) || + trimmed.startsWith("ws://", ignoreCase = true) || + trimmed.startsWith("wss://", ignoreCase = true) +} + +internal fun String.urlEncode(): String { + return URLEncoder.encode(this, Charsets.UTF_8.name()) +} + +private fun logExternalLink(message: String) { + logDesktopDiagnostic(ExternalLinkLogTag) { message } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFileDialogs.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFileDialogs.kt new file mode 100644 index 0000000..fd2bacd --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFileDialogs.kt @@ -0,0 +1,95 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.FileType +import com.aryan.reader.shared.ImportedBookFile +import com.aryan.reader.shared.ReaderPlatform +import com.aryan.reader.shared.SharedFileCapabilities +import java.awt.FileDialog +import java.awt.Frame +import java.io.File +import javax.swing.JFileChooser + +internal val DesktopReadableFileTypes = SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP) +internal val DesktopSyncableFileTypes = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.DESKTOP) +internal val DesktopBookFileTypes = DesktopReadableFileTypes +private val DesktopBookFileDialogPattern = SharedFileCapabilities.all + .filter { it.type in DesktopBookFileTypes } + .flatMap { capability -> capability.extensions.map { extension -> "*.$extension" } } + .joinToString(";") + +internal fun desktopBookFileTypesForDialog(): Set = DesktopBookFileTypes + +internal fun chooseFiles(): List { + val dialog = FileDialog(null as Frame?, "Import books", FileDialog.LOAD).apply { + isMultipleMode = true + isVisible = true + } + return dialog.files.orEmpty().map { it.toDesktopImportedBookFile() } +} + +internal fun chooseBookFile(): File? { + val dialog = FileDialog(null as Frame?, "Open Book", FileDialog.LOAD).apply { + file = DesktopBookFileDialogPattern + isVisible = true + } + val directory = dialog.directory ?: return null + val file = dialog.file ?: return null + return File(directory, file) +} + +internal fun choosePdfFile(): File? { + val dialog = FileDialog(null as Frame?, "Open PDF", FileDialog.LOAD).apply { + file = "*.pdf" + isVisible = true + } + val directory = dialog.directory ?: return null + val file = dialog.file ?: return null + return File(directory, file) +} + +internal fun chooseFontFile(): File? { + val dialog = FileDialog(null as Frame?, "Choose font", FileDialog.LOAD).apply { + file = "*.ttf;*.otf;*.woff2" + isVisible = true + } + val directory = dialog.directory ?: return null + val file = dialog.file ?: return null + return File(directory, file) +} + +internal fun chooseReaderTextureFile(): File? { + val dialog = FileDialog(null as Frame?, "Choose reader texture", FileDialog.LOAD).apply { + file = "*.png;*.jpg;*.jpeg;*.webp;*.gif;*.bmp" + isVisible = true + } + val directory = dialog.directory ?: return null + val file = dialog.file ?: return null + return File(directory, file) +} + +internal fun chooseFolder(): File? { + val chooser = JFileChooser().apply { + dialogTitle = "Import folder" + fileSelectionMode = JFileChooser.DIRECTORIES_ONLY + isAcceptAllFileFilterUsed = false + } + return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) { + chooser.selectedFile + } else { + null + } +} + +internal fun ImportedBookFile.desktopFileType(): FileType { + return SharedFileCapabilities.fileTypeForName(name) +} + +internal fun File.toDesktopImportedBookFile(sourceFolder: String? = null): ImportedBookFile { + return ImportedBookFile( + name = name, + uriString = null, + localPath = absolutePath, + size = length(), + sourceFolder = sourceFolder + ) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFileDropTarget.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFileDropTarget.kt new file mode 100644 index 0000000..30c5690 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFileDropTarget.kt @@ -0,0 +1,228 @@ +package com.aryan.reader.desktop + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.aryan.reader.shared.ImportedBookFile +import com.aryan.reader.shared.ReaderPlatform +import com.aryan.reader.shared.SharedFileCapabilities +import java.awt.Component +import java.awt.Container +import java.awt.EventQueue +import java.awt.datatransfer.DataFlavor +import java.awt.dnd.DnDConstants +import java.awt.dnd.DropTarget +import java.awt.dnd.DropTargetAdapter +import java.awt.dnd.DropTargetDragEvent +import java.awt.dnd.DropTargetDropEvent +import java.awt.dnd.DropTargetEvent +import java.io.File + +internal data class DesktopDropImportState( + val active: Boolean = false, + val supportedCount: Int = 0, + val totalFileCount: Int = 0, + val hasFilePayload: Boolean = false +) + +@Composable +internal fun DesktopFileDropTarget( + window: Component?, + onFilesDropped: (List) -> Unit, + onDragStateChange: (DesktopDropImportState) -> Unit +) { + val onFilesDroppedState = rememberUpdatedState(onFilesDropped) + val onDragStateChangeState = rememberUpdatedState(onDragStateChange) + + DisposableEffect(window) { + if (window == null) { + onDispose { } + } else { + val installedTargets = mutableListOf() + var disposed = false + var lastDragState = DesktopDropImportState() + + fun publishDragState(state: DesktopDropImportState) { + if (state == lastDragState) return + lastDragState = state + onDragStateChangeState.value(state) + } + + val listener = object : DropTargetAdapter() { + override fun dragEnter(event: DropTargetDragEvent) { + handleDrag(event) + } + + override fun dragOver(event: DropTargetDragEvent) { + handleDrag(event) + } + + override fun dragExit(event: DropTargetEvent) { + publishDragState(DesktopDropImportState()) + } + + override fun drop(event: DropTargetDropEvent) { + if (!event.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { + event.rejectDrop() + publishDragState(DesktopDropImportState()) + return + } + event.acceptDrop(DnDConstants.ACTION_COPY) + val files = event.transferable.localDraggedFiles().filter { it.isFile } + if (files.isEmpty()) { + event.dropComplete(false) + publishDragState(DesktopDropImportState()) + return + } + + onFilesDroppedState.value(files.map { it.toDesktopImportedBookFile() }) + event.dropComplete(true) + publishDragState(DesktopDropImportState()) + } + + private fun handleDrag(event: DropTargetDragEvent) { + val hasFilePayload = event.isDataFlavorSupported(DataFlavor.javaFileListFlavor) + publishDragState( + DesktopDropImportState( + active = true, + hasFilePayload = hasFilePayload + ) + ) + if (hasFilePayload) { + event.acceptDrag(DnDConstants.ACTION_COPY) + } else { + event.rejectDrag() + } + } + } + window.installDropTargets(listener, installedTargets) + EventQueue.invokeLater { + if (!disposed) { + window.installDropTargets(listener, installedTargets) + } + } + + onDispose { + disposed = true + installedTargets.forEach { installed -> + runCatching { installed.dropTarget.removeDropTargetListener(listener) } + installed.component.dropTarget = installed.previous + } + publishDragState(DesktopDropImportState()) + } + } + } +} + +private data class InstalledDropTarget( + val component: Component, + val previous: DropTarget?, + val dropTarget: DropTarget +) + +private fun Component.installDropTargets( + listener: DropTargetAdapter, + installedTargets: MutableList +) { + collectDropTargetComponents() + .distinct() + .filterNot { component -> installedTargets.any { it.component == component } } + .forEach { component -> + val previous = component.dropTarget + val target = DropTarget(component, DnDConstants.ACTION_COPY, listener, true) + installedTargets += InstalledDropTarget(component, previous, target) + } +} + +private fun Component.collectDropTargetComponents(): List { + val collected = mutableListOf() + + fun visit(component: Component) { + collected += component + if (component is Container) { + component.components.forEach(::visit) + } + } + + visit(this) + return collected +} + +@Composable +internal fun DesktopDropImportOverlay(state: DesktopDropImportState) { + if (!state.active) return + + val hasSupportedFiles = state.supportedCount > 0 + val title = when { + hasSupportedFiles -> "Drop to import ${state.supportedCount} file${if (state.supportedCount == 1) "" else "s"}" + state.hasFilePayload -> "Drop supported files to import" + else -> "Drop files to import" + } + val body = if (hasSupportedFiles) { + val skipped = state.totalFileCount - state.supportedCount + if (skipped > 0) { + "$skipped unsupported file${if (skipped == 1) "" else "s"} will be skipped." + } else { + "Release to add to your library." + } + } else { + SharedFileCapabilities.supportedFormatsLabel(ReaderPlatform.DESKTOP) + } + + Box( + modifier = Modifier + .fillMaxSize() + .zIndex(20f) + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.36f)), + contentAlignment = Alignment.Center + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + tonalElevation = 8.dp, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.55f)) + ) { + Column( + modifier = Modifier.padding(horizontal = 30.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text( + body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + } +} + +private fun java.awt.datatransfer.Transferable.localDraggedFiles(): List { + if (!isDataFlavorSupported(DataFlavor.javaFileListFlavor)) return emptyList() + return runCatching { + @Suppress("UNCHECKED_CAST") + (getTransferData(DataFlavor.javaFileListFlavor) as? List<*>) + .orEmpty() + .filterIsInstance() + }.getOrDefault(emptyList()) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryUi.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryUi.kt new file mode 100644 index 0000000..006bf64 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryUi.kt @@ -0,0 +1,506 @@ +package com.aryan.reader.desktop + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.aryan.reader.shared.AppAction +import com.aryan.reader.shared.BannerMessage +import com.aryan.reader.shared.BookItem +import com.aryan.reader.shared.SharedFolderPathResolver +import com.aryan.reader.shared.SharedReaderScreenState +import com.aryan.reader.shared.Shelf +import com.aryan.reader.shared.SmartCollectionDefinition +import com.aryan.reader.shared.SmartField +import com.aryan.reader.shared.SmartOperator +import com.aryan.reader.shared.SmartRule +import com.aryan.reader.shared.Tag +import com.aryan.reader.shared.reader.ReaderSettings +import com.aryan.reader.shared.reduce +import com.aryan.reader.shared.ui.NonReaderLibraryTab +import com.aryan.reader.shared.ui.SharedHomeScreen +import com.aryan.reader.shared.ui.SharedLibraryScreen +import com.aryan.reader.shared.ui.SharedShelvesScreen +import com.aryan.reader.shared.ui.SharedStableOutlinedTextField +import java.io.File + +internal fun BookItem.hasEmbeddedMetadataChange(updated: BookItem): Boolean { + return title != updated.title || + author != updated.author || + description != updated.description || + seriesName != updated.seriesName || + seriesIndex != updated.seriesIndex +} + +internal fun String.toDesktopSafeFileName(): String { + return replace(Regex("[^A-Za-z0-9._-]"), "_").take(120).ifBlank { "book" } +} + +internal fun BookItem.withDesktopImportMetadata( + enriched: BookItem, + original: BookItem? +): BookItem { + fun shouldApplyText(current: String?, originalValue: String?): Boolean { + return current.isNullOrBlank() || current == originalValue + } + + return copy( + title = if (shouldApplyText(title, original?.title)) { + enriched.title ?: title + } else { + title + }, + author = if (shouldApplyText(author, original?.author)) { + enriched.author ?: author + } else { + author + }, + description = if (shouldApplyText(description, original?.description)) { + enriched.description ?: description + } else { + description + }, + seriesName = if (shouldApplyText(seriesName, original?.seriesName)) { + enriched.seriesName ?: seriesName + } else { + seriesName + }, + seriesIndex = if (seriesIndex == null || seriesIndex == original?.seriesIndex) { + enriched.seriesIndex ?: seriesIndex + } else { + seriesIndex + }, + originalTitle = originalTitle ?: enriched.originalTitle ?: enriched.title, + originalAuthor = originalAuthor ?: enriched.originalAuthor ?: enriched.author, + originalSeriesName = originalSeriesName ?: enriched.originalSeriesName ?: enriched.seriesName, + originalSeriesIndex = originalSeriesIndex ?: enriched.originalSeriesIndex ?: enriched.seriesIndex, + originalDescription = originalDescription ?: enriched.originalDescription ?: enriched.description, + fileSize = enriched.fileSize.takeIf { it > 0L } ?: fileSize, + fileContentModifiedTimestamp = enriched.fileContentModifiedTimestamp.takeIf { it > 0L } + ?: fileContentModifiedTimestamp, + coverImagePath = coverImagePath?.takeIf { File(it).isFile } ?: enriched.coverImagePath, + folderTextMetadataParsed = folderTextMetadataParsed || enriched.folderTextMetadataParsed + ) +} + +internal fun resolvedDesktopReaderSettings( + book: BookItem, + readerDefaultSettings: ReaderSettings +): ReaderSettings { + return book.readerSettings ?: readerDefaultSettings +} + +@Composable +internal fun DesktopReaderOpeningScreen( + opening: DesktopReaderOpening, + onReturnToLibrary: () -> Unit +) { + Box( + modifier = Modifier.fillMaxSize().padding(32.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + CircularProgressIndicator() + Text( + text = "Opening ${opening.title}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) + Text( + text = opening.formatLabel, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + TextButton(onClick = onReturnToLibrary) { + Text("Return to library") + } + } + } +} + +@Composable +internal fun HomeScreen( + state: SharedReaderScreenState, + onImportBooks: () -> Unit, + onImportFolder: () -> Unit, + onRead: (BookItem) -> Unit, + onSelect: (String) -> Unit, + onClearSelection: () -> Unit, + onRemoveSelected: () -> Unit, + onShowBookInfo: (BookItem) -> Unit, + onEditBook: (BookItem) -> Unit, + onTagSelectedBooks: () -> Unit, + onAddSelectedBooksToShelf: () -> Unit, + onOpenTab: (BookItem) -> Unit, + onCloseTab: (BookItem) -> Unit, + onCloseAllTabs: () -> Unit, + onRecentLimitChange: (Int) -> Unit, + onTogglePinned: (BookItem) -> Unit, + onOpenSettings: () -> Unit +) { + SharedHomeScreen( + state = state, + onImportBooks = onImportBooks, + onImportFolder = onImportFolder, + onOpenBook = onRead, + onToggleSelection = onSelect, + onClearSelection = onClearSelection, + onRemoveSelected = onRemoveSelected, + onShowBookInfo = onShowBookInfo, + onEditBook = onEditBook, + onTagSelectedBooks = onTagSelectedBooks, + onAddSelectedBooksToShelf = onAddSelectedBooksToShelf, + onOpenTab = onOpenTab, + onCloseTab = onCloseTab, + onCloseAllTabs = onCloseAllTabs, + onRecentLimitChange = onRecentLimitChange, + onTogglePinned = onTogglePinned, + onOpenSettings = onOpenSettings, + showActiveTabs = false + ) +} + +@Composable +internal fun LibraryScreen( + state: SharedReaderScreenState, + selectedLibraryTab: NonReaderLibraryTab, + onLibraryTabChange: (NonReaderLibraryTab) -> Unit, + onStateChange: (SharedReaderScreenState) -> Unit, + onImportBooks: () -> Unit, + onRead: (BookItem) -> Unit, + onSelect: (String) -> Unit, + onClearSelection: () -> Unit, + onRemoveSelected: () -> Unit, + onShowBookInfo: (BookItem) -> Unit, + onEditBook: (BookItem) -> Unit, + onCreateShelf: () -> Unit, + onCreateSmartShelf: () -> Unit, + onRenameShelf: (Shelf) -> Unit, + onDeleteShelf: (Shelf) -> Unit, + onRemoveFolder: (Shelf) -> Unit, + onTagSelectedBooks: () -> Unit, + onAddSelectedBooksToShelf: () -> Unit, + onImportFolder: () -> Unit, + onSyncFolderMetadata: () -> Unit, + onScanFolders: () -> Unit, + onTogglePinned: (BookItem) -> Unit +) { + SharedLibraryScreen( + state = state, + selectedTab = selectedLibraryTab, + onTabChange = onLibraryTabChange, + onStateChange = onStateChange, + onImportBooks = onImportBooks, + onOpenBook = onRead, + onToggleSelection = onSelect, + onClearSelection = onClearSelection, + onRemoveSelected = onRemoveSelected, + onShowBookInfo = onShowBookInfo, + onEditBook = onEditBook, + onCreateShelf = onCreateShelf, + onCreateSmartShelf = onCreateSmartShelf, + onRenameShelf = onRenameShelf, + onDeleteShelf = onDeleteShelf, + onRemoveFolder = onRemoveFolder, + onTagSelectedBooks = onTagSelectedBooks, + onAddSelectedBooksToShelf = onAddSelectedBooksToShelf, + onImportFolder = onImportFolder, + onSyncFolderMetadata = onSyncFolderMetadata, + onScanFolders = onScanFolders, + onTogglePinned = onTogglePinned, + useImportEmptyStateWhenLibraryEmpty = true + ) +} + +@Composable +internal fun ShelvesScreen( + shelves: List, + selectedBookIds: Set, + pinnedBookIds: Set, + onRead: (BookItem) -> Unit, + onSelect: (String) -> Unit, + onShowBookInfo: (BookItem) -> Unit, + onEditBook: (BookItem) -> Unit, + onTogglePinned: (BookItem) -> Unit, + onCreateShelf: () -> Unit, + onCreateSmartShelf: () -> Unit, + onRenameShelf: (Shelf) -> Unit, + onDeleteShelf: (Shelf) -> Unit, + onRemoveFolder: (Shelf) -> Unit +) { + SharedShelvesScreen( + shelves = shelves, + selectedBookIds = selectedBookIds, + pinnedBookIds = pinnedBookIds, + onOpenBook = onRead, + onToggleSelection = onSelect, + onShowBookInfo = onShowBookInfo, + onEditBook = onEditBook, + onTogglePinned = onTogglePinned, + onCreateShelf = onCreateShelf, + onCreateSmartShelf = onCreateSmartShelf, + onRenameShelf = onRenameShelf, + onDeleteShelf = onDeleteShelf, + onRemoveFolder = onRemoveFolder + ) +} + +private data class DesktopSmartRuleDraft( + val field: SmartField = SmartField.TITLE, + val operator: SmartOperator = SmartOperator.CONTAINS, + val value: String = "" +) { + fun toRule(): SmartRule? { + val trimmed = value.trim() + if (trimmed.isBlank()) return null + return SmartRule(field = field, operator = operator, value = trimmed) + } +} + +@Composable +internal fun SmartShelfDialog( + onDismiss: () -> Unit, + onConfirm: (String, SmartCollectionDefinition) -> Unit +) { + var name by remember { mutableStateOf("") } + var matchAll by remember { mutableStateOf(true) } + var rules by remember { mutableStateOf(listOf(DesktopSmartRuleDraft())) } + val validRules = rules.mapNotNull { it.toRule() } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Create smart shelf") }, + text = { + Column( + modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + SharedStableOutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Shelf name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + FilterChip( + selected = matchAll, + onClick = { matchAll = true }, + label = { Text("All") } + ) + FilterChip( + selected = !matchAll, + onClick = { matchAll = false }, + label = { Text("Any") } + ) + Spacer(Modifier.weight(1f)) + TextButton( + onClick = { rules = rules + DesktopSmartRuleDraft() }, + enabled = rules.size < 4 + ) { + Text("Add rule") + } + } + rules.forEachIndexed { index, draft -> + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + SmartRuleDropdown( + label = "Field", + selected = draft.field, + options = SmartField.entries.toList(), + optionLabel = { it.desktopLabel() }, + onSelected = { field -> + rules = rules.updateAt(index) { + val operator = smartOperatorsFor(field).first() + copy(field = field, operator = operator, value = "") + } + } + ) + SmartRuleDropdown( + label = "Operator", + selected = draft.operator, + options = smartOperatorsFor(draft.field), + optionLabel = { it.desktopLabel() }, + onSelected = { operator -> + rules = rules.updateAt(index) { copy(operator = operator) } + } + ) + if (rules.size > 1) { + TextButton(onClick = { rules = rules.filterIndexed { i, _ -> i != index } }) { + Text("Remove") + } + } + } + SharedStableOutlinedTextField( + value = draft.value, + onValueChange = { value -> rules = rules.updateAt(index) { copy(value = value) } }, + label = { Text(draft.field.valueLabel()) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + selectionKey = index + ) + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + onConfirm(name, SmartCollectionDefinition(matchAll = matchAll, rules = validRules)) + }, + enabled = name.isNotBlank() && validRules.isNotEmpty() + ) { + Text("Create") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + +@Composable +private fun SmartRuleDropdown( + label: String, + selected: T, + options: List, + optionLabel: (T) -> String, + onSelected: (T) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + Box { + TextButton(onClick = { expanded = true }) { + Text("$label: ${optionLabel(selected)}") + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(optionLabel(option)) }, + onClick = { + expanded = false + onSelected(option) + } + ) + } + } + } +} + +private fun smartOperatorsFor(field: SmartField): List { + return when (field) { + SmartField.PROGRESS -> listOf(SmartOperator.GREATER_THAN, SmartOperator.LESS_THAN, SmartOperator.EQUALS) + else -> listOf(SmartOperator.CONTAINS, SmartOperator.EQUALS) + } +} + +private fun SmartField.desktopLabel(): String { + return when (this) { + SmartField.TITLE -> "Title" + SmartField.AUTHOR -> "Author" + SmartField.PROGRESS -> "Progress" + SmartField.FILE_TYPE -> "File type" + SmartField.FOLDER -> "Folder" + SmartField.TAG -> "Tag" + } +} + +private fun SmartField.valueLabel(): String { + return when (this) { + SmartField.PROGRESS -> "Percent" + SmartField.FILE_TYPE -> "Type, e.g. PDF" + SmartField.FOLDER -> "Folder path" + SmartField.TAG -> "Tag name" + SmartField.TITLE -> "Title text" + SmartField.AUTHOR -> "Author text" + } +} + +private fun SmartOperator.desktopLabel(): String { + return when (this) { + SmartOperator.EQUALS -> "Equals" + SmartOperator.CONTAINS -> "Contains" + SmartOperator.GREATER_THAN -> "Greater than" + SmartOperator.LESS_THAN -> "Less than" + } +} + +private inline fun List.updateAt( + index: Int, + transform: DesktopSmartRuleDraft.() -> DesktopSmartRuleDraft +): List { + return mapIndexed { i, draft -> if (i == index) draft.transform() else draft } +} + +internal fun SharedReaderScreenState.withBanner(message: String, isError: Boolean = false): SharedReaderScreenState { + return reduce(AppAction.BannerShown(BannerMessage(message, isError = isError))) +} + +internal object DesktopFolderPathResolver : SharedFolderPathResolver { + override fun relativeFolderSegments(item: BookItem): List { + val sourceFolder = item.sourceFolder ?: return emptyList() + val bookPath = item.path ?: return emptyList() + val parentFile = File(bookPath).parentFile ?: return emptyList() + val paths = runCatching { + File(sourceFolder).toPath().toAbsolutePath().normalize() to + parentFile.toPath().toAbsolutePath().normalize() + }.getOrNull() ?: return emptyList() + val (root, parent) = paths + if (!parent.startsWith(root) || parent == root) return emptyList() + return root.relativize(parent).map { it.toString() }.filter { it.isNotBlank() } + } +} + +internal fun List.collectTags(): List { + return flatMap { it.tags }.distinctBy { it.id }.sortedBy { it.name.lowercase() } +} + +internal fun BookItem.cardTitleForMessage(): String { + return title?.takeIf { it.isNotBlank() } ?: displayName +} + +internal fun Long.toReadableSize(): String { + if (this <= 0L) return "Unknown" + val units = listOf("B", "KB", "MB", "GB", "TB") + var value = this.toDouble() + var unitIndex = 0 + while (value >= 1024.0 && unitIndex < units.lastIndex) { + value /= 1024.0 + unitIndex += 1 + } + return if (unitIndex == 0) { + "$this ${units[unitIndex]}" + } else { + "${String.format("%.1f", value)} ${units[unitIndex]}" + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLogFormatting.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLogFormatting.kt new file mode 100644 index 0000000..673db6a --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLogFormatting.kt @@ -0,0 +1,8 @@ +package com.aryan.reader.desktop + +internal fun String.logPreview(maxLength: Int = 96): String { + return replace(Regex("\\s+"), " ") + .trim() + .let { if (it.length <= maxLength) it else it.take(maxLength) + "..." } + .replace("\"", "\\\"") +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfAnnotationUi.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfAnnotationUi.kt new file mode 100644 index 0000000..03c28d4 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfAnnotationUi.kt @@ -0,0 +1,441 @@ +package com.aryan.reader.desktop + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.aryan.reader.shared.pdf.PdfAnnotationKind +import com.aryan.reader.shared.pdf.PdfInkTool +import com.aryan.reader.shared.pdf.SharedPdfAndroidHighlightColors +import com.aryan.reader.shared.pdf.SharedPdfAnnotation +import com.aryan.reader.shared.pdf.SharedPdfAnnotationDefaults +import com.aryan.reader.shared.pdf.SharedPdfEmbeddedAnnotation +import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette +import com.aryan.reader.shared.pdf.sharedPdfStrokePercent +import com.aryan.reader.shared.pdf.sharedPdfStrokeWidthRange +import com.aryan.reader.shared.pdf.sharedPdfTextStyle +import com.aryan.reader.shared.pdf.withSharedPdfTextStyle +import com.aryan.reader.shared.ui.SharedHsvColorPickerDialog +import com.aryan.reader.shared.ui.SharedPdfTextStyleControls +import com.aryan.reader.shared.ui.SharedStableOutlinedTextField + +@Composable +internal fun DesktopPdfAnnotationEditor( + annotation: SharedPdfAnnotation, + onUpdate: (SharedPdfAnnotation) -> Unit, + onDelete: () -> Unit, + onClose: () -> Unit, + onCopy: () -> Unit, + showSearch: Boolean, + highlighterPalette: List = SharedPdfHighlighterPalette.defaultColors, + onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit = {}, + onSearch: () -> Unit +) { + val highlighterColors = remember(highlighterPalette) { + SharedPdfAndroidHighlightColors.palette + } + var editingHighlighterSlot by remember(annotation.id, highlighterColors) { mutableStateOf(null) } + val isHighlighterAnnotation = annotation.kind == PdfAnnotationKind.HIGHLIGHT || + annotation.tool == PdfInkTool.HIGHLIGHTER || + annotation.tool == PdfInkTool.HIGHLIGHTER_ROUND + + Surface( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(2.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Selected ${annotation.desktopLabel()}", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = onClose) { + Text("Close") + } + } + Text( + "Page ${annotation.pageIndex + 1}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + if (annotation.text.isNotBlank()) { + Surface( + color = Color(annotation.colorArgb).copy(alpha = 0.10f), + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, Color(annotation.colorArgb).copy(alpha = 0.28f)), + modifier = Modifier.fillMaxWidth() + ) { + Row(modifier = Modifier.heightIn(min = 72.dp)) { + Box( + modifier = Modifier + .width(6.dp) + .fillMaxHeight() + .background(Color(annotation.colorArgb)) + ) + Text( + "\"${annotation.text}\"", + style = MaterialTheme.typography.bodyMedium, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.88f), + modifier = Modifier.padding(14.dp) + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + DesktopBottomSheetToolButton( + icon = Icons.Default.ContentCopy, + label = "Copy", + onClick = onCopy + ) + if (showSearch) { + DesktopBottomSheetToolButton( + icon = Icons.Default.Search, + label = "Search", + onClick = onSearch + ) + } + } + } + if (annotation.kind == PdfAnnotationKind.TEXT) { + SharedStableOutlinedTextField( + value = annotation.text, + onValueChange = { onUpdate(annotation.copy(text = it)) }, + label = { Text("Text note") }, + minLines = 2, + modifier = Modifier.fillMaxWidth(), + selectionKey = annotation.id + ) + SharedPdfTextStyleControls( + style = annotation.sharedPdfTextStyle(), + onStyleChange = { onUpdate(annotation.withSharedPdfTextStyle(it)) } + ) + } + if (annotation.kind != PdfAnnotationKind.TEXT) { + val palette = if (isHighlighterAnnotation) { + highlighterColors + } else { + SharedPdfAnnotationDefaults.penPalette + } + Text("Color", style = MaterialTheme.typography.labelLarge) + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + palette.forEachIndexed { _, argb -> + Surface( + modifier = Modifier + .size(26.dp) + .clickable { + val nextColor = if (isHighlighterAnnotation) { + SharedPdfAndroidHighlightColors.nearestArgb(argb) + } else { + argb + } + onUpdate(annotation.copy(colorArgb = nextColor)) + }, + color = Color(argb), + shape = RoundedCornerShape(13.dp), + content = {} + ) + } + if (isHighlighterAnnotation) { + Box( + modifier = Modifier + .size(30.dp) + .clip(RoundedCornerShape(15.dp)) + .background( + Brush.sweepGradient( + listOf( + Color.Red, + Color.Yellow, + Color.Green, + Color.Cyan, + Color.Blue, + Color.Magenta, + Color.Red + ) + ) + ) + .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.32f), RoundedCornerShape(15.dp)) + .clickable { + editingHighlighterSlot = highlighterColors + .indexOf(annotation.colorArgb) + .takeIf { it >= 0 } + ?: 0 + } + ) + } + } + SharedStableOutlinedTextField( + value = annotation.note.orEmpty(), + onValueChange = { note -> onUpdate(annotation.copy(note = note.takeIf { it.isNotBlank() })) }, + label = { Text("Note") }, + minLines = 3, + maxLines = 5, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + selectionKey = annotation.id + ) + } + if (annotation.kind == PdfAnnotationKind.INK) { + val strokeRange = annotation.tool.sharedPdfStrokeWidthRange() + val strokeValue = annotation.strokeWidth.coerceIn(strokeRange.start, strokeRange.endInclusive) + Text("Thickness ${strokeValue.sharedPdfStrokePercent(strokeRange)}", style = MaterialTheme.typography.labelLarge) + Slider( + value = strokeValue, + onValueChange = { onUpdate(annotation.copy(strokeWidth = it.coerceAtLeast(0.0001f))) }, + valueRange = strokeRange + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = onDelete) { + Text("Delete") + } + } + } + } + editingHighlighterSlot?.let { requestedSlot -> + val slot = requestedSlot.coerceIn(0, highlighterColors.lastIndex) + val initialColor = Color(highlighterColors[slot]).copy(alpha = 1f) + SharedHsvColorPickerDialog( + initialColor = initialColor, + title = "Highlight color ${slot + 1}", + onDismiss = { editingHighlighterSlot = null }, + onSave = { color -> + val nextArgb = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb() + val syncedArgb = SharedPdfAndroidHighlightColors.nearestArgb(nextArgb) + onHighlighterPaletteChange( + SharedPdfHighlighterPalette(highlighterColors).withColorAt( + slotIndex = slot, + colorArgb = nextArgb + ) + ) + onUpdate(annotation.copy(colorArgb = syncedArgb)) + editingHighlighterSlot = null + } + ) { liveColor -> + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + highlighterColors.forEachIndexed { index, argb -> + val color = if (index == slot) liveColor else Color(argb).copy(alpha = 1f) + Box( + modifier = Modifier + .size(42.dp) + .clip(RoundedCornerShape(21.dp)) + .background(color) + .border( + width = if (index == slot) 3.dp else 1.dp, + color = if (index == slot) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.35f), + shape = RoundedCornerShape(21.dp) + ) + .clickable { editingHighlighterSlot = index }, + contentAlignment = Alignment.Center + ) { + Text( + text = "${index + 1}", + color = if (color.luminance() > 0.5f) Color.Black else Color.White, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } +} + +@Composable +private fun DesktopBottomSheetToolButton( + icon: ImageVector, + label: String, + onClick: () -> Unit +) { + Column( + modifier = Modifier + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = icon, + contentDescription = label, + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.78f), + modifier = Modifier.size(22.dp) + ) + Text( + label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +internal fun DesktopPdfEmbeddedAnnotationPanel( + annotation: SharedPdfEmbeddedAnnotation, + onCopy: () -> Unit, + onClose: () -> Unit +) { + Surface( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Embedded PDF comment", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = onClose) { + Text("Close") + } + } + Text( + "Page ${annotation.pageIndex + 1}${annotation.author.takeIf { it.isNotBlank() }?.let { " - $it" }.orEmpty()}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + DesktopPdfEmbeddedComment( + author = annotation.author, + contents = annotation.contents.ifBlank { "No comment" }, + depth = 0 + ) + DesktopPdfEmbeddedReplies(annotation.replies, depth = 1) + TextButton(onClick = onCopy) { + Text("Copy thread") + } + } + } +} + +@Composable +private fun DesktopPdfEmbeddedReplies( + replies: List, + depth: Int +) { + replies.forEach { reply -> + HorizontalDivider() + DesktopPdfEmbeddedComment( + author = reply.author, + contents = reply.contents, + depth = depth + ) + if (reply.replies.isNotEmpty()) { + DesktopPdfEmbeddedReplies(reply.replies, depth + 1) + } + } +} + +@Composable +private fun DesktopPdfEmbeddedComment( + author: String, + contents: String, + depth: Int +) { + Column( + modifier = Modifier.padding(start = (depth * 12).dp), + verticalArrangement = Arrangement.spacedBy(3.dp) + ) { + Text( + author.ifBlank { "Unknown" }, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + contents.ifBlank { "No comment" }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +internal fun SharedPdfAnnotation.desktopLabel(): String { + return when (kind) { + PdfAnnotationKind.HIGHLIGHT -> "highlight" + PdfAnnotationKind.INK -> tool.name.lowercase().replace('_', ' ') + PdfAnnotationKind.TEXT -> "text note" + } +} + +internal fun SharedPdfAnnotation.desktopSheetTitle(): String { + return when (kind) { + PdfAnnotationKind.HIGHLIGHT -> "Highlight" + PdfAnnotationKind.INK -> "Annotation" + PdfAnnotationKind.TEXT -> "Text note" + } +} + +internal fun SharedPdfEmbeddedAnnotation.threadText(): String { + return buildString { + append(author.ifBlank { "Unknown" }) + append(": ") + appendLine(contents.ifBlank { "No comment" }) + fun appendReplies(replies: List, indent: String) { + replies.forEach { reply -> + append(indent) + append(reply.author.ifBlank { "Unknown" }) + append(": ") + appendLine(reply.contents.ifBlank { "No comment" }) + appendReplies(reply.replies, "$indent ") + } + } + appendReplies(replies, " ") + }.trimEnd() +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfAppearance.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfAppearance.kt new file mode 100644 index 0000000..8c0c060 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfAppearance.kt @@ -0,0 +1,202 @@ +package com.aryan.reader.desktop + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.ImageShader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.aryan.reader.shared.BuiltInPdfReaderThemes +import com.aryan.reader.shared.PdfDisplayMode +import com.aryan.reader.shared.ReaderTheme +import com.aryan.reader.shared.reader.ReaderSettings + +internal enum class DesktopPdfInspectorTab(val title: String) { + VIEW("View"), + MARKUP("Markup"), + ASSIST("Assist") +} + +internal data class DesktopPdfThemeStyle( + val theme: ReaderTheme, + val viewerBackgroundColor: Color, + val pageBackgroundColor: Color, + val colorFilter: ColorFilter?, + val textureBitmap: ImageBitmap?, + val textureAlpha: Float, + val textureBlendMode: BlendMode +) + +@Composable +internal fun DesktopPdfThemedPageImage( + bitmap: ImageBitmap, + contentDescription: String, + themeStyle: DesktopPdfThemeStyle, + modifier: Modifier = Modifier +) { + Box(modifier = modifier.background(themeStyle.pageBackgroundColor)) { + Image( + bitmap = bitmap, + contentDescription = contentDescription, + colorFilter = themeStyle.colorFilter, + modifier = Modifier.fillMaxSize() + ) + val textureBitmap = themeStyle.textureBitmap + if (textureBitmap != null && themeStyle.textureAlpha > 0f) { + Canvas(modifier = Modifier.fillMaxSize()) { + drawRect( + brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)), + size = size, + blendMode = themeStyle.textureBlendMode, + alpha = themeStyle.textureAlpha + ) + } + } + } +} + +internal fun ReaderSettings?.toDesktopPdfReaderSettings(): ReaderSettings { + val defaults = ReaderSettings(themeId = "no_theme") + val settings = this ?: defaults + val themeId = settings.themeId + val hasPdfTheme = BuiltInPdfReaderThemes.any { it.id == themeId } + val hasCustomColors = settings.backgroundColorArgb != null && settings.textColorArgb != null + return settings.copy( + themeId = when { + themeId == null -> "no_theme" + hasPdfTheme || hasCustomColors -> themeId + else -> "no_theme" + } + ) +} + +internal fun ReaderSettings.toDesktopPdfThemeStyle(displayMode: PdfDisplayMode): DesktopPdfThemeStyle { + val theme = toDesktopPdfTheme() + val pageBackground = desktopPdfPageBackgroundColor(theme, displayMode) + val isDarkTexture = theme.isDark || theme.id == "reverse" + return DesktopPdfThemeStyle( + theme = theme, + viewerBackgroundColor = pageBackground, + pageBackgroundColor = pageBackground, + colorFilter = theme.toDesktopPdfColorFilter(), + textureBitmap = DesktopReaderTextures.imageBitmapFor(textureId), + textureAlpha = if (textureId == null) 0f else textureAlpha.coerceIn(0f, 1f), + textureBlendMode = if (isDarkTexture) BlendMode.Screen else BlendMode.Multiply + ) +} + +private fun ReaderSettings.toDesktopPdfTheme(): ReaderTheme { + BuiltInPdfReaderThemes.firstOrNull { it.id == themeId }?.let { return it } + val background = backgroundColorArgb?.toComposeColor() + val text = textColorArgb?.toComposeColor() + return if (background != null && text != null) { + ReaderTheme( + id = themeId ?: "desktop_pdf_custom", + name = "Custom", + backgroundColor = background, + textColor = text, + isDark = darkMode, + textureId = textureId, + isCustom = true + ) + } else { + BuiltInPdfReaderThemes.first() + } +} + +private fun ReaderTheme.toDesktopPdfColorFilter(): ColorFilter? { + return when (id) { + "no_theme", "system" -> null + "reverse" -> { + val colorMatrix = floatArrayOf( + -1f, 0f, 0f, 0f, 255f, + 0f, -1f, 0f, 0f, 255f, + 0f, 0f, -1f, 0f, 255f, + 0f, 0f, 0f, 1f, 0f + ) + ColorFilter.colorMatrix(ColorMatrix(colorMatrix)) + } + else -> { + if (!backgroundColor.isSpecified || !textColor.isSpecified) return null + val bgR = backgroundColor.red * 255f + val bgG = backgroundColor.green * 255f + val bgB = backgroundColor.blue * 255f + val fgR = textColor.red * 255f + val fgG = textColor.green * 255f + val fgB = textColor.blue * 255f + val dr = (bgR - fgR) / 255f + val dg = (bgG - fgG) / 255f + val db = (bgB - fgB) / 255f + val lumR = 0.2126f + val lumG = 0.7152f + val lumB = 0.0722f + val colorMatrix = floatArrayOf( + dr * lumR, dr * lumG, dr * lumB, 0f, fgR, + dg * lumR, dg * lumG, dg * lumB, 0f, fgG, + db * lumR, db * lumG, db * lumB, 0f, fgB, + 0f, 0f, 0f, 1f, 0f + ) + ColorFilter.colorMatrix(ColorMatrix(colorMatrix)) + } + } +} + +@Composable +internal fun DesktopPdfInspectorSection( + title: String, + content: @Composable ColumnScope.() -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + content() + } +} + +@Composable +internal fun DesktopPdfVisualOptionSwitch( + title: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + Text( + description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +private fun Long.toComposeColor(): Color { + return Color(this and 0xFFFFFFFFL) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfChromeUi.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfChromeUi.kt new file mode 100644 index 0000000..ecc9e39 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfChromeUi.kt @@ -0,0 +1,422 @@ +package com.aryan.reader.desktop + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.NavigateBefore +import androidx.compose.material.icons.automirrored.filled.NavigateNext +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.filled.ZoomOut +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.aryan.reader.shared.SearchHighlightMode +import com.aryan.reader.shared.pdf.SharedPdfSearchResult +import com.aryan.reader.shared.ui.ReaderMinimalSlider +import com.aryan.reader.shared.ui.SharedStableOutlinedTextField +import kotlinx.coroutines.delay + +@Composable +internal fun DesktopPdfFullscreenBottomChrome( + pageIndex: Int, + pageCount: Int, + showJumpHistory: Boolean, + jumpBackPage: Int?, + jumpForwardPage: Int?, + onPrevious: () -> Unit, + onNext: () -> Unit, + onPageScrub: (Float) -> Unit, + onPageScrubFinished: () -> Unit, + onJumpBack: () -> Unit, + onJumpForward: () -> Unit, + onClearJumpHistory: () -> Unit +) { + val chromeBackground = MaterialTheme.colorScheme.surface + val chromeContent = MaterialTheme.colorScheme.onSurface + val sliderActive = MaterialTheme.colorScheme.primary + val sliderInactive = MaterialTheme.colorScheme.surfaceVariant + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 6.dp, end = 16.dp, bottom = 0.dp), + shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp), + color = chromeBackground, + contentColor = chromeContent, + tonalElevation = 0.dp, + shadowElevation = 1.dp, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + val hasJumpTargets = jumpBackPage != null || jumpForwardPage != null + DesktopPdfJumpHistoryControls( + visible = showJumpHistory, + backPage = jumpBackPage, + forwardPage = jumpForwardPage, + onBack = onJumpBack, + onForward = onJumpForward, + onClear = onClearJumpHistory + ) + if (showJumpHistory && hasJumpTargets) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val canGoPrevious = pageIndex > 0 + val canGoNext = pageIndex < pageCount - 1 + IconButton(onClick = onPrevious, enabled = canGoPrevious) { + Icon( + Icons.AutoMirrored.Filled.NavigateBefore, + contentDescription = "Previous page", + tint = chromeContent.copy(alpha = if (canGoPrevious) 0.78f else 0.32f) + ) + } + ReaderMinimalSlider( + value = pageIndex.toFloat(), + onValueChange = onPageScrub, + onValueChangeFinished = onPageScrubFinished, + valueRange = 0f..(pageCount - 1).coerceAtLeast(0).toFloat(), + enabled = pageCount > 1, + activeColor = sliderActive, + inactiveColor = sliderInactive, + thumbColor = sliderActive, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = onNext, enabled = canGoNext) { + Icon( + Icons.AutoMirrored.Filled.NavigateNext, + contentDescription = "Next page", + tint = chromeContent.copy(alpha = if (canGoNext) 0.78f else 0.32f) + ) + } + } + } + } +} + +@Composable +internal fun DesktopPdfZoomPercentageIndicator( + percentage: Int, + onResetZoomClick: () -> Unit +) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.8f) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Text( + text = "$percentage%", + color = Color.White, + style = MaterialTheme.typography.bodyLarge + ) + Spacer(Modifier.width(8.dp)) + Box( + modifier = Modifier + .width(1.dp) + .height(16.dp) + .background(Color.White.copy(alpha = 0.5f)) + ) + Spacer(Modifier.width(8.dp)) + Icon( + imageVector = Icons.Default.ZoomOut, + contentDescription = "Reset zoom", + tint = Color.White, + modifier = Modifier + .size(20.dp) + .clip(RoundedCornerShape(4.dp)) + .clickable(onClick = onResetZoomClick) + ) + } + } +} + +@Composable +internal fun DesktopPdfSearchTopBar( + query: String, + showResultsPanel: Boolean, + onQueryChange: (String) -> Unit, + onClose: () -> Unit, + onToggleResults: () -> Unit +) { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + delay(80) + runCatching { focusRequester.requestFocus() } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) { + Icon(Icons.Default.Close, contentDescription = "Close search") + } + SharedStableOutlinedTextField( + value = query, + onValueChange = onQueryChange, + placeholder = { Text("Search in PDF") }, + singleLine = true, + modifier = Modifier.weight(1f).focusRequester(focusRequester), + trailingIcon = if (query.isNotEmpty()) { + { + IconButton(onClick = { onQueryChange("") }) { + Icon(Icons.Default.Close, contentDescription = "Clear search") + } + } + } else { + null + }, + selectionKey = "desktop-pdf-search" + ) + IconButton(onClick = onToggleResults, modifier = Modifier.size(36.dp)) { + Icon( + if (showResultsPanel) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = if (showResultsPanel) "Hide search results" else "Show search results" + ) + } + } + } +} + +@Composable +internal fun BoxScope.DesktopPdfSearchOverlay( + isSearchActive: Boolean, + showResultsPanel: Boolean, + query: String, + results: List, + activeSearchIndex: Int, + highlightMode: SearchHighlightMode, + isIndexing: Boolean, + indexedPageCount: Int, + pageCount: Int, + onResultClick: (Int) -> Unit, + onShowResults: () -> Unit, + onPrevious: () -> Unit, + onNext: () -> Unit, + onToggleHighlightMode: () -> Unit +) { + AnimatedVisibility( + visible = isSearchActive && showResultsPanel, + enter = slideInVertically { -it } + fadeIn(), + exit = slideOutVertically { -it } + fadeOut(), + modifier = Modifier.fillMaxSize().zIndex(30f) + ) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Column(Modifier.fillMaxSize()) { + if (isIndexing) { + val progress = indexedPageCount.toFloat() / pageCount.coerceAtLeast(1).toFloat() + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.fillMaxWidth() + ) { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp)) { + Text( + "Indexing ${indexedPageCount.coerceAtMost(pageCount)}/$pageCount pages", + style = MaterialTheme.typography.bodySmall + ) + LinearProgressIndicator( + progress = { progress.coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth().padding(top = 6.dp) + ) + } + } + } + + when { + query.isBlank() -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Type to search this PDF", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + results.isEmpty() -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + if (isIndexing) "No matches in indexed pages yet" else "No matches", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + else -> { + Text( + when { + isIndexing -> "${results.size} matches so far" + else -> "${results.size} matches" + }, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) + HorizontalDivider() + LazyColumn(Modifier.fillMaxSize()) { + itemsIndexed( + items = results, + key = { index, result -> "${result.pageIndex}_${result.matchIndex}_$index" } + ) { index, result -> + Surface( + modifier = Modifier.fillMaxWidth().clickable { onResultClick(index) }, + color = if (index == activeSearchIndex) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + } + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + "Page ${result.pageIndex + 1}", + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + result.preview, + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + } + HorizontalDivider() + } + } + } + } + } + } + } + + AnimatedVisibility( + visible = isSearchActive && !showResultsPanel && results.isNotEmpty(), + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 18.dp) + .zIndex(31f) + ) { + DesktopPdfSearchNavigationPill( + activeSearchIndex = activeSearchIndex, + resultCount = results.size, + highlightMode = highlightMode, + onShowResults = onShowResults, + onPrevious = onPrevious, + onNext = onNext, + onToggleHighlightMode = onToggleHighlightMode + ) + } +} + +@Composable +private fun DesktopPdfSearchNavigationPill( + activeSearchIndex: Int, + resultCount: Int, + highlightMode: SearchHighlightMode, + onShowResults: () -> Unit, + onPrevious: () -> Unit, + onNext: () -> Unit, + onToggleHighlightMode: () -> Unit +) { + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + tonalElevation = 6.dp, + shadowElevation = 8.dp + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + IconButton(onClick = onToggleHighlightMode, modifier = Modifier.size(36.dp)) { + Icon( + if (highlightMode == SearchHighlightMode.ALL) Icons.Default.Visibility else Icons.Default.VisibilityOff, + contentDescription = "Toggle search highlights", + tint = if (highlightMode == SearchHighlightMode.ALL) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + IconButton(onClick = onPrevious, enabled = resultCount > 0, modifier = Modifier.size(36.dp)) { + Icon(Icons.AutoMirrored.Filled.NavigateBefore, contentDescription = "Previous search result") + } + Text( + text = if (activeSearchIndex in 0 until resultCount) { + "${activeSearchIndex + 1}/$resultCount" + } else { + "$resultCount matches" + }, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.clickable(onClick = onShowResults).padding(horizontal = 8.dp) + ) + IconButton(onClick = onNext, enabled = resultCount > 0, modifier = Modifier.size(36.dp)) { + Icon(Icons.AutoMirrored.Filled.NavigateNext, contentDescription = "Next search result") + } + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfNavigationUi.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfNavigationUi.kt new file mode 100644 index 0000000..b990cd2 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfNavigationUi.kt @@ -0,0 +1,338 @@ +package com.aryan.reader.desktop + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.aryan.reader.shared.PdfTocEntry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +internal fun DesktopPdfJumpHistoryControls( + visible: Boolean, + modifier: Modifier = Modifier, + backPage: Int?, + forwardPage: Int?, + onBack: () -> Unit, + onForward: () -> Unit, + onClear: () -> Unit +) { + val hasJumpTargets = backPage != null || forwardPage != null + AnimatedVisibility( + visible = visible && hasJumpTargets, + enter = slideInVertically { fullHeight -> fullHeight } + fadeIn(), + exit = slideOutVertically { fullHeight -> fullHeight } + fadeOut(), + modifier = modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton( + onClick = onBack, + enabled = backPage != null, + modifier = Modifier.weight(1f) + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Jump back", + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(4.dp)) + Text( + backPage?.let { "P. ${it + 1}" } ?: "", + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + TextButton( + onClick = onClear, + modifier = Modifier.weight(0.8f) + ) { + Icon( + Icons.Default.Close, + contentDescription = "Clear jump history", + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(4.dp)) + Text("Clear", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + + TextButton( + onClick = onForward, + enabled = forwardPage != null, + modifier = Modifier.weight(1f) + ) { + Text( + forwardPage?.let { "P. ${it + 1}" } ?: "", + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.width(4.dp)) + Icon( + Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = "Jump forward", + modifier = Modifier.size(18.dp) + ) + } + } + } +} + +internal fun desktopPdfTocParentIndices(toc: List): Set { + return toc.indices.filter { index -> + val next = toc.getOrNull(index + 1) + next != null && next.nestLevel > toc[index].nestLevel + }.toSet() +} + +internal fun desktopPdfTocAncestorIndices( + toc: List, + originalIndex: Int +): Set { + val targetDepth = toc.getOrNull(originalIndex)?.nestLevel ?: return emptySet() + val ancestors = mutableSetOf() + var currentDepth = targetDepth + for (index in originalIndex downTo 0) { + val entry = toc[index] + if (entry.nestLevel < currentDepth) { + ancestors += index + currentDepth = entry.nestLevel + } + if (currentDepth == 0) break + } + return ancestors +} + +internal fun desktopVisiblePdfTocEntries( + toc: List, + expandedIndices: Set +): List> { + val result = mutableListOf>() + val visibilityStack = BooleanArray(50) { false } + visibilityStack[0] = true + + toc.forEachIndexed { index, entry -> + val depth = entry.nestLevel.coerceIn(0, visibilityStack.lastIndex) + if (visibilityStack[depth]) { + result += index to entry + if (depth + 1 < visibilityStack.size) { + visibilityStack[depth + 1] = index in expandedIndices + } + } else if (depth + 1 < visibilityStack.size) { + visibilityStack[depth + 1] = false + } + } + return result +} + +@Composable +internal fun DesktopPdfTocTreeItem( + entry: PdfTocEntry, + selected: Boolean, + hasChildren: Boolean, + isExpanded: Boolean, + onToggleExpand: () -> Unit, + onClick: () -> Unit +) { + Surface( + color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth().clickable { onClick() } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 46.dp) + .padding(start = (entry.nestLevel.coerceAtLeast(0) * 14).dp) + .padding(horizontal = 4.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(34.dp) + .clickable(enabled = hasChildren) { onToggleExpand() }, + contentAlignment = Alignment.Center + ) { + if (hasChildren) { + Icon( + imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Text( + entry.title, + fontWeight = if (selected) FontWeight.Bold else if (entry.nestLevel == 0) FontWeight.SemiBold else FontWeight.Normal, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Text( + "p. ${entry.pageIndex + 1}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp) + ) + } + } +} + +@Composable +internal fun DesktopPdfNavigationEmpty(message: String) { + Box( + modifier = Modifier.fillMaxSize().padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +internal fun DesktopPdfThumbnailTile( + document: DesktopPdfDocument, + pageIndex: Int, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val documentHandleId = document.handleId + var thumbnail by remember(documentHandleId, pageIndex) { mutableStateOf(null) } + var renderFailed by remember(documentHandleId, pageIndex) { mutableStateOf(false) } + val pageSize = document.pageSizes.getOrNull(pageIndex) + val thumbnailScale = remember(pageSize) { + val width = pageSize?.width?.coerceAtLeast(1f) ?: 612f + (120f / width).coerceIn(0.08f, 0.35f) + } + + LaunchedEffect(documentHandleId, pageIndex, thumbnailScale) { + thumbnail = null + renderFailed = false + val rendered = withContext(Dispatchers.IO) { + runCatching { + DesktopPdfium.renderPage( + document = document, + pageIndex = pageIndex, + scale = thumbnailScale, + renderAnnotations = false + ) + }.getOrNull() + } + thumbnail = rendered + renderFailed = rendered == null + } + + Surface( + modifier = modifier.aspectRatio(0.707f).clickable(onClick = onClick), + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + border = BorderStroke( + width = if (selected) 2.dp else 1.dp, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant + ) + ) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + val render = thumbnail + if (render != null) { + Image( + bitmap = render.image, + contentDescription = "Page ${pageIndex + 1}", + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize().padding(3.dp) + ) + } else { + Text( + if (renderFailed) "!" else "${pageIndex + 1}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( + text = "${pageIndex + 1}", + style = MaterialTheme.typography.labelSmall, + color = Color.White, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(4.dp) + .background(Color.Black.copy(alpha = 0.58f), RoundedCornerShape(4.dp)) + .padding(horizontal = 5.dp, vertical = 1.dp) + ) + } + } +} + +@Composable +internal fun DesktopPdfPageScrubOverlay( + pageIndex: Int?, + pageCount: Int +) { + if (pageIndex == null || pageCount <= 0) return + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Surface( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), + shape = RoundedCornerShape(16.dp), + tonalElevation = 6.dp, + shadowElevation = 8.dp + ) { + Text( + text = "Page ${pageIndex + 1} of $pageCount", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp) + ) + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfPageInteractions.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfPageInteractions.kt new file mode 100644 index 0000000..8c2750b --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfPageInteractions.kt @@ -0,0 +1,328 @@ +package com.aryan.reader.desktop + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntSize +import com.aryan.reader.shared.pdf.PdfAnnotationKind +import com.aryan.reader.shared.pdf.PdfInkTool +import com.aryan.reader.shared.pdf.PdfNormalizedPoint +import com.aryan.reader.shared.pdf.PdfPageBounds +import com.aryan.reader.shared.pdf.PdfPagePoint +import com.aryan.reader.shared.pdf.PdfSelectionGeometry +import com.aryan.reader.shared.pdf.PdfTextCharBounds +import com.aryan.reader.shared.pdf.SharedPdfAnnotation +import com.aryan.reader.shared.pdf.SharedPdfInkRenderer +import com.aryan.reader.shared.pdf.SharedPdfTextDraft +import com.aryan.reader.shared.ui.sharedPdfHitTest +import com.aryan.reader.shared.ui.toSharedPdfPoint + +internal val PdfInkTool.isDesktopHighlighter: Boolean + get() = this == PdfInkTool.HIGHLIGHTER || this == PdfInkTool.HIGHLIGHTER_ROUND + +internal val SharedPdfAnnotation.isDesktopTextSelectionHighlight: Boolean + get() = kind == PdfAnnotationKind.HIGHLIGHT && + text.isNotBlank() && + rangeStartIndex != null && + rangeEndIndex != null + +internal fun List.withDesktopPdfDragPoint( + point: Offset, + canvasSize: IntSize, + tool: PdfInkTool, + snapHighlighter: Boolean, + timestamp: Long +): List { + val nextPoint = point.toSharedPdfPoint(canvasSize, timestamp) + if (snapHighlighter && tool.isDesktopHighlighter && isNotEmpty()) { + val pageAspectRatio = canvasSize.width.toFloat() / canvasSize.height.coerceAtLeast(1).toFloat() + return listOf( + first(), + SharedPdfInkRenderer.calculateSnappedPoint( + currentPoint = nextPoint, + startPoint = first(), + pageAspectRatio = pageAspectRatio + ) + ) + } + return this + nextPoint +} + +internal data class DesktopPdfCharHit( + val index: Int, + val source: String, + val point: Offset, + val normalized: PdfNormalizedPoint +) + +internal fun SharedPdfAnnotation.toDesktopPdfTextSelection(): DesktopPdfTextSelection { + return DesktopPdfTextSelection( + text = text, + lineBounds = boundsList.ifEmpty { listOfNotNull(bounds) }, + startIndex = rangeStartIndex ?: 0, + endIndex = rangeEndIndex ?: text.length + ) +} + +internal fun DesktopPdfDocument.linkAt( + pageIndex: Int, + point: Offset, + canvasSize: IntSize +): DesktopPdfLinkTarget? { + if (canvasSize.width <= 0 || canvasSize.height <= 0) return null + return DesktopPdfium.linkAt( + document = this, + pageIndex = pageIndex, + normalizedX = point.x / canvasSize.width, + normalizedY = point.y / canvasSize.height, + viewportWidth = canvasSize.width, + viewportHeight = canvasSize.height + ) +} + +internal fun DesktopPdfDocument.charHitAt( + pageIndex: Int, + point: Offset, + canvasSize: IntSize +): DesktopPdfCharHit? { + val normalized = PdfSelectionGeometry.normalizedPoint( + pointX = point.x, + pointY = point.y, + viewportWidth = canvasSize.width, + viewportHeight = canvasSize.height + ) ?: return null + val nativeIndex = DesktopPdfium.charIndexAt( + document = this, + pageIndex = pageIndex, + normalizedX = normalized.x, + normalizedY = normalized.y, + viewportWidth = canvasSize.width, + viewportHeight = canvasSize.height + ) + if (nativeIndex != null) { + return DesktopPdfCharHit( + index = nativeIndex, + source = "native", + point = point, + normalized = normalized + ) + } + val fallback = PdfSelectionGeometry.nearestCharOnLine( + chars = textPageData(pageIndex).chars.visiblePdfTextBounds(), + point = normalized + ) ?: return null + return DesktopPdfCharHit( + index = fallback.index, + source = "fallback_line", + point = point, + normalized = normalized + ) +} + +internal fun DesktopPdfDocument.wordSelectionAt( + pageIndex: Int, + point: Offset, + canvasSize: IntSize +): DesktopPdfTextSelection? { + val hit = charHitAt(pageIndex, point, canvasSize) ?: return null + if (hit.source == "fallback_line" && !isPointNearTextChar(pageIndex, hit.index, hit.normalized)) { + return null + } + val pageText = textPageData(pageIndex).text + if (pageText.isEmpty()) return null + val hitIndex = hit.index.coerceIn(0, pageText.lastIndex) + if (!pageText[hitIndex].isDesktopPdfWordPart()) return null + var startIndex = hitIndex + while (startIndex > 0 && pageText[startIndex - 1].isDesktopPdfWordPart()) { + startIndex -= 1 + } + var endIndex = hitIndex + while (endIndex < pageText.lastIndex && pageText[endIndex + 1].isDesktopPdfWordPart()) { + endIndex += 1 + } + return selectionBetweenIndexes( + pageIndex = pageIndex, + startIndex = startIndex, + endIndex = endIndex, + canvasSize = canvasSize, + useNativeBounds = true + ) +} + +private fun DesktopPdfDocument.isPointNearTextChar( + pageIndex: Int, + charIndex: Int, + point: PdfNormalizedPoint +): Boolean { + val charBounds = textPageData(pageIndex).chars + .visiblePdfTextBounds() + .firstOrNull { it.index == charIndex } + ?: return false + val horizontalPadding = maxOf((charBounds.right - charBounds.left) * 2f, 0.025f) + val verticalPadding = maxOf((charBounds.bottom - charBounds.top) * 0.65f, 0.006f) + return point.x in (charBounds.left - horizontalPadding)..(charBounds.right + horizontalPadding) && + point.y in (charBounds.top - verticalPadding)..(charBounds.bottom + verticalPadding) +} + +private fun Char.isDesktopPdfWordPart(): Boolean { + return isLetterOrDigit() || this == '\'' || this == '-' || this == '_' +} + +internal fun DesktopPdfDocument.selectionPreviewBetweenIndexes( + pageIndex: Int, + startIndex: Int, + endIndex: Int, + canvasSize: IntSize +): DesktopPdfTextSelection? { + return selectionBetweenIndexes( + pageIndex = pageIndex, + startIndex = startIndex, + endIndex = endIndex, + canvasSize = canvasSize, + useNativeBounds = false, + includeText = false + ) +} + +internal fun DesktopPdfDocument.selectionBetweenIndexes( + pageIndex: Int, + startIndex: Int, + endIndex: Int, + canvasSize: IntSize, + useNativeBounds: Boolean = true, + includeText: Boolean = true +): DesktopPdfTextSelection? { + val chars = textPageData(pageIndex).chars + if (chars.isEmpty()) return null + val firstIndex = minOf(startIndex, endIndex) + val lastIndex = maxOf(startIndex, endIndex) + val selectedChars = chars.filter { it.index in firstIndex..lastIndex } + if (selectedChars.isEmpty()) return null + val text = if (includeText) { + selectedChars.joinToString("") { it.char.toString() } + .replace(DesktopPdfSelectionInlineWhitespaceRegex, " ") + .replace(DesktopPdfSelectionBlankLinesRegex, "\n\n") + .trim() + } else { + "" + } + if (includeText && text.isBlank()) return null + val fallbackBounds = PdfSelectionGeometry.lineBoundsForChars(selectedChars.visiblePdfTextBounds()) + if (!includeText && fallbackBounds.isEmpty()) return null + val nativeBounds = if (useNativeBounds) { + DesktopPdfium.textRectsForRange( + document = this, + pageIndex = pageIndex, + startIndex = firstIndex, + endIndex = lastIndex, + viewportWidth = canvasSize.width, + viewportHeight = canvasSize.height + ).map { it.toPdfPageBounds() } + .filter { it.right > it.left && it.bottom > it.top } + .mergePdfBoundsByLine() + } else { + emptyList() + } + return DesktopPdfTextSelection( + text = text, + lineBounds = nativeBounds.ifEmpty { fallbackBounds }, + startIndex = firstIndex, + endIndex = lastIndex + ) +} + +internal fun DesktopPdfTextRect.toPdfPageBounds(): PdfPageBounds { + return PdfPageBounds( + left = left, + top = top, + right = right, + bottom = bottom + ) +} + +internal fun SharedPdfAnnotation.toRenderablePdfAnnotations( + document: DesktopPdfDocument, + pageIndex: Int, + canvasSize: IntSize +): List { + val startIndex = rangeStartIndex + val endIndex = rangeEndIndex + if (kind != PdfAnnotationKind.HIGHLIGHT || startIndex == null || endIndex == null) { + return listOf(this) + } + if (canvasSize.width <= 0 || canvasSize.height <= 0) { + return listOf(this) + } + val dynamicBounds = DesktopPdfium.textRectsForRange( + document = document, + pageIndex = pageIndex, + startIndex = startIndex, + endIndex = endIndex, + viewportWidth = canvasSize.width, + viewportHeight = canvasSize.height + ).map { it.toPdfPageBounds() } + .filter { it.right > it.left && it.bottom > it.top } + .mergePdfBoundsByLine() + + return dynamicBounds.ifEmpty { boundsList.ifEmpty { listOfNotNull(bounds) } } + .mapIndexed { index, dynamicBounds -> + copy( + id = "${id}_line_$index", + bounds = dynamicBounds + ) + } +} + +internal fun SharedPdfTextDraft.containsOffset( + pageIndex: Int, + offset: Offset, + canvasSize: IntSize +): Boolean { + if (this.pageIndex != pageIndex || canvasSize.width <= 0 || canvasSize.height <= 0) return false + val left = bounds.left * canvasSize.width + val right = bounds.right * canvasSize.width + val top = bounds.top * canvasSize.height + val bottom = bounds.bottom * canvasSize.height + return offset.x in left..right && offset.y in top..bottom +} + +internal fun List.textAnnotationHitAt( + pageIndex: Int, + point: Offset, + canvasSize: IntSize +): SharedPdfAnnotation? { + return asReversed().firstOrNull { annotation -> + annotation.kind == PdfAnnotationKind.TEXT && + annotation.pageIndex == pageIndex && + annotation.sharedPdfHitTest(point, canvasSize) + } +} + +internal fun List.mergePdfBoundsByLine(): List { + return PdfSelectionGeometry.mergeBoundsByLine(this) +} + +private fun List.visiblePdfTextBounds(): List { + return asSequence() + .filter { it.hasBounds && !it.char.isISOControl() } + .map { it.toPdfTextCharBounds() } + .toList() +} + +private fun DesktopPdfTextChar.toPdfTextCharBounds(): PdfTextCharBounds { + return PdfTextCharBounds( + index = index, + left = left, + top = top, + right = right, + bottom = bottom + ) +} + +internal const val DesktopPdfSelectionPreviewThrottleMillis = 32L +internal const val DesktopPdfZoomCommitDebounceMillis = 180L +internal const val DesktopPdfZoomRenderDebounceMillis = 300L +internal const val DesktopPdfViewportPersistDebounceMillis = 300L +internal const val DesktopPdfPaginationPrefetchDelayMillis = 450L +internal const val DesktopPdfRenderScaleTolerance = 0.01f +internal const val DesktopPdfPaginationRenderCacheRadius = 2 +private val DesktopPdfSelectionInlineWhitespaceRegex = Regex("[ \\t\\x0B\\f\\r]+") +private val DesktopPdfSelectionBlankLinesRegex = Regex("\\n{3,}") diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSelectionUi.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSelectionUi.kt new file mode 100644 index 0000000..66e9cf6 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSelectionUi.kt @@ -0,0 +1,503 @@ +package com.aryan.reader.desktop + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathParser +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.aryan.reader.shared.pdf.PdfPageBounds +import com.aryan.reader.shared.pdf.SharedPdfAndroidHighlightColors +import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette +import com.aryan.reader.shared.ui.SharedHsvColorPickerDialog +import com.aryan.reader.shared.ui.SharedSelectionMenuRect +import com.aryan.reader.shared.ui.SharedSelectionMenuSize +import com.aryan.reader.shared.ui.SharedSelectionMenuViewport +import com.aryan.reader.shared.ui.sharedSelectionMenuPlacement +import kotlin.math.roundToInt + +internal data class DesktopPdfTextSelection( + val text: String, + val lineBounds: List, + val startIndex: Int, + val endIndex: Int +) + +private data class DesktopPdfSelectionCanvasBounds( + val left: Float, + val top: Float, + val right: Float, + val bottom: Float +) { + val centerX: Float get() = (left + right) / 2f +} + +private fun DesktopPdfTextSelection.canvasBounds(canvasSize: IntSize): DesktopPdfSelectionCanvasBounds? { + val validBounds = lineBounds.filter { it.right > it.left && it.bottom > it.top } + if (validBounds.isEmpty() || canvasSize.width <= 0 || canvasSize.height <= 0) return null + return DesktopPdfSelectionCanvasBounds( + left = validBounds.minOf { it.left } * canvasSize.width, + top = validBounds.minOf { it.top } * canvasSize.height, + right = validBounds.maxOf { it.right } * canvasSize.width, + bottom = validBounds.maxOf { it.bottom } * canvasSize.height + ) +} + +internal fun DesktopPdfTextSelection.menuAnchor( + canvasSize: IntSize, + fallback: Offset? +): Offset { + val bounds = canvasBounds(canvasSize) ?: return fallback ?: Offset.Zero + return Offset(x = bounds.centerX, y = bounds.top) +} + +private fun DesktopPdfTextSelection.startHandleOffset(canvasSize: IntSize): Offset? { + if (canvasSize.width <= 0 || canvasSize.height <= 0) return null + val first = lineBounds.firstOrNull { it.right > it.left && it.bottom > it.top } ?: return null + return Offset( + x = first.left * canvasSize.width, + y = first.bottom * canvasSize.height + ) +} + +private fun DesktopPdfTextSelection.endHandleOffset(canvasSize: IntSize): Offset? { + if (canvasSize.width <= 0 || canvasSize.height <= 0) return null + val last = lineBounds.lastOrNull { it.right > it.left && it.bottom > it.top } ?: return null + return Offset( + x = last.right * canvasSize.width, + y = last.bottom * canvasSize.height + ) +} + +internal fun DesktopPdfTextSelection.handleAt( + point: Offset, + canvasSize: IntSize +): DesktopPdfSelectionHandle? { + val start = startHandleOffset(canvasSize) + val end = endHandleOffset(canvasSize) + + fun Offset.containsHandlePoint(): Boolean { + val halfWidth = DesktopPdfSelectionHandleTouchWidthPx / 2f + return point.x in (x - halfWidth)..(x + halfWidth) && + point.y in (y - DesktopPdfSelectionHandleTouchTopPx)..(y + DesktopPdfSelectionHandleTouchBottomPx) + } + + return when { + start != null && start.containsHandlePoint() -> DesktopPdfSelectionHandle.START + end != null && end.containsHandlePoint() -> DesktopPdfSelectionHandle.END + else -> null + } +} + +@Composable +internal fun PdfSearchHighlightOverlay( + bounds: List, + canvasSize: IntSize, + color: Color +) { + if (bounds.isEmpty() || canvasSize.width <= 0 || canvasSize.height <= 0) return + Canvas(Modifier.fillMaxSize()) { + bounds.forEach { rect -> + drawRect( + color = color, + topLeft = Offset(rect.left * canvasSize.width, rect.top * canvasSize.height), + size = androidx.compose.ui.geometry.Size( + (rect.right - rect.left) * canvasSize.width, + (rect.bottom - rect.top) * canvasSize.height + ) + ) + } + } +} + +@Composable +internal fun PdfTextSelectionOverlay( + selection: DesktopPdfTextSelection?, + canvasSize: IntSize +) { + val bounds = selection?.lineBounds.orEmpty() + if (bounds.isEmpty()) return + Canvas(Modifier.fillMaxSize()) { + bounds.forEach { rect -> + drawRect( + color = Color(0x663B82F6), + topLeft = Offset(rect.left * canvasSize.width, rect.top * canvasSize.height), + size = androidx.compose.ui.geometry.Size( + (rect.right - rect.left) * canvasSize.width, + (rect.bottom - rect.top) * canvasSize.height + ) + ) + } + } +} + +@Composable +internal fun PdfTextSelectionHandles( + selection: DesktopPdfTextSelection?, + canvasSize: IntSize, + activeHandle: DesktopPdfSelectionHandle? +) { + selection ?: return + if (canvasSize.width <= 0 || canvasSize.height <= 0) return + val density = LocalDensity.current + val handleSize = 24.dp + val handleWidthPx = with(density) { handleSize.toPx() } + val start = selection.startHandleOffset(canvasSize) + val end = selection.endHandleOffset(canvasSize) + val handleColor = MaterialTheme.colorScheme.primary + + fun Modifier.handleOffset(position: Offset): Modifier = offset { + IntOffset( + x = (position.x - handleWidthPx / 2f).roundToInt(), + y = position.y.roundToInt() + ) + } + + Box(Modifier.fillMaxSize()) { + start?.let { position -> + Icon( + imageVector = DesktopPdfSelectionMenuIcons.Teardrop, + contentDescription = "Selection start handle", + tint = handleColor.copy(alpha = if (activeHandle == DesktopPdfSelectionHandle.END) 0.72f else 1f), + modifier = Modifier + .handleOffset(position) + .size(handleSize) + .graphicsLayer { + rotationZ = 30f + transformOrigin = TransformOrigin(0.5f, 0f) + } + ) + } + end?.let { position -> + Icon( + imageVector = DesktopPdfSelectionMenuIcons.Teardrop, + contentDescription = "Selection end handle", + tint = handleColor.copy(alpha = if (activeHandle == DesktopPdfSelectionHandle.START) 0.72f else 1f), + modifier = Modifier + .handleOffset(position) + .size(handleSize) + .graphicsLayer { + rotationZ = -30f + transformOrigin = TransformOrigin(0.5f, 0f) + } + ) + } + } +} + +private object DesktopPdfSelectionMenuIcons { + val Copy = vector( + name = "DesktopPdfSelectionCopy", + pathData = "M360,720Q327,720 303.5,696.5Q280,673 280,640L280,160Q280,127 303.5,103.5Q327,80 360,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,640Q800,673 776.5,696.5Q753,720 720,720L360,720ZM360,640L720,640Q720,640 720,640Q720,640 720,640L720,160Q720,160 720,160Q720,160 720,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640ZM200,880Q167,880 143.5,856.5Q120,833 120,800L120,240L200,240L200,800Q200,800 200,800Q200,800 200,800L640,800L640,880L200,880ZM360,640Q360,640 360,640Q360,640 360,640L360,160Q360,160 360,160Q360,160 360,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640Z" + ) + val Dictionary = vector( + name = "DesktopPdfSelectionDictionary", + pathData = "M160,569L205,569L228,503L332,503L356,569L400,569L303,311L257,311L160,569ZM241,466L279,359L281,359L319,466L241,466ZM560,396L560,328Q593,314 627.5,307Q662,300 700,300Q726,300 751,304Q776,308 800,314L800,378Q776,369 751.5,364.5Q727,360 700,360Q662,360 627,369.5Q592,379 560,396ZM560,616L560,548Q593,534 627.5,527Q662,520 700,520Q726,520 751,524Q776,528 800,534L800,598Q776,589 751.5,584.5Q727,580 700,580Q662,580 627,589Q592,598 560,616ZM560,506L560,438Q593,424 627.5,417Q662,410 700,410Q726,410 751,414Q776,418 800,424L800,488Q776,479 751.5,474.5Q727,470 700,470Q662,470 627,479.5Q592,489 560,506ZM260,640Q307,640 351.5,650.5Q396,661 440,682L440,288Q399,264 353,252Q307,240 260,240Q224,240 188.5,247Q153,254 120,268Q120,268 120,268Q120,268 120,268L120,664Q120,664 120,664Q120,664 120,664Q155,652 189.5,646Q224,640 260,640ZM520,682Q564,661 608.5,650.5Q653,640 700,640Q736,640 770.5,646Q805,652 840,664Q840,664 840,664Q840,664 840,664L840,268Q840,268 840,268Q840,268 840,268Q807,254 771.5,247Q736,240 700,240Q653,240 607,252Q561,264 520,288L520,682ZM480,800Q432,762 376,741Q320,720 260,720Q218,720 177.5,731Q137,742 100,762Q79,773 59.5,761Q40,749 40,726L40,244Q40,233 45.5,223Q51,213 62,208Q108,184 158,172Q208,160 260,160Q318,160 373.5,175Q429,190 480,220Q531,190 586.5,175Q642,160 700,160Q752,160 802,172Q852,184 898,208Q909,213 914.5,223Q920,233 920,244L920,726Q920,749 900.5,761Q881,773 860,762Q823,742 782.5,731Q742,720 700,720Q640,720 584,741Q528,762 480,800ZM280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461L280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461L280,461Q280,461 280,461Q280,461 280,461Z" + ) + val Search = vector( + name = "DesktopPdfSelectionSearch", + pathData = "M784,840L532,588Q502,612 463,626Q424,640 380,640Q271,640 195.5,564.5Q120,489 120,380Q120,271 195.5,195.5Q271,120 380,120Q489,120 564.5,195.5Q640,271 640,380Q640,424 626,463Q612,502 588,532L840,784L784,840ZM380,560Q455,560 507.5,507.5Q560,455 560,380Q560,305 507.5,252.5Q455,200 380,200Q305,200 252.5,252.5Q200,305 200,380Q200,455 252.5,507.5Q305,560 380,560Z" + ) + val Teardrop = vector( + name = "DesktopPdfSelectionTeardrop", + pathData = "M480,860Q347,860 253.5,768Q160,676 160,544Q160,481 184.5,423.5Q209,366 254,322L480,100L706,322Q751,366 775.5,423.5Q800,481 800,544Q800,676 706.5,768Q613,860 480,860Z" + ) + + private fun vector(name: String, pathData: String): ImageVector { + return ImageVector.Builder( + name = name, + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f + ).apply { + addPath( + pathData = PathParser().parsePathString(pathData).toNodes(), + fill = SolidColor(Color.Black) + ) + }.build() + } +} + +internal enum class DesktopPdfSelectionHandle { + START, + END +} + +@Composable +internal fun PdfSelectionMenu( + selection: DesktopPdfTextSelection?, + menuOffset: Offset?, + canvasSize: IntSize, + highlighterPalette: List = SharedPdfHighlighterPalette.defaultColors, + onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit, + onCopy: () -> Unit, + onHighlight: (Int) -> Unit, + onSearch: () -> Unit, + onDefine: () -> Unit, + onSpeak: () -> Unit, + showDefine: Boolean, + showSpeak: Boolean, + showSearch: Boolean, + onClear: () -> Unit +) { + selection ?: return + val anchor = menuOffset ?: return + val selectionBounds = selection.canvasBounds(canvasSize) + val paletteColors = remember(highlighterPalette) { + SharedPdfAndroidHighlightColors.palette + } + var editingHighlighterSlot by remember(selection.startIndex, selection.endIndex, paletteColors) { + mutableStateOf(null) + } + val actions = buildList { + add(PdfSelectionMenuAction("Copy", DesktopPdfSelectionMenuIcons.Copy, onCopy)) + if (showDefine) add(PdfSelectionMenuAction("Define", DesktopPdfSelectionMenuIcons.Dictionary, onDefine)) + if (showSpeak) add(PdfSelectionMenuAction("Speak", Icons.AutoMirrored.Filled.VolumeUp, onSpeak)) + if (showSearch) add(PdfSelectionMenuAction("Search", DesktopPdfSelectionMenuIcons.Search, onSearch)) + add(PdfSelectionMenuAction("Clear", Icons.Default.Close, onClear, isDestructive = true)) + } + val estimatedHeight = PdfSelectionMenuPaletteHeightPx + + (((actions.size + 2) / 3).coerceAtLeast(1) * PdfSelectionMenuActionRowHeightPx) + val placement = sharedSelectionMenuPlacement( + viewport = SharedSelectionMenuViewport(canvasSize.width, canvasSize.height), + popup = SharedSelectionMenuSize( + width = PdfSelectionMenuWidthPx.roundToInt(), + height = estimatedHeight.roundToInt() + ), + selection = if (selectionBounds != null) { + SharedSelectionMenuRect( + left = selectionBounds.left, + top = selectionBounds.top, + right = selectionBounds.right, + bottom = selectionBounds.bottom + ) + } else { + SharedSelectionMenuRect( + left = anchor.x, + top = anchor.y, + right = anchor.x, + bottom = anchor.y + ) + }, + marginPx = PdfSelectionMenuMarginPx, + gapPx = PdfSelectionMenuAnchorGapPx + ) + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) { + Surface( + color = MaterialTheme.colorScheme.surface, + tonalElevation = 4.dp, + shadowElevation = 10.dp, + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.offset { + IntOffset(placement.x, placement.y) + } + ) { + Column( + modifier = Modifier + .widthIn(min = 180.dp, max = 220.dp) + .padding(bottom = 6.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + paletteColors.forEach { colorArgb -> + Surface( + modifier = Modifier + .padding(horizontal = 4.dp) + .size(28.dp) + .clickable { onHighlight(colorArgb) }, + color = Color(colorArgb), + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.28f)), + content = {} + ) + } + Box( + modifier = Modifier + .padding(horizontal = 4.dp) + .size(28.dp) + .clip(RoundedCornerShape(16.dp)) + .background( + Brush.sweepGradient( + listOf( + Color.Red, + Color.Yellow, + Color.Green, + Color.Cyan, + Color.Blue, + Color.Magenta, + Color.Red + ) + ) + ) + .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.32f), RoundedCornerShape(16.dp)) + .clickable { editingHighlighterSlot = 0 } + ) + } + HorizontalDivider() + actions.chunked(3).forEach { rowActions -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 6.dp, vertical = 3.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + rowActions.forEach { action -> + val tint = if (action.isDestructive) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + } + Column( + modifier = Modifier + .width(58.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable { action.onClick() } + .padding(vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = action.icon, + contentDescription = action.label, + tint = tint, + modifier = Modifier.size(22.dp) + ) + Text( + action.label, + style = MaterialTheme.typography.labelSmall, + color = tint, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + repeat(3 - rowActions.size) { + Spacer(modifier = Modifier.width(58.dp)) + } + } + } + } + } + } + editingHighlighterSlot?.let { requestedSlot -> + val slot = requestedSlot.coerceIn(0, paletteColors.lastIndex) + val initialColor = Color(paletteColors[slot]).copy(alpha = 1f) + SharedHsvColorPickerDialog( + initialColor = initialColor, + title = "Highlight color ${slot + 1}", + onDismiss = { editingHighlighterSlot = null }, + onSave = { color -> + onHighlighterPaletteChange( + SharedPdfHighlighterPalette(paletteColors).withColorAt( + slotIndex = slot, + colorArgb = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb() + ) + ) + editingHighlighterSlot = null + } + ) { liveColor -> + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + paletteColors.forEachIndexed { index, argb -> + val color = if (index == slot) liveColor else Color(argb).copy(alpha = 1f) + Box( + modifier = Modifier + .size(42.dp) + .clip(RoundedCornerShape(21.dp)) + .background(color) + .border( + width = if (index == slot) 3.dp else 1.dp, + color = if (index == slot) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.35f), + shape = RoundedCornerShape(21.dp) + ) + .clickable { editingHighlighterSlot = index }, + contentAlignment = Alignment.Center + ) { + Text( + text = "${index + 1}", + color = if (color.luminance() > 0.5f) Color.Black else Color.White, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } + } +} + +private data class PdfSelectionMenuAction( + val label: String, + val icon: ImageVector, + val onClick: () -> Unit, + val isDestructive: Boolean = false +) + +private const val PdfSelectionMenuWidthPx = 220f +private const val PdfSelectionMenuPaletteHeightPx = 54f +private const val PdfSelectionMenuActionRowHeightPx = 66f +private const val PdfSelectionMenuAnchorGapPx = 16f +private const val PdfSelectionMenuMarginPx = 6f +private const val DesktopPdfSelectionHandleTouchWidthPx = 44f +private const val DesktopPdfSelectionHandleTouchTopPx = 8f +private const val DesktopPdfSelectionHandleTouchBottomPx = 40f diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSidecars.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSidecars.kt new file mode 100644 index 0000000..9cdc61c --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfSidecars.kt @@ -0,0 +1,83 @@ +package com.aryan.reader.desktop + +import java.io.File +import java.util.Base64 + +private const val DesktopPdfSearchIndexHeader = "EpistemePdfSearchIndex\t1" + +internal fun desktopPdfAnnotationFile(documentPath: String): File { + val safeName = documentPath.hashCode().toString().replace("-", "n") + return File(desktopUserDataRoot(), "annotations/pdf_$safeName.json") +} + +internal fun desktopPdfBookmarkFile(documentPath: String): File { + val safeName = documentPath.hashCode().toString().replace("-", "n") + return File(desktopUserDataRoot(), "annotations/pdf_${safeName}_bookmarks.json") +} + +internal fun desktopPdfRichTextFile(documentPath: String): File { + val safeName = documentPath.hashCode().toString().replace("-", "n") + return File(desktopUserDataRoot(), "annotations/pdf_${safeName}_rich_text.json") +} + +internal fun desktopPdfSearchIndexFile(documentPath: String): File { + val safeName = documentPath.hashCode().toString().replace("-", "n") + return File(desktopUserCacheRoot(), "search/pdf_${safeName}_text_index.tsv") +} + +internal fun restoreDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFile: File): Int { + val sourceFile = File(document.path) + val lines = runCatching { indexFile.readLines(Charsets.UTF_8) }.getOrNull() ?: return document.indexedSearchTextPageCount() + if (lines.firstOrNull() != DesktopPdfSearchIndexHeader) return 0 + val metadata = lines + .asSequence() + .drop(1) + .takeWhile { !it.startsWith("page\t") } + .mapNotNull { line -> + val parts = line.split('\t', limit = 2) + if (parts.size == 2) parts[0] to parts[1] else null + } + .toMap() + val isFresh = metadata["pathHash"] == document.path.hashCode().toString() && + metadata["fileSize"] == sourceFile.length().toString() && + metadata["lastModified"] == sourceFile.lastModified().toString() && + metadata["pageCount"] == document.pageCount.toString() + if (!isFresh) return 0 + + val decoder = Base64.getDecoder() + lines.asSequence() + .filter { it.startsWith("page\t") } + .forEach { line -> + val parts = line.split('\t', limit = 3) + val pageIndex = parts.getOrNull(1)?.toIntOrNull() ?: return@forEach + val text = runCatching { + String(decoder.decode(parts.getOrNull(2).orEmpty()), Charsets.UTF_8) + }.getOrDefault("") + document.cacheSearchTextPage(pageIndex, text) + } + return document.indexedSearchTextPageCount() +} + +internal fun saveDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFile: File) { + val sourceFile = File(document.path) + val pages = document.indexedSearchPages() + if (pages.isEmpty()) return + val encoder = Base64.getEncoder() + val payload = buildString { + appendLine(DesktopPdfSearchIndexHeader) + appendLine("pathHash\t${document.path.hashCode()}") + appendLine("fileSize\t${sourceFile.length()}") + appendLine("lastModified\t${sourceFile.lastModified()}") + appendLine("pageCount\t${document.pageCount}") + pages.forEach { page -> + append("page\t") + append(page.pageIndex) + append('\t') + appendLine(encoder.encodeToString(page.text.toByteArray(Charsets.UTF_8))) + } + } + runCatching { + indexFile.parentFile?.mkdirs() + indexFile.writeText(payload, Charsets.UTF_8) + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfZoom.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfZoom.kt new file mode 100644 index 0000000..3296c39 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfZoom.kt @@ -0,0 +1,290 @@ +package com.aryan.reader.desktop + +import androidx.compose.foundation.gestures.calculateCentroid +import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isCtrlPressed as isPointerCtrlPressed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import com.aryan.reader.shared.PdfDisplayMode +import com.aryan.reader.shared.pdf.PdfZoomSpec +import kotlin.math.abs +import kotlin.math.exp +import kotlin.math.roundToInt + +private const val DesktopPdfZoomGestureFrameMillis = 16L +internal const val DesktopPdfPaginationFastFirstRenderMaxScale = 2.0f + +internal fun desktopPdfScrollZoomFactor(scrollDelta: Float): Float { + if (!scrollDelta.isFinite() || abs(scrollDelta) < 0.01f) return 1f + val normalizedDelta = scrollDelta.coerceIn(-8f, 8f) + return exp((-normalizedDelta * 0.12f).toDouble()).toFloat() +} + +internal fun desktopPdfZoomTarget( + currentZoom: Float, + zoomSpec: PdfZoomSpec, + factor: Float +): Float { + val baseZoom = currentZoom.takeIf { it.isFinite() } ?: zoomSpec.default + val safeFactor = factor.takeIf { it.isFinite() && it > 0f } ?: 1f + return zoomSpec.clamp(baseZoom * safeFactor) +} + +internal fun desktopPdfAnchoredScrollTarget( + currentScroll: Int, + anchor: Float, + oldZoom: Float, + newZoom: Float +): Int { + if ( + !anchor.isFinite() || + !oldZoom.isFinite() || + !newZoom.isFinite() || + oldZoom <= 0f || + newZoom <= 0f + ) { + return currentScroll.coerceAtLeast(0) + } + val zoomRatio = newZoom / oldZoom + return (((currentScroll + anchor) * zoomRatio) - anchor).roundToInt().coerceAtLeast(0) +} + +internal fun desktopPdfAnchoredLazyItemScrollOffset( + itemOffset: Int, + anchor: Float, + oldZoom: Float, + newZoom: Float +): Int { + if ( + !anchor.isFinite() || + !oldZoom.isFinite() || + !newZoom.isFinite() || + oldZoom <= 0f || + newZoom <= 0f + ) { + return (-itemOffset).coerceAtLeast(0) + } + val zoomRatio = newZoom / oldZoom + val offsetWithinItem = anchor - itemOffset + return ((offsetWithinItem * zoomRatio) - anchor).roundToInt().coerceAtLeast(0) +} + +internal fun desktopPdfAnchoredPageScrollDelta( + viewportRootOffset: Offset, + oldPageRootOffset: Offset, + currentPageRootOffset: Offset, + anchor: Offset, + oldZoom: Float, + newZoom: Float +): IntOffset? { + if ( + !anchor.x.isFinite() || + !anchor.y.isFinite() || + !oldZoom.isFinite() || + !newZoom.isFinite() || + oldZoom <= 0f || + newZoom <= 0f + ) { + return null + } + val rootAnchor = viewportRootOffset + anchor + val oldPageLocal = rootAnchor - oldPageRootOffset + val zoomRatio = newZoom / oldZoom + val newPageLocal = Offset(oldPageLocal.x * zoomRatio, oldPageLocal.y * zoomRatio) + val desiredPageRoot = rootAnchor - newPageLocal + val delta = currentPageRootOffset - desiredPageRoot + return IntOffset(delta.x.roundToInt(), delta.y.roundToInt()) +} + +internal fun desktopPdfPaginationFirstRenderScale( + requestedScale: Float, + hasPageRender: Boolean, + isOpeningRender: Boolean = false +): Float { + if (hasPageRender || isOpeningRender || !requestedScale.isFinite() || requestedScale <= 0f) { + return requestedScale + } + return requestedScale.coerceAtMost(DesktopPdfPaginationFastFirstRenderMaxScale) +} + +internal data class DesktopPdfZoomPreview( + val baseZoom: Float, + val zoom: Float, + val anchor: Offset?, + val displayMode: PdfDisplayMode, + val pageIndex: Int? +) + +internal data class DesktopPdfCachedPageRender( + val render: DesktopPdfPageRender, + val scale: Float +) + +internal fun desktopPdfZoomPreviewPivotFraction( + viewportRootOffset: Offset, + pageRootOffset: Offset, + anchor: Offset, + pageCanvasSize: IntSize +): Offset? { + if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return null + if (!anchor.x.isFinite() || !anchor.y.isFinite()) return null + val pageAnchor = viewportRootOffset + anchor - pageRootOffset + if (!pageAnchor.x.isFinite() || !pageAnchor.y.isFinite()) return null + return Offset( + x = (pageAnchor.x / pageCanvasSize.width).coerceIn(0f, 1f), + y = (pageAnchor.y / pageCanvasSize.height).coerceIn(0f, 1f) + ) +} + +internal fun desktopPdfDocumentZoomPreviewTranslation( + viewportRootOffset: Offset, + pageRootOffset: Offset, + anchor: Offset, + previewScale: Float +): Offset? { + if (!anchor.x.isFinite() || !anchor.y.isFinite()) return null + if (!previewScale.isFinite() || previewScale <= 0f) return null + val rootAnchor = viewportRootOffset + anchor + if (!rootAnchor.x.isFinite() || !rootAnchor.y.isFinite()) return null + return Offset( + x = (pageRootOffset.x - rootAnchor.x) * (previewScale - 1f), + y = (pageRootOffset.y - rootAnchor.y) * (previewScale - 1f) + ) +} + +internal fun Modifier.desktopPdfZoomPreviewLayer( + preview: DesktopPdfZoomPreview?, + currentZoom: Float, + viewportRootOffset: Offset, + pageRootOffset: Offset, + pageCanvasSize: IntSize +): Modifier { + val activePreview = preview ?: return this + if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return this + if (!currentZoom.isFinite() || currentZoom <= 0f) return this + if (!activePreview.zoom.isFinite() || activePreview.zoom <= 0f) return this + val previewScale = activePreview.zoom / currentZoom + if (!previewScale.isFinite() || abs(previewScale - 1f) < 0.0001f) return this + val transformOrigin = activePreview.anchor?.let { anchor -> + desktopPdfZoomPreviewPivotFraction( + viewportRootOffset = viewportRootOffset, + pageRootOffset = pageRootOffset, + anchor = anchor, + pageCanvasSize = pageCanvasSize + )?.let { pivot -> + TransformOrigin(pivotFractionX = pivot.x, pivotFractionY = pivot.y) + } ?: TransformOrigin.Center + } ?: TransformOrigin.Center + return graphicsLayer { + scaleX = previewScale + scaleY = previewScale + this.transformOrigin = transformOrigin + } +} + +internal fun Modifier.desktopPdfDocumentZoomPreviewLayer( + preview: DesktopPdfZoomPreview?, + currentZoom: Float, + viewportRootOffset: Offset, + pageRootOffset: Offset +): Modifier { + val activePreview = preview ?: return this + if (!currentZoom.isFinite() || currentZoom <= 0f) return this + if (!activePreview.zoom.isFinite() || activePreview.zoom <= 0f) return this + val previewScale = activePreview.zoom / currentZoom + if (!previewScale.isFinite() || abs(previewScale - 1f) < 0.0001f) return this + val translation = activePreview.anchor?.let { anchor -> + desktopPdfDocumentZoomPreviewTranslation( + viewportRootOffset = viewportRootOffset, + pageRootOffset = pageRootOffset, + anchor = anchor, + previewScale = previewScale + ) + } ?: Offset.Zero + return graphicsLayer { + scaleX = previewScale + scaleY = previewScale + translationX = translation.x + translationY = translation.y + transformOrigin = TransformOrigin(0f, 0f) + } +} + +@Composable +internal fun Modifier.desktopPdfZoomGestures( + currentZoom: Float, + zoomSpec: PdfZoomSpec, + onZoomChanged: (oldZoom: Float, newZoom: Float, anchor: Offset?) -> Unit +): Modifier { + val latestZoom by rememberUpdatedState(currentZoom) + val latestOnZoomChanged by rememberUpdatedState(onZoomChanged) + return this.pointerInput(zoomSpec) { + var gestureZoom = latestZoom + var appliedGestureZoom = latestZoom + var lastZoomEventAt = 0L + var lastAppliedZoomAt = 0L + fun applyZoomFactor(factor: Float, eventTime: Long, anchor: Offset?) { + if (lastZoomEventAt == 0L || eventTime - lastZoomEventAt > 180L) { + gestureZoom = latestZoom + appliedGestureZoom = latestZoom + lastAppliedZoomAt = 0L + } + val newZoom = desktopPdfZoomTarget(gestureZoom, zoomSpec, factor) + gestureZoom = newZoom + lastZoomEventAt = eventTime + val shouldApplyNow = lastAppliedZoomAt == 0L || + eventTime - lastAppliedZoomAt >= DesktopPdfZoomGestureFrameMillis || + newZoom == zoomSpec.min || + newZoom == zoomSpec.max + if (shouldApplyNow && newZoom != appliedGestureZoom) { + latestOnZoomChanged(appliedGestureZoom, newZoom, anchor) + appliedGestureZoom = newZoom + lastAppliedZoomAt = eventTime + } + } + + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val eventTime = event.changes.maxOfOrNull { it.uptimeMillis } ?: 0L + if (event.type == PointerEventType.Scroll && event.keyboardModifiers.isPointerCtrlPressed) { + val scrollDelta = event.changes.fold(Offset.Zero) { total, change -> + total + change.scrollDelta + } + val zoomDelta = if (abs(scrollDelta.y) >= abs(scrollDelta.x)) scrollDelta.y else scrollDelta.x + val factor = desktopPdfScrollZoomFactor(zoomDelta) + if (abs(factor - 1f) > 0.0001f) { + applyZoomFactor(factor, eventTime, event.changes.firstOrNull()?.position) + event.changes.forEach { it.consume() } + } + continue + } + + val pressedPointers = event.changes.count { it.pressed } + if (pressedPointers > 1) { + val zoomChange = event.calculateZoom() + if (zoomChange.isFinite() && abs(zoomChange - 1f) > 0.005f) { + val centroid = event.calculateCentroid(useCurrent = false) + val anchor = if (centroid == Offset.Unspecified) { + event.changes.firstOrNull { it.pressed }?.position + } else { + centroid + } + applyZoomFactor(zoomChange, eventTime, anchor) + } + event.changes.forEach { it.consume() } + } + } + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderDiagnostics.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderDiagnostics.kt new file mode 100644 index 0000000..84f1647 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderDiagnostics.kt @@ -0,0 +1,68 @@ +package com.aryan.reader.desktop + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntSize + +private const val PdfZoomPerfLogTag = "EpistemePdfZoomPerf" +private const val PdfLinkLogTag = "EpistemePdfLink" +private const val EpubLinkLogTag = "EpistemeEpubLink" +private const val EpubPaginationLogTag = "EpistemeEpubPagination" +private const val ReaderGapLogTag = "EpistemeReaderGap" +private const val EpubSelectionDebugLogTag = "EPUB_SELECTION_DEBUG" + +internal fun logPdfSelection(message: String) { +} + +internal fun logPdfZoomPerf(message: String) { + logDesktopDiagnostic(PdfZoomPerfLogTag) { message } +} + +internal fun logPdfZoomPerf(message: () -> String) { + logDesktopDiagnostic(PdfZoomPerfLogTag, message) +} + +internal fun logPdfLink(message: String) { + logDesktopDiagnostic(PdfLinkLogTag) { message } +} + +internal fun logEpubLink(message: String) { + logDesktopDiagnostic(EpubLinkLogTag) { message } +} + +internal fun logEpubPagination(message: String) { + logDesktopDiagnostic(EpubPaginationLogTag) { message } +} + +internal fun logReaderGap(message: String) { + logDesktopDiagnostic(ReaderGapLogTag) { message } +} + +internal fun logEpubSelectionDebug(message: String) { + logDesktopDiagnostic(EpubSelectionDebugLogTag) { message } +} + +internal fun DesktopPdfLinkTarget.formatLogTarget(): String { + return "dest=${destPageIndex?.let { it + 1 } ?: "null"} uri=\"${uri.orEmpty().logPreview()}\"" +} + +internal fun Float.formatLogFloat(): String { + return String.format("%.3f", this) +} + +internal fun Offset?.formatLogOffset(): String { + if (this == null) return "none" + return "${x.formatLogFloat()},${y.formatLogFloat()}" +} + +internal fun IntSize.formatLogSize(): String { + return "${width}x${height}" +} + +internal fun DesktopPdfCharHit?.formatLogHit(prefix: String): String { + if (this == null) { + return "${prefix}Index=null ${prefix}Source=none ${prefix}X=null ${prefix}Y=null ${prefix}Nx=null ${prefix}Ny=null" + } + return "${prefix}Index=$index ${prefix}Source=$source " + + "${prefix}X=${point.x.formatLogFloat()} ${prefix}Y=${point.y.formatLogFloat()} " + + "${prefix}Nx=${normalized.x.formatLogFloat()} ${prefix}Ny=${normalized.y.formatLogFloat()}" +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderOpening.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderOpening.kt new file mode 100644 index 0000000..77012d8 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderOpening.kt @@ -0,0 +1,36 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.BookItem +import com.aryan.reader.shared.reader.ReaderSessionState +import com.aryan.reader.shared.ui.SharedAppTab + +internal data class DesktopReaderOpening( + val requestId: Long, + val bookId: String, + val title: String, + val formatLabel: String, + val returnTab: SharedAppTab +) + +internal sealed interface DesktopReaderOpenResult { + val opening: DesktopReaderOpening + val book: BookItem + + data class Pdf( + override val opening: DesktopReaderOpening, + override val book: BookItem, + val document: DesktopPdfDocument + ) : DesktopReaderOpenResult + + data class Text( + override val opening: DesktopReaderOpening, + override val book: BookItem, + val session: ReaderSessionState + ) : DesktopReaderOpenResult + + data class Failure( + override val opening: DesktopReaderOpening, + override val book: BookItem, + val message: String + ) : DesktopReaderOpenResult +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderPanels.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderPanels.kt new file mode 100644 index 0000000..fcd3bb9 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderPanels.kt @@ -0,0 +1,535 @@ +package com.aryan.reader.desktop + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL +import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL_ID +import com.aryan.reader.shared.ReaderAiByokSettings +import com.aryan.reader.shared.ReaderAiFeature +import com.aryan.reader.shared.ReaderAiModelOption +import com.aryan.reader.shared.ReaderAiModelOptions +import com.aryan.reader.shared.ReaderAiResultState +import com.aryan.reader.shared.ReaderAutoScrollState +import com.aryan.reader.shared.ReaderCloudTtsVoices +import com.aryan.reader.shared.ReaderExtrasState +import com.aryan.reader.shared.ReaderExternalLookupAction +import com.aryan.reader.shared.ReaderTtsReadScope +import com.aryan.reader.shared.ReaderTtsReplacementPreferences +import com.aryan.reader.shared.maskedReaderAiKey +import com.aryan.reader.shared.ui.SharedMarkdownText +import com.aryan.reader.shared.ui.SharedReaderPopupLayer +import com.aryan.reader.shared.ui.SharedReaderTtsReplacementControls +import com.aryan.reader.shared.ui.SharedStableOutlinedTextField +import com.aryan.reader.shared.ui.sharedReaderPopupWidth + +@Composable +internal fun DesktopReaderBottomSheet( + title: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + SharedReaderPopupLayer(onDismiss = onDismiss) { + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .zIndex(40f) + ) { + Box( + modifier = Modifier + .matchParentSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.24f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss + ) + ) + val sheetHorizontalPadding = 24.dp + val sheetAvailableWidth = (maxWidth - sheetHorizontalPadding - sheetHorizontalPadding).coerceAtLeast(0.dp) + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = sheetHorizontalPadding, vertical = 16.dp) + .width(sharedReaderPopupWidth(sheetAvailableWidth)) + .heightIn(max = 560.dp), + shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp, bottomStart = 10.dp, bottomEnd = 10.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp, + shadowElevation = 16.dp + ) { + Column( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .width(42.dp) + .height(4.dp) + .background(MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(999.dp)) + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } + HorizontalDivider() + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + content() + } + } + } + } + } +} + +@Composable +internal fun DesktopReaderAiResultSheet( + result: ReaderAiResultState, + onDismiss: () -> Unit +) { + DesktopReaderBottomSheet( + title = result.title ?: "AI", + onDismiss = onDismiss + ) { + val errorMessage = result.errorMessage + when { + result.isLoading -> Text("Working...", color = MaterialTheme.colorScheme.onSurfaceVariant) + errorMessage != null -> Text(errorMessage, color = MaterialTheme.colorScheme.error) + else -> SharedMarkdownText(result.text) + } + } +} + +@Composable +internal fun DesktopAiByokSettingsDialog( + settings: ReaderAiByokSettings, + secureStorageAvailable: Boolean, + onSettingsChange: (ReaderAiByokSettings) -> Unit, + onDismiss: () -> Unit +) { + val sanitized = settings.sanitized() + var selectedProvider by remember { mutableStateOf("gemini") } + var pendingKey by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("AI keys and models") }, + text = { + Column( + modifier = Modifier + .heightIn(max = 640.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (!secureStorageAvailable) { + Text( + "Secure key storage is unavailable on this operating system. Keys entered here will be used for this session but will not be persisted.", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + + Text("Saved keys", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + DesktopSavedAiKeyRow( + label = "Gemini", + keyValue = sanitized.geminiKey, + onClear = { onSettingsChange(sanitized.copy(geminiKey = "", ttsModel = "")) } + ) + DesktopSavedAiKeyRow( + label = "Groq", + keyValue = sanitized.groqKey, + onClear = { onSettingsChange(sanitized.copy(groqKey = "")) } + ) + + HorizontalDivider() + + Text("Add or replace key", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { + listOf("gemini" to "Gemini", "groq" to "Groq").forEach { (provider, label) -> + FilterChip( + selected = selectedProvider == provider, + onClick = { selectedProvider = provider }, + label = { Text(label) } + ) + } + } + SharedStableOutlinedTextField( + value = pendingKey, + onValueChange = { pendingKey = it }, + label = { Text("API key") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + TextButton( + enabled = pendingKey.isNotBlank(), + onClick = { + val trimmed = pendingKey.trim() + val next = when (selectedProvider) { + "gemini" -> sanitized.copy( + geminiKey = trimmed, + ttsModel = sanitized.ttsModel.ifBlank { GEMINI_CLOUD_TTS_MODEL_ID } + ) + "groq" -> sanitized.copy(groqKey = trimmed) + else -> sanitized + } + onSettingsChange(next) + pendingKey = "" + }, + modifier = Modifier.align(Alignment.End) + ) { + Text("Save key") + } + + HorizontalDivider() + + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text("Show AI in reader", style = MaterialTheme.typography.titleMedium) + Text( + "Matches the Android hide toggle for smart dictionary, summaries, and recaps.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = !sanitized.hideReaderAiFeatures, + onCheckedChange = { enabled -> + onSettingsChange(sanitized.copy(hideReaderAiFeatures = !enabled)) + } + ) + } + + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text("Use one model for all features", style = MaterialTheme.typography.titleMedium) + Text( + "Turn this off to choose separate models per reader AI feature.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = sanitized.useOneModel, + onCheckedChange = { onSettingsChange(sanitized.copy(useOneModel = it)) } + ) + } + + if (sanitized.useOneModel) { + DesktopAiModelSelector( + title = "All AI features", + description = "Smart dictionary, summaries, and recaps all use this model.", + selectedId = sanitized.modelForAll, + onSelected = { onSettingsChange(sanitized.copy(modelForAll = it)) } + ) + } else { + DesktopAiModelSelector( + title = "Smart dictionary", + description = "Used when defining selected words or phrases.", + selectedId = sanitized.defineModel, + onSelected = { onSettingsChange(sanitized.copy(defineModel = it)) } + ) + DesktopAiModelSelector( + title = "Summaries", + description = "Used for EPUB summaries and PDF page summaries.", + selectedId = sanitized.summarizeModel, + onSelected = { onSettingsChange(sanitized.copy(summarizeModel = it)) } + ) + DesktopAiModelSelector( + title = "Recaps", + description = "Used for story recap generation.", + selectedId = sanitized.recapModel, + onSelected = { onSettingsChange(sanitized.copy(recapModel = it)) } + ) + } + + DesktopAiModelSelector( + title = "Cloud TTS", + description = "Uses the saved Gemini key. Only $GEMINI_CLOUD_TTS_MODEL is supported for now.", + selectedId = sanitized.ttsModel, + options = listOf(ReaderAiModelOption("gemini", GEMINI_CLOUD_TTS_MODEL)), + onSelected = { onSettingsChange(sanitized.copy(ttsModel = it)) } + ) + Text("Cloud TTS voice", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + ReaderCloudTtsVoices.chunked(3).forEach { rowVoices -> + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { + rowVoices.forEach { voice -> + FilterChip( + selected = sanitized.ttsSpeakerId == voice.id, + onClick = { onSettingsChange(sanitized.copy(ttsSpeakerId = voice.id)) }, + label = { + Column { + Text(voice.name, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + voice.description, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + ) + } + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Done") + } + } + ) +} + +@Composable +private fun DesktopSavedAiKeyRow( + label: String, + keyValue: String, + onClear: () -> Unit +) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(label, fontWeight = FontWeight.SemiBold) + Text( + keyValue.takeIf { it.isNotBlank() }?.let(::maskedReaderAiKey) ?: "No key saved", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + TextButton(enabled = keyValue.isNotBlank(), onClick = onClear) { + Text("Clear") + } + } +} + +@Composable +private fun DesktopAiModelSelector( + title: String, + description: String, + selectedId: String, + options: List = ReaderAiModelOptions, + onSelected: (String) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { + FilterChip( + selected = selectedId.isBlank(), + onClick = { onSelected("") }, + label = { Text("No model") } + ) + options.forEach { option -> + FilterChip( + selected = selectedId == option.id, + onClick = { onSelected(option.id) }, + label = { Text(option.label) } + ) + } + } + } +} + +@Composable +internal fun DesktopPdfExtrasPanel( + pageText: String, + recapText: String, + extrasState: ReaderExtrasState, + aiByokSettings: ReaderAiByokSettings, + externalLookupAvailable: Boolean, + cloudTtsFeatureAvailable: Boolean, + onExternalLookup: (ReaderExternalLookupAction, String) -> Unit, + onAiAction: (ReaderAiFeature, String) -> Unit, + onCloudTtsStart: (ReaderTtsReadScope) -> Unit, + onCloudTtsPauseResume: () -> Unit, + onCloudTtsStop: () -> Unit, + onCloudTtsClearCache: () -> Unit, + onAutoScrollChange: (ReaderAutoScrollState) -> Unit, + ttsReplacementPreferences: ReaderTtsReplacementPreferences, + ttsReplacementBookId: String, + onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit +) { + val settings = aiByokSettings.sanitized() + val autoScroll = extrasState.autoScroll.sanitized() + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Text("Extras", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + if (externalLookupAvailable) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { + ReaderExternalLookupAction.entries.forEach { action -> + FilterChip( + selected = false, + enabled = pageText.isNotBlank(), + onClick = { onExternalLookup(action, pageText) }, + label = { Text(action.title) } + ) + } + } + } + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text("Auto scroll", modifier = Modifier.weight(1f)) + Switch( + checked = autoScroll.enabled, + onCheckedChange = { onAutoScrollChange(autoScroll.copy(enabled = it)) } + ) + } + Slider( + value = autoScroll.speed, + onValueChange = { onAutoScrollChange(autoScroll.copy(speed = it).sanitized()) }, + valueRange = 12f..160f + ) + val ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused + if (cloudTtsFeatureAvailable) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text( + when { + extrasState.cloudTts.isLoading -> "Preparing audio" + extrasState.cloudTts.isPaused -> "Paused" + extrasState.cloudTts.isPlaying -> "Reading" + settings.isCloudTtsAvailable -> "Cloud TTS ready" + else -> "Cloud TTS needs Gemini" + }, + fontWeight = FontWeight.SemiBold + ) + extrasState.cloudTts.errorMessage?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + val statusMessage = extrasState.cloudTts.progress.currentPositionLabel + ?: extrasState.cloudTts.statusMessage?.takeIf { it.isNotBlank() } + statusMessage?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + TextButton( + enabled = settings.isCloudTtsAvailable || ttsBusy, + onClick = { + if (ttsBusy) { + onCloudTtsStop() + } else { + onCloudTtsStart(ReaderTtsReadScope.BOOK) + } + } + ) { + Text(if (ttsBusy) "Stop" else "Read") + } + } + if (extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { + TextButton(onClick = onCloudTtsPauseResume) { + Text(if (extrasState.cloudTts.isPaused) "Resume" else "Pause") + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { + TextButton( + enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(), + onClick = { onCloudTtsStart(ReaderTtsReadScope.PAGE) } + ) { + Text("Page") + } + TextButton( + enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(), + onClick = { onCloudTtsStart(ReaderTtsReadScope.BOOK) } + ) { + Text("From here") + } + } + val cacheSummary = extrasState.cloudTts.cacheSummary + if (cacheSummary.hasCachedAudio) { + Text( + "Cache: ${cacheSummary.currentVoiceLabel}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (cacheSummary.hasCurrentVoiceCachedAudio) { + TextButton(onClick = onCloudTtsClearCache) { + Text("Clear voice cache") + } + } + } + } + SharedReaderTtsReplacementControls( + preferences = ttsReplacementPreferences, + bookId = ttsReplacementBookId, + onPreferencesChange = onTtsReplacementPreferencesChange + ) + if (settings.areReaderAiFeaturesAvailable) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { + TextButton( + enabled = pageText.isNotBlank() && !extrasState.aiResult.isLoading, + onClick = { onAiAction(ReaderAiFeature.SUMMARIZE, pageText) } + ) { + Text("Summarize page") + } + TextButton( + enabled = recapText.isNotBlank() && !extrasState.aiResult.isLoading, + onClick = { onAiAction(ReaderAiFeature.RECAP, recapText) } + ) { + Text("Recap") + } + } + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTextures.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTextures.kt new file mode 100644 index 0000000..c3adec6 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTextures.kt @@ -0,0 +1,107 @@ +package com.aryan.reader.desktop + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import com.aryan.reader.shared.ReaderTexture +import com.aryan.reader.shared.ReaderTextureFilePrefix +import java.io.ByteArrayInputStream +import java.io.File +import java.util.Base64 +import java.util.Locale +import javax.imageio.ImageIO + +internal object DesktopReaderTextures { + private val bytesCache = mutableMapOf() + private val dataUriCache = mutableMapOf() + private val imageCache = mutableMapOf() + private val importExtensions = setOf("jpg", "jpeg", "png", "webp", "gif", "bmp") + + fun importedTextureIds(): List { + return readerTextureDirectory() + .listFiles { file -> file.isFile && file.extension.lowercase(Locale.ROOT) in importExtensions } + ?.sortedBy { it.name.lowercase(Locale.ROOT) } + ?.map { ReaderTextureFilePrefix + it.absolutePath } + .orEmpty() + } + + fun importTexture(source: File): String? { + if (!source.isFile) return null + val extension = source.extension.lowercase(Locale.ROOT) + .takeIf { it in importExtensions } + ?: return null + val safeName = source.nameWithoutExtension + .replace(Regex("[^A-Za-z0-9._-]+"), "_") + .trim('_') + .ifBlank { "texture" } + val directory = readerTextureDirectory().apply { mkdirs() } + val target = File(directory, "texture_${System.currentTimeMillis()}_$safeName.$extension") + return runCatching { + source.copyTo(target, overwrite = false) + val textureId = ReaderTextureFilePrefix + target.absolutePath + bytesCache.remove(textureId) + dataUriCache.remove(textureId) + imageCache.remove(textureId) + textureId + }.getOrNull() + } + + fun dataUriFor(textureId: String): String? { + return dataUriCache.getOrPut(textureId) { + val bytes = bytesFor(textureId) ?: return@getOrPut null + val extension = textureExtension(textureId) + "data:${imageMimeTypeForExtension(extension)};base64," + + Base64.getEncoder().encodeToString(bytes) + } + } + + fun imageBitmapFor(textureId: String?): ImageBitmap? { + val id = textureId ?: return null + return imageCache.getOrPut(id) { + val bytes = bytesFor(id) ?: return@getOrPut null + runCatching { + ImageIO.read(ByteArrayInputStream(bytes))?.toComposeImageBitmap() + }.getOrNull() + } + } + + private fun bytesFor(textureId: String): ByteArray? { + return bytesCache.getOrPut(textureId) { + if (textureId.startsWith(ReaderTextureFilePrefix)) { + File(textureId.removePrefix(ReaderTextureFilePrefix)).takeIf { it.isFile }?.readBytes() + } else { + val texture = ReaderTexture.entries.firstOrNull { it.id == textureId } ?: return@getOrPut null + val classLoader = Thread.currentThread().contextClassLoader ?: DesktopReaderTextures::class.java.classLoader + classLoader + ?.getResourceAsStream(texture.assetPath) + ?.use { it.readBytes() } + ?: DesktopReaderTextures::class.java.classLoader + ?.getResourceAsStream(texture.assetPath) + ?.use { it.readBytes() } + } + } + } + + private fun textureExtension(textureId: String): String { + if (textureId.startsWith(ReaderTextureFilePrefix)) { + return File(textureId.removePrefix(ReaderTextureFilePrefix)).extension + } + return ReaderTexture.entries.firstOrNull { it.id == textureId } + ?.assetPath + ?.substringAfterLast('.', "png") + ?: "png" + } + + private fun readerTextureDirectory(): File { + return File(desktopUserDataRoot(), "reader_textures") + } +} + +private fun imageMimeTypeForExtension(extension: String): String { + return when (extension.lowercase(Locale.ROOT)) { + "jpg", "jpeg" -> "image/jpeg" + "webp" -> "image/webp" + "gif" -> "image/gif" + "bmp" -> "image/bmp" + else -> "image/png" + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTypography.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTypography.kt new file mode 100644 index 0000000..99779ee --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopReaderTypography.kt @@ -0,0 +1,42 @@ +package com.aryan.reader.desktop + +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.platform.Font as DesktopFont +import com.aryan.reader.shared.CustomFontItem +import com.aryan.reader.shared.reader.ReaderPage +import com.aryan.reader.shared.reader.ReaderSettings +import java.io.File + +internal fun ReaderSettings.toDesktopReaderFontFamily(): FontFamily { + customFontPath?.takeIf { it.isNotBlank() }?.let { path -> + runCatching { FontFamily(DesktopFont(File(path))) }.getOrNull()?.let { return it } + } + return fontFamily.toComposeFontFamily() +} + +private fun String.toComposeFontFamily(): FontFamily { + return when (this) { + "Serif" -> FontFamily.Serif + "Sans" -> FontFamily.SansSerif + "Mono" -> FontFamily.Monospace + else -> FontFamily.Default + } +} + +internal fun List.samePageLayoutAs(other: List): Boolean { + if (size != other.size) return false + return indices.all { index -> + val left = this[index] + val right = other[index] + left.pageIndex == right.pageIndex && + left.chapterIndex == right.chapterIndex && + left.startOffset == right.startOffset && + left.endOffset == right.endOffset && + left.text.length == right.text.length && + left.semanticBlocks.size == right.semanticBlocks.size + } +} + +internal fun CustomFontItem.toDesktopPreviewFontFamily(): FontFamily? { + return runCatching { FontFamily(DesktopFont(File(path))) }.getOrNull() +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt index 7869128..c432c9f 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt @@ -3,9 +3,6 @@ package com.aryan.reader.desktop import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.Canvas import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -16,18 +13,11 @@ import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.gestures.calculateCentroid -import androidx.compose.foundation.gestures.calculateZoom import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight @@ -39,29 +29,18 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.NavigateBefore import androidx.compose.material.icons.automirrored.filled.NavigateNext import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.ZoomIn import androidx.compose.material.icons.filled.ZoomOut import androidx.compose.material3.AlertDialog @@ -79,7 +58,6 @@ import androidx.compose.material3.Slider import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Surface -import androidx.compose.material3.Switch import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -102,84 +80,38 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.ColorMatrix -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.ImageShader -import androidx.compose.ui.graphics.ShaderBrush -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.TileMode -import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.PathParser -import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isCtrlPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type -import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.changedToUp import androidx.compose.ui.input.pointer.isPrimaryPressed -import androidx.compose.ui.input.pointer.isCtrlPressed as isPointerCtrlPressed import androidx.compose.ui.input.pointer.isSecondaryPressed import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.input.pointer.positionChanged import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.platform.Font as DesktopFont import androidx.compose.ui.text.rememberTextMeasurer -import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.isSpecified -import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex -import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPlacement -import androidx.compose.ui.window.WindowPosition -import androidx.compose.ui.window.WindowState -import androidx.compose.ui.window.application -import androidx.compose.ui.window.rememberWindowState -import com.aryan.reader.paginatedreader.SemanticBlock -import com.aryan.reader.paginatedreader.SemanticFlexContainer -import com.aryan.reader.paginatedreader.SemanticHeader -import com.aryan.reader.paginatedreader.SemanticImage -import com.aryan.reader.paginatedreader.SemanticList -import com.aryan.reader.paginatedreader.SemanticListItem -import com.aryan.reader.paginatedreader.SemanticMath -import com.aryan.reader.paginatedreader.SemanticParagraph -import com.aryan.reader.paginatedreader.SemanticSpacer -import com.aryan.reader.paginatedreader.SemanticTable -import com.aryan.reader.paginatedreader.SemanticTextBlock -import com.aryan.reader.paginatedreader.SemanticWrappingBlock import com.aryan.reader.shared.AppAction -import com.aryan.reader.shared.AppContrastOption -import com.aryan.reader.shared.AppThemeMode import com.aryan.reader.shared.BannerMessage import com.aryan.reader.shared.BookItem import com.aryan.reader.shared.BookShelfRef @@ -190,18 +122,12 @@ import com.aryan.reader.shared.FileType import com.aryan.reader.shared.ImportedBookFile import com.aryan.reader.shared.LibraryAction import com.aryan.reader.shared.PdfDisplayMode -import com.aryan.reader.shared.PdfTocEntry -import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL -import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL_ID import com.aryan.reader.shared.ReaderAiByokSettings import com.aryan.reader.shared.ReaderAiFeature -import com.aryan.reader.shared.ReaderAiModelOption -import com.aryan.reader.shared.ReaderAiModelOptions import com.aryan.reader.shared.ReaderAiResultState import com.aryan.reader.shared.ReaderAction import com.aryan.reader.shared.ReaderAutoScrollState import com.aryan.reader.shared.ReaderCloudTtsState -import com.aryan.reader.shared.ReaderCloudTtsVoices import com.aryan.reader.shared.ReaderContextExtractor import com.aryan.reader.shared.ReaderExtrasState import com.aryan.reader.shared.ReaderExternalLookupAction @@ -209,9 +135,6 @@ import com.aryan.reader.shared.ReaderFeatureSurface import com.aryan.reader.shared.ReaderHighlightPalette import com.aryan.reader.shared.ReaderLocator import com.aryan.reader.shared.ReaderPlatform -import com.aryan.reader.shared.ReaderTexture -import com.aryan.reader.shared.ReaderTextureFilePrefix -import com.aryan.reader.shared.ReaderTheme import com.aryan.reader.shared.ReaderTtsCacheSummary import com.aryan.reader.shared.ReaderToolbarPreferences import com.aryan.reader.shared.ReaderTtsChunk @@ -222,7 +145,6 @@ import com.aryan.reader.shared.ReaderTtsReplacementPreferences import com.aryan.reader.shared.SearchHighlightMode import com.aryan.reader.shared.SharedFileCapabilities import com.aryan.reader.shared.SharedFeaturePolicy -import com.aryan.reader.shared.SharedFolderPathResolver import com.aryan.reader.shared.SharedImportOutcomeCounts import com.aryan.reader.shared.SharedImportPlanner import com.aryan.reader.shared.SharedLibraryEditor @@ -238,22 +160,14 @@ import com.aryan.reader.shared.Shelf import com.aryan.reader.shared.ShelfRecord import com.aryan.reader.shared.ShelfType import com.aryan.reader.shared.SmartCollectionDefinition -import com.aryan.reader.shared.SmartField -import com.aryan.reader.shared.SmartOperator -import com.aryan.reader.shared.SmartRule -import com.aryan.reader.shared.Tag import com.aryan.reader.shared.UserHighlight import com.aryan.reader.shared.externalLookupUrl -import com.aryan.reader.shared.maskedReaderAiKey import com.aryan.reader.shared.sharedSettingsHubModel import com.aryan.reader.shared.withTtsReplacements import com.aryan.reader.shared.pdf.PdfAnnotationKind import com.aryan.reader.shared.pdf.PdfInkTool -import com.aryan.reader.shared.pdf.PdfNormalizedPoint import com.aryan.reader.shared.pdf.PdfPageBounds import com.aryan.reader.shared.pdf.PdfPagePoint -import com.aryan.reader.shared.pdf.PdfSelectionGeometry -import com.aryan.reader.shared.pdf.PdfTextCharBounds import com.aryan.reader.shared.pdf.PdfVisiblePageLayout import com.aryan.reader.shared.pdf.PdfZoomSpec import com.aryan.reader.shared.pdf.SharedPdfAnnotation @@ -263,7 +177,6 @@ import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer import com.aryan.reader.shared.pdf.SharedPdfBookmarkSerializer import com.aryan.reader.shared.pdf.SharedPdfEmbeddedAnnotation import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette -import com.aryan.reader.shared.pdf.SharedPdfInkRenderer import com.aryan.reader.shared.pdf.SharedPdfJumpHistory import com.aryan.reader.shared.pdf.SharedPdfReaderAction import com.aryan.reader.shared.pdf.SharedPdfReaderState @@ -282,8 +195,6 @@ import com.aryan.reader.shared.pdf.mostVisiblePdfPageIndex import com.aryan.reader.shared.pdf.pdfVerticalPageGapDp import com.aryan.reader.shared.pdf.reduce import com.aryan.reader.shared.pdf.sharedPdfTextStyle -import com.aryan.reader.shared.pdf.sharedPdfStrokePercent -import com.aryan.reader.shared.pdf.sharedPdfStrokeWidthRange import com.aryan.reader.shared.pdf.toAnnotation import com.aryan.reader.shared.pdf.updateCurrentSharedPdfTextStyle import com.aryan.reader.shared.pdf.withBounds @@ -291,9 +202,7 @@ import com.aryan.reader.shared.pdf.withSharedPdfTextStyle import com.aryan.reader.shared.pdf.withStyle import com.aryan.reader.shared.pdf.withText import com.aryan.reader.shared.reader.ReaderEngine -import com.aryan.reader.shared.reader.ReaderLayoutSignature import com.aryan.reader.shared.reader.ReaderLinkTarget -import com.aryan.reader.shared.reader.ReaderPage import com.aryan.reader.shared.reader.ReaderReadingMode import com.aryan.reader.shared.reader.ReaderSettings import com.aryan.reader.shared.reader.ReaderSessionState @@ -302,7 +211,6 @@ import com.aryan.reader.shared.reader.SharedEpubChapter import com.aryan.reader.shared.reader.SharedEpubPaginationCache import com.aryan.reader.shared.reader.SharedEpubMetadataEditor import com.aryan.reader.shared.reader.SharedEpubMetadataUpdate -import com.aryan.reader.shared.reader.SharedReaderTextAlign import com.aryan.reader.shared.reader.SharedJvmBookLoader import com.aryan.reader.shared.reader.ReaderViewportSpec import com.aryan.reader.shared.reader.SharedMeasuredEpubPaginator @@ -320,7 +228,6 @@ import com.aryan.reader.shared.ui.NonReaderLibraryTab import com.aryan.reader.shared.ui.ReaderContentNavigationTarget import com.aryan.reader.shared.ui.ReaderContentRenderPlan import com.aryan.reader.shared.ui.ReaderMinimalSlider -import com.aryan.reader.shared.ui.SharedNativeReaderLinkClick import com.aryan.reader.shared.ui.SharedNativeReaderSelectionAction import com.aryan.reader.shared.ui.SharedNativePaginatedReader import com.aryan.reader.shared.ui.ReaderWorkspaceShell @@ -334,36 +241,27 @@ import com.aryan.reader.shared.ui.SharedBookInfoDialog import com.aryan.reader.shared.ui.SharedConfirmDialog import com.aryan.reader.shared.ui.SharedCustomFontsScreen import com.aryan.reader.shared.ui.SharedHelpFeedbackScreen -import com.aryan.reader.shared.ui.SharedHomeScreen -import com.aryan.reader.shared.ui.SharedLibraryScreen -import com.aryan.reader.shared.ui.SharedMarkdownText import com.aryan.reader.shared.ui.SharedOpdsScreen import com.aryan.reader.shared.ui.SharedSettingsHub import com.aryan.reader.shared.ui.SharedPdfAnnotationOverlay import com.aryan.reader.shared.ui.SharedPdfAnnotationToolDock import com.aryan.reader.shared.ui.SharedPdfEmbeddedAnnotationOverlay import com.aryan.reader.shared.ui.SharedPdfHighlighterPaletteEditor -import com.aryan.reader.shared.ui.SharedHsvColorPickerDialog import com.aryan.reader.shared.ui.SharedPdfInlineTextEditorOverlay import com.aryan.reader.shared.ui.SharedPdfPageNumberOverlay import com.aryan.reader.shared.ui.SharedPdfRichTextHiddenInput import com.aryan.reader.shared.ui.SharedPdfRichTextLayer import com.aryan.reader.shared.ui.SharedPdfTextAnnotationDock import com.aryan.reader.shared.ui.SharedPdfTextBoxEditorOverlay -import com.aryan.reader.shared.ui.SharedPdfTextStyleControls -import com.aryan.reader.shared.ui.SharedReaderPopupLayer import com.aryan.reader.shared.ui.SharedReaderScreen import com.aryan.reader.shared.ui.SharedStableOutlinedTextField import com.aryan.reader.shared.ui.SharedReaderThemeControls -import com.aryan.reader.shared.ui.SharedReaderTtsReplacementControls import com.aryan.reader.shared.ui.SharedPdfVerticalScrollbar import com.aryan.reader.shared.ui.SharedReaderVerticalScrollbar -import com.aryan.reader.shared.ui.SharedShelvesScreen import com.aryan.reader.shared.ui.SharedSupportProjectScreen import com.aryan.reader.shared.ui.SharedTextInputDialog import com.aryan.reader.shared.ui.pdfReaderWorkspaceModel import com.aryan.reader.shared.ui.sharedAcceleratedLazyWheelScroll -import com.aryan.reader.shared.ui.sharedReaderPopupWidth import com.aryan.reader.shared.ui.sharedPdfEmbeddedHitTest import com.aryan.reader.shared.ui.sharedPdfHitTest import com.aryan.reader.shared.ui.toSharedPdfPoint @@ -388,82 +286,18 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import java.awt.Desktop -import java.awt.Container -import java.awt.EventQueue -import java.awt.FileDialog -import java.awt.Frame -import java.awt.GraphicsDevice import java.awt.Component -import java.awt.KeyEventDispatcher -import java.awt.KeyboardFocusManager -import java.awt.Rectangle -import java.awt.Toolkit -import java.awt.datatransfer.DataFlavor -import java.awt.dnd.DnDConstants -import java.awt.dnd.DropTarget -import java.awt.dnd.DropTargetAdapter -import java.awt.dnd.DropTargetDragEvent -import java.awt.dnd.DropTargetEvent -import java.awt.dnd.DropTargetDropEvent import java.awt.event.KeyEvent as AwtKeyEvent -import java.io.ByteArrayInputStream import java.io.File -import java.net.URI -import java.net.URLDecoder -import java.net.URLEncoder -import java.util.Base64 -import java.util.Locale import java.util.UUID import java.util.concurrent.atomic.AtomicReference -import javax.imageio.ImageIO -import javax.swing.JFileChooser import kotlin.math.abs -import kotlin.math.exp import kotlin.math.max import kotlin.math.roundToInt -private data class DesktopReaderOpening( - val requestId: Long, - val bookId: String, - val title: String, - val formatLabel: String, - val returnTab: SharedAppTab -) - -private sealed interface DesktopReaderOpenResult { - val opening: DesktopReaderOpening - val book: BookItem - - data class Pdf( - override val opening: DesktopReaderOpening, - override val book: BookItem, - val document: DesktopPdfDocument - ) : DesktopReaderOpenResult - - data class Text( - override val opening: DesktopReaderOpening, - override val book: BookItem, - val session: ReaderSessionState - ) : DesktopReaderOpenResult - - data class Failure( - override val opening: DesktopReaderOpening, - override val book: BookItem, - val message: String - ) : DesktopReaderOpenResult -} - fun main() { launchEpistemeDesktopApplication() } - private fun desktopEmptyReaderBook(): SharedEpubBook { return SharedEpubBook( id = "desktop_empty_reader", @@ -487,446 +321,9 @@ private fun SharedLibrarySnapshot.withDesktopDefaults(): SharedLibrarySnapshot { } } -internal fun launchEpistemeDesktopApplication(startupSplash: DesktopStartupSplash? = null) { - configureComposeSwingInterop() - application { - val windowDefaults = remember { epistemeDesktopWindowDefaults() } - val windowStateStore = remember { DesktopWindowStateStore() } - val restoredWindowState = remember { windowStateStore.load() } - val windowState = rememberWindowState( - placement = restoredWindowState?.toWindowPlacement() - ?: DesktopWindowStateSnapshot.default().toWindowPlacement(), - position = restoredWindowState?.toWindowPosition() ?: WindowPosition(Alignment.Center), - size = restoredWindowState?.toWindowSize(windowDefaults.defaultSize) ?: windowDefaults.defaultSize - ) - var readerFullscreen by remember { mutableStateOf(false) } - DesktopWindowStatePersistenceEffect( - windowState = windowState, - store = windowStateStore, - enabled = !readerFullscreen - ) - Window( - onCloseRequest = ::exitApplication, - title = windowDefaults.title, - state = windowState, - icon = painterResource(windowDefaults.iconResourcePath) - ) { - DisposableEffect(window, windowDefaults.minimumSize) { - window.minimumSize = windowDefaults.minimumSize - onDispose { - startupSplash?.close() - } - } - EpistemeDesktopStartupGate( - window = window, - startupSplash = startupSplash, - appWindowPlacement = windowState.placement, - readerFullscreen = readerFullscreen, - onReaderFullscreenChange = { readerFullscreen = it } - ) - } - } -} - -@Composable -private fun EpistemeDesktopStartupGate( - window: Component?, - startupSplash: DesktopStartupSplash?, - appWindowPlacement: WindowPlacement, - readerFullscreen: Boolean, - onReaderFullscreenChange: (Boolean) -> Unit -) { - var showApp by remember { mutableStateOf(false) } - - DisposableEffect(startupSplash) { - onDispose { - startupSplash?.close() - } - } - - if (showApp) { - EpistemeDesktopApp( - window = window, - appWindowPlacement = appWindowPlacement, - readerFullscreen = readerFullscreen, - onReaderFullscreenChange = onReaderFullscreenChange - ) - } else { - EpistemeDesktopStartupScreen(window = window) - } - - LaunchedEffect(Unit) { - withFrameNanos { } - startupSplash?.close() - delay(80L) - showApp = true - } -} - -@Composable -private fun EpistemeDesktopStartupScreen(window: Component?) { - val appTitle = remember { epistemeDesktopWindowDefaults().title } - SharedAppTheme( - appThemeMode = AppThemeMode.SYSTEM, - appContrastOption = AppContrastOption.STANDARD, - appTextDimFactorLight = 1.0f, - appTextDimFactorDark = 1.0f, - appSeedColor = DesktopDefaultAppSeedColor - ) { - EpistemeDesktopWindowChromeEffect( - window = window, - captionColor = MaterialTheme.colorScheme.surface, - textColor = MaterialTheme.colorScheme.onSurface, - borderColor = MaterialTheme.colorScheme.background - ) - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(14.dp), - modifier = Modifier.padding(32.dp) - ) { - Image( - painter = painterResource(EpistemeDesktopWindowIconResource), - contentDescription = null, - modifier = Modifier.size(64.dp) - ) - Text( - text = appTitle, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground - ) - Text( - text = "Opening your library", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - CircularProgressIndicator( - modifier = Modifier.size(28.dp), - strokeWidth = 3.dp - ) - } - } - } -} - -internal const val ComposeInteropBlendingProperty = "compose.interop.blending" -internal const val ComposeInteropBlendingEnabled = "true" -private const val DesktopWindowStatePersistDebounceMillis = 450L -private val DesktopDefaultAppSeedColor = Color(0xFFFFB300) - -internal fun configureComposeSwingInterop() { - // Must run before Compose creates the desktop window. Vertical EPUB embeds a Swing-backed - // JCEF WebView, and current Compose interop can leave a stale black native rectangle after - // that reader surface is removed unless interop blending is enabled. - if (System.getProperty(ComposeInteropBlendingProperty).isNullOrBlank()) { - System.setProperty(ComposeInteropBlendingProperty, ComposeInteropBlendingEnabled) - } -} - -@Composable -private fun DesktopWindowStatePersistenceEffect( - windowState: WindowState, - store: DesktopWindowStateStore, - enabled: Boolean -) { - val persistenceEnabled by rememberUpdatedState(enabled) - LaunchedEffect(windowState, store) { - snapshotFlow { DesktopWindowStateSnapshot.fromWindowState(windowState) } - .distinctUntilChanged() - .collectLatest { snapshot -> - if (!persistenceEnabled || snapshot == null) return@collectLatest - delay(DesktopWindowStatePersistDebounceMillis) - if (persistenceEnabled) { - withContext(Dispatchers.IO) { - store.save(snapshot) - } - } - } - } -} - -@Composable -private fun DesktopReaderFullscreenEffect( - window: Component?, - enabled: Boolean -) { - val awtWindow = window as? java.awt.Window ?: return - val fullscreenSnapshot = remember(awtWindow) { - AtomicReference() - } - val pendingExitSnapshot = remember(awtWindow) { - AtomicReference() - } - - LaunchedEffect(awtWindow, enabled) { - if (!enabled && fullscreenSnapshot.get() == null) { - return@LaunchedEffect - } - if (enabled) { - EventQueue.invokeLater { - awtWindow.captureDesktopReaderFullscreenSnapshot(fullscreenSnapshot) - } - } - delay(if (enabled) 180L else 80L) - EventQueue.invokeLater { - if (enabled) { - pendingExitSnapshot.set(null) - awtWindow.enterDesktopReaderFullscreen(fullscreenSnapshot) - } else { - awtWindow.beginDesktopReaderFullscreenExit(fullscreenSnapshot, pendingExitSnapshot) - } - } - delay(120L) - EventQueue.invokeLater { - if (!enabled) { - awtWindow.restoreDesktopReaderFullscreenExitBounds(pendingExitSnapshot.getAndSet(null)) - } - awtWindow.refreshDesktopReaderWindowFocus() - } - } - - DisposableEffect(awtWindow) { - onDispose { - EventQueue.invokeLater { - awtWindow.beginDesktopReaderFullscreenExit(fullscreenSnapshot) - } - } - } -} - -private data class DesktopReaderFullscreenSnapshot( - val device: GraphicsDevice?, - val frameState: Int?, - val frameBounds: Rectangle?, - val alwaysOnTop: Boolean -) - -private fun java.awt.Window.enterDesktopReaderFullscreen( - snapshotRef: AtomicReference -) { - if (!isDisplayable) return - focusableWindowState = true - captureDesktopReaderFullscreenSnapshot(snapshotRef) - applyDesktopReaderBorderlessFullscreen(snapshotRef.get()) - refreshDesktopReaderWindowFocus() -} - -private fun java.awt.Window.captureDesktopReaderFullscreenSnapshot( - snapshotRef: AtomicReference -) { - snapshotRef.compareAndSet( - null, - DesktopReaderFullscreenSnapshot( - device = graphicsConfiguration?.device, - frameState = (this as? Frame)?.extendedState, - frameBounds = bounds.desktopReaderCopy(), - alwaysOnTop = isAlwaysOnTop - ) - ) -} - -private fun java.awt.Window.applyDesktopReaderBorderlessFullscreen(snapshot: DesktopReaderFullscreenSnapshot?) { - if (!isDisplayable) return - val device = snapshot?.device ?: graphicsConfiguration?.device - val frame = this as? Frame - focusableWindowState = true - if (frame != null) { - frame.extendedState = frame.extendedState and Frame.ICONIFIED.inv() and Frame.MAXIMIZED_BOTH.inv() - frame.state = Frame.NORMAL - } - device?.let { fullscreenDevice -> - runCatching { - if (fullscreenDevice.fullScreenWindow == this) { - fullscreenDevice.fullScreenWindow = null - } - } - } - runCatching { - bounds = device?.desktopReaderScreenBounds() ?: graphicsConfiguration?.bounds?.desktopReaderCopy() ?: bounds - } - runCatching { - isAlwaysOnTop = true - } -} - -private fun java.awt.Window.beginDesktopReaderFullscreenExit( - snapshotRef: AtomicReference, - pendingExitSnapshotRef: AtomicReference? = null -) { - val snapshot = snapshotRef.getAndSet(null) - if (snapshot == null) return - pendingExitSnapshotRef?.set(snapshot) - val device = snapshot.device ?: graphicsConfiguration?.device - device?.let { fullscreenDevice -> - runCatching { - if (fullscreenDevice.fullScreenWindow == this) { - fullscreenDevice.fullScreenWindow = null - } - } - } - runCatching { - isAlwaysOnTop = snapshot.alwaysOnTop - } - if (!isVisible) { - isVisible = true - } - (this as? Frame)?.let { frame -> - frame.state = Frame.NORMAL - frame.extendedState = Frame.NORMAL - } -} - -private fun java.awt.Window.restoreDesktopReaderFullscreenExitBounds(snapshot: DesktopReaderFullscreenSnapshot?) { - if (snapshot == null) return - runCatching { - isAlwaysOnTop = snapshot.alwaysOnTop - } - if (!isVisible) { - isVisible = true - } - val frame = this as? Frame - if (frame == null) { - bounds = snapshot.frameBounds.desktopReaderRestoreBounds(snapshot.device) - return - } - val restoreMaximized = snapshot.frameState?.let { state -> - state and Frame.MAXIMIZED_BOTH == Frame.MAXIMIZED_BOTH - } == true - frame.extendedState = Frame.NORMAL - frame.state = Frame.NORMAL - frame.bounds = snapshot.frameBounds.desktopReaderRestoreBounds(snapshot.device) - if (restoreMaximized) { - frame.maximizedBounds = snapshot.device?.desktopReaderUsableBounds() - EventQueue.invokeLater { - if (frame.isDisplayable && frame.isShowing) { - frame.extendedState = Frame.MAXIMIZED_BOTH - } - } - } - frame.toFront() - frame.requestFocus() - frame.validate() -} - -private fun GraphicsDevice.desktopReaderScreenBounds(): Rectangle { - return defaultConfiguration.bounds.desktopReaderCopy() -} - -private fun GraphicsDevice.desktopReaderUsableBounds(): Rectangle? { - val configuration = defaultConfiguration ?: return null - return runCatching { - val bounds = configuration.bounds - val insets = Toolkit.getDefaultToolkit().getScreenInsets(configuration) - Rectangle( - bounds.x + insets.left, - bounds.y + insets.top, - (bounds.width - insets.left - insets.right).coerceAtLeast(1), - (bounds.height - insets.top - insets.bottom).coerceAtLeast(1) - ) - }.getOrNull() -} - -private fun Rectangle?.desktopReaderRestoreBounds(device: GraphicsDevice?): Rectangle { - val usableBounds = device?.desktopReaderUsableBounds() - ?: return this?.desktopReaderCopy() ?: Rectangle(80, 80, 1280, 820) - val source = this ?: usableBounds - val width = source.width.coerceIn(640, usableBounds.width.coerceAtLeast(640)) - val height = source.height.coerceIn(480, usableBounds.height.coerceAtLeast(480)) - val looksFullscreen = source.x <= usableBounds.x && - source.y <= usableBounds.y && - source.width >= usableBounds.width && - source.height >= usableBounds.height - if (looksFullscreen) { - return usableBounds.desktopReaderCopy() - } - val maxX = (usableBounds.x + usableBounds.width - width).coerceAtLeast(usableBounds.x) - val maxY = (usableBounds.y + usableBounds.height - height).coerceAtLeast(usableBounds.y) - return Rectangle( - source.x.coerceIn(usableBounds.x, maxX), - source.y.coerceIn(usableBounds.y, maxY), - width, - height - ) -} - -private fun Rectangle.desktopReaderCopy(): Rectangle { - return Rectangle(x, y, width, height) -} - -private fun java.awt.Window.refreshDesktopReaderWindowFocus() { - if (!isDisplayable) return - if (this is Frame && extendedState and Frame.ICONIFIED != 0) { - extendedState = extendedState and Frame.ICONIFIED.inv() - } - toFront() - requestFocus() - requestFocusInWindow() - focusOwner?.requestFocus() -} - -@Composable -private fun DesktopReaderFullscreenKeyEffect( - enabled: Boolean, - onKeyPressed: (AwtKeyEvent) -> Boolean -) { - val currentOnKeyPressed by rememberUpdatedState(onKeyPressed) - DisposableEffect(enabled) { - if (!enabled) { - onDispose {} - } else { - val focusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager() - val dispatcher = KeyEventDispatcher { event -> - val modalWindowActive = focusManager.activeWindow?.isDesktopReaderModalWindow() == true - !modalWindowActive && event.id == AwtKeyEvent.KEY_PRESSED && currentOnKeyPressed(event) - } - focusManager.addKeyEventDispatcher(dispatcher) - onDispose { - focusManager.removeKeyEventDispatcher(dispatcher) - } - } - } -} - -private fun java.awt.Window.isDesktopReaderModalWindow(): Boolean { - val windowTitle = when (this) { - is java.awt.Dialog -> title - is Frame -> title - else -> "" - } - return name?.startsWith(DesktopReaderModalWindowNamePrefix) == true || - windowTitle.startsWith("Reader Panel") || - windowTitle.startsWith("Reader Popup") -} - -private const val DesktopReaderModalWindowNamePrefix = "shared-reader-modal:" - -internal data class DesktopWebViewRuntimeState( - val initialized: Boolean = false, - val restartRequired: Boolean = false, - val downloadProgress: Float = -1f, - val errorMessage: String? = null -) - -internal fun shouldRequestDesktopWebViewRuntime(readerSurface: ReaderFeatureSurface?): Boolean { - return readerSurface == ReaderFeatureSurface.TEXT_READER -} - -internal fun shouldStartDesktopWebViewRuntime( - requested: Boolean, - state: DesktopWebViewRuntimeState -): Boolean { - return requested && !state.initialized && !state.restartRequired && state.errorMessage == null -} - @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun EpistemeDesktopApp( +internal fun EpistemeDesktopApp( window: Component? = null, appWindowPlacement: WindowPlacement, readerFullscreen: Boolean, @@ -1957,19 +1354,49 @@ private fun EpistemeDesktopApp( fun schedulePdfEmbeddedAnnotationsLoad(document: DesktopPdfDocument) { scope.launch { delay(650L) - if (activePdfDocument?.path != document.path) return@launch + if (activePdfDocument?.handleId != document.handleId) return@launch val annotations = withContext(Dispatchers.IO) { DesktopPdfium.loadEmbeddedAnnotations(document) } - if (activePdfDocument?.path == document.path) { + if (activePdfDocument?.handleId == document.handleId) { document.replaceEmbeddedAnnotations(annotations) } } } + fun exitReaderTo(tab: SharedAppTab) { + val wasPdfReaderVisible = selectedTab == SharedAppTab.READER && + openingReader == null && + activePdfDocument != null + val detachedPdfDocument = activePdfDocument + openingReader = null + activePdfDocument = null + selectedTab = tab + if (!wasPdfReaderVisible) { + detachedPdfDocument?.close() + } + } + + fun selectAppTab(tab: SharedAppTab) { + val nextTab = if (tab == SharedAppTab.CATALOGS && !featurePolicy.opdsCatalogs) { + SharedAppTab.HOME + } else { + tab + } + if (nextTab == SharedAppTab.SETTINGS) { + settingsQuery = "" + settingsDestination = SharedSettingsDestination.ROOT + } + if (nextTab != SharedAppTab.READER && (selectedTab == SharedAppTab.READER || activePdfDocument != null)) { + exitReaderTo(nextTab) + } else { + selectedTab = nextTab + } + } + fun applyReaderOpenResult(result: DesktopReaderOpenResult) { if (openingReader?.requestId != result.opening.requestId) { - if (result is DesktopReaderOpenResult.Pdf && activePdfDocument?.path != result.document.path) { + if (result is DesktopReaderOpenResult.Pdf && activePdfDocument?.handleId != result.document.handleId) { result.document.close() } return @@ -1978,12 +1405,15 @@ private fun EpistemeDesktopApp( openingReader = null when (result) { is DesktopReaderOpenResult.Failure -> { + activePdfDocument?.close() + activePdfDocument = null + activeReaderBookId = null selectedTab = result.opening.returnTab updateState(state.withBanner(result.message, isError = true)) } is DesktopReaderOpenResult.Pdf -> { - activePdfDocument?.takeIf { it.path != result.document.path }?.close() + activePdfDocument?.takeIf { it.handleId != result.document.handleId }?.close() activePdfDocument = result.document activeReaderBookId = result.book.id recordBookOpened(result.book.id) @@ -2195,7 +1625,7 @@ private fun EpistemeDesktopApp( fun importAndOpenBook() { val file = chooseBookFile() ?: return - val importedFile = file.toImportedBookFile() + val importedFile = file.toDesktopImportedBookFile() val type = importedFile.desktopFileType() if (type !in DesktopBookFileTypes) { updateState( @@ -2214,7 +1644,7 @@ private fun EpistemeDesktopApp( fun importAndOpenPdf() { val file = choosePdfFile() ?: return - importFiles(listOf(file.toImportedBookFile())) { importedBooks -> + importFiles(listOf(file.toDesktopImportedBookFile())) { importedBooks -> importedBooks.firstOrNull()?.let(::openReader) } } @@ -2299,7 +1729,7 @@ private fun EpistemeDesktopApp( } emitOpds(opdsController.updateDownloadState(entry.id, null)) result.onSuccess { file -> - importFiles(listOf(file.toImportedBookFile())) + importFiles(listOf(file.toDesktopImportedBookFile())) updateState(state.withBanner("Downloaded ${file.name} from OPDS.")) }.onFailure { error -> updateState( @@ -2412,16 +1842,7 @@ private fun EpistemeDesktopApp( isTabsEnabled = state.isTabsEnabled, featurePolicy = featurePolicy, onTabSelected = { tab -> - val nextTab = if (tab == SharedAppTab.CATALOGS && !featurePolicy.opdsCatalogs) { - SharedAppTab.HOME - } else { - tab - } - if (nextTab == SharedAppTab.SETTINGS) { - settingsQuery = "" - settingsDestination = SharedSettingsDestination.ROOT - } - selectedTab = nextTab + selectAppTab(tab) }, onImportFiles = { importFiles(chooseFiles()) }, onImportFolder = { chooseFolder()?.let(::importFolder) }, @@ -2436,7 +1857,13 @@ private fun EpistemeDesktopApp( onAppSeedColorChange = { color -> updateState(state.reduce(AppAction.AppSeedColorChanged(color))) }, onCustomAppThemeAdded = { theme -> updateState(state.reduce(AppAction.CustomAppThemeAdded(theme))) }, onCustomAppThemeDeleted = { themeId -> updateState(state.reduce(AppAction.CustomAppThemeDeleted(themeId))) }, - onTabsEnabledChange = { enabled -> updateState(state.reduce(AppAction.TabsEnabledChanged(enabled))) }, + onTabsEnabledChange = { enabled -> + if (!enabled && (selectedTab == SharedAppTab.READER || activePdfDocument != null || openingReader != null)) { + exitReaderTo(SharedAppTab.HOME) + activeReaderBookId = null + } + updateState(state.reduce(AppAction.TabsEnabledChanged(enabled))) + }, onAiSettingsRequested = if (featurePolicy.aiAndCloud) { { showAiByokSettingsDialog = true } } else { @@ -2472,7 +1899,7 @@ private fun EpistemeDesktopApp( onOpenSettings = { settingsQuery = "" settingsDestination = SharedSettingsDestination.ROOT - selectedTab = SharedAppTab.SETTINGS + selectAppTab(SharedAppTab.SETTINGS) } ) @@ -2524,13 +1951,22 @@ private fun EpistemeDesktopApp( onAction = { action -> when (action) { SharedSettingsAction.APP_THEME -> showDesktopAppThemeSettingsDialog = true - SharedSettingsAction.TABS_TOGGLE -> updateState(state.reduce(AppAction.TabsEnabledChanged(!state.isTabsEnabled))) + SharedSettingsAction.TABS_TOGGLE -> { + if ( + state.isTabsEnabled && + (selectedTab == SharedAppTab.READER || activePdfDocument != null || openingReader != null) + ) { + exitReaderTo(SharedAppTab.HOME) + activeReaderBookId = null + } + updateState(state.reduce(AppAction.TabsEnabledChanged(!state.isTabsEnabled))) + } SharedSettingsAction.FOLDER_SYNC -> updateState(state.reduce(AppAction.FolderSyncEnabledChanged(!state.isFolderSyncEnabled))) SharedSettingsAction.AI_SETTINGS -> showAiByokSettingsDialog = true - SharedSettingsAction.CUSTOM_FONTS -> selectedTab = SharedAppTab.CUSTOM_FONTS - SharedSettingsAction.HELP_FEEDBACK -> selectedTab = SharedAppTab.FEEDBACK - SharedSettingsAction.SUPPORT -> selectedTab = SharedAppTab.SUPPORT - SharedSettingsAction.ABOUT -> selectedTab = SharedAppTab.ABOUT + SharedSettingsAction.CUSTOM_FONTS -> selectAppTab(SharedAppTab.CUSTOM_FONTS) + SharedSettingsAction.HELP_FEEDBACK -> selectAppTab(SharedAppTab.FEEDBACK) + SharedSettingsAction.SUPPORT -> selectAppTab(SharedAppTab.SUPPORT) + SharedSettingsAction.ABOUT -> selectAppTab(SharedAppTab.ABOUT) SharedSettingsAction.CLEAR_BOOK_CACHE -> showClearBookCacheDialog = true SharedSettingsAction.CLEAR_REFLOW_CACHE, SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA, @@ -2692,8 +2128,7 @@ private fun EpistemeDesktopApp( DesktopReaderOpeningScreen( opening = opening, onReturnToLibrary = { - openingReader = null - selectedTab = opening.returnTab + exitReaderTo(opening.returnTab) } ) } else if (pdfDocument != null) { @@ -2710,7 +2145,7 @@ private fun EpistemeDesktopApp( ?: state.pdfReaderDefaultSettings, onReturnToLibrary = { onReaderFullscreenChange(false) - selectedTab = SharedAppTab.LIBRARY + exitReaderTo(SharedAppTab.LIBRARY) }, onFullscreenChange = onReaderFullscreenChange, onPageStateChange = { page, progress, viewport -> @@ -2751,7 +2186,7 @@ private fun EpistemeDesktopApp( }, onReturnToLibrary = { onReaderFullscreenChange(false) - selectedTab = SharedAppTab.LIBRARY + exitReaderTo(SharedAppTab.LIBRARY) }, onFullscreenChange = onReaderFullscreenChange, toolbarPreferences = state.readerToolbarPreferences, @@ -2965,612 +2400,6 @@ private fun EpistemeDesktopApp( } } -private data class DesktopDropImportState( - val active: Boolean = false, - val supportedCount: Int = 0, - val totalFileCount: Int = 0, - val hasFilePayload: Boolean = false -) - -private fun BookItem.hasEmbeddedMetadataChange(updated: BookItem): Boolean { - return title != updated.title || - author != updated.author || - description != updated.description || - seriesName != updated.seriesName || - seriesIndex != updated.seriesIndex -} - -private fun String.toDesktopSafeFileName(): String { - return replace(Regex("[^A-Za-z0-9._-]"), "_").take(120).ifBlank { "book" } -} - -@Composable -private fun DesktopFileDropTarget( - window: Component?, - onFilesDropped: (List) -> Unit, - onDragStateChange: (DesktopDropImportState) -> Unit -) { - val onFilesDroppedState = rememberUpdatedState(onFilesDropped) - val onDragStateChangeState = rememberUpdatedState(onDragStateChange) - - DisposableEffect(window) { - if (window == null) { - onDispose { } - } else { - val installedTargets = mutableListOf() - var disposed = false - var lastDragState = DesktopDropImportState() - - fun publishDragState(state: DesktopDropImportState) { - if (state == lastDragState) return - lastDragState = state - onDragStateChangeState.value(state) - } - - val listener = object : DropTargetAdapter() { - override fun dragEnter(event: DropTargetDragEvent) { - handleDrag(event) - } - - override fun dragOver(event: DropTargetDragEvent) { - handleDrag(event) - } - - override fun dragExit(event: DropTargetEvent) { - publishDragState(DesktopDropImportState()) - } - - override fun drop(event: DropTargetDropEvent) { - if (!event.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { - event.rejectDrop() - publishDragState(DesktopDropImportState()) - return - } - event.acceptDrop(DnDConstants.ACTION_COPY) - val files = event.transferable.localDraggedFiles().filter { it.isFile } - if (files.isEmpty()) { - event.dropComplete(false) - publishDragState(DesktopDropImportState()) - return - } - - onFilesDroppedState.value(files.map { it.toImportedBookFile() }) - event.dropComplete(true) - publishDragState(DesktopDropImportState()) - } - - private fun handleDrag(event: DropTargetDragEvent) { - val hasFilePayload = event.isDataFlavorSupported(DataFlavor.javaFileListFlavor) - publishDragState( - DesktopDropImportState( - active = true, - hasFilePayload = hasFilePayload - ) - ) - if (hasFilePayload) { - event.acceptDrag(DnDConstants.ACTION_COPY) - } else { - event.rejectDrag() - } - } - } - window.installDropTargets(listener, installedTargets) - EventQueue.invokeLater { - if (!disposed) { - window.installDropTargets(listener, installedTargets) - } - } - - onDispose { - disposed = true - installedTargets.forEach { installed -> - runCatching { installed.dropTarget.removeDropTargetListener(listener) } - installed.component.dropTarget = installed.previous - } - publishDragState(DesktopDropImportState()) - } - } - } -} - -private data class InstalledDropTarget( - val component: Component, - val previous: DropTarget?, - val dropTarget: DropTarget -) - -private fun Component.installDropTargets( - listener: DropTargetAdapter, - installedTargets: MutableList -) { - collectDropTargetComponents() - .distinct() - .filterNot { component -> installedTargets.any { it.component == component } } - .forEach { component -> - val previous = component.dropTarget - val target = DropTarget(component, DnDConstants.ACTION_COPY, listener, true) - installedTargets += InstalledDropTarget(component, previous, target) - } -} - -private fun Component.collectDropTargetComponents(): List { - val collected = mutableListOf() - - fun visit(component: Component) { - collected += component - if (component is Container) { - component.components.forEach(::visit) - } - } - - visit(this) - return collected -} - -@Composable -private fun DesktopDropImportOverlay(state: DesktopDropImportState) { - if (!state.active) return - - val hasSupportedFiles = state.supportedCount > 0 - val title = when { - hasSupportedFiles -> "Drop to import ${state.supportedCount} file${if (state.supportedCount == 1) "" else "s"}" - state.hasFilePayload -> "Drop supported files to import" - else -> "Drop files to import" - } - val body = if (hasSupportedFiles) { - val skipped = state.totalFileCount - state.supportedCount - if (skipped > 0) { - "$skipped unsupported file${if (skipped == 1) "" else "s"} will be skipped." - } else { - "Release to add to your library." - } - } else { - SharedFileCapabilities.supportedFormatsLabel(ReaderPlatform.DESKTOP) - } - - Box( - modifier = Modifier - .fillMaxSize() - .zIndex(20f) - .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.36f)), - contentAlignment = Alignment.Center - ) { - Surface( - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface, - tonalElevation = 8.dp, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.55f)) - ) { - Column( - modifier = Modifier.padding(horizontal = 30.dp, vertical = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) - Text( - body, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - } - } - } -} - -private fun java.awt.datatransfer.Transferable.localDraggedFiles(): List { - if (!isDataFlavorSupported(DataFlavor.javaFileListFlavor)) return emptyList() - return runCatching { - @Suppress("UNCHECKED_CAST") - (getTransferData(DataFlavor.javaFileListFlavor) as? List<*>) - .orEmpty() - .filterIsInstance() - }.getOrDefault(emptyList()) -} - -private fun BookItem.withDesktopImportMetadata( - enriched: BookItem, - original: BookItem? -): BookItem { - fun shouldApplyText(current: String?, originalValue: String?): Boolean { - return current.isNullOrBlank() || current == originalValue - } - - return copy( - title = if (shouldApplyText(title, original?.title)) { - enriched.title ?: title - } else { - title - }, - author = if (shouldApplyText(author, original?.author)) { - enriched.author ?: author - } else { - author - }, - description = if (shouldApplyText(description, original?.description)) { - enriched.description ?: description - } else { - description - }, - seriesName = if (shouldApplyText(seriesName, original?.seriesName)) { - enriched.seriesName ?: seriesName - } else { - seriesName - }, - seriesIndex = if (seriesIndex == null || seriesIndex == original?.seriesIndex) { - enriched.seriesIndex ?: seriesIndex - } else { - seriesIndex - }, - originalTitle = originalTitle ?: enriched.originalTitle ?: enriched.title, - originalAuthor = originalAuthor ?: enriched.originalAuthor ?: enriched.author, - originalSeriesName = originalSeriesName ?: enriched.originalSeriesName ?: enriched.seriesName, - originalSeriesIndex = originalSeriesIndex ?: enriched.originalSeriesIndex ?: enriched.seriesIndex, - originalDescription = originalDescription ?: enriched.originalDescription ?: enriched.description, - fileSize = enriched.fileSize.takeIf { it > 0L } ?: fileSize, - fileContentModifiedTimestamp = enriched.fileContentModifiedTimestamp.takeIf { it > 0L } - ?: fileContentModifiedTimestamp, - coverImagePath = coverImagePath?.takeIf { File(it).isFile } ?: enriched.coverImagePath, - folderTextMetadataParsed = folderTextMetadataParsed || enriched.folderTextMetadataParsed - ) -} - -internal fun resolvedDesktopReaderSettings( - book: BookItem, - readerDefaultSettings: ReaderSettings -): ReaderSettings { - return book.readerSettings ?: readerDefaultSettings -} - -@Composable -private fun DesktopReaderOpeningScreen( - opening: DesktopReaderOpening, - onReturnToLibrary: () -> Unit -) { - Box( - modifier = Modifier.fillMaxSize().padding(32.dp), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - CircularProgressIndicator() - Text( - text = "Opening ${opening.title}", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center - ) - Text( - text = opening.formatLabel, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - TextButton(onClick = onReturnToLibrary) { - Text("Return to library") - } - } - } -} - -@Composable -private fun HomeScreen( - state: SharedReaderScreenState, - onImportBooks: () -> Unit, - onImportFolder: () -> Unit, - onRead: (BookItem) -> Unit, - onSelect: (String) -> Unit, - onClearSelection: () -> Unit, - onRemoveSelected: () -> Unit, - onShowBookInfo: (BookItem) -> Unit, - onEditBook: (BookItem) -> Unit, - onTagSelectedBooks: () -> Unit, - onAddSelectedBooksToShelf: () -> Unit, - onOpenTab: (BookItem) -> Unit, - onCloseTab: (BookItem) -> Unit, - onCloseAllTabs: () -> Unit, - onRecentLimitChange: (Int) -> Unit, - onTogglePinned: (BookItem) -> Unit, - onOpenSettings: () -> Unit -) { - SharedHomeScreen( - state = state, - onImportBooks = onImportBooks, - onImportFolder = onImportFolder, - onOpenBook = onRead, - onToggleSelection = onSelect, - onClearSelection = onClearSelection, - onRemoveSelected = onRemoveSelected, - onShowBookInfo = onShowBookInfo, - onEditBook = onEditBook, - onTagSelectedBooks = onTagSelectedBooks, - onAddSelectedBooksToShelf = onAddSelectedBooksToShelf, - onOpenTab = onOpenTab, - onCloseTab = onCloseTab, - onCloseAllTabs = onCloseAllTabs, - onRecentLimitChange = onRecentLimitChange, - onTogglePinned = onTogglePinned, - onOpenSettings = onOpenSettings, - showActiveTabs = false - ) -} - -@Composable -private fun LibraryScreen( - state: SharedReaderScreenState, - selectedLibraryTab: NonReaderLibraryTab, - onLibraryTabChange: (NonReaderLibraryTab) -> Unit, - onStateChange: (SharedReaderScreenState) -> Unit, - onImportBooks: () -> Unit, - onRead: (BookItem) -> Unit, - onSelect: (String) -> Unit, - onClearSelection: () -> Unit, - onRemoveSelected: () -> Unit, - onShowBookInfo: (BookItem) -> Unit, - onEditBook: (BookItem) -> Unit, - onCreateShelf: () -> Unit, - onCreateSmartShelf: () -> Unit, - onRenameShelf: (Shelf) -> Unit, - onDeleteShelf: (Shelf) -> Unit, - onRemoveFolder: (Shelf) -> Unit, - onTagSelectedBooks: () -> Unit, - onAddSelectedBooksToShelf: () -> Unit, - onImportFolder: () -> Unit, - onSyncFolderMetadata: () -> Unit, - onScanFolders: () -> Unit, - onTogglePinned: (BookItem) -> Unit -) { - SharedLibraryScreen( - state = state, - selectedTab = selectedLibraryTab, - onTabChange = onLibraryTabChange, - onStateChange = onStateChange, - onImportBooks = onImportBooks, - onOpenBook = onRead, - onToggleSelection = onSelect, - onClearSelection = onClearSelection, - onRemoveSelected = onRemoveSelected, - onShowBookInfo = onShowBookInfo, - onEditBook = onEditBook, - onCreateShelf = onCreateShelf, - onCreateSmartShelf = onCreateSmartShelf, - onRenameShelf = onRenameShelf, - onDeleteShelf = onDeleteShelf, - onRemoveFolder = onRemoveFolder, - onTagSelectedBooks = onTagSelectedBooks, - onAddSelectedBooksToShelf = onAddSelectedBooksToShelf, - onImportFolder = onImportFolder, - onSyncFolderMetadata = onSyncFolderMetadata, - onScanFolders = onScanFolders, - onTogglePinned = onTogglePinned, - useImportEmptyStateWhenLibraryEmpty = true - ) -} - -@Composable -private fun ShelvesScreen( - shelves: List, - selectedBookIds: Set, - pinnedBookIds: Set, - onRead: (BookItem) -> Unit, - onSelect: (String) -> Unit, - onShowBookInfo: (BookItem) -> Unit, - onEditBook: (BookItem) -> Unit, - onTogglePinned: (BookItem) -> Unit, - onCreateShelf: () -> Unit, - onCreateSmartShelf: () -> Unit, - onRenameShelf: (Shelf) -> Unit, - onDeleteShelf: (Shelf) -> Unit, - onRemoveFolder: (Shelf) -> Unit -) { - SharedShelvesScreen( - shelves = shelves, - selectedBookIds = selectedBookIds, - pinnedBookIds = pinnedBookIds, - onOpenBook = onRead, - onToggleSelection = onSelect, - onShowBookInfo = onShowBookInfo, - onEditBook = onEditBook, - onTogglePinned = onTogglePinned, - onCreateShelf = onCreateShelf, - onCreateSmartShelf = onCreateSmartShelf, - onRenameShelf = onRenameShelf, - onDeleteShelf = onDeleteShelf, - onRemoveFolder = onRemoveFolder - ) -} - -private data class DesktopSmartRuleDraft( - val field: SmartField = SmartField.TITLE, - val operator: SmartOperator = SmartOperator.CONTAINS, - val value: String = "" -) { - fun toRule(): SmartRule? { - val trimmed = value.trim() - if (trimmed.isBlank()) return null - return SmartRule(field = field, operator = operator, value = trimmed) - } -} - -@Composable -private fun SmartShelfDialog( - onDismiss: () -> Unit, - onConfirm: (String, SmartCollectionDefinition) -> Unit -) { - var name by remember { mutableStateOf("") } - var matchAll by remember { mutableStateOf(true) } - var rules by remember { mutableStateOf(listOf(DesktopSmartRuleDraft())) } - val validRules = rules.mapNotNull { it.toRule() } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Create smart shelf") }, - text = { - Column( - modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - SharedStableOutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("Shelf name") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - FilterChip( - selected = matchAll, - onClick = { matchAll = true }, - label = { Text("All") } - ) - FilterChip( - selected = !matchAll, - onClick = { matchAll = false }, - label = { Text("Any") } - ) - Spacer(Modifier.weight(1f)) - TextButton( - onClick = { rules = rules + DesktopSmartRuleDraft() }, - enabled = rules.size < 4 - ) { - Text("Add rule") - } - } - rules.forEachIndexed { index, draft -> - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - SmartRuleDropdown( - label = "Field", - selected = draft.field, - options = SmartField.entries.toList(), - optionLabel = { it.desktopLabel() }, - onSelected = { field -> - rules = rules.updateAt(index) { - val operator = smartOperatorsFor(field).first() - copy(field = field, operator = operator, value = "") - } - } - ) - SmartRuleDropdown( - label = "Operator", - selected = draft.operator, - options = smartOperatorsFor(draft.field), - optionLabel = { it.desktopLabel() }, - onSelected = { operator -> - rules = rules.updateAt(index) { copy(operator = operator) } - } - ) - if (rules.size > 1) { - TextButton(onClick = { rules = rules.filterIndexed { i, _ -> i != index } }) { - Text("Remove") - } - } - } - SharedStableOutlinedTextField( - value = draft.value, - onValueChange = { value -> rules = rules.updateAt(index) { copy(value = value) } }, - label = { Text(draft.field.valueLabel()) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - selectionKey = index - ) - } - } - } - }, - confirmButton = { - TextButton( - onClick = { - onConfirm(name, SmartCollectionDefinition(matchAll = matchAll, rules = validRules)) - }, - enabled = name.isNotBlank() && validRules.isNotEmpty() - ) { - Text("Create") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - } - ) -} - -@Composable -private fun SmartRuleDropdown( - label: String, - selected: T, - options: List, - optionLabel: (T) -> String, - onSelected: (T) -> Unit -) { - var expanded by remember { mutableStateOf(false) } - Box { - TextButton(onClick = { expanded = true }) { - Text("$label: ${optionLabel(selected)}") - } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - options.forEach { option -> - DropdownMenuItem( - text = { Text(optionLabel(option)) }, - onClick = { - expanded = false - onSelected(option) - } - ) - } - } - } -} - -private fun smartOperatorsFor(field: SmartField): List { - return when (field) { - SmartField.PROGRESS -> listOf(SmartOperator.GREATER_THAN, SmartOperator.LESS_THAN, SmartOperator.EQUALS) - else -> listOf(SmartOperator.CONTAINS, SmartOperator.EQUALS) - } -} - -private fun SmartField.desktopLabel(): String { - return when (this) { - SmartField.TITLE -> "Title" - SmartField.AUTHOR -> "Author" - SmartField.PROGRESS -> "Progress" - SmartField.FILE_TYPE -> "File type" - SmartField.FOLDER -> "Folder" - SmartField.TAG -> "Tag" - } -} - -private fun SmartField.valueLabel(): String { - return when (this) { - SmartField.PROGRESS -> "Percent" - SmartField.FILE_TYPE -> "Type, e.g. PDF" - SmartField.FOLDER -> "Folder path" - SmartField.TAG -> "Tag name" - SmartField.TITLE -> "Title text" - SmartField.AUTHOR -> "Author text" - } -} - -private fun SmartOperator.desktopLabel(): String { - return when (this) { - SmartOperator.EQUALS -> "Equals" - SmartOperator.CONTAINS -> "Contains" - SmartOperator.GREATER_THAN -> "Greater than" - SmartOperator.LESS_THAN -> "Less than" - } -} - -private inline fun List.updateAt( - index: Int, - transform: DesktopSmartRuleDraft.() -> DesktopSmartRuleDraft -): List { - return mapIndexed { i, draft -> if (i == index) draft.transform() else draft } -} - private val DesktopPdfAnnotationTools = listOf( PdfInkTool.PEN, PdfInkTool.FOUNTAIN_PEN, @@ -3581,567 +2410,6 @@ private val DesktopPdfAnnotationTools = listOf( PdfInkTool.ERASER ) -private enum class DesktopPdfInspectorTab(val title: String) { - VIEW("View"), - MARKUP("Markup"), - ASSIST("Assist") -} - -private data class DesktopPdfThemeStyle( - val theme: ReaderTheme, - val viewerBackgroundColor: Color, - val pageBackgroundColor: Color, - val colorFilter: ColorFilter?, - val textureBitmap: ImageBitmap?, - val textureAlpha: Float, - val textureBlendMode: BlendMode -) - -@Composable -private fun DesktopPdfThemedPageImage( - bitmap: ImageBitmap, - contentDescription: String, - themeStyle: DesktopPdfThemeStyle, - modifier: Modifier = Modifier -) { - Box(modifier = modifier.background(themeStyle.pageBackgroundColor)) { - Image( - bitmap = bitmap, - contentDescription = contentDescription, - colorFilter = themeStyle.colorFilter, - modifier = Modifier.fillMaxSize() - ) - val textureBitmap = themeStyle.textureBitmap - if (textureBitmap != null && themeStyle.textureAlpha > 0f) { - Canvas(modifier = Modifier.fillMaxSize()) { - drawRect( - brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)), - size = size, - blendMode = themeStyle.textureBlendMode, - alpha = themeStyle.textureAlpha - ) - } - } - } -} - -private fun ReaderSettings?.toDesktopPdfReaderSettings(): ReaderSettings { - val defaults = ReaderSettings(themeId = "no_theme") - val settings = this ?: defaults - val themeId = settings.themeId - val hasPdfTheme = BuiltInPdfReaderThemes.any { it.id == themeId } - val hasCustomColors = settings.backgroundColorArgb != null && settings.textColorArgb != null - return settings.copy( - themeId = when { - themeId == null -> "no_theme" - hasPdfTheme || hasCustomColors -> themeId - else -> "no_theme" - } - ) -} - -private fun ReaderSettings.toDesktopPdfThemeStyle(displayMode: PdfDisplayMode): DesktopPdfThemeStyle { - val theme = toDesktopPdfTheme() - val pageBackground = desktopPdfPageBackgroundColor(theme, displayMode) - val isDarkTexture = theme.isDark || theme.id == "reverse" - return DesktopPdfThemeStyle( - theme = theme, - viewerBackgroundColor = pageBackground, - pageBackgroundColor = pageBackground, - colorFilter = theme.toDesktopPdfColorFilter(), - textureBitmap = DesktopReaderTextures.imageBitmapFor(textureId), - textureAlpha = if (textureId == null) 0f else textureAlpha.coerceIn(0f, 1f), - textureBlendMode = if (isDarkTexture) BlendMode.Screen else BlendMode.Multiply - ) -} - -private fun ReaderSettings.toDesktopPdfTheme(): ReaderTheme { - BuiltInPdfReaderThemes.firstOrNull { it.id == themeId }?.let { return it } - val background = backgroundColorArgb?.toComposeColor() - val text = textColorArgb?.toComposeColor() - return if (background != null && text != null) { - ReaderTheme( - id = themeId ?: "desktop_pdf_custom", - name = "Custom", - backgroundColor = background, - textColor = text, - isDark = darkMode, - textureId = textureId, - isCustom = true - ) - } else { - BuiltInPdfReaderThemes.first() - } -} - -private fun ReaderTheme.toDesktopPdfColorFilter(): ColorFilter? { - return when (id) { - "no_theme", "system" -> null - "reverse" -> { - val colorMatrix = floatArrayOf( - -1f, 0f, 0f, 0f, 255f, - 0f, -1f, 0f, 0f, 255f, - 0f, 0f, -1f, 0f, 255f, - 0f, 0f, 0f, 1f, 0f - ) - ColorFilter.colorMatrix(ColorMatrix(colorMatrix)) - } - else -> { - if (!backgroundColor.isSpecified || !textColor.isSpecified) return null - val bgR = backgroundColor.red * 255f - val bgG = backgroundColor.green * 255f - val bgB = backgroundColor.blue * 255f - val fgR = textColor.red * 255f - val fgG = textColor.green * 255f - val fgB = textColor.blue * 255f - val dr = (bgR - fgR) / 255f - val dg = (bgG - fgG) / 255f - val db = (bgB - fgB) / 255f - val lumR = 0.2126f - val lumG = 0.7152f - val lumB = 0.0722f - val colorMatrix = floatArrayOf( - dr * lumR, dr * lumG, dr * lumB, 0f, fgR, - dg * lumR, dg * lumG, dg * lumB, 0f, fgG, - db * lumR, db * lumG, db * lumB, 0f, fgB, - 0f, 0f, 0f, 1f, 0f - ) - ColorFilter.colorMatrix(ColorMatrix(colorMatrix)) - } - } -} - -@Composable -private fun DesktopPdfInspectorSection( - title: String, - content: @Composable ColumnScope.() -> Unit -) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) - content() - } -} - -@Composable -private fun DesktopPdfVisualOptionSwitch( - title: String, - description: String, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit -) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) - Text( - description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch(checked = checked, onCheckedChange = onCheckedChange) - } -} - -private object DesktopReaderTextures { - private val bytesCache = mutableMapOf() - private val dataUriCache = mutableMapOf() - private val imageCache = mutableMapOf() - private val importExtensions = setOf("jpg", "jpeg", "png", "webp", "gif", "bmp") - - fun importedTextureIds(): List { - return readerTextureDirectory() - .listFiles { file -> file.isFile && file.extension.lowercase(Locale.ROOT) in importExtensions } - ?.sortedBy { it.name.lowercase(Locale.ROOT) } - ?.map { ReaderTextureFilePrefix + it.absolutePath } - .orEmpty() - } - - fun importTexture(source: File): String? { - if (!source.isFile) return null - val extension = source.extension.lowercase(Locale.ROOT) - .takeIf { it in importExtensions } - ?: return null - val safeName = source.nameWithoutExtension - .replace(Regex("[^A-Za-z0-9._-]+"), "_") - .trim('_') - .ifBlank { "texture" } - val directory = readerTextureDirectory().apply { mkdirs() } - val target = File(directory, "texture_${System.currentTimeMillis()}_$safeName.$extension") - return runCatching { - source.copyTo(target, overwrite = false) - val textureId = ReaderTextureFilePrefix + target.absolutePath - bytesCache.remove(textureId) - dataUriCache.remove(textureId) - imageCache.remove(textureId) - textureId - }.getOrNull() - } - - fun dataUriFor(textureId: String): String? { - return dataUriCache.getOrPut(textureId) { - val bytes = bytesFor(textureId) ?: return@getOrPut null - val extension = textureExtension(textureId) - "data:${imageMimeTypeForExtension(extension)};base64," + - Base64.getEncoder().encodeToString(bytes) - } - } - - fun imageBitmapFor(textureId: String?): ImageBitmap? { - val id = textureId ?: return null - return imageCache.getOrPut(id) { - val bytes = bytesFor(id) ?: return@getOrPut null - runCatching { - ImageIO.read(ByteArrayInputStream(bytes))?.toComposeImageBitmap() - }.getOrNull() - } - } - - private fun bytesFor(textureId: String): ByteArray? { - return bytesCache.getOrPut(textureId) { - if (textureId.startsWith(ReaderTextureFilePrefix)) { - File(textureId.removePrefix(ReaderTextureFilePrefix)).takeIf { it.isFile }?.readBytes() - } else { - val texture = ReaderTexture.entries.firstOrNull { it.id == textureId } ?: return@getOrPut null - val classLoader = Thread.currentThread().contextClassLoader ?: DesktopReaderTextures::class.java.classLoader - classLoader - ?.getResourceAsStream(texture.assetPath) - ?.use { it.readBytes() } - ?: DesktopReaderTextures::class.java.classLoader - ?.getResourceAsStream(texture.assetPath) - ?.use { it.readBytes() } - } - } - } - - private fun textureExtension(textureId: String): String { - if (textureId.startsWith(ReaderTextureFilePrefix)) { - return File(textureId.removePrefix(ReaderTextureFilePrefix)).extension - } - return ReaderTexture.entries.firstOrNull { it.id == textureId } - ?.assetPath - ?.substringAfterLast('.', "png") - ?: "png" - } - - private fun readerTextureDirectory(): File { - return File(desktopUserDataRoot(), "reader_textures") - } -} - -private fun imageMimeTypeForExtension(extension: String): String { - return when (extension.lowercase(Locale.ROOT)) { - "jpg", "jpeg" -> "image/jpeg" - "webp" -> "image/webp" - "gif" -> "image/gif" - "bmp" -> "image/bmp" - else -> "image/png" - } -} - -private fun Long.toComposeColor(): Color { - return Color(this and 0xFFFFFFFFL) -} - -private val PdfInkTool.isDesktopHighlighter: Boolean - get() = this == PdfInkTool.HIGHLIGHTER || this == PdfInkTool.HIGHLIGHTER_ROUND - -private val SharedPdfAnnotation.isDesktopTextSelectionHighlight: Boolean - get() = kind == PdfAnnotationKind.HIGHLIGHT && - text.isNotBlank() && - rangeStartIndex != null && - rangeEndIndex != null - -private fun List.withDesktopPdfDragPoint( - point: Offset, - canvasSize: IntSize, - tool: PdfInkTool, - snapHighlighter: Boolean, - timestamp: Long -): List { - val nextPoint = point.toSharedPdfPoint(canvasSize, timestamp) - if (snapHighlighter && tool.isDesktopHighlighter && isNotEmpty()) { - val pageAspectRatio = canvasSize.width.toFloat() / canvasSize.height.coerceAtLeast(1).toFloat() - return listOf( - first(), - SharedPdfInkRenderer.calculateSnappedPoint( - currentPoint = nextPoint, - startPoint = first(), - pageAspectRatio = pageAspectRatio - ) - ) - } - return this + nextPoint -} - -internal fun desktopPdfScrollZoomFactor(scrollDelta: Float): Float { - if (!scrollDelta.isFinite() || abs(scrollDelta) < 0.01f) return 1f - val normalizedDelta = scrollDelta.coerceIn(-8f, 8f) - return exp((-normalizedDelta * 0.12f).toDouble()).toFloat() -} - -internal fun desktopPdfZoomTarget( - currentZoom: Float, - zoomSpec: PdfZoomSpec, - factor: Float -): Float { - val baseZoom = currentZoom.takeIf { it.isFinite() } ?: zoomSpec.default - val safeFactor = factor.takeIf { it.isFinite() && it > 0f } ?: 1f - return zoomSpec.clamp(baseZoom * safeFactor) -} - -internal fun desktopPdfAnchoredScrollTarget( - currentScroll: Int, - anchor: Float, - oldZoom: Float, - newZoom: Float -): Int { - if ( - !anchor.isFinite() || - !oldZoom.isFinite() || - !newZoom.isFinite() || - oldZoom <= 0f || - newZoom <= 0f - ) { - return currentScroll.coerceAtLeast(0) - } - val zoomRatio = newZoom / oldZoom - return (((currentScroll + anchor) * zoomRatio) - anchor).roundToInt().coerceAtLeast(0) -} - -internal fun desktopPdfAnchoredLazyItemScrollOffset( - itemOffset: Int, - anchor: Float, - oldZoom: Float, - newZoom: Float -): Int { - if ( - !anchor.isFinite() || - !oldZoom.isFinite() || - !newZoom.isFinite() || - oldZoom <= 0f || - newZoom <= 0f - ) { - return (-itemOffset).coerceAtLeast(0) - } - val zoomRatio = newZoom / oldZoom - val offsetWithinItem = anchor - itemOffset - return ((offsetWithinItem * zoomRatio) - anchor).roundToInt().coerceAtLeast(0) -} - -internal fun desktopPdfAnchoredPageScrollDelta( - viewportRootOffset: Offset, - oldPageRootOffset: Offset, - currentPageRootOffset: Offset, - anchor: Offset, - oldZoom: Float, - newZoom: Float -): IntOffset? { - if ( - !anchor.x.isFinite() || - !anchor.y.isFinite() || - !oldZoom.isFinite() || - !newZoom.isFinite() || - oldZoom <= 0f || - newZoom <= 0f - ) { - return null - } - val rootAnchor = viewportRootOffset + anchor - val oldPageLocal = rootAnchor - oldPageRootOffset - val zoomRatio = newZoom / oldZoom - val newPageLocal = Offset(oldPageLocal.x * zoomRatio, oldPageLocal.y * zoomRatio) - val desiredPageRoot = rootAnchor - newPageLocal - val delta = currentPageRootOffset - desiredPageRoot - return IntOffset(delta.x.roundToInt(), delta.y.roundToInt()) -} - -internal fun desktopPdfPaginationFirstRenderScale( - requestedScale: Float, - hasPageRender: Boolean, - isOpeningRender: Boolean = false -): Float { - if (hasPageRender || isOpeningRender || !requestedScale.isFinite() || requestedScale <= 0f) { - return requestedScale - } - return requestedScale.coerceAtMost(DesktopPdfPaginationFastFirstRenderMaxScale) -} - -private data class DesktopPdfZoomPreview( - val baseZoom: Float, - val zoom: Float, - val anchor: Offset?, - val displayMode: PdfDisplayMode, - val pageIndex: Int? -) - -private data class DesktopPdfCachedPageRender( - val render: DesktopPdfPageRender, - val scale: Float -) - -internal fun desktopPdfZoomPreviewPivotFraction( - viewportRootOffset: Offset, - pageRootOffset: Offset, - anchor: Offset, - pageCanvasSize: IntSize -): Offset? { - if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return null - if (!anchor.x.isFinite() || !anchor.y.isFinite()) return null - val pageAnchor = viewportRootOffset + anchor - pageRootOffset - if (!pageAnchor.x.isFinite() || !pageAnchor.y.isFinite()) return null - return Offset( - x = (pageAnchor.x / pageCanvasSize.width).coerceIn(0f, 1f), - y = (pageAnchor.y / pageCanvasSize.height).coerceIn(0f, 1f) - ) -} - -internal fun desktopPdfDocumentZoomPreviewTranslation( - viewportRootOffset: Offset, - pageRootOffset: Offset, - anchor: Offset, - previewScale: Float -): Offset? { - if (!anchor.x.isFinite() || !anchor.y.isFinite()) return null - if (!previewScale.isFinite() || previewScale <= 0f) return null - val rootAnchor = viewportRootOffset + anchor - if (!rootAnchor.x.isFinite() || !rootAnchor.y.isFinite()) return null - return Offset( - x = (pageRootOffset.x - rootAnchor.x) * (previewScale - 1f), - y = (pageRootOffset.y - rootAnchor.y) * (previewScale - 1f) - ) -} - -private fun Modifier.desktopPdfZoomPreviewLayer( - preview: DesktopPdfZoomPreview?, - currentZoom: Float, - viewportRootOffset: Offset, - pageRootOffset: Offset, - pageCanvasSize: IntSize -): Modifier { - val activePreview = preview ?: return this - if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return this - if (!currentZoom.isFinite() || currentZoom <= 0f) return this - if (!activePreview.zoom.isFinite() || activePreview.zoom <= 0f) return this - val previewScale = activePreview.zoom / currentZoom - if (!previewScale.isFinite() || abs(previewScale - 1f) < 0.0001f) return this - val transformOrigin = activePreview.anchor?.let { anchor -> - desktopPdfZoomPreviewPivotFraction( - viewportRootOffset = viewportRootOffset, - pageRootOffset = pageRootOffset, - anchor = anchor, - pageCanvasSize = pageCanvasSize - )?.let { pivot -> - TransformOrigin(pivotFractionX = pivot.x, pivotFractionY = pivot.y) - } ?: TransformOrigin.Center - } ?: TransformOrigin.Center - return graphicsLayer { - scaleX = previewScale - scaleY = previewScale - this.transformOrigin = transformOrigin - } -} - -private fun Modifier.desktopPdfDocumentZoomPreviewLayer( - preview: DesktopPdfZoomPreview?, - currentZoom: Float, - viewportRootOffset: Offset, - pageRootOffset: Offset -): Modifier { - val activePreview = preview ?: return this - if (!currentZoom.isFinite() || currentZoom <= 0f) return this - if (!activePreview.zoom.isFinite() || activePreview.zoom <= 0f) return this - val previewScale = activePreview.zoom / currentZoom - if (!previewScale.isFinite() || abs(previewScale - 1f) < 0.0001f) return this - val translation = activePreview.anchor?.let { anchor -> - desktopPdfDocumentZoomPreviewTranslation( - viewportRootOffset = viewportRootOffset, - pageRootOffset = pageRootOffset, - anchor = anchor, - previewScale = previewScale - ) - } ?: Offset.Zero - return graphicsLayer { - scaleX = previewScale - scaleY = previewScale - translationX = translation.x - translationY = translation.y - transformOrigin = TransformOrigin(0f, 0f) - } -} - -@Composable -private fun Modifier.desktopPdfZoomGestures( - currentZoom: Float, - zoomSpec: PdfZoomSpec, - onZoomChanged: (oldZoom: Float, newZoom: Float, anchor: Offset?) -> Unit -): Modifier { - val latestZoom by rememberUpdatedState(currentZoom) - val latestOnZoomChanged by rememberUpdatedState(onZoomChanged) - return this.pointerInput(zoomSpec) { - var gestureZoom = latestZoom - var appliedGestureZoom = latestZoom - var lastZoomEventAt = 0L - var lastAppliedZoomAt = 0L - fun applyZoomFactor(factor: Float, eventTime: Long, anchor: Offset?) { - if (lastZoomEventAt == 0L || eventTime - lastZoomEventAt > 180L) { - gestureZoom = latestZoom - appliedGestureZoom = latestZoom - lastAppliedZoomAt = 0L - } - val newZoom = desktopPdfZoomTarget(gestureZoom, zoomSpec, factor) - gestureZoom = newZoom - lastZoomEventAt = eventTime - val shouldApplyNow = lastAppliedZoomAt == 0L || - eventTime - lastAppliedZoomAt >= DesktopPdfZoomGestureFrameMillis || - newZoom == zoomSpec.min || - newZoom == zoomSpec.max - if (shouldApplyNow && newZoom != appliedGestureZoom) { - latestOnZoomChanged(appliedGestureZoom, newZoom, anchor) - appliedGestureZoom = newZoom - lastAppliedZoomAt = eventTime - } - } - - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent(PointerEventPass.Initial) - val eventTime = event.changes.maxOfOrNull { it.uptimeMillis } ?: 0L - if (event.type == PointerEventType.Scroll && event.keyboardModifiers.isPointerCtrlPressed) { - val scrollDelta = event.changes.fold(Offset.Zero) { total, change -> - total + change.scrollDelta - } - val zoomDelta = if (abs(scrollDelta.y) >= abs(scrollDelta.x)) scrollDelta.y else scrollDelta.x - val factor = desktopPdfScrollZoomFactor(zoomDelta) - if (abs(factor - 1f) > 0.0001f) { - applyZoomFactor(factor, eventTime, event.changes.firstOrNull()?.position) - event.changes.forEach { it.consume() } - } - continue - } - - val pressedPointers = event.changes.count { it.pressed } - if (pressedPointers > 1) { - val zoomChange = event.calculateZoom() - if (zoomChange.isFinite() && abs(zoomChange - 1f) > 0.005f) { - val centroid = event.calculateCentroid(useCurrent = false) - val anchor = if (centroid == Offset.Unspecified) { - event.changes.firstOrNull { it.pressed }?.position - } else { - centroid - } - applyZoomFactor(zoomChange, eventTime, anchor) - } - event.changes.forEach { it.consume() } - } - } - } - } -} - @Composable private fun PdfReaderScreen( document: DesktopPdfDocument, @@ -4164,14 +2432,15 @@ private fun PdfReaderScreen( onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit, featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard ) { + val documentHandleId = document.handleId val zoomSpec = remember { DesktopPdfZoomSpec } - val restoredInitialViewport = remember(document.path, initialViewport) { + val restoredInitialViewport = remember(documentHandleId, initialViewport) { initialViewport?.sanitized(document.pageCount, zoomSpec) } - var pdfReaderSettings by remember(document.path) { + var pdfReaderSettings by remember(documentHandleId) { mutableStateOf(initialReaderSettings.toDesktopPdfReaderSettings()) } - var pdfState by remember(document.path) { + var pdfState by remember(documentHandleId) { mutableStateOf( SharedPdfReaderState.initial( pageCount = document.pageCount, @@ -4183,39 +2452,39 @@ private fun PdfReaderScreen( ) ) } - var renderedPage by remember(document.path) { mutableStateOf(null) } - var renderedPageIndex by remember(document.path) { mutableStateOf(null) } - var renderedPageScale by remember(document.path) { mutableStateOf(null) } - var renderError by remember(document.path) { mutableStateOf(null) } - var isRendering by remember(document.path) { mutableStateOf(false) } - var renderJob by remember(document.path) { mutableStateOf(null) } - val zoomAnchorJob = remember(document.path) { AtomicReference(null) } - val zoomCommitJob = remember(document.path) { AtomicReference(null) } - var pdfZoomPreview by remember(document.path) { mutableStateOf(null) } - var activeTextDraft by remember(document.path) { mutableStateOf(null) } - var textStyleConfig by remember(document.path) { mutableStateOf(SharedPdfTextStyleConfig()) } - var pageCanvasSize by remember(document.path) { mutableStateOf(IntSize.Zero) } - var pdfZoomViewportRootOffset by remember(document.path) { mutableStateOf(Offset.Zero) } - var paginatedPageRootOffset by remember(document.path) { mutableStateOf(Offset.Zero) } - val verticalPageRootOffsets = remember(document.path) { mutableStateMapOf() } - val paginatedRenderCache = remember(document.path) { mutableStateMapOf() } - var activeStroke by remember(document.path, pdfState.pageIndex) { mutableStateOf>(emptyList()) } - var eraserPosition by remember(document.path, pdfState.pageIndex, pdfState.selectedTool) { mutableStateOf(null) } - var isHighlighterSnapEnabled by remember(document.path) { mutableStateOf(false) } - var selectionStartIndex by remember(document.path, pdfState.pageIndex) { mutableStateOf(null) } - var selectionEndIndex by remember(document.path, pdfState.pageIndex) { mutableStateOf(null) } - var selectionStartHit by remember(document.path, pdfState.pageIndex) { mutableStateOf(null) } - var selectionEndHit by remember(document.path, pdfState.pageIndex) { mutableStateOf(null) } - var textSelection by remember(document.path, pdfState.pageIndex) { mutableStateOf(null) } - var selectionMenuOffset by remember(document.path, pdfState.pageIndex) { mutableStateOf(null) } - var activeSelectionHandle by remember(document.path, pdfState.pageIndex) { mutableStateOf(null) } - var pageScrubPreview by remember(document.path) { mutableStateOf(null) } - var pageScrubStartPage by remember(document.path) { mutableStateOf(null) } - var showPdfZoomIndicator by remember(document.path) { mutableStateOf(false) } - var isPdfZoomIndicatorInitialized by remember(document.path) { mutableStateOf(false) } - var jumpHistory by remember(document.path) { mutableStateOf(SharedPdfJumpHistory()) } - var externalLinkDialogUrl by remember(document.path) { mutableStateOf(null) } - var pdfExtrasState by remember(document.path) { + var renderedPage by remember(documentHandleId) { mutableStateOf(null) } + var renderedPageIndex by remember(documentHandleId) { mutableStateOf(null) } + var renderedPageScale by remember(documentHandleId) { mutableStateOf(null) } + var renderError by remember(documentHandleId) { mutableStateOf(null) } + var isRendering by remember(documentHandleId) { mutableStateOf(false) } + var renderJob by remember(documentHandleId) { mutableStateOf(null) } + val zoomAnchorJob = remember(documentHandleId) { AtomicReference(null) } + val zoomCommitJob = remember(documentHandleId) { AtomicReference(null) } + var pdfZoomPreview by remember(documentHandleId) { mutableStateOf(null) } + var activeTextDraft by remember(documentHandleId) { mutableStateOf(null) } + var textStyleConfig by remember(documentHandleId) { mutableStateOf(SharedPdfTextStyleConfig()) } + var pageCanvasSize by remember(documentHandleId) { mutableStateOf(IntSize.Zero) } + var pdfZoomViewportRootOffset by remember(documentHandleId) { mutableStateOf(Offset.Zero) } + var paginatedPageRootOffset by remember(documentHandleId) { mutableStateOf(Offset.Zero) } + val verticalPageRootOffsets = remember(documentHandleId) { mutableStateMapOf() } + val paginatedRenderCache = remember(documentHandleId) { mutableStateMapOf() } + var activeStroke by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf>(emptyList()) } + var eraserPosition by remember(documentHandleId, pdfState.pageIndex, pdfState.selectedTool) { mutableStateOf(null) } + var isHighlighterSnapEnabled by remember(documentHandleId) { mutableStateOf(false) } + var selectionStartIndex by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf(null) } + var selectionEndIndex by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf(null) } + var selectionStartHit by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf(null) } + var selectionEndHit by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf(null) } + var textSelection by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf(null) } + var selectionMenuOffset by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf(null) } + var activeSelectionHandle by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf(null) } + var pageScrubPreview by remember(documentHandleId) { mutableStateOf(null) } + var pageScrubStartPage by remember(documentHandleId) { mutableStateOf(null) } + var showPdfZoomIndicator by remember(documentHandleId) { mutableStateOf(false) } + var isPdfZoomIndicatorInitialized by remember(documentHandleId) { mutableStateOf(false) } + var jumpHistory by remember(documentHandleId) { mutableStateOf(SharedPdfJumpHistory()) } + var externalLinkDialogUrl by remember(documentHandleId) { mutableStateOf(null) } + var pdfExtrasState by remember(documentHandleId) { mutableStateOf( ReaderExtrasState( cloudTts = ReaderCloudTtsState( @@ -4225,29 +2494,32 @@ private fun PdfReaderScreen( ) ) } - var pdfTtsJob by remember(document.path) { mutableStateOf(null) } - val annotationFile = remember(document.path) { desktopPdfAnnotationFile(document.path) } - val bookmarkFile = remember(document.path) { desktopPdfBookmarkFile(document.path) } - val richTextFile = remember(document.path) { desktopPdfRichTextFile(document.path) } - val searchIndexFile = remember(document.path) { desktopPdfSearchIndexFile(document.path) } + var pdfTtsJob by remember(documentHandleId) { mutableStateOf(null) } + val annotationFile = remember(documentHandleId) { desktopPdfAnnotationFile(document.path) } + val bookmarkFile = remember(documentHandleId) { desktopPdfBookmarkFile(document.path) } + val richTextFile = remember(documentHandleId) { desktopPdfRichTextFile(document.path) } + val searchIndexFile = remember(documentHandleId) { desktopPdfSearchIndexFile(document.path) } val clipboardManager = LocalClipboardManager.current val density = LocalDensity.current val pdfScope = rememberCoroutineScope() - var isFullscreen by remember(document.path) { mutableStateOf(false) } + var isFullscreen by remember(documentHandleId) { mutableStateOf(false) } val currentPdfFullscreen by rememberUpdatedState(isFullscreen) val currentOnPdfFullscreenChange by rememberUpdatedState(onFullscreenChange) - DisposableEffect(document.path) { + DisposableEffect(documentHandleId) { onDispose { + renderJob?.cancel() + pdfTtsJob?.cancel() zoomCommitJob.getAndSet(null)?.cancel() zoomAnchorJob.getAndSet(null)?.cancel() if (currentPdfFullscreen) { currentOnPdfFullscreenChange(false) } + document.close() } } - var isRichTextMode by remember(document.path) { mutableStateOf(false) } - var isRichTextLoaded by remember(document.path) { mutableStateOf(false) } - val richTextController = remember(document.path) { + var isRichTextMode by remember(documentHandleId) { mutableStateOf(false) } + var isRichTextLoaded by remember(documentHandleId) { mutableStateOf(false) } + val richTextController = remember(documentHandleId) { SharedPdfRichTextController( scope = pdfScope, onDocumentChange = { richDocument -> @@ -4290,14 +2562,14 @@ private fun PdfReaderScreen( ?.verticalFirstPageScrollOffset ?: 0 ) - val pdfReaderFocusRequester = remember(document.path) { FocusRequester() } + val pdfReaderFocusRequester = remember(documentHandleId) { FocusRequester() } val currentTextSelection by rememberUpdatedState(textSelection) val currentPdfAnnotations by rememberUpdatedState(pdfState.annotations) val currentPdfPageIndex by rememberUpdatedState(pdfState.pageIndex) val currentPdfScale by rememberUpdatedState(pdfState.zoom) val currentPdfDisplayMode by rememberUpdatedState(pdfState.displayMode) - LaunchedEffect(isFullscreen, document.path) { + LaunchedEffect(isFullscreen, documentHandleId) { repeat(if (isFullscreen) 4 else 1) { attempt -> delay(if (attempt == 0) 80L else 120L) runCatching { pdfReaderFocusRequester.requestFocus() } @@ -4700,7 +2972,7 @@ private fun PdfReaderScreen( } } - LaunchedEffect(document.path, pageIndex, displayMode) { + LaunchedEffect(documentHandleId, pageIndex, displayMode) { runCatching { pdfReaderFocusRequester.requestFocus() } } @@ -4739,7 +3011,7 @@ private fun PdfReaderScreen( return ((pageIndex + 1).toFloat() / document.pageCount.coerceAtLeast(1)) * 100f } - var latestPdfViewport by remember(document.path) { + var latestPdfViewport by remember(documentHandleId) { mutableStateOf(restoredInitialViewport ?: pdfViewportSnapshot()) } @@ -4756,12 +3028,12 @@ private fun PdfReaderScreen( val end = (pageIndex + 1).coerceAtMost((document.pageCount - 1).coerceAtLeast(0)) start..end } - var arePdfAnnotationsLoaded by remember(document.path) { mutableStateOf(false) } - var arePdfBookmarksLoaded by remember(document.path) { mutableStateOf(false) } - var indexedSearchPageCount by remember(document.path) { mutableStateOf(document.indexedSearchTextPageCount()) } - var isSearchIndexing by remember(document.path) { mutableStateOf(false) } - var searchResults by remember(document.path) { mutableStateOf>(emptyList()) } - var selectedEmbeddedAnnotationId by remember(document.path) { mutableStateOf(null) } + var arePdfAnnotationsLoaded by remember(documentHandleId) { mutableStateOf(false) } + var arePdfBookmarksLoaded by remember(documentHandleId) { mutableStateOf(false) } + var indexedSearchPageCount by remember(documentHandleId) { mutableStateOf(document.indexedSearchTextPageCount()) } + var isSearchIndexing by remember(documentHandleId) { mutableStateOf(false) } + var searchResults by remember(documentHandleId) { mutableStateOf>(emptyList()) } + var selectedEmbeddedAnnotationId by remember(documentHandleId) { mutableStateOf(null) } val selectedAnnotation = remember(annotations, selectedAnnotationId) { annotations.firstOrNull { it.id == selectedAnnotationId } } @@ -4814,7 +3086,7 @@ private fun PdfReaderScreen( selectedEmbeddedAnnotation != null || pdfExtrasState.aiResult.hasContent || (textSelection != null && selectionMenuOffset != null) - LaunchedEffect(pdfPopupActive, document.path) { + LaunchedEffect(pdfPopupActive, documentHandleId) { if (!pdfPopupActive) { delay(120L) runCatching { pdfReaderFocusRequester.requestFocus() } @@ -4831,7 +3103,7 @@ private fun PdfReaderScreen( ) } - LaunchedEffect(document.path) { + LaunchedEffect(documentHandleId) { arePdfAnnotationsLoaded = false val loadedAnnotations = if (annotationFile.exists()) { withContext(Dispatchers.IO) { @@ -4844,7 +3116,7 @@ private fun PdfReaderScreen( arePdfAnnotationsLoaded = true } - LaunchedEffect(document.path, annotations, arePdfAnnotationsLoaded) { + LaunchedEffect(documentHandleId, annotations, arePdfAnnotationsLoaded) { if (!arePdfAnnotationsLoaded) return@LaunchedEffect withContext(Dispatchers.IO) { runCatching { @@ -4855,7 +3127,7 @@ private fun PdfReaderScreen( onLocalSidecarsChanged() } - LaunchedEffect(document.path) { + LaunchedEffect(documentHandleId) { isRichTextLoaded = false SharedPdfRichTextLog.d( "desktop.loadRichText start path=\"${richTextFile.absolutePath.logPreview(160)}\" exists=${richTextFile.exists()}" @@ -4879,7 +3151,7 @@ private fun PdfReaderScreen( SharedPdfRichTextLog.d("desktop.loadRichText ready") } - LaunchedEffect(document.path) { + LaunchedEffect(documentHandleId) { arePdfBookmarksLoaded = false val loadedBookmarks = if (bookmarkFile.exists()) { withContext(Dispatchers.IO) { @@ -4892,7 +3164,7 @@ private fun PdfReaderScreen( arePdfBookmarksLoaded = true } - LaunchedEffect(document.path, bookmarks, arePdfBookmarksLoaded) { + LaunchedEffect(documentHandleId, bookmarks, arePdfBookmarksLoaded) { if (!arePdfBookmarksLoaded) return@LaunchedEffect withContext(Dispatchers.IO) { runCatching { @@ -4903,7 +3175,7 @@ private fun PdfReaderScreen( onLocalSidecarsChanged() } - LaunchedEffect(document.path) { + LaunchedEffect(documentHandleId) { val restoredPageCount = withContext(Dispatchers.IO) { restoreDesktopPdfSearchIndex(document, searchIndexFile) } @@ -4931,7 +3203,7 @@ private fun PdfReaderScreen( logPdfZoomPerf { "search_index_done indexed=$indexedSearchPageCount/${document.pageCount}" } } - LaunchedEffect(document.path, searchQuery, indexedSearchPageCount) { + LaunchedEffect(documentHandleId, searchQuery, indexedSearchPageCount) { val normalizedQuery = searchQuery.trim() searchResults = if (normalizedQuery.isBlank()) { emptyList() @@ -5501,11 +3773,11 @@ private fun PdfReaderScreen( } } - LaunchedEffect(document.path, document.pageCount) { + LaunchedEffect(documentHandleId, document.pageCount) { jumpHistory = jumpHistory.pruned(document.pageCount) } - LaunchedEffect(document.path, document.pageCount) { + LaunchedEffect(documentHandleId, document.pageCount) { snapshotFlow { pdfViewportSnapshot() } .distinctUntilChanged() .collectLatest { viewport -> @@ -5515,14 +3787,14 @@ private fun PdfReaderScreen( } } - DisposableEffect(document.path) { + DisposableEffect(documentHandleId) { onDispose { persistPdfViewport() } } - var pendingInitialViewportRestore by remember(document.path) { mutableStateOf(restoredInitialViewport) } - LaunchedEffect(document.path, displayMode) { + var pendingInitialViewportRestore by remember(documentHandleId) { mutableStateOf(restoredInitialViewport) } + LaunchedEffect(documentHandleId, displayMode) { if (displayMode == PdfDisplayMode.VERTICAL_SCROLL && pageIndex in 0 until document.pageCount) { if (pendingInitialViewportRestore?.displayMode == PdfDisplayMode.VERTICAL_SCROLL) return@LaunchedEffect verticalListState.scrollToItem(pageIndex) @@ -5530,7 +3802,7 @@ private fun PdfReaderScreen( } LaunchedEffect( - document.path, + documentHandleId, pendingInitialViewportRestore, displayMode, renderedPageIndex, @@ -5589,7 +3861,7 @@ private fun PdfReaderScreen( goToPage(pageIndex + 1) } - LaunchedEffect(document.path, displayMode, verticalListState) { + LaunchedEffect(documentHandleId, displayMode, verticalListState) { if (displayMode != PdfDisplayMode.VERTICAL_SCROLL) return@LaunchedEffect snapshotFlow { val layoutInfo = verticalListState.layoutInfo @@ -5619,7 +3891,7 @@ private fun PdfReaderScreen( } } - LaunchedEffect(document.path, pageIndex, scale, displayMode) { + LaunchedEffect(documentHandleId, pageIndex, scale, displayMode) { renderJob?.cancel() if (displayMode != PdfDisplayMode.PAGINATION) { isRendering = false @@ -5981,10 +4253,10 @@ private fun PdfReaderScreen( @Composable fun PdfNavigationSidebar() { val tabs = listOf("TOC", "Annotations", "Bookmarks", "Pages") - var selectedTabIndex by remember(document.path) { mutableStateOf(0) } + var selectedTabIndex by remember(documentHandleId) { mutableStateOf(0) } val navigationScope = rememberCoroutineScope() val pdfTocParentIndices = remember(document.toc) { desktopPdfTocParentIndices(document.toc) } - var expandedPdfTocEntryIndices by remember(document.path, document.toc) { + var expandedPdfTocEntryIndices by remember(documentHandleId, document.toc) { mutableStateOf(pdfTocParentIndices) } @@ -6440,7 +4712,7 @@ private fun PdfReaderScreen( .focusable(), leftSidebar = { _ -> PdfNavigationSidebar() }, rightInspector = { - var selectedPdfInspectorTab by remember(document.path) { mutableStateOf(DesktopPdfInspectorTab.VIEW) } + var selectedPdfInspectorTab by remember(documentHandleId) { mutableStateOf(DesktopPdfInspectorTab.VIEW) } val viewInspectorListState = rememberLazyListState() val markupInspectorListState = rememberLazyListState() val assistInspectorListState = rememberLazyListState() @@ -7594,1126 +5866,6 @@ private fun PdfReaderScreen( } } -@Composable -private fun DesktopPdfFullscreenBottomChrome( - pageIndex: Int, - pageCount: Int, - showJumpHistory: Boolean, - jumpBackPage: Int?, - jumpForwardPage: Int?, - onPrevious: () -> Unit, - onNext: () -> Unit, - onPageScrub: (Float) -> Unit, - onPageScrubFinished: () -> Unit, - onJumpBack: () -> Unit, - onJumpForward: () -> Unit, - onClearJumpHistory: () -> Unit -) { - val chromeBackground = MaterialTheme.colorScheme.surface - val chromeContent = MaterialTheme.colorScheme.onSurface - val sliderActive = MaterialTheme.colorScheme.primary - val sliderInactive = MaterialTheme.colorScheme.surfaceVariant - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, top = 6.dp, end = 16.dp, bottom = 0.dp), - shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp), - color = chromeBackground, - contentColor = chromeContent, - tonalElevation = 0.dp, - shadowElevation = 1.dp, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) - ) { - Column(modifier = Modifier.fillMaxWidth()) { - val hasJumpTargets = jumpBackPage != null || jumpForwardPage != null - DesktopPdfJumpHistoryControls( - visible = showJumpHistory, - backPage = jumpBackPage, - forwardPage = jumpForwardPage, - onBack = onJumpBack, - onForward = onJumpForward, - onClear = onClearJumpHistory - ) - if (showJumpHistory && hasJumpTargets) { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - } - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - val canGoPrevious = pageIndex > 0 - val canGoNext = pageIndex < pageCount - 1 - IconButton(onClick = onPrevious, enabled = canGoPrevious) { - Icon( - Icons.AutoMirrored.Filled.NavigateBefore, - contentDescription = "Previous page", - tint = chromeContent.copy(alpha = if (canGoPrevious) 0.78f else 0.32f) - ) - } - ReaderMinimalSlider( - value = pageIndex.toFloat(), - onValueChange = onPageScrub, - onValueChangeFinished = onPageScrubFinished, - valueRange = 0f..(pageCount - 1).coerceAtLeast(0).toFloat(), - enabled = pageCount > 1, - activeColor = sliderActive, - inactiveColor = sliderInactive, - thumbColor = sliderActive, - modifier = Modifier.weight(1f) - ) - IconButton(onClick = onNext, enabled = canGoNext) { - Icon( - Icons.AutoMirrored.Filled.NavigateNext, - contentDescription = "Next page", - tint = chromeContent.copy(alpha = if (canGoNext) 0.78f else 0.32f) - ) - } - } - } - } -} - -@Composable -private fun DesktopPdfZoomPercentageIndicator( - percentage: Int, - onResetZoomClick: () -> Unit -) { - Surface( - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.8f) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) - ) { - Text( - text = "$percentage%", - color = Color.White, - style = MaterialTheme.typography.bodyLarge - ) - Spacer(Modifier.width(8.dp)) - Box( - modifier = Modifier - .width(1.dp) - .height(16.dp) - .background(Color.White.copy(alpha = 0.5f)) - ) - Spacer(Modifier.width(8.dp)) - Icon( - imageVector = Icons.Default.ZoomOut, - contentDescription = "Reset zoom", - tint = Color.White, - modifier = Modifier - .size(20.dp) - .clip(RoundedCornerShape(4.dp)) - .clickable(onClick = onResetZoomClick) - ) - } - } -} - -@Composable -private fun DesktopPdfSearchTopBar( - query: String, - showResultsPanel: Boolean, - onQueryChange: (String) -> Unit, - onClose: () -> Unit, - onToggleResults: () -> Unit -) { - val focusRequester = remember { FocusRequester() } - - LaunchedEffect(Unit) { - delay(80) - runCatching { focusRequester.requestFocus() } - } - - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(6.dp), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 2.dp - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) { - Icon(Icons.Default.Close, contentDescription = "Close search") - } - SharedStableOutlinedTextField( - value = query, - onValueChange = onQueryChange, - placeholder = { Text("Search in PDF") }, - singleLine = true, - modifier = Modifier.weight(1f).focusRequester(focusRequester), - trailingIcon = if (query.isNotEmpty()) { - { - IconButton(onClick = { onQueryChange("") }) { - Icon(Icons.Default.Close, contentDescription = "Clear search") - } - } - } else { - null - }, - selectionKey = "desktop-pdf-search" - ) - IconButton(onClick = onToggleResults, modifier = Modifier.size(36.dp)) { - Icon( - if (showResultsPanel) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, - contentDescription = if (showResultsPanel) "Hide search results" else "Show search results" - ) - } - } - } -} - -@Composable -private fun BoxScope.DesktopPdfSearchOverlay( - isSearchActive: Boolean, - showResultsPanel: Boolean, - query: String, - results: List, - activeSearchIndex: Int, - highlightMode: SearchHighlightMode, - isIndexing: Boolean, - indexedPageCount: Int, - pageCount: Int, - onResultClick: (Int) -> Unit, - onShowResults: () -> Unit, - onPrevious: () -> Unit, - onNext: () -> Unit, - onToggleHighlightMode: () -> Unit -) { - AnimatedVisibility( - visible = isSearchActive && showResultsPanel, - enter = slideInVertically { -it } + fadeIn(), - exit = slideOutVertically { -it } + fadeOut(), - modifier = Modifier.fillMaxSize().zIndex(30f) - ) { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - Column(Modifier.fillMaxSize()) { - if (isIndexing) { - val progress = indexedPageCount.toFloat() / pageCount.coerceAtLeast(1).toFloat() - Surface( - color = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.fillMaxWidth() - ) { - Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp)) { - Text( - "Indexing ${indexedPageCount.coerceAtMost(pageCount)}/$pageCount pages", - style = MaterialTheme.typography.bodySmall - ) - LinearProgressIndicator( - progress = { progress.coerceIn(0f, 1f) }, - modifier = Modifier.fillMaxWidth().padding(top = 6.dp) - ) - } - } - } - - when { - query.isBlank() -> { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("Type to search this PDF", color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - - results.isEmpty() -> { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - if (isIndexing) "No matches in indexed pages yet" else "No matches", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - else -> { - Text( - when { - isIndexing -> "${results.size} matches so far" - else -> "${results.size} matches" - }, - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) - ) - HorizontalDivider() - LazyColumn(Modifier.fillMaxSize()) { - itemsIndexed( - items = results, - key = { index, result -> "${result.pageIndex}_${result.matchIndex}_$index" } - ) { index, result -> - Surface( - modifier = Modifier.fillMaxWidth().clickable { onResultClick(index) }, - color = if (index == activeSearchIndex) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surface - } - ) { - Column( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - "Page ${result.pageIndex + 1}", - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - result.preview, - style = MaterialTheme.typography.bodyMedium, - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) - } - } - HorizontalDivider() - } - } - } - } - } - } - } - - AnimatedVisibility( - visible = isSearchActive && !showResultsPanel && results.isNotEmpty(), - enter = fadeIn(), - exit = fadeOut(), - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 18.dp) - .zIndex(31f) - ) { - DesktopPdfSearchNavigationPill( - activeSearchIndex = activeSearchIndex, - resultCount = results.size, - highlightMode = highlightMode, - onShowResults = onShowResults, - onPrevious = onPrevious, - onNext = onNext, - onToggleHighlightMode = onToggleHighlightMode - ) - } -} - -@Composable -private fun DesktopPdfSearchNavigationPill( - activeSearchIndex: Int, - resultCount: Int, - highlightMode: SearchHighlightMode, - onShowResults: () -> Unit, - onPrevious: () -> Unit, - onNext: () -> Unit, - onToggleHighlightMode: () -> Unit -) { - Surface( - shape = RoundedCornerShape(50), - color = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant, - tonalElevation = 6.dp, - shadowElevation = 8.dp - ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - IconButton(onClick = onToggleHighlightMode, modifier = Modifier.size(36.dp)) { - Icon( - if (highlightMode == SearchHighlightMode.ALL) Icons.Default.Visibility else Icons.Default.VisibilityOff, - contentDescription = "Toggle search highlights", - tint = if (highlightMode == SearchHighlightMode.ALL) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - ) - } - IconButton(onClick = onPrevious, enabled = resultCount > 0, modifier = Modifier.size(36.dp)) { - Icon(Icons.AutoMirrored.Filled.NavigateBefore, contentDescription = "Previous search result") - } - Text( - text = if (activeSearchIndex in 0 until resultCount) { - "${activeSearchIndex + 1}/$resultCount" - } else { - "$resultCount matches" - }, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.clickable(onClick = onShowResults).padding(horizontal = 8.dp) - ) - IconButton(onClick = onNext, enabled = resultCount > 0, modifier = Modifier.size(36.dp)) { - Icon(Icons.AutoMirrored.Filled.NavigateNext, contentDescription = "Next search result") - } - } - } -} - -@Composable -private fun DesktopReaderBottomSheet( - title: String, - onDismiss: () -> Unit, - modifier: Modifier = Modifier, - content: @Composable () -> Unit -) { - SharedReaderPopupLayer(onDismiss = onDismiss) { - BoxWithConstraints( - modifier = modifier - .fillMaxSize() - .zIndex(40f) - ) { - Box( - modifier = Modifier - .matchParentSize() - .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.24f)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onDismiss - ) - ) - val sheetHorizontalPadding = 24.dp - val sheetAvailableWidth = (maxWidth - sheetHorizontalPadding - sheetHorizontalPadding).coerceAtLeast(0.dp) - Surface( - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(horizontal = sheetHorizontalPadding, vertical = 16.dp) - .width(sharedReaderPopupWidth(sheetAvailableWidth)) - .heightIn(max = 560.dp), - shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp, bottomStart = 10.dp, bottomEnd = 10.dp), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 8.dp, - shadowElevation = 16.dp - ) { - Column( - modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Box( - modifier = Modifier - .align(Alignment.CenterHorizontally) - .width(42.dp) - .height(4.dp) - .background(MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(999.dp)) - ) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - IconButton(onClick = onDismiss) { - Icon(Icons.Default.Close, contentDescription = "Close") - } - } - HorizontalDivider() - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - content() - } - } - } - } - } -} - -@Composable -private fun DesktopReaderAiResultSheet( - result: ReaderAiResultState, - onDismiss: () -> Unit -) { - DesktopReaderBottomSheet( - title = result.title ?: "AI", - onDismiss = onDismiss - ) { - val errorMessage = result.errorMessage - when { - result.isLoading -> Text("Working...", color = MaterialTheme.colorScheme.onSurfaceVariant) - errorMessage != null -> Text(errorMessage, color = MaterialTheme.colorScheme.error) - else -> SharedMarkdownText(result.text) - } - } -} - -@Composable -private fun DesktopAiByokSettingsDialog( - settings: ReaderAiByokSettings, - secureStorageAvailable: Boolean, - onSettingsChange: (ReaderAiByokSettings) -> Unit, - onDismiss: () -> Unit -) { - val sanitized = settings.sanitized() - var selectedProvider by remember { mutableStateOf("gemini") } - var pendingKey by remember { mutableStateOf("") } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("AI keys and models") }, - text = { - Column( - modifier = Modifier - .heightIn(max = 640.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - if (!secureStorageAvailable) { - Text( - "Secure key storage is unavailable on this operating system. Keys entered here will be used for this session but will not be persisted.", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall - ) - } - - Text("Saved keys", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - DesktopSavedAiKeyRow( - label = "Gemini", - keyValue = sanitized.geminiKey, - onClear = { onSettingsChange(sanitized.copy(geminiKey = "", ttsModel = "")) } - ) - DesktopSavedAiKeyRow( - label = "Groq", - keyValue = sanitized.groqKey, - onClear = { onSettingsChange(sanitized.copy(groqKey = "")) } - ) - - HorizontalDivider() - - Text("Add or replace key", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { - listOf("gemini" to "Gemini", "groq" to "Groq").forEach { (provider, label) -> - FilterChip( - selected = selectedProvider == provider, - onClick = { selectedProvider = provider }, - label = { Text(label) } - ) - } - } - SharedStableOutlinedTextField( - value = pendingKey, - onValueChange = { pendingKey = it }, - label = { Text("API key") }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth() - ) - TextButton( - enabled = pendingKey.isNotBlank(), - onClick = { - val trimmed = pendingKey.trim() - val next = when (selectedProvider) { - "gemini" -> sanitized.copy( - geminiKey = trimmed, - ttsModel = sanitized.ttsModel.ifBlank { GEMINI_CLOUD_TTS_MODEL_ID } - ) - "groq" -> sanitized.copy(groqKey = trimmed) - else -> sanitized - } - onSettingsChange(next) - pendingKey = "" - }, - modifier = Modifier.align(Alignment.End) - ) { - Text("Save key") - } - - HorizontalDivider() - - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text("Show AI in reader", style = MaterialTheme.typography.titleMedium) - Text( - "Matches the Android hide toggle for smart dictionary, summaries, and recaps.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch( - checked = !sanitized.hideReaderAiFeatures, - onCheckedChange = { enabled -> - onSettingsChange(sanitized.copy(hideReaderAiFeatures = !enabled)) - } - ) - } - - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text("Use one model for all features", style = MaterialTheme.typography.titleMedium) - Text( - "Turn this off to choose separate models per reader AI feature.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch( - checked = sanitized.useOneModel, - onCheckedChange = { onSettingsChange(sanitized.copy(useOneModel = it)) } - ) - } - - if (sanitized.useOneModel) { - DesktopAiModelSelector( - title = "All AI features", - description = "Smart dictionary, summaries, and recaps all use this model.", - selectedId = sanitized.modelForAll, - onSelected = { onSettingsChange(sanitized.copy(modelForAll = it)) } - ) - } else { - DesktopAiModelSelector( - title = "Smart dictionary", - description = "Used when defining selected words or phrases.", - selectedId = sanitized.defineModel, - onSelected = { onSettingsChange(sanitized.copy(defineModel = it)) } - ) - DesktopAiModelSelector( - title = "Summaries", - description = "Used for EPUB summaries and PDF page summaries.", - selectedId = sanitized.summarizeModel, - onSelected = { onSettingsChange(sanitized.copy(summarizeModel = it)) } - ) - DesktopAiModelSelector( - title = "Recaps", - description = "Used for story recap generation.", - selectedId = sanitized.recapModel, - onSelected = { onSettingsChange(sanitized.copy(recapModel = it)) } - ) - } - - DesktopAiModelSelector( - title = "Cloud TTS", - description = "Uses the saved Gemini key. Only $GEMINI_CLOUD_TTS_MODEL is supported for now.", - selectedId = sanitized.ttsModel, - options = listOf(ReaderAiModelOption("gemini", GEMINI_CLOUD_TTS_MODEL)), - onSelected = { onSettingsChange(sanitized.copy(ttsModel = it)) } - ) - Text("Cloud TTS voice", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - ReaderCloudTtsVoices.chunked(3).forEach { rowVoices -> - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { - rowVoices.forEach { voice -> - FilterChip( - selected = sanitized.ttsSpeakerId == voice.id, - onClick = { onSettingsChange(sanitized.copy(ttsSpeakerId = voice.id)) }, - label = { - Column { - Text(voice.name, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text( - voice.description, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - ) - } - } - } - } - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text("Done") - } - } - ) -} - -@Composable -private fun DesktopSavedAiKeyRow( - label: String, - keyValue: String, - onClear: () -> Unit -) { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text(label, fontWeight = FontWeight.SemiBold) - Text( - keyValue.takeIf { it.isNotBlank() }?.let(::maskedReaderAiKey) ?: "No key saved", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - TextButton(enabled = keyValue.isNotBlank(), onClick = onClear) { - Text("Clear") - } - } -} - -@Composable -private fun DesktopAiModelSelector( - title: String, - description: String, - selectedId: String, - options: List = ReaderAiModelOptions, - onSelected: (String) -> Unit -) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) - Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { - FilterChip( - selected = selectedId.isBlank(), - onClick = { onSelected("") }, - label = { Text("No model") } - ) - options.forEach { option -> - FilterChip( - selected = selectedId == option.id, - onClick = { onSelected(option.id) }, - label = { Text(option.label) } - ) - } - } - } -} - -@Composable -private fun DesktopPdfExtrasPanel( - pageText: String, - recapText: String, - extrasState: ReaderExtrasState, - aiByokSettings: ReaderAiByokSettings, - externalLookupAvailable: Boolean, - cloudTtsFeatureAvailable: Boolean, - onExternalLookup: (ReaderExternalLookupAction, String) -> Unit, - onAiAction: (ReaderAiFeature, String) -> Unit, - onCloudTtsStart: (ReaderTtsReadScope) -> Unit, - onCloudTtsPauseResume: () -> Unit, - onCloudTtsStop: () -> Unit, - onCloudTtsClearCache: () -> Unit, - onAutoScrollChange: (ReaderAutoScrollState) -> Unit, - ttsReplacementPreferences: ReaderTtsReplacementPreferences, - ttsReplacementBookId: String, - onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit -) { - val settings = aiByokSettings.sanitized() - val autoScroll = extrasState.autoScroll.sanitized() - - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - Text("Extras", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - if (externalLookupAvailable) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { - ReaderExternalLookupAction.entries.forEach { action -> - FilterChip( - selected = false, - enabled = pageText.isNotBlank(), - onClick = { onExternalLookup(action, pageText) }, - label = { Text(action.title) } - ) - } - } - } - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text("Auto scroll", modifier = Modifier.weight(1f)) - Switch( - checked = autoScroll.enabled, - onCheckedChange = { onAutoScrollChange(autoScroll.copy(enabled = it)) } - ) - } - Slider( - value = autoScroll.speed, - onValueChange = { onAutoScrollChange(autoScroll.copy(speed = it).sanitized()) }, - valueRange = 12f..160f - ) - val ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused - if (cloudTtsFeatureAvailable) { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text( - when { - extrasState.cloudTts.isLoading -> "Preparing audio" - extrasState.cloudTts.isPaused -> "Paused" - extrasState.cloudTts.isPlaying -> "Reading" - settings.isCloudTtsAvailable -> "Cloud TTS ready" - else -> "Cloud TTS needs Gemini" - }, - fontWeight = FontWeight.SemiBold - ) - extrasState.cloudTts.errorMessage?.let { - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) - } - val statusMessage = extrasState.cloudTts.progress.currentPositionLabel - ?: extrasState.cloudTts.statusMessage?.takeIf { it.isNotBlank() } - statusMessage?.let { - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - TextButton( - enabled = settings.isCloudTtsAvailable || ttsBusy, - onClick = { - if (ttsBusy) { - onCloudTtsStop() - } else { - onCloudTtsStart(ReaderTtsReadScope.BOOK) - } - } - ) { - Text(if (ttsBusy) "Stop" else "Read") - } - } - if (extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { - TextButton(onClick = onCloudTtsPauseResume) { - Text(if (extrasState.cloudTts.isPaused) "Resume" else "Pause") - } - } - } - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { - TextButton( - enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(), - onClick = { onCloudTtsStart(ReaderTtsReadScope.PAGE) } - ) { - Text("Page") - } - TextButton( - enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(), - onClick = { onCloudTtsStart(ReaderTtsReadScope.BOOK) } - ) { - Text("From here") - } - } - val cacheSummary = extrasState.cloudTts.cacheSummary - if (cacheSummary.hasCachedAudio) { - Text( - "Cache: ${cacheSummary.currentVoiceLabel}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - if (cacheSummary.hasCurrentVoiceCachedAudio) { - TextButton(onClick = onCloudTtsClearCache) { - Text("Clear voice cache") - } - } - } - } - SharedReaderTtsReplacementControls( - preferences = ttsReplacementPreferences, - bookId = ttsReplacementBookId, - onPreferencesChange = onTtsReplacementPreferencesChange - ) - if (settings.areReaderAiFeaturesAvailable) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { - TextButton( - enabled = pageText.isNotBlank() && !extrasState.aiResult.isLoading, - onClick = { onAiAction(ReaderAiFeature.SUMMARIZE, pageText) } - ) { - Text("Summarize page") - } - TextButton( - enabled = recapText.isNotBlank() && !extrasState.aiResult.isLoading, - onClick = { onAiAction(ReaderAiFeature.RECAP, recapText) } - ) { - Text("Recap") - } - } - } - } -} - -@Composable -private fun DesktopPdfJumpHistoryControls( - visible: Boolean, - modifier: Modifier = Modifier, - backPage: Int?, - forwardPage: Int?, - onBack: () -> Unit, - onForward: () -> Unit, - onClear: () -> Unit -) { - val hasJumpTargets = backPage != null || forwardPage != null - AnimatedVisibility( - visible = visible && hasJumpTargets, - enter = slideInVertically { fullHeight -> fullHeight } + fadeIn(), - exit = slideOutVertically { fullHeight -> fullHeight } + fadeOut(), - modifier = modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(40.dp) - .padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - TextButton( - onClick = onBack, - enabled = backPage != null, - modifier = Modifier.weight(1f) - ) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Jump back", - modifier = Modifier.size(18.dp) - ) - Spacer(Modifier.width(4.dp)) - Text( - backPage?.let { "P. ${it + 1}" } ?: "", - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - - TextButton( - onClick = onClear, - modifier = Modifier.weight(0.8f) - ) { - Icon( - Icons.Default.Close, - contentDescription = "Clear jump history", - modifier = Modifier.size(18.dp) - ) - Spacer(Modifier.width(4.dp)) - Text("Clear", maxLines = 1, overflow = TextOverflow.Ellipsis) - } - - TextButton( - onClick = onForward, - enabled = forwardPage != null, - modifier = Modifier.weight(1f) - ) { - Text( - forwardPage?.let { "P. ${it + 1}" } ?: "", - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Spacer(Modifier.width(4.dp)) - Icon( - Icons.AutoMirrored.Filled.ArrowForward, - contentDescription = "Jump forward", - modifier = Modifier.size(18.dp) - ) - } - } - } -} - -private fun desktopPdfTocParentIndices(toc: List): Set { - return toc.indices.filter { index -> - val next = toc.getOrNull(index + 1) - next != null && next.nestLevel > toc[index].nestLevel - }.toSet() -} - -private fun desktopPdfTocAncestorIndices( - toc: List, - originalIndex: Int -): Set { - val targetDepth = toc.getOrNull(originalIndex)?.nestLevel ?: return emptySet() - val ancestors = mutableSetOf() - var currentDepth = targetDepth - for (index in originalIndex downTo 0) { - val entry = toc[index] - if (entry.nestLevel < currentDepth) { - ancestors += index - currentDepth = entry.nestLevel - } - if (currentDepth == 0) break - } - return ancestors -} - -private fun desktopVisiblePdfTocEntries( - toc: List, - expandedIndices: Set -): List> { - val result = mutableListOf>() - val visibilityStack = BooleanArray(50) { false } - visibilityStack[0] = true - - toc.forEachIndexed { index, entry -> - val depth = entry.nestLevel.coerceIn(0, visibilityStack.lastIndex) - if (visibilityStack[depth]) { - result += index to entry - if (depth + 1 < visibilityStack.size) { - visibilityStack[depth + 1] = index in expandedIndices - } - } else if (depth + 1 < visibilityStack.size) { - visibilityStack[depth + 1] = false - } - } - return result -} - -@Composable -private fun DesktopPdfTocTreeItem( - entry: PdfTocEntry, - selected: Boolean, - hasChildren: Boolean, - isExpanded: Boolean, - onToggleExpand: () -> Unit, - onClick: () -> Unit -) { - Surface( - color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth().clickable { onClick() } - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 46.dp) - .padding(start = (entry.nestLevel.coerceAtLeast(0) * 14).dp) - .padding(horizontal = 4.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(34.dp) - .clickable(enabled = hasChildren) { onToggleExpand() }, - contentAlignment = Alignment.Center - ) { - if (hasChildren) { - Icon( - imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = if (isExpanded) "Collapse" else "Expand", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - Text( - entry.title, - fontWeight = if (selected) FontWeight.Bold else if (entry.nestLevel == 0) FontWeight.SemiBold else FontWeight.Normal, - color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - Text( - "p. ${entry.pageIndex + 1}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 8.dp) - ) - } - } -} - -@Composable -private fun DesktopPdfNavigationEmpty(message: String) { - Box( - modifier = Modifier.fillMaxSize().padding(16.dp), - contentAlignment = Alignment.Center - ) { - Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant) - } -} - -@Composable -private fun DesktopPdfThumbnailTile( - document: DesktopPdfDocument, - pageIndex: Int, - selected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - var thumbnail by remember(document.path, pageIndex) { mutableStateOf(null) } - var renderFailed by remember(document.path, pageIndex) { mutableStateOf(false) } - val pageSize = document.pageSizes.getOrNull(pageIndex) - val thumbnailScale = remember(pageSize) { - val width = pageSize?.width?.coerceAtLeast(1f) ?: 612f - (120f / width).coerceIn(0.08f, 0.35f) - } - - LaunchedEffect(document.path, pageIndex, thumbnailScale) { - thumbnail = null - renderFailed = false - val rendered = withContext(Dispatchers.IO) { - runCatching { - DesktopPdfium.renderPage( - document = document, - pageIndex = pageIndex, - scale = thumbnailScale, - renderAnnotations = false - ) - }.getOrNull() - } - thumbnail = rendered - renderFailed = rendered == null - } - - Surface( - modifier = modifier.aspectRatio(0.707f).clickable(onClick = onClick), - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - border = BorderStroke( - width = if (selected) 2.dp else 1.dp, - color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant - ) - ) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - val render = thumbnail - if (render != null) { - Image( - bitmap = render.image, - contentDescription = "Page ${pageIndex + 1}", - contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxSize().padding(3.dp) - ) - } else { - Text( - if (renderFailed) "!" else "${pageIndex + 1}", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Text( - text = "${pageIndex + 1}", - style = MaterialTheme.typography.labelSmall, - color = Color.White, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(4.dp) - .background(Color.Black.copy(alpha = 0.58f), RoundedCornerShape(4.dp)) - .padding(horizontal = 5.dp, vertical = 1.dp) - ) - } - } -} - -@Composable -private fun DesktopPdfPageScrubOverlay( - pageIndex: Int?, - pageCount: Int -) { - if (pageIndex == null || pageCount <= 0) return - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Surface( - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), - shape = RoundedCornerShape(16.dp), - tonalElevation = 6.dp, - shadowElevation = 8.dp - ) { - Text( - text = "Page ${pageIndex + 1} of $pageCount", - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp) - ) - } - } -} - @Composable private fun DesktopVerticalPdfPage( document: DesktopPdfDocument, @@ -8765,21 +5917,22 @@ private fun DesktopVerticalPdfPage( onPan: (Offset) -> Unit, onPagePositioned: (Int, Offset) -> Unit ) { + val documentHandleId = document.handleId val density = LocalDensity.current - var renderedPage by remember(document.path, pageIndex) { mutableStateOf(null) } - var renderError by remember(document.path, pageIndex) { mutableStateOf(null) } - var isRendering by remember(document.path, pageIndex) { mutableStateOf(true) } - var pageCanvasSize by remember(document.path, pageIndex) { mutableStateOf(IntSize.Zero) } - var pageRootOffset by remember(document.path, pageIndex) { mutableStateOf(Offset.Zero) } - var selectionStartIndex by remember(document.path, pageIndex) { mutableStateOf(null) } - var selectionEndIndex by remember(document.path, pageIndex) { mutableStateOf(null) } - var selectionStartHit by remember(document.path, pageIndex) { mutableStateOf(null) } - var selectionEndHit by remember(document.path, pageIndex) { mutableStateOf(null) } - var textSelection by remember(document.path, pageIndex) { mutableStateOf(null) } - var selectionMenuOffset by remember(document.path, pageIndex) { mutableStateOf(null) } - var activeSelectionHandle by remember(document.path, pageIndex) { mutableStateOf(null) } - var activeStroke by remember(document.path, pageIndex, selectedTool) { mutableStateOf>(emptyList()) } - var eraserPosition by remember(document.path, pageIndex, selectedTool) { mutableStateOf(null) } + var renderedPage by remember(documentHandleId, pageIndex) { mutableStateOf(null) } + var renderError by remember(documentHandleId, pageIndex) { mutableStateOf(null) } + var isRendering by remember(documentHandleId, pageIndex) { mutableStateOf(true) } + var pageCanvasSize by remember(documentHandleId, pageIndex) { mutableStateOf(IntSize.Zero) } + var pageRootOffset by remember(documentHandleId, pageIndex) { mutableStateOf(Offset.Zero) } + var selectionStartIndex by remember(documentHandleId, pageIndex) { mutableStateOf(null) } + var selectionEndIndex by remember(documentHandleId, pageIndex) { mutableStateOf(null) } + var selectionStartHit by remember(documentHandleId, pageIndex) { mutableStateOf(null) } + var selectionEndHit by remember(documentHandleId, pageIndex) { mutableStateOf(null) } + var textSelection by remember(documentHandleId, pageIndex) { mutableStateOf(null) } + var selectionMenuOffset by remember(documentHandleId, pageIndex) { mutableStateOf(null) } + var activeSelectionHandle by remember(documentHandleId, pageIndex) { mutableStateOf(null) } + var activeStroke by remember(documentHandleId, pageIndex, selectedTool) { mutableStateOf>(emptyList()) } + var eraserPosition by remember(documentHandleId, pageIndex, selectedTool) { mutableStateOf(null) } val currentTextSelection by rememberUpdatedState(textSelection) val currentAnnotations by rememberUpdatedState(annotations) @@ -8799,7 +5952,7 @@ private fun DesktopVerticalPdfPage( eraserPosition = null } - LaunchedEffect(document.path, pageIndex, scale, shouldRender) { + LaunchedEffect(documentHandleId, pageIndex, scale, shouldRender) { if (!shouldRender) { renderedPage = null renderError = null @@ -9492,1186 +6645,6 @@ private fun DesktopVerticalPdfPage( } } -@Composable -private fun DesktopPdfAnnotationEditor( - annotation: SharedPdfAnnotation, - onUpdate: (SharedPdfAnnotation) -> Unit, - onDelete: () -> Unit, - onClose: () -> Unit, - onCopy: () -> Unit, - showSearch: Boolean, - highlighterPalette: List = SharedPdfHighlighterPalette.defaultColors, - onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit = {}, - onSearch: () -> Unit -) { - val highlighterColors = remember(highlighterPalette) { - SharedPdfAndroidHighlightColors.palette - } - var editingHighlighterSlot by remember(annotation.id, highlighterColors) { mutableStateOf(null) } - val isHighlighterAnnotation = annotation.kind == PdfAnnotationKind.HIGHLIGHT || - annotation.tool == PdfInkTool.HIGHLIGHTER || - annotation.tool == PdfInkTool.HIGHLIGHTER_ROUND - - Surface( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(12.dp), - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.padding(2.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - "Selected ${annotation.desktopLabel()}", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f) - ) - TextButton(onClick = onClose) { - Text("Close") - } - } - Text( - "Page ${annotation.pageIndex + 1}", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall - ) - if (annotation.text.isNotBlank()) { - Surface( - color = Color(annotation.colorArgb).copy(alpha = 0.10f), - shape = RoundedCornerShape(12.dp), - border = BorderStroke(1.dp, Color(annotation.colorArgb).copy(alpha = 0.28f)), - modifier = Modifier.fillMaxWidth() - ) { - Row(modifier = Modifier.heightIn(min = 72.dp)) { - Box( - modifier = Modifier - .width(6.dp) - .fillMaxHeight() - .background(Color(annotation.colorArgb)) - ) - Text( - "\"${annotation.text}\"", - style = MaterialTheme.typography.bodyMedium, - maxLines = 4, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.88f), - modifier = Modifier.padding(14.dp) - ) - } - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - DesktopBottomSheetToolButton( - icon = Icons.Default.ContentCopy, - label = "Copy", - onClick = onCopy - ) - if (showSearch) { - DesktopBottomSheetToolButton( - icon = Icons.Default.Search, - label = "Search", - onClick = onSearch - ) - } - } - } - if (annotation.kind == PdfAnnotationKind.TEXT) { - SharedStableOutlinedTextField( - value = annotation.text, - onValueChange = { onUpdate(annotation.copy(text = it)) }, - label = { Text("Text note") }, - minLines = 2, - modifier = Modifier.fillMaxWidth(), - selectionKey = annotation.id - ) - SharedPdfTextStyleControls( - style = annotation.sharedPdfTextStyle(), - onStyleChange = { onUpdate(annotation.withSharedPdfTextStyle(it)) } - ) - } - if (annotation.kind != PdfAnnotationKind.TEXT) { - val palette = if (isHighlighterAnnotation) { - highlighterColors - } else { - SharedPdfAnnotationDefaults.penPalette - } - Text("Color", style = MaterialTheme.typography.labelLarge) - Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - palette.forEachIndexed { _, argb -> - Surface( - modifier = Modifier - .size(26.dp) - .clickable { - val nextColor = if (isHighlighterAnnotation) { - SharedPdfAndroidHighlightColors.nearestArgb(argb) - } else { - argb - } - onUpdate(annotation.copy(colorArgb = nextColor)) - }, - color = Color(argb), - shape = RoundedCornerShape(13.dp), - content = {} - ) - } - if (isHighlighterAnnotation) { - Box( - modifier = Modifier - .size(30.dp) - .clip(RoundedCornerShape(15.dp)) - .background( - Brush.sweepGradient( - listOf( - Color.Red, - Color.Yellow, - Color.Green, - Color.Cyan, - Color.Blue, - Color.Magenta, - Color.Red - ) - ) - ) - .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.32f), RoundedCornerShape(15.dp)) - .clickable { - editingHighlighterSlot = highlighterColors - .indexOf(annotation.colorArgb) - .takeIf { it >= 0 } - ?: 0 - } - ) - } - } - SharedStableOutlinedTextField( - value = annotation.note.orEmpty(), - onValueChange = { note -> onUpdate(annotation.copy(note = note.takeIf { it.isNotBlank() })) }, - label = { Text("Note") }, - minLines = 3, - maxLines = 5, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - selectionKey = annotation.id - ) - } - if (annotation.kind == PdfAnnotationKind.INK) { - val strokeRange = annotation.tool.sharedPdfStrokeWidthRange() - val strokeValue = annotation.strokeWidth.coerceIn(strokeRange.start, strokeRange.endInclusive) - Text("Thickness ${strokeValue.sharedPdfStrokePercent(strokeRange)}", style = MaterialTheme.typography.labelLarge) - Slider( - value = strokeValue, - onValueChange = { onUpdate(annotation.copy(strokeWidth = it.coerceAtLeast(0.0001f))) }, - valueRange = strokeRange - ) - } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TextButton(onClick = onDelete) { - Text("Delete") - } - } - } - } - editingHighlighterSlot?.let { requestedSlot -> - val slot = requestedSlot.coerceIn(0, highlighterColors.lastIndex) - val initialColor = Color(highlighterColors[slot]).copy(alpha = 1f) - SharedHsvColorPickerDialog( - initialColor = initialColor, - title = "Highlight color ${slot + 1}", - onDismiss = { editingHighlighterSlot = null }, - onSave = { color -> - val nextArgb = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb() - val syncedArgb = SharedPdfAndroidHighlightColors.nearestArgb(nextArgb) - onHighlighterPaletteChange( - SharedPdfHighlighterPalette(highlighterColors).withColorAt( - slotIndex = slot, - colorArgb = nextArgb - ) - ) - onUpdate(annotation.copy(colorArgb = syncedArgb)) - editingHighlighterSlot = null - } - ) { liveColor -> - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - highlighterColors.forEachIndexed { index, argb -> - val color = if (index == slot) liveColor else Color(argb).copy(alpha = 1f) - Box( - modifier = Modifier - .size(42.dp) - .clip(RoundedCornerShape(21.dp)) - .background(color) - .border( - width = if (index == slot) 3.dp else 1.dp, - color = if (index == slot) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.35f), - shape = RoundedCornerShape(21.dp) - ) - .clickable { editingHighlighterSlot = index }, - contentAlignment = Alignment.Center - ) { - Text( - text = "${index + 1}", - color = if (color.luminance() > 0.5f) Color.Black else Color.White, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold - ) - } - } - } - } - } -} - -@Composable -private fun DesktopBottomSheetToolButton( - icon: ImageVector, - label: String, - onClick: () -> Unit -) { - Column( - modifier = Modifier - .clickable(onClick = onClick) - .padding(horizontal = 10.dp, vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - Icon( - imageVector = icon, - contentDescription = label, - tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.78f), - modifier = Modifier.size(22.dp) - ) - Text( - label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } -} - -@Composable -private fun DesktopPdfEmbeddedAnnotationPanel( - annotation: SharedPdfEmbeddedAnnotation, - onCopy: () -> Unit, - onClose: () -> Unit -) { - Surface( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - "Embedded PDF comment", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f) - ) - TextButton(onClick = onClose) { - Text("Close") - } - } - Text( - "Page ${annotation.pageIndex + 1}${annotation.author.takeIf { it.isNotBlank() }?.let { " - $it" }.orEmpty()}", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall - ) - DesktopPdfEmbeddedComment( - author = annotation.author, - contents = annotation.contents.ifBlank { "No comment" }, - depth = 0 - ) - DesktopPdfEmbeddedReplies(annotation.replies, depth = 1) - TextButton(onClick = onCopy) { - Text("Copy thread") - } - } - } -} - -@Composable -private fun DesktopPdfEmbeddedReplies( - replies: List, - depth: Int -) { - replies.forEach { reply -> - HorizontalDivider() - DesktopPdfEmbeddedComment( - author = reply.author, - contents = reply.contents, - depth = depth - ) - if (reply.replies.isNotEmpty()) { - DesktopPdfEmbeddedReplies(reply.replies, depth + 1) - } - } -} - -@Composable -private fun DesktopPdfEmbeddedComment( - author: String, - contents: String, - depth: Int -) { - Column( - modifier = Modifier.padding(start = (depth * 12).dp), - verticalArrangement = Arrangement.spacedBy(3.dp) - ) { - Text( - author.ifBlank { "Unknown" }, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold - ) - Text( - contents.ifBlank { "No comment" }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } -} - -private data class DesktopPdfTextSelection( - val text: String, - val lineBounds: List, - val startIndex: Int, - val endIndex: Int -) - -private data class DesktopPdfSelectionCanvasBounds( - val left: Float, - val top: Float, - val right: Float, - val bottom: Float -) { - val centerX: Float get() = (left + right) / 2f -} - -private fun DesktopPdfTextSelection.canvasBounds(canvasSize: IntSize): DesktopPdfSelectionCanvasBounds? { - val validBounds = lineBounds.filter { it.right > it.left && it.bottom > it.top } - if (validBounds.isEmpty() || canvasSize.width <= 0 || canvasSize.height <= 0) return null - return DesktopPdfSelectionCanvasBounds( - left = validBounds.minOf { it.left } * canvasSize.width, - top = validBounds.minOf { it.top } * canvasSize.height, - right = validBounds.maxOf { it.right } * canvasSize.width, - bottom = validBounds.maxOf { it.bottom } * canvasSize.height - ) -} - -private fun DesktopPdfTextSelection.menuAnchor( - canvasSize: IntSize, - fallback: Offset? -): Offset { - val bounds = canvasBounds(canvasSize) ?: return fallback ?: Offset.Zero - return Offset(x = bounds.centerX, y = bounds.top) -} - -private fun DesktopPdfTextSelection.startHandleOffset(canvasSize: IntSize): Offset? { - if (canvasSize.width <= 0 || canvasSize.height <= 0) return null - val first = lineBounds.firstOrNull { it.right > it.left && it.bottom > it.top } ?: return null - return Offset( - x = first.left * canvasSize.width, - y = first.bottom * canvasSize.height - ) -} - -private fun DesktopPdfTextSelection.endHandleOffset(canvasSize: IntSize): Offset? { - if (canvasSize.width <= 0 || canvasSize.height <= 0) return null - val last = lineBounds.lastOrNull { it.right > it.left && it.bottom > it.top } ?: return null - return Offset( - x = last.right * canvasSize.width, - y = last.bottom * canvasSize.height - ) -} - -private fun DesktopPdfTextSelection.handleAt( - point: Offset, - canvasSize: IntSize -): DesktopPdfSelectionHandle? { - val start = startHandleOffset(canvasSize) - val end = endHandleOffset(canvasSize) - - fun Offset.containsHandlePoint(): Boolean { - val halfWidth = DesktopPdfSelectionHandleTouchWidthPx / 2f - return point.x in (x - halfWidth)..(x + halfWidth) && - point.y in (y - DesktopPdfSelectionHandleTouchTopPx)..(y + DesktopPdfSelectionHandleTouchBottomPx) - } - - return when { - start != null && start.containsHandlePoint() -> DesktopPdfSelectionHandle.START - end != null && end.containsHandlePoint() -> DesktopPdfSelectionHandle.END - else -> null - } -} - -private data class DesktopPdfCharHit( - val index: Int, - val source: String, - val point: Offset, - val normalized: PdfNormalizedPoint -) - -private fun SharedPdfAnnotation.desktopLabel(): String { - return when (kind) { - PdfAnnotationKind.HIGHLIGHT -> "highlight" - PdfAnnotationKind.INK -> tool.name.lowercase().replace('_', ' ') - PdfAnnotationKind.TEXT -> "text note" - } -} - -private fun SharedPdfAnnotation.desktopSheetTitle(): String { - return when (kind) { - PdfAnnotationKind.HIGHLIGHT -> "Highlight" - PdfAnnotationKind.INK -> "Annotation" - PdfAnnotationKind.TEXT -> "Text note" - } -} - -private fun SharedPdfAnnotation.toDesktopPdfTextSelection(): DesktopPdfTextSelection { - return DesktopPdfTextSelection( - text = text, - lineBounds = boundsList.ifEmpty { listOfNotNull(bounds) }, - startIndex = rangeStartIndex ?: 0, - endIndex = rangeEndIndex ?: text.length - ) -} - -private fun SharedPdfEmbeddedAnnotation.threadText(): String { - return buildString { - append(author.ifBlank { "Unknown" }) - append(": ") - appendLine(contents.ifBlank { "No comment" }) - fun appendReplies(replies: List, indent: String) { - replies.forEach { reply -> - append(indent) - append(reply.author.ifBlank { "Unknown" }) - append(": ") - appendLine(reply.contents.ifBlank { "No comment" }) - appendReplies(reply.replies, "$indent ") - } - } - appendReplies(replies, " ") - }.trimEnd() -} - -private fun DesktopPdfDocument.linkAt( - pageIndex: Int, - point: Offset, - canvasSize: IntSize -): DesktopPdfLinkTarget? { - if (canvasSize.width <= 0 || canvasSize.height <= 0) return null - return DesktopPdfium.linkAt( - document = this, - pageIndex = pageIndex, - normalizedX = point.x / canvasSize.width, - normalizedY = point.y / canvasSize.height, - viewportWidth = canvasSize.width, - viewportHeight = canvasSize.height - ) -} - -@Composable -private fun PdfSearchHighlightOverlay( - bounds: List, - canvasSize: IntSize, - color: Color -) { - if (bounds.isEmpty() || canvasSize.width <= 0 || canvasSize.height <= 0) return - Canvas(Modifier.fillMaxSize()) { - bounds.forEach { rect -> - drawRect( - color = color, - topLeft = Offset(rect.left * canvasSize.width, rect.top * canvasSize.height), - size = androidx.compose.ui.geometry.Size( - (rect.right - rect.left) * canvasSize.width, - (rect.bottom - rect.top) * canvasSize.height - ) - ) - } - } -} - -@Composable -private fun PdfTextSelectionOverlay( - selection: DesktopPdfTextSelection?, - canvasSize: IntSize -) { - val bounds = selection?.lineBounds.orEmpty() - if (bounds.isEmpty()) return - Canvas(Modifier.fillMaxSize()) { - bounds.forEach { rect -> - drawRect( - color = Color(0x663B82F6), - topLeft = Offset(rect.left * canvasSize.width, rect.top * canvasSize.height), - size = androidx.compose.ui.geometry.Size( - (rect.right - rect.left) * canvasSize.width, - (rect.bottom - rect.top) * canvasSize.height - ) - ) - } - } -} - -@Composable -private fun PdfTextSelectionHandles( - selection: DesktopPdfTextSelection?, - canvasSize: IntSize, - activeHandle: DesktopPdfSelectionHandle? -) { - selection ?: return - if (canvasSize.width <= 0 || canvasSize.height <= 0) return - val density = LocalDensity.current - val handleSize = 24.dp - val handleWidthPx = with(density) { handleSize.toPx() } - val start = selection.startHandleOffset(canvasSize) - val end = selection.endHandleOffset(canvasSize) - val handleColor = MaterialTheme.colorScheme.primary - - fun Modifier.handleOffset(position: Offset): Modifier = offset { - IntOffset( - x = (position.x - handleWidthPx / 2f).roundToInt(), - y = position.y.roundToInt() - ) - } - - Box(Modifier.fillMaxSize()) { - start?.let { position -> - Icon( - imageVector = DesktopPdfSelectionMenuIcons.Teardrop, - contentDescription = "Selection start handle", - tint = handleColor.copy(alpha = if (activeHandle == DesktopPdfSelectionHandle.END) 0.72f else 1f), - modifier = Modifier - .handleOffset(position) - .size(handleSize) - .graphicsLayer { - rotationZ = 30f - transformOrigin = TransformOrigin(0.5f, 0f) - } - ) - } - end?.let { position -> - Icon( - imageVector = DesktopPdfSelectionMenuIcons.Teardrop, - contentDescription = "Selection end handle", - tint = handleColor.copy(alpha = if (activeHandle == DesktopPdfSelectionHandle.START) 0.72f else 1f), - modifier = Modifier - .handleOffset(position) - .size(handleSize) - .graphicsLayer { - rotationZ = -30f - transformOrigin = TransformOrigin(0.5f, 0f) - } - ) - } - } -} - -private object DesktopPdfSelectionMenuIcons { - val Copy = vector( - name = "DesktopPdfSelectionCopy", - pathData = "M360,720Q327,720 303.5,696.5Q280,673 280,640L280,160Q280,127 303.5,103.5Q327,80 360,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,640Q800,673 776.5,696.5Q753,720 720,720L360,720ZM360,640L720,640Q720,640 720,640Q720,640 720,640L720,160Q720,160 720,160Q720,160 720,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640ZM200,880Q167,880 143.5,856.5Q120,833 120,800L120,240L200,240L200,800Q200,800 200,800Q200,800 200,800L640,800L640,880L200,880ZM360,640Q360,640 360,640Q360,640 360,640L360,160Q360,160 360,160Q360,160 360,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640Z" - ) - val Dictionary = vector( - name = "DesktopPdfSelectionDictionary", - pathData = "M160,569L205,569L228,503L332,503L356,569L400,569L303,311L257,311L160,569ZM241,466L279,359L281,359L319,466L241,466ZM560,396L560,328Q593,314 627.5,307Q662,300 700,300Q726,300 751,304Q776,308 800,314L800,378Q776,369 751.5,364.5Q727,360 700,360Q662,360 627,369.5Q592,379 560,396ZM560,616L560,548Q593,534 627.5,527Q662,520 700,520Q726,520 751,524Q776,528 800,534L800,598Q776,589 751.5,584.5Q727,580 700,580Q662,580 627,589Q592,598 560,616ZM560,506L560,438Q593,424 627.5,417Q662,410 700,410Q726,410 751,414Q776,418 800,424L800,488Q776,479 751.5,474.5Q727,470 700,470Q662,470 627,479.5Q592,489 560,506ZM260,640Q307,640 351.5,650.5Q396,661 440,682L440,288Q399,264 353,252Q307,240 260,240Q224,240 188.5,247Q153,254 120,268Q120,268 120,268Q120,268 120,268L120,664Q120,664 120,664Q120,664 120,664Q155,652 189.5,646Q224,640 260,640ZM520,682Q564,661 608.5,650.5Q653,640 700,640Q736,640 770.5,646Q805,652 840,664Q840,664 840,664Q840,664 840,664L840,268Q840,268 840,268Q840,268 840,268Q807,254 771.5,247Q736,240 700,240Q653,240 607,252Q561,264 520,288L520,682ZM480,800Q432,762 376,741Q320,720 260,720Q218,720 177.5,731Q137,742 100,762Q79,773 59.5,761Q40,749 40,726L40,244Q40,233 45.5,223Q51,213 62,208Q108,184 158,172Q208,160 260,160Q318,160 373.5,175Q429,190 480,220Q531,190 586.5,175Q642,160 700,160Q752,160 802,172Q852,184 898,208Q909,213 914.5,223Q920,233 920,244L920,726Q920,749 900.5,761Q881,773 860,762Q823,742 782.5,731Q742,720 700,720Q640,720 584,741Q528,762 480,800ZM280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461L280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461L280,461Q280,461 280,461Q280,461 280,461Z" - ) - val Search = vector( - name = "DesktopPdfSelectionSearch", - pathData = "M784,840L532,588Q502,612 463,626Q424,640 380,640Q271,640 195.5,564.5Q120,489 120,380Q120,271 195.5,195.5Q271,120 380,120Q489,120 564.5,195.5Q640,271 640,380Q640,424 626,463Q612,502 588,532L840,784L784,840ZM380,560Q455,560 507.5,507.5Q560,455 560,380Q560,305 507.5,252.5Q455,200 380,200Q305,200 252.5,252.5Q200,305 200,380Q200,455 252.5,507.5Q305,560 380,560Z" - ) - val Teardrop = vector( - name = "DesktopPdfSelectionTeardrop", - pathData = "M480,860Q347,860 253.5,768Q160,676 160,544Q160,481 184.5,423.5Q209,366 254,322L480,100L706,322Q751,366 775.5,423.5Q800,481 800,544Q800,676 706.5,768Q613,860 480,860Z" - ) - - private fun vector(name: String, pathData: String): ImageVector { - return ImageVector.Builder( - name = name, - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f - ).apply { - addPath( - pathData = PathParser().parsePathString(pathData).toNodes(), - fill = SolidColor(Color.Black) - ) - }.build() - } -} - -private enum class DesktopPdfSelectionHandle { - START, - END -} - -@Composable -private fun PdfSelectionMenu( - selection: DesktopPdfTextSelection?, - menuOffset: Offset?, - canvasSize: IntSize, - highlighterPalette: List = SharedPdfHighlighterPalette.defaultColors, - onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit, - onCopy: () -> Unit, - onHighlight: (Int) -> Unit, - onSearch: () -> Unit, - onDefine: () -> Unit, - onSpeak: () -> Unit, - showDefine: Boolean, - showSpeak: Boolean, - showSearch: Boolean, - onClear: () -> Unit -) { - selection ?: return - val anchor = menuOffset ?: return - val selectionBounds = selection.canvasBounds(canvasSize) - val paletteColors = remember(highlighterPalette) { - SharedPdfAndroidHighlightColors.palette - } - var editingHighlighterSlot by remember(selection.startIndex, selection.endIndex, paletteColors) { - mutableStateOf(null) - } - val actions = buildList { - add(PdfSelectionMenuAction("Copy", DesktopPdfSelectionMenuIcons.Copy, onCopy)) - if (showDefine) add(PdfSelectionMenuAction("Define", DesktopPdfSelectionMenuIcons.Dictionary, onDefine)) - if (showSpeak) add(PdfSelectionMenuAction("Speak", Icons.AutoMirrored.Filled.VolumeUp, onSpeak)) - if (showSearch) add(PdfSelectionMenuAction("Search", DesktopPdfSelectionMenuIcons.Search, onSearch)) - add(PdfSelectionMenuAction("Clear", Icons.Default.Close, onClear, isDestructive = true)) - } - val estimatedHeight = PdfSelectionMenuPaletteHeightPx + - (((actions.size + 2) / 3).coerceAtLeast(1) * PdfSelectionMenuActionRowHeightPx) - val left = (anchor.x - (PdfSelectionMenuWidthPx / 2f)).coerceIn( - PdfSelectionMenuMarginPx, - (canvasSize.width.toFloat() - PdfSelectionMenuWidthPx).coerceAtLeast(PdfSelectionMenuMarginPx) - ) - val selectionTop = selectionBounds?.top ?: anchor.y - val selectionBottom = selectionBounds?.bottom ?: anchor.y - val preferredTop = selectionTop - estimatedHeight - PdfSelectionMenuAnchorGapPx - val fallbackTop = selectionBottom + PdfSelectionMenuAnchorGapPx - val hasRoomAbove = preferredTop >= PdfSelectionMenuMarginPx - val pageHeight = canvasSize.height.toFloat() - val hasRoomBelow = fallbackTop + estimatedHeight <= pageHeight - PdfSelectionMenuMarginPx - val rawTop = when { - hasRoomAbove -> preferredTop - hasRoomBelow -> fallbackTop - selectionTop > pageHeight - selectionBottom -> preferredTop - else -> fallbackTop - } - val top = rawTop.coerceIn( - PdfSelectionMenuMarginPx, - (canvasSize.height.toFloat() - estimatedHeight).coerceAtLeast(PdfSelectionMenuMarginPx) - ) - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) { - Surface( - color = MaterialTheme.colorScheme.surface, - tonalElevation = 4.dp, - shadowElevation = 10.dp, - shape = RoundedCornerShape(12.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), - modifier = Modifier.offset { - IntOffset(left.roundToInt(), top.roundToInt()) - } - ) { - Column( - modifier = Modifier - .widthIn(min = 200.dp, max = 240.dp) - .padding(bottom = 6.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 12.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - paletteColors.forEach { colorArgb -> - Surface( - modifier = Modifier - .padding(horizontal = 6.dp) - .size(32.dp) - .clickable { onHighlight(colorArgb) }, - color = Color(colorArgb), - shape = RoundedCornerShape(16.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.28f)), - content = {} - ) - } - Box( - modifier = Modifier - .padding(horizontal = 6.dp) - .size(32.dp) - .clip(RoundedCornerShape(16.dp)) - .background( - Brush.sweepGradient( - listOf( - Color.Red, - Color.Yellow, - Color.Green, - Color.Cyan, - Color.Blue, - Color.Magenta, - Color.Red - ) - ) - ) - .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.32f), RoundedCornerShape(16.dp)) - .clickable { editingHighlighterSlot = 0 } - ) - } - HorizontalDivider() - actions.chunked(3).forEach { rowActions -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - rowActions.forEach { action -> - val tint = if (action.isDestructive) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.onSurface - } - Column( - modifier = Modifier - .width(64.dp) - .clip(RoundedCornerShape(8.dp)) - .clickable { action.onClick() } - .padding(vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Icon( - imageVector = action.icon, - contentDescription = action.label, - tint = tint, - modifier = Modifier.size(24.dp) - ) - Text( - action.label, - style = MaterialTheme.typography.labelSmall, - color = tint, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - repeat(3 - rowActions.size) { - Spacer(modifier = Modifier.width(64.dp)) - } - } - } - } - } - } - editingHighlighterSlot?.let { requestedSlot -> - val slot = requestedSlot.coerceIn(0, paletteColors.lastIndex) - val initialColor = Color(paletteColors[slot]).copy(alpha = 1f) - SharedHsvColorPickerDialog( - initialColor = initialColor, - title = "Highlight color ${slot + 1}", - onDismiss = { editingHighlighterSlot = null }, - onSave = { color -> - onHighlighterPaletteChange( - SharedPdfHighlighterPalette(paletteColors).withColorAt( - slotIndex = slot, - colorArgb = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb() - ) - ) - editingHighlighterSlot = null - } - ) { liveColor -> - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - paletteColors.forEachIndexed { index, argb -> - val color = if (index == slot) liveColor else Color(argb).copy(alpha = 1f) - Box( - modifier = Modifier - .size(42.dp) - .clip(RoundedCornerShape(21.dp)) - .background(color) - .border( - width = if (index == slot) 3.dp else 1.dp, - color = if (index == slot) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.35f), - shape = RoundedCornerShape(21.dp) - ) - .clickable { editingHighlighterSlot = index }, - contentAlignment = Alignment.Center - ) { - Text( - text = "${index + 1}", - color = if (color.luminance() > 0.5f) Color.Black else Color.White, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold - ) - } - } - } - } - } - } -} - -private data class PdfSelectionMenuAction( - val label: String, - val icon: ImageVector, - val onClick: () -> Unit, - val isDestructive: Boolean = false -) - -private fun DesktopPdfDocument.charHitAt( - pageIndex: Int, - point: Offset, - canvasSize: IntSize -): DesktopPdfCharHit? { - val normalized = PdfSelectionGeometry.normalizedPoint( - pointX = point.x, - pointY = point.y, - viewportWidth = canvasSize.width, - viewportHeight = canvasSize.height - ) ?: return null - val nativeIndex = DesktopPdfium.charIndexAt( - document = this, - pageIndex = pageIndex, - normalizedX = normalized.x, - normalizedY = normalized.y, - viewportWidth = canvasSize.width, - viewportHeight = canvasSize.height - ) - if (nativeIndex != null) { - return DesktopPdfCharHit( - index = nativeIndex, - source = "native", - point = point, - normalized = normalized - ) - } - val fallback = PdfSelectionGeometry.nearestCharOnLine( - chars = textPageData(pageIndex).chars.visiblePdfTextBounds(), - point = normalized - ) ?: return null - return DesktopPdfCharHit( - index = fallback.index, - source = "fallback_line", - point = point, - normalized = normalized - ) -} - -private fun DesktopPdfDocument.wordSelectionAt( - pageIndex: Int, - point: Offset, - canvasSize: IntSize -): DesktopPdfTextSelection? { - val hit = charHitAt(pageIndex, point, canvasSize) ?: return null - if (hit.source == "fallback_line" && !isPointNearTextChar(pageIndex, hit.index, hit.normalized)) { - return null - } - val pageText = textPageData(pageIndex).text - if (pageText.isEmpty()) return null - val hitIndex = hit.index.coerceIn(0, pageText.lastIndex) - if (!pageText[hitIndex].isDesktopPdfWordPart()) return null - var startIndex = hitIndex - while (startIndex > 0 && pageText[startIndex - 1].isDesktopPdfWordPart()) { - startIndex -= 1 - } - var endIndex = hitIndex - while (endIndex < pageText.lastIndex && pageText[endIndex + 1].isDesktopPdfWordPart()) { - endIndex += 1 - } - return selectionBetweenIndexes( - pageIndex = pageIndex, - startIndex = startIndex, - endIndex = endIndex, - canvasSize = canvasSize, - useNativeBounds = true - ) -} - -private fun DesktopPdfDocument.isPointNearTextChar( - pageIndex: Int, - charIndex: Int, - point: PdfNormalizedPoint -): Boolean { - val charBounds = textPageData(pageIndex).chars - .visiblePdfTextBounds() - .firstOrNull { it.index == charIndex } - ?: return false - val horizontalPadding = maxOf((charBounds.right - charBounds.left) * 2f, 0.025f) - val verticalPadding = maxOf((charBounds.bottom - charBounds.top) * 0.65f, 0.006f) - return point.x in (charBounds.left - horizontalPadding)..(charBounds.right + horizontalPadding) && - point.y in (charBounds.top - verticalPadding)..(charBounds.bottom + verticalPadding) -} - -private fun Char.isDesktopPdfWordPart(): Boolean { - return isLetterOrDigit() || this == '\'' || this == '-' || this == '_' -} - -private fun DesktopPdfDocument.selectionPreviewBetweenIndexes( - pageIndex: Int, - startIndex: Int, - endIndex: Int, - canvasSize: IntSize -): DesktopPdfTextSelection? { - return selectionBetweenIndexes( - pageIndex = pageIndex, - startIndex = startIndex, - endIndex = endIndex, - canvasSize = canvasSize, - useNativeBounds = false, - includeText = false - ) -} - -private fun DesktopPdfDocument.selectionBetweenIndexes( - pageIndex: Int, - startIndex: Int, - endIndex: Int, - canvasSize: IntSize, - useNativeBounds: Boolean = true, - includeText: Boolean = true -): DesktopPdfTextSelection? { - val chars = textPageData(pageIndex).chars - if (chars.isEmpty()) return null - val firstIndex = minOf(startIndex, endIndex) - val lastIndex = maxOf(startIndex, endIndex) - val selectedChars = chars.filter { it.index in firstIndex..lastIndex } - if (selectedChars.isEmpty()) return null - val text = if (includeText) { - selectedChars.joinToString("") { it.char.toString() } - .replace(DesktopPdfSelectionInlineWhitespaceRegex, " ") - .replace(DesktopPdfSelectionBlankLinesRegex, "\n\n") - .trim() - } else { - "" - } - if (includeText && text.isBlank()) return null - val fallbackBounds = PdfSelectionGeometry.lineBoundsForChars(selectedChars.visiblePdfTextBounds()) - if (!includeText && fallbackBounds.isEmpty()) return null - val nativeBounds = if (useNativeBounds) { - DesktopPdfium.textRectsForRange( - document = this, - pageIndex = pageIndex, - startIndex = firstIndex, - endIndex = lastIndex, - viewportWidth = canvasSize.width, - viewportHeight = canvasSize.height - ).map { it.toPdfPageBounds() } - .filter { it.right > it.left && it.bottom > it.top } - .mergePdfBoundsByLine() - } else { - emptyList() - } - return DesktopPdfTextSelection( - text = text, - lineBounds = nativeBounds.ifEmpty { fallbackBounds }, - startIndex = firstIndex, - endIndex = lastIndex - ) -} - -private fun DesktopPdfTextRect.toPdfPageBounds(): PdfPageBounds { - return PdfPageBounds( - left = left, - top = top, - right = right, - bottom = bottom - ) -} - -private fun SharedPdfAnnotation.toRenderablePdfAnnotations( - document: DesktopPdfDocument, - pageIndex: Int, - canvasSize: IntSize -): List { - val startIndex = rangeStartIndex - val endIndex = rangeEndIndex - if (kind != PdfAnnotationKind.HIGHLIGHT || startIndex == null || endIndex == null) { - return listOf(this) - } - if (canvasSize.width <= 0 || canvasSize.height <= 0) { - return listOf(this) - } - val dynamicBounds = DesktopPdfium.textRectsForRange( - document = document, - pageIndex = pageIndex, - startIndex = startIndex, - endIndex = endIndex, - viewportWidth = canvasSize.width, - viewportHeight = canvasSize.height - ).map { it.toPdfPageBounds() } - .filter { it.right > it.left && it.bottom > it.top } - .mergePdfBoundsByLine() - - return dynamicBounds.ifEmpty { boundsList.ifEmpty { listOfNotNull(bounds) } } - .mapIndexed { index, dynamicBounds -> - copy( - id = "${id}_line_$index", - bounds = dynamicBounds - ) - } -} - -private fun SharedPdfTextDraft.containsOffset( - pageIndex: Int, - offset: Offset, - canvasSize: IntSize -): Boolean { - if (this.pageIndex != pageIndex || canvasSize.width <= 0 || canvasSize.height <= 0) return false - val left = bounds.left * canvasSize.width - val right = bounds.right * canvasSize.width - val top = bounds.top * canvasSize.height - val bottom = bounds.bottom * canvasSize.height - return offset.x in left..right && offset.y in top..bottom -} - -private fun List.textAnnotationHitAt( - pageIndex: Int, - point: Offset, - canvasSize: IntSize -): SharedPdfAnnotation? { - return asReversed().firstOrNull { annotation -> - annotation.kind == PdfAnnotationKind.TEXT && - annotation.pageIndex == pageIndex && - annotation.sharedPdfHitTest(point, canvasSize) - } -} - -private fun List.mergePdfBoundsByLine(): List { - return PdfSelectionGeometry.mergeBoundsByLine(this) -} - -private fun List.visiblePdfTextBounds(): List { - return asSequence() - .filter { it.hasBounds && !it.char.isISOControl() } - .map { it.toPdfTextCharBounds() } - .toList() -} - -private fun DesktopPdfTextChar.toPdfTextCharBounds(): PdfTextCharBounds { - return PdfTextCharBounds( - index = index, - left = left, - top = top, - right = right, - bottom = bottom - ) -} - -private const val DesktopPdfSelectionPreviewThrottleMillis = 32L -private const val DesktopPdfZoomGestureFrameMillis = 16L -private const val DesktopPdfZoomCommitDebounceMillis = 180L -private const val DesktopPdfZoomRenderDebounceMillis = 300L -private const val DesktopPdfViewportPersistDebounceMillis = 300L -private const val DesktopPdfPaginationPrefetchDelayMillis = 450L -internal const val DesktopPdfPaginationFastFirstRenderMaxScale = 2.0f -private const val DesktopPdfRenderScaleTolerance = 0.01f -private const val DesktopPdfPaginationRenderCacheRadius = 2 -private val DesktopPdfSelectionInlineWhitespaceRegex = Regex("[ \\t\\x0B\\f\\r]+") -private val DesktopPdfSelectionBlankLinesRegex = Regex("\\n{3,}") -private const val PdfSelectionMenuWidthPx = 240f -private const val PdfSelectionMenuPaletteHeightPx = 62f -private const val PdfSelectionMenuActionRowHeightPx = 76f -private const val PdfSelectionMenuAnchorGapPx = 16f -private const val PdfSelectionMenuMarginPx = 6f -private const val DesktopPdfSelectionHandleTouchWidthPx = 44f -private const val DesktopPdfSelectionHandleTouchTopPx = 8f -private const val DesktopPdfSelectionHandleTouchBottomPx = 40f - -internal fun desktopPdfAnnotationFile(documentPath: String): File { - val safeName = documentPath.hashCode().toString().replace("-", "n") - return File(desktopUserDataRoot(), "annotations/pdf_$safeName.json") -} - -internal fun desktopPdfBookmarkFile(documentPath: String): File { - val safeName = documentPath.hashCode().toString().replace("-", "n") - return File(desktopUserDataRoot(), "annotations/pdf_${safeName}_bookmarks.json") -} - -internal fun desktopPdfRichTextFile(documentPath: String): File { - val safeName = documentPath.hashCode().toString().replace("-", "n") - return File(desktopUserDataRoot(), "annotations/pdf_${safeName}_rich_text.json") -} - -private fun desktopPdfSearchIndexFile(documentPath: String): File { - val safeName = documentPath.hashCode().toString().replace("-", "n") - return File(desktopUserCacheRoot(), "search/pdf_${safeName}_text_index.tsv") -} - -private fun restoreDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFile: File): Int { - val sourceFile = File(document.path) - val lines = runCatching { indexFile.readLines(Charsets.UTF_8) }.getOrNull() ?: return document.indexedSearchTextPageCount() - if (lines.firstOrNull() != DesktopPdfSearchIndexHeader) return 0 - val metadata = lines - .asSequence() - .drop(1) - .takeWhile { !it.startsWith("page\t") } - .mapNotNull { line -> - val parts = line.split('\t', limit = 2) - if (parts.size == 2) parts[0] to parts[1] else null - } - .toMap() - val isFresh = metadata["pathHash"] == document.path.hashCode().toString() && - metadata["fileSize"] == sourceFile.length().toString() && - metadata["lastModified"] == sourceFile.lastModified().toString() && - metadata["pageCount"] == document.pageCount.toString() - if (!isFresh) return 0 - - val decoder = Base64.getDecoder() - lines.asSequence() - .filter { it.startsWith("page\t") } - .forEach { line -> - val parts = line.split('\t', limit = 3) - val pageIndex = parts.getOrNull(1)?.toIntOrNull() ?: return@forEach - val text = runCatching { - String(decoder.decode(parts.getOrNull(2).orEmpty()), Charsets.UTF_8) - }.getOrDefault("") - document.cacheSearchTextPage(pageIndex, text) - } - return document.indexedSearchTextPageCount() -} - -private fun saveDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFile: File) { - val sourceFile = File(document.path) - val pages = document.indexedSearchPages() - if (pages.isEmpty()) return - val encoder = Base64.getEncoder() - val payload = buildString { - appendLine(DesktopPdfSearchIndexHeader) - appendLine("pathHash\t${document.path.hashCode()}") - appendLine("fileSize\t${sourceFile.length()}") - appendLine("lastModified\t${sourceFile.lastModified()}") - appendLine("pageCount\t${document.pageCount}") - pages.forEach { page -> - append("page\t") - append(page.pageIndex) - append('\t') - appendLine(encoder.encodeToString(page.text.toByteArray(Charsets.UTF_8))) - } - } - runCatching { - indexFile.parentFile?.mkdirs() - indexFile.writeText(payload, Charsets.UTF_8) - } -} - -private const val DesktopPdfSearchIndexHeader = "EpistemePdfSearchIndex\t1" - @Composable private fun ReaderScreen( session: ReaderSessionState, @@ -11072,56 +7045,6 @@ private fun ReaderScreen( } } -private data class DesktopEpubPaginationRequest( - val bookId: String, - val chapterSignature: Int, - val layoutSignature: ReaderLayoutSignature, - val viewport: ReaderViewportSpec, - val density: DesktopEpubPaginationDensity, - val cacheGeneration: Int -) - -private data class DesktopEpubPaginationDensity( - val density: Float, - val fontScale: Float -) - -private fun SharedEpubBook.desktopPaginationContentSignature(): Int { - return chapters.fold(31 * id.hashCode() + css.hashCode()) { acc, chapter -> - 31 * acc + - chapter.id.hashCode() + - chapter.plainText.length + - chapter.plainText.hashCode() + - chapter.semanticBlocks.hashCode() + - chapter.htmlContent.length + - chapter.htmlContent.hashCode() + - chapter.baseHref.orEmpty().hashCode() - } -} - -@Composable -private fun DesktopEpubPaginationPreparing( - active: Boolean, - modifier: Modifier = Modifier -) { - Box( - modifier = modifier, - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - CircularProgressIndicator() - Text( - if (active) "Preparing pages" else "Measuring reader layout", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} - @Composable private fun DesktopEpubWebView( html: String, @@ -11472,949 +7395,3 @@ private fun DesktopEpubWebView( } } } - -private data class DesktopReaderPosition( - val pageIndex: Int, - val locator: ReaderLocator? -) - -private data class DesktopReaderHighlightClick( - val highlightId: String -) - -private data class DesktopEpubLinkClick( - val href: String, - val chapterIndex: Int?, - val text: String? = null, - val chapterId: String? = null, - val chapterHref: String? = null, - val source: String = "bridge" -) - -private fun SharedNativeReaderLinkClick.toDesktopEpubLinkClick(): DesktopEpubLinkClick { - return DesktopEpubLinkClick( - href = href, - chapterIndex = chapterIndex, - text = text, - source = "native" - ) -} - -private data class DesktopEpubHandledLink( - val href: String, - val handledAtMs: Long -) - -private enum class DesktopReaderSelectionAction { - DEFINE, - SPEAK, - SEARCH -} - -private enum class DesktopReaderKeyNavigation { - NEXT, - PREVIOUS, - FIRST, - LAST, - SEARCH, - NEXT_SEARCH, - EXIT_FULLSCREEN -} - -private fun AwtKeyEvent.desktopReaderKeyNavigationOrNull(fullscreen: Boolean): DesktopReaderKeyNavigation? { - if (id != AwtKeyEvent.KEY_PRESSED) return null - if (fullscreen && keyCode == AwtKeyEvent.VK_ESCAPE) { - return DesktopReaderKeyNavigation.EXIT_FULLSCREEN - } - if (isControlDown && keyCode == AwtKeyEvent.VK_F) { - return DesktopReaderKeyNavigation.SEARCH - } - if (isControlDown && keyCode == AwtKeyEvent.VK_G) { - return DesktopReaderKeyNavigation.NEXT_SEARCH - } - return when (keyCode) { - AwtKeyEvent.VK_RIGHT, - AwtKeyEvent.VK_PAGE_DOWN -> DesktopReaderKeyNavigation.NEXT - AwtKeyEvent.VK_LEFT, - AwtKeyEvent.VK_PAGE_UP -> DesktopReaderKeyNavigation.PREVIOUS - AwtKeyEvent.VK_HOME -> DesktopReaderKeyNavigation.FIRST - AwtKeyEvent.VK_END -> DesktopReaderKeyNavigation.LAST - else -> null - } -} - -private data class DesktopReaderSelectionActionPayload( - val action: DesktopReaderSelectionAction, - val text: String -) - -private fun String.readerHighlightClickOrNull(): DesktopReaderHighlightClick? { - fun parse(rawJson: String): DesktopReaderHighlightClick? = runCatching { - val obj = Json.parseToJsonElement(rawJson).jsonObject - val highlightId = obj["id"] - ?.takeUnless { it is JsonNull } - ?.jsonPrimitive - ?.contentOrNull - ?.takeIf { it.isNotBlank() } - ?: obj["highlightId"] - ?.takeUnless { it is JsonNull } - ?.jsonPrimitive - ?.contentOrNull - ?.takeIf { it.isNotBlank() } - ?: return@runCatching null - DesktopReaderHighlightClick(highlightId) - }.getOrNull() - - parse(this)?.let { return it } - return runCatching { - Json.parseToJsonElement(this).jsonPrimitive.contentOrNull - }.getOrNull()?.let { parse(it) } -} - -private fun String.readerSelectionActionOrNull(): DesktopReaderSelectionActionPayload? { - fun parse(rawJson: String): DesktopReaderSelectionActionPayload? = runCatching { - val obj = Json.parseToJsonElement(rawJson).jsonObject - val text = obj["text"] - ?.takeUnless { it is JsonNull } - ?.jsonPrimitive - ?.contentOrNull - ?.takeIf { it.isNotBlank() } - ?: return@runCatching null - val action = when ( - obj["action"] - ?.takeUnless { it is JsonNull } - ?.jsonPrimitive - ?.contentOrNull - ?.lowercase() - ) { - "define" -> DesktopReaderSelectionAction.DEFINE - "speak" -> DesktopReaderSelectionAction.SPEAK - "web-search", "search" -> DesktopReaderSelectionAction.SEARCH - else -> return@runCatching null - } - DesktopReaderSelectionActionPayload(action, text) - }.getOrNull() - - parse(this)?.let { return it } - return runCatching { - Json.parseToJsonElement(this).jsonPrimitive.contentOrNull - }.getOrNull()?.let { parse(it) } -} - -private fun String.readerSelectionDebugMessageOrNull(): String? { - fun parse(rawJson: String): String? = runCatching { - Json.parseToJsonElement(rawJson) - .jsonObject["message"] - ?.takeUnless { it is JsonNull } - ?.jsonPrimitive - ?.contentOrNull - ?.takeIf { it.isNotBlank() } - }.getOrNull() - - parse(this)?.let { return it } - return runCatching { - Json.parseToJsonElement(this).jsonPrimitive.contentOrNull - }.getOrNull()?.let { parse(it) } -} - -private fun String.readerPaginationLogMessageOrNull(): String? { - fun parse(rawJson: String): String? = runCatching { - Json.parseToJsonElement(rawJson) - .jsonObject["message"] - ?.takeUnless { it is JsonNull } - ?.jsonPrimitive - ?.contentOrNull - ?.takeIf { it.isNotBlank() } - }.getOrNull() - - parse(this)?.let { return it } - return runCatching { - Json.parseToJsonElement(this).jsonPrimitive.contentOrNull - }.getOrNull()?.let { parse(it) } -} - -private fun String.readerPositionOrNull(): DesktopReaderPosition? { - fun parse(rawJson: String): DesktopReaderPosition? = runCatching { - val obj = Json.parseToJsonElement(rawJson).jsonObject - val pageIndex = obj["pageIndex"] - ?.takeUnless { it is JsonNull } - ?.jsonPrimitive - ?.intOrNull - ?: return@runCatching null - val locator = ReaderLocator( - chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, - pageIndex = pageIndex, - startOffset = obj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, - endOffset = obj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, - textQuote = obj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, - cfi = obj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull - ) - DesktopReaderPosition(pageIndex, locator) - }.getOrNull() - - parse(this)?.let { return it } - return runCatching { - Json.parseToJsonElement(this).jsonPrimitive.contentOrNull - }.getOrNull()?.let { parse(it) } -} - -private fun String.readerKeyNavigationOrNull(): DesktopReaderKeyNavigation? { - fun parse(rawJson: String): DesktopReaderKeyNavigation? = runCatching { - val action = Json.parseToJsonElement(rawJson) - .jsonObject["action"] - ?.takeUnless { it is JsonNull } - ?.jsonPrimitive - ?.contentOrNull - ?: return@runCatching null - when (action) { - "next" -> DesktopReaderKeyNavigation.NEXT - "previous" -> DesktopReaderKeyNavigation.PREVIOUS - "first" -> DesktopReaderKeyNavigation.FIRST - "last" -> DesktopReaderKeyNavigation.LAST - "search" -> DesktopReaderKeyNavigation.SEARCH - "nextSearch" -> DesktopReaderKeyNavigation.NEXT_SEARCH - "exitFullscreen" -> DesktopReaderKeyNavigation.EXIT_FULLSCREEN - else -> null - } - }.getOrNull() - - parse(this)?.let { return it } - return runCatching { - Json.parseToJsonElement(this).jsonPrimitive.contentOrNull - }.getOrNull()?.let { parse(it) } -} - -private fun String.readerLinkClickOrNull(): DesktopEpubLinkClick? { - fun parse(rawJson: String): DesktopEpubLinkClick? = runCatching { - val obj = Json.parseToJsonElement(rawJson).jsonObject - val href = obj["href"] - ?.takeUnless { it is JsonNull } - ?.jsonPrimitive - ?.contentOrNull - ?.takeIf { it.isNotBlank() } - ?: return@runCatching null - DesktopEpubLinkClick( - href = href, - chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, - text = obj["text"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, - chapterId = obj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, - chapterHref = obj["chapterHref"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull - ) - }.getOrNull() - - parse(this)?.let { return it } - return runCatching { - Json.parseToJsonElement(this).jsonPrimitive.contentOrNull - }.getOrNull()?.let { parse(it) } -} - -private fun String.readerLinkClickFromIntercept(): DesktopEpubLinkClick? { - val trimmed = trim() - if (trimmed.startsWith("readerlink:", ignoreCase = true)) { - logEpubLink("request_intercept_readerlink raw=\"${trimmed.logPreview()}\"") - val payload = trimmed.substringAfter("?", missingDelimiterValue = "") - .split('&') - .firstOrNull { it.substringBefore("=").equals("payload", ignoreCase = true) } - ?.substringAfter("=", missingDelimiterValue = "") - ?.takeIf { it.isNotBlank() } - if (payload == null) { - logEpubLink("request_intercept_readerlink_ignored reason=missing_payload") - return null - } - val decoded = runCatching { - URLDecoder.decode(payload, Charsets.UTF_8.name()) - }.getOrElse { - logEpubLink("request_intercept_payload_decode_failed error=\"${it.message.orEmpty().logPreview()}\"") - return null - } - val link = decoded.readerLinkClickOrNull()?.copy(source = "request") - if (link == null) { - logEpubLink("request_intercept_readerlink_ignored reason=parse_failed payload=\"${decoded.logPreview()}\"") - } - return link - } - return readerHrefFromIntercept()?.let { href -> - DesktopEpubLinkClick( - href = href, - chapterIndex = null, - source = "request" - ) - } -} - -private fun String.readerHrefFromIntercept(): String? { - val trimmed = trim() - if (trimmed.isBlank()) return null - if (trimmed.equals("about:blank", ignoreCase = true)) return null - if (trimmed.startsWith("file:///kcefbrowser/", ignoreCase = true)) return null - if (trimmed.startsWith("file:/kcefbrowser/", ignoreCase = true)) return null - if (trimmed.startsWith("file://", ignoreCase = true)) return null - if (trimmed.startsWith("about:blank#", ignoreCase = true)) return "#${trimmed.substringAfter('#')}" - if (trimmed.startsWith("data:", ignoreCase = true)) return null - if (trimmed.startsWith("blob:", ignoreCase = true)) return null - return trimmed -} - -private fun ReaderLocator.toReaderLocatorJson(): String { - return buildString { - append("{") - val values = buildList { - chapterIndex?.let { add("\"chapterIndex\":$it") } - pageIndex?.let { add("\"pageIndex\":$it") } - startOffset?.let { add("\"startOffset\":$it") } - endOffset?.let { add("\"endOffset\":$it") } - cfi?.let { add("\"cfi\":${it.toJsonStringLiteral()}") } - textQuote?.let { add("\"textQuote\":${it.toJsonStringLiteral()}") } - } - append(values.joinToString(",")) - append("}") - } -} - -private fun String.toJsonStringLiteral(): String { - val builder = StringBuilder("\"") - forEach { char -> - when (char) { - '\\' -> builder.append("\\\\") - '"' -> builder.append("\\\"") - '\n' -> builder.append("\\n") - '\r' -> builder.append("\\r") - '\t' -> builder.append("\\t") - '\b' -> builder.append("\\b") - '\u000C' -> builder.append("\\f") - else -> { - if (char.code < 0x20) { - builder.append("\\u") - builder.append(char.code.toString(16).padStart(4, '0')) - } else { - builder.append(char) - } - } - } - } - builder.append('"') - return builder.toString() -} - -@Composable -private fun DesktopWebViewRuntimeIndicator( - state: DesktopWebViewRuntimeState, - modifier: Modifier = Modifier -) { - val message = when { - state.errorMessage != null -> "Embedded webview could not start: ${state.errorMessage}" - state.restartRequired -> "Embedded webview installed. Restart Episteme to finish setup." - state.downloadProgress >= 0f -> "Preparing bundled embedded webview ${state.downloadProgress.toInt()}%" - else -> "Preparing embedded webview..." - } - - Box( - modifier = modifier.padding(32.dp), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - if (state.errorMessage == null && !state.restartRequired) { - CircularProgressIndicator() - } - Text( - text = message, - color = if (state.errorMessage == null) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center - ) - if (state.downloadProgress in 0f..100f) { - LinearProgressIndicator( - progress = { state.downloadProgress / 100f }, - modifier = Modifier.width(260.dp) - ) - } - } - } -} - -@Composable -private fun SemanticBlockView( - block: SemanticBlock, - foreground: Color, - searchQuery: String, - searchHighlight: Color, - fallbackTextAlign: TextAlign, - fallbackFontFamily: FontFamily, - settings: ReaderSettings -) { - val modifier = Modifier - .fillMaxWidth() - .padding( - start = block.style.blockStyle.margin.left.safeDp(), - top = block.style.blockStyle.margin.top.safeDp(), - end = block.style.blockStyle.margin.right.safeDp(), - bottom = block.style.blockStyle.margin.bottom.safeDp() - ) - .then( - if (block.style.blockStyle.backgroundColor.isSpecified) { - Modifier.background(block.style.blockStyle.backgroundColor, RoundedCornerShape(4.dp)) - } else { - Modifier - } - ) - .padding( - start = block.style.blockStyle.padding.left.safeDp(), - top = block.style.blockStyle.padding.top.safeDp(), - end = block.style.blockStyle.padding.right.safeDp(), - bottom = block.style.blockStyle.padding.bottom.safeDp() - ) - - when (block) { - is SemanticHeader -> { - Text( - text = block.toAnnotatedString(searchQuery, searchHighlight), - color = foreground, - modifier = modifier, - textAlign = block.style.paragraphStyle.textAlign.takeUnless { it == TextAlign.Unspecified } ?: fallbackTextAlign, - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = (settings.fontSize * headerScale(block.level)).sp, - lineHeight = (settings.fontSize * headerScale(block.level) * settings.lineSpacing).sp, - fontWeight = FontWeight.Bold, - fontFamily = fallbackFontFamily - ) - ) - } - - is SemanticParagraph -> SemanticTextView(block, modifier, foreground, searchQuery, searchHighlight, fallbackTextAlign, fallbackFontFamily, settings) - is SemanticListItem -> SemanticTextView(block, modifier, foreground, searchQuery, searchHighlight, fallbackTextAlign, fallbackFontFamily, settings) - is SemanticTextBlock -> SemanticTextView(block, modifier, foreground, searchQuery, searchHighlight, fallbackTextAlign, fallbackFontFamily, settings) - - is SemanticList -> { - Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { - block.items.forEachIndexed { index, item -> - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text(if (block.isOrdered) "${index + 1}." else "•", color = foreground) - SemanticTextView( - block = item, - modifier = Modifier.weight(1f), - foreground = foreground, - searchQuery = searchQuery, - searchHighlight = searchHighlight, - fallbackTextAlign = fallbackTextAlign, - fallbackFontFamily = fallbackFontFamily, - settings = settings - ) - } - } - } - } - - is SemanticFlexContainer -> { - Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { - block.children.forEach { - SemanticBlockView(it, foreground, searchQuery, searchHighlight, fallbackTextAlign, fallbackFontFamily, settings) - } - } - } - - is SemanticWrappingBlock -> { - Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { - SemanticBlockView(block.floatedImage, foreground, searchQuery, searchHighlight, fallbackTextAlign, fallbackFontFamily, settings) - block.paragraphsToWrap.forEach { - SemanticBlockView(it, foreground, searchQuery, searchHighlight, fallbackTextAlign, fallbackFontFamily, settings) - } - } - } - - is SemanticTable -> { - Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) { - block.rows.forEach { row -> - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - row.forEach { cell -> - Column(modifier = Modifier.weight(cell.colspan.toFloat().coerceAtLeast(1f))) { - cell.content.forEach { - SemanticBlockView(it, foreground, searchQuery, searchHighlight, fallbackTextAlign, fallbackFontFamily, settings) - } - } - } - } - } - } - } - - is SemanticImage -> { - Text( - text = block.altText?.takeIf { it.isNotBlank() } ?: block.path.substringAfterLast('/').substringAfterLast('\\'), - color = foreground.copy(alpha = 0.7f), - modifier = modifier, - style = MaterialTheme.typography.bodySmall - ) - } - - is SemanticMath -> { - Text( - text = block.altText ?: "Equation", - color = foreground, - modifier = modifier, - style = MaterialTheme.typography.bodyMedium - ) - } - - is SemanticSpacer -> Spacer(modifier.height(if (block.isExplicitLineBreak) 8.dp else 16.dp)) - } -} - -@Composable -private fun SemanticTextView( - block: SemanticTextBlock, - modifier: Modifier, - foreground: Color, - searchQuery: String, - searchHighlight: Color, - fallbackTextAlign: TextAlign, - fallbackFontFamily: FontFamily, - settings: ReaderSettings -) { - Text( - text = block.toAnnotatedString(searchQuery, searchHighlight), - color = foreground, - modifier = modifier, - textAlign = block.style.paragraphStyle.textAlign.takeUnless { it == TextAlign.Unspecified } ?: fallbackTextAlign, - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = settings.fontSize.sp, - lineHeight = (settings.fontSize * settings.lineSpacing).sp, - fontFamily = fallbackFontFamily - ) - ) -} - -private fun SemanticTextBlock.toAnnotatedString(query: String, highlightColor: Color): AnnotatedString { - val normalized = query.trim() - return buildAnnotatedString { - append(text) - spans.forEach { span -> - val start = span.start.coerceIn(0, text.length) - val end = span.end.coerceIn(start, text.length) - if (start < end) { - addStyle(span.style.spanStyle, start, end) - } - } - if (normalized.length >= 2) { - var startIndex = 0 - while (startIndex < text.length) { - val index = text.indexOf(normalized, startIndex, ignoreCase = true) - if (index < 0) break - addStyle(SpanStyle(background = highlightColor), index, index + normalized.length) - startIndex = index + normalized.length - } - } - } -} - -private fun headerScale(level: Int): Float { - return when (level) { - 1 -> 1.5f - 2 -> 1.35f - 3 -> 1.2f - 4 -> 1.1f - else -> 1f - } -} - -private fun Dp.safeDp(): Dp = if (isSpecified) this else 0.dp - -private fun SharedReaderTextAlign.toComposeTextAlign(): TextAlign { - return when (this) { - SharedReaderTextAlign.START -> TextAlign.Start - SharedReaderTextAlign.JUSTIFY -> TextAlign.Justify - SharedReaderTextAlign.CENTER -> TextAlign.Center - } -} - -private fun String.toComposeFontFamily(): FontFamily { - return when (this) { - "Serif" -> FontFamily.Serif - "Sans" -> FontFamily.SansSerif - "Mono" -> FontFamily.Monospace - else -> FontFamily.Default - } -} - -private fun ReaderSettings.toDesktopReaderFontFamily(): FontFamily { - customFontPath?.takeIf { it.isNotBlank() }?.let { path -> - runCatching { FontFamily(DesktopFont(File(path))) }.getOrNull()?.let { return it } - } - return fontFamily.toComposeFontFamily() -} - -private fun List.samePageLayoutAs(other: List): Boolean { - if (size != other.size) return false - return indices.all { index -> - val left = this[index] - val right = other[index] - left.pageIndex == right.pageIndex && - left.chapterIndex == right.chapterIndex && - left.startOffset == right.startOffset && - left.endOffset == right.endOffset && - left.text.length == right.text.length && - left.semanticBlocks.size == right.semanticBlocks.size - } -} - -private fun CustomFontItem.toDesktopPreviewFontFamily(): FontFamily? { - return runCatching { FontFamily(DesktopFont(File(path))) }.getOrNull() -} - -@Composable -private fun ScreenScaffold( - title: String, - subtitle: String, - trailing: @Composable () -> Unit = {}, - content: @Composable () -> Unit -) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(18.dp) - ) { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) - Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - trailing() - } - content() - } -} - -private fun chooseFiles(): List { - val dialog = FileDialog(null as Frame?, "Import books", FileDialog.LOAD).apply { - isMultipleMode = true - isVisible = true - } - return dialog.files.orEmpty().map { it.toImportedBookFile() } -} - -private fun chooseBookFile(): File? { - val dialog = FileDialog(null as Frame?, "Open Book", FileDialog.LOAD).apply { - file = DesktopBookFileDialogPattern - isVisible = true - } - val directory = dialog.directory ?: return null - val file = dialog.file ?: return null - return File(directory, file) -} - -private fun choosePdfFile(): File? { - val dialog = FileDialog(null as Frame?, "Open PDF", FileDialog.LOAD).apply { - file = "*.pdf" - isVisible = true - } - val directory = dialog.directory ?: return null - val file = dialog.file ?: return null - return File(directory, file) -} - -private fun chooseFontFile(): File? { - val dialog = FileDialog(null as Frame?, "Choose font", FileDialog.LOAD).apply { - file = "*.ttf;*.otf;*.woff2" - isVisible = true - } - val directory = dialog.directory ?: return null - val file = dialog.file ?: return null - return File(directory, file) -} - -private fun chooseReaderTextureFile(): File? { - val dialog = FileDialog(null as Frame?, "Choose reader texture", FileDialog.LOAD).apply { - file = "*.png;*.jpg;*.jpeg;*.webp;*.gif;*.bmp" - isVisible = true - } - val directory = dialog.directory ?: return null - val file = dialog.file ?: return null - return File(directory, file) -} - -private fun chooseFolder(): File? { - val chooser = JFileChooser().apply { - dialogTitle = "Import folder" - fileSelectionMode = JFileChooser.DIRECTORIES_ONLY - isAcceptAllFileFilterUsed = false - } - return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) { - chooser.selectedFile - } else { - null - } -} - -private fun SharedReaderScreenState.withBanner(message: String, isError: Boolean = false): SharedReaderScreenState { - return reduce(AppAction.BannerShown(BannerMessage(message, isError = isError))) -} - -private val DesktopReadableFileTypes = SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP) -private val DesktopSyncableFileTypes = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.DESKTOP) -private val DesktopBookFileTypes = DesktopReadableFileTypes -private val DesktopBookFileDialogPattern = SharedFileCapabilities.all - .filter { it.type in DesktopBookFileTypes } - .flatMap { capability -> capability.extensions.map { extension -> "*.$extension" } } - .joinToString(";") - -internal fun desktopBookFileTypesForDialog(): Set = DesktopBookFileTypes - -private const val EpistemeSourceUrl = "https://github.com/Aryan-Raj3112/episteme" -private const val EpistemeIssuesUrl = "https://github.com/Aryan-Raj3112/episteme/issues" -private const val EpistemeGitHubSponsorsUrl = "https://github.com/sponsors/Aryan-Raj3112" -private const val EpistemePatreonUrl = "https://www.patreon.com/c/epistemereader" -private const val EpistemeSupportEmail = "epistemereader@gmail.com" - -private fun desktopFeedbackSubject(profile: DesktopBuildProfile): String { - return "Feedback: ${profile.appName}" -} - -private fun desktopAppVersionName(): String { - val version = System.getProperty(DesktopVersionProperty) - ?.takeIf { it.isNotBlank() } - ?: EpistemeDesktopAppVersion::class.java.getPackage()?.implementationVersion - ?.takeIf { it.isNotBlank() } - return version?.let { "Version $it" } ?: "Version unavailable" -} - -private object EpistemeDesktopAppVersion - -private fun ImportedBookFile.desktopFileType(): FileType { - return SharedFileCapabilities.fileTypeForName(name) -} - -private object DesktopFolderPathResolver : SharedFolderPathResolver { - override fun relativeFolderSegments(item: BookItem): List { - val sourceFolder = item.sourceFolder ?: return emptyList() - val bookPath = item.path ?: return emptyList() - val parentFile = File(bookPath).parentFile ?: return emptyList() - val paths = runCatching { - File(sourceFolder).toPath().toAbsolutePath().normalize() to - parentFile.toPath().toAbsolutePath().normalize() - }.getOrNull() ?: return emptyList() - val (root, parent) = paths - if (!parent.startsWith(root) || parent == root) return emptyList() - return root.relativize(parent).map { it.toString() }.filter { it.isNotBlank() } - } -} - -private fun List.collectTags(): List { - return flatMap { it.tags }.distinctBy { it.id }.sortedBy { it.name.lowercase() } -} - -private fun BookItem.cardTitleForMessage(): String { - return title?.takeIf { it.isNotBlank() } ?: displayName -} - -private fun Long.toReadableSize(): String { - if (this <= 0L) return "Unknown" - val units = listOf("B", "KB", "MB", "GB", "TB") - var value = this.toDouble() - var unitIndex = 0 - while (value >= 1024.0 && unitIndex < units.lastIndex) { - value /= 1024.0 - unitIndex += 1 - } - return if (unitIndex == 0) { - "$this ${units[unitIndex]}" - } else { - "${String.format("%.1f", value)} ${units[unitIndex]}" - } -} - -private fun File.toImportedBookFile(sourceFolder: String? = null): ImportedBookFile { - return ImportedBookFile( - name = name, - uriString = null, - localPath = absolutePath, - size = length(), - sourceFolder = sourceFolder - ) -} - -@Composable -private fun DesktopExternalLinkDialog( - url: String?, - onDismiss: () -> Unit -) { - if (url == null) return - val clipboardManager = LocalClipboardManager.current - LaunchedEffect(url) { - logExternalLink("dialog_show url=\"${url.logPreview()}\"") - } - fun dismiss() { - logExternalLink("dialog_dismiss url=\"${url.logPreview()}\"") - onDismiss() - } - DesktopReaderBottomSheet( - title = "External link", - onDismiss = ::dismiss - ) { - Text( - "You clicked an external link.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(10.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) - ) { - Text( - url, - modifier = Modifier.padding(12.dp), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - TextButton(onClick = ::dismiss) { - Text("Cancel") - } - TextButton( - onClick = { - logExternalLink("dialog_copy url=\"${url.logPreview()}\"") - clipboardManager.setText(AnnotatedString(url)) - onDismiss() - } - ) { - Text("Copy") - } - TextButton( - onClick = { - logExternalLink("dialog_open url=\"${url.logPreview()}\"") - openExternalUrl(url) - onDismiss() - } - ) { - Text("Open") - } - } - } -} - -private fun openExternalUrl(url: String) { - if (!currentDesktopBuildProfile().featurePolicy.projectLinks) { - logExternalLink("open_blocked_offline url=\"${url.logPreview()}\"") - return - } - val normalizedUrl = url.normalizedExternalUrl() - runCatching { - if (Desktop.isDesktopSupported()) { - val desktop = Desktop.getDesktop() - if (normalizedUrl.startsWith("mailto:", ignoreCase = true)) { - desktop.mail(URI(normalizedUrl)) - } else { - desktop.browse(URI(normalizedUrl)) - } - logExternalLink("open_system_browser_success url=\"${normalizedUrl.logPreview()}\"") - } else { - logExternalLink("open_system_browser_unavailable url=\"${normalizedUrl.logPreview()}\"") - } - }.onFailure { throwable -> - logExternalLink("open_system_browser_failed url=\"${normalizedUrl.logPreview()}\" error=\"${throwable.message.orEmpty().logPreview()}\"") - } -} - -private fun String.normalizedExternalUrl(): String { - val trimmed = trim() - return if (trimmed.startsWith("www.", ignoreCase = true)) { - "https://$trimmed" - } else { - trimmed - } -} - -private fun String.isRemoteNetworkUrl(): Boolean { - val trimmed = trim() - return trimmed.startsWith("http://", ignoreCase = true) || - trimmed.startsWith("https://", ignoreCase = true) || - trimmed.startsWith("ws://", ignoreCase = true) || - trimmed.startsWith("wss://", ignoreCase = true) -} - -private fun String.urlEncode(): String { - return URLEncoder.encode(this, Charsets.UTF_8.name()) -} -private const val PdfZoomPerfLogTag = "EpistemePdfZoomPerf" -private const val PdfLinkLogTag = "EpistemePdfLink" -private const val EpubLinkLogTag = "EpistemeEpubLink" -private const val EpubPaginationLogTag = "EpistemeEpubPagination" -private const val ReaderGapLogTag = "EpistemeReaderGap" -private const val EpubSelectionDebugLogTag = "EPUB_SELECTION_DEBUG" -private const val ExternalLinkLogTag = "EpistemeExternalLink" - -private fun logPdfSelection(message: String) { -} - -private fun logPdfZoomPerf(message: String) { - logDesktopDiagnostic(PdfZoomPerfLogTag) { message } -} - -private inline fun logPdfZoomPerf(message: () -> String) { - logDesktopDiagnostic(PdfZoomPerfLogTag, message) -} - -private fun logPdfLink(message: String) { - logDesktopDiagnostic(PdfLinkLogTag) { message } -} - -private fun logEpubLink(message: String) { - logDesktopDiagnostic(EpubLinkLogTag) { message } -} - -private fun logEpubPagination(message: String) { - logDesktopDiagnostic(EpubPaginationLogTag) { message } -} - -private fun logReaderGap(message: String) { - logDesktopDiagnostic(ReaderGapLogTag) { message } -} - -private fun logEpubSelectionDebug(message: String) { - logDesktopDiagnostic(EpubSelectionDebugLogTag) { message } -} - -private fun logExternalLink(message: String) { - logDesktopDiagnostic(ExternalLinkLogTag) { message } -} - -private fun DesktopPdfLinkTarget.formatLogTarget(): String { - return "dest=${destPageIndex?.let { it + 1 } ?: "null"} uri=\"${uri.orEmpty().logPreview()}\"" -} - -private fun String.logPreview(maxLength: Int = 96): String { - return replace(Regex("\\s+"), " ") - .trim() - .let { if (it.length <= maxLength) it else it.take(maxLength) + "..." } - .replace("\"", "\\\"") -} - -private fun Float.formatLogFloat(): String { - return String.format("%.3f", this) -} - -private fun Offset?.formatLogOffset(): String { - if (this == null) return "none" - return "${x.formatLogFloat()},${y.formatLogFloat()}" -} - -private fun IntSize.formatLogSize(): String { - return "${width}x${height}" -} - -private fun DesktopPdfCharHit?.formatLogHit(prefix: String): String { - if (this == null) { - return "${prefix}Index=null ${prefix}Source=none ${prefix}X=null ${prefix}Y=null ${prefix}Nx=null ${prefix}Ny=null" - } - return "${prefix}Index=$index ${prefix}Source=$source " + - "${prefix}X=${point.x.formatLogFloat()} ${prefix}Y=${point.y.formatLogFloat()} " + - "${prefix}Nx=${normalized.x.formatLogFloat()} ${prefix}Ny=${normalized.y.formatLogFloat()}" -} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAppearanceModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAppearanceModels.kt index af06e0e..0c8ffa3 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAppearanceModels.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAppearanceModels.kt @@ -21,6 +21,7 @@ enum class ReaderFont(val id: String, val displayName: String, val fontFamilyNam enum class ReaderTextAlign(val id: String, val cssValue: String, val displayName: String) { DEFAULT("default", "", "Default"), LEFT("left", "left", "Left"), + RIGHT("right", "right", "Right"), JUSTIFY("justify", "justify", "Justify") } @@ -176,6 +177,7 @@ fun ReaderTextAlign.toSharedReaderTextAlign(): SharedReaderTextAlign { return when (this) { ReaderTextAlign.DEFAULT, ReaderTextAlign.LEFT -> SharedReaderTextAlign.START + ReaderTextAlign.RIGHT -> SharedReaderTextAlign.RIGHT ReaderTextAlign.JUSTIFY -> SharedReaderTextAlign.JUSTIFY } } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilder.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilder.kt index 8b38f1f..3a56c12 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilder.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilder.kt @@ -212,6 +212,7 @@ object ReaderHtmlDocumentBuilder { val appearance = settings.toDocumentAppearanceCss(textureDataUri) val align = when (settings.textAlign) { SharedReaderTextAlign.START -> "left" + SharedReaderTextAlign.RIGHT -> "right" SharedReaderTextAlign.JUSTIFY -> "justify" SharedReaderTextAlign.CENTER -> "center" } @@ -418,13 +419,14 @@ object ReaderHtmlDocumentBuilder { display: none; flex-direction: column; width: max-content; - max-width: min(300px, calc(100vw - 16px)); + max-width: min(280px, calc(100vw - 16px)); padding: 0 0 6px; border-radius: 14px; background: color-mix(in srgb, var(--reader-bg) 92%, var(--reader-fg)); border: 1px solid color-mix(in srgb, var(--reader-fg) 18%, transparent); box-shadow: 0 18px 44px rgba(0, 0, 0, 0.28); - overflow: hidden; + max-height: calc(100vh - 16px); + overflow: auto; } #reader-selection-menu button { border: 0; @@ -440,16 +442,16 @@ object ReaderHtmlDocumentBuilder { display: flex; justify-content: center; align-items: center; - gap: 10px; + gap: 8px; width: 100%; box-sizing: border-box; - padding: 10px 12px; + padding: 8px 10px; border-bottom: 1px solid color-mix(in srgb, var(--reader-fg) 12%, transparent); overflow-x: auto; } #reader-selection-menu .reader-selection-color { - width: 28px; - height: 28px; + width: 24px; + height: 24px; flex: 0 0 auto; padding: 0; border-radius: 999px; @@ -458,34 +460,34 @@ object ReaderHtmlDocumentBuilder { } #reader-selection-menu .reader-selection-actions { display: grid; - grid-template-columns: repeat(3, 78px); - gap: 4px; - padding: 6px 8px 2px; + grid-template-columns: repeat(3, 70px); + gap: 3px; + padding: 5px 6px 2px; } #reader-selection-menu .reader-selection-action { - min-height: 58px; + min-height: 52px; border-radius: 10px; display: flex; flex-direction: column; align-items: center; justify-content: center; - gap: 5px; - padding: 7px 4px; + gap: 4px; + padding: 6px 4px; line-height: 1; white-space: nowrap; } #reader-selection-menu .reader-selection-icon { display: grid; place-items: center; - width: 24px; - height: 24px; + width: 22px; + height: 22px; border-radius: 999px; background: color-mix(in srgb, var(--reader-fg) 9%, transparent); color: color-mix(in srgb, var(--reader-fg) 86%, transparent); } #reader-selection-menu .reader-selection-icon svg { - width: 18px; - height: 18px; + width: 16px; + height: 16px; display: block; fill: currentColor; } @@ -1185,18 +1187,90 @@ object ReaderHtmlDocumentBuilder { function positionMenu(left, top, anchorRect) { menu.style.visibility = 'hidden'; menu.style.display = 'flex'; + var margin = 8; + var gap = 14; + var viewportWidth = Math.max(0, window.innerWidth || 0); + var viewportHeight = Math.max(0, window.innerHeight || 0); + menu.style.maxHeight = Math.max(0, viewportHeight - margin * 2) + 'px'; var menuWidth = menu.offsetWidth || 300; var menuHeight = menu.offsetHeight || 230; - var margin = 8; - var nextLeft = left; - var nextTop = top; - if (anchorRect) { - nextLeft = anchorRect.left + (anchorRect.width / 2) - (menuWidth / 2); - nextTop = anchorRect.top - menuHeight - 14; - if (nextTop < margin) nextTop = anchorRect.bottom + 14; + + function clampMenuStart(preferred, size, viewportSize) { + if (viewportSize <= 0 || size <= 0) return 0; + if (viewportSize <= size) return 0; + var maxStart = viewportSize - size; + var minStart = Math.min(margin, maxStart); + var maxClampedStart = Math.max(minStart, viewportSize - size - margin); + return Math.max(minStart, Math.min(maxClampedStart, preferred)); + } + function selectionMenuCandidate(x, y) { + return { + left: clampMenuStart(x, menuWidth, viewportWidth), + top: clampMenuStart(y, menuHeight, viewportHeight) + }; + } + function overlapAreaWithSelection(candidate, rect) { + var overlapWidth = Math.min(candidate.left + menuWidth, rect.right) - Math.max(candidate.left, rect.left); + var overlapHeight = Math.min(candidate.top + menuHeight, rect.bottom) - Math.max(candidate.top, rect.top); + return Math.max(0, overlapWidth) * Math.max(0, overlapHeight); + } + function distanceFromSelection(candidate, rect) { + var dx = Math.max(rect.left - (candidate.left + menuWidth), candidate.left - rect.right, 0); + var dy = Math.max(rect.top - (candidate.top + menuHeight), candidate.top - rect.bottom, 0); + return dx * dx + dy * dy; + } + var rect = anchorRect ? { + left: Math.max(0, Math.min(viewportWidth, Math.min(anchorRect.left, anchorRect.right))), + top: Math.max(0, Math.min(viewportHeight, Math.min(anchorRect.top, anchorRect.bottom))), + right: Math.max(0, Math.min(viewportWidth, Math.max(anchorRect.left, anchorRect.right))), + bottom: Math.max(0, Math.min(viewportHeight, Math.max(anchorRect.top, anchorRect.bottom))) + } : { + left: Math.max(0, Math.min(viewportWidth, left)), + top: Math.max(0, Math.min(viewportHeight, top)), + right: Math.max(0, Math.min(viewportWidth, left)), + bottom: Math.max(0, Math.min(viewportHeight, top)) + }; + var centerX = (rect.left + rect.right) / 2; + var centerY = (rect.top + rect.bottom) / 2; + var above = selectionMenuCandidate(centerX - menuWidth / 2, rect.top - gap - menuHeight); + var below = selectionMenuCandidate(centerX - menuWidth / 2, rect.bottom + gap); + var right = selectionMenuCandidate(rect.right + gap, centerY - menuHeight / 2); + var leftSide = selectionMenuCandidate(rect.left - gap - menuWidth, centerY - menuHeight / 2); + var nextLeft = above.left; + var nextTop = above.top; + if (above.top + menuHeight <= rect.top - gap && above.top >= margin) { + nextLeft = above.left; + nextTop = above.top; + } else if (below.top >= rect.bottom + gap && below.top + menuHeight <= viewportHeight - margin) { + nextLeft = below.left; + nextTop = below.top; + } else { + var leftSpace = rect.left - gap - margin; + var rightSpace = viewportWidth - rect.right - gap - margin; + var firstSide = rightSpace >= leftSpace ? right : leftSide; + var secondSide = rightSpace >= leftSpace ? leftSide : right; + var firstFits = firstSide === right + ? firstSide.left >= rect.right + gap + : firstSide.left + menuWidth <= rect.left - gap; + var secondFits = secondSide === right + ? secondSide.left >= rect.right + gap + : secondSide.left + menuWidth <= rect.left - gap; + if (firstFits && firstSide.top >= margin && firstSide.top + menuHeight <= viewportHeight - margin) { + nextLeft = firstSide.left; + nextTop = firstSide.top; + } else if (secondFits && secondSide.top >= margin && secondSide.top + menuHeight <= viewportHeight - margin) { + nextLeft = secondSide.left; + nextTop = secondSide.top; + } else { + var fallback = [above, below, firstSide, secondSide].sort(function (a, b) { + var overlapDelta = overlapAreaWithSelection(a, rect) - overlapAreaWithSelection(b, rect); + if (overlapDelta !== 0) return overlapDelta; + return distanceFromSelection(a, rect) - distanceFromSelection(b, rect); + })[0]; + nextLeft = fallback.left; + nextTop = fallback.top; + } } - nextLeft = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, nextLeft)); - nextTop = Math.max(margin, Math.min(window.innerHeight - menuHeight - margin, nextTop)); menu.style.left = nextLeft + 'px'; menu.style.top = nextTop + 'px'; menu.style.visibility = 'visible'; @@ -2458,7 +2532,11 @@ object ReaderHtmlDocumentBuilder { } }); document.addEventListener('pointercancel', function () { - if (!activeSelectionHandle) selectionPointerDown = false; + if (!activeSelectionHandle) { + selectionPointerDown = false; + scheduleMenuFromSelection(); + scheduleVisiblePageReport(); + } }); document.addEventListener('mouseup', function (event) { if (menu.contains(event.target)) return; @@ -2467,6 +2545,19 @@ object ReaderHtmlDocumentBuilder { scheduleMenuFromSelection(); scheduleVisiblePageReport(); }); + document.addEventListener('touchend', function (event) { + if (menu.contains(event.target)) return; + if (activeSelectionHandle) return; + selectionPointerDown = false; + scheduleMenuFromSelection(); + scheduleVisiblePageReport(); + }, { passive: true }); + document.addEventListener('touchcancel', function () { + if (activeSelectionHandle) return; + selectionPointerDown = false; + scheduleMenuFromSelection(); + scheduleVisiblePageReport(); + }, { passive: true }); document.addEventListener('keyup', function () { scheduleMenuFromSelection(); }); diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderModels.kt index 2933501..4bd9d49 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderModels.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderModels.kt @@ -45,6 +45,7 @@ enum class ReaderPageSpreadMode { enum class SharedReaderTextAlign { START, + RIGHT, JUSTIFY, CENTER } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedNativePaginatedReader.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedNativePaginatedReader.kt index 2470351..8295ee2 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedNativePaginatedReader.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedNativePaginatedReader.kt @@ -350,9 +350,10 @@ fun SharedNativePaginatedReader( ) } if (!selectionGestureActive && !selectionHandleDragging) { + val highlightPalette = renderPlan.highlightPalette.sanitized().colors SharedNativeSelectionMenu( selection = selection, - highlightPalette = renderPlan.highlightPalette.sanitized().colors, + highlightPalette = highlightPalette, enabledSelectionActions = enabledSelectionActions, background = renderPlan.background, foreground = renderPlan.foreground, @@ -375,7 +376,9 @@ fun SharedNativePaginatedReader( sharedNativeSelectionMenuOffset( selection = selection, readerCoordinates = readerCoordinates, - density = density + density = density, + highlightPaletteSize = highlightPalette.size, + actionCount = enabledSelectionActions.size + 2 ) } ) @@ -620,21 +623,21 @@ private fun SharedNativeSelectionMenu( Column( modifier = Modifier .width(IntrinsicSize.Max) - .widthIn(max = 300.dp) + .widthIn(max = 280.dp) .padding(bottom = 6.dp) ) { if (highlightPalette.isNotEmpty()) { Row( modifier = Modifier .horizontalScroll(rememberScrollState()) - .padding(horizontal = 12.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically ) { highlightPalette.forEach { color -> Box( modifier = Modifier - .size(28.dp) + .size(24.dp) .clip(CircleShape) .background(color.color) .border( @@ -650,8 +653,8 @@ private fun SharedNativeSelectionMenu( } Column( modifier = Modifier - .padding(start = 8.dp, top = 6.dp, end = 8.dp, bottom = 2.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) + .padding(start = 6.dp, top = 5.dp, end = 6.dp, bottom = 2.dp), + verticalArrangement = Arrangement.spacedBy(3.dp) ) { actions.chunked(3).forEach { rowActions -> Row( @@ -688,17 +691,17 @@ private fun SharedNativeSelectionIconButton( ) { Column( modifier = Modifier - .width(78.dp) - .height(58.dp) + .width(70.dp) + .height(52.dp) .clip(RoundedCornerShape(10.dp)) .clickable { action.onClick() } - .padding(horizontal = 4.dp, vertical = 7.dp), + .padding(horizontal = 4.dp, vertical = 6.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.CenterVertically) ) { Box( modifier = Modifier - .size(24.dp) + .size(22.dp) .clip(CircleShape) .background(iconBackground), contentAlignment = Alignment.Center @@ -707,7 +710,7 @@ private fun SharedNativeSelectionIconButton( imageVector = action.icon, contentDescription = action.label, tint = iconColor, - modifier = Modifier.size(18.dp) + modifier = Modifier.size(16.dp) ) } Text( @@ -1878,27 +1881,53 @@ private data class SharedNativeSelectionEndpoint( private fun sharedNativeSelectionMenuOffset( selection: SharedNativeReaderTextSelection, readerCoordinates: LayoutCoordinates?, - density: Density + density: Density, + highlightPaletteSize: Int, + actionCount: Int ): IntOffset { val coordinates = readerCoordinates?.takeIf { it.isAttached } ?: return IntOffset(16, 16) if (selection.rect == Rect.Zero) return IntOffset(16, 16) - val centerX = (selection.rect.left + selection.rect.right) / 2f - val topLocal = coordinates.windowToLocal(Offset(centerX, selection.rect.top)) - val bottomLocal = coordinates.windowToLocal(Offset(centerX, selection.rect.bottom)) + val leftTopLocal = coordinates.windowToLocal(Offset(selection.rect.left, selection.rect.top)) + val rightBottomLocal = coordinates.windowToLocal(Offset(selection.rect.right, selection.rect.bottom)) val paddingPx = with(density) { 16.dp.toPx() } - val estimatedWidthPx = with(density) { 300.dp.toPx() } - val estimatedHeightPx = with(density) { 154.dp.toPx() } - val maxX = (coordinates.size.width - estimatedWidthPx - paddingPx).coerceAtLeast(paddingPx) - val x = (topLocal.x - estimatedWidthPx / 2f).coerceIn(paddingPx, maxX) - val yAbove = topLocal.y - estimatedHeightPx - paddingPx - val y = if (yAbove >= paddingPx) { - yAbove - } else { - (bottomLocal.y + paddingPx).coerceAtMost( - (coordinates.size.height - estimatedHeightPx - paddingPx).coerceAtLeast(paddingPx) - ) + val estimatedWidthPx = with(density) { 280.dp.toPx() } + val estimatedHeightPx = sharedNativeSelectionMenuEstimatedHeightPx( + density = density, + highlightPaletteSize = highlightPaletteSize, + actionCount = actionCount + ) + val selectionRect = SharedSelectionMenuRect( + left = leftTopLocal.x, + top = leftTopLocal.y, + right = rightBottomLocal.x, + bottom = rightBottomLocal.y + ) + val placement = sharedSelectionMenuPlacement( + viewport = SharedSelectionMenuViewport(coordinates.size.width, coordinates.size.height), + popup = SharedSelectionMenuSize( + width = estimatedWidthPx.roundToInt(), + height = estimatedHeightPx.roundToInt() + ), + selection = selectionRect, + marginPx = paddingPx, + gapPx = paddingPx + ) + return IntOffset(placement.x, placement.y) +} + +private fun sharedNativeSelectionMenuEstimatedHeightPx( + density: Density, + highlightPaletteSize: Int, + actionCount: Int +): Float { + val actionRows = ((actionCount.coerceAtLeast(1) + 2) / 3).coerceAtLeast(1) + return with(density) { + val paletteHeight = if (highlightPaletteSize > 0) 41.dp.toPx() else 0f + val actionsHeight = 7.dp.toPx() + + (actionRows * 52).dp.toPx() + + ((actionRows - 1).coerceAtLeast(0) * 3).dp.toPx() + paletteHeight + actionsHeight } - return IntOffset(x.roundToInt(), y.roundToInt()) } private fun sharedNativeSelectionHandleOffset( @@ -2395,6 +2424,7 @@ private fun ReaderSettings.renderedDefaultBlockSpacingDp(): Dp { private fun SharedReaderTextAlign.toComposeTextAlign(): TextAlign { return when (this) { SharedReaderTextAlign.START -> TextAlign.Start + SharedReaderTextAlign.RIGHT -> TextAlign.Right SharedReaderTextAlign.JUSTIFY -> TextAlign.Justify SharedReaderTextAlign.CENTER -> TextAlign.Center } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderChrome.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderChrome.kt index 60f2502..32bf14f 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderChrome.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderChrome.kt @@ -1495,6 +1495,13 @@ fun SharedReaderFormatControls( }, label = { Text("Left") } ) + FilterChip( + selected = settings.textAlign == SharedReaderTextAlign.RIGHT, + onClick = { + onReaderAction(ReaderAction.SettingsChanged(settings.copy(textAlign = SharedReaderTextAlign.RIGHT))) + }, + label = { Text("Right") } + ) FilterChip( selected = settings.textAlign == SharedReaderTextAlign.JUSTIFY, onClick = { diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedSelectionMenuPlacement.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedSelectionMenuPlacement.kt new file mode 100644 index 0000000..5348144 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedSelectionMenuPlacement.kt @@ -0,0 +1,182 @@ +package com.aryan.reader.shared.ui + +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +data class SharedSelectionMenuViewport( + val width: Int, + val height: Int +) + +data class SharedSelectionMenuSize( + val width: Int, + val height: Int +) + +data class SharedSelectionMenuRect( + val left: Float, + val top: Float, + val right: Float, + val bottom: Float +) + +enum class SharedSelectionMenuPlacement { + ABOVE, + BELOW, + LEFT, + RIGHT, + FALLBACK +} + +data class SharedSelectionMenuPlacementResult( + val x: Int, + val y: Int, + val placement: SharedSelectionMenuPlacement +) + +fun sharedSelectionMenuPlacement( + viewport: SharedSelectionMenuViewport, + popup: SharedSelectionMenuSize, + selection: SharedSelectionMenuRect, + marginPx: Float, + gapPx: Float +): SharedSelectionMenuPlacementResult { + val viewportWidth = viewport.width.coerceAtLeast(0).toFloat() + val viewportHeight = viewport.height.coerceAtLeast(0).toFloat() + val popupWidth = popup.width.coerceAtLeast(0).toFloat() + val popupHeight = popup.height.coerceAtLeast(0).toFloat() + val margin = marginPx.coerceAtLeast(0f) + val gap = gapPx.coerceAtLeast(0f) + val keepClear = selection.normalized().clampedToViewport(viewportWidth, viewportHeight) + + fun centeredX(): Float = keepClear.centerX - popupWidth / 2f + fun centeredY(): Float = keepClear.centerY - popupHeight / 2f + fun clamped(x: Float, y: Float): SharedSelectionMenuCandidate { + return SharedSelectionMenuCandidate( + x = clampStart(x, popupWidth, viewportWidth, margin), + y = clampStart(y, popupHeight, viewportHeight, margin) + ) + } + + val above = clamped(centeredX(), keepClear.top - gap - popupHeight) + if (above.y + popupHeight <= keepClear.top - gap && above.y >= margin) { + return above.toResult(SharedSelectionMenuPlacement.ABOVE) + } + + val below = clamped(centeredX(), keepClear.bottom + gap) + if (below.y >= keepClear.bottom + gap && below.y + popupHeight <= viewportHeight - margin) { + return below.toResult(SharedSelectionMenuPlacement.BELOW) + } + + val leftSpace = keepClear.left - gap - margin + val rightSpace = viewportWidth - keepClear.right - gap - margin + val sideCandidates = if (rightSpace >= leftSpace) { + listOf( + SharedSelectionMenuPlacement.RIGHT to clamped(keepClear.right + gap, centeredY()), + SharedSelectionMenuPlacement.LEFT to clamped(keepClear.left - gap - popupWidth, centeredY()) + ) + } else { + listOf( + SharedSelectionMenuPlacement.LEFT to clamped(keepClear.left - gap - popupWidth, centeredY()), + SharedSelectionMenuPlacement.RIGHT to clamped(keepClear.right + gap, centeredY()) + ) + } + sideCandidates.forEach { (placement, candidate) -> + val fitsHorizontally = when (placement) { + SharedSelectionMenuPlacement.LEFT -> candidate.x + popupWidth <= keepClear.left - gap + SharedSelectionMenuPlacement.RIGHT -> candidate.x >= keepClear.right + gap + else -> false + } + if (fitsHorizontally && candidate.y >= margin && candidate.y + popupHeight <= viewportHeight - margin) { + return candidate.toResult(placement) + } + } + + return listOf( + above, + below, + sideCandidates[0].second, + sideCandidates[1].second + ).minWith( + compareBy { candidate -> + candidate.overlapAreaWith(keepClear, popupWidth, popupHeight) + }.thenBy { candidate -> + candidate.distanceFrom(keepClear, popupWidth, popupHeight) + } + ).toResult(SharedSelectionMenuPlacement.FALLBACK) +} + +private data class SharedSelectionMenuCandidate( + val x: Float, + val y: Float +) { + fun toResult(placement: SharedSelectionMenuPlacement): SharedSelectionMenuPlacementResult { + return SharedSelectionMenuPlacementResult( + x = x.roundToInt(), + y = y.roundToInt(), + placement = placement + ) + } + + fun overlapAreaWith( + rect: SharedSelectionMenuRect, + width: Float, + height: Float + ): Float { + val overlapWidth = min(x + width, rect.right) - max(x, rect.left) + val overlapHeight = min(y + height, rect.bottom) - max(y, rect.top) + return overlapWidth.coerceAtLeast(0f) * overlapHeight.coerceAtLeast(0f) + } + + fun distanceFrom( + rect: SharedSelectionMenuRect, + width: Float, + height: Float + ): Float { + val dx = maxOf(rect.left - (x + width), x - rect.right, 0f) + val dy = maxOf(rect.top - (y + height), y - rect.bottom, 0f) + return dx * dx + dy * dy + } +} + +private fun SharedSelectionMenuRect.normalized(): SharedSelectionMenuRect { + return SharedSelectionMenuRect( + left = min(left, right), + top = min(top, bottom), + right = max(left, right), + bottom = max(top, bottom) + ) +} + +private val SharedSelectionMenuRect.centerX: Float + get() = (left + right) / 2f + +private val SharedSelectionMenuRect.centerY: Float + get() = (top + bottom) / 2f + +private fun SharedSelectionMenuRect.clampedToViewport( + viewportWidth: Float, + viewportHeight: Float +): SharedSelectionMenuRect { + return SharedSelectionMenuRect( + left = left.coerceIn(0f, viewportWidth), + top = top.coerceIn(0f, viewportHeight), + right = right.coerceIn(0f, viewportWidth), + bottom = bottom.coerceIn(0f, viewportHeight) + ).normalized() +} + +private fun clampStart( + preferred: Float, + popupSize: Float, + viewportSize: Float, + margin: Float +): Float { + if (viewportSize <= 0f || popupSize <= 0f) return 0f + if (viewportSize <= popupSize) return 0f + val maxStart = viewportSize - popupSize + val min = margin.coerceIn(0f, maxStart) + val max = (viewportSize - popupSize - margin).coerceAtLeast(min) + return preferred.coerceIn(min, max) +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderActionReducerTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderActionReducerTest.kt index d80918f..a4183b0 100644 --- a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderActionReducerTest.kt +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderActionReducerTest.kt @@ -287,7 +287,7 @@ class ReaderActionReducerTest { verticalMargin = 2.0f, font = ReaderFont.ROBOTO_MONO, customPath = null, - textAlign = ReaderTextAlign.JUSTIFY + textAlign = ReaderTextAlign.RIGHT ) ), engine @@ -301,7 +301,7 @@ class ReaderActionReducerTest { assertEquals(0.8f, updated.reader.settings.paragraphSpacing, 0.0001f) assertEquals(1.3f, updated.reader.settings.imageScale, 0.0001f) assertEquals("Mono", updated.reader.settings.fontFamily) - assertEquals(SharedReaderTextAlign.JUSTIFY, updated.reader.settings.textAlign) + assertEquals(SharedReaderTextAlign.RIGHT, updated.reader.settings.textAlign) assertTrue(updated.reader.settings.darkMode) assertEquals(ReaderReadingMode.VERTICAL, updated.reader.settings.readingMode) assertEquals(812, updated.reader.settings.pageWidth) diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt index 76ad406..3cd0279 100644 --- a/shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt @@ -27,6 +27,17 @@ import kotlin.test.assertTrue class ReaderHtmlDocumentBuilderTest { + @Test + fun `page document writes right alignment reader css variable`() { + val html = ReaderHtmlDocumentBuilder.pageDocument( + book = repeatedWordBook("alpha beta"), + page = ReaderPage(0, 0, "One", "alpha beta", 0, 10), + settings = ReaderSettings(textAlign = SharedReaderTextAlign.RIGHT) + ) + + assertTrue(html.contains("--reader-align: right;")) + } + @Test fun `page document renders only the highlighted occurrence from locator offsets`() { val text = "alpha beta alpha beta" @@ -321,11 +332,14 @@ class ReaderHtmlDocumentBuilderTest { assertTrue(html.contains("function scheduleMenuFromSelection()")) assertTrue(html.contains("selectionAnchorRect(selection)")) + assertTrue(html.contains("selectionMenuCandidate")) + assertTrue(html.contains("overlapAreaWithSelection")) assertTrue(html.contains("if (selectionPointerDown || activeSelectionHandle) return;")) assertTrue(html.contains("rangeBoundaryRect(range.startContainer")) assertTrue(html.contains("document.addEventListener('selectionchange'")) assertTrue(html.contains("document.addEventListener('pointerdown'")) assertTrue(html.contains("document.addEventListener('mouseup'")) + assertTrue(html.contains("document.addEventListener('touchend'")) assertTrue(html.contains("document.addEventListener('contextmenu'")) } diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedSelectionMenuPlacementTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedSelectionMenuPlacementTest.kt new file mode 100644 index 0000000..19ee6f6 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedSelectionMenuPlacementTest.kt @@ -0,0 +1,99 @@ +package com.aryan.reader.shared.ui + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SharedSelectionMenuPlacementTest { + @Test + fun `places menu above selection when there is room`() { + val result = sharedSelectionMenuPlacement( + viewport = SharedSelectionMenuViewport(width = 800, height = 600), + popup = SharedSelectionMenuSize(width = 240, height = 120), + selection = SharedSelectionMenuRect(left = 300f, top = 300f, right = 360f, bottom = 330f), + marginPx = 16f, + gapPx = 12f + ) + + assertEquals(SharedSelectionMenuPlacement.ABOVE, result.placement) + assertEquals(210, result.x) + assertEquals(168, result.y) + } + + @Test + fun `places menu below selection when above is blocked`() { + val result = sharedSelectionMenuPlacement( + viewport = SharedSelectionMenuViewport(width = 800, height = 600), + popup = SharedSelectionMenuSize(width = 240, height = 120), + selection = SharedSelectionMenuRect(left = 300f, top = 40f, right = 360f, bottom = 70f), + marginPx = 16f, + gapPx = 12f + ) + + assertEquals(SharedSelectionMenuPlacement.BELOW, result.placement) + assertEquals(210, result.x) + assertEquals(82, result.y) + } + + @Test + fun `places menu on wider side in short landscape viewport`() { + val result = sharedSelectionMenuPlacement( + viewport = SharedSelectionMenuViewport(width = 800, height = 320), + popup = SharedSelectionMenuSize(width = 240, height = 180), + selection = SharedSelectionMenuRect(left = 300f, top = 120f, right = 380f, bottom = 190f), + marginPx = 16f, + gapPx = 12f + ) + + assertEquals(SharedSelectionMenuPlacement.RIGHT, result.placement) + assertEquals(392, result.x) + assertEquals(65, result.y) + } + + @Test + fun `keeps menu off selected text when a valid side placement exists`() { + val result = sharedSelectionMenuPlacement( + viewport = SharedSelectionMenuViewport(width = 800, height = 320), + popup = SharedSelectionMenuSize(width = 240, height = 180), + selection = SharedSelectionMenuRect(left = 300f, top = 120f, right = 380f, bottom = 190f), + marginPx = 16f, + gapPx = 12f + ) + + assertEquals(0f, result.rect(width = 240, height = 180).overlapAreaWith( + SharedSelectionMenuRect(left = 300f, top = 120f, right = 380f, bottom = 190f) + )) + } + + @Test + fun `falls back predictably when selection consumes the viewport`() { + val result = sharedSelectionMenuPlacement( + viewport = SharedSelectionMenuViewport(width = 320, height = 220), + popup = SharedSelectionMenuSize(width = 260, height = 180), + selection = SharedSelectionMenuRect(left = 10f, top = 20f, right = 310f, bottom = 200f), + marginPx = 16f, + gapPx = 12f + ) + + assertEquals(SharedSelectionMenuPlacement.FALLBACK, result.placement) + assertEquals(30, result.x) + assertEquals(16, result.y) + } +} + +private fun SharedSelectionMenuPlacementResult.rect( + width: Int, + height: Int +): SharedSelectionMenuRect { + return SharedSelectionMenuRect( + left = x.toFloat(), + top = y.toFloat(), + right = x + width.toFloat(), + bottom = y + height.toFloat() + ) +} + +private fun SharedSelectionMenuRect.overlapAreaWith(other: SharedSelectionMenuRect): Float { + val overlapWidth = minOf(right, other.right) - maxOf(left, other.left) + val overlapHeight = minOf(bottom, other.bottom) - maxOf(top, other.top) + return overlapWidth.coerceAtLeast(0f) * overlapHeight.coerceAtLeast(0f) +} diff --git a/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedMeasuredEpubPaginator.kt b/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedMeasuredEpubPaginator.kt index f2298f1..1c7724c 100644 --- a/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedMeasuredEpubPaginator.kt +++ b/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedMeasuredEpubPaginator.kt @@ -719,6 +719,7 @@ class SharedMeasuredEpubPaginator( private fun SharedReaderTextAlign.toComposeTextAlign(): TextAlign { return when (this) { SharedReaderTextAlign.START -> TextAlign.Start + SharedReaderTextAlign.RIGHT -> TextAlign.Right SharedReaderTextAlign.JUSTIFY -> TextAlign.Justify SharedReaderTextAlign.CENTER -> TextAlign.Center }