Windows ga (#358)
* Enhance Cloud TTS with navigation controls and shared UI overlay in desktop app * Refactor Cloud TTS voice settings and remove standalone settings overlay on desktop app * Persist reader window state and improve slider interaction in desktop app * Improve PDF page transitions and refine focus management in desktop app * Refactor scrollbar interaction and adjust desktop modal focus handling * Improve PDF sidecar synchronization and cross-platform metadata compatibility * Refactor PDF annotation comment logic to shared module and implement Desktop UI * Refactor reader screen to use tap-to-toggle and full-width styling in desktop app * Refactor reader workspace layout and chrome-panel interactions * Implement global search keyboard shortcuts and focusable chrome layers * Implement flavor-specific legal links and update the About UI * Refactor reader UI controls on desktop app * Enhance desktop folder sync with background metadata extraction and improved error handling * Refactor Library UI and remove redundant Home tab in desktop app * Add custom tooltips to reader icon buttons in desktop app * Integrate app theme controls into reader interfaces on desktop * Add right-to-left pagination support and improve focus restoration on desktop app * Improve EPUB pagination geometry and diagnostic logging for layout cutoffs on desktop app * Update desktop reader defaults and implement settings migration * Implement block-based position tracking in ReaderLocator * Enhance EPUB highlighting reliability in desktop app * Add support for custom reader themes and update highlight palette logic in desktop app * Replace the Tools panel with a "More" dropdown menu and refactor account UI * Implement account profile header in desktop sidebar * Implement cloud sync reliability improvements and sidebar toggle on desktop app * Improve EPUB annotation synchronization and highlight mapping accuracy in desktop app * Integrate WebView2 for EPUB vertical rendering on Windows * Refactor reader layout logic and enhance WebView2 diagnostics * Improve vertical reading layout and WebView2 resizing on Desktop * Refine vertical reading mode layout and margin handling * Enhance reader locator precision and Desktop mode-switching reliability * Implement chapter-level caching and warm-start pagination in desktop app * Replace bundled KCEF with native system webviews via SWT * Refactor EPUB page info bar visibility and layout logic * Improve PDF toolbar persistence and fix tab reactivation logic * Enable multi-selection and bulk operations for custom fonts * Refactor instrumentation tests * Add EPUB UI test fixture and initial instrumentation tests * Expand EpubReader UI tests and improve accessibility * Add instrumentation tests and test tags for library and reader screens * Enhance OPDS parser logic and catalog integration * Add support for toggling local synchronization on a per-folder basis. * Implement tri-state sizing for the TTS overlay * Persist TTS overlay size across sessions * Refactor reader brightness control and add incremental step buttons * Improve CSS support, pagination control, and style-aware semantic caching * Improve link handling, interaction, and diagnostics in the paginated reader * crash fixes * Implement persistent pending removal for external files * Implement book-specific word replacements * Add native vertical reading mode with custom renderer * Implement text selection and navigation improvements for the native vertical reader * Implement locator-based navigation and improved vertical scrolling in native vertical mode in epub * Implement lazy loading and chapter prefetching for native vertical reader * Improve window lifecycle and disposal handling on Desktop * Optimize vertical reading performance in desktop app * Enhance TTS start accuracy and diagnostic logging on desktop * Refactor AI settings visibility on desktop * Improve pagination height measurement and enhance cutoff diagnostics * Implement lifecycle management and improve justified text splitting for pagination * Refine AI usage tracking and force AI feature visibility on Desktop * Add descriptive context comments and usage examples to string and plural resources. * Optimize performance and memory usage in search and state mapping * Replace reader page sliders with minimal slider and navigation controls * Add support for CBT comic archives * Harden file path validation and XML parsing to prevent security vulnerabilities * Implement local account profile caching and optimize desktop performance * Improve desktop persistence reliability and add Linux secure storage support * Improved PDF zoom stability and layout prediction during zoom commits * Improved PDF spread layout prediction, reader focus restoration, and account profile caching * Enhance highlight precision and scoping using block-local offsets and CFIs * Enhance cloud book content synchronization and background downloads * Implement granular timestamp tracking for reading positions and PDF annotations * Restrict diagnostic logging and stack traces to debug builds * Refine PDF page gaps and reader chrome interaction logic * Refactor PDF highlight rendering and overhaul Desktop sidebar UI * Implement a new interaction dock and undo/redo history for PDF annotations in desktop * Enhance PDF color picker and improve navigation scroll restoration * Add highlight palette customization and improve selection menu UI in desktop app epub reader * Enhance desktop shelf management and library organization
This commit is contained in:
parent
5971eaa571
commit
83dcafa4b6
444 changed files with 47279 additions and 8096 deletions
|
|
@ -3,7 +3,7 @@ plugins {
|
|||
id("com.android.library")
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.compose.multiplatform)
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20"
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.kover)
|
||||
}
|
||||
|
||||
|
|
@ -28,7 +28,6 @@ kotlin {
|
|||
commonMain.dependencies {
|
||||
implementation(compose.foundation)
|
||||
implementation(compose.material3)
|
||||
implementation(compose.materialIconsExtended)
|
||||
implementation(compose.ui)
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
|
|
@ -48,4 +47,8 @@ android {
|
|||
defaultConfig {
|
||||
minSdk = 26
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
internal actual val SharedReaderDiagnosticsEnabled: Boolean = false
|
||||
import android.util.Log
|
||||
import com.aryan.reader.shared.BuildConfig
|
||||
|
||||
internal actual fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean = false
|
||||
internal actual val SharedReaderDiagnosticsEnabled: Boolean = BuildConfig.DEBUG
|
||||
|
||||
internal actual fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean {
|
||||
if (!BuildConfig.DEBUG) return false
|
||||
return tag == SharedEpubCutoffDiagnosticsTag ||
|
||||
runCatching { Log.isLoggable(tag, Log.DEBUG) }.getOrDefault(false)
|
||||
}
|
||||
|
||||
internal actual fun writeSharedReaderDiagnostic(tag: String, message: String) {
|
||||
if (!BuildConfig.DEBUG) return
|
||||
Log.d(tag, message)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
package androidx.compose.material.icons
|
||||
|
||||
object Icons {
|
||||
object Filled
|
||||
val Default: Filled get() = Filled
|
||||
|
||||
object Outlined
|
||||
|
||||
object AutoMirrored {
|
||||
object Filled
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
@file:Suppress("ObjectPropertyName", "unused")
|
||||
|
||||
package androidx.compose.material.icons.automirrored.filled
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.PathParser
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Icons.AutoMirrored.Filled.ArrowBack: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.arrowBack
|
||||
|
||||
val Icons.AutoMirrored.Filled.ArrowForward: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.arrowForward
|
||||
|
||||
val Icons.AutoMirrored.Filled.KeyboardArrowRight: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.keyboardArrowRight
|
||||
|
||||
val Icons.AutoMirrored.Filled.LibraryBooks: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.libraryBooks
|
||||
|
||||
val Icons.AutoMirrored.Filled.List: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.list
|
||||
|
||||
val Icons.AutoMirrored.Filled.MenuBook: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.menuBook
|
||||
|
||||
val Icons.AutoMirrored.Filled.NavigateBefore: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.navigateBefore
|
||||
|
||||
val Icons.AutoMirrored.Filled.NavigateNext: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.navigateNext
|
||||
|
||||
val Icons.AutoMirrored.Filled.OpenInNew: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.openInNew
|
||||
|
||||
val Icons.AutoMirrored.Filled.Redo: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.redo
|
||||
|
||||
val Icons.AutoMirrored.Filled.Sort: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.sort
|
||||
|
||||
val Icons.AutoMirrored.Filled.Undo: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.undo
|
||||
|
||||
val Icons.AutoMirrored.Filled.VolumeUp: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.volumeUp
|
||||
|
||||
private object EpistemeAutoMirroredFilledIcons {
|
||||
val arrowBack: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "ArrowBack",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M313,520L537,744L480,800L160,480L480,160L537,216L313,440L800,440L800,520L313,520Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val arrowForward: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "ArrowForward",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M647,520L160,520L160,440L647,440L423,216L480,160L800,480L480,800L423,744L647,520Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val keyboardArrowRight: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "KeyboardArrowRight",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M504,480L320,296L376,240L616,480L376,720L320,664L504,480Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val libraryBooks: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "LibraryBooks",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M400,560L560,560L560,480L400,480L400,560ZM400,440L720,440L720,360L400,360L400,440ZM400,320L720,320L720,240L400,240L400,320ZM320,720Q287,720 263.5,696.5Q240,673 240,640L240,160Q240,127 263.5,103.5Q287,80 320,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L320,720ZM320,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L320,160Q320,160 320,160Q320,160 320,160L320,640Q320,640 320,640Q320,640 320,640ZM160,880Q127,880 103.5,856.5Q80,833 80,800L80,240L160,240L160,800Q160,800 160,800Q160,800 160,800L720,800L720,880L160,880ZM320,160L320,160Q320,160 320,160Q320,160 320,160L320,640Q320,640 320,640Q320,640 320,640L320,640Q320,640 320,640Q320,640 320,640L320,160Q320,160 320,160Q320,160 320,160Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val list: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "List",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M280,360L280,280L840,280L840,360L280,360ZM280,520L280,440L840,440L840,520L280,520ZM280,680L280,600L840,600L840,680L280,680ZM160,360Q143,360 131.5,348.5Q120,337 120,320Q120,303 131.5,291.5Q143,280 160,280Q177,280 188.5,291.5Q200,303 200,320Q200,337 188.5,348.5Q177,360 160,360ZM160,520Q143,520 131.5,508.5Q120,497 120,480Q120,463 131.5,451.5Q143,440 160,440Q177,440 188.5,451.5Q200,463 200,480Q200,497 188.5,508.5Q177,520 160,520ZM160,680Q143,680 131.5,668.5Q120,657 120,640Q120,623 131.5,611.5Q143,600 160,600Q177,600 188.5,611.5Q200,623 200,640Q200,657 188.5,668.5Q177,680 160,680Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val menuBook: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "MenuBook",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M560,396L560,328Q593,314 627.5,307Q662,300 700,300Q726,300 751,304Q776,308 800,314L800,378Q776,369 751.5,364.5Q727,360 700,360Q662,360 627,369.5Q592,379 560,396ZM560,616L560,548Q593,534 627.5,527Q662,520 700,520Q726,520 751,524Q776,528 800,534L800,598Q776,589 751.5,584.5Q727,580 700,580Q662,580 627,589Q592,598 560,616ZM560,506L560,438Q593,424 627.5,417Q662,410 700,410Q726,410 751,414Q776,418 800,424L800,488Q776,479 751.5,474.5Q727,470 700,470Q662,470 627,479.5Q592,489 560,506ZM260,640Q307,640 351.5,650.5Q396,661 440,682L440,288Q399,264 353,252Q307,240 260,240Q224,240 188.5,247Q153,254 120,268Q120,268 120,268Q120,268 120,268L120,664Q120,664 120,664Q120,664 120,664Q155,652 189.5,646Q224,640 260,640ZM520,682Q564,661 608.5,650.5Q653,640 700,640Q736,640 770.5,646Q805,652 840,664Q840,664 840,664Q840,664 840,664L840,268Q840,268 840,268Q840,268 840,268Q807,254 771.5,247Q736,240 700,240Q653,240 607,252Q561,264 520,288L520,682ZM480,800Q432,762 376,741Q320,720 260,720Q218,720 177.5,731Q137,742 100,762Q79,773 59.5,761Q40,749 40,726L40,244Q40,233 45.5,223Q51,213 62,208Q108,184 158,172Q208,160 260,160Q318,160 373.5,175Q429,190 480,220Q531,190 586.5,175Q642,160 700,160Q752,160 802,172Q852,184 898,208Q909,213 914.5,223Q920,233 920,244L920,726Q920,749 900.5,761Q881,773 860,762Q823,742 782.5,731Q742,720 700,720Q640,720 584,741Q528,762 480,800ZM280,466Q280,466 280,466Q280,466 280,466L280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466L280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val navigateBefore: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "NavigateBefore",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M560,720L320,480L560,240L616,296L432,480L616,664L560,720Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val navigateNext: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "NavigateNext",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M504,480L320,296L376,240L616,480L376,720L320,664L504,480Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val openInNew: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "OpenInNew",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L480,120L480,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L760,760Q760,760 760,760Q760,760 760,760L760,480L840,480L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM388,628L332,572L704,200L560,200L560,120L840,120L840,400L760,400L760,256L388,628Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val redo: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "Redo",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M396,760Q299,760 229.5,697Q160,634 160,540Q160,446 229.5,383Q299,320 396,320L648,320L544,216L600,160L800,360L600,560L544,504L648,400L396,400Q333,400 286.5,440Q240,480 240,540Q240,600 286.5,640Q333,680 396,680L680,680L680,760L396,760Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val sort: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "Sort",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M120,720L120,640L360,640L360,720L120,720ZM120,520L120,440L600,440L600,520L120,520ZM120,320L120,240L840,240L840,320L120,320Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val undo: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "Undo",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M280,760L280,680L564,680Q627,680 673.5,640Q720,600 720,540Q720,480 673.5,440Q627,400 564,400L312,400L416,504L360,560L160,360L360,160L416,216L312,320L564,320Q661,320 730.5,383Q800,446 800,540Q800,634 730.5,697Q661,760 564,760L280,760Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val volumeUp: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "VolumeUp",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M560,829L560,747Q650,721 705,647Q760,573 760,479Q760,385 705,311Q650,237 560,211L560,129Q684,157 762,254.5Q840,352 840,479Q840,606 762,703.5Q684,801 560,829ZM120,600L120,360L280,360L480,160L480,800L280,600L120,600ZM560,640L560,318Q607,340 633.5,384Q660,428 660,480Q660,531 633.5,574.5Q607,618 560,640ZM400,354L314,440L200,440L200,520L314,520L400,606L400,354ZM300,480L300,480L300,480L300,480L300,480L300,480Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun materialIcon(
|
||||
name: String,
|
||||
defaultWidth: Float,
|
||||
defaultHeight: Float,
|
||||
viewportWidth: Float,
|
||||
viewportHeight: Float,
|
||||
autoMirror: Boolean,
|
||||
paths: List<String>
|
||||
): ImageVector {
|
||||
return ImageVector.Builder(
|
||||
name = name,
|
||||
defaultWidth = defaultWidth.dp,
|
||||
defaultHeight = defaultHeight.dp,
|
||||
viewportWidth = viewportWidth,
|
||||
viewportHeight = viewportHeight,
|
||||
autoMirror = autoMirror
|
||||
).apply {
|
||||
paths.forEach { pathData ->
|
||||
addPath(
|
||||
pathData = PathParser().parsePathString(pathData).toNodes(),
|
||||
fill = SolidColor(Color.Black)
|
||||
)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,159 @@
|
|||
@file:Suppress("ObjectPropertyName", "unused")
|
||||
|
||||
package androidx.compose.material.icons.outlined
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.PathParser
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Icons.Outlined.AccountCircle: ImageVector
|
||||
get() = EpistemeOutlinedIcons.accountCircle
|
||||
|
||||
val Icons.Outlined.Email: ImageVector
|
||||
get() = EpistemeOutlinedIcons.email
|
||||
|
||||
val Icons.Outlined.FavoriteBorder: ImageVector
|
||||
get() = EpistemeOutlinedIcons.favoriteBorder
|
||||
|
||||
val Icons.Outlined.Feedback: ImageVector
|
||||
get() = EpistemeOutlinedIcons.feedback
|
||||
|
||||
val Icons.Outlined.FileOpen: ImageVector
|
||||
get() = EpistemeOutlinedIcons.fileOpen
|
||||
|
||||
val Icons.Outlined.Gavel: ImageVector
|
||||
get() = EpistemeOutlinedIcons.gavel
|
||||
|
||||
val Icons.Outlined.Policy: ImageVector
|
||||
get() = EpistemeOutlinedIcons.policy
|
||||
|
||||
private object EpistemeOutlinedIcons {
|
||||
val accountCircle: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "AccountCircle",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = false,
|
||||
paths = listOf(
|
||||
"""M234,684Q285,645 348,622.5Q411,600 480,600Q549,600 612,622.5Q675,645 726,684Q761,643 780.5,591Q800,539 800,480Q800,347 706.5,253.5Q613,160 480,160Q347,160 253.5,253.5Q160,347 160,480Q160,539 179.5,591Q199,643 234,684ZM380.5,479.5Q340,439 340,380Q340,321 380.5,280.5Q421,240 480,240Q539,240 579.5,280.5Q620,321 620,380Q620,439 579.5,479.5Q539,520 480,520Q421,520 380.5,479.5ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM580,784.5Q627,769 666,740Q627,711 580,695.5Q533,680 480,680Q427,680 380,695.5Q333,711 294,740Q333,769 380,784.5Q427,800 480,800Q533,800 580,784.5ZM523,423Q540,406 540,380Q540,354 523,337Q506,320 480,320Q454,320 437,337Q420,354 420,380Q420,406 437,423Q454,440 480,440Q506,440 523,423ZM480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380ZM480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val email: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "Email",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = false,
|
||||
paths = listOf(
|
||||
"""M160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L800,160Q833,160 856.5,183.5Q880,207 880,240L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM480,520L160,320L160,720Q160,720 160,720Q160,720 160,720L800,720Q800,720 800,720Q800,720 800,720L800,320L480,520ZM480,440L800,240L160,240L480,440ZM160,320L160,240L160,240L160,320L160,720Q160,720 160,720Q160,720 160,720L160,720Q160,720 160,720Q160,720 160,720L160,320Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val favoriteBorder: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "FavoriteBorder",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = false,
|
||||
paths = listOf(
|
||||
"""M480,840L422,788Q321,697 255,631Q189,565 150,512.5Q111,460 95.5,416Q80,372 80,326Q80,232 143,169Q206,106 300,106Q352,106 399,128Q446,150 480,190Q514,150 561,128Q608,106 660,106Q754,106 817,169Q880,232 880,326Q880,372 864.5,416Q849,460 810,512.5Q771,565 705,631Q639,697 538,788L480,840ZM480,732Q576,646 638,584.5Q700,523 736,477.5Q772,432 786,396.5Q800,361 800,326Q800,266 760,226Q720,186 660,186Q613,186 573,212.5Q533,239 518,280L518,280L442,280L442,280Q427,239 387,212.5Q347,186 300,186Q240,186 200,226Q160,266 160,326Q160,361 174,396.5Q188,432 224,477.5Q260,523 322,584.5Q384,646 480,732ZM480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459L480,459L480,459L480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val feedback: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "Feedback",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = false,
|
||||
paths = listOf(
|
||||
"""M480,600Q497,600 508.5,588.5Q520,577 520,560Q520,543 508.5,531.5Q497,520 480,520Q463,520 451.5,531.5Q440,543 440,560Q440,577 451.5,588.5Q463,600 480,600ZM440,440L520,440L520,200L440,200L440,440ZM80,880L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L240,720L80,880ZM206,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L160,160Q160,160 160,160Q160,160 160,160L160,685L206,640ZM160,640L160,640L160,160Q160,160 160,160Q160,160 160,160L160,160Q160,160 160,160Q160,160 160,160L160,640Q160,640 160,640Q160,640 160,640Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val fileOpen: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "FileOpen",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = false,
|
||||
paths = listOf(
|
||||
"""M240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L560,80L800,320L800,560L720,560L720,360L520,360L520,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800L600,800L600,880L240,880ZM878,895L760,777L760,866L680,866L680,640L906,640L906,720L816,720L934,838L878,895ZM240,800L240,560L240,560L240,360L240,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val gavel: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "Gavel",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = false,
|
||||
paths = listOf(
|
||||
"""M160,840L160,760L640,760L640,840L160,840ZM386,646L160,420L244,334L472,560L386,646ZM640,392L414,164L500,80L726,306L640,392ZM824,800L302,278L358,222L880,744L824,800Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val policy: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "Policy",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = false,
|
||||
paths = listOf(
|
||||
"""M480,880Q341,845 250.5,720.5Q160,596 160,444L160,200L480,80L800,200L800,444Q800,529 771,607.5Q742,686 688,746L560,618Q542,629 521.5,634.5Q501,640 480,640Q414,640 367,593Q320,546 320,480Q320,414 367,367Q414,320 480,320Q546,320 593,367Q640,414 640,480Q640,502 634.5,522.5Q629,543 618,562L678,622Q698,581 709,536Q720,491 720,444L720,255L480,165L240,255L240,444Q240,565 308,664Q376,763 480,796Q506,788 529.5,775.5Q553,763 576,746L632,802Q599,829 560.5,849Q522,869 480,880ZM536.5,536.5Q560,513 560,480Q560,447 536.5,423.5Q513,400 480,400Q447,400 423.5,423.5Q400,447 400,480Q400,513 423.5,536.5Q447,560 480,560Q513,560 536.5,536.5ZM488,483L488,483Q488,483 488,483Q488,483 488,483L488,483Q488,483 488,483Q488,483 488,483L488,483L488,483L488,483L488,483Q488,483 488,483Q488,483 488,483Q488,483 488,483Q488,483 488,483Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun materialIcon(
|
||||
name: String,
|
||||
defaultWidth: Float,
|
||||
defaultHeight: Float,
|
||||
viewportWidth: Float,
|
||||
viewportHeight: Float,
|
||||
autoMirror: Boolean,
|
||||
paths: List<String>
|
||||
): ImageVector {
|
||||
return ImageVector.Builder(
|
||||
name = name,
|
||||
defaultWidth = defaultWidth.dp,
|
||||
defaultHeight = defaultHeight.dp,
|
||||
viewportWidth = viewportWidth,
|
||||
viewportHeight = viewportHeight,
|
||||
autoMirror = autoMirror
|
||||
).apply {
|
||||
paths.forEach { pathData ->
|
||||
addPath(
|
||||
pathData = PathParser().parsePathString(pathData).toNodes(),
|
||||
fill = SolidColor(Color.Black)
|
||||
)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -78,7 +78,20 @@ data class BlockStyle(
|
|||
@ProtoNumber(31) @Serializable(with = DpSerializer::class) val borderTopRightRadius: Dp = 0.dp,
|
||||
@ProtoNumber(32) @Serializable(with = DpSerializer::class) val borderBottomRightRadius: Dp = 0.dp,
|
||||
@ProtoNumber(33) @Serializable(with = DpSerializer::class) val borderBottomLeftRadius: Dp = 0.dp,
|
||||
@ProtoNumber(34) @Serializable(with = DpSerializer::class) val borderSpacing: Dp = 0.dp
|
||||
@ProtoNumber(34) @Serializable(with = DpSerializer::class) val borderSpacing: Dp = 0.dp,
|
||||
@ProtoNumber(35) @Serializable(with = DpSerializer::class) val minWidth: Dp = Dp.Unspecified,
|
||||
@ProtoNumber(36) @Serializable(with = DpSerializer::class) val minHeight: Dp = Dp.Unspecified,
|
||||
@ProtoNumber(37) @Serializable(with = DpSerializer::class) val maxHeight: Dp = Dp.Unspecified,
|
||||
@ProtoNumber(38) val overflow: String? = null,
|
||||
@ProtoNumber(39) val breakBefore: String? = null,
|
||||
@ProtoNumber(40) val breakAfter: String? = null,
|
||||
@ProtoNumber(41) val breakInside: String? = null,
|
||||
@ProtoNumber(42) val widows: Int = 2,
|
||||
@ProtoNumber(43) val orphans: Int = 2,
|
||||
@ProtoNumber(44) val visibility: String? = null,
|
||||
@ProtoNumber(45) val objectFit: String? = null,
|
||||
@ProtoNumber(46) val objectPosition: String? = null,
|
||||
@ProtoNumber(47) val backgroundImage: String? = null
|
||||
) {
|
||||
fun merge(other: BlockStyle): BlockStyle {
|
||||
return BlockStyle(
|
||||
|
|
@ -125,7 +138,20 @@ data class BlockStyle(
|
|||
horizontalAlign = other.horizontalAlign ?: this.horizontalAlign,
|
||||
filter = other.filter ?: this.filter,
|
||||
borderCollapse = other.borderCollapse ?: this.borderCollapse,
|
||||
borderSpacing = if (other.borderSpacing != 0.dp) other.borderSpacing else this.borderSpacing
|
||||
borderSpacing = if (other.borderSpacing != 0.dp) other.borderSpacing else this.borderSpacing,
|
||||
minWidth = if (other.minWidth.isSpecified) other.minWidth else this.minWidth,
|
||||
minHeight = if (other.minHeight.isSpecified) other.minHeight else this.minHeight,
|
||||
maxHeight = if (other.maxHeight.isSpecified) other.maxHeight else this.maxHeight,
|
||||
overflow = other.overflow ?: this.overflow,
|
||||
breakBefore = other.breakBefore ?: this.breakBefore,
|
||||
breakAfter = other.breakAfter ?: this.breakAfter,
|
||||
breakInside = other.breakInside ?: this.breakInside,
|
||||
widows = if (other.widows != 2) other.widows else this.widows,
|
||||
orphans = if (other.orphans != 2) other.orphans else this.orphans,
|
||||
visibility = other.visibility ?: this.visibility,
|
||||
objectFit = other.objectFit ?: this.objectFit,
|
||||
objectPosition = other.objectPosition ?: this.objectPosition,
|
||||
backgroundImage = other.backgroundImage ?: this.backgroundImage
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -307,7 +333,10 @@ data class CssStyle(
|
|||
@ProtoNumber(13) @Serializable(with = TextUnitSerializer::class) val wordSpacing: TextUnit = TextUnit.Unspecified,
|
||||
@ProtoNumber(14) val textDecorationStyle: String? = null,
|
||||
@ProtoNumber(15) @Serializable(with = ColorSerializer::class) val textDecorationColor: Color = Color.Unspecified,
|
||||
@ProtoNumber(16) @Serializable(with = DpSerializer::class) val textUnderlineOffset: Dp = Dp.Unspecified
|
||||
@ProtoNumber(16) @Serializable(with = DpSerializer::class) val textUnderlineOffset: Dp = Dp.Unspecified,
|
||||
@ProtoNumber(17) val whiteSpace: String? = null,
|
||||
@ProtoNumber(18) val verticalAlign: String? = null,
|
||||
@ProtoNumber(19) val customProperties: Map<String, String> = emptyMap()
|
||||
) {
|
||||
fun merge(other: CssStyle): CssStyle {
|
||||
return CssStyle(
|
||||
|
|
@ -326,7 +355,10 @@ data class CssStyle(
|
|||
wordSpacing = if (other.wordSpacing.isSpecified) other.wordSpacing else this.wordSpacing,
|
||||
textDecorationStyle = other.textDecorationStyle ?: this.textDecorationStyle,
|
||||
textDecorationColor = if (other.textDecorationColor.isSpecified) other.textDecorationColor else this.textDecorationColor,
|
||||
textUnderlineOffset = if (other.textUnderlineOffset.isSpecified) other.textUnderlineOffset else this.textUnderlineOffset
|
||||
textUnderlineOffset = if (other.textUnderlineOffset.isSpecified) other.textUnderlineOffset else this.textUnderlineOffset,
|
||||
whiteSpace = other.whiteSpace ?: this.whiteSpace,
|
||||
verticalAlign = other.verticalAlign ?: this.verticalAlign,
|
||||
customProperties = this.customProperties + other.customProperties
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -340,7 +372,9 @@ data class CssSelector(
|
|||
@Serializable
|
||||
data class CssRule(
|
||||
@ProtoNumber(1) val selector: CssSelector,
|
||||
@ProtoNumber(2) val style: CssStyle
|
||||
@ProtoNumber(2) val style: CssStyle,
|
||||
@ProtoNumber(3) val pseudoElement: String? = null,
|
||||
@ProtoNumber(4) val sourceOrder: Int = 0
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
|
@ -371,7 +405,8 @@ data class OptimizedCssRules(
|
|||
@ProtoNumber(1) val byTag: Map<String, List<CssRule>> = emptyMap(),
|
||||
@ProtoNumber(2) val byClass: Map<String, List<CssRule>> = emptyMap(),
|
||||
@ProtoNumber(3) val byId: Map<String, List<CssRule>> = emptyMap(),
|
||||
@ProtoNumber(4) val otherComplex: List<CssRule> = emptyList()
|
||||
@ProtoNumber(4) val otherComplex: List<CssRule> = emptyList(),
|
||||
@ProtoNumber(5) val allRules: List<CssRule> = emptyList()
|
||||
) {
|
||||
fun merge(other: OptimizedCssRules): OptimizedCssRules {
|
||||
fun mergeMap(
|
||||
|
|
@ -397,16 +432,17 @@ data class OptimizedCssRules(
|
|||
byTag = mergeMap(this.byTag, other.byTag),
|
||||
byClass = mergeMap(this.byClass, other.byClass),
|
||||
byId = mergeMap(this.byId, other.byId),
|
||||
otherComplex = this.otherComplex + other.otherComplex
|
||||
otherComplex = this.otherComplex + other.otherComplex,
|
||||
allRules = this.toFlatList() + other.toFlatList()
|
||||
)
|
||||
}
|
||||
|
||||
fun toFlatList(): List<CssRule> {
|
||||
return byTag.values.flatten() + byClass.values.flatten() + byId.values.flatten() + otherComplex
|
||||
return allRules.ifEmpty { byTag.values.flatten() + byClass.values.flatten() + byId.values.flatten() + otherComplex }
|
||||
}
|
||||
}
|
||||
|
||||
data class OptimizedCssParseResult(
|
||||
val rules: OptimizedCssRules,
|
||||
val fontFaces: List<FontFaceInfo>
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ sealed interface AppAction {
|
|||
data class AppFontPreferenceChanged(val preference: AppFontPreference) : AppAction
|
||||
data class CustomAppThemeAdded(val theme: CustomAppTheme) : AppAction
|
||||
data class CustomAppThemeDeleted(val themeId: String) : AppAction
|
||||
data class CustomReaderThemesChanged(val themes: List<ReaderTheme>) : AppAction
|
||||
data class SyncEnabledChanged(val enabled: Boolean) : AppAction
|
||||
data class FolderSyncEnabledChanged(val enabled: Boolean) : AppAction
|
||||
data class TabsEnabledChanged(val enabled: Boolean) : AppAction
|
||||
|
|
|
|||
|
|
@ -272,6 +272,7 @@ data class SharedReaderScreenState(
|
|||
val appSeedColor: Color? = null,
|
||||
val appFontPreference: AppFontPreference = AppFontPreference.System,
|
||||
val customAppThemes: List<CustomAppTheme> = emptyList(),
|
||||
val customReaderThemes: List<ReaderTheme> = emptyList(),
|
||||
val readerDefaultSettings: ReaderSettings = ReaderSettings(),
|
||||
val pdfReaderDefaultSettings: ReaderSettings = ReaderSettings(themeId = "no_theme"),
|
||||
val allTags: List<Tag> = emptyList(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
enum class SharedCloudBookMetadataWinner {
|
||||
LOCAL,
|
||||
REMOTE,
|
||||
SAME
|
||||
}
|
||||
|
||||
fun sharedCloudBookReadingMetadataWinner(
|
||||
localModifiedTimestamp: Long?,
|
||||
remoteModifiedTimestamp: Long
|
||||
): SharedCloudBookMetadataWinner {
|
||||
val localTimestamp = localModifiedTimestamp ?: Long.MIN_VALUE
|
||||
return when {
|
||||
localTimestamp > remoteModifiedTimestamp -> SharedCloudBookMetadataWinner.LOCAL
|
||||
remoteModifiedTimestamp > localTimestamp -> SharedCloudBookMetadataWinner.REMOTE
|
||||
else -> SharedCloudBookMetadataWinner.SAME
|
||||
}
|
||||
}
|
||||
|
||||
fun sharedCloudBookMetadataWinner(
|
||||
localModifiedTimestamp: Long?,
|
||||
remoteModifiedTimestamp: Long,
|
||||
localSidecarModifiedTimestamp: Long = 0L
|
||||
): SharedCloudBookMetadataWinner {
|
||||
val localTimestamp = maxOf(localModifiedTimestamp ?: Long.MIN_VALUE, localSidecarModifiedTimestamp)
|
||||
return when {
|
||||
localTimestamp > remoteModifiedTimestamp -> SharedCloudBookMetadataWinner.LOCAL
|
||||
remoteModifiedTimestamp > localTimestamp -> SharedCloudBookMetadataWinner.REMOTE
|
||||
else -> SharedCloudBookMetadataWinner.SAME
|
||||
}
|
||||
}
|
||||
|
||||
fun shouldApplyRemoteCloudBookMetadataUpdate(
|
||||
localModifiedTimestamp: Long?,
|
||||
remoteModifiedTimestamp: Long
|
||||
): Boolean {
|
||||
return sharedCloudBookReadingMetadataWinner(
|
||||
localModifiedTimestamp = localModifiedTimestamp,
|
||||
remoteModifiedTimestamp = remoteModifiedTimestamp
|
||||
) == SharedCloudBookMetadataWinner.REMOTE
|
||||
}
|
||||
|
||||
fun shouldUploadLocalCloudBookMetadataUpdate(
|
||||
localModifiedTimestamp: Long,
|
||||
remoteModifiedTimestamp: Long
|
||||
): Boolean {
|
||||
return sharedCloudBookReadingMetadataWinner(
|
||||
localModifiedTimestamp = localModifiedTimestamp,
|
||||
remoteModifiedTimestamp = remoteModifiedTimestamp
|
||||
) == SharedCloudBookMetadataWinner.LOCAL
|
||||
}
|
||||
|
||||
fun shouldApplyRemoteCloudBookUpdate(
|
||||
localModifiedTimestamp: Long?,
|
||||
remoteModifiedTimestamp: Long,
|
||||
localSidecarModifiedTimestamp: Long = 0L
|
||||
): Boolean {
|
||||
return sharedCloudBookMetadataWinner(
|
||||
localModifiedTimestamp = localModifiedTimestamp,
|
||||
remoteModifiedTimestamp = remoteModifiedTimestamp,
|
||||
localSidecarModifiedTimestamp = localSidecarModifiedTimestamp
|
||||
) == SharedCloudBookMetadataWinner.REMOTE
|
||||
}
|
||||
|
||||
fun shouldUploadLocalCloudBookUpdate(
|
||||
localModifiedTimestamp: Long,
|
||||
remoteModifiedTimestamp: Long,
|
||||
localSidecarModifiedTimestamp: Long = 0L
|
||||
): Boolean {
|
||||
return sharedCloudBookMetadataWinner(
|
||||
localModifiedTimestamp = localModifiedTimestamp,
|
||||
remoteModifiedTimestamp = remoteModifiedTimestamp,
|
||||
localSidecarModifiedTimestamp = localSidecarModifiedTimestamp
|
||||
) == SharedCloudBookMetadataWinner.LOCAL
|
||||
}
|
||||
|
||||
fun shouldDownloadRemoteCloudBookContent(
|
||||
localFileAvailable: Boolean,
|
||||
localContentModifiedTimestamp: Long,
|
||||
remoteContentModifiedTimestamp: Long,
|
||||
remoteDeleted: Boolean = false
|
||||
): Boolean {
|
||||
return !remoteDeleted &&
|
||||
remoteContentModifiedTimestamp > 0L &&
|
||||
(!localFileAvailable || remoteContentModifiedTimestamp > localContentModifiedTimestamp)
|
||||
}
|
||||
|
||||
fun shouldUploadLocalCloudBookContent(
|
||||
localFileAvailable: Boolean,
|
||||
localContentModifiedTimestamp: Long,
|
||||
remoteContentModifiedTimestamp: Long?
|
||||
): Boolean {
|
||||
return localFileAvailable &&
|
||||
localContentModifiedTimestamp > 0L &&
|
||||
localContentModifiedTimestamp > (remoteContentModifiedTimestamp ?: 0L)
|
||||
}
|
||||
|
||||
fun sharedCloudBookContentFileName(bookId: String, type: FileType): String? {
|
||||
val extension = SharedFileCapabilities.primaryExtensionFor(type) ?: return null
|
||||
return "$bookId.$extension"
|
||||
}
|
||||
|
|
@ -89,6 +89,10 @@ object SharedFileCapabilities {
|
|||
"application/x-rar-compressed",
|
||||
"application/x-cb7",
|
||||
"application/x-7z-compressed",
|
||||
"application/vnd.comicbook+tar",
|
||||
"application/x-cbt",
|
||||
"application/x-tar",
|
||||
"application/tar",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
|
|
@ -166,6 +170,13 @@ object SharedFileCapabilities {
|
|||
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
|
||||
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.CBT,
|
||||
displayName = "CBT",
|
||||
extensions = setOf("cbt"),
|
||||
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
|
||||
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.DOCX,
|
||||
displayName = "DOCX",
|
||||
|
|
@ -211,11 +222,13 @@ object SharedFileCapabilities {
|
|||
FileType.CBZ to "application/zip",
|
||||
FileType.CBR to "application/zip",
|
||||
FileType.CB7 to "application/zip",
|
||||
FileType.CBT to "application/x-tar",
|
||||
FileType.DOCX to "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
FileType.PPTX to "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
FileType.ODT to "application/vnd.oasis.opendocument.text",
|
||||
FileType.FODT to "application/x-vnd.oasis.opendocument.text-flat-xml"
|
||||
)
|
||||
val comicArchiveTypes: Set<FileType> = setOf(FileType.CBZ, FileType.CBR, FileType.CB7, FileType.CBT)
|
||||
val knownFileTypes: Set<FileType> = all.mapTo(mutableSetOf()) { it.type }
|
||||
|
||||
fun capabilityFor(type: FileType): FileTypeCapability? {
|
||||
|
|
@ -234,6 +247,10 @@ object SharedFileCapabilities {
|
|||
return mimeTypesByType[type]
|
||||
}
|
||||
|
||||
fun isComicArchive(type: FileType): Boolean {
|
||||
return type in comicArchiveTypes
|
||||
}
|
||||
|
||||
fun fileTypeForName(fileName: String): FileType {
|
||||
return resolveFileTypeForName(fileName) ?: FileType.UNKNOWN
|
||||
}
|
||||
|
|
@ -267,6 +284,9 @@ object SharedFileCapabilities {
|
|||
"application/x-cb7", "application/x-7z-compressed" -> {
|
||||
if (fileName?.endsWith(".cb7", ignoreCase = true) == true) FileType.CB7 else null
|
||||
}
|
||||
"application/vnd.comicbook+tar", "application/x-cbt", "application/x-tar", "application/tar" -> {
|
||||
if (fileName?.endsWith(".cbt", ignoreCase = true) == true) FileType.CBT else null
|
||||
}
|
||||
"application/pdf" -> FileType.PDF
|
||||
"application/epub+zip" -> FileType.EPUB
|
||||
"application/x-fictionbook+xml", "application/x-zip-compressed-fb2" -> FileType.FB2
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import com.aryan.reader.shared.reader.ReaderBookmark
|
|||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
|
||||
enum class FileType {
|
||||
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT, PPTX, UNKNOWN
|
||||
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, CBT, DOCX, ODT, FODT, PPTX, UNKNOWN
|
||||
}
|
||||
|
||||
val PDF_VIEWER_FILE_TYPES: Set<FileType>
|
||||
|
|
@ -67,7 +67,8 @@ data class SyncedFolder(
|
|||
val uriString: String,
|
||||
val name: String,
|
||||
val lastScanTime: Long,
|
||||
val allowedFileTypes: Set<FileType> = SharedFileCapabilities.knownFileTypes
|
||||
val allowedFileTypes: Set<FileType> = SharedFileCapabilities.knownFileTypes,
|
||||
val localSyncEnabled: Boolean = true
|
||||
)
|
||||
|
||||
data class BookItem(
|
||||
|
|
@ -99,7 +100,8 @@ data class BookItem(
|
|||
val readerSettings: ReaderSettings? = null,
|
||||
val readerBookmarks: List<ReaderBookmark> = emptyList(),
|
||||
val readerHighlights: List<UserHighlight> = emptyList(),
|
||||
val pdfReaderViewport: SharedPdfReaderViewport? = null
|
||||
val pdfReaderViewport: SharedPdfReaderViewport? = null,
|
||||
val readingPositionModifiedTimestamp: Long = 0L
|
||||
)
|
||||
|
||||
data class Shelf(
|
||||
|
|
|
|||
|
|
@ -99,6 +99,46 @@ object SharedLibraryEditor {
|
|||
)
|
||||
}
|
||||
|
||||
fun createShelfWithBooks(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
name: String,
|
||||
bookIds: Iterable<String>,
|
||||
clearSelection: Boolean = true,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedLibraryMutationResult? {
|
||||
val trimmed = cleanShelfName(name) ?: return null
|
||||
val selectedBooks = cleanBookIds(bookIds)
|
||||
val shelfId = "shelf_$nowMillis"
|
||||
val newRefs = selectedBooks.map { bookId ->
|
||||
BookShelfRef(bookId = bookId, shelfId = shelfId, addedAt = nowMillis)
|
||||
}
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(
|
||||
selectedBookIds = if (clearSelection && selectedBooks.isNotEmpty()) emptySet() else state.selectedBookIds,
|
||||
bannerMessage = if (selectedBooks.isEmpty()) {
|
||||
BannerMessage.string(
|
||||
"banner_shelf_created",
|
||||
"Created shelf \"%1\$s\".",
|
||||
trimmed
|
||||
)
|
||||
} else {
|
||||
BannerMessage.quantity(
|
||||
"banner_shelf_created_with_books",
|
||||
selectedBooks.size,
|
||||
"Created shelf \"%1\$s\" with %2\$d book.",
|
||||
"Created shelf \"%1\$s\" with %2\$d books.",
|
||||
trimmed,
|
||||
selectedBooks.size
|
||||
)
|
||||
}
|
||||
),
|
||||
shelfRecords = shelfRecords + ShelfRecord(id = shelfId, name = trimmed),
|
||||
shelfRefs = shelfRefs + newRefs
|
||||
)
|
||||
}
|
||||
|
||||
fun createSmartShelf(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
|
|
@ -239,25 +279,73 @@ object SharedLibraryEditor {
|
|||
shelfId: String,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedLibraryMutationResult? {
|
||||
val selected = state.selectedBookIds
|
||||
if (selected.isEmpty()) return null
|
||||
val existing = shelfRefs.mapTo(mutableSetOf()) { it.bookId to it.shelfId }
|
||||
val additions = selected.mapNotNull { bookId ->
|
||||
if (!existing.add(bookId to shelfId)) null else BookShelfRef(bookId = bookId, shelfId = shelfId, addedAt = nowMillis)
|
||||
}
|
||||
return addBooksToShelves(
|
||||
state = state,
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs,
|
||||
bookIds = state.selectedBookIds,
|
||||
shelfIds = listOf(shelfId),
|
||||
clearSelection = true,
|
||||
nowMillis = nowMillis,
|
||||
bannerName = "banner_books_added_to_shelf",
|
||||
singularMessage = "%1\$d book added to shelf.",
|
||||
pluralMessage = "%1\$d books added to shelf."
|
||||
)
|
||||
}
|
||||
|
||||
fun addBooksToShelves(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
bookIds: Iterable<String>,
|
||||
shelfIds: Iterable<String>,
|
||||
clearSelection: Boolean = true,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedLibraryMutationResult? {
|
||||
return addBooksToShelves(
|
||||
state = state,
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs,
|
||||
bookIds = bookIds,
|
||||
shelfIds = shelfIds,
|
||||
clearSelection = clearSelection,
|
||||
nowMillis = nowMillis,
|
||||
bannerName = "banner_books_added_to_shelves",
|
||||
singularMessage = "%1\$d shelf entry added.",
|
||||
pluralMessage = "%1\$d shelf entries added."
|
||||
)
|
||||
}
|
||||
|
||||
fun replaceShelfBooks(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
shelfId: String,
|
||||
bookIds: Iterable<String>,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedLibraryMutationResult? {
|
||||
val cleanShelfId = shelfId.trim()
|
||||
if (!canMutateShelf(cleanShelfId)) return null
|
||||
val selectedBooks = cleanBookIds(bookIds)
|
||||
val shelfName = state.shelves.firstOrNull { it.id == cleanShelfId }?.name
|
||||
?: shelfRecords.firstOrNull { it.id == cleanShelfId }?.name
|
||||
?: cleanShelfId
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(
|
||||
selectedBookIds = emptySet(),
|
||||
bannerMessage = BannerMessage.quantity(
|
||||
"banner_books_added_to_shelf",
|
||||
additions.size,
|
||||
"%1\$d book added to shelf.",
|
||||
"%1\$d books added to shelf.",
|
||||
additions.size
|
||||
"banner_shelf_books_updated",
|
||||
selectedBooks.size,
|
||||
"Updated \"%1\$s\" with %2\$d book.",
|
||||
"Updated \"%1\$s\" with %2\$d books.",
|
||||
shelfName,
|
||||
selectedBooks.size
|
||||
)
|
||||
),
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs + additions
|
||||
shelfRefs = shelfRefs.filterNot { it.shelfId == cleanShelfId } +
|
||||
selectedBooks.map { bookId ->
|
||||
BookShelfRef(bookId = bookId, shelfId = cleanShelfId, addedAt = nowMillis)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -325,6 +413,51 @@ object SharedLibraryEditor {
|
|||
shelfRefs = shelfRefs
|
||||
)
|
||||
}
|
||||
|
||||
private fun addBooksToShelves(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
bookIds: Iterable<String>,
|
||||
shelfIds: Iterable<String>,
|
||||
clearSelection: Boolean,
|
||||
nowMillis: Long,
|
||||
bannerName: String,
|
||||
singularMessage: String,
|
||||
pluralMessage: String
|
||||
): SharedLibraryMutationResult? {
|
||||
val selectedBooks = cleanBookIds(bookIds)
|
||||
val targetShelfIds = shelfIds
|
||||
.map { it.trim() }
|
||||
.filter { canMutateShelf(it) }
|
||||
.distinct()
|
||||
if (selectedBooks.isEmpty() || targetShelfIds.isEmpty()) return null
|
||||
|
||||
val existing = shelfRefs.mapTo(mutableSetOf()) { it.bookId to it.shelfId }
|
||||
val additions = targetShelfIds.flatMap { shelfId ->
|
||||
selectedBooks.mapNotNull { bookId ->
|
||||
if (!existing.add(bookId to shelfId)) {
|
||||
null
|
||||
} else {
|
||||
BookShelfRef(bookId = bookId, shelfId = shelfId, addedAt = nowMillis)
|
||||
}
|
||||
}
|
||||
}
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(
|
||||
selectedBookIds = if (clearSelection) emptySet() else state.selectedBookIds,
|
||||
bannerMessage = BannerMessage.quantity(
|
||||
bannerName,
|
||||
additions.size,
|
||||
singularMessage,
|
||||
pluralMessage,
|
||||
additions.size
|
||||
)
|
||||
),
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs + additions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun parseTagList(input: String, knownTags: List<Tag>, nowMillis: Long = currentTimestamp()): List<Tag> {
|
||||
|
|
|
|||
|
|
@ -141,6 +141,13 @@ data class SharedFolderBookMetadata(
|
|||
seriesIndex = existing?.seriesIndex,
|
||||
lastPageIndex = lastPage,
|
||||
readerPosition = parsedReaderPosition ?: existing?.readerPosition,
|
||||
readingPositionModifiedTimestamp = if (
|
||||
parsedReaderPosition != null || lastPage != null || progressPercentage > 0f
|
||||
) {
|
||||
metadataTimestamp
|
||||
} else {
|
||||
existing?.readingPositionModifiedTimestamp ?: 0L
|
||||
},
|
||||
readerBookmarks = parsedBookmarks ?: existing?.readerBookmarks.orEmpty(),
|
||||
readerHighlights = parsedHighlights ?: existing?.readerHighlights.orEmpty()
|
||||
)
|
||||
|
|
@ -152,6 +159,9 @@ data class SharedFolderBookMetadata(
|
|||
chapterIndex = lastChapterIndex,
|
||||
cfi = lastPositionCfi,
|
||||
pageIndex = lastPage
|
||||
).withFallbacks(
|
||||
blockIndex = locatorBlockIndex,
|
||||
charOffset = locatorCharOffset
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -272,6 +282,15 @@ object LocalFolderSyncEngine {
|
|||
nowMillis: Long = currentTimestamp(),
|
||||
metadataOnly: Boolean = false
|
||||
): LocalFolderSyncResult {
|
||||
if (!folder.localSyncEnabled) {
|
||||
return LocalFolderSyncResult(
|
||||
state = state,
|
||||
idMigrations = emptyMap(),
|
||||
removedBookIds = emptySet(),
|
||||
stats = LocalFolderSyncStats()
|
||||
)
|
||||
}
|
||||
|
||||
val folderRoot = folder.uriString
|
||||
val allowedTypes = folder.allowedFileTypes
|
||||
val booksById = linkedMapOf<String, BookItem>()
|
||||
|
|
@ -439,16 +458,7 @@ fun BookItem.toSharedFolderBookMetadata(): SharedFolderBookMetadata? {
|
|||
!bookmarksJson.isNullOrBlank() ||
|
||||
!highlightsJson.isNullOrBlank()
|
||||
if (!isDirty) return null
|
||||
val positionCfi = position?.cfi ?: position?.let { locator ->
|
||||
val chapterIndex = locator.chapterIndex
|
||||
val startOffset = locator.startOffset
|
||||
val endOffset = locator.endOffset ?: startOffset
|
||||
if (chapterIndex != null && startOffset != null && endOffset != null) {
|
||||
"desktop:$chapterIndex:$startOffset:$endOffset"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
val positionCfi = position?.toStablePositionCfi()
|
||||
|
||||
return SharedFolderBookMetadata(
|
||||
bookId = id,
|
||||
|
|
@ -463,8 +473,8 @@ fun BookItem.toSharedFolderBookMetadata(): SharedFolderBookMetadata? {
|
|||
isRecent = isRecent,
|
||||
lastModifiedTimestamp = localFolderModifiedTimestamp(),
|
||||
bookmarksJson = bookmarksJson,
|
||||
locatorBlockIndex = null,
|
||||
locatorCharOffset = null,
|
||||
locatorBlockIndex = position?.blockIndex,
|
||||
locatorCharOffset = position?.charOffset,
|
||||
customName = null,
|
||||
highlightsJson = highlightsJson,
|
||||
seriesName = null,
|
||||
|
|
|
|||
|
|
@ -42,11 +42,15 @@ data class ReaderLocator(
|
|||
val pageIndex: Int? = null,
|
||||
val startOffset: Int? = null,
|
||||
val endOffset: Int? = null,
|
||||
val blockIndex: Int? = null,
|
||||
val charOffset: Int? = null,
|
||||
val textQuote: String? = null,
|
||||
val cfi: String? = null
|
||||
) {
|
||||
val hasTextRange: Boolean
|
||||
get() = startOffset != null && endOffset != null && endOffset >= startOffset
|
||||
val hasBlockPosition: Boolean
|
||||
get() = blockIndex != null && charOffset != null
|
||||
|
||||
fun withFallbacks(
|
||||
chapterIndex: Int? = null,
|
||||
|
|
@ -55,6 +59,8 @@ data class ReaderLocator(
|
|||
pageIndex: Int? = null,
|
||||
startOffset: Int? = null,
|
||||
endOffset: Int? = null,
|
||||
blockIndex: Int? = null,
|
||||
charOffset: Int? = null,
|
||||
textQuote: String? = null,
|
||||
cfi: String? = null
|
||||
): ReaderLocator {
|
||||
|
|
@ -65,6 +71,8 @@ data class ReaderLocator(
|
|||
pageIndex = this.pageIndex ?: pageIndex,
|
||||
startOffset = this.startOffset ?: startOffset,
|
||||
endOffset = this.endOffset ?: endOffset,
|
||||
blockIndex = this.blockIndex ?: blockIndex,
|
||||
charOffset = this.charOffset ?: charOffset,
|
||||
textQuote = this.textQuote ?: textQuote,
|
||||
cfi = this.cfi ?: cfi
|
||||
)
|
||||
|
|
@ -74,6 +82,10 @@ data class ReaderLocator(
|
|||
val sameChapter = chapterIndex == null || other.chapterIndex == null || chapterIndex == other.chapterIndex
|
||||
if (!sameChapter) return false
|
||||
|
||||
if (hasBlockPosition && other.hasBlockPosition) {
|
||||
return blockIndex == other.blockIndex && charOffset == other.charOffset
|
||||
}
|
||||
|
||||
if (hasTextRange && other.hasTextRange) {
|
||||
return startOffset == other.startOffset && endOffset == other.endOffset
|
||||
}
|
||||
|
|
@ -92,21 +104,40 @@ data class ReaderLocator(
|
|||
pageIndex: Int? = null,
|
||||
textQuote: String? = null
|
||||
): ReaderLocator {
|
||||
val desktopParts = cfi
|
||||
val stableCfi = cfi?.toStableReaderPositionCfi()
|
||||
val desktopParts = stableCfi
|
||||
?.takeIf { it.startsWith("desktop:") }
|
||||
?.split(':')
|
||||
.orEmpty()
|
||||
val parsedChapterIndex = desktopParts.getOrNull(1)?.toIntOrNull()
|
||||
val possibleStartOffset = desktopParts.getOrNull(2)?.toIntOrNull()
|
||||
val possibleEndOffset = desktopParts.getOrNull(3)?.toIntOrNull()
|
||||
val androidLocatorParts = stableCfi
|
||||
?.takeIf { it.startsWith("android-locator:") }
|
||||
?.split(':')
|
||||
.orEmpty()
|
||||
val parsedAndroidChapterIndex = androidLocatorParts.getOrNull(1)?.toIntOrNull()
|
||||
val parsedBlockIndex = androidLocatorParts.getOrNull(2)?.toIntOrNull()
|
||||
val parsedCharOffset = androidLocatorParts.getOrNull(3)?.toIntOrNull()
|
||||
?.takeIf { it >= 0 }
|
||||
val parsedAndroidEndOffset = parsedCharOffset
|
||||
?.let { start -> textQuote?.takeIf { it.isNotBlank() }?.let { start + it.length } }
|
||||
val hasOffsetRange = desktopParts.size == 4 &&
|
||||
possibleStartOffset != null &&
|
||||
possibleEndOffset != null &&
|
||||
possibleStartOffset >= 0 &&
|
||||
possibleEndOffset >= possibleStartOffset &&
|
||||
possibleEndOffset - possibleStartOffset <= 100_000
|
||||
val parsedStartOffset = if (hasOffsetRange) possibleStartOffset else null
|
||||
val parsedEndOffset = if (hasOffsetRange) possibleEndOffset else null
|
||||
val parsedStartOffset = when {
|
||||
hasOffsetRange -> possibleStartOffset
|
||||
parsedBlockIndex != null && parsedAndroidEndOffset != null -> parsedCharOffset
|
||||
else -> null
|
||||
}
|
||||
val parsedEndOffset = when {
|
||||
hasOffsetRange -> possibleEndOffset
|
||||
parsedBlockIndex != null -> parsedAndroidEndOffset
|
||||
else -> null
|
||||
}
|
||||
val parsedPageIndex = when {
|
||||
pageIndex != null -> pageIndex
|
||||
desktopParts.size == 3 || desktopParts.size >= 5 || (desktopParts.size == 4 && !hasOffsetRange) ->
|
||||
|
|
@ -114,23 +145,56 @@ data class ReaderLocator(
|
|||
else -> null
|
||||
}
|
||||
return ReaderLocator(
|
||||
chapterIndex = chapterIndex ?: parsedChapterIndex,
|
||||
chapterIndex = chapterIndex ?: parsedChapterIndex ?: parsedAndroidChapterIndex,
|
||||
pageIndex = parsedPageIndex,
|
||||
startOffset = parsedStartOffset,
|
||||
endOffset = parsedEndOffset,
|
||||
blockIndex = parsedBlockIndex,
|
||||
charOffset = parsedCharOffset,
|
||||
textQuote = textQuote,
|
||||
cfi = cfi
|
||||
cfi = stableCfi ?: cfi
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun String.toStableReaderPositionCfi(): String {
|
||||
val trimmed = trim()
|
||||
if (!trimmed.startsWith("desktop-scroll:")) return trimmed
|
||||
return trimmed
|
||||
.split(':', limit = 4)
|
||||
.getOrNull(3)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: trimmed
|
||||
}
|
||||
|
||||
fun ReaderLocator.toStablePositionCfi(): String? {
|
||||
cfi
|
||||
?.toStableReaderPositionCfi()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.takeUnless { it.startsWith("desktop-scroll:") || it.startsWith("desktop-scroll-page:") }
|
||||
?.let { return it }
|
||||
|
||||
val chapter = chapterIndex
|
||||
val start = startOffset
|
||||
val end = endOffset ?: start
|
||||
return when {
|
||||
chapter != null && blockIndex != null && charOffset != null ->
|
||||
"android-locator:$chapter:$blockIndex:$charOffset"
|
||||
chapter != null && start != null && end != null ->
|
||||
"desktop:$chapter:$start:$end"
|
||||
chapter != null && pageIndex != null ->
|
||||
"desktop:$chapter:$pageIndex"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
data class ReaderHighlightPalette(
|
||||
val colors: List<HighlightColor> = defaultColors
|
||||
) {
|
||||
fun sanitized(): ReaderHighlightPalette {
|
||||
val distinct = colors.distinct().filter { it in HighlightColor.entries }
|
||||
return copy(colors = distinct.ifEmpty { defaultColors })
|
||||
val knownColors = colors.filter { it in HighlightColor.entries }
|
||||
return copy(colors = knownColors.takeIf { it.size == PaletteSize } ?: defaultColors)
|
||||
}
|
||||
|
||||
fun contains(color: HighlightColor): Boolean {
|
||||
|
|
@ -147,14 +211,13 @@ data class ReaderHighlightPalette(
|
|||
}
|
||||
|
||||
companion object {
|
||||
const val PaletteSize: Int = 4
|
||||
val defaultColors: List<HighlightColor>
|
||||
get() = listOf(
|
||||
HighlightColor.YELLOW,
|
||||
HighlightColor.GREEN,
|
||||
HighlightColor.BLUE,
|
||||
HighlightColor.RED,
|
||||
HighlightColor.PURPLE,
|
||||
HighlightColor.ORANGE
|
||||
HighlightColor.RED
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -238,6 +238,8 @@ object EpubAnnotationSerializer {
|
|||
pageIndex?.let { put("pageIndex", JsonPrimitive(it)) }
|
||||
startOffset?.let { put("startOffset", JsonPrimitive(it)) }
|
||||
endOffset?.let { put("endOffset", JsonPrimitive(it)) }
|
||||
blockIndex?.let { put("blockIndex", JsonPrimitive(it)) }
|
||||
charOffset?.let { put("charOffset", JsonPrimitive(it)) }
|
||||
textQuote?.let { put("textQuote", JsonPrimitive(it)) }
|
||||
cfi?.let { put("cfi", JsonPrimitive(it)) }
|
||||
}
|
||||
|
|
@ -253,6 +255,8 @@ object EpubAnnotationSerializer {
|
|||
pageIndex = obj.int("pageIndex"),
|
||||
startOffset = obj.int("startOffset"),
|
||||
endOffset = obj.int("endOffset"),
|
||||
blockIndex = obj.int("blockIndex"),
|
||||
charOffset = obj.int("charOffset"),
|
||||
textQuote = obj.string("textQuote"),
|
||||
cfi = obj.string("cfi")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -101,13 +101,29 @@ data class ReaderTheme(
|
|||
val isCustom: Boolean = false
|
||||
)
|
||||
|
||||
val BuiltInReaderThemes = listOf(
|
||||
ReaderTheme("system", "System", Color.Unspecified, Color.Unspecified, false),
|
||||
fun List<ReaderTheme>.sanitizeCustomReaderThemes(): List<ReaderTheme> {
|
||||
val seenIds = mutableSetOf<String>()
|
||||
return asReversed()
|
||||
.filter { theme ->
|
||||
theme.isCustom &&
|
||||
theme.id.isNotBlank() &&
|
||||
theme.name.isNotBlank() &&
|
||||
theme.backgroundColor.isSpecified &&
|
||||
theme.textColor.isSpecified &&
|
||||
seenIds.add(theme.id)
|
||||
}
|
||||
.asReversed()
|
||||
}
|
||||
|
||||
private val StandardReaderSolidThemes = listOf(
|
||||
ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false),
|
||||
ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
|
||||
ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
|
||||
ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
|
||||
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true),
|
||||
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true)
|
||||
)
|
||||
|
||||
private val StandardReaderTexturedThemes = listOf(
|
||||
ReaderTheme("natural_white_texture", "Natural White", Color(0xFFF7F1E5), Color(0xFF1D1B18), false, textureId = ReaderTexture.NATURAL_WHITE.id),
|
||||
ReaderTheme("retina_texture", "Retina", Color(0xFFF1E4CD), Color(0xFF2A2119), false, textureId = ReaderTexture.RETINA_WOOD.id),
|
||||
ReaderTheme("veneer_texture", "Veneer", Color(0xFFF4E7CF), Color(0xFF2A2119), false, textureId = ReaderTexture.LIGHT_VENEER.id),
|
||||
|
|
@ -116,21 +132,16 @@ val BuiltInReaderThemes = listOf(
|
|||
ReaderTheme("retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id)
|
||||
)
|
||||
|
||||
val BuiltInReaderThemes = listOf(
|
||||
ReaderTheme("system", "System", Color.Unspecified, Color.Unspecified, false)
|
||||
) + StandardReaderSolidThemes + StandardReaderTexturedThemes
|
||||
|
||||
val BuiltInPdfReaderThemes = listOf(
|
||||
ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
|
||||
ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true),
|
||||
ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false),
|
||||
ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
|
||||
ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
|
||||
ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
|
||||
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true),
|
||||
ReaderTheme("pdf_natural_white_texture", "Natural White", Color(0xFFF7F1E5), Color(0xFF1D1B18), false, textureId = ReaderTexture.NATURAL_WHITE.id),
|
||||
ReaderTheme("pdf_retina_texture", "Retina", Color(0xFFF1E4CD), Color(0xFF2A2119), false, textureId = ReaderTexture.RETINA_WOOD.id),
|
||||
ReaderTheme("pdf_veneer_texture", "Veneer", Color(0xFFF4E7CF), Color(0xFF2A2119), false, textureId = ReaderTexture.LIGHT_VENEER.id),
|
||||
ReaderTheme("pdf_grey_wash_texture", "Grey Wash", Color(0xFF202124), Color(0xFFFFFFFF), true, textureId = ReaderTexture.GREY_WASH.id),
|
||||
ReaderTheme("pdf_fabric_texture", "Fabric", Color(0xFF262626), Color(0xFFE8E2D8), true, textureId = ReaderTexture.CLASSY_FABRIC.id),
|
||||
ReaderTheme("pdf_retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id)
|
||||
)
|
||||
ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true)
|
||||
) + StandardReaderSolidThemes + StandardReaderTexturedThemes.map { theme ->
|
||||
theme.copy(id = "pdf_${theme.id}")
|
||||
}
|
||||
|
||||
fun FormatSettings.toReaderSettings(base: ReaderSettings = ReaderSettings()): ReaderSettings {
|
||||
val horizontalMarginPx = (ReaderAppearanceDefaults.marginPx * horizontalMargin).roundToInt()
|
||||
|
|
@ -169,6 +180,59 @@ fun ReaderTheme.toReaderSettings(base: ReaderSettings = ReaderSettings()): Reade
|
|||
)
|
||||
}
|
||||
|
||||
fun ReaderSettings.resetReaderFormatSettings(): ReaderSettings {
|
||||
val defaults = ReaderSettings()
|
||||
return copy(
|
||||
fontSize = defaults.fontSize,
|
||||
lineSpacing = defaults.lineSpacing,
|
||||
margin = defaults.margin,
|
||||
horizontalMargin = defaults.horizontalMargin,
|
||||
verticalMargin = defaults.verticalMargin,
|
||||
textAlign = defaults.textAlign,
|
||||
pageWidth = defaults.pageWidth,
|
||||
fontFamily = defaults.fontFamily,
|
||||
paragraphSpacing = defaults.paragraphSpacing,
|
||||
imageScale = defaults.imageScale,
|
||||
customFontPath = defaults.customFontPath
|
||||
)
|
||||
}
|
||||
|
||||
fun ReaderSettings.withHorizontalReaderMargin(horizontalMarginPx: Int): ReaderSettings {
|
||||
val nextHorizontal = horizontalMarginPx.coerceIn(
|
||||
ReaderAppearanceDefaults.minMarginPx,
|
||||
ReaderAppearanceDefaults.maxMarginPx
|
||||
)
|
||||
val currentVertical = resolvedVerticalMargin.coerceIn(
|
||||
ReaderAppearanceDefaults.minMarginPx,
|
||||
ReaderAppearanceDefaults.maxMarginPx
|
||||
)
|
||||
return copy(
|
||||
margin = max(nextHorizontal, currentVertical),
|
||||
horizontalMargin = nextHorizontal,
|
||||
verticalMargin = currentVertical
|
||||
)
|
||||
}
|
||||
|
||||
fun ReaderSettings.withVerticalReaderMargin(verticalMarginPx: Int): ReaderSettings {
|
||||
val currentHorizontal = resolvedHorizontalMargin.coerceIn(
|
||||
ReaderAppearanceDefaults.minMarginPx,
|
||||
ReaderAppearanceDefaults.maxMarginPx
|
||||
)
|
||||
val nextVertical = verticalMarginPx.coerceIn(
|
||||
ReaderAppearanceDefaults.minMarginPx,
|
||||
ReaderAppearanceDefaults.maxMarginPx
|
||||
)
|
||||
return copy(
|
||||
margin = max(currentHorizontal, nextVertical),
|
||||
horizontalMargin = currentHorizontal,
|
||||
verticalMargin = nextVertical
|
||||
)
|
||||
}
|
||||
|
||||
fun ReaderSettings.shouldShowPageWidthFormatControl(): Boolean {
|
||||
return readingMode == ReaderReadingMode.PAGINATED
|
||||
}
|
||||
|
||||
fun readerThemeById(themeId: String?): ReaderTheme? {
|
||||
return BuiltInReaderThemes.firstOrNull { it.id == themeId }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,13 @@ import com.aryan.reader.shared.reader.ReaderPage
|
|||
import com.aryan.reader.shared.reader.ReaderSessionState
|
||||
import com.aryan.reader.shared.reader.SharedEpubBook
|
||||
import com.aryan.reader.shared.reader.SharedEpubChapter
|
||||
import com.aryan.reader.shared.reader.logSharedReaderDiagnostic
|
||||
|
||||
const val GEMINI_CLOUD_TTS_MODEL = "gemini-3.1-flash-live-preview"
|
||||
const val GEMINI_CLOUD_TTS_MODEL_ID = "gemini:$GEMINI_CLOUD_TTS_MODEL"
|
||||
const val DEFAULT_CLOUD_TTS_SPEAKER_ID = "Aoede"
|
||||
const val READER_TTS_CHUNK_MAX_LENGTH = 250
|
||||
private const val ReaderTtsStartTraceLogTag = "EpistemeDesktopTtsStartTrace"
|
||||
|
||||
data class ReaderCloudTtsVoice(
|
||||
val id: String,
|
||||
|
|
@ -325,15 +327,42 @@ object ReaderTtsPlanner {
|
|||
val anchor = session.navigationLocator
|
||||
val pageIndex = anchor?.pageIndex ?: session.reader.currentPageIndex
|
||||
val pages = session.reader.pages.dropWhile { it.pageIndex < pageIndex.coerceAtLeast(0) }
|
||||
val syntheticDesktopAnchor = anchor?.isSyntheticDesktopTtsAnchor() == true
|
||||
val chunks = chunksForPages(session.reader.book, pages)
|
||||
val chapterIndex = anchor?.chapterIndex
|
||||
val startOffset = anchor?.startOffset
|
||||
if (chapterIndex == null && startOffset == null) return chunks
|
||||
var nextIndex = 0
|
||||
return chunks.mapNotNull { chunk ->
|
||||
chunk.afterLocator(chapterIndex = chapterIndex, startOffset = startOffset)
|
||||
?.copy(index = nextIndex++)
|
||||
logReaderTtsStartTrace {
|
||||
"event=planner_from_here_start pageIndex=$pageIndex pages=${pages.size} chunks=${chunks.size} " +
|
||||
"syntheticDesktop=$syntheticDesktopAnchor " +
|
||||
"anchor=${anchor.readerTtsLocatorSummary()} first=${chunks.firstOrNull().readerTtsChunkSummary()} " +
|
||||
"second=${chunks.getOrNull(1).readerTtsChunkSummary()}"
|
||||
}
|
||||
if (chapterIndex == null && startOffset == null) return chunks
|
||||
val target = anchor?.toTtsChunkTarget()
|
||||
val startChunkIndex = findReaderTtsChunkStartIndex(chunks, target)
|
||||
?: chunks.indexOfFirst { it.isOnOrAfterLocator(chapterIndex, startOffset) }.takeIf { it >= 0 }
|
||||
?: run {
|
||||
logReaderTtsStartTrace {
|
||||
"event=planner_from_here_empty reason=no_start_chunk target=${target.readerTtsTargetSummary()} " +
|
||||
"anchor=${anchor.readerTtsLocatorSummary()} chunks=${chunks.size}"
|
||||
}
|
||||
return emptyList()
|
||||
}
|
||||
val initialChunk = anchor?.let { chunks[startChunkIndex].sliceFromLocator(it) }
|
||||
val sessionChunks = if (initialChunk == null) {
|
||||
chunks.drop(startChunkIndex + 1)
|
||||
} else {
|
||||
chunks.withInitialChunkOverride(startChunkIndex, initialChunk).drop(startChunkIndex)
|
||||
}
|
||||
logReaderTtsStartTrace {
|
||||
"event=planner_from_here_result target=${target.readerTtsTargetSummary()} startChunkIndex=$startChunkIndex " +
|
||||
"sourceChunk=${chunks.getOrNull(startChunkIndex).readerTtsChunkSummary()} " +
|
||||
"initialChunk=${initialChunk.readerTtsChunkSummary()} resultChunks=${sessionChunks.size} " +
|
||||
"resultFirst=${sessionChunks.firstOrNull().readerTtsChunkSummary()}"
|
||||
}
|
||||
return sessionChunks
|
||||
.filter { it.text.isNotBlank() }
|
||||
.mapIndexed { index, chunk -> chunk.copy(index = index) }
|
||||
}
|
||||
|
||||
fun chunksForText(
|
||||
|
|
@ -401,28 +430,51 @@ object ReaderTtsPlanner {
|
|||
}
|
||||
}
|
||||
|
||||
private fun ReaderTtsChunk.afterLocator(chapterIndex: Int?, startOffset: Int?): ReaderTtsChunk? {
|
||||
private fun ReaderTtsChunk.isOnOrAfterLocator(chapterIndex: Int?, startOffset: Int?): Boolean {
|
||||
if (chapterIndex != null) {
|
||||
if (this.chapterIndex < chapterIndex) return null
|
||||
if (this.chapterIndex > chapterIndex) return this
|
||||
if (this.chapterIndex < chapterIndex) return false
|
||||
if (this.chapterIndex > chapterIndex) return true
|
||||
}
|
||||
val anchorOffset = startOffset ?: return this
|
||||
if (endOffset <= anchorOffset) return null
|
||||
if (anchorOffset <= this.startOffset) return this
|
||||
return trimStartTo(anchorOffset)
|
||||
val anchorOffset = startOffset ?: return true
|
||||
return endOffset > anchorOffset
|
||||
}
|
||||
|
||||
private fun ReaderTtsChunk.trimStartTo(sourceOffset: Int): ReaderTtsChunk? {
|
||||
val boundedOffset = sourceOffset.coerceIn(startOffset, endOffset)
|
||||
if (boundedOffset <= startOffset) return this
|
||||
if (boundedOffset >= endOffset) return null
|
||||
val rawDrop = (boundedOffset - startOffset).coerceIn(0, text.length)
|
||||
val remaining = text.drop(rawDrop)
|
||||
private fun ReaderTtsChunk.sliceFromLocator(locator: ReaderLocator): ReaderTtsChunk? {
|
||||
if (locator.chapterIndex != null && locator.chapterIndex != chapterIndex) return this
|
||||
val sourceOffset = locator.startOffset ?: return this
|
||||
val rawDrop = (sourceOffset - startOffset).coerceIn(0, text.length)
|
||||
val drop = rawDrop
|
||||
if (drop <= 0) {
|
||||
logReaderTtsStartTrace {
|
||||
"event=planner_slice_keep reason=drop_at_start rawDrop=$rawDrop " +
|
||||
"locator=${locator.readerTtsLocatorSummary()} chunk=${readerTtsChunkSummary()}"
|
||||
}
|
||||
return this
|
||||
}
|
||||
if (drop >= text.length) {
|
||||
logReaderTtsStartTrace {
|
||||
"event=planner_slice_skip reason=drop_past_end rawDrop=$rawDrop " +
|
||||
"chosenDrop=$drop locator=${locator.readerTtsLocatorSummary()} chunk=${readerTtsChunkSummary()}"
|
||||
}
|
||||
return null
|
||||
}
|
||||
val remaining = text.drop(drop)
|
||||
val leadingWhitespace = remaining.indexOfFirst { !it.isWhitespace() }
|
||||
if (leadingWhitespace < 0) return null
|
||||
if (leadingWhitespace < 0) {
|
||||
logReaderTtsStartTrace {
|
||||
"event=planner_slice_skip reason=blank_after_drop rawDrop=$rawDrop " +
|
||||
"chosenDrop=$drop locator=${locator.readerTtsLocatorSummary()} chunk=${readerTtsChunkSummary()}"
|
||||
}
|
||||
return null
|
||||
}
|
||||
val nextText = remaining.drop(leadingWhitespace)
|
||||
if (nextText.isBlank()) return null
|
||||
val nextStartOffset = (boundedOffset + leadingWhitespace).coerceAtMost(endOffset)
|
||||
val nextStartOffset = (sourceOffset + leadingWhitespace).coerceAtMost(endOffset)
|
||||
logReaderTtsStartTrace {
|
||||
"event=planner_slice_result rawDrop=$rawDrop chosenDrop=$drop " +
|
||||
"leadingWhitespace=$leadingWhitespace nextStart=$nextStartOffset locator=${locator.readerTtsLocatorSummary()} " +
|
||||
"chunk=${readerTtsChunkSummary()} nextText=\"${nextText.readerTtsLogPreview()}\""
|
||||
}
|
||||
return copy(
|
||||
text = nextText,
|
||||
spokenText = nextText,
|
||||
|
|
@ -545,7 +597,7 @@ object ReaderTtsPlanner {
|
|||
chunks += ReaderTtsTextRange(
|
||||
text = currentText.toString(),
|
||||
start = currentStart,
|
||||
end = currentStart + currentText.length
|
||||
end = currentEnd
|
||||
)
|
||||
}
|
||||
currentText = StringBuilder()
|
||||
|
|
@ -554,19 +606,25 @@ object ReaderTtsPlanner {
|
|||
}
|
||||
|
||||
for (sentence in sentenceRanges) {
|
||||
val appendText = if (currentText.isEmpty()) {
|
||||
sentence.text
|
||||
} else {
|
||||
val gapStart = (currentEnd - sourceStart).coerceIn(0, source.length)
|
||||
val gapEnd = (sentence.end - sourceStart).coerceIn(gapStart, source.length)
|
||||
source.substring(gapStart, gapEnd)
|
||||
}
|
||||
if (sentence.text.length > maxLength) {
|
||||
flushCurrent()
|
||||
chunks += sentence
|
||||
continue
|
||||
}
|
||||
if (currentText.isNotEmpty() && currentText.length + sentence.text.length + 1 > maxLength) {
|
||||
if (currentText.isNotEmpty() && currentText.length + appendText.length > maxLength) {
|
||||
flushCurrent()
|
||||
currentText.append(sentence.text)
|
||||
currentStart = sentence.start
|
||||
currentEnd = sentence.end
|
||||
} else {
|
||||
if (currentText.isNotEmpty()) currentText.append(" ")
|
||||
currentText.append(sentence.text)
|
||||
currentText.append(appendText)
|
||||
if (currentStart < 0) currentStart = sentence.start
|
||||
currentEnd = sentence.end
|
||||
}
|
||||
|
|
@ -612,6 +670,162 @@ object ReaderTtsPlanner {
|
|||
val start: Int,
|
||||
val end: Int
|
||||
)
|
||||
|
||||
private data class ReaderTtsChunkTarget(
|
||||
val text: String,
|
||||
val sourceCfi: String?,
|
||||
val startOffset: Int
|
||||
)
|
||||
|
||||
private fun ReaderLocator.toTtsChunkTarget(): ReaderTtsChunkTarget? {
|
||||
val offset = startOffset ?: return null
|
||||
val sourceCfi = cfi
|
||||
?.readerTtsSourceCfiBase()
|
||||
?.takeIf { it.startsWith("/") }
|
||||
return ReaderTtsChunkTarget(
|
||||
text = textQuote.orEmpty(),
|
||||
sourceCfi = sourceCfi,
|
||||
startOffset = offset
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderLocator.isSyntheticDesktopTtsAnchor(): Boolean {
|
||||
val value = cfi.orEmpty()
|
||||
return value.startsWith("desktop:") ||
|
||||
value.startsWith("desktop-scroll:") ||
|
||||
value.startsWith("desktop-scroll-page:")
|
||||
}
|
||||
|
||||
private fun findReaderTtsChunkStartIndex(
|
||||
chunks: List<ReaderTtsChunk>,
|
||||
target: ReaderTtsChunkTarget?
|
||||
): Int? {
|
||||
if (target == null) return null
|
||||
|
||||
val exactIndex = chunks.indexOfFirst {
|
||||
readerSameTtsChunkSource(it.sourceCfi, target.sourceCfi) &&
|
||||
it.startOffset == target.startOffset &&
|
||||
it.text.normalizedReaderTtsText() == target.text.normalizedReaderTtsText()
|
||||
}
|
||||
if (exactIndex >= 0) return exactIndex
|
||||
|
||||
val sourceAndOffsetIndex = chunks.indexOfFirst {
|
||||
readerSameTtsChunkSource(it.sourceCfi, target.sourceCfi) &&
|
||||
target.startOffset >= it.startOffset &&
|
||||
target.startOffset < it.endOffset
|
||||
}
|
||||
if (sourceAndOffsetIndex >= 0) return sourceAndOffsetIndex
|
||||
|
||||
val sourceAndTextIndex = chunks.indexOfFirst {
|
||||
readerSameTtsChunkSource(it.sourceCfi, target.sourceCfi) &&
|
||||
readerTtsTextMatches(it.text, target.text)
|
||||
}
|
||||
if (sourceAndTextIndex >= 0) return sourceAndTextIndex
|
||||
|
||||
val sourceNearestOffsetIndex = chunks
|
||||
.mapIndexedNotNull { index, chunk ->
|
||||
if (readerSameTtsChunkSource(chunk.sourceCfi, target.sourceCfi)) {
|
||||
index to kotlin.math.abs(chunk.startOffset - target.startOffset)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
.minByOrNull { it.second }
|
||||
?.first
|
||||
if (sourceNearestOffsetIndex != null) return sourceNearestOffsetIndex
|
||||
|
||||
return findUniqueReaderTtsTextMatch(chunks, target.text)
|
||||
}
|
||||
|
||||
private fun List<ReaderTtsChunk>.withInitialChunkOverride(
|
||||
startChunkIndex: Int,
|
||||
initialChunk: ReaderTtsChunk?
|
||||
): List<ReaderTtsChunk> {
|
||||
if (initialChunk == null || startChunkIndex !in indices) return this
|
||||
val existing = this[startChunkIndex]
|
||||
if (
|
||||
existing.text == initialChunk.text &&
|
||||
existing.sourceCfi == initialChunk.sourceCfi &&
|
||||
existing.startOffset == initialChunk.startOffset
|
||||
) {
|
||||
return this
|
||||
}
|
||||
return toMutableList().also { it[startChunkIndex] = initialChunk }
|
||||
}
|
||||
|
||||
private fun readerSameTtsChunkSource(first: String?, second: String?): Boolean {
|
||||
val firstSource = first.orEmpty()
|
||||
val secondSource = second.orEmpty()
|
||||
if (firstSource.isBlank() || secondSource.isBlank()) return firstSource == secondSource
|
||||
val firstPath = firstSource.readerTtsSourceCfiBase()
|
||||
val secondPath = secondSource.readerTtsSourceCfiBase()
|
||||
return firstPath == secondPath ||
|
||||
readerTtsCfiPathContains(firstPath, secondPath) ||
|
||||
readerTtsCfiPathContains(secondPath, firstPath)
|
||||
}
|
||||
|
||||
private fun readerTtsCfiPathContains(parentPath: String, childPath: String): Boolean {
|
||||
if (parentPath.isBlank() || childPath.isBlank() || parentPath == childPath) return false
|
||||
val parentParts = parentPath.split('/').filter { it.isNotEmpty() }
|
||||
val childParts = childPath.split('/').filter { it.isNotEmpty() }
|
||||
return parentParts.size < childParts.size && childParts.take(parentParts.size) == parentParts
|
||||
}
|
||||
|
||||
private fun readerTtsTextMatches(first: String, second: String): Boolean {
|
||||
val firstNormalized = first.normalizedReaderTtsText()
|
||||
val secondNormalized = second.normalizedReaderTtsText()
|
||||
if (firstNormalized.isBlank() || secondNormalized.isBlank()) return false
|
||||
return firstNormalized == secondNormalized ||
|
||||
firstNormalized.startsWith(secondNormalized) ||
|
||||
secondNormalized.startsWith(firstNormalized)
|
||||
}
|
||||
|
||||
private fun findUniqueReaderTtsTextMatch(chunks: List<ReaderTtsChunk>, text: String): Int? {
|
||||
val matches = chunks.mapIndexedNotNull { index, chunk ->
|
||||
index.takeIf { readerTtsTextMatches(chunk.text, text) }
|
||||
}
|
||||
return matches.singleOrNull()
|
||||
}
|
||||
|
||||
private fun String.readerTtsSourceCfiBase(): String {
|
||||
return substringBefore('|').substringBefore(':')
|
||||
}
|
||||
|
||||
private fun String.normalizedReaderTtsText(): String {
|
||||
return replace(Regex("\\s+"), " ").trim()
|
||||
}
|
||||
|
||||
private inline fun logReaderTtsStartTrace(message: () -> String) {
|
||||
logSharedReaderDiagnostic(ReaderTtsStartTraceLogTag, message)
|
||||
}
|
||||
|
||||
private fun ReaderLocator?.readerTtsLocatorSummary(maxTextLength: Int = 120): String {
|
||||
if (this == null) return "null"
|
||||
return "chapter=${chapterIndex ?: "null"} page=${pageIndex ?: "null"} " +
|
||||
"offsets=${startOffset ?: "null"}..${endOffset ?: "null"} " +
|
||||
"block=${blockIndex ?: "null"} char=${charOffset ?: "null"} " +
|
||||
"cfi=\"${cfi.orEmpty().readerTtsLogPreview(180)}\" text=\"${textQuote.orEmpty().readerTtsLogPreview(maxTextLength)}\""
|
||||
}
|
||||
|
||||
private fun ReaderTtsChunk?.readerTtsChunkSummary(maxTextLength: Int = 120): String {
|
||||
if (this == null) return "null"
|
||||
return "index=$index page=$pageIndex chapter=$chapterIndex offsets=$startOffset..$endOffset " +
|
||||
"sourceCfi=\"${sourceCfi.orEmpty().readerTtsLogPreview(160)}\" textChars=${text.length} " +
|
||||
"text=\"${text.readerTtsLogPreview(maxTextLength)}\" spoken=\"${spokenText.readerTtsLogPreview(maxTextLength)}\""
|
||||
}
|
||||
|
||||
private fun ReaderTtsChunkTarget?.readerTtsTargetSummary(maxTextLength: Int = 120): String {
|
||||
if (this == null) return "null"
|
||||
return "offset=$startOffset sourceCfi=\"${sourceCfi.orEmpty().readerTtsLogPreview(160)}\" " +
|
||||
"text=\"${text.readerTtsLogPreview(maxTextLength)}\""
|
||||
}
|
||||
|
||||
private fun String.readerTtsLogPreview(maxLength: Int = 120): String {
|
||||
return replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
.let { if (it.length <= maxLength) it else it.take(maxLength) + "..." }
|
||||
.replace("\"", "\\\"")
|
||||
}
|
||||
}
|
||||
|
||||
data class ReaderCloudTtsState(
|
||||
|
|
@ -625,6 +839,31 @@ data class ReaderCloudTtsState(
|
|||
val cacheSummary: ReaderTtsCacheSummary = ReaderTtsCacheSummary()
|
||||
)
|
||||
|
||||
data class ReaderCloudTtsControlsModel(
|
||||
val isVisible: Boolean,
|
||||
val canPauseResume: Boolean,
|
||||
val canSkipPrevious: Boolean,
|
||||
val canSkipNext: Boolean,
|
||||
val canLocateCurrentChunk: Boolean
|
||||
)
|
||||
|
||||
fun readerCloudTtsControlsModel(cloudTts: ReaderCloudTtsState): ReaderCloudTtsControlsModel {
|
||||
val progress = cloudTts.progress
|
||||
val visible = cloudTts.isLoading || cloudTts.isPlaying || cloudTts.isPaused
|
||||
val hasCurrentChunk = progress.currentChunk != null
|
||||
return ReaderCloudTtsControlsModel(
|
||||
isVisible = visible,
|
||||
canPauseResume = cloudTts.isPlaying || cloudTts.isPaused,
|
||||
canSkipPrevious = !cloudTts.isLoading &&
|
||||
progress.currentChunkIndex > 0 &&
|
||||
progress.chunks.isNotEmpty(),
|
||||
canSkipNext = !cloudTts.isLoading &&
|
||||
progress.currentChunkIndex >= 0 &&
|
||||
progress.currentChunkIndex < progress.chunks.lastIndex,
|
||||
canLocateCurrentChunk = hasCurrentChunk
|
||||
)
|
||||
}
|
||||
|
||||
data class ReaderAiResultState(
|
||||
val title: String? = null,
|
||||
val text: String = "",
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@ private val DefaultReaderBottomToolIds: Set<String>
|
|||
ReaderTool.SLIDER.id,
|
||||
ReaderTool.TOC.id,
|
||||
ReaderTool.FORMAT.id,
|
||||
ReaderTool.SEARCH.id,
|
||||
ReaderTool.AI_FEATURES.id,
|
||||
ReaderTool.TTS_CONTROLS.id
|
||||
ReaderTool.SEARCH.id
|
||||
)
|
||||
|
||||
enum class ReaderTool(
|
||||
|
|
@ -22,8 +20,8 @@ enum class ReaderTool(
|
|||
TOC("toc", "Sidebar", "Bottom Bar"),
|
||||
FORMAT("format", "Text Formatting", "Bottom Bar"),
|
||||
SEARCH("search", "Search", "Bottom Bar", supportsDesktopQuickAction = true),
|
||||
AI_FEATURES("ai_features", "AI Features", "Bottom Bar", supportsDesktopQuickAction = true),
|
||||
TTS_CONTROLS("tts_controls", "TTS Controls", "Bottom Bar", supportsDesktopQuickAction = true),
|
||||
AI_FEATURES("ai_features", "AI Features", "Bottom Bar"),
|
||||
TTS_CONTROLS("tts_controls", "TTS Controls", "Bottom Bar"),
|
||||
READING_MODE("reading_mode", "Reading Mode", "Overflow Menu"),
|
||||
BOOKMARK("bookmark", "Bookmark", "Overflow Menu", supportsDesktopQuickAction = true),
|
||||
TAP_TO_TURN("tap_to_turn", "Tap to Turn Pages", "Overflow Menu"),
|
||||
|
|
@ -31,7 +29,6 @@ enum class ReaderTool(
|
|||
PAGE_TURN_ANIM("page_turn_anim", "Realistic Page Turns", "Overflow Menu"),
|
||||
KEEP_SCREEN_ON("keep_screen_on", "Keep Screen On", "Overflow Menu"),
|
||||
VISUAL_OPTIONS("visual_options", "Visual Options", "Overflow Menu"),
|
||||
AUTO_SCROLL("auto_scroll", "Auto Scroll", "Overflow Menu", supportsDesktopQuickAction = true),
|
||||
TTS_SETTINGS("tts_settings", "TTS Voice Settings", "Overflow Menu"),
|
||||
TTS_REPLACEMENTS("tts_replacements", "TTS Word Replacements", "Overflow Menu");
|
||||
|
||||
|
|
|
|||
|
|
@ -90,22 +90,11 @@ data class ReaderTtsReplacementApplyResult(
|
|||
|
||||
object ReaderTtsReplacementEngine {
|
||||
fun validate(rule: ReaderTtsReplacementRule): ReaderTtsReplacementValidation {
|
||||
if (rule.from.isBlank()) {
|
||||
return ReaderTtsReplacementValidation(isValid = false, message = "Enter text to replace.")
|
||||
}
|
||||
if (!rule.isRegex) {
|
||||
return ReaderTtsReplacementValidation(isValid = true)
|
||||
}
|
||||
return runCatching { rule.toRegex() }
|
||||
.fold(
|
||||
onSuccess = { ReaderTtsReplacementValidation(isValid = true) },
|
||||
onFailure = {
|
||||
ReaderTtsReplacementValidation(
|
||||
isValid = false,
|
||||
message = it.message ?: "This regex is not valid.",
|
||||
)
|
||||
},
|
||||
)
|
||||
val validation = ReaderWordReplacementEngine.validate(rule.toWordReplacementRule())
|
||||
return ReaderTtsReplacementValidation(
|
||||
isValid = validation.isValid,
|
||||
message = validation.message,
|
||||
)
|
||||
}
|
||||
|
||||
fun apply(
|
||||
|
|
@ -117,52 +106,31 @@ object ReaderTtsReplacementEngine {
|
|||
return ReaderTtsReplacementApplyResult(text = text)
|
||||
}
|
||||
|
||||
var current = text
|
||||
val applied = mutableListOf<String>()
|
||||
val errors = mutableListOf<ReaderTtsReplacementError>()
|
||||
|
||||
preferences.activeRulesForBook(bookId).forEach { rule ->
|
||||
if (!rule.enabled || rule.from.isBlank()) return@forEach
|
||||
val regex = runCatching { rule.toRegex() }
|
||||
.onFailure {
|
||||
errors += ReaderTtsReplacementError(
|
||||
ruleId = rule.id,
|
||||
message = it.message ?: "Invalid regex.",
|
||||
)
|
||||
}
|
||||
.getOrNull() ?: return@forEach
|
||||
val replacement = if (rule.isRegex) rule.to else Regex.escapeReplacement(rule.to)
|
||||
val next = runCatching { regex.replace(current, replacement) }
|
||||
.onFailure {
|
||||
errors += ReaderTtsReplacementError(
|
||||
ruleId = rule.id,
|
||||
message = it.message ?: "Invalid replacement.",
|
||||
)
|
||||
}
|
||||
.getOrNull() ?: return@forEach
|
||||
if (next != current) {
|
||||
applied += rule.id
|
||||
current = next
|
||||
}
|
||||
}
|
||||
val result = ReaderWordReplacementEngine.apply(
|
||||
text = text,
|
||||
rules = preferences.activeRulesForBook(bookId).map { it.toWordReplacementRule() },
|
||||
)
|
||||
|
||||
return ReaderTtsReplacementApplyResult(
|
||||
text = current,
|
||||
appliedRuleIds = applied,
|
||||
errors = errors,
|
||||
text = result.text,
|
||||
appliedRuleIds = result.appliedRuleIds,
|
||||
errors = result.errors.map {
|
||||
ReaderTtsReplacementError(ruleId = it.ruleId, message = it.message)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ReaderTtsReplacementRule.toRegex(): Regex {
|
||||
val source = if (isRegex) from else Regex.escape(from)
|
||||
val boundedSource = if (wholeWord) {
|
||||
"""(?<![\p{L}\p{N}_])(?:$source)(?![\p{L}\p{N}_])"""
|
||||
} else {
|
||||
source
|
||||
}
|
||||
val options = if (matchCase) emptySet() else setOf(RegexOption.IGNORE_CASE)
|
||||
return Regex(pattern = boundedSource, options = options)
|
||||
}
|
||||
private fun ReaderTtsReplacementRule.toWordReplacementRule(): ReaderWordReplacementRule {
|
||||
return ReaderWordReplacementRule(
|
||||
id = id,
|
||||
from = from,
|
||||
to = to,
|
||||
enabled = enabled,
|
||||
isRegex = isRegex,
|
||||
matchCase = matchCase,
|
||||
wholeWord = wholeWord,
|
||||
)
|
||||
}
|
||||
|
||||
fun ReaderTtsChunk.withTtsReplacements(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,195 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class ReaderWordReplacementRule(
|
||||
val id: String,
|
||||
val from: String,
|
||||
val to: String,
|
||||
val enabled: Boolean = true,
|
||||
val isRegex: Boolean = false,
|
||||
val matchCase: Boolean = false,
|
||||
val wholeWord: Boolean = true,
|
||||
)
|
||||
|
||||
data class ReaderWordReplacementValidation(
|
||||
val isValid: Boolean,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
data class ReaderWordReplacementError(
|
||||
val ruleId: String,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
data class ReaderWordReplacementApplyResult(
|
||||
val text: String,
|
||||
val appliedRuleIds: List<String> = emptyList(),
|
||||
val errors: List<ReaderWordReplacementError> = emptyList(),
|
||||
)
|
||||
|
||||
object ReaderWordReplacementEngine {
|
||||
fun validate(rule: ReaderWordReplacementRule): ReaderWordReplacementValidation {
|
||||
if (rule.from.isBlank()) {
|
||||
return ReaderWordReplacementValidation(isValid = false, message = "Enter text to replace.")
|
||||
}
|
||||
if (!rule.isRegex) {
|
||||
return ReaderWordReplacementValidation(isValid = true)
|
||||
}
|
||||
return runCatching { rule.toRegex() }
|
||||
.fold(
|
||||
onSuccess = { ReaderWordReplacementValidation(isValid = true) },
|
||||
onFailure = {
|
||||
ReaderWordReplacementValidation(
|
||||
isValid = false,
|
||||
message = it.message ?: "This regex is not valid.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun apply(
|
||||
text: String,
|
||||
rules: List<ReaderWordReplacementRule>,
|
||||
): ReaderWordReplacementApplyResult {
|
||||
if (text.isEmpty() || rules.isEmpty()) {
|
||||
return ReaderWordReplacementApplyResult(text = text)
|
||||
}
|
||||
|
||||
var current = text
|
||||
val applied = mutableListOf<String>()
|
||||
val errors = mutableListOf<ReaderWordReplacementError>()
|
||||
|
||||
rules.forEach { rule ->
|
||||
if (!rule.enabled || rule.from.isBlank()) return@forEach
|
||||
val regex = runCatching { rule.toRegex() }
|
||||
.onFailure {
|
||||
errors += ReaderWordReplacementError(
|
||||
ruleId = rule.id,
|
||||
message = it.message ?: "Invalid regex.",
|
||||
)
|
||||
}
|
||||
.getOrNull() ?: return@forEach
|
||||
val replacement = if (rule.isRegex) rule.to else Regex.escapeReplacement(rule.to)
|
||||
val next = runCatching { regex.replace(current, replacement) }
|
||||
.onFailure {
|
||||
errors += ReaderWordReplacementError(
|
||||
ruleId = rule.id,
|
||||
message = it.message ?: "Invalid replacement.",
|
||||
)
|
||||
}
|
||||
.getOrNull() ?: return@forEach
|
||||
if (next != current) {
|
||||
applied += rule.id
|
||||
current = next
|
||||
}
|
||||
}
|
||||
|
||||
return ReaderWordReplacementApplyResult(
|
||||
text = current,
|
||||
appliedRuleIds = applied,
|
||||
errors = errors,
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderWordReplacementRule.toRegex(): Regex {
|
||||
val source = if (isRegex) from else Regex.escape(from)
|
||||
val boundedSource = if (wholeWord) {
|
||||
"""(?<![\p{L}\p{N}_])(?:$source)(?![\p{L}\p{N}_])"""
|
||||
} else {
|
||||
source
|
||||
}
|
||||
val options = if (matchCase) emptySet() else setOf(RegexOption.IGNORE_CASE)
|
||||
return Regex(pattern = boundedSource, options = options)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ReaderBookReplacementPreferences(
|
||||
val fileRules: Map<String, List<ReaderWordReplacementRule>> = emptyMap(),
|
||||
) {
|
||||
fun rulesForFile(fileId: String?): List<ReaderWordReplacementRule> {
|
||||
return fileRules[fileId.orEmpty()].orEmpty()
|
||||
}
|
||||
|
||||
fun activeRulesForFile(fileId: String?): List<ReaderWordReplacementRule> {
|
||||
return rulesForFile(fileId).filter { it.enabled && it.from.isNotBlank() }
|
||||
}
|
||||
|
||||
fun withFileRules(
|
||||
fileId: String?,
|
||||
rules: List<ReaderWordReplacementRule>,
|
||||
): ReaderBookReplacementPreferences {
|
||||
val key = fileId.orEmpty()
|
||||
val nextRules = if (rules.isEmpty()) {
|
||||
fileRules - key
|
||||
} else {
|
||||
fileRules + (key to rules)
|
||||
}
|
||||
return copy(fileRules = nextRules)
|
||||
}
|
||||
|
||||
fun scopedToFile(fileId: String?): ReaderBookReplacementPreferences {
|
||||
val key = fileId.orEmpty()
|
||||
val rules = rulesForFile(key)
|
||||
return if (rules.isEmpty()) {
|
||||
ReaderBookReplacementPreferences()
|
||||
} else {
|
||||
ReaderBookReplacementPreferences(fileRules = mapOf(key to rules))
|
||||
}
|
||||
}
|
||||
|
||||
fun signatureForFile(fileId: String?): String {
|
||||
return activeRulesForFile(fileId).joinToString(separator = "|") { rule ->
|
||||
listOf(
|
||||
rule.id,
|
||||
rule.from,
|
||||
rule.to,
|
||||
rule.enabled.toString(),
|
||||
rule.isRegex.toString(),
|
||||
rule.matchCase.toString(),
|
||||
rule.wholeWord.toString(),
|
||||
).joinToString(separator = "\u001F")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object ReaderBookReplacementEngine {
|
||||
fun validate(rule: ReaderWordReplacementRule): ReaderWordReplacementValidation {
|
||||
return ReaderWordReplacementEngine.validate(rule)
|
||||
}
|
||||
|
||||
fun apply(
|
||||
text: String,
|
||||
preferences: ReaderBookReplacementPreferences,
|
||||
fileId: String?,
|
||||
): ReaderWordReplacementApplyResult {
|
||||
return ReaderWordReplacementEngine.apply(
|
||||
text = text,
|
||||
rules = preferences.activeRulesForFile(fileId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object ReaderBookReplacementPreferencesJson {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = false
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
fun encode(preferences: ReaderBookReplacementPreferences): String {
|
||||
return json.encodeToString(preferences)
|
||||
}
|
||||
|
||||
fun decodeOrEmpty(raw: String?): ReaderBookReplacementPreferences {
|
||||
if (raw.isNullOrBlank()) return ReaderBookReplacementPreferences()
|
||||
return runCatching {
|
||||
json.decodeFromString<ReaderBookReplacementPreferences>(raw)
|
||||
}.getOrNull() ?: ReaderBookReplacementPreferences()
|
||||
}
|
||||
}
|
||||
|
|
@ -646,6 +646,7 @@ data class SharedSettingsHubInput(
|
|||
val isSignedIn: Boolean = false,
|
||||
val isProUser: Boolean = false,
|
||||
val accountAvailable: Boolean = true,
|
||||
val includeAccountAuthActions: Boolean = true,
|
||||
val syncAvailable: Boolean = true,
|
||||
val folderSyncAvailable: Boolean = true,
|
||||
val aiSettingsAvailable: Boolean = true,
|
||||
|
|
@ -750,7 +751,7 @@ fun sharedSettingsHubModel(input: SharedSettingsHubInput): SharedSettingsHubMode
|
|||
SharedSettingsSectionModel(
|
||||
section = SharedSettingsSection.SYNC_ACCOUNTS,
|
||||
items = buildList {
|
||||
if (input.accountAvailable && input.featurePolicy.aiAndCloud) {
|
||||
if (input.includeAccountAuthActions && input.accountAvailable && input.featurePolicy.aiAndCloud) {
|
||||
if (input.isSignedIn) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ data class SharedFeaturePolicy(
|
|||
) {
|
||||
companion object {
|
||||
val Standard = SharedFeaturePolicy()
|
||||
val OssOnline = SharedFeaturePolicy(
|
||||
networkAccess = true,
|
||||
aiAndCloud = true,
|
||||
byokAi = true
|
||||
)
|
||||
val OssOffline = SharedFeaturePolicy(
|
||||
networkAccess = false,
|
||||
opdsCatalogs = false,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
const val EPISTEME_POLICY_BASE_URL = "https://aryan-raj3112.github.io/reader-policy"
|
||||
|
||||
enum class SharedLegalProfile {
|
||||
STANDARD,
|
||||
OSS
|
||||
}
|
||||
|
||||
data class SharedLegalLinks(
|
||||
val privacyPolicyUrl: String,
|
||||
val termsUrl: String,
|
||||
val licensesUrl: String
|
||||
)
|
||||
|
||||
fun sharedLegalLinksForProfile(profile: SharedLegalProfile): SharedLegalLinks {
|
||||
val privacyPath: String
|
||||
val termsPath: String
|
||||
when (profile) {
|
||||
SharedLegalProfile.STANDARD -> {
|
||||
privacyPath = "privacy-policy.html"
|
||||
termsPath = "terms-and-conditions.html"
|
||||
}
|
||||
SharedLegalProfile.OSS -> {
|
||||
privacyPath = "oss-privacy-policy.html"
|
||||
termsPath = "oss-terms-of-service.html"
|
||||
}
|
||||
}
|
||||
return SharedLegalLinks(
|
||||
privacyPolicyUrl = "$EPISTEME_POLICY_BASE_URL/$privacyPath",
|
||||
termsUrl = "$EPISTEME_POLICY_BASE_URL/$termsPath",
|
||||
licensesUrl = "$EPISTEME_POLICY_BASE_URL/licenses.html"
|
||||
)
|
||||
}
|
||||
|
|
@ -45,8 +45,10 @@ data class SharedLibrarySnapshot(
|
|||
val appSeedColor: Color? = null,
|
||||
val appFontPreference: AppFontPreference = AppFontPreference.System,
|
||||
val customAppThemes: List<CustomAppTheme> = emptyList(),
|
||||
val customReaderThemes: List<ReaderTheme> = emptyList(),
|
||||
val readerDefaultSettings: ReaderSettings = ReaderSettings(),
|
||||
val pdfReaderDefaultSettings: ReaderSettings = ReaderSettings(themeId = "no_theme"),
|
||||
val desktopReaderDefaultsVersion: Int = 0,
|
||||
val readerToolbarPreferences: ReaderToolbarPreferences = ReaderToolbarPreferences(),
|
||||
val readerHighlightPalette: ReaderHighlightPalette = ReaderHighlightPalette(),
|
||||
val pdfHighlighterPalette: SharedPdfHighlighterPalette = SharedPdfHighlighterPalette(),
|
||||
|
|
@ -54,7 +56,7 @@ data class SharedLibrarySnapshot(
|
|||
)
|
||||
|
||||
object SharedLibrarySnapshotJson {
|
||||
private const val SCHEMA_VERSION = 20
|
||||
private const val SCHEMA_VERSION = 22
|
||||
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
|
|
@ -106,11 +108,15 @@ object SharedLibrarySnapshotJson {
|
|||
?.asAppFontPreferenceOrNull()
|
||||
?: AppFontPreference.System,
|
||||
customAppThemes = root.array("customAppThemes").mapNotNull { it.asCustomAppThemeOrNull() },
|
||||
customReaderThemes = root.array("customReaderThemes")
|
||||
.mapNotNull { it.asReaderThemeOrNull() }
|
||||
.sanitizeCustomReaderThemes(),
|
||||
readerDefaultSettings = readerDefaultSettings.migrateLegacyDefaultReadingMode(schemaVersion),
|
||||
pdfReaderDefaultSettings = root["pdfReaderDefaultSettings"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderSettingsOrNull()
|
||||
?: ReaderSettings(themeId = "no_theme"),
|
||||
desktopReaderDefaultsVersion = root.int("desktopReaderDefaultsVersion", 0),
|
||||
readerToolbarPreferences = root["readerToolbarPreferences"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderToolbarPreferencesOrNull()
|
||||
|
|
@ -154,8 +160,12 @@ object SharedLibrarySnapshotJson {
|
|||
"appSeedColor" to snapshot.appSeedColor.asJson(),
|
||||
"appFontPreference" to snapshot.appFontPreference.sanitized().toJsonObject(),
|
||||
"customAppThemes" to JsonArray(snapshot.customAppThemes.map { it.toJsonObject() }),
|
||||
"customReaderThemes" to JsonArray(
|
||||
snapshot.customReaderThemes.sanitizeCustomReaderThemes().map { it.toJsonObject() }
|
||||
),
|
||||
"readerDefaultSettings" to snapshot.readerDefaultSettings.asJson(),
|
||||
"pdfReaderDefaultSettings" to snapshot.pdfReaderDefaultSettings.asJson(),
|
||||
"desktopReaderDefaultsVersion" to JsonPrimitive(snapshot.desktopReaderDefaultsVersion),
|
||||
"readerToolbarPreferences" to snapshot.readerToolbarPreferences.sanitized().toJsonObject(),
|
||||
"readerHighlightPalette" to snapshot.readerHighlightPalette.sanitized().toJsonObject(),
|
||||
"pdfHighlighterPalette" to snapshot.pdfHighlighterPalette.sanitized().toJsonObject(),
|
||||
|
|
@ -279,7 +289,8 @@ private fun JsonElement.asBookItemOrNull(): BookItem? {
|
|||
readerSettings = obj["readerSettings"]?.takeUnless { it is JsonNull }?.asReaderSettingsOrNull(),
|
||||
readerBookmarks = obj.array("readerBookmarks").mapNotNull { it.asReaderBookmarkOrNull() },
|
||||
readerHighlights = obj.array("readerHighlights").mapNotNull { it.asReaderHighlightOrNull() },
|
||||
pdfReaderViewport = obj["pdfReaderViewport"]?.takeUnless { it is JsonNull }?.asSharedPdfReaderViewportOrNull()
|
||||
pdfReaderViewport = obj["pdfReaderViewport"]?.takeUnless { it is JsonNull }?.asSharedPdfReaderViewportOrNull(),
|
||||
readingPositionModifiedTimestamp = obj.long("readingPositionModifiedTimestamp")
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -336,7 +347,8 @@ private fun JsonElement.asSyncedFolderOrNull(): SyncedFolder? {
|
|||
.mapNotNull { runCatching { FileType.valueOf(it) }.getOrNull() }
|
||||
.filter { it in SharedFileCapabilities.knownFileTypes }
|
||||
.toSet()
|
||||
.ifEmpty { SharedFileCapabilities.knownFileTypes }
|
||||
.ifEmpty { SharedFileCapabilities.knownFileTypes },
|
||||
localSyncEnabled = obj.boolean("localSyncEnabled", true)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -349,6 +361,19 @@ private fun JsonElement.asCustomAppThemeOrNull(): CustomAppTheme? {
|
|||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderThemeOrNull(): ReaderTheme? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return ReaderTheme(
|
||||
id = obj.string("id") ?: return null,
|
||||
name = obj.string("name") ?: return null,
|
||||
backgroundColor = obj.int("bgColor")?.let { Color(it) } ?: return null,
|
||||
textColor = obj.int("textColor")?.let { Color(it) } ?: return null,
|
||||
isDark = obj.boolean("isDark", false),
|
||||
textureId = obj.string("textureId")?.takeIf { it.isNotBlank() },
|
||||
isCustom = true
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asAppFontPreferenceOrNull(): AppFontPreference? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val kind = obj.string("kind")
|
||||
|
|
@ -391,7 +416,8 @@ private fun BookItem.toJsonObject(): JsonObject {
|
|||
"readerSettings" to readerSettings.asJson(),
|
||||
"readerBookmarks" to JsonArray(readerBookmarks.map { it.toJsonObject() }),
|
||||
"readerHighlights" to JsonArray(readerHighlights.map { it.toJsonObject() }),
|
||||
"pdfReaderViewport" to pdfReaderViewport.asJson()
|
||||
"pdfReaderViewport" to pdfReaderViewport.asJson(),
|
||||
"readingPositionModifiedTimestamp" to JsonPrimitive(readingPositionModifiedTimestamp)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -451,7 +477,8 @@ private fun SyncedFolder.toJsonObject(): JsonObject {
|
|||
.filter { it in SharedFileCapabilities.knownFileTypes }
|
||||
.map { it.name }
|
||||
.sorted()
|
||||
.asJsonArray()
|
||||
.asJsonArray(),
|
||||
"localSyncEnabled" to JsonPrimitive(localSyncEnabled)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -466,6 +493,18 @@ private fun CustomAppTheme.toJsonObject(): JsonObject {
|
|||
)
|
||||
}
|
||||
|
||||
private fun ReaderTheme.toJsonObject(): JsonObject {
|
||||
val values = mutableMapOf<String, JsonElement>(
|
||||
"id" to JsonPrimitive(id),
|
||||
"name" to JsonPrimitive(name),
|
||||
"bgColor" to JsonPrimitive(backgroundColor.toArgb()),
|
||||
"textColor" to JsonPrimitive(textColor.toArgb()),
|
||||
"isDark" to JsonPrimitive(isDark)
|
||||
)
|
||||
textureId?.let { values["textureId"] = JsonPrimitive(it) }
|
||||
return JsonObject(values)
|
||||
}
|
||||
|
||||
private fun AppFontPreference.toJsonObject(): JsonObject {
|
||||
val sanitized = sanitized()
|
||||
return JsonObject(
|
||||
|
|
@ -529,6 +568,7 @@ private fun JsonElement.asReaderSettingsOrNull(): ReaderSettings? {
|
|||
pageSpreadMode = obj.string("pageSpreadMode")
|
||||
?.let { runCatching { ReaderPageSpreadMode.valueOf(it) }.getOrNull() }
|
||||
?: defaults.pageSpreadMode,
|
||||
rightToLeftPagination = obj.boolean("rightToLeftPagination", defaults.rightToLeftPagination),
|
||||
pdfVerticalPageGapVisible = obj.boolean(
|
||||
"pdfVerticalPageGapVisible",
|
||||
defaults.pdfVerticalPageGapVisible
|
||||
|
|
@ -650,6 +690,8 @@ private fun JsonElement.asReaderLocatorOrNull(): ReaderLocator? {
|
|||
pageIndex = obj.int("pageIndex"),
|
||||
startOffset = obj.int("startOffset"),
|
||||
endOffset = obj.int("endOffset"),
|
||||
blockIndex = obj.int("blockIndex"),
|
||||
charOffset = obj.int("charOffset"),
|
||||
textQuote = obj.string("textQuote"),
|
||||
cfi = obj.string("cfi")
|
||||
)
|
||||
|
|
@ -681,6 +723,7 @@ private fun ReaderSettings?.asJson(): JsonElement {
|
|||
"pageInfoMode" to JsonPrimitive(settings.pageInfoMode.name),
|
||||
"pageInfoPosition" to JsonPrimitive(settings.pageInfoPosition.name),
|
||||
"pageSpreadMode" to JsonPrimitive(settings.pageSpreadMode.name),
|
||||
"rightToLeftPagination" to JsonPrimitive(settings.rightToLeftPagination),
|
||||
"pdfVerticalPageGapVisible" to JsonPrimitive(settings.pdfVerticalPageGapVisible),
|
||||
"pdfPageNumberOverlayVisible" to JsonPrimitive(settings.pdfPageNumberOverlayVisible),
|
||||
"pdfFirstPageStandaloneInSpread" to JsonPrimitive(settings.pdfFirstPageStandaloneInSpread),
|
||||
|
|
@ -767,6 +810,8 @@ private fun ReaderLocator.toJsonObject(): JsonObject {
|
|||
pageIndex?.let { put("pageIndex", JsonPrimitive(it)) }
|
||||
startOffset?.let { put("startOffset", JsonPrimitive(it)) }
|
||||
endOffset?.let { put("endOffset", JsonPrimitive(it)) }
|
||||
blockIndex?.let { put("blockIndex", JsonPrimitive(it)) }
|
||||
charOffset?.let { put("charOffset", JsonPrimitive(it)) }
|
||||
textQuote?.let { put("textQuote", JsonPrimitive(it)) }
|
||||
cfi?.let { put("cfi", JsonPrimitive(it)) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,9 @@ fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState {
|
|||
appSeedColor = if (shouldClearSeed) null else appSeedColor
|
||||
)
|
||||
}
|
||||
is AppAction.CustomReaderThemesChanged -> copy(
|
||||
customReaderThemes = action.themes.sanitizeCustomReaderThemes()
|
||||
)
|
||||
is AppAction.SyncEnabledChanged -> copy(isSyncEnabled = action.enabled)
|
||||
is AppAction.FolderSyncEnabledChanged -> copy(isFolderSyncEnabled = action.enabled)
|
||||
is AppAction.TabsEnabledChanged -> copy(
|
||||
|
|
@ -101,9 +104,11 @@ fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState {
|
|||
if (bookId.isBlank()) {
|
||||
this
|
||||
} else {
|
||||
val currentTabIds = openTabIds.distinct()
|
||||
val nextTabIds = if (bookId in currentTabIds) currentTabIds else currentTabIds + bookId
|
||||
copy(
|
||||
isTabsEnabled = true,
|
||||
openTabIds = (openTabIds - bookId) + bookId,
|
||||
openTabIds = nextTabIds,
|
||||
activeTabBookId = bookId
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,10 +47,18 @@ data class OpdsAcquisition(
|
|||
mimeType.contains("x-mobipocket-ebook", ignoreCase = true) -> "MOBI"
|
||||
mimeType.contains("fictionbook", ignoreCase = true) ||
|
||||
mimeType.contains("fb2", ignoreCase = true) -> "FB2"
|
||||
mimeType.contains("cbz", ignoreCase = true) ||
|
||||
mimeType.contains("comicbook", ignoreCase = true) -> "CBZ"
|
||||
mimeType.contains("cbt", ignoreCase = true) ||
|
||||
mimeType.contains("comicbook+tar", ignoreCase = true) ||
|
||||
mimeType.contains("x-tar", ignoreCase = true) ||
|
||||
mimeType.equals("application/tar", ignoreCase = true) -> "CBT"
|
||||
mimeType.contains("cbr", ignoreCase = true) ||
|
||||
mimeType.contains("comicbook-rar", ignoreCase = true) ||
|
||||
mimeType.contains("rar", ignoreCase = true) -> "CBR"
|
||||
mimeType.contains("cb7", ignoreCase = true) ||
|
||||
mimeType.contains("7z", ignoreCase = true) -> "CB7"
|
||||
mimeType.contains("cbz", ignoreCase = true) ||
|
||||
mimeType.contains("comicbook+zip", ignoreCase = true) ||
|
||||
mimeType.contains("comicbook", ignoreCase = true) -> "CBZ"
|
||||
mimeType.contains("txt", ignoreCase = true) ||
|
||||
mimeType.contains("text/plain", ignoreCase = true) -> "TXT"
|
||||
else -> mimeType.substringAfterLast("/").uppercase()
|
||||
|
|
@ -63,7 +71,7 @@ data class OpdsAcquisition(
|
|||
"PPTX" -> 4
|
||||
"MOBI" -> 3
|
||||
"FB2", "MD", "HTML" -> 2
|
||||
"CBZ", "CBR", "CB7" -> 1
|
||||
"CBZ", "CBR", "CB7", "CBT" -> 1
|
||||
"TXT" -> 0
|
||||
else -> -1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.aryan.reader.shared.opds
|
||||
|
||||
import com.aryan.reader.shared.BookItem
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
|
||||
object SharedOpdsSearch {
|
||||
|
|
@ -18,7 +19,14 @@ object SharedOpdsSearch {
|
|||
|
||||
fun expandSearchTemplate(template: String, query: String): String {
|
||||
val encoded = query.percentEncode()
|
||||
val expandedSearchTerms = template.replace("{searchTerms}", encoded)
|
||||
val expandedSearchTerms = template
|
||||
.replace("{searchTerms}", encoded)
|
||||
.replace("{count}", DefaultSearchCount)
|
||||
.replace("{startPage}", DefaultSearchStartPage)
|
||||
.replace("{startIndex}", DefaultSearchStartIndex)
|
||||
.replace("{language}", DefaultSearchLanguage)
|
||||
.replace("{inputEncoding}", DefaultSearchEncoding)
|
||||
.replace("{outputEncoding}", DefaultSearchEncoding)
|
||||
if (expandedSearchTerms != template) return expandedSearchTerms
|
||||
|
||||
val queryTemplate = Regex("""\{([?&])([^}]+)\}""").find(template)
|
||||
|
|
@ -56,6 +64,12 @@ object SharedOpdsSearch {
|
|||
contains("{query}") ||
|
||||
contains("{keyword}")
|
||||
}
|
||||
|
||||
private const val DefaultSearchCount = "12"
|
||||
private const val DefaultSearchStartPage = "1"
|
||||
private const val DefaultSearchStartIndex = "1"
|
||||
private const val DefaultSearchLanguage = "*"
|
||||
private const val DefaultSearchEncoding = "UTF-8"
|
||||
}
|
||||
|
||||
object SharedOpdsDownloadNamer {
|
||||
|
|
@ -82,6 +96,7 @@ object SharedOpdsDownloadNamer {
|
|||
"CBZ" -> ".cbz"
|
||||
"CBR" -> ".cbr"
|
||||
"CB7" -> ".cb7"
|
||||
"CBT" -> ".cbt"
|
||||
"MD" -> ".md"
|
||||
"HTML" -> ".html"
|
||||
"TXT" -> ".txt"
|
||||
|
|
@ -125,6 +140,101 @@ object SharedOpdsDownloadNamer {
|
|||
}
|
||||
}
|
||||
|
||||
object SharedOpdsLocalBookMatcher {
|
||||
fun findBook(entry: OpdsEntry, books: List<BookItem>): BookItem? {
|
||||
return find(
|
||||
entry = entry,
|
||||
books = books,
|
||||
title = { it.title },
|
||||
displayName = { it.displayName },
|
||||
path = { it.path }
|
||||
)
|
||||
}
|
||||
|
||||
fun <T> find(
|
||||
entry: OpdsEntry,
|
||||
books: List<T>,
|
||||
title: (T) -> String?,
|
||||
displayName: (T) -> String?,
|
||||
path: (T) -> String?
|
||||
): T? {
|
||||
val entryKeys = entry.matchKeys()
|
||||
return books.firstOrNull { book ->
|
||||
book.matchKeys(title, displayName, path).any { it in entryKeys }
|
||||
}
|
||||
}
|
||||
|
||||
private fun OpdsEntry.matchKeys(): Set<String> {
|
||||
return buildSet {
|
||||
addNormalized(title)
|
||||
val safeTitle = SharedOpdsDownloadNamer.safeFileStem(title)
|
||||
addNormalized(safeTitle)
|
||||
addNormalized(safeTitle.take(50))
|
||||
acquisitions.forEach { acquisition ->
|
||||
addFileNameKeys(acquisition.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> T.matchKeys(
|
||||
title: (T) -> String?,
|
||||
displayName: (T) -> String?,
|
||||
path: (T) -> String?
|
||||
): Set<String> {
|
||||
return buildSet {
|
||||
addNormalized(title(this@matchKeys))
|
||||
addFileNameKeys(displayName(this@matchKeys))
|
||||
addFileNameKeys(path(this@matchKeys))
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableSet<String>.addFileNameKeys(value: String?) {
|
||||
val decodedName = value
|
||||
?.substringBefore('?')
|
||||
?.substringBefore('#')
|
||||
?.substringAfterLast('/')
|
||||
?.substringAfterLast('\\')
|
||||
?.percentDecode()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: return
|
||||
addNormalized(decodedName)
|
||||
addNormalized(decodedName.withoutKnownExtension())
|
||||
addNormalized(decodedName.withoutKnownExtension().withoutOpdsDownloadPrefix())
|
||||
}
|
||||
|
||||
private fun MutableSet<String>.addNormalized(value: String?) {
|
||||
val normalized = value?.normalizedMatchKey() ?: return
|
||||
if (normalized.isNotBlank()) add(normalized)
|
||||
}
|
||||
|
||||
private fun String.withoutKnownExtension(): String {
|
||||
val knownSuffix = SharedFileCapabilities.fileExtensionSuffixForName(this)
|
||||
if (knownSuffix != null && endsWith(knownSuffix, ignoreCase = true)) {
|
||||
return dropLast(knownSuffix.length)
|
||||
}
|
||||
val extension = substringAfterLast('.', missingDelimiterValue = "")
|
||||
return if (extension.length in 1..8 && extension.all { it.isLetterOrDigit() }) {
|
||||
substringBeforeLast('.')
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.normalizedMatchKey(): String {
|
||||
return percentDecode()
|
||||
.withoutOpdsDownloadPrefix()
|
||||
.replace(Regex("""[^\p{L}\p{N}]+"""), " ")
|
||||
.trim()
|
||||
.lowercase()
|
||||
.replace(Regex("""\s+"""), " ")
|
||||
.removePrefix("opds dl ")
|
||||
}
|
||||
|
||||
private fun String.withoutOpdsDownloadPrefix(): String {
|
||||
return replace(Regex("""^opds[_\-\s]+dl[_\-\s]+""", RegexOption.IGNORE_CASE), "")
|
||||
}
|
||||
}
|
||||
|
||||
object SharedOpdsStreamUri {
|
||||
private const val SCHEME_PREFIX = "opds-pse://stream"
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,38 @@ data class SharedPdfAnnotationComment(
|
|||
val modifiedAt: Long = 0L
|
||||
)
|
||||
|
||||
const val DEFAULT_SHARED_PDF_COMMENT_AUTHOR = "Reader"
|
||||
|
||||
fun List<SharedPdfAnnotationComment>.visiblePdfAnnotationComments(): List<SharedPdfAnnotationComment> {
|
||||
val visibleCommentIds = filter { it.contents.isNotBlank() }.map { it.id }.toSet()
|
||||
return filter { it.contents.isNotBlank() }
|
||||
.map { comment ->
|
||||
if (comment.parentId != null && comment.parentId !in visibleCommentIds) {
|
||||
comment.copy(parentId = null)
|
||||
} else {
|
||||
comment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun List<SharedPdfAnnotationComment>.pdfCommentChildren(parentId: String?): List<SharedPdfAnnotationComment> {
|
||||
return filter { it.parentId == parentId }
|
||||
.sortedWith(compareBy({ it.createdAt.takeIf { timestamp -> timestamp > 0L } ?: Long.MAX_VALUE }, { it.id }))
|
||||
}
|
||||
|
||||
fun List<SharedPdfAnnotationComment>.withoutPdfCommentThread(commentId: String): List<SharedPdfAnnotationComment> {
|
||||
val childrenByParentId = groupBy { it.parentId }
|
||||
val idsToRemove = mutableSetOf<String>()
|
||||
|
||||
fun collect(id: String) {
|
||||
if (!idsToRemove.add(id)) return
|
||||
childrenByParentId[id].orEmpty().forEach { child -> collect(child.id) }
|
||||
}
|
||||
|
||||
collect(commentId)
|
||||
return filterNot { it.id in idsToRemove }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfAnnotation(
|
||||
val id: String,
|
||||
|
|
@ -162,13 +194,7 @@ object SharedPdfAnnotationDefaults {
|
|||
0xFFFFFFFF.toInt()
|
||||
)
|
||||
|
||||
val highlighterPalette: List<Int> = listOf(
|
||||
0x8CFF9800.toInt(),
|
||||
0x8CFFEB3B.toInt(),
|
||||
0x8C81C784.toInt(),
|
||||
0x8C64B5F6.toInt(),
|
||||
0x8CE1BEE7.toInt()
|
||||
)
|
||||
val highlighterPalette: List<Int> = SharedPdfAndroidHighlightColors.palette.take(4)
|
||||
|
||||
fun configFor(tool: PdfInkTool): PdfToolConfig {
|
||||
return when (tool) {
|
||||
|
|
@ -176,8 +202,8 @@ object SharedPdfAnnotationDefaults {
|
|||
PdfInkTool.PEN -> PdfToolConfig(0xFFFF0000.toInt(), 0.008f)
|
||||
PdfInkTool.FOUNTAIN_PEN -> PdfToolConfig(0xFF0000FF.toInt(), 0.008f)
|
||||
PdfInkTool.PENCIL -> PdfToolConfig(0xFF444444.toInt(), 0.008f)
|
||||
PdfInkTool.HIGHLIGHTER -> PdfToolConfig(0x8CFF9800.toInt(), 0.035f)
|
||||
PdfInkTool.HIGHLIGHTER_ROUND -> PdfToolConfig(0x8CFFEB3B.toInt(), 0.035f)
|
||||
PdfInkTool.HIGHLIGHTER -> PdfToolConfig(highlighterPalette[0], 0.035f)
|
||||
PdfInkTool.HIGHLIGHTER_ROUND -> PdfToolConfig(highlighterPalette[1], 0.035f)
|
||||
PdfInkTool.ERASER -> PdfToolConfig(0x00000000, 0.03f)
|
||||
PdfInkTool.TEXT -> PdfToolConfig(0xFF000000.toInt(), 0.02f)
|
||||
}
|
||||
|
|
@ -209,9 +235,11 @@ data class SharedPdfHighlighterPalette(
|
|||
|
||||
companion object {
|
||||
const val DefaultAlpha: Int = 0x8C
|
||||
const val MaxColors: Int = 5
|
||||
const val MaxColors: Int = 4
|
||||
val defaultColors: List<Int>
|
||||
get() = SharedPdfAnnotationDefaults.highlighterPalette.map { it.withPdfHighlighterAlpha() }
|
||||
get() = SharedPdfAnnotationDefaults.highlighterPalette
|
||||
.take(MaxColors)
|
||||
.map { it.withPdfHighlighterAlpha() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -219,18 +247,21 @@ object SharedPdfAndroidHighlightColors {
|
|||
const val StoredAlpha: Int = 0x8C
|
||||
const val RenderAlpha: Float = 0.4f
|
||||
|
||||
val orderedNames: List<String> = listOf("ORANGE", "YELLOW", "GREEN", "BLUE", "PURPLE")
|
||||
|
||||
val colorsByName: Map<String, Int> = mapOf(
|
||||
"YELLOW" to 0xFFFBC02D.toInt(),
|
||||
"GREEN" to 0xFF388E3C.toInt(),
|
||||
"BLUE" to 0xFF1976D2.toInt(),
|
||||
"RED" to 0xFFD32F2F.toInt()
|
||||
"ORANGE" to 0xFFFF9800.toInt(),
|
||||
"YELLOW" to 0xFFFFEB3B.toInt(),
|
||||
"GREEN" to 0xFF81C784.toInt(),
|
||||
"BLUE" to 0xFF64B5F6.toInt(),
|
||||
"PURPLE" to 0xFFE1BEE7.toInt()
|
||||
)
|
||||
|
||||
val palette: List<Int>
|
||||
get() = colorsByName.keys.map(::argbForName)
|
||||
get() = orderedNames.map(::argbForName)
|
||||
|
||||
fun argbForName(name: String): Int {
|
||||
val opaqueArgb = colorsByName[name.uppercase()] ?: colorsByName.getValue("YELLOW")
|
||||
val opaqueArgb = colorsByName[name.uppercase()] ?: colorsByName.getValue("ORANGE")
|
||||
return (StoredAlpha shl 24) or (opaqueArgb and 0x00FFFFFF)
|
||||
}
|
||||
|
||||
|
|
@ -242,7 +273,7 @@ object SharedPdfAndroidHighlightColors {
|
|||
val dg = ((rgb shr 8) and 0xFF) - ((candidate shr 8) and 0xFF)
|
||||
val db = (rgb and 0xFF) - (candidate and 0xFF)
|
||||
dr * dr + dg * dg + db * db
|
||||
}?.key ?: "YELLOW"
|
||||
}?.key ?: "ORANGE"
|
||||
}
|
||||
|
||||
fun nearestArgb(argb: Int): Int {
|
||||
|
|
|
|||
|
|
@ -186,12 +186,20 @@ data class SharedPdfReaderState(
|
|||
val isTextSelectionMode: Boolean = false,
|
||||
val bookmarks: List<SharedPdfBookmark> = emptyList(),
|
||||
val selectedAnnotationId: String? = null,
|
||||
val annotations: List<SharedPdfAnnotation> = emptyList()
|
||||
val annotations: List<SharedPdfAnnotation> = emptyList(),
|
||||
val toolConfigs: Map<PdfInkTool, PdfToolConfig> = emptyMap(),
|
||||
val penPalette: List<Int> = SharedPdfAnnotationDefaults.penPalette,
|
||||
val lastActivePenTool: PdfInkTool = PdfInkTool.PEN,
|
||||
val lastActiveHighlighterTool: PdfInkTool = PdfInkTool.HIGHLIGHTER,
|
||||
val annotationUndoStack: List<SharedPdfAnnotationHistoryAction> = emptyList(),
|
||||
val annotationRedoStack: List<SharedPdfAnnotationHistoryAction> = emptyList()
|
||||
) {
|
||||
val safePageCount: Int get() = pageCount.coerceAtLeast(0)
|
||||
val lastPageIndex: Int get() = (safePageCount - 1).coerceAtLeast(0)
|
||||
val canGoPrevious: Boolean get() = pageIndex > 0
|
||||
val canGoNext: Boolean get() = pageIndex < lastPageIndex
|
||||
val canUndoAnnotationEdit: Boolean get() = annotationUndoStack.isNotEmpty()
|
||||
val canRedoAnnotationEdit: Boolean get() = annotationRedoStack.isNotEmpty()
|
||||
val progressPercent: Float get() = ((pageIndex + 1).toFloat() / safePageCount.coerceAtLeast(1)) * 100f
|
||||
|
||||
fun coerced(zoomSpec: PdfZoomSpec = PdfZoomSpec()): SharedPdfReaderState {
|
||||
|
|
@ -202,6 +210,10 @@ data class SharedPdfReaderState(
|
|||
activeSearchResultIndex = activeSearchResultIndex.coerceAtLeast(-1),
|
||||
zoom = zoomSpec.clamp(zoom),
|
||||
bookmarks = bookmarks.normalizedBookmarks(lastPageIndex),
|
||||
penPalette = penPalette.sanitizedSharedPdfPenPalette(),
|
||||
lastActivePenTool = lastActivePenTool.takeIf { it.isSharedPdfPenTool } ?: PdfInkTool.PEN,
|
||||
lastActiveHighlighterTool = lastActiveHighlighterTool.takeIf { it.isSharedPdfHighlighterTool }
|
||||
?: PdfInkTool.HIGHLIGHTER,
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { selectedId ->
|
||||
annotations.any { it.id == selectedId }
|
||||
}
|
||||
|
|
@ -225,6 +237,11 @@ data class SharedPdfReaderState(
|
|||
}
|
||||
}
|
||||
|
||||
sealed interface SharedPdfAnnotationHistoryAction {
|
||||
data class Add(val pageIndex: Int, val annotation: SharedPdfAnnotation) : SharedPdfAnnotationHistoryAction
|
||||
data class Remove(val itemsByPage: Map<Int, List<SharedPdfAnnotation>>) : SharedPdfAnnotationHistoryAction
|
||||
}
|
||||
|
||||
sealed interface SharedPdfReaderAction {
|
||||
data class GoToPage(val pageIndex: Int) : SharedPdfReaderAction
|
||||
data object PreviousPage : SharedPdfReaderAction
|
||||
|
|
@ -248,6 +265,7 @@ sealed interface SharedPdfReaderAction {
|
|||
data class ToolSelected(val tool: PdfInkTool) : SharedPdfReaderAction
|
||||
data class ColorSelected(val colorArgb: Int) : SharedPdfReaderAction
|
||||
data class StrokeWidthChanged(val strokeWidth: Float) : SharedPdfReaderAction
|
||||
data class PenPaletteChanged(val colors: List<Int>) : SharedPdfReaderAction
|
||||
data class TextSelectionModeChanged(val enabled: Boolean) : SharedPdfReaderAction
|
||||
data class BookmarksLoaded(val bookmarks: List<SharedPdfBookmark>) : SharedPdfReaderAction
|
||||
data class BookmarkToggled(
|
||||
|
|
@ -262,6 +280,8 @@ sealed interface SharedPdfReaderAction {
|
|||
data class AnnotationDeleted(val annotationId: String) : SharedPdfReaderAction
|
||||
data class AnnotationsChanged(val annotations: List<SharedPdfAnnotation>) : SharedPdfReaderAction
|
||||
data class UndoLastAnnotationOnPage(val pageIndex: Int) : SharedPdfReaderAction
|
||||
data object UndoAnnotationEdit : SharedPdfReaderAction
|
||||
data object RedoAnnotationEdit : SharedPdfReaderAction
|
||||
data class ClearPageAnnotations(val pageIndex: Int) : SharedPdfReaderAction
|
||||
}
|
||||
|
||||
|
|
@ -327,16 +347,23 @@ fun SharedPdfReaderState.reduce(
|
|||
}
|
||||
}
|
||||
is SharedPdfReaderAction.ToolSelected -> {
|
||||
val config = SharedPdfAnnotationDefaults.configFor(action.tool)
|
||||
val config = toolConfigFor(action.tool)
|
||||
copy(
|
||||
selectedTool = action.tool,
|
||||
selectedColorArgb = config.colorArgb,
|
||||
strokeWidth = config.strokeWidth,
|
||||
isTextSelectionMode = false
|
||||
isTextSelectionMode = false,
|
||||
lastActivePenTool = if (action.tool.isSharedPdfPenTool) action.tool else lastActivePenTool,
|
||||
lastActiveHighlighterTool = if (action.tool.isSharedPdfHighlighterTool) {
|
||||
action.tool
|
||||
} else {
|
||||
lastActiveHighlighterTool
|
||||
}
|
||||
)
|
||||
}
|
||||
is SharedPdfReaderAction.ColorSelected -> copy(selectedColorArgb = action.colorArgb)
|
||||
is SharedPdfReaderAction.StrokeWidthChanged -> copy(strokeWidth = action.strokeWidth.coerceAtLeast(0.0001f))
|
||||
is SharedPdfReaderAction.ColorSelected -> withActiveToolColor(action.colorArgb)
|
||||
is SharedPdfReaderAction.StrokeWidthChanged -> withActiveToolStrokeWidth(action.strokeWidth.coerceAtLeast(0.0001f))
|
||||
is SharedPdfReaderAction.PenPaletteChanged -> copy(penPalette = action.colors.sanitizedSharedPdfPenPalette())
|
||||
is SharedPdfReaderAction.TextSelectionModeChanged -> {
|
||||
if (action.enabled) {
|
||||
val config = SharedPdfAnnotationDefaults.configFor(PdfInkTool.NONE)
|
||||
|
|
@ -365,10 +392,19 @@ fun SharedPdfReaderState.reduce(
|
|||
}
|
||||
copy(bookmarks = nextBookmarks.normalizedBookmarks(lastPageIndex))
|
||||
}
|
||||
is SharedPdfReaderAction.AnnotationsLoaded -> copy(annotations = action.annotations.toList())
|
||||
is SharedPdfReaderAction.AnnotationsLoaded -> copy(
|
||||
annotations = action.annotations.toList(),
|
||||
annotationUndoStack = emptyList(),
|
||||
annotationRedoStack = emptyList()
|
||||
)
|
||||
is SharedPdfReaderAction.AnnotationAdded -> copy(
|
||||
annotations = annotations + action.annotation,
|
||||
selectedAnnotationId = action.annotation.id
|
||||
selectedAnnotationId = action.annotation.id,
|
||||
annotationUndoStack = annotationUndoStack + SharedPdfAnnotationHistoryAction.Add(
|
||||
pageIndex = action.annotation.pageIndex,
|
||||
annotation = action.annotation
|
||||
),
|
||||
annotationRedoStack = emptyList()
|
||||
)
|
||||
is SharedPdfReaderAction.AnnotationSelected -> copy(
|
||||
selectedAnnotationId = action.annotationId?.takeIf { id -> annotations.any { it.id == id } }
|
||||
|
|
@ -378,36 +414,158 @@ fun SharedPdfReaderState.reduce(
|
|||
if (index < 0) {
|
||||
this
|
||||
} else {
|
||||
copy(annotations = annotations.toMutableList().also { it[index] = action.annotation })
|
||||
copy(
|
||||
annotations = annotations.toMutableList().also { it[index] = action.annotation },
|
||||
annotationRedoStack = emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
is SharedPdfReaderAction.AnnotationDeleted -> copy(
|
||||
annotations = annotations.filterNot { it.id == action.annotationId },
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it != action.annotationId }
|
||||
is SharedPdfReaderAction.AnnotationDeleted -> {
|
||||
val removed = annotations.firstOrNull { it.id == action.annotationId }
|
||||
if (removed == null) {
|
||||
this
|
||||
} else {
|
||||
copy(
|
||||
annotations = annotations.filterNot { it.id == action.annotationId },
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it != action.annotationId },
|
||||
annotationUndoStack = annotationUndoStack + SharedPdfAnnotationHistoryAction.Remove(
|
||||
itemsByPage = mapOf(removed.pageIndex to listOf(removed))
|
||||
),
|
||||
annotationRedoStack = emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
is SharedPdfReaderAction.AnnotationsChanged -> copy(
|
||||
annotations = action.annotations.toList(),
|
||||
annotationUndoStack = emptyList(),
|
||||
annotationRedoStack = emptyList()
|
||||
)
|
||||
is SharedPdfReaderAction.AnnotationsChanged -> copy(annotations = action.annotations.toList())
|
||||
is SharedPdfReaderAction.UndoLastAnnotationOnPage -> {
|
||||
val index = annotations.indexOfLast { it.pageIndex == action.pageIndex }
|
||||
if (index < 0) {
|
||||
this
|
||||
} else {
|
||||
val removed = annotations[index]
|
||||
val removedId = annotations[index].id
|
||||
copy(
|
||||
annotations = annotations.toMutableList().also { it.removeAt(index) },
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it != removedId }
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it != removedId },
|
||||
annotationUndoStack = annotationUndoStack + SharedPdfAnnotationHistoryAction.Remove(
|
||||
itemsByPage = mapOf(removed.pageIndex to listOf(removed))
|
||||
),
|
||||
annotationRedoStack = emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
SharedPdfReaderAction.UndoAnnotationEdit -> undoSharedPdfAnnotationEdit()
|
||||
SharedPdfReaderAction.RedoAnnotationEdit -> redoSharedPdfAnnotationEdit()
|
||||
is SharedPdfReaderAction.ClearPageAnnotations -> {
|
||||
val removedIds = annotations.filter { it.pageIndex == action.pageIndex }.map { it.id }.toSet()
|
||||
copy(
|
||||
annotations = annotations.filterNot { it.pageIndex == action.pageIndex },
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it !in removedIds }
|
||||
)
|
||||
val removed = annotations.filter { it.pageIndex == action.pageIndex }
|
||||
if (removed.isEmpty()) {
|
||||
this
|
||||
} else {
|
||||
val removedIds = removed.mapTo(mutableSetOf()) { it.id }
|
||||
copy(
|
||||
annotations = annotations.filterNot { it.pageIndex == action.pageIndex },
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it !in removedIds },
|
||||
annotationUndoStack = annotationUndoStack + SharedPdfAnnotationHistoryAction.Remove(
|
||||
itemsByPage = mapOf(action.pageIndex to removed)
|
||||
),
|
||||
annotationRedoStack = emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
}.coerced(zoomSpec)
|
||||
}
|
||||
|
||||
private fun SharedPdfReaderState.toolConfigFor(tool: PdfInkTool): PdfToolConfig {
|
||||
return toolConfigs[tool] ?: SharedPdfAnnotationDefaults.configFor(tool)
|
||||
}
|
||||
|
||||
private fun SharedPdfReaderState.withActiveToolColor(colorArgb: Int): SharedPdfReaderState {
|
||||
if (!selectedTool.isSharedPdfConfigurableTool) {
|
||||
return copy(selectedColorArgb = colorArgb)
|
||||
}
|
||||
val currentConfig = toolConfigFor(selectedTool)
|
||||
return copy(
|
||||
selectedColorArgb = colorArgb,
|
||||
toolConfigs = toolConfigs + (selectedTool to currentConfig.copy(colorArgb = colorArgb))
|
||||
)
|
||||
}
|
||||
|
||||
private fun SharedPdfReaderState.withActiveToolStrokeWidth(strokeWidth: Float): SharedPdfReaderState {
|
||||
if (!selectedTool.isSharedPdfConfigurableTool) {
|
||||
return copy(strokeWidth = strokeWidth)
|
||||
}
|
||||
val currentConfig = toolConfigFor(selectedTool)
|
||||
return copy(
|
||||
strokeWidth = strokeWidth,
|
||||
toolConfigs = toolConfigs + (selectedTool to currentConfig.copy(strokeWidth = strokeWidth))
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<Int>.sanitizedSharedPdfPenPalette(): List<Int> {
|
||||
val defaults = SharedPdfAnnotationDefaults.penPalette
|
||||
val normalized = filter { it != 0 }.take(defaults.size)
|
||||
val filled = if (normalized.isEmpty()) {
|
||||
defaults
|
||||
} else {
|
||||
normalized + defaults.drop(normalized.size)
|
||||
}
|
||||
return filled.take(defaults.size)
|
||||
}
|
||||
|
||||
private val PdfInkTool.isSharedPdfPenTool: Boolean
|
||||
get() = this == PdfInkTool.FOUNTAIN_PEN || this == PdfInkTool.PEN || this == PdfInkTool.PENCIL
|
||||
|
||||
private val PdfInkTool.isSharedPdfHighlighterTool: Boolean
|
||||
get() = this == PdfInkTool.HIGHLIGHTER || this == PdfInkTool.HIGHLIGHTER_ROUND
|
||||
|
||||
private val PdfInkTool.isSharedPdfConfigurableTool: Boolean
|
||||
get() = this != PdfInkTool.NONE
|
||||
|
||||
private fun SharedPdfReaderState.undoSharedPdfAnnotationEdit(): SharedPdfReaderState {
|
||||
val action = annotationUndoStack.lastOrNull() ?: return this
|
||||
val nextUndoStack = annotationUndoStack.dropLast(1)
|
||||
return when (action) {
|
||||
is SharedPdfAnnotationHistoryAction.Add -> copy(
|
||||
annotations = annotations.filterNot { it.id == action.annotation.id },
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it != action.annotation.id },
|
||||
annotationUndoStack = nextUndoStack,
|
||||
annotationRedoStack = annotationRedoStack + action
|
||||
)
|
||||
|
||||
is SharedPdfAnnotationHistoryAction.Remove -> copy(
|
||||
annotations = annotations + action.itemsByPage.values.flatten(),
|
||||
annotationUndoStack = nextUndoStack,
|
||||
annotationRedoStack = annotationRedoStack + action
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SharedPdfReaderState.redoSharedPdfAnnotationEdit(): SharedPdfReaderState {
|
||||
val action = annotationRedoStack.lastOrNull() ?: return this
|
||||
val nextRedoStack = annotationRedoStack.dropLast(1)
|
||||
return when (action) {
|
||||
is SharedPdfAnnotationHistoryAction.Add -> copy(
|
||||
annotations = annotations + action.annotation,
|
||||
selectedAnnotationId = action.annotation.id,
|
||||
annotationUndoStack = annotationUndoStack + action,
|
||||
annotationRedoStack = nextRedoStack
|
||||
)
|
||||
|
||||
is SharedPdfAnnotationHistoryAction.Remove -> {
|
||||
val removedIds = action.itemsByPage.values.flatten().mapTo(mutableSetOf()) { it.id }
|
||||
copy(
|
||||
annotations = annotations.filterNot { it.id in removedIds },
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it !in removedIds },
|
||||
annotationUndoStack = annotationUndoStack + action,
|
||||
annotationRedoStack = nextRedoStack
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object SharedPdfSearchEngine {
|
||||
fun search(
|
||||
pageTexts: List<String>,
|
||||
|
|
|
|||
|
|
@ -59,7 +59,9 @@ object PdfSelectionGeometry {
|
|||
chars: List<PdfTextCharBounds>,
|
||||
lineTolerance: Float = DefaultCharLineTolerance
|
||||
): List<PdfPageBounds> {
|
||||
return chars.groupByLine(lineTolerance).map { it.toCharLineBounds() }
|
||||
return mergeBoundsByLine(
|
||||
bounds = chars.groupByLine(lineTolerance).map { it.toCharLineBounds() }
|
||||
)
|
||||
}
|
||||
|
||||
fun nearestCharOnLine(
|
||||
|
|
|
|||
|
|
@ -36,6 +36,15 @@ object PdfSpreadLayout {
|
|||
return listOf(start, start + 1).filter { it in 0 until pageCount }
|
||||
}
|
||||
|
||||
fun visiblePageIndicesForDisplay(
|
||||
pageIndex: Int,
|
||||
pageCount: Int,
|
||||
settings: ReaderSettings
|
||||
): List<Int> {
|
||||
val indices = visiblePageIndices(pageIndex, pageCount, settings)
|
||||
return if (settings.rightToLeftPagination) indices.asReversed() else indices
|
||||
}
|
||||
|
||||
fun spreadStartPageIndices(
|
||||
pageCount: Int,
|
||||
settings: ReaderSettings
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ package com.aryan.reader.shared.pdf
|
|||
|
||||
import kotlin.math.sqrt
|
||||
|
||||
private const val DEFAULT_PDF_COMMENT_AUTHOR = "Reader"
|
||||
|
||||
data class SharedPdfAnnotationExportPayload(
|
||||
val inkAnnotations: List<SharedPdfInkAnnotationExport> = emptyList(),
|
||||
val highlightAnnotations: List<SharedPdfHighlightAnnotationExport> = emptyList()
|
||||
|
|
@ -208,7 +206,7 @@ private fun List<SharedPdfHighlightCommentExport>.toSingleVisiblePdfCommentThrea
|
|||
SharedPdfHighlightCommentExport(
|
||||
id = "${highlightId}_comments",
|
||||
parentId = null,
|
||||
author = root.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR },
|
||||
author = root.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR },
|
||||
contents = threadContents,
|
||||
createdAt = createdAt,
|
||||
modifiedAt = modifiedAt
|
||||
|
|
@ -228,7 +226,7 @@ private fun List<SharedPdfHighlightCommentExport>.formatAsPdfCommentThread(): St
|
|||
|
||||
if (lines.isNotEmpty()) lines += ""
|
||||
val indent = " ".repeat(depth)
|
||||
val author = comment.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }
|
||||
val author = comment.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }
|
||||
lines += "$indent$author:"
|
||||
comment.contents.lines().forEach { line ->
|
||||
lines += "$indent$line"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import kotlinx.serialization.json.longOrNull
|
|||
|
||||
object SharedPdfAnnotationSidecarCodec {
|
||||
const val KEY_PDF_ANNOTATIONS = "pdfAnnotations"
|
||||
const val KEY_PDF_ANNOTATION_DELETIONS = "pdfAnnotationDeletions"
|
||||
const val KEY_LEGACY_INK = "ink"
|
||||
const val KEY_LEGACY_TEXT_BOXES = "textBoxes"
|
||||
const val KEY_LEGACY_HIGHLIGHTS = "highlights"
|
||||
|
|
@ -38,16 +39,17 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
}
|
||||
|
||||
fun annotationsFromData(data: JsonObject): List<SharedPdfAnnotation> {
|
||||
data[KEY_PDF_ANNOTATIONS]?.let { return decodeAnnotationsElement(it) }
|
||||
val deletedIds = annotationDeletionsFromData(data).keys
|
||||
data[KEY_PDF_ANNOTATIONS]?.let { return decodeAnnotationsElement(it).filterNot { annotation -> annotation.id in deletedIds } }
|
||||
|
||||
data[KEY_LEGACY_INK]?.let { ink ->
|
||||
val decoded = decodeAnnotationsElement(ink)
|
||||
if (decoded.isNotEmpty() || ink.looksLikeSharedAnnotationStore()) {
|
||||
return decoded
|
||||
return decoded.filterNot { annotation -> annotation.id in deletedIds }
|
||||
}
|
||||
}
|
||||
|
||||
return legacyAndroidAnnotationsFromData(data)
|
||||
return legacyAndroidAnnotationsFromData(data).filterNot { annotation -> annotation.id in deletedIds }
|
||||
}
|
||||
|
||||
fun withCanonicalAnnotations(data: JsonObject): JsonObject {
|
||||
|
|
@ -62,6 +64,80 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
return json.encodeToString(JsonElement.serializer(), withCanonicalAnnotations(data))
|
||||
}
|
||||
|
||||
fun mergeAnnotationDataJson(
|
||||
localDataJson: String,
|
||||
remoteDataJson: String,
|
||||
preferRemoteOnConflict: Boolean
|
||||
): String {
|
||||
val localData = parseObjectOrNull(localDataJson)?.sidecarDataObject() ?: JsonObject(emptyMap())
|
||||
val remoteData = parseObjectOrNull(remoteDataJson)?.sidecarDataObject() ?: JsonObject(emptyMap())
|
||||
val localCanonical = withCanonicalAnnotations(localData)
|
||||
val remoteCanonical = withCanonicalAnnotations(remoteData)
|
||||
val localAnnotations = annotationsFromData(localCanonical)
|
||||
val remoteAnnotations = annotationsFromData(remoteCanonical)
|
||||
val mergedDeletions = mergeAnnotationDeletions(
|
||||
annotationDeletionsFromData(localCanonical),
|
||||
annotationDeletionsFromData(remoteCanonical)
|
||||
)
|
||||
val mergedById = linkedMapOf<String, SharedPdfAnnotation>()
|
||||
val first = if (preferRemoteOnConflict) localAnnotations else remoteAnnotations
|
||||
val second = if (preferRemoteOnConflict) remoteAnnotations else localAnnotations
|
||||
first.forEach { annotation ->
|
||||
if (annotation.id !in mergedDeletions) mergedById[annotation.id] = annotation
|
||||
}
|
||||
second.forEach { annotation ->
|
||||
if (annotation.id !in mergedDeletions) mergedById[annotation.id] = annotation
|
||||
}
|
||||
val base = (if (preferRemoteOnConflict) remoteCanonical else localCanonical).toMutableMap()
|
||||
base[KEY_PDF_ANNOTATIONS] = encodeAnnotationsElement(mergedById.values.toList().sortedForSync())
|
||||
if (mergedDeletions.isNotEmpty()) {
|
||||
base[KEY_PDF_ANNOTATION_DELETIONS] = encodeAnnotationDeletionsElement(mergedDeletions)
|
||||
} else {
|
||||
base.remove(KEY_PDF_ANNOTATION_DELETIONS)
|
||||
}
|
||||
return json.encodeToString(JsonElement.serializer(), JsonObject(base))
|
||||
}
|
||||
|
||||
fun annotationCountFromDataJson(rawDataJson: String): Int {
|
||||
val data = parseObjectOrNull(rawDataJson)?.sidecarDataObject() ?: return 0
|
||||
return annotationsFromData(withCanonicalAnnotations(data)).size
|
||||
}
|
||||
|
||||
fun annotationDeletionsFromData(data: JsonObject): Map<String, Long> {
|
||||
return data[KEY_PDF_ANNOTATION_DELETIONS].parseAnnotationDeletions()
|
||||
}
|
||||
|
||||
fun annotationDeletionsFromJson(rawJson: String): Map<String, Long> {
|
||||
val element = runCatching { json.parseToJsonElement(rawJson) }.getOrNull() ?: return emptyMap()
|
||||
return when (element) {
|
||||
is JsonObject -> {
|
||||
val data = element.sidecarDataObject()
|
||||
annotationDeletionsFromData(data).ifEmpty { element.parseAnnotationDeletions() }
|
||||
}
|
||||
else -> element.parseAnnotationDeletions()
|
||||
}
|
||||
}
|
||||
|
||||
fun annotationDeletionsJson(deletions: Map<String, Long>): String {
|
||||
return json.encodeToString(JsonElement.serializer(), encodeAnnotationDeletionsElement(deletions))
|
||||
}
|
||||
|
||||
fun encodeAnnotationDeletionsElement(deletions: Map<String, Long>): JsonElement {
|
||||
return JsonArray(
|
||||
deletions
|
||||
.filterKeys { it.isNotBlank() }
|
||||
.toSortedMap()
|
||||
.map { (id, deletedAt) ->
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"deletedAt" to JsonPrimitive(deletedAt)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun legacyAndroidDataFromAnnotations(
|
||||
annotations: List<SharedPdfAnnotation>,
|
||||
existingData: JsonObject = JsonObject(emptyMap())
|
||||
|
|
@ -283,6 +359,47 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
return runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull()
|
||||
}
|
||||
|
||||
private fun List<SharedPdfAnnotation>.sortedForSync(): List<SharedPdfAnnotation> {
|
||||
return sortedWith(
|
||||
compareBy<SharedPdfAnnotation> { it.pageIndex }
|
||||
.thenBy { it.createdAt.takeIf { timestamp -> timestamp > 0L } ?: Long.MAX_VALUE }
|
||||
.thenBy { it.id }
|
||||
)
|
||||
}
|
||||
|
||||
private fun mergeAnnotationDeletions(
|
||||
local: Map<String, Long>,
|
||||
remote: Map<String, Long>
|
||||
): Map<String, Long> {
|
||||
if (local.isEmpty()) return remote
|
||||
if (remote.isEmpty()) return local
|
||||
return buildMap {
|
||||
(local.keys + remote.keys).forEach { id ->
|
||||
put(id, maxOf(local[id] ?: 0L, remote[id] ?: 0L))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.parseAnnotationDeletions(): Map<String, Long> {
|
||||
val element = this ?: return emptyMap()
|
||||
val array = element.jsonArrayOrNull()
|
||||
?: element.jsonObjectOrNull()?.array(KEY_PDF_ANNOTATION_DELETIONS)
|
||||
?: return emptyMap()
|
||||
return buildMap {
|
||||
array.forEach { item ->
|
||||
val primitiveId = item.jsonPrimitiveOrNull()?.contentOrNull
|
||||
val obj = item.jsonObjectOrNull()
|
||||
val id = primitiveId?.takeIf { it.isNotBlank() } ?: obj?.string("id")
|
||||
if (id.isNullOrBlank()) return@forEach
|
||||
val deletedAt = obj?.long("deletedAt")
|
||||
?: obj?.long("timestamp")
|
||||
?: obj?.long("ts")
|
||||
?: 0L
|
||||
put(id, maxOf(this[id] ?: 0L, deletedAt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stableAnnotationId(prefix: String, element: JsonElement): String {
|
||||
return "${prefix}_${localFolderSyncSha256ShortHex(json.encodeToString(JsonElement.serializer(), element))}"
|
||||
}
|
||||
|
|
@ -314,6 +431,10 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
this[KEY_LEGACY_HIGHLIGHTS] != null
|
||||
}
|
||||
|
||||
private fun JsonObject.sidecarDataObject(): JsonObject {
|
||||
return this["data"]?.jsonObjectOrNull() ?: this
|
||||
}
|
||||
|
||||
private fun JsonElement.jsonArrayOrNull(): JsonArray? {
|
||||
if (this is JsonNull) return null
|
||||
return runCatching { jsonArray }.getOrNull()
|
||||
|
|
@ -324,6 +445,11 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
return runCatching { jsonObject }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonElement.jsonPrimitiveOrNull(): JsonPrimitive? {
|
||||
if (this is JsonNull) return null
|
||||
return runCatching { jsonPrimitive }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.array(name: String): JsonArray? = this[name]?.jsonArrayOrNull()
|
||||
|
||||
private fun JsonObject.objectValue(name: String): JsonObject? = this[name]?.jsonObjectOrNull()
|
||||
|
|
|
|||
|
|
@ -52,6 +52,17 @@ const val SHARED_PDF_PAGE_BREAK_CHAR: Char = '\u000C'
|
|||
private const val SHARED_PDF_ZWSP = "\u200B"
|
||||
private const val SHARED_PDF_RICH_FONT_PATH_TAG = "pdf-rich-font-path"
|
||||
|
||||
internal fun sharedPdfRichTextSelectionBounds(
|
||||
selectionStart: Int,
|
||||
selectionEnd: Int,
|
||||
textLength: Int
|
||||
): Pair<Int, Int>? {
|
||||
val safeLength = textLength.coerceAtLeast(0)
|
||||
val localStart = minOf(selectionStart, selectionEnd).coerceIn(0, safeLength)
|
||||
val localEnd = maxOf(selectionStart, selectionEnd).coerceIn(0, safeLength)
|
||||
return if (localStart < localEnd) localStart to localEnd else null
|
||||
}
|
||||
|
||||
object SharedPdfRichTextLog {
|
||||
var enabled: Boolean = true
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,16 @@ package com.aryan.reader.shared.reader
|
|||
|
||||
import com.aryan.reader.paginatedreader.SemanticBlock
|
||||
import com.aryan.reader.paginatedreader.SemanticFlexContainer
|
||||
import com.aryan.reader.paginatedreader.SemanticImage
|
||||
import com.aryan.reader.paginatedreader.SemanticList
|
||||
import com.aryan.reader.paginatedreader.SemanticMath
|
||||
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.HighlightColor
|
||||
import com.aryan.reader.shared.UserHighlight
|
||||
import com.aryan.reader.shared.toStableReaderPositionCfi
|
||||
|
||||
sealed interface ReaderLinkTarget {
|
||||
data class External(val url: String) : ReaderLinkTarget
|
||||
|
|
@ -103,10 +107,10 @@ class ReaderEngine(
|
|||
highlights: List<UserHighlight> = emptyList()
|
||||
): ReaderSessionState {
|
||||
val pages = pagesFor(book, settings)
|
||||
val requestedInitialIndex = initialLocator
|
||||
val locatorResolvedIndex = initialLocator
|
||||
?.let { pages.findPageIndexForLocator(it) }
|
||||
?.takeIf { it >= 0 }
|
||||
?: initialPageIndex
|
||||
val requestedInitialIndex = locatorResolvedIndex ?: initialPageIndex
|
||||
val initialIndex = ReaderSpreadLayout.normalizePageIndex(requestedInitialIndex, pages.size, settings)
|
||||
val reader = PaginatedReaderState(
|
||||
book = book,
|
||||
|
|
@ -114,7 +118,13 @@ class ReaderEngine(
|
|||
currentPageIndex = initialIndex,
|
||||
settings = settings
|
||||
)
|
||||
return ReaderSessionState(
|
||||
logReaderPositionTrace {
|
||||
"event=engine_create_session_start book=\"${book.title.positionTracePreview(120)}\" " +
|
||||
"mode=${settings.readingMode} pages=${pages.size} initialPage=$initialPageIndex " +
|
||||
"locatorResolved=${locatorResolvedIndex ?: "null"} requested=$requestedInitialIndex normalized=$initialIndex " +
|
||||
"initialLocator=${initialLocator.positionTraceSummary()}"
|
||||
}
|
||||
val session = ReaderSessionState(
|
||||
reader = reader,
|
||||
bookmarks = bookmarks
|
||||
.mapNotNull { it.normalizedForBook(book, pages) }
|
||||
|
|
@ -128,6 +138,13 @@ class ReaderEngine(
|
|||
?.normalizedForResolvedPage(book, pages, requestedInitialIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0)))
|
||||
?: reader.currentPage?.toLocator(book)
|
||||
)
|
||||
logReaderPositionTrace {
|
||||
"event=engine_create_session_done book=\"${book.title.positionTracePreview(120)}\" " +
|
||||
"mode=${settings.readingMode} currentPage=${session.reader.currentPageIndex} " +
|
||||
"visiblePages=${session.reader.visiblePages.map { it.pageIndex }} " +
|
||||
"navigationLocator=${session.navigationLocator.positionTraceSummary()}"
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
fun next(state: ReaderSessionState): ReaderSessionState {
|
||||
|
|
@ -149,10 +166,17 @@ class ReaderEngine(
|
|||
fun goToPage(state: ReaderSessionState, pageIndex: Int): ReaderSessionState {
|
||||
val target = ReaderSpreadLayout.normalizePageIndex(pageIndex, state.reader.pages.size, state.reader.settings)
|
||||
val page = state.reader.pages.getOrNull(target)
|
||||
val locator = page?.let {
|
||||
if (state.reader.settings.readingMode == ReaderReadingMode.VERTICAL) {
|
||||
it.toVerticalScrollPageLocator(state.reader.book)
|
||||
} else {
|
||||
it.toLocator(state.reader.book)
|
||||
}
|
||||
}
|
||||
return state.copy(
|
||||
reader = state.reader.copy(currentPageIndex = target),
|
||||
activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == target },
|
||||
navigationLocator = page?.toLocator(state.reader.book),
|
||||
navigationLocator = locator,
|
||||
navigationRequestId = state.navigationRequestId + 1
|
||||
)
|
||||
}
|
||||
|
|
@ -173,15 +197,14 @@ class ReaderEngine(
|
|||
}
|
||||
|
||||
fun goToLocator(state: ReaderSessionState, locator: ReaderLocator): ReaderSessionState {
|
||||
val requestedPageIndex = state.reader.pages.indexOfFirst { page -> page.contains(locator) }
|
||||
val requestedPageIndex = state.reader.pages.findPageIndexForLocator(locator)
|
||||
.takeIf { it >= 0 }
|
||||
?: locator.pageIndex
|
||||
?.takeIf { it in state.reader.pages.indices }
|
||||
?: return state
|
||||
val pageIndex = ReaderSpreadLayout.normalizePageIndex(requestedPageIndex, state.reader.pages.size, state.reader.settings)
|
||||
val page = state.reader.pages.getOrNull(pageIndex) ?: return state
|
||||
val requestedPage = state.reader.pages.getOrNull(requestedPageIndex) ?: page
|
||||
val requestedChapter = state.reader.book.chapters.getOrNull(requestedPage.chapterIndex)
|
||||
val blockPosition = requestedPage.firstLocatorBlockPosition()
|
||||
val normalizedLocator = locator.copy(pageIndex = requestedPageIndex).withFallbacks(
|
||||
chapterIndex = requestedPage.chapterIndex,
|
||||
chapterId = requestedChapter?.id,
|
||||
|
|
@ -189,6 +212,8 @@ class ReaderEngine(
|
|||
pageIndex = requestedPageIndex,
|
||||
startOffset = requestedPage.startOffset,
|
||||
endOffset = requestedPage.endOffset,
|
||||
blockIndex = blockPosition?.blockIndex,
|
||||
charOffset = blockPosition?.charOffset,
|
||||
textQuote = locator.textQuote ?: requestedPage.text.preview(),
|
||||
cfi = locator.cfi ?: requestedPage.toDesktopCfi()
|
||||
)
|
||||
|
|
@ -345,12 +370,26 @@ class ReaderEngine(
|
|||
fun syncVisiblePage(state: ReaderSessionState, pageIndex: Int, locator: ReaderLocator? = null): ReaderSessionState {
|
||||
val target = ReaderSpreadLayout.normalizePageIndex(pageIndex, state.reader.pages.size, state.reader.settings)
|
||||
val normalizedLocator = locator?.normalizedForPage(state, pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0)))
|
||||
if (target == state.reader.currentPageIndex && normalizedLocator == null) return state
|
||||
return state.copy(
|
||||
if (target == state.reader.currentPageIndex && normalizedLocator == null) {
|
||||
logReaderPositionTrace {
|
||||
"event=engine_sync_visible_skip reason=unchanged_no_locator mode=${state.reader.settings.readingMode} " +
|
||||
"inputPage=$pageIndex target=$target current=${state.reader.currentPageIndex}"
|
||||
}
|
||||
return state
|
||||
}
|
||||
val next = state.copy(
|
||||
reader = state.reader.copy(currentPageIndex = target),
|
||||
activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == pageIndex },
|
||||
navigationLocator = normalizedLocator ?: state.navigationLocator
|
||||
)
|
||||
logReaderPositionTrace {
|
||||
"event=engine_sync_visible_done mode=${state.reader.settings.readingMode} inputPage=$pageIndex " +
|
||||
"target=$target previousPage=${state.reader.currentPageIndex} nextPage=${next.reader.currentPageIndex} " +
|
||||
"inputLocator=${locator.positionTraceSummary()} normalizedLocator=${normalizedLocator.positionTraceSummary()} " +
|
||||
"previousNavigation=${state.navigationLocator.positionTraceSummary()} " +
|
||||
"nextNavigation=${next.navigationLocator.positionTraceSummary()}"
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
fun updateSettings(state: ReaderSessionState, settings: ReaderSettings): ReaderSessionState {
|
||||
|
|
@ -509,13 +548,12 @@ class ReaderEngine(
|
|||
chapterTitle: String? = null,
|
||||
preview: String? = null
|
||||
): ReaderSessionState {
|
||||
val targetPageIndex = state.reader.pages.indexOfFirst { page -> page.contains(locator) }
|
||||
val targetPageIndex = state.reader.pages.findPageIndexForLocator(locator)
|
||||
.takeIf { it >= 0 }
|
||||
?: locator.pageIndex
|
||||
?.takeIf { it in state.reader.pages.indices }
|
||||
?: state.reader.currentPageIndex
|
||||
val page = state.reader.pages.getOrNull(targetPageIndex) ?: return state
|
||||
val chapter = state.reader.book.chapters.getOrNull(page.chapterIndex)
|
||||
val blockPosition = page.firstLocatorBlockPosition()
|
||||
val normalizedLocator = locator.copy(pageIndex = targetPageIndex).withFallbacks(
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
|
|
@ -523,8 +561,12 @@ class ReaderEngine(
|
|||
pageIndex = targetPageIndex,
|
||||
startOffset = page.startOffset,
|
||||
endOffset = page.endOffset,
|
||||
blockIndex = blockPosition?.blockIndex,
|
||||
charOffset = blockPosition?.charOffset,
|
||||
textQuote = preview ?: page.text.preview(),
|
||||
cfi = locator.cfi ?: "desktop:${page.chapterIndex}:${locator.startOffset ?: page.startOffset}:${locator.endOffset ?: locator.startOffset ?: page.startOffset}"
|
||||
cfi = locator.cfi
|
||||
?: blockPosition?.androidStyleCfi()
|
||||
?: "desktop:${page.chapterIndex}:${locator.startOffset ?: page.startOffset}:${locator.endOffset ?: locator.startOffset ?: page.startOffset}"
|
||||
)
|
||||
val existing = state.bookmarks.firstOrNull {
|
||||
it.locator.sameLocation(normalizedLocator) ||
|
||||
|
|
@ -679,12 +721,13 @@ class ReaderEngine(
|
|||
if (state.searchResults.isEmpty()) return state
|
||||
val targetIndex = resultIndex.coerceIn(0, state.searchResults.lastIndex)
|
||||
val result = state.searchResults[targetIndex]
|
||||
val requestedPage = state.reader.pages.indexOfFirst { page -> page.contains(result.locator) }
|
||||
val requestedPage = state.reader.pages.findPageIndexForLocator(result.locator)
|
||||
.takeIf { it >= 0 }
|
||||
?: result.pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0))
|
||||
val targetPage = ReaderSpreadLayout.normalizePageIndex(requestedPage, state.reader.pages.size, state.reader.settings)
|
||||
val page = state.reader.pages.getOrNull(targetPage)
|
||||
val chapter = page?.let { state.reader.book.chapters.getOrNull(it.chapterIndex) }
|
||||
val blockPosition = page?.firstLocatorBlockPosition()
|
||||
return state.copy(
|
||||
reader = state.reader.copy(currentPageIndex = targetPage),
|
||||
activeSearchResultIndex = targetIndex,
|
||||
|
|
@ -692,7 +735,9 @@ class ReaderEngine(
|
|||
chapterIndex = page?.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
href = chapter?.baseHref,
|
||||
pageIndex = requestedPage
|
||||
pageIndex = requestedPage,
|
||||
blockIndex = blockPosition?.blockIndex,
|
||||
charOffset = blockPosition?.charOffset
|
||||
),
|
||||
navigationRequestId = state.navigationRequestId + 1
|
||||
)
|
||||
|
|
@ -731,7 +776,7 @@ private fun ReaderPage.contains(locator: ReaderLocator): Boolean {
|
|||
val start = locator.startOffset ?: return false
|
||||
val end = locator.endOffset ?: start
|
||||
return if (start == end) {
|
||||
start in startOffset..endOffset
|
||||
containsCollapsedOffset(start)
|
||||
} else {
|
||||
start < endOffset && end > startOffset
|
||||
}
|
||||
|
|
@ -740,11 +785,128 @@ private fun ReaderPage.contains(locator: ReaderLocator): Boolean {
|
|||
return targetPage != null && targetPage == pageIndex
|
||||
}
|
||||
|
||||
private fun ReaderPage.containsCollapsedOffset(offset: Int): Boolean {
|
||||
return if (startOffset == endOffset) {
|
||||
offset == startOffset
|
||||
} else {
|
||||
offset >= startOffset && offset < endOffset
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<ReaderPage>.findPageIndexForLocator(locator: ReaderLocator): Int {
|
||||
return indexOfFirst { page -> page.contains(locator) }
|
||||
.takeIf { it >= 0 }
|
||||
?: locator.pageIndex?.takeIf { it in indices }
|
||||
?: -1
|
||||
if (locator.blockIndex != null) {
|
||||
val blockIndex = findPageIndexForBlockLocator(locator)
|
||||
if (blockIndex >= 0) return blockIndex
|
||||
}
|
||||
|
||||
if (locator.hasTextRange) {
|
||||
val textRangeIndex = indexOfFirst { page -> page.containsTextRange(locator) }
|
||||
if (textRangeIndex >= 0) return textRangeIndex
|
||||
|
||||
if (locator.startOffset == locator.endOffset) {
|
||||
val offset = locator.startOffset
|
||||
val targetChapter = locator.chapterIndex
|
||||
val finalBoundaryIndex = indexOfLast { page ->
|
||||
(targetChapter == null || targetChapter == page.chapterIndex) &&
|
||||
page.startOffset < page.endOffset &&
|
||||
page.endOffset == offset
|
||||
}
|
||||
if (finalBoundaryIndex >= 0) return finalBoundaryIndex
|
||||
}
|
||||
}
|
||||
|
||||
return locator.pageIndex?.takeIf { it in indices } ?: -1
|
||||
}
|
||||
|
||||
private fun ReaderPage.containsTextRange(locator: ReaderLocator): Boolean {
|
||||
val targetChapter = locator.chapterIndex
|
||||
if (targetChapter != null && targetChapter != chapterIndex) return false
|
||||
val start = locator.startOffset ?: return false
|
||||
val end = locator.endOffset ?: start
|
||||
return if (start == end) {
|
||||
containsCollapsedOffset(start)
|
||||
} else {
|
||||
start < endOffset && end > startOffset
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<ReaderPage>.findPageIndexForBlockLocator(locator: ReaderLocator): Int {
|
||||
val blockIndex = locator.blockIndex ?: return -1
|
||||
val charOffset = locator.charOffset
|
||||
val targetChapter = locator.chapterIndex
|
||||
var fallbackPageIndex = -1
|
||||
for ((pageIndex, page) in withIndex()) {
|
||||
if (targetChapter != null && page.chapterIndex != targetChapter) continue
|
||||
val blocks = page.semanticBlocks.flattenSemanticBlocks()
|
||||
if (fallbackPageIndex < 0 && blocks.any { it.blockIndex == blockIndex }) {
|
||||
fallbackPageIndex = pageIndex
|
||||
}
|
||||
if (charOffset == null) continue
|
||||
for (block in blocks.filterIsInstance<SemanticTextBlock>()) {
|
||||
if (block.blockIndex != blockIndex) continue
|
||||
val start = block.startCharOffsetInSource
|
||||
val end = start + block.text.length
|
||||
if (charOffset in start until end || (block.text.isEmpty() && charOffset == start)) {
|
||||
return pageIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallbackPageIndex
|
||||
}
|
||||
|
||||
private data class ReaderBlockPosition(
|
||||
val blockIndex: Int,
|
||||
val charOffset: Int,
|
||||
val cfi: String? = null,
|
||||
val localCharOffset: Int = 0
|
||||
) {
|
||||
fun androidStyleCfi(): String? {
|
||||
val base = cfi
|
||||
?.takeIf { it.startsWith("/") }
|
||||
?.substringBefore(':')
|
||||
?: return null
|
||||
return "$base:${localCharOffset.coerceAtLeast(0)}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun ReaderPage.firstLocatorBlockPosition(): ReaderBlockPosition? {
|
||||
val blocks = semanticBlocks.flattenSemanticBlocks()
|
||||
val textBlock = blocks
|
||||
.filterIsInstance<SemanticTextBlock>()
|
||||
.firstOrNull { it.text.isNotBlank() }
|
||||
?: blocks.filterIsInstance<SemanticTextBlock>().firstOrNull()
|
||||
if (textBlock != null) {
|
||||
return ReaderBlockPosition(
|
||||
blockIndex = textBlock.blockIndex,
|
||||
charOffset = textBlock.startCharOffsetInSource,
|
||||
cfi = textBlock.cfi,
|
||||
localCharOffset = 0
|
||||
)
|
||||
}
|
||||
val firstBlock = blocks.firstOrNull() ?: return null
|
||||
return ReaderBlockPosition(
|
||||
blockIndex = firstBlock.blockIndex,
|
||||
charOffset = 0,
|
||||
cfi = firstBlock.cfi,
|
||||
localCharOffset = 0
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<SemanticBlock>.flattenSemanticBlocks(): List<SemanticBlock> {
|
||||
return flatMap { it.flattenSemanticBlock() }
|
||||
}
|
||||
|
||||
private fun SemanticBlock.flattenSemanticBlock(): List<SemanticBlock> {
|
||||
return when (this) {
|
||||
is SemanticList -> listOf(this) + items
|
||||
is SemanticTable -> listOf(this) + rows.flatMap { row -> row.flatMap { cell -> cell.content.flattenSemanticBlocks() } }
|
||||
is SemanticFlexContainer -> listOf(this) + children.flattenSemanticBlocks()
|
||||
is SemanticWrappingBlock -> listOf(this, floatedImage) + paragraphsToWrap
|
||||
is SemanticImage,
|
||||
is SemanticMath,
|
||||
is SemanticSpacer,
|
||||
is SemanticTextBlock -> listOf(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ReaderLocator.normalizedForResolvedPage(
|
||||
|
|
@ -756,6 +918,7 @@ private fun ReaderLocator.normalizedForResolvedPage(
|
|||
val chapter = book.chapters.getOrNull(page.chapterIndex)
|
||||
val start = startOffset ?: page.startOffset
|
||||
val end = (endOffset ?: start).coerceAtLeast(start)
|
||||
val blockPosition = page.firstLocatorBlockPosition()
|
||||
return copy(pageIndex = page.pageIndex).withFallbacks(
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
|
|
@ -763,18 +926,25 @@ private fun ReaderLocator.normalizedForResolvedPage(
|
|||
pageIndex = page.pageIndex,
|
||||
startOffset = start,
|
||||
endOffset = end,
|
||||
blockIndex = blockPosition?.blockIndex,
|
||||
charOffset = blockPosition?.charOffset,
|
||||
textQuote = textQuote ?: page.text.preview(),
|
||||
cfi = cfi ?: "desktop:${page.chapterIndex}:$start:$end"
|
||||
cfi = cfi
|
||||
?.toStableReaderPositionCfi()
|
||||
?.takeUnless { it.startsWith("desktop-scroll:") || it.startsWith("desktop-scroll-page:") }
|
||||
?: blockPosition?.androidStyleCfi()
|
||||
?: "desktop:${page.chapterIndex}:$start:$end"
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderBookmark.normalizedForBook(book: SharedEpubBook, pages: List<ReaderPage>): ReaderBookmark? {
|
||||
val targetPageIndex = pages.indexOfFirst { page -> page.contains(locator) }
|
||||
val targetPageIndex = pages.findPageIndexForLocator(locator)
|
||||
.takeIf { it >= 0 }
|
||||
?: pageIndex.takeIf { it in pages.indices }
|
||||
?: return null
|
||||
val page = pages.getOrNull(targetPageIndex) ?: return null
|
||||
val chapter = book.chapters.getOrNull(page.chapterIndex)
|
||||
val blockPosition = page.firstLocatorBlockPosition()
|
||||
val normalizedLocator = locator.copy(pageIndex = targetPageIndex).withFallbacks(
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
|
|
@ -782,8 +952,10 @@ private fun ReaderBookmark.normalizedForBook(book: SharedEpubBook, pages: List<R
|
|||
pageIndex = targetPageIndex,
|
||||
startOffset = page.startOffset,
|
||||
endOffset = page.endOffset,
|
||||
blockIndex = blockPosition?.blockIndex,
|
||||
charOffset = blockPosition?.charOffset,
|
||||
textQuote = preview.ifBlank { page.text.preview() },
|
||||
cfi = locator.cfi ?: page.toDesktopCfi()
|
||||
cfi = locator.cfi ?: blockPosition?.androidStyleCfi() ?: page.toDesktopCfi()
|
||||
)
|
||||
return copy(
|
||||
pageIndex = targetPageIndex,
|
||||
|
|
@ -800,6 +972,8 @@ private fun ReaderBookmark.locationKey(): String {
|
|||
locator.pageIndex,
|
||||
locator.startOffset,
|
||||
locator.endOffset,
|
||||
locator.blockIndex,
|
||||
locator.charOffset,
|
||||
locator.cfi
|
||||
).joinToString(":")
|
||||
}
|
||||
|
|
@ -808,7 +982,9 @@ private fun bookmarkId(bookId: String, pageIndex: Int, locator: ReaderLocator):
|
|||
val chapter = locator.chapterIndex ?: -1
|
||||
val start = locator.startOffset ?: -1
|
||||
val end = locator.endOffset ?: start
|
||||
return "${bookId}_${pageIndex}_${chapter}_${start}_${end}"
|
||||
val block = locator.blockIndex ?: -1
|
||||
val char = locator.charOffset ?: -1
|
||||
return "${bookId}_${pageIndex}_${chapter}_${start}_${end}_${block}_${char}"
|
||||
}
|
||||
|
||||
private fun ReaderLocator.belongsTo(page: ReaderPage): Boolean {
|
||||
|
|
@ -832,6 +1008,7 @@ private fun ReaderLocator.normalizedForPage(state: ReaderSessionState, pageIndex
|
|||
val chapter = state.reader.book.chapters.getOrNull(page.chapterIndex)
|
||||
val start = startOffset ?: page.startOffset
|
||||
val end = (endOffset ?: start).coerceAtLeast(start)
|
||||
val blockPosition = page.firstLocatorBlockPosition()
|
||||
return copy(pageIndex = page.pageIndex).withFallbacks(
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
|
|
@ -839,13 +1016,20 @@ private fun ReaderLocator.normalizedForPage(state: ReaderSessionState, pageIndex
|
|||
pageIndex = page.pageIndex,
|
||||
startOffset = start,
|
||||
endOffset = end,
|
||||
blockIndex = blockPosition?.blockIndex,
|
||||
charOffset = blockPosition?.charOffset,
|
||||
textQuote = textQuote ?: page.text.preview(),
|
||||
cfi = cfi ?: "desktop:${page.chapterIndex}:$start:$end"
|
||||
cfi = cfi
|
||||
?.toStableReaderPositionCfi()
|
||||
?.takeUnless { it.startsWith("desktop-scroll:") || it.startsWith("desktop-scroll-page:") }
|
||||
?: blockPosition?.androidStyleCfi()
|
||||
?: "desktop:${page.chapterIndex}:$start:$end"
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderPage.toLocator(book: SharedEpubBook): ReaderLocator {
|
||||
val chapter = book.chapters.getOrNull(chapterIndex)
|
||||
val blockPosition = firstLocatorBlockPosition()
|
||||
return ReaderLocator(
|
||||
chapterIndex = chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
|
|
@ -853,11 +1037,17 @@ private fun ReaderPage.toLocator(book: SharedEpubBook): ReaderLocator {
|
|||
pageIndex = pageIndex,
|
||||
startOffset = startOffset,
|
||||
endOffset = endOffset,
|
||||
blockIndex = blockPosition?.blockIndex,
|
||||
charOffset = blockPosition?.charOffset,
|
||||
textQuote = text.preview(),
|
||||
cfi = toDesktopCfi()
|
||||
cfi = blockPosition?.androidStyleCfi() ?: toDesktopCfi()
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderPage.toVerticalScrollPageLocator(book: SharedEpubBook): ReaderLocator {
|
||||
return toLocator(book).copy(cfi = "desktop-scroll-page:$pageIndex")
|
||||
}
|
||||
|
||||
private fun ReaderPage.toDesktopCfi(): String {
|
||||
return "desktop:$chapterIndex:$startOffset:$endOffset"
|
||||
}
|
||||
|
|
@ -1001,3 +1191,27 @@ private fun Char?.isWordChar(): Boolean {
|
|||
private fun logReaderLink(message: String) {
|
||||
logSharedReaderDiagnostic("ReaderLinkResolve") { message }
|
||||
}
|
||||
|
||||
private const val ReaderPositionTraceLogTag = "EpistemeDesktopPositionTrace"
|
||||
|
||||
private fun logReaderPositionTrace(message: () -> String) {
|
||||
logSharedReaderDiagnostic(ReaderPositionTraceLogTag, message)
|
||||
}
|
||||
|
||||
private fun ReaderLocator?.positionTraceSummary(maxTextLength: Int = 90): String {
|
||||
if (this == null) return "null"
|
||||
return "chapter=${chapterIndex ?: "null"} page=${pageIndex ?: "null"} " +
|
||||
"offsets=${startOffset ?: "null"}..${endOffset ?: "null"} " +
|
||||
"block=${blockIndex ?: "null"} char=${charOffset ?: "null"} " +
|
||||
"chapterId=\"${chapterId.orEmpty().positionTracePreview(80)}\" " +
|
||||
"href=\"${href.orEmpty().positionTracePreview(120)}\" " +
|
||||
"cfi=\"${cfi.orEmpty().positionTracePreview(180)}\" " +
|
||||
"text=\"${textQuote.orEmpty().positionTracePreview(maxTextLength)}\""
|
||||
}
|
||||
|
||||
private fun String.positionTracePreview(maxLength: Int = 96): String {
|
||||
return replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
.let { if (it.length <= maxLength) it else it.take(maxLength) + "..." }
|
||||
.replace("\"", "\\\"")
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -110,6 +110,8 @@ private fun ReaderLocator.jumpLocationKey(): String {
|
|||
href.orEmpty(),
|
||||
startOffset?.toString().orEmpty(),
|
||||
endOffset?.toString().orEmpty(),
|
||||
blockIndex?.toString().orEmpty(),
|
||||
charOffset?.toString().orEmpty(),
|
||||
stableCfi
|
||||
).joinToString("|")
|
||||
}
|
||||
|
|
@ -120,6 +122,8 @@ private fun ReaderLocator.jumpLocationKey(): String {
|
|||
pageIndex?.toString().orEmpty(),
|
||||
startOffset?.toString().orEmpty(),
|
||||
endOffset?.toString().orEmpty(),
|
||||
blockIndex?.toString().orEmpty(),
|
||||
charOffset?.toString().orEmpty(),
|
||||
cfi.orEmpty()
|
||||
).joinToString("|")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ data class ReaderSettings(
|
|||
val pageInfoMode: PageInfoMode = PageInfoMode.DEFAULT,
|
||||
val pageInfoPosition: PageInfoPosition = PageInfoPosition.BOTTOM,
|
||||
val pageSpreadMode: ReaderPageSpreadMode = ReaderPageSpreadMode.SINGLE,
|
||||
val rightToLeftPagination: Boolean = false,
|
||||
val pdfVerticalPageGapVisible: Boolean = true,
|
||||
val pdfPageNumberOverlayVisible: Boolean = true,
|
||||
val pdfFirstPageStandaloneInSpread: Boolean = false,
|
||||
|
|
@ -171,7 +172,7 @@ data class PaginatedReaderState(
|
|||
val canGoNext: Boolean get() = ReaderSpreadLayout.canGoNext(currentPageIndex, pages.size, settings)
|
||||
val currentSpreadStartIndex: Int get() = ReaderSpreadLayout.normalizePageIndex(currentPageIndex, pages.size, settings)
|
||||
val visiblePages: List<ReaderPage>
|
||||
get() = ReaderSpreadLayout.visiblePageIndices(currentPageIndex, pages.size, settings)
|
||||
get() = ReaderSpreadLayout.visiblePageIndicesForDisplay(currentPageIndex, pages.size, settings)
|
||||
.mapNotNull { pages.getOrNull(it) }
|
||||
}
|
||||
|
||||
|
|
@ -211,6 +212,11 @@ object ReaderSpreadLayout {
|
|||
return listOf(start, start + 1).filter { it in 0 until pageCount }
|
||||
}
|
||||
|
||||
fun visiblePageIndicesForDisplay(pageIndex: Int, pageCount: Int, settings: ReaderSettings): List<Int> {
|
||||
val indices = visiblePageIndices(pageIndex, pageCount, settings)
|
||||
return if (settings.isRightToLeftPaginationEnabled()) indices.asReversed() else indices
|
||||
}
|
||||
|
||||
fun pageRangeLabel(pageIndex: Int, pageCount: Int, settings: ReaderSettings): String {
|
||||
val total = pageCount.coerceAtLeast(1)
|
||||
val pages = visiblePageIndices(pageIndex, total, settings).ifEmpty { listOf(0) }
|
||||
|
|
@ -252,3 +258,7 @@ object ReaderSpreadLayout {
|
|||
fun ReaderSettings.isTwoPageSpreadEnabled(): Boolean {
|
||||
return readingMode == ReaderReadingMode.PAGINATED && pageSpreadMode == ReaderPageSpreadMode.TWO_PAGE
|
||||
}
|
||||
|
||||
fun ReaderSettings.isRightToLeftPaginationEnabled(): Boolean {
|
||||
return readingMode == ReaderReadingMode.PAGINATED && rightToLeftPagination
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ package com.aryan.reader.shared.reader
|
|||
|
||||
internal const val SharedReaderDiagnosticsProperty = "episteme.desktop.diagnostics"
|
||||
internal const val SharedReaderDiagnosticsTagsProperty = "episteme.desktop.diagnostics.tags"
|
||||
internal const val SharedEpubCutoffDiagnosticsTag = "EpistemeEpubCutoff"
|
||||
|
||||
internal expect val SharedReaderDiagnosticsEnabled: Boolean
|
||||
internal expect fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean
|
||||
internal expect fun writeSharedReaderDiagnostic(tag: String, message: String)
|
||||
|
||||
internal inline fun logSharedReaderDiagnostic(tag: String, message: () -> String) {
|
||||
if (SharedReaderDiagnosticsEnabled && isSharedReaderDiagnosticTagEnabled(tag)) {
|
||||
println("$tag ${message()}")
|
||||
writeSharedReaderDiagnostic(tag, message())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,13 +28,33 @@ enum class SharedAppToolAction {
|
|||
TABS_TOGGLE
|
||||
}
|
||||
|
||||
enum class SharedAppMoreGroup {
|
||||
LIBRARY,
|
||||
ACCOUNT,
|
||||
PREFERENCES,
|
||||
HELP
|
||||
}
|
||||
|
||||
data class SharedAppMoreSection(
|
||||
val group: SharedAppMoreGroup,
|
||||
val actions: List<SharedAppToolAction>
|
||||
)
|
||||
|
||||
data class SharedAppShellModel(
|
||||
val primaryTabs: List<SharedAppTab>,
|
||||
val primaryActions: List<SharedAppToolAction>,
|
||||
val selectedPrimaryTab: SharedAppTab,
|
||||
val toolActions: List<SharedAppToolAction>,
|
||||
val moreSections: List<SharedAppMoreSection>,
|
||||
val showPrimaryNavigation: Boolean
|
||||
)
|
||||
|
||||
data class SharedSidebarSyncToggleModel(
|
||||
val visible: Boolean,
|
||||
val enabled: Boolean,
|
||||
val checked: Boolean
|
||||
)
|
||||
|
||||
fun sharedAppShellModel(
|
||||
selectedTab: SharedAppTab,
|
||||
aiSettingsAvailable: Boolean,
|
||||
|
|
@ -43,12 +63,16 @@ fun sharedAppShellModel(
|
|||
val primaryTabs = buildList {
|
||||
add(SharedAppTab.LIBRARY)
|
||||
if (featurePolicy.opdsCatalogs) add(SharedAppTab.CATALOGS)
|
||||
if (featurePolicy.aiAndCloud) add(SharedAppTab.PRO)
|
||||
}
|
||||
val primaryActions = buildList {
|
||||
if (aiSettingsAvailable && featurePolicy.aiAndCloud && !featurePolicy.byokAi) {
|
||||
add(SharedAppToolAction.AI_SETTINGS)
|
||||
}
|
||||
}
|
||||
val selectedPrimaryTab = when (selectedTab) {
|
||||
SharedAppTab.HOME -> SharedAppTab.LIBRARY
|
||||
SharedAppTab.SHELVES -> SharedAppTab.LIBRARY
|
||||
SharedAppTab.SETTINGS,
|
||||
SharedAppTab.PRO,
|
||||
SharedAppTab.CUSTOM_FONTS,
|
||||
SharedAppTab.SUPPORT,
|
||||
SharedAppTab.FEEDBACK,
|
||||
|
|
@ -57,11 +81,7 @@ fun sharedAppShellModel(
|
|||
}.takeIf { it in primaryTabs } ?: SharedAppTab.LIBRARY
|
||||
val toolActions = buildList {
|
||||
add(SharedAppToolAction.SETTINGS)
|
||||
add(SharedAppToolAction.IMPORT_FILES)
|
||||
add(SharedAppToolAction.IMPORT_FOLDER)
|
||||
add(SharedAppToolAction.SYNC)
|
||||
add(SharedAppToolAction.APP_THEME)
|
||||
if (featurePolicy.aiAndCloud) add(SharedAppToolAction.PRO)
|
||||
if (aiSettingsAvailable && featurePolicy.aiAndCloud) add(SharedAppToolAction.AI_SETTINGS)
|
||||
add(SharedAppToolAction.CUSTOM_FONTS)
|
||||
if (featurePolicy.projectLinks) {
|
||||
|
|
@ -69,16 +89,58 @@ fun sharedAppShellModel(
|
|||
add(SharedAppToolAction.SUPPORT)
|
||||
}
|
||||
add(SharedAppToolAction.ABOUT)
|
||||
add(SharedAppToolAction.TABS_TOGGLE)
|
||||
}
|
||||
return SharedAppShellModel(
|
||||
primaryTabs = primaryTabs,
|
||||
primaryActions = primaryActions,
|
||||
selectedPrimaryTab = selectedPrimaryTab,
|
||||
toolActions = toolActions,
|
||||
moreSections = sharedAppMoreSections(toolActions),
|
||||
showPrimaryNavigation = selectedTab != SharedAppTab.READER
|
||||
)
|
||||
}
|
||||
|
||||
fun sharedAppMoreSections(actions: List<SharedAppToolAction>): List<SharedAppMoreSection> {
|
||||
return listOf(
|
||||
SharedAppMoreSection(
|
||||
group = SharedAppMoreGroup.PREFERENCES,
|
||||
actions = actions.filter {
|
||||
it == SharedAppToolAction.SETTINGS ||
|
||||
it == SharedAppToolAction.APP_THEME ||
|
||||
it == SharedAppToolAction.AI_SETTINGS ||
|
||||
it == SharedAppToolAction.CUSTOM_FONTS
|
||||
}
|
||||
),
|
||||
SharedAppMoreSection(
|
||||
group = SharedAppMoreGroup.HELP,
|
||||
actions = actions.filter {
|
||||
it == SharedAppToolAction.HELP_FEEDBACK ||
|
||||
it == SharedAppToolAction.SUPPORT ||
|
||||
it == SharedAppToolAction.ABOUT
|
||||
}
|
||||
)
|
||||
).filter { it.actions.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun sharedSidebarSyncToggleModel(
|
||||
isSignedIn: Boolean,
|
||||
accountAvailable: Boolean,
|
||||
syncAvailable: Boolean,
|
||||
isProUser: Boolean,
|
||||
isSyncEnabled: Boolean,
|
||||
featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard
|
||||
): SharedSidebarSyncToggleModel {
|
||||
val visible = isSignedIn &&
|
||||
accountAvailable &&
|
||||
syncAvailable &&
|
||||
featurePolicy.aiAndCloud
|
||||
return SharedSidebarSyncToggleModel(
|
||||
visible = visible,
|
||||
enabled = visible && isProUser,
|
||||
checked = visible && isSyncEnabled
|
||||
)
|
||||
}
|
||||
|
||||
data class NonReaderHomeLayoutModel(
|
||||
val continueBook: BookItem?,
|
||||
val activeTabs: List<BookItem>,
|
||||
|
|
@ -153,7 +215,7 @@ private val LibraryFileTypeGroupTemplates = listOf(
|
|||
NonReaderLibraryFileTypeGroup(
|
||||
titleKey = "desktop_file_type_group_comics",
|
||||
titleFallback = "Comics",
|
||||
fileTypes = listOf(FileType.CBZ, FileType.CBR, FileType.CB7)
|
||||
fileTypes = listOf(FileType.CBZ, FileType.CBR, FileType.CB7, FileType.CBT)
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -166,9 +228,20 @@ private val AndroidLibraryTabs = listOf(
|
|||
private val DesktopLibraryTabs = listOf(
|
||||
NonReaderLibraryTab.BOOKS,
|
||||
NonReaderLibraryTab.SHELVES,
|
||||
NonReaderLibraryTab.FOLDERS
|
||||
NonReaderLibraryTab.FOLDERS,
|
||||
NonReaderLibraryTab.UNREAD,
|
||||
NonReaderLibraryTab.IN_PROGRESS,
|
||||
NonReaderLibraryTab.COMPLETED
|
||||
)
|
||||
|
||||
internal enum class NonReaderLibraryPrimaryAction {
|
||||
NEW_SHELF
|
||||
}
|
||||
|
||||
internal enum class NonReaderBookOverflowAction {
|
||||
ADD_TO_SHELF
|
||||
}
|
||||
|
||||
internal fun visibleNonReaderLibraryTabs(
|
||||
platform: ReaderPlatform = ReaderPlatform.ANDROID
|
||||
): List<NonReaderLibraryTab> {
|
||||
|
|
@ -178,6 +251,42 @@ internal fun visibleNonReaderLibraryTabs(
|
|||
}
|
||||
}
|
||||
|
||||
internal fun primaryLibraryActionsForTab(
|
||||
tab: NonReaderLibraryTab,
|
||||
platform: ReaderPlatform = ReaderPlatform.ANDROID
|
||||
): List<NonReaderLibraryPrimaryAction> {
|
||||
return if (platform == ReaderPlatform.DESKTOP && tab.visibleLibraryTab(platform) == NonReaderLibraryTab.SHELVES) {
|
||||
listOf(NonReaderLibraryPrimaryAction.NEW_SHELF)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun bookOverflowActionsForPlatform(
|
||||
platform: ReaderPlatform = ReaderPlatform.ANDROID
|
||||
): Set<NonReaderBookOverflowAction> {
|
||||
return when (platform) {
|
||||
ReaderPlatform.DESKTOP -> setOf(NonReaderBookOverflowAction.ADD_TO_SHELF)
|
||||
ReaderPlatform.ANDROID -> emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class LibraryCommandBarLayout {
|
||||
INLINE,
|
||||
STACKED
|
||||
}
|
||||
|
||||
internal fun libraryCommandBarLayoutForWidth(
|
||||
widthDp: Float,
|
||||
platform: ReaderPlatform = ReaderPlatform.DESKTOP
|
||||
): LibraryCommandBarLayout {
|
||||
return if (platform == ReaderPlatform.DESKTOP && widthDp >= 980f) {
|
||||
LibraryCommandBarLayout.INLINE
|
||||
} else {
|
||||
LibraryCommandBarLayout.STACKED
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NonReaderLibraryTab.visibleLibraryTab(
|
||||
platform: ReaderPlatform = ReaderPlatform.ANDROID
|
||||
): NonReaderLibraryTab {
|
||||
|
|
@ -221,7 +330,7 @@ internal fun nonReaderLibraryFileTypeGroups(
|
|||
}
|
||||
|
||||
fun SharedReaderScreenState.toNonReaderLibraryOrganizationModel(): NonReaderLibraryOrganizationModel {
|
||||
val books = rawLibraryBooks
|
||||
val books = organizationBooks()
|
||||
val rootFolderCount = shelves.count { it.type == ShelfType.FOLDER && it.parentShelfId == null }
|
||||
val tagIds = (allTags.map { it.id } + books.flatMap { book -> book.tags.map { it.id } }).toSet()
|
||||
return NonReaderLibraryOrganizationModel(
|
||||
|
|
@ -244,6 +353,13 @@ fun SharedReaderScreenState.toNonReaderLibraryOrganizationModel(): NonReaderLibr
|
|||
)
|
||||
}
|
||||
|
||||
private fun SharedReaderScreenState.organizationBooks(): List<BookItem> {
|
||||
if (shelves.isEmpty()) return rawLibraryBooks
|
||||
return shelves
|
||||
.flatMap { it.books }
|
||||
.distinctBy { it.id }
|
||||
}
|
||||
|
||||
private fun LibraryFilters.activeFilterCount(): Int {
|
||||
return fileTypes.size +
|
||||
sourceFolders.size +
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ import androidx.compose.foundation.layout.Box
|
|||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
|
|
@ -178,7 +176,18 @@ fun SharedHomeScreen(
|
|||
showActiveTabs: Boolean = true,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val model = state.toNonReaderHomeLayoutModel()
|
||||
val model = remember(
|
||||
state.recentBooks,
|
||||
state.openTabs,
|
||||
state.openTabIds,
|
||||
state.activeTabBookId,
|
||||
state.isTabsEnabled,
|
||||
state.pinnedHomeBookIds,
|
||||
state.selectedBookIds,
|
||||
state.rawLibraryBooks
|
||||
) {
|
||||
state.toNonReaderHomeLayoutModel()
|
||||
}
|
||||
NonReaderScreenScaffold(
|
||||
title = readerString("nav_home", "Home"),
|
||||
subtitle = readerString("desktop_home_subtitle", "Continue reading and recent books"),
|
||||
|
|
@ -323,12 +332,15 @@ fun SharedLibraryScreen(
|
|||
onShowBookInfo: (BookItem) -> Unit = {},
|
||||
onEditBook: (BookItem) -> Unit = {},
|
||||
onCreateShelf: () -> Unit = {},
|
||||
onCreateShelfWithBooks: (String, Set<String>) -> Unit = { _, _ -> },
|
||||
onCreateSmartShelf: () -> Unit = {},
|
||||
onRenameShelf: (Shelf) -> Unit = {},
|
||||
onDeleteShelf: (Shelf) -> Unit = {},
|
||||
onRemoveFolder: (Shelf) -> Unit = {},
|
||||
onTagSelectedBooks: () -> Unit = {},
|
||||
onAddSelectedBooksToShelf: () -> Unit = {},
|
||||
onAddBooksToShelf: (Set<String>) -> Unit = {},
|
||||
onManageShelfBooks: ((Shelf) -> Unit)? = null,
|
||||
onImportFolder: () -> Unit = {},
|
||||
onSyncFolderMetadata: () -> Unit = {},
|
||||
onScanFolders: () -> Unit = {},
|
||||
|
|
@ -337,7 +349,15 @@ fun SharedLibraryScreen(
|
|||
useImportEmptyStateWhenLibraryEmpty: Boolean = false,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val organization = state.toNonReaderLibraryOrganizationModel()
|
||||
val organization = remember(
|
||||
state.rawLibraryBooks,
|
||||
state.shelves,
|
||||
state.allTags,
|
||||
state.syncedFolders,
|
||||
state.libraryFilters
|
||||
) {
|
||||
state.toNonReaderLibraryOrganizationModel()
|
||||
}
|
||||
val activeLibraryTab = selectedTab.visibleLibraryTab(platform)
|
||||
var showFilters by remember { mutableStateOf(false) }
|
||||
var viewMode by remember { mutableStateOf(BookViewMode.COVERS) }
|
||||
|
|
@ -399,8 +419,10 @@ fun SharedLibraryScreen(
|
|||
Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
LibraryToolbar(
|
||||
state = state,
|
||||
selectedTab = activeLibraryTab,
|
||||
viewMode = viewMode,
|
||||
showFilters = showFilters,
|
||||
platform = platform,
|
||||
onViewModeChange = { viewMode = it },
|
||||
onToggleFilters = { showFilters = !showFilters },
|
||||
onStateChange = onStateChange,
|
||||
|
|
@ -419,11 +441,14 @@ fun SharedLibraryScreen(
|
|||
onImportBooks = onImportBooks,
|
||||
onImportFolder = onImportFolder,
|
||||
useImportEmptyStateWhenLibraryEmpty = useImportEmptyStateWhenLibraryEmpty,
|
||||
onCreateShelf = onCreateShelf,
|
||||
onOpenBook = onOpenBook,
|
||||
onToggleSelection = onToggleSelection,
|
||||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddBooksToShelf = onAddBooksToShelf,
|
||||
onManageShelfBooks = onManageShelfBooks,
|
||||
onRenameShelf = onRenameShelf,
|
||||
onDeleteShelf = onDeleteShelf,
|
||||
onRemoveFolder = onRemoveFolder,
|
||||
|
|
@ -443,8 +468,10 @@ fun SharedLibraryScreen(
|
|||
)
|
||||
LibraryToolbar(
|
||||
state = state,
|
||||
selectedTab = activeLibraryTab,
|
||||
viewMode = viewMode,
|
||||
showFilters = showFilters,
|
||||
platform = platform,
|
||||
onViewModeChange = { viewMode = it },
|
||||
onToggleFilters = { showFilters = !showFilters },
|
||||
onStateChange = onStateChange,
|
||||
|
|
@ -463,11 +490,14 @@ fun SharedLibraryScreen(
|
|||
onImportBooks = onImportBooks,
|
||||
onImportFolder = onImportFolder,
|
||||
useImportEmptyStateWhenLibraryEmpty = useImportEmptyStateWhenLibraryEmpty,
|
||||
onCreateShelf = onCreateShelf,
|
||||
onOpenBook = onOpenBook,
|
||||
onToggleSelection = onToggleSelection,
|
||||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddBooksToShelf = onAddBooksToShelf,
|
||||
onManageShelfBooks = onManageShelfBooks,
|
||||
onRenameShelf = onRenameShelf,
|
||||
onDeleteShelf = onDeleteShelf,
|
||||
onRemoveFolder = onRemoveFolder,
|
||||
|
|
@ -904,8 +934,10 @@ private fun LibraryNavItem(
|
|||
@Composable
|
||||
private fun LibraryToolbar(
|
||||
state: SharedReaderScreenState,
|
||||
selectedTab: NonReaderLibraryTab,
|
||||
viewMode: BookViewMode,
|
||||
showFilters: Boolean,
|
||||
platform: ReaderPlatform,
|
||||
onViewModeChange: (BookViewMode) -> Unit,
|
||||
onToggleFilters: () -> Unit,
|
||||
onStateChange: (SharedReaderScreenState) -> Unit,
|
||||
|
|
@ -913,13 +945,129 @@ private fun LibraryToolbar(
|
|||
onImportFolder: () -> Unit,
|
||||
onCreateShelf: () -> Unit
|
||||
) {
|
||||
BoxWithConstraints {
|
||||
val layout = libraryCommandBarLayoutForWidth(maxWidth.value, platform)
|
||||
if (layout == LibraryCommandBarLayout.INLINE) {
|
||||
DesktopLibraryCommandBar(
|
||||
state = state,
|
||||
selectedTab = selectedTab,
|
||||
viewMode = viewMode,
|
||||
showFilters = showFilters,
|
||||
platform = platform,
|
||||
onViewModeChange = onViewModeChange,
|
||||
onToggleFilters = onToggleFilters,
|
||||
onStateChange = onStateChange,
|
||||
onImportBooks = onImportBooks,
|
||||
onImportFolder = onImportFolder,
|
||||
onCreateShelf = onCreateShelf
|
||||
)
|
||||
} else {
|
||||
StackedLibraryCommandBar(
|
||||
state = state,
|
||||
selectedTab = selectedTab,
|
||||
viewMode = viewMode,
|
||||
showFilters = showFilters,
|
||||
platform = platform,
|
||||
onViewModeChange = onViewModeChange,
|
||||
onToggleFilters = onToggleFilters,
|
||||
onStateChange = onStateChange,
|
||||
onImportBooks = onImportBooks,
|
||||
onImportFolder = onImportFolder,
|
||||
onCreateShelf = onCreateShelf
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopLibraryCommandBar(
|
||||
state: SharedReaderScreenState,
|
||||
selectedTab: NonReaderLibraryTab,
|
||||
viewMode: BookViewMode,
|
||||
showFilters: Boolean,
|
||||
platform: ReaderPlatform,
|
||||
onViewModeChange: (BookViewMode) -> Unit,
|
||||
onToggleFilters: () -> Unit,
|
||||
onStateChange: (SharedReaderScreenState) -> Unit,
|
||||
onImportBooks: () -> Unit,
|
||||
onImportFolder: () -> Unit,
|
||||
onCreateShelf: () -> Unit
|
||||
) {
|
||||
val showCreateShelfPrimaryAction = NonReaderLibraryPrimaryAction.NEW_SHELF in
|
||||
primaryLibraryActionsForTab(selectedTab, platform)
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(SharedUiTokens.surfaceRadius),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
border = sharedSubtleBorder(alpha = 0.5f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
LibrarySearchField(
|
||||
state = state,
|
||||
onStateChange = onStateChange,
|
||||
modifier = Modifier.weight(1f).widthIn(min = 260.dp)
|
||||
)
|
||||
SortMenu(
|
||||
sortOrder = state.sortOrder,
|
||||
onSortOrderChange = { onStateChange(state.reduce(LibraryAction.SortChanged(it))) }
|
||||
)
|
||||
LibraryFilterButton(
|
||||
filters = state.libraryFilters,
|
||||
showFilters = showFilters,
|
||||
onToggleFilters = onToggleFilters
|
||||
)
|
||||
Button(onClick = onImportBooks) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(readerString("desktop_import_files", "Import files"))
|
||||
}
|
||||
OutlinedButton(onClick = onImportFolder) {
|
||||
Icon(Icons.Default.CreateNewFolder, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(readerString("fab_add_folder", "Add folder"))
|
||||
}
|
||||
if (showCreateShelfPrimaryAction) {
|
||||
Button(onClick = onCreateShelf) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(readerString("fab_new_shelf", "New shelf"))
|
||||
}
|
||||
}
|
||||
LibraryMoreActionsMenu(
|
||||
viewMode = viewMode,
|
||||
onViewModeChange = onViewModeChange,
|
||||
onCreateShelf = onCreateShelf,
|
||||
showCreateShelfAction = !showCreateShelfPrimaryAction
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StackedLibraryCommandBar(
|
||||
state: SharedReaderScreenState,
|
||||
selectedTab: NonReaderLibraryTab,
|
||||
viewMode: BookViewMode,
|
||||
showFilters: Boolean,
|
||||
platform: ReaderPlatform,
|
||||
onViewModeChange: (BookViewMode) -> Unit,
|
||||
onToggleFilters: () -> Unit,
|
||||
onStateChange: (SharedReaderScreenState) -> Unit,
|
||||
onImportBooks: () -> Unit,
|
||||
onImportFolder: () -> Unit,
|
||||
onCreateShelf: () -> Unit
|
||||
) {
|
||||
val showCreateShelfPrimaryAction = NonReaderLibraryPrimaryAction.NEW_SHELF in
|
||||
primaryLibraryActionsForTab(selectedTab, platform)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SharedStableOutlinedTextField(
|
||||
value = state.searchQuery,
|
||||
onValueChange = { onStateChange(state.reduce(LibraryAction.SearchChanged(it))) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
label = { Text(readerString("library_search_placeholder", "Search books, authors, or tags")) },
|
||||
singleLine = true,
|
||||
LibrarySearchField(
|
||||
state = state,
|
||||
onStateChange = onStateChange,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Row(
|
||||
|
|
@ -927,52 +1075,135 @@ private fun LibraryToolbar(
|
|||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
SortMenu(sortOrder = state.sortOrder, onSortOrderChange = { onStateChange(state.reduce(LibraryAction.SortChanged(it))) })
|
||||
OutlinedButton(onClick = { onViewModeChange(if (viewMode == BookViewMode.COVERS) BookViewMode.LIST else BookViewMode.COVERS) }) {
|
||||
Icon(if (viewMode == BookViewMode.COVERS) Icons.AutoMirrored.Filled.List else Icons.Default.Book, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
SortMenu(
|
||||
sortOrder = state.sortOrder,
|
||||
onSortOrderChange = { onStateChange(state.reduce(LibraryAction.SortChanged(it))) }
|
||||
)
|
||||
LibraryFilterButton(
|
||||
filters = state.libraryFilters,
|
||||
showFilters = showFilters,
|
||||
onToggleFilters = onToggleFilters
|
||||
)
|
||||
Button(onClick = onImportBooks) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
if (viewMode == BookViewMode.COVERS) {
|
||||
readerString("desktop_list_view", "List")
|
||||
} else {
|
||||
readerString("desktop_cover_view", "Covers")
|
||||
}
|
||||
)
|
||||
}
|
||||
OutlinedButton(onClick = onToggleFilters) {
|
||||
Icon(Icons.Default.FilterList, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(if (showFilters) readerString("desktop_hide_filters", "Hide filters") else readerString("filter_library", "Filters"))
|
||||
if (state.libraryFilters.isActive) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
) {
|
||||
Text(
|
||||
state.libraryFilters.activeFilterBadge(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 7.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = onCreateShelf) {
|
||||
Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(readerString("fab_new_shelf", "New shelf"))
|
||||
Text(readerString("desktop_import_files", "Import files"))
|
||||
}
|
||||
OutlinedButton(onClick = onImportFolder) {
|
||||
Icon(Icons.Default.CreateNewFolder, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(readerString("fab_add_folder", "Add folder"))
|
||||
}
|
||||
Button(onClick = onImportBooks) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(readerString("desktop_import_files", "Import files"))
|
||||
if (showCreateShelfPrimaryAction) {
|
||||
Button(onClick = onCreateShelf) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(readerString("fab_new_shelf", "New shelf"))
|
||||
}
|
||||
}
|
||||
LibraryMoreActionsMenu(
|
||||
viewMode = viewMode,
|
||||
onViewModeChange = onViewModeChange,
|
||||
onCreateShelf = onCreateShelf,
|
||||
showCreateShelfAction = !showCreateShelfPrimaryAction
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LibrarySearchField(
|
||||
state: SharedReaderScreenState,
|
||||
onStateChange: (SharedReaderScreenState) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SharedStableOutlinedTextField(
|
||||
value = state.searchQuery,
|
||||
onValueChange = { onStateChange(state.reduce(LibraryAction.SearchChanged(it))) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
label = { Text(readerString("library_search_placeholder", "Search books, authors, or tags")) },
|
||||
singleLine = true,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LibraryFilterButton(
|
||||
filters: LibraryFilters,
|
||||
showFilters: Boolean,
|
||||
onToggleFilters: () -> Unit
|
||||
) {
|
||||
OutlinedButton(onClick = onToggleFilters) {
|
||||
Icon(Icons.Default.FilterList, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(if (showFilters) readerString("desktop_hide_filters", "Hide filters") else readerString("filter_library", "Filters"))
|
||||
if (filters.isActive) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
) {
|
||||
Text(
|
||||
filters.activeFilterBadge(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 7.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LibraryMoreActionsMenu(
|
||||
viewMode: BookViewMode,
|
||||
onViewModeChange: (BookViewMode) -> Unit,
|
||||
onCreateShelf: () -> Unit,
|
||||
showCreateShelfAction: Boolean = true
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
IconButton(onClick = { expanded = true }) {
|
||||
Icon(
|
||||
Icons.Default.MoreVert,
|
||||
contentDescription = readerString("desktop_more", "More"),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
if (viewMode == BookViewMode.COVERS) Icons.AutoMirrored.Filled.List else Icons.Default.Book,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
if (viewMode == BookViewMode.COVERS) {
|
||||
readerString("desktop_list_view", "List")
|
||||
} else {
|
||||
readerString("desktop_cover_view", "Covers")
|
||||
}
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
expanded = false
|
||||
onViewModeChange(
|
||||
if (viewMode == BookViewMode.COVERS) BookViewMode.LIST else BookViewMode.COVERS
|
||||
)
|
||||
}
|
||||
)
|
||||
if (showCreateShelfAction) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = { Icon(Icons.Default.Folder, contentDescription = null) },
|
||||
text = { Text(readerString("fab_new_shelf", "New shelf")) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onCreateShelf()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -990,11 +1221,14 @@ private fun LibraryContent(
|
|||
onImportBooks: () -> Unit,
|
||||
onImportFolder: () -> Unit,
|
||||
useImportEmptyStateWhenLibraryEmpty: Boolean = false,
|
||||
onCreateShelf: () -> Unit,
|
||||
onOpenBook: (BookItem) -> Unit,
|
||||
onToggleSelection: (String) -> Unit,
|
||||
onShowBookInfo: (BookItem) -> Unit,
|
||||
onEditBook: (BookItem) -> Unit,
|
||||
onTogglePinned: (BookItem) -> Unit,
|
||||
onAddBooksToShelf: (Set<String>) -> Unit,
|
||||
onManageShelfBooks: ((Shelf) -> Unit)?,
|
||||
onRenameShelf: (Shelf) -> Unit,
|
||||
onDeleteShelf: (Shelf) -> Unit,
|
||||
onRemoveFolder: (Shelf) -> Unit,
|
||||
|
|
@ -1003,9 +1237,38 @@ private fun LibraryContent(
|
|||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
val shelvesById = remember(state.shelves) { state.shelves.associateBy { it.id } }
|
||||
val visibleBooks = remember(state.libraryBooks, selectedTab, platform) {
|
||||
state.booksForNonReaderLibraryTab(selectedTab, platform)
|
||||
}
|
||||
val tagShelves = remember(state.shelves) {
|
||||
state.shelves.filter { it.type == ShelfType.TAG && it.bookCount > 0 }
|
||||
}
|
||||
val browseShelves = remember(state.shelves) {
|
||||
state.shelves.filter {
|
||||
it.type != ShelfType.FOLDER &&
|
||||
it.type != ShelfType.TAG &&
|
||||
it.type != ShelfType.SMART
|
||||
}
|
||||
}
|
||||
val smartShelves = remember(state.shelves) {
|
||||
state.shelves.filter { it.type == ShelfType.SMART }
|
||||
}
|
||||
val rootFolderShelves = remember(state.shelves) {
|
||||
state.shelves.filter { it.type == ShelfType.FOLDER && it.parentShelfId == null }
|
||||
}
|
||||
val addToShelfFromBookAction = if (NonReaderBookOverflowAction.ADD_TO_SHELF in bookOverflowActionsForPlatform(platform)) {
|
||||
onAddBooksToShelf
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val manageShelfBooksAction = if (platform == ReaderPlatform.DESKTOP) onManageShelfBooks else null
|
||||
val showNewShelfPrimaryAction = NonReaderLibraryPrimaryAction.NEW_SHELF in
|
||||
primaryLibraryActionsForTab(selectedTab, platform)
|
||||
if (showFilters) {
|
||||
LibraryFilterPanel(
|
||||
state = state,
|
||||
platform = platform,
|
||||
onStateChange = onStateChange
|
||||
)
|
||||
} else if (state.libraryFilters.isActive || state.searchQuery.isNotBlank()) {
|
||||
|
|
@ -1017,7 +1280,7 @@ private fun LibraryContent(
|
|||
NonReaderLibraryTab.UNREAD,
|
||||
NonReaderLibraryTab.IN_PROGRESS,
|
||||
NonReaderLibraryTab.COMPLETED -> {
|
||||
val books = state.booksForNonReaderLibraryTab(selectedTab, platform)
|
||||
val books = visibleBooks
|
||||
if (books.isEmpty()) {
|
||||
if (state.rawLibraryBooks.isEmpty() && useImportEmptyStateWhenLibraryEmpty) {
|
||||
LibraryImportEmptyState(
|
||||
|
|
@ -1052,13 +1315,13 @@ private fun LibraryContent(
|
|||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddToShelf = addToShelfFromBookAction?.let { addToShelf -> { book -> addToShelf(setOf(book.id)) } },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
NonReaderLibraryTab.SHELVES -> {
|
||||
val tagShelves = state.shelves.filter { it.type == ShelfType.TAG && it.bookCount > 0 }
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
BrowseByTagRow(
|
||||
tagShelves = tagShelves,
|
||||
|
|
@ -1077,7 +1340,7 @@ private fun LibraryContent(
|
|||
}
|
||||
)
|
||||
ShelfCollection(
|
||||
shelves = state.shelves.filter { it.type != ShelfType.FOLDER && it.type != ShelfType.TAG && it.type != ShelfType.SMART },
|
||||
shelves = browseShelves,
|
||||
selectedBookIds = state.selectedBookIds,
|
||||
pinnedBookIds = state.pinnedLibraryBookIds,
|
||||
onOpenBook = onOpenBook,
|
||||
|
|
@ -1085,18 +1348,22 @@ private fun LibraryContent(
|
|||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddBooksToShelf = addToShelfFromBookAction,
|
||||
onManageShelfBooks = manageShelfBooksAction,
|
||||
onRenameShelf = onRenameShelf,
|
||||
onDeleteShelf = onDeleteShelf,
|
||||
onRemoveFolder = onRemoveFolder,
|
||||
onCreateShelf = if (showNewShelfPrimaryAction) onCreateShelf else null,
|
||||
emptyTitle = readerString("desktop_no_shelves_yet", "No shelves yet"),
|
||||
emptyBody = readerString("desktop_no_shelves_desc", "Manual shelves and series collections will appear here."),
|
||||
emptyActionLabel = if (showNewShelfPrimaryAction) readerString("fab_new_shelf", "New shelf") else null,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
NonReaderLibraryTab.SMART_SHELVES -> ShelfCollection(
|
||||
shelves = state.shelves.filter { it.type == ShelfType.SMART },
|
||||
shelves = smartShelves,
|
||||
selectedBookIds = state.selectedBookIds,
|
||||
pinnedBookIds = state.pinnedLibraryBookIds,
|
||||
onOpenBook = onOpenBook,
|
||||
|
|
@ -1104,6 +1371,7 @@ private fun LibraryContent(
|
|||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddBooksToShelf = addToShelfFromBookAction,
|
||||
onRenameShelf = onRenameShelf,
|
||||
onDeleteShelf = onDeleteShelf,
|
||||
emptyTitle = readerString("desktop_no_smart_shelves_yet", "No smart shelves yet"),
|
||||
|
|
@ -1112,7 +1380,7 @@ private fun LibraryContent(
|
|||
)
|
||||
|
||||
NonReaderLibraryTab.TAGS -> ShelfCollection(
|
||||
shelves = state.shelves.filter { it.type == ShelfType.TAG && it.bookCount > 0 },
|
||||
shelves = tagShelves,
|
||||
selectedBookIds = state.selectedBookIds,
|
||||
pinnedBookIds = state.pinnedLibraryBookIds,
|
||||
onOpenBook = onOpenBook,
|
||||
|
|
@ -1120,6 +1388,7 @@ private fun LibraryContent(
|
|||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddBooksToShelf = addToShelfFromBookAction,
|
||||
emptyTitle = readerString("desktop_no_tags_yet", "No tags yet"),
|
||||
emptyBody = readerString("desktop_no_tags_desc", "Tags added to books will appear here."),
|
||||
modifier = Modifier.weight(1f)
|
||||
|
|
@ -1127,12 +1396,12 @@ private fun LibraryContent(
|
|||
|
||||
NonReaderLibraryTab.FOLDERS -> {
|
||||
val currentFolder = state.viewingShelfId
|
||||
?.let { id -> state.shelves.firstOrNull { it.id == id && it.type == ShelfType.FOLDER } }
|
||||
?.let { id -> shelvesById[id]?.takeIf { it.type == ShelfType.FOLDER } }
|
||||
if (currentFolder != null) {
|
||||
FolderShelfDetail(
|
||||
shelf = currentFolder,
|
||||
childShelves = currentFolder.childShelfIds.mapNotNull { childId ->
|
||||
state.shelves.firstOrNull { it.id == childId }
|
||||
shelvesById[childId]
|
||||
},
|
||||
selectedBookIds = state.selectedBookIds,
|
||||
pinnedBookIds = state.pinnedLibraryBookIds,
|
||||
|
|
@ -1141,6 +1410,7 @@ private fun LibraryContent(
|
|||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddBooksToShelf = addToShelfFromBookAction,
|
||||
onOpenShelf = { shelf -> onStateChange(state.copy(viewingShelfId = shelf.id)) },
|
||||
onBack = { onStateChange(state.copy(viewingShelfId = currentFolder.parentShelfId)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
|
|
@ -1154,7 +1424,7 @@ private fun LibraryContent(
|
|||
)
|
||||
}
|
||||
ShelfCollection(
|
||||
shelves = state.shelves.filter { it.type == ShelfType.FOLDER && it.parentShelfId == null },
|
||||
shelves = rootFolderShelves,
|
||||
selectedBookIds = state.selectedBookIds,
|
||||
pinnedBookIds = state.pinnedLibraryBookIds,
|
||||
onOpenBook = onOpenBook,
|
||||
|
|
@ -1162,6 +1432,7 @@ private fun LibraryContent(
|
|||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddBooksToShelf = addToShelfFromBookAction,
|
||||
onRemoveFolder = onRemoveFolder,
|
||||
onOpenShelf = { shelf -> onStateChange(state.copy(viewingShelfId = shelf.id)) },
|
||||
emptyTitle = readerString("desktop_no_folders_yet", "No folders yet"),
|
||||
|
|
@ -1257,9 +1528,9 @@ private fun LibraryFilterSummary(
|
|||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
private fun LibraryFilterPanel(
|
||||
state: SharedReaderScreenState,
|
||||
platform: ReaderPlatform,
|
||||
onStateChange: (SharedReaderScreenState) -> Unit
|
||||
) {
|
||||
Surface(
|
||||
|
|
@ -1267,7 +1538,7 @@ private fun LibraryFilterPanel(
|
|||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
border = sharedSubtleBorder()
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(SharedUiTokens.panelPadding), verticalArrangement = Arrangement.spacedBy(SharedUiTokens.contentGap)) {
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(readerString("filter_library", "Filters"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
|
||||
if (state.libraryFilters.isActive || state.searchQuery.isNotBlank()) {
|
||||
|
|
@ -1278,30 +1549,20 @@ private fun LibraryFilterPanel(
|
|||
}
|
||||
|
||||
LibraryFilterSection(title = readerString("filter_file_type", "File type")) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
nonReaderLibraryFileTypeGroups().forEach { group ->
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
readerString(group.titleKey, group.titleFallback),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
group.fileTypes.forEach { type ->
|
||||
FilterChip(
|
||||
selected = type in state.libraryFilters.fileTypes,
|
||||
onClick = {
|
||||
val updated = state.libraryFilters.fileTypes.toggle(type)
|
||||
onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(fileTypes = updated))))
|
||||
},
|
||||
label = { Text(SharedFileCapabilities.displayNameFor(type)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
nonReaderLibraryFileTypeGroups(platform).flatMap { it.fileTypes }.forEach { type ->
|
||||
FilterChip(
|
||||
selected = type in state.libraryFilters.fileTypes,
|
||||
onClick = {
|
||||
val updated = state.libraryFilters.fileTypes.toggle(type)
|
||||
onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(fileTypes = updated))))
|
||||
},
|
||||
label = { Text(SharedFileCapabilities.displayNameFor(type)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1328,7 +1589,14 @@ private fun LibraryFilterPanel(
|
|||
onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(sourceFolders = updated))))
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(16.dp)) },
|
||||
label = { Text(folder.name) }
|
||||
label = {
|
||||
Text(
|
||||
folder.name,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.widthIn(max = 180.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1360,9 +1628,10 @@ private fun LibraryFilterPanel(
|
|||
|
||||
if (state.allTags.isNotEmpty()) {
|
||||
LibraryFilterSection(title = readerString("section_tags", "Tags")) {
|
||||
FlowRow(
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
state.allTags.forEach { tag ->
|
||||
FilterChip(
|
||||
|
|
@ -1372,7 +1641,14 @@ private fun LibraryFilterPanel(
|
|||
onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(tagIds = updated))))
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(16.dp)) },
|
||||
label = { Text(tag.name) }
|
||||
label = {
|
||||
Text(
|
||||
tag.name,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.widthIn(max = 160.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1387,11 +1663,11 @@ private fun LibraryFilterSection(
|
|||
title: String,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
content()
|
||||
|
|
@ -1414,6 +1690,7 @@ private fun BookGrid(
|
|||
onShowBookInfo: (BookItem) -> Unit,
|
||||
onEditBook: (BookItem) -> Unit,
|
||||
onTogglePinned: (BookItem) -> Unit,
|
||||
onAddToShelf: ((BookItem) -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (viewMode == BookViewMode.LIST) {
|
||||
|
|
@ -1432,7 +1709,8 @@ private fun BookGrid(
|
|||
onToggleSelection = { onToggleSelection(book.id) },
|
||||
onShowInfo = { onShowBookInfo(book) },
|
||||
onEdit = { onEditBook(book) },
|
||||
onTogglePinned = { onTogglePinned(book) }
|
||||
onTogglePinned = { onTogglePinned(book) },
|
||||
onAddToShelf = onAddToShelf?.let { addToShelf -> { addToShelf(book) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1454,7 +1732,8 @@ private fun BookGrid(
|
|||
onToggleSelection = { onToggleSelection(book.id) },
|
||||
onShowInfo = { onShowBookInfo(book) },
|
||||
onEdit = { onEditBook(book) },
|
||||
onTogglePinned = { onTogglePinned(book) }
|
||||
onTogglePinned = { onTogglePinned(book) },
|
||||
onAddToShelf = onAddToShelf?.let { addToShelf -> { addToShelf(book) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1473,6 +1752,7 @@ private fun BookTile(
|
|||
onShowInfo: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onTogglePinned: () -> Unit,
|
||||
onAddToShelf: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
|
|
@ -1522,7 +1802,8 @@ private fun BookTile(
|
|||
onTogglePinned = onTogglePinned,
|
||||
onShowInfo = onShowInfo,
|
||||
onEdit = onEdit,
|
||||
onToggleSelection = onToggleSelection
|
||||
onToggleSelection = onToggleSelection,
|
||||
onAddToShelf = onAddToShelf
|
||||
)
|
||||
}
|
||||
TypeBadge(book.type, modifier = Modifier.align(Alignment.BottomEnd).padding(6.dp))
|
||||
|
|
@ -1557,7 +1838,8 @@ private fun BookListItem(
|
|||
onToggleSelection: () -> Unit,
|
||||
onShowInfo: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onTogglePinned: () -> Unit
|
||||
onTogglePinned: () -> Unit,
|
||||
onAddToShelf: (() -> Unit)? = null
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
Surface(
|
||||
|
|
@ -1598,7 +1880,8 @@ private fun BookListItem(
|
|||
onTogglePinned = onTogglePinned,
|
||||
onShowInfo = onShowInfo,
|
||||
onEdit = onEdit,
|
||||
onToggleSelection = onToggleSelection
|
||||
onToggleSelection = onToggleSelection,
|
||||
onAddToShelf = onAddToShelf
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1614,7 +1897,8 @@ private fun BookActionMenu(
|
|||
onTogglePinned: () -> Unit,
|
||||
onShowInfo: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onToggleSelection: () -> Unit
|
||||
onToggleSelection: () -> Unit,
|
||||
onAddToShelf: (() -> Unit)? = null
|
||||
) {
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||
DropdownMenuItem(
|
||||
|
|
@ -1641,6 +1925,16 @@ private fun BookActionMenu(
|
|||
onEdit()
|
||||
}
|
||||
)
|
||||
if (onAddToShelf != null) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = { Icon(Icons.Default.Folder, contentDescription = null) },
|
||||
text = { Text(readerString("desktop_add_to_shelf", "Add to shelf")) },
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onAddToShelf()
|
||||
}
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
leadingIcon = { Icon(if (selected) Icons.Default.Check else Icons.AutoMirrored.Filled.List, contentDescription = null) },
|
||||
text = { Text(if (selected) readerString("clear_selection", "Clear selection") else readerString("action_select", "Select")) },
|
||||
|
|
@ -1818,12 +2112,16 @@ private fun ShelfCollection(
|
|||
onShowBookInfo: (BookItem) -> Unit,
|
||||
onEditBook: (BookItem) -> Unit,
|
||||
onTogglePinned: (BookItem) -> Unit,
|
||||
onAddBooksToShelf: ((Set<String>) -> Unit)? = null,
|
||||
onManageShelfBooks: ((Shelf) -> Unit)? = null,
|
||||
onRenameShelf: (Shelf) -> Unit = {},
|
||||
onDeleteShelf: (Shelf) -> Unit = {},
|
||||
onRemoveFolder: (Shelf) -> Unit = {},
|
||||
onOpenShelf: ((Shelf) -> Unit)? = null,
|
||||
onCreateShelf: (() -> Unit)? = null,
|
||||
emptyTitle: String,
|
||||
emptyBody: String,
|
||||
emptyActionLabel: String? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (shelves.isEmpty()) {
|
||||
|
|
@ -1831,6 +2129,8 @@ private fun ShelfCollection(
|
|||
icon = { Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(56.dp)) },
|
||||
title = emptyTitle,
|
||||
body = emptyBody,
|
||||
actionLabel = emptyActionLabel,
|
||||
onAction = onCreateShelf,
|
||||
modifier = modifier
|
||||
)
|
||||
return
|
||||
|
|
@ -1851,6 +2151,8 @@ private fun ShelfCollection(
|
|||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddBooksToShelf = onAddBooksToShelf,
|
||||
onManageShelfBooks = onManageShelfBooks,
|
||||
onRenameShelf = onRenameShelf,
|
||||
onDeleteShelf = onDeleteShelf,
|
||||
onRemoveFolder = onRemoveFolder,
|
||||
|
|
@ -1870,6 +2172,8 @@ private fun ShelfSection(
|
|||
onShowBookInfo: (BookItem) -> Unit,
|
||||
onEditBook: (BookItem) -> Unit,
|
||||
onTogglePinned: (BookItem) -> Unit,
|
||||
onAddBooksToShelf: ((Set<String>) -> Unit)?,
|
||||
onManageShelfBooks: ((Shelf) -> Unit)?,
|
||||
onRenameShelf: (Shelf) -> Unit,
|
||||
onDeleteShelf: (Shelf) -> Unit,
|
||||
onRemoveFolder: (Shelf) -> Unit,
|
||||
|
|
@ -1912,6 +2216,23 @@ private fun ShelfSection(
|
|||
}
|
||||
}
|
||||
if (shelf.type == ShelfType.MANUAL && shelf.id != "unshelved") {
|
||||
if (onManageShelfBooks != null) {
|
||||
OutlinedButton(onClick = { onManageShelfBooks(shelf) }) {
|
||||
Icon(
|
||||
if (shelf.bookCount == 0) Icons.Default.Add else Icons.Default.FormatListNumbered,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
if (shelf.bookCount == 0) {
|
||||
readerString("fab_add_books", "Add books")
|
||||
} else {
|
||||
readerString("desktop_manage_books", "Manage books")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { onRenameShelf(shelf) }, modifier = Modifier.size(34.dp)) {
|
||||
Icon(Icons.Default.Edit, contentDescription = readerString("menu_rename_shelf", "Rename shelf"), modifier = Modifier.size(18.dp))
|
||||
}
|
||||
|
|
@ -1937,6 +2258,7 @@ private fun ShelfSection(
|
|||
onShowInfo = { onShowBookInfo(book) },
|
||||
onEdit = { onEditBook(book) },
|
||||
onTogglePinned = { onTogglePinned(book) },
|
||||
onAddToShelf = onAddBooksToShelf?.let { addToShelf -> { addToShelf(setOf(book.id)) } },
|
||||
modifier = Modifier.width(148.dp)
|
||||
)
|
||||
}
|
||||
|
|
@ -1957,6 +2279,7 @@ private fun FolderShelfDetail(
|
|||
onShowBookInfo: (BookItem) -> Unit,
|
||||
onEditBook: (BookItem) -> Unit,
|
||||
onTogglePinned: (BookItem) -> Unit,
|
||||
onAddBooksToShelf: ((Set<String>) -> Unit)? = null,
|
||||
onOpenShelf: (Shelf) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
|
|
@ -2021,7 +2344,8 @@ private fun FolderShelfDetail(
|
|||
onToggleSelection = { onToggleSelection(book.id) },
|
||||
onShowInfo = { onShowBookInfo(book) },
|
||||
onEdit = { onEditBook(book) },
|
||||
onTogglePinned = { onTogglePinned(book) }
|
||||
onTogglePinned = { onTogglePinned(book) },
|
||||
onAddToShelf = onAddBooksToShelf?.let { addToShelf -> { addToShelf(setOf(book.id)) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -2431,7 +2755,7 @@ private fun fileTypeColor(type: FileType): Color {
|
|||
FileType.PDF -> Color(0xFF9C4146)
|
||||
FileType.EPUB, FileType.MOBI -> Color(0xFF006C4C)
|
||||
FileType.DOCX, FileType.ODT, FileType.FODT, FileType.PPTX -> Color(0xFF0F52BA)
|
||||
FileType.CBZ, FileType.CBR, FileType.CB7 -> Color(0xFF705D49)
|
||||
FileType.CBZ, FileType.CBR, FileType.CB7, FileType.CBT -> Color(0xFF705D49)
|
||||
else -> Color(0xFF5D6B82)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.aryan.reader.shared.ReaderAutoScrollState
|
||||
import com.aryan.reader.shared.ReaderHighlightPalette
|
||||
import com.aryan.reader.shared.ReaderLocator
|
||||
import com.aryan.reader.shared.UserHighlight
|
||||
|
|
@ -14,7 +13,6 @@ data class ReaderContentNavigationTarget(
|
|||
val locator: ReaderLocator?,
|
||||
val requestId: Long,
|
||||
val readingMode: ReaderReadingMode,
|
||||
val autoScroll: ReaderAutoScrollState = ReaderAutoScrollState(),
|
||||
val ttsLocator: ReaderLocator? = null,
|
||||
val ttsRequestId: Long = 0L
|
||||
)
|
||||
|
|
@ -28,6 +26,7 @@ sealed interface ReaderContentRenderPlan {
|
|||
data class WebDocument(
|
||||
val html: String,
|
||||
val appearanceScript: String,
|
||||
val highlightPaletteScript: String,
|
||||
override val background: Color,
|
||||
override val foreground: Color,
|
||||
override val navigationTarget: ReaderContentNavigationTarget,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -17,6 +18,7 @@ import androidx.compose.ui.geometry.CornerRadius
|
|||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -32,9 +34,12 @@ fun ReaderMinimalSlider(
|
|||
onValueChangeFinished: (() -> Unit)? = null,
|
||||
activeColor: Color? = null,
|
||||
inactiveColor: Color? = null,
|
||||
thumbColor: Color? = null
|
||||
thumbColor: Color? = null,
|
||||
markerValue: Float? = null,
|
||||
markerColor: Color? = null
|
||||
) {
|
||||
var widthPx by remember { mutableFloatStateOf(0f) }
|
||||
var dragValue by remember { mutableStateOf<Float?>(null) }
|
||||
val rangeStart = valueRange.start
|
||||
val rangeEnd = valueRange.endInclusive
|
||||
|
||||
|
|
@ -45,22 +50,36 @@ fun ReaderMinimalSlider(
|
|||
}
|
||||
|
||||
val inputModifier = if (enabled) {
|
||||
Modifier.pointerInput(rangeStart, rangeEnd, widthPx) {
|
||||
Modifier.pointerInput(rangeStart, rangeEnd) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
onValueChangeStarted?.invoke()
|
||||
onValueChange(valueForOffset(down.position.x))
|
||||
down.consume()
|
||||
var gestureStarted = false
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
if (change == null || !change.pressed) break
|
||||
onValueChange(valueForOffset(change.position.x))
|
||||
change.consume()
|
||||
fun updateValue(offsetX: Float) {
|
||||
val nextValue = valueForOffset(offsetX)
|
||||
dragValue = nextValue
|
||||
onValueChange(nextValue)
|
||||
}
|
||||
|
||||
onValueChangeFinished?.invoke()
|
||||
try {
|
||||
onValueChangeStarted?.invoke()
|
||||
gestureStarted = true
|
||||
updateValue(down.position.x)
|
||||
down.consume()
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
if (change == null || !change.pressed) break
|
||||
updateValue(change.position.x)
|
||||
change.consume()
|
||||
}
|
||||
} finally {
|
||||
if (gestureStarted) {
|
||||
onValueChangeFinished?.invoke()
|
||||
}
|
||||
dragValue = null
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -70,6 +89,8 @@ fun ReaderMinimalSlider(
|
|||
val effectiveActiveColor = activeColor ?: MaterialTheme.colorScheme.primary
|
||||
val effectiveInactiveColor = inactiveColor ?: MaterialTheme.colorScheme.surfaceVariant
|
||||
val effectiveThumbColor = thumbColor ?: MaterialTheme.colorScheme.primary
|
||||
val effectiveMarkerColor = markerColor ?: effectiveActiveColor
|
||||
val markerFraction = readerMinimalSliderMarkerFraction(markerValue, valueRange)
|
||||
val disabledAlpha = if (enabled) 1f else 0.38f
|
||||
|
||||
Box(
|
||||
|
|
@ -80,8 +101,9 @@ fun ReaderMinimalSlider(
|
|||
) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val range = rangeEnd - rangeStart
|
||||
val displayValue = dragValue ?: value
|
||||
val fraction = if (range > 0f) {
|
||||
((value.coerceIn(rangeStart, rangeEnd) - rangeStart) / range).coerceIn(0f, 1f)
|
||||
((displayValue.coerceIn(rangeStart, rangeEnd) - rangeStart) / range).coerceIn(0f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
|
@ -104,6 +126,22 @@ fun ReaderMinimalSlider(
|
|||
cornerRadius = cornerRadius
|
||||
)
|
||||
|
||||
markerFraction?.let { fraction ->
|
||||
val markerWidth = 2.dp.toPx()
|
||||
val markerHeight = 12.dp.toPx()
|
||||
val markerX = if (size.width <= markerWidth) {
|
||||
size.width / 2f
|
||||
} else {
|
||||
(size.width * fraction).coerceIn(markerWidth / 2f, size.width - markerWidth / 2f)
|
||||
}
|
||||
drawRoundRect(
|
||||
color = effectiveMarkerColor.copy(alpha = effectiveMarkerColor.alpha * disabledAlpha),
|
||||
topLeft = Offset(markerX - markerWidth / 2f, centerY - markerHeight / 2f),
|
||||
size = Size(markerWidth, markerHeight),
|
||||
cornerRadius = CornerRadius(markerWidth / 2f, markerWidth / 2f)
|
||||
)
|
||||
}
|
||||
|
||||
val thumbCenterX = if (size.width <= thumbRadius * 2f) {
|
||||
size.width / 2f
|
||||
} else {
|
||||
|
|
@ -117,3 +155,16 @@ fun ReaderMinimalSlider(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun readerMinimalSliderMarkerFraction(
|
||||
markerValue: Float?,
|
||||
valueRange: ClosedFloatingPointRange<Float>
|
||||
): Float? {
|
||||
val marker = markerValue ?: return null
|
||||
val rangeStart = valueRange.start
|
||||
val rangeEnd = valueRange.endInclusive
|
||||
if (rangeEnd <= rangeStart) return null
|
||||
|
||||
return ((marker.coerceIn(rangeStart, rangeEnd) - rangeStart) / (rangeEnd - rangeStart))
|
||||
.coerceIn(0f, 1f)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupPositionProvider
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun ReaderTooltipIconButton(
|
||||
tooltip: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
var hovered by remember { mutableStateOf(false) }
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
val density = LocalDensity.current
|
||||
val marginPx = with(density) { 8.dp.roundToPx() }
|
||||
val offsetPx = with(density) { 8.dp.roundToPx() }
|
||||
|
||||
LaunchedEffect(hovered, tooltip) {
|
||||
if (hovered && tooltip.isNotBlank()) {
|
||||
delay(450L)
|
||||
visible = hovered
|
||||
} else {
|
||||
visible = false
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.pointerInput(tooltip) {
|
||||
awaitPointerEventScope {
|
||||
var isHovered = false
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
when (event.type) {
|
||||
PointerEventType.Enter,
|
||||
PointerEventType.Move -> if (!isHovered) {
|
||||
isHovered = true
|
||||
hovered = true
|
||||
}
|
||||
PointerEventType.Exit,
|
||||
PointerEventType.Press -> if (isHovered) {
|
||||
isHovered = false
|
||||
hovered = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
IconButton(onClick = onClick, modifier = modifier, enabled = enabled) {
|
||||
content.invoke()
|
||||
}
|
||||
if (visible) {
|
||||
Popup(
|
||||
popupPositionProvider = ReaderTooltipPositionProvider(
|
||||
marginPx = marginPx,
|
||||
offsetPx = offsetPx
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = MaterialTheme.colorScheme.inverseSurface,
|
||||
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 4.dp
|
||||
) {
|
||||
Text(
|
||||
text = tooltip,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 260.dp)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class ReaderTooltipPositionProvider(
|
||||
private val marginPx: Int,
|
||||
private val offsetPx: Int
|
||||
) : PopupPositionProvider {
|
||||
override fun calculatePosition(
|
||||
anchorBounds: IntRect,
|
||||
windowSize: IntSize,
|
||||
layoutDirection: LayoutDirection,
|
||||
popupContentSize: IntSize
|
||||
): IntOffset {
|
||||
val centeredX = anchorBounds.left + (anchorBounds.width - popupContentSize.width) / 2
|
||||
val maxX = (windowSize.width - popupContentSize.width - marginPx).coerceAtLeast(marginPx)
|
||||
val x = centeredX.coerceIn(marginPx, maxX)
|
||||
val belowY = anchorBounds.bottom + offsetPx
|
||||
val aboveY = anchorBounds.top - popupContentSize.height - offsetPx
|
||||
val y = if (belowY + popupContentSize.height <= windowSize.height - marginPx) {
|
||||
belowY
|
||||
} else {
|
||||
aboveY.coerceAtLeast(marginPx)
|
||||
}
|
||||
return IntOffset(x, y)
|
||||
}
|
||||
}
|
||||
|
|
@ -39,7 +39,6 @@ enum class ReaderWorkspaceTopAction {
|
|||
APPEARANCE,
|
||||
READ_ALOUD,
|
||||
AI,
|
||||
AUTO_SCROLL,
|
||||
TOOLS
|
||||
}
|
||||
|
||||
|
|
@ -52,9 +51,47 @@ enum class ReaderWorkspaceBottomAction {
|
|||
data class ReaderWorkspaceChromeModel(
|
||||
val preferAutoHide: Boolean,
|
||||
val forceVisible: Boolean,
|
||||
val forceVisibleReasons: Set<String> = emptySet()
|
||||
val forceVisibleReasons: Set<String> = emptySet(),
|
||||
val revealVisibleReasons: Set<String> = emptySet()
|
||||
)
|
||||
|
||||
internal fun readerWorkspaceChromeVisible(
|
||||
requestedVisible: Boolean,
|
||||
lockedVisible: Boolean,
|
||||
forcedVisible: Boolean
|
||||
): Boolean {
|
||||
return lockedVisible || forcedVisible || requestedVisible
|
||||
}
|
||||
|
||||
internal fun readerWorkspaceChromeVisibleAfterReaderTap(
|
||||
requestedVisible: Boolean,
|
||||
lockedVisible: Boolean,
|
||||
forcedVisible: Boolean,
|
||||
rightPanelClosedByTap: Boolean = false
|
||||
): Boolean {
|
||||
return if (lockedVisible || forcedVisible || rightPanelClosedByTap) {
|
||||
true
|
||||
} else {
|
||||
!requestedVisible
|
||||
}
|
||||
}
|
||||
|
||||
internal fun readerWorkspaceShouldCloseRightPanelAfterReaderTap(
|
||||
rightPanelOpen: Boolean,
|
||||
hasInspectorSections: Boolean,
|
||||
closeRightPanelOnReaderTap: Boolean
|
||||
): Boolean {
|
||||
return closeRightPanelOnReaderTap && rightPanelOpen && hasInspectorSections
|
||||
}
|
||||
|
||||
internal fun readerWorkspaceLeftPanelVisible(
|
||||
toggledOpen: Boolean,
|
||||
chromeVisible: Boolean,
|
||||
hasNavigationSections: Boolean
|
||||
): Boolean {
|
||||
return toggledOpen && chromeVisible && hasNavigationSections
|
||||
}
|
||||
|
||||
data class ReaderWorkspaceFileActionState(
|
||||
val canShare: Boolean = false,
|
||||
val canSaveCopy: Boolean = false,
|
||||
|
|
@ -94,7 +131,8 @@ fun epubReaderWorkspaceModel(
|
|||
extrasState: ReaderExtrasState,
|
||||
aiAvailable: Boolean,
|
||||
cloudTtsAvailable: Boolean = true,
|
||||
externalLookupAvailable: Boolean = true
|
||||
externalLookupAvailable: Boolean = true,
|
||||
appThemeControlsAvailable: Boolean = false
|
||||
): ReaderWorkspaceModel {
|
||||
val preferences = toolbarPreferences.sanitized()
|
||||
val leftSections = listOf(
|
||||
|
|
@ -104,16 +142,21 @@ fun epubReaderWorkspaceModel(
|
|||
ReaderWorkspaceLeftSection.IMAGES
|
||||
)
|
||||
val inspectorSections = buildList {
|
||||
if (preferences.isVisible(ReaderTool.THEME) || preferences.isVisible(ReaderTool.FORMAT)) {
|
||||
if (
|
||||
appThemeControlsAvailable ||
|
||||
preferences.isVisible(ReaderTool.THEME) ||
|
||||
preferences.isVisible(ReaderTool.FORMAT) ||
|
||||
preferences.isVisible(ReaderTool.VISUAL_OPTIONS) ||
|
||||
preferences.isVisible(ReaderTool.READING_MODE)
|
||||
) {
|
||||
add(ReaderWorkspaceInspectorSection.APPEARANCE)
|
||||
}
|
||||
if (preferences.isVisible(ReaderTool.READING_MODE)) {
|
||||
add(ReaderWorkspaceInspectorSection.TOOLS)
|
||||
}
|
||||
if (
|
||||
(aiAvailable && preferences.isVisible(ReaderTool.AI_FEATURES)) ||
|
||||
(cloudTtsAvailable && preferences.isVisible(ReaderTool.TTS_CONTROLS)) ||
|
||||
preferences.isVisible(ReaderTool.AUTO_SCROLL)
|
||||
(cloudTtsAvailable && (
|
||||
preferences.isVisible(ReaderTool.TTS_CONTROLS) ||
|
||||
preferences.isVisible(ReaderTool.TTS_SETTINGS)
|
||||
)) ||
|
||||
preferences.isVisible(ReaderTool.TTS_REPLACEMENTS)
|
||||
) {
|
||||
add(ReaderWorkspaceInspectorSection.AI_TTS)
|
||||
}
|
||||
|
|
@ -126,7 +169,6 @@ fun epubReaderWorkspaceModel(
|
|||
if (ReaderWorkspaceInspectorSection.APPEARANCE in inspectorSections) add(ReaderWorkspaceTopAction.APPEARANCE)
|
||||
if (cloudTtsAvailable && preferences.isVisible(ReaderTool.TTS_CONTROLS)) add(ReaderWorkspaceTopAction.READ_ALOUD)
|
||||
if (aiAvailable && preferences.isVisible(ReaderTool.AI_FEATURES)) add(ReaderWorkspaceTopAction.AI)
|
||||
if (preferences.isVisible(ReaderTool.AUTO_SCROLL)) add(ReaderWorkspaceTopAction.AUTO_SCROLL)
|
||||
if (inspectorSections.isNotEmpty()) add(ReaderWorkspaceTopAction.TOOLS)
|
||||
}.distinct()
|
||||
val bottomActions = buildList {
|
||||
|
|
@ -149,7 +191,7 @@ fun epubReaderWorkspaceModel(
|
|||
richTextEditing = false,
|
||||
loading = false,
|
||||
errorMessage = null,
|
||||
autoScroll = extrasState.autoScroll,
|
||||
autoScroll = ReaderAutoScrollState(),
|
||||
ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused
|
||||
)
|
||||
)
|
||||
|
|
@ -211,7 +253,6 @@ fun pdfReaderWorkspaceModel(
|
|||
add(ReaderWorkspaceTopAction.APPEARANCE)
|
||||
if (cloudTtsAvailable) add(ReaderWorkspaceTopAction.READ_ALOUD)
|
||||
if (aiAvailable) add(ReaderWorkspaceTopAction.AI)
|
||||
add(ReaderWorkspaceTopAction.AUTO_SCROLL)
|
||||
add(ReaderWorkspaceTopAction.TOOLS)
|
||||
}
|
||||
return ReaderWorkspaceModel(
|
||||
|
|
@ -234,7 +275,7 @@ fun pdfReaderWorkspaceModel(
|
|||
richTextEditing = richTextEditing,
|
||||
loading = loading,
|
||||
errorMessage = errorMessage,
|
||||
autoScroll = extrasState.autoScroll,
|
||||
autoScroll = ReaderAutoScrollState(),
|
||||
ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused
|
||||
)
|
||||
)
|
||||
|
|
@ -252,7 +293,7 @@ fun readerWorkspaceChromeModel(
|
|||
autoScroll: ReaderAutoScrollState,
|
||||
ttsBusy: Boolean
|
||||
): ReaderWorkspaceChromeModel {
|
||||
val reasons = buildSet {
|
||||
val forceReasons = buildSet {
|
||||
if (searchActive) add("search")
|
||||
if (leftPanelOpen) add("left-panel")
|
||||
if (inspectorOpen) add("inspector")
|
||||
|
|
@ -261,11 +302,14 @@ fun readerWorkspaceChromeModel(
|
|||
if (loading) add("loading")
|
||||
if (!errorMessage.isNullOrBlank()) add("error")
|
||||
if (autoScroll.sanitized().enabled) add("auto-scroll")
|
||||
}
|
||||
val revealReasons = buildSet {
|
||||
if (ttsBusy) add("tts")
|
||||
}
|
||||
return ReaderWorkspaceChromeModel(
|
||||
preferAutoHide = preferAutoHide,
|
||||
forceVisible = reasons.isNotEmpty(),
|
||||
forceVisibleReasons = reasons
|
||||
forceVisible = forceReasons.isNotEmpty(),
|
||||
forceVisibleReasons = forceReasons,
|
||||
revealVisibleReasons = revealReasons
|
||||
)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,59 +1,54 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.LibraryBooks
|
||||
import androidx.compose.material.icons.automirrored.filled.MenuBook
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.CreateNewFolder
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Feedback
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.ImportExport
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material.icons.filled.TextFields
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
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.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -70,9 +65,9 @@ import com.aryan.reader.shared.AppContrastOption
|
|||
import com.aryan.reader.shared.AppThemeMode
|
||||
import com.aryan.reader.shared.CustomAppTheme
|
||||
import com.aryan.reader.shared.SharedFeaturePolicy
|
||||
import com.aryan.reader.shared.UserData
|
||||
|
||||
enum class SharedAppTab {
|
||||
HOME,
|
||||
LIBRARY,
|
||||
SHELVES,
|
||||
CATALOGS,
|
||||
|
|
@ -97,6 +92,15 @@ fun SharedAppShell(
|
|||
customAppThemes: List<CustomAppTheme> = emptyList(),
|
||||
isTabsEnabled: Boolean = true,
|
||||
featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard,
|
||||
currentUser: UserData? = null,
|
||||
accountAvailable: Boolean = featurePolicy.aiAndCloud,
|
||||
isOssBuild: Boolean = false,
|
||||
isProUser: Boolean = false,
|
||||
isSyncEnabled: Boolean = false,
|
||||
syncAvailable: Boolean = featurePolicy.aiAndCloud,
|
||||
onSignInRequested: (() -> Unit)? = null,
|
||||
accountAvatar: (@Composable (UserData, Modifier) -> Unit)? = null,
|
||||
onSyncEnabledChange: (Boolean) -> Unit = {},
|
||||
onTabSelected: (SharedAppTab) -> Unit,
|
||||
onImportFiles: () -> Unit,
|
||||
onImportFolder: () -> Unit = {},
|
||||
|
|
@ -121,7 +125,23 @@ fun SharedAppShell(
|
|||
featurePolicy = featurePolicy
|
||||
)
|
||||
}
|
||||
var showToolsPanel by remember { mutableStateOf(false) }
|
||||
val sidebarSyncToggleModel = remember(
|
||||
currentUser != null,
|
||||
accountAvailable,
|
||||
syncAvailable,
|
||||
isProUser,
|
||||
isSyncEnabled,
|
||||
featurePolicy
|
||||
) {
|
||||
sharedSidebarSyncToggleModel(
|
||||
isSignedIn = currentUser != null,
|
||||
accountAvailable = accountAvailable,
|
||||
syncAvailable = syncAvailable,
|
||||
isProUser = isProUser,
|
||||
isSyncEnabled = isSyncEnabled,
|
||||
featurePolicy = featurePolicy
|
||||
)
|
||||
}
|
||||
var showAppThemeSettings by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
|
|
@ -141,15 +161,65 @@ fun SharedAppShell(
|
|||
SharedAppSidebar(
|
||||
selectedTab = shellModel.selectedPrimaryTab,
|
||||
primaryTabs = shellModel.primaryTabs,
|
||||
primaryActions = shellModel.primaryActions,
|
||||
currentUser = currentUser,
|
||||
accountAvailable = accountAvailable,
|
||||
isOssBuild = isOssBuild,
|
||||
syncToggleModel = sidebarSyncToggleModel,
|
||||
onAccountClick = { onTabSelected(SharedAppTab.PRO) },
|
||||
onSignInRequested = onSignInRequested,
|
||||
accountAvatar = accountAvatar,
|
||||
onSyncEnabledChange = onSyncEnabledChange,
|
||||
onTabSelected = onTabSelected,
|
||||
onToolsClick = { showToolsPanel = true }
|
||||
onPrimaryAction = { action ->
|
||||
when (action) {
|
||||
SharedAppToolAction.AI_SETTINGS -> onAiSettingsRequested?.invoke()
|
||||
else -> Unit
|
||||
}
|
||||
},
|
||||
moreMenu = {
|
||||
SharedMoreMenuButton(
|
||||
compact = false,
|
||||
moreSections = shellModel.moreSections,
|
||||
isTabsEnabled = isTabsEnabled,
|
||||
onImportFiles = onImportFiles,
|
||||
onImportFolder = onImportFolder,
|
||||
onSyncRequested = onSyncRequested,
|
||||
onFolderMetadataSyncRequested = onFolderMetadataSyncRequested,
|
||||
onAppThemeRequested = { showAppThemeSettings = true },
|
||||
onAiSettingsRequested = { onAiSettingsRequested?.invoke() },
|
||||
onOpenTab = onTabSelected,
|
||||
onTabsEnabledChange = onTabsEnabledChange
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
SharedAppCompactRail(
|
||||
selectedTab = shellModel.selectedPrimaryTab,
|
||||
primaryTabs = shellModel.primaryTabs,
|
||||
primaryActions = shellModel.primaryActions,
|
||||
onTabSelected = onTabSelected,
|
||||
onToolsClick = { showToolsPanel = true }
|
||||
onPrimaryAction = { action ->
|
||||
when (action) {
|
||||
SharedAppToolAction.AI_SETTINGS -> onAiSettingsRequested?.invoke()
|
||||
else -> Unit
|
||||
}
|
||||
},
|
||||
moreMenu = {
|
||||
SharedMoreMenuButton(
|
||||
compact = true,
|
||||
moreSections = shellModel.moreSections,
|
||||
isTabsEnabled = isTabsEnabled,
|
||||
onImportFiles = onImportFiles,
|
||||
onImportFolder = onImportFolder,
|
||||
onSyncRequested = onSyncRequested,
|
||||
onFolderMetadataSyncRequested = onFolderMetadataSyncRequested,
|
||||
onAppThemeRequested = { showAppThemeSettings = true },
|
||||
onAiSettingsRequested = { onAiSettingsRequested?.invoke() },
|
||||
onOpenTab = onTabSelected,
|
||||
onTabsEnabledChange = onTabsEnabledChange
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -163,55 +233,6 @@ fun SharedAppShell(
|
|||
content(selectedTab)
|
||||
}
|
||||
}
|
||||
|
||||
if (showToolsPanel) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.24f))
|
||||
.clickable { showToolsPanel = false }
|
||||
)
|
||||
SharedToolsPanel(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.fillMaxHeight()
|
||||
.widthIn(max = 390.dp),
|
||||
isTabsEnabled = isTabsEnabled,
|
||||
toolActions = shellModel.toolActions,
|
||||
onClose = { showToolsPanel = false },
|
||||
onImportFiles = {
|
||||
showToolsPanel = false
|
||||
onImportFiles()
|
||||
},
|
||||
onImportFolder = {
|
||||
showToolsPanel = false
|
||||
onImportFolder()
|
||||
},
|
||||
onSyncRequested = {
|
||||
showToolsPanel = false
|
||||
onSyncRequested()
|
||||
},
|
||||
onFolderMetadataSyncRequested = onFolderMetadataSyncRequested?.let { syncMetadata ->
|
||||
{
|
||||
showToolsPanel = false
|
||||
syncMetadata()
|
||||
}
|
||||
},
|
||||
onAppThemeRequested = {
|
||||
showToolsPanel = false
|
||||
showAppThemeSettings = true
|
||||
},
|
||||
onAiSettingsRequested = {
|
||||
showToolsPanel = false
|
||||
onAiSettingsRequested?.invoke()
|
||||
},
|
||||
onOpenTab = { tab ->
|
||||
showToolsPanel = false
|
||||
onTabSelected(tab)
|
||||
},
|
||||
onTabsEnabledChange = onTabsEnabledChange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -239,8 +260,18 @@ fun SharedAppShell(
|
|||
private fun SharedAppSidebar(
|
||||
selectedTab: SharedAppTab,
|
||||
primaryTabs: List<SharedAppTab>,
|
||||
primaryActions: List<SharedAppToolAction>,
|
||||
currentUser: UserData?,
|
||||
accountAvailable: Boolean,
|
||||
isOssBuild: Boolean,
|
||||
syncToggleModel: SharedSidebarSyncToggleModel,
|
||||
onAccountClick: () -> Unit,
|
||||
onSignInRequested: (() -> Unit)?,
|
||||
accountAvatar: (@Composable (UserData, Modifier) -> Unit)?,
|
||||
onSyncEnabledChange: (Boolean) -> Unit,
|
||||
onTabSelected: (SharedAppTab) -> Unit,
|
||||
onToolsClick: () -> Unit
|
||||
onPrimaryAction: (SharedAppToolAction) -> Unit,
|
||||
moreMenu: @Composable () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
|
|
@ -255,24 +286,48 @@ private fun SharedAppSidebar(
|
|||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(SharedUiTokens.compactGap)
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 10.dp, vertical = 12.dp)) {
|
||||
Text(readerString("app_name", "Episteme"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Text(readerString("desktop_library_and_reader", "Library and reader"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
SharedSidebarHeader(
|
||||
currentUser = currentUser,
|
||||
accountAvailable = accountAvailable,
|
||||
isOssBuild = isOssBuild,
|
||||
onAccountClick = onAccountClick,
|
||||
onSignInRequested = onSignInRequested,
|
||||
accountAvatar = accountAvatar
|
||||
)
|
||||
primaryTabs.forEach { tab ->
|
||||
if (tab == SharedAppTab.PRO) {
|
||||
primaryActions.forEach { action ->
|
||||
SharedSidebarButton(
|
||||
label = action.labelForPrimaryNavigation(),
|
||||
icon = action.iconForPrimaryNavigation(),
|
||||
onClick = { onPrimaryAction(action) }
|
||||
)
|
||||
}
|
||||
}
|
||||
SharedSidebarNavItem(
|
||||
tab = tab,
|
||||
selected = selectedTab == tab,
|
||||
onClick = { onTabSelected(tab) }
|
||||
)
|
||||
if (tab == SharedAppTab.PRO && syncToggleModel.visible) {
|
||||
SharedSidebarSyncToggle(
|
||||
model = syncToggleModel,
|
||||
onSyncEnabledChange = onSyncEnabledChange
|
||||
)
|
||||
}
|
||||
}
|
||||
if (SharedAppTab.PRO !in primaryTabs) {
|
||||
primaryActions.forEach { action ->
|
||||
SharedSidebarButton(
|
||||
label = action.labelForPrimaryNavigation(),
|
||||
icon = action.iconForPrimaryNavigation(),
|
||||
onClick = { onPrimaryAction(action) }
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
HorizontalDivider()
|
||||
SharedSidebarButton(
|
||||
label = readerString("desktop_tools", "Tools"),
|
||||
icon = Icons.Default.Settings,
|
||||
onClick = onToolsClick
|
||||
)
|
||||
moreMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -281,11 +336,23 @@ private fun SharedAppSidebar(
|
|||
private fun SharedAppCompactRail(
|
||||
selectedTab: SharedAppTab,
|
||||
primaryTabs: List<SharedAppTab>,
|
||||
primaryActions: List<SharedAppToolAction>,
|
||||
onTabSelected: (SharedAppTab) -> Unit,
|
||||
onToolsClick: () -> Unit
|
||||
onPrimaryAction: (SharedAppToolAction) -> Unit,
|
||||
moreMenu: @Composable () -> Unit
|
||||
) {
|
||||
NavigationRail(containerColor = MaterialTheme.colorScheme.surfaceContainerLow) {
|
||||
primaryTabs.forEach { tab ->
|
||||
if (tab == SharedAppTab.PRO) {
|
||||
primaryActions.forEach { action ->
|
||||
NavigationRailItem(
|
||||
selected = false,
|
||||
onClick = { onPrimaryAction(action) },
|
||||
icon = { Icon(action.iconForPrimaryNavigation(), contentDescription = null) },
|
||||
label = { Text(action.labelForPrimaryNavigation()) }
|
||||
)
|
||||
}
|
||||
}
|
||||
NavigationRailItem(
|
||||
selected = selectedTab == tab,
|
||||
onClick = { onTabSelected(tab) },
|
||||
|
|
@ -293,13 +360,167 @@ private fun SharedAppCompactRail(
|
|||
label = { Text(tab.localizedLabel()) }
|
||||
)
|
||||
}
|
||||
if (SharedAppTab.PRO !in primaryTabs) {
|
||||
primaryActions.forEach { action ->
|
||||
NavigationRailItem(
|
||||
selected = false,
|
||||
onClick = { onPrimaryAction(action) },
|
||||
icon = { Icon(action.iconForPrimaryNavigation(), contentDescription = null) },
|
||||
label = { Text(action.labelForPrimaryNavigation()) }
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
IconButton(onClick = onToolsClick) {
|
||||
Icon(Icons.Default.Settings, contentDescription = readerString("desktop_tools", "Tools"))
|
||||
moreMenu()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSidebarHeader(
|
||||
currentUser: UserData?,
|
||||
accountAvailable: Boolean,
|
||||
isOssBuild: Boolean,
|
||||
onAccountClick: () -> Unit,
|
||||
onSignInRequested: (() -> Unit)?,
|
||||
accountAvatar: (@Composable (UserData, Modifier) -> Unit)?
|
||||
) {
|
||||
val signInRequested = onSignInRequested
|
||||
val avatarContent = accountAvatar
|
||||
when {
|
||||
isOssBuild -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
SharedInitialAvatar(initial = "E", modifier = Modifier.size(42.dp))
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
readerString("desktop_app_name_oss", "Episteme oss"),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
readerString("desktop_offline_oss_reader", "Offline desktop reader"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
currentUser != null -> {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)),
|
||||
onClick = onAccountClick
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (avatarContent != null) {
|
||||
avatarContent(currentUser, Modifier.size(42.dp))
|
||||
} else {
|
||||
SharedInitialAvatar(initial = currentUser.initial(), modifier = Modifier.size(42.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
currentUser.displayName ?: currentUser.email ?: readerString("desktop_signed_in", "Signed in"),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
currentUser.email ?: readerString("desktop_account_and_credits", "Account & credits"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
accountAvailable && signInRequested != null -> {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)),
|
||||
onClick = signInRequested
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.AccountCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(42.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
readerString("drawer_sign_in", "Sign in with Google"),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
readerString("desktop_sign_in_account_header_desc", "Sync account, Pro, and credits"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Column(Modifier.padding(horizontal = 10.dp, vertical = 12.dp)) {
|
||||
Text(readerString("app_name", "Episteme"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Text(readerString("desktop_library_and_reader", "Library and reader"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedInitialAvatar(
|
||||
initial: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(initial, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun UserData.initial(): String {
|
||||
return (displayName ?: email ?: "E")
|
||||
.trim()
|
||||
.firstOrNull()
|
||||
?.uppercase()
|
||||
?: "E"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSidebarNavItem(
|
||||
tab: SharedAppTab,
|
||||
|
|
@ -334,6 +555,68 @@ private fun SharedSidebarNavItem(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSidebarSyncToggle(
|
||||
model: SharedSidebarSyncToggleModel,
|
||||
onSyncEnabledChange: (Boolean) -> Unit
|
||||
) {
|
||||
val title = readerString("desktop_cloud_sync", "Cloud sync")
|
||||
val summary = when {
|
||||
!model.enabled -> readerString("desktop_pro_required", "Pro required")
|
||||
model.checked -> readerString("content_desc_enabled", "Enabled")
|
||||
else -> readerString("desktop_disabled", "Disabled")
|
||||
}
|
||||
val contentColor = if (model.enabled) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.62f)
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
contentColor = contentColor,
|
||||
onClick = {
|
||||
if (model.enabled) {
|
||||
onSyncEnabledChange(!model.checked)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Sync, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
summary,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = model.checked,
|
||||
enabled = model.enabled,
|
||||
onCheckedChange = { checked ->
|
||||
if (model.enabled) {
|
||||
onSyncEnabledChange(checked)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSidebarButton(
|
||||
label: String,
|
||||
|
|
@ -359,11 +642,10 @@ private fun SharedSidebarButton(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedToolsPanel(
|
||||
modifier: Modifier,
|
||||
private fun SharedMoreMenuButton(
|
||||
compact: Boolean,
|
||||
moreSections: List<SharedAppMoreSection>,
|
||||
isTabsEnabled: Boolean,
|
||||
toolActions: List<SharedAppToolAction>,
|
||||
onClose: () -> Unit,
|
||||
onImportFiles: () -> Unit,
|
||||
onImportFolder: () -> Unit,
|
||||
onSyncRequested: () -> Unit,
|
||||
|
|
@ -373,190 +655,178 @@ private fun SharedToolsPanel(
|
|||
onOpenTab: (SharedAppTab) -> Unit,
|
||||
onTabsEnabledChange: (Boolean) -> Unit
|
||||
) {
|
||||
val hasWorkspaceActions = SharedAppToolAction.SETTINGS in toolActions ||
|
||||
SharedAppToolAction.APP_THEME in toolActions ||
|
||||
SharedAppToolAction.TABS_TOGGLE in toolActions
|
||||
val hasLibraryActions = SharedAppToolAction.IMPORT_FILES in toolActions ||
|
||||
SharedAppToolAction.IMPORT_FOLDER in toolActions ||
|
||||
SharedAppToolAction.SYNC in toolActions
|
||||
val hasSettingsActions = SharedAppToolAction.PRO in toolActions ||
|
||||
SharedAppToolAction.AI_SETTINGS in toolActions ||
|
||||
SharedAppToolAction.CUSTOM_FONTS in toolActions
|
||||
val hasProjectActions = SharedAppToolAction.HELP_FEEDBACK in toolActions ||
|
||||
SharedAppToolAction.SUPPORT in toolActions ||
|
||||
SharedAppToolAction.ABOUT in toolActions
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 8.dp,
|
||||
shadowElevation = 8.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(readerString("desktop_tools", "Tools"), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text(readerString("desktop_tools_desc", "Import, sync, and app settings"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Default.Close, contentDescription = readerString("desktop_close_tools", "Close tools"))
|
||||
}
|
||||
fun runAction(action: SharedAppToolAction) {
|
||||
expanded = false
|
||||
when (action) {
|
||||
SharedAppToolAction.SETTINGS -> onOpenTab(SharedAppTab.SETTINGS)
|
||||
SharedAppToolAction.IMPORT_FILES -> onImportFiles()
|
||||
SharedAppToolAction.IMPORT_FOLDER -> onImportFolder()
|
||||
SharedAppToolAction.SYNC -> onSyncRequested()
|
||||
SharedAppToolAction.APP_THEME -> onAppThemeRequested()
|
||||
SharedAppToolAction.PRO -> onOpenTab(SharedAppTab.PRO)
|
||||
SharedAppToolAction.AI_SETTINGS -> onAiSettingsRequested()
|
||||
SharedAppToolAction.CUSTOM_FONTS -> onOpenTab(SharedAppTab.CUSTOM_FONTS)
|
||||
SharedAppToolAction.HELP_FEEDBACK -> onOpenTab(SharedAppTab.FEEDBACK)
|
||||
SharedAppToolAction.SUPPORT -> onOpenTab(SharedAppTab.SUPPORT)
|
||||
SharedAppToolAction.ABOUT -> onOpenTab(SharedAppTab.ABOUT)
|
||||
SharedAppToolAction.TABS_TOGGLE -> onTabsEnabledChange(!isTabsEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = if (compact) Modifier else Modifier.fillMaxWidth()) {
|
||||
if (compact) {
|
||||
IconButton(onClick = { expanded = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = readerString("desktop_more_menu", "More"))
|
||||
}
|
||||
|
||||
if (hasWorkspaceActions) {
|
||||
SharedToolsSection(readerString("desktop_workspace", "Workspace")) {
|
||||
if (SharedAppToolAction.SETTINGS in toolActions) {
|
||||
SharedToolRow(Icons.Default.Settings, readerString("desktop_settings_hub", "Settings hub")) { onOpenTab(SharedAppTab.SETTINGS) }
|
||||
}
|
||||
if (SharedAppToolAction.APP_THEME in toolActions) {
|
||||
SharedToolRow(
|
||||
icon = Icons.Default.Palette,
|
||||
title = readerString("app_theme_title", "App theme"),
|
||||
onClick = onAppThemeRequested
|
||||
} else {
|
||||
SharedSidebarButton(
|
||||
label = readerString("desktop_more_menu", "More"),
|
||||
icon = Icons.Default.MoreVert,
|
||||
onClick = { expanded = true }
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = Modifier.width(300.dp)
|
||||
) {
|
||||
moreSections.forEachIndexed { sectionIndex, section ->
|
||||
SharedMoreMenuSectionLabel(section.group)
|
||||
section.actions.forEach { action ->
|
||||
if (action == SharedAppToolAction.SYNC && onFolderMetadataSyncRequested != null) {
|
||||
SharedMoreMenuItem(
|
||||
icon = Icons.Default.Sync,
|
||||
title = readerString("desktop_sync_metadata", "Sync metadata"),
|
||||
onClick = {
|
||||
expanded = false
|
||||
onFolderMetadataSyncRequested()
|
||||
}
|
||||
)
|
||||
SharedMoreMenuItem(
|
||||
icon = Icons.Default.Search,
|
||||
title = readerString("desktop_full_scan", "Full scan"),
|
||||
onClick = { runAction(action) }
|
||||
)
|
||||
} else {
|
||||
SharedMoreMenuItem(
|
||||
icon = action.iconForMoreMenu(),
|
||||
title = action.labelForMoreMenu(isTabsEnabled),
|
||||
checked = if (action == SharedAppToolAction.TABS_TOGGLE) isTabsEnabled else null,
|
||||
onClick = { runAction(action) }
|
||||
)
|
||||
}
|
||||
if (SharedAppToolAction.TABS_TOGGLE in toolActions) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 2.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(readerString("desktop_open_readers", "Open readers"), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
if (isTabsEnabled) readerString("content_desc_enabled", "Enabled") else readerString("desktop_disabled", "Disabled"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = isTabsEnabled,
|
||||
onCheckedChange = onTabsEnabledChange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sectionIndex != moreSections.lastIndex) {
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
if (hasLibraryActions) {
|
||||
SharedToolsSection(readerString("library_title", "Library")) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
if (SharedAppToolAction.IMPORT_FILES in toolActions) {
|
||||
Button(onClick = onImportFiles, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Default.ImportExport, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(readerString("desktop_import_files", "Import files"))
|
||||
}
|
||||
}
|
||||
if (SharedAppToolAction.IMPORT_FOLDER in toolActions) {
|
||||
OutlinedButton(onClick = onImportFolder, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Default.CreateNewFolder, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(readerString("fab_add_folder", "Add folder"))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (SharedAppToolAction.SYNC in toolActions) {
|
||||
if (onFolderMetadataSyncRequested == null) {
|
||||
FilledTonalButton(onClick = onSyncRequested, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(Icons.Default.Sync, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(readerString("desktop_sync_folders", "Sync folders"))
|
||||
}
|
||||
} else {
|
||||
SharedToolRow(Icons.Default.Sync, readerString("desktop_sync_metadata", "Sync metadata"), onFolderMetadataSyncRequested)
|
||||
SharedToolRow(Icons.Default.Search, readerString("desktop_full_scan", "Full scan")) {
|
||||
onSyncRequested()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasSettingsActions) {
|
||||
SharedToolsSection(readerString("settings", "Settings")) {
|
||||
if (SharedAppToolAction.PRO in toolActions) {
|
||||
SharedToolRow(Icons.Default.Star, readerString("desktop_pro_and_credits", "Pro and credits")) { onOpenTab(SharedAppTab.PRO) }
|
||||
}
|
||||
if (SharedAppToolAction.AI_SETTINGS in toolActions) {
|
||||
SharedToolRow(Icons.Default.Settings, readerString("ai_settings_title", "AI keys and models"), onAiSettingsRequested)
|
||||
}
|
||||
if (SharedAppToolAction.CUSTOM_FONTS in toolActions) {
|
||||
SharedToolRow(Icons.Default.TextFields, readerString("custom_fonts", "Custom fonts")) { onOpenTab(SharedAppTab.CUSTOM_FONTS) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasProjectActions) {
|
||||
SharedToolsSection(readerString("desktop_project", "Project")) {
|
||||
if (SharedAppToolAction.HELP_FEEDBACK in toolActions) {
|
||||
SharedToolRow(Icons.Default.Feedback, readerString("drawer_help_feedback", "Help & feedback")) { onOpenTab(SharedAppTab.FEEDBACK) }
|
||||
}
|
||||
if (SharedAppToolAction.SUPPORT in toolActions) {
|
||||
SharedToolRow(Icons.Default.Favorite, readerString("drawer_support_project", "Support project")) { onOpenTab(SharedAppTab.SUPPORT) }
|
||||
}
|
||||
if (SharedAppToolAction.ABOUT in toolActions) {
|
||||
SharedToolRow(Icons.Default.Info, readerString("about_title", "About Episteme")) { onOpenTab(SharedAppTab.ABOUT) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedToolsSection(
|
||||
title: String,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
|
||||
content()
|
||||
}
|
||||
private fun SharedMoreMenuSectionLabel(group: SharedAppMoreGroup) {
|
||||
Text(
|
||||
group.labelForMoreMenu(),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedToolRow(
|
||||
private fun SharedMoreMenuItem(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
checked: Boolean? = null,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
DropdownMenuItem(
|
||||
text = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
leadingIcon = { Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp)) },
|
||||
trailingIcon = checked?.let { isChecked ->
|
||||
{ Switch(checked = isChecked, onCheckedChange = null) }
|
||||
},
|
||||
onClick = onClick
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Text(title, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedAppToolAction.labelForPrimaryNavigation(): String {
|
||||
return when (this) {
|
||||
SharedAppToolAction.AI_SETTINGS -> readerString("desktop_ai_keys", "AI keys")
|
||||
else -> labelForMoreMenu(isTabsEnabled = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SharedAppToolAction.iconForPrimaryNavigation(): ImageVector {
|
||||
return when (this) {
|
||||
SharedAppToolAction.AI_SETTINGS -> Icons.Default.Lock
|
||||
else -> iconForMoreMenu()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedAppMoreGroup.labelForMoreMenu(): String {
|
||||
return when (this) {
|
||||
SharedAppMoreGroup.LIBRARY -> readerString("desktop_more_library_actions", "Library actions")
|
||||
SharedAppMoreGroup.ACCOUNT -> readerString("desktop_account_and_credits", "Account & credits")
|
||||
SharedAppMoreGroup.PREFERENCES -> readerString("desktop_preferences", "Preferences")
|
||||
SharedAppMoreGroup.HELP -> readerString("desktop_help", "Help")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedAppToolAction.labelForMoreMenu(isTabsEnabled: Boolean): String {
|
||||
return when (this) {
|
||||
SharedAppToolAction.SETTINGS -> readerString("settings", "Settings")
|
||||
SharedAppToolAction.IMPORT_FILES -> readerString("desktop_import_files", "Import files")
|
||||
SharedAppToolAction.IMPORT_FOLDER -> readerString("fab_add_folder", "Add folder")
|
||||
SharedAppToolAction.SYNC -> readerString("desktop_sync_folders", "Sync folders")
|
||||
SharedAppToolAction.APP_THEME -> readerString("app_theme_title", "App theme")
|
||||
SharedAppToolAction.PRO -> readerString("desktop_account_and_credits", "Account & credits")
|
||||
SharedAppToolAction.AI_SETTINGS -> readerString("ai_settings_title", "AI keys and models")
|
||||
SharedAppToolAction.CUSTOM_FONTS -> readerString("custom_fonts", "Custom fonts")
|
||||
SharedAppToolAction.HELP_FEEDBACK -> readerString("drawer_help_feedback", "Help & feedback")
|
||||
SharedAppToolAction.SUPPORT -> readerString("drawer_support_project", "Support project")
|
||||
SharedAppToolAction.ABOUT -> readerString("about_title", "About Episteme")
|
||||
SharedAppToolAction.TABS_TOGGLE -> if (isTabsEnabled) {
|
||||
readerString("desktop_reader_tabs_on", "Reader tabs on")
|
||||
} else {
|
||||
readerString("desktop_reader_tabs_off", "Reader tabs off")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SharedAppToolAction.iconForMoreMenu(): ImageVector {
|
||||
return when (this) {
|
||||
SharedAppToolAction.SETTINGS -> Icons.Default.Settings
|
||||
SharedAppToolAction.IMPORT_FILES -> Icons.Default.ImportExport
|
||||
SharedAppToolAction.IMPORT_FOLDER -> Icons.Default.CreateNewFolder
|
||||
SharedAppToolAction.SYNC -> Icons.Default.Sync
|
||||
SharedAppToolAction.APP_THEME -> Icons.Default.Palette
|
||||
SharedAppToolAction.PRO -> Icons.Default.Star
|
||||
SharedAppToolAction.AI_SETTINGS -> Icons.Default.Settings
|
||||
SharedAppToolAction.CUSTOM_FONTS -> Icons.Default.TextFields
|
||||
SharedAppToolAction.HELP_FEEDBACK -> Icons.Default.Feedback
|
||||
SharedAppToolAction.SUPPORT -> Icons.Default.Favorite
|
||||
SharedAppToolAction.ABOUT -> Icons.Default.Info
|
||||
SharedAppToolAction.TABS_TOGGLE -> Icons.AutoMirrored.Filled.MenuBook
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedAppTab.localizedLabel(): String {
|
||||
return when (this) {
|
||||
SharedAppTab.HOME -> readerString("nav_home", "Home")
|
||||
SharedAppTab.LIBRARY -> readerString("library_title", "Library")
|
||||
SharedAppTab.SHELVES -> readerString("tab_shelves", "Shelves")
|
||||
SharedAppTab.CATALOGS -> readerString("opds_stream", "OPDS")
|
||||
SharedAppTab.READER -> readerString("desktop_reader", "Reader")
|
||||
SharedAppTab.SETTINGS -> readerString("settings", "Settings")
|
||||
SharedAppTab.PRO -> readerString("desktop_pro", "Pro")
|
||||
SharedAppTab.PRO -> readerString("desktop_account_and_credits", "Account & credits")
|
||||
SharedAppTab.CUSTOM_FONTS -> readerString("custom_fonts", "Custom fonts")
|
||||
SharedAppTab.SUPPORT -> readerString("desktop_support", "Support")
|
||||
SharedAppTab.FEEDBACK -> readerString("desktop_feedback", "Feedback")
|
||||
|
|
@ -566,7 +836,6 @@ private fun SharedAppTab.localizedLabel(): String {
|
|||
|
||||
private val SharedAppTab.icon: ImageVector
|
||||
get() = when (this) {
|
||||
SharedAppTab.HOME -> Icons.Default.Home
|
||||
SharedAppTab.LIBRARY -> Icons.AutoMirrored.Filled.LibraryBooks
|
||||
SharedAppTab.SHELVES -> Icons.Default.Folder
|
||||
SharedAppTab.CATALOGS -> Icons.Default.Cloud
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import androidx.compose.foundation.border
|
|||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.drag
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -49,6 +48,7 @@ import androidx.compose.material3.Typography
|
|||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -64,6 +64,9 @@ import androidx.compose.ui.graphics.SolidColor
|
|||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.changedToUp
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.TextRange
|
||||
|
|
@ -267,112 +270,29 @@ fun SharedAppThemeSettingsDialog(
|
|||
onCustomThemeDeleted: (String) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var showCreateDialog by remember { mutableStateOf(false) }
|
||||
val defaultCustomThemeName = readerString("desktop_custom_theme_default", "Custom")
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(readerString("app_theme_title", "App theme"), fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Column(
|
||||
SharedAppThemeControls(
|
||||
appThemeMode = appThemeMode,
|
||||
appContrastOption = appContrastOption,
|
||||
appTextDimFactorLight = appTextDimFactorLight,
|
||||
appTextDimFactorDark = appTextDimFactorDark,
|
||||
appSeedColor = appSeedColor,
|
||||
customAppThemes = customAppThemes,
|
||||
onThemeModeChanged = onThemeModeChanged,
|
||||
onContrastOptionChanged = onContrastOptionChanged,
|
||||
onTextDimFactorLightChanged = onTextDimFactorLightChanged,
|
||||
onTextDimFactorDarkChanged = onTextDimFactorDarkChanged,
|
||||
onSeedColorChanged = onSeedColorChanged,
|
||||
onCustomThemeAdded = onCustomThemeAdded,
|
||||
onCustomThemeDeleted = onCustomThemeDeleted,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 620.dp)
|
||||
.heightIn(max = 620.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
SettingsLabel(readerString("app_theme_appearance", "Appearance"))
|
||||
SegmentedControl(
|
||||
values = AppThemeMode.entries,
|
||||
selectedValue = appThemeMode,
|
||||
label = { it.localizedLabel() },
|
||||
onValueSelected = onThemeModeChanged
|
||||
)
|
||||
|
||||
SettingsLabel(readerString("app_theme_contrast", "Contrast"))
|
||||
SegmentedControl(
|
||||
values = AppContrastOption.entries,
|
||||
selectedValue = appContrastOption,
|
||||
label = { it.localizedLabel() },
|
||||
onValueSelected = onContrastOptionChanged
|
||||
)
|
||||
|
||||
if (appThemeMode == AppThemeMode.SYSTEM) {
|
||||
TextBrightnessSlider(
|
||||
label = readerString("app_theme_text_brightness_light", "Text brightness (Light)"),
|
||||
value = appTextDimFactorLight,
|
||||
onValueChange = onTextDimFactorLightChanged
|
||||
)
|
||||
TextBrightnessSlider(
|
||||
label = readerString("app_theme_text_brightness_dark", "Text brightness (Dark)"),
|
||||
value = appTextDimFactorDark,
|
||||
onValueChange = onTextDimFactorDarkChanged
|
||||
)
|
||||
} else {
|
||||
TextBrightnessSlider(
|
||||
label = readerString("app_theme_text_brightness", "Text brightness"),
|
||||
value = if (appThemeMode == AppThemeMode.DARK) appTextDimFactorDark else appTextDimFactorLight,
|
||||
onValueChange = if (appThemeMode == AppThemeMode.DARK) onTextDimFactorDarkChanged else onTextDimFactorLightChanged
|
||||
)
|
||||
}
|
||||
|
||||
SettingsLabel(readerString("app_theme_color_scheme", "Color scheme"))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
ThemeSwatch(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
selected = appSeedColor == null,
|
||||
label = readerString("app_theme_dynamic", "Dynamic"),
|
||||
onClick = { onSeedColorChanged(null) }
|
||||
)
|
||||
AppThemePresets.forEach { preset ->
|
||||
ThemeSwatch(
|
||||
color = preset.color,
|
||||
selected = appSeedColor == preset.color,
|
||||
label = readerString(preset.nameKey, preset.nameFallback),
|
||||
onClick = { onSeedColorChanged(preset.color) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
SettingsLabel(readerString("theme_my_themes", "My themes"))
|
||||
IconButton(onClick = { showCreateDialog = true }, modifier = Modifier.size(32.dp)) {
|
||||
Icon(Icons.Default.Add, contentDescription = readerString("content_desc_add_custom_theme", "Add custom theme"))
|
||||
}
|
||||
}
|
||||
|
||||
if (customAppThemes.isEmpty()) {
|
||||
Text(
|
||||
readerString("desktop_no_custom_themes_yet", "No custom themes yet"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
customAppThemes.forEach { theme ->
|
||||
ThemeSwatch(
|
||||
color = theme.seedColor,
|
||||
selected = appSeedColor == theme.seedColor,
|
||||
label = theme.name,
|
||||
onClick = { onSeedColorChanged(theme.seedColor) },
|
||||
onDelete = { onCustomThemeDeleted(theme.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.verticalScroll(rememberScrollState())
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
|
|
@ -380,6 +300,124 @@ fun SharedAppThemeSettingsDialog(
|
|||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedAppThemeControls(
|
||||
appThemeMode: AppThemeMode,
|
||||
appContrastOption: AppContrastOption,
|
||||
appTextDimFactorLight: Float,
|
||||
appTextDimFactorDark: Float,
|
||||
appSeedColor: Color?,
|
||||
customAppThemes: List<CustomAppTheme>,
|
||||
onThemeModeChanged: (AppThemeMode) -> Unit,
|
||||
onContrastOptionChanged: (AppContrastOption) -> Unit,
|
||||
onTextDimFactorLightChanged: (Float) -> Unit,
|
||||
onTextDimFactorDarkChanged: (Float) -> Unit,
|
||||
onSeedColorChanged: (Color?) -> Unit,
|
||||
onCustomThemeAdded: (CustomAppTheme) -> Unit,
|
||||
onCustomThemeDeleted: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var showCreateDialog by remember { mutableStateOf(false) }
|
||||
val defaultCustomThemeName = readerString("desktop_custom_theme_default", "Custom")
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
SettingsLabel(readerString("app_theme_appearance", "Appearance"))
|
||||
SegmentedControl(
|
||||
values = AppThemeMode.entries,
|
||||
selectedValue = appThemeMode,
|
||||
label = { it.localizedLabel() },
|
||||
onValueSelected = onThemeModeChanged
|
||||
)
|
||||
|
||||
SettingsLabel(readerString("app_theme_contrast", "Contrast"))
|
||||
SegmentedControl(
|
||||
values = AppContrastOption.entries,
|
||||
selectedValue = appContrastOption,
|
||||
label = { it.localizedLabel() },
|
||||
onValueSelected = onContrastOptionChanged
|
||||
)
|
||||
|
||||
if (appThemeMode == AppThemeMode.SYSTEM) {
|
||||
TextBrightnessSlider(
|
||||
label = readerString("app_theme_text_brightness_light", "Text brightness (Light)"),
|
||||
value = appTextDimFactorLight,
|
||||
onValueChange = onTextDimFactorLightChanged
|
||||
)
|
||||
TextBrightnessSlider(
|
||||
label = readerString("app_theme_text_brightness_dark", "Text brightness (Dark)"),
|
||||
value = appTextDimFactorDark,
|
||||
onValueChange = onTextDimFactorDarkChanged
|
||||
)
|
||||
} else {
|
||||
TextBrightnessSlider(
|
||||
label = readerString("app_theme_text_brightness", "Text brightness"),
|
||||
value = if (appThemeMode == AppThemeMode.DARK) appTextDimFactorDark else appTextDimFactorLight,
|
||||
onValueChange = if (appThemeMode == AppThemeMode.DARK) onTextDimFactorDarkChanged else onTextDimFactorLightChanged
|
||||
)
|
||||
}
|
||||
|
||||
SettingsLabel(readerString("app_theme_color_scheme", "Color scheme"))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
ThemeSwatch(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
selected = appSeedColor == null,
|
||||
label = readerString("app_theme_dynamic", "Dynamic"),
|
||||
onClick = { onSeedColorChanged(null) }
|
||||
)
|
||||
AppThemePresets.forEach { preset ->
|
||||
ThemeSwatch(
|
||||
color = preset.color,
|
||||
selected = appSeedColor == preset.color,
|
||||
label = readerString(preset.nameKey, preset.nameFallback),
|
||||
onClick = { onSeedColorChanged(preset.color) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
SettingsLabel(readerString("theme_my_themes", "My themes"))
|
||||
IconButton(onClick = { showCreateDialog = true }, modifier = Modifier.size(32.dp)) {
|
||||
Icon(Icons.Default.Add, contentDescription = readerString("content_desc_add_custom_theme", "Add custom theme"))
|
||||
}
|
||||
}
|
||||
|
||||
if (customAppThemes.isEmpty()) {
|
||||
Text(
|
||||
readerString("desktop_no_custom_themes_yet", "No custom themes yet"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
customAppThemes.forEach { theme ->
|
||||
ThemeSwatch(
|
||||
color = theme.seedColor,
|
||||
selected = appSeedColor == theme.seedColor,
|
||||
label = theme.name,
|
||||
onClick = { onSeedColorChanged(theme.seedColor) },
|
||||
onDelete = { onCustomThemeDeleted(theme.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCreateDialog) {
|
||||
SharedCreateAppThemeDialog(
|
||||
|
|
@ -656,11 +694,19 @@ fun SharedHsvColorPickerDialog(
|
|||
onDismiss: () -> Unit,
|
||||
onSave: (Color) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
resetColor: Color? = null,
|
||||
stateKey: Any? = null,
|
||||
onLiveColorChange: (Color) -> Unit = {},
|
||||
preview: @Composable (Color) -> Unit = {}
|
||||
) {
|
||||
var hsv by remember(initialColor) { mutableStateOf(initialColor.toSharedHsvColor()) }
|
||||
val effectiveStateKey = stateKey ?: initialColor
|
||||
var hsv by remember(effectiveStateKey) { mutableStateOf(initialColor.toSharedHsvColor()) }
|
||||
val color = hsv.toComposeColor()
|
||||
|
||||
LaunchedEffect(effectiveStateKey, color) {
|
||||
onLiveColorChange(color)
|
||||
}
|
||||
|
||||
fun updateFromColor(nextColor: Color) {
|
||||
hsv = nextColor.toSharedHsvColor()
|
||||
}
|
||||
|
|
@ -709,7 +755,8 @@ fun SharedHsvColorPickerDialog(
|
|||
onHueSatChanged = { hue, saturation ->
|
||||
hsv = hsv.copy(hue = hue, saturation = saturation)
|
||||
},
|
||||
modifier = Modifier.size(240.dp)
|
||||
modifier = Modifier.size(240.dp),
|
||||
gestureKey = effectiveStateKey
|
||||
)
|
||||
|
||||
SharedBrightnessSlider(
|
||||
|
|
@ -717,7 +764,8 @@ fun SharedHsvColorPickerDialog(
|
|||
saturation = hsv.saturation,
|
||||
value = hsv.value,
|
||||
onValueChanged = { hsv = hsv.copy(value = it) },
|
||||
modifier = Modifier.fillMaxWidth().height(24.dp).clip(RoundedCornerShape(12.dp))
|
||||
modifier = Modifier.fillMaxWidth().height(24.dp).clip(RoundedCornerShape(12.dp)),
|
||||
gestureKey = effectiveStateKey
|
||||
)
|
||||
|
||||
Row(
|
||||
|
|
@ -767,9 +815,14 @@ fun SharedHsvColorPickerDialog(
|
|||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (resetColor != null) {
|
||||
TextButton(onClick = { updateFromColor(resetColor) }) {
|
||||
Text(readerString("action_reset", "Reset"), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(readerString("action_cancel", "Cancel"))
|
||||
}
|
||||
|
|
@ -795,32 +848,23 @@ fun SharedHsvWheel(
|
|||
saturation: Float,
|
||||
currentColor: Color,
|
||||
onHueSatChanged: (Float, Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
gestureKey: Any? = Unit
|
||||
) {
|
||||
val touchPadding = 12.dp
|
||||
|
||||
Box(
|
||||
modifier = modifier.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
val paddingPx = touchPadding.toPx()
|
||||
|
||||
fun update(offset: Offset) {
|
||||
val selection = sharedHsvWheelSelection(
|
||||
offsetX = offset.x,
|
||||
offsetY = offset.y,
|
||||
width = size.width.toFloat(),
|
||||
height = size.height.toFloat(),
|
||||
paddingPx = paddingPx
|
||||
)
|
||||
onHueSatChanged(selection.hue, selection.saturation)
|
||||
}
|
||||
|
||||
update(down.position)
|
||||
drag(down.id) { change ->
|
||||
change.consume()
|
||||
update(change.position)
|
||||
}
|
||||
modifier = modifier.pointerInput(gestureKey) {
|
||||
val paddingPx = touchPadding.toPx()
|
||||
awaitSharedColorPickerDrag { offset ->
|
||||
val selection = sharedHsvWheelSelection(
|
||||
offsetX = offset.x,
|
||||
offsetY = offset.y,
|
||||
width = size.width.toFloat(),
|
||||
height = size.height.toFloat(),
|
||||
paddingPx = paddingPx
|
||||
)
|
||||
onHueSatChanged(selection.hue, selection.saturation)
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
|
@ -896,7 +940,8 @@ fun SharedSpectrumBox(
|
|||
saturation: Float,
|
||||
currentColor: Color,
|
||||
onHueSatChanged: (Float, Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
gestureKey: Any? = Unit
|
||||
) {
|
||||
val rainbowColors = listOf(
|
||||
Color.Red,
|
||||
|
|
@ -910,26 +955,16 @@ fun SharedSpectrumBox(
|
|||
val touchPadding = 12.dp
|
||||
|
||||
Box(
|
||||
modifier = modifier.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
val paddingPx = touchPadding.toPx()
|
||||
modifier = modifier.pointerInput(gestureKey) {
|
||||
val paddingPx = touchPadding.toPx()
|
||||
awaitSharedColorPickerDrag { offset ->
|
||||
val activeWidth = size.width.toFloat() - (paddingPx * 2)
|
||||
val activeHeight = size.height.toFloat() - (paddingPx * 2)
|
||||
|
||||
fun update(offset: Offset) {
|
||||
val relativeX = offset.x - paddingPx
|
||||
val relativeY = offset.y - paddingPx
|
||||
val nextHue = (relativeX / activeWidth).coerceIn(0f, 1f) * 360f
|
||||
val nextSaturation = (relativeY / activeHeight).coerceIn(0f, 1f)
|
||||
onHueSatChanged(nextHue, nextSaturation)
|
||||
}
|
||||
|
||||
update(down.position)
|
||||
drag(down.id) { change ->
|
||||
change.consume()
|
||||
update(change.position)
|
||||
}
|
||||
val relativeX = offset.x - paddingPx
|
||||
val relativeY = offset.y - paddingPx
|
||||
val nextHue = (relativeX / activeWidth).coerceIn(0f, 1f) * 360f
|
||||
val nextSaturation = (relativeY / activeHeight).coerceIn(0f, 1f)
|
||||
onHueSatChanged(nextHue, nextSaturation)
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
|
@ -982,27 +1017,18 @@ fun SharedBrightnessSlider(
|
|||
saturation: Float,
|
||||
value: Float,
|
||||
onValueChanged: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
gestureKey: Any? = Unit
|
||||
) {
|
||||
val baseColor = remember(hue, saturation) {
|
||||
Color.hsv(hue, saturation, 1f)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
|
||||
fun update(offset: Offset) {
|
||||
val nextValue = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
|
||||
onValueChanged(nextValue)
|
||||
}
|
||||
|
||||
update(down.position)
|
||||
drag(down.id) { change ->
|
||||
change.consume()
|
||||
update(change.position)
|
||||
}
|
||||
modifier = modifier.pointerInput(gestureKey) {
|
||||
awaitSharedColorPickerDrag { offset ->
|
||||
val nextValue = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
|
||||
onValueChanged(nextValue)
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
|
@ -1021,6 +1047,27 @@ fun SharedBrightnessSlider(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun PointerInputScope.awaitSharedColorPickerDrag(
|
||||
onPosition: (Offset) -> Unit
|
||||
) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||
down.consume()
|
||||
onPosition(down.position)
|
||||
val pointerId = down.id
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
val change = event.changes.firstOrNull { it.id == pointerId } ?: return@awaitEachGesture
|
||||
onPosition(change.position)
|
||||
change.consume()
|
||||
if (change.changedToUp() || !change.pressed) {
|
||||
return@awaitEachGesture
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedRgbInputColumn(
|
||||
label: String,
|
||||
|
|
|
|||
|
|
@ -30,9 +30,11 @@ import androidx.compose.material.icons.filled.ExpandMore
|
|||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Restore
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
|
|
@ -62,6 +64,7 @@ import com.aryan.reader.shared.BookItem
|
|||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.Shelf
|
||||
import com.aryan.reader.shared.Tag
|
||||
import com.aryan.reader.shared.cardAuthor
|
||||
import com.aryan.reader.shared.cardTitle
|
||||
import com.aryan.reader.shared.formatFileSize
|
||||
import com.aryan.reader.shared.parseTagList
|
||||
|
|
@ -131,32 +134,51 @@ fun SharedAddToShelfDialog(
|
|||
shelves: List<Shelf>,
|
||||
onDismiss: () -> Unit,
|
||||
onCreateShelf: () -> Unit,
|
||||
onShelfSelected: (Shelf) -> Unit
|
||||
onShelvesSelected: (Set<String>) -> Unit
|
||||
) {
|
||||
var selectedShelfIds by remember(shelves) { mutableStateOf(emptySet<String>()) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(readerString("desktop_add_to_shelf", "Add to shelf")) },
|
||||
text = {
|
||||
if (shelves.isEmpty()) {
|
||||
Text(readerString("desktop_create_shelf_first", "Create a shelf first, then add selected books to it."))
|
||||
Text(readerString("desktop_create_shelf_for_books", "Create a shelf and the chosen books will be added to it."))
|
||||
} else {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
items(shelves, key = { it.id }) { shelf ->
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth().clickable { onShelfSelected(shelf) }
|
||||
) {
|
||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
shelf.name,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text("${shelf.bookCount}", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
readerString("desktop_select_shelves_to_add", "Choose one or more shelves."),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.heightIn(max = 360.dp)) {
|
||||
items(shelves, key = { it.id }) { shelf ->
|
||||
val selected = shelf.id in selectedShelfIds
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth().clickable {
|
||||
selectedShelfIds = selectedShelfIds.toggle(shelf.id)
|
||||
}
|
||||
) {
|
||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = selected,
|
||||
onCheckedChange = {
|
||||
selectedShelfIds = selectedShelfIds.toggle(shelf.id)
|
||||
}
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
shelf.name,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text("${shelf.bookCount}", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -164,8 +186,22 @@ fun SharedAddToShelfDialog(
|
|||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onCreateShelf) {
|
||||
Text(readerString("fab_new_shelf", "New shelf"))
|
||||
if (shelves.isEmpty()) {
|
||||
Button(onClick = onCreateShelf) {
|
||||
Text(readerString("fab_new_shelf", "New shelf"))
|
||||
}
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = onCreateShelf) {
|
||||
Text(readerString("fab_new_shelf", "New shelf"))
|
||||
}
|
||||
Button(
|
||||
onClick = { onShelvesSelected(selectedShelfIds) },
|
||||
enabled = selectedShelfIds.isNotEmpty()
|
||||
) {
|
||||
Text(readerString("action_add", "Add"))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
|
|
@ -176,6 +212,112 @@ fun SharedAddToShelfDialog(
|
|||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedManageShelfBooksDialog(
|
||||
shelf: Shelf,
|
||||
books: List<BookItem>,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (Set<String>) -> Unit
|
||||
) {
|
||||
var query by remember(shelf.id) { mutableStateOf("") }
|
||||
var selectedBookIds by remember(shelf.id, shelf.books) {
|
||||
mutableStateOf(shelf.books.mapTo(linkedSetOf<String>()) { it.id }.toSet())
|
||||
}
|
||||
val normalizedQuery = query.trim()
|
||||
val visibleBooks = remember(books, normalizedQuery) {
|
||||
if (normalizedQuery.isBlank()) {
|
||||
books
|
||||
} else {
|
||||
books.filter { book ->
|
||||
book.cardTitle().contains(normalizedQuery, ignoreCase = true) ||
|
||||
book.cardAuthor().contains(normalizedQuery, ignoreCase = true) ||
|
||||
book.displayName.contains(normalizedQuery, ignoreCase = true) ||
|
||||
book.tags.any { tag -> tag.name.contains(normalizedQuery, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(readerString("desktop_manage_shelf_books_title", "Manage %1\$s", shelf.name)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
SharedStableOutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
label = { Text(readerString("library_search_placeholder", "Search books, authors, or tags")) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
readerString("desktop_shelf_selected_book_count", "%1\$d selected", selectedBookIds.size),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (books.isEmpty()) {
|
||||
Text(readerString("your_library_empty", "Your library is empty"))
|
||||
} else if (visibleBooks.isEmpty()) {
|
||||
Text(readerString("desktop_no_books_match", "No books match."))
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
items(visibleBooks, key = { it.id }) { book ->
|
||||
val selected = book.id in selectedBookIds
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth().clickable {
|
||||
selectedBookIds = selectedBookIds.toggle(book.id)
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Checkbox(
|
||||
checked = selected,
|
||||
onCheckedChange = {
|
||||
selectedBookIds = selectedBookIds.toggle(book.id)
|
||||
}
|
||||
)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(book.cardTitle(), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
book.cardAuthor(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = { onSave(selectedBookIds) }) {
|
||||
Text(readerString("action_save", "Save"))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(readerString("action_cancel", "Cancel"))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun Set<String>.toggle(value: String): Set<String> {
|
||||
return if (value in this) this - value else this + value
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedBookInfoDialog(
|
||||
book: BookItem,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -66,6 +66,7 @@ import com.aryan.reader.shared.BookItem
|
|||
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.SharedOpdsLocalBookMatcher
|
||||
import com.aryan.reader.shared.opds.SharedOpdsDownloadState
|
||||
import com.aryan.reader.shared.opds.SharedOpdsScreenState
|
||||
import com.aryan.reader.shared.opds.SharedOpdsText
|
||||
|
|
@ -866,9 +867,7 @@ private fun SharedOpdsCatalogDialog(
|
|||
}
|
||||
|
||||
private fun OpdsEntry.findLocalBook(localLibraryBooks: List<BookItem>): BookItem? {
|
||||
return localLibraryBooks.firstOrNull {
|
||||
it.title.equals(title, ignoreCase = true) || it.displayName.equals(title, ignoreCase = true)
|
||||
}
|
||||
return SharedOpdsLocalBookMatcher.findBook(this, localLibraryBooks)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -39,6 +39,7 @@ import androidx.compose.ui.unit.isSpecified
|
|||
import androidx.compose.ui.unit.sp
|
||||
import com.aryan.reader.shared.pdf.SharedPdfRichTextController
|
||||
import com.aryan.reader.shared.pdf.SharedPdfRichTextLog
|
||||
import com.aryan.reader.shared.pdf.sharedPdfRichTextSelectionBounds
|
||||
import com.aryan.reader.shared.pdf.withoutTrailingSharedPdfPageBreak
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.roundToInt
|
||||
|
|
@ -202,10 +203,12 @@ fun SharedPdfRichTextLayer(
|
|||
|
||||
if (isTextEditingEnabled && controller.activePageIndex == pageIndex) {
|
||||
val selection = controller.editingValue.selection
|
||||
val localStart = selection.start.coerceIn(0, textToRender.length)
|
||||
val localEnd = selection.end.coerceIn(0, textToRender.length)
|
||||
|
||||
if (localStart != localEnd) {
|
||||
sharedPdfRichTextSelectionBounds(
|
||||
selectionStart = selection.start,
|
||||
selectionEnd = selection.end,
|
||||
textLength = textToRender.length
|
||||
)?.let { (localStart, localEnd) ->
|
||||
val selectionPath = measureResult.getPathForRange(localStart, localEnd)
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
drawPath(selectionPath, Color(0xFFB3D7FF).copy(alpha = 0.5f))
|
||||
|
|
@ -213,6 +216,7 @@ fun SharedPdfRichTextLayer(
|
|||
}
|
||||
|
||||
if (selection.collapsed && controller.isCursorVisible) {
|
||||
val localStart = selection.start.coerceIn(0, textToRender.length)
|
||||
val alpha = if (isScrolling) {
|
||||
1f
|
||||
} else {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -13,6 +13,7 @@ internal data class SharedReaderModalAnchorBounds(
|
|||
)
|
||||
|
||||
internal val LocalSharedReaderModalAnchorBounds = compositionLocalOf<SharedReaderModalAnchorBounds?> { null }
|
||||
internal val LocalSharedReaderModalFocusableOverride = compositionLocalOf<Boolean?> { null }
|
||||
|
||||
internal enum class SharedReaderModalLevel {
|
||||
Panel,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import androidx.compose.runtime.derivedStateOf
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
|
|
@ -157,6 +158,11 @@ fun SharedReaderVerticalScrollbar(
|
|||
targetValue = if (isDraggingScrollbar) scrollbarActiveHeight else scrollbarIdleHeight,
|
||||
label = "sharedReaderScrollbarHeight"
|
||||
)
|
||||
val currentScrollbarTrackHeight by rememberUpdatedState(scrollbarTrackHeight)
|
||||
val currentScrollbarContentHeight by rememberUpdatedState(state.contentHeightPx)
|
||||
val currentScrollbarViewportHeight by rememberUpdatedState(state.viewportHeightPx)
|
||||
val currentScrollbarProgress by rememberUpdatedState(state.progress)
|
||||
val currentScrollbarActiveThumbHeight by rememberUpdatedState(with(density) { scrollbarActiveHeight.toPx() })
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
|
|
@ -166,6 +172,68 @@ fun SharedReaderVerticalScrollbar(
|
|||
.onGloballyPositioned { coordinates ->
|
||||
scrollbarTrackHeight = coordinates.size.height.toFloat()
|
||||
}
|
||||
.pointerInput(listState) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
try {
|
||||
isDraggingScrollbar = true
|
||||
scrollbarVisible = true
|
||||
scrollInteractionTick += 1
|
||||
down.consume()
|
||||
|
||||
val initialContentHeight = currentScrollbarContentHeight
|
||||
val initialViewportHeight = currentScrollbarViewportHeight
|
||||
val initialMaxScroll = (initialContentHeight - initialViewportHeight).coerceAtLeast(0f)
|
||||
val initialTrackHeight = currentScrollbarTrackHeight.takeIf { it > 0f }
|
||||
?: initialViewportHeight
|
||||
val initialThumbHeight = currentScrollbarActiveThumbHeight.coerceAtLeast(1f)
|
||||
val initialTrackSpace = (initialTrackHeight - initialThumbHeight).coerceAtLeast(1f)
|
||||
val initialThumbTop = (initialTrackSpace * currentScrollbarProgress)
|
||||
.coerceIn(0f, initialTrackSpace)
|
||||
val downY = down.position.y.coerceIn(0f, initialTrackHeight)
|
||||
val thumbDragOffset = if (downY in initialThumbTop..(initialThumbTop + initialThumbHeight)) {
|
||||
downY - initialThumbTop
|
||||
} else {
|
||||
initialThumbHeight / 2f
|
||||
}
|
||||
var lastScrollPx = currentScrollbarProgress * initialMaxScroll
|
||||
|
||||
fun scrollToPointer(pointerY: Float) {
|
||||
val contentHeight = currentScrollbarContentHeight
|
||||
val viewportHeight = currentScrollbarViewportHeight
|
||||
val maxScroll = (contentHeight - viewportHeight).coerceAtLeast(0f)
|
||||
if (maxScroll <= 0f) return
|
||||
val trackHeight = currentScrollbarTrackHeight.takeIf { it > 0f }
|
||||
?: viewportHeight
|
||||
val thumbHeight = currentScrollbarActiveThumbHeight.coerceAtLeast(1f)
|
||||
val trackSpace = (trackHeight - thumbHeight).coerceAtLeast(1f)
|
||||
val targetThumbTop = (pointerY - thumbDragOffset).coerceIn(0f, trackSpace)
|
||||
val targetScrollPx = (targetThumbTop / trackSpace) * maxScroll
|
||||
val scrollDelta = targetScrollPx - lastScrollPx
|
||||
if (abs(scrollDelta) > 0.01f) {
|
||||
listState.dispatchRawDelta(scrollDelta)
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
lastScrollPx = targetScrollPx
|
||||
}
|
||||
|
||||
scrollToPointer(down.position.y)
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
if (change == null || !change.pressed) break
|
||||
|
||||
if (change.position.y != change.previousPosition.y) {
|
||||
change.consume()
|
||||
scrollToPointer(change.position.y)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isDraggingScrollbar = false
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
val thumbHeightPx = with(density) { barHeight.toPx() }
|
||||
val effectiveTrackHeight = scrollbarTrackHeight.takeIf { it > 0f } ?: state.viewportHeightPx
|
||||
|
|
@ -184,39 +252,6 @@ fun SharedReaderVerticalScrollbar(
|
|||
modifier = Modifier
|
||||
.height(barHeight)
|
||||
.width(36.dp)
|
||||
.pointerInput(scrollbarTrackHeight, state.contentHeightPx, state.viewportHeightPx) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
try {
|
||||
isDraggingScrollbar = true
|
||||
scrollbarVisible = true
|
||||
scrollInteractionTick += 1
|
||||
down.consume()
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
if (change == null || !change.pressed) break
|
||||
|
||||
val deltaY = change.position.y - change.previousPosition.y
|
||||
if (deltaY != 0f) {
|
||||
change.consume()
|
||||
val trackHeight = scrollbarTrackHeight.takeIf { it > 0f }
|
||||
?: state.viewportHeightPx
|
||||
val trackSpace = (trackHeight - with(density) { scrollbarActiveHeight.toPx() })
|
||||
.coerceAtLeast(1f)
|
||||
val scrollDelta = (deltaY / trackSpace) *
|
||||
(state.contentHeightPx - state.viewportHeightPx)
|
||||
listState.dispatchRawDelta(scrollDelta)
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isDraggingScrollbar = false
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
|
@ -314,6 +349,11 @@ fun SharedPdfVerticalScrollbar(
|
|||
targetValue = if (isDraggingScrollbar) scrollbarActiveHeight else scrollbarIdleHeight,
|
||||
label = "sharedPdfScrollbarHeight"
|
||||
)
|
||||
val currentScrollbarTrackHeight by rememberUpdatedState(scrollbarTrackHeight)
|
||||
val currentScrollbarContentHeight by rememberUpdatedState(state.contentHeightPx)
|
||||
val currentScrollbarViewportHeight by rememberUpdatedState(state.viewportHeightPx)
|
||||
val currentScrollbarProgress by rememberUpdatedState(state.progress)
|
||||
val currentScrollbarActiveThumbHeight by rememberUpdatedState(with(density) { scrollbarActiveHeight.toPx() })
|
||||
val safeCurrentPage = if (pageCount > 0) currentPage.coerceIn(0, pageCount - 1) else 0
|
||||
|
||||
Box(
|
||||
|
|
@ -324,6 +364,68 @@ fun SharedPdfVerticalScrollbar(
|
|||
.onGloballyPositioned { coordinates ->
|
||||
scrollbarTrackHeight = coordinates.size.height.toFloat()
|
||||
}
|
||||
.pointerInput(listState) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
try {
|
||||
isDraggingScrollbar = true
|
||||
scrollbarVisible = true
|
||||
scrollInteractionTick += 1
|
||||
down.consume()
|
||||
|
||||
val initialContentHeight = currentScrollbarContentHeight
|
||||
val initialViewportHeight = currentScrollbarViewportHeight
|
||||
val initialMaxScroll = (initialContentHeight - initialViewportHeight).coerceAtLeast(0f)
|
||||
val initialTrackHeight = currentScrollbarTrackHeight.takeIf { it > 0f }
|
||||
?: initialViewportHeight
|
||||
val initialThumbHeight = currentScrollbarActiveThumbHeight.coerceAtLeast(1f)
|
||||
val initialTrackSpace = (initialTrackHeight - initialThumbHeight).coerceAtLeast(1f)
|
||||
val initialThumbTop = (initialTrackSpace * currentScrollbarProgress)
|
||||
.coerceIn(0f, initialTrackSpace)
|
||||
val downY = down.position.y.coerceIn(0f, initialTrackHeight)
|
||||
val thumbDragOffset = if (downY in initialThumbTop..(initialThumbTop + initialThumbHeight)) {
|
||||
downY - initialThumbTop
|
||||
} else {
|
||||
initialThumbHeight / 2f
|
||||
}
|
||||
var lastScrollPx = currentScrollbarProgress * initialMaxScroll
|
||||
|
||||
fun scrollToPointer(pointerY: Float) {
|
||||
val contentHeight = currentScrollbarContentHeight
|
||||
val viewportHeight = currentScrollbarViewportHeight
|
||||
val maxScroll = (contentHeight - viewportHeight).coerceAtLeast(0f)
|
||||
if (maxScroll <= 0f) return
|
||||
val trackHeight = currentScrollbarTrackHeight.takeIf { it > 0f }
|
||||
?: viewportHeight
|
||||
val thumbHeight = currentScrollbarActiveThumbHeight.coerceAtLeast(1f)
|
||||
val trackSpace = (trackHeight - thumbHeight).coerceAtLeast(1f)
|
||||
val targetThumbTop = (pointerY - thumbDragOffset).coerceIn(0f, trackSpace)
|
||||
val targetScrollPx = (targetThumbTop / trackSpace) * maxScroll
|
||||
val scrollDelta = targetScrollPx - lastScrollPx
|
||||
if (abs(scrollDelta) > 0.01f) {
|
||||
listState.dispatchRawDelta(scrollDelta)
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
lastScrollPx = targetScrollPx
|
||||
}
|
||||
|
||||
scrollToPointer(down.position.y)
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
if (change == null || !change.pressed) break
|
||||
|
||||
if (change.position.y != change.previousPosition.y) {
|
||||
change.consume()
|
||||
scrollToPointer(change.position.y)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isDraggingScrollbar = false
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
val thumbHeightPx = with(density) { barHeight.toPx() }
|
||||
val effectiveTrackHeight = scrollbarTrackHeight.takeIf { it > 0f } ?: state.viewportHeightPx
|
||||
|
|
@ -367,39 +469,6 @@ fun SharedPdfVerticalScrollbar(
|
|||
modifier = Modifier
|
||||
.height(barHeight)
|
||||
.width(48.dp)
|
||||
.pointerInput(scrollbarTrackHeight, state.contentHeightPx, state.viewportHeightPx) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
try {
|
||||
isDraggingScrollbar = true
|
||||
scrollbarVisible = true
|
||||
scrollInteractionTick += 1
|
||||
down.consume()
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
if (change == null || !change.pressed) break
|
||||
|
||||
val deltaY = change.position.y - change.previousPosition.y
|
||||
if (deltaY != 0f) {
|
||||
change.consume()
|
||||
val trackHeight = scrollbarTrackHeight.takeIf { it > 0f }
|
||||
?: state.viewportHeightPx
|
||||
val trackSpace = (trackHeight - with(density) { scrollbarActiveHeight.toPx() })
|
||||
.coerceAtLeast(1f)
|
||||
val scrollDelta = (deltaY / trackSpace) *
|
||||
(state.contentHeightPx - state.viewportHeightPx)
|
||||
listState.dispatchRawDelta(scrollDelta)
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isDraggingScrollbar = false
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
|
|
|||
|
|
@ -0,0 +1,379 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowLeft
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.MyLocation
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.SkipNext
|
||||
import androidx.compose.material.icons.filled.SkipPrevious
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.shared.ReaderAiByokSettings
|
||||
import com.aryan.reader.shared.ReaderCloudTtsState
|
||||
import com.aryan.reader.shared.ReaderTtsReadScope
|
||||
import com.aryan.reader.shared.readerCloudTtsControlsModel
|
||||
import com.aryan.reader.shared.readerCloudTtsVoiceById
|
||||
|
||||
@Composable
|
||||
fun SharedReaderTtsOverlayControls(
|
||||
settings: ReaderAiByokSettings,
|
||||
cloudTts: ReaderCloudTtsState,
|
||||
credits: Int,
|
||||
showCredits: Boolean,
|
||||
isCollapsed: Boolean,
|
||||
onCollapseChange: (Boolean) -> Unit,
|
||||
onPauseResume: () -> Unit,
|
||||
onSkipPrevious: () -> Unit,
|
||||
onSkipNext: () -> Unit,
|
||||
onLocateCurrentChunk: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val sanitized = settings.sanitized()
|
||||
val controls = readerCloudTtsControlsModel(cloudTts)
|
||||
if (!controls.isVisible) return
|
||||
|
||||
val voice = remember(sanitized.ttsSpeakerId) { readerCloudTtsVoiceById(sanitized.ttsSpeakerId) }
|
||||
val progress = cloudTts.progress
|
||||
val chunk = progress.currentChunk
|
||||
val chunkLabel = if (progress.currentChunkIndex >= 0 && progress.chunks.isNotEmpty()) {
|
||||
readerString(
|
||||
"desktop_tts_chunk_count",
|
||||
"Part %1\$d/%2\$d",
|
||||
progress.currentChunkIndex + 1,
|
||||
progress.chunks.size
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val progressFraction = if (progress.currentChunkIndex >= 0 && progress.chunks.isNotEmpty()) {
|
||||
((progress.currentChunkIndex + 1).toFloat() / progress.chunks.size).coerceIn(0f, 1f)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val title = when {
|
||||
cloudTts.isLoading -> readerString("desktop_preparing_audio", "Preparing audio")
|
||||
cloudTts.isPaused -> readerString("desktop_paused", "Paused")
|
||||
cloudTts.isPlaying -> readerString("label_reading", "Reading")
|
||||
else -> readerString("action_read_aloud", "Read aloud")
|
||||
}
|
||||
val chapterLabel = chunk?.chapterTitle
|
||||
?.lineSequence()
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotBlank() }
|
||||
?.joinToString(" - ")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val statusLine = cloudTts.errorMessage
|
||||
?: progress.currentPositionLabel
|
||||
?: cloudTts.statusMessage
|
||||
?: chapterLabel
|
||||
?: voice?.let { "${it.name}: ${it.description}" }
|
||||
?: ""
|
||||
val scopeLabel = readerTtsScopeLabel(progress.scope)
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)),
|
||||
modifier = modifier.widthIn(max = 560.dp).animateContentSize()
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = isCollapsed,
|
||||
transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
|
||||
label = "SharedReaderTtsOverlay"
|
||||
) { collapsed ->
|
||||
if (collapsed) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { onCollapseChange(false) },
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.KeyboardArrowLeft,
|
||||
contentDescription = readerString("content_desc_expand", "Expand"),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.widthIn(max = 220.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp)
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
chunkLabel ?: scopeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
SharedReaderTtsPlayPauseButton(
|
||||
isPlaying = cloudTts.isPlaying,
|
||||
isLoading = cloudTts.isLoading,
|
||||
enabled = controls.canPauseResume,
|
||||
size = 36,
|
||||
iconSize = 20,
|
||||
onClick = onPauseResume
|
||||
)
|
||||
IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = readerString("content_desc_stop_tts", "Stop read aloud"),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (statusLine.isNotBlank()) {
|
||||
Text(
|
||||
statusLine,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (cloudTts.errorMessage != null) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
IconButton(
|
||||
enabled = controls.canLocateCurrentChunk,
|
||||
onClick = onLocateCurrentChunk,
|
||||
modifier = Modifier.size(34.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.MyLocation,
|
||||
contentDescription = readerString("desktop_locate_current_tts", "Locate current reading"),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onCollapseChange(true) }, modifier = Modifier.size(34.dp)) {
|
||||
Icon(
|
||||
Icons.Default.KeyboardArrowRight,
|
||||
contentDescription = readerString("content_desc_collapse", "Collapse"),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onClose, modifier = Modifier.size(34.dp)) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = readerString("content_desc_stop_tts", "Stop read aloud"),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
SharedReaderTtsPill(readerString("tts_mode_cloud_ai", "Cloud AI"))
|
||||
voice?.let { SharedReaderTtsPill(it.name) }
|
||||
if (showCredits) {
|
||||
SharedReaderTtsPill(readerString("credits_count", "%1\$d credits", credits))
|
||||
}
|
||||
SharedReaderTtsPill(chunkLabel ?: scopeLabel)
|
||||
}
|
||||
|
||||
progressFraction?.let { fraction ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(3.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.18f))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(fraction)
|
||||
.height(3.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
IconButton(
|
||||
enabled = controls.canSkipPrevious,
|
||||
onClick = onSkipPrevious,
|
||||
modifier = Modifier.size(44.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.SkipPrevious,
|
||||
contentDescription = readerString("content_desc_tts_previous_chunk", "Previous chunk"),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
SharedReaderTtsPlayPauseButton(
|
||||
isPlaying = cloudTts.isPlaying,
|
||||
isLoading = cloudTts.isLoading,
|
||||
enabled = controls.canPauseResume,
|
||||
size = 56,
|
||||
iconSize = 28,
|
||||
onClick = onPauseResume
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
IconButton(
|
||||
enabled = controls.canSkipNext,
|
||||
onClick = onSkipNext,
|
||||
modifier = Modifier.size(44.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.SkipNext,
|
||||
contentDescription = readerString("content_desc_tts_next_chunk", "Next chunk"),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedReaderTtsPill(text: String) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedReaderTtsPlayPauseButton(
|
||||
isPlaying: Boolean,
|
||||
isLoading: Boolean,
|
||||
enabled: Boolean,
|
||||
size: Int,
|
||||
iconSize: Int,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Box(modifier = Modifier.size(size.dp), contentAlignment = Alignment.Center) {
|
||||
FilledIconButton(
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(size.dp),
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
contentDescription = readerString("content_desc_play_pause", "Play or pause"),
|
||||
modifier = Modifier.size(iconSize.dp)
|
||||
)
|
||||
}
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(size.dp),
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
|
||||
strokeWidth = if (size >= 56) 3.dp else 2.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun readerTtsScopeLabel(scope: ReaderTtsReadScope): String {
|
||||
return when (scope) {
|
||||
ReaderTtsReadScope.PAGE -> readerString("desktop_page", "Page")
|
||||
ReaderTtsReadScope.CHAPTER -> readerString("chapter", "Chapter")
|
||||
ReaderTtsReadScope.BOOK -> readerString("desktop_from_here", "From here")
|
||||
}
|
||||
}
|
||||
|
|
@ -47,6 +47,7 @@ import androidx.compose.ui.unit.dp
|
|||
import com.aryan.reader.shared.BuiltInPdfReaderThemes
|
||||
import com.aryan.reader.shared.CustomFontItem
|
||||
import com.aryan.reader.shared.ReaderAction
|
||||
import com.aryan.reader.shared.ReaderTheme
|
||||
import com.aryan.reader.shared.ReaderToolbarPreferences
|
||||
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
|
||||
import com.aryan.reader.shared.SharedSettingsAction
|
||||
|
|
@ -78,6 +79,8 @@ fun SharedSettingsHub(
|
|||
onReaderToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit = {},
|
||||
customFonts: List<CustomFontItem> = emptyList(),
|
||||
onPickCustomFont: (() -> String?)? = null,
|
||||
customReaderThemes: List<ReaderTheme> = emptyList(),
|
||||
onCustomReaderThemesChange: (List<ReaderTheme>) -> Unit = {},
|
||||
readerCustomTextureIds: List<String> = emptyList(),
|
||||
onImportReaderTexture: ((ReaderSettings) -> ReaderSettings?)? = null,
|
||||
showTopBar: Boolean = true,
|
||||
|
|
@ -167,6 +170,8 @@ fun SharedSettingsHub(
|
|||
onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange,
|
||||
customFonts = customFonts,
|
||||
onPickCustomFont = onPickCustomFont,
|
||||
customReaderThemes = customReaderThemes,
|
||||
onCustomReaderThemesChange = onCustomReaderThemesChange,
|
||||
readerCustomTextureIds = readerCustomTextureIds,
|
||||
onImportReaderTexture = onImportReaderTexture,
|
||||
modifier = Modifier.weight(1f)
|
||||
|
|
@ -490,6 +495,8 @@ private fun SharedSettingsDetailPage(
|
|||
onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit,
|
||||
customFonts: List<CustomFontItem>,
|
||||
onPickCustomFont: (() -> String?)?,
|
||||
customReaderThemes: List<ReaderTheme>,
|
||||
onCustomReaderThemesChange: (List<ReaderTheme>) -> Unit,
|
||||
readerCustomTextureIds: List<String>,
|
||||
onImportReaderTexture: ((ReaderSettings) -> ReaderSettings?)?,
|
||||
modifier: Modifier
|
||||
|
|
@ -510,7 +517,6 @@ private fun SharedSettingsDetailPage(
|
|||
SharedSettingsDestination.EPUB_FORMAT -> {
|
||||
SharedReaderFormatControls(
|
||||
settings = settings,
|
||||
toolbarPreferences = ReaderToolbarPreferences(),
|
||||
onPickCustomFont = onPickCustomFont,
|
||||
customFonts = customFonts,
|
||||
onReaderAction = { action ->
|
||||
|
|
@ -521,6 +527,8 @@ private fun SharedSettingsDetailPage(
|
|||
SharedSettingsDestination.EPUB_THEME_TEXTURE -> {
|
||||
SharedReaderThemeControls(
|
||||
settings = settings,
|
||||
customThemes = customReaderThemes,
|
||||
onCustomThemesChange = onCustomReaderThemesChange,
|
||||
customTextureIds = readerCustomTextureIds,
|
||||
onImportTexture = onImportReaderTexture,
|
||||
onSettingsChange = onSettingsChange
|
||||
|
|
@ -547,15 +555,25 @@ private fun SharedSettingsDetailPage(
|
|||
SharedReaderThemeControls(
|
||||
settings = pdfSettings,
|
||||
builtInThemes = BuiltInPdfReaderThemes,
|
||||
customThemes = customReaderThemes,
|
||||
onCustomThemesChange = onCustomReaderThemesChange,
|
||||
customTextureIds = readerCustomTextureIds,
|
||||
onImportTexture = onImportReaderTexture,
|
||||
onSettingsChange = onPdfSettingsChange
|
||||
)
|
||||
HorizontalDivider()
|
||||
Text(readerString("visual_options_title", "Visual options"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
SharedPdfVisualOptionDefaultsSwitch(
|
||||
title = readerString("menu_right_to_left_pagination", "Paginated (right-to-left)"),
|
||||
summary = readerString("visual_options_right_to_left_pagination_desc", "Uses right-to-left page order when PDF pagination mode is active."),
|
||||
checked = pdfSettings.rightToLeftPagination,
|
||||
onCheckedChange = { enabled ->
|
||||
onPdfSettingsChange(pdfSettings.copy(rightToLeftPagination = enabled))
|
||||
}
|
||||
)
|
||||
SharedPdfVisualOptionDefaultsSwitch(
|
||||
title = readerString("visual_options_remove_page_gap", "Remove gap between pages"),
|
||||
summary = readerString("desktop_remove_gap_between_pages_desc", "Applies to vertical reading mode."),
|
||||
summary = readerString("desktop_remove_gap_between_pages_desc", "Applies to vertical reading and two-page spreads."),
|
||||
checked = !pdfSettings.pdfVerticalPageGapVisible,
|
||||
onCheckedChange = { removeGap ->
|
||||
onPdfSettingsChange(pdfSettings.copy(pdfVerticalPageGapVisible = !removeGap))
|
||||
|
|
|
|||
|
|
@ -32,8 +32,11 @@ import androidx.compose.material.icons.filled.Delete
|
|||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Feedback
|
||||
import androidx.compose.material.icons.filled.FileOpen
|
||||
import androidx.compose.material.icons.filled.Gavel
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Policy
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.TextFields
|
||||
import androidx.compose.material3.AlertDialog
|
||||
|
|
@ -494,6 +497,9 @@ fun SharedAboutScreen(
|
|||
buildLabel: String,
|
||||
onOpenSource: (() -> Unit)? = null,
|
||||
onOpenIssues: (() -> Unit)? = null,
|
||||
onOpenPrivacyPolicy: (() -> Unit)? = null,
|
||||
onOpenTerms: (() -> Unit)? = null,
|
||||
onOpenLicenses: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SharedScreenScaffold(
|
||||
|
|
@ -544,6 +550,30 @@ fun SharedAboutScreen(
|
|||
onClick = onOpenIssues
|
||||
)
|
||||
}
|
||||
if (onOpenPrivacyPolicy != null) {
|
||||
SharedUtilityOptionCard(
|
||||
title = readerString("legal_privacy_policy", "Privacy Policy"),
|
||||
body = readerString("about_privacy_desc", "How Episteme handles data for this edition."),
|
||||
icon = { Icon(Icons.Default.Policy, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenPrivacyPolicy
|
||||
)
|
||||
}
|
||||
if (onOpenTerms != null) {
|
||||
SharedUtilityOptionCard(
|
||||
title = readerString("legal_terms_of_service", "Terms of Service"),
|
||||
body = readerString("about_terms_desc", "Usage terms and conditions."),
|
||||
icon = { Icon(Icons.Default.Gavel, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenTerms
|
||||
)
|
||||
}
|
||||
if (onOpenLicenses != null) {
|
||||
SharedUtilityOptionCard(
|
||||
title = readerString("legal_licenses", "Licenses"),
|
||||
body = readerString("about_licenses_desc", "Open source libraries used."),
|
||||
icon = { Icon(Icons.Default.FileOpen, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenLicenses
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class CloudSyncDecisionsTest {
|
||||
|
||||
@Test
|
||||
fun `newer remote metadata applies over local metadata`() {
|
||||
assertEquals(
|
||||
SharedCloudBookMetadataWinner.REMOTE,
|
||||
sharedCloudBookMetadataWinner(
|
||||
localModifiedTimestamp = 100L,
|
||||
remoteModifiedTimestamp = 200L
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
shouldApplyRemoteCloudBookUpdate(
|
||||
localModifiedTimestamp = 100L,
|
||||
remoteModifiedTimestamp = 200L
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newer local sidecar wins even when book metadata is older`() {
|
||||
assertEquals(
|
||||
SharedCloudBookMetadataWinner.LOCAL,
|
||||
sharedCloudBookMetadataWinner(
|
||||
localModifiedTimestamp = 100L,
|
||||
remoteModifiedTimestamp = 200L,
|
||||
localSidecarModifiedTimestamp = 300L
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
SharedCloudBookMetadataWinner.REMOTE,
|
||||
sharedCloudBookReadingMetadataWinner(
|
||||
localModifiedTimestamp = 100L,
|
||||
remoteModifiedTimestamp = 200L
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
shouldUploadLocalCloudBookUpdate(
|
||||
localModifiedTimestamp = 100L,
|
||||
remoteModifiedTimestamp = 200L,
|
||||
localSidecarModifiedTimestamp = 300L
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
shouldApplyRemoteCloudBookMetadataUpdate(
|
||||
localModifiedTimestamp = 100L,
|
||||
remoteModifiedTimestamp = 200L
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale remote metadata is ignored`() {
|
||||
assertFalse(
|
||||
shouldApplyRemoteCloudBookUpdate(
|
||||
localModifiedTimestamp = 300L,
|
||||
remoteModifiedTimestamp = 200L
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
shouldUploadLocalCloudBookUpdate(
|
||||
localModifiedTimestamp = 300L,
|
||||
remoteModifiedTimestamp = 200L
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `content sync only moves changed file payloads`() {
|
||||
assertTrue(
|
||||
shouldDownloadRemoteCloudBookContent(
|
||||
localFileAvailable = true,
|
||||
localContentModifiedTimestamp = 100L,
|
||||
remoteContentModifiedTimestamp = 200L
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
shouldDownloadRemoteCloudBookContent(
|
||||
localFileAvailable = false,
|
||||
localContentModifiedTimestamp = 0L,
|
||||
remoteContentModifiedTimestamp = 200L
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
shouldDownloadRemoteCloudBookContent(
|
||||
localFileAvailable = true,
|
||||
localContentModifiedTimestamp = 300L,
|
||||
remoteContentModifiedTimestamp = 200L
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
shouldDownloadRemoteCloudBookContent(
|
||||
localFileAvailable = false,
|
||||
localContentModifiedTimestamp = 0L,
|
||||
remoteContentModifiedTimestamp = 200L,
|
||||
remoteDeleted = true
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
shouldUploadLocalCloudBookContent(
|
||||
localFileAvailable = true,
|
||||
localContentModifiedTimestamp = 300L,
|
||||
remoteContentModifiedTimestamp = 200L
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cloud book content file name uses shared primary extension`() {
|
||||
assertEquals("book-1.epub", sharedCloudBookContentFileName("book-1", FileType.EPUB))
|
||||
assertEquals("book-1.md", sharedCloudBookContentFileName("book-1", FileType.MD))
|
||||
assertEquals("book-1.mobi", sharedCloudBookContentFileName("book-1", FileType.MOBI))
|
||||
assertEquals(null, sharedCloudBookContentFileName("book-1", FileType.UNKNOWN))
|
||||
}
|
||||
}
|
||||
|
|
@ -165,6 +165,8 @@ class EpubAnnotationSerializerTest {
|
|||
val oldDesktopLocator = ReaderLocator.fromLegacy(cfi = "desktop:2:7:123456:abc")
|
||||
val timestampFallbackLocator = ReaderLocator.fromLegacy(cfi = "desktop:2:7:1780000000000")
|
||||
val rangedDesktopLocator = ReaderLocator.fromLegacy(cfi = "desktop:2:40:55")
|
||||
val scrollWrappedDesktopLocator = ReaderLocator.fromLegacy(cfi = "desktop-scroll:5238:5238:desktop:2:40:55")
|
||||
val androidLocator = ReaderLocator.fromLegacy(cfi = "android-locator:3:42:128")
|
||||
|
||||
assertEquals(2, oldDesktopLocator.chapterIndex)
|
||||
assertEquals(7, oldDesktopLocator.pageIndex)
|
||||
|
|
@ -174,5 +176,26 @@ class EpubAnnotationSerializerTest {
|
|||
assertEquals(2, rangedDesktopLocator.chapterIndex)
|
||||
assertEquals(40, rangedDesktopLocator.startOffset)
|
||||
assertEquals(55, rangedDesktopLocator.endOffset)
|
||||
assertEquals(2, scrollWrappedDesktopLocator.chapterIndex)
|
||||
assertEquals(40, scrollWrappedDesktopLocator.startOffset)
|
||||
assertEquals(55, scrollWrappedDesktopLocator.endOffset)
|
||||
assertEquals("desktop:2:40:55", scrollWrappedDesktopLocator.cfi)
|
||||
assertEquals(3, androidLocator.chapterIndex)
|
||||
assertEquals(42, androidLocator.blockIndex)
|
||||
assertEquals(128, androidLocator.charOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `android locator with quote hydrates absolute text range for synced highlights`() {
|
||||
val locator = ReaderLocator.fromLegacy(
|
||||
cfi = "android-locator:3:42:128",
|
||||
textQuote = "marked"
|
||||
)
|
||||
|
||||
assertEquals(3, locator.chapterIndex)
|
||||
assertEquals(42, locator.blockIndex)
|
||||
assertEquals(128, locator.charOffset)
|
||||
assertEquals(128, locator.startOffset)
|
||||
assertEquals(134, locator.endOffset)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ class FileCapabilitiesTest {
|
|||
SharedFileCapabilities.mimeTypeFor(FileType.PPTX)
|
||||
)
|
||||
assertTrue("application/pdf" in SharedFileCapabilities.androidFilePickerMimeTypes)
|
||||
assertTrue("application/x-tar" in SharedFileCapabilities.androidFilePickerMimeTypes)
|
||||
assertTrue("text/x-kotlin" in SharedFileCapabilities.androidFilePickerMimeTypes)
|
||||
assertFalse("*/*" in SharedFileCapabilities.androidFilePickerMimeTypes)
|
||||
assertEquals(
|
||||
|
|
@ -48,6 +49,7 @@ class FileCapabilitiesTest {
|
|||
FileType.CBZ,
|
||||
FileType.CBR,
|
||||
FileType.CB7,
|
||||
FileType.CBT,
|
||||
FileType.DOCX,
|
||||
FileType.PPTX,
|
||||
FileType.ODT,
|
||||
|
|
@ -85,14 +87,16 @@ class FileCapabilitiesTest {
|
|||
)
|
||||
assertEquals(
|
||||
ReaderFeatureSurface.PDF_VIEWER,
|
||||
SharedFileCapabilities.surfaceFor(FileType.CBR, ReaderPlatform.DESKTOP)
|
||||
SharedFileCapabilities.surfaceFor(FileType.CBT, ReaderPlatform.DESKTOP)
|
||||
)
|
||||
assertEquals(
|
||||
ReaderFeatureSurface.EPUB_READER,
|
||||
SharedFileCapabilities.surfaceFor(FileType.MD, ReaderPlatform.ANDROID)
|
||||
)
|
||||
assertTrue(SharedFileCapabilities.canOpen(FileType.CBZ, ReaderPlatform.ANDROID))
|
||||
assertTrue(SharedFileCapabilities.canOpen(FileType.CBZ, ReaderPlatform.DESKTOP))
|
||||
assertTrue(SharedFileCapabilities.canOpen(FileType.CBT, ReaderPlatform.ANDROID))
|
||||
assertTrue(SharedFileCapabilities.canOpen(FileType.CBT, ReaderPlatform.DESKTOP))
|
||||
assertTrue(SharedFileCapabilities.isComicArchive(FileType.CBT))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -102,6 +106,7 @@ class FileCapabilitiesTest {
|
|||
assertEquals(FileType.HTML, "chapter.xhtml".toFileType())
|
||||
assertEquals(FileType.MOBI, SharedFileCapabilities.fileTypeForName("book.azw3"))
|
||||
assertEquals(FileType.FB2, SharedFileCapabilities.fileTypeForName("book.fb2.zip"))
|
||||
assertEquals(FileType.CBT, SharedFileCapabilities.fileTypeForName("comic.cbt"))
|
||||
assertEquals(FileType.PPTX, SharedFileCapabilities.fileTypeForName("slides.pptx"))
|
||||
assertEquals(FileType.HTML, SharedFileCapabilities.fileTypeForName("payload.json.txt"))
|
||||
assertEquals(FileType.EPUB, SharedFileCapabilities.fileTypeForName("book.epub.txt"))
|
||||
|
|
@ -124,8 +129,10 @@ class FileCapabilitiesTest {
|
|||
)
|
||||
assertEquals(FileType.HTML, SharedFileCapabilities.resolveFileTypeForMetadata("payload", "application/json"))
|
||||
assertEquals(FileType.CBZ, SharedFileCapabilities.resolveFileTypeForMetadata("comic.cbz", "application/zip"))
|
||||
assertEquals(FileType.CBT, SharedFileCapabilities.resolveFileTypeForMetadata("comic.cbt", "application/x-tar"))
|
||||
assertEquals(FileType.FB2, SharedFileCapabilities.resolveFileTypeForMetadata("book.fb2.zip", "application/zip"))
|
||||
assertNull(SharedFileCapabilities.resolveFileTypeForMetadata("archive.zip", "application/zip"))
|
||||
assertNull(SharedFileCapabilities.resolveFileTypeForMetadata("archive.tar", "application/x-tar"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -270,6 +270,36 @@ class LocalFolderSyncEngineTest {
|
|||
assertFalse(FileType.UNKNOWN in SyncedFolder("C:/Library", "Library", lastScanTime = 0L).allowedFileTypes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabled synced folder does not import files or metadata`() {
|
||||
val existing = book(
|
||||
id = "local_Book.pdf",
|
||||
timestamp = 100L,
|
||||
progress = 10f
|
||||
)
|
||||
val result = LocalFolderSyncEngine.syncFolder(
|
||||
state = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(existing),
|
||||
syncedFolders = listOf(syncedFolder().copy(localSyncEnabled = false))
|
||||
),
|
||||
folder = syncedFolder().copy(localSyncEnabled = false),
|
||||
files = listOf(scannedFile("New.pdf", "New.pdf")),
|
||||
remoteMetadata = mapOf(
|
||||
"local_Book.pdf" to metadata(
|
||||
id = "local_Book.pdf",
|
||||
progress = 80f,
|
||||
modified = 500L
|
||||
)
|
||||
),
|
||||
nowMillis = 1_000L
|
||||
)
|
||||
|
||||
assertEquals(listOf(existing), result.state.rawLibraryBooks)
|
||||
assertEquals(0, result.stats.newBooks)
|
||||
assertEquals(0, result.stats.remoteMetadataUpdates)
|
||||
assertTrue(result.removedBookIds.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata sidecar is skipped for clean unread folder books`() {
|
||||
assertNull(book(id = "local_Book.pdf", isRecent = false, progress = null).toSharedFolderBookMetadata())
|
||||
|
|
@ -305,6 +335,36 @@ class LocalFolderSyncEngineTest {
|
|||
assertEquals(locator, restored.readerPosition)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata sidecar preserves android block reader position`() {
|
||||
val locator = ReaderLocator(
|
||||
chapterIndex = 1,
|
||||
pageIndex = 5,
|
||||
blockIndex = 44,
|
||||
charOffset = 120,
|
||||
cfi = "android-locator:1:44:120"
|
||||
)
|
||||
|
||||
val metadata = book(
|
||||
id = "local_Book.epub",
|
||||
type = FileType.EPUB,
|
||||
progress = 37f,
|
||||
readerPosition = locator
|
||||
).toSharedFolderBookMetadata() ?: error("Expected sidecar")
|
||||
val restored = metadata.toBookItem(
|
||||
file = scannedFile("Book.epub", "Book.epub"),
|
||||
existing = null,
|
||||
nowMillis = 2_000L
|
||||
)
|
||||
|
||||
assertEquals(1, metadata.lastChapterIndex)
|
||||
assertEquals(5, metadata.lastPage)
|
||||
assertEquals("android-locator:1:44:120", metadata.lastPositionCfi)
|
||||
assertEquals(44, metadata.locatorBlockIndex)
|
||||
assertEquals(120, metadata.locatorCharOffset)
|
||||
assertEquals(locator, restored.readerPosition)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata sidecar ignores legacy editable metadata`() {
|
||||
val local = book(id = "local_Book.pdf")
|
||||
|
|
@ -451,6 +511,7 @@ class LocalFolderSyncEngineTest {
|
|||
sourceFolder: String = "C:/Library",
|
||||
timestamp: Long = 100L,
|
||||
title: String = "Book",
|
||||
type: FileType = FileType.PDF,
|
||||
progress: Float? = null,
|
||||
isRecent: Boolean = false,
|
||||
fileSize: Long = 0L,
|
||||
|
|
@ -461,7 +522,7 @@ class LocalFolderSyncEngineTest {
|
|||
return BookItem(
|
||||
id = id,
|
||||
path = path,
|
||||
type = FileType.PDF,
|
||||
type = type,
|
||||
displayName = displayName,
|
||||
timestamp = timestamp,
|
||||
coverImagePath = coverImagePath,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import com.aryan.reader.shared.pdf.PdfInkTool
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAndroidHighlightColors
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotationDefaults
|
||||
import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette
|
||||
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
|
||||
import com.aryan.reader.shared.reader.ReaderReadingMode
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import com.aryan.reader.shared.reader.SharedReaderTextAlign
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
|
@ -31,6 +40,74 @@ class ReaderAppearanceModelsTest {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf and epub built in themes share the standard reader palette`() {
|
||||
data class ThemeToken(
|
||||
val name: String,
|
||||
val backgroundArgb: Int,
|
||||
val textArgb: Int,
|
||||
val isDark: Boolean,
|
||||
val textureId: String?
|
||||
)
|
||||
|
||||
val epubPalette = BuiltInReaderThemes
|
||||
.drop(1)
|
||||
.map { theme ->
|
||||
ThemeToken(
|
||||
name = theme.name,
|
||||
backgroundArgb = theme.backgroundColor.toArgb(),
|
||||
textArgb = theme.textColor.toArgb(),
|
||||
isDark = theme.isDark,
|
||||
textureId = theme.textureId
|
||||
)
|
||||
}
|
||||
val pdfPalette = BuiltInPdfReaderThemes
|
||||
.drop(2)
|
||||
.map { theme ->
|
||||
ThemeToken(
|
||||
name = theme.name,
|
||||
backgroundArgb = theme.backgroundColor.toArgb(),
|
||||
textArgb = theme.textColor.toArgb(),
|
||||
isDark = theme.isDark,
|
||||
textureId = theme.textureId
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(epubPalette, pdfPalette)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf highlighter defaults follow android pdf highlight slots`() {
|
||||
val expectedPdfColors = SharedPdfAndroidHighlightColors.palette.take(4)
|
||||
|
||||
assertEquals(4, SharedPdfHighlighterPalette.MaxColors)
|
||||
assertEquals(expectedPdfColors, SharedPdfHighlighterPalette.defaultColors)
|
||||
assertEquals(expectedPdfColors[0], SharedPdfAnnotationDefaults.configFor(PdfInkTool.HIGHLIGHTER).colorArgb)
|
||||
assertEquals(expectedPdfColors[1], SharedPdfAnnotationDefaults.configFor(PdfInkTool.HIGHLIGHTER_ROUND).colorArgb)
|
||||
|
||||
val custom = SharedPdfHighlighterPalette(
|
||||
colors = expectedPdfColors + listOf(0xFFFF00FF.toInt())
|
||||
).sanitized()
|
||||
assertEquals(4, custom.colors.size)
|
||||
assertEquals(expectedPdfColors, custom.colors)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `custom reader themes keep android persisted shape`() {
|
||||
val first = ReaderTheme(
|
||||
id = "custom",
|
||||
name = "Custom",
|
||||
backgroundColor = Color(0xFFF5F5F5),
|
||||
textColor = Color(0xFF111111),
|
||||
isDark = false,
|
||||
isCustom = true
|
||||
)
|
||||
val replacement = first.copy(name = "Replacement")
|
||||
val builtIn = BuiltInReaderThemes.first().copy(isCustom = false)
|
||||
|
||||
assertEquals(listOf(replacement), listOf(first, builtIn, replacement).sanitizeCustomReaderThemes())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader textures expose shared desktop resource paths`() {
|
||||
assertTrue(ReaderTexture.entries.all { it.assetPath.startsWith("textures/") })
|
||||
|
|
@ -65,4 +142,82 @@ class ReaderAppearanceModelsTest {
|
|||
assertEquals(theme.backgroundColor.toArgb().toLong(), settings.backgroundColorArgb)
|
||||
assertEquals(theme.textColor.toArgb().toLong(), settings.textColorArgb)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset reader format settings keeps reader mode and appearance choices`() {
|
||||
val settings = ReaderSettings(
|
||||
fontSize = 26,
|
||||
lineSpacing = 2.0f,
|
||||
margin = 96,
|
||||
horizontalMargin = 128,
|
||||
verticalMargin = 72,
|
||||
textAlign = SharedReaderTextAlign.RIGHT,
|
||||
pageWidth = 1040,
|
||||
fontFamily = "Imported",
|
||||
customFontPath = "C:\\fonts\\Imported.ttf",
|
||||
paragraphSpacing = 2.2f,
|
||||
imageScale = 1.8f,
|
||||
readingMode = ReaderReadingMode.PAGINATED,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE,
|
||||
themeId = "sepia",
|
||||
textureId = ReaderTexture.PAPER.id,
|
||||
textureAlpha = 0.35f,
|
||||
darkMode = true,
|
||||
backgroundColorArgb = 0xFF101010,
|
||||
textColorArgb = 0xFFEAEAEA
|
||||
)
|
||||
|
||||
val reset = settings.resetReaderFormatSettings()
|
||||
val defaults = ReaderSettings()
|
||||
|
||||
assertEquals(defaults.fontSize, reset.fontSize)
|
||||
assertEquals(defaults.lineSpacing, reset.lineSpacing)
|
||||
assertEquals(defaults.margin, reset.margin)
|
||||
assertEquals(defaults.horizontalMargin, reset.horizontalMargin)
|
||||
assertEquals(defaults.verticalMargin, reset.verticalMargin)
|
||||
assertEquals(defaults.textAlign, reset.textAlign)
|
||||
assertEquals(defaults.pageWidth, reset.pageWidth)
|
||||
assertEquals(defaults.fontFamily, reset.fontFamily)
|
||||
assertEquals(defaults.customFontPath, reset.customFontPath)
|
||||
assertEquals(defaults.paragraphSpacing, reset.paragraphSpacing)
|
||||
assertEquals(defaults.imageScale, reset.imageScale)
|
||||
|
||||
assertEquals(ReaderReadingMode.PAGINATED, reset.readingMode)
|
||||
assertEquals(ReaderPageSpreadMode.TWO_PAGE, reset.pageSpreadMode)
|
||||
assertEquals("sepia", reset.themeId)
|
||||
assertEquals(ReaderTexture.PAPER.id, reset.textureId)
|
||||
assertEquals(0.35f, reset.textureAlpha)
|
||||
assertEquals(true, reset.darkMode)
|
||||
assertEquals(0xFF101010, reset.backgroundColorArgb)
|
||||
assertEquals(0xFFEAEAEA, reset.textColorArgb)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `axis margin updates keep the opposite axis fixed`() {
|
||||
val defaultSettings = ReaderSettings()
|
||||
|
||||
val horizontalOnly = defaultSettings.withHorizontalReaderMargin(96)
|
||||
assertEquals(96, horizontalOnly.resolvedHorizontalMargin)
|
||||
assertEquals(defaultSettings.resolvedVerticalMargin, horizontalOnly.resolvedVerticalMargin)
|
||||
assertEquals(96, horizontalOnly.margin)
|
||||
assertEquals(defaultSettings.resolvedVerticalMargin, horizontalOnly.verticalMargin)
|
||||
|
||||
val verticalOnly = horizontalOnly.withVerticalReaderMargin(24)
|
||||
assertEquals(96, verticalOnly.resolvedHorizontalMargin)
|
||||
assertEquals(24, verticalOnly.resolvedVerticalMargin)
|
||||
assertEquals(96, verticalOnly.margin)
|
||||
assertEquals(96, verticalOnly.horizontalMargin)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `page width format control is only shown for paginated mode`() {
|
||||
assertEquals(
|
||||
false,
|
||||
ReaderSettings(readingMode = ReaderReadingMode.VERTICAL).shouldShowPageWidthFormatControl()
|
||||
)
|
||||
assertEquals(
|
||||
true,
|
||||
ReaderSettings(readingMode = ReaderReadingMode.PAGINATED).shouldShowPageWidthFormatControl()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReaderBookReplacementEngineTest {
|
||||
@Test
|
||||
fun `book replacements apply only to matching file id`() {
|
||||
val preferences = ReaderBookReplacementPreferences(
|
||||
fileRules = mapOf(
|
||||
"book-a" to listOf(rule(from = "Alice", to = "Alicia")),
|
||||
"book-b" to listOf(rule(from = "Alice", to = "Alix")),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"Alicia looked around.",
|
||||
ReaderBookReplacementEngine.apply("Alice looked around.", preferences, "book-a").text,
|
||||
)
|
||||
assertEquals(
|
||||
"Alix looked around.",
|
||||
ReaderBookReplacementEngine.apply("Alice looked around.", preferences, "book-b").text,
|
||||
)
|
||||
assertEquals(
|
||||
"Alice looked around.",
|
||||
ReaderBookReplacementEngine.apply("Alice looked around.", preferences, "missing").text,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `book replacements have no global fallback`() {
|
||||
val preferences = ReaderBookReplacementPreferences(
|
||||
fileRules = mapOf("" to listOf(rule(from = "global", to = "local"))),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"global rule",
|
||||
ReaderBookReplacementEngine.apply("global rule", preferences, "book").text,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `book replacements serialize and preserve per file rules`() {
|
||||
val preferences = ReaderBookReplacementPreferences(
|
||||
fileRules = mapOf(
|
||||
"book" to listOf(
|
||||
rule(
|
||||
id = "regex",
|
||||
from = """A(\w+)""",
|
||||
to = "B\$1",
|
||||
isRegex = true,
|
||||
wholeWord = false,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val decoded = ReaderBookReplacementPreferencesJson.decodeOrEmpty(
|
||||
ReaderBookReplacementPreferencesJson.encode(preferences),
|
||||
)
|
||||
|
||||
assertEquals(preferences, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `signature only reflects active rules for file`() {
|
||||
val preferences = ReaderBookReplacementPreferences(
|
||||
fileRules = mapOf(
|
||||
"book" to listOf(
|
||||
rule(id = "on", from = "old", to = "new"),
|
||||
rule(id = "off", from = "draft", to = "unused", enabled = false),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val signature = preferences.signatureForFile("book")
|
||||
|
||||
assertTrue("old" in signature)
|
||||
assertTrue("draft" !in signature)
|
||||
assertEquals("", preferences.signatureForFile("missing"))
|
||||
}
|
||||
|
||||
private fun rule(
|
||||
id: String = "rule",
|
||||
from: String,
|
||||
to: String,
|
||||
enabled: Boolean = true,
|
||||
isRegex: Boolean = false,
|
||||
matchCase: Boolean = false,
|
||||
wholeWord: Boolean = true,
|
||||
): ReaderWordReplacementRule {
|
||||
return ReaderWordReplacementRule(
|
||||
id = id,
|
||||
from = from,
|
||||
to = to,
|
||||
enabled = enabled,
|
||||
isRegex = isRegex,
|
||||
matchCase = matchCase,
|
||||
wholeWord = wholeWord,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -55,7 +55,8 @@ class ReaderDefaultSettingsStateTest {
|
|||
fontFamily = "Serif",
|
||||
themeId = "dark",
|
||||
textureId = "paper",
|
||||
textureAlpha = 0.25f
|
||||
textureAlpha = 0.25f,
|
||||
rightToLeftPagination = true
|
||||
)
|
||||
|
||||
val decoded = SharedLibrarySnapshotJson.decodeOrEmpty(
|
||||
|
|
@ -72,7 +73,8 @@ class ReaderDefaultSettingsStateTest {
|
|||
val epubDefaults = ReaderSettings(themeId = "sepia")
|
||||
val pdfDefaults = ReaderSettings(
|
||||
themeId = "reverse",
|
||||
pdfFirstPageStandaloneInSpread = true
|
||||
pdfFirstPageStandaloneInSpread = true,
|
||||
rightToLeftPagination = true
|
||||
)
|
||||
|
||||
val decoded = SharedLibrarySnapshotJson.decodeOrEmpty(
|
||||
|
|
|
|||
|
|
@ -48,6 +48,35 @@ class ReaderExtrasModelsTest {
|
|||
assertIs<ReaderByokTextRequestResult.Ready>(ready)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader ai one model setting matches Android model selection logic`() {
|
||||
val oneModel = ReaderByokTextRequests.build(
|
||||
settings = ReaderAiByokSettings(
|
||||
geminiKey = "gemini_test",
|
||||
groqKey = "gsk_test",
|
||||
useOneModel = true,
|
||||
modelForAll = "groq:qwen/qwen3-32b",
|
||||
defineModel = "gemini:gemini-flash-lite-latest"
|
||||
),
|
||||
feature = ReaderAiFeature.DEFINE,
|
||||
text = "epistemic"
|
||||
)
|
||||
val perFeature = ReaderByokTextRequests.build(
|
||||
settings = ReaderAiByokSettings(
|
||||
geminiKey = "gemini_test",
|
||||
groqKey = "gsk_test",
|
||||
useOneModel = false,
|
||||
modelForAll = "groq:qwen/qwen3-32b",
|
||||
defineModel = "gemini:gemini-flash-lite-latest"
|
||||
),
|
||||
feature = ReaderAiFeature.DEFINE,
|
||||
text = "epistemic"
|
||||
)
|
||||
|
||||
assertEquals("groq:qwen/qwen3-32b", assertIs<ReaderByokTextRequestResult.Ready>(oneModel).request.model.id)
|
||||
assertEquals("gemini:gemini-flash-lite-latest", assertIs<ReaderByokTextRequestResult.Ready>(perFeature).request.model.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BYOK cloud tts is available only with gemini key and cloud tts model`() {
|
||||
assertFalse(ReaderAiByokSettings(geminiKey = "key").isCloudTtsAvailable)
|
||||
|
|
@ -112,6 +141,56 @@ class ReaderExtrasModelsTest {
|
|||
assertTrue(populated.hasCurrentVoiceCachedAudio)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cloud tts overlay is visible only for active reader playback`() {
|
||||
assertFalse(readerCloudTtsControlsModel(ReaderCloudTtsState(isAvailable = true)).isVisible)
|
||||
assertTrue(readerCloudTtsControlsModel(ReaderCloudTtsState(isLoading = true)).isVisible)
|
||||
assertTrue(readerCloudTtsControlsModel(ReaderCloudTtsState(isPlaying = true)).isVisible)
|
||||
assertTrue(readerCloudTtsControlsModel(ReaderCloudTtsState(isPaused = true)).isVisible)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cloud tts overlay exposes chunk navigation only when a chunk can be skipped`() {
|
||||
val chunks = List(3) { index ->
|
||||
ReaderTtsChunk(
|
||||
index = index,
|
||||
pageIndex = index,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "Chapter",
|
||||
text = "Part ${index + 1}.",
|
||||
startOffset = index * 10,
|
||||
endOffset = index * 10 + 7
|
||||
)
|
||||
}
|
||||
|
||||
val first = readerCloudTtsControlsModel(
|
||||
ReaderCloudTtsState(
|
||||
isPlaying = true,
|
||||
progress = ReaderTtsProgress(chunks = chunks, currentChunkIndex = 0)
|
||||
)
|
||||
)
|
||||
val middle = readerCloudTtsControlsModel(
|
||||
ReaderCloudTtsState(
|
||||
isPlaying = true,
|
||||
progress = ReaderTtsProgress(chunks = chunks, currentChunkIndex = 1)
|
||||
)
|
||||
)
|
||||
val loading = readerCloudTtsControlsModel(
|
||||
ReaderCloudTtsState(
|
||||
isLoading = true,
|
||||
progress = ReaderTtsProgress(chunks = chunks, currentChunkIndex = 1)
|
||||
)
|
||||
)
|
||||
|
||||
assertFalse(first.canSkipPrevious)
|
||||
assertTrue(first.canSkipNext)
|
||||
assertTrue(first.canLocateCurrentChunk)
|
||||
assertTrue(middle.canSkipPrevious)
|
||||
assertTrue(middle.canSkipNext)
|
||||
assertFalse(loading.canSkipPrevious)
|
||||
assertFalse(loading.canSkipNext)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hidden reader ai follows android availability logic`() {
|
||||
val visible = ReaderAiByokSettings(
|
||||
|
|
@ -234,6 +313,142 @@ class ReaderExtrasModelsTest {
|
|||
assertFalse(chunks.any { it.text.startsWith("First hidden") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts planner keeps synthetic desktop locators at android style chunk boundary`() {
|
||||
val visibleLine = "Gilberte's either noticing or suffering by his peculations. Tears came to my eyes."
|
||||
val source = "Hidden before this visual line. $visibleLine Later visible sentence."
|
||||
val visibleStart = source.indexOf(visibleLine)
|
||||
val book = SharedEpubBook(
|
||||
id = "tts-desktop-line",
|
||||
fileName = "tts-desktop-line.epub",
|
||||
title = "TTS desktop line",
|
||||
chapters = listOf(SharedEpubChapter("one", "One", source))
|
||||
)
|
||||
val page = ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = source,
|
||||
startOffset = 0,
|
||||
endOffset = source.length
|
||||
)
|
||||
val session = ReaderSessionState(
|
||||
reader = PaginatedReaderState(
|
||||
book = book,
|
||||
pages = listOf(page),
|
||||
currentPageIndex = 0
|
||||
),
|
||||
navigationLocator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
pageIndex = 0,
|
||||
startOffset = visibleStart,
|
||||
endOffset = visibleStart,
|
||||
textQuote = visibleLine,
|
||||
cfi = "desktop:0:$visibleStart:$visibleStart"
|
||||
)
|
||||
)
|
||||
|
||||
val first = ReaderTtsPlanner.chunksFromCurrentLocation(session).first()
|
||||
|
||||
assertEquals(visibleStart, first.startOffset)
|
||||
assertTrue(first.text.startsWith(visibleLine))
|
||||
assertFalse(first.text.startsWith("Hidden before"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts planner trims onward chunks with source offsets after sentence gaps`() {
|
||||
val source = "First hidden sentence.\n\nSecond visible sentence starts on the top line."
|
||||
val visibleOffset = source.indexOf("Second")
|
||||
val book = SharedEpubBook(
|
||||
id = "tts-visible-gap",
|
||||
fileName = "tts-visible-gap.epub",
|
||||
title = "TTS visible gap",
|
||||
chapters = listOf(SharedEpubChapter("one", "One", source))
|
||||
)
|
||||
val session = ReaderEngine().createSession(book).copy(
|
||||
navigationLocator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
pageIndex = 0,
|
||||
startOffset = visibleOffset,
|
||||
endOffset = visibleOffset,
|
||||
textQuote = "Second visible sentence starts on the top line."
|
||||
)
|
||||
)
|
||||
|
||||
val first = ReaderTtsPlanner.chunksFromCurrentLocation(session).first()
|
||||
|
||||
assertEquals(visibleOffset, first.startOffset)
|
||||
assertTrue(first.text.startsWith("Second visible sentence starts"))
|
||||
assertFalse(first.text.startsWith("cond visible"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts planner matches android source cfi before slicing initial chunk`() {
|
||||
val hidden = "Hidden block text that should never be trimmed into."
|
||||
val visible = "Visible line starts here and should be spoken."
|
||||
val visibleOffset = 20
|
||||
val hiddenBlock = SemanticParagraph(
|
||||
text = hidden,
|
||||
spans = emptyList(),
|
||||
style = CssStyle(),
|
||||
elementId = null,
|
||||
cfi = "/4/2",
|
||||
startCharOffsetInSource = 0,
|
||||
blockIndex = 0
|
||||
)
|
||||
val visibleBlock = SemanticParagraph(
|
||||
text = visible,
|
||||
spans = emptyList(),
|
||||
style = CssStyle(),
|
||||
elementId = null,
|
||||
cfi = "/4/4",
|
||||
startCharOffsetInSource = visibleOffset,
|
||||
blockIndex = 1
|
||||
)
|
||||
val book = SharedEpubBook(
|
||||
id = "tts-cfi-match",
|
||||
fileName = "tts-cfi-match.epub",
|
||||
title = "TTS CFI match",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = "$hidden\n$visible",
|
||||
semanticBlocks = listOf(hiddenBlock, visibleBlock)
|
||||
)
|
||||
)
|
||||
)
|
||||
val page = ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "$hidden\n$visible",
|
||||
startOffset = 0,
|
||||
endOffset = hidden.length + visible.length + visibleOffset
|
||||
)
|
||||
val session = ReaderSessionState(
|
||||
reader = PaginatedReaderState(
|
||||
book = book,
|
||||
pages = listOf(page),
|
||||
currentPageIndex = 0
|
||||
),
|
||||
navigationLocator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
pageIndex = 0,
|
||||
startOffset = visibleOffset,
|
||||
endOffset = visibleOffset,
|
||||
textQuote = visible,
|
||||
cfi = "/4/4:0"
|
||||
)
|
||||
)
|
||||
|
||||
val first = ReaderTtsPlanner.chunksFromCurrentLocation(session).first()
|
||||
|
||||
assertEquals("/4/4", first.sourceCfi)
|
||||
assertTrue(first.text.startsWith("Visible line starts here"))
|
||||
assertFalse(first.text.contains("Hidden block"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts planner maps trimmed page text back to source offsets`() {
|
||||
val source = "Intro.\n\n Leading words continue."
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ class ReaderToolbarPreferencesTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `highlight palette reducer sanitizes colors`() {
|
||||
fun `highlight palette reducer follows android four slot palette`() {
|
||||
val state = SharedReaderScreenState()
|
||||
.reduce(
|
||||
AppAction.ReaderHighlightPaletteChanged(
|
||||
|
|
@ -46,6 +46,19 @@ class ReaderToolbarPreferencesTest {
|
|||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf(HighlightColor.CYAN, HighlightColor.YELLOW), state.readerHighlightPalette.colors)
|
||||
assertEquals(ReaderHighlightPalette.defaultColors, state.readerHighlightPalette.colors)
|
||||
|
||||
val customized = state.reduce(
|
||||
AppAction.ReaderHighlightPaletteChanged(
|
||||
ReaderHighlightPalette(
|
||||
colors = listOf(HighlightColor.CYAN, HighlightColor.CYAN, HighlightColor.PINK, HighlightColor.WHITE)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(HighlightColor.CYAN, HighlightColor.CYAN, HighlightColor.PINK, HighlightColor.WHITE),
|
||||
customized.readerHighlightPalette.colors
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,6 +86,27 @@ class SettingsHubModelsTest {
|
|||
assertTrue(SharedSettingsAction.FOLDER_SYNC in actions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop can hide account auth rows while preserving sync controls`() {
|
||||
val model = sharedSettingsHubModel(
|
||||
SharedSettingsHubInput(
|
||||
platform = SharedSettingsPlatform.DESKTOP,
|
||||
includeAccountAuthActions = false,
|
||||
accountAvailable = true,
|
||||
syncAvailable = true,
|
||||
folderSyncAvailable = true,
|
||||
isSignedIn = true,
|
||||
isProUser = true
|
||||
)
|
||||
)
|
||||
val actions = model.visibleNestedActions()
|
||||
|
||||
assertFalse(SharedSettingsAction.SIGN_IN in actions)
|
||||
assertFalse(SharedSettingsAction.SIGN_OUT in actions)
|
||||
assertTrue(SharedSettingsAction.CLOUD_SYNC in actions)
|
||||
assertTrue(SharedSettingsAction.FOLDER_SYNC in actions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader tabs setting can be omitted for platforms without visible tabs`() {
|
||||
val model = sharedSettingsHubModel(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class SharedLegalLinksTest {
|
||||
@Test
|
||||
fun `standard and oss legal profiles use separate policy pages`() {
|
||||
val standard = sharedLegalLinksForProfile(SharedLegalProfile.STANDARD)
|
||||
val oss = sharedLegalLinksForProfile(SharedLegalProfile.OSS)
|
||||
|
||||
assertEquals("$EPISTEME_POLICY_BASE_URL/privacy-policy.html", standard.privacyPolicyUrl)
|
||||
assertEquals("$EPISTEME_POLICY_BASE_URL/terms-and-conditions.html", standard.termsUrl)
|
||||
assertEquals("$EPISTEME_POLICY_BASE_URL/oss-privacy-policy.html", oss.privacyPolicyUrl)
|
||||
assertEquals("$EPISTEME_POLICY_BASE_URL/oss-terms-of-service.html", oss.termsUrl)
|
||||
assertEquals(standard.licensesUrl, oss.licensesUrl)
|
||||
}
|
||||
}
|
||||
|
|
@ -77,6 +77,113 @@ class SharedLibraryEditorTest {
|
|||
assertEquals("banner_books_added_to_shelf", result.state.bannerMessage?.text?.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addBooksToShelves adds missing refs to multiple shelves and can keep selection`() {
|
||||
val state = SharedReaderScreenState(selectedBookIds = setOf("selected"))
|
||||
val refs = listOf(BookShelfRef(bookId = "one", shelfId = "manual_a", addedAt = 1L))
|
||||
|
||||
val result = SharedLibraryEditor.addBooksToShelves(
|
||||
state = state,
|
||||
shelfRecords = listOf(ShelfRecord("manual_a", "A"), ShelfRecord("manual_b", "B")),
|
||||
shelfRefs = refs,
|
||||
bookIds = listOf(" one ", "two", "one"),
|
||||
shelfIds = listOf("manual_a", "manual_b", "unshelved", " "),
|
||||
clearSelection = false,
|
||||
nowMillis = 9L
|
||||
)
|
||||
|
||||
requireNotNull(result)
|
||||
assertEquals(setOf("selected"), result.state.selectedBookIds)
|
||||
assertEquals(
|
||||
listOf(
|
||||
BookShelfRef(bookId = "one", shelfId = "manual_a", addedAt = 1L),
|
||||
BookShelfRef(bookId = "two", shelfId = "manual_a", addedAt = 9L),
|
||||
BookShelfRef(bookId = "one", shelfId = "manual_b", addedAt = 9L),
|
||||
BookShelfRef(bookId = "two", shelfId = "manual_b", addedAt = 9L)
|
||||
),
|
||||
result.shelfRefs
|
||||
)
|
||||
assertEquals("3 shelf entries added.", result.state.bannerMessage?.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addBooksToShelves clears selection when requested`() {
|
||||
val state = SharedReaderScreenState(selectedBookIds = setOf("one"))
|
||||
|
||||
val result = SharedLibraryEditor.addBooksToShelves(
|
||||
state = state,
|
||||
shelfRecords = listOf(ShelfRecord("manual", "Manual")),
|
||||
shelfRefs = emptyList(),
|
||||
bookIds = listOf("one"),
|
||||
shelfIds = listOf("manual"),
|
||||
clearSelection = true,
|
||||
nowMillis = 10L
|
||||
)
|
||||
|
||||
requireNotNull(result)
|
||||
assertTrue(result.state.selectedBookIds.isEmpty())
|
||||
assertEquals(
|
||||
listOf(BookShelfRef(bookId = "one", shelfId = "manual", addedAt = 10L)),
|
||||
result.shelfRefs
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaceShelfBooks only rewrites target shelf refs`() {
|
||||
val state = SharedReaderScreenState(
|
||||
shelves = listOf(Shelf("manual", "Manual", ShelfType.MANUAL, emptyList()))
|
||||
)
|
||||
val refs = listOf(
|
||||
BookShelfRef(bookId = "old", shelfId = "manual", addedAt = 1L),
|
||||
BookShelfRef(bookId = "keep", shelfId = "other", addedAt = 2L)
|
||||
)
|
||||
|
||||
val result = SharedLibraryEditor.replaceShelfBooks(
|
||||
state = state,
|
||||
shelfRecords = listOf(ShelfRecord("manual", "Manual")),
|
||||
shelfRefs = refs,
|
||||
shelfId = "manual",
|
||||
bookIds = listOf("new", "new", " "),
|
||||
nowMillis = 7L
|
||||
)
|
||||
|
||||
requireNotNull(result)
|
||||
assertEquals(
|
||||
listOf(
|
||||
BookShelfRef(bookId = "keep", shelfId = "other", addedAt = 2L),
|
||||
BookShelfRef(bookId = "new", shelfId = "manual", addedAt = 7L)
|
||||
),
|
||||
result.shelfRefs
|
||||
)
|
||||
assertEquals("Updated \"Manual\" with 1 book.", result.state.bannerMessage?.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createShelfWithBooks creates shelf refs and clears selection`() {
|
||||
val state = SharedReaderScreenState(selectedBookIds = setOf("one", "two"))
|
||||
|
||||
val result = SharedLibraryEditor.createShelfWithBooks(
|
||||
state = state,
|
||||
shelfRecords = emptyList(),
|
||||
shelfRefs = emptyList(),
|
||||
name = " Favorites ",
|
||||
bookIds = listOf("one", "two", "one"),
|
||||
nowMillis = 12L
|
||||
)
|
||||
|
||||
requireNotNull(result)
|
||||
assertTrue(result.state.selectedBookIds.isEmpty())
|
||||
assertEquals(listOf(ShelfRecord("shelf_12", "Favorites")), result.shelfRecords)
|
||||
assertEquals(
|
||||
listOf(
|
||||
BookShelfRef(bookId = "one", shelfId = "shelf_12", addedAt = 12L),
|
||||
BookShelfRef(bookId = "two", shelfId = "shelf_12", addedAt = 12L)
|
||||
),
|
||||
result.shelfRefs
|
||||
)
|
||||
assertEquals("Created shelf \"Favorites\" with 2 books.", result.state.bannerMessage?.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createSmartShelf stores trimmed shared rules and rejects blank definitions`() {
|
||||
val definition = SmartCollectionDefinition(
|
||||
|
|
|
|||
|
|
@ -163,6 +163,11 @@ class SharedLibraryProjectorTest {
|
|||
assertEquals(setOf("one"), opened.pinnedHomeBookIds)
|
||||
assertEquals(setOf("two"), opened.pinnedLibraryBookIds)
|
||||
|
||||
val reactivated = opened.reduce(AppAction.BookTabOpened("one"))
|
||||
|
||||
assertEquals(listOf("one", "two"), reactivated.openTabIds)
|
||||
assertEquals("one", reactivated.activeTabBookId)
|
||||
|
||||
val closedActive = opened.reduce(AppAction.BookTabClosed("two"))
|
||||
|
||||
assertEquals(listOf("one"), closedActive.openTabIds)
|
||||
|
|
@ -375,6 +380,7 @@ class SharedLibraryProjectorTest {
|
|||
assertEquals(FileType.PDF, "REPORT.PDF".toFileType())
|
||||
assertEquals(FileType.HTML, "page.htm".toFileType())
|
||||
assertEquals(FileType.CBZ, "comic.cbz".toFileType())
|
||||
assertEquals(FileType.CBT, "comic.cbt".toFileType())
|
||||
assertEquals(FileType.UNKNOWN, "archive.zip".toFileType())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ class SharedLibrarySnapshotJsonTest {
|
|||
pageInfoMode = PageInfoMode.SYNC,
|
||||
pageInfoPosition = PageInfoPosition.TOP,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE,
|
||||
rightToLeftPagination = true,
|
||||
pdfVerticalPageGapVisible = false,
|
||||
pdfPageNumberOverlayVisible = false,
|
||||
pdfFirstPageStandaloneInSpread = true,
|
||||
|
|
@ -121,7 +122,8 @@ class SharedLibrarySnapshotJsonTest {
|
|||
paginatedVerticalScrollOffset = 140,
|
||||
verticalFirstPageIndex = 3,
|
||||
verticalFirstPageScrollOffset = 44
|
||||
)
|
||||
),
|
||||
readingPositionModifiedTimestamp = 9_000L
|
||||
)
|
||||
),
|
||||
shelfRecords = listOf(ShelfRecord(id = "shelf", name = "Shelf", isSmart = true, smartRulesJson = "{}")),
|
||||
|
|
@ -154,6 +156,25 @@ class SharedLibrarySnapshotJsonTest {
|
|||
customAppThemes = listOf(
|
||||
CustomAppTheme(id = "forest", name = "Forest", seedColor = Color(0xFF006C4C))
|
||||
),
|
||||
customReaderThemes = listOf(
|
||||
ReaderTheme(
|
||||
id = "my_solid",
|
||||
name = "My Solid",
|
||||
backgroundColor = Color(0xFFF5F5F5),
|
||||
textColor = Color(0xFF111111),
|
||||
isDark = false,
|
||||
isCustom = true
|
||||
),
|
||||
ReaderTheme(
|
||||
id = "my_texture",
|
||||
name = "My Texture",
|
||||
backgroundColor = Color(0xFF222222),
|
||||
textColor = Color(0xFFEFEFEF),
|
||||
isDark = true,
|
||||
textureId = ReaderTexture.CANVAS.id,
|
||||
isCustom = true
|
||||
)
|
||||
),
|
||||
readerDefaultSettings = ReaderSettings(themeId = "sepia"),
|
||||
pdfReaderDefaultSettings = ReaderSettings(themeId = "reverse"),
|
||||
readerToolbarPreferences = ReaderToolbarPreferences(
|
||||
|
|
@ -162,7 +183,7 @@ class SharedLibrarySnapshotJsonTest {
|
|||
bottomToolIds = setOf(ReaderTool.BOOKMARK.id)
|
||||
).sanitized(),
|
||||
readerHighlightPalette = ReaderHighlightPalette(
|
||||
colors = listOf(HighlightColor.YELLOW, HighlightColor.CYAN)
|
||||
colors = listOf(HighlightColor.YELLOW, HighlightColor.CYAN, HighlightColor.CYAN, HighlightColor.WHITE)
|
||||
),
|
||||
readerTtsReplacementPreferences = ReaderTtsReplacementPreferences(
|
||||
globalRules = listOf(
|
||||
|
|
@ -250,6 +271,7 @@ class SharedLibrarySnapshotJsonTest {
|
|||
|
||||
assertTrue(settings.pdfVerticalPageGapVisible)
|
||||
assertTrue(settings.pdfPageNumberOverlayVisible)
|
||||
assertFalse(settings.rightToLeftPagination)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -295,7 +317,8 @@ class SharedLibrarySnapshotJsonTest {
|
|||
"uriString": "C:/Books",
|
||||
"name": "Books",
|
||||
"lastScanTime": 12,
|
||||
"allowedFileTypes": ["PDF", "UNKNOWN", "EPUB"]
|
||||
"allowedFileTypes": ["PDF", "UNKNOWN", "EPUB"],
|
||||
"localSyncEnabled": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -304,6 +327,7 @@ class SharedLibrarySnapshotJsonTest {
|
|||
val folder = decoded.syncedFolders.single()
|
||||
|
||||
assertEquals(setOf(FileType.PDF, FileType.EPUB), folder.allowedFileTypes)
|
||||
assertFalse(folder.localSyncEnabled)
|
||||
assertFalse(FileType.UNKNOWN in folder.allowedFileTypes)
|
||||
|
||||
val encoded = SharedLibrarySnapshotJson.encode(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.aryan.reader.shared.opds
|
||||
|
||||
import com.aryan.reader.shared.BookItem
|
||||
import com.aryan.reader.shared.FileType
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
|
@ -67,6 +69,13 @@ class SharedOpdsCatalogsTest {
|
|||
"https://example.org/search?q=ada%20lovelace",
|
||||
SharedOpdsSearch.expandSearchTemplate("https://example.org/search?q={searchTerms}", "ada lovelace")
|
||||
)
|
||||
assertEquals(
|
||||
"https://example.org/search?q=ada%20lovelace&per-page=12&page=1",
|
||||
SharedOpdsSearch.expandSearchTemplate(
|
||||
"https://example.org/search?q={searchTerms}&per-page={count}&page={startPage}",
|
||||
"ada lovelace"
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
"https://example.org/search?existing=1&query=ada%20lovelace",
|
||||
SharedOpdsSearch.expandSearchTemplate("https://example.org/search?existing=1", "ada lovelace")
|
||||
|
|
@ -114,5 +123,50 @@ class SharedOpdsCatalogsTest {
|
|||
urlPathSegment = null
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
".cbt",
|
||||
SharedOpdsDownloadNamer.resolveExtension(
|
||||
acquisition = OpdsAcquisition("https://example.org/download", "application/vnd.comicbook+tar"),
|
||||
contentDisposition = null,
|
||||
urlPathSegment = null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local book matcher recognizes opds download temp names and acquisition filenames`() {
|
||||
val entry = OpdsEntry(
|
||||
id = "entry",
|
||||
title = "A Catalog Book",
|
||||
summary = null,
|
||||
coverUrl = null,
|
||||
acquisitions = listOf(
|
||||
OpdsAcquisition("https://example.org/files/alternate-title.epub", "application/epub+zip")
|
||||
),
|
||||
navigationUrl = null
|
||||
)
|
||||
val books = listOf(
|
||||
BookItem(
|
||||
id = "book",
|
||||
path = "file:///library/opds_dl_A_Catalog_Book.epub",
|
||||
type = FileType.EPUB,
|
||||
displayName = "opds_dl_A_Catalog_Book.epub",
|
||||
timestamp = 1L,
|
||||
title = "Embedded Title"
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(books.single(), SharedOpdsLocalBookMatcher.findBook(entry, books))
|
||||
|
||||
val acquisitionNamedBook = books.single().copy(
|
||||
path = "file:///library/alternate-title.epub",
|
||||
displayName = "alternate-title.epub",
|
||||
title = "Different Embedded Title"
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
acquisitionNamedBook,
|
||||
SharedOpdsLocalBookMatcher.findBook(entry, listOf(acquisitionNamedBook))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,6 +160,37 @@ class PdfReaderSessionTest {
|
|||
assertEquals(config.strokeWidth, state.strokeWidth)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tool color and thickness changes persist per active tool`() {
|
||||
val penColor = 0xFF123456.toInt()
|
||||
val highlighterColor = 0x8CABCDEF.toInt()
|
||||
|
||||
val penConfigured = SharedPdfReaderState.initial(pageCount = 1)
|
||||
.reduce(SharedPdfReaderAction.ToolSelected(PdfInkTool.PEN))
|
||||
.reduce(SharedPdfReaderAction.ColorSelected(penColor))
|
||||
.reduce(SharedPdfReaderAction.StrokeWidthChanged(0.012f))
|
||||
val highlighterConfigured = penConfigured
|
||||
.reduce(SharedPdfReaderAction.ToolSelected(PdfInkTool.HIGHLIGHTER))
|
||||
.reduce(SharedPdfReaderAction.ColorSelected(highlighterColor))
|
||||
val penAgain = highlighterConfigured.reduce(SharedPdfReaderAction.ToolSelected(PdfInkTool.PEN))
|
||||
val highlighterAgain = penAgain.reduce(SharedPdfReaderAction.ToolSelected(PdfInkTool.HIGHLIGHTER))
|
||||
|
||||
assertEquals(penColor, penAgain.selectedColorArgb)
|
||||
assertEquals(0.012f, penAgain.strokeWidth)
|
||||
assertEquals(highlighterColor, highlighterAgain.selectedColorArgb)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pen palette changes follow android fixed slot behavior`() {
|
||||
val customColor = 0xFF010203.toInt()
|
||||
|
||||
val state = SharedPdfReaderState.initial(pageCount = 1)
|
||||
.reduce(SharedPdfReaderAction.PenPaletteChanged(listOf(0, customColor, 0xFF040506.toInt())))
|
||||
|
||||
assertEquals(SharedPdfAnnotationDefaults.penPalette.size, state.penPalette.size)
|
||||
assertEquals(customColor, state.penPalette.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text selection markup tools and neutral mode are exclusive`() {
|
||||
val selectingText = SharedPdfReaderState.initial(pageCount = 1)
|
||||
|
|
@ -192,6 +223,29 @@ class PdfReaderSessionTest {
|
|||
assertEquals(listOf(first), state.annotations)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `annotation undo and redo follow add remove history`() {
|
||||
val first = annotation("first", pageIndex = 0)
|
||||
val second = annotation("second", pageIndex = 0)
|
||||
|
||||
val added = SharedPdfReaderState.initial(pageCount = 1)
|
||||
.reduce(SharedPdfReaderAction.AnnotationAdded(first))
|
||||
.reduce(SharedPdfReaderAction.AnnotationAdded(second))
|
||||
|
||||
val undoneAdd = added.reduce(SharedPdfReaderAction.UndoAnnotationEdit)
|
||||
val redoneAdd = undoneAdd.reduce(SharedPdfReaderAction.RedoAnnotationEdit)
|
||||
val removed = redoneAdd.reduce(SharedPdfReaderAction.ClearPageAnnotations(0))
|
||||
val undoneRemove = removed.reduce(SharedPdfReaderAction.UndoAnnotationEdit)
|
||||
val redoneRemove = undoneRemove.reduce(SharedPdfReaderAction.RedoAnnotationEdit)
|
||||
|
||||
assertEquals(listOf(first), undoneAdd.annotations)
|
||||
assertEquals(true, undoneAdd.canRedoAnnotationEdit)
|
||||
assertEquals(listOf(first, second), redoneAdd.annotations)
|
||||
assertEquals(emptyList(), removed.annotations)
|
||||
assertEquals(listOf(first, second), undoneRemove.annotations)
|
||||
assertEquals(emptyList(), redoneRemove.annotations)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bookmark actions toggle and normalize pages`() {
|
||||
val state = SharedPdfReaderState.initial(pageCount = 4)
|
||||
|
|
|
|||
|
|
@ -64,4 +64,20 @@ class PdfSelectionGeometryTest {
|
|||
|
||||
assertEquals(2, merged.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `line fallback collapses overlapping glyph bands on the same visual line`() {
|
||||
val bounds = PdfSelectionGeometry.lineBoundsForChars(
|
||||
listOf(
|
||||
PdfTextCharBounds(index = 1, left = 0.10f, top = 0.100f, right = 0.13f, bottom = 0.130f),
|
||||
PdfTextCharBounds(index = 2, left = 0.14f, top = 0.116f, right = 0.17f, bottom = 0.146f),
|
||||
PdfTextCharBounds(index = 3, left = 0.18f, top = 0.101f, right = 0.21f, bottom = 0.131f)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(PdfPageBounds(left = 0.10f, top = 0.100f, right = 0.21f, bottom = 0.146f)),
|
||||
bounds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,19 @@ class PdfSpreadLayoutTest {
|
|||
assertEquals(listOf(0, 2, 4), PdfSpreadLayout.spreadStartPageIndices(pageCount = 5, settings = settings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `right to left pagination reverses only the displayed pdf spread order`() {
|
||||
val settings = ReaderSettings(
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE,
|
||||
rightToLeftPagination = true
|
||||
)
|
||||
|
||||
assertEquals(listOf(2, 3), PdfSpreadLayout.visiblePageIndices(3, pageCount = 10, settings = settings))
|
||||
assertEquals(listOf(3, 2), PdfSpreadLayout.visiblePageIndicesForDisplay(3, pageCount = 10, settings = settings))
|
||||
assertEquals(4, PdfSpreadLayout.nextPageIndex(2, pageCount = 10, settings = settings))
|
||||
assertEquals(0, PdfSpreadLayout.previousPageIndex(2, pageCount = 10, settings = settings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two page mode can keep the first page alone`() {
|
||||
val settings = ReaderSettings(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
package com.aryan.reader.shared.pdf
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class SharedPdfAnnotationCommentsTest {
|
||||
@Test
|
||||
fun `visible comments filter blanks and promote orphan replies`() {
|
||||
val comments = listOf(
|
||||
SharedPdfAnnotationComment(id = "root", contents = "Root"),
|
||||
SharedPdfAnnotationComment(id = "reply", parentId = "root", contents = "Reply"),
|
||||
SharedPdfAnnotationComment(id = "blank-parent", contents = ""),
|
||||
SharedPdfAnnotationComment(id = "orphan", parentId = "blank-parent", contents = "Orphan")
|
||||
)
|
||||
|
||||
val visible = comments.visiblePdfAnnotationComments()
|
||||
|
||||
assertEquals(listOf("root", "reply", "orphan"), visible.map { it.id })
|
||||
assertEquals("root", visible.single { it.id == "reply" }.parentId)
|
||||
assertEquals(null, visible.single { it.id == "orphan" }.parentId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `comment helpers preserve nested thread behavior`() {
|
||||
val comments = listOf(
|
||||
SharedPdfAnnotationComment(id = "undated", contents = "Undated"),
|
||||
SharedPdfAnnotationComment(id = "newer", contents = "Newer", createdAt = 30L),
|
||||
SharedPdfAnnotationComment(id = "older", contents = "Older", createdAt = 10L),
|
||||
SharedPdfAnnotationComment(id = "child", parentId = "older", contents = "Child"),
|
||||
SharedPdfAnnotationComment(id = "grandchild", parentId = "child", contents = "Grandchild"),
|
||||
SharedPdfAnnotationComment(id = "sibling", contents = "Sibling", createdAt = 20L)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("older", "sibling", "newer", "undated"),
|
||||
comments.pdfCommentChildren(parentId = null).map { it.id }
|
||||
)
|
||||
assertEquals(
|
||||
listOf("undated", "newer", "older", "sibling"),
|
||||
comments.withoutPdfCommentThread("child").map { it.id }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -254,6 +254,106 @@ class SharedPdfAnnotationSerializerTest {
|
|||
assertEquals(0, legacy.getValue("highlights").jsonArray.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sidecar codec merges local and remote annotation additions`() {
|
||||
val local = SharedPdfAnnotation(
|
||||
id = "local-ink",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.INK,
|
||||
points = listOf(PdfPagePoint(0.1f, 0.2f, 10L)),
|
||||
colorArgb = 0xFF000000.toInt(),
|
||||
createdAt = 10L
|
||||
)
|
||||
val remote = SharedPdfAnnotation(
|
||||
id = "remote-ink",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.INK,
|
||||
points = listOf(PdfPagePoint(0.3f, 0.4f, 20L)),
|
||||
colorArgb = 0xFFFF0000.toInt(),
|
||||
createdAt = 20L
|
||||
)
|
||||
fun payload(annotation: SharedPdfAnnotation): String {
|
||||
return testJson.encodeToString(
|
||||
JsonElement.serializer(),
|
||||
JsonObject(
|
||||
mapOf(
|
||||
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS to
|
||||
SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(listOf(annotation))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val merged = SharedPdfAnnotationSidecarCodec.mergeAnnotationDataJson(
|
||||
localDataJson = payload(local),
|
||||
remoteDataJson = payload(remote),
|
||||
preferRemoteOnConflict = false
|
||||
)
|
||||
val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(
|
||||
testJson.parseToJsonElement(merged).jsonObject
|
||||
)
|
||||
|
||||
assertEquals(listOf("local-ink", "remote-ink"), annotations.map { it.id })
|
||||
assertEquals(2, SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(merged))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sidecar codec deletion tombstones remove stale remote annotations`() {
|
||||
val deletedRemote = SharedPdfAnnotation(
|
||||
id = "deleted-remote-ink",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.INK,
|
||||
points = listOf(PdfPagePoint(0.1f, 0.2f, 10L)),
|
||||
colorArgb = 0xFF000000.toInt(),
|
||||
createdAt = 10L
|
||||
)
|
||||
val local = SharedPdfAnnotation(
|
||||
id = "local-ink",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.INK,
|
||||
points = listOf(PdfPagePoint(0.3f, 0.4f, 20L)),
|
||||
colorArgb = 0xFFFF0000.toInt(),
|
||||
createdAt = 20L
|
||||
)
|
||||
fun payload(
|
||||
annotations: List<SharedPdfAnnotation>,
|
||||
deletions: Map<String, Long> = emptyMap()
|
||||
): String {
|
||||
return testJson.encodeToString(
|
||||
JsonElement.serializer(),
|
||||
JsonObject(
|
||||
buildMap {
|
||||
put(
|
||||
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS,
|
||||
SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations)
|
||||
)
|
||||
if (deletions.isNotEmpty()) {
|
||||
put(
|
||||
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATION_DELETIONS,
|
||||
SharedPdfAnnotationSidecarCodec.encodeAnnotationDeletionsElement(deletions)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val merged = SharedPdfAnnotationSidecarCodec.mergeAnnotationDataJson(
|
||||
localDataJson = payload(
|
||||
annotations = listOf(local),
|
||||
deletions = mapOf(deletedRemote.id to 100L)
|
||||
),
|
||||
remoteDataJson = payload(listOf(deletedRemote)),
|
||||
preferRemoteOnConflict = false
|
||||
)
|
||||
val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(
|
||||
testJson.parseToJsonElement(merged).jsonObject
|
||||
)
|
||||
|
||||
assertEquals(listOf("local-ink"), annotations.map { it.id })
|
||||
assertEquals(mapOf(deletedRemote.id to 100L), SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(merged))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `embedded annotation threads link replies and nearby orphan comments`() {
|
||||
val root = embeddedAnnotation(
|
||||
|
|
|
|||
|
|
@ -178,6 +178,13 @@ class SharedPdfRichTextTest {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selection bounds normalize reversed and clamped rich text selections`() {
|
||||
assertEquals(44 to 45, sharedPdfRichTextSelectionBounds(45, 44, textLength = 45))
|
||||
assertEquals(0 to 5, sharedPdfRichTextSelectionBounds(-3, 99, textLength = 5))
|
||||
assertEquals(null, sharedPdfRichTextSelectionBounds(3, 3, textLength = 5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trailing page break creates editable blank page layout`() {
|
||||
val globalText = AnnotatedString("$SHARED_PDF_PAGE_BREAK_CHAR")
|
||||
|
|
|
|||
|
|
@ -38,6 +38,40 @@ class ReaderEngineTest {
|
|||
assertSame(first.reader.pages, second.reader.pages)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible locator and bookmarks prefer android style cfi when semantic blocks provide it`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(
|
||||
SharedEpubBook(
|
||||
id = "semantic",
|
||||
fileName = "semantic.epub",
|
||||
title = "Semantic",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = "Alpha beta",
|
||||
semanticBlocks = listOf(
|
||||
SemanticParagraph(
|
||||
text = "Alpha beta",
|
||||
spans = emptyList(),
|
||||
style = CssStyle(),
|
||||
elementId = null,
|
||||
cfi = "/4/2/2",
|
||||
startCharOffsetInSource = 0
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val bookmarked = engine.toggleBookmark(session)
|
||||
|
||||
assertEquals("/4/2/2:0", session.navigationLocator?.cfi)
|
||||
assertEquals("/4/2/2:0", bookmarked.bookmarks.single().locator.cfi)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visual settings update does not repaginate or move current page`() {
|
||||
val engine = ReaderEngine()
|
||||
|
|
@ -62,6 +96,43 @@ class ReaderEngineTest {
|
|||
assertEquals("night", updated.reader.settings.themeId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical page navigation uses scroll page locator for webview slider sync`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(
|
||||
book = longBook(),
|
||||
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL)
|
||||
)
|
||||
|
||||
val moved = engine.goToPage(session, 1)
|
||||
|
||||
assertEquals(1, moved.reader.currentPageIndex)
|
||||
assertEquals("desktop-scroll-page:1", moved.navigationLocator?.cfi)
|
||||
assertEquals(1, moved.navigationLocator?.pageIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible page sync stores stable vertical locator cfi instead of scroll metrics`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(
|
||||
book = longBook(),
|
||||
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL)
|
||||
)
|
||||
val page = session.reader.pages[1]
|
||||
val locator = ReaderLocator(
|
||||
chapterIndex = page.chapterIndex,
|
||||
pageIndex = page.pageIndex,
|
||||
startOffset = page.startOffset + 24,
|
||||
endOffset = page.startOffset + 24,
|
||||
cfi = "desktop-scroll:120:800:desktop:${page.chapterIndex}:${page.startOffset + 24}:${page.startOffset + 24}"
|
||||
)
|
||||
|
||||
val synced = engine.syncVisiblePage(session, page.pageIndex, locator)
|
||||
|
||||
assertEquals("desktop:${page.chapterIndex}:${page.startOffset + 24}:${page.startOffset + 24}", synced.navigationLocator?.cfi)
|
||||
assertEquals(locator.startOffset, synced.navigationLocator?.startOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createSession restores precise locator ahead of fallback page index`() {
|
||||
val engine = ReaderEngine()
|
||||
|
|
@ -358,6 +429,81 @@ class ReaderEngineTest {
|
|||
assertEquals(160, replaced.navigationLocator?.startOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replacePages resolves page start anchors to the page after a touching boundary`() {
|
||||
val engine = ReaderEngine()
|
||||
val book = manualRangeBook()
|
||||
val pages = listOf(
|
||||
ReaderPage(0, 0, "One", "first", 0, 100),
|
||||
ReaderPage(1, 0, "One", "second", 100, 200),
|
||||
ReaderPage(2, 0, "One", "third", 200, 300)
|
||||
)
|
||||
val session = engine.createSession(book).copy(
|
||||
reader = PaginatedReaderState(book, pages, currentPageIndex = 1),
|
||||
navigationLocator = ReaderLocator(chapterIndex = 0, pageIndex = 1, startOffset = 100, endOffset = 100),
|
||||
navigationRequestId = 8L
|
||||
)
|
||||
|
||||
val replaced = engine.replacePages(
|
||||
state = session,
|
||||
pages = pages,
|
||||
reflowAnchor = session.navigationLocator,
|
||||
navigationRequestIdAtReflowStart = 8L
|
||||
)
|
||||
|
||||
assertEquals(1, replaced.reader.currentPageIndex)
|
||||
assertEquals(1, replaced.navigationLocator?.pageIndex)
|
||||
assertEquals(100, replaced.navigationLocator?.startOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replacePages resolves android block locator after measured pagination`() {
|
||||
val engine = ReaderEngine()
|
||||
val book = manualRangeBook()
|
||||
val initialSession = engine.createSession(
|
||||
book = book,
|
||||
initialLocator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
pageIndex = 0,
|
||||
blockIndex = 42,
|
||||
charOffset = 160,
|
||||
cfi = "android-locator:0:42:160"
|
||||
)
|
||||
)
|
||||
val measuredPages = listOf(
|
||||
ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "first",
|
||||
startOffset = 0,
|
||||
endOffset = 100,
|
||||
semanticBlocks = listOf(SemanticParagraph("first", emptyList(), CssStyle(), null, null, 0, 7))
|
||||
),
|
||||
ReaderPage(
|
||||
pageIndex = 1,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "target",
|
||||
startOffset = 150,
|
||||
endOffset = 210,
|
||||
semanticBlocks = listOf(SemanticParagraph("target", emptyList(), CssStyle(), null, null, 150, 42))
|
||||
)
|
||||
)
|
||||
|
||||
val replaced = engine.replacePages(
|
||||
state = initialSession,
|
||||
pages = measuredPages,
|
||||
reflowAnchor = initialSession.navigationLocator,
|
||||
navigationRequestIdAtReflowStart = initialSession.navigationRequestId
|
||||
)
|
||||
|
||||
assertEquals(1, replaced.reader.currentPageIndex)
|
||||
assertEquals(1, replaced.navigationLocator?.pageIndex)
|
||||
assertEquals(42, replaced.navigationLocator?.blockIndex)
|
||||
assertEquals(160, replaced.navigationLocator?.charOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replacePages lets newer explicit navigation override reflow anchor`() {
|
||||
val engine = ReaderEngine()
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import com.aryan.reader.paginatedreader.SemanticTable
|
|||
import com.aryan.reader.paginatedreader.SemanticTableCell
|
||||
import com.aryan.reader.shared.HighlightColor
|
||||
import com.aryan.reader.shared.ReaderLocator
|
||||
import com.aryan.reader.shared.ReaderHighlightPalette
|
||||
import com.aryan.reader.shared.ReaderTexture
|
||||
import com.aryan.reader.shared.UserHighlight
|
||||
import kotlin.test.Test
|
||||
|
|
@ -126,6 +127,10 @@ class ReaderHtmlDocumentBuilderTest {
|
|||
assertTrue(html.contains("startOffset >= pageEnd || endOffset <= pageStart"))
|
||||
assertTrue(html.contains("normalizedRangeForText(searchRoot, expectedNormalized, false)"))
|
||||
assertTrue(html.contains("locator.textQuote || highlight.text"))
|
||||
assertTrue(html.contains("var sourceCfiBases = readerCfiBases(sourceCfi);"))
|
||||
assertTrue(html.contains("return readerHostMatchesCfi(host, sourceCfiBases);"))
|
||||
assertTrue(html.contains("if (cfiOffsets) {"))
|
||||
assertTrue(html.contains("if (hasPreciseOffsets) return;"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -142,6 +147,8 @@ class ReaderHtmlDocumentBuilderTest {
|
|||
assertTrue(html.contains("highlight_bridge_error attempt="))
|
||||
assertTrue(html.contains("var marker = document.createElement('span');"))
|
||||
assertTrue(html.contains("range.intersectsNode(node)"))
|
||||
assertTrue(html.contains("function readerHighlightCfiForRange(startSegment, endSegment, chapterIndex, startOffset, endOffset)"))
|
||||
assertTrue(html.contains("function readerOffsetsForSourceCfi(chapterIndex, sourceCfi, expectedText)"))
|
||||
assertFalse(html.contains("paintUserHighlightRange(payload"))
|
||||
assertTrue(localWrapIndex >= 0)
|
||||
assertTrue(bridgeSendIndex > localWrapIndex)
|
||||
|
|
@ -224,6 +231,109 @@ class ReaderHtmlDocumentBuilderTest {
|
|||
assertTrue(html.contains("EpistemeEpubPagination"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `page document scopes android locator highlights to one page in a spread`() {
|
||||
val pageText = "prefix target suffix"
|
||||
val book = semanticHighlightBook(
|
||||
leftText = pageText,
|
||||
rightText = pageText
|
||||
)
|
||||
val left = ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = pageText,
|
||||
startOffset = 100,
|
||||
endOffset = 100 + pageText.length,
|
||||
semanticBlocks = listOf(
|
||||
SemanticParagraph(pageText, emptyList(), CssStyle(), null, "/4/2", startCharOffsetInSource = 100, blockIndex = 42)
|
||||
)
|
||||
)
|
||||
val right = ReaderPage(
|
||||
pageIndex = 1,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = pageText,
|
||||
startOffset = 200,
|
||||
endOffset = 200 + pageText.length,
|
||||
semanticBlocks = listOf(
|
||||
SemanticParagraph(pageText, emptyList(), CssStyle(), null, "/4/4", startCharOffsetInSource = 200, blockIndex = 43)
|
||||
)
|
||||
)
|
||||
val highlight = UserHighlight(
|
||||
id = "highlight-android",
|
||||
cfi = "android-locator:0:42:108",
|
||||
text = "target",
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = 0,
|
||||
locator = ReaderLocator.fromLegacy(
|
||||
chapterIndex = 0,
|
||||
cfi = "android-locator:0:42:108",
|
||||
textQuote = "target"
|
||||
)
|
||||
)
|
||||
|
||||
val html = ReaderHtmlDocumentBuilder.pageDocument(
|
||||
book = book,
|
||||
page = left,
|
||||
visiblePages = listOf(left, right),
|
||||
settings = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE),
|
||||
highlights = listOf(highlight)
|
||||
)
|
||||
|
||||
assertEquals(1, Regex("data-reader-highlight-id=\"highlight-android\"").findAll(html).count())
|
||||
assertTrue(html.contains("""data-reader-page-index="0""""))
|
||||
assertTrue(html.contains("""data-reader-page-index="1""""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `page document scopes source cfi text fallback highlights to cfi page`() {
|
||||
val pageText = "prefix target suffix"
|
||||
val book = semanticHighlightBook(
|
||||
leftText = pageText,
|
||||
rightText = pageText
|
||||
)
|
||||
val left = ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = pageText,
|
||||
startOffset = 100,
|
||||
endOffset = 100 + pageText.length,
|
||||
semanticBlocks = listOf(
|
||||
SemanticParagraph(pageText, emptyList(), CssStyle(), null, "/4/2", startCharOffsetInSource = 100, blockIndex = 42)
|
||||
)
|
||||
)
|
||||
val right = ReaderPage(
|
||||
pageIndex = 1,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = pageText,
|
||||
startOffset = 200,
|
||||
endOffset = 200 + pageText.length,
|
||||
semanticBlocks = listOf(
|
||||
SemanticParagraph(pageText, emptyList(), CssStyle(), null, "/4/4", startCharOffsetInSource = 200, blockIndex = 43)
|
||||
)
|
||||
)
|
||||
val highlight = UserHighlight(
|
||||
id = "highlight-cfi",
|
||||
cfi = "/4/2:8|/4/2:14",
|
||||
text = "target",
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = 0
|
||||
)
|
||||
|
||||
val html = ReaderHtmlDocumentBuilder.pageDocument(
|
||||
book = book,
|
||||
page = left,
|
||||
visiblePages = listOf(left, right),
|
||||
settings = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE),
|
||||
highlights = listOf(highlight)
|
||||
)
|
||||
|
||||
assertEquals(1, Regex("data-reader-highlight-id=\"highlight-cfi\"").findAll(html).count())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical document carries active locator for shared scroll navigation`() {
|
||||
val html = ReaderHtmlDocumentBuilder.verticalDocument(
|
||||
|
|
@ -248,10 +358,96 @@ class ReaderHtmlDocumentBuilderTest {
|
|||
assertTrue(html.contains("data-reader-active-chapter-index=\"1\""))
|
||||
assertTrue(html.contains("data-reader-active-start-offset=\"7\""))
|
||||
assertTrue(html.contains("scrollToActiveLocator"))
|
||||
assertTrue(html.contains("readerDesktopPositionTraceLog"))
|
||||
assertTrue(html.contains("bestVisibleReaderHost"))
|
||||
assertTrue(html.contains("function prepareVerticalScrollMeasurement(targetChapter)"))
|
||||
assertTrue(html.contains("pendingRestoreLocator = locator"))
|
||||
assertTrue(html.contains("positionCfi = stableReaderCfi(positionCfi) || stableDesktopCfi(chapterIndex, offset, offset)"))
|
||||
assertFalse(html.contains("positionCfi = 'desktop-scroll:' + metrics.scrollY"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical document styles native scrollbar from reader theme variables`() {
|
||||
fun `vertical document centers followed tts locator without changing active locator scroll`() {
|
||||
val html = ReaderHtmlDocumentBuilder.verticalDocument(
|
||||
book = repeatedWordBook("alpha beta gamma"),
|
||||
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL)
|
||||
)
|
||||
val activeScrollStart = html.indexOf("function scrollToActiveLocator()")
|
||||
val activeCallStart = html.indexOf("scrollToLocator({", activeScrollStart)
|
||||
val activeCallEnd = html.indexOf("});", activeCallStart)
|
||||
assertTrue(activeScrollStart >= 0)
|
||||
assertTrue(activeCallStart > activeScrollStart)
|
||||
assertTrue(activeCallEnd > activeCallStart)
|
||||
val activeScrollCall = html.substring(activeCallStart, activeCallEnd)
|
||||
|
||||
assertTrue(html.contains("function scrollToLocator(locator, options)"))
|
||||
assertTrue(html.contains("function shouldCenterScrollTarget(options)"))
|
||||
assertTrue(html.contains("function shouldTrackScrollRestore(options)"))
|
||||
assertTrue(html.contains("return documentTop - Math.round((viewportHeight - rectHeight) / 2);"))
|
||||
assertTrue(html.contains("if (follow && locator) scrollToLocator(locator, { align: 'center', trackRestore: false });"))
|
||||
assertFalse(activeScrollCall.contains("align: 'center'"))
|
||||
assertFalse(activeScrollCall.contains("trackRestore: false"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical document reports visible locator from top reader edge`() {
|
||||
val html = ReaderHtmlDocumentBuilder.verticalDocument(
|
||||
book = repeatedWordBook("alpha beta gamma"),
|
||||
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL)
|
||||
)
|
||||
|
||||
assertTrue(html.contains("return Math.max(1, Math.min(height - 1, 8));"))
|
||||
assertFalse(html.contains("Math.round(height * 0.12)"))
|
||||
assertTrue(html.contains("function firstVisibleLineRect()"))
|
||||
assertTrue(html.contains("function firstVisibleLineStart(targetLineRect)"))
|
||||
assertTrue(html.contains("var topLineStart = topLineRect ? firstVisibleLineStart(topLineRect) : null;"))
|
||||
assertTrue(html.contains("event=web_line_start_choice"))
|
||||
assertTrue(html.contains("return visibleResult(node, i, offset + i);"))
|
||||
assertFalse(html.contains("charRect.top >= viewportTop - 0.5"))
|
||||
assertTrue(html.contains("var sourceOffset = boundaryOffsetWithinContent(content, node, localOffset);"))
|
||||
assertTrue(html.contains("return positionFromReaderHost(chapter, requestedOffset, preferredY, 'restore_locator_visible');"))
|
||||
assertTrue(html.contains("var position = pendingRestoreVisiblePosition() || currentVisiblePosition();"))
|
||||
assertTrue(html.contains("return fallback || { offset: contentStart, textNode: null };"))
|
||||
assertTrue(html.contains("EpistemeDesktopTtsStartTrace"))
|
||||
assertTrue(html.contains("readerTtsStartTraceLog('event=web_position_report_send"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical document can render a chapter window while retaining page anchors`() {
|
||||
val html = ReaderHtmlDocumentBuilder.verticalDocument(
|
||||
book = SharedEpubBook(
|
||||
id = "book",
|
||||
fileName = "book.epub",
|
||||
title = "Book",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter("one", "One", "First chapter body."),
|
||||
SharedEpubChapter("two", "Two", "Second chapter body."),
|
||||
SharedEpubChapter("three", "Three", "Third chapter body.")
|
||||
)
|
||||
),
|
||||
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL),
|
||||
pages = listOf(
|
||||
ReaderPage(0, 0, "One", "First chapter body.", 0, 19),
|
||||
ReaderPage(1, 1, "Two", "Second chapter body.", 0, 20),
|
||||
ReaderPage(2, 2, "Three", "Third chapter body.", 0, 19)
|
||||
),
|
||||
renderedChapterRange = 1..1
|
||||
)
|
||||
|
||||
assertFalse(html.contains("First chapter body."))
|
||||
assertTrue(html.contains("Second chapter body."))
|
||||
assertFalse(html.contains("Third chapter body."))
|
||||
assertFalse(html.contains("data-reader-chapter-index=\"0\""))
|
||||
assertTrue(html.contains("data-reader-chapter-index=\"1\""))
|
||||
assertFalse(html.contains("data-reader-chapter-index=\"2\""))
|
||||
assertTrue(html.contains("\"chapterIndex\":0"))
|
||||
assertTrue(html.contains("\"chapterIndex\":1"))
|
||||
assertTrue(html.contains("\"chapterIndex\":2"))
|
||||
assertTrue(html.contains("function renderedVerticalPageAnchors()"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical document keeps native webview scrollbar at edge`() {
|
||||
val html = ReaderHtmlDocumentBuilder.verticalDocument(
|
||||
book = repeatedWordBook("alpha beta"),
|
||||
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL)
|
||||
|
|
@ -259,9 +455,187 @@ class ReaderHtmlDocumentBuilderTest {
|
|||
|
||||
assertTrue(html.contains("--reader-scrollbar-track: color-mix(in srgb, var(--reader-bg)"))
|
||||
assertTrue(html.contains("--reader-scrollbar-thumb: color-mix(in srgb, var(--reader-fg)"))
|
||||
assertTrue(html.contains("scrollbar-color: var(--reader-scrollbar-thumb) var(--reader-scrollbar-track)"))
|
||||
assertTrue(html.contains("body.reader-vertical::-webkit-scrollbar-thumb"))
|
||||
assertTrue(html.contains("body.reader-vertical::-webkit-scrollbar-thumb:hover"))
|
||||
assertTrue(html.contains("""<html class="reader-vertical-root">"""))
|
||||
assertTrue(Regex("html\\.reader-vertical-root \\{\\s*width: 100%;\\s*min-width: 0;\\s*overflow-y: scroll;\\s*scrollbar-width: thin;").containsMatchIn(html))
|
||||
assertTrue(html.contains("html.reader-vertical-root::-webkit-scrollbar"))
|
||||
assertFalse(html.contains("html.reader-vertical-root::-webkit-scrollbar,\n body.reader-vertical::-webkit-scrollbar {\n width: 0;"))
|
||||
assertTrue(html.contains("scrollbar-gutter: stable;"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical document lays out continuous full width content`() {
|
||||
val html = ReaderHtmlDocumentBuilder.verticalDocument(
|
||||
book = repeatedWordBook("alpha beta"),
|
||||
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL, pageWidth = 520)
|
||||
)
|
||||
val verticalExpansionCss = Regex(
|
||||
"body\\.reader-vertical > \\.chapter,\\s*" +
|
||||
"body\\.reader-vertical > :not\\(\\.chapter\\):not\\(#reader-selection-menu\\):not\\(\\.reader-selection-handle\\):not\\(script\\):not\\(style\\),\\s*" +
|
||||
"body\\.reader-vertical > \\.chapter > :not\\(\\.reader-content\\),\\s*" +
|
||||
"body\\.reader-vertical > \\.chapter > \\.chapter-title,\\s*" +
|
||||
"body\\.reader-vertical > \\.chapter > \\.reader-content \\{\\s*" +
|
||||
"box-sizing: border-box !important;\\s*" +
|
||||
"min-width: 0 !important;"
|
||||
)
|
||||
val verticalMarginCss = Regex(
|
||||
"body\\.reader-vertical > \\.chapter \\{\\s*" +
|
||||
"width: 100% !important;\\s*" +
|
||||
"max-width: none !important;\\s*" +
|
||||
"margin: 0 !important;"
|
||||
)
|
||||
val verticalContentCss = Regex(
|
||||
"body\\.reader-vertical > :not\\(\\.chapter\\):not\\(#reader-selection-menu\\):not\\(\\.reader-selection-handle\\):not\\(script\\):not\\(style\\),\\s*" +
|
||||
"body\\.reader-vertical > \\.chapter > :not\\(\\.reader-content\\),\\s*" +
|
||||
"body\\.reader-vertical > \\.chapter > \\.chapter-title,\\s*" +
|
||||
"body\\.reader-vertical > \\.chapter > \\.reader-content \\{\\s*" +
|
||||
"width: var\\(--reader-vertical-page-width\\) !important;\\s*" +
|
||||
"max-width: none !important;\\s*" +
|
||||
"margin-left: auto !important;\\s*" +
|
||||
"margin-right: auto !important;"
|
||||
)
|
||||
val verticalChapterSiblingResetCss = Regex(
|
||||
"body\\.reader-vertical > \\.chapter > :not\\(\\.reader-content\\) \\{\\s*" +
|
||||
"position: static !important;\\s*" +
|
||||
"left: auto !important;\\s*" +
|
||||
"right: auto !important;"
|
||||
)
|
||||
val verticalContentClampCss = Regex(
|
||||
"body\\.reader-vertical \\.reader-content p,\\s*" +
|
||||
"body\\.reader-vertical \\.reader-content div,\\s*" +
|
||||
"body\\.reader-vertical \\.reader-content h1,"
|
||||
)
|
||||
val verticalTextAlignCss = Regex(
|
||||
"body\\.reader-vertical \\.reader-content,\\s*" +
|
||||
"body\\.reader-vertical \\.reader-content p,\\s*" +
|
||||
"body\\.reader-vertical \\.reader-content li,"
|
||||
)
|
||||
val verticalPositionResetCss = Regex(
|
||||
"position: static !important;\\s*" +
|
||||
"left: auto !important;\\s*" +
|
||||
"right: auto !important;\\s*" +
|
||||
"top: auto !important;\\s*" +
|
||||
"bottom: auto !important;\\s*" +
|
||||
"transform: none !important;\\s*" +
|
||||
"float: none !important;\\s*" +
|
||||
"clear: none !important;"
|
||||
)
|
||||
val verticalNestedWrapperResetCss = Regex(
|
||||
"body\\.reader-vertical \\.reader-content div,\\s*" +
|
||||
"body\\.reader-vertical \\.reader-content section,\\s*" +
|
||||
"body\\.reader-vertical \\.reader-content article,"
|
||||
)
|
||||
val verticalTitleClampCss = Regex(
|
||||
"body\\.reader-vertical \\.reader-content :where\\(h1, h2, h3, h4, h5, h6, hgroup, center,"
|
||||
)
|
||||
val verticalMarginResetCss = Regex(
|
||||
"body\\.reader-vertical \\.reader-content > p,\\s*" +
|
||||
"body\\.reader-vertical \\.reader-content > div,\\s*" +
|
||||
"body\\.reader-vertical \\.reader-content > h1,"
|
||||
)
|
||||
val paginatedWidthCss = Regex(
|
||||
"\\.chapter, \\.page \\{\\s*" +
|
||||
"max-width: var\\(--reader-page-width\\);"
|
||||
)
|
||||
val verticalBodyCss = Regex(
|
||||
"body\\.reader-vertical \\{\\s*" +
|
||||
"width: 100%;\\s*" +
|
||||
"max-width: 100%;\\s*" +
|
||||
"min-height: 100vh;\\s*" +
|
||||
"min-height: 100dvh;\\s*" +
|
||||
"min-width: 0;\\s*" +
|
||||
"overflow-x: hidden;\\s*" +
|
||||
"overflow-y: auto;\\s*" +
|
||||
"padding: var\\(--reader-vertical-margin-y\\) 0;"
|
||||
)
|
||||
|
||||
assertTrue(html.contains("--reader-vertical-margin-y: 16px;"))
|
||||
assertTrue(html.contains("--reader-vertical-content-width: 92ch;"))
|
||||
assertTrue(html.contains("--reader-vertical-page-width: max(0px, calc(100% - (var(--reader-margin-x) * 2)));"))
|
||||
assertFalse(html.contains("body.reader-vertical .chapter,"))
|
||||
assertFalse(html.contains("body.reader-vertical .chapter > :not(.reader-content)"))
|
||||
assertTrue(verticalBodyCss.containsMatchIn(html))
|
||||
assertTrue(verticalExpansionCss.containsMatchIn(html))
|
||||
assertTrue(html.contains("content-visibility: auto;"))
|
||||
assertTrue(html.contains("contain-intrinsic-size: auto 1200px;"))
|
||||
assertTrue(verticalMarginCss.containsMatchIn(html))
|
||||
assertTrue(verticalContentCss.containsMatchIn(html))
|
||||
assertTrue(verticalChapterSiblingResetCss.containsMatchIn(html))
|
||||
assertTrue(verticalTextAlignCss.containsMatchIn(html))
|
||||
assertFalse(html.contains("body.reader-vertical .reader-content {\n flex: 1 0 auto;"))
|
||||
assertFalse(html.contains("min-height: 100dvh;\n display: flex;"))
|
||||
assertTrue(html.contains("text-align: var(--reader-align) !important;"))
|
||||
assertTrue(verticalContentClampCss.containsMatchIn(html))
|
||||
assertTrue(verticalPositionResetCss.containsMatchIn(html))
|
||||
assertTrue(verticalNestedWrapperResetCss.containsMatchIn(html))
|
||||
assertTrue(verticalTitleClampCss.containsMatchIn(html))
|
||||
assertTrue(verticalMarginResetCss.containsMatchIn(html))
|
||||
assertTrue(paginatedWidthCss.containsMatchIn(html))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical document exposes page anchor updater for in place format changes`() {
|
||||
val html = ReaderHtmlDocumentBuilder.verticalDocument(
|
||||
book = repeatedWordBook("alpha beta"),
|
||||
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL),
|
||||
pages = listOf(
|
||||
ReaderPage(0, 0, "One", "alpha", 0, 5),
|
||||
ReaderPage(1, 0, "One", "beta", 6, 10)
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(html.contains("var readerPageAnchors = ["))
|
||||
assertTrue(html.contains("window.readerSetPageAnchors = function (anchors)"))
|
||||
assertTrue(html.contains("readerPageAnchors = anchors;"))
|
||||
assertTrue(html.contains("function pageForVerticalScroll()"))
|
||||
assertTrue(html.contains("desktop-scroll-page:"))
|
||||
assertTrue(html.contains("pageIndex: numberAttribute(document.body, 'data-reader-active-page-index', null)"))
|
||||
assertTrue(html.contains("chapterId: host.getAttribute('data-reader-chapter-id')"))
|
||||
assertTrue(html.contains("href: host.getAttribute('data-reader-chapter-href')"))
|
||||
assertTrue(html.contains("blockIndex: blockPosition ? blockPosition.blockIndex : null"))
|
||||
assertTrue(html.contains("charOffset: blockPosition ? blockPosition.charOffset : null"))
|
||||
assertTrue(html.contains("blockIndex: numberAttribute(document.body, 'data-reader-active-block-index', null)"))
|
||||
assertTrue(html.contains("charOffset: numberAttribute(document.body, 'data-reader-active-char-offset', null)"))
|
||||
assertTrue(html.contains("cfi: document.body.getAttribute('data-reader-active-cfi')"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `appearance update script carries vertical format settings`() {
|
||||
val script = ReaderHtmlDocumentBuilder.appearanceUpdateScript(
|
||||
settings = ReaderSettings(
|
||||
fontSize = 24,
|
||||
lineSpacing = 1.8f,
|
||||
margin = 60,
|
||||
horizontalMargin = 72,
|
||||
verticalMargin = 90,
|
||||
pageWidth = 940,
|
||||
paragraphSpacing = 1.4f,
|
||||
imageScale = 1.35f,
|
||||
textAlign = SharedReaderTextAlign.JUSTIFY,
|
||||
fontFamily = "Serif"
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(script.contains("root.style.setProperty('--reader-font-size', \"24px\");"))
|
||||
assertTrue(script.contains("root.style.setProperty('--reader-line-height', \"1.8\");"))
|
||||
assertTrue(script.contains("root.style.setProperty('--reader-page-width', \"940px\");"))
|
||||
assertTrue(script.contains("root.style.setProperty('--reader-margin-x', \"72px\");"))
|
||||
assertTrue(script.contains("root.style.setProperty('--reader-vertical-margin-y', \"30px\");"))
|
||||
assertTrue(script.contains("root.style.setProperty('--reader-vertical-page-width', 'max(0px, calc(100% - (var(--reader-margin-x) * 2)))');"))
|
||||
assertTrue(script.contains("root.style.setProperty('--reader-image-scale', \"135%\");"))
|
||||
assertTrue(script.contains("root.style.setProperty('--reader-align', \"justify\");"))
|
||||
assertTrue(script.contains("root.style.setProperty('--reader-family', \"Georgia, 'Times New Roman', serif\");"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `page anchor update script avoids full vertical document reload`() {
|
||||
val script = ReaderHtmlDocumentBuilder.pageAnchorsUpdateScript(
|
||||
listOf(
|
||||
ReaderPage(3, 1, "Two", "chapter", 42, 84)
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(script.contains("window.readerSetPageAnchors"))
|
||||
assertTrue(script.contains("""{"pageIndex":3,"chapterIndex":1,"startOffset":42,"endOffset":84}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -284,6 +658,7 @@ class ReaderHtmlDocumentBuilderTest {
|
|||
assertFalse(html.contains("""data-action="define""""))
|
||||
assertFalse(html.contains("""data-action="speak""""))
|
||||
assertTrue(html.contains("""data-action="web-search""""))
|
||||
assertTrue(html.contains("""data-action="palette""""))
|
||||
assertTrue(html.contains("""aria-label="Search""""))
|
||||
assertTrue(html.contains("""<svg viewBox="0 0 960 960""""))
|
||||
assertFalse(html.contains("""data-action="dictionary""""))
|
||||
|
|
@ -311,6 +686,7 @@ class ReaderHtmlDocumentBuilderTest {
|
|||
assertFalse(html.contains("""data-action="web-search""""))
|
||||
assertFalse(html.contains("""data-action="translate""""))
|
||||
assertFalse(html.contains("""data-action="find""""))
|
||||
assertTrue(html.contains("""data-action="palette""""))
|
||||
assertTrue(html.contains("""data-action="copy""""))
|
||||
assertTrue(html.contains("""data-action="clear""""))
|
||||
}
|
||||
|
|
@ -343,6 +719,121 @@ class ReaderHtmlDocumentBuilderTest {
|
|||
assertTrue(html.contains("document.addEventListener('contextmenu'"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical selection script derives offsets from raw html chapters`() {
|
||||
val html = ReaderHtmlDocumentBuilder.verticalDocument(
|
||||
book = SharedEpubBook(
|
||||
id = "book",
|
||||
fileName = "book.epub",
|
||||
title = "Book",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = "Alpha beta Gamma delta",
|
||||
htmlContent = "<p><span>Alpha beta</span></p><p><span>Gamma delta</span></p>"
|
||||
)
|
||||
)
|
||||
),
|
||||
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL)
|
||||
)
|
||||
|
||||
assertTrue(html.contains("function normalizedOffsetForBoundary(root, container, offset)"))
|
||||
assertTrue(html.contains("var explicitOffset = absoluteOffsetForBoundary(content, container, offset);"))
|
||||
assertTrue(html.contains("var normalizedOffset = normalizedOffsetForBoundary(content, container, offset);"))
|
||||
assertTrue(html.contains("return normalizedOffset === null ? null : contentStartOffset(content) + normalizedOffset;"))
|
||||
assertTrue(html.contains("var boundaryInside = nodeInside(content, range.startContainer) || nodeInside(content, range.endContainer);"))
|
||||
assertTrue(html.contains("range.intersectsNode(content)"))
|
||||
assertTrue(html.contains("selection_segments_rejected contents="))
|
||||
assertTrue(html.contains("function readerHighlightFlowLog(message)"))
|
||||
assertTrue(html.contains("selection_begin mode="))
|
||||
assertTrue(html.contains("var actionSegments = selectionSegmentsForRange(actionRange);"))
|
||||
assertTrue(html.contains("payload.locator = {"))
|
||||
assertTrue(html.contains("window.kmpJsBridge.callNative('readerSelectionAction', JSON.stringify(payload));"))
|
||||
assertTrue(html.contains("sendSelectionAction('palette', text)"))
|
||||
assertTrue(html.contains("bridge_send_success attempt="))
|
||||
assertTrue(html.contains("<p><span>Alpha beta</span></p><p><span>Gamma delta</span></p>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical document prefers semantic blocks when available for cross mode locators`() {
|
||||
val html = ReaderHtmlDocumentBuilder.verticalDocument(
|
||||
book = SharedEpubBook(
|
||||
id = "book",
|
||||
fileName = "book.epub",
|
||||
title = "Book",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = "Semantic text",
|
||||
htmlContent = "<p>Raw text</p>",
|
||||
semanticBlocks = listOf(
|
||||
SemanticParagraph(
|
||||
text = "Semantic text",
|
||||
spans = emptyList(),
|
||||
style = CssStyle(),
|
||||
elementId = null,
|
||||
cfi = "/4/2/4",
|
||||
startCharOffsetInSource = 40,
|
||||
blockIndex = 12
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL)
|
||||
)
|
||||
|
||||
assertTrue(html.contains("Semantic text"))
|
||||
assertTrue(html.contains("""data-reader-cfi="/4/2/4""""))
|
||||
assertTrue(html.contains("""data-reader-block-index="12""""))
|
||||
assertFalse(html.contains("Raw text"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical selection menu renders every configured highlight palette slot`() {
|
||||
val html = ReaderHtmlDocumentBuilder.verticalDocument(
|
||||
book = repeatedWordBook("alpha beta"),
|
||||
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL),
|
||||
highlightPalette = ReaderHighlightPalette(
|
||||
listOf(
|
||||
HighlightColor.CYAN,
|
||||
HighlightColor.MAGENTA,
|
||||
HighlightColor.LIME,
|
||||
HighlightColor.PINK
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(4, Regex("""class="reader-selection-color"""").findAll(html).count())
|
||||
assertTrue(html.contains("""data-color-id="cyan""""))
|
||||
assertTrue(html.contains("""data-color-id="pink""""))
|
||||
assertTrue(html.contains("""class="reader-selection-spectrum""""))
|
||||
assertTrue(html.contains("""data-action="palette""""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `highlight palette update script replaces selection menu colors without document reload`() {
|
||||
val script = ReaderHtmlDocumentBuilder.highlightPaletteUpdateScript(
|
||||
ReaderHighlightPalette(
|
||||
listOf(
|
||||
HighlightColor.CYAN,
|
||||
HighlightColor.MAGENTA,
|
||||
HighlightColor.LIME,
|
||||
HighlightColor.PINK
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(script.contains("reader-selection-colors"))
|
||||
assertTrue(script.contains("""data-color-id=\"cyan\""""))
|
||||
assertTrue(script.contains("""data-color-id=\"pink\""""))
|
||||
assertTrue(script.contains("""reader-selection-spectrum"""))
|
||||
assertTrue(script.contains("""data-action=\"palette\""""))
|
||||
assertFalse(script.contains("location.reload"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selection menu renders icons and draggable handles`() {
|
||||
val html = ReaderHtmlDocumentBuilder.pageDocument(
|
||||
|
|
@ -402,10 +893,12 @@ class ReaderHtmlDocumentBuilderTest {
|
|||
|
||||
assertTrue(html.contains("function readerHostsForLocator(chapterIndex, startOffset, endOffset)"))
|
||||
assertTrue(html.contains("function readerHostForLocator(chapterIndex, startOffset, endOffset)"))
|
||||
assertTrue(html.contains("function readerHostElementForCfiPoint(chapterIndex, cfiPoint)"))
|
||||
assertTrue(html.contains("var hosts = Array.prototype.slice.call(document.querySelectorAll(chapterSelector));"))
|
||||
assertTrue(html.contains("var targetChapters = readerHostsForLocator(chapterIndex, startOffset, endOffset);"))
|
||||
assertTrue(html.contains("var chapter = readerHostForLocator(chapterIndex, startOffset, endOffset);"))
|
||||
assertTrue(html.contains("data-reader-active-page-index"))
|
||||
assertTrue(html.contains("positionFromReaderHost(activePage, activeStart)"))
|
||||
assertTrue(html.contains("positionFromReaderHost(activePage, activeStart, readerProbeY(), 'active_page')"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -436,7 +929,8 @@ class ReaderHtmlDocumentBuilderTest {
|
|||
|
||||
assertTrue(html.contains("function selectionSegmentsForRange(range)"))
|
||||
assertTrue(html.contains("var sameChapter = segments.every(function (segment)"))
|
||||
assertTrue(html.contains("var cfi = 'desktop:' + chapterIndex + ':' + startOffset + ':' + endOffset;"))
|
||||
assertTrue(html.contains("var cfi = readerHighlightCfiForRange(firstSegment, lastSegment, chapterIndex, startOffset, endOffset);"))
|
||||
assertTrue(html.contains("return startPoint + '|' + endPoint;"))
|
||||
assertTrue(html.contains("payloads.forEach(function (payload)"))
|
||||
assertTrue(html.contains("wrapRangeTextSegments(segment.range"))
|
||||
}
|
||||
|
|
@ -477,7 +971,7 @@ class ReaderHtmlDocumentBuilderTest {
|
|||
title = "One",
|
||||
plainText = "Before image after image.",
|
||||
semanticBlocks = listOf(
|
||||
SemanticParagraph("Before image", emptyList(), CssStyle(), null, null, startCharOffsetInSource = 0),
|
||||
SemanticParagraph("Before image", emptyList(), CssStyle(), null, null, startCharOffsetInSource = 0, blockIndex = 7),
|
||||
SemanticImage("data:image/png;base64,abc", "Cover", null, null, CssStyle(), "cover-image", "/4/2"),
|
||||
SemanticParagraph("after image", emptyList(), CssStyle(), null, null, startCharOffsetInSource = 13)
|
||||
)
|
||||
|
|
@ -492,6 +986,8 @@ class ReaderHtmlDocumentBuilderTest {
|
|||
)
|
||||
|
||||
assertTrue(html.contains("""<img src="data:image/png;base64,abc" alt="Cover""""))
|
||||
assertTrue(html.contains("""loading="lazy" decoding="async""""))
|
||||
assertTrue(html.contains("""data-reader-block-index="7""""))
|
||||
assertTrue(html.contains("""data-reader-cfi="/4/2""""))
|
||||
assertTrue(html.contains("""data-reader-block-index="0""""))
|
||||
}
|
||||
|
|
@ -735,4 +1231,23 @@ class ReaderHtmlDocumentBuilderTest {
|
|||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun semanticHighlightBook(leftText: String, rightText: String): SharedEpubBook {
|
||||
return SharedEpubBook(
|
||||
id = "book",
|
||||
fileName = "book.epub",
|
||||
title = "Book",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = "$leftText\n\n$rightText",
|
||||
semanticBlocks = listOf(
|
||||
SemanticParagraph(leftText, emptyList(), CssStyle(), null, "/4/2", startCharOffsetInSource = 100, blockIndex = 42),
|
||||
SemanticParagraph(rightText, emptyList(), CssStyle(), null, "/4/4", startCharOffsetInSource = 200, blockIndex = 43)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,20 @@ class ReaderSpreadLayoutTest {
|
|||
assertEquals(3, ReaderSpreadLayout.pageNumberForSliderPosition(2, pageCount = 10, settings = settings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `right to left pagination reverses only the displayed spread order`() {
|
||||
val settings = ReaderSettings(
|
||||
readingMode = ReaderReadingMode.PAGINATED,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE,
|
||||
rightToLeftPagination = true
|
||||
)
|
||||
|
||||
assertEquals(listOf(2, 3), ReaderSpreadLayout.visiblePageIndices(3, pageCount = 10, settings = settings))
|
||||
assertEquals(listOf(3, 2), ReaderSpreadLayout.visiblePageIndicesForDisplay(3, pageCount = 10, settings = settings))
|
||||
assertEquals(4, ReaderSpreadLayout.nextPageIndex(2, pageCount = 10, settings = settings))
|
||||
assertEquals(0, ReaderSpreadLayout.previousPageIndex(2, pageCount = 10, settings = settings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two page mode advances by spread and clamps odd final page`() {
|
||||
val settings = ReaderSettings(
|
||||
|
|
|
|||
|
|
@ -39,22 +39,63 @@ class NonReaderLayoutModelsTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `desktop library keeps browse focused on books shelves and folders`() {
|
||||
fun `desktop library includes organization and reading status tabs`() {
|
||||
val visibleTabs = visibleNonReaderLibraryTabs(ReaderPlatform.DESKTOP)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
NonReaderLibraryTab.BOOKS,
|
||||
NonReaderLibraryTab.SHELVES,
|
||||
NonReaderLibraryTab.FOLDERS
|
||||
NonReaderLibraryTab.FOLDERS,
|
||||
NonReaderLibraryTab.UNREAD,
|
||||
NonReaderLibraryTab.IN_PROGRESS,
|
||||
NonReaderLibraryTab.COMPLETED
|
||||
),
|
||||
visibleTabs
|
||||
)
|
||||
assertFalse(NonReaderLibraryTab.SMART_SHELVES in visibleTabs)
|
||||
assertFalse(NonReaderLibraryTab.TAGS in visibleTabs)
|
||||
assertFalse(NonReaderLibraryTab.UNREAD in visibleTabs)
|
||||
assertFalse(NonReaderLibraryTab.IN_PROGRESS in visibleTabs)
|
||||
assertFalse(NonReaderLibraryTab.COMPLETED in visibleTabs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop shelves tab exposes primary new shelf action only on desktop`() {
|
||||
assertEquals(
|
||||
listOf(NonReaderLibraryPrimaryAction.NEW_SHELF),
|
||||
primaryLibraryActionsForTab(NonReaderLibraryTab.SHELVES, ReaderPlatform.DESKTOP)
|
||||
)
|
||||
assertEquals(
|
||||
emptyList<NonReaderLibraryPrimaryAction>(),
|
||||
primaryLibraryActionsForTab(NonReaderLibraryTab.SHELVES, ReaderPlatform.ANDROID)
|
||||
)
|
||||
assertEquals(
|
||||
emptyList<NonReaderLibraryPrimaryAction>(),
|
||||
primaryLibraryActionsForTab(NonReaderLibraryTab.BOOKS, ReaderPlatform.DESKTOP)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop book overflow exposes add to shelf action without changing android`() {
|
||||
assertEquals(
|
||||
setOf(NonReaderBookOverflowAction.ADD_TO_SHELF),
|
||||
bookOverflowActionsForPlatform(ReaderPlatform.DESKTOP)
|
||||
)
|
||||
assertEquals(emptySet<NonReaderBookOverflowAction>(), bookOverflowActionsForPlatform(ReaderPlatform.ANDROID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop library command bar uses inline layout only on wide panes`() {
|
||||
assertEquals(
|
||||
LibraryCommandBarLayout.STACKED,
|
||||
libraryCommandBarLayoutForWidth(widthDp = 979f, platform = ReaderPlatform.DESKTOP)
|
||||
)
|
||||
assertEquals(
|
||||
LibraryCommandBarLayout.INLINE,
|
||||
libraryCommandBarLayoutForWidth(widthDp = 980f, platform = ReaderPlatform.DESKTOP)
|
||||
)
|
||||
assertEquals(
|
||||
LibraryCommandBarLayout.STACKED,
|
||||
libraryCommandBarLayoutForWidth(widthDp = 1200f, platform = ReaderPlatform.ANDROID)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -71,7 +112,7 @@ class NonReaderLayoutModelsTest {
|
|||
assertTrue(FileType.PPTX in groupedTypes)
|
||||
assertTrue(
|
||||
nonReaderLibraryFileTypeGroups()
|
||||
.any { it.title == "Comics" && FileType.CBR in it.fileTypes && FileType.CB7 in it.fileTypes }
|
||||
.any { it.title == "Comics" && FileType.CBR in it.fileTypes && FileType.CB7 in it.fileTypes && FileType.CBT in it.fileTypes }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -176,7 +217,7 @@ class NonReaderLayoutModelsTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `hidden desktop status tabs fall back to all books`() {
|
||||
fun `desktop status tabs filter books while android hidden statuses fall back`() {
|
||||
val unread = book("unread", type = FileType.EPUB, progress = 0f)
|
||||
val inProgress = book("progress", type = FileType.PDF, progress = 44f)
|
||||
val complete = book("complete", type = FileType.CBZ, progress = 100f)
|
||||
|
|
@ -186,17 +227,21 @@ class NonReaderLayoutModelsTest {
|
|||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("unread", "progress", "complete"),
|
||||
listOf("unread"),
|
||||
state.booksForNonReaderLibraryTab(NonReaderLibraryTab.UNREAD, ReaderPlatform.DESKTOP).map { it.id }
|
||||
)
|
||||
assertEquals(
|
||||
listOf("unread", "progress", "complete"),
|
||||
listOf("progress"),
|
||||
state.visibleBooksForLibrarySelection(NonReaderLibraryTab.IN_PROGRESS, ReaderPlatform.DESKTOP).map { it.id }
|
||||
)
|
||||
assertEquals(
|
||||
listOf("unread", "progress", "complete"),
|
||||
listOf("complete"),
|
||||
state.booksForNonReaderLibraryTab(NonReaderLibraryTab.COMPLETED, ReaderPlatform.DESKTOP).map { it.id }
|
||||
)
|
||||
assertEquals(
|
||||
listOf("unread", "progress", "complete"),
|
||||
state.booksForNonReaderLibraryTab(NonReaderLibraryTab.UNREAD, ReaderPlatform.ANDROID).map { it.id }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -250,34 +295,100 @@ class NonReaderLayoutModelsTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `shell model keeps primary navigation simple and exposes all tool actions`() {
|
||||
fun `shell model keeps account in primary navigation and exposes more actions`() {
|
||||
val model = sharedAppShellModel(
|
||||
selectedTab = SharedAppTab.CUSTOM_FONTS,
|
||||
aiSettingsAvailable = true
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(SharedAppTab.LIBRARY, SharedAppTab.CATALOGS),
|
||||
listOf(SharedAppTab.LIBRARY, SharedAppTab.CATALOGS, SharedAppTab.PRO),
|
||||
model.primaryTabs
|
||||
)
|
||||
assertEquals(listOf(SharedAppToolAction.AI_SETTINGS), model.primaryActions)
|
||||
assertEquals(SharedAppTab.LIBRARY, model.selectedPrimaryTab)
|
||||
assertTrue(SharedAppToolAction.IMPORT_FILES in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.SETTINGS in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.IMPORT_FOLDER in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.SYNC in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.APP_THEME in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.PRO in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.AI_SETTINGS in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.CUSTOM_FONTS in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.HELP_FEEDBACK in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.SUPPORT in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.ABOUT in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.TABS_TOGGLE in model.toolActions)
|
||||
assertFalse(SharedAppToolAction.IMPORT_FILES in model.toolActions)
|
||||
assertFalse(SharedAppToolAction.IMPORT_FOLDER in model.toolActions)
|
||||
assertFalse(SharedAppToolAction.SYNC in model.toolActions)
|
||||
assertFalse(SharedAppToolAction.PRO in model.toolActions)
|
||||
assertFalse(SharedAppToolAction.TABS_TOGGLE in model.toolActions)
|
||||
assertTrue(model.showPrimaryNavigation)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
SharedAppMoreGroup.PREFERENCES,
|
||||
SharedAppMoreGroup.HELP
|
||||
),
|
||||
model.moreSections.map { it.group }
|
||||
)
|
||||
|
||||
val accountModel = sharedAppShellModel(SharedAppTab.PRO, aiSettingsAvailable = true)
|
||||
assertEquals(SharedAppTab.PRO, accountModel.selectedPrimaryTab)
|
||||
|
||||
val withoutAi = sharedAppShellModel(SharedAppTab.SHELVES, aiSettingsAvailable = false)
|
||||
assertEquals(SharedAppTab.LIBRARY, withoutAi.selectedPrimaryTab)
|
||||
assertEquals(emptyList(), withoutAi.primaryActions)
|
||||
assertFalse(SharedAppToolAction.AI_SETTINGS in withoutAi.toolActions)
|
||||
|
||||
val byokModel = sharedAppShellModel(
|
||||
selectedTab = SharedAppTab.LIBRARY,
|
||||
aiSettingsAvailable = true,
|
||||
featurePolicy = SharedFeaturePolicy.OssOnline
|
||||
)
|
||||
assertEquals(emptyList(), byokModel.primaryActions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shell model groups more menu preferences and help only`() {
|
||||
val model = sharedAppShellModel(
|
||||
selectedTab = SharedAppTab.LIBRARY,
|
||||
aiSettingsAvailable = true
|
||||
)
|
||||
|
||||
assertFalse(model.moreSections.any { it.group == SharedAppMoreGroup.LIBRARY })
|
||||
assertFalse(model.moreSections.any { it.group == SharedAppMoreGroup.ACCOUNT })
|
||||
assertEquals(
|
||||
listOf(
|
||||
SharedAppToolAction.SETTINGS,
|
||||
SharedAppToolAction.APP_THEME,
|
||||
SharedAppToolAction.AI_SETTINGS,
|
||||
SharedAppToolAction.CUSTOM_FONTS
|
||||
),
|
||||
model.moreSections.single { it.group == SharedAppMoreGroup.PREFERENCES }.actions
|
||||
)
|
||||
assertEquals(
|
||||
listOf(
|
||||
SharedAppToolAction.HELP_FEEDBACK,
|
||||
SharedAppToolAction.SUPPORT,
|
||||
SharedAppToolAction.ABOUT
|
||||
),
|
||||
model.moreSections.single { it.group == SharedAppMoreGroup.HELP }.actions
|
||||
)
|
||||
|
||||
val legacyActions = sharedAppMoreSections(
|
||||
listOf(
|
||||
SharedAppToolAction.IMPORT_FILES,
|
||||
SharedAppToolAction.PRO,
|
||||
SharedAppToolAction.SETTINGS,
|
||||
SharedAppToolAction.TABS_TOGGLE,
|
||||
SharedAppToolAction.ABOUT
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
listOf(SharedAppMoreGroup.PREFERENCES, SharedAppMoreGroup.HELP),
|
||||
legacyActions.map { it.group }
|
||||
)
|
||||
assertEquals(
|
||||
listOf(SharedAppToolAction.SETTINGS),
|
||||
legacyActions.single { it.group == SharedAppMoreGroup.PREFERENCES }.actions
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -304,17 +415,68 @@ class NonReaderLayoutModelsTest {
|
|||
)
|
||||
|
||||
assertEquals(listOf(SharedAppTab.LIBRARY), model.primaryTabs)
|
||||
assertEquals(emptyList(), model.primaryActions)
|
||||
assertEquals(SharedAppTab.LIBRARY, model.selectedPrimaryTab)
|
||||
assertFalse(SharedAppToolAction.PRO in model.toolActions)
|
||||
assertFalse(SharedAppToolAction.AI_SETTINGS in model.toolActions)
|
||||
assertFalse(SharedAppToolAction.HELP_FEEDBACK in model.toolActions)
|
||||
assertFalse(SharedAppToolAction.SUPPORT in model.toolActions)
|
||||
assertFalse(SharedAppToolAction.PRO in model.toolActions)
|
||||
assertFalse(model.moreSections.any { it.group == SharedAppMoreGroup.ACCOUNT })
|
||||
assertFalse(model.moreSections.any { it.group == SharedAppMoreGroup.LIBRARY })
|
||||
assertFalse(model.moreSections.any { it.group == SharedAppMoreGroup.HELP && SharedAppToolAction.SUPPORT in it.actions })
|
||||
assertTrue(SharedAppToolAction.SETTINGS in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.CUSTOM_FONTS in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.ABOUT in model.toolActions)
|
||||
assertTrue(model.showPrimaryNavigation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sidebar sync toggle is visible only for signed in account builds and follows pro gating`() {
|
||||
assertEquals(
|
||||
SharedSidebarSyncToggleModel(visible = false, enabled = false, checked = false),
|
||||
sharedSidebarSyncToggleModel(
|
||||
isSignedIn = false,
|
||||
accountAvailable = true,
|
||||
syncAvailable = true,
|
||||
isProUser = true,
|
||||
isSyncEnabled = true
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
SharedSidebarSyncToggleModel(visible = true, enabled = true, checked = true),
|
||||
sharedSidebarSyncToggleModel(
|
||||
isSignedIn = true,
|
||||
accountAvailable = true,
|
||||
syncAvailable = true,
|
||||
isProUser = true,
|
||||
isSyncEnabled = true
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
SharedSidebarSyncToggleModel(visible = true, enabled = false, checked = true),
|
||||
sharedSidebarSyncToggleModel(
|
||||
isSignedIn = true,
|
||||
accountAvailable = true,
|
||||
syncAvailable = true,
|
||||
isProUser = false,
|
||||
isSyncEnabled = true
|
||||
)
|
||||
)
|
||||
|
||||
assertFalse(
|
||||
sharedSidebarSyncToggleModel(
|
||||
isSignedIn = true,
|
||||
accountAvailable = false,
|
||||
syncAvailable = true,
|
||||
isProUser = true,
|
||||
isSyncEnabled = true,
|
||||
featurePolicy = SharedFeaturePolicy.OssOffline
|
||||
).visible
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `collection cover stack uses Android cover order and limit`() {
|
||||
val books = listOf(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class ReaderMinimalSliderTest {
|
||||
@Test
|
||||
fun markerFractionMapsValueIntoRange() {
|
||||
assertEquals(
|
||||
0.5f,
|
||||
readerMinimalSliderMarkerFraction(
|
||||
markerValue = 5f,
|
||||
valueRange = 0f..10f
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markerFractionClampsOutsideRange() {
|
||||
assertEquals(
|
||||
0f,
|
||||
readerMinimalSliderMarkerFraction(
|
||||
markerValue = -4f,
|
||||
valueRange = 1f..9f
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
1f,
|
||||
readerMinimalSliderMarkerFraction(
|
||||
markerValue = 12f,
|
||||
valueRange = 1f..9f
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markerFractionIsAbsentForMissingOrEmptyRange() {
|
||||
assertNull(readerMinimalSliderMarkerFraction(null, 0f..10f))
|
||||
assertNull(readerMinimalSliderMarkerFraction(4f, 5f..5f))
|
||||
}
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ class ReaderWorkspaceModelsTest {
|
|||
)
|
||||
assertFalse(ReaderWorkspaceLeftSection.SEARCH in model.leftSections)
|
||||
assertFalse(ReaderWorkspaceTopAction.BOOKMARK in model.topActions)
|
||||
assertFalse(ReaderWorkspaceInspectorSection.APPEARANCE in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceInspectorSection.APPEARANCE in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceInspectorSection.AI_TTS in model.inspectorSections)
|
||||
assertFalse(ReaderWorkspaceInspectorSection.TOOLBAR in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceTopAction.SEARCH in model.topActions)
|
||||
|
|
@ -75,13 +75,193 @@ class ReaderWorkspaceModelsTest {
|
|||
assertFalse(model.preferAutoHide)
|
||||
assertTrue(model.forceVisible)
|
||||
assertEquals(
|
||||
setOf("search", "inspector", "annotation", "rich-text", "loading", "error", "auto-scroll", "tts"),
|
||||
setOf("search", "inspector", "annotation", "rich-text", "loading", "error", "auto-scroll"),
|
||||
model.forceVisibleReasons
|
||||
)
|
||||
assertEquals(setOf("tts"), model.revealVisibleReasons)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader tap toggles chrome when it is not locked or forced`() {
|
||||
assertTrue(
|
||||
readerWorkspaceChromeVisibleAfterReaderTap(
|
||||
requestedVisible = false,
|
||||
lockedVisible = false,
|
||||
forcedVisible = false
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
readerWorkspaceChromeVisibleAfterReaderTap(
|
||||
requestedVisible = true,
|
||||
lockedVisible = false,
|
||||
forcedVisible = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `epub workspace ignores desktop visual options in inspector`() {
|
||||
fun `reader tap closes inspector and reveals chrome when panel suppresses bars`() {
|
||||
assertTrue(
|
||||
readerWorkspaceChromeVisibleAfterReaderTap(
|
||||
requestedVisible = false,
|
||||
lockedVisible = false,
|
||||
forcedVisible = false,
|
||||
rightPanelClosedByTap = true
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
readerWorkspaceShouldCloseRightPanelAfterReaderTap(
|
||||
rightPanelOpen = true,
|
||||
hasInspectorSections = true,
|
||||
closeRightPanelOnReaderTap = true
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
readerWorkspaceShouldCloseRightPanelAfterReaderTap(
|
||||
rightPanelOpen = true,
|
||||
hasInspectorSections = false,
|
||||
closeRightPanelOnReaderTap = true
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
readerWorkspaceShouldCloseRightPanelAfterReaderTap(
|
||||
rightPanelOpen = true,
|
||||
hasInspectorSections = true,
|
||||
closeRightPanelOnReaderTap = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active tts reveals chrome without locking reader tap toggle`() {
|
||||
val model = readerWorkspaceChromeModel(
|
||||
preferAutoHide = true,
|
||||
searchActive = false,
|
||||
leftPanelOpen = false,
|
||||
inspectorOpen = false,
|
||||
annotationEditing = false,
|
||||
richTextEditing = false,
|
||||
loading = false,
|
||||
errorMessage = null,
|
||||
autoScroll = ReaderAutoScrollState(),
|
||||
ttsBusy = true
|
||||
)
|
||||
|
||||
assertFalse(model.forceVisible)
|
||||
assertEquals(emptySet(), model.forceVisibleReasons)
|
||||
assertEquals(setOf("tts"), model.revealVisibleReasons)
|
||||
assertFalse(
|
||||
readerWorkspaceChromeVisibleAfterReaderTap(
|
||||
requestedVisible = true,
|
||||
lockedVisible = false,
|
||||
forcedVisible = model.forceVisible
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `locked or forced reader chrome stays visible after reader taps`() {
|
||||
assertTrue(
|
||||
readerWorkspaceChromeVisible(
|
||||
requestedVisible = false,
|
||||
lockedVisible = true,
|
||||
forcedVisible = false
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
readerWorkspaceChromeVisibleAfterReaderTap(
|
||||
requestedVisible = false,
|
||||
lockedVisible = false,
|
||||
forcedVisible = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `left reader panel is drawn with chrome while preserving its toggle state`() {
|
||||
assertFalse(
|
||||
readerWorkspaceLeftPanelVisible(
|
||||
toggledOpen = true,
|
||||
chromeVisible = false,
|
||||
hasNavigationSections = true
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
readerWorkspaceLeftPanelVisible(
|
||||
toggledOpen = true,
|
||||
chromeVisible = true,
|
||||
hasNavigationSections = true
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
readerWorkspaceLeftPanelVisible(
|
||||
toggledOpen = false,
|
||||
chromeVisible = true,
|
||||
hasNavigationSections = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader focus is restored after closing the final workspace panel`() {
|
||||
assertTrue(
|
||||
readerWorkspaceShouldRestoreFocusAfterPanelClose(
|
||||
closingPanelOpen = true,
|
||||
otherPanelOpen = false
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
readerWorkspaceShouldRestoreFocusAfterPanelClose(
|
||||
closingPanelOpen = false,
|
||||
otherPanelOpen = false
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
readerWorkspaceShouldRestoreFocusAfterPanelClose(
|
||||
closingPanelOpen = true,
|
||||
otherPanelOpen = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader focus is restored when an open sidebar is hidden with chrome`() {
|
||||
assertTrue(
|
||||
readerWorkspaceShouldRestoreFocusAfterPanelVisibilityChange(
|
||||
wasPanelVisible = true,
|
||||
isPanelVisible = false,
|
||||
panelOpen = true,
|
||||
otherPanelOpen = false
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
readerWorkspaceShouldRestoreFocusAfterPanelVisibilityChange(
|
||||
wasPanelVisible = true,
|
||||
isPanelVisible = true,
|
||||
panelOpen = true,
|
||||
otherPanelOpen = false
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
readerWorkspaceShouldRestoreFocusAfterPanelVisibilityChange(
|
||||
wasPanelVisible = true,
|
||||
isPanelVisible = false,
|
||||
panelOpen = true,
|
||||
otherPanelOpen = true
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
readerWorkspaceShouldRestoreFocusAfterPanelVisibilityChange(
|
||||
wasPanelVisible = true,
|
||||
isPanelVisible = false,
|
||||
panelOpen = false,
|
||||
otherPanelOpen = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `epub workspace exposes visual options through tools popup`() {
|
||||
val session = ReaderEngine().createSession(readerFixtureBook())
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
hiddenToolIds = ReaderTool.entries
|
||||
|
|
@ -96,8 +276,31 @@ class ReaderWorkspaceModelsTest {
|
|||
aiAvailable = true
|
||||
)
|
||||
|
||||
assertTrue(ReaderWorkspaceInspectorSection.APPEARANCE in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceTopAction.TOOLS in model.topActions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `epub workspace maps reading mode into appearance popup instead of tools inspector`() {
|
||||
val session = ReaderEngine().createSession(readerFixtureBook())
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
hiddenToolIds = ReaderTool.entries
|
||||
.filterNot { it == ReaderTool.READING_MODE }
|
||||
.mapTo(mutableSetOf()) { it.id }
|
||||
)
|
||||
|
||||
val model = epubReaderWorkspaceModel(
|
||||
session = session,
|
||||
toolbarPreferences = preferences,
|
||||
extrasState = ReaderExtrasState(),
|
||||
aiAvailable = false,
|
||||
cloudTtsAvailable = false,
|
||||
externalLookupAvailable = false
|
||||
)
|
||||
|
||||
assertTrue(ReaderWorkspaceInspectorSection.APPEARANCE in model.inspectorSections)
|
||||
assertFalse(ReaderWorkspaceInspectorSection.TOOLS in model.inspectorSections)
|
||||
assertFalse(ReaderWorkspaceTopAction.TOOLS in model.topActions)
|
||||
assertTrue(ReaderWorkspaceTopAction.TOOLS in model.topActions)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -122,12 +325,32 @@ class ReaderWorkspaceModelsTest {
|
|||
assertFalse(ReaderWorkspaceTopAction.TOOLS in model.topActions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `epub workspace exposes tools popup for desktop app theme controls`() {
|
||||
val session = ReaderEngine().createSession(readerFixtureBook())
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
hiddenToolIds = ReaderTool.entries.mapTo(mutableSetOf()) { it.id }
|
||||
)
|
||||
|
||||
val model = epubReaderWorkspaceModel(
|
||||
session = session,
|
||||
toolbarPreferences = preferences,
|
||||
extrasState = ReaderExtrasState(),
|
||||
aiAvailable = false,
|
||||
cloudTtsAvailable = false,
|
||||
externalLookupAvailable = false,
|
||||
appThemeControlsAvailable = true
|
||||
)
|
||||
|
||||
assertTrue(ReaderWorkspaceInspectorSection.APPEARANCE in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceTopAction.TOOLS in model.topActions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toolbar quick actions preserve visibility order and bottom placement`() {
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
hiddenToolIds = setOf(ReaderTool.BOOKMARK.id),
|
||||
toolOrder = listOf(
|
||||
ReaderTool.AUTO_SCROLL,
|
||||
ReaderTool.SEARCH,
|
||||
ReaderTool.AI_FEATURES,
|
||||
ReaderTool.THEME,
|
||||
|
|
@ -152,11 +375,12 @@ class ReaderWorkspaceModelsTest {
|
|||
aiAvailable = true
|
||||
)
|
||||
|
||||
assertEquals(listOf(ReaderTool.AUTO_SCROLL, ReaderTool.THEME), topTools.take(2))
|
||||
assertEquals(ReaderTool.THEME, topTools.first())
|
||||
assertEquals(listOf(ReaderTool.SEARCH), bottomToolsWithoutAi)
|
||||
assertEquals(listOf(ReaderTool.SEARCH, ReaderTool.AI_FEATURES), bottomToolsWithAi)
|
||||
assertEquals(listOf(ReaderTool.SEARCH), bottomToolsWithAi)
|
||||
assertFalse(ReaderTool.BOOKMARK in topTools)
|
||||
assertFalse(ReaderTool.BOOKMARK in bottomToolsWithAi)
|
||||
assertFalse(ReaderTool.AI_FEATURES in bottomToolsWithAi)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -187,6 +411,97 @@ class ReaderWorkspaceModelsTest {
|
|||
assertEquals(listOf(ReaderTool.SEARCH), tools)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts controls use top read aloud action instead of toolbar quick action`() {
|
||||
val session = ReaderEngine().createSession(readerFixtureBook())
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
toolOrder = listOf(ReaderTool.TTS_CONTROLS) + ReaderTool.entries,
|
||||
bottomToolIds = setOf(ReaderTool.TTS_CONTROLS.id)
|
||||
)
|
||||
|
||||
val tools = readerWorkspaceQuickActionTools(
|
||||
toolbarPreferences = preferences,
|
||||
bottom = true,
|
||||
aiAvailable = false,
|
||||
cloudTtsAvailable = true,
|
||||
externalLookupAvailable = true
|
||||
)
|
||||
val model = epubReaderWorkspaceModel(
|
||||
session = session,
|
||||
toolbarPreferences = preferences,
|
||||
extrasState = ReaderExtrasState(),
|
||||
aiAvailable = false,
|
||||
cloudTtsAvailable = true,
|
||||
externalLookupAvailable = true
|
||||
)
|
||||
|
||||
assertFalse(ReaderTool.TTS_CONTROLS in tools)
|
||||
assertTrue(ReaderWorkspaceTopAction.READ_ALOUD in model.topActions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ai features use top hub action instead of toolbar quick action`() {
|
||||
val session = ReaderEngine().createSession(readerFixtureBook())
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
hiddenToolIds = ReaderTool.entries
|
||||
.filterNot { it == ReaderTool.AI_FEATURES }
|
||||
.mapTo(mutableSetOf()) { it.id },
|
||||
toolOrder = listOf(ReaderTool.AI_FEATURES) + ReaderTool.entries,
|
||||
bottomToolIds = setOf(ReaderTool.AI_FEATURES.id)
|
||||
)
|
||||
|
||||
val tools = readerWorkspaceQuickActionTools(
|
||||
toolbarPreferences = preferences,
|
||||
bottom = true,
|
||||
aiAvailable = true,
|
||||
cloudTtsAvailable = false,
|
||||
externalLookupAvailable = true
|
||||
)
|
||||
val model = epubReaderWorkspaceModel(
|
||||
session = session,
|
||||
toolbarPreferences = preferences,
|
||||
extrasState = ReaderExtrasState(),
|
||||
aiAvailable = true,
|
||||
cloudTtsAvailable = false,
|
||||
externalLookupAvailable = true
|
||||
)
|
||||
|
||||
assertFalse(ReaderTool.AI_FEATURES in tools)
|
||||
assertTrue(ReaderWorkspaceTopAction.AI in model.topActions)
|
||||
assertFalse(ReaderWorkspaceInspectorSection.AI_TTS in model.inspectorSections)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retired auto scroll preferences are ignored for desktop reader tools`() {
|
||||
val session = ReaderEngine().createSession(readerFixtureBook())
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
hiddenToolIds = setOf("auto_scroll"),
|
||||
toolOrder = ReaderTool.entries
|
||||
)
|
||||
|
||||
val tools = readerWorkspaceQuickActionTools(
|
||||
toolbarPreferences = preferences,
|
||||
bottom = false,
|
||||
aiAvailable = false,
|
||||
cloudTtsAvailable = false,
|
||||
externalLookupAvailable = false
|
||||
)
|
||||
val model = epubReaderWorkspaceModel(
|
||||
session = session,
|
||||
toolbarPreferences = preferences,
|
||||
extrasState = ReaderExtrasState(autoScroll = ReaderAutoScrollState(enabled = true)),
|
||||
aiAvailable = false,
|
||||
cloudTtsAvailable = false,
|
||||
externalLookupAvailable = false
|
||||
)
|
||||
|
||||
assertNull(ReaderTool.fromId("auto_scroll"))
|
||||
assertFalse("auto_scroll" in preferences.sanitized().hiddenToolIds)
|
||||
assertFalse(tools.any { it.id == "auto_scroll" })
|
||||
assertFalse(ReaderWorkspaceTopAction.TOOLS in model.topActions)
|
||||
assertFalse("auto-scroll" in model.chrome.forceVisibleReasons)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf workspace defaults to reading first while keeping annotation tools in inspector`() {
|
||||
val model = pdfReaderWorkspaceModel(
|
||||
|
|
@ -230,7 +545,7 @@ class ReaderWorkspaceModelsTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `pdf workspace forces chrome for search editing errors tts and vertical auto scroll`() {
|
||||
fun `pdf workspace forces chrome for search editing errors and reveals tts`() {
|
||||
val model = pdfReaderWorkspaceModel(
|
||||
state = SharedPdfReaderState.initial(pageCount = 4).copy(searchQuery = "needle"),
|
||||
displayMode = PdfDisplayMode.VERTICAL_SCROLL,
|
||||
|
|
@ -255,8 +570,9 @@ class ReaderWorkspaceModelsTest {
|
|||
assertTrue("search" in model.chrome.forceVisibleReasons)
|
||||
assertTrue("annotation" in model.chrome.forceVisibleReasons)
|
||||
assertTrue("error" in model.chrome.forceVisibleReasons)
|
||||
assertTrue("auto-scroll" in model.chrome.forceVisibleReasons)
|
||||
assertTrue("tts" in model.chrome.forceVisibleReasons)
|
||||
assertFalse("auto-scroll" in model.chrome.forceVisibleReasons)
|
||||
assertFalse("tts" in model.chrome.forceVisibleReasons)
|
||||
assertTrue("tts" in model.chrome.revealVisibleReasons)
|
||||
assertFalse(ReaderWorkspaceTopAction.AI in model.topActions)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import com.aryan.reader.paginatedreader.CssStyle
|
||||
import com.aryan.reader.paginatedreader.SemanticParagraph
|
||||
import com.aryan.reader.shared.HighlightColor
|
||||
import com.aryan.reader.shared.ReaderLocator
|
||||
import com.aryan.reader.shared.UserHighlight
|
||||
import com.aryan.reader.shared.reader.ReaderPage
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
|
|
@ -31,6 +41,24 @@ class SharedNativePaginatedReaderInteractionTest {
|
|||
assertNull(range)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selection gesture key ignores paint-only annotated string changes`() {
|
||||
val plain = AnnotatedString("Alpha beta")
|
||||
val selected = buildAnnotatedString {
|
||||
append("Alpha beta")
|
||||
addStyle(SpanStyle(background = Color.Blue), start = 0, end = 5)
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
sharedNativeReaderSelectionGestureKey("0:1:0", plain),
|
||||
sharedNativeReaderSelectionGestureKey("0:1:0", selected)
|
||||
)
|
||||
assertNotEquals(
|
||||
sharedNativeReaderSelectionGestureKey("0:1:0", plain),
|
||||
sharedNativeReaderSelectionGestureKey("0:1:0", AnnotatedString("Alpha beta gamma"))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `highlight for native selection keeps desktop locator offsets`() {
|
||||
val selection = SharedNativeReaderTextSelection(
|
||||
|
|
@ -78,6 +106,272 @@ class SharedNativePaginatedReaderInteractionTest {
|
|||
assertEquals(4, highlight.locator.pageIndex)
|
||||
assertEquals(105, highlight.locator.startOffset)
|
||||
assertEquals(220, highlight.locator.endOffset)
|
||||
assertEquals(8, highlight.locator.blockIndex)
|
||||
assertEquals(105, highlight.locator.charOffset)
|
||||
assertEquals("selected across blocks", highlight.locator.textQuote)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native paginated keeps cfi highlights visible only on anchored page`() {
|
||||
val highlight = UserHighlight(
|
||||
id = "highlight-1",
|
||||
cfi = "/4/2:3|/4/2:8",
|
||||
text = "alpha",
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = 2
|
||||
)
|
||||
val page = ReaderPage(
|
||||
pageIndex = 20,
|
||||
chapterIndex = 2,
|
||||
chapterTitle = "Chapter",
|
||||
text = "alpha beta",
|
||||
startOffset = 100,
|
||||
endOffset = 110,
|
||||
semanticBlocks = listOf(
|
||||
SemanticParagraph(
|
||||
text = "alpha beta",
|
||||
spans = emptyList(),
|
||||
style = CssStyle(),
|
||||
elementId = null,
|
||||
cfi = "/4/2",
|
||||
startCharOffsetInSource = 100,
|
||||
blockIndex = 7
|
||||
)
|
||||
)
|
||||
)
|
||||
val unrelatedPage = ReaderPage(
|
||||
pageIndex = 21,
|
||||
chapterIndex = 2,
|
||||
chapterTitle = "Chapter",
|
||||
text = "alpha beta",
|
||||
startOffset = 200,
|
||||
endOffset = 210,
|
||||
semanticBlocks = listOf(
|
||||
SemanticParagraph(
|
||||
text = "alpha beta",
|
||||
spans = emptyList(),
|
||||
style = CssStyle(),
|
||||
elementId = null,
|
||||
cfi = "/4/4",
|
||||
startCharOffsetInSource = 200,
|
||||
blockIndex = 8
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val visible = sharedNativeVisibleHighlightsForPage(listOf(highlight), page)
|
||||
val unrelatedVisible = sharedNativeVisibleHighlightsForPage(listOf(highlight), unrelatedPage)
|
||||
|
||||
assertEquals(listOf(highlight), visible)
|
||||
assertEquals(emptyList(), unrelatedVisible)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native highlight mapping prefers locator offsets before cfi offsets`() {
|
||||
val highlight = UserHighlight(
|
||||
id = "highlight-1",
|
||||
cfi = "/4/2:0|/4/2:5",
|
||||
text = "target",
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = 0,
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
startOffset = 8,
|
||||
endOffset = 14,
|
||||
textQuote = "target",
|
||||
cfi = "/4/2:0|/4/2:5"
|
||||
)
|
||||
)
|
||||
|
||||
val range = sharedNativeHighlightRangeForBlock(
|
||||
highlight = highlight,
|
||||
blockCfi = "/4/2",
|
||||
textStartOffset = 0,
|
||||
textLength = "prefix target suffix".length,
|
||||
text = "prefix target suffix"
|
||||
)
|
||||
|
||||
assertEquals(8, range?.start)
|
||||
assertEquals(14, range?.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native highlight mapping can use source cfi when locator offsets miss block range`() {
|
||||
val highlight = UserHighlight(
|
||||
id = "highlight-1",
|
||||
cfi = "/4/2:8|/4/2:14",
|
||||
text = "target",
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = 0,
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
startOffset = 108,
|
||||
endOffset = 114,
|
||||
textQuote = "target",
|
||||
cfi = "/4/2:8|/4/2:14"
|
||||
)
|
||||
)
|
||||
|
||||
val range = sharedNativeHighlightRangeForBlock(
|
||||
highlight = highlight,
|
||||
blockCfi = "/4/2",
|
||||
textStartOffset = 300,
|
||||
textLength = "prefix target suffix".length,
|
||||
text = "prefix target suffix"
|
||||
)
|
||||
|
||||
assertEquals(8, range?.start)
|
||||
assertEquals(14, range?.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native highlight mapping ignores block local offsets on sibling cfi blocks`() {
|
||||
val highlight = UserHighlight(
|
||||
id = "highlight-1",
|
||||
cfi = "/4/2:8|/4/2:14",
|
||||
text = "target",
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = 0,
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
startOffset = 8,
|
||||
endOffset = 14,
|
||||
blockIndex = 42,
|
||||
charOffset = 8,
|
||||
textQuote = "target",
|
||||
cfi = "/4/2:8|/4/2:14"
|
||||
)
|
||||
)
|
||||
|
||||
val selectedRange = sharedNativeHighlightRangeForBlock(
|
||||
highlight = highlight,
|
||||
blockCfi = "/4/2",
|
||||
blockIndex = 42,
|
||||
blockCharOffset = 0,
|
||||
textStartOffset = 0,
|
||||
textLength = "prefix target suffix".length,
|
||||
text = "prefix target suffix"
|
||||
)
|
||||
val siblingRange = sharedNativeHighlightRangeForBlock(
|
||||
highlight = highlight,
|
||||
blockCfi = "/4/4",
|
||||
blockIndex = 43,
|
||||
blockCharOffset = 0,
|
||||
textStartOffset = 0,
|
||||
textLength = "prefix target suffix".length,
|
||||
text = "prefix target suffix"
|
||||
)
|
||||
|
||||
assertEquals(8, selectedRange?.start)
|
||||
assertEquals(14, selectedRange?.end)
|
||||
assertNull(siblingRange)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native highlight mapping can use android style block locator`() {
|
||||
val highlight = UserHighlight(
|
||||
id = "highlight-1",
|
||||
cfi = "android-locator:0:42:108",
|
||||
text = "target",
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = 0,
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
blockIndex = 42,
|
||||
charOffset = 108,
|
||||
textQuote = "target",
|
||||
cfi = "android-locator:0:42:108"
|
||||
)
|
||||
)
|
||||
|
||||
val range = sharedNativeHighlightRangeForBlock(
|
||||
highlight = highlight,
|
||||
blockCfi = "/4/2",
|
||||
blockIndex = 42,
|
||||
blockCharOffset = 100,
|
||||
textStartOffset = 100,
|
||||
textLength = "prefix target suffix".length,
|
||||
text = "prefix target suffix"
|
||||
)
|
||||
|
||||
assertEquals(8, range?.start)
|
||||
assertEquals(14, range?.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native highlight mapping prefers block locator before overlapping offsets`() {
|
||||
val highlight = UserHighlight(
|
||||
id = "highlight-1",
|
||||
cfi = "android-locator:0:42:108",
|
||||
text = "target",
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = 0,
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
startOffset = 0,
|
||||
endOffset = 6,
|
||||
blockIndex = 42,
|
||||
charOffset = 108,
|
||||
textQuote = "target",
|
||||
cfi = "android-locator:0:42:108"
|
||||
)
|
||||
)
|
||||
|
||||
val range = sharedNativeHighlightRangeForBlock(
|
||||
highlight = highlight,
|
||||
blockCfi = "/4/2",
|
||||
blockIndex = 42,
|
||||
blockCharOffset = 100,
|
||||
textStartOffset = 0,
|
||||
textLength = "prefix target suffix".length,
|
||||
text = "prefix target suffix"
|
||||
)
|
||||
|
||||
assertEquals(8, range?.start)
|
||||
assertEquals(14, range?.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native highlight mapping treats source cfi offsets as block local`() {
|
||||
val highlight = UserHighlight(
|
||||
id = "highlight-1",
|
||||
cfi = "/4/2:8|/4/2:14",
|
||||
text = "target",
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = 0
|
||||
)
|
||||
|
||||
val range = sharedNativeHighlightRangeForBlock(
|
||||
highlight = highlight,
|
||||
blockCfi = "/4/2",
|
||||
textStartOffset = 100,
|
||||
textLength = "prefix target suffix".length,
|
||||
text = "prefix target suffix"
|
||||
)
|
||||
|
||||
assertEquals(8, range?.start)
|
||||
assertEquals(14, range?.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native highlight mapping still accepts legacy absolute cfi offsets`() {
|
||||
val highlight = UserHighlight(
|
||||
id = "highlight-1",
|
||||
cfi = "/4/2:108|/4/2:114",
|
||||
text = "target",
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = 0
|
||||
)
|
||||
|
||||
val range = sharedNativeHighlightRangeForBlock(
|
||||
highlight = highlight,
|
||||
blockCfi = "/4/2",
|
||||
textStartOffset = 100,
|
||||
textLength = "prefix target suffix".length,
|
||||
text = "prefix target suffix"
|
||||
)
|
||||
|
||||
assertEquals(8, range?.start)
|
||||
assertEquals(14, range?.end)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import com.aryan.reader.shared.pdf.PdfAnnotationKind
|
||||
import com.aryan.reader.shared.pdf.PdfInkTool
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAndroidHighlightColors
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class SharedPdfAnnotationUiTest {
|
||||
@Test
|
||||
fun `text highlight annotations render with readable highlighter blending`() {
|
||||
val annotation = SharedPdfAnnotation(
|
||||
id = "highlight-1",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.HIGHLIGHT,
|
||||
tool = PdfInkTool.HIGHLIGHTER,
|
||||
colorArgb = Color.Yellow.copy(alpha = 0.9f).toArgb()
|
||||
)
|
||||
|
||||
val style = sharedPdfHighlightAnnotationOverlayStyle(annotation)
|
||||
|
||||
assertEquals(BlendMode.Multiply, style.blendMode)
|
||||
assertEquals(SharedPdfAndroidHighlightColors.RenderAlpha, style.color.alpha)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text highlight annotations preserve lower custom opacity`() {
|
||||
val annotation = SharedPdfAnnotation(
|
||||
id = "highlight-1",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.HIGHLIGHT,
|
||||
tool = PdfInkTool.HIGHLIGHTER,
|
||||
colorArgb = Color.Yellow.copy(alpha = 0.18f).toArgb()
|
||||
)
|
||||
|
||||
val style = sharedPdfHighlightAnnotationOverlayStyle(annotation)
|
||||
|
||||
assertEquals(0.18f, style.color.alpha, 0.005f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `interaction dock keeps reading modes before markup actions`() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
SharedPdfInteractionDockItem.PAN,
|
||||
SharedPdfInteractionDockItem.SELECT_TEXT,
|
||||
SharedPdfInteractionDockItem.PEN,
|
||||
SharedPdfInteractionDockItem.HIGHLIGHTER,
|
||||
SharedPdfInteractionDockItem.TEXT_NOTE,
|
||||
SharedPdfInteractionDockItem.ERASER,
|
||||
SharedPdfInteractionDockItem.UNDO,
|
||||
SharedPdfInteractionDockItem.REDO,
|
||||
SharedPdfInteractionDockItem.CLEAR_PAGE
|
||||
),
|
||||
sharedPdfInteractionDockItems()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `interaction dock only exposes available markup groups`() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
SharedPdfInteractionDockItem.PAN,
|
||||
SharedPdfInteractionDockItem.SELECT_TEXT,
|
||||
SharedPdfInteractionDockItem.TEXT_NOTE,
|
||||
SharedPdfInteractionDockItem.UNDO,
|
||||
SharedPdfInteractionDockItem.REDO,
|
||||
SharedPdfInteractionDockItem.CLEAR_PAGE
|
||||
),
|
||||
sharedPdfInteractionDockItems(tools = listOf(PdfInkTool.TEXT))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,14 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
private const val SharedReaderDiagnosticsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS"
|
||||
private const val SharedReaderDiagnosticsTagsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS_TAGS"
|
||||
|
||||
private val SharedReaderDiagnosticTags: Set<String> =
|
||||
System.getProperty(SharedReaderDiagnosticsTagsProperty)
|
||||
.orEmpty()
|
||||
listOfNotNull(
|
||||
System.getProperty(SharedReaderDiagnosticsTagsProperty),
|
||||
System.getenv(SharedReaderDiagnosticsTagsEnv)
|
||||
)
|
||||
.joinToString(" ")
|
||||
.split(',', ';', ' ', '\t', '\n')
|
||||
.mapNotNull { rawTag ->
|
||||
rawTag.trim()
|
||||
|
|
@ -15,9 +21,16 @@ internal actual val SharedReaderDiagnosticsEnabled: Boolean =
|
|||
System.getProperty(SharedReaderDiagnosticsProperty)
|
||||
?.trim()
|
||||
?.equals("true", ignoreCase = true) == true ||
|
||||
System.getenv(SharedReaderDiagnosticsEnv)
|
||||
?.trim()
|
||||
?.equals("true", ignoreCase = true) == true ||
|
||||
SharedReaderDiagnosticTags.isNotEmpty()
|
||||
|
||||
internal actual fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean {
|
||||
if (SharedReaderDiagnosticTags.isEmpty()) return true
|
||||
return "*" in SharedReaderDiagnosticTags || tag.lowercase() in SharedReaderDiagnosticTags
|
||||
}
|
||||
|
||||
internal actual fun writeSharedReaderDiagnostic(tag: String, message: String) {
|
||||
println("$tag $message")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,12 @@ import org.jetbrains.skia.Image as SkiaImage
|
|||
import java.awt.RenderingHints
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.File
|
||||
import java.util.concurrent.Semaphore
|
||||
import javax.imageio.ImageIO
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal object DesktopBookCoverImageCache {
|
||||
private const val MaxEntries = 96
|
||||
private const val MaxEntries = 160
|
||||
private const val MaxCoverDimensionPx = 512
|
||||
|
||||
private data class Entry(
|
||||
|
|
@ -20,36 +21,40 @@ internal object DesktopBookCoverImageCache {
|
|||
)
|
||||
|
||||
private val entries = LinkedHashMap<String, Entry>(MaxEntries, 0.75f, true)
|
||||
private val decodeSlots = Semaphore(2)
|
||||
|
||||
fun peek(path: String): ImageBitmap? {
|
||||
return synchronized(entries) {
|
||||
entries[File(path).absolutePath]?.bitmap
|
||||
}
|
||||
}
|
||||
|
||||
fun load(path: String): ImageBitmap? {
|
||||
val file = File(path)
|
||||
if (!file.isFile) return null
|
||||
val key = file.absolutePath
|
||||
val length = file.length()
|
||||
val lastModified = file.lastModified()
|
||||
return synchronized(entries) {
|
||||
synchronized(entries) {
|
||||
val entry = entries[key]
|
||||
if (entry != null && entry.length == length && entry.lastModified == lastModified) {
|
||||
entry.bitmap
|
||||
} else {
|
||||
entries.remove(key)
|
||||
null
|
||||
return entry.bitmap
|
||||
}
|
||||
entries.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
fun load(path: String): ImageBitmap? {
|
||||
peek(path)?.let { return it }
|
||||
val file = File(path)
|
||||
if (!file.isFile) return null
|
||||
val bitmap = decodeCover(file) ?: return null
|
||||
decodeSlots.acquireUninterruptibly()
|
||||
val bitmap = try {
|
||||
decodeCover(file)
|
||||
} finally {
|
||||
decodeSlots.release()
|
||||
} ?: return null
|
||||
val entry = Entry(
|
||||
length = file.length(),
|
||||
lastModified = file.lastModified(),
|
||||
length = length,
|
||||
lastModified = lastModified,
|
||||
bitmap = bitmap
|
||||
)
|
||||
synchronized(entries) {
|
||||
entries[file.absolutePath] = entry
|
||||
entries[key] = entry
|
||||
trimToMaxEntries()
|
||||
}
|
||||
return bitmap
|
||||
|
|
|
|||
|
|
@ -23,10 +23,11 @@ internal actual fun LocalBookCoverImage(
|
|||
}
|
||||
|
||||
LaunchedEffect(path) {
|
||||
if (bitmap == null) {
|
||||
bitmap = withContext(Dispatchers.IO) {
|
||||
DesktopBookCoverImageCache.load(path)
|
||||
}
|
||||
val loaded = withContext(Dispatchers.IO) {
|
||||
DesktopBookCoverImageCache.load(path)
|
||||
}
|
||||
if (loaded != null && loaded != bitmap) {
|
||||
bitmap = loaded
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -24,9 +25,13 @@ import java.awt.Point
|
|||
import java.awt.event.WindowAdapter
|
||||
import java.awt.event.WindowEvent
|
||||
import java.awt.Window as AwtWindow
|
||||
import java.util.Collections
|
||||
import java.util.WeakHashMap
|
||||
import javax.swing.RootPaneContainer
|
||||
|
||||
private val LocalSharedReaderModalOwnerWindow = compositionLocalOf<AwtWindow?> { null }
|
||||
private val SharedReaderModalOwnerByWindow: MutableMap<AwtWindow, AwtWindow> =
|
||||
Collections.synchronizedMap(WeakHashMap<AwtWindow, AwtWindow>())
|
||||
|
||||
@Composable
|
||||
actual fun SharedReaderModalOwnerWindowProvider(
|
||||
|
|
@ -47,9 +52,14 @@ internal actual fun SharedReaderModalLayer(
|
|||
) {
|
||||
val anchor = LocalSharedReaderModalAnchorBounds.current
|
||||
val density = LocalDensity.current
|
||||
val focusableOverride = LocalSharedReaderModalFocusableOverride.current
|
||||
val explicitOwnerWindow = LocalSharedReaderModalOwnerWindow.current
|
||||
val fallbackOwnerWindow = remember { currentNonModalOwnerWindow() }
|
||||
val ownerWindow = explicitOwnerWindow ?: fallbackOwnerWindow
|
||||
val modalWindowFocusable = sharedReaderModalLayerWindowFocusable(
|
||||
level = level,
|
||||
focusableOverride = focusableOverride
|
||||
)
|
||||
val dialogSize = with(density) {
|
||||
anchor?.let {
|
||||
when {
|
||||
|
|
@ -61,7 +71,10 @@ internal actual fun SharedReaderModalLayer(
|
|||
}
|
||||
level.isEdgePanelLayer() -> {
|
||||
DpSize(
|
||||
width = level.edgePanelLayerWidth(it.widthPx.toDp()),
|
||||
width = sharedReaderModalEdgePanelLayerWidth(
|
||||
level = level,
|
||||
anchorWidth = it.widthPx.toDp()
|
||||
),
|
||||
height = it.heightPx.toDp().coerceAtLeast(360.dp)
|
||||
)
|
||||
}
|
||||
|
|
@ -91,7 +104,7 @@ internal actual fun SharedReaderModalLayer(
|
|||
SharedReaderModalLevel.ChromeBottom -> "Reader Chrome Bottom"
|
||||
}
|
||||
var modalVisible by remember(ownerWindow, explicitOwnerWindow, level) {
|
||||
mutableStateOf(sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, level))
|
||||
mutableStateOf(sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, null))
|
||||
}
|
||||
|
||||
LaunchedEffect(dialogPosition, dialogSize) {
|
||||
|
|
@ -110,26 +123,61 @@ internal actual fun SharedReaderModalLayer(
|
|||
}
|
||||
}
|
||||
DisposableEffect(ownerWindow, explicitOwnerWindow, level) {
|
||||
if (ownerWindow == null || explicitOwnerWindow == null || !level.isChromeLayer()) {
|
||||
if (ownerWindow == null || explicitOwnerWindow == null) {
|
||||
modalVisible = true
|
||||
onDispose {}
|
||||
} else {
|
||||
fun syncVisibility() {
|
||||
modalVisible = sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, level)
|
||||
var disposed = false
|
||||
fun hideImmediately(ownerClosing: Boolean) {
|
||||
if (disposed) return
|
||||
if (
|
||||
sharedReaderModalLayerShouldHideImmediately(
|
||||
ownerShowing = ownerWindow.isShowing,
|
||||
ownerDisplayable = ownerWindow.isDisplayable,
|
||||
ownerClosing = ownerClosing
|
||||
)
|
||||
) {
|
||||
modalVisible = false
|
||||
}
|
||||
}
|
||||
fun syncVisibility(oppositeWindow: AwtWindow?) {
|
||||
if (disposed) return
|
||||
if (
|
||||
sharedReaderModalLayerShouldHideImmediately(
|
||||
ownerShowing = ownerWindow.isShowing,
|
||||
ownerDisplayable = ownerWindow.isDisplayable,
|
||||
ownerClosing = false
|
||||
)
|
||||
) {
|
||||
hideImmediately(ownerClosing = false)
|
||||
return
|
||||
}
|
||||
val nextVisible = sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, oppositeWindow)
|
||||
if (nextVisible) {
|
||||
modalVisible = true
|
||||
} else {
|
||||
EventQueue.invokeLater {
|
||||
if (!disposed) {
|
||||
modalVisible = sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val listener = object : WindowAdapter() {
|
||||
override fun windowActivated(e: WindowEvent?) = syncVisibility()
|
||||
override fun windowDeactivated(e: WindowEvent?) = syncVisibility()
|
||||
override fun windowGainedFocus(e: WindowEvent?) = syncVisibility()
|
||||
override fun windowLostFocus(e: WindowEvent?) = syncVisibility()
|
||||
override fun windowIconified(e: WindowEvent?) = syncVisibility()
|
||||
override fun windowDeiconified(e: WindowEvent?) = syncVisibility()
|
||||
override fun windowClosed(e: WindowEvent?) = syncVisibility()
|
||||
override fun windowClosing(e: WindowEvent?) = hideImmediately(ownerClosing = true)
|
||||
override fun windowClosed(e: WindowEvent?) = hideImmediately(ownerClosing = true)
|
||||
override fun windowActivated(e: WindowEvent?) = syncVisibility(e?.oppositeWindow)
|
||||
override fun windowDeactivated(e: WindowEvent?) = syncVisibility(e?.oppositeWindow)
|
||||
override fun windowGainedFocus(e: WindowEvent?) = syncVisibility(e?.oppositeWindow)
|
||||
override fun windowLostFocus(e: WindowEvent?) = syncVisibility(e?.oppositeWindow)
|
||||
override fun windowIconified(e: WindowEvent?) = syncVisibility(e?.oppositeWindow)
|
||||
override fun windowDeiconified(e: WindowEvent?) = syncVisibility(e?.oppositeWindow)
|
||||
}
|
||||
ownerWindow.addWindowListener(listener)
|
||||
ownerWindow.addWindowFocusListener(listener)
|
||||
syncVisibility()
|
||||
syncVisibility(null)
|
||||
onDispose {
|
||||
disposed = true
|
||||
ownerWindow.removeWindowListener(listener)
|
||||
ownerWindow.removeWindowFocusListener(listener)
|
||||
}
|
||||
|
|
@ -145,12 +193,65 @@ internal actual fun SharedReaderModalLayer(
|
|||
transparent = true,
|
||||
resizable = false,
|
||||
alwaysOnTop = true,
|
||||
focusable = !level.isChromeLayer()
|
||||
focusable = modalWindowFocusable
|
||||
) {
|
||||
val modalWindow = window
|
||||
LaunchedEffect(modalWindow, level) {
|
||||
DisposableEffect(modalWindow, ownerWindow, explicitOwnerWindow, level) {
|
||||
modalWindow.name = SharedReaderModalWindowNamePrefix + level.name
|
||||
if (ownerWindow != null && explicitOwnerWindow != null) {
|
||||
synchronized(SharedReaderModalOwnerByWindow) {
|
||||
SharedReaderModalOwnerByWindow[modalWindow] = ownerWindow
|
||||
}
|
||||
}
|
||||
var disposed = false
|
||||
fun syncVisibility(oppositeWindow: AwtWindow?) {
|
||||
if (disposed) return
|
||||
if (ownerWindow != null && explicitOwnerWindow != null) {
|
||||
if (
|
||||
sharedReaderModalLayerShouldHideImmediately(
|
||||
ownerShowing = ownerWindow.isShowing,
|
||||
ownerDisplayable = ownerWindow.isDisplayable,
|
||||
ownerClosing = false
|
||||
)
|
||||
) {
|
||||
modalVisible = false
|
||||
return
|
||||
}
|
||||
val nextVisible = sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, oppositeWindow)
|
||||
if (nextVisible) {
|
||||
modalVisible = true
|
||||
} else {
|
||||
EventQueue.invokeLater {
|
||||
if (!disposed) {
|
||||
modalVisible = sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val listener = object : WindowAdapter() {
|
||||
override fun windowActivated(e: WindowEvent?) = syncVisibility(e?.oppositeWindow)
|
||||
override fun windowDeactivated(e: WindowEvent?) = syncVisibility(e?.oppositeWindow)
|
||||
override fun windowGainedFocus(e: WindowEvent?) = syncVisibility(e?.oppositeWindow)
|
||||
override fun windowLostFocus(e: WindowEvent?) = syncVisibility(e?.oppositeWindow)
|
||||
override fun windowIconified(e: WindowEvent?) = syncVisibility(e?.oppositeWindow)
|
||||
override fun windowClosed(e: WindowEvent?) = syncVisibility(e?.oppositeWindow)
|
||||
}
|
||||
modalWindow.addWindowListener(listener)
|
||||
modalWindow.addWindowFocusListener(listener)
|
||||
onDispose {
|
||||
disposed = true
|
||||
modalWindow.removeWindowListener(listener)
|
||||
modalWindow.removeWindowFocusListener(listener)
|
||||
synchronized(SharedReaderModalOwnerByWindow) {
|
||||
SharedReaderModalOwnerByWindow.remove(modalWindow)
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(modalWindow, level, modalWindowFocusable) {
|
||||
modalWindow.name = SharedReaderModalWindowNamePrefix + level.name
|
||||
modalWindow.isAlwaysOnTop = true
|
||||
modalWindow.setFocusableWindowState(modalWindowFocusable)
|
||||
val frontAttempts = when (level) {
|
||||
SharedReaderModalLevel.Popup -> 4
|
||||
SharedReaderModalLevel.Panel,
|
||||
|
|
@ -161,11 +262,14 @@ internal actual fun SharedReaderModalLayer(
|
|||
}
|
||||
repeat(frontAttempts) { attempt ->
|
||||
delay(if (attempt == 0) 30L else 80L)
|
||||
modalWindow.isAlwaysOnTop = true
|
||||
modalWindow.toFront()
|
||||
if (!level.isChromeLayer()) {
|
||||
modalWindow.requestFocus()
|
||||
modalWindow.requestFocusInWindow()
|
||||
if (!modalWindow.isDisplayable) return@repeat
|
||||
runCatching {
|
||||
modalWindow.isAlwaysOnTop = true
|
||||
modalWindow.toFront()
|
||||
if (modalWindowFocusable && !level.isEdgePanelLayer()) {
|
||||
modalWindow.requestFocus()
|
||||
modalWindow.requestFocusInWindow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -174,6 +278,13 @@ internal actual fun SharedReaderModalLayer(
|
|||
}
|
||||
}
|
||||
|
||||
internal fun sharedReaderModalLayerWindowFocusable(
|
||||
level: SharedReaderModalLevel,
|
||||
focusableOverride: Boolean?
|
||||
): Boolean {
|
||||
return focusableOverride ?: !level.isChromeLayer()
|
||||
}
|
||||
|
||||
internal actual fun sharedReaderModalLayerUsesSizedEdgeWindow(level: SharedReaderModalLevel): Boolean {
|
||||
return level.isEdgePanelLayer()
|
||||
}
|
||||
|
|
@ -181,6 +292,7 @@ internal actual fun sharedReaderModalLayerUsesSizedEdgeWindow(level: SharedReade
|
|||
private const val SharedReaderModalWindowNamePrefix = "shared-reader-modal:"
|
||||
private val SharedReaderChromeTopLayerHeight = 104.dp
|
||||
private val SharedReaderChromeBottomLayerHeight = 164.dp
|
||||
private val SharedReaderChromeBottomLayerOverlap = 8.dp
|
||||
private val SharedReaderLeftPanelWidth = 340.dp
|
||||
private val SharedReaderRightPanelWidth = 380.dp
|
||||
private val SharedReaderLeftNarrowPanelMaxWidth = 320.dp
|
||||
|
|
@ -191,12 +303,46 @@ private val SharedReaderWidePanelBreakpoint = 1120.dp
|
|||
private fun sharedReaderModalLayerVisible(
|
||||
ownerWindow: AwtWindow?,
|
||||
explicitOwnerWindow: AwtWindow?,
|
||||
level: SharedReaderModalLevel
|
||||
oppositeWindow: AwtWindow?
|
||||
): Boolean {
|
||||
if (explicitOwnerWindow == null || !level.isChromeLayer()) return true
|
||||
return ownerWindow?.let { window ->
|
||||
window.isShowing && window.isDisplayable && (window.isActive || window.isFocused)
|
||||
} == true
|
||||
if (explicitOwnerWindow == null) return true
|
||||
return ownerWindow?.sharedReaderChromeLayerVisible(oppositeWindow) == true
|
||||
}
|
||||
|
||||
internal fun sharedReaderModalChromeLayerVisible(
|
||||
ownerShowing: Boolean,
|
||||
ownerDisplayable: Boolean,
|
||||
ownerMinimized: Boolean,
|
||||
ownerActive: Boolean,
|
||||
ownerFocused: Boolean,
|
||||
ownerModalActive: Boolean
|
||||
): Boolean {
|
||||
return ownerShowing &&
|
||||
ownerDisplayable &&
|
||||
!ownerMinimized &&
|
||||
(ownerActive || ownerFocused || ownerModalActive)
|
||||
}
|
||||
|
||||
internal fun sharedReaderModalLayerShouldHideImmediately(
|
||||
ownerShowing: Boolean,
|
||||
ownerDisplayable: Boolean,
|
||||
ownerClosing: Boolean
|
||||
): Boolean {
|
||||
return ownerClosing || !ownerShowing || !ownerDisplayable
|
||||
}
|
||||
|
||||
private fun AwtWindow.sharedReaderChromeLayerVisible(oppositeWindow: AwtWindow?): Boolean {
|
||||
return sharedReaderModalChromeLayerVisible(
|
||||
ownerShowing = isShowing,
|
||||
ownerDisplayable = isDisplayable,
|
||||
ownerMinimized = (this as? java.awt.Frame)?.let { frame ->
|
||||
frame.extendedState and java.awt.Frame.ICONIFIED != 0
|
||||
} == true,
|
||||
ownerActive = isActive,
|
||||
ownerFocused = isFocused,
|
||||
ownerModalActive = oppositeWindow.isSharedReaderModalWindowForOwner(this) ||
|
||||
sharedReaderModalWindowActiveForOwner(this)
|
||||
)
|
||||
}
|
||||
|
||||
private fun sharedReaderModalLayerPosition(
|
||||
|
|
@ -212,7 +358,12 @@ private fun sharedReaderModalLayerPosition(
|
|||
}
|
||||
if (anchor != null && ownerLocation != null) {
|
||||
val topPx = when (level) {
|
||||
SharedReaderModalLevel.ChromeBottom -> anchor.topPx + anchor.heightPx - dialogSize.height.toPx()
|
||||
SharedReaderModalLevel.ChromeBottom -> sharedReaderModalChromeBottomLayerTopPx(
|
||||
anchorTopPx = anchor.topPx,
|
||||
anchorHeightPx = anchor.heightPx,
|
||||
dialogHeightPx = dialogSize.height.toPx(),
|
||||
overlapPx = SharedReaderChromeBottomLayerOverlap.toPx()
|
||||
)
|
||||
else -> anchor.topPx
|
||||
}
|
||||
val leftPx = when (level) {
|
||||
|
|
@ -229,6 +380,15 @@ private fun sharedReaderModalLayerPosition(
|
|||
}
|
||||
}
|
||||
|
||||
internal fun sharedReaderModalChromeBottomLayerTopPx(
|
||||
anchorTopPx: Float,
|
||||
anchorHeightPx: Float,
|
||||
dialogHeightPx: Float,
|
||||
overlapPx: Float
|
||||
): Float {
|
||||
return anchorTopPx + anchorHeightPx - dialogHeightPx + overlapPx
|
||||
}
|
||||
|
||||
private fun AwtWindow.sharedReaderModalContentLocationOnScreen(): Point {
|
||||
val contentPane = (this as? RootPaneContainer)?.contentPane
|
||||
if (contentPane != null && contentPane.isShowing) {
|
||||
|
|
@ -251,14 +411,17 @@ private fun SharedReaderModalLevel.chromeLayerHeight() = when (this) {
|
|||
else -> 0.dp
|
||||
}
|
||||
|
||||
private fun SharedReaderModalLevel.edgePanelLayerWidth(anchorWidth: androidx.compose.ui.unit.Dp): androidx.compose.ui.unit.Dp {
|
||||
val preferredWideWidth = when (this) {
|
||||
SharedReaderModalLevel.PanelRight -> SharedReaderRightPanelWidth
|
||||
else -> SharedReaderLeftPanelWidth
|
||||
internal fun sharedReaderModalEdgePanelLayerWidth(
|
||||
level: SharedReaderModalLevel,
|
||||
anchorWidth: Dp
|
||||
): Dp {
|
||||
val preferredWideWidth = when (level) {
|
||||
SharedReaderModalLevel.PanelLeft -> SharedReaderLeftPanelWidth
|
||||
else -> SharedReaderRightPanelWidth
|
||||
}
|
||||
val preferredNarrowWidth = when (this) {
|
||||
SharedReaderModalLevel.PanelRight -> minOf(SharedReaderRightNarrowPanelMaxWidth, anchorWidth * SharedReaderNarrowPanelFraction)
|
||||
else -> minOf(SharedReaderLeftNarrowPanelMaxWidth, anchorWidth * SharedReaderNarrowPanelFraction)
|
||||
val preferredNarrowWidth = when (level) {
|
||||
SharedReaderModalLevel.PanelLeft -> minOf(SharedReaderLeftNarrowPanelMaxWidth, anchorWidth * SharedReaderNarrowPanelFraction)
|
||||
else -> minOf(SharedReaderRightNarrowPanelMaxWidth, anchorWidth * SharedReaderNarrowPanelFraction)
|
||||
}
|
||||
return if (anchorWidth >= SharedReaderWidePanelBreakpoint) {
|
||||
preferredWideWidth.coerceAtMost(anchorWidth)
|
||||
|
|
@ -308,3 +471,29 @@ private fun AwtWindow.isSharedReaderModalWindow(): Boolean {
|
|||
windowTitle.startsWith("Reader Popup") ||
|
||||
windowTitle.startsWith("Reader Chrome")
|
||||
}
|
||||
|
||||
private fun AwtWindow?.isSharedReaderModalWindowForOwner(ownerWindow: AwtWindow): Boolean {
|
||||
val window = this ?: return false
|
||||
return window.sharedReaderModalOwnerInWindowChain() == ownerWindow
|
||||
}
|
||||
|
||||
private fun sharedReaderModalWindowActiveForOwner(ownerWindow: AwtWindow): Boolean {
|
||||
return AwtWindow.getWindows().any { window ->
|
||||
window.isShowing &&
|
||||
window.isDisplayable &&
|
||||
window.isSharedReaderModalWindowForOwner(ownerWindow) &&
|
||||
(window.isActive || window.isFocused)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AwtWindow.sharedReaderModalOwnerInWindowChain(): AwtWindow? {
|
||||
var current: AwtWindow? = this
|
||||
while (current != null) {
|
||||
val window = current
|
||||
synchronized(SharedReaderModalOwnerByWindow) {
|
||||
SharedReaderModalOwnerByWindow[window]
|
||||
}?.let { owner -> return owner }
|
||||
current = window.owner
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
package com.aryan.reader.paginatedreader
|
||||
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class HtmlParserLinkTest {
|
||||
@Test
|
||||
fun `block anchor propagates href to paragraph text`() {
|
||||
val blocks = parse(
|
||||
"""
|
||||
<html>
|
||||
<body>
|
||||
<a href="chapter2.xhtml#start"><p>Continue reading</p></a>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
val paragraph = blocks.single() as SemanticParagraph
|
||||
val linkSpan = paragraph.spans.single { it.linkHref == "chapter2.xhtml#start" }
|
||||
|
||||
assertEquals("Continue reading", paragraph.text)
|
||||
assertEquals(0, linkSpan.start)
|
||||
assertEquals(paragraph.text.length, linkSpan.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `block anchor propagates href to heading text`() {
|
||||
val blocks = parse(
|
||||
"""
|
||||
<html>
|
||||
<body>
|
||||
<a href="#details"><h2>Details</h2></a>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
val heading = blocks.single() as SemanticHeader
|
||||
|
||||
assertEquals("Details", heading.text)
|
||||
assertTrue(heading.spans.any { span ->
|
||||
span.linkHref == "#details" &&
|
||||
span.start == 0 &&
|
||||
span.end == heading.text.length
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nested inline spans inherit anchor href`() {
|
||||
val blocks = parse(
|
||||
"""
|
||||
<html>
|
||||
<body>
|
||||
<p><a href="notes.xhtml#n1"><span>note</span></a></p>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
val paragraph = blocks.single() as SemanticParagraph
|
||||
|
||||
assertEquals("note", paragraph.text)
|
||||
assertTrue(paragraph.spans.any { span ->
|
||||
span.tag == "span" &&
|
||||
span.linkHref == "notes.xhtml#n1" &&
|
||||
span.start == 0 &&
|
||||
span.end == paragraph.text.length
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `namespaced anchor href is treated as link`() {
|
||||
val blocks = parse(
|
||||
"""
|
||||
<html>
|
||||
<body>
|
||||
<p><a xlink:href="appendix.xhtml#more">Appendix</a></p>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
val paragraph = blocks.single() as SemanticParagraph
|
||||
|
||||
assertEquals("Appendix", paragraph.text)
|
||||
assertTrue(paragraph.spans.any { span ->
|
||||
span.linkHref == "appendix.xhtml#more" &&
|
||||
span.start == 0 &&
|
||||
span.end == paragraph.text.length
|
||||
})
|
||||
}
|
||||
|
||||
private fun parse(html: String): List<SemanticBlock> {
|
||||
return htmlToSemanticBlocks(
|
||||
html = html,
|
||||
cssRules = OptimizedCssRules(),
|
||||
textStyle = TextStyle(fontSize = 16.sp),
|
||||
chapterAbsPath = "OEBPS/chapter1.xhtml",
|
||||
extractionBasePath = "",
|
||||
density = Density(1f),
|
||||
fontFamilyMap = emptyMap(),
|
||||
constraints = Constraints(maxWidth = 400, maxHeight = 800)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -151,6 +151,89 @@ class SharedOpdsParserTest {
|
|||
assertEquals("https://example.org/root/stream/{pageNumber}", entry.pseUrlTemplate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse cover links from opds cover relations and image typed links`() {
|
||||
val xmlFeed = SharedOpdsParser().parse(
|
||||
bodyString = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>XML Catalog</title>
|
||||
<entry>
|
||||
<id>xml-cover</id>
|
||||
<title>XML Cover Book</title>
|
||||
<link rel="http://opds-spec.org/cover" href="/api/v1/opds/42/cover" type="image/jpeg" />
|
||||
<link rel="http://opds-spec.org/acquisition/open-access" type="application/epub+zip" href="/api/v1/opds/42/download" />
|
||||
</entry>
|
||||
</feed>
|
||||
""".trimIndent(),
|
||||
baseUrl = "https://grimmory.example/api/v1/opds/catalog"
|
||||
)
|
||||
|
||||
assertEquals("https://grimmory.example/api/v1/opds/42/cover", xmlFeed.entries.single().coverUrl)
|
||||
|
||||
val jsonFeed = SharedOpdsParser().parse(
|
||||
bodyString = """
|
||||
{
|
||||
"publications": [
|
||||
{
|
||||
"metadata": {"identifier": "json-cover", "title": "JSON Cover Book"},
|
||||
"links": [
|
||||
{"rel": "cover", "href": "/api/v1/opds/77/cover", "type": "image/jpeg"},
|
||||
{"rel": "http://opds-spec.org/acquisition", "href": "/api/v1/opds/77/download", "type": "application/pdf"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
baseUrl = "https://grimmory.example/api/v1/opds/catalog"
|
||||
)
|
||||
|
||||
assertEquals("https://grimmory.example/api/v1/opds/77/cover", jsonFeed.entries.single().coverUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extract OpenSearch template prefers OPDS acquisition feeds over generic Atom feeds`() {
|
||||
val template = SharedOpdsParser().extractOpenSearchTemplate(
|
||||
bodyString = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
|
||||
<Url type="application/atom+xml" template="https://example.org/feeds/atom/all?query={searchTerms}&per-page={count}&page={startPage}"/>
|
||||
<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition" template="https://example.org/feeds/opds/all?query={searchTerms}&per-page={count}&page={startPage}"/>
|
||||
</OpenSearchDescription>
|
||||
""".trimIndent(),
|
||||
openSearchUrl = "https://example.org/opensearch"
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"https://example.org/feeds/opds/all?query={searchTerms}&per-page={count}&page={startPage}",
|
||||
template
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse ebook enclosure links as acquisitions`() {
|
||||
val feed = SharedOpdsParser().parse(
|
||||
bodyString = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>Atom Search</title>
|
||||
<entry>
|
||||
<id>atom-book</id>
|
||||
<title>Atom Book</title>
|
||||
<link rel="enclosure" title="EPUB" type="application/epub+zip" href="downloads/book.epub" />
|
||||
<link rel="enclosure" title="MP3" type="audio/mpeg" href="downloads/audio.mp3" />
|
||||
</entry>
|
||||
</feed>
|
||||
""".trimIndent(),
|
||||
baseUrl = "https://example.org/feeds/atom/all"
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
OpdsAcquisition("https://example.org/feeds/atom/downloads/book.epub", "application/epub+zip"),
|
||||
feed.entries.single().acquisitions.single()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse OPDS 2 groups and fallback metadata produce navigation entries`() {
|
||||
val feed = SharedOpdsParser().parse(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aryan.reader.paginatedreader.CssStyle
|
||||
import com.aryan.reader.paginatedreader.SemanticParagraph
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
|
|
@ -81,6 +84,51 @@ class SharedEpubPaginationCacheTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chapter page cache round trips one measured chapter`() = runBlocking {
|
||||
val root = Files.createTempDirectory("reader-page-cache").toFile()
|
||||
try {
|
||||
val cache = SharedEpubPaginationCache(root)
|
||||
val book = cacheBook().copy(
|
||||
chapters = listOf(
|
||||
cacheBook().chapters.first(),
|
||||
cacheBook().chapters.first().copy(id = "chapter-2", title = "Two", plainText = "Second chapter.")
|
||||
)
|
||||
)
|
||||
val settings = ReaderSettings()
|
||||
val viewport = ReaderViewportSpec(widthPx = 960, heightPx = 720)
|
||||
val pages = listOf(
|
||||
ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "First cached page",
|
||||
startOffset = 0,
|
||||
endOffset = 17
|
||||
),
|
||||
ReaderPage(
|
||||
pageIndex = 1,
|
||||
chapterIndex = 1,
|
||||
chapterTitle = "Two",
|
||||
text = "Second cached page",
|
||||
startOffset = 0,
|
||||
endOffset = 18
|
||||
)
|
||||
)
|
||||
|
||||
cache.save(book, settings, viewport, pages)
|
||||
val loadedChapter = cache.loadChapter(book, settings, viewport, chapterIndex = 1)
|
||||
|
||||
assertNotNull(loadedChapter)
|
||||
assertEquals(1, loadedChapter.size)
|
||||
assertEquals(1, loadedChapter.first().pageIndex)
|
||||
assertEquals(1, loadedChapter.first().chapterIndex)
|
||||
assertEquals("Second cached page", loadedChapter.first().text)
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pagination cache key changes for spread mode`() {
|
||||
val root = Files.createTempDirectory("reader-page-cache").toFile()
|
||||
|
|
@ -97,6 +145,49 @@ class SharedEpubPaginationCacheTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `page cache ignores semanticless pages for semantic books`() = runBlocking {
|
||||
val root = Files.createTempDirectory("reader-page-cache").toFile()
|
||||
try {
|
||||
val cache = SharedEpubPaginationCache(root)
|
||||
val book = cacheBook().copy(
|
||||
chapters = listOf(
|
||||
cacheBook().chapters.first().copy(
|
||||
semanticBlocks = listOf(
|
||||
SemanticParagraph(
|
||||
text = "Cached page content.",
|
||||
spans = emptyList(),
|
||||
style = CssStyle(fontSize = 22.sp),
|
||||
elementId = null,
|
||||
cfi = null,
|
||||
startCharOffsetInSource = 0,
|
||||
blockIndex = 0
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val settings = ReaderSettings()
|
||||
val viewport = ReaderViewportSpec(widthPx = 960, heightPx = 720)
|
||||
val pages = listOf(
|
||||
ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "Cached page",
|
||||
startOffset = 0,
|
||||
endOffset = 11
|
||||
)
|
||||
)
|
||||
|
||||
cache.save(book, settings, viewport, pages)
|
||||
|
||||
assertNull(cache.load(book, settings, viewport))
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear all removes persisted and memory pagination pages`() = runBlocking {
|
||||
val root = Files.createTempDirectory("reader-page-cache").toFile()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aryan.reader.paginatedreader.CssStyle
|
||||
import com.aryan.reader.paginatedreader.SemanticParagraph
|
||||
import com.aryan.reader.shared.FileType
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
|
|
@ -32,6 +35,17 @@ class SharedJvmBookLoadCacheTest {
|
|||
title = "One",
|
||||
plainText = "Hello cache.",
|
||||
htmlContent = "<p>Hello cache.</p>",
|
||||
semanticBlocks = listOf(
|
||||
SemanticParagraph(
|
||||
text = "Hello cache.",
|
||||
spans = emptyList(),
|
||||
style = CssStyle(fontSize = 18.sp),
|
||||
elementId = null,
|
||||
cfi = null,
|
||||
startCharOffsetInSource = 0,
|
||||
blockIndex = 0
|
||||
)
|
||||
),
|
||||
baseHref = "one.xhtml"
|
||||
)
|
||||
)
|
||||
|
|
@ -49,6 +63,40 @@ class SharedJvmBookLoadCacheTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `book load cache rejects styled reader books without semantic blocks`() {
|
||||
val root = Files.createTempDirectory("reader-book-load-cache").toFile()
|
||||
try {
|
||||
val cache = SharedJvmBookLoadCache(root)
|
||||
val key = SharedJvmBookLoadCacheKey(
|
||||
canonicalPath = "C:/Books/book.epub",
|
||||
type = FileType.EPUB,
|
||||
length = 1234L,
|
||||
lastModified = 5678L
|
||||
)
|
||||
val book = SharedEpubBook(
|
||||
id = "C:/Books/book.epub",
|
||||
fileName = "book.epub",
|
||||
title = "Cached Book",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = "Hello cache.",
|
||||
htmlContent = "<p>Hello cache.</p>",
|
||||
baseHref = "one.xhtml"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
cache.save(key, book)
|
||||
|
||||
assertNull(cache.load(key))
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `book load cache misses when source fingerprint changes`() {
|
||||
val root = Files.createTempDirectory("reader-book-load-cache").toFile()
|
||||
|
|
|
|||
|
|
@ -171,49 +171,7 @@ class SharedJvmBookLoaderTest {
|
|||
@Test
|
||||
fun `epub loader keeps embedded images in semantic pagination blocks`() = withTempDir { dir ->
|
||||
val file = File(dir, "image-book.epub")
|
||||
writeZip(file) {
|
||||
text(
|
||||
"META-INF/container.xml",
|
||||
"""
|
||||
<container>
|
||||
<rootfiles>
|
||||
<rootfile full-path="OPS/content.opf"/>
|
||||
</rootfiles>
|
||||
</container>
|
||||
""".trimIndent()
|
||||
)
|
||||
text(
|
||||
"OPS/content.opf",
|
||||
"""
|
||||
<package>
|
||||
<metadata>
|
||||
<dc:title xmlns:dc="http://purl.org/dc/elements/1.1/">Image Book</dc:title>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="pixel" href="images/pixel.png" media-type="image/png"/>
|
||||
</manifest>
|
||||
<spine>
|
||||
<itemref idref="chapter"/>
|
||||
</spine>
|
||||
</package>
|
||||
""".trimIndent()
|
||||
)
|
||||
text(
|
||||
"OPS/chapter.xhtml",
|
||||
"""
|
||||
<html>
|
||||
<body>
|
||||
<h1>One</h1>
|
||||
<p>Before</p>
|
||||
<img src="images/pixel.png" alt="Pixel"/>
|
||||
<p>After</p>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
)
|
||||
bytes("OPS/images/pixel.png", onePixelPng)
|
||||
}
|
||||
writeImageEpub(file)
|
||||
|
||||
val book = SharedJvmBookLoader.loadEpub(file)
|
||||
val image = book.chapters.single().semanticBlocks.filterIsInstance<SemanticImage>().single()
|
||||
|
|
@ -222,6 +180,49 @@ class SharedJvmBookLoaderTest {
|
|||
assertEquals("Pixel", image.altText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `epub loader can skip semantic blocks for vertical fast path`() = withTempDir { dir ->
|
||||
val file = File(dir, "image-book.epub")
|
||||
writeImageEpub(file)
|
||||
|
||||
val book = SharedJvmBookLoader.loadEpub(file, parseSemanticBlocks = false)
|
||||
val chapter = book.chapters.single()
|
||||
|
||||
assertTrue(chapter.semanticBlocks.isEmpty())
|
||||
assertTrue(chapter.htmlContent.contains("data:image/png;base64,"))
|
||||
assertTrue(chapter.plainText.contains("Before"))
|
||||
assertTrue(chapter.plainText.contains("After"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `epub loader can prepare html for selected vertical chapters only`() = withTempDir { dir ->
|
||||
val file = File(dir, "two-chapters.epub")
|
||||
writeTwoChapterEpub(file)
|
||||
|
||||
val book = SharedJvmBookLoader.loadEpub(
|
||||
file = file,
|
||||
parseSemanticBlocks = false,
|
||||
preparedHtmlChapterRange = 1..1
|
||||
)
|
||||
|
||||
assertEquals(2, book.chapters.size)
|
||||
assertTrue(book.chapters[0].plainText.contains("First chapter text"))
|
||||
assertTrue(book.chapters[0].htmlContent.isBlank())
|
||||
assertTrue(book.chapters[1].plainText.contains("Second chapter text"))
|
||||
assertTrue(book.chapters[1].htmlContent.contains("Second chapter text"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `epub loader does not inline stylesheet font resources`() = withTempDir { dir ->
|
||||
val file = File(dir, "font-book.epub")
|
||||
writeImageEpub(file)
|
||||
|
||||
val css = SharedJvmBookLoader.loadEpub(file).css.values.single()
|
||||
|
||||
assertTrue(css.contains("fonts/reader.woff2"))
|
||||
assertTrue(!css.contains("data:font/woff2"))
|
||||
}
|
||||
|
||||
private fun withTempDir(block: (File) -> Unit) {
|
||||
val dir = Files.createTempDirectory("reader-shared-loader").toFile()
|
||||
try {
|
||||
|
|
@ -245,6 +246,110 @@ class SharedJvmBookLoaderTest {
|
|||
}
|
||||
}
|
||||
|
||||
private fun writeImageEpub(file: File) {
|
||||
writeZip(file) {
|
||||
text(
|
||||
"META-INF/container.xml",
|
||||
"""
|
||||
<container>
|
||||
<rootfiles>
|
||||
<rootfile full-path="OPS/content.opf"/>
|
||||
</rootfiles>
|
||||
</container>
|
||||
""".trimIndent()
|
||||
)
|
||||
text(
|
||||
"OPS/content.opf",
|
||||
"""
|
||||
<package>
|
||||
<metadata>
|
||||
<dc:title xmlns:dc="http://purl.org/dc/elements/1.1/">Image Book</dc:title>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="style" href="styles/book.css" media-type="text/css"/>
|
||||
<item id="font" href="styles/fonts/reader.woff2" media-type="font/woff2"/>
|
||||
<item id="pixel" href="images/pixel.png" media-type="image/png"/>
|
||||
</manifest>
|
||||
<spine>
|
||||
<itemref idref="chapter"/>
|
||||
</spine>
|
||||
</package>
|
||||
""".trimIndent()
|
||||
)
|
||||
text(
|
||||
"OPS/chapter.xhtml",
|
||||
"""
|
||||
<html>
|
||||
<body>
|
||||
<h1>One</h1>
|
||||
<p>Before</p>
|
||||
<img src="images/pixel.png" alt="Pixel"/>
|
||||
<p>After</p>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
)
|
||||
text(
|
||||
"OPS/styles/book.css",
|
||||
"""
|
||||
@font-face {
|
||||
font-family: "Fixture Serif";
|
||||
src: url("fonts/reader.woff2") format("woff2");
|
||||
}
|
||||
body { font-family: "Fixture Serif"; }
|
||||
""".trimIndent()
|
||||
)
|
||||
bytes("OPS/images/pixel.png", onePixelPng)
|
||||
bytes("OPS/styles/fonts/reader.woff2", byteArrayOf(0, 1, 2, 3))
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeTwoChapterEpub(file: File) {
|
||||
writeZip(file) {
|
||||
text(
|
||||
"META-INF/container.xml",
|
||||
"""
|
||||
<container>
|
||||
<rootfiles>
|
||||
<rootfile full-path="OPS/content.opf"/>
|
||||
</rootfiles>
|
||||
</container>
|
||||
""".trimIndent()
|
||||
)
|
||||
text(
|
||||
"OPS/content.opf",
|
||||
"""
|
||||
<package>
|
||||
<metadata>
|
||||
<dc:title xmlns:dc="http://purl.org/dc/elements/1.1/">Two Chapters</dc:title>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="first" href="first.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="second" href="second.xhtml" media-type="application/xhtml+xml"/>
|
||||
</manifest>
|
||||
<spine>
|
||||
<itemref idref="first"/>
|
||||
<itemref idref="second"/>
|
||||
</spine>
|
||||
</package>
|
||||
""".trimIndent()
|
||||
)
|
||||
text(
|
||||
"OPS/first.xhtml",
|
||||
"""
|
||||
<html><body><h1>First</h1><p>First chapter text.</p></body></html>
|
||||
""".trimIndent()
|
||||
)
|
||||
text(
|
||||
"OPS/second.xhtml",
|
||||
"""
|
||||
<html><body><h1>Second</h1><p>Second chapter text.</p></body></html>
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun minimalMobi(textRecord: ByteArray): ByteArray {
|
||||
val record0 = ByteArray(16)
|
||||
record0.writeU16(0, 1)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ class SharedMeasuredEpubPaginatorTest {
|
|||
settings = ReaderSettings(
|
||||
pageWidth = 760,
|
||||
margin = 48,
|
||||
readingMode = ReaderReadingMode.PAGINATED,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE
|
||||
),
|
||||
viewport = ReaderViewportSpec(widthPx = 2_400, heightPx = 1_200)
|
||||
|
|
@ -29,6 +30,51 @@ class SharedMeasuredEpubPaginatorTest {
|
|||
assertEquals(1_104, geometry.pageHeightPx)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two page geometry subtracts margins inside each rendered page on constrained viewports`() {
|
||||
val geometry = measuredPageGeometryFor(
|
||||
settings = ReaderSettings(
|
||||
pageWidth = 760,
|
||||
horizontalMargin = 80,
|
||||
verticalMargin = 40,
|
||||
readingMode = ReaderReadingMode.PAGINATED,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE
|
||||
),
|
||||
viewport = ReaderViewportSpec(widthPx = 1_300, heightPx = 900)
|
||||
)
|
||||
|
||||
assertEquals(476, geometry.pageWidthPx)
|
||||
assertEquals(820, geometry.pageHeightPx)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `paginated single page geometry matches one rendered page in a spread`() {
|
||||
val singlePageGeometry = measuredPageGeometryFor(
|
||||
settings = ReaderSettings(
|
||||
pageWidth = 760,
|
||||
horizontalMargin = 80,
|
||||
verticalMargin = 40,
|
||||
readingMode = ReaderReadingMode.PAGINATED,
|
||||
pageSpreadMode = ReaderPageSpreadMode.SINGLE
|
||||
),
|
||||
viewport = ReaderViewportSpec(widthPx = 1_300, heightPx = 900)
|
||||
)
|
||||
val twoPageGeometry = measuredPageGeometryFor(
|
||||
settings = ReaderSettings(
|
||||
pageWidth = 760,
|
||||
horizontalMargin = 80,
|
||||
verticalMargin = 40,
|
||||
readingMode = ReaderReadingMode.PAGINATED,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE
|
||||
),
|
||||
viewport = ReaderViewportSpec(widthPx = 1_300, heightPx = 900)
|
||||
)
|
||||
|
||||
assertEquals(twoPageGeometry, singlePageGeometry)
|
||||
assertEquals(476, singlePageGeometry.pageWidthPx)
|
||||
assertEquals(820, singlePageGeometry.pageHeightPx)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `geometry does not invent minimum page space beyond the rendered viewport`() {
|
||||
val geometry = measuredPageGeometryFor(
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue