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();",
@ -812,34 +819,29 @@ 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(
onTap = {
Timber.d("Kotlin: Color clicked. Existing? ${state.isExistingHighlight}") Timber.d("Kotlin: Color clicked. Existing? ${state.isExistingHighlight}")
if (state.isExistingHighlight && state.cfi != null) { if (state.isExistingHighlight && state.cfi != null) {
// UPDATE EXISTING HIGHLIGHT
Timber.d("Kotlin: Requesting UPDATE via JS for CFI: ${state.cfi}")
localWebViewRef?.evaluateJavascript( localWebViewRef?.evaluateJavascript(
"javascript:window.HighlightBridgeHelper.updateHighlightStyle('${state.cfi}', '${colorEnum.cssClass}', '${colorEnum.id}');", "javascript:window.HighlightBridgeHelper.updateHighlightStyle('${state.cfi}', '${colorEnum.cssClass}', '${colorEnum.id}');",
null null
) )
} else { } else {
// CREATE NEW HIGHLIGHT
Timber.d("Kotlin: Requesting CREATE via JS")
localWebViewRef?.evaluateJavascript( localWebViewRef?.evaluateJavascript(
"javascript:window.HighlightBridgeHelper.createUserHighlight('${colorEnum.cssClass}', '${colorEnum.id}');", "javascript:window.HighlightBridgeHelper.createUserHighlight('${colorEnum.cssClass}', '${colorEnum.id}');",
null null
@ -848,9 +850,20 @@ fun ChapterWebView(
state.finishActionModeCallback() state.finishActionModeCallback()
localWebViewRef?.clearFocus() localWebViewRef?.clearFocus()
customMenuState = null customMenuState = null
},
onLongPress = {
showPaletteManager = true
} }
) )
} }
)
}
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
@ -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) {
Timber.d(
"DRAWING: Highlight '${highlight.text.take(10)}' on Block ${block.cfi} at range $range"
)
}
if (isDarkTheme) {
drawPath( drawPath(
path, path,
highlight.color.color.copy(alpha = 0.6f), highlight.color.color.copy(alpha = 0.4f),
blendMode = BlendMode.SrcOver 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)
@ -2419,8 +2419,7 @@ internal fun PaginatedReaderContent(
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()
@ -2492,10 +2491,9 @@ internal fun PaginatedReaderContent(
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 // Deconstruct Triple val (_, coords, _) = 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()
@ -2694,7 +2692,9 @@ internal fun PaginatedReaderContent(
} }
} }
state.onHide() state.onHide()
}, onDelete = null, isProUser = isProUser, isOss = isOss }, onDelete = null, isProUser = isProUser, isOss = isOss,
activeHighlightPalette = activeHighlightPalette,
onOpenPaletteManager = { showPaletteManager = true }
) )
} }
} }
@ -2726,11 +2726,41 @@ internal fun PaginatedReaderContent(
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!!
@ -2744,25 +2774,46 @@ internal fun PaginatedReaderContent(
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 = { },
onSelectAll = null,
onDictionary = {
if (isProUser || countWords(highlight.text) <= 1) { if (isProUser || countWords(highlight.text) <= 1) {
onWordSelectedForAiDefinition(highlight.text) onWordSelectedForAiDefinition(highlight.text)
} else { } else {
onShowDictionaryUpsellDialog() onShowDictionaryUpsellDialog()
} }
activeHighlightForMenu = null activeHighlightForMenu = null
}, onHighlight = { color -> },
onHighlight = { color ->
Timber.d("Menu: Updating highlight color to ${color.id}") Timber.d("Menu: Updating highlight color to ${color.id}")
onHighlightDeleted(highlight.cfi) onHighlightDeleted(highlight.cfi)
onHighlightCreated(highlight.cfi, highlight.text, color.id) onHighlightCreated(highlight.cfi, highlight.text, color.id)
activeHighlightForMenu = null activeHighlightForMenu = null
}, onDelete = { },
onDelete = {
onHighlightDeleted(highlight.cfi) onHighlightDeleted(highlight.cfi)
activeHighlightForMenu = null activeHighlightForMenu = null
}, isProUser = isProUser, isOss = isOss },
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) {
Spacer(modifier = Modifier.width(8.dp))
SpectrumButton(
onClick = onOpenPaletteManager,
size = 32.dp
) )
.clickable { onHighlight(colorEnum) })
} }
} }
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(
Modifier
.fillMaxWidth() .fillMaxWidth()
.height(IntrinsicSize.Min)) { .height(IntrinsicSize.Min)
) {
val hasFixedWidths = val hasFixedWidths =
tableRow.any { it.style.blockStyle.width != Dp.Unspecified } tableRow.any { it.style.blockStyle.width != Dp.Unspecified }