* 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
4937 lines
271 KiB
Kotlin
4937 lines
271 KiB
Kotlin
// PaginatedReader.kt
|
|
@file:Suppress("VariableNeverRead")
|
|
|
|
package com.aryan.reader.paginatedreader
|
|
|
|
import android.annotation.SuppressLint
|
|
import android.content.ActivityNotFoundException
|
|
import android.content.ClipData
|
|
import android.content.ClipboardManager
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.os.Build
|
|
import android.widget.Toast
|
|
import androidx.compose.ui.unit.isSpecified
|
|
import androidx.annotation.RequiresApi
|
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
import androidx.compose.foundation.background
|
|
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.detectTapGestures
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.aspectRatio
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.IntrinsicSize
|
|
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.lazy.grid.GridCells
|
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|
import androidx.compose.foundation.lazy.grid.items
|
|
import androidx.compose.foundation.magnifier
|
|
import androidx.compose.foundation.pager.HorizontalPager
|
|
import androidx.compose.foundation.pager.PagerState
|
|
import androidx.compose.foundation.shape.CircleShape
|
|
import androidx.compose.material3.AlertDialog
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
import androidx.compose.material3.Icon
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.DisposableEffect
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.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
|
|
import androidx.compose.runtime.rememberCoroutineScope
|
|
import androidx.compose.runtime.rememberUpdatedState
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.runtime.snapshotFlow
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.composed
|
|
import androidx.compose.ui.draw.drawBehind
|
|
import androidx.compose.ui.draw.drawWithContent
|
|
import androidx.compose.ui.geometry.CornerRadius
|
|
import androidx.compose.ui.geometry.Offset
|
|
import androidx.compose.ui.geometry.Rect
|
|
import androidx.compose.ui.geometry.RoundRect
|
|
import androidx.compose.ui.geometry.Size
|
|
import androidx.compose.ui.geometry.isSpecified
|
|
import androidx.compose.ui.geometry.toRect
|
|
import androidx.compose.ui.graphics.BlendMode
|
|
import androidx.compose.ui.graphics.Brush
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.graphics.ColorFilter
|
|
import androidx.compose.ui.graphics.ColorMatrix
|
|
import androidx.compose.ui.graphics.ImageBitmap
|
|
import androidx.compose.ui.graphics.ImageShader
|
|
import androidx.compose.ui.graphics.Path
|
|
import androidx.compose.ui.graphics.PathEffect
|
|
import androidx.compose.ui.graphics.RectangleShape
|
|
import androidx.compose.ui.graphics.ShaderBrush
|
|
import androidx.compose.ui.graphics.StrokeCap
|
|
import androidx.compose.ui.graphics.StrokeJoin
|
|
import androidx.compose.ui.graphics.TileMode
|
|
import androidx.compose.ui.graphics.TransformOrigin
|
|
import androidx.compose.ui.graphics.drawscope.Fill
|
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
|
import androidx.compose.ui.graphics.drawscope.clipPath
|
|
import androidx.compose.ui.graphics.drawscope.clipRect
|
|
import androidx.compose.ui.graphics.graphicsLayer
|
|
import androidx.compose.ui.graphics.isSpecified
|
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
|
import androidx.compose.ui.input.pointer.pointerInput
|
|
import androidx.compose.ui.layout.ContentScale
|
|
import androidx.compose.ui.layout.Layout
|
|
import androidx.compose.ui.layout.LayoutCoordinates
|
|
import androidx.compose.ui.layout.onGloballyPositioned
|
|
import androidx.compose.ui.layout.positionInWindow
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.platform.LocalDensity
|
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
|
import androidx.compose.ui.platform.LocalViewConfiguration
|
|
import androidx.compose.ui.res.imageResource
|
|
import androidx.compose.ui.res.painterResource
|
|
import androidx.compose.ui.res.stringResource
|
|
import androidx.compose.ui.text.AnnotatedString
|
|
import androidx.compose.ui.text.PlatformTextStyle
|
|
import androidx.compose.ui.text.SpanStyle
|
|
import androidx.compose.ui.text.TextLayoutResult
|
|
import androidx.compose.ui.text.TextMeasurer
|
|
import androidx.compose.ui.text.TextStyle
|
|
import androidx.compose.ui.text.buildAnnotatedString
|
|
import androidx.compose.ui.text.drawText
|
|
import androidx.compose.ui.text.font.FontFamily
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.text.rememberTextMeasurer
|
|
import androidx.compose.ui.text.style.LineBreak
|
|
import androidx.compose.ui.text.style.LineHeightStyle
|
|
import androidx.compose.ui.text.style.TextAlign
|
|
import androidx.compose.ui.text.style.TextIndent
|
|
import androidx.compose.ui.unit.Constraints
|
|
import androidx.compose.ui.unit.Density
|
|
import androidx.compose.ui.unit.Dp
|
|
import androidx.compose.ui.unit.DpSize
|
|
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.TextUnit
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
import androidx.compose.ui.unit.toSize
|
|
import androidx.compose.ui.window.Popup
|
|
import androidx.compose.ui.window.PopupPositionProvider
|
|
import androidx.compose.ui.window.PopupProperties
|
|
import androidx.compose.ui.zIndex
|
|
import androidx.core.net.toUri
|
|
import coil.ImageLoader
|
|
import coil.compose.AsyncImage
|
|
import coil.imageLoader
|
|
import coil.request.ImageRequest.Builder
|
|
import com.aryan.reader.R
|
|
import com.aryan.reader.loadReaderTextureBitmap
|
|
import com.aryan.reader.countWords
|
|
import com.aryan.reader.epub.EpubBook
|
|
import com.aryan.reader.epubreader.HighlightColor
|
|
import com.aryan.reader.epubreader.PaginatedTextSelectionMenu
|
|
import com.aryan.reader.epubreader.PaletteManagerDialog
|
|
import com.aryan.reader.epubreader.ReaderTextAlign
|
|
import com.aryan.reader.epubreader.TtsHighlightInfo
|
|
import com.aryan.reader.epubreader.UserHighlight
|
|
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.FlowPreview
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.flow.collectLatest
|
|
import kotlinx.coroutines.flow.debounce
|
|
import kotlinx.coroutines.flow.filter
|
|
import kotlinx.coroutines.flow.first
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
import kotlinx.serialization.ExperimentalSerializationApi
|
|
import kotlinx.serialization.protobuf.ProtoBuf
|
|
import org.jsoup.Jsoup
|
|
import timber.log.Timber
|
|
import java.io.File
|
|
import java.net.URI
|
|
import java.net.URLDecoder
|
|
import kotlin.math.abs
|
|
import kotlin.math.roundToInt
|
|
import kotlin.math.sqrt
|
|
|
|
data class PaginatedSelection(
|
|
val startBlockIndex: Int,
|
|
val endBlockIndex: Int,
|
|
val startBaseCfi: String,
|
|
val endBaseCfi: String,
|
|
val startOffset: Int,
|
|
val endOffset: Int,
|
|
val text: String,
|
|
val rect: Rect,
|
|
val startPageIndex: Int,
|
|
val endPageIndex: Int,
|
|
val startBlockCharOffset: Int = 0,
|
|
val endBlockCharOffset: Int = 0,
|
|
val textPerBlock: Map<String, String> = emptyMap()
|
|
)
|
|
|
|
private data class SelectionBlockKey(
|
|
val pageIndex: Int,
|
|
val blockIndex: Int,
|
|
val blockCharOffset: Int
|
|
)
|
|
|
|
private fun buildSelectionBlockKey(
|
|
pageIndex: Int,
|
|
blockIndex: Int,
|
|
blockCharOffset: Int
|
|
): String = "${pageIndex}_${blockIndex}_${blockCharOffset}"
|
|
|
|
private fun parseSelectionBlockKey(key: String): SelectionBlockKey? {
|
|
val parts = key.split("_")
|
|
if (parts.size != 3) return null
|
|
return SelectionBlockKey(
|
|
pageIndex = parts[0].toIntOrNull() ?: return null,
|
|
blockIndex = parts[1].toIntOrNull() ?: return null,
|
|
blockCharOffset = parts[2].toIntOrNull() ?: return null
|
|
)
|
|
}
|
|
|
|
private fun compareSelectionBlockKeys(
|
|
firstKey: String,
|
|
secondKey: String
|
|
): Int {
|
|
val first = parseSelectionBlockKey(firstKey)
|
|
val second = parseSelectionBlockKey(secondKey)
|
|
|
|
if (first == null && second == null) return firstKey.compareTo(secondKey)
|
|
if (first == null) return 1
|
|
if (second == null) return -1
|
|
|
|
return compareValuesBy(
|
|
first,
|
|
second,
|
|
SelectionBlockKey::pageIndex,
|
|
SelectionBlockKey::blockIndex,
|
|
SelectionBlockKey::blockCharOffset
|
|
)
|
|
}
|
|
|
|
private fun getTextBlockCharOffset(block: TextContentBlock): Int = when (block) {
|
|
is ParagraphBlock -> block.startCharOffsetInSource
|
|
is HeaderBlock -> block.startCharOffsetInSource
|
|
is QuoteBlock -> block.startCharOffsetInSource
|
|
is ListItemBlock -> block.startCharOffsetInSource
|
|
}
|
|
|
|
private fun headerFontScale(level: Int): Float = when (level) {
|
|
1 -> 1.5f
|
|
2 -> 1.4f
|
|
3 -> 1.3f
|
|
4 -> 1.2f
|
|
5 -> 1.1f
|
|
else -> 1.0f
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
private fun createHeaderTextStyle(
|
|
baseStyle: TextStyle,
|
|
level: Int,
|
|
textAlign: TextAlign?
|
|
): TextStyle {
|
|
val scale = headerFontScale(level)
|
|
val scaledFontSize = baseStyle.fontSize * scale
|
|
val scaledLineHeight = if (baseStyle.lineHeight != TextUnit.Unspecified) {
|
|
baseStyle.lineHeight * scale
|
|
} else {
|
|
scaledFontSize * 1.2f
|
|
}
|
|
|
|
return baseStyle.copy(
|
|
fontWeight = FontWeight.Bold,
|
|
fontSize = scaledFontSize,
|
|
lineHeight = scaledLineHeight,
|
|
textAlign = textAlign ?: baseStyle.textAlign
|
|
)
|
|
}
|
|
|
|
private fun compareBlockPositionsOnPage(
|
|
firstBlockIndex: Int,
|
|
firstBlockCharOffset: Int,
|
|
secondBlockIndex: Int,
|
|
secondBlockCharOffset: Int
|
|
): Int = when {
|
|
firstBlockIndex != secondBlockIndex -> firstBlockIndex.compareTo(secondBlockIndex)
|
|
else -> firstBlockCharOffset.compareTo(secondBlockCharOffset)
|
|
}
|
|
|
|
private fun isBlockSelectedOnPage(
|
|
block: TextContentBlock,
|
|
pageIndex: Int,
|
|
selection: PaginatedSelection
|
|
): Boolean {
|
|
if (pageIndex < selection.startPageIndex || pageIndex > selection.endPageIndex) return false
|
|
if (pageIndex > selection.startPageIndex && pageIndex < selection.endPageIndex) return true
|
|
|
|
val blockCharOffset = getTextBlockCharOffset(block)
|
|
val afterStart = if (pageIndex == selection.startPageIndex) {
|
|
compareBlockPositionsOnPage(
|
|
block.blockIndex,
|
|
blockCharOffset,
|
|
selection.startBlockIndex,
|
|
selection.startBlockCharOffset
|
|
) >= 0
|
|
} else {
|
|
true
|
|
}
|
|
val beforeEnd = if (pageIndex == selection.endPageIndex) {
|
|
compareBlockPositionsOnPage(
|
|
block.blockIndex,
|
|
blockCharOffset,
|
|
selection.endBlockIndex,
|
|
selection.endBlockCharOffset
|
|
) <= 0
|
|
} else {
|
|
true
|
|
}
|
|
|
|
return afterStart && beforeEnd
|
|
}
|
|
|
|
class ReactiveBlockMap(
|
|
private val delegate: MutableMap<String, Triple<TextLayoutResult, LayoutCoordinates, TextContentBlock>> = mutableStateMapOf()
|
|
) : MutableMap<String, Triple<TextLayoutResult, LayoutCoordinates, TextContentBlock>> by delegate {
|
|
var tick by mutableIntStateOf(0)
|
|
|
|
override fun put(key: String, value: Triple<TextLayoutResult, LayoutCoordinates, TextContentBlock>): Triple<TextLayoutResult, LayoutCoordinates, TextContentBlock>? {
|
|
tick++
|
|
return delegate.put(key, value)
|
|
}
|
|
|
|
override fun remove(key: String): Triple<TextLayoutResult, LayoutCoordinates, TextContentBlock>? {
|
|
tick++
|
|
return delegate.remove(key)
|
|
}
|
|
|
|
override fun clear() {
|
|
tick++
|
|
delegate.clear()
|
|
}
|
|
}
|
|
|
|
data class PendingCrossPageSelection(val fromPageIndex: Int)
|
|
|
|
enum class SelectionHandle { START, END }
|
|
|
|
private class SmartPopupPositionProvider(
|
|
private val contentRect: Rect, private val density: Density
|
|
) : PopupPositionProvider {
|
|
override fun calculatePosition(
|
|
anchorBounds: IntRect,
|
|
windowSize: IntSize,
|
|
layoutDirection: LayoutDirection,
|
|
popupContentSize: IntSize
|
|
): IntOffset {
|
|
val padding = with(density) { 8.dp.roundToPx() }
|
|
val popupWidth = popupContentSize.width
|
|
val popupHeight = popupContentSize.height
|
|
|
|
var x = (contentRect.center.x - popupWidth / 2).toInt()
|
|
x = x.coerceIn(0, windowSize.width - popupWidth)
|
|
|
|
val topY = (contentRect.top - popupHeight - padding).toInt()
|
|
val bottomY = (contentRect.bottom + padding).toInt()
|
|
|
|
var y = topY
|
|
if (y < 0) {
|
|
y = if (bottomY + popupHeight <= windowSize.height) {
|
|
bottomY
|
|
} else {
|
|
val spaceTop = contentRect.top
|
|
val spaceBottom = windowSize.height - contentRect.bottom
|
|
if (spaceBottom > spaceTop) {
|
|
bottomY.coerceAtMost(windowSize.height - popupHeight)
|
|
} else {
|
|
topY.coerceAtLeast(0)
|
|
}
|
|
}
|
|
}
|
|
|
|
return IntOffset(x, y)
|
|
}
|
|
}
|
|
|
|
internal object CfiUtils {
|
|
fun compare(cfi1: String, cfi2: String): Int {
|
|
val path1 = cfi1.split(':').first()
|
|
val path2 = cfi2.split(':').first()
|
|
|
|
val parts1 = path1.split('/').filter { it.isNotEmpty() }.mapNotNull { it.toIntOrNull() }
|
|
val parts2 = path2.split('/').filter { it.isNotEmpty() }.mapNotNull { it.toIntOrNull() }
|
|
|
|
val length = minOf(parts1.size, parts2.size)
|
|
for (i in 0 until length) {
|
|
val cmp = parts1[i].compareTo(parts2[i])
|
|
if (cmp != 0) return cmp
|
|
}
|
|
|
|
if (parts1.size != parts2.size) {
|
|
return parts1.size.compareTo(parts2.size)
|
|
}
|
|
|
|
val offset1 = cfi1.substringAfter(':', "0").toIntOrNull() ?: 0
|
|
val offset2 = cfi2.substringAfter(':', "0").toIntOrNull() ?: 0
|
|
return offset1.compareTo(offset2)
|
|
}
|
|
|
|
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(
|
|
text: AnnotatedString, query: String, highlightColor: Color
|
|
): AnnotatedString {
|
|
if (query.length < 3) return text
|
|
|
|
return buildAnnotatedString {
|
|
append(text)
|
|
val textString = text.text
|
|
var startIndex = 0
|
|
while (startIndex < textString.length) {
|
|
val index = textString.indexOf(query, startIndex, ignoreCase = true)
|
|
if (index == -1) break
|
|
addStyle(
|
|
style = SpanStyle(background = highlightColor),
|
|
start = index,
|
|
end = index + query.length
|
|
)
|
|
startIndex = index + query.length
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun computeImageRenderSizePx(
|
|
block: ImageBlock,
|
|
density: Density,
|
|
maxWidthPx: Float,
|
|
imageSizeMultiplier: Float
|
|
): Pair<Float, Float> {
|
|
val intrinsicWidth = block.intrinsicWidth
|
|
val intrinsicHeight = block.intrinsicHeight
|
|
if (intrinsicWidth == null || intrinsicHeight == null || intrinsicWidth <= 0f || intrinsicHeight <= 0f) {
|
|
return 0f to 0f
|
|
}
|
|
|
|
val aspectRatio = intrinsicHeight / intrinsicWidth
|
|
val baseWidth = with(density) {
|
|
if (block.style.width.isSpecified && block.style.width > 0.dp) {
|
|
block.style.width.toPx()
|
|
} else {
|
|
maxWidthPx
|
|
}
|
|
}
|
|
|
|
var scaledWidth = baseWidth * imageSizeMultiplier
|
|
if (block.style.maxWidth.isSpecified && block.style.maxWidth > 0.dp) {
|
|
scaledWidth = scaledWidth.coerceAtMost(with(density) { block.style.maxWidth.toPx() } * imageSizeMultiplier)
|
|
}
|
|
scaledWidth = scaledWidth.coerceAtMost(maxWidthPx)
|
|
|
|
return scaledWidth to (scaledWidth * aspectRatio)
|
|
}
|
|
|
|
private fun computeImageRenderSizeDp(
|
|
block: ImageBlock,
|
|
density: Density,
|
|
maxWidthDp: Dp,
|
|
imageSizeMultiplier: Float
|
|
): Pair<Dp, Dp>? {
|
|
val (widthPx, heightPx) = computeImageRenderSizePx(
|
|
block = block,
|
|
density = density,
|
|
maxWidthPx = with(density) { maxWidthDp.toPx() },
|
|
imageSizeMultiplier = imageSizeMultiplier
|
|
)
|
|
if (widthPx <= 0f || heightPx <= 0f) return null
|
|
return with(density) { widthPx.toDp() to heightPx.toDp() }
|
|
}
|
|
|
|
private fun imageBlockContentAlignment(style: BlockStyle): Alignment {
|
|
return when {
|
|
style.float == "right" || style.horizontalAlign == "right" || style.horizontalAlign == "end" -> Alignment.CenterEnd
|
|
style.float == "left" || style.horizontalAlign == "left" || style.horizontalAlign == "start" -> Alignment.CenterStart
|
|
else -> Alignment.Center
|
|
}
|
|
}
|
|
|
|
private fun tableCellImageModifier(
|
|
block: ImageBlock,
|
|
density: Density,
|
|
imageSizeMultiplier: Float
|
|
): Modifier {
|
|
val baseModifier = if (block.style.width.isSpecified && block.style.width > 0.dp) {
|
|
Modifier.width(block.style.width * imageSizeMultiplier)
|
|
} else {
|
|
Modifier.fillMaxWidth(imageSizeMultiplier.coerceIn(0f, 1f))
|
|
}
|
|
|
|
val intrinsicWidth = block.intrinsicWidth
|
|
val intrinsicHeight = block.intrinsicHeight
|
|
val sizedModifier = if (
|
|
intrinsicWidth != null &&
|
|
intrinsicHeight != null &&
|
|
intrinsicWidth > 0f &&
|
|
intrinsicHeight > 0f
|
|
) {
|
|
baseModifier.aspectRatio(intrinsicWidth / intrinsicHeight)
|
|
} else {
|
|
baseModifier.height(
|
|
if (block.expectedHeight > 0) {
|
|
with(density) { (block.expectedHeight * imageSizeMultiplier).toDp() }
|
|
} else {
|
|
250.dp
|
|
}
|
|
)
|
|
}
|
|
|
|
return if (block.style.maxWidth.isSpecified && block.style.maxWidth > 0.dp) {
|
|
sizedModifier.widthIn(max = block.style.maxWidth * imageSizeMultiplier)
|
|
} else {
|
|
sizedModifier
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun WrappingContentLayout(
|
|
block: WrappingContentBlock,
|
|
textStyle: TextStyle,
|
|
imageSizeMultiplier: Float,
|
|
modifier: Modifier = Modifier,
|
|
searchQuery: String,
|
|
ttsHighlightInfo: TtsHighlightInfo?,
|
|
searchHighlightColor: Color,
|
|
ttsHighlightColor: Color
|
|
) {
|
|
val textMeasurer = rememberTextMeasurer()
|
|
val fullText = remember(block.paragraphsToWrap, searchQuery, ttsHighlightInfo) {
|
|
buildAnnotatedString {
|
|
block.paragraphsToWrap.forEachIndexed { index, p ->
|
|
val searchHighlighted =
|
|
highlightQueryInText(p.content, searchQuery, searchHighlightColor)
|
|
val finalContent = if (ttsHighlightInfo != null && p.cfi == ttsHighlightInfo.cfi) {
|
|
buildAnnotatedString {
|
|
append(searchHighlighted)
|
|
val blockStartAbs = p.startCharOffsetInSource
|
|
val blockEndAbs = p.startCharOffsetInSource + searchHighlighted.length
|
|
val highlightStartAbs = ttsHighlightInfo.offset
|
|
val highlightEndAbs = ttsHighlightInfo.offset + ttsHighlightInfo.text.length
|
|
val intersectionStartAbs = maxOf(blockStartAbs, highlightStartAbs)
|
|
val intersectionEndAbs = minOf(blockEndAbs, highlightEndAbs)
|
|
|
|
if (intersectionStartAbs < intersectionEndAbs) {
|
|
val highlightStartRelative = intersectionStartAbs - blockStartAbs
|
|
val highlightEndRelative = intersectionEndAbs - blockStartAbs
|
|
addStyle(
|
|
style = SpanStyle(
|
|
background = ttsHighlightColor
|
|
), start = highlightStartRelative, end = highlightEndRelative
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
searchHighlighted
|
|
}
|
|
append(finalContent)
|
|
if (index < block.paragraphsToWrap.lastIndex) append("\n\n")
|
|
}
|
|
}
|
|
}
|
|
val (paragraphStartOffsets, paragraphEndOffsetMap) = remember(block.paragraphsToWrap) {
|
|
val starts = mutableSetOf<Int>()
|
|
val endMap = mutableMapOf<Int, Int>()
|
|
var currentOffset = 0
|
|
block.paragraphsToWrap.forEachIndexed { index, p ->
|
|
starts.add(currentOffset)
|
|
currentOffset += p.content.length
|
|
endMap[currentOffset - 1] = index
|
|
if (index < block.paragraphsToWrap.lastIndex) {
|
|
currentOffset += 2
|
|
}
|
|
}
|
|
starts to endMap
|
|
}
|
|
val density = LocalDensity.current
|
|
var textLayouts by remember {
|
|
mutableStateOf<List<Pair<TextLayoutResult, Offset>>>(emptyList())
|
|
}
|
|
var totalHeight by remember { mutableIntStateOf(0) }
|
|
|
|
Layout(content = {
|
|
AsyncImage(
|
|
model = Builder(LocalContext.current).data(File(block.floatedImage.path)).build(),
|
|
contentDescription = block.floatedImage.altText,
|
|
contentScale = ContentScale.Fit
|
|
)
|
|
}, modifier = modifier.drawBehind {
|
|
textLayouts.forEach { (layout, offset) ->
|
|
drawText(layout, topLeft = offset)
|
|
}
|
|
}) { measurables, constraints ->
|
|
val (imageRenderWidthPx, imageRenderHeightPx) = run {
|
|
computeImageRenderSizePx(
|
|
block = block.floatedImage,
|
|
density = density,
|
|
maxWidthPx = constraints.maxWidth.toFloat(),
|
|
imageSizeMultiplier = imageSizeMultiplier
|
|
)
|
|
}
|
|
|
|
val imagePlacable = if (imageRenderWidthPx > 0 && imageRenderHeightPx > 0) {
|
|
measurables.first().measure(
|
|
Constraints.fixed(
|
|
imageRenderWidthPx.roundToInt(), imageRenderHeightPx.roundToInt()
|
|
)
|
|
)
|
|
} else {
|
|
null
|
|
}
|
|
|
|
val effectiveImageWidth = imagePlacable?.width ?: 0
|
|
val effectiveImageHeight = imagePlacable?.height ?: 0
|
|
|
|
var currentY = 0f
|
|
var textOffset = 0
|
|
val layouts = mutableListOf<Pair<TextLayoutResult, Offset>>()
|
|
|
|
while (textOffset < fullText.length) {
|
|
val isBesideImage = currentY < effectiveImageHeight
|
|
val floatLeft = block.floatedImage.style.float == "left"
|
|
|
|
val currentMaxWidth = if (isBesideImage) {
|
|
(constraints.maxWidth - effectiveImageWidth).coerceAtLeast(0)
|
|
} else {
|
|
constraints.maxWidth
|
|
}
|
|
|
|
if (currentMaxWidth <= 0) break
|
|
|
|
val lineConstraints = constraints.copy(minWidth = 0, maxWidth = currentMaxWidth)
|
|
val remainingText = fullText.subSequence(textOffset, fullText.length)
|
|
|
|
val styleForMeasure =
|
|
remainingText.spanStyles.firstOrNull { it.item.fontFamily != null }?.item?.fontFamily?.let {
|
|
textStyle.copy(fontFamily = it)
|
|
} ?: textStyle
|
|
|
|
val layoutResult = textMeasurer.measure(
|
|
remainingText, style = styleForMeasure, constraints = lineConstraints
|
|
)
|
|
|
|
val firstLineEndOffset = layoutResult.getLineEnd(0, visibleEnd = true)
|
|
if (firstLineEndOffset == 0 && remainingText.isNotEmpty()) {
|
|
textOffset++
|
|
continue
|
|
}
|
|
if (firstLineEndOffset == 0) break
|
|
val lineText = remainingText.subSequence(0, firstLineEndOffset)
|
|
val isStartOfParagraph = paragraphStartOffsets.contains(textOffset)
|
|
val finalLineText = if (isStartOfParagraph) {
|
|
lineText
|
|
} else {
|
|
val stylesWithIndent =
|
|
lineText.paragraphStyles.filter { it.item.textIndent != null }
|
|
if (stylesWithIndent.isNotEmpty()) {
|
|
buildAnnotatedString {
|
|
append(lineText)
|
|
stylesWithIndent.forEach {
|
|
addStyle(
|
|
it.item.copy(textIndent = TextIndent(0.sp, 0.sp)), it.start, it.end
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
lineText
|
|
}
|
|
}
|
|
|
|
val lineLayout = textMeasurer.measure(
|
|
finalLineText, style = styleForMeasure, constraints = lineConstraints
|
|
)
|
|
val xOffset = if (isBesideImage && floatLeft) effectiveImageWidth.toFloat() else 0f
|
|
|
|
layouts.add(lineLayout to Offset(xOffset, currentY))
|
|
|
|
currentY += lineLayout.size.height
|
|
val endOfLineVisibleCharIndex = textOffset + firstLineEndOffset - 1
|
|
val paraIndex = paragraphEndOffsetMap[endOfLineVisibleCharIndex]
|
|
|
|
if (paraIndex != null && paraIndex < block.paragraphsToWrap.lastIndex) {
|
|
val currentPara = block.paragraphsToWrap[paraIndex]
|
|
val nextPara = block.paragraphsToWrap[paraIndex + 1]
|
|
|
|
val gap = with(density) {
|
|
val marginBottom = currentPara.style.margin.bottom.toPx()
|
|
val marginTop = nextPara.style.margin.top.toPx()
|
|
maxOf(marginBottom, marginTop)
|
|
}
|
|
currentY += gap
|
|
}
|
|
textOffset += firstLineEndOffset
|
|
while (textOffset < fullText.length && fullText[textOffset].isWhitespace()) {
|
|
textOffset++
|
|
}
|
|
}
|
|
textLayouts = layouts
|
|
totalHeight = maxOf(currentY, effectiveImageHeight.toFloat()).roundToInt()
|
|
layout(constraints.maxWidth, totalHeight) {
|
|
if (imagePlacable != null) {
|
|
val imageX = if (block.floatedImage.style.float == "left") 0
|
|
else constraints.maxWidth - effectiveImageWidth
|
|
imagePlacable.placeRelative(x = imageX, y = 0)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
|
@OptIn(ExperimentalFoundationApi::class, ExperimentalSerializationApi::class, FlowPreview::class)
|
|
@Composable
|
|
fun PaginatedReaderScreen(
|
|
modifier: Modifier = Modifier,
|
|
book: EpubBook,
|
|
bookId: String? = null,
|
|
isDarkTheme: Boolean,
|
|
effectiveBg: Color,
|
|
effectiveText: Color,
|
|
pagerState: PagerState,
|
|
isPageTurnAnimationEnabled: Boolean,
|
|
isRightToLeftPagination: Boolean = false,
|
|
searchQuery: String,
|
|
fontSizeMultiplier: Float,
|
|
lineHeightMultiplier: Float,
|
|
paragraphGapMultiplier: Float,
|
|
imageSizeMultiplier: Float,
|
|
horizontalMarginMultiplier: Float,
|
|
verticalMarginMultiplier: Float,
|
|
fontFamily: FontFamily,
|
|
textAlign: ReaderTextAlign,
|
|
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,
|
|
onTap: (Offset?) -> Unit,
|
|
isProUser: Boolean,
|
|
isOss: Boolean = false,
|
|
onShowDictionaryUpsellDialog: () -> Unit,
|
|
onWordSelectedForAiDefinition: (String) -> Unit,
|
|
onTranslate: (String) -> Unit,
|
|
onSearch: (String) -> Unit,
|
|
onStartTtsFromSelection: (String, Int) -> Unit,
|
|
onNoteRequested: (String?) -> Unit,
|
|
onFootnoteRequested: (String) -> Unit,
|
|
onInternalLinkNavigated: (Int) -> Unit = {},
|
|
userHighlights: List<UserHighlight>,
|
|
onHighlightCreated: (String, String, String) -> Unit,
|
|
onHighlightDeleted: (String) -> Unit,
|
|
activeHighlightPalette: List<HighlightColor>,
|
|
onUpdatePalette: (Int, HighlightColor) -> Unit,
|
|
activeTextureId: String? = null,
|
|
activeTextureAlpha: Float = 0.55f
|
|
) {
|
|
LaunchedEffect(userHighlights) {
|
|
Timber.d("PaginatedReaderScreen: Received ${userHighlights.size} highlights.")
|
|
userHighlights.forEach {
|
|
Timber.d(" -> Received Highlight: CFI=${it.cfi}, Text='${it.text.take(20)}...'")
|
|
}
|
|
}
|
|
|
|
val context = LocalContext.current
|
|
val textureBitmap = remember(activeTextureId) {
|
|
loadReaderTextureBitmap(context, activeTextureId)
|
|
}
|
|
|
|
val textureModifier = if (textureBitmap != null) {
|
|
Modifier.drawBehind {
|
|
val brush = ShaderBrush(
|
|
ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)
|
|
)
|
|
drawRect(brush = brush, blendMode = BlendMode.SrcOver, alpha = activeTextureAlpha.coerceIn(0f, 1f))
|
|
}
|
|
} 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()
|
|
val baseTextStyle = MaterialTheme.typography.bodyLarge
|
|
|
|
var debouncedFontSizeMult by remember { mutableFloatStateOf(fontSizeMultiplier) }
|
|
var debouncedLineHeightMult by remember { mutableFloatStateOf(lineHeightMultiplier) }
|
|
var debouncedParagraphGapMult by remember { mutableFloatStateOf(paragraphGapMultiplier) }
|
|
var debouncedImageSizeMult by remember { mutableFloatStateOf(imageSizeMultiplier) }
|
|
var debouncedHorizontalMarginMult by remember { mutableFloatStateOf(horizontalMarginMultiplier) }
|
|
var debouncedVerticalMarginMult by remember { mutableFloatStateOf(verticalMarginMultiplier) }
|
|
var debouncedFontFamily by remember { mutableStateOf(fontFamily) }
|
|
var debouncedTextAlign by remember { mutableStateOf(textAlign) }
|
|
|
|
var anchorLocatorForReconfig by remember { mutableStateOf<Locator?>(null) }
|
|
val currentPaginatorRef = remember { mutableStateOf<IPaginator?>(null) }
|
|
val latestFallbackLocatorForReconfiguration by rememberUpdatedState(fallbackLocatorForReconfiguration)
|
|
|
|
var previousConstraints by remember {
|
|
mutableStateOf(this.constraints)
|
|
}
|
|
|
|
if (previousConstraints != this.constraints) {
|
|
val activePaginator = currentPaginatorRef.value
|
|
val currentPage = pagerState.currentPage
|
|
val locator = resolvePaginatedReconfigurationAnchor(
|
|
currentPageLocator = (activePaginator as? BookPaginator)?.getLocatorForPage(currentPage),
|
|
fallbackLocator = fallbackLocatorForReconfiguration
|
|
)
|
|
anchorLocatorForReconfig = locator
|
|
|
|
Timber.tag("ThemeReconfig").d("""
|
|
RECONFIG DETECTED
|
|
- Reason: Constraints
|
|
- Current Page: $currentPage
|
|
- Saved Locator: $locator
|
|
""".trimIndent())
|
|
previousConstraints = this.constraints
|
|
}
|
|
|
|
val layoutTextStyle = remember(
|
|
baseTextStyle,
|
|
debouncedFontSizeMult,
|
|
debouncedLineHeightMult,
|
|
debouncedFontFamily
|
|
) {
|
|
val adjustedFontSize = baseTextStyle.fontSize * debouncedFontSizeMult
|
|
val adjustedLineHeight = adjustedFontSize * paginationLineHeightMultiplierForWebViewSetting(debouncedLineHeightMult)
|
|
|
|
baseTextStyle.copy(
|
|
color = Color.Unspecified,
|
|
fontSize = adjustedFontSize,
|
|
lineHeight = adjustedLineHeight,
|
|
fontFamily = debouncedFontFamily,
|
|
lineBreak = LineBreak.Paragraph,
|
|
letterSpacing = TextUnit.Unspecified,
|
|
platformStyle = PlatformTextStyle(includeFontPadding = false),
|
|
lineHeightStyle = LineHeightStyle(
|
|
alignment = LineHeightStyle.Alignment.Proportional,
|
|
trim = LineHeightStyle.Trim.None
|
|
)
|
|
)
|
|
}
|
|
val textStyle = remember(layoutTextStyle, effectiveText) {
|
|
layoutTextStyle.copy(color = effectiveText)
|
|
}
|
|
|
|
LaunchedEffect(pagerState) {
|
|
snapshotFlow { pagerState.currentPage }.collect { page ->
|
|
Timber.tag("PageTurnDiag").i("Pager Settled: Now on page $page at ${System.currentTimeMillis()}")
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(pagerState) {
|
|
snapshotFlow { pagerState.isScrollInProgress }.collect { isScrolling ->
|
|
Timber.tag("PageTurnDiag").d("Pager Scroll State: isScrolling=$isScrolling")
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, paragraphGapMultiplier, imageSizeMultiplier, horizontalMarginMultiplier, verticalMarginMultiplier, fontFamily, textAlign) {
|
|
if (fontSizeMultiplier != debouncedFontSizeMult ||
|
|
lineHeightMultiplier != debouncedLineHeightMult ||
|
|
paragraphGapMultiplier != debouncedParagraphGapMult ||
|
|
imageSizeMultiplier != debouncedImageSizeMult ||
|
|
horizontalMarginMultiplier != debouncedHorizontalMarginMult ||
|
|
verticalMarginMultiplier != debouncedVerticalMarginMult ||
|
|
fontFamily != debouncedFontFamily ||
|
|
textAlign != debouncedTextAlign
|
|
) {
|
|
Timber.d("Formatting changed. Waiting for debounce.")
|
|
delay(400L)
|
|
|
|
val activePaginator = currentPaginatorRef.value
|
|
val currentPage = pagerState.currentPage
|
|
val locator = resolvePaginatedReconfigurationAnchor(
|
|
currentPageLocator = (activePaginator as? BookPaginator)?.getLocatorForPage(currentPage),
|
|
fallbackLocator = fallbackLocatorForReconfiguration
|
|
)
|
|
if (locator != null) {
|
|
anchorLocatorForReconfig = locator
|
|
}
|
|
|
|
debouncedFontSizeMult = fontSizeMultiplier
|
|
debouncedLineHeightMult = lineHeightMultiplier
|
|
debouncedParagraphGapMult = paragraphGapMultiplier
|
|
debouncedImageSizeMult = imageSizeMultiplier
|
|
debouncedHorizontalMarginMult = horizontalMarginMultiplier
|
|
debouncedVerticalMarginMult = verticalMarginMultiplier
|
|
debouncedFontFamily = fontFamily
|
|
debouncedTextAlign = textAlign
|
|
Timber.d("Debounce complete. Applying new format settings.")
|
|
}
|
|
}
|
|
|
|
val userTextAlign = remember(debouncedTextAlign) {
|
|
when (debouncedTextAlign) {
|
|
ReaderTextAlign.JUSTIFY -> TextAlign.Justify
|
|
ReaderTextAlign.LEFT -> TextAlign.Left
|
|
ReaderTextAlign.DEFAULT -> null
|
|
}
|
|
}
|
|
|
|
val density = LocalDensity.current
|
|
val requestedHorizontalPadding = 16.dp * debouncedHorizontalMarginMult
|
|
val requestedVerticalPadding = 16.dp * debouncedVerticalMarginMult
|
|
val effectiveReaderPadding =
|
|
remember(this.constraints, density, requestedHorizontalPadding, requestedVerticalPadding) {
|
|
val requestedHorizontalPaddingPx = with(density) { requestedHorizontalPadding.roundToPx() }
|
|
val requestedVerticalPaddingPx = with(density) { requestedVerticalPadding.roundToPx() }
|
|
val minReadableWidthPx = with(density) { 96.dp.roundToPx() }
|
|
.coerceAtMost(this.constraints.maxWidth)
|
|
val minReadableHeightPx = with(density) { 160.dp.roundToPx() }
|
|
.coerceAtMost(this.constraints.maxHeight)
|
|
val horizontalPaddingPx = requestedHorizontalPaddingPx.coerceAtMost(
|
|
((this.constraints.maxWidth - minReadableWidthPx) / 2).coerceAtLeast(0)
|
|
)
|
|
val verticalPaddingPx = requestedVerticalPaddingPx.coerceAtMost(
|
|
((this.constraints.maxHeight - minReadableHeightPx) / 2).coerceAtLeast(0)
|
|
)
|
|
with(density) {
|
|
horizontalPaddingPx.toDp() to verticalPaddingPx.toDp()
|
|
}
|
|
}
|
|
val horizontalPadding = effectiveReaderPadding.first
|
|
val verticalPadding = effectiveReaderPadding.second
|
|
|
|
val textConstraints =
|
|
remember(this.constraints, density, horizontalPadding, verticalPadding) {
|
|
val horizontalPaddingPx = with(density) { horizontalPadding.roundToPx() }
|
|
val verticalPaddingPx = with(density) { verticalPadding.roundToPx() }
|
|
val finalConstraints = this.constraints.copy(
|
|
minWidth = 0,
|
|
maxWidth = (this.constraints.maxWidth - (2 * horizontalPaddingPx)).coerceAtLeast(1),
|
|
minHeight = 0,
|
|
maxHeight = (this.constraints.maxHeight - (2 * verticalPaddingPx)).coerceAtLeast(1)
|
|
)
|
|
finalConstraints
|
|
}
|
|
|
|
val coroutineScope = rememberCoroutineScope()
|
|
val context = LocalContext.current
|
|
val mathMLRenderer = remember { MathMLRenderer(context.applicationContext) }
|
|
|
|
DisposableEffect(Unit) {
|
|
onDispose {
|
|
mathMLRenderer.destroy()
|
|
Timber.d("PaginatedReaderScreen disposed, MathMLRenderer destroyed.")
|
|
}
|
|
}
|
|
|
|
val effectiveInitialChapter =
|
|
remember(initialChapterIndexInBook, anchorLocatorForReconfig) {
|
|
anchorLocatorForReconfig?.chapterIndex ?: initialChapterIndexInBook ?: 0
|
|
}
|
|
|
|
LaunchedEffect(anchorLocatorForReconfig) {
|
|
anchorLocatorForReconfig?.let { locator ->
|
|
onReconfigurationAnchorCaptured(locator)
|
|
onReconfigurationRestoreActiveChanged(true)
|
|
}
|
|
}
|
|
|
|
val paginator = remember(book, bookId, textConstraints, layoutTextStyle, userTextAlign, debouncedParagraphGapMult, debouncedImageSizeMult, debouncedVerticalMarginMult) {
|
|
val userAgentStylesheet = UserAgentStylesheet.default
|
|
var allRules = OptimizedCssRules()
|
|
val allFontFaces = mutableListOf<FontFaceInfo>()
|
|
|
|
val uaResult = CssParser.parse(
|
|
cssContent = userAgentStylesheet,
|
|
cssPath = null,
|
|
baseFontSizeSp = layoutTextStyle.fontSize.value,
|
|
density = density.density,
|
|
constraints = textConstraints,
|
|
isDarkTheme = false,
|
|
adaptThemeColors = false
|
|
)
|
|
allRules = allRules.merge(uaResult.rules)
|
|
allFontFaces.addAll(uaResult.fontFaces)
|
|
|
|
book.css.forEach { (path, content) ->
|
|
val bookCssResult = CssParser.parse(
|
|
cssContent = content,
|
|
cssPath = path,
|
|
baseFontSizeSp = layoutTextStyle.fontSize.value,
|
|
density = density.density,
|
|
constraints = textConstraints,
|
|
isDarkTheme = false,
|
|
adaptThemeColors = false
|
|
)
|
|
allRules = allRules.merge(bookCssResult.rules)
|
|
allFontFaces.addAll(bookCssResult.fontFaces)
|
|
}
|
|
val fontFamilyMap = loadFontFamilies(
|
|
fontFaces = allFontFaces, extractionPath = book.extractionBasePath
|
|
)
|
|
val bookCacheDao =
|
|
BookCacheDatabase.getDatabase(context.applicationContext).bookCacheDao()
|
|
val proto = ProtoBuf { serializersModule = semanticBlockModule }
|
|
|
|
val uniqueBookId = bookId ?: if (book.fileName.length > 20) book.fileName else book.title
|
|
|
|
Timber.d("Recreating BookPaginator for ID: $uniqueBookId. TextAlign: $userTextAlign")
|
|
Timber.tag("ReflowPaginationDiag").d("PaginatedReaderScreen: Instantiating BookPaginator. book.chaptersForPagination.size=${book.chaptersForPagination.size}, initialChapter=$effectiveInitialChapter")
|
|
|
|
BookPaginator(
|
|
coroutineScope = coroutineScope,
|
|
chapters = book.chaptersForPagination,
|
|
textMeasurer = textMeasurer,
|
|
constraints = textConstraints,
|
|
textStyle = layoutTextStyle,
|
|
extractionBasePath = book.extractionBasePath,
|
|
density = density,
|
|
fontFamilyMap = fontFamilyMap,
|
|
isDarkTheme = isDarkTheme,
|
|
themeBackgroundColor = effectiveBg,
|
|
themeTextColor = effectiveText,
|
|
bookId = uniqueBookId,
|
|
bookCacheDao = bookCacheDao,
|
|
proto = proto,
|
|
initialChapterToPaginate = effectiveInitialChapter,
|
|
bookCss = book.css,
|
|
userAgentStylesheet = userAgentStylesheet,
|
|
allFontFaces = allFontFaces,
|
|
context = context.applicationContext,
|
|
mathMLRenderer = mathMLRenderer,
|
|
userTextAlign = userTextAlign,
|
|
paragraphGapMultiplier = debouncedParagraphGapMult,
|
|
imageSizeMultiplier = debouncedImageSizeMult,
|
|
verticalMarginMultiplier = debouncedVerticalMarginMult
|
|
)
|
|
}
|
|
|
|
LaunchedEffect(paginator) {
|
|
onPaginatorReady(paginator)
|
|
currentPaginatorRef.value = paginator
|
|
}
|
|
|
|
LaunchedEffect(paginator) {
|
|
if (anchorLocatorForReconfig != null) {
|
|
Timber.tag("POS_DIAG").d("Restoration Triggered. Anchor Locator: $anchorLocatorForReconfig")
|
|
|
|
try {
|
|
onReconfigurationRestoreActiveChanged(true)
|
|
snapshotFlow { paginator.isLoading }.filter { !it }.first()
|
|
|
|
val targetLocator = anchorLocatorForReconfig
|
|
if (targetLocator != null) {
|
|
val page = paginator.findPageForLocator(targetLocator)
|
|
|
|
Timber.tag("POS_DIAG").d("Restoration Result: Paginator resolved locator to page: $page")
|
|
|
|
if (page != null) {
|
|
pagerState.scrollToPage(page)
|
|
paginator.onUserScrolledTo(page)
|
|
Timber.tag("POS_DIAG").i("Restoration: Pager scrolled to $page")
|
|
} else {
|
|
val startPage = paginator.chapterStartPageIndices[targetLocator.chapterIndex]
|
|
if (startPage != null) {
|
|
Timber.tag("POS_DIAG").w("Restoration: Precise page not found, falling back to chapter start: $startPage")
|
|
pagerState.scrollToPage(startPage)
|
|
paginator.onUserScrolledTo(startPage)
|
|
}
|
|
}
|
|
anchorLocatorForReconfig = null
|
|
}
|
|
} finally {
|
|
onReconfigurationRestoreActiveChanged(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
var isLoading by remember { mutableStateOf(true) }
|
|
var totalPageCount by remember { mutableIntStateOf(0) }
|
|
var generation by remember { mutableIntStateOf(0) }
|
|
|
|
LaunchedEffect(paginator) {
|
|
launch { snapshotFlow { paginator.isLoading }.collect {
|
|
Timber.tag("ReflowPaginationDiag").d("PaginatedReaderScreen: paginator.isLoading=$it")
|
|
isLoading = it
|
|
} }
|
|
launch {
|
|
snapshotFlow { paginator.totalPageCount }.collect { newTotalPageCount ->
|
|
Timber.tag("ReflowPaginationDiag").d("PaginatedReaderScreen: paginator.totalPageCount=$newTotalPageCount")
|
|
totalPageCount = newTotalPageCount
|
|
}
|
|
}
|
|
launch { snapshotFlow { paginator.generation }.collect {
|
|
Timber.tag("ReflowPaginationDiag").d("PaginatedReaderScreen: paginator.generation=$it")
|
|
generation = it
|
|
} }
|
|
}
|
|
|
|
LaunchedEffect(pagerState, paginator) {
|
|
snapshotFlow { pagerState.currentPage }.debounce(500)
|
|
.collectLatest { page ->
|
|
if (anchorLocatorForReconfig == null) {
|
|
paginator.onUserScrolledTo(page)
|
|
}
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(paginator, pagerState) {
|
|
paginator.pageShiftRequest.collect { shiftAmount ->
|
|
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 ->
|
|
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 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
val uiState = PaginatedReaderUiState(
|
|
isLoading = isLoading, totalPageCount = totalPageCount, generation = generation
|
|
)
|
|
|
|
PaginatedReaderContent(
|
|
uiState = uiState,
|
|
pagerState = pagerState,
|
|
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
|
|
isRightToLeftPagination = isRightToLeftPagination,
|
|
effectiveBg = effectiveBg,
|
|
searchQuery = searchQuery,
|
|
ttsHighlightInfo = ttsHighlightInfo,
|
|
textStyle = textStyle,
|
|
imageSizeMultiplier = debouncedImageSizeMult,
|
|
horizontalPadding = horizontalPadding,
|
|
verticalPadding = verticalPadding,
|
|
onGetPage = { pageIndex ->
|
|
val startTime = System.currentTimeMillis()
|
|
val result = paginator.getPageContent(pageIndex)
|
|
val duration = System.currentTimeMillis() - startTime
|
|
if (duration > 16) {
|
|
Timber.tag("PageTurnDiag")
|
|
.w("HEAVY TASK: paginator.getPageContent($pageIndex) took ${duration}ms on Thread ${Thread.currentThread().name}")
|
|
}
|
|
result
|
|
},
|
|
onGetChapterPath = { pageIndex -> paginator.getChapterPathForPage(pageIndex) },
|
|
onGetChapterInfo = { pageIndex ->
|
|
paginator.findChapterIndexForPage(pageIndex)?.let { chapterIndex ->
|
|
val chapter = book.chaptersForPagination.getOrNull(chapterIndex)
|
|
val estimatedPages = paginator.chapterPageCounts[chapterIndex]
|
|
if (chapter != null) {
|
|
Pair(chapter.title, estimatedPages)
|
|
} else {
|
|
null
|
|
}
|
|
}
|
|
},
|
|
onInternalLinkNavigated = onInternalLinkNavigated,
|
|
onLinkClick = { currentChapterPath, href, onNavComplete ->
|
|
coroutineScope.launch(Dispatchers.IO) {
|
|
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()
|
|
|
|
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(currentChapterPath).resolve(pathPart)
|
|
.normalize().path
|
|
} catch (_: Exception) {
|
|
null
|
|
}
|
|
}
|
|
|
|
if (targetPath != null) {
|
|
val targetChapter = book.chaptersForPagination.find {
|
|
try {
|
|
URI(it.absPath).normalize().path == targetPath
|
|
} catch (_: Exception) {
|
|
false
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!footnoteHtml.isNullOrBlank()) {
|
|
withContext(Dispatchers.Main) { onFootnoteRequested(footnoteHtml) }
|
|
} else {
|
|
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 }
|
|
}
|
|
}
|
|
},
|
|
onTap = onTap,
|
|
isProUser = isProUser,
|
|
isOss = isOss,
|
|
onShowDictionaryUpsellDialog = onShowDictionaryUpsellDialog,
|
|
onWordSelectedForAiDefinition = onWordSelectedForAiDefinition,
|
|
onTranslate = onTranslate,
|
|
onSearch = onSearch,
|
|
onStartTtsFromSelection = onStartTtsFromSelection,
|
|
onNoteRequested = onNoteRequested,
|
|
userHighlights = userHighlights,
|
|
onHighlightCreated = onHighlightCreated,
|
|
onHighlightDeleted = onHighlightDeleted,
|
|
isDarkTheme = isDarkTheme,
|
|
activeHighlightPalette = activeHighlightPalette,
|
|
onUpdatePalette = onUpdatePalette,
|
|
effectiveText = effectiveText,
|
|
pageTextureModifier = if (isPageTurnAnimationEnabled) Modifier else textureModifier,
|
|
pageTextureBitmap = textureBitmap,
|
|
pageTextureAlpha = activeTextureAlpha.coerceIn(0f, 1f)
|
|
)
|
|
|
|
androidx.compose.animation.AnimatedVisibility(
|
|
visible = isNavigatingByLink,
|
|
enter = androidx.compose.animation.fadeIn(),
|
|
exit = androidx.compose.animation.fadeOut()
|
|
) {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.7f))
|
|
.clickable(enabled = true) { },
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
CircularProgressIndicator()
|
|
Spacer(Modifier.height(16.dp))
|
|
Text(
|
|
"Navigating...",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.onBackground
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun parseEmphasisAnnotation(annotation: String, defaultColor: Color): TextEmphasis {
|
|
Timber.d("Parsing annotation string: '$annotation'")
|
|
val map = annotation.split(';').filter { it.isNotBlank() }.associate {
|
|
val (key, value) = it.split(':', limit = 2)
|
|
key to value
|
|
}
|
|
val emphasis = TextEmphasis(
|
|
style = map["s"],
|
|
fill = map["f"],
|
|
color = map["c"]?.toULongOrNull()?.let { Color(it) } ?: defaultColor,
|
|
position = map["p"])
|
|
Timber.d("Parsed annotation to object: $emphasis")
|
|
return emphasis
|
|
}
|
|
|
|
private fun findFuzzyMatch(source: String, target: String, ignoreCase: Boolean = true): IntRange? {
|
|
if (target.isBlank()) return null
|
|
val targetWords = target.split("\\s+".toRegex()).filter { it.isNotEmpty() }
|
|
if (targetWords.isEmpty()) return null
|
|
|
|
var searchStart = 0
|
|
while (searchStart < source.length) {
|
|
val firstIdx = source.indexOf(targetWords[0], searchStart, ignoreCase = ignoreCase)
|
|
if (firstIdx == -1) return null
|
|
|
|
var currentIdx = firstIdx + targetWords[0].length
|
|
var allMatch = true
|
|
|
|
for (i in 1 until targetWords.size) {
|
|
while (currentIdx < source.length && source[currentIdx].isWhitespace()) {
|
|
currentIdx++
|
|
}
|
|
if (currentIdx >= source.length) {
|
|
allMatch = false
|
|
break
|
|
}
|
|
|
|
val word = targetWords[i]
|
|
if (source.regionMatches(currentIdx, word, 0, word.length, ignoreCase = ignoreCase)) {
|
|
currentIdx += word.length
|
|
} else {
|
|
allMatch = false
|
|
break
|
|
}
|
|
}
|
|
|
|
if (allMatch) return firstIdx until currentIdx
|
|
searchStart = firstIdx + 1
|
|
}
|
|
return null
|
|
}
|
|
|
|
internal fun getHighlightOffsetsInBlock(
|
|
block: TextContentBlock, highlight: UserHighlight
|
|
): IntRange? {
|
|
if (block.cfi == null) return null
|
|
|
|
val blockPath = CfiUtils.getPath(block.cfi!!)
|
|
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
|
|
is HeaderBlock -> block.startCharOffsetInSource
|
|
is QuoteBlock -> block.startCharOffsetInSource
|
|
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}"
|
|
)
|
|
|
|
val relevantPart = parts.find { cfiPart ->
|
|
val highlightPath = CfiUtils.getPath(cfiPart)
|
|
|
|
if (highlightPath.startsWith(blockPath)) return@find true
|
|
|
|
val highlightSegments = highlightPath.split('/').filter { it.isNotEmpty() }
|
|
val blockSegments = blockPath.split('/').filter { it.isNotEmpty() }
|
|
|
|
if (highlightSegments.size > blockSegments.size) {
|
|
val pathWithoutFirst = "/" + highlightSegments.drop(1).joinToString("/")
|
|
if (pathWithoutFirst.startsWith(blockPath)) return@find true
|
|
}
|
|
|
|
if (highlightSegments.isNotEmpty() && blockSegments.isNotEmpty()) {
|
|
if (highlightSegments[0] != blockSegments[0]) {
|
|
val highlightTail = highlightSegments.drop(1)
|
|
val blockTail = blockSegments.drop(1)
|
|
if (blockTail.isNotEmpty() && highlightTail.size >= blockTail.size) {
|
|
var match = true
|
|
for (i in blockTail.indices) {
|
|
if (blockTail[i] != highlightTail[i]) {
|
|
match = false
|
|
break
|
|
}
|
|
}
|
|
if (match) return@find true
|
|
}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
if (relevantPart != null) {
|
|
Timber.d(
|
|
" -> Block ${block.cfi} matches specific part of multipart highlight: $relevantPart"
|
|
)
|
|
}
|
|
|
|
val blockText = block.content.text
|
|
val highlightText = highlight.text
|
|
|
|
if (blockText.isEmpty() || highlightText.isEmpty()) return null
|
|
|
|
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) {
|
|
fun arePathsEquivalent(path1: String, path2: String): Boolean {
|
|
val p1 = CfiUtils.getPath(path1).split('/').filter { it.isNotEmpty() }
|
|
val p2 = CfiUtils.getPath(path2).split('/').filter { it.isNotEmpty() }
|
|
|
|
if (p1 == p2) return true
|
|
|
|
if (p1.size == p2.size && p1.isNotEmpty()) {
|
|
return p1.drop(1) == p2.drop(1)
|
|
}
|
|
return false
|
|
}
|
|
|
|
val startMatches = arePathsEquivalent(startCfi, block.cfi!!)
|
|
val endMatches = if (endCfi != null) arePathsEquivalent(endCfi, block.cfi!!) else false
|
|
|
|
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 = startAbs ?: CfiUtils.getOffset(startCfi)
|
|
val relOffset = absOffset - blockStartAbs
|
|
|
|
if (relOffset < 0) {
|
|
s = 0
|
|
} else {
|
|
val safeStart = (relOffset - 50).coerceAtLeast(0)
|
|
val safeEnd = (relOffset + 50).coerceAtMost(blockText.length)
|
|
|
|
if (safeStart < safeEnd) {
|
|
val windowText = blockText.substring(safeStart, safeEnd)
|
|
val prefix = highlightText.trim().take(20).trim()
|
|
|
|
var snapped = false
|
|
if (prefix.isNotEmpty()) {
|
|
val matches = mutableListOf<Int>()
|
|
var idx = windowText.indexOf(prefix, ignoreCase = true)
|
|
while (idx != -1) {
|
|
matches.add(idx)
|
|
idx = windowText.indexOf(prefix, idx + 1, ignoreCase = true)
|
|
}
|
|
|
|
if (matches.isNotEmpty()) {
|
|
val targetRel = relOffset - safeStart
|
|
val bestRel = matches.minByOrNull { abs(it - targetRel) }!!
|
|
val newS = safeStart + bestRel
|
|
Timber.d(
|
|
"Snapped start offset from rel $relOffset to $newS based on prefix '$prefix'"
|
|
)
|
|
s = newS
|
|
snapped = true
|
|
}
|
|
}
|
|
|
|
if (!snapped) {
|
|
s = relOffset
|
|
}
|
|
} else {
|
|
s = relOffset
|
|
}
|
|
}
|
|
}
|
|
|
|
if (endMatches) {
|
|
val absOffset = endAbs ?: CfiUtils.getOffset(endCfi!!)
|
|
val relOffset = absOffset - blockStartAbs
|
|
|
|
Timber.d(
|
|
" -> EndCFI Match. AbsOffset: $absOffset. RelOffset: $relOffset. Block Length: ${blockText.length}"
|
|
)
|
|
|
|
e = if (relOffset > blockText.length) {
|
|
blockText.length
|
|
} else {
|
|
relOffset
|
|
}
|
|
}
|
|
|
|
s = s.coerceIn(0, blockText.length)
|
|
e = e.coerceIn(0, blockText.length)
|
|
|
|
if (s < e) {
|
|
Timber.d("Fallback to CFI offsets for block ${block.cfi}. Range: $s..$e")
|
|
return s until e
|
|
} else {
|
|
Timber.w(
|
|
" -> Invalid Range detected (likely highlight is on other split part): $s..$e"
|
|
)
|
|
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)
|
|
}
|
|
|
|
if (startIndex >= 0) {
|
|
return startIndex until (startIndex + highlightText.length)
|
|
}
|
|
|
|
val match = findFuzzyMatch(blockText, highlightText)
|
|
if (match != null) return match
|
|
|
|
if (relevantPart != null) {
|
|
Timber.d(
|
|
"Failed to match highlight text in block despite CFI match. " + "BlockCfi=${block.cfi}, HighlightCfi=${highlight.cfi}. "
|
|
)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
private fun List<ContentBlock>.extractTextBlocks(): List<TextContentBlock> {
|
|
val result = mutableListOf<TextContentBlock>()
|
|
for (block in this) {
|
|
when (block) {
|
|
is WrappingContentBlock -> result.addAll(block.paragraphsToWrap)
|
|
is FlexContainerBlock -> result.addAll(block.children.extractTextBlocks())
|
|
is TableBlock -> {
|
|
block.rows.forEach { row ->
|
|
row.forEach { cell ->
|
|
result.addAll(cell.content.extractTextBlocks())
|
|
}
|
|
}
|
|
}
|
|
is TextContentBlock -> result.add(block)
|
|
else -> {}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
@Composable
|
|
private fun TextWithEmphasis(
|
|
text: AnnotatedString,
|
|
modifier: Modifier = Modifier,
|
|
style: TextStyle,
|
|
pageIndex: Int,
|
|
@Suppress("unused") textMeasurer: TextMeasurer,
|
|
onLinkClick: (String) -> Unit,
|
|
onGeneralTap: (Offset) -> Unit,
|
|
block: TextContentBlock,
|
|
userHighlights: List<UserHighlight>,
|
|
activeSelection: PaginatedSelection?,
|
|
@Suppress("unused") onSelectionChange: (PaginatedSelection?) -> Unit,
|
|
onHighlightClick: (UserHighlight, Rect) -> Unit,
|
|
@Suppress("unused") isDarkTheme: Boolean,
|
|
onRegisterLayout: ((TextLayoutResult, LayoutCoordinates) -> Unit)? = null
|
|
) {
|
|
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
|
|
val viewConfiguration = LocalViewConfiguration.current
|
|
var layoutCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
|
val scope = rememberCoroutineScope()
|
|
var pressedHighlightCfi by remember { mutableStateOf<String?>(null) }
|
|
val density = LocalDensity.current
|
|
|
|
data class EmphasisMarkInfo(val center: Offset, val radius: Float, val color: Color)
|
|
data class UnderlineDrawInfo(val path: Path?, val effect: PathEffect?, val minX: Float, val maxX: Float, val y: Float, val decoStyle: String, val decoColor: Color)
|
|
|
|
// --- CACHING DECORATIONS FOR PERFORMANCE ---
|
|
val cachedHighlights = remember(block, userHighlights, textLayoutResult, pressedHighlightCfi) {
|
|
val startTime = System.currentTimeMillis()
|
|
val paths = mutableListOf<Pair<Path, Color>>()
|
|
val layout = textLayoutResult
|
|
if (layout != null && block.cfi != null && userHighlights.isNotEmpty()) {
|
|
userHighlights.forEach { highlight ->
|
|
val range = getHighlightOffsetsInBlock(block, highlight)
|
|
if (range != null) {
|
|
try {
|
|
val path = layout.getPathForRange(range.first, range.last + 1)
|
|
paths.add(path to highlight.color.color.copy(alpha = 0.4f))
|
|
if (highlight.cfi == pressedHighlightCfi) {
|
|
paths.add(path to Color.Black.copy(alpha = 0.1f))
|
|
}
|
|
} catch (e: Exception) {
|
|
Timber.tag("DecorationsDiag").e(e, "Highlight path out of bounds")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
val duration = System.currentTimeMillis() - startTime
|
|
if (duration > 5) {
|
|
Timber.tag("DecorationsDiag").w("Calculated highlight paths for block ${block.blockIndex} in ${duration}ms")
|
|
}
|
|
paths
|
|
}
|
|
|
|
val cachedEmphasisMarks = remember(textLayoutResult, text, style.color, density) {
|
|
val startTime = System.currentTimeMillis()
|
|
val marks = mutableListOf<EmphasisMarkInfo>()
|
|
val layout = textLayoutResult
|
|
if (layout != null) {
|
|
val emphasisAnnotations = text.getStringAnnotations("TextEmphasis", 0, text.length)
|
|
if (emphasisAnnotations.isNotEmpty()) {
|
|
with(density) { // Provides the scope for .toPx()
|
|
emphasisAnnotations.forEach { annotation ->
|
|
val emphasis = parseEmphasisAnnotation(annotation.item, style.color)
|
|
val markColor = if (emphasis.color.isSpecified) emphasis.color else style.color
|
|
val markSize = layout.layoutInput.style.fontSize.toPx() * 0.3f
|
|
for (offset in annotation.start until annotation.end) {
|
|
if (offset >= text.text.length || text.text[offset].isWhitespace()) continue
|
|
try {
|
|
val boundingBox = layout.getBoundingBox(offset)
|
|
val center = Offset(
|
|
boundingBox.center.x,
|
|
if (emphasis.position == "under") boundingBox.bottom + markSize * 0.1f
|
|
else boundingBox.top - markSize * 0.1f
|
|
)
|
|
marks.add(EmphasisMarkInfo(center, markSize / 2, markColor))
|
|
} catch (e: Exception) {
|
|
Timber.tag("DecorationsDiag").e(e, "Emphasis mark out of bounds")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
val duration = System.currentTimeMillis() - startTime
|
|
if (duration > 5) {
|
|
Timber.tag("DecorationsDiag").w("Calculated emphasis marks for block ${block.blockIndex} in ${duration}ms")
|
|
}
|
|
marks
|
|
}
|
|
|
|
val cachedUnderlines = remember(textLayoutResult, text, style.color, density) {
|
|
val startTime = System.currentTimeMillis()
|
|
val lines = mutableListOf<UnderlineDrawInfo>()
|
|
val layout = textLayoutResult
|
|
if (layout != null) {
|
|
val customUnderlines = text.getStringAnnotations("CustomUnderline", 0, text.length)
|
|
if (customUnderlines.isNotEmpty()) {
|
|
val maxIdx = maxOf(0, text.length - 1)
|
|
val groupedUnderlines = customUnderlines.groupBy { it.item }
|
|
val mergedUnderlines = mutableListOf<AnnotatedString.Range<String>>()
|
|
|
|
groupedUnderlines.forEach { (item, annotations) ->
|
|
val sorted = annotations.sortedBy { it.start }
|
|
var currentStart = -1
|
|
var currentEnd = -1
|
|
|
|
for (ann in sorted) {
|
|
if (currentStart == -1) {
|
|
currentStart = ann.start
|
|
currentEnd = ann.end
|
|
} else if (ann.start <= currentEnd) {
|
|
currentEnd = maxOf(currentEnd, ann.end)
|
|
} else {
|
|
mergedUnderlines.add(AnnotatedString.Range(item, currentStart, currentEnd))
|
|
currentStart = ann.start
|
|
currentEnd = ann.end
|
|
}
|
|
}
|
|
if (currentStart != -1) {
|
|
mergedUnderlines.add(AnnotatedString.Range(item, currentStart, currentEnd))
|
|
}
|
|
}
|
|
|
|
with(density) {
|
|
mergedUnderlines.forEach { annotation ->
|
|
val parts = annotation.item.split('|')
|
|
val decoStyle = parts.getOrNull(0) ?: "solid"
|
|
val colorStr = parts.getOrNull(1) ?: "Unspecified"
|
|
val decoColor = if (colorStr != "Unspecified") Color(colorStr.toULong()) else style.color
|
|
|
|
val safeStart = annotation.start.coerceIn(0, text.length)
|
|
val safeEnd = annotation.end.coerceIn(0, text.length)
|
|
if (safeStart < safeEnd) {
|
|
val startLine = layout.getLineForOffset(safeStart.coerceIn(0, maxIdx))
|
|
val endLine = layout.getLineForOffset((safeEnd - 1).coerceIn(0, maxIdx))
|
|
|
|
for (line in startLine..endLine) {
|
|
val lineStart = layout.getLineStart(line)
|
|
val lineEnd = layout.getLineEnd(line, visibleEnd = true)
|
|
|
|
val intersectionStart = maxOf(safeStart, lineStart)
|
|
val intersectionEnd = minOf(safeEnd, lineEnd)
|
|
|
|
var actualStart = intersectionStart
|
|
while (actualStart < intersectionEnd && text[actualStart].isWhitespace()) {
|
|
actualStart++
|
|
}
|
|
|
|
var actualEnd = intersectionEnd
|
|
while (actualEnd > actualStart && text[actualEnd - 1].isWhitespace()) {
|
|
actualEnd--
|
|
}
|
|
|
|
if (actualStart < actualEnd) {
|
|
var minX = Float.POSITIVE_INFINITY
|
|
var maxX = Float.NEGATIVE_INFINITY
|
|
for (i in actualStart until actualEnd) {
|
|
try {
|
|
val box = layout.getBoundingBox(i)
|
|
minX = minOf(minX, box.left, box.right)
|
|
maxX = maxOf(maxX, box.left, box.right)
|
|
} catch (e: Exception) {
|
|
Timber.tag("DecorationsDiag").e(e, "Underline box out of bounds")
|
|
}
|
|
}
|
|
|
|
if (minX < maxX && !minX.isInfinite() && !maxX.isInfinite()) {
|
|
val baseline = layout.getLineBaseline(line)
|
|
val defaultOffset = layout.layoutInput.style.fontSize.toPx() * 0.1f
|
|
val requestedOffset = parts.getOrNull(2)?.toFloatOrNull()?.dp?.toPx()
|
|
val y = baseline + (requestedOffset ?: defaultOffset)
|
|
|
|
var underlinePath: Path? = null
|
|
var effect: PathEffect? = null
|
|
|
|
when (decoStyle) {
|
|
"wavy" -> {
|
|
underlinePath = Path()
|
|
underlinePath.moveTo(minX, y)
|
|
val waveLength = 4.dp.toPx()
|
|
val amplitude = 1.dp.toPx()
|
|
var currentX = minX
|
|
var isUp = true
|
|
|
|
while (currentX < maxX) {
|
|
val nextX = minOf(currentX + waveLength / 2f, maxX)
|
|
val midX = currentX + (nextX - currentX) / 2f
|
|
val cpY = if (isUp) y - amplitude else y + amplitude
|
|
underlinePath.quadraticTo(midX, cpY, nextX, y)
|
|
currentX = nextX
|
|
isUp = !isUp
|
|
}
|
|
}
|
|
"dashed" -> {
|
|
effect = PathEffect.dashPathEffect(floatArrayOf(4.dp.toPx(), 4.dp.toPx()))
|
|
}
|
|
"dotted" -> {
|
|
effect = PathEffect.dashPathEffect(floatArrayOf(1f, 4.dp.toPx()))
|
|
}
|
|
}
|
|
|
|
lines.add(UnderlineDrawInfo(underlinePath, effect, minX, maxX, y, decoStyle, decoColor))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
val duration = System.currentTimeMillis() - startTime
|
|
if (duration > 5) {
|
|
Timber.tag("DecorationsDiag").w("Calculated custom underlines for block ${block.blockIndex} in ${duration}ms")
|
|
}
|
|
lines
|
|
}
|
|
|
|
val customDrawer = Modifier.drawBehind {
|
|
val drawStartTime = System.currentTimeMillis()
|
|
|
|
textLayoutResult?.let { layoutResult ->
|
|
if (activeSelection != null) {
|
|
val currentBlockAbs = getTextBlockCharOffset(block)
|
|
val isSelectedOnPage = isBlockSelectedOnPage(block, pageIndex, activeSelection)
|
|
val isStart =
|
|
pageIndex == activeSelection.startPageIndex &&
|
|
block.blockIndex == activeSelection.startBlockIndex &&
|
|
currentBlockAbs == activeSelection.startBlockCharOffset
|
|
val isEnd =
|
|
pageIndex == activeSelection.endPageIndex &&
|
|
block.blockIndex == activeSelection.endBlockIndex &&
|
|
currentBlockAbs == activeSelection.endBlockCharOffset
|
|
|
|
if (isSelectedOnPage) {
|
|
val sOffset = if (isStart) activeSelection.startOffset else 0
|
|
val eOffset = if (isEnd) activeSelection.endOffset else layoutResult.layoutInput.text.length
|
|
|
|
if (sOffset < eOffset) {
|
|
try {
|
|
val path = layoutResult.getPathForRange(sOffset, eOffset)
|
|
drawPath(path, Color(0xFF1976D2).copy(alpha = 0.3f))
|
|
} catch (e: Exception) {
|
|
Timber.tag("DecorationsDiag").e(e, "Highlight path out of bounds")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
cachedHighlights.forEach { (path, color) ->
|
|
drawPath(path, color, blendMode = BlendMode.SrcOver)
|
|
}
|
|
|
|
cachedEmphasisMarks.forEach { mark ->
|
|
drawCircle(mark.color, mark.radius, mark.center, style = Stroke(1f))
|
|
}
|
|
|
|
cachedUnderlines.forEach { line ->
|
|
when (line.decoStyle) {
|
|
"wavy" -> {
|
|
line.path?.let { p ->
|
|
drawPath(p, color = line.decoColor, style = Stroke(width = 1.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round))
|
|
}
|
|
}
|
|
"dashed", "dotted" -> {
|
|
drawLine(
|
|
color = line.decoColor,
|
|
start = Offset(line.minX, line.y),
|
|
end = Offset(line.maxX, line.y),
|
|
strokeWidth = if (line.decoStyle == "dotted") 2.dp.toPx() else 1.dp.toPx(),
|
|
cap = if (line.decoStyle == "dotted") StrokeCap.Round else StrokeCap.Butt,
|
|
pathEffect = line.effect
|
|
)
|
|
}
|
|
else -> { // Solid or Double
|
|
drawLine(
|
|
color = line.decoColor,
|
|
start = Offset(line.minX, line.y),
|
|
end = Offset(line.maxX, line.y),
|
|
strokeWidth = 1.dp.toPx()
|
|
)
|
|
if (line.decoStyle == "double") {
|
|
drawLine(
|
|
color = line.decoColor,
|
|
start = Offset(line.minX, line.y + 2.dp.toPx()),
|
|
end = Offset(line.maxX, line.y + 2.dp.toPx()),
|
|
strokeWidth = 1.dp.toPx()
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
val drawDuration = System.currentTimeMillis() - drawStartTime
|
|
if (drawDuration > 5) {
|
|
Timber.tag("DecorationsDiag").w("Modifier.drawBehind took ${drawDuration}ms for block ${block.blockIndex}")
|
|
}
|
|
}
|
|
|
|
fun getHighlightAt(offset: Offset, layout: TextLayoutResult): Pair<UserHighlight, Rect>? {
|
|
if (block.cfi == null) return null
|
|
|
|
// Optimization: Quick bounds check
|
|
val charOffset = layout.getOffsetForPosition(offset)
|
|
val lineIndex = layout.getLineForOffset(charOffset)
|
|
val lineLeft = layout.getLineLeft(lineIndex)
|
|
val lineRight = layout.getLineRight(lineIndex)
|
|
if (offset.x < minOf(lineLeft, lineRight) - 50 || offset.x > maxOf(
|
|
lineLeft, lineRight
|
|
) + 50
|
|
) {
|
|
return null
|
|
}
|
|
|
|
// Iterate highlights reversed (topmost first)
|
|
for (highlight in userHighlights.reversed()) {
|
|
val range = getHighlightOffsetsInBlock(block, highlight) ?: continue
|
|
|
|
if (charOffset in range) {
|
|
val path = layout.getPathForRange(range.first, range.last)
|
|
val bounds = path.getBounds()
|
|
return highlight to bounds
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
Text(text = text, style = style, modifier = modifier
|
|
.onGloballyPositioned {
|
|
layoutCoordinates = it
|
|
if (textLayoutResult != null && block.cfi != null) {
|
|
onRegisterLayout?.invoke(textLayoutResult!!, it)
|
|
}
|
|
}
|
|
.then(customDrawer)
|
|
.pointerInput(userHighlights, text) {
|
|
detectTapGestures(
|
|
onLongPress = { offset ->
|
|
textLayoutResult?.let { layout ->
|
|
val charOffset = layout.getOffsetForPosition(offset)
|
|
val wordBoundary = layout.getWordBoundary(charOffset)
|
|
|
|
var start = wordBoundary.start
|
|
var end = wordBoundary.end
|
|
|
|
val textStr = text.text
|
|
while (start < end && start < textStr.length && !textStr[start].isLetterOrDigit()) start++
|
|
while (end > start && end <= textStr.length && !textStr[end - 1].isLetterOrDigit()) end--
|
|
|
|
if (start < end && block.cfi != null) {
|
|
layoutCoordinates?.let { coords ->
|
|
if (coords.isAttached) {
|
|
val maxIdx = maxOf(0, textStr.length - 1)
|
|
val startBox = layout.getBoundingBox(start.coerceIn(0, maxIdx))
|
|
val endBox = layout.getBoundingBox((end - 1).coerceIn(0, maxIdx))
|
|
|
|
val topLeftWin = coords.localToWindow(startBox.topLeft)
|
|
val bottomRightWin = coords.localToWindow(endBox.bottomRight)
|
|
|
|
val selText = textStr.substring(start, end)
|
|
|
|
val startBlockAbs = when (block) {
|
|
is ParagraphBlock -> block.startCharOffsetInSource
|
|
is HeaderBlock -> block.startCharOffsetInSource
|
|
is QuoteBlock -> block.startCharOffsetInSource
|
|
is ListItemBlock -> block.startCharOffsetInSource
|
|
}
|
|
|
|
onSelectionChange(
|
|
PaginatedSelection(
|
|
startBlockIndex = block.blockIndex,
|
|
endBlockIndex = block.blockIndex,
|
|
startBaseCfi = block.cfi!!,
|
|
endBaseCfi = block.cfi!!,
|
|
startOffset = start,
|
|
endOffset = end,
|
|
text = selText,
|
|
rect = Rect(topLeftWin, bottomRightWin),
|
|
startPageIndex = pageIndex,
|
|
endPageIndex = pageIndex,
|
|
startBlockCharOffset = startBlockAbs,
|
|
endBlockCharOffset = startBlockAbs,
|
|
textPerBlock = mapOf(
|
|
buildSelectionBlockKey(
|
|
pageIndex = pageIndex,
|
|
blockIndex = block.blockIndex,
|
|
blockCharOffset = startBlockAbs
|
|
) to selText
|
|
)
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
onTap = { offset ->
|
|
textLayoutResult?.let { layout ->
|
|
val hit = getHighlightAt(offset, layout)
|
|
if (hit != null) {
|
|
val (highlight, localRect) = hit
|
|
val globalRect = layoutCoordinates?.let { coords ->
|
|
if (coords.isAttached) {
|
|
val topLeft = coords.localToWindow(localRect.topLeft)
|
|
val bottomRight = coords.localToWindow(localRect.bottomRight)
|
|
Rect(topLeft, bottomRight)
|
|
} else null
|
|
} ?: localRect
|
|
onHighlightClick(highlight, globalRect)
|
|
return@detectTapGestures
|
|
}
|
|
|
|
val charOffset = layout.getOffsetForPosition(offset)
|
|
val urlAnnotation = text.getStringAnnotations("URL", charOffset, charOffset).firstOrNull()
|
|
if (urlAnnotation != null) onLinkClick(urlAnnotation.item)
|
|
else onGeneralTap(offset)
|
|
}
|
|
}
|
|
)
|
|
}, onTextLayout = {
|
|
textLayoutResult = it
|
|
if (layoutCoordinates != null && block.cfi != null) {
|
|
onRegisterLayout?.invoke(it, layoutCoordinates!!)
|
|
}
|
|
})
|
|
}
|
|
|
|
@SuppressLint("BinaryOperationInTimber")
|
|
private fun checkLayoutMismatch(
|
|
blockIndex: Int,
|
|
blockType: String,
|
|
expectedHeight: Int,
|
|
actualHeight: Int,
|
|
textSnippet: String,
|
|
diagnostics: String = "",
|
|
@Suppress("SameParameterValue") tolerance: Int = 2
|
|
) {
|
|
if (expectedHeight == 0) {
|
|
Timber.tag("PAGINATION_MISMATCH").w(
|
|
"Block #$blockIndex ($blockType) has expectedHeight=0. Skipping check. Text: '$textSnippet'" +
|
|
if (diagnostics.isNotBlank()) "\n -> Diagnostics: $diagnostics" else ""
|
|
)
|
|
return
|
|
}
|
|
|
|
if (actualHeight > expectedHeight + tolerance) {
|
|
val diff = actualHeight - expectedHeight
|
|
Timber.tag("PAGINATION_MISMATCH").e(
|
|
"OVERFLOW DETECTED! Block #$blockIndex ($blockType)\n" +
|
|
" -> Expected: ${expectedHeight}px\n" +
|
|
" -> Actual: ${actualHeight}px\n" +
|
|
" -> Diff: +${diff}px\n" +
|
|
" -> Content: '$textSnippet'" +
|
|
if (diagnostics.isNotBlank()) "\n -> Diagnostics: $diagnostics" else ""
|
|
)
|
|
}
|
|
}
|
|
|
|
@Suppress("unused")
|
|
@SuppressLint("UnusedBoxWithConstraintsScope")
|
|
@OptIn(ExperimentalFoundationApi::class)
|
|
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
|
@Composable
|
|
internal fun PaginatedReaderContent(
|
|
uiState: PaginatedReaderUiState,
|
|
pagerState: PagerState,
|
|
isPageTurnAnimationEnabled: Boolean,
|
|
isRightToLeftPagination: Boolean = false,
|
|
effectiveBg: Color,
|
|
effectiveText: Color,
|
|
searchQuery: String,
|
|
ttsHighlightInfo: TtsHighlightInfo?,
|
|
textStyle: TextStyle,
|
|
imageSizeMultiplier: Float,
|
|
horizontalPadding: Dp,
|
|
verticalPadding: Dp,
|
|
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,
|
|
onShowDictionaryUpsellDialog: () -> Unit,
|
|
onWordSelectedForAiDefinition: (String) -> Unit,
|
|
onTranslate: (String) -> Unit,
|
|
onSearch: (String) -> Unit,
|
|
onStartTtsFromSelection: (String, Int) -> Unit,
|
|
onNoteRequested: (String?) -> Unit,
|
|
onGetChapterInfo: (Int) -> Pair<String, Int?>?,
|
|
userHighlights: List<UserHighlight>,
|
|
onHighlightCreated: (String, String, String) -> Unit,
|
|
onHighlightDeleted: (String) -> Unit,
|
|
activeHighlightPalette: List<HighlightColor>,
|
|
onUpdatePalette: (Int, HighlightColor) -> Unit,
|
|
isDarkTheme: Boolean,
|
|
pageTextureModifier: Modifier = Modifier,
|
|
pageTextureBitmap: ImageBitmap? = null,
|
|
pageTextureAlpha: Float = 0f
|
|
) {
|
|
val coroutineScope = rememberCoroutineScope()
|
|
val density = LocalDensity.current
|
|
var showExternalLinkDialog by remember { mutableStateOf<String?>(null) }
|
|
val context = LocalContext.current
|
|
val imageLoader = context.imageLoader
|
|
val textMeasurer = rememberTextMeasurer()
|
|
var activeSelection by remember { mutableStateOf<PaginatedSelection?>(null) }
|
|
|
|
if (showExternalLinkDialog != null) {
|
|
val urlToShow = showExternalLinkDialog!!
|
|
AlertDialog(
|
|
onDismissRequest = { showExternalLinkDialog = null },
|
|
title = { Text(stringResource(R.string.dialog_external_link_title)) },
|
|
text = {
|
|
Text(
|
|
stringResource(R.string.dialog_external_link_desc, urlToShow)
|
|
)
|
|
},
|
|
confirmButton = {
|
|
Row(horizontalArrangement = Arrangement.End) {
|
|
TextButton(
|
|
onClick = {
|
|
val clipboard =
|
|
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
|
val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_link), urlToShow)
|
|
clipboard.setPrimaryClip(clip)
|
|
showExternalLinkDialog = null
|
|
}) { Text(stringResource(R.string.action_copy)) }
|
|
TextButton(
|
|
onClick = {
|
|
val intent = Intent(Intent.ACTION_VIEW, urlToShow.toUri())
|
|
try {
|
|
context.startActivity(intent)
|
|
} catch (e: ActivityNotFoundException) {
|
|
Timber.e(
|
|
e, "No activity found to handle intent for URL: $urlToShow"
|
|
)
|
|
Toast.makeText(
|
|
context, context.getString(R.string.error_no_browser), Toast.LENGTH_LONG
|
|
).show()
|
|
}
|
|
showExternalLinkDialog = null
|
|
}) { Text(stringResource(R.string.action_open)) }
|
|
}
|
|
},
|
|
dismissButton = {
|
|
TextButton(onClick = { showExternalLinkDialog = null }) { Text(stringResource(R.string.action_cancel)) }
|
|
})
|
|
}
|
|
|
|
var pageTurnTouchY by remember { mutableStateOf<Float?>(null) }
|
|
var lastKnownSelectionRect by remember { mutableStateOf<Pair<Rect, Int>?>(null) }
|
|
|
|
LaunchedEffect(activeSelection) {
|
|
if (activeSelection != null && activeSelection!!.rect != Rect.Zero) {
|
|
lastKnownSelectionRect = activeSelection!!.rect to pagerState.currentPage
|
|
}
|
|
}
|
|
|
|
val blockLayoutMap = remember {
|
|
ReactiveBlockMap()
|
|
}
|
|
var showColorPickerDialog by remember { mutableStateOf<Int?>(null) }
|
|
|
|
var showPaletteManager by remember { mutableStateOf(false) }
|
|
var pagerWindowBounds by remember { mutableStateOf(Rect.Zero) }
|
|
val hapticFeedback = LocalHapticFeedback.current
|
|
var isDraggingHandle by remember { mutableStateOf(false) }
|
|
var pendingCrossPageSelection by remember { mutableStateOf<PendingCrossPageSelection?>(null) }
|
|
var crossPageTriggerInfo by remember { mutableStateOf<Pair<Int, String>?>(null) }
|
|
|
|
LaunchedEffect(pagerState) {
|
|
var previousPage = pagerState.currentPage
|
|
snapshotFlow { pagerState.currentPage }.collect { newPage ->
|
|
if (newPage == previousPage + 1) {
|
|
if (crossPageTriggerInfo != null && crossPageTriggerInfo!!.first == previousPage) {
|
|
pendingCrossPageSelection = PendingCrossPageSelection(fromPageIndex = previousPage)
|
|
Timber.d("CrossPageSelection: Strict bottom trigger activated, queued for page $newPage")
|
|
}
|
|
} else if (newPage != previousPage) {
|
|
pendingCrossPageSelection = null
|
|
}
|
|
crossPageTriggerInfo = null
|
|
previousPage = newPage
|
|
}
|
|
}
|
|
|
|
if (uiState.isLoading) {
|
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
CircularProgressIndicator()
|
|
}
|
|
} else {
|
|
Timber.tag("ReflowPaginationDiag").d("PaginatedReaderContent: isLoading=false, totalPageCount=${uiState.totalPageCount}")
|
|
if (uiState.totalPageCount > 0) {
|
|
uiState.generation
|
|
|
|
var rootCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
|
var magnifierCenter by remember { mutableStateOf(Offset.Unspecified) }
|
|
|
|
val magnifierModifier = if (magnifierCenter.isSpecified) {
|
|
Modifier.magnifier(
|
|
sourceCenter = { magnifierCenter },
|
|
zoom = 1.5f,
|
|
size = DpSize(140.dp, 48.dp),
|
|
cornerRadius = 24.dp,
|
|
elevation = 4.dp
|
|
)
|
|
} else Modifier
|
|
|
|
Box(modifier = Modifier.fillMaxSize().onGloballyPositioned { rootCoords = it }.then(magnifierModifier)) {
|
|
run {
|
|
HorizontalPager(
|
|
state = pagerState,
|
|
modifier = Modifier.fillMaxSize().onGloballyPositioned { coords ->
|
|
pagerWindowBounds =
|
|
Rect(coords.positionInWindow(), coords.size.toSize())
|
|
}.pointerInput(Unit) {
|
|
awaitPointerEventScope {
|
|
while (true) {
|
|
val event = awaitPointerEvent(PointerEventPass.Initial)
|
|
val down = event.changes.firstOrNull { it.pressed }
|
|
if (down != null) {
|
|
pageTurnTouchY = down.position.y
|
|
}
|
|
}
|
|
}
|
|
},
|
|
beyondViewportPageCount = 1,
|
|
reverseLayout = isRightToLeftPagination
|
|
) { pageIndex ->
|
|
val pageOffset =
|
|
(pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
|
|
val zIndex = -pageOffset
|
|
|
|
val pageModifier = if (isPageTurnAnimationEnabled) {
|
|
Modifier.zIndex(zIndex).realisticBookPage(
|
|
pagerState,
|
|
pageIndex,
|
|
effectiveBg,
|
|
isDarkTheme,
|
|
pageTurnTouchY,
|
|
pageTextureBitmap,
|
|
pageTextureAlpha
|
|
)
|
|
} else Modifier
|
|
|
|
var pageContent by remember { mutableStateOf<Page?>(null) }
|
|
var currentChapterPath by remember { mutableStateOf<String?>(null) }
|
|
val themedPageContent = remember(pageContent, isDarkTheme, effectiveBg, effectiveText) {
|
|
pageContent?.applyReaderThemeForDisplay(
|
|
isDarkTheme = isDarkTheme,
|
|
themeBackgroundColor = effectiveBg,
|
|
themeTextColor = effectiveText
|
|
)
|
|
}
|
|
|
|
LaunchedEffect(pageIndex, uiState.generation) {
|
|
val fetchStartTime = System.currentTimeMillis()
|
|
Timber.tag("PageTurnDiag").d("Page $pageIndex: Starting content fetch")
|
|
|
|
pageContent = onGetPage(pageIndex)
|
|
|
|
val fetchDuration = System.currentTimeMillis() - fetchStartTime
|
|
Timber.tag("PageTurnDiag").d("Page $pageIndex: Content fetched in ${fetchDuration}ms")
|
|
|
|
onGetChapterPath(pageIndex)?.let { currentChapterPath = it }
|
|
}
|
|
|
|
SideEffect {
|
|
Timber.tag("PageTurnDiag").v("Page $pageIndex: Re-composing content area")
|
|
}
|
|
|
|
val textBlocksOnPage =
|
|
themedPageContent?.content?.extractTextBlocks()
|
|
?.filter { it.cfi != null } ?: emptyList()
|
|
val lastTextBlock = textBlocksOnPage.lastOrNull()
|
|
val lastBlockAbs = lastTextBlock?.let {
|
|
when (it) {
|
|
is ParagraphBlock -> it.startCharOffsetInSource
|
|
is HeaderBlock -> it.startCharOffsetInSource
|
|
is QuoteBlock -> it.startCharOffsetInSource
|
|
is ListItemBlock -> it.startCharOffsetInSource
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(activeSelection, lastTextBlock, isDraggingHandle) {
|
|
if (isDraggingHandle && activeSelection != null && lastTextBlock != null &&
|
|
activeSelection!!.endPageIndex == pageIndex &&
|
|
activeSelection!!.endBlockIndex == lastTextBlock.blockIndex &&
|
|
activeSelection!!.endBlockCharOffset == lastBlockAbs) {
|
|
if (activeSelection!!.endOffset >= lastTextBlock.content.text.length - 3) {
|
|
if (crossPageTriggerInfo?.first != pageIndex) {
|
|
Timber.tag("TextSelectionDiag")
|
|
.d("Cross-page trigger ACTIVATED. Selection at bottom-right of page $pageIndex.")
|
|
crossPageTriggerInfo = pageIndex to lastTextBlock.cfi!!
|
|
}
|
|
return@LaunchedEffect
|
|
}
|
|
}
|
|
if (!isDraggingHandle && activeSelection == null && crossPageTriggerInfo?.first == pageIndex) {
|
|
Timber.tag("TextSelectionDiag")
|
|
.d("Cross-page trigger CLEARED on page $pageIndex (Custom).")
|
|
crossPageTriggerInfo = null
|
|
}
|
|
}
|
|
|
|
// Smart Cross-page selection logic
|
|
LaunchedEffect(pendingCrossPageSelection, pageContent) {
|
|
val pending = pendingCrossPageSelection ?: return@LaunchedEffect
|
|
if (pageIndex != pending.fromPageIndex + 1) return@LaunchedEffect
|
|
val content = pageContent ?: return@LaunchedEffect
|
|
|
|
val firstTextBlock =
|
|
content.content.extractTextBlocks()
|
|
.firstOrNull { it.cfi != null } ?: run {
|
|
pendingCrossPageSelection = null
|
|
return@LaunchedEffect
|
|
}
|
|
|
|
var layoutInfo: Triple<TextLayoutResult, LayoutCoordinates, TextContentBlock>? =
|
|
null
|
|
for (i in 0 until 20) {
|
|
layoutInfo = blockLayoutMap["${firstTextBlock.cfi}_$pageIndex"]
|
|
if (layoutInfo != null && layoutInfo.second.isAttached) break
|
|
delay(50)
|
|
}
|
|
|
|
if (layoutInfo == null || !layoutInfo.second.isAttached) {
|
|
pendingCrossPageSelection = null
|
|
return@LaunchedEffect
|
|
}
|
|
|
|
val text = firstTextBlock.content.text
|
|
if (text.isEmpty()) {
|
|
pendingCrossPageSelection = null
|
|
return@LaunchedEffect
|
|
}
|
|
|
|
// Smart boundary logic (>10 chars & ends at a word)
|
|
var endIndex = minOf(text.length, 10)
|
|
if (text.length > 10) {
|
|
for (i in 10 until text.length) {
|
|
if (text[i].isWhitespace() || !text[i].isLetterOrDigit()) {
|
|
endIndex = i
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
val path = layoutInfo.first.getPathForRange(0, endIndex)
|
|
val localRect = path.getBounds()
|
|
val windowTopLeft =
|
|
layoutInfo.second.localToWindow(localRect.topLeft)
|
|
val windowBottomRight =
|
|
layoutInfo.second.localToWindow(localRect.bottomRight)
|
|
|
|
val previousSel = activeSelection
|
|
|
|
val firstTextBlockAbs = when (firstTextBlock) {
|
|
is ParagraphBlock -> firstTextBlock.startCharOffsetInSource
|
|
is HeaderBlock -> firstTextBlock.startCharOffsetInSource
|
|
is QuoteBlock -> firstTextBlock.startCharOffsetInSource
|
|
is ListItemBlock -> firstTextBlock.startCharOffsetInSource
|
|
}
|
|
|
|
val newTextPerBlock = (previousSel?.textPerBlock ?: emptyMap()).toMutableMap()
|
|
newTextPerBlock[
|
|
buildSelectionBlockKey(
|
|
pageIndex = pageIndex,
|
|
blockIndex = firstTextBlock.blockIndex,
|
|
blockCharOffset = firstTextBlockAbs
|
|
)
|
|
] = text.substring(0, endIndex)
|
|
|
|
val newText = newTextPerBlock.entries
|
|
.sortedWith { first, second ->
|
|
compareSelectionBlockKeys(first.key, second.key)
|
|
}
|
|
.joinToString(" ") { it.value }
|
|
|
|
activeSelection = PaginatedSelection(
|
|
startBlockIndex = previousSel?.startBlockIndex ?: firstTextBlock.blockIndex,
|
|
endBlockIndex = firstTextBlock.blockIndex,
|
|
startBaseCfi = previousSel?.startBaseCfi ?: firstTextBlock.cfi!!,
|
|
endBaseCfi = firstTextBlock.cfi!!,
|
|
startOffset = previousSel?.startOffset ?: 0,
|
|
endOffset = endIndex,
|
|
text = newText,
|
|
rect = Rect(windowTopLeft, windowBottomRight),
|
|
startPageIndex = previousSel?.startPageIndex ?: pending.fromPageIndex,
|
|
endPageIndex = pageIndex,
|
|
startBlockCharOffset = previousSel?.startBlockCharOffset ?: firstTextBlockAbs,
|
|
endBlockCharOffset = firstTextBlockAbs,
|
|
textPerBlock = newTextPerBlock
|
|
)
|
|
} catch (e: Exception) {
|
|
Timber.e(e, "CrossPageSelection: Failed to create selection")
|
|
}
|
|
|
|
pendingCrossPageSelection = null
|
|
}
|
|
|
|
Box(modifier = Modifier.fillMaxSize().background(effectiveBg).then(pageTextureModifier).then(pageModifier)) {
|
|
Box(modifier = Modifier.fillMaxSize()) {
|
|
Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) {
|
|
detectTapGestures(
|
|
onTap = { offset ->
|
|
Timber.d("Tap detected on empty page area.")
|
|
activeSelection = null
|
|
onTap(offset)
|
|
})
|
|
}.padding(
|
|
horizontal = horizontalPadding,
|
|
vertical = verticalPadding
|
|
), contentAlignment = Alignment.TopStart) {
|
|
if (themedPageContent != null) {
|
|
val displayPage = themedPageContent
|
|
val onGeneralTapCallback: (Offset) -> Unit = { offset ->
|
|
activeSelection = null
|
|
onTap(offset)
|
|
}
|
|
val onLinkClickCallback: (String) -> Unit = { href ->
|
|
Timber.d("Link clicked: $href")
|
|
if (href.startsWith("http://") || href.startsWith("https://")) {
|
|
showExternalLinkDialog = href
|
|
} 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Column(modifier = Modifier.fillMaxSize()) {
|
|
val searchHighlightColor =
|
|
MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)
|
|
val ttsHighlightColor =
|
|
MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f)
|
|
|
|
displayPage.content.forEach { block ->
|
|
val marginModifier = Modifier.padding(
|
|
top = block.style.margin.top.coerceAtLeast(0.dp),
|
|
bottom = block.style.margin.bottom.coerceAtLeast(
|
|
0.dp
|
|
)
|
|
)
|
|
|
|
val alignModifier =
|
|
if (block.style.horizontalAlign == "center") {
|
|
Modifier.align(Alignment.CenterHorizontally)
|
|
} else {
|
|
Modifier.padding(
|
|
start = block.style.margin.left.coerceAtLeast(
|
|
0.dp
|
|
),
|
|
end = block.style.margin.right.coerceAtLeast(
|
|
0.dp
|
|
)
|
|
)
|
|
}
|
|
|
|
val widthModifier =
|
|
if (block.style.width != Dp.Unspecified) {
|
|
Modifier.width(block.style.width)
|
|
} else {
|
|
Modifier.fillMaxWidth()
|
|
}
|
|
|
|
val styleModifier =
|
|
alignModifier.then(if (block.style.horizontalAlign == "center") widthModifier else Modifier)
|
|
.drawCssBorders(
|
|
blockStyle = block.style,
|
|
density = density
|
|
)
|
|
|
|
val diagnosticModifier =
|
|
Modifier.onGloballyPositioned { coordinates ->
|
|
val actualHeight =
|
|
coordinates.size.height
|
|
if (block.expectedHeight > 0) {
|
|
val snippet = when (block) {
|
|
is ParagraphBlock -> block.content.text.take(
|
|
50
|
|
)
|
|
|
|
is HeaderBlock -> block.content.text.take(
|
|
50
|
|
)
|
|
|
|
is QuoteBlock -> block.content.text.take(
|
|
50
|
|
)
|
|
|
|
is ListItemBlock -> block.content.text.take(
|
|
50
|
|
)
|
|
|
|
is TextContentBlock -> block.content.text.take(
|
|
50
|
|
)
|
|
|
|
else -> "Non-text content"
|
|
}
|
|
|
|
checkLayoutMismatch(
|
|
blockIndex = block.blockIndex,
|
|
blockType = block::class.simpleName
|
|
?: "Block",
|
|
expectedHeight = block.expectedHeight,
|
|
actualHeight = actualHeight,
|
|
textSnippet = snippet,
|
|
diagnostics = buildString {
|
|
append("page=")
|
|
append(pageIndex)
|
|
append(", width=")
|
|
append(coordinates.size.width)
|
|
append("px, styleWidth=")
|
|
append(block.style.width)
|
|
append(", maxWidth=")
|
|
append(block.style.maxWidth)
|
|
append(", margin=")
|
|
append(block.style.margin)
|
|
append(", padding=")
|
|
append(block.style.padding)
|
|
append(", borders=(")
|
|
append(block.style.borderLeft?.width ?: 0.dp)
|
|
append(", ")
|
|
append(block.style.borderTop?.width ?: 0.dp)
|
|
append(", ")
|
|
append(block.style.borderRight?.width ?: 0.dp)
|
|
append(", ")
|
|
append(block.style.borderBottom?.width ?: 0.dp)
|
|
append(")")
|
|
when (block) {
|
|
is ParagraphBlock -> {
|
|
append(", start=")
|
|
append(block.startCharOffsetInSource)
|
|
append(", end=")
|
|
append(block.endCharOffsetInSource)
|
|
append(", chars=")
|
|
append(block.content.length)
|
|
append(", textAlign=")
|
|
append(block.textAlign)
|
|
}
|
|
|
|
is HeaderBlock -> {
|
|
append(", start=")
|
|
append(block.startCharOffsetInSource)
|
|
append(", end=")
|
|
append(block.endCharOffsetInSource)
|
|
append(", chars=")
|
|
append(block.content.length)
|
|
append(", textAlign=")
|
|
append(block.textAlign)
|
|
}
|
|
|
|
is QuoteBlock -> {
|
|
append(", start=")
|
|
append(block.startCharOffsetInSource)
|
|
append(", end=")
|
|
append(block.endCharOffsetInSource)
|
|
append(", chars=")
|
|
append(block.content.length)
|
|
append(", textAlign=")
|
|
append(block.textAlign)
|
|
}
|
|
|
|
is ListItemBlock -> {
|
|
append(", start=")
|
|
append(block.startCharOffsetInSource)
|
|
append(", end=")
|
|
append(block.endCharOffsetInSource)
|
|
append(", chars=")
|
|
append(block.content.length)
|
|
}
|
|
|
|
is TextContentBlock -> {
|
|
append(", chars=")
|
|
append(block.content.length)
|
|
}
|
|
|
|
else -> Unit
|
|
}
|
|
},
|
|
tolerance = 2
|
|
)
|
|
}
|
|
}.then(marginModifier).then(styleModifier)
|
|
|
|
Box(modifier = diagnosticModifier) {
|
|
val paddingModifier = Modifier.padding(
|
|
start = block.style.padding.left.coerceAtLeast(
|
|
0.dp
|
|
) + (block.style.borderLeft?.width ?: 0.dp),
|
|
top = block.style.padding.top.coerceAtLeast(
|
|
0.dp
|
|
) + (block.style.borderTop?.width ?: 0.dp),
|
|
end = block.style.padding.right.coerceAtLeast(
|
|
0.dp
|
|
) + (block.style.borderRight?.width
|
|
?: 0.dp),
|
|
bottom = block.style.padding.bottom.coerceAtLeast(
|
|
0.dp
|
|
) + (block.style.borderBottom?.width
|
|
?: 0.dp)
|
|
).then(
|
|
if (block.style.horizontalAlign != "center") widthModifier else Modifier.fillMaxWidth()
|
|
)
|
|
|
|
@Suppress("DEPRECATION") when (block) {
|
|
is ParagraphBlock -> {
|
|
val paragraphStyle = textStyle.copy(
|
|
textAlign = block.textAlign
|
|
?: textStyle.textAlign
|
|
)
|
|
val searchHighlighted =
|
|
highlightQueryInText(
|
|
block.content,
|
|
searchQuery,
|
|
searchHighlightColor
|
|
)
|
|
val finalContent =
|
|
if (ttsHighlightInfo != null && block.cfi == ttsHighlightInfo.cfi) {
|
|
buildAnnotatedString {
|
|
append(searchHighlighted)
|
|
|
|
// Define absolute ranges
|
|
val blockStartAbs =
|
|
block.startCharOffsetInSource
|
|
val blockEndAbs =
|
|
block.startCharOffsetInSource + searchHighlighted.length
|
|
val highlightStartAbs =
|
|
ttsHighlightInfo.offset
|
|
val highlightEndAbs =
|
|
ttsHighlightInfo.offset + ttsHighlightInfo.text.length
|
|
|
|
// Calculate intersection
|
|
val intersectionStartAbs =
|
|
maxOf(
|
|
blockStartAbs,
|
|
highlightStartAbs
|
|
)
|
|
val intersectionEndAbs =
|
|
minOf(
|
|
blockEndAbs,
|
|
highlightEndAbs
|
|
)
|
|
|
|
// Check for overlap and apply
|
|
// style
|
|
if (intersectionStartAbs < intersectionEndAbs) {
|
|
val highlightStartRelative =
|
|
intersectionStartAbs - blockStartAbs
|
|
val highlightEndRelative =
|
|
intersectionEndAbs - blockStartAbs
|
|
addStyle(
|
|
style = SpanStyle(
|
|
background = ttsHighlightColor
|
|
),
|
|
start = highlightStartRelative,
|
|
end = highlightEndRelative
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
searchHighlighted
|
|
}
|
|
|
|
@Suppress(
|
|
"UnusedVariable",
|
|
"Unused"
|
|
) val diagnosticModifier =
|
|
if (block.textAlign == TextAlign.Justify) {
|
|
Modifier.onGloballyPositioned { coordinates ->
|
|
val width =
|
|
coordinates.size.width
|
|
Timber.d(
|
|
"""
|
|
[UI Render]
|
|
Block Index: ${block.blockIndex}
|
|
Text Start: ${
|
|
block.content.text.take(
|
|
20
|
|
)
|
|
}...
|
|
Actual Render Width Px: $width
|
|
------------------------------------------------
|
|
""".trimIndent()
|
|
)
|
|
}
|
|
} else {
|
|
Modifier
|
|
}
|
|
|
|
TextWithEmphasis(
|
|
text = finalContent,
|
|
style = paragraphStyle,
|
|
modifier = paddingModifier,
|
|
pageIndex = pageIndex,
|
|
textMeasurer = textMeasurer,
|
|
onLinkClick = onLinkClickCallback,
|
|
onGeneralTap = onGeneralTapCallback,
|
|
block = block,
|
|
userHighlights = userHighlights,
|
|
activeSelection = activeSelection,
|
|
onSelectionChange = { sel ->
|
|
activeSelection = sel
|
|
},
|
|
onHighlightClick = { highlight, _ ->
|
|
onNoteRequested(highlight.cfi)
|
|
activeSelection = null
|
|
},
|
|
isDarkTheme = isDarkTheme,
|
|
onRegisterLayout = { layout, coords ->
|
|
if (block.cfi != null) blockLayoutMap["${block.cfi}_$pageIndex"] =
|
|
Triple(
|
|
layout,
|
|
coords,
|
|
block
|
|
)
|
|
})
|
|
}
|
|
|
|
is HeaderBlock -> {
|
|
val style = createHeaderTextStyle(
|
|
baseStyle = textStyle,
|
|
level = block.level,
|
|
textAlign = block.textAlign
|
|
)
|
|
val searchHighlighted =
|
|
highlightQueryInText(
|
|
block.content,
|
|
searchQuery,
|
|
searchHighlightColor
|
|
)
|
|
val finalContent =
|
|
if (ttsHighlightInfo != null && block.cfi == ttsHighlightInfo.cfi) {
|
|
buildAnnotatedString {
|
|
append(searchHighlighted)
|
|
|
|
val blockStartAbs =
|
|
block.startCharOffsetInSource
|
|
val blockEndAbs =
|
|
block.startCharOffsetInSource + searchHighlighted.length
|
|
val highlightStartAbs =
|
|
ttsHighlightInfo.offset
|
|
val highlightEndAbs =
|
|
ttsHighlightInfo.offset + ttsHighlightInfo.text.length
|
|
|
|
val intersectionStartAbs =
|
|
maxOf(
|
|
blockStartAbs,
|
|
highlightStartAbs
|
|
)
|
|
val intersectionEndAbs =
|
|
minOf(
|
|
blockEndAbs,
|
|
highlightEndAbs
|
|
)
|
|
|
|
if (intersectionStartAbs < intersectionEndAbs) {
|
|
val highlightStartRelative =
|
|
intersectionStartAbs - blockStartAbs
|
|
val highlightEndRelative =
|
|
intersectionEndAbs - blockStartAbs
|
|
addStyle(
|
|
style = SpanStyle(
|
|
background = ttsHighlightColor
|
|
),
|
|
start = highlightStartRelative,
|
|
end = highlightEndRelative
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
searchHighlighted
|
|
}
|
|
TextWithEmphasis(
|
|
text = finalContent,
|
|
style = style,
|
|
modifier = paddingModifier,
|
|
pageIndex = pageIndex,
|
|
textMeasurer = textMeasurer,
|
|
onLinkClick = onLinkClickCallback,
|
|
onGeneralTap = onGeneralTapCallback,
|
|
block = block,
|
|
userHighlights = userHighlights,
|
|
activeSelection = activeSelection,
|
|
onSelectionChange = { sel ->
|
|
activeSelection = sel
|
|
},
|
|
onHighlightClick = { highlight, _ ->
|
|
onNoteRequested(
|
|
highlight.cfi
|
|
)
|
|
activeSelection = null
|
|
},
|
|
isDarkTheme = isDarkTheme,
|
|
onRegisterLayout = { layout, coords ->
|
|
if (block.cfi != null) blockLayoutMap["${block.cfi}_$pageIndex"] =
|
|
Triple(
|
|
layout,
|
|
coords,
|
|
block
|
|
)
|
|
})
|
|
}
|
|
|
|
is QuoteBlock -> {
|
|
val quoteStyle = textStyle.copy(
|
|
textAlign = block.textAlign
|
|
?: textStyle.textAlign
|
|
)
|
|
val quoteModifier =
|
|
paddingModifier.padding(start = 16.dp)
|
|
val searchHighlighted =
|
|
highlightQueryInText(
|
|
block.content,
|
|
searchQuery,
|
|
searchHighlightColor
|
|
)
|
|
val finalContent =
|
|
if (ttsHighlightInfo != null && block.cfi == ttsHighlightInfo.cfi) {
|
|
buildAnnotatedString {
|
|
append(searchHighlighted)
|
|
|
|
val blockStartAbs =
|
|
block.startCharOffsetInSource
|
|
val blockEndAbs =
|
|
block.startCharOffsetInSource + searchHighlighted.length
|
|
val highlightStartAbs =
|
|
ttsHighlightInfo.offset
|
|
val highlightEndAbs =
|
|
ttsHighlightInfo.offset + ttsHighlightInfo.text.length
|
|
|
|
val intersectionStartAbs =
|
|
maxOf(
|
|
blockStartAbs,
|
|
highlightStartAbs
|
|
)
|
|
val intersectionEndAbs =
|
|
minOf(
|
|
blockEndAbs,
|
|
highlightEndAbs
|
|
)
|
|
|
|
if (intersectionStartAbs < intersectionEndAbs) {
|
|
val highlightStartRelative =
|
|
intersectionStartAbs - blockStartAbs
|
|
val highlightEndRelative =
|
|
intersectionEndAbs - blockStartAbs
|
|
addStyle(
|
|
style = SpanStyle(
|
|
background = ttsHighlightColor
|
|
),
|
|
start = highlightStartRelative,
|
|
end = highlightEndRelative
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
searchHighlighted
|
|
}
|
|
TextWithEmphasis(
|
|
text = finalContent,
|
|
style = quoteStyle,
|
|
modifier = quoteModifier,
|
|
pageIndex = pageIndex,
|
|
textMeasurer = textMeasurer,
|
|
onLinkClick = onLinkClickCallback,
|
|
onGeneralTap = onGeneralTapCallback,
|
|
block = block,
|
|
userHighlights = userHighlights,
|
|
activeSelection = activeSelection,
|
|
onSelectionChange = { sel ->
|
|
activeSelection = sel
|
|
},
|
|
onHighlightClick = { highlight, _ ->
|
|
onNoteRequested(highlight.cfi)
|
|
activeSelection = null
|
|
},
|
|
isDarkTheme = isDarkTheme,
|
|
onRegisterLayout = { layout, coords ->
|
|
if (block.cfi != null) blockLayoutMap["${block.cfi}_$pageIndex"] =
|
|
Triple(
|
|
layout,
|
|
coords,
|
|
block
|
|
)
|
|
})
|
|
}
|
|
|
|
is ListItemBlock -> {
|
|
Row(
|
|
modifier = paddingModifier,
|
|
verticalAlignment = Alignment.Top
|
|
) {
|
|
val markerAreaModifier =
|
|
Modifier.width(32.dp)
|
|
.padding(end = 8.dp)
|
|
val itemMarkerImage = block.itemMarkerImage
|
|
val itemMarker = block.itemMarker
|
|
|
|
if (itemMarkerImage != null) {
|
|
val imageRequest =
|
|
Builder(LocalContext.current).data(
|
|
File(
|
|
itemMarkerImage
|
|
)
|
|
).crossfade(true).build()
|
|
val imageSize = with(density) {
|
|
(textStyle.fontSize.value * 0.8f).sp.toDp()
|
|
}
|
|
|
|
AsyncImage(
|
|
model = imageRequest,
|
|
contentDescription = stringResource(R.string.content_desc_list_item_marker),
|
|
modifier = markerAreaModifier.height(
|
|
imageSize
|
|
),
|
|
alignment = Alignment.CenterEnd,
|
|
contentScale = ContentScale.FillHeight
|
|
)
|
|
} else if (itemMarker != null) {
|
|
Text(
|
|
text = itemMarker,
|
|
style = textStyle.copy(
|
|
textAlign = TextAlign.End
|
|
),
|
|
modifier = markerAreaModifier
|
|
)
|
|
}
|
|
val searchHighlighted =
|
|
highlightQueryInText(
|
|
block.content,
|
|
searchQuery,
|
|
searchHighlightColor
|
|
)
|
|
val finalContent =
|
|
if (ttsHighlightInfo != null && block.cfi == ttsHighlightInfo.cfi) {
|
|
buildAnnotatedString {
|
|
append(searchHighlighted)
|
|
|
|
val blockStartAbs =
|
|
block.startCharOffsetInSource
|
|
val blockEndAbs =
|
|
block.startCharOffsetInSource + searchHighlighted.length
|
|
val highlightStartAbs =
|
|
ttsHighlightInfo.offset
|
|
val highlightEndAbs =
|
|
ttsHighlightInfo.offset + ttsHighlightInfo.text.length
|
|
|
|
val intersectionStartAbs =
|
|
maxOf(
|
|
blockStartAbs,
|
|
highlightStartAbs
|
|
)
|
|
val intersectionEndAbs =
|
|
minOf(
|
|
blockEndAbs,
|
|
highlightEndAbs
|
|
)
|
|
|
|
if (intersectionStartAbs < intersectionEndAbs) {
|
|
val highlightStartRelative =
|
|
intersectionStartAbs - blockStartAbs
|
|
val highlightEndRelative =
|
|
intersectionEndAbs - blockStartAbs
|
|
addStyle(
|
|
style = SpanStyle(
|
|
background = ttsHighlightColor
|
|
),
|
|
start = highlightStartRelative,
|
|
end = highlightEndRelative
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
searchHighlighted
|
|
}
|
|
TextWithEmphasis(
|
|
text = finalContent,
|
|
style = textStyle,
|
|
modifier = Modifier.weight(1f),
|
|
pageIndex = pageIndex,
|
|
textMeasurer = textMeasurer,
|
|
onLinkClick = onLinkClickCallback,
|
|
onGeneralTap = onGeneralTapCallback,
|
|
block = block,
|
|
userHighlights = userHighlights,
|
|
activeSelection = activeSelection,
|
|
onSelectionChange = { sel ->
|
|
activeSelection = sel
|
|
},
|
|
onHighlightClick = { highlight, _ ->
|
|
onNoteRequested(highlight.cfi)
|
|
activeSelection = null
|
|
},
|
|
isDarkTheme = isDarkTheme,
|
|
onRegisterLayout = { layout, coords ->
|
|
if (block.cfi != null) blockLayoutMap["${block.cfi}_$pageIndex"] =
|
|
Triple(
|
|
layout,
|
|
coords,
|
|
block
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
is WrappingContentBlock -> {
|
|
WrappingContentLayout(
|
|
block = block,
|
|
textStyle = textStyle,
|
|
imageSizeMultiplier = imageSizeMultiplier,
|
|
modifier = paddingModifier,
|
|
searchQuery = searchQuery,
|
|
ttsHighlightInfo = ttsHighlightInfo,
|
|
searchHighlightColor = searchHighlightColor,
|
|
ttsHighlightColor = ttsHighlightColor
|
|
)
|
|
}
|
|
|
|
is FlexContainerBlock -> {
|
|
|
|
if (block.style.flexDirection == "row") {
|
|
val horizontalArrangement =
|
|
when (block.style.justifyContent) {
|
|
"center" -> Arrangement.Center
|
|
"flex-end" -> Arrangement.End
|
|
"space-between" -> Arrangement.SpaceBetween
|
|
"space-around" -> Arrangement.SpaceAround
|
|
else -> Arrangement.Start
|
|
}
|
|
val verticalAlignment =
|
|
when (block.style.alignItems) {
|
|
"center" -> Alignment.CenterVertically
|
|
"flex-end" -> Alignment.Bottom
|
|
else -> Alignment.Top
|
|
}
|
|
Row(
|
|
modifier = paddingModifier.fillMaxWidth(),
|
|
horizontalArrangement = horizontalArrangement,
|
|
verticalAlignment = verticalAlignment
|
|
) {
|
|
block.children.forEach { childBlock ->
|
|
RenderFlexChildBlock(
|
|
childBlock = childBlock,
|
|
textStyle = textStyle,
|
|
imageSizeMultiplier = imageSizeMultiplier,
|
|
searchQuery = searchQuery,
|
|
searchHighlightColor = searchHighlightColor,
|
|
ttsHighlightInfo = ttsHighlightInfo,
|
|
ttsHighlightColor = ttsHighlightColor,
|
|
textMeasurer = textMeasurer,
|
|
onLinkClickCallback = onLinkClickCallback,
|
|
onGeneralTapCallback = onGeneralTapCallback,
|
|
userHighlights = userHighlights,
|
|
activeSelection = activeSelection,
|
|
onSelectionChange = { sel ->
|
|
activeSelection =
|
|
sel
|
|
},
|
|
onHighlightClick = { highlight, _ ->
|
|
onNoteRequested(
|
|
highlight.cfi
|
|
)
|
|
activeSelection =
|
|
null
|
|
},
|
|
isDarkTheme = isDarkTheme,
|
|
blockLayoutMap = blockLayoutMap,
|
|
density = density,
|
|
imageLoader = imageLoader,
|
|
pageIndex = pageIndex
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
val verticalArrangement =
|
|
when (block.style.justifyContent) {
|
|
"center" -> Arrangement.Center
|
|
"flex-end" -> Arrangement.Bottom
|
|
"space-between" -> Arrangement.SpaceBetween
|
|
"space-around" -> Arrangement.SpaceAround
|
|
else -> Arrangement.Top
|
|
}
|
|
val horizontalAlignment =
|
|
when (block.style.alignItems) {
|
|
"center" -> Alignment.CenterHorizontally
|
|
"flex-end" -> Alignment.End
|
|
else -> Alignment.Start
|
|
}
|
|
Column(
|
|
modifier = paddingModifier.fillMaxWidth(),
|
|
verticalArrangement = verticalArrangement,
|
|
horizontalAlignment = horizontalAlignment
|
|
) {
|
|
block.children.forEach { childBlock ->
|
|
RenderFlexChildBlock(
|
|
childBlock = childBlock,
|
|
textStyle = textStyle,
|
|
imageSizeMultiplier = imageSizeMultiplier,
|
|
searchQuery = searchQuery,
|
|
searchHighlightColor = searchHighlightColor,
|
|
ttsHighlightInfo = ttsHighlightInfo,
|
|
ttsHighlightColor = ttsHighlightColor,
|
|
textMeasurer = textMeasurer,
|
|
onLinkClickCallback = onLinkClickCallback,
|
|
onGeneralTapCallback = onGeneralTapCallback,
|
|
userHighlights = userHighlights,
|
|
activeSelection = activeSelection,
|
|
onSelectionChange = { sel ->
|
|
activeSelection =
|
|
sel
|
|
},
|
|
onHighlightClick = { highlight, _ ->
|
|
onNoteRequested(
|
|
highlight.cfi
|
|
)
|
|
activeSelection =
|
|
null
|
|
},
|
|
isDarkTheme = isDarkTheme,
|
|
blockLayoutMap = blockLayoutMap,
|
|
density = density,
|
|
imageLoader = imageLoader,
|
|
pageIndex = pageIndex
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
is MathBlock -> {
|
|
val svgContent = block.svgContent?.takeIf { it.isNotBlank() }
|
|
Timber.d(
|
|
"PaginatedReader: Rendering MathBlock. Alt: '${block.altText}', Has SVG: ${svgContent != null}"
|
|
)
|
|
if (svgContent != null) {
|
|
val nonBlankSvgContent = svgContent
|
|
BoxWithConstraints(
|
|
modifier = paddingModifier
|
|
) {
|
|
val localDensity =
|
|
LocalDensity.current
|
|
val fontSizePx =
|
|
with(localDensity) {
|
|
textStyle.fontSize.toPx()
|
|
}
|
|
val containerWidthPx =
|
|
with(localDensity) {
|
|
maxWidth.roundToPx()
|
|
}
|
|
val widthPx = parseSvgDimension(
|
|
block.svgWidth,
|
|
fontSizePx,
|
|
containerWidthPx,
|
|
localDensity
|
|
)
|
|
val heightPx =
|
|
parseSvgDimension(
|
|
block.svgHeight,
|
|
fontSizePx,
|
|
containerWidthPx,
|
|
localDensity
|
|
)
|
|
|
|
var imageModifier: Modifier =
|
|
Modifier
|
|
if (widthPx != null) {
|
|
val finalWidthDp =
|
|
with(localDensity) { widthPx.toDp() }
|
|
Timber.d("Applying calculated width to MathBlock image: $finalWidthDp")
|
|
imageModifier =
|
|
imageModifier.width(
|
|
finalWidthDp
|
|
)
|
|
} else {
|
|
Timber.w("Could not calculate a specific width for MathBlock. It will fill available space.")
|
|
imageModifier =
|
|
imageModifier.fillMaxWidth()
|
|
}
|
|
|
|
if (heightPx != null) {
|
|
val finalHeightDp =
|
|
with(localDensity) { heightPx.toDp() }
|
|
Timber.d("Applying calculated height to MathBlock image: $finalHeightDp")
|
|
imageModifier =
|
|
imageModifier.height(
|
|
finalHeightDp
|
|
)
|
|
} else {
|
|
val viewBoxParts =
|
|
block.svgViewBox?.split(
|
|
' ',
|
|
','
|
|
)
|
|
?.mapNotNull { it.toFloatOrNull() }
|
|
if (viewBoxParts != null && viewBoxParts.size == 4 && viewBoxParts[2] > 0) {
|
|
val aspectRatio =
|
|
viewBoxParts[3] / viewBoxParts[2]
|
|
val effectiveWidth =
|
|
widthPx
|
|
?: containerWidthPx.toFloat()
|
|
val finalHeightDp =
|
|
with(localDensity) { (effectiveWidth * aspectRatio).toDp() }
|
|
imageModifier =
|
|
imageModifier.height(
|
|
finalHeightDp
|
|
)
|
|
} else {
|
|
val fallbackHeightDp =
|
|
with(localDensity) { (textStyle.fontSize.value * 3).sp.toDp() }
|
|
imageModifier =
|
|
imageModifier.height(
|
|
fallbackHeightDp
|
|
)
|
|
}
|
|
}
|
|
|
|
val imageRequest =
|
|
Builder(LocalContext.current).data(
|
|
SvgData(
|
|
nonBlankSvgContent
|
|
)
|
|
).listener(
|
|
onError = { _, result ->
|
|
Timber.e(
|
|
result.throwable,
|
|
"Coil failed to load SVG for MathBlock."
|
|
)
|
|
}).build()
|
|
|
|
val colorFilter =
|
|
if (block.isFromMathJax) ColorFilter.tint(
|
|
textStyle.color
|
|
)
|
|
else null
|
|
|
|
AsyncImage(
|
|
model = imageRequest,
|
|
contentDescription = block.altText
|
|
?: "Equation",
|
|
modifier = imageModifier,
|
|
contentScale = ContentScale.Fit,
|
|
colorFilter = colorFilter,
|
|
imageLoader = imageLoader
|
|
)
|
|
}
|
|
} else {
|
|
Timber.w(
|
|
"PaginatedReader: MathBlock has no SVG content, rendering alt text."
|
|
)
|
|
Text(
|
|
text = block.altText
|
|
?: "[Equation not available]",
|
|
style = textStyle,
|
|
modifier = paddingModifier
|
|
)
|
|
}
|
|
}
|
|
|
|
is ImageBlock -> {
|
|
val style = block.style
|
|
val colorFilter =
|
|
if (block.style.filter == "invert(100%)") {
|
|
val matrix = floatArrayOf(
|
|
-1f,
|
|
0f,
|
|
0f,
|
|
0f,
|
|
255f,
|
|
0f,
|
|
-1f,
|
|
0f,
|
|
0f,
|
|
255f,
|
|
0f,
|
|
0f,
|
|
-1f,
|
|
0f,
|
|
255f,
|
|
0f,
|
|
0f,
|
|
0f,
|
|
1f,
|
|
0f
|
|
)
|
|
ColorFilter.colorMatrix(
|
|
ColorMatrix(matrix)
|
|
)
|
|
} else {
|
|
null
|
|
}
|
|
val context = LocalContext.current
|
|
val imageRequest =
|
|
Builder(context).data(File(block.path))
|
|
.listener(onSuccess = { _, _ ->
|
|
Timber.d(
|
|
"Coil successfully loaded image: ${block.path}"
|
|
)
|
|
}, onError = { _, result ->
|
|
Timber.e(
|
|
result.throwable,
|
|
"Coil FAILED to load image: ${block.path}"
|
|
)
|
|
}).crossfade(true).build()
|
|
|
|
BoxWithConstraints(
|
|
modifier = paddingModifier,
|
|
contentAlignment = imageBlockContentAlignment(style)
|
|
) {
|
|
val scaledSize = computeImageRenderSizeDp(
|
|
block = block,
|
|
density = density,
|
|
maxWidthDp = maxWidth,
|
|
imageSizeMultiplier = imageSizeMultiplier
|
|
)
|
|
val finalImageModifier = Modifier
|
|
.then(
|
|
if (scaledSize != null) {
|
|
Modifier.width(scaledSize.first).height(scaledSize.second)
|
|
} else if (style.width.isSpecified && style.width > 0.dp) {
|
|
Modifier.width(style.width)
|
|
} else {
|
|
Modifier.fillMaxWidth()
|
|
}
|
|
)
|
|
.then(
|
|
if (scaledSize == null && style.maxWidth.isSpecified && style.maxWidth > 0.dp) {
|
|
Modifier.widthIn(max = style.maxWidth)
|
|
} else {
|
|
Modifier
|
|
}
|
|
)
|
|
.then(
|
|
if (scaledSize == null) {
|
|
if (block.expectedHeight > 0) {
|
|
Modifier.height(with(density) { (block.expectedHeight * imageSizeMultiplier).toDp() })
|
|
} else {
|
|
Modifier.height(250.dp)
|
|
}
|
|
} else {
|
|
Modifier
|
|
}
|
|
)
|
|
.onGloballyPositioned { coords ->
|
|
Timber.tag("IMAGE_DIAG").v("Actual Rendered Height for [#${block.blockIndex}]: ${coords.size.height}px")
|
|
}
|
|
|
|
AsyncImage(
|
|
model = imageRequest,
|
|
contentDescription = block.altText
|
|
?: "Image from EPUB",
|
|
modifier = finalImageModifier,
|
|
contentScale = ContentScale.Fit,
|
|
colorFilter = colorFilter
|
|
)
|
|
}
|
|
}
|
|
|
|
is SpacerBlock -> {
|
|
Box(
|
|
modifier = Modifier.fillMaxWidth()
|
|
.height(block.height)
|
|
.drawCssBorders(
|
|
block.style,
|
|
density
|
|
)
|
|
)
|
|
}
|
|
|
|
is TableBlock -> {
|
|
Column(modifier = paddingModifier) {
|
|
block.rows.forEach { tableRow ->
|
|
Row(
|
|
Modifier.fillMaxWidth()
|
|
.height(
|
|
IntrinsicSize.Min
|
|
)
|
|
) {
|
|
val hasFixedWidths =
|
|
tableRow.any {
|
|
it.style.blockStyle.width != Dp.Unspecified
|
|
}
|
|
|
|
tableRow.forEach { cell ->
|
|
val cellStyle =
|
|
cell.style.blockStyle
|
|
|
|
val cellContainerModifier =
|
|
if (hasFixedWidths) {
|
|
if (cellStyle.width != Dp.Unspecified) Modifier.width(
|
|
cellStyle.width
|
|
)
|
|
else Modifier.weight(
|
|
cell.colspan.toFloat(),
|
|
fill = true
|
|
)
|
|
} else {
|
|
Modifier.weight(
|
|
cell.colspan.toFloat(),
|
|
fill = true
|
|
)
|
|
}
|
|
|
|
val alignment =
|
|
when (cell.style.paragraphStyle.textAlign) {
|
|
TextAlign.Center -> Alignment.CenterHorizontally
|
|
TextAlign.End -> Alignment.End
|
|
else -> Alignment.Start
|
|
}
|
|
|
|
val cellModifier =
|
|
cellContainerModifier.fillMaxHeight()
|
|
.then(
|
|
if (cellStyle.backgroundColor.isSpecified) {
|
|
Modifier.background(
|
|
cellStyle.backgroundColor
|
|
)
|
|
} else {
|
|
Modifier
|
|
}
|
|
)
|
|
.drawCssBorders(
|
|
cellStyle,
|
|
density
|
|
).padding(
|
|
start = cellStyle.padding.left.coerceAtLeast(
|
|
0.dp
|
|
),
|
|
top = cellStyle.padding.top.coerceAtLeast(
|
|
0.dp
|
|
),
|
|
end = cellStyle.padding.right.coerceAtLeast(
|
|
0.dp
|
|
),
|
|
bottom = cellStyle.padding.bottom.coerceAtLeast(
|
|
0.dp
|
|
)
|
|
)
|
|
|
|
Column(
|
|
modifier = cellModifier,
|
|
horizontalAlignment = alignment
|
|
) {
|
|
val cellTextStyle =
|
|
if (cell.isHeader) {
|
|
textStyle.copy(
|
|
fontWeight = FontWeight.Bold
|
|
)
|
|
} else {
|
|
textStyle
|
|
}
|
|
|
|
cell.content.forEach { blockInCell ->
|
|
when (blockInCell) {
|
|
is ParagraphBlock -> {
|
|
Text(
|
|
text = blockInCell.content,
|
|
style = cellTextStyle,
|
|
modifier = Modifier.fillMaxWidth()
|
|
)
|
|
}
|
|
|
|
is HeaderBlock -> {
|
|
Text(
|
|
text = blockInCell.content,
|
|
style = cellTextStyle.copy(
|
|
fontWeight = FontWeight.Bold
|
|
),
|
|
modifier = Modifier.fillMaxWidth()
|
|
)
|
|
}
|
|
|
|
is ListItemBlock -> {
|
|
Row(
|
|
verticalAlignment = Alignment.Top
|
|
) {
|
|
val itemMarker = blockInCell.itemMarker
|
|
if (itemMarker != null) {
|
|
Text(
|
|
text = itemMarker,
|
|
style = cellTextStyle,
|
|
modifier = Modifier.padding(
|
|
end = 4.dp
|
|
)
|
|
)
|
|
}
|
|
Text(
|
|
text = blockInCell.content,
|
|
style = cellTextStyle,
|
|
modifier = Modifier.weight(
|
|
1f
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
is SpacerBlock -> {
|
|
Spacer(
|
|
modifier = Modifier.fillMaxWidth()
|
|
.height(
|
|
blockInCell.height
|
|
)
|
|
.drawCssBorders(
|
|
blockInCell.style,
|
|
density
|
|
)
|
|
)
|
|
}
|
|
|
|
is ImageBlock -> {
|
|
AsyncImage(
|
|
model = Builder(
|
|
LocalContext.current
|
|
).data(
|
|
File(
|
|
blockInCell.path
|
|
)
|
|
)
|
|
.build(),
|
|
contentDescription = blockInCell.altText,
|
|
contentScale = ContentScale.Fit,
|
|
modifier = tableCellImageModifier(
|
|
block = blockInCell,
|
|
density = density,
|
|
imageSizeMultiplier = imageSizeMultiplier
|
|
)
|
|
)
|
|
}
|
|
|
|
is TextContentBlock -> {
|
|
Text(
|
|
text = blockInCell.content,
|
|
style = cellTextStyle,
|
|
modifier = Modifier.fillMaxWidth()
|
|
)
|
|
}
|
|
|
|
else -> {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
var chapterInfo by remember {
|
|
mutableStateOf<Pair<String, Int?>?>(null)
|
|
}
|
|
LaunchedEffect(pageIndex) {
|
|
chapterInfo = onGetChapterInfo(pageIndex)
|
|
}
|
|
|
|
ChapterLoadingPlaceholder(title = chapterInfo?.first)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (activeSelection != null) {
|
|
val sel = activeSelection!!
|
|
val currentPageSuffix = "_${pagerState.currentPage}"
|
|
|
|
val currentPageBlocks =
|
|
blockLayoutMap.filterKeys { it.endsWith(currentPageSuffix) }.values.filter { it.second.isAttached }
|
|
val visibleSelectedBlocks =
|
|
currentPageBlocks.filter { isBlockSelectedOnPage(it.third, pagerState.currentPage, sel) }
|
|
|
|
if (!isDraggingHandle && visibleSelectedBlocks.isNotEmpty()) {
|
|
val menuAnchorRect = run {
|
|
var minLeft = Float.MAX_VALUE
|
|
var minTop = Float.MAX_VALUE
|
|
var maxRight = Float.MIN_VALUE
|
|
var maxBottom = Float.MIN_VALUE
|
|
|
|
visibleSelectedBlocks.forEach { triple ->
|
|
val (textLayout, coords, block) = triple
|
|
|
|
val currentBlockAbs = getTextBlockCharOffset(block)
|
|
val isStartBlockPart =
|
|
pagerState.currentPage == sel.startPageIndex &&
|
|
block.blockIndex == sel.startBlockIndex &&
|
|
currentBlockAbs == sel.startBlockCharOffset
|
|
val isEndBlockPart =
|
|
pagerState.currentPage == sel.endPageIndex &&
|
|
block.blockIndex == sel.endBlockIndex &&
|
|
currentBlockAbs == sel.endBlockCharOffset
|
|
|
|
val blockStartOffset = if (isStartBlockPart) sel.startOffset else 0
|
|
val blockEndOffset = if (isEndBlockPart) sel.endOffset else textLayout.layoutInput.text.length
|
|
|
|
val textLen = textLayout.layoutInput.text.length
|
|
val maxIdx = maxOf(0, textLen - 1)
|
|
|
|
val safeStart = blockStartOffset.coerceIn(0, textLen)
|
|
val safeEnd = blockEndOffset.coerceIn(safeStart, textLen)
|
|
|
|
if (safeStart < safeEnd) {
|
|
try {
|
|
val startBox = textLayout.getBoundingBox(safeStart.coerceIn(0, maxIdx))
|
|
val endBox = textLayout.getBoundingBox((safeEnd - 1).coerceIn(0, maxIdx))
|
|
|
|
val topWin =
|
|
coords.localToWindow(Offset(0f, startBox.top)).y
|
|
val bottomWin =
|
|
coords.localToWindow(Offset(0f, endBox.bottom)).y
|
|
val leftWin1 =
|
|
coords.localToWindow(Offset(startBox.left, 0f)).x
|
|
val rightWin1 =
|
|
coords.localToWindow(Offset(startBox.right, 0f)).x
|
|
val leftWin2 =
|
|
coords.localToWindow(Offset(endBox.left, 0f)).x
|
|
val rightWin2 =
|
|
coords.localToWindow(Offset(endBox.right, 0f)).x
|
|
|
|
minTop = minOf(minTop, topWin)
|
|
maxBottom = maxOf(maxBottom, bottomWin)
|
|
minLeft = minOf(minLeft, leftWin1, leftWin2)
|
|
maxRight = maxOf(maxRight, rightWin1, rightWin2)
|
|
} catch (e: Exception) {
|
|
Timber.e(e, "Error calculating exact selection bounds")
|
|
}
|
|
}
|
|
}
|
|
|
|
val handleSizePx = with(density) { 36.dp.toPx() }
|
|
if (minTop != Float.MAX_VALUE && maxBottom != Float.MIN_VALUE) {
|
|
Rect(minLeft, minTop, maxRight, maxBottom + handleSizePx)
|
|
} else {
|
|
Rect(
|
|
sel.rect.left,
|
|
sel.rect.top,
|
|
sel.rect.right,
|
|
sel.rect.bottom + handleSizePx
|
|
)
|
|
}
|
|
}
|
|
|
|
Popup(
|
|
popupPositionProvider = remember(
|
|
menuAnchorRect,
|
|
density
|
|
) { SmartPopupPositionProvider(menuAnchorRect, density) },
|
|
onDismissRequest = { activeSelection = null },
|
|
properties = PopupProperties(
|
|
dismissOnClickOutside = false
|
|
)
|
|
) {
|
|
PaginatedTextSelectionMenu(
|
|
onCopy = {
|
|
val clipboardManager =
|
|
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
|
val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), sel.text)
|
|
clipboardManager.setPrimaryClip(clip)
|
|
activeSelection = null
|
|
},
|
|
onSelectAll = null,
|
|
onDictionary = {
|
|
if (isProUser || countWords(sel.text) <= 1) {
|
|
onWordSelectedForAiDefinition(sel.text)
|
|
} else {
|
|
onShowDictionaryUpsellDialog()
|
|
}
|
|
activeSelection = null
|
|
},
|
|
onTranslate = {
|
|
onTranslate(sel.text)
|
|
activeSelection = null
|
|
},
|
|
onSearch = {
|
|
onSearch(sel.text)
|
|
activeSelection = null
|
|
},
|
|
onHighlight = { color ->
|
|
val finalCfi =
|
|
"${sel.startBaseCfi}:${sel.startOffset}|${sel.endBaseCfi}:${sel.endOffset}"
|
|
onHighlightCreated(finalCfi, sel.text, color.id)
|
|
activeSelection = null
|
|
},
|
|
onNote = {
|
|
onNoteRequested(null)
|
|
val finalCfi =
|
|
"${sel.startBaseCfi}:${sel.startOffset}|${sel.endBaseCfi}:${sel.endOffset}"
|
|
onHighlightCreated(finalCfi, sel.text, HighlightColor.YELLOW.id)
|
|
activeSelection = null
|
|
},
|
|
onTts = {
|
|
val startAbs = sel.startOffset + sel.startBlockCharOffset
|
|
onStartTtsFromSelection(sel.startBaseCfi, startAbs)
|
|
activeSelection = null
|
|
},
|
|
onDelete = null,
|
|
isProUser = isProUser,
|
|
isOss = isOss,
|
|
activeHighlightPalette = activeHighlightPalette,
|
|
onOpenPaletteManager = { showPaletteManager = true })
|
|
}
|
|
}
|
|
|
|
val updateSelection: (Offset, SelectionHandle) -> SelectionHandle =
|
|
{ windowPos, currentDragHandle ->
|
|
var activeDragHandle = currentDragHandle
|
|
|
|
val attachedBlocks =
|
|
blockLayoutMap.filterKeys { it.endsWith(currentPageSuffix) }.values.filter { it.second.isAttached }
|
|
.sortedBy { it.second.positionInWindow().y }
|
|
|
|
if (attachedBlocks.isNotEmpty()) {
|
|
val targetTriple = attachedBlocks.minByOrNull {
|
|
val coords = it.second
|
|
val rect = Rect(coords.positionInWindow(), coords.size.toSize())
|
|
val dx =
|
|
maxOf(rect.left - windowPos.x, 0f, windowPos.x - rect.right)
|
|
val dy =
|
|
maxOf(rect.top - windowPos.y, 0f, windowPos.y - rect.bottom)
|
|
dx * dx + dy * dy
|
|
} ?: attachedBlocks.last()
|
|
|
|
val (textLayout, coords, block) = targetTriple
|
|
val localPos = coords.windowToLocal(windowPos)
|
|
val offset = textLayout.getOffsetForPosition(localPos)
|
|
.coerceIn(0, textLayout.layoutInput.text.length)
|
|
|
|
val isStartHandle = activeDragHandle == SelectionHandle.START
|
|
var newStartIdx =
|
|
if (isStartHandle) block.blockIndex else sel.startBlockIndex
|
|
var newEndIdx =
|
|
if (isStartHandle) sel.endBlockIndex else block.blockIndex
|
|
var newStartOffset = if (isStartHandle) offset else sel.startOffset
|
|
var newEndOffset = if (isStartHandle) sel.endOffset else offset
|
|
var newStartCfi = if (isStartHandle) block.cfi!! else sel.startBaseCfi
|
|
var newEndCfi = if (isStartHandle) sel.endBaseCfi else block.cfi!!
|
|
var newStartPageIdx = if (isStartHandle) pagerState.currentPage else sel.startPageIndex
|
|
var newEndPageIdx = if (isStartHandle) sel.endPageIndex else pagerState.currentPage
|
|
|
|
val currentBlockAbs = getTextBlockCharOffset(block)
|
|
var newStartBlockAbs = if (isStartHandle) currentBlockAbs else sel.startBlockCharOffset
|
|
var newEndBlockAbs = if (!isStartHandle) currentBlockAbs else sel.endBlockCharOffset
|
|
|
|
val isReversed = when {
|
|
newStartPageIdx != newEndPageIdx -> newStartPageIdx > newEndPageIdx
|
|
else -> {
|
|
val blockCompare = compareBlockPositionsOnPage(
|
|
newStartIdx,
|
|
newStartBlockAbs,
|
|
newEndIdx,
|
|
newEndBlockAbs
|
|
)
|
|
if (blockCompare != 0) blockCompare > 0 else newStartOffset > newEndOffset
|
|
}
|
|
}
|
|
|
|
if (isReversed) {
|
|
newStartPageIdx = newEndPageIdx.also { newEndPageIdx = newStartPageIdx }
|
|
newStartIdx = newEndIdx.also { newEndIdx = newStartIdx }
|
|
newStartOffset = newEndOffset.also { newEndOffset = newStartOffset }
|
|
newStartCfi = newEndCfi.also { newEndCfi = newStartCfi }
|
|
newStartBlockAbs = newEndBlockAbs.also { newEndBlockAbs = newStartBlockAbs }
|
|
activeDragHandle = if (activeDragHandle == SelectionHandle.START) SelectionHandle.END else SelectionHandle.START
|
|
}
|
|
|
|
if (
|
|
newStartPageIdx != sel.startPageIndex ||
|
|
newEndPageIdx != sel.endPageIndex ||
|
|
newStartIdx != sel.startBlockIndex ||
|
|
newEndIdx != sel.endBlockIndex ||
|
|
newStartOffset != sel.startOffset ||
|
|
newEndOffset != sel.endOffset
|
|
) {
|
|
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
|
|
|
val relevantBlocks = attachedBlocks
|
|
.filter {
|
|
isBlockSelectedOnPage(
|
|
block = it.third,
|
|
pageIndex = pagerState.currentPage,
|
|
selection = PaginatedSelection(
|
|
startBlockIndex = newStartIdx,
|
|
endBlockIndex = newEndIdx,
|
|
startBaseCfi = newStartCfi,
|
|
endBaseCfi = newEndCfi,
|
|
startOffset = newStartOffset,
|
|
endOffset = newEndOffset,
|
|
text = sel.text,
|
|
rect = sel.rect,
|
|
startPageIndex = newStartPageIdx,
|
|
endPageIndex = newEndPageIdx,
|
|
startBlockCharOffset = newStartBlockAbs,
|
|
endBlockCharOffset = newEndBlockAbs,
|
|
textPerBlock = sel.textPerBlock
|
|
)
|
|
)
|
|
}
|
|
.sortedWith(compareBy({ it.third.blockIndex }, { getTextBlockCharOffset(it.third) }))
|
|
|
|
val newTextPerBlock = sel.textPerBlock.toMutableMap()
|
|
newTextPerBlock.keys.removeAll { keyStr ->
|
|
parseSelectionBlockKey(keyStr)?.pageIndex == pagerState.currentPage
|
|
}
|
|
|
|
for (b in relevantBlocks) {
|
|
val txt = b.third.content.text
|
|
val bAbs = getTextBlockCharOffset(b.third)
|
|
val isStartBlockPart = b.third.blockIndex == newStartIdx && bAbs == newStartBlockAbs
|
|
val isEndBlockPart = b.third.blockIndex == newEndIdx && bAbs == newEndBlockAbs
|
|
|
|
val s = if (isStartBlockPart) newStartOffset else 0
|
|
val e = if (isEndBlockPart) newEndOffset else txt.length
|
|
|
|
val safeS = s.coerceIn(0, txt.length)
|
|
val safeE = e.coerceIn(safeS, txt.length)
|
|
|
|
if (safeS < safeE) {
|
|
newTextPerBlock[
|
|
buildSelectionBlockKey(
|
|
pageIndex = pagerState.currentPage,
|
|
blockIndex = b.third.blockIndex,
|
|
blockCharOffset = bAbs
|
|
)
|
|
] = txt.substring(safeS, safeE)
|
|
} else {
|
|
newTextPerBlock.remove(
|
|
buildSelectionBlockKey(
|
|
pageIndex = pagerState.currentPage,
|
|
blockIndex = b.third.blockIndex,
|
|
blockCharOffset = bAbs
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
val newText = newTextPerBlock.entries
|
|
.sortedWith { first, second ->
|
|
compareSelectionBlockKeys(first.key, second.key)
|
|
}
|
|
.joinToString(" ") { it.value }
|
|
|
|
val sLayout = blockLayoutMap["${newStartCfi}_$newStartPageIdx"]?.takeIf {
|
|
val abs = getTextBlockCharOffset(it.third)
|
|
abs == newStartBlockAbs
|
|
}
|
|
|
|
val eLayout = blockLayoutMap["${newEndCfi}_$newEndPageIdx"]
|
|
var newRect = sel.rect
|
|
|
|
if (sLayout != null && eLayout != null && sLayout.second.isAttached && eLayout.second.isAttached) {
|
|
val sMaxIdx = maxOf(0, sLayout.first.layoutInput.text.length - 1)
|
|
val eMaxIdx = maxOf(0, eLayout.first.layoutInput.text.length - 1)
|
|
|
|
val sRectLocal = sLayout.first.getBoundingBox(
|
|
newStartOffset.coerceIn(0, sMaxIdx)
|
|
)
|
|
val sRectWin = Rect(
|
|
sLayout.second.localToWindow(sRectLocal.topLeft),
|
|
sLayout.second.localToWindow(sRectLocal.bottomRight)
|
|
)
|
|
val eRectLocal = eLayout.first.getBoundingBox(
|
|
(newEndOffset - 1).coerceIn(0, eMaxIdx)
|
|
)
|
|
val eRectWin = Rect(
|
|
eLayout.second.localToWindow(eRectLocal.topLeft),
|
|
eLayout.second.localToWindow(eRectLocal.bottomRight)
|
|
)
|
|
newRect = Rect(
|
|
minOf(sRectWin.left, eRectWin.left),
|
|
sRectWin.top,
|
|
maxOf(sRectWin.right, eRectWin.right),
|
|
eRectWin.bottom
|
|
)
|
|
} else {
|
|
var minLeft = Float.MAX_VALUE
|
|
var minTop = Float.MAX_VALUE
|
|
var maxRight = Float.MIN_VALUE
|
|
var maxBottom = Float.MIN_VALUE
|
|
relevantBlocks.forEach { b ->
|
|
if (b.second.isAttached) {
|
|
val r = Rect(
|
|
b.second.positionInWindow(),
|
|
b.second.size.toSize()
|
|
)
|
|
minLeft = minOf(minLeft, r.left)
|
|
minTop = minOf(minTop, r.top)
|
|
maxRight = maxOf(maxRight, r.right)
|
|
maxBottom = maxOf(maxBottom, r.bottom)
|
|
}
|
|
}
|
|
if (minLeft != Float.MAX_VALUE) {
|
|
newRect = Rect(minLeft, minTop, maxRight, maxBottom)
|
|
}
|
|
}
|
|
|
|
activeSelection = PaginatedSelection(
|
|
startBlockIndex = newStartIdx,
|
|
endBlockIndex = newEndIdx,
|
|
startBaseCfi = newStartCfi,
|
|
endBaseCfi = newEndCfi,
|
|
startOffset = newStartOffset,
|
|
endOffset = newEndOffset,
|
|
text = newText,
|
|
rect = newRect,
|
|
startPageIndex = newStartPageIdx,
|
|
endPageIndex = newEndPageIdx,
|
|
startBlockCharOffset = newStartBlockAbs,
|
|
endBlockCharOffset = newEndBlockAbs,
|
|
textPerBlock = newTextPerBlock
|
|
)
|
|
}
|
|
}
|
|
activeDragHandle
|
|
}
|
|
|
|
val latestUpdateSelection by rememberUpdatedState(updateSelection)
|
|
|
|
listOf(SelectionHandle.START, SelectionHandle.END).forEach { handleType ->
|
|
val isStart = handleType == SelectionHandle.START
|
|
var handleCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
|
|
|
Box(
|
|
modifier = Modifier
|
|
.graphicsLayer {
|
|
@Suppress("UNUSED_VARIABLE") val animOffset = pagerState.currentPageOffsetFraction
|
|
@Suppress("UNUSED_VARIABLE") val currPage = pagerState.currentPage
|
|
@Suppress("UNUSED_VARIABLE") val isScrolling = pagerState.isScrollInProgress
|
|
@Suppress("UNUSED_VARIABLE") val tick = blockLayoutMap.tick
|
|
|
|
val handlePageIndex = if (isStart) sel.startPageIndex else sel.endPageIndex
|
|
val pos = if (handlePageIndex == pagerState.currentPage) {
|
|
val selCfi = if (isStart) sel.startBaseCfi else sel.endBaseCfi
|
|
val selOffset = if (isStart) sel.startOffset else sel.endOffset
|
|
val targetBlockAbs = if (isStart) sel.startBlockCharOffset else sel.endBlockCharOffset
|
|
val layoutInfo = blockLayoutMap["${selCfi}_$handlePageIndex"]?.takeIf {
|
|
val blockAbs = getTextBlockCharOffset(it.third)
|
|
blockAbs == targetBlockAbs
|
|
}
|
|
|
|
if (layoutInfo != null && layoutInfo.second.isAttached && rootCoords != null && rootCoords!!.isAttached) {
|
|
val textLayout = layoutInfo.first
|
|
val coords = layoutInfo.second
|
|
val maxIdx = maxOf(0, textLayout.layoutInput.text.length - 1)
|
|
val safeOffset = selOffset.coerceIn(0, textLayout.layoutInput.text.length)
|
|
val safeOffsetForLine = safeOffset.coerceIn(0, maxIdx)
|
|
|
|
val line = textLayout.getLineForOffset(safeOffsetForLine)
|
|
val x = textLayout.getHorizontalPosition(safeOffset, usePrimaryDirection = true)
|
|
val y = textLayout.getLineBottom(line)
|
|
|
|
try {
|
|
val windowPos = coords.localToWindow(Offset(x, y))
|
|
rootCoords!!.windowToLocal(windowPos)
|
|
} catch (e: Exception) {
|
|
Offset.Unspecified
|
|
}
|
|
} else {
|
|
Offset.Unspecified
|
|
}
|
|
} else {
|
|
Offset.Unspecified
|
|
}
|
|
|
|
if (pos.isSpecified) {
|
|
translationX = pos.x - 18.dp.toPx()
|
|
translationY = pos.y
|
|
alpha = 1f
|
|
} else {
|
|
alpha = 0f
|
|
}
|
|
}
|
|
.size(36.dp)
|
|
.onGloballyPositioned { handleCoords = it }
|
|
.pointerInput(handleType) {
|
|
awaitEachGesture {
|
|
val down = awaitFirstDown()
|
|
down.consume()
|
|
isDraggingHandle = true
|
|
var currentDragHandle = handleType
|
|
|
|
while (true) {
|
|
val event = awaitPointerEvent()
|
|
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
|
if (!change.pressed) {
|
|
change.consume()
|
|
break
|
|
}
|
|
change.consume()
|
|
|
|
if (handleCoords != null && rootCoords != null && handleCoords!!.isAttached && rootCoords!!.isAttached) {
|
|
try {
|
|
val pointerWindow = handleCoords!!.localToWindow(change.position)
|
|
val pointerRoot = rootCoords!!.windowToLocal(pointerWindow)
|
|
val targetRootY = pointerRoot.y - 36.dp.toPx()
|
|
val targetRootPos = Offset(pointerRoot.x, targetRootY)
|
|
|
|
magnifierCenter = targetRootPos
|
|
|
|
val targetWindowPos = rootCoords!!.localToWindow(targetRootPos)
|
|
currentDragHandle = latestUpdateSelection(targetWindowPos, currentDragHandle)
|
|
} catch (e: Exception) {
|
|
// Ignore detachment crashes during fast scrolls
|
|
}
|
|
}
|
|
}
|
|
isDraggingHandle = false
|
|
magnifierCenter = Offset.Unspecified
|
|
}
|
|
},
|
|
contentAlignment = Alignment.TopCenter
|
|
) {
|
|
Icon(
|
|
painter = painterResource(R.drawable.teardrop),
|
|
contentDescription = if (isStart) "Start handle" else "End handle",
|
|
modifier = Modifier.size(36.dp).graphicsLayer {
|
|
rotationZ = if (isStart) 30f else -30f
|
|
transformOrigin = TransformOrigin(0.5f, 0f)
|
|
},
|
|
tint = Color(0xFF1976D2)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (showColorPickerDialog != null) {
|
|
AlertDialog(
|
|
onDismissRequest = { showColorPickerDialog = null },
|
|
title = { Text(stringResource(R.string.dialog_select_color)) },
|
|
text = {
|
|
LazyVerticalGrid(
|
|
columns = GridCells.Adaptive(minSize = 48.dp),
|
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
) {
|
|
items(HighlightColor.entries) { colorOption ->
|
|
Box(
|
|
modifier = Modifier.size(48.dp)
|
|
.background(colorOption.color, CircleShape).border(
|
|
1.dp,
|
|
MaterialTheme.colorScheme.outline,
|
|
CircleShape
|
|
).clickable {
|
|
onUpdatePalette(
|
|
showColorPickerDialog!!,
|
|
colorOption
|
|
)
|
|
showColorPickerDialog = null
|
|
})
|
|
}
|
|
}
|
|
},
|
|
confirmButton = {
|
|
TextButton(onClick = {
|
|
showColorPickerDialog = null
|
|
}) { Text(stringResource(R.string.action_close)) }
|
|
})
|
|
}
|
|
|
|
if (showPaletteManager) {
|
|
PaletteManagerDialog(
|
|
currentPalette = activeHighlightPalette,
|
|
onDismiss = { showPaletteManager = false },
|
|
onSave = { newPalette ->
|
|
newPalette.forEachIndexed { index, color ->
|
|
onUpdatePalette(index, color)
|
|
}
|
|
showPaletteManager = false
|
|
})
|
|
}
|
|
}
|
|
} else {
|
|
Timber.w("Book has no pages to display.")
|
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
Text(stringResource(R.string.msg_book_no_content))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun ChapterLoadingPlaceholder(title: String?) {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.padding(horizontal = 32.dp, vertical = 64.dp),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Column(
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
verticalArrangement = Arrangement.Center
|
|
) {
|
|
if (title != null) {
|
|
Text(
|
|
text = title,
|
|
style = MaterialTheme.typography.headlineSmall,
|
|
textAlign = TextAlign.Center,
|
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
|
|
)
|
|
Spacer(Modifier.height(16.dp))
|
|
}
|
|
CircularProgressIndicator()
|
|
Spacer(Modifier.height(16.dp))
|
|
Text(
|
|
text = "Preparing chapter.",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
textAlign = TextAlign.Center,
|
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun RenderFlexChildBlock(
|
|
childBlock: ContentBlock,
|
|
textStyle: TextStyle,
|
|
imageSizeMultiplier: Float,
|
|
searchQuery: String,
|
|
searchHighlightColor: Color,
|
|
ttsHighlightInfo: TtsHighlightInfo?,
|
|
ttsHighlightColor: Color,
|
|
textMeasurer: TextMeasurer,
|
|
onLinkClickCallback: (String) -> Unit,
|
|
onGeneralTapCallback: (Offset) -> Unit,
|
|
userHighlights: List<UserHighlight>,
|
|
activeSelection: PaginatedSelection?,
|
|
onSelectionChange: (PaginatedSelection?) -> Unit,
|
|
onHighlightClick: (UserHighlight, Rect) -> Unit,
|
|
isDarkTheme: Boolean,
|
|
blockLayoutMap: MutableMap<String, Triple<TextLayoutResult, LayoutCoordinates, TextContentBlock>>,
|
|
density: Density,
|
|
imageLoader: ImageLoader,
|
|
pageIndex: Int
|
|
) {
|
|
@Composable
|
|
fun renderTextBlock(block: TextContentBlock) {
|
|
val searchHighlighted =
|
|
highlightQueryInText(block.content, searchQuery, searchHighlightColor)
|
|
val finalContent = if (ttsHighlightInfo != null && block.cfi == ttsHighlightInfo.cfi) {
|
|
buildAnnotatedString {
|
|
append(searchHighlighted)
|
|
val blockStartAbs = block.startCharOffsetInSource
|
|
val blockEndAbs = block.startCharOffsetInSource + searchHighlighted.length
|
|
val highlightStartAbs = ttsHighlightInfo.offset
|
|
val highlightEndAbs = ttsHighlightInfo.offset + ttsHighlightInfo.text.length
|
|
|
|
val intersectionStartAbs = maxOf(blockStartAbs, highlightStartAbs)
|
|
val intersectionEndAbs = minOf(blockEndAbs, highlightEndAbs)
|
|
|
|
if (intersectionStartAbs < intersectionEndAbs) {
|
|
val highlightStartRelative = intersectionStartAbs - blockStartAbs
|
|
val highlightEndRelative = intersectionEndAbs - blockStartAbs
|
|
addStyle(
|
|
style = SpanStyle(background = ttsHighlightColor),
|
|
start = highlightStartRelative,
|
|
end = highlightEndRelative
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
searchHighlighted
|
|
}
|
|
|
|
// Apply block specific styles (like header font weight)
|
|
val finalStyle = if (block is HeaderBlock) {
|
|
createHeaderTextStyle(
|
|
baseStyle = textStyle,
|
|
level = block.level,
|
|
textAlign = block.textAlign
|
|
)
|
|
} else {
|
|
textStyle
|
|
}
|
|
|
|
TextWithEmphasis(
|
|
text = finalContent,
|
|
style = finalStyle,
|
|
modifier = Modifier,
|
|
pageIndex = pageIndex,
|
|
textMeasurer = textMeasurer,
|
|
onLinkClick = onLinkClickCallback,
|
|
onGeneralTap = onGeneralTapCallback,
|
|
block = block,
|
|
userHighlights = userHighlights,
|
|
activeSelection = activeSelection,
|
|
onSelectionChange = onSelectionChange,
|
|
onHighlightClick = onHighlightClick,
|
|
isDarkTheme = isDarkTheme,
|
|
onRegisterLayout = { layout, coords ->
|
|
block.cfi?.let { cfi ->
|
|
blockLayoutMap["${cfi}_$pageIndex"] = Triple(layout, coords, block)
|
|
}
|
|
})
|
|
}
|
|
|
|
when (childBlock) {
|
|
is ListItemBlock -> {
|
|
Row(modifier = Modifier, verticalAlignment = Alignment.Top) {
|
|
val markerAreaModifier = Modifier
|
|
.width(32.dp)
|
|
.padding(end = 8.dp)
|
|
val itemMarkerImage = childBlock.itemMarkerImage
|
|
val itemMarker = childBlock.itemMarker
|
|
|
|
if (itemMarkerImage != null) {
|
|
val imageRequest =
|
|
Builder(LocalContext.current).data(File(itemMarkerImage))
|
|
.crossfade(true).build()
|
|
val imageSize = with(density) { (textStyle.fontSize.value * 0.8f).sp.toDp() }
|
|
|
|
AsyncImage(
|
|
model = imageRequest,
|
|
contentDescription = stringResource(R.string.content_desc_list_item_marker),
|
|
modifier = markerAreaModifier.height(imageSize),
|
|
alignment = Alignment.CenterEnd,
|
|
contentScale = ContentScale.FillHeight
|
|
)
|
|
} else if (itemMarker != null) {
|
|
Text(
|
|
text = itemMarker,
|
|
style = textStyle.copy(textAlign = TextAlign.End),
|
|
modifier = markerAreaModifier
|
|
)
|
|
}
|
|
|
|
// Reuse text rendering logic
|
|
renderTextBlock(childBlock)
|
|
}
|
|
}
|
|
|
|
is ParagraphBlock -> renderTextBlock(childBlock)
|
|
is HeaderBlock -> renderTextBlock(childBlock)
|
|
is QuoteBlock -> renderTextBlock(childBlock)
|
|
is TextContentBlock -> renderTextBlock(childBlock)
|
|
is ImageBlock -> {
|
|
val style = childBlock.style
|
|
val colorFilter = if (childBlock.style.filter == "invert(100%)") {
|
|
val matrix = floatArrayOf(
|
|
-1f,
|
|
0f,
|
|
0f,
|
|
0f,
|
|
255f,
|
|
0f,
|
|
-1f,
|
|
0f,
|
|
0f,
|
|
255f,
|
|
0f,
|
|
0f,
|
|
-1f,
|
|
0f,
|
|
255f,
|
|
0f,
|
|
0f,
|
|
0f,
|
|
1f,
|
|
0f
|
|
)
|
|
ColorFilter.colorMatrix(ColorMatrix(matrix))
|
|
} else null
|
|
|
|
BoxWithConstraints(contentAlignment = imageBlockContentAlignment(style)) {
|
|
val scaledSize = computeImageRenderSizeDp(
|
|
block = childBlock,
|
|
density = density,
|
|
maxWidthDp = maxWidth,
|
|
imageSizeMultiplier = imageSizeMultiplier
|
|
)
|
|
val imageModifier = Modifier
|
|
.then(
|
|
if (scaledSize != null) {
|
|
Modifier.width(scaledSize.first).height(scaledSize.second)
|
|
} else if (style.width != Dp.Unspecified && style.width > 0.dp) {
|
|
Modifier.width(style.width)
|
|
} else {
|
|
Modifier
|
|
}
|
|
)
|
|
.then(
|
|
if (scaledSize == null && style.maxWidth != Dp.Unspecified && style.maxWidth > 0.dp) {
|
|
Modifier.widthIn(max = style.maxWidth)
|
|
} else {
|
|
Modifier
|
|
}
|
|
)
|
|
.then(
|
|
if (scaledSize == null) {
|
|
if (childBlock.expectedHeight > 0) {
|
|
Modifier.height(with(density) { (childBlock.expectedHeight * imageSizeMultiplier).toDp() })
|
|
} else {
|
|
Modifier.height(250.dp)
|
|
}
|
|
} else {
|
|
Modifier
|
|
}
|
|
)
|
|
|
|
AsyncImage(
|
|
model = Builder(LocalContext.current).data(File(childBlock.path)).crossfade(true)
|
|
.build(),
|
|
contentDescription = childBlock.altText,
|
|
modifier = imageModifier,
|
|
contentScale = ContentScale.Fit,
|
|
colorFilter = colorFilter,
|
|
imageLoader = imageLoader
|
|
)
|
|
}
|
|
}
|
|
|
|
is SpacerBlock -> {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(childBlock.height)
|
|
.drawCssBorders(childBlock.style, density)
|
|
)
|
|
}
|
|
|
|
is TableBlock -> {
|
|
Column(modifier = Modifier.fillMaxWidth()) {
|
|
childBlock.rows.forEach { tableRow ->
|
|
Row(
|
|
Modifier
|
|
.fillMaxWidth()
|
|
.height(IntrinsicSize.Min)
|
|
) {
|
|
val hasFixedWidths =
|
|
tableRow.any { it.style.blockStyle.width != Dp.Unspecified }
|
|
|
|
tableRow.forEach { cell ->
|
|
val cellStyle = cell.style.blockStyle
|
|
val cellModifier = Modifier
|
|
.fillMaxHeight()
|
|
.then(
|
|
if (hasFixedWidths && cellStyle.width != Dp.Unspecified) Modifier.width(
|
|
cellStyle.width
|
|
)
|
|
else Modifier.weight(
|
|
cell.colspan.toFloat(), fill = true
|
|
)
|
|
)
|
|
.then(
|
|
if (cellStyle.backgroundColor.isSpecified) Modifier.background(
|
|
cellStyle.backgroundColor
|
|
)
|
|
else Modifier
|
|
)
|
|
.drawCssBorders(cellStyle, density)
|
|
.padding(
|
|
start = cellStyle.padding.left.coerceAtLeast(
|
|
0.dp
|
|
),
|
|
top = cellStyle.padding.top.coerceAtLeast(0.dp),
|
|
end = cellStyle.padding.right.coerceAtLeast(
|
|
0.dp
|
|
),
|
|
bottom = cellStyle.padding.bottom.coerceAtLeast(
|
|
0.dp
|
|
)
|
|
)
|
|
|
|
val alignment = when (cell.style.paragraphStyle.textAlign) {
|
|
TextAlign.Center -> Alignment.CenterHorizontally
|
|
TextAlign.End -> Alignment.End
|
|
else -> Alignment.Start
|
|
}
|
|
|
|
Column(modifier = cellModifier, horizontalAlignment = alignment) {
|
|
val cellTextStyle =
|
|
if (cell.isHeader) textStyle.copy(fontWeight = FontWeight.Bold)
|
|
else textStyle
|
|
cell.content.forEach { blockInCell ->
|
|
if (blockInCell is TextContentBlock) {
|
|
Text(
|
|
text = blockInCell.content,
|
|
style = cellTextStyle,
|
|
modifier = Modifier.fillMaxWidth()
|
|
)
|
|
} else if (blockInCell is ImageBlock) {
|
|
AsyncImage(
|
|
model = Builder(LocalContext.current).data(
|
|
File(
|
|
blockInCell.path
|
|
)
|
|
).build(),
|
|
contentDescription = blockInCell.altText,
|
|
contentScale = ContentScale.Fit,
|
|
modifier = tableCellImageModifier(
|
|
block = blockInCell,
|
|
density = density,
|
|
imageSizeMultiplier = imageSizeMultiplier
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
else -> {
|
|
Timber.w(
|
|
"FlexContainerBlock child type still not supported: ${childBlock::class.simpleName}"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@OptIn(ExperimentalFoundationApi::class)
|
|
private fun Modifier.realisticBookPage(
|
|
pagerState: PagerState,
|
|
pageIndex: Int,
|
|
paperColor: Color,
|
|
isDarkTheme: Boolean,
|
|
touchY: Float?,
|
|
textureBitmap: ImageBitmap? = null,
|
|
textureAlpha: Float = 0f
|
|
): Modifier = composed {
|
|
|
|
val frontPath = remember { Path() }
|
|
val backPath = remember { Path() }
|
|
val reflectedScreenPath = remember { Path() }
|
|
|
|
this
|
|
.graphicsLayer {
|
|
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
|
|
|
|
if (abs(pageOffset) > 0.001f && abs(pageOffset) < 0.999f) {
|
|
Timber.tag("PageTurnFixDiag").d("graphicsLayer: Page $pageIndex, Offset: $pageOffset")
|
|
}
|
|
|
|
if (pageOffset <= 1f && pageOffset > -1f) {
|
|
translationX = -pageOffset * size.width
|
|
}
|
|
|
|
if (pageOffset != 0f) {
|
|
shadowElevation = 10f
|
|
shape = RectangleShape
|
|
clip = false
|
|
}
|
|
}
|
|
.drawWithContent {
|
|
val drawStart = System.nanoTime()
|
|
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
|
|
fun drawPaperBackground() {
|
|
drawRect(color = paperColor)
|
|
if (textureBitmap != null && textureAlpha > 0f) {
|
|
drawRect(
|
|
brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)),
|
|
blendMode = BlendMode.SrcOver,
|
|
alpha = textureAlpha
|
|
)
|
|
}
|
|
}
|
|
|
|
if (abs(pageOffset) < 0.001f) {
|
|
drawPaperBackground()
|
|
drawContent()
|
|
}
|
|
else if (pageOffset < 0f && pageOffset > -1f) {
|
|
val progress = -pageOffset
|
|
val w = size.width
|
|
val h = size.height
|
|
|
|
val startY = touchY ?: h
|
|
val rawCenterDist = ((startY - h / 2f) / (h / 2f)).coerceIn(-1f, 1f)
|
|
|
|
val flattenFactor = if (progress > 0.75f) {
|
|
((progress - 0.75f) / 0.25f).coerceIn(0f, 1f)
|
|
} else {
|
|
0f
|
|
}
|
|
val centerDist = rawCenterDist * (1f - flattenFactor)
|
|
|
|
val cornerY = if (centerDist >= 0) h else 0f
|
|
|
|
val dragX = w - w * 2.2f * progress
|
|
val dragY = cornerY - h * 0.5f * progress * centerDist
|
|
|
|
val midX = (w + dragX) / 2f
|
|
val midY = (cornerY + dragY) / 2f
|
|
|
|
val dx = w - dragX
|
|
val dy = cornerY - dragY
|
|
val nLen = sqrt(dx * dx + dy * dy)
|
|
|
|
// CRITICAL GEOMETRY LOG
|
|
if (progress > 0.8f) { // Focus logs on the "end" of the turn where the stall happens
|
|
Timber.tag("PageTurnFixDiag").i(
|
|
"Geometry Page $pageIndex: progress=$progress, nLen=$nLen, cornerY=$cornerY, dragX=$dragX, midX=$midX"
|
|
)
|
|
}
|
|
|
|
if (nLen > 0f) {
|
|
val nx = dx / nLen
|
|
val ny = dy / nLen
|
|
|
|
if (nx.isNaN() || ny.isNaN()) {
|
|
Timber.tag("PageTurnFixDiag").e("NAN DETECTED in Normal Vectors: nx=$nx, ny=$ny")
|
|
}
|
|
|
|
val huge = w * 3f
|
|
val vx = -ny
|
|
|
|
val p1X = midX + vx * huge
|
|
val p1Y = midY + nx * huge
|
|
val p2X = midX - vx * huge
|
|
val p2Y = midY - nx * huge
|
|
|
|
frontPath.rewind()
|
|
frontPath.moveTo(p1X, p1Y)
|
|
frontPath.lineTo(p2X, p2Y)
|
|
frontPath.lineTo(p2X - nx * huge, p2Y - ny * huge)
|
|
frontPath.lineTo(p1X - nx * huge, p1Y - ny * huge)
|
|
frontPath.close()
|
|
|
|
clipPath(frontPath) {
|
|
drawPaperBackground()
|
|
this@drawWithContent.drawContent()
|
|
}
|
|
|
|
val shadowWidth = (40.dp.toPx() * (1f - progress)).coerceAtLeast(10.dp.toPx())
|
|
backPath.rewind()
|
|
backPath.moveTo(p1X, p1Y)
|
|
backPath.lineTo(p2X, p2Y)
|
|
backPath.lineTo(p2X + nx * huge, p2Y + ny * huge)
|
|
backPath.lineTo(p1X + nx * huge, p1Y + ny * huge)
|
|
backPath.close()
|
|
|
|
val dropShadowBrush = Brush.linearGradient(
|
|
colors = listOf(Color.Black.copy(alpha = 0.4f), Color.Transparent),
|
|
start = Offset(midX, midY),
|
|
end = Offset(midX + nx * shadowWidth, midY + ny * shadowWidth)
|
|
)
|
|
clipRect(0f, 0f, w, h) {
|
|
drawPath(backPath, dropShadowBrush)
|
|
}
|
|
|
|
fun reflect(px: Float, py: Float): Offset {
|
|
val vX = px - midX
|
|
val vY = py - midY
|
|
val dist = vX * nx + vY * ny
|
|
return Offset(px - 2 * dist * nx, py - 2 * dist * ny)
|
|
}
|
|
|
|
val rTL = reflect(0f, 0f)
|
|
val rTR = reflect(w, 0f)
|
|
val rBR = reflect(w, h)
|
|
val rBL = reflect(0f, h)
|
|
|
|
reflectedScreenPath.rewind()
|
|
reflectedScreenPath.moveTo(rTL.x, rTL.y)
|
|
reflectedScreenPath.lineTo(rTR.x, rTR.y)
|
|
reflectedScreenPath.lineTo(rBR.x, rBR.y)
|
|
reflectedScreenPath.lineTo(rBL.x, rBL.y)
|
|
reflectedScreenPath.close()
|
|
|
|
clipRect(0f, 0f, w, h) {
|
|
clipPath(frontPath) {
|
|
drawPath(reflectedScreenPath, color = paperColor)
|
|
if (textureBitmap != null && textureAlpha > 0f) {
|
|
clipPath(reflectedScreenPath) {
|
|
drawRect(
|
|
brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)),
|
|
blendMode = BlendMode.SrcOver,
|
|
alpha = textureAlpha
|
|
)
|
|
}
|
|
}
|
|
val flapTint = if (isDarkTheme) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f)
|
|
drawPath(reflectedScreenPath, color = flapTint)
|
|
|
|
val innerShadowWidth = shadowWidth * 0.7f
|
|
val innerShadowBrush = Brush.linearGradient(
|
|
colors = listOf(Color.Black.copy(alpha = 0.25f), Color.Black.copy(alpha = 0.05f), Color.Transparent),
|
|
start = Offset(midX, midY),
|
|
end = Offset(midX - nx * innerShadowWidth, midY - ny * innerShadowWidth)
|
|
)
|
|
drawPath(reflectedScreenPath, innerShadowBrush)
|
|
|
|
drawPath(
|
|
path = reflectedScreenPath,
|
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.15f) else Color.Black.copy(alpha = 0.15f),
|
|
style = Stroke(width = 1.dp.toPx())
|
|
)
|
|
}
|
|
|
|
drawLine(
|
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.1f),
|
|
start = Offset(p1X, p1Y),
|
|
end = Offset(p2X, p2Y),
|
|
strokeWidth = 1.dp.toPx()
|
|
)
|
|
}
|
|
|
|
} else {
|
|
drawPaperBackground()
|
|
drawContent()
|
|
}
|
|
}
|
|
else {
|
|
drawPaperBackground()
|
|
drawContent()
|
|
}
|
|
|
|
val drawDuration = (System.nanoTime() - drawStart) / 1_000_000.0
|
|
if (drawDuration > 12.0) { // Log slow frames (anything near the 16ms frame budget)
|
|
Timber.tag("PageTurnFixDiag").w("Slow Draw on Page $pageIndex: ${drawDuration}ms")
|
|
}
|
|
}
|
|
}
|
|
|
|
@Suppress("KotlinConstantConditions")
|
|
@Composable
|
|
fun Modifier.drawCssBorders(
|
|
blockStyle: BlockStyle,
|
|
@Suppress("unused") density: Density
|
|
): Modifier = this.drawBehind {
|
|
val borderTop = blockStyle.borderTop
|
|
val borderRight = blockStyle.borderRight
|
|
val borderBottom = blockStyle.borderBottom
|
|
val borderLeft = blockStyle.borderLeft
|
|
val topWidth = borderTop?.width?.toPx() ?: 0f
|
|
val rightWidth = borderRight?.width?.toPx() ?: 0f
|
|
val bottomWidth = borderBottom?.width?.toPx() ?: 0f
|
|
val leftWidth = borderLeft?.width?.toPx() ?: 0f
|
|
|
|
val tlRadius = blockStyle.borderTopLeftRadius.toPx()
|
|
val trRadius = blockStyle.borderTopRightRadius.toPx()
|
|
val brRadius = blockStyle.borderBottomRightRadius.toPx()
|
|
val blRadius = blockStyle.borderBottomLeftRadius.toPx()
|
|
|
|
if (blockStyle.backgroundColor.isSpecified && blockStyle.backgroundColor != Color.Transparent) {
|
|
val bgPath = Path().apply {
|
|
addRoundRect(
|
|
RoundRect(
|
|
rect = size.toRect(),
|
|
topLeft = CornerRadius(tlRadius, tlRadius),
|
|
topRight = CornerRadius(trRadius, trRadius),
|
|
bottomRight = CornerRadius(brRadius, brRadius),
|
|
bottomLeft = CornerRadius(blRadius, blRadius)
|
|
)
|
|
)
|
|
}
|
|
drawPath(bgPath, color = blockStyle.backgroundColor, style = Fill)
|
|
}
|
|
|
|
// 2. Helper for PathEffects
|
|
fun getPathEffect(style: String?, width: Float): PathEffect? {
|
|
return when (style) {
|
|
"dashed" -> PathEffect.dashPathEffect(floatArrayOf(width * 3f, width * 2f), 0f)
|
|
"dotted" -> PathEffect.dashPathEffect(floatArrayOf(width, width), 0f)
|
|
else -> null
|
|
}
|
|
}
|
|
|
|
// TOP
|
|
if (topWidth > 0f && borderTop != null) {
|
|
val color = borderTop.color
|
|
val effect = getPathEffect(borderTop.style, topWidth)
|
|
val offset = topWidth / 2f
|
|
|
|
val startX = if (tlRadius > 0) tlRadius else 0f
|
|
val endX = if (trRadius > 0) size.width - trRadius else size.width
|
|
|
|
drawLine(
|
|
color = color,
|
|
start = Offset(startX, offset),
|
|
end = Offset(endX, offset),
|
|
strokeWidth = topWidth,
|
|
pathEffect = effect
|
|
)
|
|
}
|
|
|
|
// BOTTOM
|
|
if (bottomWidth > 0f && borderBottom != null) {
|
|
val color = borderBottom.color
|
|
val effect = getPathEffect(borderBottom.style, bottomWidth)
|
|
val offset = size.height - (bottomWidth / 2f)
|
|
|
|
val startX = if (blRadius > 0) blRadius else 0f
|
|
val endX = if (brRadius > 0) size.width - brRadius else size.width
|
|
|
|
drawLine(
|
|
color = color,
|
|
start = Offset(startX, offset),
|
|
end = Offset(endX, offset),
|
|
strokeWidth = bottomWidth,
|
|
pathEffect = effect
|
|
)
|
|
}
|
|
|
|
// LEFT
|
|
if (leftWidth > 0f && borderLeft != null) {
|
|
val color = borderLeft.color
|
|
val effect = getPathEffect(borderLeft.style, leftWidth)
|
|
val offset = leftWidth / 2f
|
|
|
|
val startY = if (tlRadius > 0) tlRadius else 0f
|
|
val endY = if (blRadius > 0) size.height - blRadius else size.height
|
|
|
|
drawLine(
|
|
color = color,
|
|
start = Offset(offset, startY),
|
|
end = Offset(offset, endY),
|
|
strokeWidth = leftWidth,
|
|
pathEffect = effect
|
|
)
|
|
}
|
|
|
|
// RIGHT
|
|
if (rightWidth > 0f && borderRight != null) {
|
|
val color = borderRight.color
|
|
val effect = getPathEffect(borderRight.style, rightWidth)
|
|
val offset = size.width - (rightWidth / 2f)
|
|
|
|
val startY = if (trRadius > 0) trRadius else 0f
|
|
val endY = if (brRadius > 0) size.height - brRadius else size.height
|
|
|
|
drawLine(
|
|
color = color,
|
|
start = Offset(offset, startY),
|
|
end = Offset(offset, endY),
|
|
strokeWidth = rightWidth,
|
|
pathEffect = effect
|
|
)
|
|
}
|
|
|
|
if (tlRadius > 0f && topWidth > 0f && leftWidth > 0f && borderTop != null) {
|
|
drawArc(
|
|
color = borderTop.color,
|
|
startAngle = 180f, sweepAngle = 90f,
|
|
useCenter = false,
|
|
topLeft = Offset(leftWidth/2f, topWidth/2f),
|
|
size = Size(tlRadius * 2 - leftWidth, tlRadius * 2 - topWidth),
|
|
style = Stroke(width = topWidth)
|
|
)
|
|
}
|
|
|
|
if (trRadius > 0f && topWidth > 0f && rightWidth > 0f && borderTop != null) {
|
|
drawArc(
|
|
color = borderTop.color,
|
|
startAngle = 270f, sweepAngle = 90f,
|
|
useCenter = false,
|
|
topLeft = Offset(size.width - (trRadius * 2) + (rightWidth/2f), topWidth/2f),
|
|
size = Size(trRadius * 2 - rightWidth, trRadius * 2 - topWidth),
|
|
style = Stroke(width = topWidth)
|
|
)
|
|
}
|
|
|
|
if (brRadius > 0f && bottomWidth > 0f && rightWidth > 0f && borderBottom != null) {
|
|
drawArc(
|
|
color = borderBottom.color,
|
|
startAngle = 0f, sweepAngle = 90f,
|
|
useCenter = false,
|
|
topLeft = Offset(size.width - (brRadius * 2) + (rightWidth/2f), size.height - (brRadius * 2) + (bottomWidth/2f)),
|
|
size = Size(brRadius * 2 - rightWidth, brRadius * 2 - bottomWidth),
|
|
style = Stroke(width = bottomWidth)
|
|
)
|
|
}
|
|
|
|
if (blRadius > 0f && bottomWidth > 0f && leftWidth > 0f && borderBottom != null) {
|
|
drawArc(
|
|
color = borderBottom.color,
|
|
startAngle = 90f, sweepAngle = 90f,
|
|
useCenter = false,
|
|
topLeft = Offset(leftWidth/2f, size.height - (blRadius * 2) + (bottomWidth/2f)),
|
|
size = Size(blRadius * 2 - leftWidth, blRadius * 2 - bottomWidth),
|
|
style = Stroke(width = bottomWidth)
|
|
)
|
|
}
|
|
}
|