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:
parent
c0d0e57e79
commit
b20ade9946
247 changed files with 43321 additions and 7087 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue