* Centralize library management logic and introduce support for plain text and HTML formats * Centralize library management logic and introduce support for plain text and HTML formats * Expand unit test coverage for library state management, UI models, and MainViewModel features. * Add comprehensive unit tests for PDF reader core logic, preferences, and data persistence * Add unit tests for EPUB parsing, content loading, search functionality, and reader JavaScript bridges. * Add unit tests for OPDS parsing and Smart Collection engine, and integrate Kover plugin * Add comprehensive unit tests * Centralize library snapshot serialization in the `shared` module and improve filtering and sorting logic. * Implement text selection, highlighting, and reading state persistence for PDF and EPUB engines in desktop version * Folder import support for desktop app * Introduce Smart Shelves with rule-based filtering in desktop version * Implement shared EPUB annotation serialization and highlight rendering * Centralize file type capabilities and platform-specific support logic * Refactor reader state management to use a central reducer * Implement customizable reader toolbar and advanced formatting settings in shared * Implement locator-based navigation and customizable highlight palette for desktop app * Enhance reader customization and expand search functionality in desktop app * Redesign reader settings and tools into a tabbed control panel in desktop app * Enhance reader navigation and highlight precision in desktop app * Implement bidirectional position synchronization and dynamic highlights in the desktop reader * Implement shared state management and enhanced search for the PDF reader in desktop app * Add vertical scroll support to the desktop PDF reader * Implement ink, text, and eraser annotation support in desktop PDF viewer * Implement PDF bookmarks, Table of Contents, and annotation editing in desktop app * Implement link handling and navigation for PDF and EPUB readers in desktop app * Implement PDF jump history for navigation in desktop app * Enhance PDF ink rendering and annotation capabilities in desktop app * Implement advanced PDF text annotations with inline editing and rich styling in desktop app * Add move handle and movement logic for PDF text annotations in desktop app * Implement local folder synchronization and metadata sidecar support in desktop app * Implement book metadata extraction and drag-and-drop import for Desktop * Implement dynamic and custom app theme management for desktop * Introduce canonical PDF annotation codec and support for multi-segment highlights * Implement rich text editing and pagination support for the PDF reader in desktop app * Improve PDF rich text pagination, synchronization, and observability in desktop * Hide trailing structural page breaks in rich text editor * Implement a unified JVM book loader and expand supported formats on Desktop * Add comic archive support for Desktop and enhance MOBI parsing * Implement shared OPDS catalog support and UI for Android and Desktop * Improve native WebView lifecycle and surface transition management on Desktop * Enable Compose Swing interop blending and simplify Desktop WebView management * Integrate BYOK AI features and Cloud TTS for desktop * Enhance Desktop TTS with streaming audio and improved secure storage for AI key * Implement scoped Cloud TTS with synchronized highlighting for EPUB and PDF in desktop app * Implement custom font management and utility screens in desktop app * Implement PDFium-based PDF annotation export * Remove PdfBox dependency and standardize PDF export via Pdfium * Implement local audio caching and playback controls for Gemini Cloud TTS in desktop app * Implement reader themes and custom texture support in desktop app * Redesign non-reader UI with responsive navigation and enhanced library management in desktop app * Introduce ReaderWorkspaceShell to unify EPUB and PDF reader layouts in desktop app * Exclude manual-only files from automated sync and import * Implement customizable Text-to-Speech (TTS) word replacements * Optimize reader performance with persistent layout caching and decoupled theme rendering * Improve position restoration during reader reconfiguration in epub pagination * Use independent thickness for eraser tool and stylus override
8029 lines
364 KiB
Kotlin
8029 lines
364 KiB
Kotlin
package com.aryan.reader.desktop
|
|
|
|
import androidx.compose.foundation.Canvas
|
|
import androidx.compose.foundation.BorderStroke
|
|
import androidx.compose.foundation.Image
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.foundation.focusable
|
|
import androidx.compose.foundation.gestures.detectDragGestures
|
|
import androidx.compose.foundation.gestures.detectTapGestures
|
|
import androidx.compose.foundation.horizontalScroll
|
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.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.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.itemsIndexed
|
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
import androidx.compose.foundation.rememberScrollState
|
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
import androidx.compose.foundation.verticalScroll
|
|
import androidx.compose.material.icons.Icons
|
|
import androidx.compose.material.icons.automirrored.filled.NavigateBefore
|
|
import androidx.compose.material.icons.automirrored.filled.NavigateNext
|
|
import androidx.compose.material.icons.filled.Close
|
|
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.OutlinedTextField
|
|
import androidx.compose.material3.Slider
|
|
import androidx.compose.material3.SnackbarHostState
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.material3.Switch
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.DisposableEffect
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.key
|
|
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.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.geometry.Offset
|
|
import androidx.compose.ui.graphics.BlendMode
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.graphics.ColorFilter
|
|
import androidx.compose.ui.graphics.ColorMatrix
|
|
import androidx.compose.ui.graphics.ImageBitmap
|
|
import androidx.compose.ui.graphics.ImageShader
|
|
import androidx.compose.ui.graphics.ShaderBrush
|
|
import androidx.compose.ui.graphics.TileMode
|
|
import androidx.compose.ui.graphics.isSpecified
|
|
import androidx.compose.ui.graphics.toComposeImageBitmap
|
|
import androidx.compose.ui.input.key.Key
|
|
import androidx.compose.ui.input.key.KeyEventType
|
|
import androidx.compose.ui.input.key.isCtrlPressed
|
|
import androidx.compose.ui.input.key.key
|
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
|
import androidx.compose.ui.input.key.type
|
|
import androidx.compose.ui.input.pointer.PointerEventType
|
|
import androidx.compose.ui.input.pointer.isPrimaryPressed
|
|
import androidx.compose.ui.input.pointer.isSecondaryPressed
|
|
import androidx.compose.ui.input.pointer.pointerInput
|
|
import androidx.compose.ui.layout.onSizeChanged
|
|
import androidx.compose.ui.platform.LocalClipboardManager
|
|
import androidx.compose.ui.platform.LocalDensity
|
|
import androidx.compose.ui.text.AnnotatedString
|
|
import androidx.compose.ui.text.SpanStyle
|
|
import androidx.compose.ui.text.buildAnnotatedString
|
|
import androidx.compose.ui.text.font.FontFamily
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.text.platform.Font as DesktopFont
|
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|
import androidx.compose.ui.text.style.TextAlign
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
import androidx.compose.ui.unit.Dp
|
|
import androidx.compose.ui.unit.IntSize
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.isSpecified
|
|
import androidx.compose.ui.unit.sp
|
|
import androidx.compose.ui.zIndex
|
|
import androidx.compose.ui.window.Window
|
|
import androidx.compose.ui.window.application
|
|
import com.aryan.reader.paginatedreader.SemanticBlock
|
|
import com.aryan.reader.paginatedreader.SemanticFlexContainer
|
|
import com.aryan.reader.paginatedreader.SemanticHeader
|
|
import com.aryan.reader.paginatedreader.SemanticImage
|
|
import com.aryan.reader.paginatedreader.SemanticList
|
|
import com.aryan.reader.paginatedreader.SemanticListItem
|
|
import com.aryan.reader.paginatedreader.SemanticMath
|
|
import com.aryan.reader.paginatedreader.SemanticParagraph
|
|
import com.aryan.reader.paginatedreader.SemanticSpacer
|
|
import com.aryan.reader.paginatedreader.SemanticTable
|
|
import com.aryan.reader.paginatedreader.SemanticTextBlock
|
|
import com.aryan.reader.paginatedreader.SemanticWrappingBlock
|
|
import com.aryan.reader.shared.AppAction
|
|
import com.aryan.reader.shared.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.GEMINI_CLOUD_TTS_MODEL
|
|
import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL_ID
|
|
import com.aryan.reader.shared.ReaderAiByokSettings
|
|
import com.aryan.reader.shared.ReaderAiFeature
|
|
import com.aryan.reader.shared.ReaderAiModelOption
|
|
import com.aryan.reader.shared.ReaderAiModelOptions
|
|
import com.aryan.reader.shared.ReaderAiResultState
|
|
import com.aryan.reader.shared.ReaderAction
|
|
import com.aryan.reader.shared.ReaderAutoScrollState
|
|
import com.aryan.reader.shared.ReaderCloudTtsState
|
|
import com.aryan.reader.shared.ReaderCloudTtsVoices
|
|
import com.aryan.reader.shared.ReaderContextExtractor
|
|
import com.aryan.reader.shared.ReaderExtrasState
|
|
import com.aryan.reader.shared.ReaderExternalLookupAction
|
|
import com.aryan.reader.shared.ReaderFeatureSurface
|
|
import com.aryan.reader.shared.ReaderHighlightPalette
|
|
import com.aryan.reader.shared.ReaderLocator
|
|
import com.aryan.reader.shared.ReaderPlatform
|
|
import com.aryan.reader.shared.ReaderTexture
|
|
import com.aryan.reader.shared.ReaderTextureFilePrefix
|
|
import com.aryan.reader.shared.ReaderTheme
|
|
import com.aryan.reader.shared.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.SharedFolderPathResolver
|
|
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.Shelf
|
|
import com.aryan.reader.shared.ShelfRecord
|
|
import com.aryan.reader.shared.ShelfType
|
|
import com.aryan.reader.shared.SmartCollectionDefinition
|
|
import com.aryan.reader.shared.SmartField
|
|
import com.aryan.reader.shared.SmartOperator
|
|
import com.aryan.reader.shared.SmartRule
|
|
import com.aryan.reader.shared.SyncedFolder
|
|
import com.aryan.reader.shared.Tag
|
|
import com.aryan.reader.shared.UserHighlight
|
|
import com.aryan.reader.shared.externalLookupUrl
|
|
import com.aryan.reader.shared.maskedReaderAiKey
|
|
import com.aryan.reader.shared.withTtsReplacements
|
|
import com.aryan.reader.shared.pdf.PdfAnnotationKind
|
|
import com.aryan.reader.shared.pdf.PdfInkTool
|
|
import com.aryan.reader.shared.pdf.PdfNormalizedPoint
|
|
import com.aryan.reader.shared.pdf.PdfPageBounds
|
|
import com.aryan.reader.shared.pdf.PdfPagePoint
|
|
import com.aryan.reader.shared.pdf.PdfSelectionGeometry
|
|
import com.aryan.reader.shared.pdf.PdfTextCharBounds
|
|
import com.aryan.reader.shared.pdf.PdfVisiblePageLayout
|
|
import com.aryan.reader.shared.pdf.PdfZoomSpec
|
|
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
|
|
import com.aryan.reader.shared.pdf.SharedPdfAnnotationDefaults
|
|
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.SharedPdfInkRenderer
|
|
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.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.reduce
|
|
import com.aryan.reader.shared.pdf.sharedPdfTextStyle
|
|
import com.aryan.reader.shared.pdf.sharedPdfStrokePercent
|
|
import com.aryan.reader.shared.pdf.sharedPdfStrokeWidthRange
|
|
import com.aryan.reader.shared.pdf.toAnnotation
|
|
import com.aryan.reader.shared.pdf.updateCurrentSharedPdfTextStyle
|
|
import com.aryan.reader.shared.pdf.withBounds
|
|
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.ReaderSettings
|
|
import com.aryan.reader.shared.reader.ReaderSessionState
|
|
import com.aryan.reader.shared.reader.SampleReaderBooks
|
|
import com.aryan.reader.shared.reader.SharedReaderTextAlign
|
|
import com.aryan.reader.shared.reader.SharedJvmBookLoader
|
|
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.NonReaderLibraryTab
|
|
import com.aryan.reader.shared.ui.ReaderContentNavigationTarget
|
|
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.SharedAboutScreen
|
|
import com.aryan.reader.shared.ui.SharedBookEditDialog
|
|
import com.aryan.reader.shared.ui.SharedBookInfoDialog
|
|
import com.aryan.reader.shared.ui.SharedConfirmDialog
|
|
import com.aryan.reader.shared.ui.SharedCustomFontsScreen
|
|
import com.aryan.reader.shared.ui.SharedHelpFeedbackScreen
|
|
import com.aryan.reader.shared.ui.SharedHomeScreen
|
|
import com.aryan.reader.shared.ui.SharedLibraryScreen
|
|
import com.aryan.reader.shared.ui.SharedMarkdownText
|
|
import com.aryan.reader.shared.ui.SharedOpdsScreen
|
|
import com.aryan.reader.shared.ui.SharedPdfAnnotationOverlay
|
|
import com.aryan.reader.shared.ui.SharedPdfAnnotationToolDock
|
|
import com.aryan.reader.shared.ui.SharedPdfEmbeddedAnnotationOverlay
|
|
import com.aryan.reader.shared.ui.SharedPdfInlineTextEditorOverlay
|
|
import com.aryan.reader.shared.ui.SharedPdfPageNumberOverlay
|
|
import com.aryan.reader.shared.ui.SharedPdfRichTextHiddenInput
|
|
import com.aryan.reader.shared.ui.SharedPdfRichTextLayer
|
|
import com.aryan.reader.shared.ui.SharedPdfTextAnnotationDock
|
|
import com.aryan.reader.shared.ui.SharedPdfTextBoxEditorOverlay
|
|
import com.aryan.reader.shared.ui.SharedPdfTextStyleControls
|
|
import com.aryan.reader.shared.ui.SharedReaderScreen
|
|
import com.aryan.reader.shared.ui.SharedReaderThemeControls
|
|
import com.aryan.reader.shared.ui.SharedReaderTtsReplacementControls
|
|
import com.aryan.reader.shared.ui.SharedShelvesScreen
|
|
import com.aryan.reader.shared.ui.SharedSupportProjectScreen
|
|
import com.aryan.reader.shared.ui.SharedTextInputDialog
|
|
import com.aryan.reader.shared.ui.pdfReaderWorkspaceModel
|
|
import com.aryan.reader.shared.ui.sharedPdfEmbeddedHitTest
|
|
import com.aryan.reader.shared.ui.sharedPdfHitTest
|
|
import com.aryan.reader.shared.ui.toSharedPdfPoint
|
|
import com.aryan.reader.shared.withImportedFiles
|
|
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.WebView
|
|
import com.multiplatform.webview.web.WebViewNavigator
|
|
import com.multiplatform.webview.web.rememberWebViewNavigator
|
|
import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData
|
|
import dev.datlag.kcef.KCEF
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.Job
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
import kotlinx.coroutines.isActive
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
import kotlinx.serialization.json.Json
|
|
import kotlinx.serialization.json.JsonNull
|
|
import kotlinx.serialization.json.contentOrNull
|
|
import kotlinx.serialization.json.intOrNull
|
|
import kotlinx.serialization.json.jsonObject
|
|
import kotlinx.serialization.json.jsonPrimitive
|
|
import java.awt.Desktop
|
|
import java.awt.Container
|
|
import java.awt.EventQueue
|
|
import java.awt.FileDialog
|
|
import java.awt.Frame
|
|
import java.awt.Component
|
|
import java.awt.datatransfer.DataFlavor
|
|
import java.awt.dnd.DnDConstants
|
|
import java.awt.dnd.DropTarget
|
|
import java.awt.dnd.DropTargetAdapter
|
|
import java.awt.dnd.DropTargetDragEvent
|
|
import java.awt.dnd.DropTargetEvent
|
|
import java.awt.dnd.DropTargetDropEvent
|
|
import java.io.ByteArrayInputStream
|
|
import java.io.File
|
|
import java.net.URI
|
|
import java.net.URLDecoder
|
|
import java.net.URLEncoder
|
|
import java.util.Base64
|
|
import java.util.Locale
|
|
import java.util.UUID
|
|
import java.util.concurrent.atomic.AtomicReference
|
|
import javax.imageio.ImageIO
|
|
import javax.swing.JOptionPane
|
|
import javax.swing.SwingUtilities
|
|
import javax.swing.JFileChooser
|
|
import kotlin.math.abs
|
|
import kotlin.math.max
|
|
import kotlin.math.roundToInt
|
|
|
|
fun main() {
|
|
configureComposeSwingInterop()
|
|
application {
|
|
Window(
|
|
onCloseRequest = ::exitApplication,
|
|
title = "Episteme",
|
|
) {
|
|
EpistemeDesktopApp(window)
|
|
}
|
|
}
|
|
}
|
|
|
|
internal const val ComposeInteropBlendingProperty = "compose.interop.blending"
|
|
internal const val ComposeInteropBlendingEnabled = "true"
|
|
|
|
internal fun configureComposeSwingInterop() {
|
|
// Must run before Compose creates the desktop window. Vertical EPUB embeds a Swing-backed
|
|
// JCEF WebView, and current Compose interop can leave a stale black native rectangle after
|
|
// that reader surface is removed unless interop blending is enabled.
|
|
if (System.getProperty(ComposeInteropBlendingProperty).isNullOrBlank()) {
|
|
System.setProperty(ComposeInteropBlendingProperty, ComposeInteropBlendingEnabled)
|
|
}
|
|
}
|
|
|
|
private data class DesktopWebViewRuntimeState(
|
|
val initialized: Boolean = false,
|
|
val restartRequired: Boolean = false,
|
|
val downloadProgress: Float = -1f,
|
|
val errorMessage: String? = null
|
|
)
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
private fun EpistemeDesktopApp(window: Component? = null) {
|
|
val libraryProjector = remember { SharedLibraryStateProjector(DesktopFolderPathResolver) }
|
|
val readerEngine = remember { ReaderEngine() }
|
|
val libraryDatabase = remember { DesktopLibraryDatabase() }
|
|
val customFontStore = remember { DesktopCustomFontStore() }
|
|
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()) }
|
|
val desktopAiAdapter = remember {
|
|
DesktopByokAiAdapter { aiByokSettings }
|
|
}
|
|
val desktopTtsAdapter = remember {
|
|
DesktopGeminiCloudTtsAdapter(settingsProvider = { aiByokSettings })
|
|
}
|
|
val initialLibrarySnapshot = remember { libraryDatabase.load() }
|
|
val scope = rememberCoroutineScope()
|
|
var webViewRuntimeState by remember { mutableStateOf(DesktopWebViewRuntimeState()) }
|
|
var readerCustomTextureIds by remember { mutableStateOf(DesktopReaderTextures.importedTextureIds()) }
|
|
|
|
LaunchedEffect(Unit) {
|
|
withContext(Dispatchers.IO) {
|
|
KCEF.init(
|
|
builder = {
|
|
installDir(File("kcef-bundle"))
|
|
progress {
|
|
onDownloading {
|
|
webViewRuntimeState = webViewRuntimeState.copy(downloadProgress = max(it, 0f))
|
|
}
|
|
onInitialized {
|
|
webViewRuntimeState = webViewRuntimeState.copy(initialized = true, errorMessage = null)
|
|
}
|
|
}
|
|
settings {
|
|
cachePath = File("cache").absolutePath
|
|
}
|
|
},
|
|
onError = { error ->
|
|
webViewRuntimeState = webViewRuntimeState.copy(errorMessage = error?.message ?: error.toString())
|
|
},
|
|
onRestartRequired = {
|
|
webViewRuntimeState = webViewRuntimeState.copy(restartRequired = true)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
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,
|
|
readerToolbarPreferences = initialLibrarySnapshot.readerToolbarPreferences,
|
|
readerHighlightPalette = initialLibrarySnapshot.readerHighlightPalette,
|
|
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) }
|
|
var readerSession by remember { mutableStateOf(readerEngine.createSession(SampleReaderBooks.desktopWelcomeBook())) }
|
|
var readerExtrasState by remember {
|
|
mutableStateOf(
|
|
ReaderExtrasState(
|
|
cloudTts = ReaderCloudTtsState(
|
|
isAvailable = aiByokSettings.isCloudTtsAvailable,
|
|
cacheSummary = desktopTtsAdapter.cacheSummary(
|
|
readerSession.reader.book.title,
|
|
aiByokSettings.sanitized().ttsSpeakerId
|
|
)
|
|
)
|
|
)
|
|
)
|
|
}
|
|
var activePdfDocument by remember { mutableStateOf<DesktopPdfDocument?>(null) }
|
|
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 bookInfoDialogFor by remember { mutableStateOf<BookItem?>(null) }
|
|
var bookEditDialogFor by remember { mutableStateOf<BookItem?>(null) }
|
|
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,
|
|
readerToolbarPreferences = projected.readerToolbarPreferences,
|
|
readerHighlightPalette = projected.readerHighlightPalette,
|
|
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 updateAiByokSettings(next: ReaderAiByokSettings) {
|
|
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() =
|
|
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) {
|
|
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()) return
|
|
scope.launch(Dispatchers.IO) {
|
|
DesktopLocalFolderSync.saveBookSidecars(book)
|
|
}
|
|
}
|
|
|
|
fun updateActiveBookReadingState(pageIndex: Int, progress: Float, session: ReaderSessionState? = null) {
|
|
activeReaderBookId?.let { bookId ->
|
|
var updatedBook: BookItem? = null
|
|
val next = state.copy(
|
|
rawLibraryBooks = state.rawLibraryBooks.map { book ->
|
|
if (book.id == bookId) {
|
|
book.copy(
|
|
progressPercentage = progress,
|
|
timestamp = System.currentTimeMillis(),
|
|
isRecent = true,
|
|
lastPageIndex = pageIndex,
|
|
readerSettings = session?.reader?.settings ?: book.readerSettings,
|
|
readerBookmarks = session?.bookmarks ?: book.readerBookmarks,
|
|
readerHighlights = session?.highlights ?: book.readerHighlights
|
|
).also { updatedBook = it }
|
|
} else {
|
|
book
|
|
}
|
|
}
|
|
)
|
|
updateState(next)
|
|
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()
|
|
if (error is kotlinx.coroutines.CancellationException) {
|
|
readerExtrasState = readerExtrasState.copy(
|
|
cloudTts = readerCloudTtsStoppedState(statusMessage = "Stopped")
|
|
)
|
|
} else {
|
|
readerExtrasState = 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 importFiles(files: List<ImportedBookFile>) {
|
|
val importableFiles = files.filter { it.desktopFileType() in DesktopReadableFileTypes }
|
|
if (importableFiles.isEmpty() && files.isNotEmpty()) {
|
|
updateState(
|
|
state.withBanner(
|
|
"No supported desktop reader files were selected. " +
|
|
"${SharedFileCapabilities.supportedFormatsLabel(ReaderPlatform.DESKTOP)} are supported.",
|
|
isError = true
|
|
)
|
|
)
|
|
return
|
|
}
|
|
val skipped = files.size - importableFiles.size
|
|
val existingIds = state.rawLibraryBooks.mapTo(mutableSetOf()) { it.id }
|
|
val importablePaths = importableFiles
|
|
.mapNotNull { it.localPath ?: it.uriString }
|
|
.toSet()
|
|
val syncedFolders = mergeSyncedFolders(
|
|
existing = state.syncedFolders,
|
|
folderRoots = importableFiles.mapNotNull { it.sourceFolder }.distinct(),
|
|
nowMillis = System.currentTimeMillis()
|
|
)
|
|
val next = state.withImportedFiles(importableFiles)
|
|
.copy(syncedFolders = syncedFolders)
|
|
.let {
|
|
when {
|
|
skipped > 0 -> it.withBanner("Imported supported files. Skipped $skipped unsupported file(s).")
|
|
else -> it
|
|
}
|
|
}
|
|
updateState(next)
|
|
val targetBookIds = next.rawLibraryBooks
|
|
.asSequence()
|
|
.filter { book ->
|
|
book.id !in existingIds ||
|
|
book.path in importablePaths ||
|
|
book.id in importablePaths
|
|
}
|
|
.map { it.id }
|
|
.toSet()
|
|
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 syncLocalFolders(targetFolder: File? = null, showBanner: Boolean = true) {
|
|
if (targetFolder == null && state.syncedFolders.isEmpty()) {
|
|
updateState(state.withBanner("No local folders are linked yet.", isError = true))
|
|
return
|
|
}
|
|
|
|
val snapshotState = state
|
|
val snapshotShelfRefs = shelfRefs
|
|
if (showBanner) {
|
|
updateState(state.withBanner("Folder sync: scanning local folders..."))
|
|
}
|
|
|
|
scope.launch {
|
|
val result = withContext(Dispatchers.IO) {
|
|
DesktopLocalFolderSync.sync(
|
|
state = snapshotState,
|
|
shelfRefs = snapshotShelfRefs,
|
|
targetFolder = targetFolder
|
|
)
|
|
}
|
|
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."
|
|
else ->
|
|
"Folder sync complete: ${stats.newBooks} new, ${stats.updatedBooks + stats.remoteMetadataUpdates + metadataStats.updatedBooks} updated, ${stats.removedBooks} removed."
|
|
}
|
|
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 }) {
|
|
activePdfDocument?.close()
|
|
activePdfDocument = null
|
|
activeReaderBookId = null
|
|
readerSession = readerEngine.createSession(SampleReaderBooks.desktopWelcomeBook())
|
|
selectedTab = SharedAppTab.HOME
|
|
}
|
|
}
|
|
}
|
|
|
|
fun importFolder(folder: File) {
|
|
if (!DesktopLocalFolderSync.hasSupportedFiles(folder)) {
|
|
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) {
|
|
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 updateBookMetadata(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 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 openReader(book: BookItem) {
|
|
val desktopReaderSurface = SharedFileCapabilities.surfaceFor(book.type, ReaderPlatform.DESKTOP)
|
|
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) {
|
|
if (activePdfDocument?.path == path) {
|
|
activeReaderBookId = book.id
|
|
recordBookOpened(book.id)
|
|
selectedTab = SharedAppTab.READER
|
|
return
|
|
}
|
|
activePdfDocument?.close()
|
|
activePdfDocument = null
|
|
val document = runCatching {
|
|
DesktopPdfium.loadOpdsStream(
|
|
path = path,
|
|
title = book.title?.takeIf { it.isNotBlank() } ?: book.displayName,
|
|
reference = streamReference,
|
|
catalog = opdsRepository.catalogById(streamReference.catalogId)
|
|
)
|
|
}.getOrElse { error ->
|
|
updateState(
|
|
state.withBanner(
|
|
"Could not open OPDS stream: ${error.message ?: "unknown error"}",
|
|
isError = true
|
|
)
|
|
)
|
|
return
|
|
}
|
|
activePdfDocument = document
|
|
activeReaderBookId = book.id
|
|
recordBookOpened(book.id)
|
|
selectedTab = SharedAppTab.READER
|
|
return
|
|
}
|
|
val readerFile = File(path)
|
|
val readerPath = readerFile.absolutePath
|
|
if (activePdfDocument?.path == readerPath) {
|
|
activeReaderBookId = book.id
|
|
recordBookOpened(book.id)
|
|
selectedTab = SharedAppTab.READER
|
|
return
|
|
}
|
|
activePdfDocument?.close()
|
|
activePdfDocument = null
|
|
val document = runCatching {
|
|
if (book.type == FileType.PDF) {
|
|
DesktopPdfium.load(readerFile)
|
|
} else {
|
|
DesktopPdfium.loadComic(readerFile, book.type)
|
|
}
|
|
}.getOrElse { error ->
|
|
updateState(
|
|
state.withBanner(
|
|
"Could not open ${SharedFileCapabilities.displayNameFor(book.type)}: " +
|
|
(error.message ?: "unknown error"),
|
|
isError = true
|
|
)
|
|
)
|
|
return
|
|
}
|
|
|
|
activePdfDocument = document
|
|
activeReaderBookId = book.id
|
|
recordBookOpened(book.id)
|
|
selectedTab = SharedAppTab.READER
|
|
return
|
|
}
|
|
|
|
if (desktopReaderSurface != ReaderFeatureSurface.EPUB_READER && desktopReaderSurface != ReaderFeatureSurface.TEXT_READER) {
|
|
updateState(
|
|
state.withBanner(
|
|
"${SharedFileCapabilities.displayNameFor(book.type)} reader support comes later. " +
|
|
"${SharedFileCapabilities.supportedFormatsLabel(ReaderPlatform.DESKTOP)} are available on desktop."
|
|
)
|
|
)
|
|
return
|
|
}
|
|
|
|
val loadedBook = runCatching {
|
|
val path = book.path
|
|
if (path.isNullOrBlank()) {
|
|
SampleReaderBooks.desktopWelcomeBook()
|
|
} else {
|
|
SharedJvmBookLoader.load(
|
|
file = File(path),
|
|
type = book.type,
|
|
titleOverride = book.title?.takeIf { it.isNotBlank() },
|
|
authorOverride = book.author?.takeIf { it.isNotBlank() }
|
|
)
|
|
}
|
|
}.getOrElse { error ->
|
|
updateState(state.withBanner("Could not open ${book.type.name}: ${error.message ?: "unknown error"}", isError = true))
|
|
return
|
|
}
|
|
|
|
activePdfDocument?.close()
|
|
activePdfDocument = null
|
|
val restoredSettings = book.readerSettings ?: readerSession.reader.settings
|
|
val restoredSession = readerEngine.createSession(
|
|
book = loadedBook,
|
|
settings = restoredSettings,
|
|
initialPageIndex = book.lastPageIndex ?: 0,
|
|
bookmarks = book.readerBookmarks,
|
|
highlights = book.readerHighlights
|
|
)
|
|
val restoredProgress = book.progressPercentage
|
|
readerSession = if (book.lastPageIndex == null && restoredProgress != null) {
|
|
readerEngine.goToProgress(restoredSession, restoredProgress.coerceIn(0f, 100f) / 100f)
|
|
} else {
|
|
restoredSession
|
|
}
|
|
activeReaderBookId = book.id
|
|
recordBookOpened(book.id)
|
|
selectedTab = SharedAppTab.READER
|
|
}
|
|
|
|
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) {
|
|
activePdfDocument?.close()
|
|
activePdfDocument = null
|
|
activeReaderBookId = null
|
|
if (nextTabBook != null) {
|
|
openReader(nextTabBook)
|
|
} else {
|
|
readerSession = readerEngine.createSession(SampleReaderBooks.desktopWelcomeBook())
|
|
selectedTab = SharedAppTab.HOME
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fun closeReaderTab(book: BookItem) {
|
|
val wasActive = activeReaderBookId == book.id
|
|
val remainingIds = state.openTabIds.filterNot { it == book.id }
|
|
updateState(state.reduce(AppAction.BookTabClosed(book.id)))
|
|
if (!wasActive) return
|
|
|
|
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(SampleReaderBooks.desktopWelcomeBook())
|
|
selectedTab = SharedAppTab.HOME
|
|
}
|
|
}
|
|
|
|
fun closeAllReaderTabs() {
|
|
activePdfDocument?.close()
|
|
activePdfDocument = null
|
|
activeReaderBookId = null
|
|
readerSession = readerEngine.createSession(SampleReaderBooks.desktopWelcomeBook())
|
|
selectedTab = SharedAppTab.HOME
|
|
updateState(state.reduce(AppAction.AllTabsClosed))
|
|
}
|
|
|
|
fun importAndOpenBook() {
|
|
val file = chooseBookFile() ?: return
|
|
val importedFile = file.toImportedBookFile()
|
|
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))
|
|
openReader(
|
|
BookItem(
|
|
id = file.absolutePath,
|
|
path = file.absolutePath,
|
|
type = type,
|
|
displayName = file.name,
|
|
timestamp = System.currentTimeMillis(),
|
|
title = file.nameWithoutExtension,
|
|
fileSize = file.length()
|
|
)
|
|
)
|
|
}
|
|
|
|
fun importAndOpenPdf() {
|
|
val file = choosePdfFile() ?: return
|
|
importFiles(listOf(file.toImportedBookFile()))
|
|
openReader(
|
|
BookItem(
|
|
id = file.absolutePath,
|
|
path = file.absolutePath,
|
|
type = FileType.PDF,
|
|
displayName = file.name,
|
|
timestamp = System.currentTimeMillis(),
|
|
title = file.nameWithoutExtension,
|
|
fileSize = file.length()
|
|
)
|
|
)
|
|
}
|
|
|
|
fun emitOpds(next: com.aryan.reader.shared.opds.SharedOpdsScreenState) {
|
|
opdsState = next
|
|
}
|
|
|
|
fun openOpdsCatalog(catalog: OpdsCatalog) {
|
|
scope.launch {
|
|
opdsController.openCatalog(catalog, ::emitOpds)
|
|
}
|
|
}
|
|
|
|
fun openOpdsFeedUrl(url: String) {
|
|
scope.launch {
|
|
opdsController.openFeedUrl(url, ::emitOpds)
|
|
}
|
|
}
|
|
|
|
fun navigateOpdsBack() {
|
|
scope.launch {
|
|
opdsController.navigateBack(::emitOpds)
|
|
}
|
|
}
|
|
|
|
fun searchOpds(query: String) {
|
|
scope.launch {
|
|
opdsController.search(query, ::emitOpds)
|
|
}
|
|
}
|
|
|
|
fun loadNextOpdsPage() {
|
|
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(SampleReaderBooks.desktopWelcomeBook())
|
|
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) {
|
|
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.toImportedBookFile()))
|
|
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?) {
|
|
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()) {
|
|
syncLocalFolders(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
|
|
) {
|
|
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,
|
|
onTabSelected = { selectedTab = it },
|
|
onImportFiles = { importFiles(chooseFiles()) },
|
|
onImportFolder = { chooseFolder()?.let(::importFolder) },
|
|
onSyncRequested = {
|
|
syncLocalFolders()
|
|
},
|
|
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 -> updateState(state.reduce(AppAction.TabsEnabledChanged(enabled))) },
|
|
onAiSettingsRequested = { showAiByokSettingsDialog = true }
|
|
) { 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 = { bookInfoDialogFor = it },
|
|
onEditBook = { bookEditDialogFor = 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))) }
|
|
)
|
|
|
|
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 = { bookInfoDialogFor = it },
|
|
onEditBook = { bookEditDialogFor = it },
|
|
onCreateShelf = { showCreateShelfDialog = true },
|
|
onCreateSmartShelf = { showCreateSmartShelfDialog = true },
|
|
onRenameShelf = { shelfToRename = it },
|
|
onDeleteShelf = { shelfToDelete = it },
|
|
onRemoveFolder = { folderToRemove = it },
|
|
onTagSelectedBooks = { showTagSelectionDialog = true },
|
|
onAddSelectedBooksToShelf = { showAddToShelfDialog = true },
|
|
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 = { bookInfoDialogFor = it },
|
|
onEditBook = { bookEditDialogFor = 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 -> 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()) }
|
|
)
|
|
|
|
SharedAppTab.CUSTOM_FONTS -> SharedCustomFontsScreen(
|
|
fonts = customFonts,
|
|
onImportFont = { importCustomFont(chooseFontFile()) },
|
|
onDeleteFont = ::deleteCustomFont,
|
|
googleFontsAvailable = true,
|
|
getGoogleFonts = { customFontStore.loadGoogleFontsList() },
|
|
onDownloadGoogleFont = ::downloadGoogleFont,
|
|
fontFamilyForPreview = { font -> font.toDesktopPreviewFontFamily() }
|
|
)
|
|
|
|
SharedAppTab.FEEDBACK -> SharedHelpFeedbackScreen(
|
|
onOpenGitHubIssues = { openExternalUrl(EpistemeIssuesUrl) },
|
|
onEmailSupport = {
|
|
openExternalUrl("mailto:$EpistemeSupportEmail?subject=${EpistemeFeedbackSubject.urlEncode()}")
|
|
}
|
|
)
|
|
|
|
SharedAppTab.SUPPORT -> SharedSupportProjectScreen(
|
|
onOpenGitHubSponsors = { openExternalUrl(EpistemeGitHubSponsorsUrl) },
|
|
onOpenPatreon = { openExternalUrl(EpistemePatreonUrl) }
|
|
)
|
|
|
|
SharedAppTab.ABOUT -> SharedAboutScreen(
|
|
versionName = desktopAppVersionName(),
|
|
buildLabel = "Desktop build",
|
|
onOpenSource = { openExternalUrl(EpistemeSourceUrl) },
|
|
onOpenIssues = { openExternalUrl(EpistemeIssuesUrl) }
|
|
)
|
|
|
|
SharedAppTab.READER -> {
|
|
val pdfDocument = activePdfDocument
|
|
if (pdfDocument != null) {
|
|
PdfReaderScreen(
|
|
document = pdfDocument,
|
|
initialPageIndex = activeReaderBookId
|
|
?.let { bookId -> state.rawLibraryBooks.find { it.id == bookId }?.lastPageIndex }
|
|
?: 0,
|
|
initialReaderSettings = activeReaderBookId
|
|
?.let { bookId -> state.rawLibraryBooks.find { it.id == bookId }?.readerSettings },
|
|
onOpenPdf = ::importAndOpenPdf,
|
|
onOpenBook = ::importAndOpenBook,
|
|
onPageStateChange = { page, progress ->
|
|
updateActiveBookReadingState(page, progress)
|
|
},
|
|
onReaderSettingsChange = ::updateActiveBookReaderSettings,
|
|
customTextureIds = readerCustomTextureIds,
|
|
onImportTexture = ::importDesktopReaderTexture,
|
|
onLocalSidecarsChanged = {
|
|
activeReaderBookId
|
|
?.let { bookId -> state.rawLibraryBooks.firstOrNull { it.id == bookId } }
|
|
?.let(::syncBookSidecars)
|
|
},
|
|
aiByokSettings = aiByokSettings,
|
|
aiAdapter = desktopAiAdapter,
|
|
ttsAdapter = desktopTtsAdapter
|
|
)
|
|
} else {
|
|
ReaderScreen(
|
|
session = readerSession,
|
|
readerEngine = readerEngine,
|
|
onSessionChange = { updated ->
|
|
readerSession = updated
|
|
updateActiveBookReadingState(
|
|
pageIndex = updated.reader.currentPageIndex,
|
|
progress = updated.reader.progress,
|
|
session = updated
|
|
)
|
|
},
|
|
onOpenBook = ::importAndOpenBook,
|
|
onOpenPdf = ::importAndOpenPdf,
|
|
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,
|
|
onExternalLookup = ::openReaderExternalLookup,
|
|
onAiAction = ::runReaderAiAction,
|
|
onCloudTtsToggle = ::toggleReaderCloudTts,
|
|
onCloudTtsStart = ::startReaderCloudTts,
|
|
onCloudTtsPauseResume = ::pauseResumeReaderCloudTts,
|
|
onCloudTtsStop = ::stopReaderCloudTts,
|
|
onCloudTtsClearCache = ::clearReaderCloudTtsCache,
|
|
onAutoScrollChange = ::updateReaderAutoScroll,
|
|
readerTextureDataUri = DesktopReaderTextures::dataUriFor,
|
|
readerCustomTextureIds = readerCustomTextureIds,
|
|
onImportReaderTexture = ::importDesktopReaderTexture,
|
|
webViewRuntimeState = webViewRuntimeState
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
DesktopDropImportOverlay(dropImportState)
|
|
}
|
|
|
|
if (showAiByokSettingsDialog) {
|
|
DesktopAiByokSettingsDialog(
|
|
settings = aiByokSettings,
|
|
secureStorageAvailable = aiByokStore.isSecureStorageAvailable,
|
|
onSettingsChange = ::updateAiByokSettings,
|
|
onDismiss = { showAiByokSettingsDialog = 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 ->
|
|
SharedBookInfoDialog(
|
|
book = book,
|
|
onDismiss = { bookInfoDialogFor = null },
|
|
onEdit = {
|
|
bookEditDialogFor = book
|
|
bookInfoDialogFor = null
|
|
}
|
|
)
|
|
}
|
|
|
|
bookEditDialogFor?.let { book ->
|
|
SharedBookEditDialog(
|
|
book = book,
|
|
knownTags = state.allTags,
|
|
onDismiss = { bookEditDialogFor = null },
|
|
onSave = { updated ->
|
|
updateBookMetadata(updated)
|
|
bookEditDialogFor = null
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private data class DesktopDropImportState(
|
|
val active: Boolean = false,
|
|
val supportedCount: Int = 0,
|
|
val totalFileCount: Int = 0,
|
|
val hasFilePayload: Boolean = false
|
|
)
|
|
|
|
@Composable
|
|
private fun DesktopFileDropTarget(
|
|
window: Component?,
|
|
onFilesDropped: (List<ImportedBookFile>) -> Unit,
|
|
onDragStateChange: (DesktopDropImportState) -> Unit
|
|
) {
|
|
val onFilesDroppedState = rememberUpdatedState(onFilesDropped)
|
|
val onDragStateChangeState = rememberUpdatedState(onDragStateChange)
|
|
|
|
DisposableEffect(window) {
|
|
if (window == null) {
|
|
onDispose { }
|
|
} else {
|
|
val installedTargets = mutableListOf<InstalledDropTarget>()
|
|
var disposed = false
|
|
var lastDragState = DesktopDropImportState()
|
|
|
|
fun publishDragState(state: DesktopDropImportState) {
|
|
if (state == lastDragState) return
|
|
lastDragState = state
|
|
onDragStateChangeState.value(state)
|
|
}
|
|
|
|
val listener = object : DropTargetAdapter() {
|
|
override fun dragEnter(event: DropTargetDragEvent) {
|
|
handleDrag(event)
|
|
}
|
|
|
|
override fun dragOver(event: DropTargetDragEvent) {
|
|
handleDrag(event)
|
|
}
|
|
|
|
override fun dragExit(event: DropTargetEvent) {
|
|
publishDragState(DesktopDropImportState())
|
|
}
|
|
|
|
override fun drop(event: DropTargetDropEvent) {
|
|
if (!event.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
|
|
event.rejectDrop()
|
|
publishDragState(DesktopDropImportState())
|
|
return
|
|
}
|
|
event.acceptDrop(DnDConstants.ACTION_COPY)
|
|
val files = event.transferable.localDraggedFiles().filter { it.isFile }
|
|
if (files.isEmpty()) {
|
|
event.dropComplete(false)
|
|
publishDragState(DesktopDropImportState())
|
|
return
|
|
}
|
|
|
|
onFilesDroppedState.value(files.map { it.toImportedBookFile() })
|
|
event.dropComplete(true)
|
|
publishDragState(DesktopDropImportState())
|
|
}
|
|
|
|
private fun handleDrag(event: DropTargetDragEvent) {
|
|
val hasFilePayload = event.isDataFlavorSupported(DataFlavor.javaFileListFlavor)
|
|
publishDragState(
|
|
DesktopDropImportState(
|
|
active = true,
|
|
hasFilePayload = hasFilePayload
|
|
)
|
|
)
|
|
if (hasFilePayload) {
|
|
event.acceptDrag(DnDConstants.ACTION_COPY)
|
|
} else {
|
|
event.rejectDrag()
|
|
}
|
|
}
|
|
}
|
|
window.installDropTargets(listener, installedTargets)
|
|
EventQueue.invokeLater {
|
|
if (!disposed) {
|
|
window.installDropTargets(listener, installedTargets)
|
|
}
|
|
}
|
|
|
|
onDispose {
|
|
disposed = true
|
|
installedTargets.forEach { installed ->
|
|
runCatching { installed.dropTarget.removeDropTargetListener(listener) }
|
|
installed.component.dropTarget = installed.previous
|
|
}
|
|
publishDragState(DesktopDropImportState())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private data class InstalledDropTarget(
|
|
val component: Component,
|
|
val previous: DropTarget?,
|
|
val dropTarget: DropTarget
|
|
)
|
|
|
|
private fun Component.installDropTargets(
|
|
listener: DropTargetAdapter,
|
|
installedTargets: MutableList<InstalledDropTarget>
|
|
) {
|
|
collectDropTargetComponents()
|
|
.distinct()
|
|
.filterNot { component -> installedTargets.any { it.component == component } }
|
|
.forEach { component ->
|
|
val previous = component.dropTarget
|
|
val target = DropTarget(component, DnDConstants.ACTION_COPY, listener, true)
|
|
installedTargets += InstalledDropTarget(component, previous, target)
|
|
}
|
|
}
|
|
|
|
private fun Component.collectDropTargetComponents(): List<Component> {
|
|
val collected = mutableListOf<Component>()
|
|
|
|
fun visit(component: Component) {
|
|
collected += component
|
|
if (component is Container) {
|
|
component.components.forEach(::visit)
|
|
}
|
|
}
|
|
|
|
visit(this)
|
|
return collected
|
|
}
|
|
|
|
@Composable
|
|
private fun DesktopDropImportOverlay(state: DesktopDropImportState) {
|
|
if (!state.active) return
|
|
|
|
val hasSupportedFiles = state.supportedCount > 0
|
|
val title = when {
|
|
hasSupportedFiles -> "Drop to import ${state.supportedCount} file${if (state.supportedCount == 1) "" else "s"}"
|
|
state.hasFilePayload -> "Drop supported files to import"
|
|
else -> "Drop files to import"
|
|
}
|
|
val body = if (hasSupportedFiles) {
|
|
val skipped = state.totalFileCount - state.supportedCount
|
|
if (skipped > 0) {
|
|
"$skipped unsupported file${if (skipped == 1) "" else "s"} will be skipped."
|
|
} else {
|
|
"Release to add to your library."
|
|
}
|
|
} else {
|
|
SharedFileCapabilities.supportedFormatsLabel(ReaderPlatform.DESKTOP)
|
|
}
|
|
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.zIndex(20f)
|
|
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.36f)),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Surface(
|
|
shape = RoundedCornerShape(8.dp),
|
|
color = MaterialTheme.colorScheme.surface,
|
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
|
tonalElevation = 8.dp,
|
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.55f))
|
|
) {
|
|
Column(
|
|
modifier = Modifier.padding(horizontal = 30.dp, vertical = 24.dp),
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
) {
|
|
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
|
Text(
|
|
body,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
textAlign = TextAlign.Center
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun java.awt.datatransfer.Transferable.localDraggedFiles(): List<File> {
|
|
if (!isDataFlavorSupported(DataFlavor.javaFileListFlavor)) return emptyList()
|
|
return runCatching {
|
|
@Suppress("UNCHECKED_CAST")
|
|
(getTransferData(DataFlavor.javaFileListFlavor) as? List<*>)
|
|
.orEmpty()
|
|
.filterIsInstance<File>()
|
|
}.getOrDefault(emptyList())
|
|
}
|
|
|
|
private fun BookItem.withDesktopImportMetadata(
|
|
enriched: BookItem,
|
|
original: BookItem?
|
|
): BookItem {
|
|
fun shouldApplyText(current: String?, originalValue: String?): Boolean {
|
|
return current.isNullOrBlank() || current == originalValue
|
|
}
|
|
|
|
return copy(
|
|
title = if (shouldApplyText(title, original?.title)) {
|
|
enriched.title ?: title
|
|
} else {
|
|
title
|
|
},
|
|
author = if (shouldApplyText(author, original?.author)) {
|
|
enriched.author ?: author
|
|
} else {
|
|
author
|
|
},
|
|
fileSize = enriched.fileSize.takeIf { it > 0L } ?: fileSize,
|
|
coverImagePath = coverImagePath?.takeIf { File(it).isFile } ?: enriched.coverImagePath,
|
|
folderTextMetadataParsed = folderTextMetadataParsed || enriched.folderTextMetadataParsed
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
private fun HomeScreen(
|
|
state: SharedReaderScreenState,
|
|
onImportBooks: () -> Unit,
|
|
onImportFolder: () -> Unit,
|
|
onRead: (BookItem) -> Unit,
|
|
onSelect: (String) -> Unit,
|
|
onClearSelection: () -> Unit,
|
|
onRemoveSelected: () -> Unit,
|
|
onShowBookInfo: (BookItem) -> Unit,
|
|
onEditBook: (BookItem) -> Unit,
|
|
onTagSelectedBooks: () -> Unit,
|
|
onAddSelectedBooksToShelf: () -> Unit,
|
|
onOpenTab: (BookItem) -> Unit,
|
|
onCloseTab: (BookItem) -> Unit,
|
|
onCloseAllTabs: () -> Unit,
|
|
onRecentLimitChange: (Int) -> Unit,
|
|
onTogglePinned: (BookItem) -> Unit
|
|
) {
|
|
SharedHomeScreen(
|
|
state = state,
|
|
onImportBooks = onImportBooks,
|
|
onImportFolder = onImportFolder,
|
|
onOpenBook = onRead,
|
|
onToggleSelection = onSelect,
|
|
onClearSelection = onClearSelection,
|
|
onRemoveSelected = onRemoveSelected,
|
|
onShowBookInfo = onShowBookInfo,
|
|
onEditBook = onEditBook,
|
|
onTagSelectedBooks = onTagSelectedBooks,
|
|
onAddSelectedBooksToShelf = onAddSelectedBooksToShelf,
|
|
onOpenTab = onOpenTab,
|
|
onCloseTab = onCloseTab,
|
|
onCloseAllTabs = onCloseAllTabs,
|
|
onRecentLimitChange = onRecentLimitChange,
|
|
onTogglePinned = onTogglePinned
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
private fun LibraryScreen(
|
|
state: SharedReaderScreenState,
|
|
selectedLibraryTab: NonReaderLibraryTab,
|
|
onLibraryTabChange: (NonReaderLibraryTab) -> Unit,
|
|
onStateChange: (SharedReaderScreenState) -> Unit,
|
|
onImportBooks: () -> Unit,
|
|
onRead: (BookItem) -> Unit,
|
|
onSelect: (String) -> Unit,
|
|
onClearSelection: () -> Unit,
|
|
onRemoveSelected: () -> Unit,
|
|
onShowBookInfo: (BookItem) -> Unit,
|
|
onEditBook: (BookItem) -> Unit,
|
|
onCreateShelf: () -> Unit,
|
|
onCreateSmartShelf: () -> Unit,
|
|
onRenameShelf: (Shelf) -> Unit,
|
|
onDeleteShelf: (Shelf) -> Unit,
|
|
onRemoveFolder: (Shelf) -> Unit,
|
|
onTagSelectedBooks: () -> Unit,
|
|
onAddSelectedBooksToShelf: () -> Unit,
|
|
onImportFolder: () -> Unit,
|
|
onTogglePinned: (BookItem) -> Unit
|
|
) {
|
|
SharedLibraryScreen(
|
|
state = state,
|
|
selectedTab = selectedLibraryTab,
|
|
onTabChange = onLibraryTabChange,
|
|
onStateChange = onStateChange,
|
|
onImportBooks = onImportBooks,
|
|
onOpenBook = onRead,
|
|
onToggleSelection = onSelect,
|
|
onClearSelection = onClearSelection,
|
|
onRemoveSelected = onRemoveSelected,
|
|
onShowBookInfo = onShowBookInfo,
|
|
onEditBook = onEditBook,
|
|
onCreateShelf = onCreateShelf,
|
|
onCreateSmartShelf = onCreateSmartShelf,
|
|
onRenameShelf = onRenameShelf,
|
|
onDeleteShelf = onDeleteShelf,
|
|
onRemoveFolder = onRemoveFolder,
|
|
onTagSelectedBooks = onTagSelectedBooks,
|
|
onAddSelectedBooksToShelf = onAddSelectedBooksToShelf,
|
|
onImportFolder = onImportFolder,
|
|
onTogglePinned = onTogglePinned
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
private fun ShelvesScreen(
|
|
shelves: List<Shelf>,
|
|
selectedBookIds: Set<String>,
|
|
pinnedBookIds: Set<String>,
|
|
onRead: (BookItem) -> Unit,
|
|
onSelect: (String) -> Unit,
|
|
onShowBookInfo: (BookItem) -> Unit,
|
|
onEditBook: (BookItem) -> Unit,
|
|
onTogglePinned: (BookItem) -> Unit,
|
|
onCreateShelf: () -> Unit,
|
|
onCreateSmartShelf: () -> Unit,
|
|
onRenameShelf: (Shelf) -> Unit,
|
|
onDeleteShelf: (Shelf) -> Unit,
|
|
onRemoveFolder: (Shelf) -> Unit
|
|
) {
|
|
SharedShelvesScreen(
|
|
shelves = shelves,
|
|
selectedBookIds = selectedBookIds,
|
|
pinnedBookIds = pinnedBookIds,
|
|
onOpenBook = onRead,
|
|
onToggleSelection = onSelect,
|
|
onShowBookInfo = onShowBookInfo,
|
|
onEditBook = onEditBook,
|
|
onTogglePinned = onTogglePinned,
|
|
onCreateShelf = onCreateShelf,
|
|
onCreateSmartShelf = onCreateSmartShelf,
|
|
onRenameShelf = onRenameShelf,
|
|
onDeleteShelf = onDeleteShelf,
|
|
onRemoveFolder = onRemoveFolder
|
|
)
|
|
}
|
|
|
|
private data class DesktopSmartRuleDraft(
|
|
val field: SmartField = SmartField.TITLE,
|
|
val operator: SmartOperator = SmartOperator.CONTAINS,
|
|
val value: String = ""
|
|
) {
|
|
fun toRule(): SmartRule? {
|
|
val trimmed = value.trim()
|
|
if (trimmed.isBlank()) return null
|
|
return SmartRule(field = field, operator = operator, value = trimmed)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun SmartShelfDialog(
|
|
onDismiss: () -> Unit,
|
|
onConfirm: (String, SmartCollectionDefinition) -> Unit
|
|
) {
|
|
var name by remember { mutableStateOf("") }
|
|
var matchAll by remember { mutableStateOf(true) }
|
|
var rules by remember { mutableStateOf(listOf(DesktopSmartRuleDraft())) }
|
|
val validRules = rules.mapNotNull { it.toRule() }
|
|
|
|
AlertDialog(
|
|
onDismissRequest = onDismiss,
|
|
title = { Text("Create smart shelf") },
|
|
text = {
|
|
Column(
|
|
modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
) {
|
|
OutlinedTextField(
|
|
value = name,
|
|
onValueChange = { name = it },
|
|
label = { Text("Shelf name") },
|
|
singleLine = true,
|
|
modifier = Modifier.fillMaxWidth()
|
|
)
|
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
FilterChip(
|
|
selected = matchAll,
|
|
onClick = { matchAll = true },
|
|
label = { Text("All") }
|
|
)
|
|
FilterChip(
|
|
selected = !matchAll,
|
|
onClick = { matchAll = false },
|
|
label = { Text("Any") }
|
|
)
|
|
Spacer(Modifier.weight(1f))
|
|
TextButton(
|
|
onClick = { rules = rules + DesktopSmartRuleDraft() },
|
|
enabled = rules.size < 4
|
|
) {
|
|
Text("Add rule")
|
|
}
|
|
}
|
|
rules.forEachIndexed { index, draft ->
|
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
SmartRuleDropdown(
|
|
label = "Field",
|
|
selected = draft.field,
|
|
options = SmartField.entries.toList(),
|
|
optionLabel = { it.desktopLabel() },
|
|
onSelected = { field ->
|
|
rules = rules.updateAt(index) {
|
|
val operator = smartOperatorsFor(field).first()
|
|
copy(field = field, operator = operator, value = "")
|
|
}
|
|
}
|
|
)
|
|
SmartRuleDropdown(
|
|
label = "Operator",
|
|
selected = draft.operator,
|
|
options = smartOperatorsFor(draft.field),
|
|
optionLabel = { it.desktopLabel() },
|
|
onSelected = { operator ->
|
|
rules = rules.updateAt(index) { copy(operator = operator) }
|
|
}
|
|
)
|
|
if (rules.size > 1) {
|
|
TextButton(onClick = { rules = rules.filterIndexed { i, _ -> i != index } }) {
|
|
Text("Remove")
|
|
}
|
|
}
|
|
}
|
|
OutlinedTextField(
|
|
value = draft.value,
|
|
onValueChange = { value -> rules = rules.updateAt(index) { copy(value = value) } },
|
|
label = { Text(draft.field.valueLabel()) },
|
|
singleLine = true,
|
|
modifier = Modifier.fillMaxWidth()
|
|
)
|
|
}
|
|
}
|
|
}
|
|
},
|
|
confirmButton = {
|
|
TextButton(
|
|
onClick = {
|
|
onConfirm(name, SmartCollectionDefinition(matchAll = matchAll, rules = validRules))
|
|
},
|
|
enabled = name.isNotBlank() && validRules.isNotEmpty()
|
|
) {
|
|
Text("Create")
|
|
}
|
|
},
|
|
dismissButton = {
|
|
TextButton(onClick = onDismiss) {
|
|
Text("Cancel")
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
private fun <T> SmartRuleDropdown(
|
|
label: String,
|
|
selected: T,
|
|
options: List<T>,
|
|
optionLabel: (T) -> String,
|
|
onSelected: (T) -> Unit
|
|
) {
|
|
var expanded by remember { mutableStateOf(false) }
|
|
Box {
|
|
TextButton(onClick = { expanded = true }) {
|
|
Text("$label: ${optionLabel(selected)}")
|
|
}
|
|
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
|
options.forEach { option ->
|
|
DropdownMenuItem(
|
|
text = { Text(optionLabel(option)) },
|
|
onClick = {
|
|
expanded = false
|
|
onSelected(option)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun smartOperatorsFor(field: SmartField): List<SmartOperator> {
|
|
return when (field) {
|
|
SmartField.PROGRESS -> listOf(SmartOperator.GREATER_THAN, SmartOperator.LESS_THAN, SmartOperator.EQUALS)
|
|
else -> listOf(SmartOperator.CONTAINS, SmartOperator.EQUALS)
|
|
}
|
|
}
|
|
|
|
private fun SmartField.desktopLabel(): String {
|
|
return when (this) {
|
|
SmartField.TITLE -> "Title"
|
|
SmartField.AUTHOR -> "Author"
|
|
SmartField.PROGRESS -> "Progress"
|
|
SmartField.FILE_TYPE -> "File type"
|
|
SmartField.FOLDER -> "Folder"
|
|
SmartField.TAG -> "Tag"
|
|
}
|
|
}
|
|
|
|
private fun SmartField.valueLabel(): String {
|
|
return when (this) {
|
|
SmartField.PROGRESS -> "Percent"
|
|
SmartField.FILE_TYPE -> "Type, e.g. PDF"
|
|
SmartField.FOLDER -> "Folder path"
|
|
SmartField.TAG -> "Tag name"
|
|
SmartField.TITLE -> "Title text"
|
|
SmartField.AUTHOR -> "Author text"
|
|
}
|
|
}
|
|
|
|
private fun SmartOperator.desktopLabel(): String {
|
|
return when (this) {
|
|
SmartOperator.EQUALS -> "Equals"
|
|
SmartOperator.CONTAINS -> "Contains"
|
|
SmartOperator.GREATER_THAN -> "Greater than"
|
|
SmartOperator.LESS_THAN -> "Less than"
|
|
}
|
|
}
|
|
|
|
private inline fun List<DesktopSmartRuleDraft>.updateAt(
|
|
index: Int,
|
|
transform: DesktopSmartRuleDraft.() -> DesktopSmartRuleDraft
|
|
): List<DesktopSmartRuleDraft> {
|
|
return mapIndexed { i, draft -> if (i == index) draft.transform() else draft }
|
|
}
|
|
|
|
private val DesktopPdfAnnotationTools = listOf(
|
|
PdfInkTool.PEN,
|
|
PdfInkTool.FOUNTAIN_PEN,
|
|
PdfInkTool.PENCIL,
|
|
PdfInkTool.HIGHLIGHTER,
|
|
PdfInkTool.HIGHLIGHTER_ROUND,
|
|
PdfInkTool.TEXT,
|
|
PdfInkTool.ERASER
|
|
)
|
|
|
|
private data class DesktopPdfThemeStyle(
|
|
val theme: ReaderTheme,
|
|
val viewerBackgroundColor: Color,
|
|
val colorFilter: ColorFilter?,
|
|
val textureBitmap: ImageBitmap?,
|
|
val textureAlpha: Float,
|
|
val textureBlendMode: BlendMode
|
|
)
|
|
|
|
@Composable
|
|
private fun DesktopPdfThemedPageImage(
|
|
bitmap: ImageBitmap,
|
|
contentDescription: String,
|
|
themeStyle: DesktopPdfThemeStyle,
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
Box(modifier = modifier) {
|
|
Image(
|
|
bitmap = bitmap,
|
|
contentDescription = contentDescription,
|
|
colorFilter = themeStyle.colorFilter,
|
|
modifier = Modifier.fillMaxSize()
|
|
)
|
|
val textureBitmap = themeStyle.textureBitmap
|
|
if (textureBitmap != null && themeStyle.textureAlpha > 0f) {
|
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
|
drawRect(
|
|
brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)),
|
|
size = size,
|
|
blendMode = themeStyle.textureBlendMode,
|
|
alpha = themeStyle.textureAlpha
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun ReaderSettings?.toDesktopPdfReaderSettings(): ReaderSettings {
|
|
val defaults = ReaderSettings(themeId = "no_theme")
|
|
val settings = this ?: defaults
|
|
val themeId = settings.themeId
|
|
val hasPdfTheme = BuiltInPdfReaderThemes.any { it.id == themeId }
|
|
val hasCustomColors = settings.backgroundColorArgb != null && settings.textColorArgb != null
|
|
return settings.copy(
|
|
themeId = when {
|
|
themeId == null -> "no_theme"
|
|
hasPdfTheme || hasCustomColors -> themeId
|
|
else -> "no_theme"
|
|
}
|
|
)
|
|
}
|
|
|
|
private fun ReaderSettings.toDesktopPdfThemeStyle(displayMode: PdfDisplayMode): DesktopPdfThemeStyle {
|
|
val theme = toDesktopPdfTheme()
|
|
val viewerBackground = when (theme.id) {
|
|
"no_theme", "system" -> if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) Color.White else Color.Black
|
|
"reverse" -> if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) Color.Black else Color.White
|
|
else -> theme.backgroundColor.takeIf { it.isSpecified }
|
|
?: if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) Color.White else Color.Black
|
|
}
|
|
val isDarkTexture = theme.isDark || theme.id == "reverse"
|
|
return DesktopPdfThemeStyle(
|
|
theme = theme,
|
|
viewerBackgroundColor = viewerBackground,
|
|
colorFilter = theme.toDesktopPdfColorFilter(),
|
|
textureBitmap = DesktopReaderTextures.imageBitmapFor(textureId),
|
|
textureAlpha = if (textureId == null) 0f else textureAlpha.coerceIn(0f, 1f),
|
|
textureBlendMode = if (isDarkTexture) BlendMode.Screen else BlendMode.Multiply
|
|
)
|
|
}
|
|
|
|
private fun ReaderSettings.toDesktopPdfTheme(): ReaderTheme {
|
|
BuiltInPdfReaderThemes.firstOrNull { it.id == themeId }?.let { return it }
|
|
val background = backgroundColorArgb?.toComposeColor()
|
|
val text = textColorArgb?.toComposeColor()
|
|
return if (background != null && text != null) {
|
|
ReaderTheme(
|
|
id = themeId ?: "desktop_pdf_custom",
|
|
name = "Custom",
|
|
backgroundColor = background,
|
|
textColor = text,
|
|
isDark = darkMode,
|
|
textureId = textureId,
|
|
isCustom = true
|
|
)
|
|
} else {
|
|
BuiltInPdfReaderThemes.first()
|
|
}
|
|
}
|
|
|
|
private fun ReaderTheme.toDesktopPdfColorFilter(): ColorFilter? {
|
|
return when (id) {
|
|
"no_theme", "system" -> null
|
|
"reverse" -> {
|
|
val colorMatrix = floatArrayOf(
|
|
-1f, 0f, 0f, 0f, 255f,
|
|
0f, -1f, 0f, 0f, 255f,
|
|
0f, 0f, -1f, 0f, 255f,
|
|
0f, 0f, 0f, 1f, 0f
|
|
)
|
|
ColorFilter.colorMatrix(ColorMatrix(colorMatrix))
|
|
}
|
|
else -> {
|
|
if (!backgroundColor.isSpecified || !textColor.isSpecified) return null
|
|
val bgR = backgroundColor.red * 255f
|
|
val bgG = backgroundColor.green * 255f
|
|
val bgB = backgroundColor.blue * 255f
|
|
val fgR = textColor.red * 255f
|
|
val fgG = textColor.green * 255f
|
|
val fgB = textColor.blue * 255f
|
|
val dr = (bgR - fgR) / 255f
|
|
val dg = (bgG - fgG) / 255f
|
|
val db = (bgB - fgB) / 255f
|
|
val lumR = 0.2126f
|
|
val lumG = 0.7152f
|
|
val lumB = 0.0722f
|
|
val colorMatrix = floatArrayOf(
|
|
dr * lumR, dr * lumG, dr * lumB, 0f, fgR,
|
|
dg * lumR, dg * lumG, dg * lumB, 0f, fgG,
|
|
db * lumR, db * lumG, db * lumB, 0f, fgB,
|
|
0f, 0f, 0f, 1f, 0f
|
|
)
|
|
ColorFilter.colorMatrix(ColorMatrix(colorMatrix))
|
|
}
|
|
}
|
|
}
|
|
|
|
private object DesktopReaderTextures {
|
|
private val bytesCache = mutableMapOf<String, ByteArray?>()
|
|
private val dataUriCache = mutableMapOf<String, String?>()
|
|
private val imageCache = mutableMapOf<String, ImageBitmap?>()
|
|
private val importExtensions = setOf("jpg", "jpeg", "png", "webp", "gif", "bmp")
|
|
|
|
fun importedTextureIds(): List<String> {
|
|
return readerTextureDirectory()
|
|
.listFiles { file -> file.isFile && file.extension.lowercase(Locale.ROOT) in importExtensions }
|
|
?.sortedBy { it.name.lowercase(Locale.ROOT) }
|
|
?.map { ReaderTextureFilePrefix + it.absolutePath }
|
|
.orEmpty()
|
|
}
|
|
|
|
fun importTexture(source: File): String? {
|
|
if (!source.isFile) return null
|
|
val extension = source.extension.lowercase(Locale.ROOT)
|
|
.takeIf { it in importExtensions }
|
|
?: return null
|
|
val safeName = source.nameWithoutExtension
|
|
.replace(Regex("[^A-Za-z0-9._-]+"), "_")
|
|
.trim('_')
|
|
.ifBlank { "texture" }
|
|
val directory = readerTextureDirectory().apply { mkdirs() }
|
|
val target = File(directory, "texture_${System.currentTimeMillis()}_$safeName.$extension")
|
|
return runCatching {
|
|
source.copyTo(target, overwrite = false)
|
|
val textureId = ReaderTextureFilePrefix + target.absolutePath
|
|
bytesCache.remove(textureId)
|
|
dataUriCache.remove(textureId)
|
|
imageCache.remove(textureId)
|
|
textureId
|
|
}.getOrNull()
|
|
}
|
|
|
|
fun dataUriFor(textureId: String): String? {
|
|
return dataUriCache.getOrPut(textureId) {
|
|
val bytes = bytesFor(textureId) ?: return@getOrPut null
|
|
val extension = textureExtension(textureId)
|
|
"data:${imageMimeTypeForExtension(extension)};base64," +
|
|
Base64.getEncoder().encodeToString(bytes)
|
|
}
|
|
}
|
|
|
|
fun imageBitmapFor(textureId: String?): ImageBitmap? {
|
|
val id = textureId ?: return null
|
|
return imageCache.getOrPut(id) {
|
|
val bytes = bytesFor(id) ?: return@getOrPut null
|
|
runCatching {
|
|
ImageIO.read(ByteArrayInputStream(bytes))?.toComposeImageBitmap()
|
|
}.getOrNull()
|
|
}
|
|
}
|
|
|
|
private fun bytesFor(textureId: String): ByteArray? {
|
|
return bytesCache.getOrPut(textureId) {
|
|
if (textureId.startsWith(ReaderTextureFilePrefix)) {
|
|
File(textureId.removePrefix(ReaderTextureFilePrefix)).takeIf { it.isFile }?.readBytes()
|
|
} else {
|
|
val texture = ReaderTexture.entries.firstOrNull { it.id == textureId } ?: return@getOrPut null
|
|
val classLoader = Thread.currentThread().contextClassLoader ?: DesktopReaderTextures::class.java.classLoader
|
|
classLoader
|
|
?.getResourceAsStream(texture.assetPath)
|
|
?.use { it.readBytes() }
|
|
?: DesktopReaderTextures::class.java.classLoader
|
|
?.getResourceAsStream(texture.assetPath)
|
|
?.use { it.readBytes() }
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun textureExtension(textureId: String): String {
|
|
if (textureId.startsWith(ReaderTextureFilePrefix)) {
|
|
return File(textureId.removePrefix(ReaderTextureFilePrefix)).extension
|
|
}
|
|
return ReaderTexture.entries.firstOrNull { it.id == textureId }
|
|
?.assetPath
|
|
?.substringAfterLast('.', "png")
|
|
?: "png"
|
|
}
|
|
|
|
private fun readerTextureDirectory(): File {
|
|
val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
|
|
?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
|
|
return File(baseDir, "Episteme/reader_textures")
|
|
}
|
|
}
|
|
|
|
private fun imageMimeTypeForExtension(extension: String): String {
|
|
return when (extension.lowercase(Locale.ROOT)) {
|
|
"jpg", "jpeg" -> "image/jpeg"
|
|
"webp" -> "image/webp"
|
|
"gif" -> "image/gif"
|
|
"bmp" -> "image/bmp"
|
|
else -> "image/png"
|
|
}
|
|
}
|
|
|
|
private fun Long.toComposeColor(): Color {
|
|
return Color(this and 0xFFFFFFFFL)
|
|
}
|
|
|
|
private val PdfInkTool.isDesktopHighlighter: Boolean
|
|
get() = this == PdfInkTool.HIGHLIGHTER || this == PdfInkTool.HIGHLIGHTER_ROUND
|
|
|
|
private fun List<PdfPagePoint>.withDesktopPdfDragPoint(
|
|
point: Offset,
|
|
canvasSize: IntSize,
|
|
tool: PdfInkTool,
|
|
snapHighlighter: Boolean,
|
|
timestamp: Long
|
|
): List<PdfPagePoint> {
|
|
val nextPoint = point.toSharedPdfPoint(canvasSize, timestamp)
|
|
if (snapHighlighter && tool.isDesktopHighlighter && isNotEmpty()) {
|
|
val pageAspectRatio = canvasSize.width.toFloat() / canvasSize.height.coerceAtLeast(1).toFloat()
|
|
return listOf(
|
|
first(),
|
|
SharedPdfInkRenderer.calculateSnappedPoint(
|
|
currentPoint = nextPoint,
|
|
startPoint = first(),
|
|
pageAspectRatio = pageAspectRatio
|
|
)
|
|
)
|
|
}
|
|
return this + nextPoint
|
|
}
|
|
|
|
@Composable
|
|
private fun PdfReaderScreen(
|
|
document: DesktopPdfDocument,
|
|
initialPageIndex: Int,
|
|
initialReaderSettings: ReaderSettings? = null,
|
|
onOpenPdf: () -> Unit,
|
|
onOpenBook: () -> Unit,
|
|
onPageStateChange: (pageIndex: Int, progress: Float) -> Unit,
|
|
onReaderSettingsChange: (ReaderSettings) -> Unit = {},
|
|
customTextureIds: List<String> = emptyList(),
|
|
onImportTexture: ((ReaderSettings) -> ReaderSettings?)? = null,
|
|
onLocalSidecarsChanged: () -> Unit = {},
|
|
aiByokSettings: ReaderAiByokSettings,
|
|
aiAdapter: DesktopByokAiAdapter,
|
|
ttsAdapter: DesktopGeminiCloudTtsAdapter
|
|
) {
|
|
val zoomSpec = remember { PdfZoomSpec() }
|
|
var pdfReaderSettings by remember(document.path) {
|
|
mutableStateOf(initialReaderSettings.toDesktopPdfReaderSettings())
|
|
}
|
|
var pdfState by remember(document.path) {
|
|
val defaultTool = PdfInkTool.PEN
|
|
val defaultToolConfig = SharedPdfAnnotationDefaults.configFor(defaultTool)
|
|
mutableStateOf(
|
|
SharedPdfReaderState.initial(
|
|
pageCount = document.pageCount,
|
|
initialPageIndex = initialPageIndex,
|
|
zoomSpec = zoomSpec
|
|
).copy(
|
|
isTextSelectionMode = true,
|
|
selectedTool = defaultTool,
|
|
selectedColorArgb = defaultToolConfig.colorArgb,
|
|
strokeWidth = defaultToolConfig.strokeWidth
|
|
)
|
|
)
|
|
}
|
|
var renderedPage by remember(document.path) { mutableStateOf<DesktopPdfPageRender?>(null) }
|
|
var renderError by remember(document.path) { mutableStateOf<String?>(null) }
|
|
var isRendering by remember(document.path) { mutableStateOf(false) }
|
|
var renderJob by remember(document.path) { mutableStateOf<Job?>(null) }
|
|
var activeTextDraft by remember(document.path) { mutableStateOf<SharedPdfTextDraft?>(null) }
|
|
var textStyleConfig by remember(document.path) { mutableStateOf(SharedPdfTextStyleConfig()) }
|
|
var pageCanvasSize by remember(document.path) { mutableStateOf(IntSize.Zero) }
|
|
var activeStroke by remember(document.path, pdfState.pageIndex) { mutableStateOf<List<PdfPagePoint>>(emptyList()) }
|
|
var isHighlighterSnapEnabled by remember(document.path) { mutableStateOf(false) }
|
|
var selectionStartIndex by remember(document.path, pdfState.pageIndex) { mutableStateOf<Int?>(null) }
|
|
var selectionEndIndex by remember(document.path, pdfState.pageIndex) { mutableStateOf<Int?>(null) }
|
|
var selectionStartHit by remember(document.path, pdfState.pageIndex) { mutableStateOf<DesktopPdfCharHit?>(null) }
|
|
var selectionEndHit by remember(document.path, pdfState.pageIndex) { mutableStateOf<DesktopPdfCharHit?>(null) }
|
|
var textSelection by remember(document.path, pdfState.pageIndex) { mutableStateOf<DesktopPdfTextSelection?>(null) }
|
|
var selectionMenuOffset by remember(document.path, pdfState.pageIndex) { mutableStateOf<Offset?>(null) }
|
|
var pageScrubPreview by remember(document.path) { mutableStateOf<Int?>(null) }
|
|
var pageScrubStartPage by remember(document.path) { mutableStateOf<Int?>(null) }
|
|
var jumpHistory by remember(document.path) { mutableStateOf(SharedPdfJumpHistory()) }
|
|
var externalLinkDialogUrl by remember(document.path) { mutableStateOf<String?>(null) }
|
|
var pdfExtrasState by remember(document.path) {
|
|
mutableStateOf(
|
|
ReaderExtrasState(
|
|
cloudTts = ReaderCloudTtsState(
|
|
isAvailable = aiByokSettings.isCloudTtsAvailable,
|
|
cacheSummary = ttsAdapter.cacheSummary(document.title, aiByokSettings.sanitized().ttsSpeakerId)
|
|
)
|
|
)
|
|
)
|
|
}
|
|
var pdfTtsJob by remember(document.path) { mutableStateOf<Job?>(null) }
|
|
val annotationFile = remember(document.path) { desktopPdfAnnotationFile(document.path) }
|
|
val bookmarkFile = remember(document.path) { desktopPdfBookmarkFile(document.path) }
|
|
val richTextFile = remember(document.path) { desktopPdfRichTextFile(document.path) }
|
|
val searchIndexFile = remember(document.path) { desktopPdfSearchIndexFile(document.path) }
|
|
val clipboardManager = LocalClipboardManager.current
|
|
val density = LocalDensity.current
|
|
val pdfScope = rememberCoroutineScope()
|
|
var isRichTextMode by remember(document.path) { mutableStateOf(false) }
|
|
var isRichTextLoaded by remember(document.path) { mutableStateOf(false) }
|
|
val richTextController = remember(document.path) {
|
|
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()
|
|
val pageHorizontalScrollState = rememberScrollState()
|
|
val verticalListState = rememberLazyListState(initialFirstVisibleItemIndex = pdfState.pageIndex)
|
|
val currentTextSelection by rememberUpdatedState(textSelection)
|
|
val currentPdfAnnotations by rememberUpdatedState(pdfState.annotations)
|
|
val currentPdfPageIndex by rememberUpdatedState(pdfState.pageIndex)
|
|
|
|
fun clearPdfInteractionState() {
|
|
activeStroke = emptyList()
|
|
selectionStartIndex = null
|
|
selectionEndIndex = null
|
|
selectionStartHit = null
|
|
selectionEndHit = null
|
|
textSelection = null
|
|
selectionMenuOffset = null
|
|
}
|
|
|
|
fun dispatchPdf(action: SharedPdfReaderAction) {
|
|
val previousPage = pdfState.pageIndex
|
|
val next = pdfState.reduce(action, zoomSpec)
|
|
pdfState = next
|
|
if (next.pageIndex != previousPage) {
|
|
clearPdfInteractionState()
|
|
}
|
|
}
|
|
|
|
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}"
|
|
)
|
|
deactivateRichTextMode()
|
|
if (tool != PdfInkTool.TEXT) {
|
|
commitActiveTextDraft()
|
|
}
|
|
if (tool == PdfInkTool.TEXT && pdfState.isTextSelectionMode) {
|
|
dispatchPdf(SharedPdfReaderAction.TextSelectionModeChanged(false))
|
|
clearPdfInteractionState()
|
|
}
|
|
dispatchPdf(SharedPdfReaderAction.ToolSelected(tool))
|
|
}
|
|
|
|
val pageIndex = pdfState.pageIndex
|
|
val scale = pdfState.zoom
|
|
val displayMode = pdfState.displayMode
|
|
val searchQuery = pdfState.searchQuery
|
|
val activeSearchIndex = pdfState.activeSearchResultIndex
|
|
val searchHighlightMode = pdfState.searchHighlightMode
|
|
val selectedTool = pdfState.selectedTool
|
|
val selectedColor = pdfState.selectedColorArgb
|
|
val strokeWidth = pdfState.strokeWidth
|
|
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 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(document.path) { mutableStateOf(false) }
|
|
var arePdfBookmarksLoaded by remember(document.path) { mutableStateOf(false) }
|
|
var indexedSearchPageCount by remember(document.path) { mutableStateOf(document.indexedSearchTextPageCount()) }
|
|
var isSearchIndexing by remember(document.path) { mutableStateOf(false) }
|
|
var searchResults by remember(document.path) { mutableStateOf<List<SharedPdfSearchResult>>(emptyList()) }
|
|
var selectedEmbeddedAnnotationId by remember(document.path) { mutableStateOf<String?>(null) }
|
|
val selectedAnnotation = remember(annotations, selectedAnnotationId) {
|
|
annotations.firstOrNull { it.id == selectedAnnotationId }
|
|
}
|
|
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
|
|
|
|
fun currentPdfTtsCacheSummary() =
|
|
ttsAdapter.cacheSummary(document.title, aiByokSettings.sanitized().ttsSpeakerId)
|
|
|
|
DesktopExternalLinkDialog(
|
|
url = externalLinkDialogUrl,
|
|
onDismiss = { externalLinkDialogUrl = null }
|
|
)
|
|
|
|
LaunchedEffect(aiByokSettings) {
|
|
pdfExtrasState = pdfExtrasState.copy(
|
|
cloudTts = pdfExtrasState.cloudTts.copy(
|
|
isAvailable = aiByokSettings.isCloudTtsAvailable,
|
|
errorMessage = null,
|
|
cacheSummary = currentPdfTtsCacheSummary()
|
|
)
|
|
)
|
|
}
|
|
|
|
LaunchedEffect(document.path) {
|
|
arePdfAnnotationsLoaded = false
|
|
val loadedAnnotations = if (annotationFile.exists()) {
|
|
withContext(Dispatchers.IO) {
|
|
SharedPdfAnnotationSerializer.decode(annotationFile.readText())
|
|
}
|
|
} else {
|
|
emptyList()
|
|
}
|
|
dispatchPdf(SharedPdfReaderAction.AnnotationsLoaded(loadedAnnotations))
|
|
arePdfAnnotationsLoaded = true
|
|
}
|
|
|
|
LaunchedEffect(document.path, annotations, arePdfAnnotationsLoaded) {
|
|
if (!arePdfAnnotationsLoaded) return@LaunchedEffect
|
|
withContext(Dispatchers.IO) {
|
|
runCatching {
|
|
annotationFile.parentFile?.mkdirs()
|
|
annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations))
|
|
}
|
|
}
|
|
onLocalSidecarsChanged()
|
|
}
|
|
|
|
LaunchedEffect(document.path) {
|
|
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(document.path) {
|
|
arePdfBookmarksLoaded = false
|
|
val loadedBookmarks = if (bookmarkFile.exists()) {
|
|
withContext(Dispatchers.IO) {
|
|
SharedPdfBookmarkSerializer.decode(bookmarkFile.readText())
|
|
}
|
|
} else {
|
|
emptyList()
|
|
}
|
|
dispatchPdf(SharedPdfReaderAction.BookmarksLoaded(loadedBookmarks))
|
|
arePdfBookmarksLoaded = true
|
|
}
|
|
|
|
LaunchedEffect(document.path, bookmarks, arePdfBookmarksLoaded) {
|
|
if (!arePdfBookmarksLoaded) return@LaunchedEffect
|
|
withContext(Dispatchers.IO) {
|
|
runCatching {
|
|
bookmarkFile.parentFile?.mkdirs()
|
|
bookmarkFile.writeText(SharedPdfBookmarkSerializer.encode(bookmarks))
|
|
}
|
|
}
|
|
onLocalSidecarsChanged()
|
|
}
|
|
|
|
LaunchedEffect(document.path) {
|
|
val restoredPageCount = withContext(Dispatchers.IO) {
|
|
restoreDesktopPdfSearchIndex(document, searchIndexFile)
|
|
}
|
|
indexedSearchPageCount = restoredPageCount
|
|
isSearchIndexing = indexedSearchPageCount < document.pageCount
|
|
withContext(Dispatchers.IO) {
|
|
DesktopPdfium.indexSearchPages(
|
|
document = document,
|
|
onProgress = { indexed, _ ->
|
|
indexedSearchPageCount = indexed
|
|
},
|
|
shouldContinue = { isActive }
|
|
)
|
|
if (isActive) {
|
|
saveDesktopPdfSearchIndex(document, searchIndexFile)
|
|
}
|
|
}
|
|
if (!isActive) return@LaunchedEffect
|
|
indexedSearchPageCount = document.indexedSearchTextPageCount()
|
|
isSearchIndexing = false
|
|
}
|
|
|
|
LaunchedEffect(document.path, 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 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()
|
|
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) {
|
|
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 = SharedPdfAnnotationDefaults.configFor(PdfInkTool.HIGHLIGHTER).colorArgb,
|
|
rangeStartIndex = selection.startIndex,
|
|
rangeEndIndex = selection.endIndex,
|
|
createdAt = now
|
|
)
|
|
)
|
|
)
|
|
}
|
|
|
|
fun clearSelection() {
|
|
textSelection = null
|
|
selectionStartIndex = null
|
|
selectionEndIndex = null
|
|
selectionStartHit = null
|
|
selectionEndHit = null
|
|
selectionMenuOffset = null
|
|
}
|
|
|
|
fun highlightCurrentSelection() {
|
|
val selection = textSelection ?: return
|
|
highlightSelection(pageIndex, selection, pageCanvasSize)
|
|
clearSelection()
|
|
}
|
|
|
|
fun searchSelection(selection: DesktopPdfTextSelection) {
|
|
dispatchPdf(SharedPdfReaderAction.SearchChanged(selection.text.take(120)))
|
|
}
|
|
|
|
fun translateSelection(selection: DesktopPdfTextSelection) {
|
|
openExternalUrl(externalLookupUrl(ReaderExternalLookupAction.TRANSLATE, selection.text))
|
|
}
|
|
|
|
fun openPdfExternalLookup(action: ReaderExternalLookupAction, text: String) {
|
|
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()
|
|
val startPageIndex = pageIndex
|
|
logDesktopTts(
|
|
"pdf_sequence_toggle scope=${readScope.name} startPage=${startPageIndex + 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, startPageIndex)
|
|
.filter { it.text.isNotBlank() }
|
|
.withTtsReplacements(state.readerTtsReplacementPreferences, 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()
|
|
if (error is kotlinx.coroutines.CancellationException) {
|
|
pdfExtrasState = pdfExtrasState.copy(
|
|
cloudTts = pdfCloudTtsStoppedState(statusMessage = "Stopped")
|
|
)
|
|
} else {
|
|
pdfExtrasState = 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(state.readerTtsReplacementPreferences, 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 selectAnnotation(annotation: SharedPdfAnnotation?) {
|
|
dispatchPdf(SharedPdfReaderAction.AnnotationSelected(annotation?.id))
|
|
annotation?.let { goToPage(it.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(document.path, document.pageCount) {
|
|
jumpHistory = jumpHistory.pruned(document.pageCount)
|
|
}
|
|
|
|
LaunchedEffect(document.path, pageIndex, progressPercent) {
|
|
onPageStateChange(pageIndex, progressPercent)
|
|
}
|
|
|
|
LaunchedEffect(document.path, displayMode) {
|
|
if (displayMode == PdfDisplayMode.VERTICAL_SCROLL && pageIndex in 0 until document.pageCount) {
|
|
verticalListState.scrollToItem(pageIndex)
|
|
}
|
|
}
|
|
|
|
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(document.path, 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(document.path, pageIndex, scale, displayMode) {
|
|
renderJob?.cancel()
|
|
if (displayMode != PdfDisplayMode.PAGINATION) {
|
|
isRendering = false
|
|
renderError = null
|
|
renderedPage = null
|
|
return@LaunchedEffect
|
|
}
|
|
renderJob = launch {
|
|
delay(90)
|
|
isRendering = true
|
|
renderError = null
|
|
val pageSize = document.pageSizes[pageIndex]
|
|
val safeScale = zoomSpec.safeRenderScale(
|
|
pageSize.width,
|
|
pageSize.height, scale
|
|
)
|
|
val result = withContext(Dispatchers.IO) {
|
|
runCatching {
|
|
DesktopPdfium.renderPage(document, pageIndex, safeScale)
|
|
}
|
|
}
|
|
if (pageIndex != pageIndex || scale != scale) {
|
|
return@launch
|
|
}
|
|
renderedPage = result.getOrNull()
|
|
renderError = result.exceptionOrNull()?.message
|
|
?: if (renderedPage == null) "Failed to render page." else null
|
|
renderedPage?.let { render ->
|
|
logPdfSelection(
|
|
"render page=${pageIndex + 1} " +
|
|
"requestedScale=${scale.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
|
|
}
|
|
}
|
|
|
|
val pdfWorkspaceModel = pdfReaderWorkspaceModel(
|
|
state = pdfState,
|
|
displayMode = displayMode,
|
|
hasContents = document.toc.isNotEmpty(),
|
|
hasBookmarks = bookmarks.isNotEmpty(),
|
|
hasAnnotations = sortedAnnotations.isNotEmpty(),
|
|
hasEmbeddedComments = sortedEmbeddedAnnotations.isNotEmpty(),
|
|
searchActive = searchQuery.isNotBlank(),
|
|
annotationEditing = activeTextDraft != null ||
|
|
selectedAnnotation != null ||
|
|
selectedTool != PdfInkTool.PEN ||
|
|
!isTextSelectionMode,
|
|
richTextEditing = isRichTextMode,
|
|
loading = isRendering || isSearchIndexing,
|
|
errorMessage = renderError,
|
|
extrasState = pdfExtrasState,
|
|
aiAvailable = aiByokSettings.sanitized().areReaderAiFeaturesAvailable
|
|
)
|
|
|
|
fun handlePdfReaderKeyEvent(event: androidx.compose.ui.input.key.KeyEvent): Boolean {
|
|
if (event.type != KeyEventType.KeyDown) return false
|
|
val isEditingTextAnnotation =
|
|
activeTextDraft != null ||
|
|
(selectedTool == PdfInkTool.TEXT && selectedAnnotation?.kind == PdfAnnotationKind.TEXT)
|
|
if ((isEditingTextAnnotation || isRichTextMode) && !event.isCtrlPressed) {
|
|
return false
|
|
}
|
|
return when {
|
|
event.key == Key.DirectionLeft -> {
|
|
goToPage(pageIndex - 1)
|
|
true
|
|
}
|
|
event.key == Key.DirectionRight -> {
|
|
goToPage(pageIndex + 1)
|
|
true
|
|
}
|
|
event.key == Key.DirectionUp && displayMode == PdfDisplayMode.VERTICAL_SCROLL -> {
|
|
goToPage(pageIndex - 1)
|
|
true
|
|
}
|
|
event.key == Key.DirectionDown && displayMode == PdfDisplayMode.VERTICAL_SCROLL -> {
|
|
goToPage(pageIndex + 1)
|
|
true
|
|
}
|
|
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.Equals -> {
|
|
dispatchPdf(SharedPdfReaderAction.ZoomBy(0.15f))
|
|
true
|
|
}
|
|
event.isCtrlPressed && event.key == Key.Minus -> {
|
|
dispatchPdf(SharedPdfReaderAction.ZoomBy(-0.15f))
|
|
true
|
|
}
|
|
else -> false
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun PdfNavigationSidebar() {
|
|
Surface(
|
|
modifier = Modifier
|
|
.width(300.dp)
|
|
.fillMaxHeight(),
|
|
color = MaterialTheme.colorScheme.surface,
|
|
shape = RoundedCornerShape(8.dp),
|
|
tonalElevation = 2.dp
|
|
) {
|
|
LazyColumn(
|
|
modifier = Modifier.padding(12.dp),
|
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
) {
|
|
item {
|
|
Text("Contents", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
}
|
|
item {
|
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
TextButton(onClick = { goToPage(pageIndex - 1) }, enabled = canGoPrevious) {
|
|
Text("Previous")
|
|
}
|
|
TextButton(onClick = { goToPage(pageIndex + 1) }, enabled = canGoNext) {
|
|
Text("Next")
|
|
}
|
|
}
|
|
}
|
|
if (document.pageCount > 1) {
|
|
item {
|
|
Text(
|
|
"Page ${pageIndex + 1} of ${document.pageCount}",
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
Slider(
|
|
value = pageIndex.toFloat(),
|
|
onValueChange = { value ->
|
|
if (pageScrubStartPage == null) {
|
|
pageScrubStartPage = pdfState.pageIndex
|
|
}
|
|
val targetPage = value.toInt().coerceIn(0, document.pageCount - 1)
|
|
pageScrubPreview = targetPage
|
|
goToPage(targetPage)
|
|
},
|
|
onValueChangeFinished = {
|
|
val startPage = pageScrubStartPage
|
|
val targetPage = currentPdfPageIndex
|
|
if (startPage != null) {
|
|
jumpHistory = jumpHistory.record(
|
|
currentPageIndex = startPage,
|
|
targetPageIndex = targetPage,
|
|
pageCount = document.pageCount
|
|
)
|
|
}
|
|
pageScrubStartPage = null
|
|
pageScrubPreview = null
|
|
},
|
|
valueRange = 0f..(document.pageCount - 1).toFloat(),
|
|
steps = (document.pageCount - 2).coerceAtLeast(0)
|
|
)
|
|
}
|
|
}
|
|
item {
|
|
DesktopPdfJumpHistoryControls(
|
|
backPage = jumpHistory.backPage,
|
|
forwardPage = jumpHistory.forwardPage,
|
|
onBack = ::goBackInJumpHistory,
|
|
onForward = ::goForwardInJumpHistory,
|
|
onClear = { jumpHistory = jumpHistory.clear() }
|
|
)
|
|
}
|
|
item {
|
|
val isBookmarked = bookmarks.any { it.pageIndex == pageIndex }
|
|
TextButton(onClick = { toggleBookmark(pageIndex) }) {
|
|
Text(if (isBookmarked) "Remove bookmark" else "Bookmark page")
|
|
}
|
|
}
|
|
item {
|
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
|
Text("Search", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
Spacer(Modifier.height(8.dp))
|
|
OutlinedTextField(
|
|
value = searchQuery,
|
|
onValueChange = { dispatchPdf(SharedPdfReaderAction.SearchChanged(it)) },
|
|
label = { Text("Find in PDF") },
|
|
singleLine = true,
|
|
modifier = Modifier.fillMaxWidth()
|
|
)
|
|
}
|
|
if (searchQuery.isNotBlank()) {
|
|
item {
|
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
Text(
|
|
when {
|
|
isSearchIndexing -> {
|
|
val progress = "Indexing ${indexedSearchPageCount.coerceAtMost(document.pageCount)}/${document.pageCount}"
|
|
if (searchResults.isEmpty()) progress else "${searchResults.size} matches - $progress"
|
|
}
|
|
searchResults.isEmpty() -> "No matches"
|
|
activeSearchIndex in searchResults.indices -> "${activeSearchIndex + 1} of ${searchResults.size}"
|
|
else -> "${searchResults.size} matches"
|
|
},
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
TextButton(onClick = { goToSearchResult(activeSearchIndex - 1) }, enabled = searchResults.isNotEmpty()) {
|
|
Text("Prev")
|
|
}
|
|
TextButton(onClick = { goToSearchResult(activeSearchIndex + 1) }, enabled = searchResults.isNotEmpty()) {
|
|
Text("Next")
|
|
}
|
|
}
|
|
}
|
|
items(searchResults, key = { "nav_search_${it.pageIndex}_${it.matchIndex}_${it.preview}" }) { result ->
|
|
Surface(
|
|
color = if (result.pageIndex == pageIndex) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
|
shape = RoundedCornerShape(6.dp),
|
|
modifier = Modifier.fillMaxWidth().clickable {
|
|
goToSearchResult(searchResults.indexOf(result))
|
|
}
|
|
) {
|
|
Column(modifier = Modifier.padding(8.dp)) {
|
|
Text("Page ${result.pageIndex + 1}", fontWeight = FontWeight.SemiBold)
|
|
Text(result.preview, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (document.toc.isNotEmpty()) {
|
|
item {
|
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
|
Text("Contents", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
}
|
|
itemsIndexed(document.toc, key = { index, entry -> "nav_toc_${index}_${entry.pageIndex}_${entry.nestLevel}" }) { _, entry ->
|
|
Surface(
|
|
color = if (entry.pageIndex == pageIndex) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
|
shape = RoundedCornerShape(6.dp),
|
|
modifier = Modifier.fillMaxWidth().clickable { goToPage(entry.pageIndex, recordJump = true) }
|
|
) {
|
|
Row(
|
|
modifier = Modifier
|
|
.padding(start = (entry.nestLevel * 12).dp)
|
|
.padding(horizontal = 8.dp, vertical = 8.dp),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Text(entry.title, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
|
Text("p. ${entry.pageIndex + 1}", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (bookmarks.isNotEmpty()) {
|
|
item {
|
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
|
Text("Bookmarks", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
}
|
|
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)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
if (sortedAnnotations.isNotEmpty() || sortedEmbeddedAnnotations.isNotEmpty()) {
|
|
item {
|
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
|
Text("Notes", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
}
|
|
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().clickable { selectAnnotation(annotation) }
|
|
) {
|
|
Column(modifier = Modifier.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)
|
|
}
|
|
}
|
|
}
|
|
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().clickable { selectEmbeddedAnnotation(annotation) }
|
|
) {
|
|
Column(modifier = Modifier.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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun PdfBottomChrome() {
|
|
Surface(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
shape = RoundedCornerShape(8.dp),
|
|
color = MaterialTheme.colorScheme.surface,
|
|
tonalElevation = 2.dp
|
|
) {
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
TextButton(onClick = { goToPage(pageIndex - 1) }, enabled = canGoPrevious) {
|
|
Text("Previous")
|
|
}
|
|
Text("Page ${pageIndex + 1} of ${document.pageCount}", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
if (document.pageCount > 1) {
|
|
Slider(
|
|
value = pageIndex.toFloat(),
|
|
onValueChange = { value ->
|
|
if (pageScrubStartPage == null) {
|
|
pageScrubStartPage = pdfState.pageIndex
|
|
}
|
|
val targetPage = value.toInt().coerceIn(0, document.pageCount - 1)
|
|
pageScrubPreview = targetPage
|
|
goToPage(targetPage)
|
|
},
|
|
onValueChangeFinished = {
|
|
val startPage = pageScrubStartPage
|
|
val targetPage = currentPdfPageIndex
|
|
if (startPage != null) {
|
|
jumpHistory = jumpHistory.record(
|
|
currentPageIndex = startPage,
|
|
targetPageIndex = targetPage,
|
|
pageCount = document.pageCount
|
|
)
|
|
}
|
|
pageScrubStartPage = null
|
|
pageScrubPreview = null
|
|
},
|
|
valueRange = 0f..(document.pageCount - 1).toFloat(),
|
|
steps = (document.pageCount - 2).coerceAtLeast(0),
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
} else {
|
|
Spacer(Modifier.weight(1f))
|
|
}
|
|
Text("${progressPercent.toInt()}%", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
TextButton(onClick = { goToPage(pageIndex + 1) }, enabled = canGoNext) {
|
|
Text("Next")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
ReaderWorkspaceShell(
|
|
model = pdfWorkspaceModel,
|
|
title = document.title,
|
|
subtitle = "${document.formatLabel} - Page ${pageIndex + 1} of ${document.pageCount}",
|
|
progressLabel = "${progressPercent.toInt()}%",
|
|
modifier = Modifier
|
|
.onPreviewKeyEvent(::handlePdfReaderKeyEvent)
|
|
.focusable(),
|
|
topActions = {
|
|
TextButton(onClick = onOpenBook) {
|
|
Text("Open Book")
|
|
}
|
|
TextButton(onClick = onOpenPdf) {
|
|
Text("Open PDF")
|
|
}
|
|
},
|
|
leftSidebar = { PdfNavigationSidebar() },
|
|
rightInspector = {
|
|
Surface(
|
|
modifier = Modifier
|
|
.width(340.dp)
|
|
.fillMaxHeight(),
|
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
|
shape = RoundedCornerShape(8.dp)
|
|
) {
|
|
LazyColumn(
|
|
modifier = Modifier.padding(12.dp),
|
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
) {
|
|
item {
|
|
Text("Tools", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
}
|
|
item {
|
|
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 {
|
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
TextButton(onClick = { goToPage(0) }, enabled = canGoPrevious) {
|
|
Text("First")
|
|
}
|
|
TextButton(onClick = { goToPage(pageIndex - 1) }, enabled = canGoPrevious) {
|
|
Text("Prev")
|
|
}
|
|
}
|
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
TextButton(onClick = { goToPage(pageIndex + 1) }, enabled = canGoNext) {
|
|
Text("Next")
|
|
}
|
|
TextButton(onClick = { goToPage(document.pageCount - 1) }, enabled = canGoNext) {
|
|
Text("Last")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
item {
|
|
DesktopPdfJumpHistoryControls(
|
|
backPage = jumpHistory.backPage,
|
|
forwardPage = jumpHistory.forwardPage,
|
|
onBack = ::goBackInJumpHistory,
|
|
onForward = ::goForwardInJumpHistory,
|
|
onClear = { jumpHistory = jumpHistory.clear() }
|
|
)
|
|
}
|
|
if (document.pageCount > 1) {
|
|
item {
|
|
Text("Page ${pageIndex + 1} of ${document.pageCount}", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
Slider(
|
|
value = pageIndex.toFloat(),
|
|
onValueChange = { value ->
|
|
if (pageScrubStartPage == null) {
|
|
pageScrubStartPage = pdfState.pageIndex
|
|
}
|
|
val targetPage = value.toInt().coerceIn(0, document.pageCount - 1)
|
|
pageScrubPreview = targetPage
|
|
goToPage(targetPage)
|
|
},
|
|
onValueChangeFinished = {
|
|
val startPage = pageScrubStartPage
|
|
val targetPage = currentPdfPageIndex
|
|
if (startPage != null) {
|
|
jumpHistory = jumpHistory.record(
|
|
currentPageIndex = startPage,
|
|
targetPageIndex = targetPage,
|
|
pageCount = document.pageCount
|
|
)
|
|
}
|
|
pageScrubStartPage = null
|
|
pageScrubPreview = null
|
|
},
|
|
valueRange = 0f..(document.pageCount - 1).toFloat(),
|
|
steps = (document.pageCount - 2).coerceAtLeast(0)
|
|
)
|
|
}
|
|
}
|
|
item {
|
|
val isBookmarked = bookmarks.any { it.pageIndex == pageIndex }
|
|
TextButton(onClick = { toggleBookmark(pageIndex) }) {
|
|
Text(if (isBookmarked) "Remove bookmark" else "Bookmark page")
|
|
}
|
|
}
|
|
item {
|
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
|
SharedReaderThemeControls(
|
|
settings = pdfReaderSettings,
|
|
builtInThemes = BuiltInPdfReaderThemes,
|
|
customTextureIds = customTextureIds,
|
|
onImportTexture = onImportTexture,
|
|
onSettingsChange = ::updatePdfReaderSettings
|
|
)
|
|
}
|
|
if (bookmarks.isNotEmpty()) {
|
|
item {
|
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
|
Text("Bookmarks", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
}
|
|
items(bookmarks, key = { "bookmark_${it.pageIndex}" }) { bookmark ->
|
|
Surface(
|
|
color = if (bookmark.pageIndex == pageIndex) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
|
|
shape = RoundedCornerShape(6.dp),
|
|
modifier = Modifier.fillMaxWidth().clickable { goToPage(bookmark.pageIndex, recordJump = true) }
|
|
) {
|
|
Row(
|
|
modifier = Modifier.padding(8.dp),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Text(
|
|
bookmark.label.ifBlank { "Page ${bookmark.pageIndex + 1}" },
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
TextButton(onClick = { toggleBookmark(bookmark.pageIndex) }) {
|
|
Text("Remove")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (document.toc.isNotEmpty()) {
|
|
item {
|
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
|
Text("Contents", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
}
|
|
itemsIndexed(document.toc, key = { index, entry -> "toc_${index}_${entry.pageIndex}_${entry.nestLevel}" }) { _, entry ->
|
|
Surface(
|
|
color = if (entry.pageIndex == pageIndex) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
|
|
shape = RoundedCornerShape(6.dp),
|
|
modifier = Modifier.fillMaxWidth().clickable { goToPage(entry.pageIndex, recordJump = true) }
|
|
) {
|
|
Row(
|
|
modifier = Modifier
|
|
.padding(start = (entry.nestLevel * 12).dp)
|
|
.padding(horizontal = 8.dp, vertical = 8.dp),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Text(
|
|
entry.title,
|
|
maxLines = 2,
|
|
overflow = TextOverflow.Ellipsis,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
Text("p. ${entry.pageIndex + 1}", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
item {
|
|
Text("Zoom", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
IconButton(onClick = { dispatchPdf(SharedPdfReaderAction.ZoomBy(-0.15f)) }) {
|
|
Icon(Icons.Default.ZoomOut, contentDescription = "Zoom out")
|
|
}
|
|
Text("${(scale * 100).toInt()}%", modifier = Modifier.weight(1f), textAlign = TextAlign.Center)
|
|
IconButton(onClick = { dispatchPdf(SharedPdfReaderAction.ZoomBy(0.15f)) }) {
|
|
Icon(Icons.Default.ZoomIn, contentDescription = "Zoom in")
|
|
}
|
|
}
|
|
Slider(
|
|
value = scale,
|
|
onValueChange = { dispatchPdf(SharedPdfReaderAction.ZoomChanged(it)) },
|
|
valueRange = zoomSpec.min..zoomSpec.max
|
|
)
|
|
}
|
|
item {
|
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
|
Text("Annotations", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
FilterChip(
|
|
selected = isTextSelectionMode,
|
|
onClick = {
|
|
val enabled = !isTextSelectionMode
|
|
if (enabled) {
|
|
deactivateRichTextMode()
|
|
}
|
|
if (enabled) {
|
|
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") }
|
|
)
|
|
SharedPdfAnnotationToolDock(
|
|
selectedTool = selectedTool,
|
|
selectedColor = selectedColor,
|
|
strokeWidth = strokeWidth,
|
|
tools = DesktopPdfAnnotationTools,
|
|
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 }
|
|
)
|
|
}
|
|
selectedAnnotation?.let { annotation ->
|
|
item {
|
|
DesktopPdfAnnotationEditor(
|
|
annotation = annotation,
|
|
onUpdate = ::updateAnnotation,
|
|
onDelete = { deleteAnnotation(annotation.id) },
|
|
onClose = { dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) }
|
|
)
|
|
}
|
|
}
|
|
if (sortedAnnotations.isNotEmpty()) {
|
|
item {
|
|
Text("Annotation list", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
|
}
|
|
items(sortedAnnotations, key = { "annotation_${it.id}" }) { annotation ->
|
|
Surface(
|
|
color = if (annotation.id == selectedAnnotationId) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
|
|
shape = RoundedCornerShape(6.dp),
|
|
modifier = Modifier.fillMaxWidth().clickable { selectAnnotation(annotation) }
|
|
) {
|
|
Column(modifier = Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
Text(
|
|
annotation.desktopLabel(),
|
|
fontWeight = FontWeight.SemiBold,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
TextButton(onClick = { deleteAnnotation(annotation.id) }) {
|
|
Text("Delete")
|
|
}
|
|
}
|
|
Text(
|
|
"Page ${annotation.pageIndex + 1}${annotation.text.takeIf { it.isNotBlank() }?.let { " - ${it.logPreview(48)}" }.orEmpty()}",
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
maxLines = 2,
|
|
overflow = TextOverflow.Ellipsis
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
selectedEmbeddedAnnotation?.let { annotation ->
|
|
item {
|
|
DesktopPdfEmbeddedAnnotationPanel(
|
|
annotation = annotation,
|
|
onCopy = { clipboardManager.setText(AnnotatedString(annotation.threadText())) },
|
|
onClose = { selectedEmbeddedAnnotationId = null }
|
|
)
|
|
}
|
|
}
|
|
if (sortedEmbeddedAnnotations.isNotEmpty()) {
|
|
item {
|
|
Text("PDF comments", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
|
}
|
|
items(sortedEmbeddedAnnotations, key = { "embedded_${it.id}" }) { annotation ->
|
|
Surface(
|
|
color = if (annotation.id == selectedEmbeddedAnnotationId) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
|
|
shape = RoundedCornerShape(6.dp),
|
|
modifier = Modifier.fillMaxWidth().clickable { selectEmbeddedAnnotation(annotation) }
|
|
) {
|
|
Column(modifier = Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
Text(
|
|
annotation.author.ifBlank { "PDF comment" },
|
|
fontWeight = FontWeight.SemiBold,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
Text("p. ${annotation.pageIndex + 1}", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
}
|
|
Text(
|
|
annotation.contents.ifBlank { annotation.replies.firstOrNull()?.contents.orEmpty() }.logPreview(80),
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
maxLines = 2,
|
|
overflow = TextOverflow.Ellipsis
|
|
)
|
|
if (annotation.replies.isNotEmpty()) {
|
|
Text(
|
|
"${annotation.replies.size} replies",
|
|
color = MaterialTheme.colorScheme.primary,
|
|
style = MaterialTheme.typography.labelSmall
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (isRichTextMode || selectedTool == PdfInkTool.TEXT) {
|
|
item {
|
|
SharedPdfTextAnnotationDock(
|
|
style = if (isRichTextMode) {
|
|
richTextController.currentSharedPdfTextStyleConfig()
|
|
} else {
|
|
effectiveTextStyleConfig
|
|
},
|
|
onStyleChange = { style ->
|
|
if (isRichTextMode) {
|
|
richTextController.updateCurrentSharedPdfTextStyle(style)
|
|
} else {
|
|
updateTextStyleConfig(style)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
item {
|
|
DesktopPdfExtrasPanel(
|
|
pageText = currentPdfPageText(),
|
|
recapText = pdfTextBeforeCurrentPage(),
|
|
extrasState = pdfExtrasState,
|
|
aiByokSettings = aiByokSettings,
|
|
onExternalLookup = ::openPdfExternalLookup,
|
|
onAiAction = ::runPdfAiAction,
|
|
onCloudTtsStart = ::startPdfCloudTts,
|
|
onCloudTtsPauseResume = ::pauseResumePdfCloudTts,
|
|
onCloudTtsStop = ::stopPdfCloudTts,
|
|
onCloudTtsClearCache = ::clearPdfCloudTtsCache,
|
|
onAutoScrollChange = ::updatePdfAutoScroll,
|
|
ttsReplacementPreferences = state.readerTtsReplacementPreferences,
|
|
ttsReplacementBookId = document.path,
|
|
onTtsReplacementPreferencesChange = { preferences ->
|
|
updateState(state.reduce(AppAction.ReaderTtsReplacementPreferencesChanged(preferences)))
|
|
}
|
|
)
|
|
}
|
|
item {
|
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
|
Text("Search", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
Spacer(Modifier.height(8.dp))
|
|
OutlinedTextField(
|
|
value = searchQuery,
|
|
onValueChange = {
|
|
dispatchPdf(SharedPdfReaderAction.SearchChanged(it))
|
|
},
|
|
label = { Text("Find in PDF") },
|
|
singleLine = true,
|
|
modifier = Modifier.fillMaxWidth()
|
|
)
|
|
}
|
|
if (searchQuery.isNotBlank()) {
|
|
item {
|
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
Text(
|
|
when {
|
|
isSearchIndexing -> {
|
|
val progress = "Indexing ${indexedSearchPageCount.coerceAtMost(document.pageCount)}/${document.pageCount}"
|
|
if (searchResults.isEmpty()) progress else "${searchResults.size} matches - $progress"
|
|
}
|
|
searchResults.isEmpty() -> "No matches"
|
|
activeSearchIndex in searchResults.indices -> "${activeSearchIndex + 1} of ${searchResults.size}"
|
|
else -> "${searchResults.size} matches"
|
|
},
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
TextButton(onClick = { goToSearchResult(activeSearchIndex - 1) }, enabled = searchResults.isNotEmpty()) {
|
|
Text("Prev")
|
|
}
|
|
TextButton(onClick = { goToSearchResult(activeSearchIndex + 1) }, enabled = searchResults.isNotEmpty()) {
|
|
Text("Next")
|
|
}
|
|
}
|
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
Text(
|
|
"Highlights",
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
TextButton(
|
|
onClick = {
|
|
dispatchPdf(SharedPdfReaderAction.SearchHighlightModeToggled)
|
|
},
|
|
enabled = searchResults.isNotEmpty()
|
|
) {
|
|
Text(
|
|
when (searchHighlightMode) {
|
|
SearchHighlightMode.ALL -> "All"
|
|
SearchHighlightMode.FOCUSED -> "Focused"
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
items(searchResults, key = { "${it.pageIndex}_${it.matchIndex}_${it.preview}" }) { result ->
|
|
Surface(
|
|
color = if (result.pageIndex == pageIndex) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
|
|
shape = RoundedCornerShape(6.dp),
|
|
modifier = Modifier.fillMaxWidth().clickable {
|
|
goToSearchResult(searchResults.indexOf(result))
|
|
}
|
|
) {
|
|
Column(modifier = Modifier.padding(8.dp)) {
|
|
Text("Page ${result.pageIndex + 1}", fontWeight = FontWeight.SemiBold)
|
|
Text(result.preview, style = MaterialTheme.typography.bodySmall, maxLines = 3, overflow = TextOverflow.Ellipsis)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
bottomBar = { PdfBottomChrome() }
|
|
) {
|
|
SharedPdfRichTextHiddenInput(
|
|
controller = richTextController,
|
|
enabled = isRichTextMode,
|
|
modifier = Modifier
|
|
.align(Alignment.BottomStart)
|
|
.padding(start = 16.dp, bottom = 24.dp)
|
|
.zIndex(10f)
|
|
)
|
|
if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.background(pdfThemeStyle.viewerBackgroundColor, RoundedCornerShape(8.dp))
|
|
) {
|
|
LazyColumn(
|
|
state = verticalListState,
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.horizontalScroll(pageHorizontalScrollState)
|
|
.padding(horizontal = 24.dp, vertical = 18.dp),
|
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
|
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,
|
|
strokeWidth = strokeWidth,
|
|
isHighlighterSnapEnabled = isHighlighterSnapEnabled,
|
|
activeTextDraft = activeTextDraft,
|
|
richTextController = richTextController,
|
|
isRichTextMode = isRichTextMode,
|
|
readerAiFeaturesAvailable = aiByokSettings.sanitized().areReaderAiFeaturesAvailable,
|
|
cloudTtsAvailable = aiByokSettings.sanitized().isCloudTtsAvailable,
|
|
themeStyle = pdfThemeStyle,
|
|
shouldRender = verticalPageIndex in verticalRenderWindow,
|
|
onSelectPage = {
|
|
goToPage(
|
|
target = it,
|
|
scrollVertical = false,
|
|
saveRichTextBeforePageChange = !isRichTextMode
|
|
)
|
|
},
|
|
onCopySelection = ::copySelection,
|
|
onHighlightSelection = ::highlightSelection,
|
|
onSearchSelection = ::searchSelection,
|
|
onWebSearchSelection = { openPdfExternalLookup(ReaderExternalLookupAction.SEARCH, it.text) },
|
|
onDictionarySelection = { openPdfExternalLookup(ReaderExternalLookupAction.DICTIONARY, it.text) },
|
|
onDefineSelection = { runPdfAiAction(ReaderAiFeature.DEFINE, it.text) },
|
|
onSpeakSelection = { togglePdfCloudTts(it.text) },
|
|
onTranslateSelection = ::translateSelection,
|
|
onEmbeddedAnnotationSelected = ::selectEmbeddedAnnotation,
|
|
onLinkActivated = ::activatePdfLink,
|
|
onAnnotationAdded = { dispatchPdf(SharedPdfReaderAction.AnnotationAdded(it)) },
|
|
onAnnotationUpdated = ::updateAnnotation,
|
|
onAnnotationsChanged = { dispatchPdf(SharedPdfReaderAction.AnnotationsChanged(it)) },
|
|
onTextAnnotationSelected = ::selectTextAnnotation,
|
|
onTextDraftStarted = ::startActiveTextDraft,
|
|
onTextDraftChanged = ::updateActiveTextDraft,
|
|
onTextDraftBoundsChanged = ::updateActiveTextDraftBounds
|
|
)
|
|
}
|
|
}
|
|
DesktopPdfPageScrubOverlay(
|
|
pageIndex = pageScrubPreview,
|
|
pageCount = document.pageCount
|
|
)
|
|
}
|
|
} else {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.background(pdfThemeStyle.viewerBackgroundColor, RoundedCornerShape(8.dp))
|
|
.horizontalScroll(pageHorizontalScrollState)
|
|
.verticalScroll(pageVerticalScrollState)
|
|
.padding(24.dp),
|
|
contentAlignment = Alignment.TopCenter
|
|
) {
|
|
when {
|
|
isRendering -> CircularProgressIndicator(modifier = Modifier.padding(48.dp))
|
|
renderError != null -> Text(renderError ?: "Failed to render page.", color = MaterialTheme.colorScheme.error)
|
|
renderedPage != null -> {
|
|
val pageRender = renderedPage!!
|
|
val pageWidthDp = with(density) { pageRender.width.toDp() }
|
|
val pageHeightDp = with(density) { pageRender.height.toDp() }
|
|
val pageRenderScale = pageRender.width / document.pageSizes[pageIndex].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()
|
|
}
|
|
}
|
|
Box(
|
|
modifier = Modifier
|
|
.size(pageWidthDp, pageHeightDp)
|
|
.onSizeChanged { size ->
|
|
if (pageCanvasSize != size) {
|
|
logPdfSelection(
|
|
"layout page=${pageIndex + 1} " +
|
|
"canvas=${size.formatLogSize()} bitmap=${pageRender.width}x${pageRender.height} " +
|
|
"requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()}"
|
|
)
|
|
}
|
|
pageCanvasSize = size
|
|
}
|
|
.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) {
|
|
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,
|
|
isTextSelectionMode,
|
|
selectedTool,
|
|
selectedColor,
|
|
strokeWidth,
|
|
isHighlighterSnapEnabled,
|
|
textStyleConfig,
|
|
activeTextDraft?.id,
|
|
isRichTextMode,
|
|
pageCanvasSize,
|
|
pageRender.width,
|
|
pageRender.height
|
|
) {
|
|
if (isRichTextMode) return@pointerInput
|
|
if (isTextSelectionMode) {
|
|
detectDragGestures(
|
|
onDragStart = { start ->
|
|
selectionMenuOffset = null
|
|
val hit = document.charHitAt(pageIndex, start, pageCanvasSize)
|
|
selectionStartHit = hit
|
|
selectionStartIndex = hit?.index
|
|
selectionEndHit = null
|
|
selectionEndIndex = null
|
|
logPdfSelection(
|
|
"drag_start page=${pageIndex + 1} " +
|
|
"canvas=${pageCanvasSize.formatLogSize()} bitmap=${pageRender.width}x${pageRender.height} " +
|
|
"requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " +
|
|
hit.formatLogHit("start")
|
|
)
|
|
textSelection = null
|
|
},
|
|
onDrag = { change, _ ->
|
|
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.selectionBetweenIndexes(
|
|
pageIndex = pageIndex,
|
|
startIndex = startIndex,
|
|
endIndex = endIndex,
|
|
canvasSize = pageCanvasSize,
|
|
useNativeBounds = false
|
|
)
|
|
} else {
|
|
null
|
|
}
|
|
}
|
|
},
|
|
onDragEnd = {
|
|
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
|
|
)?.also { textSelection = it }
|
|
} else {
|
|
textSelection
|
|
}
|
|
logPdfSelection(
|
|
"drag_end page=${pageIndex + 1} " +
|
|
"canvas=${pageCanvasSize.formatLogSize()} bitmap=${pageRender.width}x${pageRender.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
|
|
},
|
|
onDragCancel = {
|
|
logPdfSelection(
|
|
"drag_cancel page=${pageIndex + 1} " +
|
|
"canvas=${pageCanvasSize.formatLogSize()} bitmap=${pageRender.width}x${pageRender.height} " +
|
|
"requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " +
|
|
selectionStartHit.formatLogHit("start") + " " +
|
|
selectionEndHit.formatLogHit("end")
|
|
)
|
|
selectionStartIndex = null
|
|
selectionEndIndex = null
|
|
selectionStartHit = null
|
|
selectionEndHit = null
|
|
}
|
|
)
|
|
} 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 {
|
|
var eraserPreviousPoint: Offset? = null
|
|
detectDragGestures(
|
|
onDragStart = { start ->
|
|
if (selectedTool == PdfInkTool.ERASER) {
|
|
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()))
|
|
}
|
|
},
|
|
onDrag = { change, _ ->
|
|
if (selectedTool == PdfInkTool.ERASER) {
|
|
val point = change.position
|
|
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()
|
|
)
|
|
}
|
|
},
|
|
onDragEnd = {
|
|
eraserPreviousPoint = null
|
|
if (activeStroke.size > 1) {
|
|
dispatchPdf(
|
|
SharedPdfReaderAction.AnnotationAdded(
|
|
SharedPdfAnnotation(
|
|
id = "ink_${System.currentTimeMillis()}",
|
|
pageIndex = pageIndex,
|
|
kind = PdfAnnotationKind.INK,
|
|
tool = selectedTool,
|
|
points = activeStroke,
|
|
colorArgb = selectedColor,
|
|
strokeWidth = strokeWidth,
|
|
createdAt = System.currentTimeMillis()
|
|
)
|
|
)
|
|
)
|
|
}
|
|
activeStroke = emptyList()
|
|
},
|
|
onDragCancel = {
|
|
eraserPreviousPoint = null
|
|
activeStroke = emptyList()
|
|
}
|
|
)
|
|
}
|
|
}
|
|
) {
|
|
DesktopPdfThemedPageImage(
|
|
bitmap = pageRender.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
|
|
)
|
|
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
|
|
)
|
|
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,
|
|
onCopy = {
|
|
textSelection?.let(::copySelection)
|
|
clearSelection()
|
|
},
|
|
onHighlight = ::highlightCurrentSelection,
|
|
onSearch = {
|
|
textSelection?.let(::searchSelection)
|
|
selectionMenuOffset = null
|
|
},
|
|
onWebSearch = {
|
|
textSelection?.let { openPdfExternalLookup(ReaderExternalLookupAction.SEARCH, it.text) }
|
|
selectionMenuOffset = null
|
|
},
|
|
onDictionary = {
|
|
textSelection?.let { openPdfExternalLookup(ReaderExternalLookupAction.DICTIONARY, it.text) }
|
|
selectionMenuOffset = null
|
|
},
|
|
onDefine = {
|
|
textSelection?.let { runPdfAiAction(ReaderAiFeature.DEFINE, it.text) }
|
|
selectionMenuOffset = null
|
|
},
|
|
onSpeak = {
|
|
textSelection?.let { togglePdfCloudTts(it.text) }
|
|
selectionMenuOffset = null
|
|
},
|
|
onTranslate = {
|
|
textSelection?.let(::translateSelection)
|
|
selectionMenuOffset = null
|
|
},
|
|
showDefine = aiByokSettings.sanitized().areReaderAiFeaturesAvailable,
|
|
showSpeak = aiByokSettings.sanitized().isCloudTtsAvailable,
|
|
onClear = ::clearSelection
|
|
)
|
|
}
|
|
}
|
|
}
|
|
DesktopPdfPageScrubOverlay(
|
|
pageIndex = pageScrubPreview,
|
|
pageCount = document.pageCount
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun DesktopAiByokSettingsDialog(
|
|
settings: ReaderAiByokSettings,
|
|
secureStorageAvailable: Boolean,
|
|
onSettingsChange: (ReaderAiByokSettings) -> Unit,
|
|
onDismiss: () -> Unit
|
|
) {
|
|
val sanitized = settings.sanitized()
|
|
var selectedProvider by remember { mutableStateOf("gemini") }
|
|
var pendingKey by remember { mutableStateOf("") }
|
|
|
|
AlertDialog(
|
|
onDismissRequest = onDismiss,
|
|
title = { Text("AI keys and models") },
|
|
text = {
|
|
Column(
|
|
modifier = Modifier
|
|
.heightIn(max = 640.dp)
|
|
.verticalScroll(rememberScrollState()),
|
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
) {
|
|
if (!secureStorageAvailable) {
|
|
Text(
|
|
"Secure key storage is unavailable on this operating system. Keys entered here will be used for this session but will not be persisted.",
|
|
color = MaterialTheme.colorScheme.error,
|
|
style = MaterialTheme.typography.bodySmall
|
|
)
|
|
}
|
|
|
|
Text("Saved keys", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
|
DesktopSavedAiKeyRow(
|
|
label = "Gemini",
|
|
keyValue = sanitized.geminiKey,
|
|
onClear = { onSettingsChange(sanitized.copy(geminiKey = "", ttsModel = "")) }
|
|
)
|
|
DesktopSavedAiKeyRow(
|
|
label = "Groq",
|
|
keyValue = sanitized.groqKey,
|
|
onClear = { onSettingsChange(sanitized.copy(groqKey = "")) }
|
|
)
|
|
|
|
HorizontalDivider()
|
|
|
|
Text("Add or replace key", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
|
listOf("gemini" to "Gemini", "groq" to "Groq").forEach { (provider, label) ->
|
|
FilterChip(
|
|
selected = selectedProvider == provider,
|
|
onClick = { selectedProvider = provider },
|
|
label = { Text(label) }
|
|
)
|
|
}
|
|
}
|
|
OutlinedTextField(
|
|
value = pendingKey,
|
|
onValueChange = { pendingKey = it },
|
|
label = { Text("API key") },
|
|
singleLine = true,
|
|
visualTransformation = PasswordVisualTransformation(),
|
|
modifier = Modifier.fillMaxWidth()
|
|
)
|
|
TextButton(
|
|
enabled = pendingKey.isNotBlank(),
|
|
onClick = {
|
|
val trimmed = pendingKey.trim()
|
|
val next = when (selectedProvider) {
|
|
"gemini" -> sanitized.copy(
|
|
geminiKey = trimmed,
|
|
ttsModel = sanitized.ttsModel.ifBlank { GEMINI_CLOUD_TTS_MODEL_ID }
|
|
)
|
|
"groq" -> sanitized.copy(groqKey = trimmed)
|
|
else -> sanitized
|
|
}
|
|
onSettingsChange(next)
|
|
pendingKey = ""
|
|
},
|
|
modifier = Modifier.align(Alignment.End)
|
|
) {
|
|
Text("Save key")
|
|
}
|
|
|
|
HorizontalDivider()
|
|
|
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
|
Column(modifier = Modifier.weight(1f)) {
|
|
Text("Show AI in reader", style = MaterialTheme.typography.titleMedium)
|
|
Text(
|
|
"Matches the Android hide toggle for smart dictionary, summaries, and recaps.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
}
|
|
Switch(
|
|
checked = !sanitized.hideReaderAiFeatures,
|
|
onCheckedChange = { enabled ->
|
|
onSettingsChange(sanitized.copy(hideReaderAiFeatures = !enabled))
|
|
}
|
|
)
|
|
}
|
|
|
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
|
Column(modifier = Modifier.weight(1f)) {
|
|
Text("Use one model for all features", style = MaterialTheme.typography.titleMedium)
|
|
Text(
|
|
"Turn this off to choose separate models per reader AI feature.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
}
|
|
Switch(
|
|
checked = sanitized.useOneModel,
|
|
onCheckedChange = { onSettingsChange(sanitized.copy(useOneModel = it)) }
|
|
)
|
|
}
|
|
|
|
if (sanitized.useOneModel) {
|
|
DesktopAiModelSelector(
|
|
title = "All AI features",
|
|
description = "Smart dictionary, summaries, and recaps all use this model.",
|
|
selectedId = sanitized.modelForAll,
|
|
onSelected = { onSettingsChange(sanitized.copy(modelForAll = it)) }
|
|
)
|
|
} else {
|
|
DesktopAiModelSelector(
|
|
title = "Smart dictionary",
|
|
description = "Used when defining selected words or phrases.",
|
|
selectedId = sanitized.defineModel,
|
|
onSelected = { onSettingsChange(sanitized.copy(defineModel = it)) }
|
|
)
|
|
DesktopAiModelSelector(
|
|
title = "Summaries",
|
|
description = "Used for EPUB summaries and PDF page summaries.",
|
|
selectedId = sanitized.summarizeModel,
|
|
onSelected = { onSettingsChange(sanitized.copy(summarizeModel = it)) }
|
|
)
|
|
DesktopAiModelSelector(
|
|
title = "Recaps",
|
|
description = "Used for story recap generation.",
|
|
selectedId = sanitized.recapModel,
|
|
onSelected = { onSettingsChange(sanitized.copy(recapModel = it)) }
|
|
)
|
|
}
|
|
|
|
DesktopAiModelSelector(
|
|
title = "Cloud TTS",
|
|
description = "Uses the saved Gemini key. Only $GEMINI_CLOUD_TTS_MODEL is supported for now.",
|
|
selectedId = sanitized.ttsModel,
|
|
options = listOf(ReaderAiModelOption("gemini", GEMINI_CLOUD_TTS_MODEL)),
|
|
onSelected = { onSettingsChange(sanitized.copy(ttsModel = it)) }
|
|
)
|
|
Text("Cloud TTS voice", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
ReaderCloudTtsVoices.chunked(3).forEach { rowVoices ->
|
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
|
rowVoices.forEach { voice ->
|
|
FilterChip(
|
|
selected = sanitized.ttsSpeakerId == voice.id,
|
|
onClick = { onSettingsChange(sanitized.copy(ttsSpeakerId = voice.id)) },
|
|
label = {
|
|
Column {
|
|
Text(voice.name, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
Text(
|
|
voice.description,
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis
|
|
)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
confirmButton = {
|
|
TextButton(onClick = onDismiss) {
|
|
Text("Done")
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
private fun DesktopSavedAiKeyRow(
|
|
label: String,
|
|
keyValue: String,
|
|
onClear: () -> Unit
|
|
) {
|
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
|
Column(modifier = Modifier.weight(1f)) {
|
|
Text(label, fontWeight = FontWeight.SemiBold)
|
|
Text(
|
|
keyValue.takeIf { it.isNotBlank() }?.let(::maskedReaderAiKey) ?: "No key saved",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
}
|
|
TextButton(enabled = keyValue.isNotBlank(), onClick = onClear) {
|
|
Text("Clear")
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun DesktopAiModelSelector(
|
|
title: String,
|
|
description: String,
|
|
selectedId: String,
|
|
options: List<ReaderAiModelOption> = ReaderAiModelOptions,
|
|
onSelected: (String) -> Unit
|
|
) {
|
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
|
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
|
FilterChip(
|
|
selected = selectedId.isBlank(),
|
|
onClick = { onSelected("") },
|
|
label = { Text("No model") }
|
|
)
|
|
options.forEach { option ->
|
|
FilterChip(
|
|
selected = selectedId == option.id,
|
|
onClick = { onSelected(option.id) },
|
|
label = { Text(option.label) }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun DesktopPdfExtrasPanel(
|
|
pageText: String,
|
|
recapText: String,
|
|
extrasState: ReaderExtrasState,
|
|
aiByokSettings: ReaderAiByokSettings,
|
|
onExternalLookup: (ReaderExternalLookupAction, String) -> Unit,
|
|
onAiAction: (ReaderAiFeature, String) -> Unit,
|
|
onCloudTtsStart: (ReaderTtsReadScope) -> Unit,
|
|
onCloudTtsPauseResume: () -> Unit,
|
|
onCloudTtsStop: () -> Unit,
|
|
onCloudTtsClearCache: () -> Unit,
|
|
onAutoScrollChange: (ReaderAutoScrollState) -> Unit,
|
|
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
|
|
ttsReplacementBookId: String,
|
|
onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit
|
|
) {
|
|
val settings = aiByokSettings.sanitized()
|
|
val autoScroll = extrasState.autoScroll.sanitized()
|
|
|
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
|
Text("Extras", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
|
ReaderExternalLookupAction.entries.forEach { action ->
|
|
FilterChip(
|
|
selected = false,
|
|
enabled = pageText.isNotBlank(),
|
|
onClick = { onExternalLookup(action, pageText) },
|
|
label = { Text(action.title) }
|
|
)
|
|
}
|
|
}
|
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
|
Text("Auto scroll", modifier = Modifier.weight(1f))
|
|
Switch(
|
|
checked = autoScroll.enabled,
|
|
onCheckedChange = { onAutoScrollChange(autoScroll.copy(enabled = it)) }
|
|
)
|
|
}
|
|
Slider(
|
|
value = autoScroll.speed,
|
|
onValueChange = { onAutoScrollChange(autoScroll.copy(speed = it).sanitized()) },
|
|
valueRange = 12f..160f
|
|
)
|
|
val ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused
|
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
|
Column(modifier = Modifier.weight(1f)) {
|
|
Text(
|
|
when {
|
|
extrasState.cloudTts.isLoading -> "Preparing audio"
|
|
extrasState.cloudTts.isPaused -> "Paused"
|
|
extrasState.cloudTts.isPlaying -> "Reading"
|
|
settings.isCloudTtsAvailable -> "Cloud TTS ready"
|
|
else -> "Cloud TTS needs Gemini"
|
|
},
|
|
fontWeight = FontWeight.SemiBold
|
|
)
|
|
extrasState.cloudTts.errorMessage?.let {
|
|
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
|
|
}
|
|
val statusMessage = extrasState.cloudTts.progress.currentPositionLabel
|
|
?: extrasState.cloudTts.statusMessage?.takeIf { it.isNotBlank() }
|
|
statusMessage?.let {
|
|
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
}
|
|
}
|
|
TextButton(
|
|
enabled = settings.isCloudTtsAvailable || ttsBusy,
|
|
onClick = {
|
|
if (ttsBusy) {
|
|
onCloudTtsStop()
|
|
} else {
|
|
onCloudTtsStart(ReaderTtsReadScope.BOOK)
|
|
}
|
|
}
|
|
) {
|
|
Text(if (ttsBusy) "Stop" else "Read")
|
|
}
|
|
}
|
|
if (extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused) {
|
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
|
TextButton(onClick = onCloudTtsPauseResume) {
|
|
Text(if (extrasState.cloudTts.isPaused) "Resume" else "Pause")
|
|
}
|
|
}
|
|
}
|
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
|
TextButton(
|
|
enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(),
|
|
onClick = { onCloudTtsStart(ReaderTtsReadScope.PAGE) }
|
|
) {
|
|
Text("Page")
|
|
}
|
|
TextButton(
|
|
enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(),
|
|
onClick = { onCloudTtsStart(ReaderTtsReadScope.BOOK) }
|
|
) {
|
|
Text("From here")
|
|
}
|
|
}
|
|
val cacheSummary = extrasState.cloudTts.cacheSummary
|
|
if (cacheSummary.hasCachedAudio) {
|
|
Text(
|
|
"Cache: ${cacheSummary.currentVoiceLabel}",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
if (cacheSummary.hasCurrentVoiceCachedAudio) {
|
|
TextButton(onClick = onCloudTtsClearCache) {
|
|
Text("Clear voice cache")
|
|
}
|
|
}
|
|
}
|
|
SharedReaderTtsReplacementControls(
|
|
preferences = ttsReplacementPreferences,
|
|
bookId = ttsReplacementBookId,
|
|
onPreferencesChange = onTtsReplacementPreferencesChange
|
|
)
|
|
if (settings.areReaderAiFeaturesAvailable) {
|
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
|
TextButton(
|
|
enabled = pageText.isNotBlank() && !extrasState.aiResult.isLoading,
|
|
onClick = { onAiAction(ReaderAiFeature.SUMMARIZE, pageText) }
|
|
) {
|
|
Text("Summarize page")
|
|
}
|
|
TextButton(
|
|
enabled = recapText.isNotBlank() && !extrasState.aiResult.isLoading,
|
|
onClick = { onAiAction(ReaderAiFeature.RECAP, recapText) }
|
|
) {
|
|
Text("Recap")
|
|
}
|
|
}
|
|
if (extrasState.aiResult.hasContent) {
|
|
Surface(color = MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(6.dp), modifier = Modifier.fillMaxWidth()) {
|
|
Column(modifier = Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
val aiErrorMessage = extrasState.aiResult.errorMessage
|
|
Text(extrasState.aiResult.title ?: "AI", fontWeight = FontWeight.SemiBold)
|
|
when {
|
|
extrasState.aiResult.isLoading -> Text("Working...", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
aiErrorMessage != null -> Text(aiErrorMessage, color = MaterialTheme.colorScheme.error)
|
|
else -> SharedMarkdownText(extrasState.aiResult.text)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun DesktopPdfJumpHistoryControls(
|
|
backPage: Int?,
|
|
forwardPage: Int?,
|
|
onBack: () -> Unit,
|
|
onForward: () -> Unit,
|
|
onClear: () -> Unit
|
|
) {
|
|
val hasJumpTargets = backPage != null || forwardPage != null
|
|
Surface(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
color = MaterialTheme.colorScheme.surface,
|
|
shape = RoundedCornerShape(6.dp)
|
|
) {
|
|
Column(
|
|
modifier = Modifier.padding(8.dp),
|
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
|
) {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
Text(
|
|
"Jump history",
|
|
style = MaterialTheme.typography.titleSmall,
|
|
fontWeight = FontWeight.SemiBold,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
IconButton(
|
|
onClick = onClear,
|
|
enabled = hasJumpTargets,
|
|
modifier = Modifier.size(32.dp)
|
|
) {
|
|
Icon(Icons.Default.Close, contentDescription = "Clear jump history")
|
|
}
|
|
}
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
TextButton(
|
|
onClick = onBack,
|
|
enabled = backPage != null,
|
|
modifier = Modifier.weight(1f)
|
|
) {
|
|
Icon(
|
|
Icons.AutoMirrored.Filled.NavigateBefore,
|
|
contentDescription = "Jump back",
|
|
modifier = Modifier.size(18.dp)
|
|
)
|
|
Spacer(Modifier.width(4.dp))
|
|
Text(
|
|
backPage?.let { "Jump back p. ${it + 1}" } ?: "Jump back",
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis
|
|
)
|
|
}
|
|
TextButton(
|
|
onClick = onForward,
|
|
enabled = forwardPage != null,
|
|
modifier = Modifier.weight(1f)
|
|
) {
|
|
Text(
|
|
forwardPage?.let { "Jump forward p. ${it + 1}" } ?: "Jump forward",
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis
|
|
)
|
|
Spacer(Modifier.width(4.dp))
|
|
Icon(
|
|
Icons.AutoMirrored.Filled.NavigateNext,
|
|
contentDescription = "Jump forward",
|
|
modifier = Modifier.size(18.dp)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun DesktopPdfPageScrubOverlay(
|
|
pageIndex: Int?,
|
|
pageCount: Int
|
|
) {
|
|
if (pageIndex == null || pageCount <= 0) return
|
|
Box(
|
|
modifier = Modifier.fillMaxSize(),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Surface(
|
|
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f),
|
|
shape = RoundedCornerShape(16.dp),
|
|
tonalElevation = 6.dp,
|
|
shadowElevation = 8.dp
|
|
) {
|
|
Text(
|
|
text = "Page ${pageIndex + 1} of $pageCount",
|
|
style = MaterialTheme.typography.headlineSmall,
|
|
color = MaterialTheme.colorScheme.onSurface,
|
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun DesktopVerticalPdfPage(
|
|
document: DesktopPdfDocument,
|
|
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,
|
|
strokeWidth: Float,
|
|
isHighlighterSnapEnabled: Boolean,
|
|
activeTextDraft: SharedPdfTextDraft?,
|
|
richTextController: SharedPdfRichTextController,
|
|
isRichTextMode: Boolean,
|
|
readerAiFeaturesAvailable: Boolean,
|
|
cloudTtsAvailable: Boolean,
|
|
themeStyle: DesktopPdfThemeStyle,
|
|
shouldRender: Boolean,
|
|
onSelectPage: (Int) -> Unit,
|
|
onCopySelection: (DesktopPdfTextSelection) -> Unit,
|
|
onHighlightSelection: (Int, DesktopPdfTextSelection, IntSize) -> Unit,
|
|
onSearchSelection: (DesktopPdfTextSelection) -> Unit,
|
|
onWebSearchSelection: (DesktopPdfTextSelection) -> Unit,
|
|
onDictionarySelection: (DesktopPdfTextSelection) -> Unit,
|
|
onDefineSelection: (DesktopPdfTextSelection) -> Unit,
|
|
onSpeakSelection: (DesktopPdfTextSelection) -> Unit,
|
|
onTranslateSelection: (DesktopPdfTextSelection) -> Unit,
|
|
onEmbeddedAnnotationSelected: (SharedPdfEmbeddedAnnotation) -> 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
|
|
) {
|
|
val density = LocalDensity.current
|
|
val pageInteractionSource = remember { MutableInteractionSource() }
|
|
var renderedPage by remember(document.path, pageIndex, scale) { mutableStateOf<DesktopPdfPageRender?>(null) }
|
|
var renderError by remember(document.path, pageIndex, scale) { mutableStateOf<String?>(null) }
|
|
var isRendering by remember(document.path, pageIndex, scale) { mutableStateOf(true) }
|
|
var pageCanvasSize by remember(document.path, pageIndex, scale) { mutableStateOf(IntSize.Zero) }
|
|
var selectionStartIndex by remember(document.path, pageIndex) { mutableStateOf<Int?>(null) }
|
|
var selectionEndIndex by remember(document.path, pageIndex) { mutableStateOf<Int?>(null) }
|
|
var selectionStartHit by remember(document.path, pageIndex) { mutableStateOf<DesktopPdfCharHit?>(null) }
|
|
var selectionEndHit by remember(document.path, pageIndex) { mutableStateOf<DesktopPdfCharHit?>(null) }
|
|
var textSelection by remember(document.path, pageIndex) { mutableStateOf<DesktopPdfTextSelection?>(null) }
|
|
var selectionMenuOffset by remember(document.path, pageIndex) { mutableStateOf<Offset?>(null) }
|
|
var activeStroke by remember(document.path, pageIndex, selectedTool) { mutableStateOf<List<PdfPagePoint>>(emptyList()) }
|
|
val currentTextSelection by rememberUpdatedState(textSelection)
|
|
val currentAnnotations by rememberUpdatedState(annotations)
|
|
|
|
fun clearSelection() {
|
|
selectionStartIndex = null
|
|
selectionEndIndex = null
|
|
selectionStartHit = null
|
|
selectionEndHit = null
|
|
textSelection = null
|
|
selectionMenuOffset = null
|
|
}
|
|
|
|
fun clearInteractionState() {
|
|
clearSelection()
|
|
activeStroke = emptyList()
|
|
}
|
|
|
|
LaunchedEffect(document.path, pageIndex, scale, shouldRender) {
|
|
if (!shouldRender) {
|
|
renderedPage = null
|
|
renderError = null
|
|
isRendering = false
|
|
clearInteractionState()
|
|
return@LaunchedEffect
|
|
}
|
|
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(45)
|
|
val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale)
|
|
val result = withContext(Dispatchers.IO) {
|
|
runCatching { DesktopPdfium.renderPage(document, pageIndex, safeScale) }
|
|
}
|
|
renderedPage = result.getOrNull()
|
|
renderError = result.exceptionOrNull()?.message
|
|
?: if (renderedPage == null) "Failed to render page." else null
|
|
isRendering = false
|
|
}
|
|
|
|
LaunchedEffect(isTextSelectionMode) {
|
|
if (!isTextSelectionMode) {
|
|
clearSelection()
|
|
} else {
|
|
activeStroke = emptyList()
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(selectedTool) {
|
|
activeStroke = emptyList()
|
|
}
|
|
|
|
Column(
|
|
modifier = Modifier.clickable(
|
|
interactionSource = pageInteractionSource,
|
|
indication = null,
|
|
onClick = { onSelectPage(pageIndex) }
|
|
),
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
|
) {
|
|
val pageSize = document.pageSizes.getOrNull(pageIndex)
|
|
val placeholderScale = pageSize?.let { zoomSpec.safeRenderScale(it.width, it.height, scale) } ?: 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)
|
|
.background(Color.White, RoundedCornerShape(2.dp))
|
|
.onSizeChanged { pageCanvasSize = it }
|
|
.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) {
|
|
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,
|
|
isTextSelectionMode,
|
|
selectedTool,
|
|
selectedColor,
|
|
strokeWidth,
|
|
isHighlighterSnapEnabled,
|
|
activeTextDraft?.id,
|
|
isRichTextMode,
|
|
pageCanvasSize,
|
|
renderedPageWidth,
|
|
renderedPageHeight
|
|
) {
|
|
if (renderedPageWidth > 0 && renderedPageHeight > 0) {
|
|
if (isRichTextMode) return@pointerInput
|
|
if (isTextSelectionMode) {
|
|
detectDragGestures(
|
|
onDragStart = { start ->
|
|
onSelectPage(pageIndex)
|
|
activeStroke = emptyList()
|
|
selectionMenuOffset = null
|
|
val hit = document.charHitAt(pageIndex, start, pageCanvasSize)
|
|
selectionStartHit = hit
|
|
selectionStartIndex = hit?.index
|
|
selectionEndHit = null
|
|
selectionEndIndex = null
|
|
logPdfSelection(
|
|
"drag_start page=${pageIndex + 1} " +
|
|
"canvas=${pageCanvasSize.formatLogSize()} bitmap=${renderedPageWidth}x$renderedPageHeight " +
|
|
"requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " +
|
|
hit.formatLogHit("start")
|
|
)
|
|
textSelection = null
|
|
},
|
|
onDrag = { change, _ ->
|
|
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.selectionBetweenIndexes(
|
|
pageIndex = pageIndex,
|
|
startIndex = startIndex,
|
|
endIndex = endIndex,
|
|
canvasSize = pageCanvasSize,
|
|
useNativeBounds = false
|
|
)
|
|
} else {
|
|
null
|
|
}
|
|
}
|
|
},
|
|
onDragEnd = {
|
|
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
|
|
)?.also {
|
|
textSelection = it
|
|
selectionMenuOffset = selectionEndHit?.point ?: selectionStartHit?.point
|
|
}
|
|
} else {
|
|
textSelection
|
|
}
|
|
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
|
|
},
|
|
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
|
|
}
|
|
)
|
|
} 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 {
|
|
var eraserPreviousPoint: Offset? = null
|
|
detectDragGestures(
|
|
onDragStart = { start ->
|
|
onSelectPage(pageIndex)
|
|
clearInteractionState()
|
|
if (selectedTool == PdfInkTool.ERASER) {
|
|
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())
|
|
)
|
|
}
|
|
},
|
|
onDrag = { change, _ ->
|
|
if (selectedTool == PdfInkTool.ERASER) {
|
|
val point = change.position
|
|
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()
|
|
)
|
|
}
|
|
},
|
|
onDragEnd = {
|
|
eraserPreviousPoint = null
|
|
if (activeStroke.size > 1) {
|
|
onAnnotationAdded(
|
|
SharedPdfAnnotation(
|
|
id = "ink_${System.currentTimeMillis()}",
|
|
pageIndex = pageIndex,
|
|
kind = PdfAnnotationKind.INK,
|
|
tool = selectedTool,
|
|
points = activeStroke,
|
|
colorArgb = selectedColor,
|
|
strokeWidth = strokeWidth,
|
|
createdAt = System.currentTimeMillis()
|
|
)
|
|
)
|
|
}
|
|
activeStroke = emptyList()
|
|
},
|
|
onDragCancel = {
|
|
eraserPreviousPoint = null
|
|
activeStroke = emptyList()
|
|
}
|
|
)
|
|
}
|
|
}
|
|
},
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
when {
|
|
!shouldRender -> {
|
|
Text("Page ${pageIndex + 1}", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
}
|
|
isRendering -> CircularProgressIndicator()
|
|
renderError != null -> Text(renderError ?: "Failed to render page.", color = MaterialTheme.colorScheme.error)
|
|
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<PdfPageBounds>()
|
|
} 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 = { onSelectPage(pageIndex) }
|
|
)
|
|
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
|
|
)
|
|
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
|
|
)
|
|
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,
|
|
onCopy = {
|
|
textSelection?.let(onCopySelection)
|
|
clearSelection()
|
|
},
|
|
onHighlight = {
|
|
textSelection?.let { onHighlightSelection(pageIndex, it, pageCanvasSize) }
|
|
clearSelection()
|
|
},
|
|
onSearch = {
|
|
textSelection?.let(onSearchSelection)
|
|
selectionMenuOffset = null
|
|
},
|
|
onWebSearch = {
|
|
textSelection?.let(onWebSearchSelection)
|
|
selectionMenuOffset = null
|
|
},
|
|
onDictionary = {
|
|
textSelection?.let(onDictionarySelection)
|
|
selectionMenuOffset = null
|
|
},
|
|
onDefine = {
|
|
textSelection?.let(onDefineSelection)
|
|
selectionMenuOffset = null
|
|
},
|
|
onSpeak = {
|
|
textSelection?.let(onSpeakSelection)
|
|
selectionMenuOffset = null
|
|
},
|
|
onTranslate = {
|
|
textSelection?.let(onTranslateSelection)
|
|
selectionMenuOffset = null
|
|
},
|
|
showDefine = readerAiFeaturesAvailable,
|
|
showSpeak = cloudTtsAvailable,
|
|
onClear = ::clearSelection
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun DesktopPdfAnnotationEditor(
|
|
annotation: SharedPdfAnnotation,
|
|
onUpdate: (SharedPdfAnnotation) -> Unit,
|
|
onDelete: () -> Unit,
|
|
onClose: () -> Unit
|
|
) {
|
|
Surface(
|
|
color = MaterialTheme.colorScheme.surface,
|
|
shape = RoundedCornerShape(6.dp),
|
|
modifier = Modifier.fillMaxWidth()
|
|
) {
|
|
Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
Text(
|
|
"Selected ${annotation.desktopLabel()}",
|
|
style = MaterialTheme.typography.titleSmall,
|
|
fontWeight = FontWeight.SemiBold,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
TextButton(onClick = onClose) {
|
|
Text("Close")
|
|
}
|
|
}
|
|
Text(
|
|
"Page ${annotation.pageIndex + 1}",
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
style = MaterialTheme.typography.bodySmall
|
|
)
|
|
if (annotation.kind == PdfAnnotationKind.TEXT) {
|
|
OutlinedTextField(
|
|
value = annotation.text,
|
|
onValueChange = { onUpdate(annotation.copy(text = it)) },
|
|
label = { Text("Text note") },
|
|
minLines = 2,
|
|
modifier = Modifier.fillMaxWidth()
|
|
)
|
|
SharedPdfTextStyleControls(
|
|
style = annotation.sharedPdfTextStyle(),
|
|
onStyleChange = { onUpdate(annotation.withSharedPdfTextStyle(it)) }
|
|
)
|
|
}
|
|
if (annotation.kind != PdfAnnotationKind.TEXT) {
|
|
val palette = if (
|
|
annotation.kind == PdfAnnotationKind.HIGHLIGHT ||
|
|
annotation.tool == PdfInkTool.HIGHLIGHTER ||
|
|
annotation.tool == PdfInkTool.HIGHLIGHTER_ROUND
|
|
) {
|
|
SharedPdfAnnotationDefaults.highlighterPalette
|
|
} else {
|
|
SharedPdfAnnotationDefaults.penPalette
|
|
}
|
|
Text("Color", style = MaterialTheme.typography.labelLarge)
|
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
palette.forEach { argb ->
|
|
Surface(
|
|
modifier = Modifier
|
|
.size(26.dp)
|
|
.clickable { onUpdate(annotation.copy(colorArgb = argb)) },
|
|
color = Color(argb),
|
|
shape = RoundedCornerShape(13.dp),
|
|
content = {}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
if (annotation.kind == PdfAnnotationKind.INK) {
|
|
val strokeRange = annotation.tool.sharedPdfStrokeWidthRange()
|
|
val strokeValue = annotation.strokeWidth.coerceIn(strokeRange.start, strokeRange.endInclusive)
|
|
Text("Thickness ${strokeValue.sharedPdfStrokePercent(strokeRange)}", style = MaterialTheme.typography.labelLarge)
|
|
Slider(
|
|
value = strokeValue,
|
|
onValueChange = { onUpdate(annotation.copy(strokeWidth = it.coerceAtLeast(0.0001f))) },
|
|
valueRange = strokeRange
|
|
)
|
|
}
|
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
TextButton(onClick = onDelete) {
|
|
Text("Delete")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun DesktopPdfEmbeddedAnnotationPanel(
|
|
annotation: SharedPdfEmbeddedAnnotation,
|
|
onCopy: () -> Unit,
|
|
onClose: () -> Unit
|
|
) {
|
|
Surface(
|
|
color = MaterialTheme.colorScheme.surface,
|
|
shape = RoundedCornerShape(6.dp),
|
|
modifier = Modifier.fillMaxWidth()
|
|
) {
|
|
Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
Text(
|
|
"Embedded PDF comment",
|
|
style = MaterialTheme.typography.titleSmall,
|
|
fontWeight = FontWeight.SemiBold,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
TextButton(onClick = onClose) {
|
|
Text("Close")
|
|
}
|
|
}
|
|
Text(
|
|
"Page ${annotation.pageIndex + 1}${annotation.author.takeIf { it.isNotBlank() }?.let { " - $it" }.orEmpty()}",
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
style = MaterialTheme.typography.bodySmall
|
|
)
|
|
DesktopPdfEmbeddedComment(
|
|
author = annotation.author,
|
|
contents = annotation.contents.ifBlank { "No comment" },
|
|
depth = 0
|
|
)
|
|
DesktopPdfEmbeddedReplies(annotation.replies, depth = 1)
|
|
TextButton(onClick = onCopy) {
|
|
Text("Copy thread")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun DesktopPdfEmbeddedReplies(
|
|
replies: List<SharedPdfEmbeddedAnnotation>,
|
|
depth: Int
|
|
) {
|
|
replies.forEach { reply ->
|
|
HorizontalDivider()
|
|
DesktopPdfEmbeddedComment(
|
|
author = reply.author,
|
|
contents = reply.contents,
|
|
depth = depth
|
|
)
|
|
if (reply.replies.isNotEmpty()) {
|
|
DesktopPdfEmbeddedReplies(reply.replies, depth + 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun DesktopPdfEmbeddedComment(
|
|
author: String,
|
|
contents: String,
|
|
depth: Int
|
|
) {
|
|
Column(
|
|
modifier = Modifier.padding(start = (depth * 12).dp),
|
|
verticalArrangement = Arrangement.spacedBy(3.dp)
|
|
) {
|
|
Text(
|
|
author.ifBlank { "Unknown" },
|
|
style = MaterialTheme.typography.labelMedium,
|
|
fontWeight = FontWeight.SemiBold
|
|
)
|
|
Text(
|
|
contents.ifBlank { "No comment" },
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
}
|
|
}
|
|
|
|
private data class DesktopPdfTextSelection(
|
|
val text: String,
|
|
val lineBounds: List<PdfPageBounds>,
|
|
val startIndex: Int,
|
|
val endIndex: Int
|
|
)
|
|
|
|
private data class DesktopPdfCharHit(
|
|
val index: Int,
|
|
val source: String,
|
|
val point: Offset,
|
|
val normalized: PdfNormalizedPoint
|
|
)
|
|
|
|
private fun SharedPdfAnnotation.desktopLabel(): String {
|
|
return when (kind) {
|
|
PdfAnnotationKind.HIGHLIGHT -> "highlight"
|
|
PdfAnnotationKind.INK -> tool.name.lowercase().replace('_', ' ')
|
|
PdfAnnotationKind.TEXT -> "text note"
|
|
}
|
|
}
|
|
|
|
private fun SharedPdfEmbeddedAnnotation.threadText(): String {
|
|
return buildString {
|
|
append(author.ifBlank { "Unknown" })
|
|
append(": ")
|
|
appendLine(contents.ifBlank { "No comment" })
|
|
fun appendReplies(replies: List<SharedPdfEmbeddedAnnotation>, indent: String) {
|
|
replies.forEach { reply ->
|
|
append(indent)
|
|
append(reply.author.ifBlank { "Unknown" })
|
|
append(": ")
|
|
appendLine(reply.contents.ifBlank { "No comment" })
|
|
appendReplies(reply.replies, "$indent ")
|
|
}
|
|
}
|
|
appendReplies(replies, " ")
|
|
}.trimEnd()
|
|
}
|
|
|
|
private fun DesktopPdfDocument.linkAt(
|
|
pageIndex: Int,
|
|
point: Offset,
|
|
canvasSize: IntSize
|
|
): DesktopPdfLinkTarget? {
|
|
if (canvasSize.width <= 0 || canvasSize.height <= 0) return null
|
|
return DesktopPdfium.linkAt(
|
|
document = this,
|
|
pageIndex = pageIndex,
|
|
normalizedX = point.x / canvasSize.width,
|
|
normalizedY = point.y / canvasSize.height,
|
|
viewportWidth = canvasSize.width,
|
|
viewportHeight = canvasSize.height
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
private fun PdfSearchHighlightOverlay(
|
|
bounds: List<PdfPageBounds>,
|
|
canvasSize: IntSize,
|
|
color: Color
|
|
) {
|
|
if (bounds.isEmpty() || canvasSize.width <= 0 || canvasSize.height <= 0) return
|
|
Canvas(Modifier.fillMaxSize()) {
|
|
bounds.forEach { rect ->
|
|
drawRect(
|
|
color = color,
|
|
topLeft = Offset(rect.left * canvasSize.width, rect.top * canvasSize.height),
|
|
size = androidx.compose.ui.geometry.Size(
|
|
(rect.right - rect.left) * canvasSize.width,
|
|
(rect.bottom - rect.top) * canvasSize.height
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun PdfTextSelectionOverlay(
|
|
selection: DesktopPdfTextSelection?,
|
|
canvasSize: IntSize
|
|
) {
|
|
val bounds = selection?.lineBounds.orEmpty()
|
|
if (bounds.isEmpty()) return
|
|
Canvas(Modifier.fillMaxSize()) {
|
|
bounds.forEach { rect ->
|
|
drawRect(
|
|
color = Color(0x663B82F6),
|
|
topLeft = Offset(rect.left * canvasSize.width, rect.top * canvasSize.height),
|
|
size = androidx.compose.ui.geometry.Size(
|
|
(rect.right - rect.left) * canvasSize.width,
|
|
(rect.bottom - rect.top) * canvasSize.height
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun PdfSelectionMenu(
|
|
selection: DesktopPdfTextSelection?,
|
|
menuOffset: Offset?,
|
|
canvasSize: IntSize,
|
|
onCopy: () -> Unit,
|
|
onHighlight: () -> Unit,
|
|
onSearch: () -> Unit,
|
|
onWebSearch: () -> Unit,
|
|
onDictionary: () -> Unit,
|
|
onDefine: () -> Unit,
|
|
onSpeak: () -> Unit,
|
|
onTranslate: () -> Unit,
|
|
showDefine: Boolean,
|
|
showSpeak: Boolean,
|
|
onClear: () -> Unit
|
|
) {
|
|
selection ?: return
|
|
val anchor = menuOffset ?: return
|
|
Surface(
|
|
color = MaterialTheme.colorScheme.surface,
|
|
tonalElevation = 6.dp,
|
|
shadowElevation = 8.dp,
|
|
shape = RoundedCornerShape(8.dp),
|
|
modifier = Modifier.padding(
|
|
start = anchor.x.coerceIn(
|
|
PdfSelectionMenuMarginPx,
|
|
(canvasSize.width.toFloat() - PdfSelectionMenuWidthPx).coerceAtLeast(PdfSelectionMenuMarginPx)
|
|
).dp,
|
|
top = anchor.y.coerceIn(
|
|
PdfSelectionMenuMarginPx,
|
|
(canvasSize.height.toFloat() - PdfSelectionMenuHeightPx).coerceAtLeast(PdfSelectionMenuMarginPx)
|
|
).dp
|
|
)
|
|
) {
|
|
Row(
|
|
modifier = Modifier
|
|
.padding(horizontal = 6.dp, vertical = 4.dp)
|
|
.horizontalScroll(rememberScrollState()),
|
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
TextButton(onClick = onCopy) { Text("Copy") }
|
|
TextButton(onClick = onHighlight) { Text("Highlight") }
|
|
if (showDefine) TextButton(onClick = onDefine) { Text("Define") }
|
|
if (showSpeak) TextButton(onClick = onSpeak) { Text("Speak") }
|
|
TextButton(onClick = onDictionary) { Text("Dict") }
|
|
TextButton(onClick = onSearch) { Text("Find") }
|
|
TextButton(onClick = onWebSearch) { Text("Web") }
|
|
TextButton(onClick = onTranslate) { Text("Translate") }
|
|
TextButton(onClick = onClear) { Text("Clear") }
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun DesktopPdfDocument.charHitAt(
|
|
pageIndex: Int,
|
|
point: Offset,
|
|
canvasSize: IntSize
|
|
): DesktopPdfCharHit? {
|
|
val normalized = PdfSelectionGeometry.normalizedPoint(
|
|
pointX = point.x,
|
|
pointY = point.y,
|
|
viewportWidth = canvasSize.width,
|
|
viewportHeight = canvasSize.height
|
|
) ?: return null
|
|
val nativeIndex = DesktopPdfium.charIndexAt(
|
|
document = this,
|
|
pageIndex = pageIndex,
|
|
normalizedX = normalized.x,
|
|
normalizedY = normalized.y,
|
|
viewportWidth = canvasSize.width,
|
|
viewportHeight = canvasSize.height
|
|
)
|
|
if (nativeIndex != null) {
|
|
return DesktopPdfCharHit(
|
|
index = nativeIndex,
|
|
source = "native",
|
|
point = point,
|
|
normalized = normalized
|
|
)
|
|
}
|
|
val fallback = PdfSelectionGeometry.nearestCharOnLine(
|
|
chars = textPageData(pageIndex).chars.visiblePdfTextBounds(),
|
|
point = normalized
|
|
) ?: return null
|
|
return DesktopPdfCharHit(
|
|
index = fallback.index,
|
|
source = "fallback_line",
|
|
point = point,
|
|
normalized = normalized
|
|
)
|
|
}
|
|
|
|
private fun DesktopPdfDocument.selectionBetweenIndexes(
|
|
pageIndex: Int,
|
|
startIndex: Int,
|
|
endIndex: Int,
|
|
canvasSize: IntSize,
|
|
useNativeBounds: Boolean = true
|
|
): DesktopPdfTextSelection? {
|
|
val chars = textPageData(pageIndex).chars
|
|
if (chars.isEmpty() || abs(startIndex - endIndex) < 1) return null
|
|
val firstIndex = minOf(startIndex, endIndex)
|
|
val lastIndex = maxOf(startIndex, endIndex)
|
|
val selectedChars = chars.filter { it.index in firstIndex..lastIndex }
|
|
val text = selectedChars.joinToString("") { it.char.toString() }
|
|
.replace(Regex("[ \\t\\x0B\\f\\r]+"), " ")
|
|
.replace(Regex("\\n{3,}"), "\n\n")
|
|
.trim()
|
|
if (text.isBlank()) return null
|
|
val fallbackBounds = PdfSelectionGeometry.lineBoundsForChars(selectedChars.visiblePdfTextBounds())
|
|
val nativeBounds = if (useNativeBounds) {
|
|
DesktopPdfium.textRectsForRange(
|
|
document = this,
|
|
pageIndex = pageIndex,
|
|
startIndex = firstIndex,
|
|
endIndex = lastIndex,
|
|
viewportWidth = canvasSize.width,
|
|
viewportHeight = canvasSize.height
|
|
).map { it.toPdfPageBounds() }
|
|
.filter { it.right > it.left && it.bottom > it.top }
|
|
.mergePdfBoundsByLine()
|
|
} else {
|
|
emptyList()
|
|
}
|
|
return DesktopPdfTextSelection(
|
|
text = text,
|
|
lineBounds = nativeBounds.ifEmpty { fallbackBounds },
|
|
startIndex = firstIndex,
|
|
endIndex = lastIndex
|
|
)
|
|
}
|
|
|
|
private fun DesktopPdfTextRect.toPdfPageBounds(): PdfPageBounds {
|
|
return PdfPageBounds(
|
|
left = left,
|
|
top = top,
|
|
right = right,
|
|
bottom = bottom
|
|
)
|
|
}
|
|
|
|
private fun SharedPdfAnnotation.toRenderablePdfAnnotations(
|
|
document: DesktopPdfDocument,
|
|
pageIndex: Int,
|
|
canvasSize: IntSize
|
|
): List<SharedPdfAnnotation> {
|
|
val startIndex = rangeStartIndex
|
|
val endIndex = rangeEndIndex
|
|
if (kind != PdfAnnotationKind.HIGHLIGHT || startIndex == null || endIndex == null) {
|
|
return listOf(this)
|
|
}
|
|
if (canvasSize.width <= 0 || canvasSize.height <= 0) {
|
|
return listOf(this)
|
|
}
|
|
val dynamicBounds = DesktopPdfium.textRectsForRange(
|
|
document = document,
|
|
pageIndex = pageIndex,
|
|
startIndex = startIndex,
|
|
endIndex = endIndex,
|
|
viewportWidth = canvasSize.width,
|
|
viewportHeight = canvasSize.height
|
|
).map { it.toPdfPageBounds() }
|
|
.filter { it.right > it.left && it.bottom > it.top }
|
|
.mergePdfBoundsByLine()
|
|
|
|
return dynamicBounds.ifEmpty { boundsList.ifEmpty { listOfNotNull(bounds) } }
|
|
.mapIndexed { index, dynamicBounds ->
|
|
copy(
|
|
id = "${id}_line_$index",
|
|
bounds = dynamicBounds
|
|
)
|
|
}
|
|
}
|
|
|
|
private fun SharedPdfTextDraft.containsOffset(
|
|
pageIndex: Int,
|
|
offset: Offset,
|
|
canvasSize: IntSize
|
|
): Boolean {
|
|
if (this.pageIndex != pageIndex || canvasSize.width <= 0 || canvasSize.height <= 0) return false
|
|
val left = bounds.left * canvasSize.width
|
|
val right = bounds.right * canvasSize.width
|
|
val top = bounds.top * canvasSize.height
|
|
val bottom = bounds.bottom * canvasSize.height
|
|
return offset.x in left..right && offset.y in top..bottom
|
|
}
|
|
|
|
private fun List<SharedPdfAnnotation>.textAnnotationHitAt(
|
|
pageIndex: Int,
|
|
point: Offset,
|
|
canvasSize: IntSize
|
|
): SharedPdfAnnotation? {
|
|
return asReversed().firstOrNull { annotation ->
|
|
annotation.kind == PdfAnnotationKind.TEXT &&
|
|
annotation.pageIndex == pageIndex &&
|
|
annotation.sharedPdfHitTest(point, canvasSize)
|
|
}
|
|
}
|
|
|
|
private fun List<PdfPageBounds>.mergePdfBoundsByLine(): List<PdfPageBounds> {
|
|
return PdfSelectionGeometry.mergeBoundsByLine(this)
|
|
}
|
|
|
|
private fun List<DesktopPdfTextChar>.visiblePdfTextBounds(): List<PdfTextCharBounds> {
|
|
return asSequence()
|
|
.filter { it.hasBounds && !it.char.isISOControl() }
|
|
.map { it.toPdfTextCharBounds() }
|
|
.toList()
|
|
}
|
|
|
|
private fun DesktopPdfTextChar.toPdfTextCharBounds(): PdfTextCharBounds {
|
|
return PdfTextCharBounds(
|
|
index = index,
|
|
left = left,
|
|
top = top,
|
|
right = right,
|
|
bottom = bottom
|
|
)
|
|
}
|
|
|
|
private const val PdfSelectionMenuWidthPx = 620f
|
|
private const val PdfSelectionMenuHeightPx = 54f
|
|
private const val PdfSelectionMenuMarginPx = 6f
|
|
|
|
internal fun desktopPdfAnnotationFile(documentPath: String): File {
|
|
val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
|
|
?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
|
|
val safeName = documentPath.hashCode().toString().replace("-", "n")
|
|
return File(baseDir, "Episteme/annotations/pdf_$safeName.json")
|
|
}
|
|
|
|
internal fun desktopPdfBookmarkFile(documentPath: String): File {
|
|
val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
|
|
?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
|
|
val safeName = documentPath.hashCode().toString().replace("-", "n")
|
|
return File(baseDir, "Episteme/annotations/pdf_${safeName}_bookmarks.json")
|
|
}
|
|
|
|
internal fun desktopPdfRichTextFile(documentPath: String): File {
|
|
val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
|
|
?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
|
|
val safeName = documentPath.hashCode().toString().replace("-", "n")
|
|
return File(baseDir, "Episteme/annotations/pdf_${safeName}_rich_text.json")
|
|
}
|
|
|
|
private fun desktopPdfSearchIndexFile(documentPath: String): File {
|
|
val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
|
|
?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
|
|
val safeName = documentPath.hashCode().toString().replace("-", "n")
|
|
return File(baseDir, "Episteme/search/pdf_${safeName}_text_index.tsv")
|
|
}
|
|
|
|
private fun restoreDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFile: File): Int {
|
|
val sourceFile = File(document.path)
|
|
val lines = runCatching { indexFile.readLines(Charsets.UTF_8) }.getOrNull() ?: return document.indexedSearchTextPageCount()
|
|
if (lines.firstOrNull() != DesktopPdfSearchIndexHeader) return 0
|
|
val metadata = lines
|
|
.asSequence()
|
|
.drop(1)
|
|
.takeWhile { !it.startsWith("page\t") }
|
|
.mapNotNull { line ->
|
|
val parts = line.split('\t', limit = 2)
|
|
if (parts.size == 2) parts[0] to parts[1] else null
|
|
}
|
|
.toMap()
|
|
val isFresh = metadata["pathHash"] == document.path.hashCode().toString() &&
|
|
metadata["fileSize"] == sourceFile.length().toString() &&
|
|
metadata["lastModified"] == sourceFile.lastModified().toString() &&
|
|
metadata["pageCount"] == document.pageCount.toString()
|
|
if (!isFresh) return 0
|
|
|
|
val decoder = Base64.getDecoder()
|
|
lines.asSequence()
|
|
.filter { it.startsWith("page\t") }
|
|
.forEach { line ->
|
|
val parts = line.split('\t', limit = 3)
|
|
val pageIndex = parts.getOrNull(1)?.toIntOrNull() ?: return@forEach
|
|
val text = runCatching {
|
|
String(decoder.decode(parts.getOrNull(2).orEmpty()), Charsets.UTF_8)
|
|
}.getOrDefault("")
|
|
document.cacheSearchTextPage(pageIndex, text)
|
|
}
|
|
return document.indexedSearchTextPageCount()
|
|
}
|
|
|
|
private fun saveDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFile: File) {
|
|
val sourceFile = File(document.path)
|
|
val pages = document.indexedSearchPages()
|
|
if (pages.isEmpty()) return
|
|
val encoder = Base64.getEncoder()
|
|
val payload = buildString {
|
|
appendLine(DesktopPdfSearchIndexHeader)
|
|
appendLine("pathHash\t${document.path.hashCode()}")
|
|
appendLine("fileSize\t${sourceFile.length()}")
|
|
appendLine("lastModified\t${sourceFile.lastModified()}")
|
|
appendLine("pageCount\t${document.pageCount}")
|
|
pages.forEach { page ->
|
|
append("page\t")
|
|
append(page.pageIndex)
|
|
append('\t')
|
|
appendLine(encoder.encodeToString(page.text.toByteArray(Charsets.UTF_8)))
|
|
}
|
|
}
|
|
runCatching {
|
|
indexFile.parentFile?.mkdirs()
|
|
indexFile.writeText(payload, Charsets.UTF_8)
|
|
}
|
|
}
|
|
|
|
private const val DesktopPdfSearchIndexHeader = "EpistemePdfSearchIndex\t1"
|
|
|
|
@Composable
|
|
private fun ReaderScreen(
|
|
session: ReaderSessionState,
|
|
readerEngine: ReaderEngine,
|
|
onSessionChange: (ReaderSessionState) -> Unit,
|
|
onOpenBook: () -> Unit,
|
|
onOpenPdf: () -> 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,
|
|
onExternalLookup: (ReaderExternalLookupAction, String) -> Unit,
|
|
onAiAction: (ReaderAiFeature, String) -> 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
|
|
) {
|
|
var externalLinkDialogUrl by remember { mutableStateOf<String?>(null) }
|
|
var lastHandledLink by remember { mutableStateOf<DesktopEpubHandledLink?>(null) }
|
|
|
|
DesktopExternalLinkDialog(
|
|
url = externalLinkDialogUrl,
|
|
onDismiss = { externalLinkDialogUrl = null }
|
|
)
|
|
|
|
SharedReaderScreen(
|
|
session = session,
|
|
readerEngine = readerEngine,
|
|
onSessionChange = onSessionChange,
|
|
onOpenBook = onOpenBook,
|
|
onOpenPdf = onOpenPdf,
|
|
toolbarPreferences = toolbarPreferences,
|
|
onToolbarPreferencesChange = onToolbarPreferencesChange,
|
|
highlightPalette = highlightPalette,
|
|
onHighlightPaletteChange = onHighlightPaletteChange,
|
|
ttsReplacementPreferences = ttsReplacementPreferences,
|
|
ttsReplacementBookId = ttsReplacementBookId,
|
|
onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange,
|
|
onPickCustomFont = onPickCustomFont,
|
|
customFonts = customFonts,
|
|
readerExtrasState = readerExtrasState,
|
|
aiByokSettings = aiByokSettings,
|
|
onExternalLookup = onExternalLookup,
|
|
onAiAction = onAiAction,
|
|
onCloudTtsStart = onCloudTtsStart,
|
|
onCloudTtsPauseResume = onCloudTtsPauseResume,
|
|
onCloudTtsStop = onCloudTtsStop,
|
|
onCloudTtsClearCache = onCloudTtsClearCache,
|
|
onAutoScrollChange = onAutoScrollChange,
|
|
readerTextureDataUri = readerTextureDataUri,
|
|
readerCustomTextureIds = readerCustomTextureIds,
|
|
onImportReaderTexture = onImportReaderTexture
|
|
) { html, background, navigationTarget, highlights, onVisiblePageChanged ->
|
|
Surface(
|
|
color = background,
|
|
shape = RoundedCornerShape(8.dp),
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.weight(1f)
|
|
) {
|
|
if (webViewRuntimeState.initialized) {
|
|
DesktopEpubWebView(
|
|
html = html,
|
|
navigationTarget = navigationTarget,
|
|
highlights = highlights,
|
|
onHighlightCreated = { highlight ->
|
|
onSessionChange(session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine))
|
|
},
|
|
onSelectionAction = { 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.DICTIONARY -> onExternalLookup(ReaderExternalLookupAction.DICTIONARY, text)
|
|
DesktopReaderSelectionAction.TRANSLATE -> onExternalLookup(ReaderExternalLookupAction.TRANSLATE, text)
|
|
DesktopReaderSelectionAction.SEARCH -> onExternalLookup(ReaderExternalLookupAction.SEARCH, text)
|
|
}
|
|
},
|
|
onLinkClicked = { 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()}\"")
|
|
externalLinkDialogUrl = target.url
|
|
}
|
|
is ReaderLinkTarget.Internal -> {
|
|
logEpubLink(
|
|
"resolved_internal chapter=${target.locator.chapterIndex} " +
|
|
"page=${target.locator.pageIndex} offset=${target.locator.startOffset}"
|
|
)
|
|
onSessionChange(readerEngine.goToLocator(session, target.locator))
|
|
}
|
|
ReaderLinkTarget.Ignored -> {
|
|
logEpubLink("resolved_ignored href=\"${link.href.logPreview()}\"")
|
|
}
|
|
}
|
|
}
|
|
},
|
|
onVisiblePageChanged = onVisiblePageChanged,
|
|
modifier = Modifier.fillMaxSize()
|
|
)
|
|
} else {
|
|
DesktopWebViewRuntimeIndicator(
|
|
state = webViewRuntimeState,
|
|
modifier = Modifier.fillMaxSize()
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun DesktopEpubWebView(
|
|
html: String,
|
|
navigationTarget: ReaderContentNavigationTarget,
|
|
highlights: List<UserHighlight>,
|
|
onHighlightCreated: (UserHighlight) -> Unit,
|
|
onSelectionAction: (DesktopReaderSelectionAction, String) -> Unit,
|
|
onLinkClicked: (DesktopEpubLinkClick) -> Unit,
|
|
onVisiblePageChanged: (Int, ReaderLocator?) -> Unit,
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
val latestOnHighlightCreated by rememberUpdatedState(onHighlightCreated)
|
|
val latestOnSelectionAction by rememberUpdatedState(onSelectionAction)
|
|
val latestOnLinkClicked by rememberUpdatedState(onLinkClicked)
|
|
val latestOnVisiblePageChanged by rememberUpdatedState(onVisiblePageChanged)
|
|
val scope = rememberCoroutineScope()
|
|
val linkRequestInterceptor = remember(scope) {
|
|
object : RequestInterceptor {
|
|
override fun onInterceptUrlRequest(
|
|
request: WebRequest,
|
|
navigator: WebViewNavigator
|
|
): WebRequestInterceptResult {
|
|
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
|
|
) {
|
|
EpubAnnotationSerializer.parseHighlightJsonLenient(message.params)?.let { highlight ->
|
|
scope.launch { latestOnHighlightCreated(highlight) }
|
|
}
|
|
}
|
|
}
|
|
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 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 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(positionHandler)
|
|
bridge.register(selectionActionHandler)
|
|
bridge.register(ttsHighlightLogHandler)
|
|
bridge.register(linkHandler)
|
|
onDispose {
|
|
bridge.unregister(highlightHandler)
|
|
bridge.unregister(positionHandler)
|
|
bridge.unregister(selectionActionHandler)
|
|
bridge.unregister(ttsHighlightLogHandler)
|
|
bridge.unregister(linkHandler)
|
|
}
|
|
}
|
|
|
|
key(html) {
|
|
val state = rememberWebViewStateWithHTMLData(
|
|
data = html,
|
|
baseUrl = null,
|
|
encoding = "utf-8",
|
|
mimeType = "text/html",
|
|
historyUrl = null
|
|
)
|
|
|
|
Box(modifier = modifier) {
|
|
WebView(
|
|
state = state,
|
|
modifier = Modifier.fillMaxSize(),
|
|
captureBackPresses = false,
|
|
navigator = navigator,
|
|
webViewJsBridge = bridge
|
|
)
|
|
|
|
LaunchedEffect(
|
|
navigationTarget.autoScroll,
|
|
navigationTarget.readingMode,
|
|
state.loadingState
|
|
) {
|
|
if (navigationTarget.readingMode != com.aryan.reader.shared.reader.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 != com.aryan.reader.shared.reader.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 == com.aryan.reader.shared.reader.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, navigationTarget.readingMode, state.loadingState) {
|
|
if (navigationTarget.readingMode != com.aryan.reader.shared.reader.ReaderReadingMode.VERTICAL) return@LaunchedEffect
|
|
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()
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private data class DesktopReaderPosition(
|
|
val pageIndex: Int,
|
|
val locator: ReaderLocator?
|
|
)
|
|
|
|
private data class DesktopEpubLinkClick(
|
|
val href: String,
|
|
val chapterIndex: Int?,
|
|
val text: String? = null,
|
|
val chapterId: String? = null,
|
|
val chapterHref: String? = null,
|
|
val source: String = "bridge"
|
|
)
|
|
|
|
private data class DesktopEpubHandledLink(
|
|
val href: String,
|
|
val handledAtMs: Long
|
|
)
|
|
|
|
private enum class DesktopReaderSelectionAction {
|
|
DEFINE,
|
|
SPEAK,
|
|
DICTIONARY,
|
|
TRANSLATE,
|
|
SEARCH
|
|
}
|
|
|
|
private data class DesktopReaderSelectionActionPayload(
|
|
val action: DesktopReaderSelectionAction,
|
|
val text: String
|
|
)
|
|
|
|
private fun String.readerSelectionActionOrNull(): DesktopReaderSelectionActionPayload? {
|
|
fun parse(rawJson: String): DesktopReaderSelectionActionPayload? = runCatching {
|
|
val obj = Json.parseToJsonElement(rawJson).jsonObject
|
|
val text = obj["text"]
|
|
?.takeUnless { it is JsonNull }
|
|
?.jsonPrimitive
|
|
?.contentOrNull
|
|
?.takeIf { it.isNotBlank() }
|
|
?: return@runCatching null
|
|
val action = when (
|
|
obj["action"]
|
|
?.takeUnless { it is JsonNull }
|
|
?.jsonPrimitive
|
|
?.contentOrNull
|
|
?.lowercase()
|
|
) {
|
|
"define" -> DesktopReaderSelectionAction.DEFINE
|
|
"speak" -> DesktopReaderSelectionAction.SPEAK
|
|
"dictionary" -> DesktopReaderSelectionAction.DICTIONARY
|
|
"translate" -> DesktopReaderSelectionAction.TRANSLATE
|
|
"web-search", "search" -> DesktopReaderSelectionAction.SEARCH
|
|
else -> return@runCatching null
|
|
}
|
|
DesktopReaderSelectionActionPayload(action, text)
|
|
}.getOrNull()
|
|
|
|
parse(this)?.let { return it }
|
|
return runCatching {
|
|
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
|
}.getOrNull()?.let { parse(it) }
|
|
}
|
|
|
|
private fun String.readerPositionOrNull(): DesktopReaderPosition? {
|
|
fun parse(rawJson: String): DesktopReaderPosition? = runCatching {
|
|
val obj = Json.parseToJsonElement(rawJson).jsonObject
|
|
val pageIndex = obj["pageIndex"]
|
|
?.takeUnless { it is JsonNull }
|
|
?.jsonPrimitive
|
|
?.intOrNull
|
|
?: return@runCatching null
|
|
val locator = ReaderLocator(
|
|
chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
|
pageIndex = pageIndex,
|
|
startOffset = obj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
|
endOffset = obj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
|
textQuote = obj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
|
cfi = obj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull
|
|
)
|
|
DesktopReaderPosition(pageIndex, locator)
|
|
}.getOrNull()
|
|
|
|
parse(this)?.let { return it }
|
|
return runCatching {
|
|
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
|
}.getOrNull()?.let { parse(it) }
|
|
}
|
|
|
|
private fun String.readerLinkClickOrNull(): DesktopEpubLinkClick? {
|
|
fun parse(rawJson: String): DesktopEpubLinkClick? = runCatching {
|
|
val obj = Json.parseToJsonElement(rawJson).jsonObject
|
|
val href = obj["href"]
|
|
?.takeUnless { it is JsonNull }
|
|
?.jsonPrimitive
|
|
?.contentOrNull
|
|
?.takeIf { it.isNotBlank() }
|
|
?: return@runCatching null
|
|
DesktopEpubLinkClick(
|
|
href = href,
|
|
chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
|
text = obj["text"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
|
chapterId = obj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
|
chapterHref = obj["chapterHref"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull
|
|
)
|
|
}.getOrNull()
|
|
|
|
parse(this)?.let { return it }
|
|
return runCatching {
|
|
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
|
}.getOrNull()?.let { parse(it) }
|
|
}
|
|
|
|
private fun String.readerLinkClickFromIntercept(): DesktopEpubLinkClick? {
|
|
val trimmed = trim()
|
|
if (trimmed.startsWith("readerlink:", ignoreCase = true)) {
|
|
logEpubLink("request_intercept_readerlink raw=\"${trimmed.logPreview()}\"")
|
|
val payload = trimmed.substringAfter("?", missingDelimiterValue = "")
|
|
.split('&')
|
|
.firstOrNull { it.substringBefore("=").equals("payload", ignoreCase = true) }
|
|
?.substringAfter("=", missingDelimiterValue = "")
|
|
?.takeIf { it.isNotBlank() }
|
|
if (payload == null) {
|
|
logEpubLink("request_intercept_readerlink_ignored reason=missing_payload")
|
|
return null
|
|
}
|
|
val decoded = runCatching {
|
|
URLDecoder.decode(payload, Charsets.UTF_8.name())
|
|
}.getOrElse {
|
|
logEpubLink("request_intercept_payload_decode_failed error=\"${it.message.orEmpty().logPreview()}\"")
|
|
return null
|
|
}
|
|
val link = decoded.readerLinkClickOrNull()?.copy(source = "request")
|
|
if (link == null) {
|
|
logEpubLink("request_intercept_readerlink_ignored reason=parse_failed payload=\"${decoded.logPreview()}\"")
|
|
}
|
|
return link
|
|
}
|
|
return readerHrefFromIntercept()?.let { href ->
|
|
DesktopEpubLinkClick(
|
|
href = href,
|
|
chapterIndex = null,
|
|
source = "request"
|
|
)
|
|
}
|
|
}
|
|
|
|
private fun String.readerHrefFromIntercept(): String? {
|
|
val trimmed = trim()
|
|
if (trimmed.isBlank()) return null
|
|
if (trimmed.equals("about:blank", ignoreCase = true)) return null
|
|
if (trimmed.startsWith("file:///kcefbrowser/", ignoreCase = true)) return null
|
|
if (trimmed.startsWith("file:/kcefbrowser/", ignoreCase = true)) return null
|
|
if (trimmed.startsWith("file://", ignoreCase = true)) return null
|
|
if (trimmed.startsWith("about:blank#", ignoreCase = true)) return "#${trimmed.substringAfter('#')}"
|
|
if (trimmed.startsWith("data:", ignoreCase = true)) return null
|
|
if (trimmed.startsWith("blob:", ignoreCase = true)) return null
|
|
return trimmed
|
|
}
|
|
|
|
private fun ReaderLocator.toReaderLocatorJson(): String {
|
|
return buildString {
|
|
append("{")
|
|
val values = buildList {
|
|
chapterIndex?.let { add("\"chapterIndex\":$it") }
|
|
pageIndex?.let { add("\"pageIndex\":$it") }
|
|
startOffset?.let { add("\"startOffset\":$it") }
|
|
endOffset?.let { add("\"endOffset\":$it") }
|
|
cfi?.let { add("\"cfi\":${it.toJsonStringLiteral()}") }
|
|
textQuote?.let { add("\"textQuote\":${it.toJsonStringLiteral()}") }
|
|
}
|
|
append(values.joinToString(","))
|
|
append("}")
|
|
}
|
|
}
|
|
|
|
private fun String.toJsonStringLiteral(): String {
|
|
val builder = StringBuilder("\"")
|
|
forEach { char ->
|
|
when (char) {
|
|
'\\' -> builder.append("\\\\")
|
|
'"' -> builder.append("\\\"")
|
|
'\n' -> builder.append("\\n")
|
|
'\r' -> builder.append("\\r")
|
|
'\t' -> builder.append("\\t")
|
|
'\b' -> builder.append("\\b")
|
|
'\u000C' -> builder.append("\\f")
|
|
else -> {
|
|
if (char.code < 0x20) {
|
|
builder.append("\\u")
|
|
builder.append(char.code.toString(16).padStart(4, '0'))
|
|
} else {
|
|
builder.append(char)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
builder.append('"')
|
|
return builder.toString()
|
|
}
|
|
|
|
@Composable
|
|
private fun DesktopWebViewRuntimeIndicator(
|
|
state: DesktopWebViewRuntimeState,
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
val message = when {
|
|
state.errorMessage != null -> "Embedded webview could not start: ${state.errorMessage}"
|
|
state.restartRequired -> "Embedded webview installed. Restart Episteme to finish setup."
|
|
state.downloadProgress >= 0f -> "Downloading embedded webview ${state.downloadProgress.toInt()}%"
|
|
else -> "Preparing embedded webview..."
|
|
}
|
|
|
|
Box(
|
|
modifier = modifier.padding(32.dp),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Column(
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
) {
|
|
if (state.errorMessage == null && !state.restartRequired) {
|
|
CircularProgressIndicator()
|
|
}
|
|
Text(
|
|
text = message,
|
|
color = if (state.errorMessage == null) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.error,
|
|
textAlign = TextAlign.Center
|
|
)
|
|
if (state.downloadProgress in 0f..100f) {
|
|
LinearProgressIndicator(
|
|
progress = { state.downloadProgress / 100f },
|
|
modifier = Modifier.width(260.dp)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun String.highlightQuery(query: String, color: Color): AnnotatedString {
|
|
val normalized = query.trim()
|
|
if (normalized.length < 2) return AnnotatedString(this)
|
|
|
|
return buildAnnotatedString {
|
|
append(this@highlightQuery)
|
|
var startIndex = 0
|
|
while (startIndex < this@highlightQuery.length) {
|
|
val index = this@highlightQuery.indexOf(normalized, startIndex, ignoreCase = true)
|
|
if (index < 0) break
|
|
addStyle(
|
|
style = SpanStyle(background = color),
|
|
start = index,
|
|
end = index + normalized.length
|
|
)
|
|
startIndex = index + normalized.length
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun SemanticBlockView(
|
|
block: SemanticBlock,
|
|
foreground: Color,
|
|
searchQuery: String,
|
|
searchHighlight: Color,
|
|
fallbackTextAlign: TextAlign,
|
|
fallbackFontFamily: FontFamily,
|
|
settings: com.aryan.reader.shared.reader.ReaderSettings
|
|
) {
|
|
val modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(
|
|
start = block.style.blockStyle.margin.left.safeDp(),
|
|
top = block.style.blockStyle.margin.top.safeDp(),
|
|
end = block.style.blockStyle.margin.right.safeDp(),
|
|
bottom = block.style.blockStyle.margin.bottom.safeDp()
|
|
)
|
|
.then(
|
|
if (block.style.blockStyle.backgroundColor.isSpecified) {
|
|
Modifier.background(block.style.blockStyle.backgroundColor, RoundedCornerShape(4.dp))
|
|
} else {
|
|
Modifier
|
|
}
|
|
)
|
|
.padding(
|
|
start = block.style.blockStyle.padding.left.safeDp(),
|
|
top = block.style.blockStyle.padding.top.safeDp(),
|
|
end = block.style.blockStyle.padding.right.safeDp(),
|
|
bottom = block.style.blockStyle.padding.bottom.safeDp()
|
|
)
|
|
|
|
when (block) {
|
|
is SemanticHeader -> {
|
|
Text(
|
|
text = block.toAnnotatedString(searchQuery, searchHighlight),
|
|
color = foreground,
|
|
modifier = modifier,
|
|
textAlign = block.style.paragraphStyle.textAlign.takeUnless { it == TextAlign.Unspecified } ?: fallbackTextAlign,
|
|
style = MaterialTheme.typography.bodyLarge.copy(
|
|
fontSize = (settings.fontSize * headerScale(block.level)).sp,
|
|
lineHeight = (settings.fontSize * headerScale(block.level) * settings.lineSpacing).sp,
|
|
fontWeight = FontWeight.Bold,
|
|
fontFamily = fallbackFontFamily
|
|
)
|
|
)
|
|
}
|
|
|
|
is SemanticParagraph -> SemanticTextView(block, modifier, foreground, searchQuery, searchHighlight, fallbackTextAlign, fallbackFontFamily, settings)
|
|
is SemanticListItem -> SemanticTextView(block, modifier, foreground, searchQuery, searchHighlight, fallbackTextAlign, fallbackFontFamily, settings)
|
|
is SemanticTextBlock -> SemanticTextView(block, modifier, foreground, searchQuery, searchHighlight, fallbackTextAlign, fallbackFontFamily, settings)
|
|
|
|
is SemanticList -> {
|
|
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
block.items.forEachIndexed { index, item ->
|
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
Text(if (block.isOrdered) "${index + 1}." else "•", color = foreground)
|
|
SemanticTextView(
|
|
block = item,
|
|
modifier = Modifier.weight(1f),
|
|
foreground = foreground,
|
|
searchQuery = searchQuery,
|
|
searchHighlight = searchHighlight,
|
|
fallbackTextAlign = fallbackTextAlign,
|
|
fallbackFontFamily = fallbackFontFamily,
|
|
settings = settings
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
is SemanticFlexContainer -> {
|
|
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
block.children.forEach {
|
|
SemanticBlockView(it, foreground, searchQuery, searchHighlight, fallbackTextAlign, fallbackFontFamily, settings)
|
|
}
|
|
}
|
|
}
|
|
|
|
is SemanticWrappingBlock -> {
|
|
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
SemanticBlockView(block.floatedImage, foreground, searchQuery, searchHighlight, fallbackTextAlign, fallbackFontFamily, settings)
|
|
block.paragraphsToWrap.forEach {
|
|
SemanticBlockView(it, foreground, searchQuery, searchHighlight, fallbackTextAlign, fallbackFontFamily, settings)
|
|
}
|
|
}
|
|
}
|
|
|
|
is SemanticTable -> {
|
|
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
block.rows.forEach { row ->
|
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
row.forEach { cell ->
|
|
Column(modifier = Modifier.weight(cell.colspan.toFloat().coerceAtLeast(1f))) {
|
|
cell.content.forEach {
|
|
SemanticBlockView(it, foreground, searchQuery, searchHighlight, fallbackTextAlign, fallbackFontFamily, settings)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
is SemanticImage -> {
|
|
Text(
|
|
text = block.altText?.takeIf { it.isNotBlank() } ?: block.path.substringAfterLast('/').substringAfterLast('\\'),
|
|
color = foreground.copy(alpha = 0.7f),
|
|
modifier = modifier,
|
|
style = MaterialTheme.typography.bodySmall
|
|
)
|
|
}
|
|
|
|
is SemanticMath -> {
|
|
Text(
|
|
text = block.altText ?: "Equation",
|
|
color = foreground,
|
|
modifier = modifier,
|
|
style = MaterialTheme.typography.bodyMedium
|
|
)
|
|
}
|
|
|
|
is SemanticSpacer -> Spacer(modifier.height(if (block.isExplicitLineBreak) 8.dp else 16.dp))
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun SemanticTextView(
|
|
block: SemanticTextBlock,
|
|
modifier: Modifier,
|
|
foreground: Color,
|
|
searchQuery: String,
|
|
searchHighlight: Color,
|
|
fallbackTextAlign: TextAlign,
|
|
fallbackFontFamily: FontFamily,
|
|
settings: com.aryan.reader.shared.reader.ReaderSettings
|
|
) {
|
|
Text(
|
|
text = block.toAnnotatedString(searchQuery, searchHighlight),
|
|
color = foreground,
|
|
modifier = modifier,
|
|
textAlign = block.style.paragraphStyle.textAlign.takeUnless { it == TextAlign.Unspecified } ?: fallbackTextAlign,
|
|
style = MaterialTheme.typography.bodyLarge.copy(
|
|
fontSize = settings.fontSize.sp,
|
|
lineHeight = (settings.fontSize * settings.lineSpacing).sp,
|
|
fontFamily = fallbackFontFamily
|
|
)
|
|
)
|
|
}
|
|
|
|
private fun SemanticTextBlock.toAnnotatedString(query: String, highlightColor: Color): AnnotatedString {
|
|
val normalized = query.trim()
|
|
return buildAnnotatedString {
|
|
append(text)
|
|
spans.forEach { span ->
|
|
val start = span.start.coerceIn(0, text.length)
|
|
val end = span.end.coerceIn(start, text.length)
|
|
if (start < end) {
|
|
addStyle(span.style.spanStyle, start, end)
|
|
}
|
|
}
|
|
if (normalized.length >= 2) {
|
|
var startIndex = 0
|
|
while (startIndex < text.length) {
|
|
val index = text.indexOf(normalized, startIndex, ignoreCase = true)
|
|
if (index < 0) break
|
|
addStyle(SpanStyle(background = highlightColor), index, index + normalized.length)
|
|
startIndex = index + normalized.length
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun headerScale(level: Int): Float {
|
|
return when (level) {
|
|
1 -> 1.5f
|
|
2 -> 1.35f
|
|
3 -> 1.2f
|
|
4 -> 1.1f
|
|
else -> 1f
|
|
}
|
|
}
|
|
|
|
private fun Dp.safeDp(): Dp = if (isSpecified) this else 0.dp
|
|
|
|
private fun SharedReaderTextAlign.toComposeTextAlign(): TextAlign {
|
|
return when (this) {
|
|
SharedReaderTextAlign.START -> TextAlign.Start
|
|
SharedReaderTextAlign.JUSTIFY -> TextAlign.Justify
|
|
SharedReaderTextAlign.CENTER -> TextAlign.Center
|
|
}
|
|
}
|
|
|
|
private fun String.toComposeFontFamily(): FontFamily {
|
|
return when (this) {
|
|
"Serif" -> FontFamily.Serif
|
|
"Sans" -> FontFamily.SansSerif
|
|
"Mono" -> FontFamily.Monospace
|
|
else -> FontFamily.Default
|
|
}
|
|
}
|
|
|
|
private fun CustomFontItem.toDesktopPreviewFontFamily(): FontFamily? {
|
|
return runCatching { FontFamily(DesktopFont(File(path))) }.getOrNull()
|
|
}
|
|
|
|
@Composable
|
|
private fun ReaderSidebar(
|
|
session: ReaderSessionState,
|
|
onSearchChange: (String) -> Unit,
|
|
onPreviousSearchResult: () -> Unit,
|
|
onNextSearchResult: () -> Unit,
|
|
onGoToChapter: (Int) -> Unit,
|
|
onGoToPage: (Int) -> Unit
|
|
) {
|
|
Surface(
|
|
modifier = Modifier
|
|
.width(280.dp)
|
|
.fillMaxHeight(),
|
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
|
shape = RoundedCornerShape(8.dp)
|
|
) {
|
|
LazyColumn(
|
|
modifier = Modifier.padding(12.dp),
|
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
) {
|
|
item {
|
|
Text("Contents", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
}
|
|
items(session.reader.book.chapters.indices.toList()) { index ->
|
|
val chapter = session.reader.book.chapters[index]
|
|
val selected = session.reader.currentPage?.chapterIndex == index
|
|
Surface(
|
|
color = if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
|
|
shape = RoundedCornerShape(6.dp),
|
|
modifier = Modifier.fillMaxWidth().clickable { onGoToChapter(index) }
|
|
) {
|
|
Text(
|
|
chapter.title,
|
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
|
|
maxLines = 2,
|
|
overflow = TextOverflow.Ellipsis
|
|
)
|
|
}
|
|
}
|
|
|
|
item {
|
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
|
Text("Bookmarks", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
}
|
|
if (session.bookmarks.isEmpty()) {
|
|
item {
|
|
Text("No bookmarks yet", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
}
|
|
} else {
|
|
items(session.bookmarks, key = { it.id }) { bookmark ->
|
|
Surface(
|
|
color = MaterialTheme.colorScheme.surface,
|
|
shape = RoundedCornerShape(6.dp),
|
|
modifier = Modifier.fillMaxWidth().clickable { onGoToPage(bookmark.pageIndex) }
|
|
) {
|
|
Column(modifier = Modifier.padding(8.dp)) {
|
|
Text(bookmark.chapterTitle, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
Text(bookmark.preview, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
item {
|
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
|
Text("Search", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
Spacer(Modifier.height(8.dp))
|
|
OutlinedTextField(
|
|
value = session.searchQuery,
|
|
onValueChange = onSearchChange,
|
|
label = { Text("Find in book") },
|
|
singleLine = true,
|
|
modifier = Modifier.fillMaxWidth()
|
|
)
|
|
if (session.searchQuery.isNotBlank() && session.searchResults.isNotEmpty()) {
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Text(
|
|
"${session.activeSearchResultIndex + 1} of ${session.searchResults.size}",
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
TextButton(onClick = onPreviousSearchResult) {
|
|
Text("Prev")
|
|
}
|
|
TextButton(onClick = onNextSearchResult) {
|
|
Text("Next")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (session.searchQuery.isNotBlank() && session.searchResults.isEmpty()) {
|
|
item {
|
|
Text("No matches", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
}
|
|
} else {
|
|
items(session.searchResults, key = { "${it.pageIndex}_${it.matchIndex}_${it.preview}" }) { result ->
|
|
Surface(
|
|
color = MaterialTheme.colorScheme.surface,
|
|
shape = RoundedCornerShape(6.dp),
|
|
modifier = Modifier.fillMaxWidth().clickable { onGoToPage(result.pageIndex) }
|
|
) {
|
|
Column(modifier = Modifier.padding(8.dp)) {
|
|
Text("Page ${result.pageIndex + 1} - ${result.chapterTitle}", fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
Text(result.preview, style = MaterialTheme.typography.bodySmall, maxLines = 3, overflow = TextOverflow.Ellipsis)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun ScreenScaffold(
|
|
title: String,
|
|
subtitle: String,
|
|
trailing: @Composable () -> Unit = {},
|
|
content: @Composable () -> Unit
|
|
) {
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.padding(24.dp),
|
|
verticalArrangement = Arrangement.spacedBy(18.dp)
|
|
) {
|
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
|
Column(modifier = Modifier.weight(1f)) {
|
|
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
|
Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
}
|
|
trailing()
|
|
}
|
|
content()
|
|
}
|
|
}
|
|
|
|
private fun chooseFiles(): List<ImportedBookFile> {
|
|
val dialog = FileDialog(null as Frame?, "Import books", FileDialog.LOAD).apply {
|
|
isMultipleMode = true
|
|
isVisible = true
|
|
}
|
|
return dialog.files.orEmpty().map { it.toImportedBookFile() }
|
|
}
|
|
|
|
private fun chooseBookFile(): File? {
|
|
val dialog = FileDialog(null as Frame?, "Open Book", FileDialog.LOAD).apply {
|
|
file = DesktopBookFileDialogPattern
|
|
isVisible = true
|
|
}
|
|
val directory = dialog.directory ?: return null
|
|
val file = dialog.file ?: return null
|
|
return File(directory, file)
|
|
}
|
|
|
|
private fun choosePdfFile(): File? {
|
|
val dialog = FileDialog(null as Frame?, "Open PDF", FileDialog.LOAD).apply {
|
|
file = "*.pdf"
|
|
isVisible = true
|
|
}
|
|
val directory = dialog.directory ?: return null
|
|
val file = dialog.file ?: return null
|
|
return File(directory, file)
|
|
}
|
|
|
|
private fun chooseFontFile(): File? {
|
|
val dialog = FileDialog(null as Frame?, "Choose font", FileDialog.LOAD).apply {
|
|
file = "*.ttf;*.otf;*.woff2"
|
|
isVisible = true
|
|
}
|
|
val directory = dialog.directory ?: return null
|
|
val file = dialog.file ?: return null
|
|
return File(directory, file)
|
|
}
|
|
|
|
private fun chooseReaderTextureFile(): File? {
|
|
val dialog = FileDialog(null as Frame?, "Choose reader texture", FileDialog.LOAD).apply {
|
|
file = "*.png;*.jpg;*.jpeg;*.webp;*.gif;*.bmp"
|
|
isVisible = true
|
|
}
|
|
val directory = dialog.directory ?: return null
|
|
val file = dialog.file ?: return null
|
|
return File(directory, file)
|
|
}
|
|
|
|
private fun chooseFolder(): File? {
|
|
val chooser = JFileChooser().apply {
|
|
dialogTitle = "Import folder"
|
|
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
|
isAcceptAllFileFilterUsed = false
|
|
}
|
|
return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
|
|
chooser.selectedFile
|
|
} else {
|
|
null
|
|
}
|
|
}
|
|
|
|
private fun SharedReaderScreenState.withBanner(message: String, isError: Boolean = false): SharedReaderScreenState {
|
|
return reduce(AppAction.BannerShown(BannerMessage(message, isError = isError)))
|
|
}
|
|
|
|
private val DesktopReadableFileTypes = SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP)
|
|
private val DesktopSyncableFileTypes = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.DESKTOP)
|
|
private val DesktopBookFileTypes = SharedFileCapabilities.all
|
|
.filter { capability ->
|
|
capability.type in DesktopReadableFileTypes && capability.type != FileType.PDF
|
|
}
|
|
.mapTo(mutableSetOf()) { it.type }
|
|
private val DesktopBookFileDialogPattern = SharedFileCapabilities.all
|
|
.filter { it.type in DesktopBookFileTypes }
|
|
.flatMap { capability -> capability.extensions.map { extension -> "*.$extension" } }
|
|
.joinToString(";")
|
|
|
|
private const val EpistemeSourceUrl = "https://github.com/Aryan-Raj3112/episteme"
|
|
private const val EpistemeIssuesUrl = "https://github.com/Aryan-Raj3112/episteme/issues"
|
|
private const val EpistemeGitHubSponsorsUrl = "https://github.com/sponsors/Aryan-Raj3112"
|
|
private const val EpistemePatreonUrl = "https://www.patreon.com/c/epistemereader"
|
|
private const val EpistemeSupportEmail = "epistemereader@gmail.com"
|
|
private const val EpistemeFeedbackSubject = "Feedback: Episteme Reader"
|
|
|
|
private fun desktopAppVersionName(): String {
|
|
return EpistemeDesktopAppVersion::class.java.getPackage()?.implementationVersion
|
|
?.let { "Version $it" }
|
|
?: "Desktop development build"
|
|
}
|
|
|
|
private object EpistemeDesktopAppVersion
|
|
|
|
private fun ImportedBookFile.desktopFileType(): FileType {
|
|
return SharedFileCapabilities.fileTypeForName(name)
|
|
}
|
|
|
|
private fun mergeSyncedFolders(
|
|
existing: List<SyncedFolder>,
|
|
folderRoots: List<String>,
|
|
nowMillis: Long
|
|
): List<SyncedFolder> {
|
|
if (folderRoots.isEmpty()) return existing
|
|
val byRoot = existing.associateBy { it.uriString }.toMutableMap()
|
|
folderRoots.forEach { root ->
|
|
val rootFile = File(root)
|
|
byRoot[root] = SyncedFolder(
|
|
uriString = root,
|
|
name = rootFile.name.takeIf { it.isNotBlank() } ?: root,
|
|
lastScanTime = nowMillis,
|
|
allowedFileTypes = DesktopSyncableFileTypes
|
|
)
|
|
}
|
|
return byRoot.values.sortedBy { it.name.lowercase() }
|
|
}
|
|
|
|
private object DesktopFolderPathResolver : SharedFolderPathResolver {
|
|
override fun relativeFolderSegments(item: BookItem): List<String> {
|
|
val sourceFolder = item.sourceFolder ?: return emptyList()
|
|
val bookPath = item.path ?: return emptyList()
|
|
val parentFile = File(bookPath).parentFile ?: return emptyList()
|
|
val paths = runCatching {
|
|
File(sourceFolder).toPath().toAbsolutePath().normalize() to
|
|
parentFile.toPath().toAbsolutePath().normalize()
|
|
}.getOrNull() ?: return emptyList()
|
|
val (root, parent) = paths
|
|
if (!parent.startsWith(root) || parent == root) return emptyList()
|
|
return root.relativize(parent).map { it.toString() }.filter { it.isNotBlank() }
|
|
}
|
|
}
|
|
|
|
private fun List<BookItem>.collectTags(): List<Tag> {
|
|
return flatMap { it.tags }.distinctBy { it.id }.sortedBy { it.name.lowercase() }
|
|
}
|
|
|
|
private fun BookItem.cardTitleForMessage(): String {
|
|
return title?.takeIf { it.isNotBlank() } ?: displayName
|
|
}
|
|
|
|
private fun Long.toReadableSize(): String {
|
|
if (this <= 0L) return "Unknown"
|
|
val units = listOf("B", "KB", "MB", "GB", "TB")
|
|
var value = this.toDouble()
|
|
var unitIndex = 0
|
|
while (value >= 1024.0 && unitIndex < units.lastIndex) {
|
|
value /= 1024.0
|
|
unitIndex += 1
|
|
}
|
|
return if (unitIndex == 0) {
|
|
"$this ${units[unitIndex]}"
|
|
} else {
|
|
"${String.format("%.1f", value)} ${units[unitIndex]}"
|
|
}
|
|
}
|
|
|
|
private fun File.toImportedBookFile(sourceFolder: String? = null): ImportedBookFile {
|
|
return ImportedBookFile(
|
|
name = name,
|
|
uriString = null,
|
|
localPath = absolutePath,
|
|
size = length(),
|
|
sourceFolder = sourceFolder
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
private fun DesktopExternalLinkDialog(
|
|
url: String?,
|
|
onDismiss: () -> Unit
|
|
) {
|
|
if (url == null) return
|
|
val clipboardManager = LocalClipboardManager.current
|
|
LaunchedEffect(url) {
|
|
logExternalLink("dialog_show url=\"${url.logPreview()}\"")
|
|
when (withContext(Dispatchers.IO) { showNativeExternalLinkDialog(url) }) {
|
|
DesktopExternalLinkAction.COPY -> {
|
|
logExternalLink("dialog_copy url=\"${url.logPreview()}\"")
|
|
clipboardManager.setText(AnnotatedString(url))
|
|
}
|
|
DesktopExternalLinkAction.OPEN -> {
|
|
logExternalLink("dialog_open url=\"${url.logPreview()}\"")
|
|
openExternalUrl(url)
|
|
}
|
|
DesktopExternalLinkAction.DISMISS -> {
|
|
logExternalLink("dialog_dismiss url=\"${url.logPreview()}\"")
|
|
}
|
|
}
|
|
onDismiss()
|
|
}
|
|
}
|
|
|
|
private enum class DesktopExternalLinkAction {
|
|
COPY,
|
|
OPEN,
|
|
DISMISS
|
|
}
|
|
|
|
private fun showNativeExternalLinkDialog(url: String): DesktopExternalLinkAction {
|
|
val result = AtomicReference(DesktopExternalLinkAction.DISMISS)
|
|
val options = arrayOf("Copy", "Open", "Cancel")
|
|
val showDialog = {
|
|
val pane = JOptionPane(
|
|
"You clicked on an external link:\n\n$url\n\nWhat would you like to do?",
|
|
JOptionPane.QUESTION_MESSAGE,
|
|
JOptionPane.DEFAULT_OPTION,
|
|
null,
|
|
options,
|
|
options[1]
|
|
)
|
|
val dialog = pane.createDialog(null as java.awt.Component?, "External Link")
|
|
dialog.isModal = true
|
|
dialog.isAlwaysOnTop = true
|
|
dialog.isVisible = true
|
|
result.set(
|
|
when (pane.value) {
|
|
options[0] -> DesktopExternalLinkAction.COPY
|
|
options[1] -> DesktopExternalLinkAction.OPEN
|
|
else -> DesktopExternalLinkAction.DISMISS
|
|
}
|
|
)
|
|
dialog.dispose()
|
|
}
|
|
if (SwingUtilities.isEventDispatchThread()) {
|
|
showDialog()
|
|
} else {
|
|
SwingUtilities.invokeAndWait { showDialog() }
|
|
}
|
|
return result.get()
|
|
}
|
|
|
|
private fun openExternalUrl(url: String) {
|
|
val normalizedUrl = url.normalizedExternalUrl()
|
|
runCatching {
|
|
if (Desktop.isDesktopSupported()) {
|
|
val desktop = Desktop.getDesktop()
|
|
if (normalizedUrl.startsWith("mailto:", ignoreCase = true)) {
|
|
desktop.mail(URI(normalizedUrl))
|
|
} else {
|
|
desktop.browse(URI(normalizedUrl))
|
|
}
|
|
logExternalLink("open_system_browser_success url=\"${normalizedUrl.logPreview()}\"")
|
|
} else {
|
|
logExternalLink("open_system_browser_unavailable url=\"${normalizedUrl.logPreview()}\"")
|
|
}
|
|
}.onFailure { throwable ->
|
|
logExternalLink("open_system_browser_failed url=\"${normalizedUrl.logPreview()}\" error=\"${throwable.message.orEmpty().logPreview()}\"")
|
|
}
|
|
}
|
|
|
|
private fun String.normalizedExternalUrl(): String {
|
|
val trimmed = trim()
|
|
return if (trimmed.startsWith("www.", ignoreCase = true)) {
|
|
"https://$trimmed"
|
|
} else {
|
|
trimmed
|
|
}
|
|
}
|
|
|
|
private fun String.urlEncode(): String {
|
|
return URLEncoder.encode(this, Charsets.UTF_8.name())
|
|
}
|
|
|
|
private const val PdfSelectionLogTag = "EpistemePdfSelection"
|
|
private const val PdfLinkLogTag = "EpistemePdfLink"
|
|
private const val EpubLinkLogTag = "EpistemeEpubLink"
|
|
private const val ExternalLinkLogTag = "EpistemeExternalLink"
|
|
|
|
private fun logPdfSelection(message: String) {
|
|
println("$PdfSelectionLogTag $message")
|
|
}
|
|
|
|
private fun logPdfLink(message: String) {
|
|
println("$PdfLinkLogTag $message")
|
|
}
|
|
|
|
private fun logEpubLink(message: String) {
|
|
println("$EpubLinkLogTag $message")
|
|
}
|
|
|
|
private fun logExternalLink(message: String) {
|
|
println("$ExternalLinkLogTag $message")
|
|
}
|
|
|
|
private fun DesktopPdfLinkTarget.formatLogTarget(): String {
|
|
return "dest=${destPageIndex?.let { it + 1 } ?: "null"} uri=\"${uri.orEmpty().logPreview()}\""
|
|
}
|
|
|
|
private fun String.logPreview(maxLength: Int = 96): String {
|
|
return replace(Regex("\\s+"), " ")
|
|
.trim()
|
|
.let { if (it.length <= maxLength) it else it.take(maxLength) + "..." }
|
|
.replace("\"", "\\\"")
|
|
}
|
|
|
|
private fun Float.formatLogFloat(): String {
|
|
return String.format("%.3f", this)
|
|
}
|
|
|
|
private fun IntSize.formatLogSize(): String {
|
|
return "${width}x${height}"
|
|
}
|
|
|
|
private fun DesktopPdfCharHit?.formatLogHit(prefix: String): String {
|
|
if (this == null) {
|
|
return "${prefix}Index=null ${prefix}Source=none ${prefix}X=null ${prefix}Y=null ${prefix}Nx=null ${prefix}Ny=null"
|
|
}
|
|
return "${prefix}Index=$index ${prefix}Source=$source " +
|
|
"${prefix}X=${point.x.formatLogFloat()} ${prefix}Y=${point.y.formatLogFloat()} " +
|
|
"${prefix}Nx=${normalized.x.formatLogFloat()} ${prefix}Ny=${normalized.y.formatLogFloat()}"
|
|
}
|