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

@ -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,