Pdf text highlight (#49)

* feat(pdf): implement native text highlighting and interactive selection menu

- Add `PdfUserHighlight` data model to track page index, character ranges, and normalized coordinates.
- Redesign `PdfSelectionMenuPopup` to match the EPUB reader style, featuring a color palette row and a deletion option.
- Integrate highlight rendering into the `PdfPageSelectionsLayer` using Canvas drawing.
- Add hit-testing to `detectTapGestures` to allow users to tap existing highlights to change colors or remove them.

* Implemented PDF highlight persistence, sync, and exporting.

* Improved PDF highlight exporting and visibility logic

- Fixed PDF highlight rendering by correctly calculating coordinates and merging adjacent rectangles into lines.
- Updated `PdfExporter` to include highlights in the exported document and added debug logging.
- Corrected visibility check in `PdfPageComposable` to account for vertical offsets.
- Enhanced `PdfViewerScreen` highlight creation to use merged line rectangles

* Refactored annotation synchronization logic and improved background sync reliability.

- Refactored JSON bundle creation and extraction into reusable helper functions.
- Improved stale file detection by checking all annotation types (ink, text, layout, highlights, text boxes).
- Added background annotation downloading to `SyncWorker`
- Implemented duplicate file cleanup in `GoogleDriveRepository` during uploads.
- Added a `showFeedback` flag to `syncFolderMetadata` to control UI visibility during background operations.
- Fixed a potential data loss edge case by aborting Firestore sync if the bundle upload fails.

* Improved highlight interaction and management in PDF viewer

- Implemented hit tolerance for highlight selection to improve touch detection accuracy.
- Added logic to shift highlight page indices when adding or deleting pages.
- Included highlights in the auto-pruning check for empty pages.
- Added a "Highlights" tab to the navigation drawer to list, navigate to, and delete user highlights.
This commit is contained in:
Aryan 2026-03-09 17:37:44 +05:30 committed by GitHub
parent 8c7c8cb48e
commit ef1bfdc57a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 921 additions and 163 deletions

View file

@ -355,7 +355,8 @@ data class PageSelectionData(
val allTextPageHighlightColor: Color,
val ttsHighlightColor: Color,
val selectionHighlightColor: Color,
val pageIndex: Int
val pageIndex: Int,
val userHighlightScreenRects: StableHolder<List<Pair<PdfUserHighlight, List<Rect>>>>,
)
@Suppress("unused")
@ -418,7 +419,11 @@ internal fun PdfPageComposable(
isScrollLocked: Boolean = false,
isVisible: Boolean = true,
isStylusOnlyMode: Boolean = false,
isHighlighterSnapEnabled: Boolean = false
isHighlighterSnapEnabled: Boolean = false,
userHighlights: List<PdfUserHighlight> = emptyList(),
onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> },
onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> },
onHighlightDelete: (String) -> Unit = {},
) {
SideEffect { Timber.tag("PdfDrawPerf").v("PdfPageComposable Recompose: Page $pageIndex") }
val pdfDocumentItem = pdfDocument.item
@ -650,6 +655,39 @@ internal fun PdfPageComposable(
val linkHighlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
val linkVerticalPaddingPx = remember(density) { with(density) { 10.dp.toPx().toInt() } }
var userHighlightScreenRects by remember { mutableStateOf<List<Pair<PdfUserHighlight, List<Rect>>>>(emptyList()) }
LaunchedEffect(userHighlights, actualBitmapWidthPx, actualBitmapHeightPx, currentPageRotation, virtualPage) {
if (!isPdfPage || actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0 || userHighlights.isEmpty()) {
userHighlightScreenRects = emptyList()
return@LaunchedEffect
}
withContext(Dispatchers.IO) {
try {
pdfDocumentItem.openPage(pdfPageIndex).use { page ->
val mapped = userHighlights.map { highlight ->
val screenRects = highlight.bounds.mapNotNull { pdfRectF ->
page.mapRectToDevice(
startX = 0,
startY = 0,
sizeX = actualBitmapWidthPx,
sizeY = actualBitmapHeightPx,
rotate = currentPageRotation,
coords = pdfRectF
).takeIf { it.width() > 0 && it.height() > 0 }
}
highlight to screenRects
}
withContext(Dispatchers.Main) {
userHighlightScreenRects = mapped
}
}
} catch (e: Exception) {
Timber.e(e, "Failed to map user highlights to screen rects")
}
}
}
LaunchedEffect(pageIndex) {
scale = 1f
offset = Offset.Zero
@ -2241,7 +2279,8 @@ internal fun PdfPageComposable(
isVerticalScroll,
isEditMode,
selectedTool,
isStylusOnlyMode
isStylusOnlyMode,
userHighlightScreenRects
) {
val isTapDetectionAllowed = !isEditMode ||
selectedTool == InkType.TEXT ||
@ -2250,13 +2289,52 @@ internal fun PdfPageComposable(
if (!isTapDetectionAllowed) return@pointerInput
detectTapGestures(onTap = { tapOffset ->
Timber.d(
"PdfPageComposable: onTap detected at $tapOffset. isVerticalScroll=$isVerticalScroll"
)
val tapInContentCoords = screenToContentCoordinates(tapOffset)
val tapXInBitmap = tapInContentCoords.x
val tapYInBitmap = tapInContentCoords.y
val hitTolerance = with(density) { 16.dp.toPx() } / inputScale
Timber.d(
"detectTapGestures: Tap at bitmap coords (${tapXInBitmap.toInt()}, ${tapYInBitmap.toInt()}) with tolerance $hitTolerance"
)
var tappedRect: Rect? = null
val hitHighlightPair = userHighlightScreenRects.findLast { pair ->
val hit = pair.second.find { r ->
val hitLeft = r.left - hitTolerance
val hitTop = r.top - hitTolerance
val hitRight = r.right + hitTolerance
val hitBottom = r.bottom + hitTolerance
tapXInBitmap in hitLeft..hitRight &&
tapYInBitmap >= hitTop && tapYInBitmap <= hitBottom
}
if (hit != null) {
tappedRect = hit
true
} else false
}
if (hitHighlightPair != null && tappedRect != null) {
val hitHighlight = hitHighlightPair.first
val anchorRect = android.graphics.Rect(
tappedRect.left,
tappedRect.top,
tappedRect.right,
tappedRect.bottom
)
customMenuState = CustomPdfMenuState(
selectedText = hitHighlight.text,
anchorRect = anchorRect,
charRange = hitHighlight.range,
isExistingHighlight = true,
highlightId = hitHighlight.id
)
return@detectTapGestures
}
Timber.d(
"detectTapGestures: Tap at bitmap coords (${tapXInBitmap.toInt()}, ${tapYInBitmap.toInt()})"
)
@ -3257,7 +3335,8 @@ internal fun PdfPageComposable(
mergedSearchAllRects,
searchHighlightMode,
searchFocusedColor,
searchAllColor
searchAllColor,
userHighlightScreenRects
) {
PageSelectionData(
pageLinks = StableHolder(pageLinks),
@ -3281,6 +3360,7 @@ internal fun PdfPageComposable(
mergedSearchFocusedRects = StableHolder(mergedSearchFocusedRects),
mergedSearchAllRects = StableHolder(mergedSearchAllRects),
searchHighlightMode = searchHighlightMode,
userHighlightScreenRects = StableHolder(userHighlightScreenRects),
)
}
@ -3306,6 +3386,9 @@ internal fun PdfPageComposable(
startHandlePos = startHandleContentPosition.value,
endHandlePos = endHandleContentPosition.value,
teardropWidthPx = teardropWidthPxState.value,
onHighlightAdd = onHighlightAdd,
onHighlightUpdate = onHighlightUpdate,
onHighlightDelete = onHighlightDelete,
teardropHeightPx = teardropHeightPxState.value,
activeDraggingHandle = activeDraggingHandle,
showMagnifier = showMagnifier,
@ -3594,6 +3677,7 @@ private fun PdfHighlightsLayer(
searchHighlightMode: SearchHighlightMode,
ocrHoverHighlights: List<RectF>,
mergedSelectionRects: List<Rect>,
userHighlightScreenRects: List<Pair<PdfUserHighlight, List<Rect>>>,
centeringOffsetX: Float,
centeringOffsetY: Float,
linkHighlightColor: Color,
@ -3610,8 +3694,9 @@ private fun PdfHighlightsLayer(
fun isVisible(r: Rect): Boolean {
val left = r.left + centeringOffsetX
val right = r.right + centeringOffsetX
val top = r.top + centeringOffsetY
val bottom = r.bottom + centeringOffsetY
return left < size.width && right < size.height && right > 0 && bottom > 0
return left < size.width && right > 0 && top < size.height && bottom > 0
}
// 1. Page Links
@ -3739,6 +3824,19 @@ private fun PdfHighlightsLayer(
)
}
}
// 9. Persistent User Highlights
userHighlightScreenRects.forEach { (highlight, screenRects) ->
screenRects.forEach { r ->
if (isVisible(r)) {
drawRect(
color = highlight.color.color.copy(alpha = 0.4f),
topLeft = Offset(r.left.toFloat(), r.top.toFloat()),
size = Size(r.width().toFloat(), r.height().toFloat())
)
}
}
}
}
}
}
@ -4093,6 +4191,7 @@ private fun PdfPageSelectionsLayer(
searchHighlightMode: SearchHighlightMode,
ocrHoverHighlights: List<RectF>,
mergedSelectionRects: List<Rect>,
userHighlightScreenRects: List<Pair<PdfUserHighlight, List<Rect>>>,
centeringOffsetX: Float,
centeringOffsetY: Float,
linkHighlightColor: Color,
@ -4101,7 +4200,10 @@ private fun PdfPageSelectionsLayer(
ttsHighlightColor: Color,
selectionHighlightColor: Color
) {
SideEffect { Timber.tag("PdfDrawPerf").v("SELECTIONS LAYER: Recomposing") }
SideEffect {
Timber.tag("PdfDrawPerf").v("SELECTIONS LAYER: Recomposing")
Timber.tag("PdfHighlightDebug").v("PdfPageSelectionsLayer Recomposing. userHighlights count: ${userHighlightScreenRects.size}")
}
val highlightStart = System.nanoTime()
PdfHighlightsLayer(
@ -4116,6 +4218,7 @@ private fun PdfPageSelectionsLayer(
searchHighlightMode = searchHighlightMode,
ocrHoverHighlights = ocrHoverHighlights,
mergedSelectionRects = mergedSelectionRects,
userHighlightScreenRects = userHighlightScreenRects,
centeringOffsetX = centeringOffsetX,
centeringOffsetY = centeringOffsetY,
linkHighlightColor = linkHighlightColor,
@ -4182,7 +4285,10 @@ private fun PdfPageRenderer(
onTextBoxDrag: (Offset) -> Unit,
onTextBoxDragEnd: () -> Unit,
onDragPageTurn: (Int) -> Unit,
draggingBoxId: String? = null
draggingBoxId: String? = null,
onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit,
onHighlightUpdate: (String, PdfHighlightColor) -> Unit,
onHighlightDelete: (String) -> Unit,
) {
Box(modifier = Modifier.fillMaxSize()) {
Box(
@ -4215,6 +4321,7 @@ private fun PdfPageRenderer(
searchHighlightMode = selectionData.searchHighlightMode,
ocrHoverHighlights = selectionData.ocrHoverHighlights.item,
mergedSelectionRects = selectionData.mergedSelectionRects.item,
userHighlightScreenRects = selectionData.userHighlightScreenRects.item,
centeringOffsetX = selectionData.centeringOffsetX,
centeringOffsetY = selectionData.centeringOffsetY,
linkHighlightColor = selectionData.linkHighlightColor,
@ -4363,7 +4470,7 @@ private fun PdfPageRenderer(
staticData.targetHeight.toDp()
})) {
Text(
text = "${selectionData.pageIndex + 1}\\$totalPages",
text = "${selectionData.pageIndex + 1}/$totalPages",
color = pageNumColor.copy(alpha = 0.5f),
style = MaterialTheme.typography.labelSmall.copy(
fontSize = 12.sp, fontWeight = FontWeight.Bold
@ -4559,7 +4666,28 @@ private fun PdfPageRenderer(
popupPositionProvider = popupPositionProvider,
onCopy = onCopy,
onAiDefine = onAiDefine,
onSelectAll = onSelectAll
onSelectAll = onSelectAll,
onColorSelected = { color ->
Timber.tag("PdfHighlightDebug").d("PdfSelectionMenuPopup onColorSelected: $color, isExisting=${state.isExistingHighlight}")
if (state.isExistingHighlight && state.highlightId != null) {
onHighlightUpdate(state.highlightId, color)
} else {
Timber.tag("PdfHighlightDebug").d("Calling onHighlightAdd for page ${selectionData.pageIndex}")
onHighlightAdd(
selectionData.pageIndex,
state.charRange,
state.selectedText,
color
)
}
onMenuDismiss()
},
onDelete = {
if (state.isExistingHighlight && state.highlightId != null) {
onHighlightDelete(state.highlightId)
}
onMenuDismiss()
}
)
}
}