General improvements (#151)

* Updated folder synchronization logic and redesigned the file type filter dialog.

* Fixed CFI generation and navigation stability in the EPUB reader.

* Improved CFI scrolling and position calculation in the EPUB reader by implementing text node traversal using `TreeWalker`. This ensures accurate positioning and scrolling when a CFI offset spans multiple fragmented text nodes.

* fix fb2 multiline titles, retain footnotes, and prevent stream leaks

- Fix FB2 titles with multiple paragraphs by inserting breaks/spaces
- Prevent resource leaks by properly closing InputStreams in all importers
- Retain "notes" and "comments" sections in FB2 instead of skipping them
- Add support for FB2 poem, stanza, cite, and link tags

* Implement persistence for zoom and pan states when pan lock is enabled in the PDF reader.

* Added `FileTypeBadge` to home and library screens

* Bump version to 1.0.41(42)
This commit is contained in:
Aryan 2026-04-05 10:36:44 +05:30 committed by GitHub
parent 65e0570d0e
commit 26692d2c05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 747 additions and 664 deletions

View file

@ -205,6 +205,9 @@ class FolderSyncWorker(
val file = fileQueue.removeAt(0)
if (file.isDirectory) {
if (file.name?.startsWith(".") == true) {
continue
}
file.listFiles().let { fileQueue.addAll(it) }
} else if (file.isFile) {
val name = file.name ?: ""

View file

@ -630,21 +630,15 @@ fun RecentFileCard(
.fallback(placeholder).crossfade(true).build(),
contentDescription = item.displayName,
contentScale = ContentScale.Crop,
modifier = Modifier
.height(160.dp)
.fillMaxWidth(),
modifier = Modifier.height(160.dp).fillMaxWidth(),
)
if (item.sourceFolderUri != null) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.background(
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).background(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = CircleShape
)
.padding(4.dp)
).padding(4.dp)
) {
Icon(
imageVector = Icons.Default.Folder,
@ -658,14 +652,10 @@ fun RecentFileCard(
val isOpdsStream = item.uriString?.startsWith("opds-pse://") == true
if (isOpdsStream) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.background(
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).background(
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = CircleShape
)
.padding(4.dp)
).padding(4.dp)
) {
Icon(
imageVector = Icons.Default.Cloud,
@ -678,14 +668,10 @@ fun RecentFileCard(
if (isPinned) {
Box(
modifier = Modifier
.align(Alignment.TopStart)
.padding(8.dp)
.background(
modifier = Modifier.align(Alignment.TopStart).padding(8.dp).background(
color = MaterialTheme.colorScheme.primaryContainer,
shape = CircleShape
)
.padding(4.dp)
).padding(4.dp)
) {
Icon(
imageVector = Icons.Default.PushPin,
@ -715,6 +701,11 @@ fun RecentFileCard(
}
}
}
Box(
modifier = Modifier.align(Alignment.BottomEnd).padding(8.dp)
) {
FileTypeBadge(type = item.type, overlay = true)
}
}
Column(

View file

@ -1404,13 +1404,25 @@ private fun LibraryListItem(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
item.progressPercentage?.let {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "${it.toInt()}% complete",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
FileTypeBadge(type = item.type, overlay = false)
item.progressPercentage?.let {
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "${it.toInt()}% complete",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
@ -1743,6 +1755,7 @@ private fun FolderCard(
}
}
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
@Composable
private fun EditFolderFiltersDialog(
folder: SyncedFolder,
@ -1753,44 +1766,74 @@ private fun EditFolderFiltersDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.filter_file_types)) },
text = {
title = {
Column {
Text(
stringResource(R.string.filter_file_types_desc),
style = MaterialTheme.typography.bodyMedium
text = stringResource(R.string.filter_file_types),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
FileType.entries.forEach { type ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
selectedTypes = if (type in selectedTypes) selectedTypes - type else selectedTypes + type
}
.padding(vertical = 4.dp)
) {
androidx.compose.material3.Checkbox(
checked = type in selectedTypes,
onCheckedChange = { checked ->
selectedTypes = if (checked) selectedTypes + type else selectedTypes - type
}
Text(
text = stringResource(R.string.filter_file_types_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
text = {
Column(modifier = Modifier.fillMaxWidth()) {
HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp))
androidx.compose.foundation.layout.FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
FileType.entries.forEach { type ->
val isSelected = type in selectedTypes
FilterChip(
selected = isSelected,
onClick = {
selectedTypes = if (isSelected) {
selectedTypes - type
} else {
selectedTypes + type
}
},
label = {
Text(
text = type.name,
style = MaterialTheme.typography.labelLarge
)
},
leadingIcon = if (isSelected) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
} else null,
shape = MaterialTheme.shapes.medium
)
Spacer(modifier = Modifier.width(8.dp))
Text(type.name)
}
}
}
},
confirmButton = {
TextButton(
androidx.compose.material3.Button(
onClick = { onConfirm(selectedTypes) },
enabled = selectedTypes.isNotEmpty()
) { Text(stringResource(R.string.action_save)) }
enabled = selectedTypes.isNotEmpty(),
shape = MaterialTheme.shapes.medium
) {
Text(stringResource(R.string.action_save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
}
)
}

View file

@ -765,7 +765,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
remoteConfigRepository.init()
if (_internalState.value.syncedFolders.isNotEmpty()) {
syncFolderMetadata()
triggerFolderSyncWorker(metadataOnly = false, showFeedback = false)
}
sweepOrphanedCache()

View file

@ -21,263 +21,305 @@ class Fb2Parser(private val context: Context) {
bookId: String,
originalBookNameHint: String,
parseContent: Boolean = true
): EpubBook {
): EpubBook = withContext(Dispatchers.IO) {
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
if (!exists()) mkdirs()
}
var streamToParse = inputStream
if (originalBookNameHint.endsWith(".zip", ignoreCase = true)) {
val zis = ZipInputStream(inputStream)
var entry = zis.nextEntry
while (entry != null) {
if (entry.name.endsWith(".fb2", ignoreCase = true)) {
break
try {
if (originalBookNameHint.endsWith(".zip", ignoreCase = true)) {
val zis = ZipInputStream(inputStream)
var entry = zis.nextEntry
while (entry != null) {
if (entry.name.endsWith(".fb2", ignoreCase = true)) {
break
}
entry = zis.nextEntry
}
if (entry != null) {
streamToParse = zis
} else {
throw Exception("No .fb2 file found inside the ZIP archive.")
}
entry = zis.nextEntry
}
if (entry != null) {
streamToParse = zis
} else {
throw Exception("No .fb2 file found inside the ZIP archive.")
}
}
val parser = Xml.newPullParser()
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
parser.setInput(streamToParse, null)
val parser = Xml.newPullParser()
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
parser.setInput(streamToParse, null)
var title = originalBookNameHint.substringBeforeLast(".")
var author = "Unknown"
var coverImageId: String? = null
var coverBytes: ByteArray? = null
var title = originalBookNameHint.substringBeforeLast(".")
var author = "Unknown"
var coverImageId: String? = null
var coverBytes: ByteArray? = null
val chapters = mutableListOf<EpubChapter>()
val images = mutableListOf<EpubImage>() // Keep track of extracted images
val chapters = mutableListOf<EpubChapter>()
val images = mutableListOf<EpubImage>() // Keep track of extracted images
var currentChapterHtml = StringBuilder()
var currentChapterTitle = "Chapter"
var chapterCount = 0
var inSection = false
var inBody = false
var inTitle = false
var skipElement = false
val titleBuilder = java.lang.StringBuilder() // Buffer to handle <p> tags inside <title>
var currentChapterHtml = StringBuilder()
var currentChapterTitle = "Chapter 1"
var chapterCount = 0
var inSection = false
var inBody = false
var inTitle = false
val titleBuilder = java.lang.StringBuilder() // Buffer to handle <p> tags inside <title>
val cssStyle = """
body { font-family: sans-serif; line-height: 1.6; padding: 1em; max-width: 800px; margin: 0 auto; }
p { margin-bottom: 1em; text-indent: 1.5em; text-align: justify; }
h1, h2, h3, h4 { text-align: center; margin-top: 1.5em; margin-bottom: 1em; }
.empty-line { height: 1.5em; }
img { max-width: 100%; height: auto; display: block; margin: 1em auto; }
.epigraph { margin-left: 2em; font-style: italic; margin-bottom: 1.5em; }
""".trimIndent()
fun saveChapter() {
if (!parseContent || currentChapterHtml.isEmpty()) return
chapterCount++
val fileName = "chapter_$chapterCount.html"
val file = File(extractionDir, fileName)
val fullHtml = """
<!DOCTYPE html>
<html>
<head>
<title>${currentChapterTitle.replace("\"", "&quot;")}</title>
<style>${cssStyle}</style>
</head>
<body>
$currentChapterHtml
</body>
</html>
val cssStyle = """
body { font-family: sans-serif; line-height: 1.6; padding: 1em; max-width: 800px; margin: 0 auto; }
p { margin-bottom: 1em; text-indent: 1.5em; text-align: justify; }
h1, h2, h3, h4 { text-align: center; margin-top: 1.5em; margin-bottom: 1em; }
.empty-line { height: 1.5em; }
img { max-width: 100%; height: auto; display: block; margin: 1em auto; }
.epigraph { margin-left: 2em; font-style: italic; margin-bottom: 1.5em; }
.cite { border-left: 4px solid currentColor; padding-left: 1em; margin-left: 0; opacity: 0.8; font-style: italic; }
.poem { margin: 1.5em 0; padding-left: 2em; }
.stanza { margin-bottom: 1em; }
""".trimIndent()
FileOutputStream(file).use { it.write(fullHtml.toByteArray()) }
val plainText = Jsoup.parse(fullHtml).text()
fun saveChapter() {
if (!parseContent || currentChapterHtml.isEmpty()) return
chapterCount++
val fileName = "chapter_$chapterCount.html"
val file = File(extractionDir, fileName)
chapters.add(
EpubChapter(
chapterId = "${bookId}_${chapterCount}",
absPath = fileName,
title = currentChapterTitle,
htmlFilePath = fileName,
plainTextContent = plainText,
htmlContent = "",
depth = 0,
isInToc = true
val fullHtml = """
<!DOCTYPE html>
<html>
<head>
<title>${currentChapterTitle.replace("\"", "&quot;")}</title>
<style>${cssStyle}</style>
</head>
<body>
$currentChapterHtml
</body>
</html>
""".trimIndent()
FileOutputStream(file).use { it.write(fullHtml.toByteArray()) }
val plainText = Jsoup.parse(fullHtml).text()
chapters.add(
EpubChapter(
chapterId = "${bookId}_${chapterCount}",
absPath = fileName,
title = currentChapterTitle,
htmlFilePath = fileName,
plainTextContent = plainText,
htmlContent = "",
depth = 0,
isInToc = true
)
)
)
currentChapterHtml.clear()
currentChapterTitle = "Chapter ${chapterCount + 1}"
}
currentChapterHtml.clear()
currentChapterTitle = "Chapter ${chapterCount + 1}"
}
var eventType = parser.eventType
var eventType = parser.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
when (eventType) {
XmlPullParser.START_TAG -> {
val name = parser.name.lowercase()
when (name) {
"book-title" -> {
title = parser.nextText().trim()
}
"first-name", "last-name", "middle-name" -> {
val namePart = parser.nextText().trim()
if (namePart.isNotBlank()) {
if (author == "Unknown") author = namePart else author += " $namePart"
while (eventType != XmlPullParser.END_DOCUMENT) {
when (eventType) {
XmlPullParser.START_TAG -> {
val name = parser.name.lowercase()
when (name) {
"book-title" -> {
title = parser.nextText().trim()
}
}
"body" -> {
val nameAttr = parser.getAttributeValue(null, "name")
if (nameAttr == "notes" || nameAttr == "comments") {
skipElement = true
} else {
"first-name", "last-name", "middle-name" -> {
val namePart = parser.nextText().trim()
if (namePart.isNotBlank()) {
if (author == "Unknown") author = namePart else author += " $namePart"
}
}
"body" -> {
inBody = true
}
}
"section" -> {
if (inBody && !skipElement) {
if (currentChapterHtml.isNotBlank()) {
saveChapter()
"section" -> {
if (inBody) {
if (currentChapterHtml.isNotBlank()) {
saveChapter()
}
inSection = true
}
inSection = true
}
}
"title" -> {
if (inSection && currentChapterHtml.isEmpty()) {
inTitle = true
titleBuilder.clear()
"title" -> {
if (inSection && currentChapterHtml.isEmpty()) {
inTitle = true
titleBuilder.clear()
}
currentChapterHtml.append("<h2>")
}
currentChapterHtml.append("<h2>")
}
"p" -> if (!inTitle) currentChapterHtml.append("<p>")
"v" -> if (!inTitle) currentChapterHtml.append("<p style='text-indent: 0;'>")
"subtitle" -> currentChapterHtml.append("<h3>")
"empty-line" -> currentChapterHtml.append("<div class='empty-line'></div>")
"strong" -> currentChapterHtml.append("<b>")
"emphasis" -> currentChapterHtml.append("<i>")
"strikethrough" -> currentChapterHtml.append("<s>")
"sup" -> currentChapterHtml.append("<sup>")
"sub" -> currentChapterHtml.append("<sub>")
"epigraph" -> currentChapterHtml.append("<div class='epigraph'>")
"image" -> {
// Safely extract href checking all possible namespace stripped versions
val href = parser.getAttributeValue(null, "l:href")
?: parser.getAttributeValue(null, "xlink:href")
?: parser.getAttributeValue("http://www.w3.org/1999/xlink", "href")
?: parser.getAttributeValue(null, "href")
"p" -> {
if (!inTitle) {
currentChapterHtml.append("<p>")
} else if (titleBuilder.isNotEmpty()) {
titleBuilder.append(" ")
currentChapterHtml.append("<br>")
}
}
"v" -> {
if (!inTitle) {
currentChapterHtml.append("<p style='text-indent: 0; text-align: left;'>")
} else if (titleBuilder.isNotEmpty()) {
titleBuilder.append(" ")
currentChapterHtml.append("<br>")
}
}
"subtitle" -> currentChapterHtml.append("<h3>")
"empty-line" -> {
if (!inTitle) {
currentChapterHtml.append("<div class='empty-line'></div>")
} else if (titleBuilder.isNotEmpty()) {
titleBuilder.append(" ")
currentChapterHtml.append("<br>")
}
}
"strong" -> currentChapterHtml.append("<b>")
"emphasis" -> currentChapterHtml.append("<i>")
"strikethrough" -> currentChapterHtml.append("<s>")
"sup" -> currentChapterHtml.append("<sup>")
"sub" -> currentChapterHtml.append("<sub>")
"epigraph" -> currentChapterHtml.append("<div class='epigraph'>")
"cite" -> currentChapterHtml.append("<blockquote class='cite'>")
"poem" -> currentChapterHtml.append("<div class='poem'>")
"stanza" -> currentChapterHtml.append("<div class='stanza'>")
"a" -> {
val href = parser.getAttributeValue(null, "l:href")
?: parser.getAttributeValue(null, "xlink:href")
?: parser.getAttributeValue("http://www.w3.org/1999/xlink", "href")
if (!inTitle) {
if (href != null) {
currentChapterHtml.append("<a href=\"$href\">")
} else {
currentChapterHtml.append("<a>")
}
}
}
"image" -> {
// Safely extract href checking all possible namespace stripped versions
val href = parser.getAttributeValue(null, "l:href")
?: parser.getAttributeValue(null, "xlink:href")
?: parser.getAttributeValue("http://www.w3.org/1999/xlink", "href")
?: parser.getAttributeValue(null, "href")
if (href != null) {
val id = href.removePrefix("#")
if (!inBody) {
coverImageId = id
} else {
currentChapterHtml.append("<img src=\"$id\" />")
if (href != null) {
val id = href.removePrefix("#")
if (!inBody) {
if (coverImageId == null) coverImageId = id
} else {
currentChapterHtml.append("<img src=\"$id\" />")
}
}
}
}
"binary" -> {
val id = parser.getAttributeValue(null, "id")
if (id != null) {
val base64Data = parser.nextText()
try {
val bytes = Base64.decode(base64Data, Base64.DEFAULT)
if (parseContent) {
val imgFile = File(extractionDir, id)
withContext(Dispatchers.IO) {
"binary" -> {
val id = parser.getAttributeValue(null, "id")
if (id != null) {
val base64Data = parser.nextText()
try {
val bytes = Base64.decode(base64Data, Base64.DEFAULT)
if (parseContent) {
val imgFile = File(extractionDir, id)
FileOutputStream(imgFile).use { it.write(bytes) }
}
}
images.add(EpubImage(absPath = id))
images.add(EpubImage(absPath = id))
if (id == coverImageId || (coverImageId == null && id.contains("cover", ignoreCase = true))) {
coverBytes = bytes
coverImageId = id
if (id == coverImageId || (coverImageId == null && id.contains("cover", ignoreCase = true))) {
coverBytes = bytes
coverImageId = id
}
} catch (e: Exception) {
Timber.e(e, "Failed to decode binary image $id")
}
} catch (e: Exception) {
Timber.e(e, "Failed to decode binary image $id")
}
}
}
}
}
XmlPullParser.TEXT -> {
val text = parser.text?.replace("&", "&amp;")?.replace("<", "&lt;")?.replace(">", "&gt;")
if (!text.isNullOrBlank()) {
if (inTitle) {
titleBuilder.append(text) // Append to buffer since it could be split by <p> tags
currentChapterHtml.append(text)
} else if (inBody && !skipElement) {
currentChapterHtml.append(text)
}
}
}
XmlPullParser.END_TAG -> {
val name = parser.name.lowercase()
when (name) {
"body" -> {
skipElement = false
inBody = false
}
"title" -> {
XmlPullParser.TEXT -> {
val text = parser.text?.replace("&", "&amp;")?.replace("<", "&lt;")?.replace(">", "&gt;")
if (!text.isNullOrBlank()) {
if (inTitle) {
currentChapterTitle = titleBuilder.toString().trim()
inTitle = false
titleBuilder.append(text) // Append to buffer since it could be split by <p> tags
currentChapterHtml.append(text)
} else if (inBody) {
currentChapterHtml.append(text)
}
currentChapterHtml.append("</h2>\n")
}
"p", "v" -> if (!inTitle) currentChapterHtml.append("</p>\n")
"subtitle" -> currentChapterHtml.append("</h3>\n")
"strong" -> currentChapterHtml.append("</b>")
"emphasis" -> currentChapterHtml.append("</i>")
"strikethrough" -> currentChapterHtml.append("</s>")
"sup" -> currentChapterHtml.append("</sup>")
"sub" -> currentChapterHtml.append("</sub>")
"epigraph" -> currentChapterHtml.append("</div>\n")
}
XmlPullParser.END_TAG -> {
val name = parser.name.lowercase()
when (name) {
"body" -> {
inBody = false
}
"title" -> {
if (inTitle) {
currentChapterTitle = titleBuilder.toString().replace("\\s+".toRegex(), " ").trim()
if (currentChapterTitle.isBlank()) {
currentChapterTitle = "Chapter ${chapterCount + 1}"
}
inTitle = false
}
currentChapterHtml.append("</h2>\n")
}
"p", "v" -> if (!inTitle) currentChapterHtml.append("</p>\n")
"subtitle" -> currentChapterHtml.append("</h3>\n")
"strong" -> currentChapterHtml.append("</b>")
"emphasis" -> currentChapterHtml.append("</i>")
"strikethrough" -> currentChapterHtml.append("</s>")
"sup" -> currentChapterHtml.append("</sup>")
"sub" -> currentChapterHtml.append("</sub>")
"epigraph" -> currentChapterHtml.append("</div>\n")
"cite" -> currentChapterHtml.append("</blockquote>\n")
"poem", "stanza" -> currentChapterHtml.append("</div>\n")
"a" -> if (!inTitle) currentChapterHtml.append("</a>")
}
}
}
if (eventType != XmlPullParser.END_DOCUMENT) {
eventType = parser.next()
}
}
// Calling nextText() moves the parser directly to END_TAG.
// We ensure we don't accidentally read past the EOF.
if (eventType != XmlPullParser.END_DOCUMENT) {
eventType = parser.next()
saveChapter()
if (chapters.isEmpty() && parseContent) {
if (currentChapterHtml.isNotBlank()) {
saveChapter()
} else {
throw Exception("No valid content found in FB2 file.")
}
}
}
saveChapter()
if (chapters.isEmpty() && parseContent) {
if (currentChapterHtml.isNotBlank()) {
saveChapter()
} else {
throw Exception("No valid content found in FB2 file.")
val coverBitmap = coverBytes?.let {
try {
BitmapFactory.decodeByteArray(it, 0, it.size)
} catch (e: Exception) {
Timber.e(e, "Failed to decode cover bitmap for FB2")
null
}
}
}
val coverBitmap = coverBytes?.let {
return@withContext EpubBook(
fileName = originalBookNameHint,
title = title,
author = author,
language = "en",
coverImage = coverBitmap,
chapters = chapters,
chaptersForPagination = chapters,
images = images,
pageList = emptyList(),
tableOfContents = emptyList(),
extractionBasePath = extractionDir.absolutePath,
css = emptyMap()
)
} finally {
try {
BitmapFactory.decodeByteArray(it, 0, it.size)
streamToParse.close()
} catch (e: Exception) {
Timber.e(e, "Failed to decode cover bitmap for FB2")
null
Timber.e(e, "Error closing FB2 stream")
}
}
return EpubBook(
fileName = originalBookNameHint,
title = title,
author = author,
language = "en",
coverImage = coverBitmap,
chapters = chapters,
chaptersForPagination = chapters,
images = images, // Extracted images attached!
pageList = emptyList(),
tableOfContents = emptyList(),
extractionBasePath = extractionDir.absolutePath,
css = emptyMap()
)
}
}

View file

@ -306,41 +306,42 @@ class SingleFileImporter(private val context: Context) {
.replace(">", "&gt;")
}
val reader = inputStream.bufferedReader()
var inParagraph = false
while (true) {
val line = reader.readLine()
if (line == null) {
if (inParagraph) {
currentChapterContent.append("</p>\n")
}
break
}
val trimmed = line.trim()
if (trimmed.isEmpty()) {
if (inParagraph) {
currentChapterContent.append("</p>\n")
inParagraph = false
inputStream.bufferedReader().use { reader ->
while (true) {
val line = reader.readLine()
if (line == null) {
if (inParagraph) {
currentChapterContent.append("</p>\n")
}
break
}
if (currentChapterContent.length >= chapterTargetSize) {
flushChapter()
}
} else {
if (!inParagraph) {
currentChapterContent.append("<p>")
inParagraph = true
val trimmed = line.trim()
if (trimmed.isEmpty()) {
if (inParagraph) {
currentChapterContent.append("</p>\n")
inParagraph = false
}
if (currentChapterContent.length >= chapterTargetSize) {
flushChapter()
}
} else {
currentChapterContent.append(" ")
}
currentChapterContent.append(escapeHtml(trimmed))
if (!inParagraph) {
currentChapterContent.append("<p>")
inParagraph = true
} else {
currentChapterContent.append(" ")
}
currentChapterContent.append(escapeHtml(trimmed))
if (currentChapterContent.length >= chapterTargetSize * 2) {
currentChapterContent.append("</p>\n")
flushChapter()
inParagraph = false
if (currentChapterContent.length >= chapterTargetSize * 2) {
currentChapterContent.append("</p>\n")
flushChapter()
inParagraph = false
}
}
}
}
@ -590,9 +591,10 @@ class SingleFileImporter(private val context: Context) {
val parseStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[DOCX] parseDocx START | file=$originalBookNameHint")
val converter = DocumentConverter()
val result = converter.convertToHtml(inputStream)
val htmlContent = result.value ?: ""
val htmlContent = inputStream.use { stream ->
val converter = DocumentConverter()
converter.convertToHtml(stream).value ?: ""
}
Timber.tag("FileOpenPerf").d("[DOCX] parseDocx: mammoth conversion done | elapsed=${System.currentTimeMillis() - parseStart}ms")

View file

@ -560,6 +560,11 @@ fun ChapterWebView(
)
}
message.startsWith("PosSaveDiag:") -> {
Timber.tag("PosSaveDiag")
.d("JS -> ${message.substringAfter("PosSaveDiag: ")}")
}
message.startsWith("HIGHLIGHT_DEBUG:") -> {
Timber.d(
"JS -> ${message.substringAfter("HIGHLIGHT_DEBUG: ")}"

View file

@ -483,7 +483,7 @@ fun EpubReaderHost(
var showJustifyWarningDialog by remember { mutableStateOf(false) }
var isNavigatingByToc by remember { mutableStateOf(false) }
var chunkTargetOverride by remember { mutableStateOf<Int?>(null) }
var chunkTargetOverride by remember { mutableStateOf(initialLocator?.let { it.blockIndex / 20 }) }
val snackbarHostState = remember { SnackbarHostState() }
@ -957,6 +957,8 @@ fun EpubReaderHost(
skipChapterRequest = false
if (ttsShouldStartOnChapterLoad && currentChapterIndex < chapters.size - 1) {
Timber.d("Executing skip chapter request for continuous TTS.")
currentScrollYPosition = 0
currentScrollHeightValue = 0
currentChapterIndex++
} else {
ttsShouldStartOnChapterLoad = false
@ -1212,6 +1214,7 @@ fun EpubReaderHost(
initialScrollTargetForChapter = ChapterScrollPosition.START
cfiToLoad = null
currentScrollYPosition = 0
currentScrollHeightValue = 0
currentChapterIndex = nextIndex
},
onToggleTtsStartOnLoad = { shouldStart -> ttsShouldStartOnChapterLoad = shouldStart },
@ -1362,7 +1365,17 @@ fun EpubReaderHost(
chapterHead = result.head
chapterChunks = result.chunks
isChapterParsing = false
loadUpToChunkIndex = result.startChunkIndex
if (initialScrollTargetForChapter == ChapterScrollPosition.END) {
loadUpToChunkIndex = max(0, result.chunks.size - 1)
loadedChunkCount = result.chunks.size
topVisibleChunkIndex = loadUpToChunkIndex
} else {
loadUpToChunkIndex = result.startChunkIndex
loadedChunkCount = min(result.chunks.size, result.startChunkIndex + 2)
topVisibleChunkIndex = 0
}
Timber.tag("ReflowPaginationDiag").d("EpubReaderScreen: loadChapterContent finished. chapterChunks.size=${chapterChunks.size}, isChapterParsing=$isChapterParsing")
if (chunkTargetOverride != null) {
@ -1371,9 +1384,6 @@ fun EpubReaderHost(
if (isInitialCfiLoad) {
isInitialCfiLoad = false
}
loadedChunkCount = 1
topVisibleChunkIndex = 0
}
EpubReaderSystemUiController(
@ -1604,6 +1614,8 @@ fun EpubReaderHost(
coroutineScope = scope,
onVerticalChapterChange = { chapterIdx, chunkIdx, result ->
initialScrollTargetForChapter = ChapterScrollPosition.START
currentScrollYPosition = 0
currentScrollHeightValue = 0
currentChapterIndex = chapterIdx
searchHighlightTarget = result
loadUpToChunkIndex = chunkIdx
@ -1674,6 +1686,7 @@ fun EpubReaderHost(
if (targetChapterIndex != currentChapterIndex) {
initialScrollTargetForChapter = null
currentScrollYPosition = 0
currentScrollHeightValue = 0
currentChapterIndex = targetChapterIndex
} else {
if (entry.fragmentId != null) {
@ -1718,6 +1731,7 @@ fun EpubReaderHost(
if (index != currentChapterIndex) {
initialScrollTargetForChapter = ChapterScrollPosition.START
currentScrollYPosition = 0
currentScrollHeightValue = 0
currentChapterIndex = index
pullToNextProgress = 0f
pullToPrevProgress = 0f
@ -1773,6 +1787,8 @@ fun EpubReaderHost(
} else {
0
}
currentScrollYPosition = 0
currentScrollHeightValue = 0
currentChapterIndex = bookmark.chapterIndex
}
else {
@ -1876,6 +1892,8 @@ fun EpubReaderHost(
if (highlight.chapterIndex != currentChapterIndex) {
chunkTargetOverride = if (targetChunk != null && targetChunk >= 0) targetChunk else 0
currentScrollYPosition = 0
currentScrollHeightValue = 0
currentChapterIndex = highlight.chapterIndex
} else {
if (targetChunk != null && targetChunk >= 0) {
@ -2069,7 +2087,8 @@ fun EpubReaderHost(
onNavigateChapter = { offset, target ->
scope.launch {
initialScrollTargetForChapter = target
if (target == ChapterScrollPosition.START) currentScrollYPosition = 0
currentScrollYPosition = 0
currentScrollHeightValue = 0
currentChapterIndex += offset
}
},
@ -2147,19 +2166,19 @@ fun EpubReaderHost(
CircularProgressIndicator()
}
} else if (chapterChunks.isNotEmpty()) {
val initialContentToLoad =
remember(loadUpToChunkIndex, chapterChunks) {
val startIdx = maxOf(0, loadUpToChunkIndex - 1)
val endIdx = minOf(chapterChunks.lastIndex, loadUpToChunkIndex + 1)
val initialContentToLoad = remember(loadUpToChunkIndex, chapterChunks) {
val targetIdx = loadUpToChunkIndex
val startIdx = maxOf(0, targetIdx - 1)
val endIdx = minOf(chapterChunks.lastIndex, targetIdx + 1)
chapterChunks.indices.joinToString(separator = "\n") { index ->
if (index in startIdx..endIdx) {
"<div class='chunk-container' data-chunk-index='$index'>${chapterChunks[index]}</div>"
} else {
"<div class='chunk-container' data-chunk-index='$index'></div>"
}
chapterChunks.indices.joinToString(separator = "\n") { index ->
if (index in startIdx..endIdx) {
"<div class='chunk-container' data-chunk-index='$index'>${chapterChunks[index]}</div>"
} else {
"<div class='chunk-container' data-chunk-index='$index'></div>"
}
}
}
val initialHtml = """
<!DOCTYPE html>
<html>
@ -2364,6 +2383,7 @@ fun EpubReaderHost(
Timber.d("Screen: Moving to next chapter (${currentChapterIndex + 1}).")
initialScrollTargetForChapter = ChapterScrollPosition.START
currentScrollYPosition = 0
currentScrollHeightValue = 0
currentChapterIndex++
isAutoScrollPlaying = true
} else {
@ -2384,6 +2404,8 @@ fun EpubReaderHost(
scope.launch {
delay(20)
initialScrollTargetForChapter = ChapterScrollPosition.END
currentScrollYPosition = 0
currentScrollHeightValue = 0
currentChapterIndex--
if (showBars) showBars = false
delay(300)
@ -2405,6 +2427,7 @@ fun EpubReaderHost(
delay(20)
initialScrollTargetForChapter = ChapterScrollPosition.START
currentScrollYPosition = 0
currentScrollHeightValue = 0
currentChapterIndex++
if (showBars) showBars = false
delay(300)
@ -2423,12 +2446,12 @@ fun EpubReaderHost(
)
scope.launch {
delay(50)
initialScrollTargetForChapter =
ChapterScrollPosition.END
initialScrollTargetForChapter = ChapterScrollPosition.END
currentScrollYPosition = 0
currentScrollHeightValue = 0
currentChapterIndex--
if (showBars) showBars = false
Timber.d("Changed to previous chapter: $currentChapterIndex, will scroll to END"
)
Timber.d("Changed to previous chapter: $currentChapterIndex, will scroll to END")
}
}
pullToPrevProgress = 0f
@ -2443,9 +2466,9 @@ fun EpubReaderHost(
)
scope.launch {
delay(50)
initialScrollTargetForChapter =
ChapterScrollPosition.START
initialScrollTargetForChapter = ChapterScrollPosition.START
currentScrollYPosition = 0
currentScrollHeightValue = 0
currentChapterIndex++
if (showBars) showBars = false
}
@ -2604,7 +2627,7 @@ fun EpubReaderHost(
showDictionaryUpsellDialog = true
},
onCfiGenerated = { cfi ->
Timber.tag("POS_DIAG").d("JS generated CFI: '$cfi'")
Timber.tag("PosSaveDiag").d("JS generated CFI string: '$cfi'")
if (cfi.isBlank() || !cfi.startsWith('/')) {
if (isSavingAndExiting) {
@ -2623,6 +2646,7 @@ fun EpubReaderHost(
)
if (locator != null) {
Timber.tag("PosSaveDiag").d("✅ Converted CFI to Locator successfully: chapter=${locator.chapterIndex}, block=${locator.blockIndex}, charOffset=${locator.charOffset}")
lastKnownLocator = locator
val progressWithinChapter =
@ -3131,8 +3155,11 @@ fun EpubReaderHost(
val chapterTitle =
chapters.getOrNull(currentChapterIndex)?.title?.take(30)?.trim()
?: "Chapter"
val displayPageInfo = if (currentScrollHeightValue <= 0 || isChapterParsing) "" else " ($currentPageInChapter/$totalPagesInCurrentChapter)"
Text(
text = "$chapterTitle ($currentPageInChapter/$totalPagesInCurrentChapter)",
text = "$chapterTitle$displayPageInfo",
style = MaterialTheme.typography.bodySmall,
color = effectiveText.copy(alpha = 0.8f),
textAlign = TextAlign.Center,
@ -3143,7 +3170,7 @@ fun EpubReaderHost(
.padding(horizontal = 48.dp)
)
if (totalBookLengthChars > 0) {
if (totalBookLengthChars > 0 && currentScrollHeightValue > 0 && !isChapterParsing) {
Text(
text = "%.1f%%".format(currentBookProgress),
style = MaterialTheme.typography.bodySmall,
@ -3484,10 +3511,14 @@ fun EpubReaderHost(
val targetChunk = locator.blockIndex / 20
chunkTargetOverride = targetChunk
if (currentChapterIndex != locator.chapterIndex) {
currentScrollYPosition = 0
currentScrollHeightValue = 0
currentChapterIndex = locator.chapterIndex
}
cfiToLoad = cfi
} else {
currentScrollYPosition = 0
currentScrollHeightValue = 0
currentChapterIndex = locator.chapterIndex
cfiToLoad = null
}

View file

@ -167,12 +167,14 @@ class LocatorConverter(
val bestMatch = findBestMatchingBlock(allBlocks, baseCfiPath)
if (bestMatch != null) {
Timber.tag("PosSaveDiag").d("Found best match for baseCfiPath $baseCfiPath -> blockIndex=${bestMatch.blockIndex}, actualBlockCfi=${bestMatch.cfi}")
Locator(
chapterIndex = chapterIndex,
blockIndex = bestMatch.blockIndex,
charOffset = charOffset
)
} else {
Timber.tag("PosSaveDiag").e("No semantic block match found for baseCfiPath $baseCfiPath inside ${allBlocks.size} parsed blocks")
null
}
}

View file

@ -432,6 +432,7 @@ internal fun PdfPageComposable(
draggingBoxId: String? = null,
isScrollLocked: Boolean = false,
isVisible: Boolean = true,
isActivePage: Boolean = true,
isStylusOnlyMode: Boolean = false,
isHighlighterSnapEnabled: Boolean = false,
userHighlights: List<PdfUserHighlight> = emptyList(),
@ -442,7 +443,9 @@ internal fun PdfPageComposable(
onTts: (Int, Int) -> Unit = { _, _ -> },
activeToolThickness: Float = 0f,
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
onPaletteClick: (() -> Unit)? = null
onPaletteClick: (() -> Unit)? = null,
lockedState: Triple<Float, Float, Float>? = null,
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null
) {
val pdfDocumentItem = pdfDocument.item
var bitmapState by remember { mutableStateOf(PdfThumbnailCache.get(pageIndex)) }
@ -474,6 +477,10 @@ internal fun PdfPageComposable(
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
LaunchedEffect(scale, offset) {
onZoomAndPanChanged?.invoke(scale, offset)
}
val currentOnSingleTap by rememberUpdatedState(onSingleTap)
val currentOnDoubleTap by rememberUpdatedState(onDoubleTap)
@ -720,12 +727,6 @@ internal fun PdfPageComposable(
}
}
LaunchedEffect(pageIndex) {
scale = 1f
offset = Offset.Zero
onScaleChanged(1f)
}
LaunchedEffect(isPerformingOcrForSelection) { onOcrStateChange(isPerformingOcrForSelection) }
LaunchedEffect(
@ -1067,9 +1068,10 @@ internal fun PdfPageComposable(
canvasHeightPx.floatValue,
isVerticalScroll,
isScrolling,
virtualPage
virtualPage,
isActivePage
) {
val needsTiling = effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000
val needsTiling = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage)
if (!needsTiling) {
if (tiles.isNotEmpty()) {
val oldTiles = tiles
@ -2594,7 +2596,7 @@ internal fun PdfPageComposable(
}
}
}, onDoubleTap = { tapOffset ->
if (isZoomEnabled && !isVerticalScroll) {
if (isZoomEnabled && !isVerticalScroll && !isScrollLocked) {
if (actualBitmapWidthPx == 0) return@detectTapGestures
coroutineScope.launch {
val startScale = scale
@ -2689,7 +2691,11 @@ internal fun PdfPageComposable(
if (!canceled) {
val rawPanChange = event.calculatePan()
val panChange = if (isScrollLocked) Offset(0f, rawPanChange.y) else rawPanChange
val panChange = if (isScrollLocked && pointerCount == 1) {
if (isVerticalScroll) Offset(0f, rawPanChange.y) else Offset.Zero
} else {
rawPanChange
}
val zoomChange = event.calculateZoom()
if (scale > 1f) {
@ -3112,14 +3118,21 @@ internal fun PdfPageComposable(
}
LaunchedEffect(
this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight
pageIndex, this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight,
isScrollLocked, lockedState
) {
scale = 1f
offset = Offset.Zero
onScaleChanged(1f)
if (isScrollLocked && !isVerticalScroll && lockedState != null) {
scale = lockedState.first
offset = Offset(lockedState.second, lockedState.third)
onScaleChanged(scale)
} else if (!isScrollLocked && !isVerticalScroll) {
scale = 1f
offset = Offset.Zero
onScaleChanged(1f)
}
Timber.d(
"PdfPageComposable Page $pageIndex | Constraints: maxWidth=${this@BoxWithConstraints.maxWidth}, maxHeight=${this@BoxWithConstraints.maxHeight}"
"PdfPageComposable Page $pageIndex initialized/resized/locked. scale=$scale, offset=$offset"
)
}

View file

@ -234,7 +234,9 @@ internal fun PdfVerticalReader(
onTts: (Int, Int) -> Unit = { _, _ -> },
activeToolThickness: Float = 0f,
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
onPaletteClick: () -> Unit = {}
onPaletteClick: () -> Unit = {},
lockedState: Triple<Float, Float, Float>? = null,
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null
) {
SideEffect { Timber.tag("PdfDrawPerf").v("LIST: PdfVerticalReader Recomposing.") }
DisposableEffect(state) {
@ -336,6 +338,10 @@ internal fun PdfVerticalReader(
val panXAnimatable = remember { Animatable(if ((screenWidth * fitZoom) < screenWidth) (screenWidth - (screenWidth * fitZoom)) / 2f else 0f) }
val panYAnimatable = remember { Animatable(0f) }
LaunchedEffect(zoomAnimatable.value, panXAnimatable.value, panYAnimatable.value) {
onZoomAndPanChanged?.invoke(zoomAnimatable.value, Offset(panXAnimatable.value, panYAnimatable.value))
}
var isResizing by remember { mutableStateOf(false) }
var previousScreenWidth by remember { mutableFloatStateOf(0f) }
var previousScreenHeight by remember { mutableFloatStateOf(0f) }
@ -401,6 +407,12 @@ internal fun PdfVerticalReader(
delay(50)
isResizing = false
targetPageDuringResize.intValue = -1
} else if (isScrollLocked && lockedState != null) {
val (savedScale, savedPanX, _) = lockedState
coroutineScope {
launch { zoomAnimatable.snapTo(savedScale) }
launch { panXAnimatable.snapTo(savedPanX) }
}
}
isInitialLayout = false
}
@ -768,63 +780,65 @@ internal fun PdfVerticalReader(
}
val onDoubleTapToZoom: (Offset) -> Unit = { tapScreenOffset ->
val currentZoom = zoomAnimatable.value
if (!isScrollLocked) {
val currentZoom = zoomAnimatable.value
val targetZoom = when {
currentZoom < 0.95f -> 1f
currentZoom < 2.45f -> 2.5f
else -> fitZoom
}
val startPanX = panXAnimatable.value
val startPanY = panYAnimatable.value
scope.launch {
zoomAnimatable.stop()
panXAnimatable.stop()
panYAnimatable.stop()
val pivotContentX = (tapScreenOffset.x - startPanX) / currentZoom
val pivotContentY = (tapScreenOffset.y - startPanY) / currentZoom
val rawNextPanX = tapScreenOffset.x - (pivotContentX * targetZoom)
val rawNextPanY = tapScreenOffset.y - (pivotContentY * targetZoom)
val (finalZoom, finalX, finalY) = clampCamera(targetZoom, rawNextPanX, rawNextPanY)
panXAnimatable.updateBounds(
lowerBound = minOf(panXAnimatable.lowerBound ?: finalX, finalX, startPanX),
upperBound = maxOf(panXAnimatable.upperBound ?: finalX, finalX, startPanX)
)
panYAnimatable.updateBounds(
lowerBound = minOf(panYAnimatable.lowerBound ?: finalY, finalY, startPanY),
upperBound = maxOf(panYAnimatable.upperBound ?: finalY, finalY, startPanY)
)
coroutineScope {
launch { zoomAnimatable.animateTo(finalZoom, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
launch { panXAnimatable.animateTo(finalX, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
launch { panYAnimatable.animateTo(finalY, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
val targetZoom = when {
currentZoom < 0.95f -> 1f
currentZoom < 2.45f -> 2.5f
else -> fitZoom
}
onZoomChange(zoomAnimatable.value)
val startPanX = panXAnimatable.value
val startPanY = panYAnimatable.value
val zoomedDocWidth = screenWidth * finalZoom
val finalMinX: Float
val finalMaxX: Float
if (zoomedDocWidth < screenWidth) {
val centeredX = (screenWidth - zoomedDocWidth) / 2f
finalMinX = centeredX
finalMaxX = centeredX
} else {
finalMinX = -(zoomedDocWidth - screenWidth)
finalMaxX = 0f
scope.launch {
zoomAnimatable.stop()
panXAnimatable.stop()
panYAnimatable.stop()
val pivotContentX = (tapScreenOffset.x - startPanX) / currentZoom
val pivotContentY = (tapScreenOffset.y - startPanY) / currentZoom
val rawNextPanX = tapScreenOffset.x - (pivotContentX * targetZoom)
val rawNextPanY = tapScreenOffset.y - (pivotContentY * targetZoom)
val (finalZoom, finalX, finalY) = clampCamera(targetZoom, rawNextPanX, rawNextPanY)
panXAnimatable.updateBounds(
lowerBound = minOf(panXAnimatable.lowerBound ?: finalX, finalX, startPanX),
upperBound = maxOf(panXAnimatable.upperBound ?: finalX, finalX, startPanX)
)
panYAnimatable.updateBounds(
lowerBound = minOf(panYAnimatable.lowerBound ?: finalY, finalY, startPanY),
upperBound = maxOf(panYAnimatable.upperBound ?: finalY, finalY, startPanY)
)
coroutineScope {
launch { zoomAnimatable.animateTo(finalZoom, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
launch { panXAnimatable.animateTo(finalX, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
launch { panYAnimatable.animateTo(finalY, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
}
onZoomChange(zoomAnimatable.value)
val zoomedDocWidth = screenWidth * finalZoom
val finalMinX: Float
val finalMaxX: Float
if (zoomedDocWidth < screenWidth) {
val centeredX = (screenWidth - zoomedDocWidth) / 2f
finalMinX = centeredX
finalMaxX = centeredX
} else {
finalMinX = -(zoomedDocWidth - screenWidth)
finalMaxX = 0f
}
panXAnimatable.updateBounds(lowerBound = finalMinX, upperBound = finalMaxX)
val zDocH = totalDocHeight * finalZoom
val minScrollY = (screenHeight - footerHeightPx - zDocH).coerceAtMost(headerHeightPx)
panYAnimatable.updateBounds(lowerBound = minScrollY, upperBound = headerHeightPx)
}
panXAnimatable.updateBounds(lowerBound = finalMinX, upperBound = finalMaxX)
val zDocH = totalDocHeight * finalZoom
val minScrollY = (screenHeight - footerHeightPx - zDocH).coerceAtMost(headerHeightPx)
panYAnimatable.updateBounds(lowerBound = minScrollY, upperBound = headerHeightPx)
}
}
@ -1042,7 +1056,7 @@ internal fun PdfVerticalReader(
val zoomChange = event.calculateZoom()
val rawPanChange = event.calculatePan()
val panChange = if (isScrollLocked) Offset(0f, rawPanChange.y) else rawPanChange
val panChange = if (isScrollLocked && !isMultiTouch) Offset(0f, rawPanChange.y) else rawPanChange
val centroid = event.calculateCentroid(useCurrent = false)
val panMagnitude = panChange.getDistance()

View file

@ -583,6 +583,25 @@ private fun getSuggestedFilename(originalName: String?, isAnnotated: Boolean): S
return "${safeBase}${suffix}_${shortId}.pdf"
}
private fun savePdfLockedState(context: Context, bookId: String, scale: Float, offsetX: Float, offsetY: Float) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit {
putFloat("pdf_locked_scale_$bookId", scale)
putFloat("pdf_locked_offset_x_$bookId", offsetX)
putFloat("pdf_locked_offset_y_$bookId", offsetY)
}
}
private fun loadPdfLockedState(context: Context, bookId: String): Triple<Float, Float, Float>? {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
if (!prefs.contains("pdf_locked_scale_$bookId")) return null
return Triple(
prefs.getFloat("pdf_locked_scale_$bookId", 1f),
prefs.getFloat("pdf_locked_offset_x_$bookId", 0f),
prefs.getFloat("pdf_locked_offset_y_$bookId", 0f)
)
}
private enum class SaveMode {
ORIGINAL, ANNOTATED
}
@ -1181,6 +1200,9 @@ fun PdfViewerScreen(
var documentPassword by rememberSaveable { mutableStateOf<String?>(null) }
var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) }
var isScrollLocked by remember { mutableStateOf(false) }
var lockedState by remember { mutableStateOf<Triple<Float, Float, Float>?>(null) }
var currentActiveScale by remember { mutableFloatStateOf(1f) }
var currentActiveOffset by remember { mutableStateOf(Offset.Zero) }
var showPasswordDialog by remember { mutableStateOf(false) }
var isPasswordError by remember { mutableStateOf(false) }
LocalView.current
@ -1239,6 +1261,7 @@ fun PdfViewerScreen(
LaunchedEffect(bookId) {
isScrollLocked = loadPdfScrollLocked(context, bookId)
isFullScreen = loadPdfFullScreen(context, bookId)
lockedState = loadPdfLockedState(context, bookId)
}
var isAutoScrollModeActive by remember { mutableStateOf(false) }
@ -1504,6 +1527,14 @@ fun PdfViewerScreen(
LaunchedEffect(displayMode) { saveDisplayMode(context, displayMode) }
LaunchedEffect(currentActiveScale, currentActiveOffset, isScrollLocked) {
if (isScrollLocked) {
delay(500)
lockedState = Triple(currentActiveScale, currentActiveOffset.x, currentActiveOffset.y)
savePdfLockedState(context, bookId, currentActiveScale, currentActiveOffset.x, currentActiveOffset.y)
}
}
val annotationSettingsRepo = remember(context) { AnnotationSettingsRepository(context) }
val toolSettings by annotationSettingsRepo.settings.collectAsState()
var showToolSettings by rememberSaveable { mutableStateOf(false) }
@ -4367,7 +4398,7 @@ fun PdfViewerScreen(
key = { it },
beyondViewportPageCount = dynamicBeyondViewportPageCount,
userScrollEnabled = run {
val enabled = currentPageScale == 1f && !(ttsState.isPlaying || ttsState.isLoading || searchState.isSearchActive) && !isPageSliderVisible && paginationDraggingBoxId == null
val enabled = (currentPageScale == 1f || (isScrollLocked && displayMode == DisplayMode.PAGINATION)) && !(ttsState.isPlaying || ttsState.isLoading || searchState.isSearchActive) && !isPageSliderVisible && paginationDraggingBoxId == null
SideEffect {
Timber.tag("PdfZoomDebug").v("Pager Scroll Enabled: $enabled (Scale: $currentPageScale, Playing: ${ttsState.isPlaying}, Slider: $isPageSliderVisible, DraggingBox: $paginationDraggingBoxId)")
}
@ -4619,6 +4650,13 @@ fun PdfViewerScreen(
onNoteRequested = onNoteRequested,
onTts = { pageIdx, charIdx -> startTtsWithPermissionCheck(pageIdx, charIdx) },
activeToolThickness = currentStrokeWidthState,
lockedState = lockedState,
onZoomAndPanChanged = { newScale, newOffset ->
if (pagerState.currentPage == pageIndex) {
currentActiveScale = newScale
currentActiveOffset = newOffset
}
},
onTwoFingerSwipe = { direction ->
coroutineScope.launch {
val targetPage =
@ -4774,6 +4812,8 @@ fun PdfViewerScreen(
},
onDragPageTurn = { /* Handled in onTextBoxDrag */ },
isVisible = isVisiblePage,
isActivePage = pagerState.currentPage == pageIndex,
isScrolling = pagerState.isScrollInProgress
)
}
@ -5025,7 +5065,12 @@ fun PdfViewerScreen(
isAutoScrollPlaying = isAutoScrollPlaying,
isAutoScrollTempPaused = isAutoScrollTempPaused,
autoScrollSpeed = autoScrollSpeed * 0.5f,
onInteractionListener = onAutoScrollInteraction
onInteractionListener = onAutoScrollInteraction,
lockedState = lockedState,
onZoomAndPanChanged = { newScale, newOffset ->
currentActiveScale = newScale
currentActiveOffset = newOffset
}
)
}
}
@ -5529,6 +5574,10 @@ fun PdfViewerScreen(
onClick = {
isScrollLocked = !isScrollLocked
savePdfScrollLocked(context, bookId, isScrollLocked)
if (isScrollLocked) {
savePdfLockedState(context, bookId, currentActiveScale, currentActiveOffset.x, currentActiveOffset.y)
lockedState = Triple(currentActiveScale, currentActiveOffset.x, currentActiveOffset.y)
}
}) {
Icon(
imageVector = if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen,