🚀 Nested Chapters in Reader

* Fixed issue with incorrect navigation
* Added Nested Chapters, e.g. Book -> Chapter

Resolves: #130
This commit is contained in:
Acclorite 2025-02-10 12:43:36 +02:00
parent 804c7d24a9
commit cd18c430a5
12 changed files with 215 additions and 63 deletions

View file

@ -164,7 +164,8 @@ class DocumentParser @Inject constructor(
) { ) {
readerText.add( readerText.add(
0, ReaderText.Chapter( 0, ReaderText.Chapter(
title = formattedLine.clearAllMarkdown() title = formattedLine.clearAllMarkdown(),
nested = false
) )
) )
chapterAdded = true chapterAdded = true

View file

@ -32,7 +32,7 @@ import javax.inject.Inject
import kotlin.collections.set import kotlin.collections.set
private const val EPUB_TAG = "EPUB Parser" private const val EPUB_TAG = "EPUB Parser"
private typealias Title = String private typealias Source = String
private val dispatcher = Dispatchers.IO.limitedParallelism(3) private val dispatcher = Dispatchers.IO.limitedParallelism(3)
@ -108,7 +108,7 @@ class EpubTextParser @Inject constructor(
private suspend fun ZipFile.parseEpub( private suspend fun ZipFile.parseEpub(
chapterEntries: List<ZipEntry>, chapterEntries: List<ZipEntry>,
imageEntries: List<ZipEntry>, imageEntries: List<ZipEntry>,
chapterTitleEntries: Map<Title, List<String>>? chapterTitleEntries: Map<Source, ReaderText.Chapter>?
): List<ReaderText> { ): List<ReaderText> {
val readerText = mutableListOf<ReaderText>() val readerText = mutableListOf<ReaderText>()
@ -159,7 +159,7 @@ class EpubTextParser @Inject constructor(
index: Int, index: Int,
entry: ZipEntry, entry: ZipEntry,
imageEntries: List<ZipEntry>, imageEntries: List<ZipEntry>,
chapterTitleMap: Map<Title, List<String>>? chapterTitleMap: Map<Source, ReaderText.Chapter>?
) { ) {
// Getting all text // Getting all text
val content = zip.getInputStream(entry).bufferedReader().use { it.readText() } val content = zip.getInputStream(entry).bufferedReader().use { it.readText() }
@ -176,22 +176,24 @@ class EpubTextParser @Inject constructor(
chapterSource = entry.name, chapterSource = entry.name,
chapterTitleMap = chapterTitleMap chapterTitleMap = chapterTitleMap
).apply { ).apply {
val chapterTitle = this ?: run { val chapter = this ?: run {
val firstVisibleText = readerText.firstOrNull { line -> val firstVisibleText = readerText.firstOrNull { line ->
line is ReaderText.Text && line.line.text.containsVisibleText() line is ReaderText.Text && line.line.text.containsVisibleText()
} as? ReaderText.Text ?: return } as? ReaderText.Text ?: return
firstVisibleText.line.text
return@run ReaderText.Chapter(
title = firstVisibleText.line.text,
nested = false
)
} }
readerText = readerText.dropWhile { line -> readerText = readerText.dropWhile { line ->
(line is ReaderText.Text && line.line.text.lowercase() == chapterTitle.lowercase()) (line is ReaderText.Text && line.line.text.lowercase() == chapter.title.lowercase())
}.toMutableList() }.toMutableList()
readerText.add( readerText.add(
0, 0,
ReaderText.Chapter( chapter
title = chapterTitle
)
) )
} }
@ -213,7 +215,7 @@ class EpubTextParser @Inject constructor(
*/ */
private suspend fun ZipFile.getChapterTitleMapFromToc( private suspend fun ZipFile.getChapterTitleMapFromToc(
tocEntry: ZipEntry? tocEntry: ZipEntry?
): Map<Title, List<String>>? { ): Map<Source, ReaderText.Chapter>? {
val tocContent = tocEntry?.let { val tocContent = tocEntry?.let {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
getInputStream(it) getInputStream(it)
@ -222,18 +224,43 @@ class EpubTextParser @Inject constructor(
val tocDocument = tocContent?.let { Jsoup.parse(it) } val tocDocument = tocContent?.let { Jsoup.parse(it) }
if (tocDocument == null) return null if (tocDocument == null) return null
var titleMap = mutableMapOf<Title, List<String>>() var titleMap = mutableMapOf<Source, ReaderText.Chapter>()
tocDocument.select("navPoint").forEach { navPoint -> tocDocument.select("navPoint").forEach { navPoint ->
val title = navPoint.selectFirst("navLabel > text")?.text()?.trim() val title = navPoint.selectFirst("navLabel > text")?.text()
?: return@forEach .let { title ->
if (title.isNullOrBlank()) return@forEach
title.trim()
}
val source = navPoint.selectFirst("content")?.attr("src")?.trim() val source = navPoint.selectFirst("content")?.attr("src")?.trim()
.let { .let { source ->
if (it == null) return@forEach if (source.isNullOrBlank()) return@forEach
Uri.parse(it).path ?: it Uri.parse(source).path ?: source
}.substringAfterLast(File.separator) }.substringAfterLast(File.separator)
titleMap[source] = (titleMap[source] ?: emptyList()) + title val parent = navPoint.parent()
.let { parent ->
if (parent == null) return@let null
if (!parent.tagName().equals("navPoint", ignoreCase = true)) return@let null
val parentSource = parent.selectFirst("content")?.attr("src")?.trim()
.let { parentSource ->
if (parentSource.isNullOrBlank()) return@forEach
Uri.parse(parentSource).path ?: parentSource
}.substringAfterLast(File.separator)
if (parentSource == source) return@let null
return@let parentSource
}
val chapter = ReaderText.Chapter(
title = titleMap[source]?.title.run {
if (this == null) return@run title
return@run "$this / $title"
},
nested = titleMap[source]?.nested ?: (parent != null)
)
titleMap[source] = chapter
} }
return titleMap return titleMap
@ -246,14 +273,12 @@ class EpubTextParser @Inject constructor(
*/ */
private fun getChapterTitleFromToc( private fun getChapterTitleFromToc(
chapterSource: String, chapterSource: String,
chapterTitleMap: Map<String, List<String>>? chapterTitleMap: Map<Source, ReaderText.Chapter>?
): String? { ): ReaderText.Chapter? {
if (chapterTitleMap.isNullOrEmpty()) return null if (chapterTitleMap.isNullOrEmpty()) return null
return chapterTitleMap return chapterTitleMap.getOrElse(chapterSource.substringAfterLast(File.separator)) {
.getOrElse(chapterSource.substringAfterLast(File.separator)) { null } null
?.joinToString(separator = " / ") }
?.ifBlank { null }
?.trim()
} }
/** /**

View file

@ -125,7 +125,8 @@ class Fb2TextParser @Inject constructor(
if (!chapterAdded && line.clearAllMarkdown().isNotBlank()) { if (!chapterAdded && line.clearAllMarkdown().isNotBlank()) {
readerText.add( readerText.add(
0, ReaderText.Chapter( 0, ReaderText.Chapter(
title = line.clearAllMarkdown() title = line.clearAllMarkdown(),
nested = false
) )
) )
chapterAdded = true chapterAdded = true

View file

@ -128,7 +128,8 @@ class PdfTextParser @Inject constructor(
if (!chapterAdded && line.clearAllMarkdown().isNotBlank()) { if (!chapterAdded && line.clearAllMarkdown().isNotBlank()) {
readerText.add( readerText.add(
0, ReaderText.Chapter( 0, ReaderText.Chapter(
title = line.clearAllMarkdown() title = line.clearAllMarkdown(),
nested = false
) )
) )
chapterAdded = true chapterAdded = true

View file

@ -44,7 +44,8 @@ class TxtTextParser @Inject constructor(
if (!chapterAdded && line.clearAllMarkdown().isNotBlank()) { if (!chapterAdded && line.clearAllMarkdown().isNotBlank()) {
readerText.add( readerText.add(
0, ReaderText.Chapter( 0, ReaderText.Chapter(
title = line.clearAllMarkdown() title = line.clearAllMarkdown(),
nested = false
) )
) )
chapterAdded = true chapterAdded = true

View file

@ -0,0 +1,16 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.domain.reader
import androidx.compose.runtime.Immutable
@Immutable
data class ExpandableChapter(
val parent: ReaderText.Chapter,
val expanded: Boolean,
val chapters: List<ReaderText.Chapter>?
)

View file

@ -9,11 +9,16 @@ package ua.acclorite.book_story.domain.reader
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import java.util.UUID
@Immutable @Immutable
sealed class ReaderText { sealed class ReaderText {
@Immutable @Immutable
data class Chapter(val title: String) : ReaderText() data class Chapter(
val id: UUID = UUID.randomUUID(),
val title: String,
val nested: Boolean
) : ReaderText()
@Immutable @Immutable
data class Text(val line: AnnotatedString) : ReaderText() data class Text(val line: AnnotatedString) : ReaderText()

View file

@ -18,6 +18,7 @@ import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
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
@ -54,7 +55,8 @@ fun ModalDrawerSelectableItem(
.clickable(enabled = enabled) { .clickable(enabled = enabled) {
onClick() onClick()
} }
.padding(horizontal = 18.dp, vertical = 18.dp) .padding(horizontal = 18.dp, vertical = 18.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
CompositionLocalProvider( CompositionLocalProvider(
LocalContentColor provides if (selected) MaterialTheme.colorScheme.onSecondaryContainer LocalContentColor provides if (selected) MaterialTheme.colorScheme.onSecondaryContainer

View file

@ -6,24 +6,32 @@
package ua.acclorite.book_story.presentation.reader package ua.acclorite.book_story.presentation.reader
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowDropUp
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.reader.ExpandableChapter
import ua.acclorite.book_story.domain.reader.ReaderText.Chapter import ua.acclorite.book_story.domain.reader.ReaderText.Chapter
import ua.acclorite.book_story.presentation.core.components.common.StyledText import ua.acclorite.book_story.presentation.core.components.common.StyledText
import ua.acclorite.book_story.presentation.core.components.modal_drawer.ModalDrawer import ua.acclorite.book_story.presentation.core.components.modal_drawer.ModalDrawer
import ua.acclorite.book_story.presentation.core.components.modal_drawer.ModalDrawerSelectableItem import ua.acclorite.book_story.presentation.core.components.modal_drawer.ModalDrawerSelectableItem
import ua.acclorite.book_story.presentation.core.components.modal_drawer.ModalDrawerTitleItem import ua.acclorite.book_story.presentation.core.components.modal_drawer.ModalDrawerTitleItem
import ua.acclorite.book_story.presentation.core.util.calculateProgress import ua.acclorite.book_story.presentation.core.util.calculateProgress
import ua.acclorite.book_story.presentation.core.util.noRippleClickable
import ua.acclorite.book_story.ui.reader.ReaderEvent import ua.acclorite.book_story.ui.reader.ReaderEvent
import ua.acclorite.book_story.ui.theme.ExpandingTransition
@Composable @Composable
fun ReaderChaptersDrawer( fun ReaderChaptersDrawer(
@ -34,15 +42,43 @@ fun ReaderChaptersDrawer(
scrollToChapter: (ReaderEvent.OnScrollToChapter) -> Unit, scrollToChapter: (ReaderEvent.OnScrollToChapter) -> Unit,
dismissDrawer: (ReaderEvent.OnDismissDrawer) -> Unit dismissDrawer: (ReaderEvent.OnDismissDrawer) -> Unit
) { ) {
val currentChapterIndex = remember(chapters, currentChapter) { val expandableChapters = remember(show, chapters, currentChapter) {
derivedStateOf { mutableStateListOf<ExpandableChapter>().apply {
chapters.indexOf(currentChapter).takeIf { it != -1 } ?: 0 var index = 0
while (index < chapters.size) {
val chapter = chapters.getOrNull(index) ?: continue
when (chapter.nested) {
false -> {
val children = chapters.drop(index + 1).takeWhile { it.nested }
add(
ExpandableChapter(
parent = chapter,
expanded = chapter.id == currentChapter?.id ||
children.any { it.id == currentChapter?.id },
chapters = children.takeIf { it.isNotEmpty() }
)
)
index += children.size + 1
}
true -> {
add(
ExpandableChapter(
parent = chapter.copy(nested = false),
expanded = false,
chapters = null
)
)
index++
}
}
}
} }
} }
ModalDrawer( ModalDrawer(
show = show, show = show,
startIndex = currentChapterIndex.value, startIndex = chapters.indexOf(currentChapter).takeIf { it != -1 } ?: 0,
onDismissRequest = { dismissDrawer(ReaderEvent.OnDismissDrawer) }, onDismissRequest = { dismissDrawer(ReaderEvent.OnDismissDrawer) },
header = { header = {
ModalDrawerTitleItem( ModalDrawerTitleItem(
@ -50,13 +86,65 @@ fun ReaderChaptersDrawer(
) )
} }
) { ) {
itemsIndexed(chapters, key = { index, _ -> index }) { index, chapter -> expandableChapters.forEach { expandableChapter ->
val selected = rememberSaveable(index, currentChapterIndex) { item {
index == currentChapterIndex.value ModalDrawerSelectableItem(
selected = expandableChapter.parent.id == currentChapter?.id,
onClick = {
scrollToChapter(
ReaderEvent.OnScrollToChapter(
chapter = expandableChapter.parent
)
)
dismissDrawer(ReaderEvent.OnDismissDrawer)
}
) {
StyledText(
text = expandableChapter.parent.title,
modifier = Modifier.weight(1f),
maxLines = 1
)
if (expandableChapter.parent == currentChapter) {
Spacer(modifier = Modifier.width(18.dp))
StyledText(text = "${currentChapterProgress.calculateProgress(0)}%")
} }
if (!expandableChapter.chapters.isNullOrEmpty()) {
Spacer(modifier = Modifier.width(18.dp))
Icon(
imageVector = Icons.Outlined.ArrowDropUp,
modifier = Modifier
.size(24.dp)
.noRippleClickable {
expandableChapters.indexOf(expandableChapter)
.also { chapterIndex ->
if (chapterIndex == -1) return@noRippleClickable
expandableChapters[chapterIndex] =
expandableChapter.copy(
expanded = !expandableChapter.expanded
)
}
}
.rotate(
animateFloatAsState(
targetValue = if (expandableChapter.expanded) 0f else -180f
).value
),
contentDescription = stringResource(
id = if (expandableChapter.expanded) R.string.collapse_content_desc
else R.string.expand_content_desc
)
)
}
}
}
if (!expandableChapter.chapters.isNullOrEmpty()) {
items(expandableChapter.chapters) { chapter ->
ExpandingTransition(visible = expandableChapter.expanded) {
ModalDrawerSelectableItem( ModalDrawerSelectableItem(
selected = selected, selected = chapter.id == currentChapter?.id,
onClick = { onClick = {
scrollToChapter( scrollToChapter(
ReaderEvent.OnScrollToChapter( ReaderEvent.OnScrollToChapter(
@ -66,14 +154,20 @@ fun ReaderChaptersDrawer(
dismissDrawer(ReaderEvent.OnDismissDrawer) dismissDrawer(ReaderEvent.OnDismissDrawer)
} }
) { ) {
Spacer(modifier = Modifier.width(18.dp))
StyledText( StyledText(
text = chapter.title, text = chapter.title,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
maxLines = 1 maxLines = 1
) )
if (selected) {
if (chapter == currentChapter) {
Spacer(modifier = Modifier.width(18.dp)) Spacer(modifier = Modifier.width(18.dp))
StyledText(text = "${currentChapterProgress.calculateProgress(1)}%") StyledText(text = "${currentChapterProgress.calculateProgress(0)}%")
}
}
}
} }
} }
} }

View file

@ -49,7 +49,9 @@ fun LazyItemScope.ReaderLayoutTextChapter(
modifier = Modifier modifier = Modifier
.padding(horizontal = sidePadding) .padding(horizontal = sidePadding)
.fillMaxWidth(), .fillMaxWidth(),
style = MaterialTheme.typography.headlineMedium.copy( style = (if (!chapter.nested) MaterialTheme.typography.headlineMedium
else MaterialTheme.typography.headlineSmall)
.copy(
color = fontColor, color = fontColor,
textAlign = chapterTitleAlignment.textAlignment textAlign = chapterTitleAlignment.textAlignment
), ),

View file

@ -480,5 +480,7 @@
<string name="show_less_content_desc">Показати менше</string> <string name="show_less_content_desc">Показати менше</string>
<string name="show_more_content_desc">Показати більше</string> <string name="show_more_content_desc">Показати більше</string>
<string name="edit_content_desc">Редагувати поле</string> <string name="edit_content_desc">Редагувати поле</string>
<string name="expand_content_desc">Розгорнути</string>
<string name="collapse_content_desc">Згорнути</string>
</resources> </resources>

View file

@ -639,5 +639,7 @@
<string name="show_less_content_desc">Show less</string> <string name="show_less_content_desc">Show less</string>
<string name="show_more_content_desc">Show more</string> <string name="show_more_content_desc">Show more</string>
<string name="edit_content_desc">Edit field</string> <string name="edit_content_desc">Edit field</string>
<string name="expand_content_desc">Expand</string>
<string name="collapse_content_desc">Collapse</string>
</resources> </resources>