contents for reading
This commit is contained in:
parent
cde4d89eed
commit
7681590992
@ -832,16 +832,34 @@ private fun ReadingPosition.toFormatHintsJson(): String =
|
||||
buildString {
|
||||
append("""{"firstVisibleItemIndex":$itemIndex,"firstVisibleItemScrollOffset":$scrollOffset""")
|
||||
readAloudSentenceIndex?.let { append(""","readAloudSentenceIndex":$it""") }
|
||||
if (backStack.isNotEmpty()) {
|
||||
append(""","backStack":[""")
|
||||
append(backStack.take(MaxStoredReadingBackStack).joinToString(",") { it.toFormatHintsJson() })
|
||||
append("]")
|
||||
}
|
||||
append("}")
|
||||
}
|
||||
|
||||
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 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()
|
||||
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> =
|
||||
SearchPrefixRegex.findAll(lowercase()).map { it.value }.distinct().toList()
|
||||
|
||||
|
||||
@ -62,6 +62,7 @@ data class ReadingPosition(
|
||||
val itemIndex: Int,
|
||||
val scrollOffset: Int,
|
||||
val readAloudSentenceIndex: Int? = null,
|
||||
val backStack: List<ReadingPosition> = emptyList(),
|
||||
)
|
||||
|
||||
data class ReaderFontSettings(
|
||||
|
||||
@ -133,6 +133,8 @@ internal open class AppStrings {
|
||||
open val noImage = "No image"
|
||||
|
||||
open val readerTheme = "Theme"
|
||||
open val tableOfContents = "Contents"
|
||||
open val backToLastPosition = "Back"
|
||||
open val readAloud = "Read aloud"
|
||||
open val readerMenu = "Book reader menu"
|
||||
open val info = "Info..."
|
||||
@ -337,6 +339,8 @@ internal object RussianStrings : AppStrings() {
|
||||
override val noImage = "Нет изображения"
|
||||
|
||||
override val readerTheme = "Тема"
|
||||
override val tableOfContents = "Содержание"
|
||||
override val backToLastPosition = "Назад"
|
||||
override val readAloud = "Читать вслух"
|
||||
override val readerMenu = "Меню чтения"
|
||||
override val info = "Информация..."
|
||||
|
||||
@ -1463,6 +1463,8 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
|
||||
lateinit var addPoemSentences: (Int, Fb2Poem) -> Unit
|
||||
lateinit var addCiteSentences: (Int, Fb2Cite) -> Unit
|
||||
lateinit var addEpigraphSentences: (Int, Fb2Epigraph) -> Unit
|
||||
lateinit var addSections: (List<Fb2Section>, Int) -> Unit
|
||||
val tocEntries = mutableListOf<ReaderTocEntry>()
|
||||
|
||||
addPoemSentences = { itemIndex, poem ->
|
||||
poem.epigraphs.forEach { epigraph -> addEpigraphSentences(itemIndex, epigraph) }
|
||||
@ -1497,18 +1499,20 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
|
||||
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()) {
|
||||
elements += ReaderElement.SectionSeparator
|
||||
} else {
|
||||
val itemIndex = elements.size
|
||||
elements += ReaderElement.SectionTitle(section.title!!, readerDepth)
|
||||
elements += ReaderElement.SectionTitle(title, readerDepth)
|
||||
sentences += ReadAloudSentence(
|
||||
index = sentences.size,
|
||||
itemIndex = itemIndex,
|
||||
start = 0,
|
||||
endExclusive = section.title!!.length,
|
||||
text = section.title!!,
|
||||
endExclusive = title.length,
|
||||
text = title,
|
||||
pauseBeforeMillis = HeadingPauseBeforeMillis + pendingPauseBeforeMillis,
|
||||
pauseAfterMillis = HeadingPauseAfterMillis,
|
||||
)
|
||||
@ -1548,7 +1552,13 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
|
||||
}
|
||||
}
|
||||
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
|
||||
@ -1558,15 +1568,16 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
|
||||
addEpigraphSentences(itemIndex, epigraph)
|
||||
elements += ReaderElement.Epigraph(epigraph, 0)
|
||||
}
|
||||
book.sections.forEach { addSection(it, 0) }
|
||||
addSections(book.sections, 0)
|
||||
elements += ReaderElement.FixedSpacer(22)
|
||||
|
||||
return ReaderContentPlan(elements, sentences)
|
||||
return ReaderContentPlan(elements, sentences, tocEntries)
|
||||
}
|
||||
|
||||
internal data class ReaderContentPlan(
|
||||
val elements: List<ReaderElement>,
|
||||
val sentences: List<ReadAloudSentence>,
|
||||
val tocEntries: List<ReaderTocEntry>,
|
||||
) {
|
||||
fun sentenceIndexAtOrAfterItem(itemIndex: Int): Int =
|
||||
sentences.firstOrNull { it.itemIndex >= itemIndex }?.index
|
||||
@ -1579,6 +1590,12 @@ internal data class ReaderContentPlan(
|
||||
?: sentenceIndexAtOrAfterItem(position.itemIndex)
|
||||
}
|
||||
|
||||
internal data class ReaderTocEntry(
|
||||
val title: String,
|
||||
val itemIndex: Int,
|
||||
val depth: Int,
|
||||
)
|
||||
|
||||
internal sealed interface ReaderElement {
|
||||
data object Cover : ReaderElement
|
||||
data class FixedSpacer(val heightDp: Int) : ReaderElement
|
||||
|
||||
@ -1,21 +1,26 @@
|
||||
package net.sergeych.toread
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.filled.BookmarkAdd
|
||||
import androidx.compose.material.icons.filled.BookmarkRemove
|
||||
@ -109,6 +114,9 @@ internal fun BookView(
|
||||
var readAloudPanelVisible by remember(fileId) { mutableStateOf(false) }
|
||||
var readAloudSettingsVisible 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 readAloudResumeSentenceIndex by remember(fileId) { mutableStateOf<Int?>(null) }
|
||||
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) {
|
||||
scope.launch {
|
||||
if (markLibraryReadingStatus(fileId, status)) {
|
||||
@ -157,7 +197,7 @@ internal fun BookView(
|
||||
if (status == BookReadingStatus.NOT_INTERESTED) {
|
||||
saveLibraryReadingPosition(
|
||||
fileId,
|
||||
ReadingPosition(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset),
|
||||
currentReadingPosition(),
|
||||
)
|
||||
saveActiveReadingFileId(null)
|
||||
onBack()
|
||||
@ -192,6 +232,8 @@ internal fun BookView(
|
||||
|
||||
LaunchedEffect(fileId) {
|
||||
loadLibraryReadingPosition(fileId)?.let { position ->
|
||||
tableOfContentsBackStack = position.backStack.take(MaxTableOfContentsBackStack)
|
||||
tableOfContentsBackPosition = tableOfContentsBackStack.backPositionFrom(position)
|
||||
readAloudResumeSentenceIndex = position.readAloudSentenceIndex
|
||||
listState.scrollToItem(position.itemIndex, position.scrollOffset)
|
||||
}
|
||||
@ -201,11 +243,7 @@ internal fun BookView(
|
||||
LaunchedEffect(fileId, listState, readAloudState.active, userScrollGeneration) {
|
||||
if (readAloudState.active) return@LaunchedEffect
|
||||
snapshotFlow {
|
||||
ReadingPosition(
|
||||
listState.firstVisibleItemIndex,
|
||||
listState.firstVisibleItemScrollOffset,
|
||||
readAloudResumeSentenceIndex,
|
||||
)
|
||||
currentReadingPosition()
|
||||
}
|
||||
.filter { restored && userScrollGeneration > 0 }
|
||||
.distinctUntilChanged()
|
||||
@ -238,7 +276,7 @@ internal fun BookView(
|
||||
val sentence = activeReadAloudSentence ?: return@LaunchedEffect
|
||||
readAloudResumeSentenceIndex = sentence.index
|
||||
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) {
|
||||
listState.animateScrollToItem(itemIndex, 0)
|
||||
}
|
||||
@ -255,11 +293,12 @@ internal fun BookView(
|
||||
scope.launch {
|
||||
saveLibraryReadingPosition(
|
||||
fileId,
|
||||
ReadingPosition(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset),
|
||||
currentReadingPosition(),
|
||||
)
|
||||
onBookInfo()
|
||||
}
|
||||
},
|
||||
onTableOfContents = ::openTableOfContents,
|
||||
onMarkAsRead = {
|
||||
setReadingStatus(BookReadingStatus.READ, strings.markedAsRead())
|
||||
},
|
||||
@ -302,11 +341,7 @@ internal fun BookView(
|
||||
},
|
||||
showReadAloudAction = showReadAloudAction,
|
||||
onReadAloud = {
|
||||
val position = ReadingPosition(
|
||||
listState.firstVisibleItemIndex,
|
||||
listState.firstVisibleItemScrollOffset,
|
||||
readAloudResumeSentenceIndex,
|
||||
)
|
||||
val position = currentReadingPosition()
|
||||
val startIndex = contentPlan.resumeSentenceIndex(position)
|
||||
ReadAloudPlatform.prepare(fileId, book.title, contentPlan.sentences, startIndex)
|
||||
readAloudPanelVisible = true
|
||||
@ -332,7 +367,7 @@ internal fun BookView(
|
||||
scope.launch {
|
||||
saveLibraryReadingPosition(
|
||||
fileId,
|
||||
ReadingPosition(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset),
|
||||
currentReadingPosition(),
|
||||
)
|
||||
saveActiveReadingFileId(null)
|
||||
onBack()
|
||||
@ -431,6 +466,35 @@ internal fun BookView(
|
||||
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
|
||||
@ -438,6 +502,7 @@ private fun CompactReaderTopBar(
|
||||
title: String,
|
||||
onThemeToggle: () -> Unit,
|
||||
onBookInfo: () -> Unit,
|
||||
onTableOfContents: () -> Unit,
|
||||
onMarkAsRead: () -> Unit,
|
||||
onMarkToRead: () -> Unit,
|
||||
onNotInterested: () -> Unit,
|
||||
@ -475,6 +540,9 @@ private fun CompactReaderTopBar(
|
||||
IconButton(onClick = onThemeToggle) {
|
||||
Icon(Icons.Filled.Palette, contentDescription = strings.readerTheme)
|
||||
}
|
||||
IconButton(onClick = onTableOfContents) {
|
||||
Icon(Icons.AutoMirrored.Filled.FormatListBulleted, contentDescription = strings.tableOfContents)
|
||||
}
|
||||
if (showReadAloudAction) {
|
||||
IconButton(onClick = onReadAloud) {
|
||||
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
|
||||
private fun ReaderFontSettingsPanel(
|
||||
settings: ReaderFontSettings,
|
||||
|
||||
@ -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 =
|
||||
Fb2Block.Paragraph(text(text))
|
||||
|
||||
|
||||
@ -665,16 +665,34 @@ private fun ReadingPosition.toFormatHintsJson(): String =
|
||||
buildString {
|
||||
append("""{"firstVisibleItemIndex":$itemIndex,"firstVisibleItemScrollOffset":$scrollOffset""")
|
||||
readAloudSentenceIndex?.let { append(""","readAloudSentenceIndex":$it""") }
|
||||
if (backStack.isNotEmpty()) {
|
||||
append(""","backStack":[""")
|
||||
append(backStack.take(MaxStoredReadingBackStack).joinToString(",") { it.toFormatHintsJson() })
|
||||
append("]")
|
||||
}
|
||||
append("}")
|
||||
}
|
||||
|
||||
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 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()
|
||||
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> =
|
||||
SearchPrefixRegex.findAll(lowercase()).map { it.value }.distinct().toList()
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user