footnotes support (as notes)
This commit is contained in:
parent
50f201d3c6
commit
8e7dcc0307
@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.aspectRatio
|
|||||||
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.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@ -27,10 +28,12 @@ import androidx.compose.foundation.lazy.LazyListState
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
@ -51,8 +54,10 @@ import androidx.compose.ui.graphics.CompositingStrategy
|
|||||||
import androidx.compose.ui.graphics.StrokeCap
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.PointerIcon
|
||||||
import androidx.compose.ui.input.pointer.PointerType
|
import androidx.compose.ui.input.pointer.PointerType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.input.pointer.pointerHoverIcon
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
@ -107,6 +112,7 @@ internal fun ContinuousBookReader(
|
|||||||
highlightedSentence: ReadAloudSentence? = null,
|
highlightedSentence: ReadAloudSentence? = null,
|
||||||
onUserScroll: () -> Unit = {},
|
onUserScroll: () -> Unit = {},
|
||||||
onImageOpen: (ViewedBookImage) -> Unit = {},
|
onImageOpen: (ViewedBookImage) -> Unit = {},
|
||||||
|
onNoteOpen: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val hyphenation = remember { HyphenationRegistry() }
|
val hyphenation = remember { HyphenationRegistry() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@ -204,6 +210,7 @@ internal fun ContinuousBookReader(
|
|||||||
textAlign = TextAlign.Justify,
|
textAlign = TextAlign.Justify,
|
||||||
modifier = Modifier.padding(start = (element.depth * 8).dp, end = 0.dp),
|
modifier = Modifier.padding(start = (element.depth * 8).dp, end = 0.dp),
|
||||||
onTextLayout = { textLineMetricsByItem[itemIndex] = it.toTextLineMetrics() },
|
onTextLayout = { textLineMetricsByItem[itemIndex] = it.toTextLineMetrics() },
|
||||||
|
onLinkOpen = onNoteOpen,
|
||||||
)
|
)
|
||||||
is ReaderElement.Subtitle -> ReaderText(
|
is ReaderElement.Subtitle -> ReaderText(
|
||||||
text = element.text,
|
text = element.text,
|
||||||
@ -214,6 +221,7 @@ internal fun ContinuousBookReader(
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp),
|
modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp),
|
||||||
onTextLayout = { textLineMetricsByItem[itemIndex] = it.toTextLineMetrics() },
|
onTextLayout = { textLineMetricsByItem[itemIndex] = it.toTextLineMetrics() },
|
||||||
|
onLinkOpen = onNoteOpen,
|
||||||
)
|
)
|
||||||
is ReaderElement.Epigraph -> ReaderEpigraph(
|
is ReaderElement.Epigraph -> ReaderEpigraph(
|
||||||
epigraph = element.epigraph,
|
epigraph = element.epigraph,
|
||||||
@ -221,6 +229,7 @@ internal fun ContinuousBookReader(
|
|||||||
hyphenation = hyphenation,
|
hyphenation = hyphenation,
|
||||||
highlightedRange = highlightedRange,
|
highlightedRange = highlightedRange,
|
||||||
depth = element.depth,
|
depth = element.depth,
|
||||||
|
onLinkOpen = onNoteOpen,
|
||||||
)
|
)
|
||||||
is ReaderElement.Cite -> ReaderCite(
|
is ReaderElement.Cite -> ReaderCite(
|
||||||
cite = element.cite,
|
cite = element.cite,
|
||||||
@ -228,6 +237,7 @@ internal fun ContinuousBookReader(
|
|||||||
hyphenation = hyphenation,
|
hyphenation = hyphenation,
|
||||||
highlightedRange = highlightedRange,
|
highlightedRange = highlightedRange,
|
||||||
depth = element.depth,
|
depth = element.depth,
|
||||||
|
onLinkOpen = onNoteOpen,
|
||||||
)
|
)
|
||||||
is ReaderElement.Poem -> ReaderPoem(
|
is ReaderElement.Poem -> ReaderPoem(
|
||||||
poem = element.poem,
|
poem = element.poem,
|
||||||
@ -235,6 +245,7 @@ internal fun ContinuousBookReader(
|
|||||||
hyphenation = hyphenation,
|
hyphenation = hyphenation,
|
||||||
highlightedRange = highlightedRange,
|
highlightedRange = highlightedRange,
|
||||||
depth = element.depth,
|
depth = element.depth,
|
||||||
|
onLinkOpen = onNoteOpen,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -246,7 +257,8 @@ private fun Modifier.pageTurnOnTouchTap(
|
|||||||
onPageUp: () -> Unit,
|
onPageUp: () -> Unit,
|
||||||
): Modifier = pointerInput(onPageDown, onPageUp) {
|
): Modifier = pointerInput(onPageDown, onPageUp) {
|
||||||
awaitEachGesture {
|
awaitEachGesture {
|
||||||
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Final)
|
||||||
|
if (down.isConsumed) return@awaitEachGesture
|
||||||
if (down.type != PointerType.Touch) {
|
if (down.type != PointerType.Touch) {
|
||||||
waitForUpOrCancellation(pass = PointerEventPass.Final)
|
waitForUpOrCancellation(pass = PointerEventPass.Final)
|
||||||
return@awaitEachGesture
|
return@awaitEachGesture
|
||||||
@ -258,7 +270,7 @@ private fun Modifier.pageTurnOnTouchTap(
|
|||||||
var cancelled = false
|
var cancelled = false
|
||||||
|
|
||||||
while (upPosition == null && !cancelled) {
|
while (upPosition == null && !cancelled) {
|
||||||
val event = awaitPointerEvent(pass = PointerEventPass.Initial)
|
val event = awaitPointerEvent(pass = PointerEventPass.Final)
|
||||||
val change = event.changes.firstOrNull { it.id == down.id }
|
val change = event.changes.firstOrNull { it.id == down.id }
|
||||||
if (change == null) {
|
if (change == null) {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
@ -345,6 +357,91 @@ private fun TextLayoutResult.toTextLineMetrics(): TextLineMetrics =
|
|||||||
lineBottoms = List(lineCount) { line -> getLineBottom(line).roundToInt() },
|
lineBottoms = List(lineCount) { line -> getLineBottom(line).roundToInt() },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun Fb2NoteReferenceDialog(
|
||||||
|
book: Fb2Book,
|
||||||
|
noteId: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onImageOpen: (ViewedBookImage) -> Unit,
|
||||||
|
onNoteOpen: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val note = book.notes[noteId]
|
||||||
|
val hyphenation = remember { HyphenationRegistry() }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(note?.title?.ifBlank { null } ?: strings.notes) },
|
||||||
|
text = {
|
||||||
|
if (note == null) {
|
||||||
|
Text(strings.noNotes, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(max = 520.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
items(note.readableBlocks()) { block ->
|
||||||
|
when (block) {
|
||||||
|
Fb2Block.EmptyLine -> Spacer(Modifier.height(12.dp))
|
||||||
|
is Fb2Block.Image -> BookImage(
|
||||||
|
book = book,
|
||||||
|
image = block.image,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
onOpen = onImageOpen,
|
||||||
|
)
|
||||||
|
is Fb2Block.Paragraph -> ReaderText(
|
||||||
|
text = block.content,
|
||||||
|
language = book.language,
|
||||||
|
hyphenation = hyphenation,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 22.sp),
|
||||||
|
textAlign = TextAlign.Start,
|
||||||
|
onLinkOpen = onNoteOpen,
|
||||||
|
)
|
||||||
|
is Fb2Block.Subtitle -> ReaderText(
|
||||||
|
text = block.content,
|
||||||
|
language = book.language,
|
||||||
|
hyphenation = hyphenation,
|
||||||
|
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
textAlign = TextAlign.Start,
|
||||||
|
onLinkOpen = onNoteOpen,
|
||||||
|
)
|
||||||
|
is Fb2Block.Epigraph -> ReaderEpigraph(
|
||||||
|
epigraph = block.epigraph,
|
||||||
|
language = book.language,
|
||||||
|
hyphenation = hyphenation,
|
||||||
|
highlightedRange = null,
|
||||||
|
depth = 0,
|
||||||
|
onLinkOpen = onNoteOpen,
|
||||||
|
)
|
||||||
|
is Fb2Block.Cite -> ReaderCite(
|
||||||
|
cite = block.cite,
|
||||||
|
language = book.language,
|
||||||
|
hyphenation = hyphenation,
|
||||||
|
highlightedRange = null,
|
||||||
|
depth = 0,
|
||||||
|
onLinkOpen = onNoteOpen,
|
||||||
|
)
|
||||||
|
is Fb2Block.Poem -> ReaderPoem(
|
||||||
|
poem = block.poem,
|
||||||
|
language = book.language,
|
||||||
|
hyphenation = hyphenation,
|
||||||
|
highlightedRange = null,
|
||||||
|
depth = 0,
|
||||||
|
onLinkOpen = onNoteOpen,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(strings.done)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DetailsPane(
|
private fun DetailsPane(
|
||||||
book: Fb2Book,
|
book: Fb2Book,
|
||||||
@ -499,6 +596,7 @@ private fun ReaderPane(book: Fb2Book, section: Fb2Section?, modifier: Modifier =
|
|||||||
hyphenation = hyphenation,
|
hyphenation = hyphenation,
|
||||||
highlightedRange = null,
|
highlightedRange = null,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
|
onLinkOpen = {},
|
||||||
)
|
)
|
||||||
is Fb2Block.Subtitle -> ReaderText(
|
is Fb2Block.Subtitle -> ReaderText(
|
||||||
text = block.content,
|
text = block.content,
|
||||||
@ -514,6 +612,7 @@ private fun ReaderPane(book: Fb2Book, section: Fb2Section?, modifier: Modifier =
|
|||||||
hyphenation = hyphenation,
|
hyphenation = hyphenation,
|
||||||
highlightedRange = null,
|
highlightedRange = null,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
|
onLinkOpen = {},
|
||||||
)
|
)
|
||||||
is Fb2Block.Cite -> ReaderCite(
|
is Fb2Block.Cite -> ReaderCite(
|
||||||
cite = block.cite,
|
cite = block.cite,
|
||||||
@ -521,6 +620,7 @@ private fun ReaderPane(book: Fb2Book, section: Fb2Section?, modifier: Modifier =
|
|||||||
hyphenation = hyphenation,
|
hyphenation = hyphenation,
|
||||||
highlightedRange = null,
|
highlightedRange = null,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
|
onLinkOpen = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -538,15 +638,30 @@ private fun ReaderText(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
highlightedRange: ReaderSentenceRange? = null,
|
highlightedRange: ReaderSentenceRange? = null,
|
||||||
onTextLayout: (TextLayoutResult) -> Unit = {},
|
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||||
|
onLinkOpen: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val highlightColor = MaterialTheme.colorScheme.secondaryContainer
|
val highlightColor = MaterialTheme.colorScheme.secondaryContainer
|
||||||
val annotatedText = text.toAnnotatedString(language, hyphenation, highlightedRange, highlightColor)
|
val annotatedText = text.toAnnotatedString(language, hyphenation, highlightedRange, highlightColor)
|
||||||
|
val hasLinks = annotatedText.hasReaderLinks()
|
||||||
val needsSoftHyphenPaintWorkaround = isDesktopPlatform
|
val needsSoftHyphenPaintWorkaround = isDesktopPlatform
|
||||||
var textLayout by remember(annotatedText) { mutableStateOf<TextLayoutResult?>(null) }
|
var textLayout by remember(annotatedText) { mutableStateOf<TextLayoutResult?>(null) }
|
||||||
val desktopHyphenColor = MaterialTheme.colorScheme.onSurface
|
val desktopHyphenColor = MaterialTheme.colorScheme.onSurface
|
||||||
val desktopHyphenGutter = 8.dp
|
val desktopHyphenGutter = 8.dp
|
||||||
val textModifier = modifier
|
val textModifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.then(
|
||||||
|
if (hasLinks) {
|
||||||
|
Modifier
|
||||||
|
.pointerHoverIcon(PointerIcon.Hand)
|
||||||
|
.readerLinkTapHandler(
|
||||||
|
annotatedText = annotatedText,
|
||||||
|
textLayout = textLayout,
|
||||||
|
onLinkOpen = onLinkOpen,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
.then(
|
.then(
|
||||||
if (needsSoftHyphenPaintWorkaround) {
|
if (needsSoftHyphenPaintWorkaround) {
|
||||||
Modifier.drawWithContent {
|
Modifier.drawWithContent {
|
||||||
@ -598,6 +713,7 @@ private fun ReaderPoem(
|
|||||||
highlightedRange: ReaderSentenceRange?,
|
highlightedRange: ReaderSentenceRange?,
|
||||||
depth: Int,
|
depth: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onLinkOpen: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val segments = remember(poem) { poem.readerSegments() }
|
val segments = remember(poem) { poem.readerSegments() }
|
||||||
Column(
|
Column(
|
||||||
@ -613,6 +729,7 @@ private fun ReaderPoem(
|
|||||||
highlightedRange = highlightedRange,
|
highlightedRange = highlightedRange,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
modifier = Modifier.padding(top = if (index == 0) 0.dp else 8.dp, bottom = 8.dp),
|
modifier = Modifier.padding(top = if (index == 0) 0.dp else 8.dp, bottom = 8.dp),
|
||||||
|
onLinkOpen = onLinkOpen,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
segments.forEach { segment ->
|
segments.forEach { segment ->
|
||||||
@ -636,6 +753,7 @@ private fun ReaderPoem(
|
|||||||
ReaderPoemSegmentKind.Verse -> Modifier.padding(start = 22.dp)
|
ReaderPoemSegmentKind.Verse -> Modifier.padding(start = 22.dp)
|
||||||
else -> Modifier
|
else -> Modifier
|
||||||
},
|
},
|
||||||
|
onLinkOpen = onLinkOpen,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -649,6 +767,7 @@ private fun ReaderEpigraph(
|
|||||||
highlightedRange: ReaderSentenceRange?,
|
highlightedRange: ReaderSentenceRange?,
|
||||||
depth: Int,
|
depth: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onLinkOpen: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@ -663,6 +782,7 @@ private fun ReaderEpigraph(
|
|||||||
hyphenation = hyphenation,
|
hyphenation = hyphenation,
|
||||||
highlightedRange = null,
|
highlightedRange = null,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
|
onLinkOpen = onLinkOpen,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
epigraph.textAuthors.forEach { author ->
|
epigraph.textAuthors.forEach { author ->
|
||||||
@ -674,6 +794,7 @@ private fun ReaderEpigraph(
|
|||||||
highlightedRange = null,
|
highlightedRange = null,
|
||||||
textAlign = TextAlign.End,
|
textAlign = TextAlign.End,
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
|
modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
|
||||||
|
onLinkOpen = onLinkOpen,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -687,6 +808,7 @@ private fun ReaderCite(
|
|||||||
highlightedRange: ReaderSentenceRange?,
|
highlightedRange: ReaderSentenceRange?,
|
||||||
depth: Int,
|
depth: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onLinkOpen: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@ -701,6 +823,7 @@ private fun ReaderCite(
|
|||||||
hyphenation = hyphenation,
|
hyphenation = hyphenation,
|
||||||
highlightedRange = null,
|
highlightedRange = null,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
|
onLinkOpen = onLinkOpen,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
cite.textAuthors.forEach { author ->
|
cite.textAuthors.forEach { author ->
|
||||||
@ -712,6 +835,7 @@ private fun ReaderCite(
|
|||||||
highlightedRange = null,
|
highlightedRange = null,
|
||||||
textAlign = TextAlign.End,
|
textAlign = TextAlign.End,
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
|
modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
|
||||||
|
onLinkOpen = onLinkOpen,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -724,6 +848,7 @@ private fun ReaderEpigraphBlock(
|
|||||||
hyphenation: HyphenationRegistry,
|
hyphenation: HyphenationRegistry,
|
||||||
highlightedRange: ReaderSentenceRange?,
|
highlightedRange: ReaderSentenceRange?,
|
||||||
depth: Int,
|
depth: Int,
|
||||||
|
onLinkOpen: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
when (block) {
|
when (block) {
|
||||||
Fb2EpigraphBlock.EmptyLine -> Spacer(Modifier.height(12.dp))
|
Fb2EpigraphBlock.EmptyLine -> Spacer(Modifier.height(12.dp))
|
||||||
@ -734,6 +859,7 @@ private fun ReaderEpigraphBlock(
|
|||||||
style = epigraphTextStyle(language),
|
style = epigraphTextStyle(language),
|
||||||
highlightedRange = highlightedRange,
|
highlightedRange = highlightedRange,
|
||||||
textAlign = TextAlign.Start,
|
textAlign = TextAlign.Start,
|
||||||
|
onLinkOpen = onLinkOpen,
|
||||||
)
|
)
|
||||||
is Fb2EpigraphBlock.Subtitle -> ReaderText(
|
is Fb2EpigraphBlock.Subtitle -> ReaderText(
|
||||||
text = block.content,
|
text = block.content,
|
||||||
@ -742,6 +868,7 @@ private fun ReaderEpigraphBlock(
|
|||||||
style = epigraphTextStyle(language).copy(fontWeight = FontWeight.SemiBold),
|
style = epigraphTextStyle(language).copy(fontWeight = FontWeight.SemiBold),
|
||||||
highlightedRange = highlightedRange,
|
highlightedRange = highlightedRange,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
|
onLinkOpen = onLinkOpen,
|
||||||
)
|
)
|
||||||
is Fb2EpigraphBlock.Poem -> ReaderPoem(
|
is Fb2EpigraphBlock.Poem -> ReaderPoem(
|
||||||
poem = block.poem,
|
poem = block.poem,
|
||||||
@ -749,6 +876,7 @@ private fun ReaderEpigraphBlock(
|
|||||||
hyphenation = hyphenation,
|
hyphenation = hyphenation,
|
||||||
highlightedRange = highlightedRange,
|
highlightedRange = highlightedRange,
|
||||||
depth = depth,
|
depth = depth,
|
||||||
|
onLinkOpen = onLinkOpen,
|
||||||
)
|
)
|
||||||
is Fb2EpigraphBlock.Cite -> ReaderCite(
|
is Fb2EpigraphBlock.Cite -> ReaderCite(
|
||||||
cite = block.cite,
|
cite = block.cite,
|
||||||
@ -756,6 +884,7 @@ private fun ReaderEpigraphBlock(
|
|||||||
hyphenation = hyphenation,
|
hyphenation = hyphenation,
|
||||||
highlightedRange = highlightedRange,
|
highlightedRange = highlightedRange,
|
||||||
depth = depth,
|
depth = depth,
|
||||||
|
onLinkOpen = onLinkOpen,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -945,6 +1074,38 @@ private fun Modifier.softImageEdgeFade(): Modifier =
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Modifier.readerLinkTapHandler(
|
||||||
|
annotatedText: AnnotatedString,
|
||||||
|
textLayout: TextLayoutResult?,
|
||||||
|
onLinkOpen: (String) -> Unit,
|
||||||
|
): Modifier = pointerInput(annotatedText, textLayout, onLinkOpen) {
|
||||||
|
awaitEachGesture {
|
||||||
|
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Main)
|
||||||
|
val link = textLayout?.readerLinkAt(annotatedText, down.position)
|
||||||
|
if (link == null) {
|
||||||
|
waitForUpOrCancellation(pass = PointerEventPass.Main)
|
||||||
|
return@awaitEachGesture
|
||||||
|
}
|
||||||
|
|
||||||
|
down.consume()
|
||||||
|
val up = waitForUpOrCancellation(pass = PointerEventPass.Main)
|
||||||
|
if (up != null) {
|
||||||
|
up.consume()
|
||||||
|
onLinkOpen(link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TextLayoutResult.readerLinkAt(text: AnnotatedString, position: Offset): String? {
|
||||||
|
val offset = getOffsetForPosition(position)
|
||||||
|
return text.getStringAnnotations(ReaderLinkAnnotationTag, offset, offset)
|
||||||
|
.firstOrNull()
|
||||||
|
?.item
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AnnotatedString.hasReaderLinks(): Boolean =
|
||||||
|
getStringAnnotations(ReaderLinkAnnotationTag, 0, length).isNotEmpty()
|
||||||
|
|
||||||
private fun Fb2Text.toAnnotatedString(
|
private fun Fb2Text.toAnnotatedString(
|
||||||
language: String?,
|
language: String?,
|
||||||
hyphenation: HyphenationRegistry,
|
hyphenation: HyphenationRegistry,
|
||||||
@ -954,14 +1115,18 @@ private fun Fb2Text.toAnnotatedString(
|
|||||||
buildAnnotatedString {
|
buildAnnotatedString {
|
||||||
var plainOffset = 0
|
var plainOffset = 0
|
||||||
spans.forEach { span ->
|
spans.forEach { span ->
|
||||||
|
val isLink = span.href.isNullOrBlank().not()
|
||||||
val spanStyle = SpanStyle(
|
val spanStyle = SpanStyle(
|
||||||
fontStyle = if (Fb2TextStyle.Emphasis in span.styles) FontStyle.Italic else null,
|
fontStyle = if (Fb2TextStyle.Emphasis in span.styles) FontStyle.Italic else null,
|
||||||
fontWeight = if (Fb2TextStyle.Strong in span.styles) FontWeight.Bold else null,
|
fontWeight = if (Fb2TextStyle.Strong in span.styles) FontWeight.Bold else null,
|
||||||
fontFamily = if (Fb2TextStyle.Code in span.styles) FontFamily.Monospace else null,
|
fontFamily = if (Fb2TextStyle.Code in span.styles) FontFamily.Monospace else null,
|
||||||
textDecoration = if (Fb2TextStyle.Strikethrough in span.styles) {
|
color = if (isLink) LinkTextColor else Color.Unspecified,
|
||||||
TextDecoration.LineThrough
|
textDecoration = when {
|
||||||
} else {
|
isLink && Fb2TextStyle.Strikethrough in span.styles ->
|
||||||
null
|
TextDecoration.combine(listOf(TextDecoration.Underline, TextDecoration.LineThrough))
|
||||||
|
isLink -> TextDecoration.Underline
|
||||||
|
Fb2TextStyle.Strikethrough in span.styles -> TextDecoration.LineThrough
|
||||||
|
else -> null
|
||||||
},
|
},
|
||||||
baselineShift = when {
|
baselineShift = when {
|
||||||
Fb2TextStyle.Superscript in span.styles -> BaselineShift.Superscript
|
Fb2TextStyle.Superscript in span.styles -> BaselineShift.Superscript
|
||||||
@ -969,9 +1134,13 @@ private fun Fb2Text.toAnnotatedString(
|
|||||||
else -> null
|
else -> null
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
span.href?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
pushStringAnnotation(ReaderLinkAnnotationTag, it)
|
||||||
|
}
|
||||||
withStyle(spanStyle) {
|
withStyle(spanStyle) {
|
||||||
appendWithHighlight(span.text, plainOffset, highlightedRange, highlightColor, language, hyphenation)
|
appendWithHighlight(span.text, plainOffset, highlightedRange, highlightColor, language, hyphenation)
|
||||||
}
|
}
|
||||||
|
if (isLink) pop()
|
||||||
plainOffset += span.text.length
|
plainOffset += span.text.length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1350,6 +1519,8 @@ private const val StarBreakPauseMillis = 1_200L
|
|||||||
private const val EllipsisPauseAfterMillis = 350L
|
private const val EllipsisPauseAfterMillis = 350L
|
||||||
private const val CombiningAcuteAccent = '\u0301'
|
private const val CombiningAcuteAccent = '\u0301'
|
||||||
private const val ReadAloudStressableLetters = "аеёиоуыэюяАЕЁИОУЫЭЮЯaeiouyAEIOUY"
|
private const val ReadAloudStressableLetters = "аеёиоуыэюяАЕЁИОУЫЭЮЯaeiouyAEIOUY"
|
||||||
|
private const val ReaderLinkAnnotationTag = "fb2-link"
|
||||||
|
private val LinkTextColor = Color(0xFF0B57D0)
|
||||||
|
|
||||||
private val ReadAloudHardcodedTextReplacements = listOf(
|
private val ReadAloudHardcodedTextReplacements = listOf(
|
||||||
ReadAloudTextReplacement(from = "Господа,", to = "Господ/а,", caseSensitive = true),
|
ReadAloudTextReplacement(from = "Господа,", to = "Господ/а,", caseSensitive = true),
|
||||||
|
|||||||
@ -87,6 +87,7 @@ internal fun BookView(
|
|||||||
var readAloudSettingsVisible by remember(fileId) { mutableStateOf(false) }
|
var readAloudSettingsVisible by remember(fileId) { mutableStateOf(false) }
|
||||||
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) }
|
||||||
|
var selectedNoteId by remember(fileId) { mutableStateOf<String?>(null) }
|
||||||
val readAloudState by ReadAloudPlatform.state.collectAsState()
|
val readAloudState by ReadAloudPlatform.state.collectAsState()
|
||||||
val readAloudSettings by ReadAloudPlatform.settingsState.collectAsState()
|
val readAloudSettings by ReadAloudPlatform.settingsState.collectAsState()
|
||||||
val platformName = getPlatform().name
|
val platformName = getPlatform().name
|
||||||
@ -306,6 +307,7 @@ internal fun BookView(
|
|||||||
highlightedSentence = highlightedSentence,
|
highlightedSentence = highlightedSentence,
|
||||||
onUserScroll = { userScrollGeneration += 1 },
|
onUserScroll = { userScrollGeneration += 1 },
|
||||||
onImageOpen = onImageOpen,
|
onImageOpen = onImageOpen,
|
||||||
|
onNoteOpen = { href -> selectedNoteId = href.removePrefix("#") },
|
||||||
)
|
)
|
||||||
if (readAloudPanelVisible && readAloudState.active) {
|
if (readAloudPanelVisible && readAloudState.active) {
|
||||||
ReadAloudPanel(
|
ReadAloudPanel(
|
||||||
@ -338,6 +340,16 @@ internal fun BookView(
|
|||||||
onVoiceSelected = { ReadAloudPlatform.selectVoice(it) },
|
onVoiceSelected = { ReadAloudPlatform.selectVoice(it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectedNoteId?.let { noteId ->
|
||||||
|
Fb2NoteReferenceDialog(
|
||||||
|
book = book,
|
||||||
|
noteId = noteId,
|
||||||
|
onDismiss = { selectedNoteId = null },
|
||||||
|
onImageOpen = onImageOpen,
|
||||||
|
onNoteOpen = { href -> selectedNoteId = href.removePrefix("#") },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@ -17,6 +17,7 @@ data class Fb2Book(
|
|||||||
val bodyImages: List<Fb2ImageRef> = emptyList(),
|
val bodyImages: List<Fb2ImageRef> = emptyList(),
|
||||||
val bodyEpigraphs: List<Fb2Epigraph> = emptyList(),
|
val bodyEpigraphs: List<Fb2Epigraph> = emptyList(),
|
||||||
val sections: List<Fb2Section> = emptyList(),
|
val sections: List<Fb2Section> = emptyList(),
|
||||||
|
val notes: Map<String, Fb2Section> = emptyMap(),
|
||||||
val binaries: List<Fb2Binary> = emptyList(),
|
val binaries: List<Fb2Binary> = emptyList(),
|
||||||
) {
|
) {
|
||||||
fun binaryFor(image: Fb2ImageRef): Fb2Binary? =
|
fun binaryFor(image: Fb2ImageRef): Fb2Binary? =
|
||||||
@ -47,6 +48,7 @@ data class Fb2DocumentInfo(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class Fb2Section(
|
data class Fb2Section(
|
||||||
|
val id: String? = null,
|
||||||
val title: String? = null,
|
val title: String? = null,
|
||||||
val paragraphs: List<String> = emptyList(),
|
val paragraphs: List<String> = emptyList(),
|
||||||
val images: List<Fb2ImageRef> = emptyList(),
|
val images: List<Fb2ImageRef> = emptyList(),
|
||||||
@ -124,6 +126,8 @@ data class Fb2Text(
|
|||||||
data class Fb2TextSpan(
|
data class Fb2TextSpan(
|
||||||
val text: String,
|
val text: String,
|
||||||
val styles: Set<Fb2TextStyle> = emptySet(),
|
val styles: Set<Fb2TextStyle> = emptySet(),
|
||||||
|
val href: String? = null,
|
||||||
|
val linkType: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class Fb2TextStyle {
|
enum class Fb2TextStyle {
|
||||||
|
|||||||
@ -13,8 +13,15 @@ internal object Fb2XmlMapper {
|
|||||||
?: throw Fb2ParseException("FB2 document is missing description/title-info")
|
?: throw Fb2ParseException("FB2 document is missing description/title-info")
|
||||||
val documentInfo = description.first("document-info")
|
val documentInfo = description.first("document-info")
|
||||||
|
|
||||||
val body = root.children("body").firstOrNull { it.attributes["name"] != "notes" }
|
val bodies = root.children("body")
|
||||||
|
val body = bodies.firstOrNull { it.attributes["name"] != "notes" }
|
||||||
?: root.first("body")
|
?: root.first("body")
|
||||||
|
val notes = bodies
|
||||||
|
.filter { it.attributes["name"] == "notes" }
|
||||||
|
.flatMap { it.children("section") }
|
||||||
|
.map(::sectionFrom)
|
||||||
|
.mapNotNull { section -> section.id?.let { it to section } }
|
||||||
|
.toMap()
|
||||||
|
|
||||||
return Fb2Book(
|
return Fb2Book(
|
||||||
title = titleInfo.firstText("book-title")
|
title = titleInfo.firstText("book-title")
|
||||||
@ -34,6 +41,7 @@ internal object Fb2XmlMapper {
|
|||||||
bodyImages = body?.children("image")?.mapNotNull(::imageRefFrom).orEmpty(),
|
bodyImages = body?.children("image")?.mapNotNull(::imageRefFrom).orEmpty(),
|
||||||
bodyEpigraphs = body?.children("epigraph")?.map(::epigraphFrom).orEmpty(),
|
bodyEpigraphs = body?.children("epigraph")?.map(::epigraphFrom).orEmpty(),
|
||||||
sections = body?.children("section")?.map(::sectionFrom).orEmpty(),
|
sections = body?.children("section")?.map(::sectionFrom).orEmpty(),
|
||||||
|
notes = notes,
|
||||||
binaries = root.children("binary").mapNotNull(::binaryFrom),
|
binaries = root.children("binary").mapNotNull(::binaryFrom),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -135,6 +143,7 @@ internal object Fb2XmlMapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Fb2Section(
|
return Fb2Section(
|
||||||
|
id = element.attributes["id"]?.takeIf { it.isNotBlank() },
|
||||||
title = element.first("title")?.text()?.ifBlank { null },
|
title = element.first("title")?.text()?.ifBlank { null },
|
||||||
paragraphs = blocks.filterIsInstance<Fb2Block.Paragraph>()
|
paragraphs = blocks.filterIsInstance<Fb2Block.Paragraph>()
|
||||||
.mapNotNull { it.content.plainText.ifBlank { null } },
|
.mapNotNull { it.content.plainText.ifBlank { null } },
|
||||||
@ -203,11 +212,23 @@ internal object Fb2XmlMapper {
|
|||||||
private fun textFrom(element: XmlElement): Fb2Text =
|
private fun textFrom(element: XmlElement): Fb2Text =
|
||||||
Fb2Text(spansFrom(element.nodes).mergeAdjacent().trimBoundaryWhitespace())
|
Fb2Text(spansFrom(element.nodes).mergeAdjacent().trimBoundaryWhitespace())
|
||||||
|
|
||||||
private fun spansFrom(nodes: List<XmlNode>, styles: Set<Fb2TextStyle> = emptySet()): List<Fb2TextSpan> =
|
private fun spansFrom(
|
||||||
|
nodes: List<XmlNode>,
|
||||||
|
styles: Set<Fb2TextStyle> = emptySet(),
|
||||||
|
href: String? = null,
|
||||||
|
linkType: String? = null,
|
||||||
|
): List<Fb2TextSpan> =
|
||||||
nodes.flatMap { node ->
|
nodes.flatMap { node ->
|
||||||
when (node) {
|
when (node) {
|
||||||
is XmlNode.TextNode -> listOfNotNull(
|
is XmlNode.TextNode -> listOfNotNull(
|
||||||
node.text.takeIf { it.isNotEmpty() }?.let { Fb2TextSpan(it, styles) }
|
node.text.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
Fb2TextSpan(
|
||||||
|
text = it,
|
||||||
|
styles = styles,
|
||||||
|
href = href,
|
||||||
|
linkType = linkType,
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
is XmlNode.ElementNode -> {
|
is XmlNode.ElementNode -> {
|
||||||
val element = node.element
|
val element = node.element
|
||||||
@ -220,7 +241,13 @@ internal object Fb2XmlMapper {
|
|||||||
"sub" -> styles + Fb2TextStyle.Subscript
|
"sub" -> styles + Fb2TextStyle.Subscript
|
||||||
else -> styles
|
else -> styles
|
||||||
}
|
}
|
||||||
spansFrom(element.nodes, nestedStyles)
|
val nestedHref = element.attributeLocal("href")
|
||||||
|
?.takeIf { element.localName == "a" && it.isNotBlank() }
|
||||||
|
?: href
|
||||||
|
val nestedLinkType = element.attributes["type"]
|
||||||
|
?.takeIf { element.localName == "a" && it.isNotBlank() }
|
||||||
|
?: linkType
|
||||||
|
spansFrom(element.nodes, nestedStyles, nestedHref, nestedLinkType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -230,7 +257,11 @@ internal object Fb2XmlMapper {
|
|||||||
val merged = mutableListOf<Fb2TextSpan>()
|
val merged = mutableListOf<Fb2TextSpan>()
|
||||||
forEach { span ->
|
forEach { span ->
|
||||||
val previous = merged.lastOrNull()
|
val previous = merged.lastOrNull()
|
||||||
if (previous != null && previous.styles == span.styles) {
|
if (previous != null &&
|
||||||
|
previous.styles == span.styles &&
|
||||||
|
previous.href == span.href &&
|
||||||
|
previous.linkType == span.linkType
|
||||||
|
) {
|
||||||
merged[merged.lastIndex] = previous.copy(text = previous.text + span.text)
|
merged[merged.lastIndex] = previous.copy(text = previous.text + span.text)
|
||||||
} else {
|
} else {
|
||||||
merged += span
|
merged += span
|
||||||
|
|||||||
@ -116,6 +116,18 @@ class Fb2FormatTest {
|
|||||||
assertEquals("Poem quote", (poemEpigraph.blocks.single() as Fb2EpigraphBlock.Paragraph).content.plainText)
|
assertEquals("Poem quote", (poemEpigraph.blocks.single() as Fb2EpigraphBlock.Paragraph).content.plainText)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesNoteLinksAndNotesBody() {
|
||||||
|
val book = Fb2Format.parseXml(notesXml)
|
||||||
|
val paragraph = book.sections.single().blocks.single() as Fb2Block.Paragraph
|
||||||
|
val link = paragraph.content.spans.single { it.href != null }
|
||||||
|
|
||||||
|
assertEquals("[1]", link.text)
|
||||||
|
assertEquals("#n_1", link.href)
|
||||||
|
assertEquals("note", link.linkType)
|
||||||
|
assertEquals("Note text.", (book.notes.getValue("n_1").blocks.single() as Fb2Block.Paragraph).content.plainText)
|
||||||
|
}
|
||||||
|
|
||||||
private val sampleXml = """
|
private val sampleXml = """
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:xlink="http://www.w3.org/1999/xlink">
|
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
@ -261,6 +273,35 @@ class Fb2FormatTest {
|
|||||||
</FictionBook>
|
</FictionBook>
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
|
private val notesXml = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:l="http://www.w3.org/1999/xlink">
|
||||||
|
<description>
|
||||||
|
<title-info>
|
||||||
|
<author><nickname>A</nickname></author>
|
||||||
|
<book-title>Notes</book-title>
|
||||||
|
<lang>en</lang>
|
||||||
|
</title-info>
|
||||||
|
<document-info>
|
||||||
|
<author><nickname>Toread</nickname></author>
|
||||||
|
<date>2026-05-12</date>
|
||||||
|
<id>notes</id>
|
||||||
|
<version>1.0</version>
|
||||||
|
</document-info>
|
||||||
|
</description>
|
||||||
|
<body>
|
||||||
|
<section>
|
||||||
|
<p>Main<a l:href="#n_1" type="note">[1]</a>.</p>
|
||||||
|
</section>
|
||||||
|
</body>
|
||||||
|
<body name="notes">
|
||||||
|
<section id="n_1">
|
||||||
|
<p>Note text.</p>
|
||||||
|
</section>
|
||||||
|
</body>
|
||||||
|
</FictionBook>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
private fun String.encodeWindows1251(): ByteArray =
|
private fun String.encodeWindows1251(): ByteArray =
|
||||||
ByteArray(length) { index ->
|
ByteArray(length) { index ->
|
||||||
val char = this[index]
|
val char = this[index]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user