book-reader/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt
Aryan 74e2cec415
Position persistence during orientation change (#64)
* Fixed PDF reading position restoration during orientation change

* Refactored navigation logic in `EpubReaderScreen` and `PaginatedReader` to improve position restoration and UI consistency.

- Renamed `isNavigatingToBookmark` to `isNavigatingToPosition` and updated the overlay UI to reflect general position navigation.
- Added an orientation change listener to restore the reading position in `VERTICAL_SCROLL` mode using the last known locator.
- Improved highlight navigation in `VERTICAL_SCROLL` and `PAGINATED` modes, including chunk injection logic for highlights and fallback pagination logic.
- Implemented an anchor locator mechanism in `PaginatedReader` to preserve the reading position during layout constraint changes (e.g., orientation or resizing).
2026-03-13 22:43:17 +05:30

3661 lines
No EOL
195 KiB
Kotlin

/*
* Episteme Reader - A native Android document reader.
* Copyright (C) 2026 Episteme
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* mail: epistemereader@gmail.com
*/
// PaginatedReader.kt
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.annotation.RequiresApi
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.style.LineHeightStyle
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.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.aspectRatio
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.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.Offset
import androidx.compose.ui.geometry.Rect
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.Path
import androidx.compose.ui.graphics.PathEffect
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.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.ClipEntry
import androidx.compose.ui.platform.Clipboard
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalTextToolbar
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.platform.TextToolbarStatus
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
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.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.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.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.countWords
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epubreader.HighlightColor
import com.aryan.reader.epubreader.PaletteManagerDialog
import com.aryan.reader.epubreader.ReaderTextAlign
import com.aryan.reader.epubreader.SpectrumButton
import com.aryan.reader.epubreader.TtsHighlightInfo
import com.aryan.reader.epubreader.UserHighlight
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
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.serialization.ExperimentalSerializationApi
import kotlinx.serialization.protobuf.ProtoBuf
import timber.log.Timber
import java.io.File
import kotlin.math.abs
import kotlin.math.roundToInt
data class PaginatedSelection(
val blockIndex: Int,
val baseCfi: String,
val startOffset: Int,
val endOffset: Int,
val text: String,
val rect: Rect
)
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
}
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
}
}
}
@Composable
private fun WrappingContentLayout(
block: WrappingContentBlock,
textStyle: TextStyle,
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 {
val imageStyle = block.floatedImage.style
val intrinsicWidth = block.floatedImage.intrinsicWidth
val intrinsicHeight = block.floatedImage.intrinsicHeight
if (intrinsicWidth == null || intrinsicHeight == null || intrinsicWidth <= 0f) {
0f to 0f
} else {
val aspectRatio = intrinsicHeight / intrinsicWidth
val renderWidth = with(density) {
var w = intrinsicWidth
if (imageStyle.width != Dp.Unspecified) {
w = imageStyle.width.toPx()
}
if (imageStyle.maxWidth != Dp.Unspecified) {
w = w.coerceAtMost(imageStyle.maxWidth.toPx())
}
w.coerceAtMost(constraints.maxWidth.toFloat())
}
renderWidth to (renderWidth * aspectRatio)
}
}
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(
book: EpubBook,
isDarkTheme: Boolean,
pagerState: PagerState,
isPageTurnAnimationEnabled: Boolean,
searchQuery: String,
fontSizeMultiplier: Float,
lineHeightMultiplier: Float,
fontFamily: FontFamily,
textAlign: ReaderTextAlign,
ttsHighlightInfo: TtsHighlightInfo?,
initialChapterIndexInBook: Int?,
modifier: Modifier = Modifier,
onPaginatorReady: (IPaginator) -> Unit,
onTap: (Offset?) -> Unit,
isProUser: Boolean,
isOss: Boolean = false,
onShowDictionaryUpsellDialog: () -> Unit,
onWordSelectedForAiDefinition: (String) -> Unit,
userHighlights: List<UserHighlight>,
onHighlightCreated: (String, String, String) -> Unit,
onHighlightDeleted: (String) -> Unit,
activeHighlightPalette: List<HighlightColor>,
onUpdatePalette: (Int, HighlightColor) -> Unit
) {
LaunchedEffect(userHighlights) {
Timber.d("PaginatedReaderScreen: Received ${userHighlights.size} highlights.")
userHighlights.forEach {
Timber.d(" -> Received Highlight: CFI=${it.cfi}, Text='${it.text.take(20)}...'")
}
}
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val textMeasurer = rememberTextMeasurer()
val textColor = if (isDarkTheme) MaterialTheme.colorScheme.onBackground
else MaterialTheme.colorScheme.onSurface
val baseTextStyle = MaterialTheme.typography.bodyLarge
var debouncedFontSizeMult by remember { mutableFloatStateOf(fontSizeMultiplier) }
var debouncedLineHeightMult by remember { mutableFloatStateOf(lineHeightMultiplier) }
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 previousConstraints = remember { arrayOf(this.constraints) }
if (previousConstraints[0] != this.constraints) {
val activePaginator = currentPaginatorRef.value
if (activePaginator is BookPaginator) {
val currentPage = pagerState.currentPage
val locator = activePaginator.getLocatorForPage(currentPage)
if (locator != null) {
anchorLocatorForReconfig = locator
}
}
previousConstraints[0] = this.constraints
}
val textStyle = remember(
baseTextStyle,
textColor,
debouncedFontSizeMult,
debouncedLineHeightMult,
debouncedFontFamily
) {
val adjustedFontSize = baseTextStyle.fontSize * debouncedFontSizeMult
val adjustedLineHeight = adjustedFontSize * debouncedLineHeightMult
baseTextStyle.copy(
color = textColor,
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
)
)
}
LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, fontFamily, textAlign) {
if (fontSizeMultiplier != debouncedFontSizeMult || lineHeightMultiplier != debouncedLineHeightMult || fontFamily != debouncedFontFamily || textAlign != debouncedTextAlign) {
Timber.d("Formatting changed. Waiting for debounce.")
delay(400L)
val activePaginator = currentPaginatorRef.value
if (activePaginator is BookPaginator) {
val currentPage = pagerState.currentPage
val locator = activePaginator.getLocatorForPage(currentPage)
if (locator != null) {
anchorLocatorForReconfig = locator
}
}
debouncedFontSizeMult = fontSizeMultiplier
debouncedLineHeightMult = lineHeightMultiplier
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 horizontalPadding = 16.dp
val verticalPadding = 16.dp
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),
minHeight = 0,
maxHeight = this.constraints.maxHeight - (2 * verticalPaddingPx)
)
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
}
val paginator = remember(book, textConstraints, isDarkTheme, textStyle, userTextAlign) {
val userAgentStylesheet = UserAgentStylesheet.default
var allRules = OptimizedCssRules()
val allFontFaces = mutableListOf<FontFaceInfo>()
val uaResult = CssParser.parse(
cssContent = userAgentStylesheet,
cssPath = null,
baseFontSizeSp = textStyle.fontSize.value,
density = density.density,
constraints = textConstraints,
isDarkTheme = isDarkTheme
)
allRules = allRules.merge(uaResult.rules)
allFontFaces.addAll(uaResult.fontFaces)
book.css.forEach { (path, content) ->
val bookCssResult = CssParser.parse(
cssContent = content,
cssPath = path,
baseFontSizeSp = textStyle.fontSize.value,
density = density.density,
constraints = textConstraints,
isDarkTheme = isDarkTheme
)
allRules = allRules.merge(bookCssResult.rules)
allFontFaces.addAll(bookCssResult.fontFaces)
}
val fontFamilyMap = loadFontFamilies(
fontFaces = allFontFaces, extractionPath = book.extractionBasePath
)
book.title
val bookCacheDao =
BookCacheDatabase.getDatabase(context.applicationContext).bookCacheDao()
val proto = ProtoBuf { serializersModule = semanticBlockModule }
val uniqueBookId = 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 = textStyle,
extractionBasePath = book.extractionBasePath,
density = density,
fontFamilyMap = fontFamilyMap,
isDarkTheme = isDarkTheme,
bookId = uniqueBookId,
bookCacheDao = bookCacheDao,
proto = proto,
initialChapterToPaginate = effectiveInitialChapter,
bookCss = book.css,
userAgentStylesheet = userAgentStylesheet,
allFontFaces = allFontFaces,
context = context.applicationContext,
mathMLRenderer = mathMLRenderer,
userTextAlign = userTextAlign
)
}
LaunchedEffect(paginator) {
onPaginatorReady(paginator)
currentPaginatorRef.value = paginator
}
LaunchedEffect(paginator) {
if (anchorLocatorForReconfig != null) {
Timber.d("Waiting for paginator to initialize before restoring anchor...")
// Suspend until isLoading becomes false
snapshotFlow { paginator.isLoading }.filter { !it }.first()
val targetLocator = anchorLocatorForReconfig
if (targetLocator != null) {
Timber.d(
"Paginator initialized. Restoring anchor: Chapter=${targetLocator.chapterIndex}, Block=${targetLocator.blockIndex}, Offset=${targetLocator.charOffset}"
)
val page = paginator.findPageForLocator(targetLocator)
if (page != null) {
pagerState.scrollToPage(page)
Timber.d("Restored to page: $page")
} else {
val startPage =
paginator.chapterStartPageIndices[targetLocator.chapterIndex]
if (startPage != null) {
Timber.w(
"Exact locator not found. Falling back to start of chapter at page $startPage"
)
pagerState.scrollToPage(startPage)
} else {
Timber.e("Failed to restore position. Chapter start index not found.")
}
}
anchorLocatorForReconfig = null
}
}
}
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 -> paginator.onUserScrolledTo(page) }
}
LaunchedEffect(paginator, pagerState) {
paginator.pageShiftRequest.collect { shiftAmount ->
val newPage = pagerState.currentPage + shiftAmount
pagerState.scrollToPage(newPage)
}
}
val uiState = PaginatedReaderUiState(
isLoading = isLoading, totalPageCount = totalPageCount, generation = generation
)
PaginatedReaderContent(
uiState = uiState,
pagerState = pagerState,
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
searchQuery = searchQuery,
ttsHighlightInfo = ttsHighlightInfo,
textStyle = textStyle,
horizontalPadding = horizontalPadding,
verticalPadding = verticalPadding,
onGetPage = { pageIndex -> paginator.getPageContent(pageIndex) },
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
}
}
},
onLinkClick = { currentChapterPath, href, onNavComplete ->
paginator.navigateToHref(currentChapterPath, href, onNavComplete)
},
onTap = onTap,
isProUser = isProUser,
isOss = isOss,
onShowDictionaryUpsellDialog = onShowDictionaryUpsellDialog,
onWordSelectedForAiDefinition = onWordSelectedForAiDefinition,
userHighlights = userHighlights,
onHighlightCreated = onHighlightCreated,
onHighlightDeleted = onHighlightDeleted,
isDarkTheme = isDarkTheme,
activeHighlightPalette = activeHighlightPalette,
onUpdatePalette = onUpdatePalette
)
}
}
private data class PaginatedMenuState(
val rect: Rect, val onCopy: () -> Unit, val onHide: () -> Unit, val onSelectAll: (() -> Unit)?
)
private class CustomPaginatedTextToolbar(
private val onShow: (Rect, () -> Unit, (() -> Unit)?) -> Unit, private val onHide: () -> Unit
) : TextToolbar {
override fun showMenu(
rect: Rect,
onCopyRequested: (() -> Unit)?,
onPasteRequested: (() -> Unit)?,
onCutRequested: (() -> Unit)?,
onSelectAllRequested: (() -> Unit)?
) {
if (onCopyRequested != null) {
onShow(rect, onCopyRequested, onSelectAllRequested)
}
}
override val status: TextToolbarStatus = TextToolbarStatus.Hidden
override fun hide() {
onHide()
}
}
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
}
private 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()
@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
}
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"
)
}
var isAfterStart = false
var isBeforeEnd = true
if (relevantPart == null) {
if (startCfi.isNotEmpty()) {
try {
if (CfiUtils.compare(block.cfi!!, startCfi) > 0) {
isAfterStart = true
}
} catch (_: Exception) {
}
}
if (endCfi != null && endCfi != startCfi) {
try {
val endPath = CfiUtils.getPath(endCfi)
val cmp = CfiUtils.compare(blockPath, endPath)
Timber.d(" -> Comparing BlockPath ($blockPath) vs EndPath ($endPath). Result: $cmp")
if (CfiUtils.compare(blockPath, endPath) > 0) {
isBeforeEnd = false
}
} catch (_: Exception) {
}
}
}
Timber.d(" -> relevantPart=$relevantPart, isAfterStart=$isAfterStart, isBeforeEnd=$isBeforeEnd")
if (relevantPart == null && (!isAfterStart || !isBeforeEnd)) {
return null
}
val blockText = block.content.text
val highlightText = highlight.text
if (blockText.isEmpty() || highlightText.isEmpty()) return null
if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length
if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length
var startIndex = blockText.indexOf(highlightText, ignoreCase = false)
if (startIndex == -1) {
startIndex = blockText.indexOf(highlightText, ignoreCase = true)
}
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) {
var s = 0
var e = blockText.length
if (startMatches) {
val absOffset = 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 = 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 (startIndex >= 0) {
return startIndex until (startIndex + highlightText.length)
}
if (relevantPart == null) {
@Suppress("KotlinConstantConditions") if (isAfterStart) {
val normBlock = blockText.filter { !it.isWhitespace() }
val normHighlight = highlightText.filter { !it.isWhitespace() }
if (normHighlight.contains(normBlock, ignoreCase = true)) {
return 0 until blockText.length
}
}
}
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
}
@Composable
private fun TextWithEmphasis(
text: AnnotatedString,
modifier: Modifier = Modifier,
style: TextStyle,
@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 customDrawer = Modifier.drawBehind {
textLayoutResult?.let { layoutResult ->
if (activeSelection != null && activeSelection.blockIndex == block.blockIndex) {
val path = layoutResult.getPathForRange(
activeSelection.startOffset, activeSelection.endOffset
)
drawPath(path, Color(0xFF1976D2).copy(alpha = 0.3f))
}
if (block.cfi != null && userHighlights.isNotEmpty()) {
userHighlights.forEach { highlight ->
val range = getHighlightOffsetsInBlock(block, highlight)
if (range != null) {
try {
val path = layoutResult.getPathForRange(
range.first, range.last + 1
)
drawPath(
path,
highlight.color.color.copy(alpha = 0.4f),
blendMode = BlendMode.SrcOver
)
if (highlight.cfi == pressedHighlightCfi) {
drawPath(
path,
Color.Black.copy(alpha = 0.1f),
blendMode = BlendMode.SrcOver
)
}
} catch (_: Exception) { }
}
}
}
val emphasisAnnotations = text.getStringAnnotations("TextEmphasis", 0, text.length)
if (emphasisAnnotations.isNotEmpty()) {
emphasisAnnotations.forEach { annotation ->
val emphasis = parseEmphasisAnnotation(annotation.item, style.color)
val markColor = if (emphasis.color.isSpecified) emphasis.color else style.color
val markSize = layoutResult.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 = layoutResult.getBoundingBox(offset)
val center = Offset(
boundingBox.center.x,
if (emphasis.position == "under") boundingBox.bottom + markSize * 0.1f
else boundingBox.top - markSize * 0.1f
)
drawCircle(markColor, markSize / 2, center, style = Stroke(1f))
} catch (_: Exception) {
}
}
}
}
}
}
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) {
awaitEachGesture {
val down = awaitFirstDown(
pass = PointerEventPass.Initial, requireUnconsumed = false
)
val layout = textLayoutResult
if (layout != null) {
val hit = getHighlightAt(down.position, layout)
if (hit != null) {
down.consume()
val startPosition = down.position
var dragDistance = 0f
var isFinished = false
val longPressJob = scope.launch {
delay(500)
pressedHighlightCfi = hit.first.cfi
}
try {
while (true) {
val event = awaitPointerEvent(
pass = PointerEventPass.Initial
)
val change = event.changes.firstOrNull {
it.id == down.id
}
if (change == null) {
longPressJob.cancel()
pressedHighlightCfi = null
break
}
change.consume()
if (change.pressed) {
dragDistance = (change.position - startPosition).getDistance()
if (dragDistance >= viewConfiguration.touchSlop) {
longPressJob.cancel()
pressedHighlightCfi = null
}
} else {
isFinished = true
longPressJob.cancel()
pressedHighlightCfi = null
break
}
}
} catch (_: Exception) {
longPressJob.cancel()
pressedHighlightCfi = null
}
if (isFinished && dragDistance < viewConfiguration.touchSlop) {
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)
}
}
}
}
}
.pointerInput(text) {
detectTapGestures(
onTap = { offset ->
textLayoutResult?.let { layout ->
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,
@Suppress("SameParameterValue") tolerance: Int = 2
) {
if (expectedHeight == 0) {
Timber.tag("PAGINATION_MISMATCH").w("Block #$blockIndex ($blockType) has expectedHeight=0. Skipping check. Text: '$textSnippet'")
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'"
)
}
}
@SuppressLint("UnusedBoxWithConstraintsScope")
@OptIn(ExperimentalFoundationApi::class)
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@Composable
internal fun PaginatedReaderContent(
uiState: PaginatedReaderUiState,
pagerState: PagerState,
isPageTurnAnimationEnabled: Boolean,
searchQuery: String,
ttsHighlightInfo: TtsHighlightInfo?,
textStyle: TextStyle,
horizontalPadding: Dp,
verticalPadding: Dp,
onGetPage: (Int) -> Page?,
onGetChapterPath: (Int) -> String?,
onLinkClick: (currentChapterPath: String, href: String, onNavComplete: (Int) -> Unit) -> Unit,
onTap: (Offset?) -> Unit,
isProUser: Boolean,
isOss: Boolean,
onShowDictionaryUpsellDialog: () -> Unit,
onWordSelectedForAiDefinition: (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
) {
val coroutineScope = rememberCoroutineScope()
val density = LocalDensity.current
var menuState by remember { mutableStateOf<PaginatedMenuState?>(null) }
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) }
var activeHighlightForMenu by remember { mutableStateOf<Pair<UserHighlight, Rect>?>(null) }
val blockLayoutMap = remember {
androidx.compose.runtime.mutableStateMapOf<String, Triple<TextLayoutResult, LayoutCoordinates, Int>>()
}
var showColorPickerDialog by remember { mutableStateOf<Int?>(null) }
var showPaletteManager by remember { mutableStateOf(false) }
if (showExternalLinkDialog != null) {
val urlToShow = showExternalLinkDialog!!
AlertDialog(
onDismissRequest = { showExternalLinkDialog = null },
title = { Text("External Link") },
text = {
Text(
"You clicked on an external link:\n\n$urlToShow\n\nWhat would you like to do?"
)
},
confirmButton = {
Row(horizontalArrangement = Arrangement.End) {
TextButton(
onClick = {
val clipboard =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Copied Link", urlToShow)
clipboard.setPrimaryClip(clip)
showExternalLinkDialog = null
}) { Text("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, "No browser found to open the link.", Toast.LENGTH_LONG
).show()
}
showExternalLinkDialog = null
}) { Text("Open") }
}
},
dismissButton = {
TextButton(onClick = { showExternalLinkDialog = null }) { Text("Cancel") }
})
}
val textToolbar = remember {
CustomPaginatedTextToolbar(onShow = { rect, onCopy, onSelectAll ->
if (activeHighlightForMenu == null) {
menuState = PaginatedMenuState(
rect, onCopy, onHide = { menuState = null }, onSelectAll
)
}
}, onHide = { menuState = null })
}
var pageTurnTouchY by remember { mutableStateOf<Float?>(null) }
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
val realClipboard: Clipboard = LocalClipboard.current
var isForDictionary by remember { mutableStateOf(false) }
var isForHighlight by remember { mutableStateOf(false) }
var capturedTextForAction by remember { mutableStateOf<String?>(null) }
val dictionaryClipboard = remember(realClipboard) {
object : Clipboard {
override val nativeClipboard: ClipboardManager
get() = realClipboard.nativeClipboard
override suspend fun getClipEntry(): ClipEntry? = realClipboard.getClipEntry()
override suspend fun setClipEntry(clipEntry: ClipEntry?) {
val text = clipEntry?.clipData?.getItemAt(0)?.text?.toString()
Timber.d(
"Clipboard intercept: setClipEntry called. Text: '$text', isForHighlight: $isForHighlight"
)
capturedTextForAction = text
if (isForDictionary) {
if (!text.isNullOrBlank()) {
onWordSelectedForAiDefinition(text)
}
} else if (isForHighlight) {
// Do not copy to real clipboard
} else {
realClipboard.setClipEntry(clipEntry)
}
}
}
}
CompositionLocalProvider(
LocalTextToolbar provides textToolbar, LocalClipboard provides dictionaryClipboard
) {
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxSize()
.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
) { pageIndex ->
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
val zIndex = -pageOffset
val pageModifier = if (isPageTurnAnimationEnabled) {
Modifier
.zIndex(zIndex)
.realisticBookPage(pagerState, pageIndex, isDarkTheme, pageTurnTouchY) // UPDATED
} else {
Modifier
}
var pageContent by remember { mutableStateOf<Page?>(null) }
var currentChapterPath by remember { mutableStateOf<String?>(null) }
LaunchedEffect(pageIndex, uiState.generation) {
Timber.tag("ReflowPaginationDiag").d("PaginatedReaderContent: Fetching page $pageIndex content. generation=${uiState.generation}")
pageContent = onGetPage(pageIndex)
Timber.tag("ReflowPaginationDiag").d("PaginatedReaderContent: Fetched page $pageIndex content. isNull=${pageContent == null}, blocks=${pageContent?.content?.size}")
onGetChapterPath(pageIndex)?.let { currentChapterPath = it }
}
Box(modifier = Modifier.fillMaxSize().then(pageModifier)) {
SelectionContainer(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures(
onTap = { offset ->
Timber.d(
"Tap detected on empty page area."
)
menuState?.onHide?.invoke()
onTap(offset)
})
}
.padding(
horizontal = horizontalPadding, vertical = verticalPadding
), contentAlignment = Alignment.TopStart) {
if (pageContent != null) {
val onGeneralTapCallback: (Offset) -> Unit = { offset ->
menuState?.onHide?.invoke()
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 ->
coroutineScope.launch {
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)
pageContent!!.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,
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,
textMeasurer = textMeasurer,
onLinkClick = onLinkClickCallback,
onGeneralTap = onGeneralTapCallback,
block = block,
userHighlights = userHighlights,
activeSelection = activeSelection,
onSelectionChange = { sel ->
activeSelection = sel
},
onHighlightClick = { highlight, rect ->
activeHighlightForMenu =
highlight to rect
activeSelection = null
menuState = null
},
isDarkTheme = isDarkTheme,
onRegisterLayout = { layout, coords ->
if (block.cfi != null) blockLayoutMap[block.cfi] =
Triple(
layout,
coords,
block.startCharOffsetInSource
)
})
}
is HeaderBlock -> {
val style = textStyle.copy(
fontWeight = FontWeight.Bold,
textAlign = block.textAlign ?: textStyle.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,
textMeasurer = textMeasurer,
onLinkClick = onLinkClickCallback,
onGeneralTap = onGeneralTapCallback,
block = block,
userHighlights = userHighlights,
activeSelection = activeSelection,
onSelectionChange = { sel ->
activeSelection = sel
},
onHighlightClick = { highlight, rect ->
activeHighlightForMenu =
highlight to rect
activeSelection = null
menuState = null
},
isDarkTheme = isDarkTheme,
onRegisterLayout = { layout, coords ->
if (block.cfi != null) blockLayoutMap[block.cfi] =
Triple(
layout,
coords,
block.startCharOffsetInSource
)
})
}
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,
textMeasurer = textMeasurer,
onLinkClick = onLinkClickCallback,
onGeneralTap = onGeneralTapCallback,
block = block,
userHighlights = userHighlights,
activeSelection = activeSelection,
onSelectionChange = { sel ->
activeSelection = sel
},
onHighlightClick = { highlight, rect ->
activeHighlightForMenu =
highlight to rect
activeSelection = null
menuState = null
},
isDarkTheme = isDarkTheme,
onRegisterLayout = { layout, coords ->
if (block.cfi != null) blockLayoutMap[block.cfi] =
Triple(
layout,
coords,
block.startCharOffsetInSource
)
})
}
is ListItemBlock -> {
Row(
modifier = paddingModifier,
verticalAlignment = Alignment.Top
) {
val markerAreaModifier =
Modifier
.width(32.dp)
.padding(end = 8.dp)
if (block.itemMarkerImage != null) {
val imageRequest =
Builder(LocalContext.current).data(
File(
block.itemMarkerImage
)
).crossfade(true).build()
val imageSize = with(density) {
(textStyle.fontSize.value * 0.8f).sp.toDp()
}
AsyncImage(
model = imageRequest,
contentDescription = "List item marker",
modifier = markerAreaModifier.height(
imageSize
),
alignment = Alignment.CenterEnd,
contentScale = ContentScale.FillHeight
)
} else if (block.itemMarker != null) {
Text(
text = block.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),
textMeasurer = textMeasurer,
onLinkClick = onLinkClickCallback,
onGeneralTap = onGeneralTapCallback,
block = block,
userHighlights = userHighlights,
activeSelection = activeSelection,
onSelectionChange = { sel ->
activeSelection = sel
},
onHighlightClick = { highlight, rect ->
activeHighlightForMenu =
highlight to rect
activeSelection = null
menuState = null
},
isDarkTheme = isDarkTheme,
onRegisterLayout = { layout, coords ->
if (block.cfi != null) blockLayoutMap[block.cfi] =
Triple(
layout,
coords,
block.startCharOffsetInSource
)
})
}
}
is WrappingContentBlock -> {
WrappingContentLayout(
block = block,
textStyle = textStyle,
modifier = paddingModifier,
searchQuery = searchQuery,
ttsHighlightInfo = ttsHighlightInfo,
searchHighlightColor = searchHighlightColor,
ttsHighlightColor = ttsHighlightColor
)
}
is FlexContainerBlock -> {
// Background, border, and padding are
// already applied by the outer Box wrapper.
// Only apply padding + width here.
val containerModifier = paddingModifier
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 = containerModifier.fillMaxWidth(),
horizontalArrangement = horizontalArrangement,
verticalAlignment = verticalAlignment
) {
block.children.forEach { childBlock ->
RenderFlexChildBlock(
childBlock = childBlock,
textStyle = textStyle,
searchQuery = searchQuery,
searchHighlightColor = searchHighlightColor,
ttsHighlightInfo = ttsHighlightInfo,
ttsHighlightColor = ttsHighlightColor,
textMeasurer = textMeasurer,
onLinkClickCallback = onLinkClickCallback,
onGeneralTapCallback = onGeneralTapCallback,
userHighlights = userHighlights,
activeSelection = activeSelection,
onSelectionChange = { sel ->
activeSelection = sel
},
onHighlightClick = { highlight, rect ->
activeHighlightForMenu =
highlight to rect
activeSelection = null
menuState = null
},
isDarkTheme = isDarkTheme,
blockLayoutMap = blockLayoutMap,
density = density,
imageLoader = imageLoader
)
}
}
} 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 = containerModifier.fillMaxWidth(),
verticalArrangement = verticalArrangement,
horizontalAlignment = horizontalAlignment
) {
block.children.forEach { childBlock ->
RenderFlexChildBlock(
childBlock = childBlock,
textStyle = textStyle,
searchQuery = searchQuery,
searchHighlightColor = searchHighlightColor,
ttsHighlightInfo = ttsHighlightInfo,
ttsHighlightColor = ttsHighlightColor,
textMeasurer = textMeasurer,
onLinkClickCallback = onLinkClickCallback,
onGeneralTapCallback = onGeneralTapCallback,
userHighlights = userHighlights,
activeSelection = activeSelection,
onSelectionChange = { sel ->
activeSelection = sel
},
onHighlightClick = { highlight, rect ->
activeHighlightForMenu =
highlight to rect
activeSelection = null
menuState = null
},
isDarkTheme = isDarkTheme,
blockLayoutMap = blockLayoutMap,
density = density,
imageLoader = imageLoader
)
}
}
}
}
is MathBlock -> {
Timber.d(
"PaginatedReader: Rendering MathBlock. Alt: '${block.altText}', Has SVG: ${!block.svgContent.isNullOrBlank()}"
)
if (!block.svgContent.isNullOrBlank()) {
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(
block.svgContent
)
).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 finalImageModifier = Modifier
.then(
if (style.width != Dp.Unspecified) Modifier.width(
style.width
)
else Modifier
)
.then(
if (style.maxWidth != Dp.Unspecified) Modifier.widthIn(
max = style.maxWidth
)
else Modifier
)
.then(
if (block.intrinsicWidth != null && block.intrinsicHeight != null && block.intrinsicWidth > 0f && block.intrinsicHeight > 0f) {
Modifier.aspectRatio(block.intrinsicWidth / block.intrinsicHeight, matchHeightConstraintsFirst = false)
} else if (style.height != Dp.Unspecified) {
Modifier.height(style.height)
} else {
Modifier.height(250.dp)
}
)
.then(paddingModifier)
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()
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
) {
if (blockInCell.itemMarker != null) {
Text(
text = blockInCell.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 -> {
val imageModifier = Modifier.fillMaxWidth().then(
if (blockInCell.intrinsicWidth != null && blockInCell.intrinsicHeight != null && blockInCell.intrinsicWidth > 0f && blockInCell.intrinsicHeight > 0f) {
Modifier.aspectRatio(blockInCell.intrinsicWidth / blockInCell.intrinsicHeight, matchHeightConstraintsFirst = false)
} else if (blockInCell.style.height != Dp.Unspecified) {
Modifier.height(blockInCell.style.height)
} else {
Modifier.height(250.dp)
}
)
AsyncImage(
model = Builder(
LocalContext.current
).data(
File(
blockInCell.path
)
).build(),
contentDescription = blockInCell.altText,
contentScale = ContentScale.Fit,
modifier = imageModifier
)
}
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)
}
}
}
}
}
}
menuState?.let { state ->
Popup(popupPositionProvider = remember(state.rect, density) {
SmartPopupPositionProvider(state.rect, density)
}, onDismissRequest = { state.onHide() }) {
PaginatedTextSelectionMenu(
onCopy = {
isForDictionary = false
isForHighlight = false
state.onCopy()
state.onHide()
}, onSelectAll = {
state.onSelectAll?.invoke()
state.onHide()
}, onDictionary = {
isForDictionary = true
state.onCopy()
isForDictionary = false
state.onHide()
}, onHighlight = { color ->
Timber.d("Menu: Highlight option clicked. Color: ${color.id}")
isForHighlight = true
state.onCopy()
isForHighlight = false
capturedTextForAction?.let { text ->
val selectionRect = state.rect
Timber.d("Menu: Selection Rect: $selectionRect")
var geometricSuccess = false
val candidates = blockLayoutMap.filter { (_, triple) ->
val (_, coords, _) = triple
if (!coords.isAttached) return@filter false
val pos = coords.positionInWindow()
val size = coords.size.toSize()
val blockRect = Rect(pos, size)
val overlaps = blockRect.overlaps(selectionRect)
overlaps
}
if (candidates.isNotEmpty()) {
Timber.d(
"Menu: Geometric candidates found: ${candidates.keys}"
)
try {
val sorted = candidates.entries.sortedBy {
it.value.second.positionInWindow().y
}
val (startCfi, startTriple) = sorted.first()
val (endCfi, endTriple) = sorted.last()
val (startLayout, startCoords, startAbsOffset) = startTriple
val (endLayout, endCoords, endAbsOffset) = endTriple
val localStart =
startCoords.windowToLocal(selectionRect.topLeft)
val localEnd = endCoords.windowToLocal(
selectionRect.bottomRight
)
var finalStartOffset =
startLayout.getOffsetForPosition(localStart)
var finalEndOffset = endLayout.getOffsetForPosition(localEnd)
var finalEndCfi = endCfi
val startText = startLayout.layoutInput.text.text
if (startCfi == endCfi) {
val matches = mutableListOf<Int>()
var idx = startText.indexOf(text)
while (idx != -1) {
matches.add(idx)
idx = startText.indexOf(text, idx + 1)
}
if (matches.isNotEmpty()) {
val bestMatch = matches.minBy {
abs(it - finalStartOffset)
}
finalStartOffset = bestMatch
finalEndOffset = bestMatch + text.length
Timber.d(
"Refined Single-Block Offset: $finalStartOffset"
)
}
} else {
val endText = endLayout.layoutInput.text.text
fun findBestMatch(
source: String,
query: String,
targetOffset: Int,
isSuffix: Boolean
): Int {
if (query.isEmpty()) return -1
var bestIdx = -1
var minDiff = Int.MAX_VALUE
var idx = source.indexOf(query)
while (idx != -1) {
val cmpPoint = if (isSuffix) idx + query.length
else idx
val diff = abs(cmpPoint - targetOffset)
if (diff < minDiff) {
minDiff = diff
bestIdx = idx
}
idx = source.indexOf(query, idx + 1)
}
return bestIdx
}
var sMatch = -1
var eMatch = -1
var usedSuffixLen = 0
val maxChunk = minOf(text.length, 50)
for (len in maxChunk downTo 3) {
val prefix = text.take(len).trim()
if (prefix.isNotEmpty()) {
val idx = findBestMatch(
startText,
prefix,
finalStartOffset,
isSuffix = false
)
if (idx != -1) {
sMatch = idx
Timber.d(
"Refined Start: Found prefix '$prefix' at $idx"
)
break
}
}
}
for (len in maxChunk downTo 3) {
val suffix = text.takeLast(len).trim()
if (suffix.isNotEmpty()) {
val idx = findBestMatch(
endText,
suffix,
finalEndOffset,
isSuffix = true
)
if (idx != -1) {
eMatch = idx
usedSuffixLen = suffix.length
Timber.d(
"Refined End: Found suffix '$suffix' at $idx"
)
break
}
}
}
if (sMatch != -1 && eMatch != -1) {
finalStartOffset = sMatch
finalEndOffset = eMatch + usedSuffixLen
} else if (eMatch == -1) {
Timber.d(
"Refined: Suffix not found in end block. Checking single block fit."
)
val fitIdx = startText.indexOf(text)
if (fitIdx != -1) {
finalEndCfi = startCfi
finalStartOffset = fitIdx
finalEndOffset = fitIdx + text.length
}
}
}
val absStart = finalStartOffset + startAbsOffset
val absEnd =
finalEndOffset + if (startCfi == finalEndCfi) startAbsOffset
else endAbsOffset
val rangeCfi = if (startCfi == finalEndCfi) {
val actualStart = minOf(absStart, absEnd)
val actualEnd = maxOf(absStart, absEnd).coerceAtLeast(
actualStart + 1
)
"$startCfi:$actualStart|$finalEndCfi:$actualEnd"
} else {
"$startCfi:$absStart|$finalEndCfi:$absEnd"
}
Timber.d("Menu: Geometric Success. CFI: $rangeCfi")
onHighlightCreated(rangeCfi, text, color.id)
geometricSuccess = true
} catch (e: Exception) {
Timber.e(e, "Menu: Geometric calculation failed.")
}
}
if (!geometricSuccess) {
Timber.w("Menu: Falling back to text search.")
val pageContent = onGetPage(pagerState.currentPage)
val textBlocks =
pageContent?.content?.filterIsInstance<TextContentBlock>()
?.filter { it.cfi != null } ?: emptyList()
var startBlock: TextContentBlock? = null
var endBlock: TextContentBlock? = null
var startOffsetRel = -1
var endOffsetRel = -1
for (block in textBlocks) {
val content = block.content.text
val idx = content.indexOf(text)
if (idx != -1) {
startBlock = block
endBlock = block
startOffsetRel = idx
endOffsetRel = idx + text.length
break
}
}
endBlock = startBlock
if (startBlock != null) {
val startAbs =
startBlock.startCharOffsetInSource + startOffsetRel
val endAbs =
endBlock.startCharOffsetInSource + (if (endOffsetRel != -1) endOffsetRel
else startOffsetRel + text.length)
onHighlightCreated(
"${startBlock.cfi}:$startAbs|${endBlock.cfi}:$endAbs",
text,
color.id
)
}
}
}
state.onHide()
}, onDelete = null, isProUser = isProUser, isOss = isOss,
activeHighlightPalette = activeHighlightPalette,
onOpenPaletteManager = { showPaletteManager = true }
)
}
}
if (activeSelection != null) {
val sel = activeSelection!!
Popup(popupPositionProvider = remember(sel.rect, density) {
SmartPopupPositionProvider(sel.rect, density)
}, onDismissRequest = { activeSelection = null }) {
PaginatedTextSelectionMenu(
onCopy = {
val clipboardManager =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("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
}, onHighlight = { color ->
Timber.d(
"CustomSelection: Highlight clicked. Text: '${sel.text}', BaseCFI: ${sel.baseCfi}, StartOffset: ${sel.startOffset}"
)
val finalCfi = if (sel.startOffset > 0) "${sel.baseCfi}:${sel.startOffset}"
else sel.baseCfi
onHighlightCreated(finalCfi, sel.text, color.id)
activeSelection = null
}, onDelete = null, isProUser = isProUser, isOss = isOss,
activeHighlightPalette = activeHighlightPalette,
onOpenPaletteManager = { showPaletteManager = true }
)
}
}
if (showColorPickerDialog != null) {
AlertDialog(
onDismissRequest = { showColorPickerDialog = null },
title = { Text("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("Close") } }
)
}
// Edit Menu (Delete)
if (activeHighlightForMenu != null) {
val (highlight, rect) = activeHighlightForMenu!!
Popup(popupPositionProvider = remember(rect, density) {
SmartPopupPositionProvider(rect, density)
}, onDismissRequest = { activeHighlightForMenu = null }) {
PaginatedTextSelectionMenu(
onCopy = {
val clipboardManager =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Copied Text", highlight.text)
clipboardManager.setPrimaryClip(clip)
activeHighlightForMenu = null
},
onSelectAll = null,
onDictionary = {
if (isProUser || countWords(highlight.text) <= 1) {
onWordSelectedForAiDefinition(highlight.text)
} else {
onShowDictionaryUpsellDialog()
}
activeHighlightForMenu = null
},
onHighlight = { color ->
Timber.d("Menu: Updating highlight color to ${color.id}")
onHighlightDeleted(highlight.cfi)
onHighlightCreated(highlight.cfi, highlight.text, color.id)
activeHighlightForMenu = null
},
onDelete = {
onHighlightDeleted(highlight.cfi)
activeHighlightForMenu = null
},
isProUser = isProUser,
isOss = isOss,
activeHighlightPalette = activeHighlightPalette,
onOpenPaletteManager = { showPaletteManager = true }
)
}
}
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("This book has no content to display.")
}
}
}
}
@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 PaginatedTextSelectionMenu(
onCopy: () -> Unit,
onSelectAll: (() -> Unit)?,
onDictionary: () -> Unit,
onHighlight: ((HighlightColor) -> Unit)?,
onDelete: (() -> Unit)?,
@Suppress("unused") isProUser: Boolean,
@Suppress("unused") isOss: Boolean,
activeHighlightPalette: List<HighlightColor> = emptyList(),
onOpenPaletteManager: (() -> Unit)? = null
) {
Surface(
shape = RoundedCornerShape(12.dp),
shadowElevation = 6.dp,
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Column(modifier = Modifier.width(IntrinsicSize.Max)) {
// 1. Colors Row
if (onHighlight != null) {
Row(
modifier = Modifier
.padding(vertical = 12.dp, horizontal = 12.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
activeHighlightPalette.forEach { colorEnum ->
Box(
modifier = Modifier
.padding(horizontal = 6.dp)
.size(32.dp)
.background(colorEnum.color, CircleShape)
.clickable { onHighlight(colorEnum) }
)
}
if (onOpenPaletteManager != null) {
Spacer(modifier = Modifier.width(8.dp))
SpectrumButton(
onClick = onOpenPaletteManager,
size = 32.dp
)
}
}
HorizontalDivider()
}
// 2. Delete Option
if (onDelete != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onDelete)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Remove",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp)
)
Text(
"Remove",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
}
HorizontalDivider()
}
// 3. Copy Option
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onCopy)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(id = R.drawable.copy),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(20.dp)
)
Text(
"Copy",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
// 4. Select All Option
if (onSelectAll != null) {
HorizontalDivider()
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onSelectAll)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(id = R.drawable.select_all),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(20.dp)
)
Text(
"Select All",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
// 5. Dictionary Option
HorizontalDivider()
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onDictionary)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(id = R.drawable.dictionary),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(20.dp)
)
Text(
"Dictionary",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
@Composable
private fun RenderFlexChildBlock(
childBlock: ContentBlock,
textStyle: TextStyle,
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, Int>>,
density: Density,
imageLoader: ImageLoader
) {
@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) {
textStyle.copy(
fontWeight = FontWeight.Bold, fontSize = textStyle.fontSize * block.level.let {
when (it) {
1 -> 1.5f
2 -> 1.4f
3 -> 1.3f
4 -> 1.2f
5 -> 1.1f
else -> 1.0f
}
})
} else {
textStyle
}
TextWithEmphasis(
text = finalContent,
style = finalStyle,
modifier = Modifier,
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] = Triple(layout, coords, block.startCharOffsetInSource)
}
})
}
when (childBlock) {
is ListItemBlock -> {
Row(modifier = Modifier, verticalAlignment = Alignment.Top) {
val markerAreaModifier = Modifier
.width(32.dp)
.padding(end = 8.dp)
if (childBlock.itemMarkerImage != null) {
val imageRequest =
Builder(LocalContext.current).data(File(childBlock.itemMarkerImage))
.crossfade(true).build()
val imageSize = with(density) { (textStyle.fontSize.value * 0.8f).sp.toDp() }
AsyncImage(
model = imageRequest,
contentDescription = "List item marker",
modifier = markerAreaModifier.height(imageSize),
alignment = Alignment.CenterEnd,
contentScale = ContentScale.FillHeight
)
} else if (childBlock.itemMarker != null) {
Text(
text = childBlock.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 imageModifier = Modifier
.then(
if (style.width != Dp.Unspecified) Modifier.width(style.width)
else Modifier
)
.then(
if (style.maxWidth != Dp.Unspecified) Modifier.widthIn(max = style.maxWidth)
else Modifier
)
.then(
if (childBlock.intrinsicWidth != null && childBlock.intrinsicHeight != null && childBlock.intrinsicWidth > 0f && childBlock.intrinsicHeight > 0f) {
Modifier.aspectRatio(childBlock.intrinsicWidth / childBlock.intrinsicHeight, matchHeightConstraintsFirst = false)
} else if (style.height != Dp.Unspecified) {
Modifier.height(style.height)
} else {
Modifier.height(250.dp)
}
)
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
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) {
val imageModifier = Modifier.fillMaxWidth().then(
if (blockInCell.intrinsicWidth != null && blockInCell.intrinsicHeight != null && blockInCell.intrinsicWidth > 0f && blockInCell.intrinsicHeight > 0f) {
Modifier.aspectRatio(blockInCell.intrinsicWidth / blockInCell.intrinsicHeight, matchHeightConstraintsFirst = false)
} else if (blockInCell.style.height != Dp.Unspecified) {
Modifier.height(blockInCell.style.height)
} else {
Modifier.height(250.dp)
}
)
AsyncImage(
model = Builder(LocalContext.current).data(
File(
blockInCell.path
)
).build(),
contentDescription = blockInCell.altText,
contentScale = ContentScale.Fit,
modifier = imageModifier
)
}
}
}
}
}
}
}
}
else -> {
Timber.w(
"FlexContainerBlock child type still not supported: ${childBlock::class.simpleName}"
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
private fun Modifier.realisticBookPage(
pagerState: PagerState,
pageIndex: Int,
isDarkTheme: Boolean,
touchY: Float?
): Modifier = composed {
val frontPath = remember { Path() }
val backPath = remember { Path() }
val reflectedScreenPath = remember { Path() }
this
.graphicsLayer {
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
if (pageOffset <= 1f && pageOffset > -1f) {
translationX = -pageOffset * size.width
}
if (pageOffset != 0f) {
shadowElevation = 10f
shape = androidx.compose.ui.graphics.RectangleShape
clip = false
}
}
.drawWithContent {
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
val paperColor = if (isDarkTheme) Color(0xFF121212) else Color(0xFFFFFFFF)
if (abs(pageOffset) < 0.001f) {
drawRect(color = paperColor)
drawContent()
}
else if (pageOffset < 0f && pageOffset > -1f) {
val progress = -pageOffset
val w = size.width
val h = size.height
val startY = touchY ?: h
val centerDist = ((startY - h / 2f) / (h / 2f)).coerceIn(-1f, 1f)
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 = kotlin.math.sqrt(dx * dx + dy * dy)
if (nLen > 0f) {
val nx = dx / nLen
val ny = dy / nLen
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) {
drawRect(color = paperColor)
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) {
val flapColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0)
drawPath(reflectedScreenPath, color = flapColor)
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 {
drawRect(color = paperColor)
drawContent()
}
}
else if (pageOffset > 0f && pageOffset <= 1f) {
drawRect(color = paperColor)
drawContent()
val dimAlpha = (0.25f * pageOffset).coerceIn(0f, 0.4f)
drawRect(color = Color.Black.copy(alpha = dimAlpha))
}
else {
drawRect(color = paperColor)
drawContent()
}
}
}
@Suppress("KotlinConstantConditions")
@Composable
fun Modifier.drawCssBorders(
blockStyle: BlockStyle,
@Suppress("unused") density: Density
): Modifier = this.drawBehind {
val topWidth = blockStyle.borderTop?.width?.toPx() ?: 0f
val rightWidth = blockStyle.borderRight?.width?.toPx() ?: 0f
val bottomWidth = blockStyle.borderBottom?.width?.toPx() ?: 0f
val leftWidth = blockStyle.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(
androidx.compose.ui.geometry.RoundRect(
rect = size.toRect(),
topLeft = androidx.compose.ui.geometry.CornerRadius(tlRadius, tlRadius),
topRight = androidx.compose.ui.geometry.CornerRadius(trRadius, trRadius),
bottomRight = androidx.compose.ui.geometry.CornerRadius(brRadius, brRadius),
bottomLeft = androidx.compose.ui.geometry.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 && blockStyle.borderTop != null) {
val color = blockStyle.borderTop.color
val effect = getPathEffect(blockStyle.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 && blockStyle.borderBottom != null) {
val color = blockStyle.borderBottom.color
val effect = getPathEffect(blockStyle.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 && blockStyle.borderLeft != null) {
val color = blockStyle.borderLeft.color
val effect = getPathEffect(blockStyle.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 && blockStyle.borderRight != null) {
val color = blockStyle.borderRight.color
val effect = getPathEffect(blockStyle.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 && blockStyle.borderTop != null) {
drawArc(
color = blockStyle.borderTop.color,
startAngle = 180f, sweepAngle = 90f,
useCenter = false,
topLeft = Offset(leftWidth/2f, topWidth/2f),
size = androidx.compose.ui.geometry.Size(tlRadius * 2 - leftWidth, tlRadius * 2 - topWidth),
style = Stroke(width = topWidth)
)
}
if (trRadius > 0f && topWidth > 0f && rightWidth > 0f && blockStyle.borderTop != null) {
drawArc(
color = blockStyle.borderTop.color,
startAngle = 270f, sweepAngle = 90f,
useCenter = false,
topLeft = Offset(size.width - (trRadius * 2) + (rightWidth/2f), topWidth/2f),
size = androidx.compose.ui.geometry.Size(trRadius * 2 - rightWidth, trRadius * 2 - topWidth),
style = Stroke(width = topWidth)
)
}
if (brRadius > 0f && bottomWidth > 0f && rightWidth > 0f && blockStyle.borderBottom != null) {
drawArc(
color = blockStyle.borderBottom.color,
startAngle = 0f, sweepAngle = 90f,
useCenter = false,
topLeft = Offset(size.width - (brRadius * 2) + (rightWidth/2f), size.height - (brRadius * 2) + (bottomWidth/2f)),
size = androidx.compose.ui.geometry.Size(brRadius * 2 - rightWidth, brRadius * 2 - bottomWidth),
style = Stroke(width = bottomWidth)
)
}
if (blRadius > 0f && bottomWidth > 0f && leftWidth > 0f && blockStyle.borderBottom != null) {
drawArc(
color = blockStyle.borderBottom.color,
startAngle = 90f, sweepAngle = 90f,
useCenter = false,
topLeft = Offset(leftWidth/2f, size.height - (blRadius * 2) + (bottomWidth/2f)),
size = androidx.compose.ui.geometry.Size(blRadius * 2 - leftWidth, blRadius * 2 - bottomWidth),
style = Stroke(width = bottomWidth)
)
}
}