contents for reading

This commit is contained in:
Sergey Chernov 2026-05-25 20:37:32 +03:00
parent cde4d89eed
commit 7681590992
7 changed files with 301 additions and 22 deletions

View File

@ -832,16 +832,34 @@ private fun ReadingPosition.toFormatHintsJson(): String =
buildString { buildString {
append("""{"firstVisibleItemIndex":$itemIndex,"firstVisibleItemScrollOffset":$scrollOffset""") append("""{"firstVisibleItemIndex":$itemIndex,"firstVisibleItemScrollOffset":$scrollOffset""")
readAloudSentenceIndex?.let { append(""","readAloudSentenceIndex":$it""") } readAloudSentenceIndex?.let { append(""","readAloudSentenceIndex":$it""") }
if (backStack.isNotEmpty()) {
append(""","backStack":[""")
append(backStack.take(MaxStoredReadingBackStack).joinToString(",") { it.toFormatHintsJson() })
append("]")
}
append("}") append("}")
} }
private fun String.toReadingPosition(): ReadingPosition? { private fun String.toReadingPosition(): ReadingPosition? {
val position = toReadingPositionObject() ?: return null
val backStackJson = Regex(""""backStack"\s*:\s*\[(.*)]""").find(this)?.groupValues?.getOrNull(1)
val backStack = backStackJson
?.let { ReadingPositionObjectRegex.findAll(it).mapNotNull { match -> match.value.toReadingPositionObject() }.toList() }
.orEmpty()
.take(MaxStoredReadingBackStack)
return position.copy(backStack = backStack)
}
private fun String.toReadingPositionObject(): ReadingPosition? {
val index = Regex(""""firstVisibleItemIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull() val index = Regex(""""firstVisibleItemIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
val offset = Regex(""""firstVisibleItemScrollOffset"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull() val offset = Regex(""""firstVisibleItemScrollOffset"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
val sentenceIndex = Regex(""""readAloudSentenceIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull() val sentenceIndex = Regex(""""readAloudSentenceIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
return if (index != null && offset != null) ReadingPosition(index, offset, sentenceIndex) else null return if (index != null && offset != null) ReadingPosition(index, offset, sentenceIndex) else null
} }
private val ReadingPositionObjectRegex = Regex("""[{][^{}]*[}]""")
private const val MaxStoredReadingBackStack = 10
private fun String.toSearchPrefixes(): List<String> = private fun String.toSearchPrefixes(): List<String> =
SearchPrefixRegex.findAll(lowercase()).map { it.value }.distinct().toList() SearchPrefixRegex.findAll(lowercase()).map { it.value }.distinct().toList()

View File

@ -62,6 +62,7 @@ data class ReadingPosition(
val itemIndex: Int, val itemIndex: Int,
val scrollOffset: Int, val scrollOffset: Int,
val readAloudSentenceIndex: Int? = null, val readAloudSentenceIndex: Int? = null,
val backStack: List<ReadingPosition> = emptyList(),
) )
data class ReaderFontSettings( data class ReaderFontSettings(

View File

@ -133,6 +133,8 @@ internal open class AppStrings {
open val noImage = "No image" open val noImage = "No image"
open val readerTheme = "Theme" open val readerTheme = "Theme"
open val tableOfContents = "Contents"
open val backToLastPosition = "Back"
open val readAloud = "Read aloud" open val readAloud = "Read aloud"
open val readerMenu = "Book reader menu" open val readerMenu = "Book reader menu"
open val info = "Info..." open val info = "Info..."
@ -337,6 +339,8 @@ internal object RussianStrings : AppStrings() {
override val noImage = "Нет изображения" override val noImage = "Нет изображения"
override val readerTheme = "Тема" override val readerTheme = "Тема"
override val tableOfContents = "Содержание"
override val backToLastPosition = "Назад"
override val readAloud = "Читать вслух" override val readAloud = "Читать вслух"
override val readerMenu = "Меню чтения" override val readerMenu = "Меню чтения"
override val info = "Информация..." override val info = "Информация..."

View File

@ -1463,6 +1463,8 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
lateinit var addPoemSentences: (Int, Fb2Poem) -> Unit lateinit var addPoemSentences: (Int, Fb2Poem) -> Unit
lateinit var addCiteSentences: (Int, Fb2Cite) -> Unit lateinit var addCiteSentences: (Int, Fb2Cite) -> Unit
lateinit var addEpigraphSentences: (Int, Fb2Epigraph) -> Unit lateinit var addEpigraphSentences: (Int, Fb2Epigraph) -> Unit
lateinit var addSections: (List<Fb2Section>, Int) -> Unit
val tocEntries = mutableListOf<ReaderTocEntry>()
addPoemSentences = { itemIndex, poem -> addPoemSentences = { itemIndex, poem ->
poem.epigraphs.forEach { epigraph -> addEpigraphSentences(itemIndex, epigraph) } poem.epigraphs.forEach { epigraph -> addEpigraphSentences(itemIndex, epigraph) }
@ -1497,18 +1499,20 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
epigraph.textAuthors.forEach { addTextSentences(itemIndex, it) } epigraph.textAuthors.forEach { addTextSentences(itemIndex, it) }
} }
fun addSection(section: Fb2Section, readerDepth: Int) { fun addSection(section: Fb2Section, readerDepth: Int, fallbackTitle: String) {
val title = section.title?.ifBlank { null } ?: fallbackTitle
val itemIndex = elements.size
tocEntries += ReaderTocEntry(title, itemIndex, readerDepth)
if (section.title.isNullOrBlank()) { if (section.title.isNullOrBlank()) {
elements += ReaderElement.SectionSeparator elements += ReaderElement.SectionSeparator
} else { } else {
val itemIndex = elements.size elements += ReaderElement.SectionTitle(title, readerDepth)
elements += ReaderElement.SectionTitle(section.title!!, readerDepth)
sentences += ReadAloudSentence( sentences += ReadAloudSentence(
index = sentences.size, index = sentences.size,
itemIndex = itemIndex, itemIndex = itemIndex,
start = 0, start = 0,
endExclusive = section.title!!.length, endExclusive = title.length,
text = section.title!!, text = title,
pauseBeforeMillis = HeadingPauseBeforeMillis + pendingPauseBeforeMillis, pauseBeforeMillis = HeadingPauseBeforeMillis + pendingPauseBeforeMillis,
pauseAfterMillis = HeadingPauseAfterMillis, pauseAfterMillis = HeadingPauseAfterMillis,
) )
@ -1548,7 +1552,13 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
} }
} }
val childDepth = if (blocks.isEmpty()) readerDepth else readerDepth + 1 val childDepth = if (blocks.isEmpty()) readerDepth else readerDepth + 1
section.sections.forEach { addSection(it, childDepth) } addSections(section.sections, childDepth)
}
addSections = { sections, readerDepth ->
sections.forEachIndexed { index, section ->
addSection(section, readerDepth, strings.sectionFallback(index))
}
} }
elements += ReaderElement.Cover elements += ReaderElement.Cover
@ -1558,15 +1568,16 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
addEpigraphSentences(itemIndex, epigraph) addEpigraphSentences(itemIndex, epigraph)
elements += ReaderElement.Epigraph(epigraph, 0) elements += ReaderElement.Epigraph(epigraph, 0)
} }
book.sections.forEach { addSection(it, 0) } addSections(book.sections, 0)
elements += ReaderElement.FixedSpacer(22) elements += ReaderElement.FixedSpacer(22)
return ReaderContentPlan(elements, sentences) return ReaderContentPlan(elements, sentences, tocEntries)
} }
internal data class ReaderContentPlan( internal data class ReaderContentPlan(
val elements: List<ReaderElement>, val elements: List<ReaderElement>,
val sentences: List<ReadAloudSentence>, val sentences: List<ReadAloudSentence>,
val tocEntries: List<ReaderTocEntry>,
) { ) {
fun sentenceIndexAtOrAfterItem(itemIndex: Int): Int = fun sentenceIndexAtOrAfterItem(itemIndex: Int): Int =
sentences.firstOrNull { it.itemIndex >= itemIndex }?.index sentences.firstOrNull { it.itemIndex >= itemIndex }?.index
@ -1579,6 +1590,12 @@ internal data class ReaderContentPlan(
?: sentenceIndexAtOrAfterItem(position.itemIndex) ?: sentenceIndexAtOrAfterItem(position.itemIndex)
} }
internal data class ReaderTocEntry(
val title: String,
val itemIndex: Int,
val depth: Int,
)
internal sealed interface ReaderElement { internal sealed interface ReaderElement {
data object Cover : ReaderElement data object Cover : ReaderElement
data class FixedSpacer(val heightDp: Int) : ReaderElement data class FixedSpacer(val heightDp: Int) : ReaderElement

View File

@ -1,21 +1,26 @@
package net.sergeych.toread package net.sergeych.toread
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
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.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.BookmarkAdd import androidx.compose.material.icons.filled.BookmarkAdd
import androidx.compose.material.icons.filled.BookmarkRemove import androidx.compose.material.icons.filled.BookmarkRemove
@ -109,6 +114,9 @@ internal fun BookView(
var readAloudPanelVisible by remember(fileId) { mutableStateOf(false) } var readAloudPanelVisible by remember(fileId) { mutableStateOf(false) }
var readAloudSettingsVisible by remember(fileId) { mutableStateOf(false) } var readAloudSettingsVisible by remember(fileId) { mutableStateOf(false) }
var readerSettingsPanelVisible by remember(fileId) { mutableStateOf(false) } var readerSettingsPanelVisible by remember(fileId) { mutableStateOf(false) }
var tableOfContentsVisible by remember(fileId) { mutableStateOf(false) }
var tableOfContentsBackStack by remember(fileId) { mutableStateOf<List<ReadingPosition>>(emptyList()) }
var tableOfContentsBackPosition by remember(fileId) { mutableStateOf<ReadingPosition?>(null) }
var readerFontSettings by remember { mutableStateOf(defaultReaderFontSettings()) } var readerFontSettings by remember { mutableStateOf(defaultReaderFontSettings()) }
var readAloudResumeSentenceIndex by remember(fileId) { mutableStateOf<Int?>(null) } var readAloudResumeSentenceIndex by remember(fileId) { mutableStateOf<Int?>(null) }
var userScrollGeneration by remember(fileId) { mutableStateOf(0) } var userScrollGeneration by remember(fileId) { mutableStateOf(0) }
@ -147,6 +155,38 @@ internal fun BookView(
} }
} }
fun currentReadingPosition(backStack: List<ReadingPosition> = tableOfContentsBackStack): ReadingPosition =
ReadingPosition(
listState.firstVisibleItemIndex,
listState.firstVisibleItemScrollOffset,
readAloudResumeSentenceIndex,
backStack,
)
fun openPosition(position: ReadingPosition, backStack: List<ReadingPosition>) {
scope.launch {
val target = position.copy(backStack = backStack)
readAloudResumeSentenceIndex = target.readAloudSentenceIndex
?: contentPlan.sentenceIndexAtOrAfterItem(target.itemIndex)
listState.animateScrollToItem(target.itemIndex, target.scrollOffset)
saveLibraryReadingPosition(fileId, target)
}
}
fun openTableOfContents() {
scope.launch {
val current = currentReadingPosition()
val savedStack = loadLibraryReadingPosition(fileId)?.backStack.orEmpty()
val nextStack = (listOf(current) + savedStack)
.withoutAdjacentReadingDuplicates()
.take(MaxTableOfContentsBackStack)
tableOfContentsBackStack = nextStack
tableOfContentsBackPosition = nextStack.backPositionFrom(current)
saveLibraryReadingPosition(fileId, current.copy(backStack = nextStack))
tableOfContentsVisible = true
}
}
fun setReadingStatus(status: BookReadingStatus, successMessage: String) { fun setReadingStatus(status: BookReadingStatus, successMessage: String) {
scope.launch { scope.launch {
if (markLibraryReadingStatus(fileId, status)) { if (markLibraryReadingStatus(fileId, status)) {
@ -157,7 +197,7 @@ internal fun BookView(
if (status == BookReadingStatus.NOT_INTERESTED) { if (status == BookReadingStatus.NOT_INTERESTED) {
saveLibraryReadingPosition( saveLibraryReadingPosition(
fileId, fileId,
ReadingPosition(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset), currentReadingPosition(),
) )
saveActiveReadingFileId(null) saveActiveReadingFileId(null)
onBack() onBack()
@ -192,6 +232,8 @@ internal fun BookView(
LaunchedEffect(fileId) { LaunchedEffect(fileId) {
loadLibraryReadingPosition(fileId)?.let { position -> loadLibraryReadingPosition(fileId)?.let { position ->
tableOfContentsBackStack = position.backStack.take(MaxTableOfContentsBackStack)
tableOfContentsBackPosition = tableOfContentsBackStack.backPositionFrom(position)
readAloudResumeSentenceIndex = position.readAloudSentenceIndex readAloudResumeSentenceIndex = position.readAloudSentenceIndex
listState.scrollToItem(position.itemIndex, position.scrollOffset) listState.scrollToItem(position.itemIndex, position.scrollOffset)
} }
@ -201,11 +243,7 @@ internal fun BookView(
LaunchedEffect(fileId, listState, readAloudState.active, userScrollGeneration) { LaunchedEffect(fileId, listState, readAloudState.active, userScrollGeneration) {
if (readAloudState.active) return@LaunchedEffect if (readAloudState.active) return@LaunchedEffect
snapshotFlow { snapshotFlow {
ReadingPosition( currentReadingPosition()
listState.firstVisibleItemIndex,
listState.firstVisibleItemScrollOffset,
readAloudResumeSentenceIndex,
)
} }
.filter { restored && userScrollGeneration > 0 } .filter { restored && userScrollGeneration > 0 }
.distinctUntilChanged() .distinctUntilChanged()
@ -238,7 +276,7 @@ internal fun BookView(
val sentence = activeReadAloudSentence ?: return@LaunchedEffect val sentence = activeReadAloudSentence ?: return@LaunchedEffect
readAloudResumeSentenceIndex = sentence.index readAloudResumeSentenceIndex = sentence.index
val itemIndex = sentence.itemIndex val itemIndex = sentence.itemIndex
saveLibraryReadingPosition(fileId, ReadingPosition(itemIndex, 0, sentence.index)) saveLibraryReadingPosition(fileId, ReadingPosition(itemIndex, 0, sentence.index, tableOfContentsBackStack))
if (listState.firstVisibleItemIndex != itemIndex || listState.firstVisibleItemScrollOffset != 0) { if (listState.firstVisibleItemIndex != itemIndex || listState.firstVisibleItemScrollOffset != 0) {
listState.animateScrollToItem(itemIndex, 0) listState.animateScrollToItem(itemIndex, 0)
} }
@ -255,11 +293,12 @@ internal fun BookView(
scope.launch { scope.launch {
saveLibraryReadingPosition( saveLibraryReadingPosition(
fileId, fileId,
ReadingPosition(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset), currentReadingPosition(),
) )
onBookInfo() onBookInfo()
} }
}, },
onTableOfContents = ::openTableOfContents,
onMarkAsRead = { onMarkAsRead = {
setReadingStatus(BookReadingStatus.READ, strings.markedAsRead()) setReadingStatus(BookReadingStatus.READ, strings.markedAsRead())
}, },
@ -302,11 +341,7 @@ internal fun BookView(
}, },
showReadAloudAction = showReadAloudAction, showReadAloudAction = showReadAloudAction,
onReadAloud = { onReadAloud = {
val position = ReadingPosition( val position = currentReadingPosition()
listState.firstVisibleItemIndex,
listState.firstVisibleItemScrollOffset,
readAloudResumeSentenceIndex,
)
val startIndex = contentPlan.resumeSentenceIndex(position) val startIndex = contentPlan.resumeSentenceIndex(position)
ReadAloudPlatform.prepare(fileId, book.title, contentPlan.sentences, startIndex) ReadAloudPlatform.prepare(fileId, book.title, contentPlan.sentences, startIndex)
readAloudPanelVisible = true readAloudPanelVisible = true
@ -332,7 +367,7 @@ internal fun BookView(
scope.launch { scope.launch {
saveLibraryReadingPosition( saveLibraryReadingPosition(
fileId, fileId,
ReadingPosition(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset), currentReadingPosition(),
) )
saveActiveReadingFileId(null) saveActiveReadingFileId(null)
onBack() onBack()
@ -431,6 +466,35 @@ internal fun BookView(
onNoteOpen = ::openReaderLink, onNoteOpen = ::openReaderLink,
) )
} }
if (tableOfContentsVisible) {
val currentTocItemIndex = contentPlan.tocEntries
.lastOrNull { it.itemIndex <= listState.firstVisibleItemIndex }
?.itemIndex
TableOfContentsDialog(
contentPlan = contentPlan,
currentItemIndex = currentTocItemIndex,
backPosition = tableOfContentsBackPosition,
onOpenPosition = { position ->
tableOfContentsVisible = false
val targetIndex = tableOfContentsBackStack.indexOfFirst { it.sameReadingPlace(position) }
val remainingStack = if (targetIndex >= 0) {
tableOfContentsBackStack.drop(targetIndex + 1)
} else {
tableOfContentsBackStack
}
tableOfContentsBackStack = remainingStack
tableOfContentsBackPosition = remainingStack.backPositionFrom(position)
openPosition(position, remainingStack)
},
onOpenEntry = { entry ->
tableOfContentsVisible = false
val sentenceIndex = contentPlan.sentenceIndexAtOrAfterItem(entry.itemIndex)
openPosition(ReadingPosition(entry.itemIndex, 0, sentenceIndex), tableOfContentsBackStack)
},
onDismiss = { tableOfContentsVisible = false },
)
}
} }
@Composable @Composable
@ -438,6 +502,7 @@ private fun CompactReaderTopBar(
title: String, title: String,
onThemeToggle: () -> Unit, onThemeToggle: () -> Unit,
onBookInfo: () -> Unit, onBookInfo: () -> Unit,
onTableOfContents: () -> Unit,
onMarkAsRead: () -> Unit, onMarkAsRead: () -> Unit,
onMarkToRead: () -> Unit, onMarkToRead: () -> Unit,
onNotInterested: () -> Unit, onNotInterested: () -> Unit,
@ -475,6 +540,9 @@ private fun CompactReaderTopBar(
IconButton(onClick = onThemeToggle) { IconButton(onClick = onThemeToggle) {
Icon(Icons.Filled.Palette, contentDescription = strings.readerTheme) Icon(Icons.Filled.Palette, contentDescription = strings.readerTheme)
} }
IconButton(onClick = onTableOfContents) {
Icon(Icons.AutoMirrored.Filled.FormatListBulleted, contentDescription = strings.tableOfContents)
}
if (showReadAloudAction) { if (showReadAloudAction) {
IconButton(onClick = onReadAloud) { IconButton(onClick = onReadAloud) {
Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = strings.readAloud) Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = strings.readAloud)
@ -616,6 +684,131 @@ private fun CompactReaderTopBar(
} }
} }
@Composable
private fun TableOfContentsDialog(
contentPlan: ReaderContentPlan,
currentItemIndex: Int?,
backPosition: ReadingPosition?,
onOpenPosition: (ReadingPosition) -> Unit,
onOpenEntry: (ReaderTocEntry) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(strings.tableOfContents) },
text = {
LazyColumn(
modifier = Modifier.fillMaxWidth().heightIn(max = 480.dp),
) {
item(key = "back") {
TableOfContentsBackRow(
enabled = backPosition != null,
onClick = { backPosition?.let(onOpenPosition) },
)
HorizontalDivider()
}
items(contentPlan.tocEntries, key = { it.itemIndex }) { entry ->
TableOfContentsEntryRow(
entry = entry,
selected = entry.itemIndex == currentItemIndex,
onClick = { onOpenEntry(entry) },
)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(strings.done)
}
},
)
}
@Composable
private fun TableOfContentsBackRow(enabled: Boolean, onClick: () -> Unit) {
val contentColor = if (enabled) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = contentColor,
modifier = Modifier.size(20.dp),
)
Text(
text = strings.backToLastPosition,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = contentColor,
modifier = Modifier.padding(start = 12.dp),
)
}
}
@Composable
private fun TableOfContentsEntryRow(
entry: ReaderTocEntry,
selected: Boolean,
onClick: () -> Unit,
) {
val rowModifier = Modifier
.fillMaxWidth()
.then(
if (selected) {
Modifier.background(MaterialTheme.colorScheme.primaryContainer)
} else {
Modifier
},
)
.clickable(onClick = onClick)
.padding(
start = (12 + entry.depth * 16).dp,
top = 10.dp,
end = 12.dp,
bottom = 10.dp,
)
Text(
text = entry.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
color = if (selected) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = rowModifier,
)
}
private fun List<ReadingPosition>.withoutAdjacentReadingDuplicates(): List<ReadingPosition> =
fold(emptyList()) { acc, position ->
if (acc.lastOrNull()?.sameReadingPlace(position) == true) acc else acc + position.withoutReadingBackStack()
}
private fun ReadingPosition.sameReadingPlace(other: ReadingPosition): Boolean =
itemIndex == other.itemIndex &&
scrollOffset == other.scrollOffset &&
readAloudSentenceIndex == other.readAloudSentenceIndex
private fun ReadingPosition.withoutReadingBackStack(): ReadingPosition =
copy(backStack = emptyList())
private fun List<ReadingPosition>.backPositionFrom(current: ReadingPosition): ReadingPosition? =
firstOrNull { !it.sameReadingPlace(current) }
private const val MaxTableOfContentsBackStack = 10
@Composable @Composable
private fun ReaderFontSettingsPanel( private fun ReaderFontSettingsPanel(
settings: ReaderFontSettings, settings: ReaderFontSettings,

View File

@ -305,6 +305,34 @@ class ReadAloudContentPlanTest {
) )
} }
@Test
fun contentPlanIncludesClickableTableOfContentsEntries() {
val plan = buildReaderContentPlan(
Fb2Book(
title = "Book",
sections = listOf(
Fb2Section(
title = "Part",
sections = listOf(
Fb2Section(
title = "Chapter",
blocks = listOf(paragraph("Chapter text.")),
),
),
),
Fb2Section(blocks = listOf(paragraph("Untitled text."))),
),
),
)
assertEquals(listOf("Part", "Chapter", "Section 2"), plan.tocEntries.map { it.title })
assertEquals(listOf(0, 0, 0), plan.tocEntries.map { it.depth })
assertEquals(
plan.elements.filterIsInstance<ReaderElement.SectionTitle>().first(),
plan.elements[plan.tocEntries.first().itemIndex],
)
}
private fun paragraph(text: String): Fb2Block.Paragraph = private fun paragraph(text: String): Fb2Block.Paragraph =
Fb2Block.Paragraph(text(text)) Fb2Block.Paragraph(text(text))

View File

@ -665,16 +665,34 @@ private fun ReadingPosition.toFormatHintsJson(): String =
buildString { buildString {
append("""{"firstVisibleItemIndex":$itemIndex,"firstVisibleItemScrollOffset":$scrollOffset""") append("""{"firstVisibleItemIndex":$itemIndex,"firstVisibleItemScrollOffset":$scrollOffset""")
readAloudSentenceIndex?.let { append(""","readAloudSentenceIndex":$it""") } readAloudSentenceIndex?.let { append(""","readAloudSentenceIndex":$it""") }
if (backStack.isNotEmpty()) {
append(""","backStack":[""")
append(backStack.take(MaxStoredReadingBackStack).joinToString(",") { it.toFormatHintsJson() })
append("]")
}
append("}") append("}")
} }
private fun String.toReadingPosition(): ReadingPosition? { private fun String.toReadingPosition(): ReadingPosition? {
val position = toReadingPositionObject() ?: return null
val backStackJson = Regex(""""backStack"\s*:\s*\[(.*)]""").find(this)?.groupValues?.getOrNull(1)
val backStack = backStackJson
?.let { ReadingPositionObjectRegex.findAll(it).mapNotNull { match -> match.value.toReadingPositionObject() }.toList() }
.orEmpty()
.take(MaxStoredReadingBackStack)
return position.copy(backStack = backStack)
}
private fun String.toReadingPositionObject(): ReadingPosition? {
val index = Regex(""""firstVisibleItemIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull() val index = Regex(""""firstVisibleItemIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
val offset = Regex(""""firstVisibleItemScrollOffset"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull() val offset = Regex(""""firstVisibleItemScrollOffset"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
val sentenceIndex = Regex(""""readAloudSentenceIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull() val sentenceIndex = Regex(""""readAloudSentenceIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
return if (index != null && offset != null) ReadingPosition(index, offset, sentenceIndex) else null return if (index != null && offset != null) ReadingPosition(index, offset, sentenceIndex) else null
} }
private val ReadingPositionObjectRegex = Regex("""[{][^{}]*[}]""")
private const val MaxStoredReadingBackStack = 10
private fun String.toSearchPrefixes(): List<String> = private fun String.toSearchPrefixes(): List<String> =
SearchPrefixRegex.findAll(lowercase()).map { it.value }.distinct().toList() SearchPrefixRegex.findAll(lowercase()).map { it.value }.distinct().toList()