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:
parent
1b048c9073
commit
1f111034f1
5 changed files with 3044 additions and 1753 deletions
|
|
@ -17,6 +17,7 @@
|
|||
*
|
||||
* mail: epistemereader@gmail.com
|
||||
*/
|
||||
// ChapterWebView.kt
|
||||
package com.aryan.reader.epubreader
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
|
|
@ -27,7 +28,6 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.graphics.Rect
|
||||
import timber.log.Timber
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebSettings
|
||||
|
|
@ -36,13 +36,14 @@ import android.webkit.WebViewClient
|
|||
import android.widget.Toast
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -69,6 +70,7 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
|
|
@ -86,6 +88,7 @@ import com.aryan.reader.countWords
|
|||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
import timber.log.Timber
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
|
||||
|
|
@ -326,6 +329,8 @@ fun ChapterWebView(
|
|||
currentTextAlign: ReaderTextAlign,
|
||||
onHighlightClicked: () -> Unit,
|
||||
onAutoScrollChapterEnd: () -> Unit = {},
|
||||
activeHighlightPalette: List<HighlightColor>,
|
||||
onUpdatePalette: (Int, HighlightColor) -> Unit
|
||||
) {
|
||||
Timber.d(
|
||||
"RenderChapterViaWebView for '$chapterTitle', Key: $key, isDarkTheme: $isDarkTheme, initialScrollTarget: $initialScrollTarget"
|
||||
|
|
@ -340,6 +345,8 @@ fun ChapterWebView(
|
|||
|
||||
val jsToInject = remember(context) { getJsToInject(context) }
|
||||
|
||||
var showPaletteManager by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(currentFontSize, currentLineHeight) {
|
||||
localWebViewRef?.evaluateJavascript(
|
||||
"javascript:if(window.getSelection) window.getSelection().removeAllRanges();",
|
||||
|
|
@ -542,9 +549,9 @@ fun ChapterWebView(
|
|||
}
|
||||
addJavascriptInterface(
|
||||
CfiJsBridge(
|
||||
onCfiReady = { cfi -> onCfiGenerated(cfi) },
|
||||
onCfiForBookmarkReady = { cfi -> onBookmarkCfiGenerated(cfi) }
|
||||
), "CfiBridge")
|
||||
onCfiReady = { cfi -> onCfiGenerated(cfi) },
|
||||
onCfiForBookmarkReady = { cfi -> onBookmarkCfiGenerated(cfi) }
|
||||
), "CfiBridge")
|
||||
addJavascriptInterface(SnippetJsBridge { cfi, snippet ->
|
||||
onSnippetForBookmarkReady(
|
||||
cfi,
|
||||
|
|
@ -812,45 +819,51 @@ fun ChapterWebView(
|
|||
Column(
|
||||
modifier = Modifier.width(IntrinsicSize.Max)
|
||||
) {
|
||||
// 1. Color Row (Improved sizing and gaps)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 12.dp, horizontal = 12.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center, // Centered colors
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
HighlightColor.entries.forEach { colorEnum ->
|
||||
activeHighlightPalette.forEachIndexed { index, colorEnum ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.size(24.dp)
|
||||
.padding(horizontal = 6.dp)
|
||||
.size(32.dp)
|
||||
.background(colorEnum.color, CircleShape)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), CircleShape)
|
||||
.clickable {
|
||||
Timber.d("Kotlin: Color clicked. Existing? ${state.isExistingHighlight}")
|
||||
|
||||
if (state.isExistingHighlight && state.cfi != null) {
|
||||
// UPDATE EXISTING HIGHLIGHT
|
||||
Timber.d("Kotlin: Requesting UPDATE via JS for CFI: ${state.cfi}")
|
||||
localWebViewRef?.evaluateJavascript(
|
||||
"javascript:window.HighlightBridgeHelper.updateHighlightStyle('${state.cfi}', '${colorEnum.cssClass}', '${colorEnum.id}');",
|
||||
null
|
||||
)
|
||||
} else {
|
||||
// CREATE NEW HIGHLIGHT
|
||||
Timber.d("Kotlin: Requesting CREATE via JS")
|
||||
localWebViewRef?.evaluateJavascript(
|
||||
"javascript:window.HighlightBridgeHelper.createUserHighlight('${colorEnum.cssClass}', '${colorEnum.id}');",
|
||||
null
|
||||
)
|
||||
}
|
||||
state.finishActionModeCallback()
|
||||
localWebViewRef?.clearFocus()
|
||||
customMenuState = null
|
||||
.pointerInput(colorEnum) {
|
||||
detectTapGestures(
|
||||
onTap = {
|
||||
Timber.d("Kotlin: Color clicked. Existing? ${state.isExistingHighlight}")
|
||||
if (state.isExistingHighlight && state.cfi != null) {
|
||||
localWebViewRef?.evaluateJavascript(
|
||||
"javascript:window.HighlightBridgeHelper.updateHighlightStyle('${state.cfi}', '${colorEnum.cssClass}', '${colorEnum.id}');",
|
||||
null
|
||||
)
|
||||
} else {
|
||||
localWebViewRef?.evaluateJavascript(
|
||||
"javascript:window.HighlightBridgeHelper.createUserHighlight('${colorEnum.cssClass}', '${colorEnum.id}');",
|
||||
null
|
||||
)
|
||||
}
|
||||
state.finishActionModeCallback()
|
||||
localWebViewRef?.clearFocus()
|
||||
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)
|
||||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,18 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.res.painterResource
|
||||
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"),
|
||||
GREEN("green", Color(0xFF388E3C), "user-highlight-green"),
|
||||
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(
|
||||
|
|
@ -88,6 +110,24 @@ fun escapeJsString(value: String): String {
|
|||
.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 ---
|
||||
|
||||
fun loadBookmarks(context: Context, bookTitle: String, chapters: List<EpubChapter>, bookmarksJson: String?): Set<Bookmark> {
|
||||
|
|
@ -284,4 +324,111 @@ 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") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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()) {
|
||||
saveHighlightsToPrefs(context, epubBook.title, userHighlights)
|
||||
}
|
||||
|
|
@ -1541,6 +1554,8 @@ fun EpubReaderHost(
|
|||
initialCfi = cfiToLoad,
|
||||
initialFragmentId = fragmentToLoad.also { },
|
||||
userHighlights = userHighlights.filter { it.chapterIndex == targetChapterIndex },
|
||||
activeHighlightPalette = currentHighlightPalette,
|
||||
onUpdatePalette = onUpdateHighlightPalette,
|
||||
onHighlightCreated = { cfi, text, colorId ->
|
||||
Timber.d("Vertical Mode (Source): Creating Highlight. CFI: $cfi")
|
||||
Timber.d("Vertical Mode (Source): Text Snippet: '${text.take(50)}...'")
|
||||
|
|
@ -2090,6 +2105,8 @@ fun EpubReaderHost(
|
|||
lineHeightMultiplier = currentLineHeight,
|
||||
fontFamily = activeFontFamily,
|
||||
textAlign = currentTextAlign,
|
||||
activeHighlightPalette = currentHighlightPalette,
|
||||
onUpdatePalette = onUpdateHighlightPalette,
|
||||
ttsHighlightInfo = TtsHighlightInfo(
|
||||
text = ttsState.currentText ?: "",
|
||||
cfi = ttsState.sourceCfi ?: "",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue