🚀 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(
0, ReaderText.Chapter(
title = formattedLine.clearAllMarkdown()
title = formattedLine.clearAllMarkdown(),
nested = false
)
)
chapterAdded = true

View file

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

View file

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

View file

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

View file

@ -44,7 +44,8 @@ class TxtTextParser @Inject constructor(
if (!chapterAdded && line.clearAllMarkdown().isNotBlank()) {
readerText.add(
0, ReaderText.Chapter(
title = line.clearAllMarkdown()
title = line.clearAllMarkdown(),
nested = false
)
)
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.ui.graphics.ImageBitmap
import androidx.compose.ui.text.AnnotatedString
import java.util.UUID
@Immutable
sealed class ReaderText {
@Immutable
data class Chapter(val title: String) : ReaderText()
data class Chapter(
val id: UUID = UUID.randomUUID(),
val title: String,
val nested: Boolean
) : ReaderText()
@Immutable
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.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
@ -54,7 +55,8 @@ fun ModalDrawerSelectableItem(
.clickable(enabled = enabled) {
onClick()
}
.padding(horizontal = 18.dp, vertical = 18.dp)
.padding(horizontal = 18.dp, vertical = 18.dp),
verticalAlignment = Alignment.CenterVertically
) {
CompositionLocalProvider(
LocalContentColor provides if (selected) MaterialTheme.colorScheme.onSecondaryContainer

View file

@ -6,24 +6,32 @@
package ua.acclorite.book_story.presentation.reader
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
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.derivedStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
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.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.ModalDrawerSelectableItem
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.noRippleClickable
import ua.acclorite.book_story.ui.reader.ReaderEvent
import ua.acclorite.book_story.ui.theme.ExpandingTransition
@Composable
fun ReaderChaptersDrawer(
@ -34,15 +42,43 @@ fun ReaderChaptersDrawer(
scrollToChapter: (ReaderEvent.OnScrollToChapter) -> Unit,
dismissDrawer: (ReaderEvent.OnDismissDrawer) -> Unit
) {
val currentChapterIndex = remember(chapters, currentChapter) {
derivedStateOf {
chapters.indexOf(currentChapter).takeIf { it != -1 } ?: 0
val expandableChapters = remember(show, chapters, currentChapter) {
mutableStateListOf<ExpandableChapter>().apply {
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(
show = show,
startIndex = currentChapterIndex.value,
startIndex = chapters.indexOf(currentChapter).takeIf { it != -1 } ?: 0,
onDismissRequest = { dismissDrawer(ReaderEvent.OnDismissDrawer) },
header = {
ModalDrawerTitleItem(
@ -50,30 +86,88 @@ fun ReaderChaptersDrawer(
)
}
) {
itemsIndexed(chapters, key = { index, _ -> index }) { index, chapter ->
val selected = rememberSaveable(index, currentChapterIndex) {
index == currentChapterIndex.value
expandableChapters.forEach { expandableChapter ->
item {
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
)
)
}
}
}
ModalDrawerSelectableItem(
selected = selected,
onClick = {
scrollToChapter(
ReaderEvent.OnScrollToChapter(
chapter = chapter
)
)
dismissDrawer(ReaderEvent.OnDismissDrawer)
}
) {
StyledText(
text = chapter.title,
modifier = Modifier.weight(1f),
maxLines = 1
)
if (selected) {
Spacer(modifier = Modifier.width(18.dp))
StyledText(text = "${currentChapterProgress.calculateProgress(1)}%")
if (!expandableChapter.chapters.isNullOrEmpty()) {
items(expandableChapter.chapters) { chapter ->
ExpandingTransition(visible = expandableChapter.expanded) {
ModalDrawerSelectableItem(
selected = chapter.id == currentChapter?.id,
onClick = {
scrollToChapter(
ReaderEvent.OnScrollToChapter(
chapter = chapter
)
)
dismissDrawer(ReaderEvent.OnDismissDrawer)
}
) {
Spacer(modifier = Modifier.width(18.dp))
StyledText(
text = chapter.title,
modifier = Modifier.weight(1f),
maxLines = 1
)
if (chapter == currentChapter) {
Spacer(modifier = Modifier.width(18.dp))
StyledText(text = "${currentChapterProgress.calculateProgress(0)}%")
}
}
}
}
}
}

View file

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

View file

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

View file

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