book-reader/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt
Aryan 056485a140
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
2026-05-16 16:19:31 +05:30

7397 lines
377 KiB
Kotlin

package com.aryan.reader.desktop
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
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.scrollBy
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.fillMaxHeight
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.offset
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.items
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.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.MoreVert
import androidx.compose.material.icons.filled.ZoomIn
import androidx.compose.material.icons.filled.ZoomOut
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
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.Slider
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.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.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.luminance
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.PointerEventType
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.isPrimaryPressed
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.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.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.zIndex
import androidx.compose.ui.window.WindowPlacement
import com.aryan.reader.shared.AppAction
import com.aryan.reader.shared.BannerMessage
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.BookShelfRef
import com.aryan.reader.shared.BuiltInPdfReaderThemes
import com.aryan.reader.shared.CustomFontItem
import com.aryan.reader.shared.EpubAnnotationSerializer
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.ReaderAiByokSettings
import com.aryan.reader.shared.ReaderAiFeature
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.ReaderContextExtractor
import com.aryan.reader.shared.ReaderExtrasState
import com.aryan.reader.shared.ReaderExternalLookupAction
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.ReaderTtsCacheSummary
import com.aryan.reader.shared.ReaderToolbarPreferences
import com.aryan.reader.shared.ReaderTtsChunk
import com.aryan.reader.shared.ReaderTtsPlanner
import com.aryan.reader.shared.ReaderTtsProgress
import com.aryan.reader.shared.ReaderTtsReadScope
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.SharedImportOutcomeCounts
import com.aryan.reader.shared.SharedImportPlanner
import com.aryan.reader.shared.SharedLibraryEditor
import com.aryan.reader.shared.SharedLibraryProjectionInput
import com.aryan.reader.shared.SharedLibrarySnapshot
import com.aryan.reader.shared.SharedLibraryStateProjector
import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.SharedSettingsAction
import com.aryan.reader.shared.SharedSettingsDestination
import com.aryan.reader.shared.SharedSettingsHubInput
import com.aryan.reader.shared.SharedSettingsPlatform
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.UserHighlight
import com.aryan.reader.shared.externalLookupUrl
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.PdfPageBounds
import com.aryan.reader.shared.pdf.PdfPagePoint
import com.aryan.reader.shared.pdf.PdfVisiblePageLayout
import com.aryan.reader.shared.pdf.PdfZoomSpec
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
import com.aryan.reader.shared.pdf.SharedPdfAnnotationDefaults
import com.aryan.reader.shared.pdf.SharedPdfAndroidHighlightColors
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.SharedPdfJumpHistory
import com.aryan.reader.shared.pdf.SharedPdfReaderAction
import com.aryan.reader.shared.pdf.SharedPdfReaderState
import com.aryan.reader.shared.pdf.SharedPdfReaderViewport
import com.aryan.reader.shared.pdf.SharedPdfRichDocument
import com.aryan.reader.shared.pdf.SharedPdfRichTextController
import com.aryan.reader.shared.pdf.SharedPdfRichTextLog
import com.aryan.reader.shared.pdf.SharedPdfRichTextSerializer
import com.aryan.reader.shared.pdf.SharedPdfSearchEngine
import com.aryan.reader.shared.pdf.SharedPdfSearchResult
import com.aryan.reader.shared.pdf.SharedPdfTextAnnotationDefaults
import com.aryan.reader.shared.pdf.SharedPdfTextDraft
import com.aryan.reader.shared.pdf.SharedPdfTextStyleConfig
import com.aryan.reader.shared.pdf.currentSharedPdfTextStyleConfig
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.toAnnotation
import com.aryan.reader.shared.pdf.updateCurrentSharedPdfTextStyle
import com.aryan.reader.shared.pdf.withBounds
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.ReaderLinkTarget
import com.aryan.reader.shared.reader.ReaderReadingMode
import com.aryan.reader.shared.reader.ReaderSettings
import com.aryan.reader.shared.reader.ReaderSessionState
import com.aryan.reader.shared.reader.SharedEpubBook
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.SharedJvmBookLoader
import com.aryan.reader.shared.reader.ReaderViewportSpec
import com.aryan.reader.shared.reader.SharedMeasuredEpubPaginator
import com.aryan.reader.shared.reader.layoutSignature
import com.aryan.reader.shared.opds.OpdsAcquisition
import com.aryan.reader.shared.opds.OpdsCatalog
import com.aryan.reader.shared.opds.OpdsEntry
import com.aryan.reader.shared.opds.OpdsStreamReference
import com.aryan.reader.shared.opds.SharedOpdsController
import com.aryan.reader.shared.opds.SharedOpdsDownloadState
import com.aryan.reader.shared.opds.SharedOpdsStreamUri
import com.aryan.reader.shared.reduce
import com.aryan.reader.shared.ui.DesktopEpubNativeImage
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.SharedNativeReaderSelectionAction
import com.aryan.reader.shared.ui.SharedNativePaginatedReader
import com.aryan.reader.shared.ui.ReaderWorkspaceShell
import com.aryan.reader.shared.ui.SharedAddToShelfDialog
import com.aryan.reader.shared.ui.SharedAppShell
import com.aryan.reader.shared.ui.SharedAppTab
import com.aryan.reader.shared.ui.SharedAppTheme
import com.aryan.reader.shared.ui.SharedAppThemeSettingsDialog
import com.aryan.reader.shared.ui.SharedAboutScreen
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.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.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.SharedReaderScreen
import com.aryan.reader.shared.ui.SharedStableOutlinedTextField
import com.aryan.reader.shared.ui.SharedReaderThemeControls
import com.aryan.reader.shared.ui.SharedPdfVerticalScrollbar
import com.aryan.reader.shared.ui.SharedReaderVerticalScrollbar
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.sharedPdfEmbeddedHitTest
import com.aryan.reader.shared.ui.sharedPdfHitTest
import com.aryan.reader.shared.ui.toSharedPdfPoint
import com.multiplatform.webview.jsbridge.IJsMessageHandler
import com.multiplatform.webview.jsbridge.JsMessage
import com.multiplatform.webview.jsbridge.rememberWebViewJsBridge
import com.multiplatform.webview.request.RequestInterceptor
import com.multiplatform.webview.request.WebRequest
import com.multiplatform.webview.request.WebRequestInterceptResult
import com.multiplatform.webview.web.LoadingState
import com.multiplatform.webview.web.WebContent
import com.multiplatform.webview.web.WebView
import com.multiplatform.webview.web.WebViewNavigator
import com.multiplatform.webview.web.WebViewState
import com.multiplatform.webview.web.rememberWebViewNavigator
import dev.datlag.kcef.KCEF
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.awt.Component
import java.awt.event.KeyEvent as AwtKeyEvent
import java.io.File
import java.util.UUID
import java.util.concurrent.atomic.AtomicReference
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
fun main() {
launchEpistemeDesktopApplication()
}
private fun desktopEmptyReaderBook(): SharedEpubBook {
return SharedEpubBook(
id = "desktop_empty_reader",
fileName = "",
title = "No book open",
chapters = listOf(
SharedEpubChapter(
id = "empty",
title = "No book open",
plainText = ""
)
)
)
}
private fun SharedLibrarySnapshot.withDesktopDefaults(): SharedLibrarySnapshot {
return if (appSeedColor == null) {
copy(appSeedColor = DesktopDefaultAppSeedColor)
} else {
this
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun EpistemeDesktopApp(
window: Component? = null,
appWindowPlacement: WindowPlacement,
readerFullscreen: Boolean,
onReaderFullscreenChange: (Boolean) -> Unit
) {
val desktopBuildProfile = remember { currentDesktopBuildProfile() }
val featurePolicy = desktopBuildProfile.featurePolicy
val libraryProjector = remember { SharedLibraryStateProjector(DesktopFolderPathResolver) }
val readerEngine = remember { ReaderEngine() }
val libraryDatabase = remember { DesktopLibraryDatabase() }
val desktopBookImporter = remember { DesktopBookImporter() }
val customFontStore = remember {
DesktopCustomFontStore(
googleFontsDownloadAvailable = { featurePolicy.googleFontsDownload }
)
}
val opdsRepository = remember { DesktopOpdsRepository() }
val opdsController = remember {
SharedOpdsController(
repository = opdsRepository,
idFactory = { UUID.randomUUID().toString() }
)
}
val aiByokStore = remember { DesktopAiByokStore() }
var aiByokSettings by remember {
mutableStateOf(aiByokStore.load().withDesktopFeaturePolicy(featurePolicy))
}
val desktopAiAdapter = remember {
DesktopByokAiAdapter(
settingsProvider = { aiByokSettings.withDesktopFeaturePolicy(featurePolicy) },
networkAccess = { featurePolicy.networkAccess }
)
}
val desktopTtsAdapter = remember {
DesktopGeminiCloudTtsAdapter(
settingsProvider = { aiByokSettings.withDesktopFeaturePolicy(featurePolicy) },
networkAccess = { featurePolicy.networkAccess }
)
}
val initialLibrarySnapshot = remember { libraryDatabase.load().withDesktopDefaults() }
val scope = rememberCoroutineScope()
var webViewRuntimeState by remember { mutableStateOf(DesktopWebViewRuntimeState()) }
var webViewRuntimeRequested by remember { mutableStateOf(false) }
var readerCustomTextureIds by remember { mutableStateOf(DesktopReaderTextures.importedTextureIds()) }
val appWindowFullscreen = appWindowPlacement == WindowPlacement.Fullscreen
EpistemeDesktopWindowDecorationEffect(
window = window,
hideDecoration = readerFullscreen && !appWindowFullscreen
)
DesktopReaderFullscreenEffect(
window = window,
enabled = readerFullscreen && !appWindowFullscreen
)
DisposableEffect(Unit) {
onDispose {
KCEF.disposeBlocking()
}
}
var shelfRecords by remember { mutableStateOf(initialLibrarySnapshot.shelfRecords) }
var shelfRefs by remember { mutableStateOf(initialLibrarySnapshot.shelfRefs) }
var state by remember {
val initialBooks = initialLibrarySnapshot.books.filter { it.type in DesktopReadableFileTypes }
val initialTags = initialLibrarySnapshot.tags.ifEmpty { initialBooks.collectTags() }
val initialState = SharedReaderScreenState(
rawLibraryBooks = initialBooks,
recentFilesLimit = initialLibrarySnapshot.recentFilesLimit,
allTags = initialTags,
syncedFolders = initialLibrarySnapshot.syncedFolders,
isTabsEnabled = initialLibrarySnapshot.isTabsEnabled,
openTabIds = initialLibrarySnapshot.openTabIds,
activeTabBookId = initialLibrarySnapshot.activeTabBookId,
pinnedHomeBookIds = initialLibrarySnapshot.pinnedHomeBookIds,
pinnedLibraryBookIds = initialLibrarySnapshot.pinnedLibraryBookIds,
useStrictFileFilter = initialLibrarySnapshot.useStrictFileFilter,
appThemeMode = initialLibrarySnapshot.appThemeMode,
appContrastOption = initialLibrarySnapshot.appContrastOption,
appTextDimFactorLight = initialLibrarySnapshot.appTextDimFactorLight,
appTextDimFactorDark = initialLibrarySnapshot.appTextDimFactorDark,
appSeedColor = initialLibrarySnapshot.appSeedColor,
customAppThemes = initialLibrarySnapshot.customAppThemes,
readerDefaultSettings = initialLibrarySnapshot.readerDefaultSettings,
pdfReaderDefaultSettings = initialLibrarySnapshot.pdfReaderDefaultSettings,
readerToolbarPreferences = initialLibrarySnapshot.readerToolbarPreferences,
readerHighlightPalette = initialLibrarySnapshot.readerHighlightPalette,
pdfHighlighterPalette = initialLibrarySnapshot.pdfHighlighterPalette,
readerTtsReplacementPreferences = initialLibrarySnapshot.readerTtsReplacementPreferences
)
mutableStateOf(
libraryProjector.project(
SharedLibraryProjectionInput(
state = initialState,
booksFromStore = initialState.rawLibraryBooks,
shelfRecords = shelfRecords,
shelfRefs = shelfRefs,
tags = initialState.allTags
)
)
)
}
var selectedTab by remember { mutableStateOf(SharedAppTab.HOME) }
var selectedLibraryTab by remember { mutableStateOf(NonReaderLibraryTab.BOOKS) }
var customFonts by remember {
mutableStateOf(initialLibrarySnapshot.customFonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() })
}
var activeReaderBookId by remember { mutableStateOf<String?>(null) }
val desktopEpubPaginationCache = remember { SharedEpubPaginationCache() }
var epubPaginationCacheGeneration by remember { mutableStateOf(0) }
LaunchedEffect(webViewRuntimeRequested) {
if (!shouldStartDesktopWebViewRuntime(webViewRuntimeRequested, webViewRuntimeState)) {
return@LaunchedEffect
}
val webViewBundleDir = withContext(Dispatchers.IO) { bundledDesktopWebViewDir() }
val webViewBundlePresent = withContext(Dispatchers.IO) {
isBundledDesktopWebViewPresent(webViewBundleDir)
}
if (!webViewBundlePresent) {
webViewRuntimeState = webViewRuntimeState.copy(
errorMessage = "Bundled embedded webview is missing from ${webViewBundleDir.absolutePath}."
)
return@LaunchedEffect
}
runCatching {
withContext(Dispatchers.IO) {
KCEF.init(
builder = {
installDir(webViewBundleDir)
progress {
onDownloading {
webViewRuntimeState = webViewRuntimeState.copy(downloadProgress = max(it, 0f))
}
onInitialized {
webViewRuntimeState = webViewRuntimeState.copy(initialized = true, errorMessage = null)
}
}
settings {
cachePath = File(desktopUserCacheRoot(), "kcef").absolutePath
}
},
onError = { error ->
webViewRuntimeState = webViewRuntimeState.copy(errorMessage = error?.message ?: error.toString())
},
onRestartRequired = {
webViewRuntimeState = webViewRuntimeState.copy(restartRequired = true)
}
)
}
}.onFailure { error ->
webViewRuntimeState = webViewRuntimeState.copy(errorMessage = error.message ?: error.toString())
}
}
var readerSession by remember { mutableStateOf(readerEngine.createSession(desktopEmptyReaderBook())) }
LaunchedEffect(readerSession.reader.book.id, readerSession.reader.settings.readingMode) {
if (
readerSession.reader.book.chapters.isNotEmpty() &&
readerSession.reader.settings.readingMode == ReaderReadingMode.VERTICAL
) {
webViewRuntimeRequested = true
}
}
var readerExtrasState by remember {
mutableStateOf(
ReaderExtrasState(
cloudTts = ReaderCloudTtsState(
isAvailable = aiByokSettings.isCloudTtsAvailable
)
)
)
}
var activePdfDocument by remember { mutableStateOf<DesktopPdfDocument?>(null) }
var openingReader by remember { mutableStateOf<DesktopReaderOpening?>(null) }
var nextReaderOpenRequestId by remember { mutableStateOf(0L) }
var showCreateShelfDialog by remember { mutableStateOf(false) }
var showCreateSmartShelfDialog by remember { mutableStateOf(false) }
var shelfToRename by remember { mutableStateOf<Shelf?>(null) }
var shelfToDelete by remember { mutableStateOf<Shelf?>(null) }
var folderToRemove by remember { mutableStateOf<Shelf?>(null) }
var showAddToShelfDialog by remember { mutableStateOf(false) }
var showTagSelectionDialog by remember { mutableStateOf(false) }
var showAiByokSettingsDialog by remember { mutableStateOf(false) }
var showDesktopAppThemeSettingsDialog by remember { mutableStateOf(false) }
var showClearBookCacheDialog by remember { mutableStateOf(false) }
var settingsQuery by remember { mutableStateOf("") }
var settingsDestination by remember { mutableStateOf(SharedSettingsDestination.ROOT) }
var bookInfoDialogFor by remember { mutableStateOf<BookItem?>(null) }
var bookInfoInitiallyEditing by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
var dropImportState by remember { mutableStateOf(DesktopDropImportState()) }
var opdsState by remember { mutableStateOf(opdsController.state) }
var readerTtsJob by remember { mutableStateOf<Job?>(null) }
fun projectState(
next: SharedReaderScreenState,
records: List<ShelfRecord> = shelfRecords,
refs: List<BookShelfRef> = shelfRefs
): SharedReaderScreenState {
return libraryProjector.project(
SharedLibraryProjectionInput(
state = next,
booksFromStore = next.rawLibraryBooks,
shelfRecords = records,
shelfRefs = refs,
tags = next.allTags.ifEmpty { next.rawLibraryBooks.collectTags() }
)
)
}
fun persistSnapshot(
projected: SharedReaderScreenState,
records: List<ShelfRecord> = shelfRecords,
refs: List<BookShelfRef> = shelfRefs,
fonts: List<CustomFontItem> = customFonts
) {
scope.launch(Dispatchers.IO) {
runCatching {
libraryDatabase.save(
SharedLibrarySnapshot(
books = projected.rawLibraryBooks,
shelfRecords = records,
shelfRefs = refs,
tags = projected.allTags,
customFonts = fonts,
syncedFolders = projected.syncedFolders,
recentFilesLimit = projected.recentFilesLimit,
isTabsEnabled = projected.isTabsEnabled,
openTabIds = projected.openTabIds,
activeTabBookId = projected.activeTabBookId,
pinnedHomeBookIds = projected.pinnedHomeBookIds,
pinnedLibraryBookIds = projected.pinnedLibraryBookIds,
useStrictFileFilter = projected.useStrictFileFilter,
appThemeMode = projected.appThemeMode,
appContrastOption = projected.appContrastOption,
appTextDimFactorLight = projected.appTextDimFactorLight,
appTextDimFactorDark = projected.appTextDimFactorDark,
appSeedColor = projected.appSeedColor,
customAppThemes = projected.customAppThemes,
readerDefaultSettings = projected.readerDefaultSettings,
pdfReaderDefaultSettings = projected.pdfReaderDefaultSettings,
readerToolbarPreferences = projected.readerToolbarPreferences,
readerHighlightPalette = projected.readerHighlightPalette,
pdfHighlighterPalette = projected.pdfHighlighterPalette,
readerTtsReplacementPreferences = projected.readerTtsReplacementPreferences
)
)
}
}
}
fun replaceLibrary(
next: SharedReaderScreenState,
records: List<ShelfRecord> = shelfRecords,
refs: List<BookShelfRef> = shelfRefs
) {
shelfRecords = records
shelfRefs = refs
val projected = projectState(next, records, refs)
state = projected
persistSnapshot(projected, records, refs)
}
fun updateState(next: SharedReaderScreenState) {
val projected = projectState(next)
state = projected
persistSnapshot(projected)
}
fun clearDesktopBookCache() {
scope.launch {
withContext(Dispatchers.IO) {
desktopEpubPaginationCache.clearAll()
SharedJvmBookLoader.clearCache()
}
epubPaginationCacheGeneration++
updateState(state.withBanner("Book cache cleared. EPUB pagination will be recreated on demand."))
}
}
fun updateAiByokSettings(next: ReaderAiByokSettings) {
if (!featurePolicy.aiAndCloud) return
val sanitized = next.sanitized()
logDesktopTts(
"settings_update keyPresent=${sanitized.geminiKey.isNotBlank()} " +
"ttsModel=\"${sanitized.ttsModel.desktopTtsPreview()}\" speaker=\"${sanitized.ttsSpeakerId.desktopTtsPreview()}\" " +
"cloudAvailable=${sanitized.isCloudTtsAvailable}"
)
aiByokSettings = sanitized
readerExtrasState = readerExtrasState.copy(
cloudTts = readerExtrasState.cloudTts.copy(
isAvailable = sanitized.isCloudTtsAvailable,
errorMessage = null,
cacheSummary = desktopTtsAdapter.cacheSummary(readerSession.reader.book.title, sanitized.ttsSpeakerId)
)
)
runCatching { aiByokStore.save(sanitized) }
.onFailure { error ->
logDesktopTts("settings_save_failed error=\"${error.desktopTtsSummary()}\"")
scope.launch {
snackbarHostState.showSnackbar(error.message ?: "AI settings could not be saved securely.")
}
}
}
fun updateReaderAutoScroll(autoScroll: ReaderAutoScrollState) {
readerExtrasState = readerExtrasState.copy(autoScroll = autoScroll.sanitized())
}
fun currentReaderTtsCacheSummary() =
if (activeReaderBookId == null) {
ReaderTtsCacheSummary()
} else {
desktopTtsAdapter.cacheSummary(readerSession.reader.book.title, aiByokSettings.sanitized().ttsSpeakerId)
}
fun readerCloudTtsStoppedState(statusMessage: String? = null, errorMessage: String? = null) = ReaderCloudTtsState(
isAvailable = aiByokSettings.sanitized().isCloudTtsAvailable,
statusMessage = statusMessage,
errorMessage = errorMessage,
cacheSummary = currentReaderTtsCacheSummary()
)
fun openReaderExternalLookup(action: ReaderExternalLookupAction, text: String) {
if (!featurePolicy.externalLookup) return
val normalizedText = text.trim()
if (normalizedText.isBlank()) return
openExternalUrl(externalLookupUrl(action, normalizedText.take(1800)))
}
fun runReaderAiAction(feature: ReaderAiFeature, text: String) {
val normalizedText = text.trim()
if (normalizedText.isBlank()) return
if (!aiByokSettings.sanitized().areReaderAiFeaturesAvailable) return
readerExtrasState = readerExtrasState.copy(
aiResult = ReaderAiResultState(
title = feature.displayName,
isLoading = true
)
)
scope.launch {
val result = when (feature) {
ReaderAiFeature.DEFINE -> desktopAiAdapter.define(
text = normalizedText.take(2400),
context = ReaderContextExtractor.currentPageText(readerSession)
).let { it.definition to it.error }
ReaderAiFeature.SUMMARIZE -> desktopAiAdapter.summarize(normalizedText).let { it.summary to it.error }
ReaderAiFeature.RECAP -> desktopAiAdapter.recap(normalizedText).let { it.recap to it.error }
}
readerExtrasState = readerExtrasState.copy(
aiResult = ReaderAiResultState(
title = feature.displayName,
text = result.first.orEmpty(),
errorMessage = result.second,
isLoading = false
)
)
}
}
fun syncBookSidecars(book: BookItem) {
if (book.sourceFolder.isNullOrBlank()) {
logDesktopFolderSync("bookSidecars.skipNoFolder book=${book.id}")
return
}
logDesktopFolderSync(
"bookSidecars.request book=${book.id} sourceFolder=\"${book.sourceFolder.orEmpty().folderSyncPreview()}\""
)
scope.launch(Dispatchers.IO) {
DesktopLocalFolderSync.saveBookSidecars(book)
}
}
fun updateActiveBookReadingState(
pageIndex: Int,
progress: Float,
session: ReaderSessionState? = null,
pdfViewport: SharedPdfReaderViewport? = null
) {
activeReaderBookId?.let { bookId ->
var updatedBook: BookItem? = null
var shouldSyncSidecars = false
val next = state.copy(
rawLibraryBooks = state.rawLibraryBooks.map { book ->
if (book.id == bookId) {
val readerPosition = session?.navigationLocator ?: book.readerPosition
shouldSyncSidecars = session != null ||
book.lastPageIndex != pageIndex ||
book.progressPercentage != progress ||
book.readerPosition != readerPosition
book.copy(
progressPercentage = progress,
timestamp = System.currentTimeMillis(),
isRecent = true,
lastPageIndex = pageIndex,
readerPosition = readerPosition,
readerSettings = session?.reader?.settings ?: book.readerSettings,
readerBookmarks = session?.bookmarks ?: book.readerBookmarks,
readerHighlights = session?.highlights ?: book.readerHighlights,
pdfReaderViewport = pdfViewport ?: book.pdfReaderViewport
).also { updatedBook = it }
} else {
book
}
}
)
updateState(next)
if (shouldSyncSidecars) {
updatedBook?.let(::syncBookSidecars)
}
}
}
fun updateActiveBookReaderSettings(settings: ReaderSettings) {
activeReaderBookId?.let { bookId ->
var updatedBook: BookItem? = null
val next = state.copy(
rawLibraryBooks = state.rawLibraryBooks.map { book ->
if (book.id == bookId) {
book.copy(
timestamp = System.currentTimeMillis(),
isRecent = true,
readerSettings = settings
).also { updatedBook = it }
} else {
book
}
}
)
updateState(next)
updatedBook?.let(::syncBookSidecars)
}
}
fun importDesktopReaderTexture(settings: ReaderSettings): ReaderSettings? {
val source = chooseReaderTextureFile() ?: return null
val textureId = DesktopReaderTextures.importTexture(source) ?: return null
readerCustomTextureIds = DesktopReaderTextures.importedTextureIds()
return settings.copy(textureId = textureId)
}
fun stopReaderCloudTts() {
logDesktopTts("reader_stop_requested")
readerTtsJob?.cancel()
readerTtsJob = null
scope.launch {
desktopTtsAdapter.stop()
readerExtrasState = readerExtrasState.copy(
cloudTts = readerCloudTtsStoppedState(statusMessage = "Stopped")
)
}
}
fun pauseResumeReaderCloudTts() {
val current = readerExtrasState.cloudTts
if (current.isPaused) {
scope.launch {
desktopTtsAdapter.resume()
readerExtrasState = readerExtrasState.copy(
cloudTts = readerExtrasState.cloudTts.copy(
isPaused = false,
isPlaying = true,
statusMessage = readerExtrasState.cloudTts.progress.currentPositionLabel ?: "Reading"
)
)
}
} else if (current.isPlaying) {
scope.launch {
desktopTtsAdapter.pause()
readerExtrasState = readerExtrasState.copy(
cloudTts = readerExtrasState.cloudTts.copy(
isPlaying = false,
isPaused = true,
statusMessage = "Paused"
)
)
}
}
}
fun clearReaderCloudTtsCache() {
desktopTtsAdapter.clearBookCacheForSpeaker(readerSession.reader.book.title, aiByokSettings.sanitized().ttsSpeakerId)
readerExtrasState = readerExtrasState.copy(
cloudTts = readerExtrasState.cloudTts.copy(
statusMessage = "Voice cache cleared",
cacheSummary = currentReaderTtsCacheSummary()
)
)
}
fun startReaderCloudTts(readScope: ReaderTtsReadScope, chunks: List<ReaderTtsChunk>) {
val replacementBookId = activeReaderBookId ?: readerSession.reader.book.title
val ttsChunks = chunks
.filter { it.text.isNotBlank() }
.withTtsReplacements(state.readerTtsReplacementPreferences, replacementBookId)
val settings = aiByokSettings.sanitized()
logDesktopTts(
"reader_sequence_toggle scope=${readScope.name} chunks=${ttsChunks.size} " +
"isPlaying=${readerExtrasState.cloudTts.isPlaying} isLoading=${readerExtrasState.cloudTts.isLoading} " +
"keyPresent=${settings.geminiKey.isNotBlank()} ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" " +
"available=${desktopTtsAdapter.isAvailable}"
)
if (readerExtrasState.cloudTts.isPlaying || readerExtrasState.cloudTts.isLoading || readerExtrasState.cloudTts.isPaused) {
stopReaderCloudTts()
return
}
if (ttsChunks.isEmpty()) {
logDesktopTts("reader_sequence_ignored reason=blank_text scope=${readScope.name}")
readerExtrasState = readerExtrasState.copy(
cloudTts = readerExtrasState.cloudTts.copy(
errorMessage = "There is no text here to read.",
cacheSummary = currentReaderTtsCacheSummary()
)
)
return
}
if (!desktopTtsAdapter.isAvailable) {
logDesktopTts("reader_sequence_blocked reason=adapter_unavailable")
readerExtrasState = readerExtrasState.copy(
cloudTts = ReaderCloudTtsState(
isAvailable = false,
errorMessage = "Add a Gemini key and select Gemini cloud TTS in AI keys and models.",
cacheSummary = currentReaderTtsCacheSummary()
)
)
return
}
val ttsSessionId = System.currentTimeMillis()
val initialProgress = ReaderTtsProgress(
sessionId = ttsSessionId,
scope = readScope,
chunks = ttsChunks,
currentChunkIndex = -1
)
readerExtrasState = readerExtrasState.copy(
cloudTts = ReaderCloudTtsState(
isAvailable = true,
isLoading = true,
statusMessage = "Preparing ${readScope.label.lowercase()}",
progress = initialProgress,
cacheSummary = currentReaderTtsCacheSummary()
)
)
readerTtsJob = scope.launch {
runCatching {
logDesktopTts("reader_sequence_start scope=${readScope.name} chunks=${ttsChunks.size}")
desktopTtsAdapter.speakChunks(readerSession.reader.book.title, readScope, ttsChunks) { index ->
if (!isActive) throw kotlinx.coroutines.CancellationException("Reader cloud TTS stopped")
val chunk = ttsChunks[index]
val progress = initialProgress.copy(currentChunkIndex = index)
if (readerSession.reader.currentPageIndex != chunk.pageIndex) {
val updatedSession = readerEngine.goToPage(readerSession, chunk.pageIndex)
readerSession = updatedSession
updateActiveBookReadingState(
pageIndex = updatedSession.reader.currentPageIndex,
progress = updatedSession.reader.progress,
session = updatedSession
)
}
readerExtrasState = readerExtrasState.copy(
cloudTts = ReaderCloudTtsState(
isAvailable = true,
isPlaying = true,
statusMessage = progress.currentPositionLabel ?: "Reading",
progress = progress,
cacheSummary = currentReaderTtsCacheSummary()
)
)
logDesktopTts(
"reader_chunk_start scope=${readScope.name} index=${index + 1}/${ttsChunks.size} " +
"page=${chunk.pageIndex + 1} chapter=${chunk.chapterIndex} offsets=${chunk.startOffset}..${chunk.endOffset} " +
"sourceCfi=\"${chunk.sourceCfi.orEmpty().logPreview()}\" chars=${chunk.text.length} " +
"text=\"${chunk.text.logPreview()}\""
)
}
}.onFailure { error ->
logDesktopTts("reader_sequence_failed error=\"${error.desktopTtsSummary()}\"")
if (error !is kotlinx.coroutines.CancellationException) error.printStackTrace()
readerExtrasState = if (error is kotlinx.coroutines.CancellationException) {
readerExtrasState.copy(
cloudTts = readerCloudTtsStoppedState(statusMessage = "Stopped")
)
} else {
readerExtrasState.copy(
cloudTts = readerCloudTtsStoppedState(errorMessage = error.message ?: "Cloud TTS failed.")
)
}
}.onSuccess {
logDesktopTts("reader_sequence_success chunks=${ttsChunks.size}")
readerExtrasState = readerExtrasState.copy(
cloudTts = readerCloudTtsStoppedState(statusMessage = "Finished")
)
}
}
}
fun toggleReaderCloudTts(text: String) {
val normalizedText = text.trim()
val settings = aiByokSettings.sanitized()
logDesktopTts(
"reader_toggle textChars=${normalizedText.length} isPlaying=${readerExtrasState.cloudTts.isPlaying} " +
"isLoading=${readerExtrasState.cloudTts.isLoading} keyPresent=${settings.geminiKey.isNotBlank()} " +
"ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" available=${desktopTtsAdapter.isAvailable}"
)
if (readerExtrasState.cloudTts.isPlaying || readerExtrasState.cloudTts.isLoading || readerExtrasState.cloudTts.isPaused) {
stopReaderCloudTts()
return
}
if (normalizedText.isBlank()) {
logDesktopTts("reader_toggle_ignored reason=blank_text")
readerExtrasState = readerExtrasState.copy(
cloudTts = readerExtrasState.cloudTts.copy(
errorMessage = "There is no text on this page to read.",
cacheSummary = currentReaderTtsCacheSummary()
)
)
return
}
if (!desktopTtsAdapter.isAvailable) {
logDesktopTts("reader_toggle_blocked reason=adapter_unavailable")
readerExtrasState = readerExtrasState.copy(
cloudTts = ReaderCloudTtsState(
isAvailable = false,
errorMessage = "Add a Gemini key and select Gemini cloud TTS in AI keys and models.",
cacheSummary = currentReaderTtsCacheSummary()
)
)
return
}
val page = readerSession.reader.currentPage
val selectionChunks = if (page != null) {
ReaderTtsPlanner.chunksForText(
text = normalizedText,
pageIndex = page.pageIndex,
chapterIndex = page.chapterIndex,
chapterTitle = page.chapterTitle,
sourceStartOffset = page.startOffset
)
} else {
ReaderTtsPlanner.chunksForText(
text = normalizedText,
pageIndex = readerSession.reader.currentPageIndex,
chapterIndex = 0,
chapterTitle = "Selection"
)
}
startReaderCloudTts(ReaderTtsReadScope.PAGE, selectionChunks)
}
fun finishImportFiles(
files: List<ImportedBookFile>,
failedCount: Int,
onImported: (List<BookItem>) -> Unit = {}
) {
val importStart = System.currentTimeMillis()
val existingIds = state.rawLibraryBooks.mapTo(mutableSetOf()) { it.id }
val importPlan = SharedImportPlanner.plan(
files = files,
existingBookIds = existingIds,
platform = ReaderPlatform.DESKTOP,
nowMillis = importStart
)
val counts = SharedImportOutcomeCounts(
addedCount = importPlan.importedCount,
duplicateCount = importPlan.duplicateCount,
unsupportedCount = importPlan.unsupportedCount,
failedCount = failedCount
)
if (files.isEmpty() && failedCount > 0) {
updateState(state.withBanner("Could not import ${failedCount} file(s).", isError = true))
return
}
if (importPlan.supportedFiles.isEmpty() && files.isNotEmpty()) {
updateState(
state.withBanner(
"No supported desktop reader files were selected. " +
"${SharedFileCapabilities.supportedFormatsLabel(ReaderPlatform.DESKTOP)} are supported.",
isError = true
)
)
return
}
val next = state.copy(rawLibraryBooks = importPlan.importedBooks + state.rawLibraryBooks)
.let {
when {
counts.addedCount > 0 && (counts.unsupportedCount > 0 || counts.failedCount > 0) -> {
val skippedCount = counts.unsupportedCount + counts.failedCount
it.withBanner("Imported ${counts.addedCount} file(s). Skipped ${skippedCount} file(s).")
}
counts.addedCount > 0 -> it.withBanner("Imported ${counts.addedCount} file(s).")
counts.duplicateCount > 0 -> it.withBanner("Those files are already in the library.")
counts.failedCount > 0 -> it.withBanner("Could not import ${counts.failedCount} file(s).", isError = true)
else -> it
}
}
updateState(next)
onImported(importPlan.importedBooks)
val targetBookIds = importPlan.importedBooks.mapTo(mutableSetOf()) { it.id }
if (targetBookIds.isEmpty()) return
val originalTargetBooksById = next.rawLibraryBooks
.filter { it.id in targetBookIds }
.associateBy { it.id }
scope.launch {
val metadataResult = withContext(Dispatchers.IO) {
DesktopFolderMetadataExtractor.enrichImportedBooks(
books = next.rawLibraryBooks,
importedBookIds = targetBookIds
)
}
if (metadataResult.stats.updatedBooks > 0) {
val enrichedBooksById = metadataResult.books
.filter { it.id in targetBookIds }
.associateBy { it.id }
updateState(
state.copy(
rawLibraryBooks = state.rawLibraryBooks.map { book ->
val enriched = enrichedBooksById[book.id] ?: return@map book
book.withDesktopImportMetadata(
enriched = enriched,
original = originalTargetBooksById[book.id]
)
}
)
)
}
}
}
fun importFiles(files: List<ImportedBookFile>, onImported: (List<BookItem>) -> Unit = {}) {
if (files.isEmpty()) return
updateState(state.withBanner("Importing ${files.size} file(s)..."))
scope.launch {
val preparedImport = withContext(Dispatchers.IO) {
desktopBookImporter.prepareImports(files)
}
finishImportFiles(
files = preparedImport.files,
failedCount = preparedImport.failedCount,
onImported = onImported
)
}
}
fun syncLocalFolders(
targetFolder: File? = null,
showBanner: Boolean = true,
metadataOnly: Boolean = false
) {
val mode = if (metadataOnly) "metadata" else "full"
logDesktopFolderSync(
"ui.sync.request mode=$mode target=\"${targetFolder?.absolutePath?.folderSyncPreview() ?: "ALL"}\" " +
"showBanner=$showBanner linkedFolders=${state.syncedFolders.size} books=${state.rawLibraryBooks.size}"
)
if (targetFolder == null && state.syncedFolders.isEmpty()) {
logDesktopFolderSync("ui.sync.skipNoFolders mode=$mode")
updateState(state.withBanner("No local folders are linked yet.", isError = true))
return
}
val snapshotState = state
val snapshotShelfRefs = shelfRefs
if (showBanner) {
val message = if (metadataOnly) {
"Folder sync: updating metadata..."
} else {
"Folder sync: scanning local folders..."
}
updateState(state.withBanner(message))
}
scope.launch {
val result = withContext(Dispatchers.IO) {
DesktopLocalFolderSync.sync(
state = snapshotState,
shelfRefs = snapshotShelfRefs,
targetFolder = targetFolder,
metadataOnly = metadataOnly
)
}
val failedCount = result.failedFolders.size
val stats = result.stats
val metadataStats = result.metadataStats
val message = when {
failedCount > 0 && stats.supportedFiles == 0 ->
"Folder sync failed for $failedCount folder(s)."
failedCount > 0 ->
"Folder sync finished with $failedCount folder(s) skipped."
metadataOnly ->
"Folder metadata sync complete."
else ->
"Folder sync complete: ${stats.newBooks} new, ${stats.updatedBooks + stats.remoteMetadataUpdates + metadataStats.updatedBooks} updated, ${stats.removedBooks} removed."
}
logDesktopFolderSync(
"ui.sync.result mode=$mode failed=$failedCount message=\"${message.folderSyncPreview()}\" " +
"new=${stats.newBooks} updated=${stats.updatedBooks} remoteUpdates=${stats.remoteMetadataUpdates} " +
"removed=${stats.removedBooks} metadataExtracted=${metadataStats.updatedBooks}"
)
val completedState = if (showBanner || failedCount > 0) {
result.state.withBanner(message, isError = failedCount > 0)
} else {
result.state
}
activeReaderBookId = activeReaderBookId?.let { result.idMigrations[it] ?: it }
replaceLibrary(
completedState,
refs = result.shelfRefs
)
if (activeReaderBookId != null && completedState.rawLibraryBooks.none { it.id == activeReaderBookId }) {
openingReader = null
activePdfDocument?.close()
activePdfDocument = null
activeReaderBookId = null
readerSession = readerEngine.createSession(desktopEmptyReaderBook())
selectedTab = SharedAppTab.HOME
}
}
}
fun syncFolderMetadata(showBanner: Boolean = true) {
syncLocalFolders(showBanner = showBanner, metadataOnly = true)
}
fun scanSyncedFolders(showBanner: Boolean = true) {
syncLocalFolders(showBanner = showBanner, metadataOnly = false)
}
fun importFolder(folder: File) {
logDesktopFolderSync("ui.importFolder.request folder=\"${folder.absolutePath.folderSyncPreview()}\"")
if (!DesktopLocalFolderSync.hasSupportedFiles(folder)) {
logDesktopFolderSync("ui.importFolder.skipNoSupportedFiles folder=\"${folder.absolutePath.folderSyncPreview()}\"")
updateState(state.withBanner("That folder does not contain any supported desktop reader files.", isError = true))
return
}
syncLocalFolders(targetFolder = folder)
}
fun importCustomFont(file: File?): CustomFontItem? {
val source = file ?: return null
return customFontStore.importFont(source)
.onSuccess { font ->
customFonts = (customFonts.filterNot { it.id == font.id } + font)
.filterNot { it.isDeleted }
.sortedBy { it.displayName.lowercase() }
updateState(state.withBanner("Imported ${font.displayName}."))
}
.onFailure { error ->
updateState(state.withBanner(error.message ?: "Could not import font.", isError = true))
}
.getOrNull()
}
fun downloadGoogleFont(fontName: String, onComplete: () -> Unit) {
if (!featurePolicy.googleFontsDownload) {
updateState(state.withBanner("Google Fonts download is unavailable in this desktop build.", isError = true))
onComplete()
return
}
scope.launch {
val result = withContext(Dispatchers.IO) {
customFontStore.downloadGoogleFont(fontName)
}
result
.onSuccess { font ->
customFonts = (customFonts.filterNot { it.id == font.id } + font)
.filterNot { it.isDeleted }
.sortedBy { it.displayName.lowercase() }
updateState(state.withBanner("${font.displayName} downloaded successfully."))
}
.onFailure { error ->
updateState(state.withBanner(error.message ?: "Could not download $fontName.", isError = true))
}
onComplete()
}
}
fun deleteCustomFont(font: CustomFontItem) {
customFontStore.deleteFont(font)
customFonts = customFonts.filterNot { it.id == font.id }
val clearedSettings = state.rawLibraryBooks.map { book ->
val settings = book.readerSettings
if (settings?.customFontPath == font.path) {
book.copy(readerSettings = settings.copy(fontFamily = "Default", customFontPath = null))
} else {
book
}
}
if (readerSession.reader.settings.customFontPath == font.path) {
readerSession = readerEngine.updateSettings(
readerSession,
readerSession.reader.settings.copy(fontFamily = "Default", customFontPath = null)
)
}
updateState(state.copy(rawLibraryBooks = clearedSettings).withBanner("Deleted ${font.displayName}."))
}
fun removeSelectedBooks() {
SharedLibraryEditor.removeSelectedBooks(state, shelfRecords, shelfRefs)?.let {
replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs)
}
}
fun createShelf(name: String) {
SharedLibraryEditor.createShelf(state, shelfRecords, shelfRefs, name, System.currentTimeMillis())?.let {
replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs)
}
}
fun createSmartShelf(name: String, definition: SmartCollectionDefinition) {
SharedLibraryEditor.createSmartShelf(state, shelfRecords, shelfRefs, name, definition, System.currentTimeMillis())?.let {
replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs)
}
}
fun renameShelf(shelf: Shelf, name: String) {
SharedLibraryEditor.renameShelf(state, shelfRecords, shelfRefs, shelf, name)?.let {
replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs)
}
}
fun deleteShelf(shelf: Shelf) {
val result = SharedLibraryEditor.deleteShelf(state, shelfRecords, shelfRefs, shelf)
replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs)
}
fun addSelectedBooksToShelf(shelfId: String) {
SharedLibraryEditor.addSelectedBooksToShelf(state, shelfRecords, shelfRefs, shelfId, System.currentTimeMillis())?.let {
replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs)
}
}
fun tagSelectedBooks(tagName: String) {
SharedLibraryEditor.tagSelectedBooks(state, shelfRecords, shelfRefs, tagName, System.currentTimeMillis())?.let {
replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs)
}
}
fun applyBookMetadataUpdate(updated: BookItem) {
val result = SharedLibraryEditor.updateBookMetadata(state, shelfRecords, shelfRefs, updated, System.currentTimeMillis())
replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs)
result.state.rawLibraryBooks.firstOrNull { it.id == updated.id }?.let(::syncBookSidecars)
}
fun writeDesktopEpubMetadata(original: BookItem, updated: BookItem): BookItem {
val file = File(original.path ?: error("Book path is missing."))
require(file.isFile && file.canWrite()) { "EPUB file is not writable." }
val backup = File(
File(desktopUserDataRoot(), "metadata_backups").apply { mkdirs() },
"${original.id.toDesktopSafeFileName()}.epub"
)
val snapshot = SharedEpubMetadataEditor.rewriteInPlace(
source = file,
backup = backup,
update = SharedEpubMetadataUpdate(
title = updated.title,
author = updated.author,
description = updated.description,
seriesName = updated.seriesName,
seriesIndex = updated.seriesIndex
)
)
return updated.copy(
title = snapshot.title ?: updated.title,
author = snapshot.author,
description = snapshot.description,
seriesName = snapshot.seriesName,
seriesIndex = snapshot.seriesIndex,
originalTitle = original.originalTitle ?: original.title,
originalAuthor = original.originalAuthor ?: original.author,
originalSeriesName = original.originalSeriesName ?: original.seriesName,
originalSeriesIndex = original.originalSeriesIndex ?: original.seriesIndex,
originalDescription = original.originalDescription ?: original.description,
fileSize = file.length(),
fileContentModifiedTimestamp = file.lastModified()
)
}
fun updateBookMetadata(updated: BookItem) {
val original = state.rawLibraryBooks.firstOrNull { it.id == updated.id }
if (original != null && original.type == FileType.EPUB && original.hasEmbeddedMetadataChange(updated)) {
scope.launch {
val rewritten = runCatching {
withContext(Dispatchers.IO) {
writeDesktopEpubMetadata(original, updated)
}
}
rewritten.onSuccess(::applyBookMetadataUpdate)
.onFailure { error ->
println("Failed to update EPUB metadata for ${updated.displayName}: ${error.message}")
updateState(state.copy(bannerMessage = BannerMessage("Could not update EPUB metadata.")))
}
}
return
}
applyBookMetadataUpdate(updated)
}
fun recordBookOpened(bookId: String) {
val now = System.currentTimeMillis()
val next = SharedLibraryEditor.markBookOpened(state, bookId, now)
val openedState = next.reduce(AppAction.BookTabOpened(bookId))
updateState(openedState)
openedState.rawLibraryBooks.firstOrNull { it.id == bookId }?.let(::syncBookSidecars)
}
fun scheduleOpenedBookMetadataExtraction(book: BookItem) {
scope.launch {
val enriched = withContext(Dispatchers.IO) {
DesktopFolderMetadataExtractor.enrichOpenedBook(book)
}
if (enriched == book) return@launch
updateState(
state.copy(
rawLibraryBooks = state.rawLibraryBooks.map { current ->
if (current.id == book.id) {
current.withDesktopImportMetadata(enriched = enriched, original = book)
} else {
current
}
}
)
)
}
}
fun schedulePdfEmbeddedAnnotationsLoad(document: DesktopPdfDocument) {
scope.launch {
delay(650L)
if (activePdfDocument?.handleId != document.handleId) return@launch
val annotations = withContext(Dispatchers.IO) {
DesktopPdfium.loadEmbeddedAnnotations(document)
}
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?.handleId != result.document.handleId) {
result.document.close()
}
return
}
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.handleId != result.document.handleId }?.close()
activePdfDocument = result.document
activeReaderBookId = result.book.id
recordBookOpened(result.book.id)
selectedTab = SharedAppTab.READER
if (result.book.type == FileType.PDF) {
schedulePdfEmbeddedAnnotationsLoad(result.document)
}
}
is DesktopReaderOpenResult.Text -> {
activePdfDocument?.close()
activePdfDocument = null
readerSession = result.session
activeReaderBookId = result.book.id
recordBookOpened(result.book.id)
selectedTab = SharedAppTab.READER
}
}
}
fun openReader(book: BookItem) {
val desktopReaderSurface = SharedFileCapabilities.surfaceFor(book.type, ReaderPlatform.DESKTOP)
if (openingReader?.bookId == book.id) return
if (shouldRequestDesktopWebViewRuntime(desktopReaderSurface)) {
webViewRuntimeRequested = true
}
if (desktopReaderSurface == ReaderFeatureSurface.PDF_VIEWER) {
val path = book.path
if (path.isNullOrBlank()) {
updateState(
state.withBanner(
"This ${SharedFileCapabilities.displayNameFor(book.type)} does not have a local path.",
isError = true
)
)
return
}
val streamReference = SharedOpdsStreamUri.parse(path)
if (streamReference != null && !featurePolicy.opdsCatalogs) {
updateState(state.withBanner("OPDS streams are unavailable in this desktop build.", isError = true))
return
}
val readerPath = streamReference?.let { path } ?: File(path).absolutePath
if (activePdfDocument?.path == readerPath) {
openingReader = null
activeReaderBookId = book.id
recordBookOpened(book.id)
selectedTab = SharedAppTab.READER
return
}
} else if (
desktopReaderSurface == ReaderFeatureSurface.EPUB_READER ||
desktopReaderSurface == ReaderFeatureSurface.TEXT_READER
) {
if (activePdfDocument == null && activeReaderBookId == book.id) {
openingReader = null
recordBookOpened(book.id)
selectedTab = SharedAppTab.READER
return
}
} else {
updateState(
state.withBanner(
"${SharedFileCapabilities.displayNameFor(book.type)} reader support comes later. " +
"${SharedFileCapabilities.supportedFormatsLabel(ReaderPlatform.DESKTOP)} are available on desktop."
)
)
return
}
scheduleOpenedBookMetadataExtraction(book)
val opening = DesktopReaderOpening(
requestId = ++nextReaderOpenRequestId,
bookId = book.id,
title = book.cardTitleForMessage(),
formatLabel = SharedFileCapabilities.displayNameFor(book.type),
returnTab = selectedTab.takeUnless { it == SharedAppTab.READER } ?: SharedAppTab.LIBRARY
)
val readerDefaultSettings = state.readerDefaultSettings
openingReader = opening
selectedTab = SharedAppTab.READER
scope.launch {
val result = withContext(Dispatchers.IO) {
runCatching {
when (desktopReaderSurface) {
ReaderFeatureSurface.PDF_VIEWER -> {
val path = book.path.orEmpty()
val streamReference = SharedOpdsStreamUri.parse(path)
val document = if (streamReference != null) {
DesktopPdfium.loadOpdsStream(
path = path,
title = book.title?.takeIf { it.isNotBlank() } ?: book.displayName,
reference = streamReference,
catalog = opdsRepository.catalogById(streamReference.catalogId)
)
} else {
val readerFile = File(path)
if (book.type == FileType.PDF) {
DesktopPdfium.load(readerFile, loadEmbeddedAnnotations = false)
} else {
DesktopPdfium.loadComic(readerFile, book.type)
}
}
DesktopReaderOpenResult.Pdf(opening, book, document)
}
ReaderFeatureSurface.EPUB_READER,
ReaderFeatureSurface.TEXT_READER -> {
val path = book.path?.takeIf { it.isNotBlank() } ?: error("Book path is missing.")
val loadedBook = SharedJvmBookLoader.load(
file = File(path),
type = book.type,
titleOverride = book.title?.takeIf { it.isNotBlank() },
authorOverride = book.author?.takeIf { it.isNotBlank() }
)
val restoredSettings = resolvedDesktopReaderSettings(book, readerDefaultSettings)
val restoredSession = readerEngine.createSession(
book = loadedBook,
settings = restoredSettings,
initialPageIndex = book.lastPageIndex ?: 0,
initialLocator = book.readerPosition,
bookmarks = book.readerBookmarks,
highlights = book.readerHighlights
)
val restoredProgress = book.progressPercentage
val session = if (book.readerPosition == null && book.lastPageIndex == null && restoredProgress != null) {
readerEngine.goToProgress(restoredSession, restoredProgress.coerceIn(0f, 100f) / 100f)
} else {
restoredSession
}
DesktopReaderOpenResult.Text(opening, book, session)
}
else -> error("${SharedFileCapabilities.displayNameFor(book.type)} reader support comes later.")
}
}.getOrElse { error ->
DesktopReaderOpenResult.Failure(
opening = opening,
book = book,
message = "Could not open ${SharedFileCapabilities.displayNameFor(book.type)}: " +
(error.message ?: "unknown error")
)
}
}
applyReaderOpenResult(result)
}
}
fun removeFolder(shelf: Shelf) {
val removedBookIds = shelf.books.mapTo(mutableSetOf()) { it.id }
val wasReadingRemovedBook = activeReaderBookId in removedBookIds
val nextTabBook = state.openTabIds
.filterNot { it in removedBookIds }
.lastOrNull()
?.let { nextId -> state.rawLibraryBooks.firstOrNull { it.id == nextId } }
SharedLibraryEditor.removeFolder(state, shelfRecords, shelfRefs, shelf)?.let {
replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs)
if (wasReadingRemovedBook) {
openingReader = null
activePdfDocument?.close()
activePdfDocument = null
activeReaderBookId = null
if (nextTabBook != null) {
openReader(nextTabBook)
} else {
readerSession = readerEngine.createSession(desktopEmptyReaderBook())
selectedTab = SharedAppTab.HOME
}
}
}
}
fun closeReaderTab(book: BookItem) {
val wasActive = activeReaderBookId == book.id
if (openingReader?.bookId == book.id) {
openingReader = null
}
val remainingIds = state.openTabIds.filterNot { it == book.id }
updateState(state.reduce(AppAction.BookTabClosed(book.id)))
if (!wasActive) return
openingReader = null
activePdfDocument?.close()
activePdfDocument = null
activeReaderBookId = null
val nextBook = remainingIds.lastOrNull()?.let { nextId ->
state.rawLibraryBooks.firstOrNull { it.id == nextId }
}
if (nextBook != null) {
openReader(nextBook)
} else {
readerSession = readerEngine.createSession(desktopEmptyReaderBook())
selectedTab = SharedAppTab.HOME
}
}
fun closeAllReaderTabs() {
openingReader = null
activePdfDocument?.close()
activePdfDocument = null
activeReaderBookId = null
readerSession = readerEngine.createSession(desktopEmptyReaderBook())
selectedTab = SharedAppTab.HOME
updateState(state.reduce(AppAction.AllTabsClosed))
}
fun importAndOpenBook() {
val file = chooseBookFile() ?: return
val importedFile = file.toDesktopImportedBookFile()
val type = importedFile.desktopFileType()
if (type !in DesktopBookFileTypes) {
updateState(
state.withBanner(
"No supported desktop reader file was selected. " +
"${SharedFileCapabilities.supportedFormatsLabel(ReaderPlatform.DESKTOP)} are supported.",
isError = true
)
)
return
}
importFiles(listOf(importedFile)) { importedBooks ->
importedBooks.firstOrNull()?.let(::openReader)
}
}
fun importAndOpenPdf() {
val file = choosePdfFile() ?: return
importFiles(listOf(file.toDesktopImportedBookFile())) { importedBooks ->
importedBooks.firstOrNull()?.let(::openReader)
}
}
fun emitOpds(next: com.aryan.reader.shared.opds.SharedOpdsScreenState) {
opdsState = next
}
fun openOpdsCatalog(catalog: OpdsCatalog) {
if (!featurePolicy.opdsCatalogs) return
scope.launch {
opdsController.openCatalog(catalog, ::emitOpds)
}
}
fun openOpdsFeedUrl(url: String) {
if (!featurePolicy.opdsCatalogs) return
scope.launch {
opdsController.openFeedUrl(url, ::emitOpds)
}
}
fun navigateOpdsBack() {
scope.launch {
opdsController.navigateBack(::emitOpds)
}
}
fun searchOpds(query: String) {
if (!featurePolicy.opdsCatalogs) return
scope.launch {
opdsController.search(query, ::emitOpds)
}
}
fun loadNextOpdsPage() {
if (!featurePolicy.opdsCatalogs) return
scope.launch {
opdsController.loadNextPage(::emitOpds)
}
}
fun removeOpdsCatalog(catalog: OpdsCatalog) {
emitOpds(opdsController.removeCatalog(catalog.id))
val streamBookIds = state.rawLibraryBooks
.filter { book -> SharedOpdsStreamUri.parse(book.path)?.catalogId == catalog.id }
.mapTo(mutableSetOf()) { it.id }
if (streamBookIds.isNotEmpty()) {
if (activeReaderBookId in streamBookIds) {
activePdfDocument?.close()
activePdfDocument = null
activeReaderBookId = null
readerSession = readerEngine.createSession(desktopEmptyReaderBook())
selectedTab = SharedAppTab.HOME
}
updateState(
state.copy(
rawLibraryBooks = state.rawLibraryBooks.filterNot { it.id in streamBookIds },
openTabIds = state.openTabIds.filterNot { it in streamBookIds },
activeTabBookId = state.activeTabBookId?.takeUnless { it in streamBookIds }
).withBanner("Removed ${streamBookIds.size} streamed OPDS book(s) from that catalog.")
)
}
}
fun downloadOpdsBook(entry: OpdsEntry, acquisition: OpdsAcquisition) {
if (!featurePolicy.opdsCatalogs) {
updateState(state.withBanner("OPDS downloads are unavailable in this desktop build.", isError = true))
return
}
val catalog = opdsState.currentCatalog
scope.launch {
emitOpds(opdsController.updateDownloadState(entry.id, SharedOpdsDownloadState(true, 0f)))
val result = runCatching {
opdsRepository.downloadBook(entry, acquisition, catalog) { progress ->
scope.launch {
if (opdsController.state.downloadingState[entry.id]?.isDownloading == true) {
emitOpds(opdsController.updateDownloadState(entry.id, SharedOpdsDownloadState(true, progress)))
}
}
}
}
emitOpds(opdsController.updateDownloadState(entry.id, null))
result.onSuccess { file ->
importFiles(listOf(file.toDesktopImportedBookFile()))
updateState(state.withBanner("Downloaded ${file.name} from OPDS."))
}.onFailure { error ->
updateState(
state.withBanner(
"Could not download ${entry.title}: ${error.message ?: "unknown error"}",
isError = true
)
)
}
}
}
fun streamOpdsBook(entry: OpdsEntry, catalog: OpdsCatalog?) {
if (!featurePolicy.opdsCatalogs) {
updateState(state.withBanner("OPDS streams are unavailable in this desktop build.", isError = true))
return
}
val pageCount = entry.pseCount
val urlTemplate = entry.pseUrlTemplate
if (pageCount == null || pageCount <= 0 || urlTemplate.isNullOrBlank()) {
updateState(state.withBanner("This OPDS entry does not expose a readable stream.", isError = true))
return
}
val reference = OpdsStreamReference(
id = entry.id.ifBlank { "${entry.title}:$urlTemplate" },
count = pageCount,
urlTemplate = urlTemplate,
catalogId = catalog?.id
)
val uriString = SharedOpdsStreamUri.build(reference)
val now = System.currentTimeMillis()
val streamBook = BookItem(
id = uriString,
path = uriString,
type = FileType.CBZ,
displayName = entry.title,
timestamp = now,
title = entry.title,
author = entry.author,
fileSize = 0L
)
if (state.rawLibraryBooks.none { it.id == streamBook.id }) {
updateState(state.copy(rawLibraryBooks = state.rawLibraryBooks + streamBook))
}
openReader(streamBook)
}
DisposableEffect(Unit) {
onDispose {
activePdfDocument?.close()
}
}
DesktopFileDropTarget(
window = window,
onFilesDropped = ::importFiles,
onDragStateChange = { dropImportState = it }
)
LaunchedEffect(Unit) {
if (state.syncedFolders.isNotEmpty()) {
scanSyncedFolders(showBanner = false)
}
}
LaunchedEffect(state.bannerMessage) {
state.bannerMessage?.let { banner ->
snackbarHostState.showSnackbar(banner.message)
updateState(state.reduce(AppAction.BannerDismissed))
}
}
LaunchedEffect(aiByokSettings, activeReaderBookId, readerSession.reader.book.title) {
readerExtrasState = readerExtrasState.copy(
cloudTts = readerExtrasState.cloudTts.copy(
isAvailable = aiByokSettings.isCloudTtsAvailable,
errorMessage = null,
cacheSummary = currentReaderTtsCacheSummary()
)
)
}
SharedAppTheme(
appThemeMode = state.appThemeMode,
appContrastOption = state.appContrastOption,
appTextDimFactorLight = state.appTextDimFactorLight,
appTextDimFactorDark = state.appTextDimFactorDark,
appSeedColor = state.appSeedColor
) {
EpistemeDesktopWindowChromeEffect(
window = window,
captionColor = MaterialTheme.colorScheme.surface,
textColor = MaterialTheme.colorScheme.onSurface,
borderColor = MaterialTheme.colorScheme.background
)
Box(
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {
SharedAppShell(
selectedTab = selectedTab,
snackbarHostState = snackbarHostState,
appThemeMode = state.appThemeMode,
appContrastOption = state.appContrastOption,
appTextDimFactorLight = state.appTextDimFactorLight,
appTextDimFactorDark = state.appTextDimFactorDark,
appSeedColor = state.appSeedColor,
customAppThemes = state.customAppThemes,
isTabsEnabled = state.isTabsEnabled,
featurePolicy = featurePolicy,
onTabSelected = { tab ->
selectAppTab(tab)
},
onImportFiles = { importFiles(chooseFiles()) },
onImportFolder = { chooseFolder()?.let(::importFolder) },
onSyncRequested = {
scanSyncedFolders()
},
onFolderMetadataSyncRequested = { syncFolderMetadata() },
onAppThemeModeChange = { mode -> updateState(state.reduce(AppAction.AppThemeChanged(mode))) },
onAppContrastOptionChange = { option -> updateState(state.reduce(AppAction.AppContrastChanged(option))) },
onAppTextDimFactorLightChange = { factor -> updateState(state.reduce(AppAction.AppTextDimFactorLightChanged(factor))) },
onAppTextDimFactorDarkChange = { factor -> updateState(state.reduce(AppAction.AppTextDimFactorDarkChanged(factor))) },
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 ->
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 {
null
}
) { tab ->
when (tab) {
SharedAppTab.HOME -> HomeScreen(
state = state,
onImportBooks = {
importFiles(chooseFiles())
},
onImportFolder = { chooseFolder()?.let(::importFolder) },
onRead = ::openReader,
onSelect = { id -> updateState(state.reduce(LibraryAction.BookSelectionToggled(id))) },
onClearSelection = { updateState(state.reduce(LibraryAction.SelectionCleared)) },
onRemoveSelected = ::removeSelectedBooks,
onShowBookInfo = {
bookInfoInitiallyEditing = false
bookInfoDialogFor = it
},
onEditBook = {
bookInfoInitiallyEditing = true
bookInfoDialogFor = it
},
onTagSelectedBooks = { showTagSelectionDialog = true },
onAddSelectedBooksToShelf = { showAddToShelfDialog = true },
onOpenTab = ::openReader,
onCloseTab = ::closeReaderTab,
onCloseAllTabs = ::closeAllReaderTabs,
onRecentLimitChange = { limit -> updateState(state.reduce(LibraryAction.RecentLimitChanged(limit))) },
onTogglePinned = { book -> updateState(state.reduce(AppAction.HomePinToggled(book.id))) },
onOpenSettings = {
settingsQuery = ""
settingsDestination = SharedSettingsDestination.ROOT
selectAppTab(SharedAppTab.SETTINGS)
}
)
SharedAppTab.SETTINGS -> SharedSettingsHub(
model = sharedSettingsHubModel(
SharedSettingsHubInput(
platform = SharedSettingsPlatform.DESKTOP,
featurePolicy = featurePolicy,
isDebugBuild = false,
isSignedIn = false,
isProUser = true,
syncAvailable = false,
folderSyncAvailable = true,
aiSettingsAvailable = featurePolicy.aiAndCloud,
includeLanguage = false,
includeScreenCaptureProtection = false,
includeExternalFileBehavior = false,
includeStrictFileFilter = false,
includeReaderTabs = false,
includeHideReaderAi = false,
isTabsEnabled = state.isTabsEnabled,
isFolderSyncEnabled = state.isFolderSyncEnabled
)
),
query = settingsQuery,
onQueryChange = { settingsQuery = it },
destination = settingsDestination,
onDestinationChange = { settingsDestination = it },
readerDefaultSettings = state.readerDefaultSettings,
onReaderDefaultSettingsChange = { settings ->
updateState(state.reduce(AppAction.ReaderDefaultSettingsChanged(settings)))
},
pdfReaderDefaultSettings = state.pdfReaderDefaultSettings,
onPdfReaderDefaultSettingsChange = { settings ->
updateState(state.reduce(AppAction.PdfReaderDefaultSettingsChanged(settings)))
},
readerToolbarPreferences = state.readerToolbarPreferences,
onReaderToolbarPreferencesChange = { preferences ->
updateState(state.reduce(AppAction.ReaderToolbarPreferencesChanged(preferences)))
},
ttsReplacementPreferences = state.readerTtsReplacementPreferences,
onTtsReplacementPreferencesChange = { preferences ->
updateState(state.reduce(AppAction.ReaderTtsReplacementPreferencesChanged(preferences)))
},
customFonts = customFonts,
onPickCustomFont = { importCustomFont(chooseFontFile())?.path },
readerCustomTextureIds = readerCustomTextureIds,
onImportReaderTexture = ::importDesktopReaderTexture,
onAction = { action ->
when (action) {
SharedSettingsAction.APP_THEME -> showDesktopAppThemeSettingsDialog = true
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 -> 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,
SharedSettingsAction.TEST_PANEL_DETECTION,
SharedSettingsAction.TEST_SPEECH_BUBBLE_DETECTION,
SharedSettingsAction.EXPORT_LOGS,
SharedSettingsAction.DEBUG_ACTIONS,
SharedSettingsAction.DEVICE_MANAGEMENT,
SharedSettingsAction.SIGN_IN,
SharedSettingsAction.SIGN_OUT,
SharedSettingsAction.CLOUD_SYNC,
SharedSettingsAction.LANGUAGE,
SharedSettingsAction.RECENT_LIMIT,
SharedSettingsAction.STRICT_FILE_FILTER,
SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR,
SharedSettingsAction.SCREEN_CAPTURE_PROTECTION,
SharedSettingsAction.HIDE_READER_AI,
SharedSettingsAction.TTS_SETTINGS,
SharedSettingsAction.PDF_READER_DEFAULTS,
SharedSettingsAction.TEXT_READER_DEFAULTS,
SharedSettingsAction.READER_TOOLBAR,
SharedSettingsAction.TTS_REPLACEMENTS,
SharedSettingsAction.LOCAL_OVERRIDE_NOTE -> Unit
}
}
)
SharedAppTab.LIBRARY -> LibraryScreen(
state = state,
selectedLibraryTab = selectedLibraryTab,
onLibraryTabChange = { selectedLibraryTab = it },
onStateChange = ::updateState,
onImportBooks = {
importFiles(chooseFiles())
},
onImportFolder = { chooseFolder()?.let(::importFolder) },
onRead = ::openReader,
onSelect = { id -> updateState(state.reduce(LibraryAction.BookSelectionToggled(id))) },
onClearSelection = { updateState(state.reduce(LibraryAction.SelectionCleared)) },
onRemoveSelected = ::removeSelectedBooks,
onShowBookInfo = {
bookInfoInitiallyEditing = false
bookInfoDialogFor = it
},
onEditBook = {
bookInfoInitiallyEditing = true
bookInfoDialogFor = it
},
onCreateShelf = { showCreateShelfDialog = true },
onCreateSmartShelf = { showCreateSmartShelfDialog = true },
onRenameShelf = { shelfToRename = it },
onDeleteShelf = { shelfToDelete = it },
onRemoveFolder = { folderToRemove = it },
onTagSelectedBooks = { showTagSelectionDialog = true },
onAddSelectedBooksToShelf = { showAddToShelfDialog = true },
onSyncFolderMetadata = { syncFolderMetadata() },
onScanFolders = { scanSyncedFolders() },
onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) }
)
SharedAppTab.SHELVES -> ShelvesScreen(
shelves = state.shelves,
onRead = ::openReader,
onSelect = { id -> updateState(state.reduce(LibraryAction.BookSelectionToggled(id))) },
selectedBookIds = state.selectedBookIds,
pinnedBookIds = state.pinnedLibraryBookIds,
onShowBookInfo = {
bookInfoInitiallyEditing = false
bookInfoDialogFor = it
},
onEditBook = {
bookInfoInitiallyEditing = true
bookInfoDialogFor = it
},
onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) },
onCreateShelf = { showCreateShelfDialog = true },
onCreateSmartShelf = { showCreateSmartShelfDialog = true },
onRenameShelf = { shelfToRename = it },
onDeleteShelf = { shelfToDelete = it },
onRemoveFolder = { folderToRemove = it }
)
SharedAppTab.CATALOGS -> {
if (featurePolicy.opdsCatalogs) {
SharedOpdsScreen(
state = opdsState,
localLibraryBooks = state.rawLibraryBooks,
onOpenCatalog = ::openOpdsCatalog,
onOpenFeedUrl = ::openOpdsFeedUrl,
onNavigateBack = ::navigateOpdsBack,
onSearch = ::searchOpds,
onLoadNextPage = ::loadNextOpdsPage,
onAddCatalog = { title, url, username, password ->
emitOpds(opdsController.addCatalog(title, url, username, password))
},
onUpdateCatalog = { id, title, url, username, password ->
emitOpds(opdsController.updateCatalog(id, title, url, username, password))
},
onRemoveCatalog = ::removeOpdsCatalog,
onDownloadBook = ::downloadOpdsBook,
onReadBook = ::openReader,
onStreamBook = ::streamOpdsBook,
onClearError = { emitOpds(opdsController.clearError()) },
coverContent = { entry, modifier ->
DesktopOpdsCoverImage(
entry = entry,
catalog = opdsState.currentCatalog,
modifier = modifier
)
}
)
} else {
Box(Modifier.fillMaxSize())
}
}
SharedAppTab.CUSTOM_FONTS -> SharedCustomFontsScreen(
fonts = customFonts,
onImportFont = { importCustomFont(chooseFontFile()) },
onDeleteFont = ::deleteCustomFont,
googleFontsAvailable = featurePolicy.googleFontsDownload,
getGoogleFonts = { customFontStore.loadGoogleFontsList() },
onDownloadGoogleFont = ::downloadGoogleFont,
fontFamilyForPreview = { font -> font.toDesktopPreviewFontFamily() }
)
SharedAppTab.FEEDBACK -> SharedHelpFeedbackScreen(
onOpenGitHubIssues = { openExternalUrl(EpistemeIssuesUrl) },
onEmailSupport = {
val subject = desktopFeedbackSubject(desktopBuildProfile).urlEncode()
openExternalUrl("mailto:$EpistemeSupportEmail?subject=$subject")
}
)
SharedAppTab.SUPPORT -> SharedSupportProjectScreen(
onOpenGitHubSponsors = { openExternalUrl(EpistemeGitHubSponsorsUrl) },
onOpenPatreon = { openExternalUrl(EpistemePatreonUrl) }
)
SharedAppTab.ABOUT -> SharedAboutScreen(
versionName = desktopAppVersionName(),
buildLabel = desktopBuildProfile.buildLabel,
onOpenSource = if (featurePolicy.projectLinks) {
{ openExternalUrl(EpistemeSourceUrl) }
} else {
null
},
onOpenIssues = if (featurePolicy.projectLinks) {
{ openExternalUrl(EpistemeIssuesUrl) }
} else {
null
}
)
SharedAppTab.READER -> {
val opening = openingReader
val pdfDocument = activePdfDocument
if (opening != null) {
DesktopReaderOpeningScreen(
opening = opening,
onReturnToLibrary = {
exitReaderTo(opening.returnTab)
}
)
} else if (pdfDocument != null) {
PdfReaderScreen(
document = pdfDocument,
initialPageIndex = activeReaderBookId
?.let { bookId -> state.rawLibraryBooks.find { it.id == bookId }?.lastPageIndex }
?: 0,
initialViewport = activeReaderBookId
?.let { bookId -> state.rawLibraryBooks.find { it.id == bookId }?.pdfReaderViewport },
initialReaderSettings = activeReaderBookId
?.let { bookId -> state.rawLibraryBooks.find { it.id == bookId } }
?.let { book -> resolvedDesktopReaderSettings(book, state.pdfReaderDefaultSettings) }
?: state.pdfReaderDefaultSettings,
onReturnToLibrary = {
onReaderFullscreenChange(false)
exitReaderTo(SharedAppTab.LIBRARY)
},
onFullscreenChange = onReaderFullscreenChange,
onPageStateChange = { page, progress, viewport ->
updateActiveBookReadingState(page, progress, pdfViewport = viewport)
},
onReaderSettingsChange = ::updateActiveBookReaderSettings,
pdfHighlighterPalette = state.pdfHighlighterPalette,
onPdfHighlighterPaletteChange = { palette ->
updateState(state.reduce(AppAction.PdfHighlighterPaletteChanged(palette)))
},
customTextureIds = readerCustomTextureIds,
onImportTexture = ::importDesktopReaderTexture,
onLocalSidecarsChanged = {
activeReaderBookId
?.let { bookId -> state.rawLibraryBooks.firstOrNull { it.id == bookId } }
?.let(::syncBookSidecars)
},
aiByokSettings = aiByokSettings,
aiAdapter = desktopAiAdapter,
ttsAdapter = desktopTtsAdapter,
ttsReplacementPreferences = state.readerTtsReplacementPreferences,
onTtsReplacementPreferencesChange = { preferences ->
updateState(state.reduce(AppAction.ReaderTtsReplacementPreferencesChanged(preferences)))
},
featurePolicy = featurePolicy
)
} else {
ReaderScreen(
session = readerSession,
readerEngine = readerEngine,
onSessionChange = { updated ->
readerSession = updated
updateActiveBookReadingState(
pageIndex = updated.reader.currentPageIndex,
progress = updated.reader.progress,
session = updated
)
},
onReturnToLibrary = {
onReaderFullscreenChange(false)
exitReaderTo(SharedAppTab.LIBRARY)
},
onFullscreenChange = onReaderFullscreenChange,
toolbarPreferences = state.readerToolbarPreferences,
onToolbarPreferencesChange = { preferences ->
updateState(state.reduce(AppAction.ReaderToolbarPreferencesChanged(preferences)))
},
highlightPalette = state.readerHighlightPalette,
onHighlightPaletteChange = { palette ->
updateState(state.reduce(AppAction.ReaderHighlightPaletteChanged(palette)))
},
ttsReplacementPreferences = state.readerTtsReplacementPreferences,
ttsReplacementBookId = activeReaderBookId ?: readerSession.reader.book.title,
onTtsReplacementPreferencesChange = { preferences ->
updateState(state.reduce(AppAction.ReaderTtsReplacementPreferencesChanged(preferences)))
},
onPickCustomFont = {
importCustomFont(chooseFontFile())?.path
},
customFonts = customFonts,
readerExtrasState = readerExtrasState,
aiByokSettings = aiByokSettings,
externalLookupAvailable = featurePolicy.externalLookup,
cloudTtsControlsAvailable = featurePolicy.aiAndCloud,
onExternalLookup = ::openReaderExternalLookup,
onAiAction = ::runReaderAiAction,
onAiResultDismiss = {
readerExtrasState = readerExtrasState.copy(aiResult = ReaderAiResultState())
},
onCloudTtsToggle = ::toggleReaderCloudTts,
onCloudTtsStart = ::startReaderCloudTts,
onCloudTtsPauseResume = ::pauseResumeReaderCloudTts,
onCloudTtsStop = ::stopReaderCloudTts,
onCloudTtsClearCache = ::clearReaderCloudTtsCache,
onAutoScrollChange = ::updateReaderAutoScroll,
readerTextureDataUri = DesktopReaderTextures::dataUriFor,
readerCustomTextureIds = readerCustomTextureIds,
onImportReaderTexture = ::importDesktopReaderTexture,
webViewRuntimeState = webViewRuntimeState,
webViewNetworkAccessEnabled = featurePolicy.networkAccess,
epubPaginationCache = desktopEpubPaginationCache,
epubPaginationCacheGeneration = epubPaginationCacheGeneration
)
}
}
}
}
DesktopDropImportOverlay(dropImportState)
}
if (showAiByokSettingsDialog) {
DesktopAiByokSettingsDialog(
settings = aiByokSettings,
secureStorageAvailable = aiByokStore.isSecureStorageAvailable,
onSettingsChange = ::updateAiByokSettings,
onDismiss = { showAiByokSettingsDialog = false }
)
}
if (showDesktopAppThemeSettingsDialog) {
SharedAppThemeSettingsDialog(
appThemeMode = state.appThemeMode,
appContrastOption = state.appContrastOption,
appTextDimFactorLight = state.appTextDimFactorLight,
appTextDimFactorDark = state.appTextDimFactorDark,
appSeedColor = state.appSeedColor,
customAppThemes = state.customAppThemes,
onThemeModeChanged = { mode -> updateState(state.reduce(AppAction.AppThemeChanged(mode))) },
onContrastOptionChanged = { option -> updateState(state.reduce(AppAction.AppContrastChanged(option))) },
onTextDimFactorLightChanged = { factor -> updateState(state.reduce(AppAction.AppTextDimFactorLightChanged(factor))) },
onTextDimFactorDarkChanged = { factor -> updateState(state.reduce(AppAction.AppTextDimFactorDarkChanged(factor))) },
onSeedColorChanged = { color -> updateState(state.reduce(AppAction.AppSeedColorChanged(color))) },
onCustomThemeAdded = { theme -> updateState(state.reduce(AppAction.CustomAppThemeAdded(theme))) },
onCustomThemeDeleted = { themeId -> updateState(state.reduce(AppAction.CustomAppThemeDeleted(themeId))) },
onDismiss = { showDesktopAppThemeSettingsDialog = false }
)
}
if (showClearBookCacheDialog) {
SharedConfirmDialog(
title = "Clear book cache",
body = "Delete generated desktop book and EPUB pagination cache files? They will be recreated the next time books are opened.",
confirmLabel = "Clear",
onDismiss = { showClearBookCacheDialog = false },
onConfirm = {
clearDesktopBookCache()
showClearBookCacheDialog = false
}
)
}
if (showCreateShelfDialog) {
SharedTextInputDialog(
title = "Create shelf",
label = "Shelf name",
initialValue = "",
confirmLabel = "Create",
onDismiss = { showCreateShelfDialog = false },
onConfirm = { name ->
createShelf(name)
showCreateShelfDialog = false
}
)
}
if (showCreateSmartShelfDialog) {
SmartShelfDialog(
onDismiss = { showCreateSmartShelfDialog = false },
onConfirm = { name, definition ->
createSmartShelf(name, definition)
showCreateSmartShelfDialog = false
}
)
}
shelfToRename?.let { shelf ->
SharedTextInputDialog(
title = "Rename shelf",
label = "Shelf name",
initialValue = shelf.name,
confirmLabel = "Rename",
onDismiss = { shelfToRename = null },
onConfirm = { name ->
renameShelf(shelf, name)
shelfToRename = null
}
)
}
shelfToDelete?.let { shelf ->
SharedConfirmDialog(
title = "Delete shelf",
body = "Delete \"${shelf.name}\"? Books stay in your library.",
confirmLabel = "Delete",
onDismiss = { shelfToDelete = null },
onConfirm = {
deleteShelf(shelf)
shelfToDelete = null
}
)
}
folderToRemove?.let { folder ->
SharedConfirmDialog(
title = "Remove folder",
body = "Remove \"${folder.name}\" and its ${folder.bookCount} book(s) from the app? Files on disk will not be deleted.",
confirmLabel = "Remove",
onDismiss = { folderToRemove = null },
onConfirm = {
removeFolder(folder)
folderToRemove = null
}
)
}
if (showAddToShelfDialog) {
SharedAddToShelfDialog(
shelves = state.shelves.filter { it.type == ShelfType.MANUAL && it.id != "unshelved" },
onDismiss = { showAddToShelfDialog = false },
onCreateShelf = {
showAddToShelfDialog = false
showCreateShelfDialog = true
},
onShelfSelected = { shelf ->
addSelectedBooksToShelf(shelf.id)
showAddToShelfDialog = false
}
)
}
if (showTagSelectionDialog) {
SharedTextInputDialog(
title = "Tag selected books",
label = "Tag name",
initialValue = state.allTags.firstOrNull()?.name.orEmpty(),
confirmLabel = "Apply",
onDismiss = { showTagSelectionDialog = false },
onConfirm = { name ->
tagSelectedBooks(name)
showTagSelectionDialog = false
}
)
}
bookInfoDialogFor?.let { book ->
val canEditEmbeddedMetadata = book.type == FileType.EPUB &&
book.path?.let { File(it).isFile && File(it).canWrite() } == true
val canRenameDisplayName = book.type != FileType.EPUB
SharedBookInfoDialog(
book = book,
knownTags = state.allTags,
initiallyEditing = bookInfoInitiallyEditing && (canEditEmbeddedMetadata || canRenameDisplayName),
canEditEmbeddedMetadata = canEditEmbeddedMetadata,
canRenameDisplayName = canRenameDisplayName,
canRestoreEmbeddedMetadata = canEditEmbeddedMetadata,
onDismiss = {
bookInfoInitiallyEditing = false
bookInfoDialogFor = null
},
onSave = { updated ->
updateBookMetadata(updated)
bookInfoInitiallyEditing = false
bookInfoDialogFor = null
},
onRestore = { restored ->
updateBookMetadata(restored)
bookInfoInitiallyEditing = false
bookInfoDialogFor = null
}
)
}
}
}
private val DesktopPdfAnnotationTools = listOf(
PdfInkTool.PEN,
PdfInkTool.FOUNTAIN_PEN,
PdfInkTool.PENCIL,
PdfInkTool.HIGHLIGHTER,
PdfInkTool.HIGHLIGHTER_ROUND,
PdfInkTool.TEXT,
PdfInkTool.ERASER
)
@Composable
private fun PdfReaderScreen(
document: DesktopPdfDocument,
initialPageIndex: Int,
initialViewport: SharedPdfReaderViewport? = null,
initialReaderSettings: ReaderSettings? = null,
onReturnToLibrary: (() -> Unit)? = null,
onFullscreenChange: (Boolean) -> Unit = {},
onPageStateChange: (pageIndex: Int, progress: Float, viewport: SharedPdfReaderViewport) -> Unit,
onReaderSettingsChange: (ReaderSettings) -> Unit = {},
pdfHighlighterPalette: SharedPdfHighlighterPalette = SharedPdfHighlighterPalette(),
onPdfHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit = {},
customTextureIds: List<String> = emptyList(),
onImportTexture: ((ReaderSettings) -> ReaderSettings?)? = null,
onLocalSidecarsChanged: () -> Unit = {},
aiByokSettings: ReaderAiByokSettings,
aiAdapter: DesktopByokAiAdapter,
ttsAdapter: DesktopGeminiCloudTtsAdapter,
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit,
featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard
) {
val documentHandleId = document.handleId
val zoomSpec = remember { DesktopPdfZoomSpec }
val restoredInitialViewport = remember(documentHandleId, initialViewport) {
initialViewport?.sanitized(document.pageCount, zoomSpec)
}
var pdfReaderSettings by remember(documentHandleId) {
mutableStateOf(initialReaderSettings.toDesktopPdfReaderSettings())
}
var pdfState by remember(documentHandleId) {
mutableStateOf(
SharedPdfReaderState.initial(
pageCount = document.pageCount,
initialPageIndex = restoredInitialViewport?.pageIndex ?: initialPageIndex,
zoomSpec = zoomSpec
).copy(
displayMode = restoredInitialViewport?.displayMode ?: DesktopDefaultPdfDisplayMode,
zoom = restoredInitialViewport?.zoom ?: zoomSpec.clamp(zoomSpec.default)
)
)
}
var renderedPage by remember(documentHandleId) { mutableStateOf<DesktopPdfPageRender?>(null) }
var renderedPageIndex by remember(documentHandleId) { mutableStateOf<Int?>(null) }
var renderedPageScale by remember(documentHandleId) { mutableStateOf<Float?>(null) }
var renderError by remember(documentHandleId) { mutableStateOf<String?>(null) }
var isRendering by remember(documentHandleId) { mutableStateOf(false) }
var renderJob by remember(documentHandleId) { mutableStateOf<Job?>(null) }
val zoomAnchorJob = remember(documentHandleId) { AtomicReference<Job?>(null) }
val zoomCommitJob = remember(documentHandleId) { AtomicReference<Job?>(null) }
var pdfZoomPreview by remember(documentHandleId) { mutableStateOf<DesktopPdfZoomPreview?>(null) }
var activeTextDraft by remember(documentHandleId) { mutableStateOf<SharedPdfTextDraft?>(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<Int, Offset>() }
val paginatedRenderCache = remember(documentHandleId) { mutableStateMapOf<Int, DesktopPdfCachedPageRender>() }
var activeStroke by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf<List<PdfPagePoint>>(emptyList()) }
var eraserPosition by remember(documentHandleId, pdfState.pageIndex, pdfState.selectedTool) { mutableStateOf<Offset?>(null) }
var isHighlighterSnapEnabled by remember(documentHandleId) { mutableStateOf(false) }
var selectionStartIndex by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf<Int?>(null) }
var selectionEndIndex by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf<Int?>(null) }
var selectionStartHit by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf<DesktopPdfCharHit?>(null) }
var selectionEndHit by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf<DesktopPdfCharHit?>(null) }
var textSelection by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf<DesktopPdfTextSelection?>(null) }
var selectionMenuOffset by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf<Offset?>(null) }
var activeSelectionHandle by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf<DesktopPdfSelectionHandle?>(null) }
var pageScrubPreview by remember(documentHandleId) { mutableStateOf<Int?>(null) }
var pageScrubStartPage by remember(documentHandleId) { mutableStateOf<Int?>(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<String?>(null) }
var pdfExtrasState by remember(documentHandleId) {
mutableStateOf(
ReaderExtrasState(
cloudTts = ReaderCloudTtsState(
isAvailable = aiByokSettings.isCloudTtsAvailable,
cacheSummary = ttsAdapter.cacheSummary(document.title, aiByokSettings.sanitized().ttsSpeakerId)
)
)
)
}
var pdfTtsJob by remember(documentHandleId) { mutableStateOf<Job?>(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(documentHandleId) { mutableStateOf(false) }
val currentPdfFullscreen by rememberUpdatedState(isFullscreen)
val currentOnPdfFullscreenChange by rememberUpdatedState(onFullscreenChange)
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(documentHandleId) { mutableStateOf(false) }
var isRichTextLoaded by remember(documentHandleId) { mutableStateOf(false) }
val richTextController = remember(documentHandleId) {
SharedPdfRichTextController(
scope = pdfScope,
onDocumentChange = { richDocument ->
if (isRichTextLoaded) {
SharedPdfRichTextLog.d(
"desktop.documentChange save path=\"${richTextFile.absolutePath.logPreview(160)}\" " +
"textLen=${richDocument.text.length} spans=${richDocument.spans.size}"
)
withContext(Dispatchers.IO) {
richTextFile.parentFile?.mkdirs()
richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument))
}
SharedPdfRichTextLog.d(
"desktop.documentChange saved path=\"${richTextFile.absolutePath.logPreview(160)}\" " +
"lastModified=${richTextFile.lastModified()}"
)
onLocalSidecarsChanged()
} else {
SharedPdfRichTextLog.d(
"desktop.documentChange ignoredBeforeLoad path=\"${richTextFile.absolutePath.logPreview(160)}\" " +
"textLen=${richDocument.text.length} spans=${richDocument.spans.size}"
)
}
}
)
}
val pageVerticalScrollState = rememberScrollState(
initial = restoredInitialViewport?.paginatedVerticalScrollOffset ?: 0
)
val pageHorizontalScrollState = rememberScrollState(
initial = restoredInitialViewport?.horizontalScrollOffset ?: 0
)
val verticalListState = rememberLazyListState(
initialFirstVisibleItemIndex = restoredInitialViewport
?.takeIf { it.displayMode == PdfDisplayMode.VERTICAL_SCROLL }
?.verticalFirstPageIndex
?: pdfState.pageIndex,
initialFirstVisibleItemScrollOffset = restoredInitialViewport
?.takeIf { it.displayMode == PdfDisplayMode.VERTICAL_SCROLL }
?.verticalFirstPageScrollOffset
?: 0
)
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, documentHandleId) {
repeat(if (isFullscreen) 4 else 1) { attempt ->
delay(if (attempt == 0) 80L else 120L)
runCatching { pdfReaderFocusRequester.requestFocus() }
}
}
fun clearPdfInteractionState() {
activeStroke = emptyList()
eraserPosition = null
selectionStartIndex = null
selectionEndIndex = null
selectionStartHit = null
selectionEndHit = null
textSelection = null
selectionMenuOffset = null
activeSelectionHandle = null
}
fun dispatchPdf(action: SharedPdfReaderAction) {
val previousPage = pdfState.pageIndex
val next = pdfState.reduce(action, zoomSpec)
pdfState = next
if (next.pageIndex != previousPage) {
clearPdfInteractionState()
}
}
fun setPdfFullscreen(enabled: Boolean) {
isFullscreen = enabled
onFullscreenChange(enabled)
}
fun updatePdfReaderSettings(settings: ReaderSettings) {
val nextSettings = settings.toDesktopPdfReaderSettings()
pdfReaderSettings = nextSettings
onReaderSettingsChange(nextSettings)
}
fun commitActiveTextDraft() {
val draft = activeTextDraft ?: return
activeTextDraft = null
val annotation = draft.toAnnotation()
if (annotation.text.isNotEmpty()) {
dispatchPdf(SharedPdfReaderAction.AnnotationAdded(annotation))
}
}
fun persistActiveTextDraftIfReady(draft: SharedPdfTextDraft) {
val annotation = draft.toAnnotation()
if (annotation.text.isNotEmpty()) {
activeTextDraft = null
textStyleConfig = draft.style
dispatchPdf(SharedPdfReaderAction.AnnotationAdded(annotation))
} else {
activeTextDraft = draft
}
}
fun startActiveTextDraft(pageIndex: Int, anchor: Offset, canvasSize: IntSize) {
if (canvasSize.width <= 0 || canvasSize.height <= 0) return
commitActiveTextDraft()
clearPdfInteractionState()
dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null))
val now = System.currentTimeMillis()
activeTextDraft = SharedPdfTextAnnotationDefaults.createDraft(
id = "text_$now",
pageIndex = pageIndex,
anchor = anchor.toSharedPdfPoint(canvasSize, now),
canvasSize = canvasSize,
style = textStyleConfig,
createdAt = now
)
}
fun updateActiveTextDraft(text: String, canvasSize: IntSize) {
activeTextDraft?.withText(text, canvasSize)?.let(::persistActiveTextDraftIfReady)
}
fun updateActiveTextDraftBounds(bounds: PdfPageBounds) {
activeTextDraft = activeTextDraft?.withBounds(bounds)
}
fun activeTextDraftContains(pageIndex: Int, offset: Offset, canvasSize: IntSize): Boolean {
return activeTextDraft?.containsOffset(pageIndex, offset, canvasSize) == true
}
fun updateTextStyleConfig(style: SharedPdfTextStyleConfig) {
textStyleConfig = style
val draft = activeTextDraft
if (draft != null) {
activeTextDraft = if (draft.pageIndex == pdfState.pageIndex && pageCanvasSize.width > 0 && pageCanvasSize.height > 0) {
draft.withStyle(style, pageCanvasSize)
} else {
draft.copy(style = style)
}
return
}
val selectedTextAnnotation = pdfState.annotations.firstOrNull {
it.id == pdfState.selectedAnnotationId && it.kind == PdfAnnotationKind.TEXT
}
if (selectedTextAnnotation != null) {
dispatchPdf(SharedPdfReaderAction.AnnotationUpdated(selectedTextAnnotation.withSharedPdfTextStyle(style)))
}
}
fun selectTextAnnotation(annotation: SharedPdfAnnotation) {
if (annotation.kind != PdfAnnotationKind.TEXT) return
SharedPdfRichTextLog.d(
"desktop.textBox.select id=${annotation.id} page=${annotation.pageIndex} " +
"richMode=$isRichTextMode textLen=${annotation.text.length}"
)
if (isRichTextMode) {
isRichTextMode = false
pdfScope.launch { richTextController.saveImmediate() }
}
commitActiveTextDraft()
clearPdfInteractionState()
textStyleConfig = annotation.sharedPdfTextStyle()
dispatchPdf(SharedPdfReaderAction.AnnotationSelected(annotation.id))
}
fun activateRichTextMode() {
SharedPdfRichTextLog.d(
"desktop.mode.activate page=${pdfState.pageIndex} " +
"globalLen=${richTextController.globalTextFieldValue.text.length} layouts=${richTextController.pageLayouts.size}"
)
commitActiveTextDraft()
clearPdfInteractionState()
dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null))
if (pdfState.isTextSelectionMode) {
dispatchPdf(SharedPdfReaderAction.TextSelectionModeChanged(false))
}
isRichTextMode = true
}
fun deactivateRichTextMode(save: Boolean = true) {
if (!isRichTextMode) return
SharedPdfRichTextLog.d(
"desktop.mode.deactivate page=${pdfState.pageIndex} save=$save " +
"activePage=${richTextController.activePageIndex} globalLen=${richTextController.globalTextFieldValue.text.length}"
)
isRichTextMode = false
if (save) {
pdfScope.launch { richTextController.saveImmediate() }
} else {
richTextController.clearSelection()
}
}
fun selectPdfAnnotationTool(tool: PdfInkTool) {
SharedPdfRichTextLog.d(
"desktop.tool.select tool=$tool richMode=$isRichTextMode page=${pdfState.pageIndex}"
)
val previousTool = pdfState.selectedTool
deactivateRichTextMode()
if (tool != PdfInkTool.TEXT) {
commitActiveTextDraft()
}
if (pdfState.isTextSelectionMode) {
dispatchPdf(SharedPdfReaderAction.TextSelectionModeChanged(false))
clearPdfInteractionState()
}
if (previousTool != tool) {
dispatchPdf(SharedPdfReaderAction.ToolSelected(tool))
}
if (tool.isDesktopHighlighter && previousTool != tool) {
pdfHighlighterPalette.sanitized().colors.firstOrNull()?.let { colorArgb ->
dispatchPdf(SharedPdfReaderAction.ColorSelected(colorArgb))
}
}
}
val pageIndex = pdfState.pageIndex
val scale = pdfState.zoom
val displayMode = pdfState.displayMode
val zoomControlScale = pdfZoomPreview?.zoom ?: scale
val shouldShowPdfZoomIndicator = abs(zoomControlScale - 1f) > 0.001f
LaunchedEffect(zoomControlScale, document.path) {
if (!isPdfZoomIndicatorInitialized) {
isPdfZoomIndicatorInitialized = true
showPdfZoomIndicator = false
return@LaunchedEffect
}
if (shouldShowPdfZoomIndicator) {
showPdfZoomIndicator = true
delay(1_500)
showPdfZoomIndicator = false
} else {
showPdfZoomIndicator = false
}
}
fun verticalZoomAnchorItem(anchor: Offset) = verticalListState.layoutInfo.visibleItemsInfo
.firstOrNull { item ->
anchor.y >= item.offset.toFloat() && anchor.y <= (item.offset + item.size).toFloat()
}
?: verticalListState.layoutInfo.visibleItemsInfo.minByOrNull { item ->
when {
anchor.y < item.offset.toFloat() -> item.offset.toFloat() - anchor.y
anchor.y > (item.offset + item.size).toFloat() -> anchor.y - (item.offset + item.size).toFloat()
else -> 0f
}
}
LaunchedEffect(scale, displayMode, pageIndex) {
val preview = pdfZoomPreview ?: return@LaunchedEffect
if (
preview.displayMode != displayMode ||
(preview.pageIndex != pageIndex && displayMode == PdfDisplayMode.PAGINATION) ||
abs(preview.baseZoom - scale) > 0.0001f
) {
pdfZoomPreview = null
zoomCommitJob.getAndSet(null)?.cancel()
}
}
fun applyAnchoredPdfZoom(oldZoom: Float, newZoom: Float, anchor: Offset?) {
val activePageIndex = currentPdfPageIndex
val activeDisplayMode = currentPdfDisplayMode
logPdfZoomPerf {
"commit_start mode=$activeDisplayMode page=${activePageIndex + 1} old=${oldZoom.formatLogFloat()} " +
"new=${newZoom.formatLogFloat()} anchor=${anchor.formatLogOffset()} " +
"renderPage=${renderedPageIndex?.let { it + 1 } ?: "none"} " +
"renderScale=${renderedPageScale?.formatLogFloat() ?: "none"} " +
"renderJobActive=${renderJob?.isActive == true}"
}
pdfZoomPreview = null
val viewportRootOffsetAtZoomStart = pdfZoomViewportRootOffset
val pageRootOffsetAtZoomStart = paginatedPageRootOffset
val targetHorizontalScroll = anchor?.let {
desktopPdfAnchoredScrollTarget(pageHorizontalScrollState.value, it.x, oldZoom, newZoom)
}
val targetVerticalScroll = anchor?.let {
desktopPdfAnchoredScrollTarget(pageVerticalScrollState.value, it.y, oldZoom, newZoom)
}
val targetVerticalItem = if (activeDisplayMode == PdfDisplayMode.VERTICAL_SCROLL && anchor != null) {
verticalZoomAnchorItem(anchor)
?.let { item ->
val fallbackOffset = desktopPdfAnchoredLazyItemScrollOffset(
itemOffset = item.offset,
anchor = anchor.y,
oldZoom = oldZoom,
newZoom = newZoom
)
val pageRootOffset = verticalPageRootOffsets[item.index]
Triple(item.index, fallbackOffset, pageRootOffset)
}
} else {
null
}
dispatchPdf(SharedPdfReaderAction.ZoomChanged(newZoom))
if (anchor != null) {
val nextAnchorJob = pdfScope.launch {
withFrameNanos { }
when (activeDisplayMode) {
PdfDisplayMode.PAGINATION -> {
suspend fun correctPageAnchor() {
val pageDelta = desktopPdfAnchoredPageScrollDelta(
viewportRootOffset = viewportRootOffsetAtZoomStart,
oldPageRootOffset = pageRootOffsetAtZoomStart,
currentPageRootOffset = paginatedPageRootOffset,
anchor = anchor,
oldZoom = oldZoom,
newZoom = newZoom
)
if (pageDelta != null) {
if (abs(pageDelta.x) > 1) {
pageHorizontalScrollState.scrollTo(
(pageHorizontalScrollState.value + pageDelta.x).coerceAtLeast(
0
)
)
}
if (abs(pageDelta.y) > 1) {
pageVerticalScrollState.scrollTo(
(pageVerticalScrollState.value + pageDelta.y).coerceAtLeast(
0
)
)
}
} else if (targetHorizontalScroll != null && targetVerticalScroll != null) {
pageHorizontalScrollState.scrollTo(targetHorizontalScroll)
pageVerticalScrollState.scrollTo(targetVerticalScroll)
}
}
correctPageAnchor()
withFrameNanos { }
correctPageAnchor()
}
PdfDisplayMode.VERTICAL_SCROLL -> {
suspend fun correctVerticalAnchor() {
val oldPageRootOffset = targetVerticalItem?.third
val currentPageRootOffset =
targetVerticalItem?.first?.let { verticalPageRootOffsets[it] }
val pageDelta =
if (oldPageRootOffset != null && currentPageRootOffset != null) {
desktopPdfAnchoredPageScrollDelta(
viewportRootOffset = viewportRootOffsetAtZoomStart,
oldPageRootOffset = oldPageRootOffset,
currentPageRootOffset = currentPageRootOffset,
anchor = anchor,
oldZoom = oldZoom,
newZoom = newZoom
)
} else {
null
}
if (pageDelta != null) {
if (abs(pageDelta.x) > 1) {
pageHorizontalScrollState.scrollTo(
(pageHorizontalScrollState.value + pageDelta.x).coerceAtLeast(
0
)
)
}
if (abs(pageDelta.y) > 1) {
verticalListState.scrollBy(pageDelta.y.toFloat())
}
} else {
targetHorizontalScroll?.let { pageHorizontalScrollState.scrollTo(it) }
targetVerticalItem?.let { (itemIndex, scrollOffset, _) ->
verticalListState.scrollToItem(itemIndex, scrollOffset)
}
}
}
correctVerticalAnchor()
withFrameNanos { }
correctVerticalAnchor()
}
}
}
zoomAnchorJob.getAndSet(nextAnchorJob)?.cancel()
}
}
fun previewAnchoredPdfZoom(oldZoom: Float, newZoom: Float, anchor: Offset?) {
val activePageIndex = currentPdfPageIndex
val activeScale = currentPdfScale
val activeDisplayMode = currentPdfDisplayMode
logPdfZoomPerf {
"preview mode=$activeDisplayMode page=${activePageIndex + 1} old=${oldZoom.formatLogFloat()} " +
"new=${newZoom.formatLogFloat()} anchor=${anchor.formatLogOffset()} " +
"hasRender=${renderedPage != null && renderedPageIndex == activePageIndex} " +
"renderScale=${renderedPageScale?.formatLogFloat() ?: "none"} " +
"renderJobActive=${renderJob?.isActive == true} cacheKeys=${paginatedRenderCache.keys.sorted().map { it + 1 }}"
}
val existingPreview = pdfZoomPreview
val baseZoom = existingPreview
?.takeIf { it.displayMode == activeDisplayMode && it.baseZoom.isFinite() && it.baseZoom > 0f }
?.baseZoom
?: oldZoom.takeIf { it.isFinite() && it > 0f }
?: activeScale
val previewPageIndex = when (activeDisplayMode) {
PdfDisplayMode.PAGINATION -> activePageIndex
PdfDisplayMode.VERTICAL_SCROLL -> anchor?.let(::verticalZoomAnchorItem)?.index ?: activePageIndex
}
pdfZoomPreview = DesktopPdfZoomPreview(
baseZoom = baseZoom,
zoom = newZoom,
anchor = anchor,
displayMode = activeDisplayMode,
pageIndex = previewPageIndex
)
val nextCommitJob = pdfScope.launch {
delay(DesktopPdfZoomCommitDebounceMillis)
val preview = pdfZoomPreview ?: return@launch
pdfZoomPreview = null
applyAnchoredPdfZoom(preview.baseZoom, preview.zoom, preview.anchor)
}
zoomCommitJob.getAndSet(nextCommitJob)?.cancel()
if (
activeDisplayMode == PdfDisplayMode.PAGINATION &&
renderedPage != null &&
renderedPageIndex == activePageIndex
) {
renderJob?.cancel()
}
}
fun cancelPendingPdfZoomPreview() {
pdfZoomPreview = null
zoomCommitJob.getAndSet(null)?.cancel()
}
fun cachePaginatedRender(page: Int, renderScale: Float, render: DesktopPdfPageRender) {
paginatedRenderCache[page] = DesktopPdfCachedPageRender(render, renderScale)
val activePageIndex = currentPdfPageIndex
val keepRange =
(activePageIndex - DesktopPdfPaginationRenderCacheRadius)..(activePageIndex + DesktopPdfPaginationRenderCacheRadius)
val evictedPages = paginatedRenderCache.keys
.filter { it !in keepRange }
evictedPages.forEach { paginatedRenderCache.remove(it) }
logPdfZoomPerf {
"cache_put page=${page + 1} scale=${renderScale.formatLogFloat()} " +
"bitmap=${render.width}x${render.height} current=${activePageIndex + 1} " +
"keys=${paginatedRenderCache.keys.sorted().map { it + 1 }} evicted=${evictedPages.map { it + 1 }}"
}
}
LaunchedEffect(documentHandleId, pageIndex, displayMode) {
runCatching { pdfReaderFocusRequester.requestFocus() }
}
val searchQuery = pdfState.searchQuery
val isPdfSearchActive = pdfState.isSearchActive
val showPdfSearchResultsPanel = pdfState.showSearchResultsPanel
val activeSearchIndex = pdfState.activeSearchResultIndex
val searchHighlightMode = pdfState.searchHighlightMode
val selectedTool = pdfState.selectedTool
val selectedColor = pdfState.selectedColorArgb
val strokeWidth = pdfState.strokeWidth
val pdfHighlighterColors = pdfHighlighterPalette.sanitized().colors
val isTextSelectionMode = pdfState.isTextSelectionMode
val bookmarks = pdfState.bookmarks
val selectedAnnotationId = pdfState.selectedAnnotationId
val annotations = pdfState.annotations
val canGoPrevious = pdfState.canGoPrevious
val canGoNext = pdfState.canGoNext
val progressPercent = pdfState.progressPercent
val latestOnPageStateChange by rememberUpdatedState(onPageStateChange)
fun pdfViewportSnapshot(): SharedPdfReaderViewport {
val state = pdfState
return SharedPdfReaderViewport(
pageIndex = state.pageIndex,
displayMode = state.displayMode,
zoom = pdfZoomPreview?.zoom ?: state.zoom,
horizontalScrollOffset = pageHorizontalScrollState.value,
paginatedVerticalScrollOffset = pageVerticalScrollState.value,
verticalFirstPageIndex = verticalListState.firstVisibleItemIndex,
verticalFirstPageScrollOffset = verticalListState.firstVisibleItemScrollOffset
).sanitized(document.pageCount, zoomSpec)
}
fun pdfProgressPercentFor(pageIndex: Int): Float {
return ((pageIndex + 1).toFloat() / document.pageCount.coerceAtLeast(1)) * 100f
}
var latestPdfViewport by remember(documentHandleId) {
mutableStateOf(restoredInitialViewport ?: pdfViewportSnapshot())
}
fun persistPdfViewport(viewport: SharedPdfReaderViewport = pdfViewportSnapshot()) {
latestPdfViewport = viewport
latestOnPageStateChange(viewport.pageIndex, pdfProgressPercentFor(viewport.pageIndex), viewport)
}
val pdfThemeStyle = remember(pdfReaderSettings, displayMode) {
pdfReaderSettings.toDesktopPdfThemeStyle(displayMode)
}
val verticalRenderWindow = remember(pageIndex, document.pageCount) {
val start = (pageIndex - 1).coerceAtLeast(0)
val end = (pageIndex + 1).coerceAtMost((document.pageCount - 1).coerceAtLeast(0))
start..end
}
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<List<SharedPdfSearchResult>>(emptyList()) }
var selectedEmbeddedAnnotationId by remember(documentHandleId) { mutableStateOf<String?>(null) }
val selectedAnnotation = remember(annotations, selectedAnnotationId) {
annotations.firstOrNull { it.id == selectedAnnotationId }
}
val selectedTextHighlight = selectedAnnotation?.takeIf { it.isDesktopTextSelectionHighlight }
val sortedAnnotations = remember(annotations) {
annotations.sortedWith(compareBy<SharedPdfAnnotation> { it.pageIndex }.thenBy { it.createdAt })
}
val sortedEmbeddedAnnotations = remember(document.embeddedAnnotations) {
document.embeddedAnnotations.sortedWith(compareBy<SharedPdfEmbeddedAnnotation> { it.pageIndex }.thenBy { it.index })
}
val selectedEmbeddedAnnotation = remember(document.embeddedAnnotations, selectedEmbeddedAnnotationId) {
document.embeddedAnnotations.firstOrNull { it.id == selectedEmbeddedAnnotationId }
}
val effectiveTextStyleConfig = remember(activeTextDraft, selectedAnnotation, textStyleConfig) {
activeTextDraft?.style
?: selectedAnnotation?.takeIf { it.kind == PdfAnnotationKind.TEXT }?.sharedPdfTextStyle()
?: textStyleConfig
}
val activePdfTtsChunk = pdfExtrasState.cloudTts.progress.currentChunk
LaunchedEffect(selectedTool) {
activeStroke = emptyList()
eraserPosition = null
}
fun updatePdfHighlighterPalette(nextPalette: SharedPdfHighlighterPalette) {
val previousSlot = pdfHighlighterPalette.sanitized().colors.indexOf(selectedColor)
val sanitizedPalette = nextPalette.sanitized()
onPdfHighlighterPaletteChange(sanitizedPalette)
if (selectedTool.isDesktopHighlighter && selectedColor !in sanitizedPalette.colors) {
val colorArgb = sanitizedPalette.colors.getOrNull(previousSlot)
?: sanitizedPalette.colors.firstOrNull()
colorArgb?.let { nextSelectedColor ->
dispatchPdf(SharedPdfReaderAction.ColorSelected(nextSelectedColor))
}
}
}
fun currentPdfTtsCacheSummary() =
ttsAdapter.cacheSummary(document.title, aiByokSettings.sanitized().ttsSpeakerId)
DesktopExternalLinkDialog(
url = externalLinkDialogUrl,
onDismiss = { externalLinkDialogUrl = null }
)
val pdfPopupActive =
externalLinkDialogUrl != null ||
selectedTextHighlight != null ||
selectedEmbeddedAnnotation != null ||
pdfExtrasState.aiResult.hasContent ||
(textSelection != null && selectionMenuOffset != null)
LaunchedEffect(pdfPopupActive, documentHandleId) {
if (!pdfPopupActive) {
delay(120L)
runCatching { pdfReaderFocusRequester.requestFocus() }
}
}
LaunchedEffect(aiByokSettings) {
pdfExtrasState = pdfExtrasState.copy(
cloudTts = pdfExtrasState.cloudTts.copy(
isAvailable = aiByokSettings.isCloudTtsAvailable,
errorMessage = null,
cacheSummary = currentPdfTtsCacheSummary()
)
)
}
LaunchedEffect(documentHandleId) {
arePdfAnnotationsLoaded = false
val loadedAnnotations = if (annotationFile.exists()) {
withContext(Dispatchers.IO) {
SharedPdfAnnotationSerializer.decode(annotationFile.readText())
}
} else {
emptyList()
}
dispatchPdf(SharedPdfReaderAction.AnnotationsLoaded(loadedAnnotations))
arePdfAnnotationsLoaded = true
}
LaunchedEffect(documentHandleId, annotations, arePdfAnnotationsLoaded) {
if (!arePdfAnnotationsLoaded) return@LaunchedEffect
withContext(Dispatchers.IO) {
runCatching {
annotationFile.parentFile?.mkdirs()
annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations))
}
}
onLocalSidecarsChanged()
}
LaunchedEffect(documentHandleId) {
isRichTextLoaded = false
SharedPdfRichTextLog.d(
"desktop.loadRichText start path=\"${richTextFile.absolutePath.logPreview(160)}\" exists=${richTextFile.exists()}"
)
val loadedRichText = withContext(Dispatchers.IO) {
if (richTextFile.exists()) {
val raw = richTextFile.readText()
SharedPdfRichTextLog.d(
"desktop.loadRichText read path=\"${richTextFile.absolutePath.logPreview(160)}\" rawLen=${raw.length}"
)
SharedPdfRichTextSerializer.decode(raw)
} else {
SharedPdfRichDocument()
}
}
SharedPdfRichTextLog.d(
"desktop.loadRichText decoded textLen=${loadedRichText.text.length} spans=${loadedRichText.spans.size}"
)
richTextController.replaceDocument(loadedRichText)
isRichTextLoaded = true
SharedPdfRichTextLog.d("desktop.loadRichText ready")
}
LaunchedEffect(documentHandleId) {
arePdfBookmarksLoaded = false
val loadedBookmarks = if (bookmarkFile.exists()) {
withContext(Dispatchers.IO) {
SharedPdfBookmarkSerializer.decode(bookmarkFile.readText())
}
} else {
emptyList()
}
dispatchPdf(SharedPdfReaderAction.BookmarksLoaded(loadedBookmarks))
arePdfBookmarksLoaded = true
}
LaunchedEffect(documentHandleId, bookmarks, arePdfBookmarksLoaded) {
if (!arePdfBookmarksLoaded) return@LaunchedEffect
withContext(Dispatchers.IO) {
runCatching {
bookmarkFile.parentFile?.mkdirs()
bookmarkFile.writeText(SharedPdfBookmarkSerializer.encode(bookmarks))
}
}
onLocalSidecarsChanged()
}
LaunchedEffect(documentHandleId) {
val restoredPageCount = withContext(Dispatchers.IO) {
restoreDesktopPdfSearchIndex(document, searchIndexFile)
}
indexedSearchPageCount = restoredPageCount
isSearchIndexing = indexedSearchPageCount < document.pageCount
logPdfZoomPerf {
"search_index_restore indexed=$indexedSearchPageCount/${document.pageCount} active=$isSearchIndexing"
}
withContext(Dispatchers.IO) {
DesktopPdfium.indexSearchPages(
document = document,
onProgress = { indexed, _ ->
indexedSearchPageCount = indexed
logPdfZoomPerf { "search_index_progress indexed=$indexed/${document.pageCount}" }
},
shouldContinue = { isActive }
)
if (isActive) {
saveDesktopPdfSearchIndex(document, searchIndexFile)
}
}
if (!isActive) return@LaunchedEffect
indexedSearchPageCount = document.indexedSearchTextPageCount()
isSearchIndexing = false
logPdfZoomPerf { "search_index_done indexed=$indexedSearchPageCount/${document.pageCount}" }
}
LaunchedEffect(documentHandleId, searchQuery, indexedSearchPageCount) {
val normalizedQuery = searchQuery.trim()
searchResults = if (normalizedQuery.isBlank()) {
emptyList()
} else {
withContext(Dispatchers.IO) {
DesktopPdfium.search(document, normalizedQuery)
}
}
}
fun goToPage(
target: Int,
scrollVertical: Boolean = true,
recordJump: Boolean = false,
saveRichTextBeforePageChange: Boolean = true
) {
val clampedTarget = target.coerceIn(0, (document.pageCount - 1).coerceAtLeast(0))
val currentPage = pdfState.pageIndex
SharedPdfRichTextLog.d(
"desktop.goToPage target=$target clamped=$clampedTarget current=$currentPage " +
"richMode=$isRichTextMode scrollVertical=$scrollVertical recordJump=$recordJump " +
"saveRich=$saveRichTextBeforePageChange activePage=${richTextController.activePageIndex}"
)
if (clampedTarget != currentPage) {
commitActiveTextDraft()
if (isRichTextMode && saveRichTextBeforePageChange) {
SharedPdfRichTextLog.d("desktop.goToPage savingRichTextBeforePageChange from=$currentPage to=$clampedTarget")
pdfScope.launch { richTextController.saveImmediate() }
}
}
if (recordJump) {
jumpHistory = jumpHistory.record(
currentPageIndex = currentPage,
targetPageIndex = clampedTarget,
pageCount = document.pageCount
)
}
dispatchPdf(SharedPdfReaderAction.GoToPage(clampedTarget))
if (scrollVertical && displayMode == PdfDisplayMode.VERTICAL_SCROLL) {
pdfScope.launch {
verticalListState.scrollToItem(clampedTarget)
}
}
}
fun updatePdfPageScrub(value: Float) {
if (pageScrubStartPage == null) {
pageScrubStartPage = pdfState.pageIndex
}
val targetPage = value.roundToInt().coerceIn(0, document.pageCount - 1)
pageScrubPreview = targetPage
goToPage(targetPage)
}
fun finishPdfPageScrub() {
val startPage = pageScrubStartPage
val targetPage = pdfState.pageIndex
if (startPage != null) {
jumpHistory = jumpHistory.record(
currentPageIndex = startPage,
targetPageIndex = targetPage,
pageCount = document.pageCount
)
}
pageScrubStartPage = null
pageScrubPreview = null
}
fun goBackInJumpHistory() {
val targetPage = jumpHistory.backPage ?: return
jumpHistory = jumpHistory.stepBack()
goToPage(targetPage)
}
fun goForwardInJumpHistory() {
val targetPage = jumpHistory.forwardPage ?: return
jumpHistory = jumpHistory.stepForward()
goToPage(targetPage)
}
fun activatePdfLink(target: DesktopPdfLinkTarget) {
target.destPageIndex
?.takeIf { it in 0 until document.pageCount }
?.let {
logPdfLink("activate_internal fromPage=${pageIndex + 1} targetPage=${it + 1}")
clearPdfInteractionState()
goToPage(it, recordJump = true)
return
}
target.uri
?.takeIf { it.isNotBlank() }
?.let {
val url = it.normalizedExternalUrl()
logPdfLink("activate_external fromPage=${pageIndex + 1} url=\"${url.logPreview()}\"")
clearPdfInteractionState()
if (featurePolicy.externalLookup) {
externalLinkDialogUrl = url
}
return
}
logPdfLink(
"activate_ignored fromPage=${pageIndex + 1} " +
"dest=${target.destPageIndex} uri=\"${target.uri.orEmpty().logPreview()}\""
)
}
fun toggleBookmark(targetPage: Int) {
val page = targetPage.coerceIn(0, (document.pageCount - 1).coerceAtLeast(0))
dispatchPdf(
SharedPdfReaderAction.BookmarkToggled(
pageIndex = page,
label = "Page ${page + 1}",
createdAt = System.currentTimeMillis()
)
)
}
fun copySelection(selection: DesktopPdfTextSelection) {
selection.text.takeIf { it.isNotBlank() }?.let {
clipboardManager.setText(AnnotatedString(it))
}
}
fun highlightSelection(
pageIndex: Int,
selection: DesktopPdfTextSelection,
canvasSize: IntSize,
colorArgb: Int = SharedPdfAnnotationDefaults.configFor(PdfInkTool.HIGHLIGHTER).colorArgb
) {
val now = System.currentTimeMillis()
val highlightBounds = DesktopPdfium.textRectsForRange(
document = document,
pageIndex = pageIndex,
startIndex = selection.startIndex,
endIndex = selection.endIndex,
viewportWidth = canvasSize.width,
viewportHeight = canvasSize.height
).map { it.toPdfPageBounds() }
.filter { it.right > it.left && it.bottom > it.top }
.mergePdfBoundsByLine()
.ifEmpty { selection.lineBounds }
logPdfSelection(
"highlight_create page=${pageIndex + 1} " +
"range=${selection.startIndex}..${selection.endIndex} " +
"chars=${selection.text.length} lines=${highlightBounds.size} " +
"text=\"${selection.text.logPreview()}\""
)
logPdfSelection(
"highlight_store page=${pageIndex + 1} " +
"range=${selection.startIndex}..${selection.endIndex} " +
"mode=dynamic_range"
)
highlightBounds.forEachIndexed { index, bounds ->
logPdfSelection(
"highlight_bound page=${pageIndex + 1} index=$index " +
"left=${bounds.left.formatLogFloat()} top=${bounds.top.formatLogFloat()} " +
"right=${bounds.right.formatLogFloat()} bottom=${bounds.bottom.formatLogFloat()}"
)
}
dispatchPdf(
SharedPdfReaderAction.AnnotationAdded(
SharedPdfAnnotation(
id = "highlight_${now}",
pageIndex = pageIndex,
kind = PdfAnnotationKind.HIGHLIGHT,
tool = PdfInkTool.HIGHLIGHTER,
bounds = highlightBounds.firstOrNull(),
boundsList = highlightBounds,
text = selection.text,
colorArgb = SharedPdfAndroidHighlightColors.nearestArgb(colorArgb),
rangeStartIndex = selection.startIndex,
rangeEndIndex = selection.endIndex,
createdAt = now
)
)
)
dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null))
}
fun clearSelection() {
textSelection = null
selectionStartIndex = null
selectionEndIndex = null
selectionStartHit = null
selectionEndHit = null
selectionMenuOffset = null
activeSelectionHandle = null
}
fun openPdfExternalLookup(action: ReaderExternalLookupAction, text: String) {
if (!featurePolicy.externalLookup) return
val normalizedText = text.trim()
if (normalizedText.isBlank()) return
openExternalUrl(externalLookupUrl(action, normalizedText.take(1800)))
}
fun currentPdfPageText(maxChars: Int = 8000): String {
return runCatching { document.textPageData(pageIndex).text.trim().take(maxChars) }.getOrDefault("")
}
fun pdfTtsChunksForPages(pageIndices: Iterable<Int>): List<ReaderTtsChunk> {
val chunks = mutableListOf<ReaderTtsChunk>()
pageIndices.forEach { targetPage ->
if (targetPage !in 0 until document.pageCount) return@forEach
val pageText = runCatching { document.textPageData(targetPage).text }.getOrDefault("")
ReaderTtsPlanner.chunksForText(
text = pageText,
pageIndex = targetPage,
chapterIndex = 0,
chapterTitle = "Page ${targetPage + 1}"
).forEach { chunk ->
chunks += chunk.copy(index = chunks.size)
}
}
return chunks
}
fun pdfTtsChunksForScope(readScope: ReaderTtsReadScope, startPageIndex: Int = pageIndex): List<ReaderTtsChunk> {
return when (readScope) {
ReaderTtsReadScope.PAGE -> pdfTtsChunksForPages(listOf(startPageIndex))
ReaderTtsReadScope.CHAPTER,
ReaderTtsReadScope.BOOK -> pdfTtsChunksForPages(startPageIndex until document.pageCount)
}
}
fun pdfTextBeforeCurrentPage(maxChars: Int = 24_000): String {
val indexedText = document.indexedSearchPages()
.filter { it.pageIndex <= pageIndex }
.joinToString("\n\n") { "Page ${it.pageIndex + 1}\n${it.text}" }
.trim()
return indexedText.ifBlank { currentPdfPageText(maxChars) }.takeLast(maxChars)
}
fun updatePdfAutoScroll(autoScroll: ReaderAutoScrollState) {
pdfExtrasState = pdfExtrasState.copy(autoScroll = autoScroll.sanitized())
}
fun pdfCloudTtsStoppedState(statusMessage: String? = null, errorMessage: String? = null) = ReaderCloudTtsState(
isAvailable = aiByokSettings.sanitized().isCloudTtsAvailable,
statusMessage = statusMessage,
errorMessage = errorMessage,
cacheSummary = currentPdfTtsCacheSummary()
)
fun runPdfAiAction(feature: ReaderAiFeature, text: String) {
val normalizedText = text.trim()
if (normalizedText.isBlank()) return
if (!aiByokSettings.sanitized().areReaderAiFeaturesAvailable) return
pdfExtrasState = pdfExtrasState.copy(
aiResult = ReaderAiResultState(
title = feature.displayName,
isLoading = true
)
)
pdfScope.launch {
val result = when (feature) {
ReaderAiFeature.DEFINE -> aiAdapter.define(normalizedText.take(2400), currentPdfPageText()).let { it.definition to it.error }
ReaderAiFeature.SUMMARIZE -> aiAdapter.summarize(normalizedText).let { it.summary to it.error }
ReaderAiFeature.RECAP -> aiAdapter.recap(normalizedText).let { it.recap to it.error }
}
pdfExtrasState = pdfExtrasState.copy(
aiResult = ReaderAiResultState(
title = feature.displayName,
text = result.first.orEmpty(),
errorMessage = result.second,
isLoading = false
)
)
}
}
fun stopPdfCloudTts() {
logDesktopTts("pdf_stop_requested")
pdfTtsJob?.cancel()
pdfTtsJob = null
pdfScope.launch {
ttsAdapter.stop()
pdfExtrasState = pdfExtrasState.copy(
cloudTts = pdfCloudTtsStoppedState(statusMessage = "Stopped")
)
}
}
fun pauseResumePdfCloudTts() {
val current = pdfExtrasState.cloudTts
if (current.isPaused) {
pdfScope.launch {
ttsAdapter.resume()
pdfExtrasState = pdfExtrasState.copy(
cloudTts = pdfExtrasState.cloudTts.copy(
isPaused = false,
isPlaying = true,
statusMessage = pdfExtrasState.cloudTts.progress.currentPositionLabel ?: "Reading"
)
)
}
} else if (current.isPlaying) {
pdfScope.launch {
ttsAdapter.pause()
pdfExtrasState = pdfExtrasState.copy(
cloudTts = pdfExtrasState.cloudTts.copy(
isPlaying = false,
isPaused = true,
statusMessage = "Paused"
)
)
}
}
}
fun clearPdfCloudTtsCache() {
ttsAdapter.clearBookCacheForSpeaker(document.title, aiByokSettings.sanitized().ttsSpeakerId)
pdfExtrasState = pdfExtrasState.copy(
cloudTts = pdfExtrasState.cloudTts.copy(
statusMessage = "Voice cache cleared",
cacheSummary = currentPdfTtsCacheSummary()
)
)
}
fun startPdfCloudTts(readScope: ReaderTtsReadScope) {
val settings = aiByokSettings.sanitized()
logDesktopTts(
"pdf_sequence_toggle scope=${readScope.name} startPage=${pageIndex + 1} " +
"isPlaying=${pdfExtrasState.cloudTts.isPlaying} isLoading=${pdfExtrasState.cloudTts.isLoading} " +
"keyPresent=${settings.geminiKey.isNotBlank()} ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" " +
"available=${ttsAdapter.isAvailable}"
)
if (pdfExtrasState.cloudTts.isPlaying || pdfExtrasState.cloudTts.isLoading || pdfExtrasState.cloudTts.isPaused) {
stopPdfCloudTts()
return
}
if (!ttsAdapter.isAvailable) {
logDesktopTts("pdf_sequence_blocked reason=adapter_unavailable")
pdfExtrasState = pdfExtrasState.copy(
cloudTts = ReaderCloudTtsState(
isAvailable = false,
errorMessage = "Add a Gemini key and select Gemini cloud TTS in AI keys and models.",
cacheSummary = currentPdfTtsCacheSummary()
)
)
return
}
val ttsSessionId = System.currentTimeMillis()
pdfExtrasState = pdfExtrasState.copy(
cloudTts = ReaderCloudTtsState(
isAvailable = true,
isLoading = true,
statusMessage = "Preparing ${readScope.label.lowercase()}",
cacheSummary = currentPdfTtsCacheSummary()
)
)
val noTextMessage = "There is no text here to read."
pdfTtsJob = pdfScope.launch {
var completedChunkCount = 0
runCatching {
val ttsChunks = withContext(Dispatchers.IO) {
pdfTtsChunksForScope(readScope, pageIndex)
.filter { it.text.isNotBlank() }
.withTtsReplacements(ttsReplacementPreferences, document.path)
}
if (ttsChunks.isEmpty()) {
logDesktopTts("pdf_sequence_ignored reason=blank_text scope=${readScope.name}")
throw IllegalStateException(noTextMessage)
}
val initialProgress = ReaderTtsProgress(
sessionId = ttsSessionId,
scope = readScope,
chunks = ttsChunks,
currentChunkIndex = -1
)
logDesktopTts("pdf_sequence_start scope=${readScope.name} chunks=${ttsChunks.size}")
ttsAdapter.speakChunks(document.title, readScope, ttsChunks) { index ->
if (!isActive) throw kotlinx.coroutines.CancellationException("PDF cloud TTS stopped")
val chunk = ttsChunks[index]
val progress = initialProgress.copy(currentChunkIndex = index)
if (chunk.pageIndex != pdfState.pageIndex) {
goToPage(chunk.pageIndex, recordJump = false)
}
pdfExtrasState = pdfExtrasState.copy(
cloudTts = ReaderCloudTtsState(
isAvailable = true,
isPlaying = true,
statusMessage = progress.currentPositionLabel ?: "Reading",
progress = progress,
cacheSummary = currentPdfTtsCacheSummary()
)
)
logDesktopTts(
"pdf_chunk_start scope=${readScope.name} index=${index + 1}/${ttsChunks.size} " +
"page=${chunk.pageIndex + 1} offsets=${chunk.startOffset}..${chunk.endOffset} chars=${chunk.text.length}"
)
completedChunkCount = index + 1
}
}.onFailure { error ->
logDesktopTts("pdf_sequence_failed error=\"${error.desktopTtsSummary()}\"")
if (error !is kotlinx.coroutines.CancellationException && error.message != noTextMessage) error.printStackTrace()
pdfExtrasState = if (error is kotlinx.coroutines.CancellationException) {
pdfExtrasState.copy(
cloudTts = pdfCloudTtsStoppedState(statusMessage = "Stopped")
)
} else {
pdfExtrasState.copy(
cloudTts = pdfCloudTtsStoppedState(errorMessage = error.message ?: "Cloud TTS failed.")
)
}
}.onSuccess {
logDesktopTts("pdf_sequence_success chunks=$completedChunkCount")
pdfExtrasState = pdfExtrasState.copy(
cloudTts = pdfCloudTtsStoppedState(statusMessage = "Finished")
)
}
}
}
fun togglePdfCloudTts(text: String) {
val normalizedText = text.trim()
val settings = aiByokSettings.sanitized()
logDesktopTts(
"pdf_toggle textChars=${normalizedText.length} isPlaying=${pdfExtrasState.cloudTts.isPlaying} " +
"isLoading=${pdfExtrasState.cloudTts.isLoading} keyPresent=${settings.geminiKey.isNotBlank()} " +
"ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" available=${ttsAdapter.isAvailable}"
)
if (pdfExtrasState.cloudTts.isPlaying || pdfExtrasState.cloudTts.isLoading || pdfExtrasState.cloudTts.isPaused) {
stopPdfCloudTts()
return
}
if (normalizedText.isBlank()) {
logDesktopTts("pdf_toggle_ignored reason=blank_text")
pdfExtrasState = pdfExtrasState.copy(
cloudTts = pdfExtrasState.cloudTts.copy(
errorMessage = "There is no text on this page to read.",
cacheSummary = currentPdfTtsCacheSummary()
)
)
return
}
if (!ttsAdapter.isAvailable) {
logDesktopTts("pdf_toggle_blocked reason=adapter_unavailable")
pdfExtrasState = pdfExtrasState.copy(
cloudTts = ReaderCloudTtsState(
isAvailable = false,
errorMessage = "Add a Gemini key and select Gemini cloud TTS in AI keys and models.",
cacheSummary = currentPdfTtsCacheSummary()
)
)
return
}
val selectionChunks = ReaderTtsPlanner.chunksForText(
text = normalizedText,
pageIndex = pageIndex,
chapterIndex = 0,
chapterTitle = "Page ${pageIndex + 1}"
).withTtsReplacements(ttsReplacementPreferences, document.path)
if (selectionChunks.isEmpty()) {
pdfExtrasState = pdfExtrasState.copy(
cloudTts = pdfExtrasState.cloudTts.copy(
errorMessage = "There is no text on this page to read.",
cacheSummary = currentPdfTtsCacheSummary()
)
)
return
}
pdfTtsJob = null
pdfExtrasState = pdfExtrasState.copy(
cloudTts = pdfExtrasState.cloudTts.copy(cacheSummary = currentPdfTtsCacheSummary())
)
pdfTtsJob = pdfScope.launch {
val initialProgress = ReaderTtsProgress(
sessionId = System.currentTimeMillis(),
scope = ReaderTtsReadScope.PAGE,
chunks = selectionChunks,
currentChunkIndex = -1
)
runCatching {
pdfExtrasState = pdfExtrasState.copy(
cloudTts = ReaderCloudTtsState(
isAvailable = true,
isLoading = true,
statusMessage = "Preparing selection",
progress = initialProgress,
cacheSummary = currentPdfTtsCacheSummary()
)
)
ttsAdapter.speakChunks(document.title, ReaderTtsReadScope.PAGE, selectionChunks) { index ->
val progress = initialProgress.copy(currentChunkIndex = index)
pdfExtrasState = pdfExtrasState.copy(
cloudTts = ReaderCloudTtsState(
isAvailable = true,
isPlaying = true,
statusMessage = progress.currentPositionLabel ?: "Reading",
progress = progress,
cacheSummary = currentPdfTtsCacheSummary()
)
)
}
}.onFailure { error ->
logDesktopTts("pdf_job_failed error=\"${error.desktopTtsSummary()}\"")
if (error !is kotlinx.coroutines.CancellationException) error.printStackTrace()
pdfExtrasState = pdfExtrasState.copy(
cloudTts = if (error is kotlinx.coroutines.CancellationException) {
pdfCloudTtsStoppedState(statusMessage = "Stopped")
} else {
pdfCloudTtsStoppedState(errorMessage = error.message ?: "Cloud TTS failed.")
}
)
}.onSuccess {
logDesktopTts("pdf_job_success")
pdfExtrasState = pdfExtrasState.copy(
cloudTts = pdfCloudTtsStoppedState(statusMessage = "Finished")
)
}
}
}
fun updateAnnotation(annotation: SharedPdfAnnotation) {
dispatchPdf(SharedPdfReaderAction.AnnotationUpdated(annotation))
}
fun deleteAnnotation(annotationId: String) {
dispatchPdf(SharedPdfReaderAction.AnnotationDeleted(annotationId))
}
fun goToAnnotation(annotation: SharedPdfAnnotation) {
dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null))
selectedEmbeddedAnnotationId = null
goToPage(annotation.pageIndex, recordJump = true)
}
fun selectAnnotation(annotation: SharedPdfAnnotation?) {
dispatchPdf(SharedPdfReaderAction.AnnotationSelected(annotation?.id))
annotation?.let { goToPage(it.pageIndex, recordJump = true) }
}
fun goToEmbeddedAnnotation(annotation: SharedPdfEmbeddedAnnotation) {
dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null))
selectedEmbeddedAnnotationId = null
goToPage(annotation.pageIndex, recordJump = true)
}
fun selectEmbeddedAnnotation(annotation: SharedPdfEmbeddedAnnotation?) {
selectedEmbeddedAnnotationId = annotation?.id
annotation?.let { goToPage(it.pageIndex, recordJump = true) }
}
fun goToSearchResult(targetIndex: Int) {
if (searchResults.isEmpty()) return
val normalizedIndex = when {
targetIndex < 0 -> searchResults.lastIndex
targetIndex > searchResults.lastIndex -> 0
else -> targetIndex
}
val targetPage = searchResults[normalizedIndex].pageIndex
jumpHistory = jumpHistory.record(
currentPageIndex = pdfState.pageIndex,
targetPageIndex = targetPage,
pageCount = document.pageCount
)
if (targetPage != pdfState.pageIndex) {
commitActiveTextDraft()
}
dispatchPdf(SharedPdfReaderAction.GoToSearchResult(targetIndex, searchResults))
if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) {
pdfScope.launch {
verticalListState.scrollToItem(targetPage)
}
}
}
LaunchedEffect(documentHandleId, document.pageCount) {
jumpHistory = jumpHistory.pruned(document.pageCount)
}
LaunchedEffect(documentHandleId, document.pageCount) {
snapshotFlow { pdfViewportSnapshot() }
.distinctUntilChanged()
.collectLatest { viewport ->
latestPdfViewport = viewport
delay(DesktopPdfViewportPersistDebounceMillis)
persistPdfViewport(viewport)
}
}
DisposableEffect(documentHandleId) {
onDispose {
persistPdfViewport()
}
}
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)
}
}
LaunchedEffect(
documentHandleId,
pendingInitialViewportRestore,
displayMode,
renderedPageIndex,
renderedPageScale
) {
val viewport = pendingInitialViewportRestore ?: return@LaunchedEffect
if (viewport.displayMode != displayMode) {
pendingInitialViewportRestore = null
return@LaunchedEffect
}
when (viewport.displayMode) {
PdfDisplayMode.PAGINATION -> {
if (renderedPageIndex != viewport.pageIndex) return@LaunchedEffect
withFrameNanos { }
pageHorizontalScrollState.scrollTo(viewport.horizontalScrollOffset)
pageVerticalScrollState.scrollTo(viewport.paginatedVerticalScrollOffset)
pendingInitialViewportRestore = null
latestPdfViewport = viewport
}
PdfDisplayMode.VERTICAL_SCROLL -> {
withFrameNanos { }
verticalListState.scrollToItem(
viewport.verticalFirstPageIndex,
viewport.verticalFirstPageScrollOffset
)
pageHorizontalScrollState.scrollTo(viewport.horizontalScrollOffset)
pendingInitialViewportRestore = null
latestPdfViewport = viewport
}
}
}
fun selectPdfPanMode() {
SharedPdfRichTextLog.d(
"desktop.tool.select tool=${PdfInkTool.NONE} richMode=$isRichTextMode page=${pdfState.pageIndex}"
)
deactivateRichTextMode()
commitActiveTextDraft()
clearPdfInteractionState()
if (pdfState.isTextSelectionMode) {
dispatchPdf(SharedPdfReaderAction.TextSelectionModeChanged(false))
}
dispatchPdf(SharedPdfReaderAction.ToolSelected(PdfInkTool.NONE))
}
LaunchedEffect(pdfExtrasState.autoScroll.sanitized(), pageIndex, canGoNext, displayMode) {
val autoScroll = pdfExtrasState.autoScroll.sanitized()
if (!autoScroll.enabled) return@LaunchedEffect
if (!canGoNext) {
updatePdfAutoScroll(autoScroll.copy(enabled = false))
return@LaunchedEffect
}
val delayMs = (180_000f / autoScroll.speed).roundToInt().coerceIn(1_200, 12_000)
delay(delayMs.toLong())
goToPage(pageIndex + 1)
}
LaunchedEffect(documentHandleId, displayMode, verticalListState) {
if (displayMode != PdfDisplayMode.VERTICAL_SCROLL) return@LaunchedEffect
snapshotFlow {
val layoutInfo = verticalListState.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
if (visibleItems.isEmpty()) {
verticalListState.firstVisibleItemIndex
} else {
mostVisiblePdfPageIndex(
visiblePages = visibleItems.map { item ->
PdfVisiblePageLayout(
pageIndex = item.index,
top = item.offset.toFloat(),
bottom = (item.offset + item.size).toFloat()
)
},
viewportTop = layoutInfo.viewportStartOffset.toFloat(),
viewportBottom = layoutInfo.viewportEndOffset.toFloat(),
fallbackPageIndex = verticalListState.firstVisibleItemIndex
)
}
}
.distinctUntilChanged()
.collect { visiblePage ->
if (visiblePage in 0 until document.pageCount && visiblePage != currentPdfPageIndex) {
goToPage(visiblePage, scrollVertical = false)
}
}
}
LaunchedEffect(documentHandleId, pageIndex, scale, displayMode) {
renderJob?.cancel()
if (displayMode != PdfDisplayMode.PAGINATION) {
isRendering = false
renderError = null
renderedPage = null
renderedPageIndex = null
renderedPageScale = null
return@LaunchedEffect
}
logPdfZoomPerf {
"render_effect page=${pageIndex + 1} scale=${scale.formatLogFloat()} " +
"existingPage=${renderedPageIndex?.let { it + 1 } ?: "none"} " +
"existingScale=${renderedPageScale?.formatLogFloat() ?: "none"} " +
"searchIndexing=$isSearchIndexing indexed=$indexedSearchPageCount/${document.pageCount} " +
"cacheKeys=${paginatedRenderCache.keys.sorted().map { it + 1 }}"
}
if (renderedPageIndex != pageIndex) {
paginatedRenderCache[pageIndex]?.let { cached ->
logPdfZoomPerf {
"cache_hit page=${pageIndex + 1} scale=${cached.scale.formatLogFloat()} " +
"bitmap=${cached.render.width}x${cached.render.height}"
}
renderedPage = cached.render
renderedPageIndex = pageIndex
renderedPageScale = cached.scale
renderError = null
isRendering = false
}
}
val hasPageRender = renderedPage != null && renderedPageIndex == pageIndex
if (!hasPageRender) {
logPdfZoomPerf { "cache_miss page=${pageIndex + 1}; showing spinner until first render" }
renderedPage = null
renderedPageIndex = null
renderedPageScale = null
isRendering = true
}
renderJob = launch {
val pageSize = document.pageSizes.getOrNull(pageIndex)
if (pageSize == null) {
renderedPage = null
renderedPageIndex = null
renderedPageScale = null
renderError = "Failed to render page."
isRendering = false
return@launch
}
val safeScale = zoomSpec.safeRenderScale(
pageSize.width,
pageSize.height, scale
)
val isOpeningRender = paginatedRenderCache.isEmpty() && !hasPageRender
val firstRenderScale = desktopPdfPaginationFirstRenderScale(
requestedScale = safeScale,
hasPageRender = hasPageRender,
isOpeningRender = isOpeningRender
)
logPdfZoomPerf {
"render_plan page=${pageIndex + 1} requestedScale=${scale.formatLogFloat()} " +
"safeScale=${safeScale.formatLogFloat()} firstScale=${firstRenderScale.formatLogFloat()} " +
"hasRender=$hasPageRender opening=$isOpeningRender"
}
suspend fun renderAt(renderScale: Float, delayMillis: Long, showSpinner: Boolean): Boolean {
logPdfZoomPerf {
"render_scheduled page=${pageIndex + 1} renderScale=${renderScale.formatLogFloat()} " +
"requestedScale=${scale.formatLogFloat()} delayMs=$delayMillis showSpinner=$showSpinner " +
"hasPageRender=$hasPageRender"
}
delay(delayMillis)
if (showSpinner) {
isRendering = true
}
renderError = null
val startedAt = System.currentTimeMillis()
val result = withContext(Dispatchers.IO) {
runCatching {
DesktopPdfium.renderPage(document, pageIndex, renderScale)
}
}
val elapsedMs = System.currentTimeMillis() - startedAt
if (currentPdfPageIndex != pageIndex || currentPdfScale != scale ||
currentPdfDisplayMode != PdfDisplayMode.PAGINATION
) {
logPdfZoomPerf {
"render_stale page=${pageIndex + 1} renderScale=${renderScale.formatLogFloat()} " +
"elapsedMs=$elapsedMs currentPage=${currentPdfPageIndex + 1} " +
"currentScale=${currentPdfScale.formatLogFloat()} mode=$currentPdfDisplayMode"
}
return false
}
result.getOrNull()?.let { render ->
cachePaginatedRender(pageIndex, renderScale, render)
renderedPage = render
renderedPageIndex = pageIndex
renderedPageScale = renderScale
}
renderError = result.exceptionOrNull()?.message
?: if (renderedPage == null || renderedPageIndex != pageIndex) "Failed to render page." else null
logPdfZoomPerf {
"render_end page=${pageIndex + 1} renderScale=${renderScale.formatLogFloat()} " +
"requestedScale=${scale.formatLogFloat()} elapsedMs=$elapsedMs success=${result.isSuccess} " +
"error=${result.exceptionOrNull()?.message?.logPreview() ?: "none"}"
}
renderedPage?.let { render ->
logPdfSelection(
"render page=${pageIndex + 1} " +
"requestedScale=${scale.formatLogFloat()} renderScale=${renderScale.formatLogFloat()} " +
"safeScale=${safeScale.formatLogFloat()} " +
"pageSize=${pageSize.width.formatLogFloat()}x${pageSize.height.formatLogFloat()} " +
"bitmap=${render.width}x${render.height} capped=${safeScale < zoomSpec.clamp(
scale
)}"
)
}
isRendering = false
return result.isSuccess && renderedPageIndex == pageIndex
}
suspend fun prefetchPage(pageToPrefetch: Int) {
if (pageToPrefetch !in 0 until document.pageCount) return
val cached = paginatedRenderCache[pageToPrefetch]
if (
cached != null &&
cached.scale >= DesktopPdfPaginationFastFirstRenderMaxScale - DesktopPdfRenderScaleTolerance
) {
logPdfZoomPerf {
"prefetch_skip_cached page=${pageToPrefetch + 1} scale=${cached.scale.formatLogFloat()}"
}
return
}
val prefetchPageSize = document.pageSizes.getOrNull(pageToPrefetch) ?: return
val prefetchScale = zoomSpec.safeRenderScale(
prefetchPageSize.width,
prefetchPageSize.height,
DesktopPdfPaginationFastFirstRenderMaxScale
)
logPdfZoomPerf {
"prefetch_start page=${pageToPrefetch + 1} scale=${prefetchScale.formatLogFloat()} " +
"current=${pageIndex + 1}"
}
val startedAt = System.currentTimeMillis()
val result = withContext(Dispatchers.IO) {
runCatching {
DesktopPdfium.renderPage(document, pageToPrefetch, prefetchScale)
}
}
val elapsedMs = System.currentTimeMillis() - startedAt
if (currentPdfPageIndex != pageIndex || currentPdfScale != scale ||
currentPdfDisplayMode != PdfDisplayMode.PAGINATION ||
pdfZoomPreview != null
) {
logPdfZoomPerf {
"prefetch_stale page=${pageToPrefetch + 1} elapsedMs=$elapsedMs " +
"currentPage=${currentPdfPageIndex + 1} currentScale=${currentPdfScale.formatLogFloat()} " +
"mode=$currentPdfDisplayMode preview=${pdfZoomPreview != null}"
}
return
}
result.getOrNull()?.let { render ->
cachePaginatedRender(pageToPrefetch, prefetchScale, render)
}
logPdfZoomPerf {
"prefetch_end page=${pageToPrefetch + 1} scale=${prefetchScale.formatLogFloat()} " +
"elapsedMs=$elapsedMs success=${result.isSuccess} " +
"error=${result.exceptionOrNull()?.message?.logPreview() ?: "none"}"
}
}
val existingScale = renderedPageScale
val needsFirstRender = !hasPageRender ||
existingScale == null ||
abs(existingScale - firstRenderScale) > DesktopPdfRenderScaleTolerance
if (needsFirstRender) {
renderAt(
renderScale = firstRenderScale,
delayMillis = if (hasPageRender) DesktopPdfZoomRenderDebounceMillis else 45L,
showSpinner = !hasPageRender
)
}
delay(DesktopPdfPaginationPrefetchDelayMillis)
if (currentPdfPageIndex == pageIndex && currentPdfScale == scale &&
currentPdfDisplayMode == PdfDisplayMode.PAGINATION &&
pdfZoomPreview == null
) {
prefetchPage(pageIndex + 1)
prefetchPage(pageIndex - 1)
}
}
}
val pdfWorkspaceModel = pdfReaderWorkspaceModel(
state = pdfState,
displayMode = displayMode,
hasContents = document.toc.isNotEmpty(),
hasBookmarks = bookmarks.isNotEmpty(),
hasAnnotations = sortedAnnotations.isNotEmpty(),
hasEmbeddedComments = sortedEmbeddedAnnotations.isNotEmpty(),
searchActive = isPdfSearchActive || searchQuery.isNotBlank(),
annotationEditing = activeTextDraft != null ||
selectedAnnotation != null ||
selectedTool != PdfInkTool.NONE ||
isTextSelectionMode,
richTextEditing = isRichTextMode,
loading = isRendering || isSearchIndexing,
errorMessage = renderError,
extrasState = pdfExtrasState,
aiAvailable = featurePolicy.aiAndCloud && aiByokSettings.sanitized().areReaderAiFeaturesAvailable,
cloudTtsAvailable = featurePolicy.aiAndCloud && aiByokSettings.sanitized().isCloudTtsAvailable,
externalLookupAvailable = featurePolicy.externalLookup
)
fun handlePdfReaderKeyEvent(event: androidx.compose.ui.input.key.KeyEvent): Boolean {
if (event.type != KeyEventType.KeyDown) return false
if (isFullscreen && event.key == Key.Escape) {
setPdfFullscreen(false)
return true
}
val isEditingTextAnnotation =
activeTextDraft != null ||
(selectedTool == PdfInkTool.TEXT && selectedAnnotation?.kind == PdfAnnotationKind.TEXT)
if ((isEditingTextAnnotation || isRichTextMode) && !event.isCtrlPressed) {
return false
}
fun scrollVertically(delta: Float): Boolean {
pdfScope.launch {
if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) {
verticalListState.scrollBy(delta)
} else {
pageVerticalScrollState.scrollBy(delta)
}
}
return true
}
return when {
event.key == Key.DirectionLeft -> {
goToPage(pageIndex - 1)
true
}
event.key == Key.DirectionRight -> {
goToPage(pageIndex + 1)
true
}
event.key == Key.DirectionUp -> scrollVertically(-96f)
event.key == Key.DirectionDown -> scrollVertically(96f)
event.key == Key.PageUp -> {
goToPage(pageIndex - 1)
true
}
event.key == Key.PageDown -> {
goToPage(pageIndex + 1)
true
}
event.key == Key.MoveHome -> {
goToPage(0)
true
}
event.key == Key.MoveEnd -> {
goToPage(document.pageCount - 1)
true
}
event.isCtrlPressed && event.key == Key.F -> {
dispatchPdf(SharedPdfReaderAction.SearchOpened)
true
}
event.isCtrlPressed && event.key == Key.Equals -> {
cancelPendingPdfZoomPreview()
dispatchPdf(SharedPdfReaderAction.ZoomBy(0.15f))
true
}
event.isCtrlPressed && event.key == Key.Minus -> {
cancelPendingPdfZoomPreview()
dispatchPdf(SharedPdfReaderAction.ZoomBy(-0.15f))
true
}
else -> false
}
}
fun handlePdfReaderAwtKeyEvent(event: AwtKeyEvent): Boolean {
if (event.id != AwtKeyEvent.KEY_PRESSED) return false
if (isFullscreen && event.keyCode == AwtKeyEvent.VK_ESCAPE) {
setPdfFullscreen(false)
return true
}
val isEditingTextAnnotation =
activeTextDraft != null ||
(selectedTool == PdfInkTool.TEXT && selectedAnnotation?.kind == PdfAnnotationKind.TEXT)
if ((isEditingTextAnnotation || isRichTextMode) && !event.isControlDown) {
return false
}
fun scrollVertically(delta: Float): Boolean {
pdfScope.launch {
if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) {
verticalListState.scrollBy(delta)
} else {
pageVerticalScrollState.scrollBy(delta)
}
}
return true
}
return when (event.keyCode) {
AwtKeyEvent.VK_LEFT -> {
goToPage(pageIndex - 1)
true
}
AwtKeyEvent.VK_RIGHT -> {
goToPage(pageIndex + 1)
true
}
AwtKeyEvent.VK_UP -> scrollVertically(-96f)
AwtKeyEvent.VK_DOWN -> scrollVertically(96f)
AwtKeyEvent.VK_PAGE_UP -> {
goToPage(pageIndex - 1)
true
}
AwtKeyEvent.VK_PAGE_DOWN -> {
goToPage(pageIndex + 1)
true
}
AwtKeyEvent.VK_HOME -> {
goToPage(0)
true
}
AwtKeyEvent.VK_END -> {
goToPage(document.pageCount - 1)
true
}
AwtKeyEvent.VK_F -> {
if (!event.isControlDown) return false
dispatchPdf(SharedPdfReaderAction.SearchOpened)
true
}
AwtKeyEvent.VK_EQUALS,
AwtKeyEvent.VK_PLUS,
AwtKeyEvent.VK_ADD -> {
if (!event.isControlDown) return false
cancelPendingPdfZoomPreview()
dispatchPdf(SharedPdfReaderAction.ZoomBy(0.15f))
true
}
AwtKeyEvent.VK_MINUS,
AwtKeyEvent.VK_SUBTRACT -> {
if (!event.isControlDown) return false
cancelPendingPdfZoomPreview()
dispatchPdf(SharedPdfReaderAction.ZoomBy(-0.15f))
true
}
else -> false
}
}
DesktopReaderFullscreenKeyEffect(
enabled = isFullscreen && !pdfPopupActive,
onKeyPressed = { event -> handlePdfReaderAwtKeyEvent(event) }
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PdfNavigationSidebar() {
val tabs = listOf("TOC", "Annotations", "Bookmarks", "Pages")
var selectedTabIndex by remember(documentHandleId) { mutableStateOf(0) }
val navigationScope = rememberCoroutineScope()
val pdfTocParentIndices = remember(document.toc) { desktopPdfTocParentIndices(document.toc) }
var expandedPdfTocEntryIndices by remember(documentHandleId, document.toc) {
mutableStateOf(pdfTocParentIndices)
}
Surface(
modifier = Modifier
.width(300.dp)
.fillMaxHeight(),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(8.dp),
tonalElevation = 2.dp
) {
Column(Modifier.fillMaxSize()) {
ScrollableTabRow(
selectedTabIndex = selectedTabIndex,
edgePadding = 0.dp
) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTabIndex == index,
onClick = { selectedTabIndex = index },
text = {
Text(
title,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
when (selectedTabIndex) {
0 -> {
if (document.toc.isEmpty()) {
DesktopPdfNavigationEmpty("No table of contents")
} else {
val tocListState = rememberLazyListState()
val visibleTocItems by remember(document.toc) {
derivedStateOf { desktopVisiblePdfTocEntries(document.toc, expandedPdfTocEntryIndices) }
}
val currentOriginalIndex = remember(document.toc, pageIndex) {
document.toc.indexOfLast { it.pageIndex <= pageIndex }
.takeIf { it >= 0 }
?: document.toc.indexOfFirst { it.pageIndex == pageIndex }.takeIf { it >= 0 }
}
fun locateCurrentTocEntry() {
val originalIndex = currentOriginalIndex ?: return
navigationScope.launch {
expandedPdfTocEntryIndices = expandedPdfTocEntryIndices +
desktopPdfTocAncestorIndices(document.toc, originalIndex)
repeat(4) {
val visibleIndex = visibleTocItems.indexOfFirst { it.first == originalIndex }
if (visibleIndex >= 0) {
tocListState.animateScrollToItem(visibleIndex)
return@launch
}
delay(30)
}
}
}
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
TextButton(onClick = { expandedPdfTocEntryIndices = pdfTocParentIndices }) {
Text("Expand all")
}
TextButton(onClick = { expandedPdfTocEntryIndices = emptySet() }) {
Text("Collapse all")
}
TextButton(onClick = ::locateCurrentTocEntry, enabled = currentOriginalIndex != null) {
Text("Locate")
}
}
HorizontalDivider()
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
LazyColumn(
state = tocListState,
modifier = Modifier
.fillMaxSize()
.sharedAcceleratedLazyWheelScroll(tocListState)
.padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
visibleTocItems,
key = { (index, entry) -> "nav_toc_${index}_${entry.pageIndex}_${entry.nestLevel}" }
) { (originalIndex, entry) ->
val nextItem = document.toc.getOrNull(originalIndex + 1)
val hasChildren = nextItem != null && nextItem.nestLevel > entry.nestLevel
val isExpanded = originalIndex in expandedPdfTocEntryIndices
DesktopPdfTocTreeItem(
entry = entry,
selected = originalIndex == currentOriginalIndex,
hasChildren = hasChildren,
isExpanded = isExpanded,
onToggleExpand = {
expandedPdfTocEntryIndices = if (isExpanded) {
expandedPdfTocEntryIndices - originalIndex
} else {
expandedPdfTocEntryIndices + originalIndex
}
},
onClick = { goToPage(entry.pageIndex, recordJump = true) }
)
}
}
SharedReaderVerticalScrollbar(
listState = tocListState,
modifier = Modifier.align(Alignment.CenterEnd)
)
}
}
}
}
1 -> {
if (sortedAnnotations.isEmpty() && sortedEmbeddedAnnotations.isEmpty()) {
DesktopPdfNavigationEmpty("No annotations yet")
} else {
val annotationsListState = rememberLazyListState()
var annotationMenuExpandedFor by remember { mutableStateOf<SharedPdfAnnotation?>(null) }
var embeddedAnnotationMenuExpandedFor by remember { mutableStateOf<SharedPdfEmbeddedAnnotation?>(null) }
var deleteAnnotationConfirmFor by remember { mutableStateOf<SharedPdfAnnotation?>(null) }
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = annotationsListState,
modifier = Modifier
.fillMaxSize()
.sharedAcceleratedLazyWheelScroll(annotationsListState)
.padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(sortedAnnotations, key = { "nav_annotation_${it.id}" }) { annotation ->
Surface(
color = if (annotation.id == selectedAnnotationId) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(6.dp),
modifier = Modifier.fillMaxWidth()
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(
modifier = Modifier
.weight(1f)
.clickable { goToAnnotation(annotation) }
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(3.dp)
) {
Text(annotation.desktopLabel(), fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("Page ${annotation.pageIndex + 1}", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall)
annotation.note?.takeIf { it.isNotBlank() }?.let { note ->
Text(note, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
Box {
IconButton(onClick = { annotationMenuExpandedFor = annotation }) {
Icon(Icons.Default.MoreVert, contentDescription = "Annotation options")
}
DropdownMenu(
expanded = annotationMenuExpandedFor == annotation,
onDismissRequest = { annotationMenuExpandedFor = null }
) {
DropdownMenuItem(
text = { Text(if (annotation.note.isNullOrBlank() && annotation.kind != PdfAnnotationKind.TEXT) "Add note" else "Edit") },
onClick = {
annotationMenuExpandedFor = null
selectAnnotation(annotation)
}
)
DropdownMenuItem(
text = { Text("Delete") },
onClick = {
annotationMenuExpandedFor = null
deleteAnnotationConfirmFor = annotation
}
)
}
}
}
}
}
items(sortedEmbeddedAnnotations, key = { "nav_embedded_${it.id}" }) { annotation ->
Surface(
color = if (annotation.id == selectedEmbeddedAnnotationId) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(6.dp),
modifier = Modifier.fillMaxWidth()
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(
modifier = Modifier
.weight(1f)
.clickable { goToEmbeddedAnnotation(annotation) }
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(3.dp)
) {
Text(annotation.author.ifBlank { "PDF comment" }, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("Page ${annotation.pageIndex + 1}", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall)
annotation.contents.takeIf { it.isNotBlank() }?.let { contents ->
Text(contents, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
Box {
IconButton(onClick = { embeddedAnnotationMenuExpandedFor = annotation }) {
Icon(Icons.Default.MoreVert, contentDescription = "Comment options")
}
DropdownMenu(
expanded = embeddedAnnotationMenuExpandedFor == annotation,
onDismissRequest = { embeddedAnnotationMenuExpandedFor = null }
) {
DropdownMenuItem(
text = { Text("Open comment") },
onClick = {
embeddedAnnotationMenuExpandedFor = null
selectEmbeddedAnnotation(annotation)
}
)
}
}
}
}
}
}
SharedReaderVerticalScrollbar(
listState = annotationsListState,
modifier = Modifier.align(Alignment.CenterEnd)
)
}
deleteAnnotationConfirmFor?.let { annotation ->
AlertDialog(
onDismissRequest = { deleteAnnotationConfirmFor = null },
title = { Text("Delete annotation?") },
text = { Text("This removes the annotation from this PDF.") },
confirmButton = {
TextButton(
onClick = {
deleteAnnotationConfirmFor = null
deleteAnnotation(annotation.id)
}
) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { deleteAnnotationConfirmFor = null }) {
Text("Cancel")
}
}
)
}
}
}
2 -> {
if (bookmarks.isEmpty()) {
DesktopPdfNavigationEmpty("No bookmarks yet")
} else {
val bookmarksListState = rememberLazyListState()
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = bookmarksListState,
modifier = Modifier
.fillMaxSize()
.sharedAcceleratedLazyWheelScroll(bookmarksListState)
.padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(bookmarks, key = { "nav_bookmark_${it.pageIndex}" }) { bookmark ->
Surface(
color = if (bookmark.pageIndex == pageIndex) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(6.dp),
modifier = Modifier.fillMaxWidth().clickable { goToPage(bookmark.pageIndex, recordJump = true) }
) {
Text(
bookmark.label.ifBlank { "Page ${bookmark.pageIndex + 1}" },
modifier = Modifier.padding(8.dp)
)
}
}
}
SharedReaderVerticalScrollbar(
listState = bookmarksListState,
modifier = Modifier.align(Alignment.CenterEnd)
)
}
}
}
3 -> {
val pageRows = remember(document.pageCount) { (0 until document.pageCount).chunked(3) }
val pagesListState = rememberLazyListState()
val currentRowIndex = pageIndex / 3
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
TextButton(
onClick = {
navigationScope.launch {
pagesListState.animateScrollToItem(currentRowIndex.coerceIn(0, pageRows.lastIndex.coerceAtLeast(0)))
}
}
) {
Text("Locate")
}
}
HorizontalDivider()
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
LazyColumn(
state = pagesListState,
modifier = Modifier
.fillMaxSize()
.sharedAcceleratedLazyWheelScroll(pagesListState)
.padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(pageRows, key = { row -> row.firstOrNull() ?: 0 }) { row ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
row.forEach { page ->
DesktopPdfThumbnailTile(
document = document,
pageIndex = page,
selected = page == pageIndex,
onClick = { goToPage(page, recordJump = true) },
modifier = Modifier.weight(1f)
)
}
repeat(3 - row.size) {
Spacer(Modifier.weight(1f))
}
}
}
}
SharedReaderVerticalScrollbar(
listState = pagesListState,
modifier = Modifier.align(Alignment.CenterEnd)
)
}
}
}
}
}
}
}
@Composable
fun PdfBottomChrome() {
val chromeBackground = MaterialTheme.colorScheme.surface
val chromeContent = MaterialTheme.colorScheme.onSurface
val sliderActive = MaterialTheme.colorScheme.primary
val sliderInactive = MaterialTheme.colorScheme.surfaceVariant
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(6.dp),
color = chromeBackground,
contentColor = chromeContent,
tonalElevation = 0.dp,
shadowElevation = 1.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Column(modifier = Modifier.fillMaxWidth()) {
DesktopPdfJumpHistoryControls(
visible = !isPdfSearchActive,
backPage = jumpHistory.backPage,
forwardPage = jumpHistory.forwardPage,
onBack = ::goBackInJumpHistory,
onForward = ::goForwardInJumpHistory,
onClear = { jumpHistory = jumpHistory.clear() }
)
if (!isPdfSearchActive && jumpHistory.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
) {
IconButton(onClick = { goToPage(pageIndex - 1) }, enabled = canGoPrevious) {
Icon(
Icons.AutoMirrored.Filled.NavigateBefore,
contentDescription = "Previous page",
tint = chromeContent.copy(alpha = if (canGoPrevious) 0.78f else 0.32f)
)
}
Text(
"Page ${pageIndex + 1} of ${document.pageCount}",
style = MaterialTheme.typography.labelSmall,
color = chromeContent.copy(alpha = 0.72f)
)
if (document.pageCount > 1) {
ReaderMinimalSlider(
value = pageIndex.toFloat(),
onValueChange = ::updatePdfPageScrub,
onValueChangeFinished = ::finishPdfPageScrub,
valueRange = 0f..(document.pageCount - 1).toFloat(),
activeColor = sliderActive,
inactiveColor = sliderInactive,
thumbColor = sliderActive,
modifier = Modifier.weight(1f)
)
} else {
Spacer(Modifier.weight(1f))
}
Text(
"${progressPercent.toInt()}%",
style = MaterialTheme.typography.labelSmall,
color = chromeContent.copy(alpha = 0.72f)
)
IconButton(onClick = { goToPage(pageIndex + 1) }, enabled = canGoNext) {
Icon(
Icons.AutoMirrored.Filled.NavigateNext,
contentDescription = "Next page",
tint = chromeContent.copy(alpha = if (canGoNext) 0.78f else 0.32f)
)
}
}
}
}
}
ReaderWorkspaceShell(
model = pdfWorkspaceModel,
title = document.title,
subtitle = "${document.formatLabel} - Page ${pageIndex + 1} of ${document.pageCount}",
progressLabel = "${progressPercent.toInt()}%",
onReturnToLibrary = onReturnToLibrary?.let { returnToLibrary ->
{
persistPdfViewport()
returnToLibrary()
}
},
isFullscreen = isFullscreen,
onFullscreenChange = ::setPdfFullscreen,
isBookmarked = bookmarks.any { it.pageIndex == pageIndex },
onToggleBookmark = { toggleBookmark(pageIndex) },
onSearchAction = { dispatchPdf(SharedPdfReaderAction.SearchOpened) },
topSearchBar = if (isPdfSearchActive) {
{
DesktopPdfSearchTopBar(
query = searchQuery,
showResultsPanel = showPdfSearchResultsPanel,
onQueryChange = { dispatchPdf(SharedPdfReaderAction.SearchChanged(it)) },
onClose = { dispatchPdf(SharedPdfReaderAction.SearchClosed) },
onToggleResults = { dispatchPdf(SharedPdfReaderAction.SearchResultsPanelToggled) }
)
}
} else {
null
},
modifier = Modifier
.focusRequester(pdfReaderFocusRequester)
.onPreviewKeyEvent(::handlePdfReaderKeyEvent)
.focusable(),
leftSidebar = { _ -> PdfNavigationSidebar() },
rightInspector = {
var selectedPdfInspectorTab by remember(documentHandleId) { mutableStateOf(DesktopPdfInspectorTab.VIEW) }
val viewInspectorListState = rememberLazyListState()
val markupInspectorListState = rememberLazyListState()
val assistInspectorListState = rememberLazyListState()
val pdfInspectorListState = when (selectedPdfInspectorTab) {
DesktopPdfInspectorTab.VIEW -> viewInspectorListState
DesktopPdfInspectorTab.MARKUP -> markupInspectorListState
DesktopPdfInspectorTab.ASSIST -> assistInspectorListState
}
Surface(
modifier = Modifier
.width(340.dp)
.fillMaxHeight(),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(8.dp)
) {
Column(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.padding(start = 12.dp, top = 12.dp, end = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("PDF tools", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
ScrollableTabRow(
selectedTabIndex = selectedPdfInspectorTab.ordinal,
edgePadding = 0.dp,
modifier = Modifier.fillMaxWidth()
) {
DesktopPdfInspectorTab.values().forEach { tab ->
Tab(
selected = selectedPdfInspectorTab == tab,
onClick = { selectedPdfInspectorTab = tab },
text = {
Text(
tab.title,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
}
HorizontalDivider()
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
LazyColumn(
state = pdfInspectorListState,
modifier = Modifier
.fillMaxSize()
.sharedAcceleratedLazyWheelScroll(pdfInspectorListState, multiplier = 2.8f)
.padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 24.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
when (selectedPdfInspectorTab) {
DesktopPdfInspectorTab.VIEW -> {
item {
DesktopPdfInspectorSection("Reading") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
FilterChip(
selected = displayMode == PdfDisplayMode.PAGINATION,
onClick = {
commitActiveTextDraft()
dispatchPdf(SharedPdfReaderAction.DisplayModeChanged(PdfDisplayMode.PAGINATION))
},
label = { Text("Page") }
)
FilterChip(
selected = displayMode == PdfDisplayMode.VERTICAL_SCROLL,
onClick = {
commitActiveTextDraft()
dispatchPdf(SharedPdfReaderAction.DisplayModeChanged(PdfDisplayMode.VERTICAL_SCROLL))
},
label = { Text("Scroll") }
)
}
}
}
item {
DesktopPdfInspectorSection("Position") {
Text("Page ${pageIndex + 1} of ${document.pageCount}", color = MaterialTheme.colorScheme.onSurfaceVariant)
if (document.pageCount > 1) {
ReaderMinimalSlider(
value = pageIndex.toFloat(),
onValueChange = ::updatePdfPageScrub,
onValueChangeFinished = ::finishPdfPageScrub,
valueRange = 0f..(document.pageCount - 1).toFloat()
)
}
}
}
item {
DesktopPdfInspectorSection("Appearance") {
SharedReaderThemeControls(
settings = pdfReaderSettings,
builtInThemes = BuiltInPdfReaderThemes,
customTextureIds = customTextureIds,
onImportTexture = onImportTexture,
onSettingsChange = ::updatePdfReaderSettings
)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text("Visual options", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)
DesktopPdfVisualOptionSwitch(
title = "Remove gap between pages",
description = "Applies to vertical reading mode.",
checked = !pdfReaderSettings.pdfVerticalPageGapVisible,
onCheckedChange = { removeGap ->
updatePdfReaderSettings(
pdfReaderSettings.copy(pdfVerticalPageGapVisible = !removeGap)
)
}
)
DesktopPdfVisualOptionSwitch(
title = "Hide page number overlay",
description = "Removes the small page count label from each page.",
checked = !pdfReaderSettings.pdfPageNumberOverlayVisible,
onCheckedChange = { hideOverlay ->
updatePdfReaderSettings(
pdfReaderSettings.copy(pdfPageNumberOverlayVisible = !hideOverlay)
)
}
)
}
}
item {
DesktopPdfInspectorSection("Zoom") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = {
cancelPendingPdfZoomPreview()
dispatchPdf(SharedPdfReaderAction.ZoomBy(-0.15f))
}) {
Icon(Icons.Default.ZoomOut, contentDescription = "Zoom out")
}
Text("${(zoomControlScale * 100).toInt()}%", modifier = Modifier.weight(1f), textAlign = TextAlign.Center)
IconButton(onClick = {
cancelPendingPdfZoomPreview()
dispatchPdf(SharedPdfReaderAction.ZoomBy(0.15f))
}) {
Icon(Icons.Default.ZoomIn, contentDescription = "Zoom in")
}
}
Slider(
value = zoomControlScale,
onValueChange = {
cancelPendingPdfZoomPreview()
dispatchPdf(SharedPdfReaderAction.ZoomChanged(it))
},
valueRange = zoomSpec.min..zoomSpec.max
)
}
}
}
DesktopPdfInspectorTab.MARKUP -> {
item {
DesktopPdfInspectorSection("Interaction") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
FilterChip(
selected = !isTextSelectionMode && selectedTool == PdfInkTool.NONE && !isRichTextMode,
onClick = ::selectPdfPanMode,
label = { Text("Pan") }
)
FilterChip(
selected = isTextSelectionMode,
onClick = {
val enabled = !isTextSelectionMode
if (enabled) {
deactivateRichTextMode()
commitActiveTextDraft()
}
dispatchPdf(SharedPdfReaderAction.TextSelectionModeChanged(enabled))
if (!enabled) {
clearPdfInteractionState()
}
},
label = { Text("Select text") }
)
FilterChip(
selected = isRichTextMode,
onClick = {
if (isRichTextMode) {
deactivateRichTextMode()
} else {
activateRichTextMode()
}
},
label = { Text("Document text") }
)
}
}
}
item {
DesktopPdfInspectorSection("Annotation tools") {
SharedPdfAnnotationToolDock(
selectedTool = selectedTool,
selectedColor = selectedColor,
strokeWidth = strokeWidth,
tools = DesktopPdfAnnotationTools,
highlighterPalette = pdfHighlighterColors,
onToolSelected = ::selectPdfAnnotationTool,
onColorSelected = { dispatchPdf(SharedPdfReaderAction.ColorSelected(it)) },
onStrokeWidthChange = { dispatchPdf(SharedPdfReaderAction.StrokeWidthChanged(it)) },
onUndo = {
dispatchPdf(SharedPdfReaderAction.UndoLastAnnotationOnPage(pageIndex))
},
onClearPage = {
dispatchPdf(SharedPdfReaderAction.ClearPageAnnotations(pageIndex))
},
isHighlighterSnapEnabled = isHighlighterSnapEnabled,
onHighlighterSnapChange = { isHighlighterSnapEnabled = it }
)
}
}
item {
DesktopPdfInspectorSection("Highlighter palette") {
SharedPdfHighlighterPaletteEditor(
palette = pdfHighlighterPalette,
onPaletteChange = ::updatePdfHighlighterPalette
)
}
}
if (isRichTextMode || selectedTool == PdfInkTool.TEXT) {
item {
DesktopPdfInspectorSection("Text style") {
SharedPdfTextAnnotationDock(
style = if (isRichTextMode) {
richTextController.currentSharedPdfTextStyleConfig()
} else {
effectiveTextStyleConfig
},
onStyleChange = { style ->
if (isRichTextMode) {
richTextController.updateCurrentSharedPdfTextStyle(style)
} else {
updateTextStyleConfig(style)
}
}
)
}
}
}
}
DesktopPdfInspectorTab.ASSIST -> {
item {
DesktopPdfExtrasPanel(
pageText = currentPdfPageText(),
recapText = pdfTextBeforeCurrentPage(),
extrasState = pdfExtrasState,
aiByokSettings = aiByokSettings,
externalLookupAvailable = featurePolicy.externalLookup,
cloudTtsFeatureAvailable = featurePolicy.aiAndCloud,
onExternalLookup = ::openPdfExternalLookup,
onAiAction = ::runPdfAiAction,
onCloudTtsStart = ::startPdfCloudTts,
onCloudTtsPauseResume = ::pauseResumePdfCloudTts,
onCloudTtsStop = ::stopPdfCloudTts,
onCloudTtsClearCache = ::clearPdfCloudTtsCache,
onAutoScrollChange = ::updatePdfAutoScroll,
ttsReplacementPreferences = ttsReplacementPreferences,
ttsReplacementBookId = document.path,
onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange
)
}
}
}
}
SharedReaderVerticalScrollbar(
listState = pdfInspectorListState,
modifier = Modifier.align(Alignment.CenterEnd)
)
}
}
}
},
bottomBar = { PdfBottomChrome() },
fullscreenBottomBar = {
DesktopPdfFullscreenBottomChrome(
pageIndex = pageIndex,
pageCount = document.pageCount,
showJumpHistory = !isPdfSearchActive,
jumpBackPage = jumpHistory.backPage,
jumpForwardPage = jumpHistory.forwardPage,
onPrevious = { goToPage(pageIndex - 1) },
onNext = { goToPage(pageIndex + 1) },
onPageScrub = ::updatePdfPageScrub,
onPageScrubFinished = ::finishPdfPageScrub,
onJumpBack = ::goBackInJumpHistory,
onJumpForward = ::goForwardInJumpHistory,
onClearJumpHistory = { jumpHistory = jumpHistory.clear() }
)
}
) {
SharedPdfRichTextHiddenInput(
controller = richTextController,
enabled = isRichTextMode,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 16.dp, bottom = 24.dp)
.zIndex(10f)
)
DesktopPdfSearchOverlay(
isSearchActive = isPdfSearchActive,
showResultsPanel = showPdfSearchResultsPanel,
query = searchQuery,
results = searchResults,
activeSearchIndex = activeSearchIndex,
highlightMode = searchHighlightMode,
isIndexing = isSearchIndexing,
indexedPageCount = indexedSearchPageCount,
pageCount = document.pageCount,
onResultClick = { index ->
goToSearchResult(index)
dispatchPdf(SharedPdfReaderAction.SearchResultsPanelToggled)
},
onShowResults = { dispatchPdf(SharedPdfReaderAction.SearchResultsPanelToggled) },
onPrevious = { goToSearchResult(activeSearchIndex - 1) },
onNext = { goToSearchResult(activeSearchIndex + 1) },
onToggleHighlightMode = { dispatchPdf(SharedPdfReaderAction.SearchHighlightModeToggled) }
)
if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) {
val verticalPageGap = pdfVerticalPageGapDp(
isPageGapVisible = pdfReaderSettings.pdfVerticalPageGapVisible,
defaultGap = DesktopDefaultPdfVerticalPageGap
)
val verticalViewportBackground = desktopPdfVerticalViewportBackgroundColor(
pageBackgroundColor = pdfThemeStyle.pageBackgroundColor,
gapBackgroundColor = MaterialTheme.colorScheme.surfaceVariant,
isPageGapVisible = pdfReaderSettings.pdfVerticalPageGapVisible
)
Box(
modifier = Modifier
.fillMaxSize()
.background(verticalViewportBackground, RoundedCornerShape(8.dp))
.onGloballyPositioned { coordinates ->
pdfZoomViewportRootOffset = coordinates.positionInRoot()
}
.desktopPdfZoomGestures(
currentZoom = scale,
zoomSpec = zoomSpec,
onZoomChanged = ::previewAnchoredPdfZoom
)
) {
LazyColumn(
state = verticalListState,
modifier = Modifier
.fillMaxSize()
.horizontalScroll(pageHorizontalScrollState)
.padding(horizontal = 24.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(verticalPageGap),
horizontalAlignment = Alignment.CenterHorizontally
) {
items((0 until document.pageCount).toList(), key = { it }) { verticalPageIndex ->
DesktopVerticalPdfPage(
document = document,
pageIndex = verticalPageIndex,
scale = scale,
zoomSpec = zoomSpec,
annotations = annotations,
searchResults = searchResults,
activeSearchIndex = activeSearchIndex,
searchHighlightMode = searchHighlightMode,
activeTtsChunk = activePdfTtsChunk,
searchQuery = searchQuery,
isTextSelectionMode = isTextSelectionMode,
selectedAnnotationId = selectedAnnotationId,
selectedEmbeddedAnnotationId = selectedEmbeddedAnnotationId,
selectedTool = selectedTool,
selectedColor = selectedColor,
highlighterPalette = pdfHighlighterColors,
strokeWidth = strokeWidth,
isHighlighterSnapEnabled = isHighlighterSnapEnabled,
activeTextDraft = activeTextDraft,
richTextController = richTextController,
isRichTextMode = isRichTextMode,
readerAiFeaturesAvailable = aiByokSettings.sanitized().areReaderAiFeaturesAvailable,
cloudTtsAvailable = aiByokSettings.sanitized().isCloudTtsAvailable,
externalLookupAvailable = featurePolicy.externalLookup,
themeStyle = pdfThemeStyle,
shouldRender = verticalPageIndex in verticalRenderWindow,
zoomPreview = pdfZoomPreview?.takeIf {
it.displayMode == PdfDisplayMode.VERTICAL_SCROLL
},
zoomViewportRootOffset = pdfZoomViewportRootOffset,
showPageNumberOverlay = pdfReaderSettings.pdfPageNumberOverlayVisible,
onSelectPage = {
goToPage(
target = it,
scrollVertical = false,
saveRichTextBeforePageChange = !isRichTextMode
)
},
onCopySelection = ::copySelection,
onHighlightSelection = ::highlightSelection,
onExternalSearchSelection = { openPdfExternalLookup(ReaderExternalLookupAction.SEARCH, it.text) },
onHighlighterPaletteChange = ::updatePdfHighlighterPalette,
onDefineSelection = { runPdfAiAction(ReaderAiFeature.DEFINE, it.text) },
onSpeakSelection = { togglePdfCloudTts(it.text) },
onEmbeddedAnnotationSelected = ::selectEmbeddedAnnotation,
onAnnotationSelected = ::selectAnnotation,
onLinkActivated = ::activatePdfLink,
onAnnotationAdded = { dispatchPdf(SharedPdfReaderAction.AnnotationAdded(it)) },
onAnnotationUpdated = ::updateAnnotation,
onAnnotationsChanged = { dispatchPdf(SharedPdfReaderAction.AnnotationsChanged(it)) },
onTextAnnotationSelected = ::selectTextAnnotation,
onTextDraftStarted = ::startActiveTextDraft,
onTextDraftChanged = ::updateActiveTextDraft,
onTextDraftBoundsChanged = ::updateActiveTextDraftBounds,
onPan = { delta ->
pdfScope.launch {
pageHorizontalScrollState.scrollBy(-delta.x)
verticalListState.scrollBy(-delta.y)
}
},
onPagePositioned = { page, offset ->
verticalPageRootOffsets[page] = offset
}
)
}
}
SharedPdfVerticalScrollbar(
listState = verticalListState,
pageCount = document.pageCount,
currentPage = pageIndex,
isDarkMode = verticalViewportBackground.luminance() < 0.5f,
modifier = Modifier.align(Alignment.CenterEnd)
)
DesktopPdfPageScrubOverlay(
pageIndex = pageScrubPreview,
pageCount = document.pageCount
)
}
} else {
Box(
modifier = Modifier
.fillMaxSize()
.background(pdfThemeStyle.viewerBackgroundColor, RoundedCornerShape(8.dp))
.onGloballyPositioned { coordinates ->
pdfZoomViewportRootOffset = coordinates.positionInRoot()
}
.desktopPdfZoomGestures(
currentZoom = scale,
zoomSpec = zoomSpec,
onZoomChanged = ::previewAnchoredPdfZoom
)
.horizontalScroll(pageHorizontalScrollState)
.verticalScroll(pageVerticalScrollState)
.padding(24.dp),
contentAlignment = Alignment.TopCenter
) {
val currentPageRender = renderedPage.takeIf { renderedPageIndex == pageIndex }
when {
currentPageRender != null -> {
val pageSize = document.pageSizes.getOrNull(pageIndex)
if (pageSize == null) {
Text("Failed to render page.", color = MaterialTheme.colorScheme.error)
return@Box
}
val pageDisplayScale = zoomSpec.clamp(scale)
val pageWidthDp = with(density) { (pageSize.width * pageDisplayScale).toDp() }
val pageHeightDp = with(density) { (pageSize.height * pageDisplayScale).toDp() }
val pageRenderScale = currentPageRender.width / pageSize.width
val pageAnnotations = remember(annotations, pageIndex, pageCanvasSize) {
annotations
.filter { it.pageIndex == pageIndex }
.flatMap { annotation ->
annotation.toRenderablePdfAnnotations(document, pageIndex, pageCanvasSize)
}
}
val selectedTextAnnotationForPage = selectedAnnotation?.takeIf {
selectedTool == PdfInkTool.TEXT &&
!isTextSelectionMode &&
it.kind == PdfAnnotationKind.TEXT &&
it.pageIndex == pageIndex
}
val visiblePageAnnotations = remember(pageAnnotations, selectedTextAnnotationForPage?.id) {
pageAnnotations.filterNot {
it.kind == PdfAnnotationKind.TEXT && it.id == selectedTextAnnotationForPage?.id
}
}
val pageEmbeddedAnnotations = remember(document.embeddedAnnotations, pageIndex) {
document.embeddedAnnotations.filter { it.pageIndex == pageIndex }
}
val searchHighlightBounds: List<PdfPageBounds> = remember(
document.path,
searchResults,
pageIndex,
activeSearchIndex,
searchHighlightMode,
pageCanvasSize,
searchQuery
) {
val queryLength = searchQuery.trim().length
if (queryLength <= 0 || pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) {
emptyList()
} else {
SharedPdfSearchEngine.highlightsForPage(
results = searchResults,
pageIndex = pageIndex,
activeResultIndex = activeSearchIndex,
mode = searchHighlightMode
).flatMap { result ->
val matchLength = result.matchLength.takeIf { it > 0 } ?: queryLength
DesktopPdfium.textRectsForRange(
document = document,
pageIndex = pageIndex,
startIndex = result.matchIndex,
endIndex = result.matchIndex + matchLength - 1,
viewportWidth = pageCanvasSize.width,
viewportHeight = pageCanvasSize.height
).map { it.toPdfPageBounds() }
.filter { it.right > it.left && it.bottom > it.top }
.mergePdfBoundsByLine()
}
}
}
val ttsHighlightBounds: List<PdfPageBounds> = remember(
document.path,
activePdfTtsChunk,
pageIndex,
pageCanvasSize
) {
val chunk = activePdfTtsChunk?.takeIf { it.pageIndex == pageIndex }
if (chunk == null || pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0 || chunk.endOffset <= chunk.startOffset) {
emptyList()
} else {
DesktopPdfium.textRectsForRange(
document = document,
pageIndex = pageIndex,
startIndex = chunk.startOffset,
endIndex = chunk.endOffset - 1,
viewportWidth = pageCanvasSize.width,
viewportHeight = pageCanvasSize.height
).map { it.toPdfPageBounds() }
.filter { it.right > it.left && it.bottom > it.top }
.mergePdfBoundsByLine()
}
}
val pageZoomPreview = pdfZoomPreview?.takeIf {
it.displayMode == PdfDisplayMode.PAGINATION &&
it.pageIndex == pageIndex
}
Box(
modifier = Modifier
.size(pageWidthDp, pageHeightDp)
.onGloballyPositioned { coordinates ->
paginatedPageRootOffset = coordinates.positionInRoot()
}
.onSizeChanged { size ->
if (pageCanvasSize != size) {
logPdfSelection(
"layout page=${pageIndex + 1} " +
"canvas=${size.formatLogSize()} bitmap=${currentPageRender.width}x${currentPageRender.height} " +
"requestedScale=${scale.formatLogFloat()} displayScale=${pageDisplayScale.formatLogFloat()} " +
"renderScale=${pageRenderScale.formatLogFloat()}"
)
}
pageCanvasSize = size
}
.desktopPdfZoomPreviewLayer(
preview = pageZoomPreview,
currentZoom = scale,
viewportRootOffset = pdfZoomViewportRootOffset,
pageRootOffset = paginatedPageRootOffset,
pageCanvasSize = pageCanvasSize
)
.background(pdfThemeStyle.pageBackgroundColor, RoundedCornerShape(2.dp))
.pointerInput(pageIndex, pageCanvasSize, isTextSelectionMode, selectedTool, isRichTextMode) {
if (isRichTextMode) return@pointerInput
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
val point = event.changes.firstOrNull()?.position ?: continue
if (event.type == PointerEventType.Press && event.buttons.isPrimaryPressed) {
val highlightHit = if (selectedTool != PdfInkTool.TEXT && selectedTool != PdfInkTool.ERASER) {
currentPdfAnnotations.asReversed().firstOrNull {
it.isDesktopTextSelectionHighlight &&
it.pageIndex == pageIndex &&
it.sharedPdfHitTest(point, pageCanvasSize)
}
} else {
null
}
if (highlightHit != null) {
selectAnnotation(highlightHit)
clearPdfInteractionState()
event.changes.forEach { it.consume() }
continue
}
if (selectedTool != PdfInkTool.TEXT) {
val linkTarget = document.linkAt(pageIndex, point, pageCanvasSize)
if (linkTarget != null) {
logPdfLink(
"tap_hit mode=page page=${pageIndex + 1} " +
"x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " +
"textSelection=$isTextSelectionMode target=${linkTarget.formatLogTarget()}"
)
activatePdfLink(linkTarget)
event.changes.forEach { it.consume() }
continue
}
}
val embeddedHit = pageEmbeddedAnnotations.findLast {
it.sharedPdfEmbeddedHitTest(point, pageCanvasSize)
}
if (embeddedHit != null) {
selectEmbeddedAnnotation(embeddedHit)
clearPdfInteractionState()
event.changes.forEach { it.consume() }
} else if (
currentTextSelection != null &&
selectionMenuOffset == null
) {
selectionMenuOffset = null
textSelection = null
selectionStartHit = null
selectionEndHit = null
}
} else if (event.type == PointerEventType.Press && event.buttons.isSecondaryPressed) {
val selection = currentTextSelection
if (selection != null) {
selectionMenuOffset = point
logPdfSelection(
"menu_open page=${pageIndex + 1} " +
"x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " +
"range=${selection.startIndex}..${selection.endIndex} " +
"chars=${selection.text.length}"
)
event.changes.forEach { it.consume() }
}
}
}
}
}
.pointerInput(pageIndex, pageCanvasSize, isTextSelectionMode, isRichTextMode) {
if (isRichTextMode || !isTextSelectionMode) return@pointerInput
detectTapGestures(
onLongPress = { point ->
val selection = document.wordSelectionAt(pageIndex, point, pageCanvasSize)
if (selection != null) {
selectionStartIndex = null
selectionEndIndex = null
selectionStartHit = null
selectionEndHit = null
activeSelectionHandle = null
textSelection = selection
selectionMenuOffset = selection.menuAnchor(pageCanvasSize, point)
logPdfSelection(
"long_press page=${pageIndex + 1} " +
"x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " +
"range=${selection.startIndex}..${selection.endIndex} " +
"chars=${selection.text.length} " +
"text=\"${selection.text.logPreview()}\""
)
}
}
)
}
.pointerInput(pageIndex, selectedTool, isTextSelectionMode, isRichTextMode) {
if (isRichTextMode || isTextSelectionMode || selectedTool != PdfInkTool.NONE) return@pointerInput
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
if (!currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture
val pointerId = down.id
var dragStarted = false
var dragDistance = 0f
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == pointerId }
?: return@awaitEachGesture
if (change.changedToUp()) {
return@awaitEachGesture
}
if (!change.positionChanged()) continue
val delta = change.positionChange()
if (!dragStarted) {
dragDistance += delta.getDistance()
if (dragDistance <= viewConfiguration.touchSlop) {
continue
}
dragStarted = true
change.consume()
continue
}
pdfScope.launch {
pageHorizontalScrollState.scrollBy(-delta.x)
pageVerticalScrollState.scrollBy(-delta.y)
}
change.consume()
}
}
}
.pointerInput(
pageIndex,
isTextSelectionMode,
selectedTool,
selectedColor,
strokeWidth,
isHighlighterSnapEnabled,
textStyleConfig,
activeTextDraft?.id,
isRichTextMode,
pageCanvasSize, currentPageRender.width,
currentPageRender.height
) {
if (isRichTextMode) return@pointerInput
if (isTextSelectionMode) {
var latestSelectionDragPoint: Offset? = null
var lastSelectionPreviewAt = 0L
detectDragGestures(
onDragStart = { start ->
latestSelectionDragPoint = start
lastSelectionPreviewAt = 0L
selectionMenuOffset = null
val existingSelection = textSelection
val handle = existingSelection?.handleAt(start, pageCanvasSize)
activeSelectionHandle = handle
val hit = document.charHitAt(pageIndex, start, pageCanvasSize)
if (handle != null && existingSelection != null) {
selectionStartHit = null
selectionStartIndex = when (handle) {
DesktopPdfSelectionHandle.START -> existingSelection.endIndex
DesktopPdfSelectionHandle.END -> existingSelection.startIndex
}
selectionEndHit = hit
selectionEndIndex = hit?.index ?: when (handle) {
DesktopPdfSelectionHandle.START -> existingSelection.startIndex
DesktopPdfSelectionHandle.END -> existingSelection.endIndex
}
} else {
selectionStartHit = hit
selectionStartIndex = hit?.index
selectionEndHit = null
selectionEndIndex = null
textSelection = null
}
logPdfSelection(
"drag_start page=${pageIndex + 1} " +
"canvas=${pageCanvasSize.formatLogSize()} bitmap=${currentPageRender.width}x${currentPageRender.height} " +
"requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " +
"handle=${handle?.name ?: "none"} " +
hit.formatLogHit("start")
)
},
onDrag = { change, _ ->
latestSelectionDragPoint = change.position
val now = System.currentTimeMillis()
if (lastSelectionPreviewAt == 0L ||
now - lastSelectionPreviewAt >= DesktopPdfSelectionPreviewThrottleMillis
) {
lastSelectionPreviewAt = now
val startIndex = selectionStartIndex
val hit = document.charHitAt(pageIndex, change.position, pageCanvasSize)
selectionEndHit = hit
val endIndex = hit?.index
val previousEndIndex = selectionEndIndex
selectionEndIndex = endIndex
if (endIndex != previousEndIndex || textSelection == null) {
textSelection = if (startIndex != null && endIndex != null) {
document.selectionPreviewBetweenIndexes(
pageIndex = pageIndex,
startIndex = startIndex,
endIndex = endIndex,
canvasSize = pageCanvasSize
)
} else {
null
}
}
}
change.consume()
},
onDragEnd = {
val finalHit = latestSelectionDragPoint
?.let { document.charHitAt(pageIndex, it, pageCanvasSize) }
?: selectionEndHit
if (finalHit != null) {
selectionEndHit = finalHit
selectionEndIndex = finalHit.index
}
val startIndex = selectionStartIndex
val endIndex = selectionEndIndex
val selection = if (startIndex != null && endIndex != null) {
document.selectionBetweenIndexes(
pageIndex = pageIndex,
startIndex = startIndex,
endIndex = endIndex,
canvasSize = pageCanvasSize,
useNativeBounds = true
)
} else {
textSelection?.takeIf { it.text.isNotBlank() }
}
textSelection = selection
selectionMenuOffset = selection?.menuAnchor(
pageCanvasSize,
finalHit?.point ?: selectionEndHit?.point ?: selectionStartHit?.point
)
logPdfSelection(
"drag_end page=${pageIndex + 1} " +
"canvas=${pageCanvasSize.formatLogSize()} bitmap=${currentPageRender.width}x${currentPageRender.height} " +
"requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " +
selectionStartHit.formatLogHit("start") + " " +
selectionEndHit.formatLogHit("end") + " " +
"range=${selection?.startIndex}..${selection?.endIndex} " +
"chars=${selection?.text?.length ?: 0} " +
"lines=${selection?.lineBounds?.size ?: 0} " +
"text=\"${selection?.text.orEmpty().logPreview()}\""
)
selectionStartIndex = null
selectionEndIndex = null
selectionStartHit = null
selectionEndHit = null
activeSelectionHandle = null
latestSelectionDragPoint = null
lastSelectionPreviewAt = 0L
},
onDragCancel = {
logPdfSelection(
"drag_cancel page=${pageIndex + 1} " +
"canvas=${pageCanvasSize.formatLogSize()} bitmap=${currentPageRender.width}x${currentPageRender.height} " +
"requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " +
selectionStartHit.formatLogHit("start") + " " +
selectionEndHit.formatLogHit("end")
)
selectionStartIndex = null
selectionEndIndex = null
selectionStartHit = null
selectionEndHit = null
activeSelectionHandle = null
latestSelectionDragPoint = null
lastSelectionPreviewAt = 0L
}
)
} else if (selectedTool == PdfInkTool.TEXT) {
detectTapGestures(
onTap = { start ->
when {
activeTextDraftContains(pageIndex, start, pageCanvasSize) -> Unit
else -> {
val textHit = currentPdfAnnotations.textAnnotationHitAt(
pageIndex = pageIndex,
point = start,
canvasSize = pageCanvasSize
)
if (textHit != null) {
selectTextAnnotation(textHit)
} else {
startActiveTextDraft(pageIndex, start, pageCanvasSize)
}
}
}
}
)
} else if (selectedTool != PdfInkTool.NONE) {
var eraserPreviousPoint: Offset? = null
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
if (!currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture
val start = down.position
if (selectedTool == PdfInkTool.ERASER) {
eraserPosition = start
val annotationSnapshot = currentPdfAnnotations
val updatedAnnotations = annotationSnapshot.filterNot {
it.pageIndex == pageIndex && it.sharedPdfHitTest(
point = start,
size = pageCanvasSize,
eraserStrokeWidth = strokeWidth
)
}
if (updatedAnnotations.size != annotationSnapshot.size) {
dispatchPdf(SharedPdfReaderAction.AnnotationsChanged(updatedAnnotations))
}
eraserPreviousPoint = start
} else {
activeStroke = listOf(start.toSharedPdfPoint(pageCanvasSize, System.currentTimeMillis()))
}
val pointerId = down.id
var dragStarted = false
while (true) {
val event = awaitPointerEvent()
if (event.changes.size > 1) {
eraserPreviousPoint = null
eraserPosition = null
activeStroke = emptyList()
return@awaitEachGesture
}
val change = event.changes.firstOrNull { it.id == pointerId }
?: run {
eraserPreviousPoint = null
eraserPosition = null
activeStroke = emptyList()
return@awaitEachGesture
}
if (change.changedToUp()) {
change.consume()
if (selectedTool != PdfInkTool.ERASER && activeStroke.isNotEmpty()) {
dispatchPdf(
SharedPdfReaderAction.AnnotationAdded(
SharedPdfAnnotation(
id = "ink_${System.currentTimeMillis()}",
pageIndex = pageIndex,
kind = PdfAnnotationKind.INK,
tool = selectedTool,
points = activeStroke,
colorArgb = selectedColor,
strokeWidth = strokeWidth,
createdAt = System.currentTimeMillis()
)
)
)
}
eraserPreviousPoint = null
eraserPosition = null
activeStroke = emptyList()
return@awaitEachGesture
}
if (!change.positionChanged()) continue
val distance = (change.position - start).getDistance()
if (selectedTool != PdfInkTool.ERASER && !dragStarted && distance <= viewConfiguration.touchSlop) continue
dragStarted = true
if (selectedTool == PdfInkTool.ERASER) {
val point = change.position
eraserPosition = point
val previousPoint = eraserPreviousPoint
val annotationSnapshot = currentPdfAnnotations
val updatedAnnotations = annotationSnapshot.filterNot {
it.pageIndex == pageIndex && it.sharedPdfHitTest(
point = point,
size = pageCanvasSize,
lastPoint = previousPoint,
eraserStrokeWidth = strokeWidth
)
}
if (updatedAnnotations.size != annotationSnapshot.size) {
dispatchPdf(SharedPdfReaderAction.AnnotationsChanged(updatedAnnotations))
}
eraserPreviousPoint = point
} else {
activeStroke = activeStroke.withDesktopPdfDragPoint(
point = change.position,
canvasSize = pageCanvasSize,
tool = selectedTool,
snapHighlighter = isHighlighterSnapEnabled,
timestamp = System.currentTimeMillis()
)
}
change.consume()
}
}
}
}
) {
DesktopPdfThemedPageImage(
bitmap = currentPageRender.image,
contentDescription = "PDF page ${pageIndex + 1}",
themeStyle = pdfThemeStyle,
modifier = Modifier.fillMaxSize()
)
SharedPdfRichTextLayer(
pageIndex = pageIndex,
controller = richTextController,
pageWidth = pageCanvasSize.width.toFloat(),
pageHeight = pageCanvasSize.height.toFloat(),
isTextEditingEnabled = isRichTextMode,
onPageTapped = {}
)
PdfSearchHighlightOverlay(
bounds = searchHighlightBounds,
canvasSize = pageCanvasSize,
color = when (searchHighlightMode) {
SearchHighlightMode.ALL -> Color(0x55FDD835)
SearchHighlightMode.FOCUSED -> Color(0x88FF9800)
}
)
PdfSearchHighlightOverlay(
bounds = ttsHighlightBounds,
canvasSize = pageCanvasSize,
color = Color(0x887DD3FC)
)
PdfTextSelectionOverlay(
selection = textSelection,
canvasSize = pageCanvasSize
)
SharedPdfAnnotationOverlay(
annotations = visiblePageAnnotations,
activeStroke = activeStroke,
canvasSize = pageCanvasSize,
activeTool = selectedTool,
activeStrokeColorArgb = selectedColor,
activeStrokeWidth = strokeWidth,
selectedAnnotationId = selectedAnnotationId,
eraserPosition = eraserPosition,
showEraserIndicator = selectedTool == PdfInkTool.ERASER,
eraserStrokeWidth = strokeWidth
)
PdfTextSelectionHandles(
selection = textSelection,
canvasSize = pageCanvasSize,
activeHandle = activeSelectionHandle
)
SharedPdfInlineTextEditorOverlay(
draft = activeTextDraft?.takeIf { it.pageIndex == pageIndex },
canvasSize = pageCanvasSize,
onTextChange = { updateActiveTextDraft(it, pageCanvasSize) },
onBoundsChange = ::updateActiveTextDraftBounds
)
selectedTextAnnotationForPage?.let { annotation ->
val bounds = annotation.bounds
if (bounds != null && activeTextDraft == null) {
SharedPdfTextBoxEditorOverlay(
id = annotation.id,
text = annotation.text,
style = annotation.sharedPdfTextStyle(),
bounds = bounds,
canvasSize = pageCanvasSize,
onTextChange = { text ->
updateAnnotation(annotation.copy(text = text))
},
onBoundsChange = { nextBounds ->
updateAnnotation(annotation.copy(bounds = nextBounds))
}
)
}
}
SharedPdfEmbeddedAnnotationOverlay(
annotations = pageEmbeddedAnnotations,
canvasSize = pageCanvasSize,
selectedAnnotationId = selectedEmbeddedAnnotationId
)
if (pdfReaderSettings.pdfPageNumberOverlayVisible) {
SharedPdfPageNumberOverlay(
pageIndex = pageIndex,
pageCount = document.pageCount
)
}
if (textSelection != null && selectionMenuOffset != null) {
Box(
modifier = Modifier
.matchParentSize()
.pointerInput(pageIndex, selectionMenuOffset) {
detectTapGestures {
selectionMenuOffset = null
textSelection = null
selectionStartHit = null
selectionEndHit = null
}
}
)
}
PdfSelectionMenu(
selection = textSelection,
menuOffset = selectionMenuOffset,
canvasSize = pageCanvasSize,
highlighterPalette = pdfHighlighterColors,
onHighlighterPaletteChange = ::updatePdfHighlighterPalette,
onCopy = {
textSelection?.let(::copySelection)
clearSelection()
},
onHighlight = { colorArgb ->
textSelection?.let { selection ->
highlightSelection(pageIndex, selection, pageCanvasSize, colorArgb)
}
clearSelection()
},
onSearch = {
textSelection?.let { openPdfExternalLookup(ReaderExternalLookupAction.SEARCH, it.text) }
clearSelection()
},
onDefine = {
textSelection?.let { runPdfAiAction(ReaderAiFeature.DEFINE, it.text) }
clearSelection()
},
onSpeak = {
textSelection?.let { togglePdfCloudTts(it.text) }
clearSelection()
},
showDefine = aiByokSettings.sanitized().areReaderAiFeaturesAvailable,
showSpeak = aiByokSettings.sanitized().isCloudTtsAvailable,
showSearch = featurePolicy.externalLookup,
onClear = ::clearSelection
)
}
}
isRendering -> CircularProgressIndicator(modifier = Modifier.padding(48.dp))
renderError != null -> Text(renderError ?: "Failed to render page.", color = MaterialTheme.colorScheme.error)
}
DesktopPdfPageScrubOverlay(
pageIndex = pageScrubPreview,
pageCount = document.pageCount
)
}
}
AnimatedVisibility(
visible = showPdfZoomIndicator,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 16.dp, end = 16.dp),
enter = fadeIn(),
exit = fadeOut()
) {
DesktopPdfZoomPercentageIndicator(
percentage = (zoomControlScale * 100).roundToInt(),
onResetZoomClick = {
cancelPendingPdfZoomPreview()
dispatchPdf(SharedPdfReaderAction.ZoomChanged(1f))
}
)
}
when {
selectedTextHighlight != null -> {
DesktopReaderBottomSheet(
title = selectedTextHighlight.desktopSheetTitle(),
onDismiss = { dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) }
) {
DesktopPdfAnnotationEditor(
annotation = selectedTextHighlight,
onUpdate = ::updateAnnotation,
onDelete = { deleteAnnotation(selectedTextHighlight.id) },
onClose = { dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) },
onCopy = {
clipboardManager.setText(AnnotatedString(selectedTextHighlight.text))
dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null))
},
showSearch = featurePolicy.externalLookup,
highlighterPalette = pdfHighlighterColors,
onHighlighterPaletteChange = ::updatePdfHighlighterPalette,
onSearch = {
openPdfExternalLookup(ReaderExternalLookupAction.SEARCH, selectedTextHighlight.text)
dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null))
}
)
}
}
selectedEmbeddedAnnotation != null -> {
DesktopReaderBottomSheet(
title = "PDF comment",
onDismiss = { selectedEmbeddedAnnotationId = null }
) {
DesktopPdfEmbeddedAnnotationPanel(
annotation = selectedEmbeddedAnnotation,
onCopy = { clipboardManager.setText(AnnotatedString(selectedEmbeddedAnnotation.threadText())) },
onClose = { selectedEmbeddedAnnotationId = null }
)
}
}
pdfExtrasState.aiResult.hasContent -> {
DesktopReaderAiResultSheet(
result = pdfExtrasState.aiResult,
onDismiss = { pdfExtrasState = pdfExtrasState.copy(aiResult = ReaderAiResultState()) }
)
}
}
}
}
@Composable
private fun DesktopVerticalPdfPage(
document: DesktopPdfDocument,
pageIndex: Int,
scale: Float,
zoomSpec: PdfZoomSpec,
annotations: List<SharedPdfAnnotation>,
searchResults: List<SharedPdfSearchResult>,
activeSearchIndex: Int,
searchHighlightMode: SearchHighlightMode,
activeTtsChunk: ReaderTtsChunk?,
searchQuery: String,
isTextSelectionMode: Boolean,
selectedAnnotationId: String?,
selectedEmbeddedAnnotationId: String?,
selectedTool: PdfInkTool,
selectedColor: Int,
highlighterPalette: List<Int>,
strokeWidth: Float,
isHighlighterSnapEnabled: Boolean,
activeTextDraft: SharedPdfTextDraft?,
richTextController: SharedPdfRichTextController,
isRichTextMode: Boolean,
readerAiFeaturesAvailable: Boolean,
cloudTtsAvailable: Boolean,
externalLookupAvailable: Boolean,
themeStyle: DesktopPdfThemeStyle,
shouldRender: Boolean,
zoomPreview: DesktopPdfZoomPreview?,
zoomViewportRootOffset: Offset,
showPageNumberOverlay: Boolean = true,
onSelectPage: (Int) -> Unit,
onCopySelection: (DesktopPdfTextSelection) -> Unit,
onHighlightSelection: (Int, DesktopPdfTextSelection, IntSize, Int) -> Unit,
onExternalSearchSelection: (DesktopPdfTextSelection) -> Unit,
onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit,
onDefineSelection: (DesktopPdfTextSelection) -> Unit,
onSpeakSelection: (DesktopPdfTextSelection) -> Unit,
onEmbeddedAnnotationSelected: (SharedPdfEmbeddedAnnotation) -> Unit,
onAnnotationSelected: (SharedPdfAnnotation?) -> Unit,
onLinkActivated: (DesktopPdfLinkTarget) -> Unit,
onAnnotationAdded: (SharedPdfAnnotation) -> Unit,
onAnnotationUpdated: (SharedPdfAnnotation) -> Unit,
onAnnotationsChanged: (List<SharedPdfAnnotation>) -> Unit,
onTextAnnotationSelected: (SharedPdfAnnotation) -> Unit,
onTextDraftStarted: (Int, Offset, IntSize) -> Unit,
onTextDraftChanged: (String, IntSize) -> Unit,
onTextDraftBoundsChanged: (PdfPageBounds) -> Unit,
onPan: (Offset) -> Unit,
onPagePositioned: (Int, Offset) -> Unit
) {
val documentHandleId = document.handleId
val density = LocalDensity.current
var renderedPage by remember(documentHandleId, pageIndex) { mutableStateOf<DesktopPdfPageRender?>(null) }
var renderError by remember(documentHandleId, pageIndex) { mutableStateOf<String?>(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<Int?>(null) }
var selectionEndIndex by remember(documentHandleId, pageIndex) { mutableStateOf<Int?>(null) }
var selectionStartHit by remember(documentHandleId, pageIndex) { mutableStateOf<DesktopPdfCharHit?>(null) }
var selectionEndHit by remember(documentHandleId, pageIndex) { mutableStateOf<DesktopPdfCharHit?>(null) }
var textSelection by remember(documentHandleId, pageIndex) { mutableStateOf<DesktopPdfTextSelection?>(null) }
var selectionMenuOffset by remember(documentHandleId, pageIndex) { mutableStateOf<Offset?>(null) }
var activeSelectionHandle by remember(documentHandleId, pageIndex) { mutableStateOf<DesktopPdfSelectionHandle?>(null) }
var activeStroke by remember(documentHandleId, pageIndex, selectedTool) { mutableStateOf<List<PdfPagePoint>>(emptyList()) }
var eraserPosition by remember(documentHandleId, pageIndex, selectedTool) { mutableStateOf<Offset?>(null) }
val currentTextSelection by rememberUpdatedState(textSelection)
val currentAnnotations by rememberUpdatedState(annotations)
fun clearSelection() {
selectionStartIndex = null
selectionEndIndex = null
selectionStartHit = null
selectionEndHit = null
textSelection = null
selectionMenuOffset = null
activeSelectionHandle = null
}
fun clearInteractionState() {
clearSelection()
activeStroke = emptyList()
eraserPosition = null
}
LaunchedEffect(documentHandleId, pageIndex, scale, shouldRender) {
if (!shouldRender) {
renderedPage = null
renderError = null
isRendering = false
clearInteractionState()
return@LaunchedEffect
}
val hasPageRender = renderedPage != null
if (!hasPageRender) {
isRendering = true
}
renderError = null
val pageSize = document.pageSizes.getOrNull(pageIndex)
if (pageSize == null) {
renderedPage = null
renderError = "Failed to render page."
isRendering = false
return@LaunchedEffect
}
delay(if (hasPageRender) DesktopPdfZoomRenderDebounceMillis else 45L)
isRendering = true
val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale)
val result = withContext(Dispatchers.IO) {
runCatching { DesktopPdfium.renderPage(document, pageIndex, safeScale) }
}
result.getOrNull()?.let { renderedPage = it }
renderError = result.exceptionOrNull()?.message
?: if (renderedPage == null) "Failed to render page." else null
isRendering = false
}
LaunchedEffect(isTextSelectionMode) {
if (!isTextSelectionMode) {
clearSelection()
} else {
activeStroke = emptyList()
eraserPosition = null
}
}
LaunchedEffect(selectedTool) {
activeStroke = emptyList()
eraserPosition = null
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
val pageSize = document.pageSizes.getOrNull(pageIndex)
val placeholderScale = zoomSpec.clamp(scale)
val placeholderWidthDp = with(density) { ((pageSize?.width ?: 612f) * placeholderScale).toDp() }
val placeholderHeightDp = with(density) { ((pageSize?.height ?: 792f) * placeholderScale).toDp() }
val renderedPageWidth = renderedPage?.width ?: 0
val renderedPageHeight = renderedPage?.height ?: 0
val pageRenderScale = if (pageSize != null && pageSize.width > 0f && renderedPageWidth > 0) {
renderedPageWidth / pageSize.width
} else {
placeholderScale
}
val pageEmbeddedAnnotations = remember(document.embeddedAnnotations, pageIndex) {
document.embeddedAnnotations.filter { it.pageIndex == pageIndex }
}
Box(
modifier = Modifier
.size(placeholderWidthDp, placeholderHeightDp)
.onGloballyPositioned { coordinates ->
val rootOffset = coordinates.positionInRoot()
pageRootOffset = rootOffset
onPagePositioned(pageIndex, rootOffset)
}
.onSizeChanged { pageCanvasSize = it }
.desktopPdfDocumentZoomPreviewLayer(
preview = zoomPreview,
currentZoom = scale,
viewportRootOffset = zoomViewportRootOffset,
pageRootOffset = pageRootOffset
)
.background(themeStyle.pageBackgroundColor, RoundedCornerShape(2.dp))
.pointerInput(pageIndex, pageCanvasSize, isTextSelectionMode, selectedTool, isRichTextMode) {
if (isRichTextMode) return@pointerInput
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
val point = event.changes.firstOrNull()?.position ?: continue
if (event.type == PointerEventType.Press && event.buttons.isPrimaryPressed) {
val highlightHit = if (selectedTool != PdfInkTool.TEXT && selectedTool != PdfInkTool.ERASER) {
currentAnnotations.asReversed().firstOrNull {
it.isDesktopTextSelectionHighlight &&
it.pageIndex == pageIndex &&
it.sharedPdfHitTest(point, pageCanvasSize)
}
} else {
null
}
if (highlightHit != null) {
onSelectPage(pageIndex)
onAnnotationSelected(highlightHit)
clearInteractionState()
event.changes.forEach { it.consume() }
continue
}
if (selectedTool != PdfInkTool.TEXT) {
val linkTarget = document.linkAt(pageIndex, point, pageCanvasSize)
if (linkTarget != null) {
logPdfLink(
"tap_hit mode=vertical page=${pageIndex + 1} " +
"x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " +
"textSelection=$isTextSelectionMode target=${linkTarget.formatLogTarget()}"
)
onSelectPage(pageIndex)
onLinkActivated(linkTarget)
clearInteractionState()
event.changes.forEach { it.consume() }
continue
}
}
val embeddedHit = pageEmbeddedAnnotations.findLast {
it.sharedPdfEmbeddedHitTest(point, pageCanvasSize)
}
if (embeddedHit != null) {
onSelectPage(pageIndex)
onEmbeddedAnnotationSelected(embeddedHit)
clearInteractionState()
event.changes.forEach { it.consume() }
} else if (
currentTextSelection != null &&
selectionMenuOffset == null
) {
clearSelection()
}
} else if (event.type == PointerEventType.Press && event.buttons.isSecondaryPressed) {
val selection = currentTextSelection
if (selection != null) {
onSelectPage(pageIndex)
selectionMenuOffset = point
logPdfSelection(
"menu_open page=${pageIndex + 1} " +
"x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " +
"range=${selection.startIndex}..${selection.endIndex} " +
"chars=${selection.text.length}"
)
event.changes.forEach { it.consume() }
}
}
}
}
}
.pointerInput(pageIndex, pageCanvasSize, isTextSelectionMode, isRichTextMode) {
if (isRichTextMode || !isTextSelectionMode) return@pointerInput
detectTapGestures(
onLongPress = { point ->
val selection = document.wordSelectionAt(pageIndex, point, pageCanvasSize)
if (selection != null) {
onSelectPage(pageIndex)
selectionStartIndex = null
selectionEndIndex = null
selectionStartHit = null
selectionEndHit = null
activeSelectionHandle = null
textSelection = selection
selectionMenuOffset = selection.menuAnchor(pageCanvasSize, point)
logPdfSelection(
"long_press page=${pageIndex + 1} " +
"x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " +
"range=${selection.startIndex}..${selection.endIndex} " +
"chars=${selection.text.length} " +
"text=\"${selection.text.logPreview()}\""
)
}
}
)
}
.pointerInput(pageIndex, selectedTool, isTextSelectionMode, isRichTextMode) {
if (isRichTextMode || isTextSelectionMode || selectedTool != PdfInkTool.NONE) return@pointerInput
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
if (!currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture
val pointerId = down.id
var dragStarted = false
var dragDistance = 0f
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == pointerId }
?: return@awaitEachGesture
if (change.changedToUp()) {
return@awaitEachGesture
}
if (!change.positionChanged()) continue
val delta = change.positionChange()
if (!dragStarted) {
dragDistance += delta.getDistance()
if (dragDistance <= viewConfiguration.touchSlop) {
continue
}
dragStarted = true
change.consume()
continue
}
onPan(delta)
change.consume()
}
}
}
.pointerInput(
pageIndex,
isTextSelectionMode,
selectedTool,
selectedColor,
strokeWidth,
isHighlighterSnapEnabled,
activeTextDraft?.id,
isRichTextMode,
pageCanvasSize,
renderedPageWidth,
renderedPageHeight
) {
if (renderedPageWidth > 0 && renderedPageHeight > 0) {
if (isRichTextMode) return@pointerInput
if (isTextSelectionMode) {
var latestSelectionDragPoint: Offset? = null
var lastSelectionPreviewAt = 0L
detectDragGestures(
onDragStart = { start ->
latestSelectionDragPoint = start
lastSelectionPreviewAt = 0L
onSelectPage(pageIndex)
activeStroke = emptyList()
selectionMenuOffset = null
val existingSelection = textSelection
val handle = existingSelection?.handleAt(start, pageCanvasSize)
activeSelectionHandle = handle
val hit = document.charHitAt(pageIndex, start, pageCanvasSize)
if (handle != null && existingSelection != null) {
selectionStartHit = null
selectionStartIndex = when (handle) {
DesktopPdfSelectionHandle.START -> existingSelection.endIndex
DesktopPdfSelectionHandle.END -> existingSelection.startIndex
}
selectionEndHit = hit
selectionEndIndex = hit?.index ?: when (handle) {
DesktopPdfSelectionHandle.START -> existingSelection.startIndex
DesktopPdfSelectionHandle.END -> existingSelection.endIndex
}
} else {
selectionStartHit = hit
selectionStartIndex = hit?.index
selectionEndHit = null
selectionEndIndex = null
textSelection = null
}
logPdfSelection(
"drag_start page=${pageIndex + 1} " +
"canvas=${pageCanvasSize.formatLogSize()} bitmap=${renderedPageWidth}x$renderedPageHeight " +
"requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " +
"handle=${handle?.name ?: "none"} " +
hit.formatLogHit("start")
)
},
onDrag = { change, _ ->
latestSelectionDragPoint = change.position
val now = System.currentTimeMillis()
if (lastSelectionPreviewAt == 0L ||
now - lastSelectionPreviewAt >= DesktopPdfSelectionPreviewThrottleMillis
) {
lastSelectionPreviewAt = now
val startIndex = selectionStartIndex
val hit = document.charHitAt(pageIndex, change.position, pageCanvasSize)
selectionEndHit = hit
val endIndex = hit?.index
val previousEndIndex = selectionEndIndex
selectionEndIndex = endIndex
if (endIndex != previousEndIndex || textSelection == null) {
textSelection = if (startIndex != null && endIndex != null) {
document.selectionPreviewBetweenIndexes(
pageIndex = pageIndex,
startIndex = startIndex,
endIndex = endIndex,
canvasSize = pageCanvasSize
)
} else {
null
}
}
}
change.consume()
},
onDragEnd = {
val finalHit = latestSelectionDragPoint
?.let { document.charHitAt(pageIndex, it, pageCanvasSize) }
?: selectionEndHit
if (finalHit != null) {
selectionEndHit = finalHit
selectionEndIndex = finalHit.index
}
val startIndex = selectionStartIndex
val endIndex = selectionEndIndex
val selection = if (startIndex != null && endIndex != null) {
document.selectionBetweenIndexes(
pageIndex = pageIndex,
startIndex = startIndex,
endIndex = endIndex,
canvasSize = pageCanvasSize,
useNativeBounds = true
)
} else {
textSelection?.takeIf { it.text.isNotBlank() }
}
textSelection = selection
selectionMenuOffset = selection?.menuAnchor(
pageCanvasSize,
finalHit?.point ?: selectionEndHit?.point ?: selectionStartHit?.point
)
logPdfSelection(
"drag_end page=${pageIndex + 1} " +
"canvas=${pageCanvasSize.formatLogSize()} bitmap=${renderedPageWidth}x$renderedPageHeight " +
"requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " +
selectionStartHit.formatLogHit("start") + " " +
selectionEndHit.formatLogHit("end") + " " +
"range=${selection?.startIndex}..${selection?.endIndex} " +
"chars=${selection?.text?.length ?: 0} " +
"lines=${selection?.lineBounds?.size ?: 0} " +
"text=\"${selection?.text.orEmpty().logPreview()}\""
)
selectionStartIndex = null
selectionEndIndex = null
selectionStartHit = null
selectionEndHit = null
activeSelectionHandle = null
latestSelectionDragPoint = null
lastSelectionPreviewAt = 0L
},
onDragCancel = {
logPdfSelection(
"drag_cancel page=${pageIndex + 1} " +
"canvas=${pageCanvasSize.formatLogSize()} bitmap=${renderedPageWidth}x$renderedPageHeight " +
"requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " +
selectionStartHit.formatLogHit("start") + " " +
selectionEndHit.formatLogHit("end")
)
selectionStartIndex = null
selectionEndIndex = null
selectionStartHit = null
selectionEndHit = null
activeSelectionHandle = null
latestSelectionDragPoint = null
lastSelectionPreviewAt = 0L
}
)
} else if (selectedTool == PdfInkTool.TEXT) {
detectTapGestures(
onTap = { start ->
onSelectPage(pageIndex)
when {
activeTextDraft?.containsOffset(pageIndex, start, pageCanvasSize) == true -> Unit
else -> {
val textHit = currentAnnotations.textAnnotationHitAt(
pageIndex = pageIndex,
point = start,
canvasSize = pageCanvasSize
)
clearInteractionState()
if (textHit != null) {
onTextAnnotationSelected(textHit)
} else {
onTextDraftStarted(pageIndex, start, pageCanvasSize)
}
}
}
}
)
} else if (selectedTool != PdfInkTool.NONE) {
var eraserPreviousPoint: Offset? = null
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
if (!currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture
val start = down.position
onSelectPage(pageIndex)
clearInteractionState()
if (selectedTool == PdfInkTool.ERASER) {
eraserPosition = start
val annotationSnapshot = currentAnnotations
val updatedAnnotations = annotationSnapshot.filterNot {
it.pageIndex == pageIndex && it.sharedPdfHitTest(
point = start,
size = pageCanvasSize,
eraserStrokeWidth = strokeWidth
)
}
if (updatedAnnotations.size != annotationSnapshot.size) {
onAnnotationsChanged(updatedAnnotations)
}
eraserPreviousPoint = start
} else {
activeStroke = listOf(
start.toSharedPdfPoint(pageCanvasSize, System.currentTimeMillis())
)
}
val pointerId = down.id
var dragStarted = false
while (true) {
val event = awaitPointerEvent()
if (event.changes.size > 1) {
eraserPreviousPoint = null
eraserPosition = null
activeStroke = emptyList()
return@awaitEachGesture
}
val change = event.changes.firstOrNull { it.id == pointerId }
?: run {
eraserPreviousPoint = null
eraserPosition = null
activeStroke = emptyList()
return@awaitEachGesture
}
if (change.changedToUp()) {
change.consume()
if (selectedTool != PdfInkTool.ERASER && activeStroke.isNotEmpty()) {
onAnnotationAdded(
SharedPdfAnnotation(
id = "ink_${System.currentTimeMillis()}",
pageIndex = pageIndex,
kind = PdfAnnotationKind.INK,
tool = selectedTool,
points = activeStroke,
colorArgb = selectedColor,
strokeWidth = strokeWidth,
createdAt = System.currentTimeMillis()
)
)
}
eraserPreviousPoint = null
eraserPosition = null
activeStroke = emptyList()
return@awaitEachGesture
}
if (!change.positionChanged()) continue
val distance = (change.position - start).getDistance()
if (selectedTool != PdfInkTool.ERASER && !dragStarted && distance <= viewConfiguration.touchSlop) continue
dragStarted = true
if (selectedTool == PdfInkTool.ERASER) {
val point = change.position
eraserPosition = point
val previousPoint = eraserPreviousPoint
val annotationSnapshot = currentAnnotations
val updatedAnnotations = annotationSnapshot.filterNot {
it.pageIndex == pageIndex && it.sharedPdfHitTest(
point = point,
size = pageCanvasSize,
lastPoint = previousPoint,
eraserStrokeWidth = strokeWidth
)
}
if (updatedAnnotations.size != annotationSnapshot.size) {
onAnnotationsChanged(updatedAnnotations)
}
eraserPreviousPoint = point
} else {
activeStroke = activeStroke.withDesktopPdfDragPoint(
point = change.position,
canvasSize = pageCanvasSize,
tool = selectedTool,
snapHighlighter = isHighlighterSnapEnabled,
timestamp = System.currentTimeMillis()
)
}
change.consume()
}
}
}
}
},
contentAlignment = Alignment.Center
) {
when {
!shouldRender -> {
Text("Page ${pageIndex + 1}", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
renderedPage != null -> {
val pageRender = renderedPage!!
val pageAnnotations = remember(annotations, pageIndex, pageCanvasSize) {
annotations
.filter { it.pageIndex == pageIndex }
.flatMap { annotation ->
annotation.toRenderablePdfAnnotations(document, pageIndex, pageCanvasSize)
}
}
val selectedTextAnnotationForPage = remember(annotations, selectedAnnotationId, selectedTool, isTextSelectionMode, pageIndex) {
annotations.firstOrNull {
selectedTool == PdfInkTool.TEXT &&
!isTextSelectionMode &&
it.id == selectedAnnotationId &&
it.kind == PdfAnnotationKind.TEXT &&
it.pageIndex == pageIndex
}
}
val visiblePageAnnotations = remember(pageAnnotations, selectedTextAnnotationForPage?.id) {
pageAnnotations.filterNot {
it.kind == PdfAnnotationKind.TEXT && it.id == selectedTextAnnotationForPage?.id
}
}
val searchHighlightBounds: List<PdfPageBounds> = remember(
document.path,
searchResults,
pageIndex,
activeSearchIndex,
searchHighlightMode,
pageCanvasSize,
searchQuery
) {
val queryLength = searchQuery.trim().length
if (queryLength <= 0 || pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) {
emptyList()
} else {
SharedPdfSearchEngine.highlightsForPage(
results = searchResults,
pageIndex = pageIndex,
activeResultIndex = activeSearchIndex,
mode = searchHighlightMode
).flatMap { result ->
val matchLength = result.matchLength.takeIf { it > 0 } ?: queryLength
DesktopPdfium.textRectsForRange(
document = document,
pageIndex = pageIndex,
startIndex = result.matchIndex,
endIndex = result.matchIndex + matchLength - 1,
viewportWidth = pageCanvasSize.width,
viewportHeight = pageCanvasSize.height
).map { it.toPdfPageBounds() }
.filter { it.right > it.left && it.bottom > it.top }
.mergePdfBoundsByLine()
}
}
}
val ttsHighlightBounds: List<PdfPageBounds> = remember(
document.path,
activeTtsChunk,
pageIndex,
pageCanvasSize
) {
val chunk = activeTtsChunk?.takeIf { it.pageIndex == pageIndex }
if (chunk == null || pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0 || chunk.endOffset <= chunk.startOffset) {
emptyList()
} else {
DesktopPdfium.textRectsForRange(
document = document,
pageIndex = pageIndex,
startIndex = chunk.startOffset,
endIndex = chunk.endOffset - 1,
viewportWidth = pageCanvasSize.width,
viewportHeight = pageCanvasSize.height
).map { it.toPdfPageBounds() }
.filter { it.right > it.left && it.bottom > it.top }
.mergePdfBoundsByLine()
}
}
DesktopPdfThemedPageImage(
bitmap = pageRender.image,
contentDescription = "PDF page ${pageIndex + 1}",
themeStyle = themeStyle,
modifier = Modifier.fillMaxSize()
)
SharedPdfRichTextLayer(
pageIndex = pageIndex,
controller = richTextController,
pageWidth = pageCanvasSize.width.toFloat(),
pageHeight = pageCanvasSize.height.toFloat(),
isTextEditingEnabled = isRichTextMode,
onPageTapped = {}
)
PdfSearchHighlightOverlay(
bounds = searchHighlightBounds,
canvasSize = pageCanvasSize,
color = when (searchHighlightMode) {
SearchHighlightMode.ALL -> Color(0x55FDD835)
SearchHighlightMode.FOCUSED -> Color(0x88FF9800)
}
)
PdfSearchHighlightOverlay(
bounds = ttsHighlightBounds,
canvasSize = pageCanvasSize,
color = Color(0x887DD3FC)
)
PdfTextSelectionOverlay(
selection = textSelection,
canvasSize = pageCanvasSize
)
SharedPdfAnnotationOverlay(
annotations = visiblePageAnnotations,
activeStroke = activeStroke,
canvasSize = pageCanvasSize,
activeTool = selectedTool,
activeStrokeColorArgb = selectedColor,
activeStrokeWidth = strokeWidth,
selectedAnnotationId = selectedAnnotationId,
eraserPosition = eraserPosition,
showEraserIndicator = selectedTool == PdfInkTool.ERASER,
eraserStrokeWidth = strokeWidth
)
PdfTextSelectionHandles(
selection = textSelection,
canvasSize = pageCanvasSize,
activeHandle = activeSelectionHandle
)
SharedPdfInlineTextEditorOverlay(
draft = activeTextDraft?.takeIf { it.pageIndex == pageIndex },
canvasSize = pageCanvasSize,
onTextChange = { onTextDraftChanged(it, pageCanvasSize) },
onBoundsChange = { onTextDraftBoundsChanged(it) }
)
selectedTextAnnotationForPage?.let { annotation ->
val bounds = annotation.bounds
if (bounds != null && activeTextDraft == null) {
SharedPdfTextBoxEditorOverlay(
id = annotation.id,
text = annotation.text,
style = annotation.sharedPdfTextStyle(),
bounds = bounds,
canvasSize = pageCanvasSize,
onTextChange = { text ->
onAnnotationUpdated(annotation.copy(text = text))
},
onBoundsChange = { nextBounds ->
onAnnotationUpdated(annotation.copy(bounds = nextBounds))
}
)
}
}
SharedPdfEmbeddedAnnotationOverlay(
annotations = pageEmbeddedAnnotations,
canvasSize = pageCanvasSize,
selectedAnnotationId = selectedEmbeddedAnnotationId
)
if (showPageNumberOverlay) {
SharedPdfPageNumberOverlay(
pageIndex = pageIndex,
pageCount = document.pageCount
)
}
if (textSelection != null && selectionMenuOffset != null) {
Box(
modifier = Modifier
.matchParentSize()
.pointerInput(pageIndex, selectionMenuOffset) {
detectTapGestures {
clearSelection()
}
}
)
}
PdfSelectionMenu(
selection = textSelection,
menuOffset = selectionMenuOffset,
canvasSize = pageCanvasSize,
highlighterPalette = highlighterPalette,
onHighlighterPaletteChange = onHighlighterPaletteChange,
onCopy = {
textSelection?.let(onCopySelection)
clearSelection()
},
onHighlight = { colorArgb ->
textSelection?.let { onHighlightSelection(pageIndex, it, pageCanvasSize, colorArgb) }
clearSelection()
},
onSearch = {
textSelection?.let(onExternalSearchSelection)
clearSelection()
},
onDefine = {
textSelection?.let(onDefineSelection)
clearSelection()
},
onSpeak = {
textSelection?.let(onSpeakSelection)
clearSelection()
},
showDefine = readerAiFeaturesAvailable,
showSpeak = cloudTtsAvailable,
showSearch = externalLookupAvailable,
onClear = ::clearSelection
)
}
isRendering -> CircularProgressIndicator()
renderError != null -> Text(renderError ?: "Failed to render page.", color = MaterialTheme.colorScheme.error)
}
}
}
}
@Composable
private fun ReaderScreen(
session: ReaderSessionState,
readerEngine: ReaderEngine,
onSessionChange: (ReaderSessionState) -> Unit,
onReturnToLibrary: (() -> Unit)? = null,
onFullscreenChange: (Boolean) -> Unit = {},
toolbarPreferences: ReaderToolbarPreferences,
onToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit,
highlightPalette: ReaderHighlightPalette,
onHighlightPaletteChange: (ReaderHighlightPalette) -> Unit,
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
ttsReplacementBookId: String?,
onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit,
onPickCustomFont: () -> String?,
customFonts: List<CustomFontItem>,
readerExtrasState: ReaderExtrasState,
aiByokSettings: ReaderAiByokSettings,
externalLookupAvailable: Boolean,
cloudTtsControlsAvailable: Boolean,
onExternalLookup: (ReaderExternalLookupAction, String) -> Unit,
onAiAction: (ReaderAiFeature, String) -> Unit,
onAiResultDismiss: () -> Unit,
onCloudTtsToggle: (String) -> Unit,
onCloudTtsStart: (ReaderTtsReadScope, List<ReaderTtsChunk>) -> Unit,
onCloudTtsPauseResume: () -> Unit,
onCloudTtsStop: () -> Unit,
onCloudTtsClearCache: () -> Unit,
onAutoScrollChange: (ReaderAutoScrollState) -> Unit,
readerTextureDataUri: (String) -> String?,
readerCustomTextureIds: List<String>,
onImportReaderTexture: ((ReaderSettings) -> ReaderSettings?)?,
webViewRuntimeState: DesktopWebViewRuntimeState,
webViewNetworkAccessEnabled: Boolean,
epubPaginationCache: SharedEpubPaginationCache,
epubPaginationCacheGeneration: Int
) {
val clipboardManager = LocalClipboardManager.current
val density = LocalDensity.current
val textMeasurer = rememberTextMeasurer()
val paginationCacheWriteScope = rememberCoroutineScope()
val measuredPaginator = remember(
textMeasurer,
density,
session.reader.settings.fontFamily,
session.reader.settings.customFontPath,
epubPaginationCache,
paginationCacheWriteScope
) {
SharedMeasuredEpubPaginator(
textMeasurer = textMeasurer,
density = density,
fontFamily = session.reader.settings.toDesktopReaderFontFamily(),
pageCache = epubPaginationCache,
cacheWriteScope = paginationCacheWriteScope
)
}
var readerViewport by remember(session.reader.book.id) { mutableStateOf(ReaderViewportSpec(0, 0)) }
val paginationLayoutSignature = session.reader.settings.layoutSignature()
val paginationContentSignature = remember(session.reader.book) {
session.reader.book.desktopPaginationContentSignature()
}
val paginationDensitySignature = DesktopEpubPaginationDensity(
density = density.density,
fontScale = density.fontScale
)
val measuredPaginationRequest = remember(
session.reader.book.id,
paginationContentSignature,
paginationLayoutSignature,
readerViewport,
paginationDensitySignature,
epubPaginationCacheGeneration
) {
if (session.reader.settings.readingMode == ReaderReadingMode.PAGINATED && readerViewport.isSpecified) {
DesktopEpubPaginationRequest(
bookId = session.reader.book.id,
chapterSignature = paginationContentSignature,
layoutSignature = paginationLayoutSignature,
viewport = readerViewport,
density = paginationDensitySignature,
cacheGeneration = epubPaginationCacheGeneration
)
} else {
null
}
}
var completedMeasuredPaginationRequest by remember(session.reader.book.id) {
mutableStateOf<DesktopEpubPaginationRequest?>(null)
}
var runningMeasuredPaginationRequest by remember(session.reader.book.id) {
mutableStateOf<DesktopEpubPaginationRequest?>(null)
}
val paginatedLayoutReady = session.reader.settings.readingMode != ReaderReadingMode.PAGINATED ||
(measuredPaginationRequest != null && completedMeasuredPaginationRequest == measuredPaginationRequest)
val latestSession by rememberUpdatedState(session)
val latestOnSessionChange by rememberUpdatedState(onSessionChange)
var externalLinkDialogUrl by remember { mutableStateOf<String?>(null) }
var lastHandledLink by remember { mutableStateOf<DesktopEpubHandledLink?>(null) }
var isFullscreen by remember(session.reader.book.id) { mutableStateOf(false) }
val currentReaderFullscreen by rememberUpdatedState(isFullscreen)
val currentOnReaderFullscreenChange by rememberUpdatedState(onFullscreenChange)
fun setReaderFullscreen(enabled: Boolean) {
isFullscreen = enabled
onFullscreenChange(enabled)
}
DesktopExternalLinkDialog(
url = externalLinkDialogUrl,
onDismiss = { externalLinkDialogUrl = null }
)
fun handleReaderFullscreenAwtKeyEvent(event: AwtKeyEvent): Boolean {
val action = event.desktopReaderKeyNavigationOrNull(fullscreen = isFullscreen) ?: return false
val currentSession = latestSession
when (action) {
DesktopReaderKeyNavigation.NEXT -> latestOnSessionChange(currentSession.reduce(ReaderAction.NextPage, readerEngine))
DesktopReaderKeyNavigation.PREVIOUS -> latestOnSessionChange(currentSession.reduce(ReaderAction.PreviousPage, readerEngine))
DesktopReaderKeyNavigation.FIRST -> latestOnSessionChange(currentSession.reduce(ReaderAction.JumpToPage(0), readerEngine))
DesktopReaderKeyNavigation.LAST -> latestOnSessionChange(currentSession.reduce(ReaderAction.JumpToPage(currentSession.reader.pages.lastIndex), readerEngine))
DesktopReaderKeyNavigation.SEARCH -> latestOnSessionChange(currentSession.reduce(ReaderAction.SearchOpened, readerEngine))
DesktopReaderKeyNavigation.NEXT_SEARCH -> latestOnSessionChange(currentSession.reduce(ReaderAction.JumpToNextSearchResult, readerEngine))
DesktopReaderKeyNavigation.EXIT_FULLSCREEN -> if (isFullscreen) setReaderFullscreen(false)
}
return true
}
DesktopReaderFullscreenKeyEffect(
enabled = isFullscreen && externalLinkDialogUrl == null,
onKeyPressed = { event -> handleReaderFullscreenAwtKeyEvent(event) }
)
LaunchedEffect(session.reader.settings.readingMode) {
if (session.reader.settings.readingMode != ReaderReadingMode.PAGINATED) {
completedMeasuredPaginationRequest = null
runningMeasuredPaginationRequest = null
}
}
DisposableEffect(session.reader.book.id) {
onDispose {
if (currentReaderFullscreen) {
currentOnReaderFullscreenChange(false)
}
}
}
LaunchedEffect(
measuredPaginationRequest,
measuredPaginator
) {
val request = measuredPaginationRequest ?: return@LaunchedEffect
if (completedMeasuredPaginationRequest == request) {
logEpubPagination(
"reflow_skip reason=request_already_measured book=\"${session.reader.book.title.logPreview()}\" " +
"viewport=${request.viewport.widthPx}x${request.viewport.heightPx}"
)
return@LaunchedEffect
}
delay(280L)
val settings = latestSession.reader.settings
if (settings.readingMode != ReaderReadingMode.PAGINATED) return@LaunchedEffect
if (settings.layoutSignature() != request.layoutSignature) return@LaunchedEffect
runningMeasuredPaginationRequest = request
try {
val reflowStartSession = latestSession
val reflowStartRequestId = reflowStartSession.navigationRequestId
val reflowAnchor = readerEngine.reflowAnchorFor(reflowStartSession)
logEpubPagination(
"reflow_start book=\"${session.reader.book.title.logPreview()}\" " +
"viewport=${request.viewport.widthPx}x${request.viewport.heightPx} " +
"spread=${settings.pageSpreadMode} font=${settings.fontSize} lineSpacing=${settings.lineSpacing} " +
"margins=${settings.resolvedHorizontalMargin}x${settings.resolvedVerticalMargin} " +
"pageWidthSetting=${settings.pageWidth} oldPages=${reflowStartSession.reader.pages.size} " +
"anchorPage=${reflowAnchor?.pageIndex} anchorOffsets=${reflowAnchor?.startOffset}..${reflowAnchor?.endOffset}"
)
val pages = measuredPaginator.paginate(
book = session.reader.book,
settings = settings,
viewport = request.viewport
)
val layoutChanged = pages.isNotEmpty() && !latestSession.reader.pages.samePageLayoutAs(pages)
logEpubPagination(
"reflow_result book=\"${session.reader.book.title.logPreview()}\" pages=${pages.size} " +
"layoutChanged=$layoutChanged currentPages=${latestSession.reader.pages.size}"
)
if (layoutChanged) {
latestOnSessionChange(
readerEngine.replacePages(
state = latestSession,
pages = pages,
reflowAnchor = reflowAnchor,
navigationRequestIdAtReflowStart = reflowStartRequestId
)
)
}
if (pages.isNotEmpty()) {
completedMeasuredPaginationRequest = request
}
} finally {
if (runningMeasuredPaginationRequest == request) {
runningMeasuredPaginationRequest = null
}
}
}
val handleDesktopSelectionAction: (DesktopReaderSelectionAction, String) -> Unit = { action, text ->
val settings = aiByokSettings.sanitized()
when (action) {
DesktopReaderSelectionAction.DEFINE -> {
if (settings.areReaderAiFeaturesAvailable) onAiAction(ReaderAiFeature.DEFINE, text)
}
DesktopReaderSelectionAction.SPEAK -> {
if (settings.isCloudTtsAvailable) onCloudTtsToggle(text)
}
DesktopReaderSelectionAction.SEARCH -> onExternalLookup(ReaderExternalLookupAction.SEARCH, text)
}
}
val nativeSelectionActions = buildSet {
val settings = aiByokSettings.sanitized()
if (settings.areReaderAiFeaturesAvailable) add(SharedNativeReaderSelectionAction.DEFINE)
if (externalLookupAvailable) add(SharedNativeReaderSelectionAction.SEARCH)
if (settings.isCloudTtsAvailable) add(SharedNativeReaderSelectionAction.SPEAK)
}
val handleNativeSelectionAction: (SharedNativeReaderSelectionAction, String) -> Unit = { action, text ->
when (action) {
SharedNativeReaderSelectionAction.DEFINE ->
handleDesktopSelectionAction(DesktopReaderSelectionAction.DEFINE, text)
SharedNativeReaderSelectionAction.SPEAK ->
handleDesktopSelectionAction(DesktopReaderSelectionAction.SPEAK, text)
SharedNativeReaderSelectionAction.SEARCH ->
handleDesktopSelectionAction(DesktopReaderSelectionAction.SEARCH, text)
}
}
val handleDesktopEpubLinkClicked: (DesktopEpubLinkClick) -> Unit = { link ->
val now = System.currentTimeMillis()
val last = lastHandledLink
if (last != null && last.href == link.href && now - last.handledAtMs < 900L) {
logEpubLink(
"click_duplicate_ignored source=${link.source} href=\"${link.href.logPreview()}\" " +
"ageMs=${now - last.handledAtMs}"
)
} else {
lastHandledLink = DesktopEpubHandledLink(link.href, now)
logEpubLink(
"click source=${link.source} href=\"${link.href.logPreview()}\" " +
"chapterIndex=${link.chapterIndex} chapterHref=\"${link.chapterHref.orEmpty().logPreview()}\" " +
"text=\"${link.text.orEmpty().logPreview()}\""
)
when (val target = readerEngine.resolveLink(session, link.href, link.chapterIndex)) {
is ReaderLinkTarget.External -> {
logEpubLink("resolved_external url=\"${target.url.logPreview()}\"")
if (externalLookupAvailable) {
externalLinkDialogUrl = target.url
}
}
is ReaderLinkTarget.Internal -> {
logEpubLink(
"resolved_internal chapter=${target.locator.chapterIndex} " +
"page=${target.locator.pageIndex} offset=${target.locator.startOffset}"
)
onSessionChange(readerEngine.jumpToLocator(session, target.locator))
}
ReaderLinkTarget.Ignored -> {
logEpubLink("resolved_ignored href=\"${link.href.logPreview()}\"")
}
}
}
}
SharedReaderScreen(
session = session,
readerEngine = readerEngine,
onSessionChange = onSessionChange,
onReturnToLibrary = onReturnToLibrary,
isFullscreen = isFullscreen,
onFullscreenChange = ::setReaderFullscreen,
toolbarPreferences = toolbarPreferences,
onToolbarPreferencesChange = onToolbarPreferencesChange,
highlightPalette = highlightPalette,
onHighlightPaletteChange = onHighlightPaletteChange,
ttsReplacementPreferences = ttsReplacementPreferences,
ttsReplacementBookId = ttsReplacementBookId,
onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange,
onPickCustomFont = onPickCustomFont,
customFonts = customFonts,
readerExtrasState = readerExtrasState,
aiByokSettings = aiByokSettings,
externalLookupAvailable = externalLookupAvailable,
cloudTtsControlsAvailable = cloudTtsControlsAvailable,
onExternalLookup = onExternalLookup,
onAiAction = onAiAction,
onAiResultDismiss = onAiResultDismiss,
onCopyText = { text -> clipboardManager.setText(AnnotatedString(text)) },
onCloudTtsStart = onCloudTtsStart,
onCloudTtsPauseResume = onCloudTtsPauseResume,
onCloudTtsStop = onCloudTtsStop,
onCloudTtsClearCache = onCloudTtsClearCache,
onAutoScrollChange = onAutoScrollChange,
readerTextureDataUri = readerTextureDataUri,
readerCustomTextureIds = readerCustomTextureIds,
onImportReaderTexture = onImportReaderTexture
) { renderPlan, onVisiblePageChanged, onHighlightSelected ->
Surface(
color = renderPlan.background,
shape = RoundedCornerShape(8.dp),
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.clip(RoundedCornerShape(8.dp))
.onSizeChanged { size ->
val next = ReaderViewportSpec(size.width, size.height)
logReaderGap(
"desktop_epub_reader_surface size=${size.width}x${size.height} " +
"mode=${session.reader.settings.readingMode} " +
"page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)}"
)
if (next != readerViewport) {
logEpubPagination(
"viewport_changed width=${next.widthPx} height=${next.heightPx} " +
"previous=${readerViewport.widthPx}x${readerViewport.heightPx}"
)
readerViewport = next
}
}
) {
if (renderPlan is ReaderContentRenderPlan.NativePaginatedPages && !paginatedLayoutReady) {
DesktopEpubPaginationPreparing(
active = runningMeasuredPaginationRequest != null,
modifier = Modifier.fillMaxSize()
)
} else {
when (renderPlan) {
is ReaderContentRenderPlan.WebDocument -> {
if (webViewRuntimeState.initialized) {
DesktopEpubWebView(
html = renderPlan.html,
appearanceScript = renderPlan.appearanceScript,
navigationTarget = renderPlan.navigationTarget,
highlights = renderPlan.highlights,
onHighlightCreated = { highlight ->
onSessionChange(session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine))
},
onHighlightSelected = onHighlightSelected,
isFullscreen = isFullscreen,
onKeyboardNavigation = { action ->
when (action) {
DesktopReaderKeyNavigation.NEXT -> onSessionChange(session.reduce(ReaderAction.NextPage, readerEngine))
DesktopReaderKeyNavigation.PREVIOUS -> onSessionChange(session.reduce(ReaderAction.PreviousPage, readerEngine))
DesktopReaderKeyNavigation.FIRST -> onSessionChange(session.reduce(ReaderAction.JumpToPage(0), readerEngine))
DesktopReaderKeyNavigation.LAST -> onSessionChange(session.reduce(ReaderAction.JumpToPage(session.reader.pages.lastIndex), readerEngine))
DesktopReaderKeyNavigation.SEARCH -> onSessionChange(session.reduce(ReaderAction.SearchOpened, readerEngine))
DesktopReaderKeyNavigation.NEXT_SEARCH -> onSessionChange(session.reduce(ReaderAction.JumpToNextSearchResult, readerEngine))
DesktopReaderKeyNavigation.EXIT_FULLSCREEN -> if (isFullscreen) setReaderFullscreen(false)
}
},
onSelectionAction = handleDesktopSelectionAction,
onLinkClicked = handleDesktopEpubLinkClicked,
onVisiblePageChanged = onVisiblePageChanged,
networkAccessEnabled = webViewNetworkAccessEnabled,
modifier = Modifier.fillMaxSize()
)
} else {
DesktopWebViewRuntimeIndicator(
state = webViewRuntimeState,
modifier = Modifier.fillMaxSize()
)
}
}
is ReaderContentRenderPlan.NativePaginatedPages -> {
SharedNativePaginatedReader(
renderPlan = renderPlan,
readerFontFamily = renderPlan.settings.toDesktopReaderFontFamily(),
searchHighlight = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.72f),
onVisiblePageChanged = onVisiblePageChanged,
enabledSelectionActions = nativeSelectionActions,
onCopyText = { text -> clipboardManager.setText(AnnotatedString(text)) },
onSelectionAction = handleNativeSelectionAction,
onHighlightCreated = { highlight ->
onSessionChange(session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine))
},
onHighlightSelected = onHighlightSelected,
onLinkClicked = { link ->
handleDesktopEpubLinkClicked(link.toDesktopEpubLinkClick())
},
imageContent = { image, imageModifier ->
DesktopEpubNativeImage(
image = image,
modifier = imageModifier
)
},
modifier = Modifier.fillMaxSize()
)
}
}
}
}
}
}
@Composable
private fun DesktopEpubWebView(
html: String,
appearanceScript: String,
navigationTarget: ReaderContentNavigationTarget,
highlights: List<UserHighlight>,
onHighlightCreated: (UserHighlight) -> Unit,
onHighlightSelected: (String) -> Unit,
isFullscreen: Boolean,
onKeyboardNavigation: (DesktopReaderKeyNavigation) -> Unit,
onSelectionAction: (DesktopReaderSelectionAction, String) -> Unit,
onLinkClicked: (DesktopEpubLinkClick) -> Unit,
onVisiblePageChanged: (Int, ReaderLocator?) -> Unit,
networkAccessEnabled: Boolean,
modifier: Modifier = Modifier
) {
val latestOnHighlightCreated by rememberUpdatedState(onHighlightCreated)
val latestOnHighlightSelected by rememberUpdatedState(onHighlightSelected)
val latestOnKeyboardNavigation by rememberUpdatedState(onKeyboardNavigation)
val latestOnSelectionAction by rememberUpdatedState(onSelectionAction)
val latestOnLinkClicked by rememberUpdatedState(onLinkClicked)
val latestOnVisiblePageChanged by rememberUpdatedState(onVisiblePageChanged)
val scope = rememberCoroutineScope()
val linkRequestInterceptor = remember(scope, networkAccessEnabled) {
object : RequestInterceptor {
override fun onInterceptUrlRequest(
request: WebRequest,
navigator: WebViewNavigator
): WebRequestInterceptResult {
if (!networkAccessEnabled && request.url.isRemoteNetworkUrl()) {
logEpubLink("request_blocked_offline url=\"${request.url.logPreview()}\"")
return WebRequestInterceptResult.Reject
}
if (!request.isForMainFrame) return WebRequestInterceptResult.Allow
val link = request.url.readerLinkClickFromIntercept() ?: return WebRequestInterceptResult.Allow
logEpubLink(
"request_intercept method=${request.method} redirect=${request.isRedirect} " +
"url=\"${request.url.logPreview()}\" href=\"${link.href.logPreview()}\""
)
scope.launch {
latestOnLinkClicked(link.copy(source = "request"))
}
return WebRequestInterceptResult.Reject
}
}
}
val navigator = rememberWebViewNavigator(requestInterceptor = linkRequestInterceptor)
val bridge = rememberWebViewJsBridge()
DisposableEffect(bridge) {
val highlightHandler = object : IJsMessageHandler {
override fun methodName(): String = "readerHighlightCreated"
override fun handle(
message: JsMessage,
navigator: WebViewNavigator?,
callback: (String) -> Unit
) {
val highlight = EpubAnnotationSerializer.parseHighlightJsonLenient(message.params)
if (highlight == null) {
logEpubSelectionDebug("highlight_parse_failed params=${message.params.logPreview(900)}")
} else {
scope.launch { latestOnHighlightCreated(highlight) }
}
}
}
val highlightSelectionHandler = object : IJsMessageHandler {
override fun methodName(): String = "readerHighlightClicked"
override fun handle(
message: JsMessage,
navigator: WebViewNavigator?,
callback: (String) -> Unit
) {
message.params.readerHighlightClickOrNull()?.let { highlightClick ->
scope.launch { latestOnHighlightSelected(highlightClick.highlightId) }
}
}
}
val positionHandler = object : IJsMessageHandler {
override fun methodName(): String = "readerPositionChanged"
override fun handle(
message: JsMessage,
navigator: WebViewNavigator?,
callback: (String) -> Unit
) {
message.params.readerPositionOrNull()?.let { position ->
scope.launch { latestOnVisiblePageChanged(position.pageIndex, position.locator) }
}
}
}
val selectionActionHandler = object : IJsMessageHandler {
override fun methodName(): String = "readerSelectionAction"
override fun handle(
message: JsMessage,
navigator: WebViewNavigator?,
callback: (String) -> Unit
) {
val selectionAction = message.params.readerSelectionActionOrNull()
if (selectionAction != null) {
scope.launch { latestOnSelectionAction(selectionAction.action, selectionAction.text) }
}
}
}
val keyNavigationHandler = object : IJsMessageHandler {
override fun methodName(): String = "readerKeyNavigation"
override fun handle(
message: JsMessage,
navigator: WebViewNavigator?,
callback: (String) -> Unit
) {
message.params.readerKeyNavigationOrNull()?.let { action ->
scope.launch { latestOnKeyboardNavigation(action) }
}
}
}
val ttsHighlightLogHandler = object : IJsMessageHandler {
override fun methodName(): String = "readerTtsHighlightLog"
override fun handle(
message: JsMessage,
navigator: WebViewNavigator?,
callback: (String) -> Unit
) {
logDesktopTts("epub_highlight_js ${message.params.logPreview(500)}")
}
}
val selectionDebugLogHandler = object : IJsMessageHandler {
override fun methodName(): String = "readerSelectionDebugLog"
override fun handle(
message: JsMessage,
navigator: WebViewNavigator?,
callback: (String) -> Unit
) {
logEpubSelectionDebug(message.params.readerSelectionDebugMessageOrNull() ?: message.params.logPreview(900))
}
}
val paginationLayoutLogHandler = object : IJsMessageHandler {
override fun methodName(): String = "readerPaginationLayoutLog"
override fun handle(
message: JsMessage,
navigator: WebViewNavigator?,
callback: (String) -> Unit
) {
logEpubPagination(message.params.readerPaginationLogMessageOrNull() ?: message.params.logPreview(900))
}
}
val gapLayoutLogHandler = object : IJsMessageHandler {
override fun methodName(): String = "readerGapLayoutLog"
override fun handle(
message: JsMessage,
navigator: WebViewNavigator?,
callback: (String) -> Unit
) {
logReaderGap(message.params.readerPaginationLogMessageOrNull() ?: message.params.logPreview(900))
}
}
val linkHandler = object : IJsMessageHandler {
override fun methodName(): String = "readerLinkClicked"
override fun handle(
message: JsMessage,
navigator: WebViewNavigator?,
callback: (String) -> Unit
) {
logEpubLink("bridge_message params=\"${message.params.logPreview()}\"")
val link = message.params.readerLinkClickOrNull()
if (link == null) {
logEpubLink("bridge_message_ignored reason=parse_failed")
} else {
logEpubLink(
"bridge_message_parsed href=\"${link.href.logPreview()}\" " +
"chapterIndex=${link.chapterIndex} chapterHref=\"${link.chapterHref.orEmpty().logPreview()}\""
)
scope.launch { latestOnLinkClicked(link) }
}
}
}
bridge.register(highlightHandler)
bridge.register(highlightSelectionHandler)
bridge.register(positionHandler)
bridge.register(selectionActionHandler)
bridge.register(keyNavigationHandler)
bridge.register(ttsHighlightLogHandler)
bridge.register(selectionDebugLogHandler)
bridge.register(paginationLayoutLogHandler)
bridge.register(gapLayoutLogHandler)
bridge.register(linkHandler)
onDispose {
bridge.unregister(highlightHandler)
bridge.unregister(highlightSelectionHandler)
bridge.unregister(positionHandler)
bridge.unregister(selectionActionHandler)
bridge.unregister(keyNavigationHandler)
bridge.unregister(ttsHighlightLogHandler)
bridge.unregister(selectionDebugLogHandler)
bridge.unregister(paginationLayoutLogHandler)
bridge.unregister(gapLayoutLogHandler)
bridge.unregister(linkHandler)
}
}
val state = remember {
WebViewState(
WebContent.Data(
data = html,
baseUrl = null,
encoding = "utf-8",
mimeType = "text/html",
historyUrl = null
)
)
}
LaunchedEffect(html) {
navigator.loadHtml(
html = html,
baseUrl = null,
mimeType = "text/html",
encoding = "utf-8",
historyUrl = null
)
}
Box(modifier = modifier) {
WebView(
state = state,
modifier = Modifier.fillMaxSize(),
captureBackPresses = false,
navigator = navigator,
webViewJsBridge = bridge
)
LaunchedEffect(state.loadingState) {
if (state.loadingState !is LoadingState.Finished) return@LaunchedEffect
navigator.evaluateJavaScript(
"""
(function () {
if (window.readerDesktopKeyNavigationInstalled) return;
window.readerDesktopKeyNavigationInstalled = true;
document.addEventListener('keydown', function (event) {
var target = event.target;
var tag = target && target.tagName ? target.tagName.toLowerCase() : '';
if (target && (target.isContentEditable || tag === 'input' || tag === 'textarea' || tag === 'select')) return;
var action = null;
if (event.ctrlKey && (event.key === 'f' || event.key === 'F')) action = 'search';
else if (event.ctrlKey && (event.key === 'g' || event.key === 'G')) action = 'nextSearch';
else if (event.key === 'ArrowRight' || event.key === 'PageDown') action = 'next';
else if (event.key === 'ArrowLeft' || event.key === 'PageUp') action = 'previous';
else if (event.key === 'Home') action = 'first';
else if (event.key === 'End') action = 'last';
else if (event.key === 'Escape' && window.readerDesktopFullscreen) action = 'exitFullscreen';
if (!action || !window.kmpJsBridge || !window.kmpJsBridge.callNative) return;
event.preventDefault();
event.stopPropagation();
window.kmpJsBridge.callNative('readerKeyNavigation', JSON.stringify({ action: action }));
}, true);
})();
""".trimIndent()
)
}
LaunchedEffect(isFullscreen, state.loadingState) {
if (state.loadingState !is LoadingState.Finished) return@LaunchedEffect
navigator.evaluateJavaScript("window.readerDesktopFullscreen = ${if (isFullscreen) "true" else "false"};")
}
LaunchedEffect(html, state.loadingState) {
if (state.loadingState !is LoadingState.Finished) return@LaunchedEffect
navigator.evaluateJavaScript("window.readerPaginationLayoutLog && window.readerPaginationLayoutLog('desktop_finished');")
}
LaunchedEffect(appearanceScript, state.loadingState) {
if (state.loadingState !is LoadingState.Finished) return@LaunchedEffect
navigator.evaluateJavaScript(appearanceScript)
}
LaunchedEffect(
navigationTarget.autoScroll,
navigationTarget.readingMode,
state.loadingState
) {
if (navigationTarget.readingMode != ReaderReadingMode.VERTICAL) return@LaunchedEffect
if (state.loadingState !is LoadingState.Finished) return@LaunchedEffect
val autoScroll = navigationTarget.autoScroll.sanitized()
val command = if (autoScroll.enabled) {
"window.readerAutoScroll && window.readerAutoScroll.start(${autoScroll.speed});"
} else {
"window.readerAutoScroll && window.readerAutoScroll.stop();"
}
navigator.evaluateJavaScript(command)
}
LaunchedEffect(
navigationTarget.requestId,
navigationTarget.readingMode,
state.loadingState
) {
if (navigationTarget.readingMode != ReaderReadingMode.VERTICAL) return@LaunchedEffect
if (state.loadingState !is LoadingState.Finished) return@LaunchedEffect
val locator = navigationTarget.locator ?: return@LaunchedEffect
navigator.evaluateJavaScript("window.readerScrollToLocator && window.readerScrollToLocator(${locator.toReaderLocatorJson()});")
}
LaunchedEffect(
navigationTarget.ttsRequestId,
navigationTarget.ttsLocator,
navigationTarget.readingMode,
state.loadingState
) {
if (state.loadingState !is LoadingState.Finished) return@LaunchedEffect
val locator = navigationTarget.ttsLocator
val command = if (locator == null) {
logDesktopTts(
"epub_highlight_command clear mode=${navigationTarget.readingMode} request=${navigationTarget.ttsRequestId}"
)
"window.readerSetTtsLocator && window.readerSetTtsLocator(null, false);"
} else {
val follow = navigationTarget.readingMode == ReaderReadingMode.VERTICAL
logDesktopTts(
"epub_highlight_command set mode=${navigationTarget.readingMode} request=${navigationTarget.ttsRequestId} " +
"follow=$follow chapter=${locator.chapterIndex} page=${locator.pageIndex} " +
"offsets=${locator.startOffset}..${locator.endOffset} cfi=\"${locator.cfi.orEmpty().logPreview()}\" " +
"text=\"${locator.textQuote.orEmpty().logPreview()}\""
)
"window.readerSetTtsLocator && window.readerSetTtsLocator(${locator.toReaderLocatorJson()}, $follow);"
}
navigator.evaluateJavaScript(command)
}
LaunchedEffect(highlights, state.loadingState) {
if (state.loadingState !is LoadingState.Finished) return@LaunchedEffect
navigator.evaluateJavaScript("window.readerApplyHighlights && window.readerApplyHighlights(${EpubAnnotationSerializer.highlightsToJson(highlights)});")
}
val loadingState = state.loadingState
if (loadingState is LoadingState.Loading) {
LinearProgressIndicator(
progress = { loadingState.progress },
modifier = Modifier.fillMaxWidth()
)
}
}
}