footnotes support (as notes)
This commit is contained in:
1 parent
50f201d3c6
commit
8e7dcc0307
5 files changed
+270
-11
No files matched your search
@@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.aspectRatio
|
||||
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.width
|
||||
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.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
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.graphicsLayer
|
||||
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.pointerInput
|
||||
import androidx.compose.ui.input.pointer.pointerHoverIcon
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
@@ -107,6 +112,7 @@ internal fun ContinuousBookReader(
|
||||
highlightedSentence: ReadAloudSentence? = null,
|
||||
onUserScroll: () -> Unit = {},
|
||||
onImageOpen: (ViewedBookImage) -> Unit = {},
|
||||
onNoteOpen: (String) -> Unit = {},
|
||||
) {
|
||||
val hyphenation = remember { HyphenationRegistry() }
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -204,6 +210,7 @@ internal fun ContinuousBookReader(
|
||||
textAlign = TextAlign.Justify,
|
||||
modifier = Modifier.padding(start = (element.depth * 8).dp, end = 0.dp),
|
||||
onTextLayout = { textLineMetricsByItem[itemIndex] = it.toTextLineMetrics() },
|
||||
onLinkOpen = onNoteOpen,
|
||||
)
|
||||
is ReaderElement.Subtitle -> ReaderText(
|
||||
text = element.text,
|
||||
@@ -214,6 +221,7 @@ internal fun ContinuousBookReader(
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp),
|
||||
onTextLayout = { textLineMetricsByItem[itemIndex] = it.toTextLineMetrics() },
|
||||
onLinkOpen = onNoteOpen,
|
||||
)
|
||||
is ReaderElement.Epigraph -> ReaderEpigraph(
|
||||
epigraph = element.epigraph,
|
||||
@@ -221,6 +229,7 @@ internal fun ContinuousBookReader(
|
||||
hyphenation = hyphenation,
|
||||
highlightedRange = highlightedRange,
|
||||
depth = element.depth,
|
||||
onLinkOpen = onNoteOpen,
|
||||
)
|
||||
is ReaderElement.Cite -> ReaderCite(
|
||||
cite = element.cite,
|
||||
@@ -228,6 +237,7 @@ internal fun ContinuousBookReader(
|
||||
hyphenation = hyphenation,
|
||||
highlightedRange = highlightedRange,
|
||||
depth = element.depth,
|
||||
onLinkOpen = onNoteOpen,
|
||||
)
|
||||
is ReaderElement.Poem -> ReaderPoem(
|
||||
poem = element.poem,
|
||||
@@ -235,6 +245,7 @@ internal fun ContinuousBookReader(
|
||||
hyphenation = hyphenation,
|
||||
highlightedRange = highlightedRange,
|
||||
depth = element.depth,
|
||||
onLinkOpen = onNoteOpen,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -246,7 +257,8 @@ private fun Modifier.pageTurnOnTouchTap(
|
||||
onPageUp: () -> Unit,
|
||||
): Modifier = pointerInput(onPageDown, onPageUp) {
|
||||
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) {
|
||||
waitForUpOrCancellation(pass = PointerEventPass.Final)
|
||||
return@awaitEachGesture
|
||||
@@ -258,7 +270,7 @@ private fun Modifier.pageTurnOnTouchTap(
|
||||
var cancelled = false
|
||||
|
||||
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 }
|
||||
if (change == null) {
|
||||
cancelled = true
|
||||
@@ -345,6 +357,91 @@ private fun TextLayoutResult.toTextLineMetrics(): TextLineMetrics =
|
||||
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
|
||||
private fun DetailsPane(
|
||||
book: Fb2Book,
|
||||
@@ -499,6 +596,7 @@ private fun ReaderPane(book: Fb2Book, section: Fb2Section?, modifier: Modifier =
|
||||
hyphenation = hyphenation,
|
||||
highlightedRange = null,
|
||||
depth = 0,
|
||||
onLinkOpen = {},
|
||||
)
|
||||
is Fb2Block.Subtitle -> ReaderText(
|
||||
text = block.content,
|
||||
@@ -514,6 +612,7 @@ private fun ReaderPane(book: Fb2Book, section: Fb2Section?, modifier: Modifier =
|
||||
hyphenation = hyphenation,
|
||||
highlightedRange = null,
|
||||
depth = 0,
|
||||
onLinkOpen = {},
|
||||
)
|
||||
is Fb2Block.Cite -> ReaderCite(
|
||||
cite = block.cite,
|
||||
@@ -521,6 +620,7 @@ private fun ReaderPane(book: Fb2Book, section: Fb2Section?, modifier: Modifier =
|
||||
hyphenation = hyphenation,
|
||||
highlightedRange = null,
|
||||
depth = 0,
|
||||
onLinkOpen = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -538,15 +638,30 @@ private fun ReaderText(
|
||||
modifier: Modifier = Modifier,
|
||||
highlightedRange: ReaderSentenceRange? = null,
|
||||
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||
onLinkOpen: (String) -> Unit = {},
|
||||
) {
|
||||
val highlightColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
val annotatedText = text.toAnnotatedString(language, hyphenation, highlightedRange, highlightColor)
|
||||
val hasLinks = annotatedText.hasReaderLinks()
|
||||
val needsSoftHyphenPaintWorkaround = isDesktopPlatform
|
||||
var textLayout by remember(annotatedText) { mutableStateOf<TextLayoutResult?>(null) }
|
||||
val desktopHyphenColor = MaterialTheme.colorScheme.onSurface
|
||||
val desktopHyphenGutter = 8.dp
|
||||
val textModifier = modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (hasLinks) {
|
||||
Modifier
|
||||
.pointerHoverIcon(PointerIcon.Hand)
|
||||
.readerLinkTapHandler(
|
||||
annotatedText = annotatedText,
|
||||
textLayout = textLayout,
|
||||
onLinkOpen = onLinkOpen,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.then(
|
||||
if (needsSoftHyphenPaintWorkaround) {
|
||||
Modifier.drawWithContent {
|
||||
@@ -598,6 +713,7 @@ private fun ReaderPoem(
|
||||
highlightedRange: ReaderSentenceRange?,
|
||||
depth: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
onLinkOpen: (String) -> Unit = {},
|
||||
) {
|
||||
val segments = remember(poem) { poem.readerSegments() }
|
||||
Column(
|
||||
@@ -613,6 +729,7 @@ private fun ReaderPoem(
|
||||
highlightedRange = highlightedRange,
|
||||
depth = 0,
|
||||
modifier = Modifier.padding(top = if (index == 0) 0.dp else 8.dp, bottom = 8.dp),
|
||||
onLinkOpen = onLinkOpen,
|
||||
)
|
||||
}
|
||||
segments.forEach { segment ->
|
||||
@@ -636,6 +753,7 @@ private fun ReaderPoem(
|
||||
ReaderPoemSegmentKind.Verse -> Modifier.padding(start = 22.dp)
|
||||
else -> Modifier
|
||||
},
|
||||
onLinkOpen = onLinkOpen,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -649,6 +767,7 @@ private fun ReaderEpigraph(
|
||||
highlightedRange: ReaderSentenceRange?,
|
||||
depth: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
onLinkOpen: (String) -> Unit = {},
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
@@ -663,6 +782,7 @@ private fun ReaderEpigraph(
|
||||
hyphenation = hyphenation,
|
||||
highlightedRange = null,
|
||||
depth = 0,
|
||||
onLinkOpen = onLinkOpen,
|
||||
)
|
||||
}
|
||||
epigraph.textAuthors.forEach { author ->
|
||||
@@ -674,6 +794,7 @@ private fun ReaderEpigraph(
|
||||
highlightedRange = null,
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
|
||||
onLinkOpen = onLinkOpen,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -687,6 +808,7 @@ private fun ReaderCite(
|
||||
highlightedRange: ReaderSentenceRange?,
|
||||
depth: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
onLinkOpen: (String) -> Unit = {},
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
@@ -701,6 +823,7 @@ private fun ReaderCite(
|
||||
hyphenation = hyphenation,
|
||||
highlightedRange = null,
|
||||
depth = 0,
|
||||
onLinkOpen = onLinkOpen,
|
||||
)
|
||||
}
|
||||
cite.textAuthors.forEach { author ->
|
||||
@@ -712,6 +835,7 @@ private fun ReaderCite(
|
||||
highlightedRange = null,
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
|
||||
onLinkOpen = onLinkOpen,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -724,6 +848,7 @@ private fun ReaderEpigraphBlock(
|
||||
hyphenation: HyphenationRegistry,
|
||||
highlightedRange: ReaderSentenceRange?,
|
||||
depth: Int,
|
||||
onLinkOpen: (String) -> Unit,
|
||||
) {
|
||||
when (block) {
|
||||
Fb2EpigraphBlock.EmptyLine -> Spacer(Modifier.height(12.dp))
|
||||
@@ -734,6 +859,7 @@ private fun ReaderEpigraphBlock(
|
||||
style = epigraphTextStyle(language),
|
||||
highlightedRange = highlightedRange,
|
||||
textAlign = TextAlign.Start,
|
||||
onLinkOpen = onLinkOpen,
|
||||
)
|
||||
is Fb2EpigraphBlock.Subtitle -> ReaderText(
|
||||
text = block.content,
|
||||
@@ -742,6 +868,7 @@ private fun ReaderEpigraphBlock(
|
||||
style = epigraphTextStyle(language).copy(fontWeight = FontWeight.SemiBold),
|
||||
highlightedRange = highlightedRange,
|
||||
textAlign = TextAlign.Center,
|
||||
onLinkOpen = onLinkOpen,
|
||||
)
|
||||
is Fb2EpigraphBlock.Poem -> ReaderPoem(
|
||||
poem = block.poem,
|
||||
@@ -749,6 +876,7 @@ private fun ReaderEpigraphBlock(
|
||||
hyphenation = hyphenation,
|
||||
highlightedRange = highlightedRange,
|
||||
depth = depth,
|
||||
onLinkOpen = onLinkOpen,
|
||||
)
|
||||
is Fb2EpigraphBlock.Cite -> ReaderCite(
|
||||
cite = block.cite,
|
||||
@@ -756,6 +884,7 @@ private fun ReaderEpigraphBlock(
|
||||
hyphenation = hyphenation,
|
||||
highlightedRange = highlightedRange,
|
||||
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(
|
||||
language: String?,
|
||||
hyphenation: HyphenationRegistry,
|
||||
@@ -954,14 +1115,18 @@ private fun Fb2Text.toAnnotatedString(
|
||||
buildAnnotatedString {
|
||||
var plainOffset = 0
|
||||
spans.forEach { span ->
|
||||
val isLink = span.href.isNullOrBlank().not()
|
||||
val spanStyle = SpanStyle(
|
||||
fontStyle = if (Fb2TextStyle.Emphasis in span.styles) FontStyle.Italic else null,
|
||||
fontWeight = if (Fb2TextStyle.Strong in span.styles) FontWeight.Bold else null,
|
||||
fontFamily = if (Fb2TextStyle.Code in span.styles) FontFamily.Monospace else null,
|
||||
textDecoration = if (Fb2TextStyle.Strikethrough in span.styles) {
|
||||
TextDecoration.LineThrough
|
||||
} else {
|
||||
null
|
||||
color = if (isLink) LinkTextColor else Color.Unspecified,
|
||||
textDecoration = when {
|
||||
isLink && Fb2TextStyle.Strikethrough in span.styles ->
|
||||
TextDecoration.combine(listOf(TextDecoration.Underline, TextDecoration.LineThrough))
|
||||
isLink -> TextDecoration.Underline
|
||||
Fb2TextStyle.Strikethrough in span.styles -> TextDecoration.LineThrough
|
||||
else -> null
|
||||
},
|
||||
baselineShift = when {
|
||||
Fb2TextStyle.Superscript in span.styles -> BaselineShift.Superscript
|
||||
@@ -969,9 +1134,13 @@ private fun Fb2Text.toAnnotatedString(
|
||||
else -> null
|
||||
},
|
||||
)
|
||||
span.href?.takeIf { it.isNotBlank() }?.let {
|
||||
pushStringAnnotation(ReaderLinkAnnotationTag, it)
|
||||
}
|
||||
withStyle(spanStyle) {
|
||||
appendWithHighlight(span.text, plainOffset, highlightedRange, highlightColor, language, hyphenation)
|
||||
}
|
||||
if (isLink) pop()
|
||||
plainOffset += span.text.length
|
||||
}
|
||||
}
|
||||
@@ -1350,6 +1519,8 @@ private const val StarBreakPauseMillis = 1_200L
|
||||
private const val EllipsisPauseAfterMillis = 350L
|
||||
private const val CombiningAcuteAccent = '\u0301'
|
||||
private const val ReadAloudStressableLetters = "аеёиоуыэюяАЕЁИОУЫЭЮЯaeiouyAEIOUY"
|
||||
private const val ReaderLinkAnnotationTag = "fb2-link"
|
||||
private val LinkTextColor = Color(0xFF0B57D0)
|
||||
|
||||
private val ReadAloudHardcodedTextReplacements = listOf(
|
||||
ReadAloudTextReplacement(from = "Господа,", to = "Господ/а,", caseSensitive = true),
|
||||
|
||||
@@ -87,6 +87,7 @@ internal fun BookView(
|
||||
var readAloudSettingsVisible by remember(fileId) { mutableStateOf(false) }
|
||||
var readAloudResumeSentenceIndex by remember(fileId) { mutableStateOf<Int?>(null) }
|
||||
var userScrollGeneration by remember(fileId) { mutableStateOf(0) }
|
||||
var selectedNoteId by remember(fileId) { mutableStateOf<String?>(null) }
|
||||
val readAloudState by ReadAloudPlatform.state.collectAsState()
|
||||
val readAloudSettings by ReadAloudPlatform.settingsState.collectAsState()
|
||||
val platformName = getPlatform().name
|
||||
@@ -306,6 +307,7 @@ internal fun BookView(
|
||||
highlightedSentence = highlightedSentence,
|
||||
onUserScroll = { userScrollGeneration += 1 },
|
||||
onImageOpen = onImageOpen,
|
||||
onNoteOpen = { href -> selectedNoteId = href.removePrefix("#") },
|
||||
)
|
||||
if (readAloudPanelVisible && readAloudState.active) {
|
||||
ReadAloudPanel(
|
||||
@@ -338,6 +340,16 @@ internal fun BookView(
|
||||
onVoiceSelected = { ReadAloudPlatform.selectVoice(it) },
|
||||
)
|
||||
}
|
||||
|
||||
selectedNoteId?.let { noteId ->
|
||||
Fb2NoteReferenceDialog(
|
||||
book = book,
|
||||
noteId = noteId,
|
||||
onDismiss = { selectedNoteId = null },
|
||||
onImageOpen = onImageOpen,
|
||||
onNoteOpen = { href -> selectedNoteId = href.removePrefix("#") },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
Reference in new issue
Block a user