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:
parent
8c7c8cb48e
commit
ef1bfdc57a
11 changed files with 921 additions and 163 deletions
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue