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
44
LICENSE
44
LICENSE
|
|
@ -616,4 +616,46 @@ an absolute waiver of all civil liability in connection with the
|
||||||
Program, unless a warranty or assumption of liability accompanies a
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
copy of the Program in return for a fee.
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
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
|
## License
|
||||||
|
|
||||||
This project is licensed under the **GNU Affero General Public License v3.0 (AGPL-3.0)**. See the [LICENSE](LICENSE) file for details.
|
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.paginatedreader.data.BookProcessingWorker
|
||||||
import com.aryan.reader.pdf.PdfCoverGenerator
|
import com.aryan.reader.pdf.PdfCoverGenerator
|
||||||
import com.aryan.reader.pdf.PdfExporter
|
import com.aryan.reader.pdf.PdfExporter
|
||||||
|
import com.aryan.reader.pdf.PdfUserHighlight
|
||||||
import com.aryan.reader.pdf.ReflowWorker
|
import com.aryan.reader.pdf.ReflowWorker
|
||||||
import com.aryan.reader.pdf.data.PageLayoutRepository
|
import com.aryan.reader.pdf.data.PageLayoutRepository
|
||||||
import com.aryan.reader.pdf.data.PdfAnnotation
|
import com.aryan.reader.pdf.data.PdfAnnotation
|
||||||
import com.aryan.reader.pdf.data.PdfAnnotationRepository
|
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.PdfTextBox
|
||||||
import com.aryan.reader.pdf.data.PdfTextBoxRepository
|
import com.aryan.reader.pdf.data.PdfTextBoxRepository
|
||||||
import com.aryan.reader.pdf.data.PdfTextRepository
|
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 pageLayoutRepository = PageLayoutRepository(appContext)
|
||||||
private val pdfRichTextRepository = com.aryan.reader.pdf.PdfRichTextRepository(appContext)
|
private val pdfRichTextRepository = com.aryan.reader.pdf.PdfRichTextRepository(appContext)
|
||||||
private val pdfTextBoxRepository = PdfTextBoxRepository(appContext)
|
private val pdfTextBoxRepository = PdfTextBoxRepository(appContext)
|
||||||
|
private val pdfHighlightRepository = PdfHighlightRepository(appContext)
|
||||||
|
|
||||||
data class PageModificationResult(
|
data class PageModificationResult(
|
||||||
val layout: List<VirtualPage>,
|
val layout: List<VirtualPage>,
|
||||||
|
|
@ -918,6 +921,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
annotations: Map<Int, List<PdfAnnotation>>,
|
annotations: Map<Int, List<PdfAnnotation>>,
|
||||||
richTextPageLayouts: List<com.aryan.reader.pdf.PageTextLayout>? = null,
|
richTextPageLayouts: List<com.aryan.reader.pdf.PageTextLayout>? = null,
|
||||||
textBoxes: List<PdfTextBox>? = null,
|
textBoxes: List<PdfTextBox>? = null,
|
||||||
|
highlights: List<PdfUserHighlight>? = null,
|
||||||
bookId: String
|
bookId: String
|
||||||
) {
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|
@ -929,13 +933,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
val outputStream = appContext.contentResolver.openOutputStream(destUri)
|
val outputStream = appContext.contentResolver.openOutputStream(destUri)
|
||||||
if (outputStream != null) {
|
if (outputStream != null) {
|
||||||
PdfExporter.exportAnnotatedPdf(
|
PdfExporter.exportAnnotatedPdf(
|
||||||
appContext,
|
context = appContext,
|
||||||
sourceUri,
|
sourceUri = sourceUri,
|
||||||
outputStream,
|
destStream = outputStream,
|
||||||
virtualPages,
|
virtualPages = virtualPages,
|
||||||
annotations,
|
inkAnnotations = annotations,
|
||||||
richTextPageLayouts,
|
richTextPageLayouts = richTextPageLayouts,
|
||||||
textBoxes
|
textBoxes = textBoxes,
|
||||||
|
highlights = highlights
|
||||||
)
|
)
|
||||||
showBanner("PDF saved successfully.")
|
showBanner("PDF saved successfully.")
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -978,6 +983,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
annotations: Map<Int, List<PdfAnnotation>>,
|
annotations: Map<Int, List<PdfAnnotation>>,
|
||||||
richTextPageLayouts: List<com.aryan.reader.pdf.PageTextLayout>? = null,
|
richTextPageLayouts: List<com.aryan.reader.pdf.PageTextLayout>? = null,
|
||||||
textBoxes: List<PdfTextBox>? = null,
|
textBoxes: List<PdfTextBox>? = null,
|
||||||
|
highlights: List<PdfUserHighlight>? = null,
|
||||||
includeAnnotations: Boolean,
|
includeAnnotations: Boolean,
|
||||||
filename: String,
|
filename: String,
|
||||||
bookId: String? = null
|
bookId: String? = null
|
||||||
|
|
@ -1009,13 +1015,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
val virtualPages = pageLayoutRepository.getLayoutOrNull(resolvedBookId)
|
val virtualPages = pageLayoutRepository.getLayoutOrNull(resolvedBookId)
|
||||||
|
|
||||||
PdfExporter.exportAnnotatedPdf(
|
PdfExporter.exportAnnotatedPdf(
|
||||||
appContext,
|
context = appContext,
|
||||||
sourceUri,
|
sourceUri = sourceUri,
|
||||||
outputStream,
|
destStream = outputStream,
|
||||||
virtualPages,
|
virtualPages = virtualPages,
|
||||||
annotations,
|
inkAnnotations = annotations,
|
||||||
richTextPageLayouts,
|
richTextPageLayouts = richTextPageLayouts,
|
||||||
textBoxes
|
textBoxes = textBoxes,
|
||||||
|
highlights = highlights
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
appContext.contentResolver.openInputStream(sourceUri)?.use { input ->
|
appContext.contentResolver.openInputStream(sourceUri)?.use { input ->
|
||||||
|
|
@ -1068,18 +1075,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
val deviceId = getInstallationId()
|
val deviceId = getInstallationId()
|
||||||
Timber.tag("AnnotationSync").d("Preparing to sync book: ${book.bookId}")
|
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 inkFile = pdfAnnotationRepository.getAnnotationFileForSync(book.bookId)
|
||||||
val richTextFile = pdfRichTextRepository.getFileForSync(book.bookId)
|
val richTextFile = pdfRichTextRepository.getFileForSync(book.bookId)
|
||||||
val layoutFile = pageLayoutRepository.getLayoutFile(book.bookId)
|
val layoutFile = pageLayoutRepository.getLayoutFile(book.bookId)
|
||||||
val textBoxFile = pdfTextBoxRepository.getFileForSync(book.bookId)
|
val textBoxFile = pdfTextBoxRepository.getFileForSync(book.bookId)
|
||||||
|
val highlightFile = pdfHighlightRepository.getFileForSync(book.bookId)
|
||||||
|
|
||||||
val hasInk = inkFile?.exists() == true
|
val hasInk = inkFile?.exists() == true
|
||||||
val hasRichText = richTextFile.exists()
|
val hasRichText = richTextFile.exists()
|
||||||
val hasLayout = layoutFile.exists()
|
val hasLayout = layoutFile.exists()
|
||||||
val hasTextBoxes = textBoxFile.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 (hasAnyData) {
|
||||||
if (googleDriveRepository.hasDrivePermissions(appContext)) {
|
if (googleDriveRepository.hasDrivePermissions(appContext)) {
|
||||||
|
|
@ -1089,42 +1096,25 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
val bundleJson = JSONObject()
|
val bundleJson = JSONObject()
|
||||||
bundleJson.put("version", 2)
|
bundleJson.put("version", 2)
|
||||||
|
|
||||||
if (hasInk) {
|
fun putJsonSafe(key: String, file: File?) {
|
||||||
|
if (file == null || !file.exists()) return
|
||||||
try {
|
try {
|
||||||
val inkContent = inkFile.readText()
|
val content = file.readText().trim()
|
||||||
val jsonArray = JSONArray(inkContent)
|
if (content.startsWith("[")) {
|
||||||
bundleJson.put("ink", jsonArray)
|
bundleJson.put(key, JSONArray(content))
|
||||||
|
} else if (content.startsWith("{")) {
|
||||||
|
bundleJson.put(key, JSONObject(content))
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Failed to parse local ink file")
|
Timber.e(e, "Failed to parse local $key file")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasRichText) {
|
if (hasInk) putJsonSafe("ink", inkFile)
|
||||||
try {
|
if (hasRichText) putJsonSafe("text", richTextFile)
|
||||||
val textContent = richTextFile.readText()
|
if (hasLayout) putJsonSafe("layout", layoutFile)
|
||||||
bundleJson.put("text", JSONArray(textContent))
|
if (hasTextBoxes) putJsonSafe("textBoxes", textBoxFile)
|
||||||
} catch (e: Exception) {
|
if (hasHighlights) putJsonSafe("highlights", highlightFile)
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val bundleFile =
|
val bundleFile =
|
||||||
File(appContext.cacheDir, "sync_bundle_${book.bookId}.json")
|
File(appContext.cacheDir, "sync_bundle_${book.bookId}.json")
|
||||||
|
|
@ -1139,7 +1129,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
Timber.tag("AnnotationSync")
|
Timber.tag("AnnotationSync")
|
||||||
.d("Bundle upload SUCCESS. ID: ${uploaded.id}")
|
.d("Bundle upload SUCCESS. ID: ${uploaded.id}")
|
||||||
} else {
|
} 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}")
|
.d("No local data (ink/text/layout) to upload for ${book.bookId}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val newTimestamp = System.currentTimeMillis()
|
||||||
val metadataToSync = book.toBookMetadata().copy(
|
val metadataToSync = book.toBookMetadata().copy(
|
||||||
lastModifiedTimestamp = System.currentTimeMillis(),
|
lastModifiedTimestamp = newTimestamp,
|
||||||
hasAnnotations = hasAnyData
|
hasAnnotations = hasAnyData
|
||||||
)
|
)
|
||||||
|
|
||||||
firestoreRepository.syncBookMetadata(currentUser.uid, metadataToSync, deviceId)
|
firestoreRepository.syncBookMetadata(currentUser.uid, metadataToSync, deviceId)
|
||||||
|
recentFilesRepository.addRecentFile(book.copy(lastModifiedTimestamp = newTimestamp))
|
||||||
Timber.tag("AnnotationSync")
|
Timber.tag("AnnotationSync")
|
||||||
.d("Firestore metadata updated for ${book.bookId} (hasData=$hasAnyData)")
|
.d("Firestore metadata updated for ${book.bookId} (hasData=$hasAnyData)")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
@ -1410,19 +1403,19 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun syncFolderMetadata() {
|
fun syncFolderMetadata(showFeedback: Boolean = false) {
|
||||||
triggerFolderSyncWorker(metadataOnly = true)
|
triggerFolderSyncWorker(metadataOnly = true, showFeedback = showFeedback)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun scanSyncedFolder() {
|
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
|
val folders = _internalState.value.syncedFolders
|
||||||
if (folders.isEmpty()) return
|
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 workManager = WorkManager.getInstance(appContext)
|
||||||
val data = androidx.work.Data.Builder()
|
val data = androidx.work.Data.Builder()
|
||||||
|
|
@ -1444,18 +1437,20 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
if (workInfo != null) {
|
if (workInfo != null) {
|
||||||
when (workInfo.state) {
|
when (workInfo.state) {
|
||||||
WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED -> {
|
WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED -> {
|
||||||
val msg = if (metadataOnly) "Folder Sync: Updating metadata..." else "Scanning folder for new books..."
|
if (showFeedback) {
|
||||||
_internalState.update { it.copy(
|
val msg = if (metadataOnly) "Folder Sync: Updating metadata..." else "Scanning folder for new books..."
|
||||||
isLoading = false,
|
_internalState.update { it.copy(
|
||||||
isRefreshing = true,
|
isLoading = false,
|
||||||
bannerMessage = BannerMessage(msg)
|
isRefreshing = true,
|
||||||
) }
|
bannerMessage = BannerMessage(msg)
|
||||||
|
) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
WorkInfo.State.SUCCEEDED -> {
|
WorkInfo.State.SUCCEEDED -> {
|
||||||
_internalState.update { it.copy(
|
_internalState.update { it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isRefreshing = false,
|
isRefreshing = false,
|
||||||
bannerMessage = BannerMessage("Folder Sync: Scan complete."),
|
bannerMessage = if (showFeedback) BannerMessage("Folder Sync: Scan complete.") else it.bannerMessage,
|
||||||
lastFolderScanTime = System.currentTimeMillis()
|
lastFolderScanTime = System.currentTimeMillis()
|
||||||
) }
|
) }
|
||||||
}
|
}
|
||||||
|
|
@ -1463,7 +1458,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
_internalState.update { it.copy(
|
_internalState.update { it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isRefreshing = false,
|
isRefreshing = false,
|
||||||
errorMessage = "Sync failed."
|
errorMessage = if (showFeedback) "Sync failed." else it.errorMessage
|
||||||
) }
|
) }
|
||||||
}
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
|
|
@ -1917,12 +1912,23 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val annotationFile =
|
val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId)
|
||||||
pdfAnnotationRepository.getAnnotationFileForSync(bookId)
|
val richTextFile = pdfRichTextRepository.getFileForSync(bookId)
|
||||||
val localFileMissing = annotationFile == null
|
val layoutFile = pageLayoutRepository.getLayoutFile(bookId)
|
||||||
val fileLastModified = annotationFile?.lastModified() ?: 0L
|
val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId)
|
||||||
val isFileStale =
|
val highlightFile = pdfHighlightRepository.getFileForSync(bookId)
|
||||||
remote.hasAnnotations && (remote.lastModifiedTimestamp > fileLastModified)
|
|
||||||
|
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) {
|
if (isMetadataNewer || localFileMissing && remote.hasAnnotations || isFileStale) {
|
||||||
Timber.tag("AnnotationSync").d("Triggering download for $bookId.")
|
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 richTextFile = pdfRichTextRepository.getFileForSync(bookId)
|
||||||
val layoutFile = pageLayoutRepository.getLayoutFile(bookId)
|
val layoutFile = pageLayoutRepository.getLayoutFile(bookId)
|
||||||
val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId)
|
val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId)
|
||||||
|
val highlightFile = pdfHighlightRepository.getFileForSync(bookId)
|
||||||
|
|
||||||
// Ensure directories exist
|
|
||||||
inkFile.parentFile?.mkdirs()
|
inkFile.parentFile?.mkdirs()
|
||||||
richTextFile.parentFile?.mkdirs()
|
richTextFile.parentFile?.mkdirs()
|
||||||
layoutFile.parentFile?.mkdirs()
|
layoutFile.parentFile?.mkdirs()
|
||||||
|
textBoxFile.parentFile?.mkdirs()
|
||||||
|
highlightFile.parentFile?.mkdirs()
|
||||||
|
|
||||||
if (isBundle) {
|
if (isBundle) {
|
||||||
val bundle = JSONObject(jsonString)
|
val bundle = JSONObject(jsonString)
|
||||||
|
|
||||||
// 1. Ink
|
fun writeSafe(key: String, file: File) {
|
||||||
if (bundle.has("ink")) {
|
if (bundle.has(key)) {
|
||||||
inkFile.writeText(bundle.getJSONArray("ink").toString())
|
file.parentFile?.mkdirs()
|
||||||
} else {
|
file.writeText(bundle.get(key).toString())
|
||||||
// If bundle exists but no ink key, implies ink was deleted or empty
|
} else {
|
||||||
if (inkFile.exists()) inkFile.delete()
|
if (file.exists()) file.delete()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Text
|
writeSafe("ink", inkFile)
|
||||||
if (bundle.has("text")) {
|
writeSafe("text", richTextFile)
|
||||||
richTextFile.writeText(bundle.getJSONArray("text").toString())
|
writeSafe("layout", layoutFile)
|
||||||
} else {
|
writeSafe("textBoxes", textBoxFile)
|
||||||
if (richTextFile.exists()) richTextFile.delete()
|
writeSafe("highlights", highlightFile)
|
||||||
}
|
|
||||||
|
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
|
|
||||||
Timber.tag("AnnotationSync").d("Unpacked unified bundle.")
|
Timber.tag("AnnotationSync").d("Unpacked unified bundle.")
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2751,7 +2745,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasFolder) {
|
if (hasFolder) {
|
||||||
syncFolderMetadata()
|
syncFolderMetadata(showFeedback = true)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Refresh failed")
|
Timber.e(e, "Refresh failed")
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
private val pdfRichTextRepository = PdfRichTextRepository(context)
|
private val pdfRichTextRepository = PdfRichTextRepository(context)
|
||||||
private val pageLayoutRepository = PageLayoutRepository(context)
|
private val pageLayoutRepository = PageLayoutRepository(context)
|
||||||
private val pdfTextBoxRepository = PdfTextBoxRepository(context)
|
private val pdfTextBoxRepository = PdfTextBoxRepository(context)
|
||||||
|
private val pdfHighlightRepository = com.aryan.reader.pdf.data.PdfHighlightRepository(context)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
if (!coverCacheDir.exists()) {
|
if (!coverCacheDir.exists()) {
|
||||||
|
|
@ -87,6 +88,7 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
coverCacheDir.deleteRecursively()
|
coverCacheDir.deleteRecursively()
|
||||||
}
|
}
|
||||||
coverCacheDir.mkdirs()
|
coverCacheDir.mkdirs()
|
||||||
|
pdfHighlightRepository.clearAll()
|
||||||
Timber.d("Cleared all local book data and cover cache.")
|
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 richTextFile = pdfRichTextRepository.getFileForSync(bookId)
|
||||||
val layoutFile = pageLayoutRepository.getLayoutFile(bookId)
|
val layoutFile = pageLayoutRepository.getLayoutFile(bookId)
|
||||||
val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId)
|
val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId)
|
||||||
|
val highlightFile = pdfHighlightRepository.getFileForSync(bookId)
|
||||||
|
|
||||||
val hasInk = inkFile?.exists() == true
|
val hasInk = inkFile?.exists() == true
|
||||||
val hasRichText = richTextFile.exists()
|
val hasRichText = richTextFile.exists()
|
||||||
val hasLayout = layoutFile.exists()
|
val hasLayout = layoutFile.exists()
|
||||||
val hasTextBoxes = textBoxFile.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.")
|
Timber.tag("FolderAnnotationSync").d("No annotations found locally for bookId: $bookId. Aborting sync.")
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
|
|
@ -214,13 +218,15 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
if (hasRichText) putJsonSafe("text", richTextFile)
|
if (hasRichText) putJsonSafe("text", richTextFile)
|
||||||
if (hasLayout) putJsonSafe("layout", layoutFile)
|
if (hasLayout) putJsonSafe("layout", layoutFile)
|
||||||
if (hasTextBoxes) putJsonSafe("textBoxes", textBoxFile)
|
if (hasTextBoxes) putJsonSafe("textBoxes", textBoxFile)
|
||||||
|
if (hasHighlights) putJsonSafe("highlights", highlightFile)
|
||||||
|
|
||||||
val tsInk = if(hasInk) inkFile.lastModified() else 0L
|
val tsInk = if(hasInk) inkFile.lastModified() else 0L
|
||||||
val tsText = if(hasRichText) richTextFile.lastModified() else 0L
|
val tsText = if(hasRichText) richTextFile.lastModified() else 0L
|
||||||
val tsLayout = if(hasLayout) layoutFile.lastModified() else 0L
|
val tsLayout = if(hasLayout) layoutFile.lastModified() else 0L
|
||||||
val tsBox = if(hasTextBoxes) textBoxFile.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())
|
val finalTs = maxOf(maxFileTs, System.currentTimeMillis())
|
||||||
|
|
||||||
Timber.tag("FolderAnnotationSync").d("Pushing annotation bundle for $bookId to folder. finalTs=$finalTs")
|
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
|
// 4. Text Boxes
|
||||||
writeSafe("textBoxes", pdfTextBoxRepository.getFileForSync(bookId))
|
writeSafe("textBoxes", pdfTextBoxRepository.getFileForSync(bookId))
|
||||||
|
|
||||||
|
// 5. Highlights
|
||||||
|
writeSafe("highlights", pdfHighlightRepository.getFileForSync(bookId))
|
||||||
|
|
||||||
Timber.tag("FolderAnnotationSync").i("Successfully imported annotation bundle for $bookId from folder.")
|
Timber.tag("FolderAnnotationSync").i("Successfully imported annotation bundle for $bookId from folder.")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.tag("FolderAnnotationSync").e(e, "Failed to import annotation bundle for $bookId")
|
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() }
|
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) {
|
suspend fun detachAllFolderBooks() = withContext(Dispatchers.IO) {
|
||||||
recentFileDao.detachAllFolderBooks()
|
recentFileDao.detachAllFolderBooks()
|
||||||
Timber.d("Detached all folder books. They are now standard local files.")
|
Timber.d("Detached all folder books. They are now standard local files.")
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,8 @@ object PdfExporter {
|
||||||
virtualPages: List<VirtualPage>?,
|
virtualPages: List<VirtualPage>?,
|
||||||
inkAnnotations: Map<Int, List<PdfAnnotation>>,
|
inkAnnotations: Map<Int, List<PdfAnnotation>>,
|
||||||
richTextPageLayouts: List<PageTextLayout>? = null,
|
richTextPageLayouts: List<PageTextLayout>? = null,
|
||||||
textBoxes: List<PdfTextBox>? = null
|
textBoxes: List<PdfTextBox>? = null,
|
||||||
|
highlights: List<PdfUserHighlight>? = null
|
||||||
) {
|
) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
var sourceDocument: PDDocument? = null
|
var sourceDocument: PDDocument? = null
|
||||||
|
|
@ -176,6 +177,8 @@ object PdfExporter {
|
||||||
if (sourceDocument.numberOfPages > 0) sourceDocument.getPage(0) else null
|
if (sourceDocument.numberOfPages > 0) sourceDocument.getPage(0) else null
|
||||||
val fontCache = PdfBoxFontCache(destDocument, context)
|
val fontCache = PdfBoxFontCache(destDocument, context)
|
||||||
|
|
||||||
|
Timber.tag("PdfExportDebug").i("Starting export. Total highlights received: ${highlights?.size ?: 0}")
|
||||||
|
|
||||||
pagesToProcess.forEachIndexed { virtualIndex, vPage ->
|
pagesToProcess.forEachIndexed { virtualIndex, vPage ->
|
||||||
val pageToDecorate: PDPage =
|
val pageToDecorate: PDPage =
|
||||||
when (vPage) {
|
when (vPage) {
|
||||||
|
|
@ -210,6 +213,15 @@ object PdfExporter {
|
||||||
val pageHeight = cropBox.height
|
val pageHeight = cropBox.height
|
||||||
val lowerLeftY = cropBox.lowerLeftY
|
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()) {
|
if (pageInkAnnos.isNotEmpty()) {
|
||||||
val (pencilAnnos, vectorAnnos) =
|
val (pencilAnnos, vectorAnnos) =
|
||||||
pageInkAnnos.partition { it.inkType == InkType.PENCIL }
|
pageInkAnnos.partition { it.inkType == InkType.PENCIL }
|
||||||
|
|
@ -270,8 +282,9 @@ object PdfExporter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
destDocument.save(destStream)
|
destDocument.save(destStream)
|
||||||
|
Timber.tag("PdfExportDebug").i("Export document saved successfully.")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Export failed")
|
Timber.tag("PdfExportDebug").e(e, "Export failed during processing")
|
||||||
throw e
|
throw e
|
||||||
} finally {
|
} finally {
|
||||||
sourceDocument?.close()
|
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(
|
private fun drawPencilOverlay(
|
||||||
document: PDDocument,
|
document: PDDocument,
|
||||||
page: PDPage,
|
page: PDPage,
|
||||||
|
|
|
||||||
|
|
@ -21,34 +21,48 @@ package com.aryan.reader.pdf
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.os.Handler
|
import android.graphics.RectF
|
||||||
import android.os.Looper
|
|
||||||
import timber.log.Timber
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
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.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.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
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.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.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.unit.dp
|
||||||
import androidx.compose.ui.window.Popup
|
import androidx.compose.ui.window.Popup
|
||||||
import androidx.compose.ui.window.PopupPositionProvider
|
import androidx.compose.ui.window.PopupPositionProvider
|
||||||
import androidx.compose.ui.window.PopupProperties
|
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.OcrEngine
|
||||||
|
import com.aryan.reader.R
|
||||||
import com.aryan.reader.pdf.ocr.OcrElement
|
import com.aryan.reader.pdf.ocr.OcrElement
|
||||||
import com.aryan.reader.pdf.ocr.OcrLine
|
import com.aryan.reader.pdf.ocr.OcrLine
|
||||||
import com.aryan.reader.pdf.ocr.OcrResult
|
import com.aryan.reader.pdf.ocr.OcrResult
|
||||||
import com.aryan.reader.pdf.ocr.OcrSymbol
|
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) {
|
enum class OcrLanguage(val displayName: String) {
|
||||||
LATIN("English, Spanish, French, etc."),
|
LATIN("English, Spanish, French, etc."),
|
||||||
|
|
@ -64,10 +78,28 @@ internal data class OcrSymbolInfo(
|
||||||
val parentLine: OcrLine
|
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(
|
internal data class CustomPdfMenuState(
|
||||||
val selectedText: String,
|
val selectedText: String,
|
||||||
val anchorRect: Rect,
|
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 {
|
internal enum class PdfSelectionMethod {
|
||||||
|
|
@ -129,7 +161,9 @@ internal fun PdfSelectionMenuPopup(
|
||||||
popupPositionProvider: PopupPositionProvider,
|
popupPositionProvider: PopupPositionProvider,
|
||||||
onCopy: (String) -> Unit,
|
onCopy: (String) -> Unit,
|
||||||
onAiDefine: (String) -> Unit,
|
onAiDefine: (String) -> Unit,
|
||||||
onSelectAll: () -> Unit
|
onSelectAll: () -> Unit,
|
||||||
|
onColorSelected: (PdfHighlightColor) -> Unit,
|
||||||
|
onDelete: () -> Unit
|
||||||
) {
|
) {
|
||||||
Popup(
|
Popup(
|
||||||
popupPositionProvider = popupPositionProvider,
|
popupPositionProvider = popupPositionProvider,
|
||||||
|
|
@ -141,27 +175,117 @@ internal fun PdfSelectionMenuPopup(
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
shadowElevation = 4.dp,
|
shadowElevation = 6.dp,
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f))
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||||
) {
|
) {
|
||||||
Row(
|
Column(
|
||||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
modifier = Modifier.width(IntrinsicSize.Max)
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
) {
|
||||||
TextButton(onClick = { onCopy(menuState.selectedText) }) {
|
// Color Row
|
||||||
Text("Copy")
|
Row(
|
||||||
}
|
modifier = Modifier
|
||||||
if (menuState.selectedText.length <= 2000) {
|
.padding(vertical = 12.dp, horizontal = 12.dp)
|
||||||
TextButton(onClick = {
|
.fillMaxWidth(),
|
||||||
onAiDefine(menuState.selectedText)
|
horizontalArrangement = Arrangement.Center,
|
||||||
}) {
|
verticalAlignment = Alignment.CenterVertically
|
||||||
Text("Dictionary")
|
) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -286,4 +410,51 @@ internal fun preprocessTextForTts(rawText: String): ProcessedText {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ProcessedText(cleanTextBuilder.toString().trim(), indexMap)
|
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 allTextPageHighlightColor: Color,
|
||||||
val ttsHighlightColor: Color,
|
val ttsHighlightColor: Color,
|
||||||
val selectionHighlightColor: Color,
|
val selectionHighlightColor: Color,
|
||||||
val pageIndex: Int
|
val pageIndex: Int,
|
||||||
|
val userHighlightScreenRects: StableHolder<List<Pair<PdfUserHighlight, List<Rect>>>>,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
|
|
@ -418,7 +419,11 @@ internal fun PdfPageComposable(
|
||||||
isScrollLocked: Boolean = false,
|
isScrollLocked: Boolean = false,
|
||||||
isVisible: Boolean = true,
|
isVisible: Boolean = true,
|
||||||
isStylusOnlyMode: Boolean = false,
|
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") }
|
SideEffect { Timber.tag("PdfDrawPerf").v("PdfPageComposable Recompose: Page $pageIndex") }
|
||||||
val pdfDocumentItem = pdfDocument.item
|
val pdfDocumentItem = pdfDocument.item
|
||||||
|
|
@ -650,6 +655,39 @@ internal fun PdfPageComposable(
|
||||||
val linkHighlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
|
val linkHighlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
|
||||||
val linkVerticalPaddingPx = remember(density) { with(density) { 10.dp.toPx().toInt() } }
|
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) {
|
LaunchedEffect(pageIndex) {
|
||||||
scale = 1f
|
scale = 1f
|
||||||
offset = Offset.Zero
|
offset = Offset.Zero
|
||||||
|
|
@ -2241,7 +2279,8 @@ internal fun PdfPageComposable(
|
||||||
isVerticalScroll,
|
isVerticalScroll,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
selectedTool,
|
selectedTool,
|
||||||
isStylusOnlyMode
|
isStylusOnlyMode,
|
||||||
|
userHighlightScreenRects
|
||||||
) {
|
) {
|
||||||
val isTapDetectionAllowed = !isEditMode ||
|
val isTapDetectionAllowed = !isEditMode ||
|
||||||
selectedTool == InkType.TEXT ||
|
selectedTool == InkType.TEXT ||
|
||||||
|
|
@ -2250,13 +2289,52 @@ internal fun PdfPageComposable(
|
||||||
if (!isTapDetectionAllowed) return@pointerInput
|
if (!isTapDetectionAllowed) return@pointerInput
|
||||||
|
|
||||||
detectTapGestures(onTap = { tapOffset ->
|
detectTapGestures(onTap = { tapOffset ->
|
||||||
Timber.d(
|
|
||||||
"PdfPageComposable: onTap detected at $tapOffset. isVerticalScroll=$isVerticalScroll"
|
|
||||||
)
|
|
||||||
|
|
||||||
val tapInContentCoords = screenToContentCoordinates(tapOffset)
|
val tapInContentCoords = screenToContentCoordinates(tapOffset)
|
||||||
val tapXInBitmap = tapInContentCoords.x
|
val tapXInBitmap = tapInContentCoords.x
|
||||||
val tapYInBitmap = tapInContentCoords.y
|
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(
|
Timber.d(
|
||||||
"detectTapGestures: Tap at bitmap coords (${tapXInBitmap.toInt()}, ${tapYInBitmap.toInt()})"
|
"detectTapGestures: Tap at bitmap coords (${tapXInBitmap.toInt()}, ${tapYInBitmap.toInt()})"
|
||||||
)
|
)
|
||||||
|
|
@ -3257,7 +3335,8 @@ internal fun PdfPageComposable(
|
||||||
mergedSearchAllRects,
|
mergedSearchAllRects,
|
||||||
searchHighlightMode,
|
searchHighlightMode,
|
||||||
searchFocusedColor,
|
searchFocusedColor,
|
||||||
searchAllColor
|
searchAllColor,
|
||||||
|
userHighlightScreenRects
|
||||||
) {
|
) {
|
||||||
PageSelectionData(
|
PageSelectionData(
|
||||||
pageLinks = StableHolder(pageLinks),
|
pageLinks = StableHolder(pageLinks),
|
||||||
|
|
@ -3281,6 +3360,7 @@ internal fun PdfPageComposable(
|
||||||
mergedSearchFocusedRects = StableHolder(mergedSearchFocusedRects),
|
mergedSearchFocusedRects = StableHolder(mergedSearchFocusedRects),
|
||||||
mergedSearchAllRects = StableHolder(mergedSearchAllRects),
|
mergedSearchAllRects = StableHolder(mergedSearchAllRects),
|
||||||
searchHighlightMode = searchHighlightMode,
|
searchHighlightMode = searchHighlightMode,
|
||||||
|
userHighlightScreenRects = StableHolder(userHighlightScreenRects),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3306,6 +3386,9 @@ internal fun PdfPageComposable(
|
||||||
startHandlePos = startHandleContentPosition.value,
|
startHandlePos = startHandleContentPosition.value,
|
||||||
endHandlePos = endHandleContentPosition.value,
|
endHandlePos = endHandleContentPosition.value,
|
||||||
teardropWidthPx = teardropWidthPxState.value,
|
teardropWidthPx = teardropWidthPxState.value,
|
||||||
|
onHighlightAdd = onHighlightAdd,
|
||||||
|
onHighlightUpdate = onHighlightUpdate,
|
||||||
|
onHighlightDelete = onHighlightDelete,
|
||||||
teardropHeightPx = teardropHeightPxState.value,
|
teardropHeightPx = teardropHeightPxState.value,
|
||||||
activeDraggingHandle = activeDraggingHandle,
|
activeDraggingHandle = activeDraggingHandle,
|
||||||
showMagnifier = showMagnifier,
|
showMagnifier = showMagnifier,
|
||||||
|
|
@ -3594,6 +3677,7 @@ private fun PdfHighlightsLayer(
|
||||||
searchHighlightMode: SearchHighlightMode,
|
searchHighlightMode: SearchHighlightMode,
|
||||||
ocrHoverHighlights: List<RectF>,
|
ocrHoverHighlights: List<RectF>,
|
||||||
mergedSelectionRects: List<Rect>,
|
mergedSelectionRects: List<Rect>,
|
||||||
|
userHighlightScreenRects: List<Pair<PdfUserHighlight, List<Rect>>>,
|
||||||
centeringOffsetX: Float,
|
centeringOffsetX: Float,
|
||||||
centeringOffsetY: Float,
|
centeringOffsetY: Float,
|
||||||
linkHighlightColor: Color,
|
linkHighlightColor: Color,
|
||||||
|
|
@ -3610,8 +3694,9 @@ private fun PdfHighlightsLayer(
|
||||||
fun isVisible(r: Rect): Boolean {
|
fun isVisible(r: Rect): Boolean {
|
||||||
val left = r.left + centeringOffsetX
|
val left = r.left + centeringOffsetX
|
||||||
val right = r.right + centeringOffsetX
|
val right = r.right + centeringOffsetX
|
||||||
|
val top = r.top + centeringOffsetY
|
||||||
val bottom = r.bottom + 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
|
// 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,
|
searchHighlightMode: SearchHighlightMode,
|
||||||
ocrHoverHighlights: List<RectF>,
|
ocrHoverHighlights: List<RectF>,
|
||||||
mergedSelectionRects: List<Rect>,
|
mergedSelectionRects: List<Rect>,
|
||||||
|
userHighlightScreenRects: List<Pair<PdfUserHighlight, List<Rect>>>,
|
||||||
centeringOffsetX: Float,
|
centeringOffsetX: Float,
|
||||||
centeringOffsetY: Float,
|
centeringOffsetY: Float,
|
||||||
linkHighlightColor: Color,
|
linkHighlightColor: Color,
|
||||||
|
|
@ -4101,7 +4200,10 @@ private fun PdfPageSelectionsLayer(
|
||||||
ttsHighlightColor: Color,
|
ttsHighlightColor: Color,
|
||||||
selectionHighlightColor: 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()
|
val highlightStart = System.nanoTime()
|
||||||
|
|
||||||
PdfHighlightsLayer(
|
PdfHighlightsLayer(
|
||||||
|
|
@ -4116,6 +4218,7 @@ private fun PdfPageSelectionsLayer(
|
||||||
searchHighlightMode = searchHighlightMode,
|
searchHighlightMode = searchHighlightMode,
|
||||||
ocrHoverHighlights = ocrHoverHighlights,
|
ocrHoverHighlights = ocrHoverHighlights,
|
||||||
mergedSelectionRects = mergedSelectionRects,
|
mergedSelectionRects = mergedSelectionRects,
|
||||||
|
userHighlightScreenRects = userHighlightScreenRects,
|
||||||
centeringOffsetX = centeringOffsetX,
|
centeringOffsetX = centeringOffsetX,
|
||||||
centeringOffsetY = centeringOffsetY,
|
centeringOffsetY = centeringOffsetY,
|
||||||
linkHighlightColor = linkHighlightColor,
|
linkHighlightColor = linkHighlightColor,
|
||||||
|
|
@ -4182,7 +4285,10 @@ private fun PdfPageRenderer(
|
||||||
onTextBoxDrag: (Offset) -> Unit,
|
onTextBoxDrag: (Offset) -> Unit,
|
||||||
onTextBoxDragEnd: () -> Unit,
|
onTextBoxDragEnd: () -> Unit,
|
||||||
onDragPageTurn: (Int) -> 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(modifier = Modifier.fillMaxSize()) {
|
||||||
Box(
|
Box(
|
||||||
|
|
@ -4215,6 +4321,7 @@ private fun PdfPageRenderer(
|
||||||
searchHighlightMode = selectionData.searchHighlightMode,
|
searchHighlightMode = selectionData.searchHighlightMode,
|
||||||
ocrHoverHighlights = selectionData.ocrHoverHighlights.item,
|
ocrHoverHighlights = selectionData.ocrHoverHighlights.item,
|
||||||
mergedSelectionRects = selectionData.mergedSelectionRects.item,
|
mergedSelectionRects = selectionData.mergedSelectionRects.item,
|
||||||
|
userHighlightScreenRects = selectionData.userHighlightScreenRects.item,
|
||||||
centeringOffsetX = selectionData.centeringOffsetX,
|
centeringOffsetX = selectionData.centeringOffsetX,
|
||||||
centeringOffsetY = selectionData.centeringOffsetY,
|
centeringOffsetY = selectionData.centeringOffsetY,
|
||||||
linkHighlightColor = selectionData.linkHighlightColor,
|
linkHighlightColor = selectionData.linkHighlightColor,
|
||||||
|
|
@ -4363,7 +4470,7 @@ private fun PdfPageRenderer(
|
||||||
staticData.targetHeight.toDp()
|
staticData.targetHeight.toDp()
|
||||||
})) {
|
})) {
|
||||||
Text(
|
Text(
|
||||||
text = "${selectionData.pageIndex + 1}\\$totalPages",
|
text = "${selectionData.pageIndex + 1}/$totalPages",
|
||||||
color = pageNumColor.copy(alpha = 0.5f),
|
color = pageNumColor.copy(alpha = 0.5f),
|
||||||
style = MaterialTheme.typography.labelSmall.copy(
|
style = MaterialTheme.typography.labelSmall.copy(
|
||||||
fontSize = 12.sp, fontWeight = FontWeight.Bold
|
fontSize = 12.sp, fontWeight = FontWeight.Bold
|
||||||
|
|
@ -4559,7 +4666,28 @@ private fun PdfPageRenderer(
|
||||||
popupPositionProvider = popupPositionProvider,
|
popupPositionProvider = popupPositionProvider,
|
||||||
onCopy = onCopy,
|
onCopy = onCopy,
|
||||||
onAiDefine = onAiDefine,
|
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,
|
autoScrollSpeed: Float = 1.0f,
|
||||||
onInteractionListener: () -> Unit = {},
|
onInteractionListener: () -> Unit = {},
|
||||||
isStylusOnlyMode: Boolean = false,
|
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.") }
|
SideEffect { Timber.tag("PdfDrawPerf").v("LIST: PdfVerticalReader Recomposing.") }
|
||||||
var globalEraserPosition by remember { mutableStateOf<Offset?>(null) }
|
var globalEraserPosition by remember { mutableStateOf<Offset?>(null) }
|
||||||
|
|
@ -1481,6 +1485,10 @@ internal fun PdfVerticalReader(
|
||||||
selectedTextBoxId = selectedTextBoxId,
|
selectedTextBoxId = selectedTextBoxId,
|
||||||
onTextBoxChange = onTextBoxChange,
|
onTextBoxChange = onTextBoxChange,
|
||||||
onTextBoxSelect = onTextBoxSelect,
|
onTextBoxSelect = onTextBoxSelect,
|
||||||
|
userHighlights = userHighlights.filter { it.pageIndex == page.index },
|
||||||
|
onHighlightAdd = onHighlightAdd,
|
||||||
|
onHighlightUpdate = onHighlightUpdate,
|
||||||
|
onHighlightDelete = onHighlightDelete,
|
||||||
onTextBoxDragStart = { box, localTopLeft, touchOffset ->
|
onTextBoxDragStart = { box, localTopLeft, touchOffset ->
|
||||||
val currentZoom = zoomAnimatable.value
|
val currentZoom = zoomAnimatable.value
|
||||||
val panX = panXAnimatable.value
|
val panX = panXAnimatable.value
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import kotlin.math.max
|
||||||
import android.graphics.RectF
|
import android.graphics.RectF
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
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.ArrowUpward
|
||||||
import androidx.compose.material.icons.filled.Brush
|
import androidx.compose.material.icons.filled.Brush
|
||||||
import androidx.compose.material.icons.filled.Check
|
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.Edit
|
||||||
import androidx.compose.material.icons.filled.Fullscreen
|
import androidx.compose.material.icons.filled.Fullscreen
|
||||||
import androidx.compose.material.icons.filled.FullscreenExit
|
import androidx.compose.material.icons.filled.FullscreenExit
|
||||||
|
|
@ -1013,6 +1015,7 @@ fun PdfViewerScreen(
|
||||||
val pdfTextRepository = remember(context) { PdfTextRepository(context) }
|
val pdfTextRepository = remember(context) { PdfTextRepository(context) }
|
||||||
val annotationRepository = remember(context) { PdfAnnotationRepository(context) }
|
val annotationRepository = remember(context) { PdfAnnotationRepository(context) }
|
||||||
val textBoxRepository = remember(context) { PdfTextBoxRepository(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()) }
|
var allAnnotations by remember { mutableStateOf<Map<Int, List<PdfAnnotation>>>(emptyMap()) }
|
||||||
|
|
||||||
|
|
@ -1179,6 +1182,90 @@ fun PdfViewerScreen(
|
||||||
val textBoxes = remember { mutableStateListOf<PdfTextBox>() }
|
val textBoxes = remember { mutableStateListOf<PdfTextBox>() }
|
||||||
var selectedTextBoxId by remember { mutableStateOf<String?>(null) }
|
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 = {
|
val onInsertPage: () -> Unit = {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
val targetIndex = currentPage + 1
|
val targetIndex = currentPage + 1
|
||||||
|
|
@ -1230,6 +1317,18 @@ fun PdfViewerScreen(
|
||||||
textBoxes.addAll(shiftedBoxes)
|
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 tempNewPage = VirtualPage.BlankPage(generateShortId(), refWidth, refHeight, wasManuallyAdded = true)
|
||||||
val optimisticPages = virtualPages.toMutableList()
|
val optimisticPages = virtualPages.toMutableList()
|
||||||
optimisticPages.add(targetIndex, tempNewPage)
|
optimisticPages.add(targetIndex, tempNewPage)
|
||||||
|
|
@ -1311,7 +1410,6 @@ fun PdfViewerScreen(
|
||||||
if (currentBookId != null && currentPage in virtualPages.indices) {
|
if (currentBookId != null && currentPage in virtualPages.indices) {
|
||||||
Timber.tag("RichTextMigration").i("DELETE: User requested deletion of page at index $currentPage")
|
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 boxesToKeep = textBoxes.filter { it.pageIndex != currentPage }
|
||||||
val shiftedBoxes = boxesToKeep.map { box ->
|
val shiftedBoxes = boxesToKeep.map { box ->
|
||||||
if (box.pageIndex > currentPage) {
|
if (box.pageIndex > currentPage) {
|
||||||
|
|
@ -1323,6 +1421,17 @@ fun PdfViewerScreen(
|
||||||
textBoxes.clear()
|
textBoxes.clear()
|
||||||
textBoxes.addAll(shiftedBoxes)
|
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 ->
|
val objectList = bookmarks.map { bookmark ->
|
||||||
JSONObject().apply {
|
JSONObject().apply {
|
||||||
put("pageIndex", bookmark.pageIndex)
|
put("pageIndex", bookmark.pageIndex)
|
||||||
|
|
@ -1498,7 +1607,8 @@ fun PdfViewerScreen(
|
||||||
currentLastIndex > highestRequiredTextPageIndex &&
|
currentLastIndex > highestRequiredTextPageIndex &&
|
||||||
!hasTextOnPage(currentLastIndex) &&
|
!hasTextOnPage(currentLastIndex) &&
|
||||||
allAnnotations[currentLastIndex].isNullOrEmpty() &&
|
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.")
|
Timber.tag("RichTextFlow").i("Auto-pruning empty page at index $currentLastIndex.")
|
||||||
pageRemoved = true
|
pageRemoved = true
|
||||||
|
|
@ -1763,6 +1873,10 @@ fun PdfViewerScreen(
|
||||||
val loadedBoxes = textBoxRepository.loadTextBoxes(currentBookId!!)
|
val loadedBoxes = textBoxRepository.loadTextBoxes(currentBookId!!)
|
||||||
textBoxes.clear()
|
textBoxes.clear()
|
||||||
textBoxes.addAll(loadedBoxes)
|
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) }
|
var pendingSaveMode by remember { mutableStateOf<SaveMode?>(null) }
|
||||||
|
|
||||||
val saveLauncher = rememberLauncherForActivityResult(
|
val saveLauncher = rememberLauncherForActivityResult(
|
||||||
|
|
@ -1788,11 +1912,9 @@ fun PdfViewerScreen(
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
val currentRichTextLayouts = richTextController?.pageLayouts
|
val currentRichTextLayouts = richTextController?.pageLayouts
|
||||||
|
|
||||||
Timber.tag("PdfExportDebug").i("*** EXPORT TRIGGERED FROM VIEWER ***")
|
Timber.tag("PdfExportDebug").i("SAVE TRIGGERED: userHighlights count: ${userHighlights.size}")
|
||||||
if (currentRichTextLayouts != null) {
|
if (userHighlights.isEmpty()) {
|
||||||
Timber.tag("PdfExportDebug").i(
|
Timber.tag("PdfExportDebug").w("Warning: userHighlights is EMPTY during save.")
|
||||||
"Passing ${currentRichTextLayouts.size} rich text pages."
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.savePdfWithAnnotations(
|
viewModel.savePdfWithAnnotations(
|
||||||
|
|
@ -1801,11 +1923,10 @@ fun PdfViewerScreen(
|
||||||
annotations = allAnnotations,
|
annotations = allAnnotations,
|
||||||
richTextPageLayouts = currentRichTextLayouts,
|
richTextPageLayouts = currentRichTextLayouts,
|
||||||
textBoxes = textBoxes.toList(),
|
textBoxes = textBoxes.toList(),
|
||||||
|
highlights = userHighlights.toList(),
|
||||||
bookId = currentBookId!!
|
bookId = currentBookId!!
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Timber.tag("PdfExportDebug").e("Cannot export: currentBookId is null")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1880,6 +2001,7 @@ fun PdfViewerScreen(
|
||||||
if (currentBookId != null) {
|
if (currentBookId != null) {
|
||||||
annotationRepository.saveAnnotations(currentBookId!!, allAnnotations)
|
annotationRepository.saveAnnotations(currentBookId!!, allAnnotations)
|
||||||
textBoxRepository.saveTextBoxes(currentBookId!!, textBoxes.toList())
|
textBoxRepository.saveTextBoxes(currentBookId!!, textBoxes.toList())
|
||||||
|
highlightRepository.saveHighlights(currentBookId!!, userHighlights.toList())
|
||||||
}
|
}
|
||||||
|
|
||||||
val objectList = bookmarks.map { bookmark ->
|
val objectList = bookmarks.map { bookmark ->
|
||||||
|
|
@ -3054,7 +3176,7 @@ fun PdfViewerScreen(
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState, gesturesEnabled = drawerState.isOpen, drawerContent = {
|
drawerState = drawerState, gesturesEnabled = drawerState.isOpen, drawerContent = {
|
||||||
ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) {
|
ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) {
|
||||||
val drawerPagerState = rememberPagerState(pageCount = { 2 })
|
val drawerPagerState = rememberPagerState(pageCount = { 3 })
|
||||||
val drawerScope = rememberCoroutineScope()
|
val drawerScope = rememberCoroutineScope()
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
|
@ -3074,6 +3196,16 @@ fun PdfViewerScreen(
|
||||||
text = { Text("Bookmarks") },
|
text = { Text("Bookmarks") },
|
||||||
modifier = Modifier.testTag("BookmarksTab")
|
modifier = Modifier.testTag("BookmarksTab")
|
||||||
)
|
)
|
||||||
|
Tab(
|
||||||
|
selected = drawerPagerState.currentPage == 2,
|
||||||
|
onClick = {
|
||||||
|
drawerScope.launch {
|
||||||
|
drawerPagerState.animateScrollToPage(2)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = { Text("Highlights") },
|
||||||
|
modifier = Modifier.testTag("HighlightsTab")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalPager(
|
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
|
currentPageScale = newScale
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
ttsHighlightData = if (pagerState.currentPage == pageIndex) ttsHighlightData
|
ttsHighlightData = if (pagerState.currentPage == pageIndex) ttsHighlightData else null,
|
||||||
else null,
|
|
||||||
searchQuery = searchState.searchQuery,
|
searchQuery = searchState.searchQuery,
|
||||||
searchHighlightMode = searchHighlightMode,
|
searchHighlightMode = searchHighlightMode,
|
||||||
searchResultToHighlight = if (pagerState.currentPage == pageIndex) searchHighlightTarget
|
searchResultToHighlight = if (pagerState.currentPage == pageIndex) searchHighlightTarget else null,
|
||||||
else null,
|
|
||||||
ocrHoverHighlights = stableOcrRects,
|
ocrHoverHighlights = stableOcrRects,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
showAllTextHighlights = showAllTextHighlights,
|
showAllTextHighlights = showAllTextHighlights,
|
||||||
|
|
@ -3631,6 +3856,10 @@ fun PdfViewerScreen(
|
||||||
onOcrModelDownloading = {
|
onOcrModelDownloading = {
|
||||||
isOcrModelDownloading = true
|
isOcrModelDownloading = true
|
||||||
},
|
},
|
||||||
|
userHighlights = userHighlights.filter { it.pageIndex == pageIndex },
|
||||||
|
onHighlightAdd = onHighlightAdd,
|
||||||
|
onHighlightUpdate = onHighlightUpdate,
|
||||||
|
onHighlightDelete = onHighlightDelete,
|
||||||
onTwoFingerSwipe = { direction ->
|
onTwoFingerSwipe = { direction ->
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
val targetPage =
|
val targetPage =
|
||||||
|
|
@ -3956,6 +4185,10 @@ fun PdfViewerScreen(
|
||||||
onWordSelectedForAiDefinition = onDictionaryLookupStable,
|
onWordSelectedForAiDefinition = onDictionaryLookupStable,
|
||||||
ttsHighlightData = ttsHighlightData,
|
ttsHighlightData = ttsHighlightData,
|
||||||
ttsReadingPage = ttsPageData?.pageIndex,
|
ttsReadingPage = ttsPageData?.pageIndex,
|
||||||
|
userHighlights = userHighlights,
|
||||||
|
onHighlightAdd = onHighlightAdd,
|
||||||
|
onHighlightUpdate = onHighlightUpdate,
|
||||||
|
onHighlightDelete = onHighlightDelete,
|
||||||
onLinkClicked = onLinkClickedStable,
|
onLinkClicked = onLinkClickedStable,
|
||||||
onInternalLinkClicked = onInternalLinkNavStable,
|
onInternalLinkClicked = onInternalLinkNavStable,
|
||||||
bookmarks = bookmarksHolder,
|
bookmarks = bookmarksHolder,
|
||||||
|
|
@ -6103,6 +6336,7 @@ fun PdfViewerScreen(
|
||||||
onClick = {
|
onClick = {
|
||||||
showShareDialog = false
|
showShareDialog = false
|
||||||
isShareLoading = true
|
isShareLoading = true
|
||||||
|
Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${userHighlights.size}")
|
||||||
val filename = getSuggestedFilename(
|
val filename = getSuggestedFilename(
|
||||||
originalFileName, isAnnotated = true
|
originalFileName, isAnnotated = true
|
||||||
)
|
)
|
||||||
|
|
@ -6115,6 +6349,7 @@ fun PdfViewerScreen(
|
||||||
annotations = allAnnotations,
|
annotations = allAnnotations,
|
||||||
richTextPageLayouts = currentRichTextLayouts,
|
richTextPageLayouts = currentRichTextLayouts,
|
||||||
textBoxes = textBoxes.toList(),
|
textBoxes = textBoxes.toList(),
|
||||||
|
highlights = userHighlights.toList(),
|
||||||
includeAnnotations = true,
|
includeAnnotations = true,
|
||||||
filename = filename,
|
filename = filename,
|
||||||
bookId = currentBookId
|
bookId = currentBookId
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,15 @@
|
||||||
*/
|
*/
|
||||||
package com.aryan.reader.pdf.data
|
package com.aryan.reader.pdf.data
|
||||||
|
|
||||||
|
import android.graphics.RectF
|
||||||
import androidx.compose.ui.geometry.Rect
|
import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import com.aryan.reader.pdf.AnnotationType
|
import com.aryan.reader.pdf.AnnotationType
|
||||||
import com.aryan.reader.pdf.InkType
|
import com.aryan.reader.pdf.InkType
|
||||||
|
import com.aryan.reader.pdf.PdfHighlightColor
|
||||||
import com.aryan.reader.pdf.PdfPoint
|
import com.aryan.reader.pdf.PdfPoint
|
||||||
|
import com.aryan.reader.pdf.PdfUserHighlight
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
@ -207,4 +210,67 @@ object TextBoxSerializer {
|
||||||
}
|
}
|
||||||
return result
|
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