Desktop app (#308)

* Implement build profiles and feature policy for offline desktop builds

* Introduce unified cross-platform Settings Hub

* Refactor main settings into a hierarchical page-based navigation model

* Refactor library projection to use shared multiplatform logic

* Refactor UI state consumption by removing intermediate screen models

* Introduce AndroidSharedStateBridge to centralize state mapping and reduction logic

* Refactor state management for tabs, selection, and pinning to use shared bridge logic

* Refactor file type management and validation into a centralized shared module

* Centralize file type resolution and improve handling of unknown types

* Centralize book import logic with SharedImportPlanner

* Refactor magnifier geometry logic and coordinate mapping

* Properly handle orientation changes in scroll-locked PDF reader

* Add screen orientation controls to EPUB and PDF readers

* Implement right-to-left (RTL) pagination support and refactor reader menus

* Separate right-to-left pagination settings for PDF and EPUB

* Ensure PDF page data is scoped by document key for multi tab support

* Implement theme-aware link styling for the epub reader

* Implement jump history for back and forward navigation in the epub reader

* Improve locator handling and navigation logic in paginated reader mode

* Implement stable pagination navigation and location tracking

* Centralize banner message management and auto-dismiss logic in MainViewModel

* Implement zoom and pan state preservation for PDF pan lock mode

* Enhance reader navigation UI and workspace layout management in desktop app

* Refactor reader navigation sidebar and relocate search controls in desktop app

* Enhance reader UI with redesigned selection menus and bottom sheet overlays

* Implement custom highlight palettes and reader theme customization in desktop app

* Implement cross-platform modal layer and refine reader UI styling

* Improve highlight accuracy and implement metadata enrichment on book open in desktop app

* Implement two-page spread layout for paginated reader on desktop

* Implement persistent caching for book loading and pagination in desktop app

* Implement persistent caching for book loading and pagination in desktop app

* Optimize reader settings updates by separating layout and appearance changes in desktop app

* Improve desktop window branding and native Windows styling

* Enhance reader selection interactions and UI across EPUB and PDF viewers in desktop app

* Refine selection handle positioning and interaction logic

* Implement EPUB selection debug logging and improve handle targeting

* Optimize desktop book loading performance and UI responsiveness

* Implement anchored zoom gestures and rendering optimizations for the Desktop PDF viewer.

* Implement smooth zoom preview for the PDF reader in desktop app

* Optimize PDF rendering performance and responsiveness in the desktop reader

* Implement conditional diagnostic logging and update desktop build configuration

* Implemented hierarchical TOC, custom scrollbars, and improved desktop modal handling

* Added management options for annotations and highlights in the sidebar in desktop app

* Implemented `SharedStableOutlinedTextField` and updated text input fields to use `TextFieldValue` for improved cursor and selection stability.

* Refined library filters and enhanced OPDS functionality in desktop app

* Improved EPUB pagination measurement and implemented layout diagnostic logging for desktop app

* Added PPTX support including document parsing, rendering, and indexing

* Improved PPTX rendering and layout accuracy

* Implemented text autofit support for PPTX rendering

* Enhanced PPTX rendering with support for custom geometry, automatic numbering, table styles, and image opacity

* Improved EPUB pagination accuracy and added layout telemetry in desktop app

* Improved folder synchronization with metadata-only mode and hashed sidecar management in desktop app

* Implemented rich text font scaling and migrated desktop ink tools to custom pointer input handling

* Implemented billing account obfuscation

* Implemented hierarchical folder navigation and improved library selection functionality in desktop app

* Implemented platform-aware directory resolution and multi-platform native library support for desktop

* Added full-screen mode for the reader workspace

* Added PDF zoom indicator and interactive vertical scrollbar with page tooltips

* Refactored speech bubble prefetching to use a limited radius and improved ML detector initialization and lifecycle management

* Updated PDF indexing to replace existing page text and removed search result item keys

* Implemented "preparing" foreground notification for TTS service

* Optimized PDF rendering performance by pre-calculating page-specific annotations

* Refactored desktop packaging tasks and improved distribution configuration

* Optimized EPUB parser memory usage and added path traversal protection

* Refactored WorkManager monitoring logic and added work pruning

* Implemented comprehensive resource cleanup and memory management for WebView-based components to prevent memory leaks

* Implemented bitmap size limits and scaling to prevent canvas rendering errors

* Split long text paragraphs into multiple semantic blocks during HTML parsing

* Implemented local ActionMode for text selection to prevent platform crashes

* Refactored PPTX text layout, optimized HtmlParser block detection, and improved banner dismissal logic

* Added desktop startup splash screen and deferred WebView initialization

* Reorganized settings hub and added separate PDF reader defaults

* Implemented embedded cover extraction and metadata support for MOBI and FB2 formats

* Implemented batching for MetadataExtractionWorker and optimized EPUB metadata extraction performance.

* Implemented procedurally generated book covers and replaced static placeholders

* Redesigned search UI with a top bar and results overlay in desktop app

* Added PDF page gap and overlay visibility options and implemented DesktopBookImporter

* Refactored PDF reader UI with tabbed inspector and improved theme background handling in desktop

* Implemented PDF viewport persistence for zoom and scroll positions in desktop app

* Improved desktop fullscreen implementation and state restoration

* Implemented desktop window state persistence

* Implemented flavor-based branding and ProGuard configuration for desktop builds

* Implemented precise reader positioning and improved highlight rendering logic in desktop app

* Added support for user-editable book metadata

* Enhanced book metadata support and integrated info/edit dialogs

* Implemented embedded EPUB metadata editing

* Improved highlight mapping and added custom scrollbar styling for the reader.

* Reduced desktop WebView bundle size by excluding unused locales and runtime files

* Added neutral pan mode as the default PDF interaction state.

* Refactored library empty states and updated primary navigation tabs in desktop app

* Implemented native paginated reader and unified content rendering architecture in desktop epub reader

* Implemented native EPUB image rendering for desktop and improved block layout spacing with margin collapsing.

* Improved pagination overflow detection in desktop

* Implemented multi-block text selection with interactive handles and CFI support in desktop epub pagination
This commit is contained in:
Aryan 2026-05-15 22:36:51 +05:30 committed by GitHub
parent c0d0e57e79
commit b20ade9946
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
247 changed files with 43321 additions and 7087 deletions

View file

@ -56,6 +56,7 @@ import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -253,6 +254,8 @@ private fun headerFontScale(level: Int): Float = when (level) {
}
private const val WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER = 1.2f
private const val TAG_STABLE_PAGE_NAV = "StablePageNav"
private const val EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS = 10_000L
private fun paginationLineHeightMultiplierForWebViewSetting(multiplier: Float): Float {
return if (abs(multiplier - 1.0f) < 0.001f) WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER else multiplier
@ -410,6 +413,30 @@ internal object CfiUtils {
fun getPath(cfi: String): String = cfi.split(':').first()
fun getOffset(cfi: String): Int = cfi.substringAfter(':', "0").toIntOrNull() ?: 0
fun getOffsetOrNull(cfi: String): Int? = cfi.substringAfter(':', "").toIntOrNull()
fun isPathStrictlyBetween(candidate: String, start: String, end: String): Boolean {
val candidateParts = pathParts(candidate) ?: return false
val startParts = pathParts(start) ?: return false
val endParts = pathParts(end) ?: return false
return comparePathParts(candidateParts, startParts) > 0 &&
comparePathParts(candidateParts, endParts) < 0
}
private fun pathParts(cfi: String): List<Int>? {
val segments = getPath(cfi).split('/').filter { it.isNotEmpty() }
if (segments.isEmpty()) return null
return segments.map { it.toIntOrNull() ?: return null }
}
private fun comparePathParts(first: List<Int>, second: List<Int>): Int {
val length = minOf(first.size, second.size)
for (index in 0 until length) {
val cmp = first[index].compareTo(second[index])
if (cmp != 0) return cmp
}
return first.size.compareTo(second.size)
}
}
private fun highlightQueryInText(
@ -729,6 +756,7 @@ fun PaginatedReaderScreen(
effectiveText: Color,
pagerState: PagerState,
isPageTurnAnimationEnabled: Boolean,
isRightToLeftPagination: Boolean = false,
searchQuery: String,
fontSizeMultiplier: Float,
lineHeightMultiplier: Float,
@ -741,6 +769,9 @@ fun PaginatedReaderScreen(
ttsHighlightInfo: TtsHighlightInfo?,
initialChapterIndexInBook: Int?,
fallbackLocatorForReconfiguration: Locator? = null,
explicitNavigationAnchor: Locator? = null,
explicitNavigationEpoch: Long = 0L,
isExternalNavigationInProgress: Boolean = false,
onReconfigurationAnchorCaptured: (Locator) -> Unit = {},
onReconfigurationRestoreActiveChanged: (Boolean) -> Unit = {},
onPaginatorReady: (IPaginator) -> Unit,
@ -754,6 +785,7 @@ fun PaginatedReaderScreen(
onStartTtsFromSelection: (String, Int) -> Unit,
onNoteRequested: (String?) -> Unit,
onFootnoteRequested: (String) -> Unit,
onInternalLinkNavigated: (Int) -> Unit = {},
userHighlights: List<UserHighlight>,
onHighlightCreated: (String, String, String) -> Unit,
onHighlightDeleted: (String) -> Unit,
@ -784,6 +816,11 @@ fun PaginatedReaderScreen(
} else Modifier
var isNavigatingByLink by remember { mutableStateOf(false) }
var localExplicitNavigationAnchor by remember { mutableStateOf<Locator?>(null) }
var localExplicitNavigationEpoch by remember { mutableLongStateOf(0L) }
val latestExternalNavigationAnchor by rememberUpdatedState(explicitNavigationAnchor)
val latestExternalNavigationEpoch by rememberUpdatedState(explicitNavigationEpoch)
val latestIsExternalNavigationInProgress by rememberUpdatedState(isExternalNavigationInProgress)
BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg)) {
val textMeasurer = rememberTextMeasurer()
@ -1107,19 +1144,96 @@ fun PaginatedReaderScreen(
LaunchedEffect(paginator, pagerState) {
paginator.pageShiftRequest.collect { shiftAmount ->
val anchor = resolvePaginatedReconfigurationAnchor(
currentPageLocator = anchorLocatorForReconfig,
fallbackLocator = latestFallbackLocatorForReconfiguration
if (pagerState.pageCount <= 0) {
Timber.tag(TAG_STABLE_PAGE_NAV)
.w("shift_drop reason=emptyPager shift=$shiftAmount")
return@collect
}
val bookPaginator = paginator as? BookPaginator
val currentPageBeforeShift = pagerState.currentPage
val now = System.currentTimeMillis()
val externalAgeMs = if (latestExternalNavigationEpoch > 0L) {
now - latestExternalNavigationEpoch
} else {
-1L
}
val localAgeMs = if (localExplicitNavigationEpoch > 0L) {
now - localExplicitNavigationEpoch
} else {
-1L
}
val recentExternalNavigation =
externalAgeMs in 0L..EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS
val recentLocalNavigation =
localAgeMs in 0L..EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS
val activeExplicitAnchor = when {
latestIsExternalNavigationInProgress -> latestExternalNavigationAnchor
isNavigatingByLink -> localExplicitNavigationAnchor
else -> null
}
val recentExplicitAnchor = when {
recentExternalNavigation -> latestExternalNavigationAnchor
recentLocalNavigation -> localExplicitNavigationAnchor
else -> null
}
val activeExplicitAnchorSource = when {
activeExplicitAnchor == null -> null
latestIsExternalNavigationInProgress -> "explicit_external_active"
else -> "explicit_link"
}
val recentExplicitAnchorSource = when {
recentExplicitAnchor == null -> null
recentExternalNavigation -> "explicit_external_recent"
else -> "explicit_link_recent"
}
val currentPageLocator = bookPaginator?.getLocatorForPage(currentPageBeforeShift)
val fallbackLocator = latestFallbackLocatorForReconfiguration
var anchorSource = "none"
val anchor = when {
anchorLocatorForReconfig != null -> {
anchorSource = "reconfiguration"
anchorLocatorForReconfig
}
activeExplicitAnchor != null -> {
anchorSource = activeExplicitAnchorSource ?: "explicit_active"
activeExplicitAnchor
}
fallbackLocator != null -> {
anchorSource = "last_known"
fallbackLocator
}
recentExplicitAnchor != null -> {
anchorSource = recentExplicitAnchorSource ?: "explicit_recent"
recentExplicitAnchor
}
currentPageLocator != null -> {
anchorSource = "current_page"
currentPageLocator
}
else -> null
}
Timber.tag(TAG_STABLE_PAGE_NAV).d(
"shift_received shift=$shiftAmount currentPage=$currentPageBeforeShift anchorSource=$anchorSource anchor=$anchor currentLocator=$currentPageLocator fallback=$fallbackLocator externalInProgress=$latestIsExternalNavigationInProgress linkInProgress=$isNavigatingByLink externalAgeMs=$externalAgeMs localAgeMs=$localAgeMs"
)
val resolvedPage = anchor?.let { locator ->
(paginator as? BookPaginator)?.findPageForLocator(locator)
bookPaginator?.findStablePageForLocator(locator)
}
if (resolvedPage != null) {
Timber.tag(TAG_STABLE_PAGE_NAV).d(
"shift_apply_stable shift=$shiftAmount from=$currentPageBeforeShift to=$resolvedPage anchorSource=$anchorSource anchor=$anchor"
)
pagerState.scrollToPage(resolvedPage)
paginator.onUserScrolledTo(resolvedPage)
} else {
val newPage = pagerState.currentPage + shiftAmount
val maxPage = (pagerState.pageCount - 1).coerceAtLeast(0)
val newPage = (currentPageBeforeShift + shiftAmount).coerceIn(0, maxPage)
Timber.tag(TAG_STABLE_PAGE_NAV).w(
"shift_apply_relative shift=$shiftAmount from=$currentPageBeforeShift to=$newPage anchorSource=$anchorSource anchor=$anchor"
)
pagerState.scrollToPage(newPage)
paginator.onUserScrolledTo(newPage)
}
@ -1134,6 +1248,7 @@ fun PaginatedReaderScreen(
uiState = uiState,
pagerState = pagerState,
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
isRightToLeftPagination = isRightToLeftPagination,
effectiveBg = effectiveBg,
searchQuery = searchQuery,
ttsHighlightInfo = ttsHighlightInfo,
@ -1163,100 +1278,126 @@ fun PaginatedReaderScreen(
}
}
},
onInternalLinkNavigated = onInternalLinkNavigated,
onLinkClick = { currentChapterPath, href, onNavComplete ->
coroutineScope.launch(Dispatchers.IO) {
isNavigatingByLink = true
var isFootnote = false
var footnoteHtml: String? = null
withContext(Dispatchers.Main) { isNavigatingByLink = true }
try {
var isFootnote = false
var footnoteHtml: String? = null
val sourceChapter =
book.chaptersForPagination.find { it.absPath == currentChapterPath }
if (sourceChapter != null) {
val sourceHtml = sourceChapter.htmlContent.ifEmpty {
try {
File(book.extractionBasePath, sourceChapter.htmlFilePath)
.readText()
} catch (_: Exception) {
""
}
}
if (sourceHtml.isNotEmpty()) {
val doc = Jsoup.parse(sourceHtml)
val safeHref = href.replace("\"", "\\\"")
val aTag = doc.select("a[href=\"$safeHref\"]").first()
if (aTag?.attr("epub:type") == "noteref" || href.startsWith("#")) {
isFootnote = true
}
} else if (href.startsWith("#")) {
isFootnote = true
}
} else if (href.startsWith("#")) {
isFootnote = true
}
if (isFootnote) {
val decodedHref = try {
URLDecoder.decode(href, "UTF-8")
} catch (_: Exception) {
href
}
val parts = decodedHref.split('#', limit = 2)
val pathPart = parts[0]
val anchor = if (parts.size > 1) parts[1] else null
if (anchor != null) {
val targetPath = if (pathPart.isBlank()) currentChapterPath else {
val sourceChapter =
book.chaptersForPagination.find { it.absPath == currentChapterPath }
if (sourceChapter != null) {
val sourceHtml = sourceChapter.htmlContent.ifEmpty {
try {
URI(currentChapterPath).resolve(pathPart)
.normalize().path
File(book.extractionBasePath, sourceChapter.htmlFilePath)
.readText()
} catch (_: Exception) {
null
""
}
}
if (sourceHtml.isNotEmpty()) {
val doc = Jsoup.parse(sourceHtml)
val safeHref = href.replace("\"", "\\\"")
val aTag = doc.select("a[href=\"$safeHref\"]").first()
if (targetPath != null) {
val targetChapter = book.chaptersForPagination.find {
val linkType = aTag?.attr("epub:type").orEmpty()
val linkRole = aTag?.attr("role").orEmpty()
if (
linkType.contains("noteref", ignoreCase = true) ||
linkRole.contains("doc-noteref", ignoreCase = true)
) {
isFootnote = true
}
}
}
run {
val decodedHref = try {
URLDecoder.decode(href, "UTF-8")
} catch (_: Exception) {
href
}
val parts = decodedHref.split('#', limit = 2)
val pathPart = parts[0]
val anchor = if (parts.size > 1) parts[1] else null
if (anchor != null) {
val targetPath = if (pathPart.isBlank()) currentChapterPath else {
try {
URI(it.absPath).normalize().path == targetPath
URI(currentChapterPath).resolve(pathPart)
.normalize().path
} catch (_: Exception) {
false
null
}
}
if (targetChapter != null) {
val targetHtml = targetChapter.htmlContent.ifEmpty {
if (targetPath != null) {
val targetChapter = book.chaptersForPagination.find {
try {
File(
book.extractionBasePath,
targetChapter.htmlFilePath
).readText()
URI(it.absPath).normalize().path == targetPath
} catch (_: Exception) {
""
false
}
}
if (targetHtml.isNotEmpty()) {
val doc = Jsoup.parse(targetHtml)
val noteEl = doc.getElementById(anchor)
if (noteEl != null) {
footnoteHtml = noteEl.html()
if (targetChapter != null) {
val targetHtml = targetChapter.htmlContent.ifEmpty {
try {
File(
book.extractionBasePath,
targetChapter.htmlFilePath
).readText()
} catch (_: Exception) {
""
}
}
if (targetHtml.isNotEmpty()) {
val doc = Jsoup.parse(targetHtml)
val noteEl = doc.getElementById(anchor)
if (noteEl != null) {
val targetType = noteEl.attr("epub:type")
val targetRole = noteEl.attr("role")
val targetClass = noteEl.className()
val targetLooksLikeFootnote =
targetType.contains("footnote", ignoreCase = true) ||
targetRole.contains("doc-footnote", ignoreCase = true) ||
targetClass.contains("footnote", ignoreCase = true)
if (isFootnote || targetLooksLikeFootnote) {
footnoteHtml = noteEl.html()
}
}
}
}
}
}
}
}
withContext(Dispatchers.Main) {
if (!footnoteHtml.isNullOrBlank()) {
onFootnoteRequested(footnoteHtml)
isNavigatingByLink = false
withContext(Dispatchers.Main) { onFootnoteRequested(footnoteHtml) }
} else {
paginator.navigateToHref(currentChapterPath, href) {
onNavComplete(it)
isNavigatingByLink = false
val targetPage = (paginator as? BookPaginator)?.findStablePageForHref(currentChapterPath, href)
withContext(Dispatchers.Main) {
if (targetPage != null) {
val targetAnchor = (paginator as? BookPaginator)?.getLocatorForPage(targetPage)
val navigationEpoch = System.currentTimeMillis()
localExplicitNavigationAnchor = targetAnchor
localExplicitNavigationEpoch = navigationEpoch
Timber.tag(TAG_STABLE_PAGE_NAV).d(
"link_resolved href=$href targetPage=$targetPage anchor=$targetAnchor epoch=$navigationEpoch"
)
paginator.onUserScrolledTo(targetPage)
onNavComplete(targetPage)
} else {
Timber.tag(TAG_STABLE_PAGE_NAV).w(
"link_failed href=$href currentChapterPath=$currentChapterPath"
)
}
}
}
} finally {
withContext(Dispatchers.Main) { isNavigatingByLink = false }
}
}
},
@ -1359,7 +1500,7 @@ private fun findFuzzyMatch(source: String, target: String, ignoreCase: Boolean =
return null
}
private fun getHighlightOffsetsInBlock(
internal fun getHighlightOffsetsInBlock(
block: TextContentBlock, highlight: UserHighlight
): IntRange? {
if (block.cfi == null) return null
@ -1368,6 +1509,7 @@ private fun getHighlightOffsetsInBlock(
val parts = highlight.cfi.split('|')
val startCfi = parts.firstOrNull() ?: highlight.cfi
val endCfi = parts.lastOrNull()
val isMultipartHighlight = endCfi != null && endCfi != startCfi
@Suppress("REDUNDANT_ELSE_IN_WHEN") val blockStartAbs = when (block) {
is ParagraphBlock -> block.startCharOffsetInSource
@ -1376,6 +1518,9 @@ private fun getHighlightOffsetsInBlock(
is ListItemBlock -> block.startCharOffsetInSource
else -> 0
}
val blockEndAbs = block.endCharOffsetInSource
.takeIf { it > blockStartAbs }
?: (blockStartAbs + block.content.text.length)
Timber.d(
"getHighlightOffsetsInBlock: Checking Block=${block.cfi} (AbsStart=$blockStartAbs) against Highlight=${highlight.cfi}"
@ -1419,48 +1564,28 @@ private fun getHighlightOffsetsInBlock(
)
}
var isAfterStart = false
var isBeforeEnd = true
if (relevantPart == null) {
if (startCfi.isNotEmpty()) {
try {
if (CfiUtils.compare(block.cfi!!, startCfi) > 0) {
isAfterStart = true
}
} catch (_: Exception) {
}
}
if (endCfi != null && endCfi != startCfi) {
try {
val endPath = CfiUtils.getPath(endCfi)
val cmp = CfiUtils.compare(blockPath, endPath)
Timber.d(" -> Comparing BlockPath ($blockPath) vs EndPath ($endPath). Result: $cmp")
if (CfiUtils.compare(blockPath, endPath) > 0) {
isBeforeEnd = false
}
} catch (_: Exception) {
}
}
}
Timber.d(" -> relevantPart=$relevantPart, isAfterStart=$isAfterStart, isBeforeEnd=$isBeforeEnd")
if (relevantPart == null && (!isAfterStart || !isBeforeEnd)) {
return null
}
val blockText = block.content.text
val highlightText = highlight.text
if (blockText.isEmpty() || highlightText.isEmpty()) return null
if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length
if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length
var startIndex = blockText.indexOf(highlightText, ignoreCase = false)
if (startIndex == -1) {
startIndex = blockText.indexOf(highlightText, ignoreCase = true)
val isIntermediateBlock = relevantPart == null &&
isMultipartHighlight &&
CfiUtils.isPathStrictlyBetween(block.cfi!!, startCfi, endCfi!!)
Timber.d(" -> relevantPart=$relevantPart, isIntermediateBlock=$isIntermediateBlock")
if (relevantPart == null) {
if (!isIntermediateBlock) return null
if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length
if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length
val normBlock = blockText.filter { !it.isWhitespace() }
val normHighlight = highlightText.filter { !it.isWhitespace() }
return if (normBlock.isNotBlank() && normHighlight.contains(normBlock, ignoreCase = true)) {
0 until blockText.length
} else {
null
}
}
if (relevantPart != null) {
@ -1482,11 +1607,27 @@ private fun getHighlightOffsetsInBlock(
Timber.d(" -> Path Equivalence: StartMatches=$startMatches, EndMatches=$endMatches")
if (startMatches || endMatches) {
val startAbs = CfiUtils.getOffsetOrNull(startCfi)
val endAbs = endCfi?.let { CfiUtils.getOffsetOrNull(it) }
if (startMatches && endMatches && startAbs != null && endAbs != null) {
val rangeStartAbs = minOf(startAbs, endAbs)
val rangeEndAbs = maxOf(startAbs, endAbs)
if (rangeEndAbs <= blockStartAbs || rangeStartAbs >= blockEndAbs) {
Timber.d(
" -> Skipping same-path split block outside highlight offsets. " +
"highlight=$rangeStartAbs..$rangeEndAbs block=$blockStartAbs..$blockEndAbs"
)
return null
}
} else {
if (startMatches && startAbs != null && startAbs >= blockEndAbs) return null
if (endMatches && endAbs != null && endAbs <= blockStartAbs) return null
}
var s = 0
var e = blockText.length
if (startMatches) {
val absOffset = CfiUtils.getOffset(startCfi)
val absOffset = startAbs ?: CfiUtils.getOffset(startCfi)
val relOffset = absOffset - blockStartAbs
if (relOffset < 0) {
@ -1530,7 +1671,7 @@ private fun getHighlightOffsetsInBlock(
}
if (endMatches) {
val absOffset = CfiUtils.getOffset(endCfi!!)
val absOffset = endAbs ?: CfiUtils.getOffset(endCfi!!)
val relOffset = absOffset - blockStartAbs
Timber.d(
@ -1559,18 +1700,16 @@ private fun getHighlightOffsetsInBlock(
}
}
if (startIndex >= 0) {
return startIndex until (startIndex + highlightText.length)
if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length
if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length
var startIndex = blockText.indexOf(highlightText, ignoreCase = false)
if (startIndex == -1) {
startIndex = blockText.indexOf(highlightText, ignoreCase = true)
}
if (relevantPart == null) {
@Suppress("KotlinConstantConditions") if (isAfterStart) {
val normBlock = blockText.filter { !it.isWhitespace() }
val normHighlight = highlightText.filter { !it.isWhitespace() }
if (normHighlight.contains(normBlock, ignoreCase = true)) {
return 0 until blockText.length
}
}
if (startIndex >= 0) {
return startIndex until (startIndex + highlightText.length)
}
val match = findFuzzyMatch(blockText, highlightText)
@ -2073,6 +2212,7 @@ internal fun PaginatedReaderContent(
uiState: PaginatedReaderUiState,
pagerState: PagerState,
isPageTurnAnimationEnabled: Boolean,
isRightToLeftPagination: Boolean = false,
effectiveBg: Color,
effectiveText: Color,
searchQuery: String,
@ -2084,6 +2224,7 @@ internal fun PaginatedReaderContent(
onGetPage: (Int) -> Page?,
onGetChapterPath: (Int) -> String?,
onLinkClick: (currentChapterPath: String, href: String, onNavComplete: (Int) -> Unit) -> Unit,
onInternalLinkNavigated: (Int) -> Unit,
onTap: (Offset?) -> Unit,
isProUser: Boolean,
isOss: Boolean,
@ -2231,7 +2372,8 @@ internal fun PaginatedReaderContent(
}
}
},
beyondViewportPageCount = 1
beyondViewportPageCount = 1,
reverseLayout = isRightToLeftPagination
) { pageIndex ->
val pageOffset =
(pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
@ -2432,7 +2574,11 @@ internal fun PaginatedReaderContent(
} else {
currentChapterPath?.let { path ->
onLinkClick(path, href) { targetPageIndex ->
onInternalLinkNavigated(targetPageIndex)
coroutineScope.launch {
Timber.tag(TAG_STABLE_PAGE_NAV).d(
"link_scroll targetPage=$targetPageIndex currentPage=${pagerState.currentPage}"
)
pagerState.scrollToPage(targetPageIndex)
}
}