Added customizable highlight color palette in epub (#6)

This introduces a user-configurable palette for text highlighting. The four default highlight colors can now be customized by the user from an expanded set of predefined colors.

Changes include:
- Added a `PaletteManagerDialog` to allow users to select and save their preferred highlight colors.
- Implemented `saveHighlightPalette` and `loadHighlightPalette` to persist the user's choices.
- The highlighting UI in both vertical and paginated readers now displays the active custom palette.
- Added new `HighlightColor` options.
- Updated JavaScript to support the expanded color set and improve highlight interaction logic.
This commit is contained in:
Aryan 2026-02-26 14:26:54 +05:30 committed by GitHub
parent 1b048c9073
commit 1f111034f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 3044 additions and 1753 deletions

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,7 @@
* *
* mail: epistemereader@gmail.com * mail: epistemereader@gmail.com
*/ */
// ChapterWebView.kt
package com.aryan.reader.epubreader package com.aryan.reader.epubreader
import android.annotation.SuppressLint import android.annotation.SuppressLint
@ -27,7 +28,6 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.graphics.Rect import android.graphics.Rect
import timber.log.Timber
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
import android.webkit.WebSettings import android.webkit.WebSettings
@ -36,13 +36,14 @@ import android.webkit.WebViewClient
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -69,6 +70,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@ -86,6 +88,7 @@ import com.aryan.reader.countWords
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.json.JSONObject import org.json.JSONObject
import timber.log.Timber
import java.io.BufferedReader import java.io.BufferedReader
import java.io.InputStreamReader import java.io.InputStreamReader
@ -326,6 +329,8 @@ fun ChapterWebView(
currentTextAlign: ReaderTextAlign, currentTextAlign: ReaderTextAlign,
onHighlightClicked: () -> Unit, onHighlightClicked: () -> Unit,
onAutoScrollChapterEnd: () -> Unit = {}, onAutoScrollChapterEnd: () -> Unit = {},
activeHighlightPalette: List<HighlightColor>,
onUpdatePalette: (Int, HighlightColor) -> Unit
) { ) {
Timber.d( Timber.d(
"RenderChapterViaWebView for '$chapterTitle', Key: $key, isDarkTheme: $isDarkTheme, initialScrollTarget: $initialScrollTarget" "RenderChapterViaWebView for '$chapterTitle', Key: $key, isDarkTheme: $isDarkTheme, initialScrollTarget: $initialScrollTarget"
@ -340,6 +345,8 @@ fun ChapterWebView(
val jsToInject = remember(context) { getJsToInject(context) } val jsToInject = remember(context) { getJsToInject(context) }
var showPaletteManager by remember { mutableStateOf(false) }
LaunchedEffect(currentFontSize, currentLineHeight) { LaunchedEffect(currentFontSize, currentLineHeight) {
localWebViewRef?.evaluateJavascript( localWebViewRef?.evaluateJavascript(
"javascript:if(window.getSelection) window.getSelection().removeAllRanges();", "javascript:if(window.getSelection) window.getSelection().removeAllRanges();",
@ -542,9 +549,9 @@ fun ChapterWebView(
} }
addJavascriptInterface( addJavascriptInterface(
CfiJsBridge( CfiJsBridge(
onCfiReady = { cfi -> onCfiGenerated(cfi) }, onCfiReady = { cfi -> onCfiGenerated(cfi) },
onCfiForBookmarkReady = { cfi -> onBookmarkCfiGenerated(cfi) } onCfiForBookmarkReady = { cfi -> onBookmarkCfiGenerated(cfi) }
), "CfiBridge") ), "CfiBridge")
addJavascriptInterface(SnippetJsBridge { cfi, snippet -> addJavascriptInterface(SnippetJsBridge { cfi, snippet ->
onSnippetForBookmarkReady( onSnippetForBookmarkReady(
cfi, cfi,
@ -812,45 +819,51 @@ fun ChapterWebView(
Column( Column(
modifier = Modifier.width(IntrinsicSize.Max) modifier = Modifier.width(IntrinsicSize.Max)
) { ) {
// 1. Color Row (Improved sizing and gaps)
Row( Row(
modifier = Modifier modifier = Modifier
.padding(vertical = 12.dp, horizontal = 12.dp) .padding(vertical = 12.dp, horizontal = 12.dp)
.fillMaxWidth(), .fillMaxWidth(),
horizontalArrangement = Arrangement.Center, // Centered colors horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
HighlightColor.entries.forEach { colorEnum -> activeHighlightPalette.forEachIndexed { index, colorEnum ->
Box( Box(
modifier = Modifier modifier = Modifier
.padding(horizontal = 8.dp) .padding(horizontal = 6.dp)
.size(24.dp) .size(32.dp)
.background(colorEnum.color, CircleShape) .background(colorEnum.color, CircleShape)
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), CircleShape) .pointerInput(colorEnum) {
.clickable { detectTapGestures(
Timber.d("Kotlin: Color clicked. Existing? ${state.isExistingHighlight}") onTap = {
Timber.d("Kotlin: Color clicked. Existing? ${state.isExistingHighlight}")
if (state.isExistingHighlight && state.cfi != null) { if (state.isExistingHighlight && state.cfi != null) {
// UPDATE EXISTING HIGHLIGHT localWebViewRef?.evaluateJavascript(
Timber.d("Kotlin: Requesting UPDATE via JS for CFI: ${state.cfi}") "javascript:window.HighlightBridgeHelper.updateHighlightStyle('${state.cfi}', '${colorEnum.cssClass}', '${colorEnum.id}');",
localWebViewRef?.evaluateJavascript( null
"javascript:window.HighlightBridgeHelper.updateHighlightStyle('${state.cfi}', '${colorEnum.cssClass}', '${colorEnum.id}');", )
null } else {
) localWebViewRef?.evaluateJavascript(
} else { "javascript:window.HighlightBridgeHelper.createUserHighlight('${colorEnum.cssClass}', '${colorEnum.id}');",
// CREATE NEW HIGHLIGHT null
Timber.d("Kotlin: Requesting CREATE via JS") )
localWebViewRef?.evaluateJavascript( }
"javascript:window.HighlightBridgeHelper.createUserHighlight('${colorEnum.cssClass}', '${colorEnum.id}');", state.finishActionModeCallback()
null localWebViewRef?.clearFocus()
) customMenuState = null
} },
state.finishActionModeCallback() onLongPress = {
localWebViewRef?.clearFocus() showPaletteManager = true
customMenuState = null }
)
} }
) )
} }
Spacer(modifier = Modifier.width(8.dp))
SpectrumButton(
onClick = { showPaletteManager = true },
size = 32.dp
)
} }
// 2. Delete Option (Only for existing highlights) // 2. Delete Option (Only for existing highlights)
@ -979,5 +992,17 @@ fun ChapterWebView(
} }
} }
} }
if (showPaletteManager) {
PaletteManagerDialog(
currentPalette = activeHighlightPalette,
onDismiss = { showPaletteManager = false },
onSave = { newPalette ->
newPalette.forEachIndexed { index, color ->
onUpdatePalette(index, color)
}
showPaletteManager = false
}
)
}
} }
} }

View file

@ -38,6 +38,18 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
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.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -65,7 +77,17 @@ enum class HighlightColor(val id: String, val color: Color, val cssClass: String
YELLOW("yellow", Color(0xFFFBC02D), "user-highlight-yellow"), YELLOW("yellow", Color(0xFFFBC02D), "user-highlight-yellow"),
GREEN("green", Color(0xFF388E3C), "user-highlight-green"), GREEN("green", Color(0xFF388E3C), "user-highlight-green"),
BLUE("blue", Color(0xFF1976D2), "user-highlight-blue"), BLUE("blue", Color(0xFF1976D2), "user-highlight-blue"),
RED("red", Color(0xFFD32F2F), "user-highlight-red") RED("red", Color(0xFFD32F2F), "user-highlight-red"),
PURPLE("purple", Color(0xFF7B1FA2), "user-highlight-purple"),
ORANGE("orange", Color(0xFFF57C00), "user-highlight-orange"),
CYAN("cyan", Color(0xFF0097A7), "user-highlight-cyan"),
MAGENTA("magenta", Color(0xFFC2185B), "user-highlight-magenta"),
LIME("lime", Color(0xFFAFB42B), "user-highlight-lime"),
PINK("pink", Color(0xFFE91E63), "user-highlight-pink"),
TEAL("teal", Color(0xFF00796B), "user-highlight-teal"),
INDIGO("indigo", Color(0xFF303F9F), "user-highlight-indigo"),
BLACK("black", Color(0xFF424242), "user-highlight-black"),
WHITE("white", Color(0xFFF5F5F5), "user-highlight-white");
} }
data class UserHighlight( data class UserHighlight(
@ -88,6 +110,24 @@ fun escapeJsString(value: String): String {
.replace("\u2029", "\\u2029") .replace("\u2029", "\\u2029")
} }
fun saveHighlightPalette(context: Context, palette: List<HighlightColor>) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val ids = palette.joinToString(",") { it.id }
prefs.edit { putString("highlight_palette_ids", ids) }
}
fun loadHighlightPalette(context: Context): List<HighlightColor> {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val savedIds = prefs.getString("highlight_palette_ids", null)
if (savedIds != null) {
val list = savedIds.split(",").mapNotNull { id ->
HighlightColor.entries.find { it.id == id }
}
if (list.size == 4) return list
}
return listOf(HighlightColor.YELLOW, HighlightColor.GREEN, HighlightColor.BLUE, HighlightColor.RED)
}
// --- Persistence Helpers --- // --- Persistence Helpers ---
fun loadBookmarks(context: Context, bookTitle: String, chapters: List<EpubChapter>, bookmarksJson: String?): Set<Bookmark> { fun loadBookmarks(context: Context, bookTitle: String, chapters: List<EpubChapter>, bookmarksJson: String?): Set<Bookmark> {
@ -285,3 +325,110 @@ fun BookmarkButton(
} }
} }
} }
@Composable
fun SpectrumButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
size: androidx.compose.ui.unit.Dp = 32.dp
) {
val rainbowColors = listOf(
Color.Red, Color(0xFFFF7F00), Color.Yellow, Color.Green,
Color.Blue, Color(0xFF4B0082), Color(0xFF8B00FF)
)
Box(
modifier = modifier
.size(size)
.background(
brush = Brush.sweepGradient(rainbowColors),
shape = CircleShape
)
.clickable(onClick = onClick)
)
}
@Composable
fun PaletteManagerDialog(
currentPalette: List<HighlightColor>,
onSave: (List<HighlightColor>) -> Unit,
onDismiss: () -> Unit
) {
var tempPalette by remember { mutableStateOf(currentPalette.toMutableList()) }
var selectedSlotIndex by remember { mutableIntStateOf(0) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Customize Palette", style = MaterialTheme.typography.titleMedium) },
text = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
Text("Tap a slot to edit:", style = MaterialTheme.typography.bodySmall)
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxWidth()
) {
tempPalette.forEachIndexed { index, colorEnum ->
val isSelected = index == selectedSlotIndex
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(48.dp)
.background(colorEnum.color, CircleShape)
.border(
width = if (isSelected) 3.dp else 1.dp,
color = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent, // Thin ring if selected
shape = CircleShape
)
.clip(CircleShape)
.clickable { selectedSlotIndex = index }
) {
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected Slot",
tint = if (colorEnum == HighlightColor.WHITE) Color.Black else Color.White,
modifier = Modifier.size(24.dp)
)
}
}
}
}
HorizontalDivider()
// 2. Bottom Grid: Available Colors
Text("Select a color for the slot:", style = MaterialTheme.typography.bodySmall)
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 40.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.height(200.dp)
) {
items(HighlightColor.entries) { colorOption ->
Box(
modifier = Modifier
.size(36.dp)
.background(colorOption.color, CircleShape)
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), CircleShape)
.clickable {
val newList = tempPalette.toMutableList()
newList[selectedSlotIndex] = colorOption
tempPalette = newList
}
)
}
}
}
},
confirmButton = {
TextButton(onClick = { onSave(tempPalette) }) { Text("Save") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}

View file

@ -283,6 +283,19 @@ fun EpubReaderHost(
} }
} }
var currentHighlightPalette by remember {
mutableStateOf(loadHighlightPalette(context))
}
val onUpdateHighlightPalette: (Int, HighlightColor) -> Unit = { index, newColor ->
val newList = currentHighlightPalette.toMutableList()
if (index in newList.indices) {
newList[index] = newColor
currentHighlightPalette = newList
saveHighlightPalette(context, newList)
}
}
LaunchedEffect(userHighlights.size, userHighlights.toList()) { LaunchedEffect(userHighlights.size, userHighlights.toList()) {
saveHighlightsToPrefs(context, epubBook.title, userHighlights) saveHighlightsToPrefs(context, epubBook.title, userHighlights)
} }
@ -1541,6 +1554,8 @@ fun EpubReaderHost(
initialCfi = cfiToLoad, initialCfi = cfiToLoad,
initialFragmentId = fragmentToLoad.also { }, initialFragmentId = fragmentToLoad.also { },
userHighlights = userHighlights.filter { it.chapterIndex == targetChapterIndex }, userHighlights = userHighlights.filter { it.chapterIndex == targetChapterIndex },
activeHighlightPalette = currentHighlightPalette,
onUpdatePalette = onUpdateHighlightPalette,
onHighlightCreated = { cfi, text, colorId -> onHighlightCreated = { cfi, text, colorId ->
Timber.d("Vertical Mode (Source): Creating Highlight. CFI: $cfi") Timber.d("Vertical Mode (Source): Creating Highlight. CFI: $cfi")
Timber.d("Vertical Mode (Source): Text Snippet: '${text.take(50)}...'") Timber.d("Vertical Mode (Source): Text Snippet: '${text.take(50)}...'")
@ -2090,6 +2105,8 @@ fun EpubReaderHost(
lineHeightMultiplier = currentLineHeight, lineHeightMultiplier = currentLineHeight,
fontFamily = activeFontFamily, fontFamily = activeFontFamily,
textAlign = currentTextAlign, textAlign = currentTextAlign,
activeHighlightPalette = currentHighlightPalette,
onUpdatePalette = onUpdateHighlightPalette,
ttsHighlightInfo = TtsHighlightInfo( ttsHighlightInfo = TtsHighlightInfo(
text = ttsState.currentText ?: "", text = ttsState.currentText ?: "",
cfi = ttsState.sourceCfi ?: "", cfi = ttsState.sourceCfi ?: "",

View file

@ -17,6 +17,7 @@
* *
* mail: epistemereader@gmail.com * mail: epistemereader@gmail.com
*/ */
// PaginatedReader.kt
package com.aryan.reader.paginatedreader package com.aryan.reader.paginatedreader
import android.annotation.SuppressLint import android.annotation.SuppressLint
@ -51,6 +52,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn 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.HorizontalPager
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@ -143,7 +147,9 @@ import com.aryan.reader.R
import com.aryan.reader.countWords import com.aryan.reader.countWords
import com.aryan.reader.epub.EpubBook import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epubreader.HighlightColor import com.aryan.reader.epubreader.HighlightColor
import com.aryan.reader.epubreader.PaletteManagerDialog
import com.aryan.reader.epubreader.ReaderTextAlign import com.aryan.reader.epubreader.ReaderTextAlign
import com.aryan.reader.epubreader.SpectrumButton
import com.aryan.reader.epubreader.TtsHighlightInfo import com.aryan.reader.epubreader.TtsHighlightInfo
import com.aryan.reader.epubreader.UserHighlight import com.aryan.reader.epubreader.UserHighlight
import com.aryan.reader.paginatedreader.data.BookCacheDatabase import com.aryan.reader.paginatedreader.data.BookCacheDatabase
@ -360,10 +366,10 @@ private fun WrappingContentLayout(
val imagePlacable = if (imageRenderWidthPx > 0 && imageRenderHeightPx > 0) { val imagePlacable = if (imageRenderWidthPx > 0 && imageRenderHeightPx > 0) {
measurables.first().measure( measurables.first().measure(
Constraints.fixed( Constraints.fixed(
imageRenderWidthPx.roundToInt(), imageRenderHeightPx.roundToInt() imageRenderWidthPx.roundToInt(), imageRenderHeightPx.roundToInt()
)
) )
)
} else { } else {
null null
} }
@ -392,8 +398,8 @@ private fun WrappingContentLayout(
val styleForMeasure = val styleForMeasure =
remainingText.spanStyles.firstOrNull { it.item.fontFamily != null }?.item?.fontFamily?.let { remainingText.spanStyles.firstOrNull { it.item.fontFamily != null }?.item?.fontFamily?.let {
textStyle.copy(fontFamily = it) textStyle.copy(fontFamily = it)
} ?: textStyle } ?: textStyle
val layoutResult = textMeasurer.measure( val layoutResult = textMeasurer.measure(
remainingText, style = styleForMeasure, constraints = lineConstraints remainingText, style = styleForMeasure, constraints = lineConstraints
@ -488,7 +494,9 @@ fun PaginatedReaderScreen(
onWordSelectedForAiDefinition: (String) -> Unit, onWordSelectedForAiDefinition: (String) -> Unit,
userHighlights: List<UserHighlight>, userHighlights: List<UserHighlight>,
onHighlightCreated: (String, String, String) -> Unit, onHighlightCreated: (String, String, String) -> Unit,
onHighlightDeleted: (String) -> Unit onHighlightDeleted: (String) -> Unit,
activeHighlightPalette: List<HighlightColor>,
onUpdatePalette: (Int, HighlightColor) -> Unit
) { ) {
LaunchedEffect(userHighlights) { LaunchedEffect(userHighlights) {
Timber.d("PaginatedReaderScreen: Received ${userHighlights.size} highlights.") Timber.d("PaginatedReaderScreen: Received ${userHighlights.size} highlights.")
@ -757,7 +765,9 @@ fun PaginatedReaderScreen(
userHighlights = userHighlights, userHighlights = userHighlights,
onHighlightCreated = onHighlightCreated, onHighlightCreated = onHighlightCreated,
onHighlightDeleted = onHighlightDeleted, onHighlightDeleted = onHighlightDeleted,
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
activeHighlightPalette = activeHighlightPalette,
onUpdatePalette = onUpdatePalette
) )
} }
} }
@ -1079,7 +1089,7 @@ private fun TextWithEmphasis(
activeSelection: PaginatedSelection?, activeSelection: PaginatedSelection?,
@Suppress("unused") onSelectionChange: (PaginatedSelection?) -> Unit, @Suppress("unused") onSelectionChange: (PaginatedSelection?) -> Unit,
onHighlightClick: (UserHighlight, Rect) -> Unit, onHighlightClick: (UserHighlight, Rect) -> Unit,
isDarkTheme: Boolean, @Suppress("unused") isDarkTheme: Boolean,
onRegisterLayout: ((TextLayoutResult, LayoutCoordinates) -> Unit)? = null onRegisterLayout: ((TextLayoutResult, LayoutCoordinates) -> Unit)? = null
) { ) {
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) } var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
@ -1107,36 +1117,20 @@ private fun TextWithEmphasis(
range.first, range.last + 1 range.first, range.last + 1
) )
if (isDarkTheme) { drawPath(
Timber.d( path,
"DRAWING: Highlight '${highlight.text.take(10)}' on Block ${block.cfi} at range $range" highlight.color.color.copy(alpha = 0.4f),
) blendMode = BlendMode.SrcOver
} )
if (isDarkTheme) {
drawPath(
path,
highlight.color.color.copy(alpha = 0.6f),
blendMode = BlendMode.SrcOver
)
} else {
drawPath(
path,
highlight.color.color.copy(alpha = 0.5f),
blendMode = BlendMode.Multiply
)
}
if (highlight.cfi == pressedHighlightCfi) { if (highlight.cfi == pressedHighlightCfi) {
drawPath( drawPath(
path, path,
Color.White.copy(alpha = 0.3f), Color.Black.copy(alpha = 0.1f),
blendMode = BlendMode.SrcOver blendMode = BlendMode.SrcOver
) )
} }
} catch (_: Exception) { } catch (_: Exception) { }
// Ignore layout errors during drawing
}
} }
} }
} }
@ -1174,8 +1168,7 @@ private fun TextWithEmphasis(
val lineLeft = layout.getLineLeft(lineIndex) val lineLeft = layout.getLineLeft(lineIndex)
val lineRight = layout.getLineRight(lineIndex) val lineRight = layout.getLineRight(lineIndex)
if (offset.x < minOf(lineLeft, lineRight) - 50 || offset.x > maxOf( if (offset.x < minOf(lineLeft, lineRight) - 50 || offset.x > maxOf(
lineLeft, lineLeft, lineRight
lineRight
) + 50 ) + 50
) { ) {
return null return null
@ -1316,6 +1309,8 @@ internal fun PaginatedReaderContent(
userHighlights: List<UserHighlight>, userHighlights: List<UserHighlight>,
onHighlightCreated: (String, String, String) -> Unit, onHighlightCreated: (String, String, String) -> Unit,
onHighlightDeleted: (String) -> Unit, onHighlightDeleted: (String) -> Unit,
activeHighlightPalette: List<HighlightColor>,
onUpdatePalette: (Int, HighlightColor) -> Unit,
isDarkTheme: Boolean isDarkTheme: Boolean
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@ -1330,6 +1325,8 @@ internal fun PaginatedReaderContent(
val blockLayoutMap = remember { val blockLayoutMap = remember {
androidx.compose.runtime.mutableStateMapOf<String, Triple<TextLayoutResult, LayoutCoordinates, Int>>() 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) { if (showExternalLinkDialog != null) {
val urlToShow = showExternalLinkDialog!! val urlToShow = showExternalLinkDialog!!
@ -1433,8 +1430,7 @@ internal fun PaginatedReaderContent(
LocalTextToolbar provides textToolbar, LocalClipboard provides dictionaryClipboard LocalTextToolbar provides textToolbar, LocalClipboard provides dictionaryClipboard
) { ) {
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState, modifier = Modifier.fillMaxSize()
modifier = Modifier.fillMaxSize()
) { pageIndex -> ) { pageIndex ->
var pageContent by remember { mutableStateOf<Page?>(null) } var pageContent by remember { mutableStateOf<Page?>(null) }
var currentChapterPath by remember { mutableStateOf<String?>(null) } var currentChapterPath by remember { mutableStateOf<String?>(null) }
@ -1524,7 +1520,9 @@ internal fun PaginatedReaderContent(
if (block.style.backgroundColor.isSpecified) { if (block.style.backgroundColor.isSpecified) {
Modifier.background( Modifier.background(
block.style.backgroundColor, block.style.backgroundColor,
shape = if (block.style.borderRadius > 0.dp) RoundedCornerShape(block.style.borderRadius) else androidx.compose.ui.graphics.RectangleShape shape = if (block.style.borderRadius > 0.dp) RoundedCornerShape(
block.style.borderRadius
) else androidx.compose.ui.graphics.RectangleShape
) )
} else { } else {
Modifier Modifier
@ -1535,7 +1533,9 @@ internal fun PaginatedReaderContent(
BorderStroke( BorderStroke(
border.width, border.color border.width, border.color
), ),
shape = if (block.style.borderRadius > 0.dp) RoundedCornerShape(block.style.borderRadius) else androidx.compose.ui.graphics.RectangleShape shape = if (block.style.borderRadius > 0.dp) RoundedCornerShape(
block.style.borderRadius
) else androidx.compose.ui.graphics.RectangleShape
) )
} ?: Modifier) } ?: Modifier)
@ -1830,10 +1830,10 @@ internal fun PaginatedReaderContent(
if (block.itemMarkerImage != null) { if (block.itemMarkerImage != null) {
val imageRequest = val imageRequest =
Builder(LocalContext.current).data( Builder(LocalContext.current).data(
File( File(
block.itemMarkerImage block.itemMarkerImage
) )
).crossfade(true).build() ).crossfade(true).build()
val imageSize = with(density) { val imageSize = with(density) {
(textStyle.fontSize.value * 0.8f).sp.toDp() (textStyle.fontSize.value * 0.8f).sp.toDp()
} }
@ -1842,8 +1842,8 @@ internal fun PaginatedReaderContent(
model = imageRequest, model = imageRequest,
contentDescription = "List item marker", contentDescription = "List item marker",
modifier = markerAreaModifier.height( modifier = markerAreaModifier.height(
imageSize imageSize
), ),
alignment = Alignment.CenterEnd, alignment = Alignment.CenterEnd,
contentScale = ContentScale.FillHeight contentScale = ContentScale.FillHeight
) )
@ -2093,16 +2093,16 @@ internal fun PaginatedReaderContent(
val imageRequest = val imageRequest =
Builder(LocalContext.current).data( Builder(LocalContext.current).data(
SvgData( SvgData(
block.svgContent block.svgContent
)
).listener(
onError = { _, result ->
Timber.e(
result.throwable,
"Coil failed to load SVG for MathBlock."
) )
).listener( }).build()
onError = { _, result ->
Timber.e(
result.throwable,
"Coil failed to load SVG for MathBlock."
)
}).build()
val colorFilter = val colorFilter =
if (block.isFromMathJax) ColorFilter.tint( if (block.isFromMathJax) ColorFilter.tint(
@ -2361,8 +2361,8 @@ internal fun PaginatedReaderContent(
Text( Text(
text = blockInCell.content, text = blockInCell.content,
style = cellTextStyle.copy( style = cellTextStyle.copy(
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
), ),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} }
@ -2416,11 +2416,10 @@ internal fun PaginatedReaderContent(
model = Builder( model = Builder(
LocalContext.current LocalContext.current
).data( ).data(
File( File(
blockInCell.path blockInCell.path
)
) )
.build(), ).build(),
contentDescription = blockInCell.altText, contentDescription = blockInCell.altText,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
@ -2470,231 +2469,232 @@ internal fun PaginatedReaderContent(
}, onDismissRequest = { state.onHide() }) { }, onDismissRequest = { state.onHide() }) {
PaginatedTextSelectionMenu( PaginatedTextSelectionMenu(
onCopy = { onCopy = {
isForDictionary = false isForDictionary = false
isForHighlight = false isForHighlight = false
state.onCopy() state.onCopy()
state.onHide() state.onHide()
}, onSelectAll = { }, onSelectAll = {
state.onSelectAll?.invoke() state.onSelectAll?.invoke()
state.onHide() state.onHide()
}, onDictionary = { }, onDictionary = {
isForDictionary = true isForDictionary = true
state.onCopy() state.onCopy()
isForDictionary = false isForDictionary = false
state.onHide() state.onHide()
}, onHighlight = { color -> }, onHighlight = { color ->
Timber.d("Menu: Highlight option clicked. Color: ${color.id}") Timber.d("Menu: Highlight option clicked. Color: ${color.id}")
isForHighlight = true isForHighlight = true
state.onCopy() state.onCopy()
isForHighlight = false isForHighlight = false
capturedTextForAction?.let { text -> capturedTextForAction?.let { text ->
val selectionRect = state.rect val selectionRect = state.rect
Timber.d("Menu: Selection Rect: $selectionRect") Timber.d("Menu: Selection Rect: $selectionRect")
// 1. Attempt Geometric Strategy (Hit Testing) var geometricSuccess = false
var geometricSuccess = false val candidates = blockLayoutMap.filter { (_, triple) ->
val candidates = blockLayoutMap.filter { (_, triple) -> val (_, coords, _) = triple
val (_, coords, _) = triple // Deconstruct Triple if (!coords.isAttached) return@filter false
if (!coords.isAttached) return@filter false val pos = coords.positionInWindow()
val pos = coords.positionInWindow() val size = coords.size.toSize()
val size = coords.size.toSize() val blockRect = Rect(pos, size)
val blockRect = Rect(pos, size) val overlaps = blockRect.overlaps(selectionRect)
val overlaps = blockRect.overlaps(selectionRect) overlaps
overlaps }
}
if (candidates.isNotEmpty()) { if (candidates.isNotEmpty()) {
Timber.d( Timber.d(
"Menu: Geometric candidates found: ${candidates.keys}" "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 = try {
startLayout.getOffsetForPosition(localStart) val sorted = candidates.entries.sortedBy {
var finalEndOffset = endLayout.getOffsetForPosition(localEnd) it.value.second.positionInWindow().y
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 (startCfi, startTriple) = sorted.first()
val bestMatch = matches.minBy { val (endCfi, endTriple) = sorted.last()
abs(it - finalStartOffset) val (startLayout, startCoords, startAbsOffset) = startTriple
} val (endLayout, endCoords, endAbsOffset) = endTriple
finalStartOffset = bestMatch val localStart =
finalEndOffset = bestMatch + text.length startCoords.windowToLocal(selectionRect.topLeft)
Timber.d( val localEnd = endCoords.windowToLocal(
"Refined Single-Block Offset: $finalStartOffset" selectionRect.bottomRight
) )
} var finalStartOffset =
} else { startLayout.getOffsetForPosition(localStart)
val endText = endLayout.layoutInput.text.text var finalEndOffset = endLayout.getOffsetForPosition(localEnd)
var finalEndCfi = endCfi
val startText = startLayout.layoutInput.text.text
fun findBestMatch( if (startCfi == endCfi) {
source: String, val matches = mutableListOf<Int>()
query: String, var idx = startText.indexOf(text)
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) { while (idx != -1) {
val cmpPoint = if (isSuffix) idx + query.length matches.add(idx)
else idx idx = startText.indexOf(text, idx + 1)
val diff = abs(cmpPoint - targetOffset)
if (diff < minDiff) {
minDiff = diff
bestIdx = idx
}
idx = source.indexOf(query, idx + 1)
} }
return bestIdx if (matches.isNotEmpty()) {
} val bestMatch = matches.minBy {
abs(it - finalStartOffset)
var sMatch = -1 }
var eMatch = -1 finalStartOffset = bestMatch
var usedSuffixLen = 0 finalEndOffset = bestMatch + text.length
Timber.d(
val maxChunk = minOf(text.length, 50) "Refined Single-Block Offset: $finalStartOffset"
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 } else {
Timber.d( val endText = endLayout.layoutInput.text.text
"Refined Start: Found prefix '$prefix' at $idx"
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
) )
break if (idx != -1) {
sMatch = idx
Timber.d(
"Refined Start: Found prefix '$prefix' at $idx"
)
break
}
} }
} }
}
for (len in maxChunk downTo 3) { for (len in maxChunk downTo 3) {
val suffix = text.takeLast(len).trim() val suffix = text.takeLast(len).trim()
if (suffix.isNotEmpty()) { if (suffix.isNotEmpty()) {
val idx = findBestMatch( val idx = findBestMatch(
endText, endText,
suffix, suffix,
finalEndOffset, finalEndOffset,
isSuffix = true 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."
) )
if (idx != -1) { val fitIdx = startText.indexOf(text)
eMatch = idx if (fitIdx != -1) {
usedSuffixLen = suffix.length finalEndCfi = startCfi
Timber.d( finalStartOffset = fitIdx
"Refined End: Found suffix '$suffix' at $idx" finalEndOffset = fitIdx + text.length
)
break
} }
} }
} }
if (sMatch != -1 && eMatch != -1) { val absStart = finalStartOffset + startAbsOffset
finalStartOffset = sMatch val absEnd =
finalEndOffset = eMatch + usedSuffixLen finalEndOffset + if (startCfi == finalEndCfi) startAbsOffset
} else if (eMatch == -1) { else endAbsOffset
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 rangeCfi = if (startCfi == finalEndCfi) {
val absEnd = val actualStart = minOf(absStart, absEnd)
finalEndOffset + if (startCfi == finalEndCfi) startAbsOffset val actualEnd = maxOf(absStart, absEnd).coerceAtLeast(
else endAbsOffset
val rangeCfi = if (startCfi == finalEndCfi) {
val actualStart = minOf(absStart, absEnd)
val actualEnd = maxOf(absStart, absEnd).coerceAtLeast(
actualStart + 1 actualStart + 1
) )
"$startCfi:$actualStart|$finalEndCfi:$actualEnd" "$startCfi:$actualStart|$finalEndCfi:$actualEnd"
} else { } else {
"$startCfi:$absStart|$finalEndCfi:$absEnd" "$startCfi:$absStart|$finalEndCfi:$absEnd"
} }
Timber.d("Menu: Geometric Success. CFI: $rangeCfi") Timber.d("Menu: Geometric Success. CFI: $rangeCfi")
onHighlightCreated(rangeCfi, text, color.id) onHighlightCreated(rangeCfi, text, color.id)
geometricSuccess = true geometricSuccess = true
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Menu: Geometric calculation failed.") 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 (!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()
if (startBlock != null) { var startBlock: TextContentBlock? = null
val startAbs = var endBlock: TextContentBlock? = null
startBlock.startCharOffsetInSource + startOffsetRel var startOffsetRel = -1
val endAbs = var endOffsetRel = -1
endBlock.startCharOffsetInSource + (if (endOffsetRel != -1) endOffsetRel
else startOffsetRel + text.length) for (block in textBlocks) {
onHighlightCreated( val content = block.content.text
"${startBlock.cfi}:$startAbs|${endBlock.cfi}:$endAbs", val idx = content.indexOf(text)
text, if (idx != -1) {
color.id 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()
state.onHide() }, onDelete = null, isProUser = isProUser, isOss = isOss,
}, onDelete = null, isProUser = isProUser, isOss = isOss activeHighlightPalette = activeHighlightPalette,
onOpenPaletteManager = { showPaletteManager = true }
) )
} }
} }
@ -2706,31 +2706,61 @@ internal fun PaginatedReaderContent(
}, onDismissRequest = { activeSelection = null }) { }, onDismissRequest = { activeSelection = null }) {
PaginatedTextSelectionMenu( PaginatedTextSelectionMenu(
onCopy = { onCopy = {
val clipboardManager = val clipboardManager =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Copied Text", sel.text) val clip = ClipData.newPlainText("Copied Text", sel.text)
clipboardManager.setPrimaryClip(clip) clipboardManager.setPrimaryClip(clip)
activeSelection = null activeSelection = null
}, onSelectAll = null, onDictionary = { }, onSelectAll = null, onDictionary = {
if (isProUser || countWords(sel.text) <= 1) { if (isProUser || countWords(sel.text) <= 1) {
onWordSelectedForAiDefinition(sel.text) onWordSelectedForAiDefinition(sel.text)
} else { } else {
onShowDictionaryUpsellDialog() onShowDictionaryUpsellDialog()
} }
activeSelection = null activeSelection = null
}, onHighlight = { color -> }, onHighlight = { color ->
Timber.d( Timber.d(
"CustomSelection: Highlight clicked. Text: '${sel.text}', BaseCFI: ${sel.baseCfi}, StartOffset: ${sel.startOffset}" "CustomSelection: Highlight clicked. Text: '${sel.text}', BaseCFI: ${sel.baseCfi}, StartOffset: ${sel.startOffset}"
) )
val finalCfi = if (sel.startOffset > 0) "${sel.baseCfi}:${sel.startOffset}" val finalCfi = if (sel.startOffset > 0) "${sel.baseCfi}:${sel.startOffset}"
else sel.baseCfi else sel.baseCfi
onHighlightCreated(finalCfi, sel.text, color.id) onHighlightCreated(finalCfi, sel.text, color.id)
activeSelection = null activeSelection = null
}, onDelete = null, isProUser = isProUser, isOss = isOss }, 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) // Edit Menu (Delete)
if (activeHighlightForMenu != null) { if (activeHighlightForMenu != null) {
val (highlight, rect) = activeHighlightForMenu!! val (highlight, rect) = activeHighlightForMenu!!
@ -2739,30 +2769,51 @@ internal fun PaginatedReaderContent(
}, onDismissRequest = { activeHighlightForMenu = null }) { }, onDismissRequest = { activeHighlightForMenu = null }) {
PaginatedTextSelectionMenu( PaginatedTextSelectionMenu(
onCopy = { onCopy = {
val clipboardManager = val clipboardManager =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Copied Text", highlight.text) val clip = ClipData.newPlainText("Copied Text", highlight.text)
clipboardManager.setPrimaryClip(clip) clipboardManager.setPrimaryClip(clip)
activeHighlightForMenu = null activeHighlightForMenu = null
}, onSelectAll = null, onDictionary = { },
if (isProUser || countWords(highlight.text) <= 1) { onSelectAll = null,
onWordSelectedForAiDefinition(highlight.text) onDictionary = {
} else { if (isProUser || countWords(highlight.text) <= 1) {
onShowDictionaryUpsellDialog() onWordSelectedForAiDefinition(highlight.text)
} } else {
activeHighlightForMenu = null onShowDictionaryUpsellDialog()
}, onHighlight = { color -> }
Timber.d("Menu: Updating highlight color to ${color.id}") activeHighlightForMenu = null
onHighlightDeleted(highlight.cfi) },
onHighlightCreated(highlight.cfi, highlight.text, color.id) onHighlight = { color ->
activeHighlightForMenu = null Timber.d("Menu: Updating highlight color to ${color.id}")
}, onDelete = { onHighlightDeleted(highlight.cfi)
onHighlightDeleted(highlight.cfi) onHighlightCreated(highlight.cfi, highlight.text, color.id)
activeHighlightForMenu = null activeHighlightForMenu = null
}, isProUser = isProUser, isOss = isOss },
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 { } else {
Timber.w("Book has no pages to display.") Timber.w("Book has no pages to display.")
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@ -2811,9 +2862,11 @@ private fun PaginatedTextSelectionMenu(
onSelectAll: (() -> Unit)?, onSelectAll: (() -> Unit)?,
onDictionary: () -> Unit, onDictionary: () -> Unit,
onHighlight: ((HighlightColor) -> Unit)?, onHighlight: ((HighlightColor) -> Unit)?,
@Suppress("SameParameterValue") onDelete: (() -> Unit)?, onDelete: (() -> Unit)?,
@Suppress("unused") isProUser: Boolean, @Suppress("unused") isProUser: Boolean,
isOss: Boolean isOss: Boolean,
activeHighlightPalette: List<HighlightColor> = emptyList(),
onOpenPaletteManager: (() -> Unit)? = null
) { ) {
Surface( Surface(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
@ -2822,7 +2875,7 @@ private fun PaginatedTextSelectionMenu(
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) { ) {
Column(modifier = Modifier.width(IntrinsicSize.Max)) { Column(modifier = Modifier.width(IntrinsicSize.Max)) {
// 1. Colors Row (Only show if highlighting is supported) // 1. Colors Row
if (onHighlight != null) { if (onHighlight != null) {
Row( Row(
modifier = Modifier modifier = Modifier
@ -2831,18 +2884,22 @@ private fun PaginatedTextSelectionMenu(
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
HighlightColor.entries.forEach { colorEnum -> activeHighlightPalette.forEach { colorEnum ->
Box( Box(
modifier = Modifier modifier = Modifier
.padding(horizontal = 8.dp) .padding(horizontal = 6.dp)
.size(24.dp) .size(32.dp)
.background(colorEnum.color, CircleShape) .background(colorEnum.color, CircleShape)
.border( .clickable { onHighlight(colorEnum) }
1.dp, MaterialTheme.colorScheme.outline.copy( )
alpha = 0.5f }
), CircleShape
) if (onOpenPaletteManager != null) {
.clickable { onHighlight(colorEnum) }) Spacer(modifier = Modifier.width(8.dp))
SpectrumButton(
onClick = onOpenPaletteManager,
size = 32.dp
)
} }
} }
HorizontalDivider() HorizontalDivider()
@ -3137,9 +3194,11 @@ private fun RenderFlexChildBlock(
is TableBlock -> { is TableBlock -> {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
childBlock.rows.forEach { tableRow -> childBlock.rows.forEach { tableRow ->
Row(Modifier Row(
.fillMaxWidth() Modifier
.height(IntrinsicSize.Min)) { .fillMaxWidth()
.height(IntrinsicSize.Min)
) {
val hasFixedWidths = val hasFixedWidths =
tableRow.any { it.style.blockStyle.width != Dp.Unspecified } tableRow.any { it.style.blockStyle.width != Dp.Unspecified }
@ -3199,10 +3258,10 @@ private fun RenderFlexChildBlock(
} else if (blockInCell is ImageBlock) { } else if (blockInCell is ImageBlock) {
AsyncImage( AsyncImage(
model = Builder(LocalContext.current).data( model = Builder(LocalContext.current).data(
File( File(
blockInCell.path blockInCell.path
) )
).build(), ).build(),
contentDescription = blockInCell.altText, contentDescription = blockInCell.altText,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()