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
42
LICENSE
42
LICENSE
|
|
@ -617,3 +617,45 @@ Program, unless a warranty or assumption of liability accompanies a
|
|||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
|
|
|||
|
|
@ -80,3 +80,11 @@ This project is made possible by the Android open-source ecosystem:
|
|||
## License
|
||||
|
||||
This project is licensed under the **GNU Affero General Public License v3.0 (AGPL-3.0)**. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Support
|
||||
|
||||
If you find Episteme Reader useful and want to support its development, consider sponsoring.
|
||||
|
||||
<a href="https://github.com/sponsors/Aryan-Raj3112">
|
||||
<img src="https://img.shields.io/badge/Sponsor-%E2%9D%A4-%23db61a2?logo=github" alt="Sponsor on GitHub"/>
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -69,10 +69,12 @@ import com.aryan.reader.paginatedreader.data.BookCacheDatabase
|
|||
import com.aryan.reader.paginatedreader.data.BookProcessingWorker
|
||||
import com.aryan.reader.pdf.PdfCoverGenerator
|
||||
import com.aryan.reader.pdf.PdfExporter
|
||||
import com.aryan.reader.pdf.PdfUserHighlight
|
||||
import com.aryan.reader.pdf.ReflowWorker
|
||||
import com.aryan.reader.pdf.data.PageLayoutRepository
|
||||
import com.aryan.reader.pdf.data.PdfAnnotation
|
||||
import com.aryan.reader.pdf.data.PdfAnnotationRepository
|
||||
import com.aryan.reader.pdf.data.PdfHighlightRepository
|
||||
import com.aryan.reader.pdf.data.PdfTextBox
|
||||
import com.aryan.reader.pdf.data.PdfTextBoxRepository
|
||||
import com.aryan.reader.pdf.data.PdfTextRepository
|
||||
|
|
@ -232,6 +234,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
private val pageLayoutRepository = PageLayoutRepository(appContext)
|
||||
private val pdfRichTextRepository = com.aryan.reader.pdf.PdfRichTextRepository(appContext)
|
||||
private val pdfTextBoxRepository = PdfTextBoxRepository(appContext)
|
||||
private val pdfHighlightRepository = PdfHighlightRepository(appContext)
|
||||
|
||||
data class PageModificationResult(
|
||||
val layout: List<VirtualPage>,
|
||||
|
|
@ -918,6 +921,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
annotations: Map<Int, List<PdfAnnotation>>,
|
||||
richTextPageLayouts: List<com.aryan.reader.pdf.PageTextLayout>? = null,
|
||||
textBoxes: List<PdfTextBox>? = null,
|
||||
highlights: List<PdfUserHighlight>? = null,
|
||||
bookId: String
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
|
|
@ -929,13 +933,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
val outputStream = appContext.contentResolver.openOutputStream(destUri)
|
||||
if (outputStream != null) {
|
||||
PdfExporter.exportAnnotatedPdf(
|
||||
appContext,
|
||||
sourceUri,
|
||||
outputStream,
|
||||
virtualPages,
|
||||
annotations,
|
||||
richTextPageLayouts,
|
||||
textBoxes
|
||||
context = appContext,
|
||||
sourceUri = sourceUri,
|
||||
destStream = outputStream,
|
||||
virtualPages = virtualPages,
|
||||
inkAnnotations = annotations,
|
||||
richTextPageLayouts = richTextPageLayouts,
|
||||
textBoxes = textBoxes,
|
||||
highlights = highlights
|
||||
)
|
||||
showBanner("PDF saved successfully.")
|
||||
} else {
|
||||
|
|
@ -978,6 +983,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
annotations: Map<Int, List<PdfAnnotation>>,
|
||||
richTextPageLayouts: List<com.aryan.reader.pdf.PageTextLayout>? = null,
|
||||
textBoxes: List<PdfTextBox>? = null,
|
||||
highlights: List<PdfUserHighlight>? = null,
|
||||
includeAnnotations: Boolean,
|
||||
filename: String,
|
||||
bookId: String? = null
|
||||
|
|
@ -1009,13 +1015,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
val virtualPages = pageLayoutRepository.getLayoutOrNull(resolvedBookId)
|
||||
|
||||
PdfExporter.exportAnnotatedPdf(
|
||||
appContext,
|
||||
sourceUri,
|
||||
outputStream,
|
||||
virtualPages,
|
||||
annotations,
|
||||
richTextPageLayouts,
|
||||
textBoxes
|
||||
context = appContext,
|
||||
sourceUri = sourceUri,
|
||||
destStream = outputStream,
|
||||
virtualPages = virtualPages,
|
||||
inkAnnotations = annotations,
|
||||
richTextPageLayouts = richTextPageLayouts,
|
||||
textBoxes = textBoxes,
|
||||
highlights = highlights
|
||||
)
|
||||
} else {
|
||||
appContext.contentResolver.openInputStream(sourceUri)?.use { input ->
|
||||
|
|
@ -1068,18 +1075,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
val deviceId = getInstallationId()
|
||||
Timber.tag("AnnotationSync").d("Preparing to sync book: ${book.bookId}")
|
||||
|
||||
// --- CHANGED BLOCK START ---
|
||||
// Gather files from all three repositories
|
||||
val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(book.bookId)
|
||||
val richTextFile = pdfRichTextRepository.getFileForSync(book.bookId)
|
||||
val layoutFile = pageLayoutRepository.getLayoutFile(book.bookId)
|
||||
val textBoxFile = pdfTextBoxRepository.getFileForSync(book.bookId)
|
||||
val highlightFile = pdfHighlightRepository.getFileForSync(book.bookId)
|
||||
|
||||
val hasInk = inkFile?.exists() == true
|
||||
val hasRichText = richTextFile.exists()
|
||||
val hasLayout = layoutFile.exists()
|
||||
val hasTextBoxes = textBoxFile.exists()
|
||||
val hasAnyData = hasInk || hasRichText || hasLayout || hasTextBoxes
|
||||
val hasHighlights = highlightFile.exists()
|
||||
val hasAnyData = hasInk || hasRichText || hasLayout || hasTextBoxes || hasHighlights
|
||||
|
||||
if (hasAnyData) {
|
||||
if (googleDriveRepository.hasDrivePermissions(appContext)) {
|
||||
|
|
@ -1089,42 +1096,25 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
val bundleJson = JSONObject()
|
||||
bundleJson.put("version", 2)
|
||||
|
||||
if (hasInk) {
|
||||
fun putJsonSafe(key: String, file: File?) {
|
||||
if (file == null || !file.exists()) return
|
||||
try {
|
||||
val inkContent = inkFile.readText()
|
||||
val jsonArray = JSONArray(inkContent)
|
||||
bundleJson.put("ink", jsonArray)
|
||||
val content = file.readText().trim()
|
||||
if (content.startsWith("[")) {
|
||||
bundleJson.put(key, JSONArray(content))
|
||||
} else if (content.startsWith("{")) {
|
||||
bundleJson.put(key, JSONObject(content))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to parse local ink file")
|
||||
Timber.e(e, "Failed to parse local $key file")
|
||||
}
|
||||
}
|
||||
|
||||
if (hasRichText) {
|
||||
try {
|
||||
val textContent = richTextFile.readText()
|
||||
bundleJson.put("text", JSONArray(textContent))
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasLayout) {
|
||||
try {
|
||||
val layoutContent = layoutFile.readText()
|
||||
bundleJson.put("layout", JSONArray(layoutContent))
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasTextBoxes) {
|
||||
try {
|
||||
val tbContent = textBoxFile.readText()
|
||||
bundleJson.put("textBoxes", JSONArray(tbContent))
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to parse local text box file")
|
||||
}
|
||||
}
|
||||
if (hasInk) putJsonSafe("ink", inkFile)
|
||||
if (hasRichText) putJsonSafe("text", richTextFile)
|
||||
if (hasLayout) putJsonSafe("layout", layoutFile)
|
||||
if (hasTextBoxes) putJsonSafe("textBoxes", textBoxFile)
|
||||
if (hasHighlights) putJsonSafe("highlights", highlightFile)
|
||||
|
||||
val bundleFile =
|
||||
File(appContext.cacheDir, "sync_bundle_${book.bookId}.json")
|
||||
|
|
@ -1139,7 +1129,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
Timber.tag("AnnotationSync")
|
||||
.d("Bundle upload SUCCESS. ID: ${uploaded.id}")
|
||||
} else {
|
||||
Timber.tag("AnnotationSync").e("Bundle upload FAILED.")
|
||||
Timber.tag("AnnotationSync").e("Bundle upload FAILED. Skipping Firestore sync to prevent data loss.")
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1148,12 +1139,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
.d("No local data (ink/text/layout) to upload for ${book.bookId}")
|
||||
}
|
||||
|
||||
val newTimestamp = System.currentTimeMillis()
|
||||
val metadataToSync = book.toBookMetadata().copy(
|
||||
lastModifiedTimestamp = System.currentTimeMillis(),
|
||||
lastModifiedTimestamp = newTimestamp,
|
||||
hasAnnotations = hasAnyData
|
||||
)
|
||||
|
||||
firestoreRepository.syncBookMetadata(currentUser.uid, metadataToSync, deviceId)
|
||||
recentFilesRepository.addRecentFile(book.copy(lastModifiedTimestamp = newTimestamp))
|
||||
Timber.tag("AnnotationSync")
|
||||
.d("Firestore metadata updated for ${book.bookId} (hasData=$hasAnyData)")
|
||||
} catch (e: Exception) {
|
||||
|
|
@ -1410,19 +1403,19 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
}
|
||||
|
||||
fun syncFolderMetadata() {
|
||||
triggerFolderSyncWorker(metadataOnly = true)
|
||||
fun syncFolderMetadata(showFeedback: Boolean = false) {
|
||||
triggerFolderSyncWorker(metadataOnly = true, showFeedback = showFeedback)
|
||||
}
|
||||
|
||||
fun scanSyncedFolder() {
|
||||
triggerFolderSyncWorker(metadataOnly = false)
|
||||
triggerFolderSyncWorker(metadataOnly = false, showFeedback = true)
|
||||
}
|
||||
|
||||
private fun triggerFolderSyncWorker(metadataOnly: Boolean) {
|
||||
private fun triggerFolderSyncWorker(metadataOnly: Boolean, showFeedback: Boolean) {
|
||||
val folders = _internalState.value.syncedFolders
|
||||
if (folders.isEmpty()) return
|
||||
|
||||
Timber.tag("FolderSync").d("Requesting folder sync for ${folders.size} folders (metadataOnly=$metadataOnly)")
|
||||
Timber.tag("FolderSync").d("Requesting folder sync for ${folders.size} folders (metadataOnly=$metadataOnly, feedback=$showFeedback)")
|
||||
|
||||
val workManager = WorkManager.getInstance(appContext)
|
||||
val data = androidx.work.Data.Builder()
|
||||
|
|
@ -1444,18 +1437,20 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
if (workInfo != null) {
|
||||
when (workInfo.state) {
|
||||
WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED -> {
|
||||
val msg = if (metadataOnly) "Folder Sync: Updating metadata..." else "Scanning folder for new books..."
|
||||
_internalState.update { it.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = true,
|
||||
bannerMessage = BannerMessage(msg)
|
||||
) }
|
||||
if (showFeedback) {
|
||||
val msg = if (metadataOnly) "Folder Sync: Updating metadata..." else "Scanning folder for new books..."
|
||||
_internalState.update { it.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = true,
|
||||
bannerMessage = BannerMessage(msg)
|
||||
) }
|
||||
}
|
||||
}
|
||||
WorkInfo.State.SUCCEEDED -> {
|
||||
_internalState.update { it.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
bannerMessage = BannerMessage("Folder Sync: Scan complete."),
|
||||
bannerMessage = if (showFeedback) BannerMessage("Folder Sync: Scan complete.") else it.bannerMessage,
|
||||
lastFolderScanTime = System.currentTimeMillis()
|
||||
) }
|
||||
}
|
||||
|
|
@ -1463,7 +1458,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
_internalState.update { it.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
errorMessage = "Sync failed."
|
||||
errorMessage = if (showFeedback) "Sync failed." else it.errorMessage
|
||||
) }
|
||||
}
|
||||
else -> Unit
|
||||
|
|
@ -1917,12 +1912,23 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
)
|
||||
}
|
||||
|
||||
val annotationFile =
|
||||
pdfAnnotationRepository.getAnnotationFileForSync(bookId)
|
||||
val localFileMissing = annotationFile == null
|
||||
val fileLastModified = annotationFile?.lastModified() ?: 0L
|
||||
val isFileStale =
|
||||
remote.hasAnnotations && (remote.lastModifiedTimestamp > fileLastModified)
|
||||
val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId)
|
||||
val richTextFile = pdfRichTextRepository.getFileForSync(bookId)
|
||||
val layoutFile = pageLayoutRepository.getLayoutFile(bookId)
|
||||
val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId)
|
||||
val highlightFile = pdfHighlightRepository.getFileForSync(bookId)
|
||||
|
||||
val anyLocalFileExists = (inkFile?.exists() == true) || richTextFile.exists() || layoutFile.exists() || textBoxFile.exists() || highlightFile.exists()
|
||||
val localFileMissing = !anyLocalFileExists
|
||||
|
||||
val fileLastModified = maxOf(
|
||||
inkFile?.lastModified() ?: 0L,
|
||||
richTextFile.lastModified(),
|
||||
layoutFile.lastModified(),
|
||||
textBoxFile.lastModified(),
|
||||
highlightFile.lastModified()
|
||||
)
|
||||
val isFileStale = remote.hasAnnotations && (remote.lastModifiedTimestamp > fileLastModified)
|
||||
|
||||
if (isMetadataNewer || localFileMissing && remote.hasAnnotations || isFileStale) {
|
||||
Timber.tag("AnnotationSync").d("Triggering download for $bookId.")
|
||||
|
|
@ -2091,43 +2097,31 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
val richTextFile = pdfRichTextRepository.getFileForSync(bookId)
|
||||
val layoutFile = pageLayoutRepository.getLayoutFile(bookId)
|
||||
val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId)
|
||||
val highlightFile = pdfHighlightRepository.getFileForSync(bookId)
|
||||
|
||||
// Ensure directories exist
|
||||
inkFile.parentFile?.mkdirs()
|
||||
richTextFile.parentFile?.mkdirs()
|
||||
layoutFile.parentFile?.mkdirs()
|
||||
textBoxFile.parentFile?.mkdirs()
|
||||
highlightFile.parentFile?.mkdirs()
|
||||
|
||||
if (isBundle) {
|
||||
val bundle = JSONObject(jsonString)
|
||||
|
||||
// 1. Ink
|
||||
if (bundle.has("ink")) {
|
||||
inkFile.writeText(bundle.getJSONArray("ink").toString())
|
||||
} else {
|
||||
// If bundle exists but no ink key, implies ink was deleted or empty
|
||||
if (inkFile.exists()) inkFile.delete()
|
||||
fun writeSafe(key: String, file: File) {
|
||||
if (bundle.has(key)) {
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText(bundle.get(key).toString())
|
||||
} else {
|
||||
if (file.exists()) file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Text
|
||||
if (bundle.has("text")) {
|
||||
richTextFile.writeText(bundle.getJSONArray("text").toString())
|
||||
} else {
|
||||
if (richTextFile.exists()) richTextFile.delete()
|
||||
}
|
||||
|
||||
// 3. Layout
|
||||
if (bundle.has("layout")) {
|
||||
layoutFile.writeText(bundle.getJSONArray("layout").toString())
|
||||
} else {
|
||||
if (layoutFile.exists()) layoutFile.delete()
|
||||
}
|
||||
|
||||
// 4. Text Boxes
|
||||
if (bundle.has("textBoxes")) {
|
||||
textBoxFile.writeText(bundle.getJSONArray("textBoxes").toString())
|
||||
} else {
|
||||
if (textBoxFile.exists()) textBoxFile.delete()
|
||||
}
|
||||
writeSafe("ink", inkFile)
|
||||
writeSafe("text", richTextFile)
|
||||
writeSafe("layout", layoutFile)
|
||||
writeSafe("textBoxes", textBoxFile)
|
||||
writeSafe("highlights", highlightFile)
|
||||
|
||||
Timber.tag("AnnotationSync").d("Unpacked unified bundle.")
|
||||
} else {
|
||||
|
|
@ -2751,7 +2745,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
|
||||
if (hasFolder) {
|
||||
syncFolderMetadata()
|
||||
syncFolderMetadata(showFeedback = true)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Refresh failed")
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ class RecentFilesRepository(private val context: Context) {
|
|||
private val pdfRichTextRepository = PdfRichTextRepository(context)
|
||||
private val pageLayoutRepository = PageLayoutRepository(context)
|
||||
private val pdfTextBoxRepository = PdfTextBoxRepository(context)
|
||||
private val pdfHighlightRepository = com.aryan.reader.pdf.data.PdfHighlightRepository(context)
|
||||
|
||||
init {
|
||||
if (!coverCacheDir.exists()) {
|
||||
|
|
@ -87,6 +88,7 @@ class RecentFilesRepository(private val context: Context) {
|
|||
coverCacheDir.deleteRecursively()
|
||||
}
|
||||
coverCacheDir.mkdirs()
|
||||
pdfHighlightRepository.clearAll()
|
||||
Timber.d("Cleared all local book data and cover cache.")
|
||||
}
|
||||
|
||||
|
|
@ -182,15 +184,17 @@ class RecentFilesRepository(private val context: Context) {
|
|||
val richTextFile = pdfRichTextRepository.getFileForSync(bookId)
|
||||
val layoutFile = pageLayoutRepository.getLayoutFile(bookId)
|
||||
val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId)
|
||||
val highlightFile = pdfHighlightRepository.getFileForSync(bookId)
|
||||
|
||||
val hasInk = inkFile?.exists() == true
|
||||
val hasRichText = richTextFile.exists()
|
||||
val hasLayout = layoutFile.exists()
|
||||
val hasTextBoxes = textBoxFile.exists()
|
||||
val hasHighlights = highlightFile.exists()
|
||||
|
||||
Timber.tag("FolderAnnotationSync").d("File checks -> hasInk: $hasInk, hasRichText: $hasRichText, hasLayout: $hasLayout, hasTextBoxes: $hasTextBoxes")
|
||||
Timber.tag("FolderAnnotationSync").d("File checks -> hasInk: $hasInk, hasRichText: $hasRichText, hasLayout: $hasLayout, hasTextBoxes: $hasTextBoxes, hasHighlights: $hasHighlights")
|
||||
|
||||
if (!hasInk && !hasRichText && !hasLayout && !hasTextBoxes) {
|
||||
if (!hasInk && !hasRichText && !hasLayout && !hasTextBoxes && !hasHighlights) {
|
||||
Timber.tag("FolderAnnotationSync").d("No annotations found locally for bookId: $bookId. Aborting sync.")
|
||||
return@withContext
|
||||
}
|
||||
|
|
@ -214,13 +218,15 @@ class RecentFilesRepository(private val context: Context) {
|
|||
if (hasRichText) putJsonSafe("text", richTextFile)
|
||||
if (hasLayout) putJsonSafe("layout", layoutFile)
|
||||
if (hasTextBoxes) putJsonSafe("textBoxes", textBoxFile)
|
||||
if (hasHighlights) putJsonSafe("highlights", highlightFile)
|
||||
|
||||
val tsInk = if(hasInk) inkFile.lastModified() else 0L
|
||||
val tsText = if(hasRichText) richTextFile.lastModified() else 0L
|
||||
val tsLayout = if(hasLayout) layoutFile.lastModified() else 0L
|
||||
val tsBox = if(hasTextBoxes) textBoxFile.lastModified() else 0L
|
||||
val tsHighlight = if(hasHighlights) highlightFile.lastModified() else 0L
|
||||
|
||||
val maxFileTs = maxOf(tsInk, tsText, tsLayout, tsBox)
|
||||
val maxFileTs = maxOf(tsInk, tsText, tsLayout, tsBox, tsHighlight)
|
||||
val finalTs = maxOf(maxFileTs, System.currentTimeMillis())
|
||||
|
||||
Timber.tag("FolderAnnotationSync").d("Pushing annotation bundle for $bookId to folder. finalTs=$finalTs")
|
||||
|
|
@ -263,6 +269,9 @@ class RecentFilesRepository(private val context: Context) {
|
|||
// 4. Text Boxes
|
||||
writeSafe("textBoxes", pdfTextBoxRepository.getFileForSync(bookId))
|
||||
|
||||
// 5. Highlights
|
||||
writeSafe("highlights", pdfHighlightRepository.getFileForSync(bookId))
|
||||
|
||||
Timber.tag("FolderAnnotationSync").i("Successfully imported annotation bundle for $bookId from folder.")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("FolderAnnotationSync").e(e, "Failed to import annotation bundle for $bookId")
|
||||
|
|
@ -294,10 +303,6 @@ class RecentFilesRepository(private val context: Context) {
|
|||
return@withContext recentFileDao.getFolderBooksWithoutCovers().map { it.toRecentFileItem() }
|
||||
}
|
||||
|
||||
suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean) = withContext(Dispatchers.IO) {
|
||||
recentFileDao.updateReflowPreference(bookId, isPreferred)
|
||||
}
|
||||
|
||||
suspend fun detachAllFolderBooks() = withContext(Dispatchers.IO) {
|
||||
recentFileDao.detachAllFolderBooks()
|
||||
Timber.d("Detached all folder books. They are now standard local files.")
|
||||
|
|
|
|||
|
|
@ -155,7 +155,8 @@ object PdfExporter {
|
|||
virtualPages: List<VirtualPage>?,
|
||||
inkAnnotations: Map<Int, List<PdfAnnotation>>,
|
||||
richTextPageLayouts: List<PageTextLayout>? = null,
|
||||
textBoxes: List<PdfTextBox>? = null
|
||||
textBoxes: List<PdfTextBox>? = null,
|
||||
highlights: List<PdfUserHighlight>? = null
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
var sourceDocument: PDDocument? = null
|
||||
|
|
@ -176,6 +177,8 @@ object PdfExporter {
|
|||
if (sourceDocument.numberOfPages > 0) sourceDocument.getPage(0) else null
|
||||
val fontCache = PdfBoxFontCache(destDocument, context)
|
||||
|
||||
Timber.tag("PdfExportDebug").i("Starting export. Total highlights received: ${highlights?.size ?: 0}")
|
||||
|
||||
pagesToProcess.forEachIndexed { virtualIndex, vPage ->
|
||||
val pageToDecorate: PDPage =
|
||||
when (vPage) {
|
||||
|
|
@ -210,6 +213,15 @@ object PdfExporter {
|
|||
val pageHeight = cropBox.height
|
||||
val lowerLeftY = cropBox.lowerLeftY
|
||||
|
||||
val pageHighlights = highlights?.filter { it.pageIndex == virtualIndex }
|
||||
Timber.tag("PdfExportDebug").d("Page $virtualIndex: Found ${pageHighlights?.size ?: 0} highlights to draw.")
|
||||
|
||||
if (!pageHighlights.isNullOrEmpty()) {
|
||||
PDPageContentStream(destDocument, pageToDecorate, PDPageContentStream.AppendMode.APPEND, true, true).use { cs ->
|
||||
drawHighlights(cs, pageHighlights)
|
||||
}
|
||||
}
|
||||
|
||||
if (pageInkAnnos.isNotEmpty()) {
|
||||
val (pencilAnnos, vectorAnnos) =
|
||||
pageInkAnnos.partition { it.inkType == InkType.PENCIL }
|
||||
|
|
@ -270,8 +282,9 @@ object PdfExporter {
|
|||
}
|
||||
}
|
||||
destDocument.save(destStream)
|
||||
Timber.tag("PdfExportDebug").i("Export document saved successfully.")
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Export failed")
|
||||
Timber.tag("PdfExportDebug").e(e, "Export failed during processing")
|
||||
throw e
|
||||
} finally {
|
||||
sourceDocument?.close()
|
||||
|
|
@ -461,6 +474,39 @@ object PdfExporter {
|
|||
}
|
||||
}
|
||||
|
||||
private fun drawHighlights(
|
||||
cs: PDPageContentStream,
|
||||
highlights: List<PdfUserHighlight>
|
||||
) {
|
||||
val gs = PDExtendedGraphicsState()
|
||||
gs.blendMode = BlendMode.MULTIPLY
|
||||
gs.nonStrokingAlphaConstant = 0.4f
|
||||
cs.setGraphicsStateParameters(gs)
|
||||
|
||||
for (highlight in highlights) {
|
||||
val r = highlight.color.color.red
|
||||
val g = highlight.color.color.green
|
||||
val b = highlight.color.color.blue
|
||||
cs.setNonStrokingColor(r, g, b)
|
||||
|
||||
for (rect in highlight.bounds) {
|
||||
val x = minOf(rect.left, rect.right)
|
||||
val y = minOf(rect.top, rect.bottom)
|
||||
val w = kotlin.math.abs(rect.right - rect.left)
|
||||
val h = kotlin.math.abs(rect.top - rect.bottom)
|
||||
|
||||
cs.addRect(x, y, w, h)
|
||||
cs.fill()
|
||||
}
|
||||
}
|
||||
|
||||
// Reset graphics state
|
||||
val resetState = PDExtendedGraphicsState()
|
||||
resetState.blendMode = BlendMode.NORMAL
|
||||
resetState.nonStrokingAlphaConstant = 1.0f
|
||||
cs.setGraphicsStateParameters(resetState)
|
||||
}
|
||||
|
||||
private fun drawPencilOverlay(
|
||||
document: PDDocument,
|
||||
page: PDPage,
|
||||
|
|
|
|||
|
|
@ -21,34 +21,48 @@ package com.aryan.reader.pdf
|
|||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Rect
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import timber.log.Timber
|
||||
import android.graphics.RectF
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CopyAll
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupPositionProvider
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import com.aryan.reader.countWords
|
||||
import io.legere.pdfiumandroid.suspend.PdfTextPageKt
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import com.aryan.reader.OcrEngine
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.pdf.ocr.OcrElement
|
||||
import com.aryan.reader.pdf.ocr.OcrLine
|
||||
import com.aryan.reader.pdf.ocr.OcrResult
|
||||
import com.aryan.reader.pdf.ocr.OcrSymbol
|
||||
import io.legere.pdfiumandroid.suspend.PdfTextPageKt
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
||||
enum class OcrLanguage(val displayName: String) {
|
||||
LATIN("English, Spanish, French, etc."),
|
||||
|
|
@ -64,10 +78,28 @@ internal data class OcrSymbolInfo(
|
|||
val parentLine: OcrLine
|
||||
)
|
||||
|
||||
enum class PdfHighlightColor(val color: Color) {
|
||||
YELLOW(Color(0xFFFBC02D)),
|
||||
GREEN(Color(0xFF388E3C)),
|
||||
BLUE(Color(0xFF1976D2)),
|
||||
RED(Color(0xFFD32F2F));
|
||||
}
|
||||
|
||||
data class PdfUserHighlight(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val pageIndex: Int,
|
||||
val bounds: List<RectF>,
|
||||
val color: PdfHighlightColor,
|
||||
val text: String,
|
||||
val range: Pair<Int, Int>
|
||||
)
|
||||
|
||||
internal data class CustomPdfMenuState(
|
||||
val selectedText: String,
|
||||
val anchorRect: Rect,
|
||||
val charRange: Pair<Int, Int>
|
||||
val charRange: Pair<Int, Int>,
|
||||
val isExistingHighlight: Boolean = false,
|
||||
val highlightId: String? = null
|
||||
)
|
||||
|
||||
internal enum class PdfSelectionMethod {
|
||||
|
|
@ -129,7 +161,9 @@ internal fun PdfSelectionMenuPopup(
|
|||
popupPositionProvider: PopupPositionProvider,
|
||||
onCopy: (String) -> Unit,
|
||||
onAiDefine: (String) -> Unit,
|
||||
onSelectAll: () -> Unit
|
||||
onSelectAll: () -> Unit,
|
||||
onColorSelected: (PdfHighlightColor) -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
Popup(
|
||||
popupPositionProvider = popupPositionProvider,
|
||||
|
|
@ -141,27 +175,117 @@ internal fun PdfSelectionMenuPopup(
|
|||
)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
shadowElevation = 4.dp,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f))
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
shadowElevation = 6.dp,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
Column(
|
||||
modifier = Modifier.width(IntrinsicSize.Max)
|
||||
) {
|
||||
TextButton(onClick = { onCopy(menuState.selectedText) }) {
|
||||
Text("Copy")
|
||||
}
|
||||
if (menuState.selectedText.length <= 2000) {
|
||||
TextButton(onClick = {
|
||||
onAiDefine(menuState.selectedText)
|
||||
}) {
|
||||
Text("Dictionary")
|
||||
// Color Row
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 12.dp, horizontal = 12.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
PdfHighlightColor.entries.forEach { colorEnum ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 6.dp)
|
||||
.size(32.dp)
|
||||
.background(colorEnum.color, CircleShape)
|
||||
.clip(CircleShape)
|
||||
.clickable {
|
||||
Timber.tag("PdfHighlightDebug").d("Color box clicked: $colorEnum")
|
||||
onColorSelected(colorEnum)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onSelectAll) {
|
||||
Text("Select All")
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Delete Option (Only for existing)
|
||||
if (menuState.isExistingHighlight) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onDelete() }
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "Remove",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Remove",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
// Standard Options
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
// Copy
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clickable { onCopy(menuState.selectedText) }
|
||||
.padding(vertical = 12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(Icons.Default.CopyAll, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Text("Copy", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
|
||||
// Dictionary
|
||||
if (menuState.selectedText.length <= 2000) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clickable { onAiDefine(menuState.selectedText) }
|
||||
.padding(vertical = 12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.dictionary),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text("Dictionary", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Select All (Only for new selection)
|
||||
if (!menuState.isExistingHighlight) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clickable { onSelectAll() }
|
||||
.padding(vertical = 12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(painter = painterResource(id = R.drawable.select_all), contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Text("Select All", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -287,3 +411,50 @@ internal fun preprocessTextForTts(rawText: String): ProcessedText {
|
|||
}
|
||||
return ProcessedText(cleanTextBuilder.toString().trim(), indexMap)
|
||||
}
|
||||
|
||||
internal fun mergePdfRectsIntoLines(rects: List<RectF>): List<RectF> {
|
||||
if (rects.isEmpty()) return emptyList()
|
||||
|
||||
val normalized = rects.map { r ->
|
||||
floatArrayOf(
|
||||
minOf(r.left, r.right),
|
||||
minOf(r.top, r.bottom),
|
||||
maxOf(r.left, r.right),
|
||||
maxOf(r.top, r.bottom)
|
||||
)
|
||||
}
|
||||
|
||||
val sorted = normalized.sortedWith(compareBy({ -it[3] }, { it[0] }))
|
||||
|
||||
val merged = mutableListOf<FloatArray>()
|
||||
var current: FloatArray? = null
|
||||
|
||||
for (r in sorted) {
|
||||
if (current == null) {
|
||||
current = r.clone()
|
||||
} else {
|
||||
val cMinY = current[1]
|
||||
val cMaxY = current[3]
|
||||
val rMinY = r[1]
|
||||
val rMaxY = r[3]
|
||||
|
||||
val overlapHeight = minOf(cMaxY, rMaxY) - maxOf(cMinY, rMinY)
|
||||
val minHeight = minOf(cMaxY - cMinY, rMaxY - rMinY)
|
||||
|
||||
if (overlapHeight > 0 && overlapHeight >= minHeight * 0.1f) {
|
||||
current[0] = minOf(current[0], r[0])
|
||||
current[1] = minOf(current[1], r[1])
|
||||
current[2] = maxOf(current[2], r[2])
|
||||
current[3] = maxOf(current[3], r[3])
|
||||
} else {
|
||||
merged.add(current)
|
||||
current = r.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
current?.let { merged.add(it) }
|
||||
|
||||
return merged.map { m ->
|
||||
RectF(m[0], m[3], m[2], m[1])
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -223,7 +223,11 @@ internal fun PdfVerticalReader(
|
|||
autoScrollSpeed: Float = 1.0f,
|
||||
onInteractionListener: () -> Unit = {},
|
||||
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("LIST: PdfVerticalReader Recomposing.") }
|
||||
var globalEraserPosition by remember { mutableStateOf<Offset?>(null) }
|
||||
|
|
@ -1481,6 +1485,10 @@ internal fun PdfVerticalReader(
|
|||
selectedTextBoxId = selectedTextBoxId,
|
||||
onTextBoxChange = onTextBoxChange,
|
||||
onTextBoxSelect = onTextBoxSelect,
|
||||
userHighlights = userHighlights.filter { it.pageIndex == page.index },
|
||||
onHighlightAdd = onHighlightAdd,
|
||||
onHighlightUpdate = onHighlightUpdate,
|
||||
onHighlightDelete = onHighlightDelete,
|
||||
onTextBoxDragStart = { box, localTopLeft, touchOffset ->
|
||||
val currentZoom = zoomAnimatable.value
|
||||
val panX = panXAnimatable.value
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import android.app.Activity
|
|||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import kotlin.math.max
|
||||
import android.graphics.RectF
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
|
|
@ -93,6 +94,7 @@ import androidx.compose.material.icons.filled.ArrowDownward
|
|||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material.icons.filled.Brush
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Fullscreen
|
||||
import androidx.compose.material.icons.filled.FullscreenExit
|
||||
|
|
@ -1013,6 +1015,7 @@ fun PdfViewerScreen(
|
|||
val pdfTextRepository = remember(context) { PdfTextRepository(context) }
|
||||
val annotationRepository = remember(context) { PdfAnnotationRepository(context) }
|
||||
val textBoxRepository = remember(context) { PdfTextBoxRepository(context) }
|
||||
val highlightRepository = remember(context) { com.aryan.reader.pdf.data.PdfHighlightRepository(context) }
|
||||
|
||||
var allAnnotations by remember { mutableStateOf<Map<Int, List<PdfAnnotation>>>(emptyMap()) }
|
||||
|
||||
|
|
@ -1179,6 +1182,90 @@ fun PdfViewerScreen(
|
|||
val textBoxes = remember { mutableStateListOf<PdfTextBox>() }
|
||||
var selectedTextBoxId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val userHighlights = remember { mutableStateListOf<PdfUserHighlight>() }
|
||||
|
||||
val onHighlightAdd = remember(pdfDocument, currentBookId) {
|
||||
{ pageIndex: Int, range: Pair<Int, Int>, text: String, color: PdfHighlightColor ->
|
||||
Timber.tag("PdfExportDebug").i("onHighlightAdd: Adding persistent highlight. Page: $pageIndex, Text: ${text.take(20)}...")
|
||||
coroutineScope.launch {
|
||||
val doc = pdfDocument
|
||||
if (doc == null) {
|
||||
Timber.tag("PdfHighlightDebug").e("onHighlightAdd failed: pdfDocument is null")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val existingOnPage = userHighlights.filter {
|
||||
it.pageIndex == pageIndex && it.color == color
|
||||
}
|
||||
|
||||
var newStart = range.first
|
||||
var newEnd = range.second
|
||||
val highlightsToRemove = mutableListOf<PdfUserHighlight>()
|
||||
|
||||
existingOnPage.forEach { h ->
|
||||
if (max(newStart, h.range.first) <= min(newEnd, h.range.second)) {
|
||||
newStart = min(newStart, h.range.first)
|
||||
newEnd = max(newEnd, h.range.second)
|
||||
highlightsToRemove.add(h)
|
||||
}
|
||||
}
|
||||
|
||||
userHighlights.removeAll(highlightsToRemove)
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
doc.openPage(pageIndex).use { page ->
|
||||
page.openTextPage().use { textPage ->
|
||||
val fullText = textPage.textPageGetText(newStart, newEnd - newStart) ?: text
|
||||
val rects = textPage.textPageGetRectsForRanges(intArrayOf(newStart, newEnd - newStart))
|
||||
|
||||
val rawPdfRects = rects?.map { r -> r.rect } ?: emptyList()
|
||||
val mergedPdfRects = mergePdfRectsIntoLines(rawPdfRects)
|
||||
|
||||
val newHighlight = PdfUserHighlight(
|
||||
pageIndex = pageIndex,
|
||||
bounds = mergedPdfRects,
|
||||
color = color,
|
||||
text = fullText,
|
||||
range = Pair(newStart, newEnd)
|
||||
)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
userHighlights.add(newHighlight)
|
||||
Timber.tag("PdfExportDebug").d("userHighlights now contains ${userHighlights.size} items.")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("PdfHighlightDebug").e(e, "Failed to create highlight")
|
||||
}
|
||||
}
|
||||
}
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
val onHighlightUpdate = remember {
|
||||
{ id: String, newColor: PdfHighlightColor ->
|
||||
Timber.tag("PdfHighlightDebug").d("onHighlightUpdate triggered: id=$id, newColor=$newColor")
|
||||
val index = userHighlights.indexOfFirst { it.id == id }
|
||||
if (index != -1) {
|
||||
val old = userHighlights[index]
|
||||
userHighlights[index] = old.copy(color = newColor)
|
||||
Timber.tag("PdfHighlightDebug").d("Highlight successfully updated")
|
||||
} else {
|
||||
Timber.tag("PdfHighlightDebug").w("Highlight update failed: ID $id not found")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val onHighlightDelete = remember {
|
||||
{ id: String ->
|
||||
userHighlights.removeAll { it.id == id }
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
val onInsertPage: () -> Unit = {
|
||||
coroutineScope.launch {
|
||||
val targetIndex = currentPage + 1
|
||||
|
|
@ -1230,6 +1317,18 @@ fun PdfViewerScreen(
|
|||
textBoxes.addAll(shiftedBoxes)
|
||||
}
|
||||
|
||||
val shiftedHighlights = userHighlights.map { highlight ->
|
||||
if (highlight.pageIndex >= targetIndex) {
|
||||
highlight.copy(pageIndex = highlight.pageIndex + 1)
|
||||
} else {
|
||||
highlight
|
||||
}
|
||||
}
|
||||
if (shiftedHighlights != userHighlights.toList()) {
|
||||
userHighlights.clear()
|
||||
userHighlights.addAll(shiftedHighlights)
|
||||
}
|
||||
|
||||
val tempNewPage = VirtualPage.BlankPage(generateShortId(), refWidth, refHeight, wasManuallyAdded = true)
|
||||
val optimisticPages = virtualPages.toMutableList()
|
||||
optimisticPages.add(targetIndex, tempNewPage)
|
||||
|
|
@ -1311,7 +1410,6 @@ fun PdfViewerScreen(
|
|||
if (currentBookId != null && currentPage in virtualPages.indices) {
|
||||
Timber.tag("RichTextMigration").i("DELETE: User requested deletion of page at index $currentPage")
|
||||
|
||||
// Update text boxes: remove those on current page, shift those after
|
||||
val boxesToKeep = textBoxes.filter { it.pageIndex != currentPage }
|
||||
val shiftedBoxes = boxesToKeep.map { box ->
|
||||
if (box.pageIndex > currentPage) {
|
||||
|
|
@ -1323,6 +1421,17 @@ fun PdfViewerScreen(
|
|||
textBoxes.clear()
|
||||
textBoxes.addAll(shiftedBoxes)
|
||||
|
||||
val highlightsToKeep = userHighlights.filter { it.pageIndex != currentPage }
|
||||
val shiftedHighlights = highlightsToKeep.map { highlight ->
|
||||
if (highlight.pageIndex > currentPage) {
|
||||
highlight.copy(pageIndex = highlight.pageIndex - 1)
|
||||
} else {
|
||||
highlight
|
||||
}
|
||||
}
|
||||
userHighlights.clear()
|
||||
userHighlights.addAll(shiftedHighlights)
|
||||
|
||||
val objectList = bookmarks.map { bookmark ->
|
||||
JSONObject().apply {
|
||||
put("pageIndex", bookmark.pageIndex)
|
||||
|
|
@ -1498,7 +1607,8 @@ fun PdfViewerScreen(
|
|||
currentLastIndex > highestRequiredTextPageIndex &&
|
||||
!hasTextOnPage(currentLastIndex) &&
|
||||
allAnnotations[currentLastIndex].isNullOrEmpty() &&
|
||||
textBoxes.none { it.pageIndex == currentLastIndex } // Check for text boxes
|
||||
textBoxes.none { it.pageIndex == currentLastIndex } &&
|
||||
userHighlights.none { it.pageIndex == currentLastIndex }
|
||||
) {
|
||||
Timber.tag("RichTextFlow").i("Auto-pruning empty page at index $currentLastIndex.")
|
||||
pageRemoved = true
|
||||
|
|
@ -1763,6 +1873,10 @@ fun PdfViewerScreen(
|
|||
val loadedBoxes = textBoxRepository.loadTextBoxes(currentBookId!!)
|
||||
textBoxes.clear()
|
||||
textBoxes.addAll(loadedBoxes)
|
||||
|
||||
val loadedHighlights = highlightRepository.loadHighlights(currentBookId!!)
|
||||
userHighlights.clear()
|
||||
userHighlights.addAll(loadedHighlights)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1776,6 +1890,16 @@ fun PdfViewerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(userHighlights.toList()) {
|
||||
if (currentBookId != null) {
|
||||
delay(1000)
|
||||
withContext(Dispatchers.IO) {
|
||||
Timber.d("Auto-saving highlights locally for book $currentBookId")
|
||||
highlightRepository.saveHighlights(currentBookId!!, userHighlights.toList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var pendingSaveMode by remember { mutableStateOf<SaveMode?>(null) }
|
||||
|
||||
val saveLauncher = rememberLauncherForActivityResult(
|
||||
|
|
@ -1788,11 +1912,9 @@ fun PdfViewerScreen(
|
|||
coroutineScope.launch {
|
||||
val currentRichTextLayouts = richTextController?.pageLayouts
|
||||
|
||||
Timber.tag("PdfExportDebug").i("*** EXPORT TRIGGERED FROM VIEWER ***")
|
||||
if (currentRichTextLayouts != null) {
|
||||
Timber.tag("PdfExportDebug").i(
|
||||
"Passing ${currentRichTextLayouts.size} rich text pages."
|
||||
)
|
||||
Timber.tag("PdfExportDebug").i("SAVE TRIGGERED: userHighlights count: ${userHighlights.size}")
|
||||
if (userHighlights.isEmpty()) {
|
||||
Timber.tag("PdfExportDebug").w("Warning: userHighlights is EMPTY during save.")
|
||||
}
|
||||
|
||||
viewModel.savePdfWithAnnotations(
|
||||
|
|
@ -1801,11 +1923,10 @@ fun PdfViewerScreen(
|
|||
annotations = allAnnotations,
|
||||
richTextPageLayouts = currentRichTextLayouts,
|
||||
textBoxes = textBoxes.toList(),
|
||||
highlights = userHighlights.toList(),
|
||||
bookId = currentBookId!!
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Timber.tag("PdfExportDebug").e("Cannot export: currentBookId is null")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1880,6 +2001,7 @@ fun PdfViewerScreen(
|
|||
if (currentBookId != null) {
|
||||
annotationRepository.saveAnnotations(currentBookId!!, allAnnotations)
|
||||
textBoxRepository.saveTextBoxes(currentBookId!!, textBoxes.toList())
|
||||
highlightRepository.saveHighlights(currentBookId!!, userHighlights.toList())
|
||||
}
|
||||
|
||||
val objectList = bookmarks.map { bookmark ->
|
||||
|
|
@ -3054,7 +3176,7 @@ fun PdfViewerScreen(
|
|||
ModalNavigationDrawer(
|
||||
drawerState = drawerState, gesturesEnabled = drawerState.isOpen, drawerContent = {
|
||||
ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) {
|
||||
val drawerPagerState = rememberPagerState(pageCount = { 2 })
|
||||
val drawerPagerState = rememberPagerState(pageCount = { 3 })
|
||||
val drawerScope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
|
|
@ -3074,6 +3196,16 @@ fun PdfViewerScreen(
|
|||
text = { Text("Bookmarks") },
|
||||
modifier = Modifier.testTag("BookmarksTab")
|
||||
)
|
||||
Tab(
|
||||
selected = drawerPagerState.currentPage == 2,
|
||||
onClick = {
|
||||
drawerScope.launch {
|
||||
drawerPagerState.animateScrollToPage(2)
|
||||
}
|
||||
},
|
||||
text = { Text("Highlights") },
|
||||
modifier = Modifier.testTag("HighlightsTab")
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalPager(
|
||||
|
|
@ -3309,6 +3441,101 @@ fun PdfViewerScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
2 -> { // Highlights Page
|
||||
if (userHighlights.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
"You haven't added any highlights yet.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
} else {
|
||||
var showDeleteConfirmDialogFor by remember {
|
||||
mutableStateOf<PdfUserHighlight?>(null)
|
||||
}
|
||||
val sortedHighlights = remember(userHighlights.toList()) {
|
||||
userHighlights.sortedBy { it.pageIndex }
|
||||
}
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
itemsIndexed(
|
||||
items = sortedHighlights,
|
||||
key = { _, highlight -> highlight.id }
|
||||
) { _, highlight ->
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = highlight.text.ifBlank { "Highlighted section" },
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = highlight.color.color.copy(alpha = 0.3f),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Page ${highlight.pageIndex + 1}",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
IconButton(
|
||||
onClick = { showDeleteConfirmDialogFor = highlight }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "Delete highlight",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
coroutineScope.launch {
|
||||
drawerState.close()
|
||||
if (displayMode == DisplayMode.PAGINATION) {
|
||||
pagerState.scrollToPage(highlight.pageIndex)
|
||||
} else {
|
||||
verticalReaderState.scrollToPage(highlight.pageIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
showDeleteConfirmDialogFor?.let { highlightToDelete ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteConfirmDialogFor = null },
|
||||
title = { Text("Delete Highlight?") },
|
||||
text = { Text("Are you sure you want to permanently delete this highlight?") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
userHighlights.removeAll { it.id == highlightToDelete.id }
|
||||
showDeleteConfirmDialogFor = null
|
||||
}
|
||||
) { Text("Delete") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { showDeleteConfirmDialogFor = null }
|
||||
) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3570,12 +3797,10 @@ fun PdfViewerScreen(
|
|||
currentPageScale = newScale
|
||||
}
|
||||
},
|
||||
ttsHighlightData = if (pagerState.currentPage == pageIndex) ttsHighlightData
|
||||
else null,
|
||||
ttsHighlightData = if (pagerState.currentPage == pageIndex) ttsHighlightData else null,
|
||||
searchQuery = searchState.searchQuery,
|
||||
searchHighlightMode = searchHighlightMode,
|
||||
searchResultToHighlight = if (pagerState.currentPage == pageIndex) searchHighlightTarget
|
||||
else null,
|
||||
searchResultToHighlight = if (pagerState.currentPage == pageIndex) searchHighlightTarget else null,
|
||||
ocrHoverHighlights = stableOcrRects,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
showAllTextHighlights = showAllTextHighlights,
|
||||
|
|
@ -3631,6 +3856,10 @@ fun PdfViewerScreen(
|
|||
onOcrModelDownloading = {
|
||||
isOcrModelDownloading = true
|
||||
},
|
||||
userHighlights = userHighlights.filter { it.pageIndex == pageIndex },
|
||||
onHighlightAdd = onHighlightAdd,
|
||||
onHighlightUpdate = onHighlightUpdate,
|
||||
onHighlightDelete = onHighlightDelete,
|
||||
onTwoFingerSwipe = { direction ->
|
||||
coroutineScope.launch {
|
||||
val targetPage =
|
||||
|
|
@ -3956,6 +4185,10 @@ fun PdfViewerScreen(
|
|||
onWordSelectedForAiDefinition = onDictionaryLookupStable,
|
||||
ttsHighlightData = ttsHighlightData,
|
||||
ttsReadingPage = ttsPageData?.pageIndex,
|
||||
userHighlights = userHighlights,
|
||||
onHighlightAdd = onHighlightAdd,
|
||||
onHighlightUpdate = onHighlightUpdate,
|
||||
onHighlightDelete = onHighlightDelete,
|
||||
onLinkClicked = onLinkClickedStable,
|
||||
onInternalLinkClicked = onInternalLinkNavStable,
|
||||
bookmarks = bookmarksHolder,
|
||||
|
|
@ -6103,6 +6336,7 @@ fun PdfViewerScreen(
|
|||
onClick = {
|
||||
showShareDialog = false
|
||||
isShareLoading = true
|
||||
Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${userHighlights.size}")
|
||||
val filename = getSuggestedFilename(
|
||||
originalFileName, isAnnotated = true
|
||||
)
|
||||
|
|
@ -6115,6 +6349,7 @@ fun PdfViewerScreen(
|
|||
annotations = allAnnotations,
|
||||
richTextPageLayouts = currentRichTextLayouts,
|
||||
textBoxes = textBoxes.toList(),
|
||||
highlights = userHighlights.toList(),
|
||||
includeAnnotations = true,
|
||||
filename = filename,
|
||||
bookId = currentBookId
|
||||
|
|
|
|||
|
|
@ -19,12 +19,15 @@
|
|||
*/
|
||||
package com.aryan.reader.pdf.data
|
||||
|
||||
import android.graphics.RectF
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import com.aryan.reader.pdf.AnnotationType
|
||||
import com.aryan.reader.pdf.InkType
|
||||
import com.aryan.reader.pdf.PdfHighlightColor
|
||||
import com.aryan.reader.pdf.PdfPoint
|
||||
import com.aryan.reader.pdf.PdfUserHighlight
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.Locale
|
||||
|
|
@ -208,3 +211,66 @@ object TextBoxSerializer {
|
|||
return result
|
||||
}
|
||||
}
|
||||
|
||||
object HighlightSerializer {
|
||||
fun toJson(highlights: List<PdfUserHighlight>): String {
|
||||
val rootArray = JSONArray()
|
||||
highlights.forEach { h ->
|
||||
val obj = JSONObject()
|
||||
obj.put("id", h.id)
|
||||
obj.put("pageIndex", h.pageIndex)
|
||||
obj.put("color", h.color.name)
|
||||
obj.put("text", h.text)
|
||||
obj.put("rangeStart", h.range.first)
|
||||
obj.put("rangeEnd", h.range.second)
|
||||
|
||||
val boundsArray = JSONArray()
|
||||
h.bounds.forEach { r ->
|
||||
val rObj = JSONObject()
|
||||
rObj.put("left", r.left.toDouble())
|
||||
rObj.put("top", r.top.toDouble())
|
||||
rObj.put("right", r.right.toDouble())
|
||||
rObj.put("bottom", r.bottom.toDouble())
|
||||
boundsArray.put(rObj)
|
||||
}
|
||||
obj.put("bounds", boundsArray)
|
||||
rootArray.put(obj)
|
||||
}
|
||||
return rootArray.toString()
|
||||
}
|
||||
|
||||
fun fromJson(json: String): List<PdfUserHighlight> {
|
||||
val result = mutableListOf<PdfUserHighlight>()
|
||||
if (json.isBlank()) return result
|
||||
try {
|
||||
val rootArray = JSONArray(json)
|
||||
for (i in 0 until rootArray.length()) {
|
||||
val obj = rootArray.getJSONObject(i)
|
||||
val boundsArray = obj.getJSONArray("bounds")
|
||||
val bounds = mutableListOf<RectF>()
|
||||
for (j in 0 until boundsArray.length()) {
|
||||
val rObj = boundsArray.getJSONObject(j)
|
||||
bounds.add(RectF(
|
||||
rObj.getDouble("left").toFloat(),
|
||||
rObj.getDouble("top").toFloat(),
|
||||
rObj.getDouble("right").toFloat(),
|
||||
rObj.getDouble("bottom").toFloat()
|
||||
))
|
||||
}
|
||||
result.add(
|
||||
PdfUserHighlight(
|
||||
id = obj.optString("id", java.util.UUID.randomUUID().toString()),
|
||||
pageIndex = obj.getInt("pageIndex"),
|
||||
bounds = bounds,
|
||||
color = try { PdfHighlightColor.valueOf(obj.getString("color")) } catch(_: Exception) { PdfHighlightColor.YELLOW },
|
||||
text = obj.optString("text", ""),
|
||||
range = Pair(obj.optInt("rangeStart", 0), obj.optInt("rangeEnd", 0))
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
// PdfHighlightRepository.kt
|
||||
package com.aryan.reader.pdf.data
|
||||
|
||||
import android.content.Context
|
||||
import com.aryan.reader.pdf.PdfUserHighlight
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
|
||||
class PdfHighlightRepository(private val context: Context) {
|
||||
|
||||
fun getFileForSync(bookId: String): File {
|
||||
val safeBookId = bookId.replace("/", "_")
|
||||
val dir = File(context.filesDir, "pdf_highlights")
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
return File(dir, "highlights_$safeBookId.json")
|
||||
}
|
||||
|
||||
suspend fun saveHighlights(bookId: String, highlights: List<PdfUserHighlight>) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val file = getFileForSync(bookId)
|
||||
if (highlights.isEmpty()) {
|
||||
if (file.exists()) file.delete()
|
||||
return@withContext
|
||||
}
|
||||
file.writeText(HighlightSerializer.toJson(highlights))
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to save local highlights")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadHighlights(bookId: String): List<PdfUserHighlight> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val file = getFileForSync(bookId)
|
||||
if (file.exists()) {
|
||||
HighlightSerializer.fromJson(file.readText())
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to load local highlights")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAll() = withContext(Dispatchers.IO) {
|
||||
val dir = File(context.filesDir, "pdf_highlights")
|
||||
if (dir.exists()) dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue