rn-pdf-king 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.js +5 -0
- package/README.md +148 -0
- package/android/build.gradle +55 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/rnpdfking/PdfKing.kt +693 -0
- package/android/src/main/java/expo/modules/rnpdfking/RnPdfKingModule.kt +163 -0
- package/android/src/main/java/expo/modules/rnpdfking/RnPdfKingView.kt +184 -0
- package/build/PdfDocument.d.ts +19 -0
- package/build/PdfDocument.d.ts.map +1 -0
- package/build/PdfDocument.js +81 -0
- package/build/PdfDocument.js.map +1 -0
- package/build/PdfPage.d.ts +24 -0
- package/build/PdfPage.d.ts.map +1 -0
- package/build/PdfPage.js +13 -0
- package/build/PdfPage.js.map +1 -0
- package/build/RnPdfKing.types.d.ts +48 -0
- package/build/RnPdfKing.types.d.ts.map +1 -0
- package/build/RnPdfKing.types.js +2 -0
- package/build/RnPdfKing.types.js.map +1 -0
- package/build/RnPdfKingModule.d.ts +13 -0
- package/build/RnPdfKingModule.d.ts.map +1 -0
- package/build/RnPdfKingModule.js +4 -0
- package/build/RnPdfKingModule.js.map +1 -0
- package/build/RnPdfKingModule.web.d.ts +13 -0
- package/build/RnPdfKingModule.web.d.ts.map +1 -0
- package/build/RnPdfKingModule.web.js +21 -0
- package/build/RnPdfKingModule.web.js.map +1 -0
- package/build/RnPdfKingView.d.ts +4 -0
- package/build/RnPdfKingView.d.ts.map +1 -0
- package/build/RnPdfKingView.js +7 -0
- package/build/RnPdfKingView.js.map +1 -0
- package/build/RnPdfKingView.web.d.ts +4 -0
- package/build/RnPdfKingView.web.d.ts.map +1 -0
- package/build/RnPdfKingView.web.js +7 -0
- package/build/RnPdfKingView.web.js.map +1 -0
- package/build/ZoomableList.d.ts +37 -0
- package/build/ZoomableList.d.ts.map +1 -0
- package/build/ZoomableList.js +289 -0
- package/build/ZoomableList.js.map +1 -0
- package/build/ZoomablePage.d.ts +10 -0
- package/build/ZoomablePage.d.ts.map +1 -0
- package/build/ZoomablePage.js +15 -0
- package/build/ZoomablePage.js.map +1 -0
- package/build/ZoomablePdfPage.d.ts +10 -0
- package/build/ZoomablePdfPage.d.ts.map +1 -0
- package/build/ZoomablePdfPage.js +17 -0
- package/build/ZoomablePdfPage.js.map +1 -0
- package/build/index.d.ts +8 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +10 -0
- package/build/index.js.map +1 -0
- package/build/zoom/constants.d.ts +36 -0
- package/build/zoom/constants.d.ts.map +1 -0
- package/build/zoom/constants.js +36 -0
- package/build/zoom/constants.js.map +1 -0
- package/build/zoom/index.d.ts +255 -0
- package/build/zoom/index.d.ts.map +1 -0
- package/build/zoom/index.js +783 -0
- package/build/zoom/index.js.map +1 -0
- package/build/zoom/utils.d.ts +55 -0
- package/build/zoom/utils.d.ts.map +1 -0
- package/build/zoom/utils.js +66 -0
- package/build/zoom/utils.js.map +1 -0
- package/bun.lock +2217 -0
- package/expo-module.config.json +9 -0
- package/ios/RnPdfKing.podspec +29 -0
- package/ios/RnPdfKingModule.swift +48 -0
- package/ios/RnPdfKingView.swift +38 -0
- package/package.json +45 -0
- package/src/PdfDocument.tsx +115 -0
- package/src/PdfPage.tsx +57 -0
- package/src/RnPdfKing.types.ts +32 -0
- package/src/RnPdfKingModule.ts +15 -0
- package/src/RnPdfKingModule.web.ts +24 -0
- package/src/RnPdfKingView.tsx +11 -0
- package/src/RnPdfKingView.web.tsx +15 -0
- package/src/ZoomableList.tsx +438 -0
- package/src/ZoomablePage.tsx +31 -0
- package/src/ZoomablePdfPage.tsx +34 -0
- package/src/index.ts +9 -0
- package/src/zoom/constants.ts +40 -0
- package/src/zoom/index.tsx +1267 -0
- package/src/zoom/utils.ts +96 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
package com.mobinx.pdfking
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import android.graphics.Canvas
|
|
6
|
+
import android.graphics.Color
|
|
7
|
+
import android.graphics.Paint
|
|
8
|
+
import android.graphics.PointF
|
|
9
|
+
import android.graphics.Rect
|
|
10
|
+
import android.graphics.RectF
|
|
11
|
+
import android.graphics.pdf.PdfRenderer
|
|
12
|
+
import android.os.ParcelFileDescriptor
|
|
13
|
+
import android.provider.OpenableColumns
|
|
14
|
+
import android.util.AttributeSet
|
|
15
|
+
import android.util.LruCache
|
|
16
|
+
import android.view.GestureDetector
|
|
17
|
+
import android.view.MotionEvent
|
|
18
|
+
import android.view.View
|
|
19
|
+
import android.net.Uri
|
|
20
|
+
import com.tom_roush.pdfbox.android.PDFBoxResourceLoader
|
|
21
|
+
import com.tom_roush.pdfbox.pdmodel.PDDocument
|
|
22
|
+
import com.tom_roush.pdfbox.text.PDFTextStripper
|
|
23
|
+
import com.tom_roush.pdfbox.text.TextPosition
|
|
24
|
+
import java.io.File
|
|
25
|
+
import java.io.IOException
|
|
26
|
+
import kotlin.math.max
|
|
27
|
+
import kotlin.math.min
|
|
28
|
+
import kotlin.math.sqrt
|
|
29
|
+
import kotlin.math.abs
|
|
30
|
+
import kotlinx.coroutines.Dispatchers
|
|
31
|
+
import kotlinx.coroutines.withContext
|
|
32
|
+
import kotlinx.coroutines.sync.Mutex
|
|
33
|
+
import kotlinx.coroutines.sync.withLock
|
|
34
|
+
import kotlinx.coroutines.runBlocking
|
|
35
|
+
|
|
36
|
+
// --- Data Models ---
|
|
37
|
+
|
|
38
|
+
data class TextChar(
|
|
39
|
+
val text: String,
|
|
40
|
+
val rect: RectF,
|
|
41
|
+
val unicode: String
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
data class Highlight(
|
|
45
|
+
val id: String,
|
|
46
|
+
val startIndex: Int,
|
|
47
|
+
val endIndex: Int,
|
|
48
|
+
val color: Int // Changed to Int for legacy Color
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
// --- Internal Text Extractor ---
|
|
52
|
+
|
|
53
|
+
private class InternalPdfTextExtractor : PDFTextStripper() {
|
|
54
|
+
val textChars = mutableListOf<TextChar>()
|
|
55
|
+
|
|
56
|
+
init {
|
|
57
|
+
sortByPosition = true
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@Throws(IOException::class)
|
|
61
|
+
override fun processTextPosition(text: TextPosition) {
|
|
62
|
+
val x = text.xDirAdj
|
|
63
|
+
val y = text.yDirAdj
|
|
64
|
+
val w = text.widthDirAdj
|
|
65
|
+
val h = text.heightDir
|
|
66
|
+
|
|
67
|
+
if (text.unicode != null && text.unicode.isNotEmpty()) {
|
|
68
|
+
textChars.add(TextChar(
|
|
69
|
+
text = text.unicode,
|
|
70
|
+
rect = RectF(x, y, x + w, y + h),
|
|
71
|
+
unicode = text.unicode
|
|
72
|
+
))
|
|
73
|
+
}
|
|
74
|
+
super.processTextPosition(text)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- Main PdfKing Class (Manager) ---
|
|
79
|
+
|
|
80
|
+
class PdfKing(private val context: Context) {
|
|
81
|
+
private var fileDescriptor: ParcelFileDescriptor? = null
|
|
82
|
+
private var pdfRenderer: PdfRenderer? = null
|
|
83
|
+
private var textDocument: PDDocument? = null
|
|
84
|
+
|
|
85
|
+
private val pdfMutex = Mutex()
|
|
86
|
+
private val bitmapCache = object : LruCache<Int, Bitmap>(10) {
|
|
87
|
+
override fun entryRemoved(evicted: Boolean, key: Int?, oldValue: Bitmap?, newValue: Bitmap?) {
|
|
88
|
+
// Don't recycle here to avoid race conditions
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Callbacks
|
|
93
|
+
var onFileLoadStarted: (() -> Unit)? = null
|
|
94
|
+
var onFileLoadSuccess: ((String, String, Int) -> Unit)? = null // Path/UriString, Name, PageCount
|
|
95
|
+
var onUnsupportedFile: (() -> Unit)? = null
|
|
96
|
+
|
|
97
|
+
init {
|
|
98
|
+
PDFBoxResourceLoader.init(context)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- File Picker Integration (Legacy support via manual intent if needed) ---
|
|
102
|
+
// Note: Compose RegisterFilePicker removed. Caller must handle file picking.
|
|
103
|
+
|
|
104
|
+
// Helper to process Uri if passed from Activity
|
|
105
|
+
suspend fun handleUriSelection(uri: Uri) {
|
|
106
|
+
onFileLoadStarted?.invoke()
|
|
107
|
+
var success = false
|
|
108
|
+
var fileName = ""
|
|
109
|
+
var filePath = ""
|
|
110
|
+
var pageCount = 0
|
|
111
|
+
|
|
112
|
+
withContext(Dispatchers.IO) {
|
|
113
|
+
try {
|
|
114
|
+
fileName = resolveDisplayName(uri)
|
|
115
|
+
val cacheFile = File(context.cacheDir, "temp_pdfking_doc.pdf")
|
|
116
|
+
|
|
117
|
+
val inputStream = context.contentResolver.openInputStream(uri)
|
|
118
|
+
if (inputStream != null) {
|
|
119
|
+
inputStream.use { input ->
|
|
120
|
+
cacheFile.outputStream().use { output ->
|
|
121
|
+
input.copyTo(output)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
loadPdf(cacheFile)
|
|
126
|
+
pageCount = getPageCount()
|
|
127
|
+
filePath = cacheFile.absolutePath
|
|
128
|
+
success = true
|
|
129
|
+
}
|
|
130
|
+
} catch (e: Exception) {
|
|
131
|
+
e.printStackTrace()
|
|
132
|
+
success = false
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Callback on Main Thread (assuming caller context)
|
|
137
|
+
// Since we are suspending, we rely on caller to be in coroutine
|
|
138
|
+
withContext(Dispatchers.Main) {
|
|
139
|
+
if (success) {
|
|
140
|
+
onFileLoadSuccess?.invoke(filePath, fileName, pageCount)
|
|
141
|
+
} else {
|
|
142
|
+
onUnsupportedFile?.invoke()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private fun resolveDisplayName(uri: Uri): String {
|
|
148
|
+
var name = "Document.pdf"
|
|
149
|
+
try {
|
|
150
|
+
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
|
151
|
+
if (cursor.moveToFirst()) {
|
|
152
|
+
val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
|
153
|
+
if (index >= 0) {
|
|
154
|
+
name = cursor.getString(index)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} catch (e: Exception) {
|
|
159
|
+
// Ignore
|
|
160
|
+
}
|
|
161
|
+
return name
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- API Methods ---
|
|
165
|
+
|
|
166
|
+
suspend fun loadPdf(pdfFile: File) {
|
|
167
|
+
pdfMutex.withLock {
|
|
168
|
+
closePdf()
|
|
169
|
+
val descriptor = ParcelFileDescriptor.open(pdfFile, ParcelFileDescriptor.MODE_READ_ONLY)
|
|
170
|
+
val renderer = PdfRenderer(descriptor)
|
|
171
|
+
try {
|
|
172
|
+
textDocument = PDDocument.load(pdfFile)
|
|
173
|
+
pdfRenderer = renderer
|
|
174
|
+
fileDescriptor = descriptor
|
|
175
|
+
bitmapCache.evictAll()
|
|
176
|
+
} catch (error: Exception) {
|
|
177
|
+
renderer.close()
|
|
178
|
+
descriptor.close()
|
|
179
|
+
throw error
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
suspend fun getPageCount(): Int = pdfMutex.withLock { pdfRenderer?.pageCount ?: 0 }
|
|
185
|
+
|
|
186
|
+
// Sync version for callers that need it (wrapping suspend)
|
|
187
|
+
fun getPageCountSync(): Int = runBlocking { getPageCount() }
|
|
188
|
+
|
|
189
|
+
suspend fun getPageBitmap(pageNo: Int): Bitmap {
|
|
190
|
+
synchronized(bitmapCache) {
|
|
191
|
+
val cached = bitmapCache.get(pageNo)
|
|
192
|
+
if (cached != null && !cached.isRecycled) return cached
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return withContext(Dispatchers.IO) {
|
|
196
|
+
pdfMutex.withLock {
|
|
197
|
+
synchronized(bitmapCache) {
|
|
198
|
+
val cached = bitmapCache.get(pageNo)
|
|
199
|
+
if (cached != null && !cached.isRecycled) return@withLock cached
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
val renderer = requireNotNull(pdfRenderer) { "No PDF loaded. Please choose a PDF file first." }
|
|
203
|
+
require(pageNo in 1..renderer.pageCount) {
|
|
204
|
+
"Page number must be between 1 and ${renderer.pageCount}."
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
renderer.openPage(pageNo - 1).use { page ->
|
|
208
|
+
val bitmap = Bitmap.createBitmap(page.width, page.height, Bitmap.Config.ARGB_8888)
|
|
209
|
+
bitmap.eraseColor(Color.WHITE)
|
|
210
|
+
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
|
211
|
+
|
|
212
|
+
synchronized(bitmapCache) {
|
|
213
|
+
bitmapCache.put(pageNo, bitmap)
|
|
214
|
+
}
|
|
215
|
+
bitmap
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
suspend fun getTextChars(pageNo: Int): List<TextChar> {
|
|
222
|
+
return withContext(Dispatchers.IO) {
|
|
223
|
+
pdfMutex.withLock {
|
|
224
|
+
val document = requireNotNull(textDocument) { "No PDF loaded. Please choose a PDF file first." }
|
|
225
|
+
require(pageNo in 1..document.numberOfPages) {
|
|
226
|
+
"Page number must be between 1 and ${document.numberOfPages}."
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
val stripper = InternalPdfTextExtractor().apply {
|
|
230
|
+
startPage = pageNo
|
|
231
|
+
endPage = pageNo
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
stripper.getText(document)
|
|
235
|
+
stripper.textChars
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
fun closePdf() {
|
|
241
|
+
try {
|
|
242
|
+
pdfRenderer?.close()
|
|
243
|
+
pdfRenderer = null
|
|
244
|
+
fileDescriptor?.close()
|
|
245
|
+
fileDescriptor = null
|
|
246
|
+
textDocument?.close()
|
|
247
|
+
textDocument = null
|
|
248
|
+
bitmapCache.evictAll()
|
|
249
|
+
} catch(e: Exception) {
|
|
250
|
+
e.printStackTrace()
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// --- PdfKing Manager Singleton ---
|
|
256
|
+
|
|
257
|
+
object PdfKingManager {
|
|
258
|
+
private var instance: PdfKing? = null
|
|
259
|
+
|
|
260
|
+
fun initialize(context: Context) {
|
|
261
|
+
if (instance == null) {
|
|
262
|
+
instance = PdfKing(context.applicationContext)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
fun getInstance(): PdfKing {
|
|
267
|
+
return instance ?: throw IllegalStateException("PdfKingManager not initialized. Call initialize(context) first.")
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// --- Legacy View Implementation ---
|
|
272
|
+
|
|
273
|
+
class PdfPageView @JvmOverloads constructor(
|
|
274
|
+
context: Context,
|
|
275
|
+
attrs: AttributeSet? = null,
|
|
276
|
+
defStyleAttr: Int = 0
|
|
277
|
+
) : View(context, attrs, defStyleAttr) {
|
|
278
|
+
|
|
279
|
+
private var bitmap: Bitmap? = null
|
|
280
|
+
private var textChars: List<TextChar> = emptyList()
|
|
281
|
+
var preDefinedHighlights: List<Highlight> = emptyList()
|
|
282
|
+
set(value) { field = value; invalidate() }
|
|
283
|
+
|
|
284
|
+
// Selection State
|
|
285
|
+
private var selectionStart: Int? = null
|
|
286
|
+
private var selectionEnd: Int? = null
|
|
287
|
+
|
|
288
|
+
// Config
|
|
289
|
+
var handleColor: Int = Color.BLUE
|
|
290
|
+
var selectionColor: Int = Color.argb(77, 0, 0, 255) // 0.3 alpha blue (approx 77/255)
|
|
291
|
+
var selectionEnabled: Boolean = true
|
|
292
|
+
|
|
293
|
+
// Callbacks
|
|
294
|
+
var onSelectionChanged: ((String, Int?, Int?, PointF?, PointF?, PointF?, PointF?) -> Unit)? = null
|
|
295
|
+
var onHighlightClick: ((String) -> Unit)? = null
|
|
296
|
+
var onSelectionStart: (() -> Unit)? = null
|
|
297
|
+
var onSelectionEnd: (() -> Unit)? = null
|
|
298
|
+
|
|
299
|
+
// Internal State
|
|
300
|
+
private var scale: Float = 1f
|
|
301
|
+
private var draggingHandle: HandleType? = null
|
|
302
|
+
private var isInteractingWithSelection: Boolean = false
|
|
303
|
+
private var isPanning: Boolean = false
|
|
304
|
+
private var startRawX: Float = 0f
|
|
305
|
+
private var startRawY: Float = 0f
|
|
306
|
+
private val longPressHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
|
307
|
+
private var longPressRunnable: Runnable? = null
|
|
308
|
+
|
|
309
|
+
// Paints
|
|
310
|
+
private val selectionPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
311
|
+
style = Paint.Style.FILL
|
|
312
|
+
}
|
|
313
|
+
private val highlightPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
314
|
+
style = Paint.Style.FILL
|
|
315
|
+
}
|
|
316
|
+
private val handleLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
317
|
+
style = Paint.Style.STROKE
|
|
318
|
+
strokeWidth = 5f
|
|
319
|
+
}
|
|
320
|
+
private val handleCirclePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
321
|
+
style = Paint.Style.FILL
|
|
322
|
+
}
|
|
323
|
+
private val bitmapPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
|
324
|
+
|
|
325
|
+
private enum class HandleType { START, END }
|
|
326
|
+
private val touchSlop = android.view.ViewConfiguration.get(context).scaledTouchSlop
|
|
327
|
+
|
|
328
|
+
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
|
|
329
|
+
override fun onSingleTapUp(e: MotionEvent): Boolean {
|
|
330
|
+
handleTap(e.x, e.y)
|
|
331
|
+
return true
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
override fun onLongPress(e: MotionEvent) {
|
|
335
|
+
if (!isPanning && selectionEnabled) {
|
|
336
|
+
handleLongPress(e.x, e.y)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
override fun onDown(e: MotionEvent): Boolean = true
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
fun setContent(bmp: Bitmap, chars: List<TextChar>) {
|
|
344
|
+
this.bitmap = bmp
|
|
345
|
+
this.textChars = chars
|
|
346
|
+
requestLayout()
|
|
347
|
+
invalidate()
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
|
351
|
+
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
|
|
352
|
+
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
|
|
353
|
+
|
|
354
|
+
val width = widthSize // Match parent/width constraint
|
|
355
|
+
|
|
356
|
+
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
|
|
357
|
+
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
|
|
358
|
+
|
|
359
|
+
val height: Int
|
|
360
|
+
if (heightMode == MeasureSpec.EXACTLY) {
|
|
361
|
+
height = heightSize
|
|
362
|
+
} else if (bitmap != null && bitmap!!.width > 0) {
|
|
363
|
+
val aspect = bitmap!!.width.toFloat() / bitmap!!.height.toFloat()
|
|
364
|
+
height = (width / aspect).toInt()
|
|
365
|
+
} else {
|
|
366
|
+
// Fallback if no exact height and no bitmap: assume standard A4 ratio (1/1.414)
|
|
367
|
+
height = (width * 1.414).toInt()
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
setMeasuredDimension(width, height)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
override fun onDraw(canvas: Canvas) {
|
|
374
|
+
super.onDraw(canvas)
|
|
375
|
+
val bmp = bitmap ?: return
|
|
376
|
+
|
|
377
|
+
// Calculate scale
|
|
378
|
+
if (bmp.width > 0) {
|
|
379
|
+
scale = width.toFloat() / bmp.width.toFloat()
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Draw Bitmap
|
|
383
|
+
val dstRect = Rect(0, 0, width, height)
|
|
384
|
+
canvas.drawBitmap(bmp, null, dstRect, bitmapPaint)
|
|
385
|
+
|
|
386
|
+
// Draw Pre-defined Highlights
|
|
387
|
+
preDefinedHighlights.forEach { highlight ->
|
|
388
|
+
highlightPaint.color = highlight.color
|
|
389
|
+
drawHighlightRange(canvas, highlight.startIndex, highlight.endIndex, highlightPaint)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Draw Selection
|
|
393
|
+
if (selectionStart != null && selectionEnd != null) {
|
|
394
|
+
selectionPaint.color = selectionColor
|
|
395
|
+
drawHighlightRange(canvas, selectionStart!!, selectionEnd!!, selectionPaint)
|
|
396
|
+
|
|
397
|
+
// Draw Handles
|
|
398
|
+
val s = min(selectionStart!!, selectionEnd!!)
|
|
399
|
+
val e = max(selectionStart!!, selectionEnd!!)
|
|
400
|
+
|
|
401
|
+
handleLinePaint.color = handleColor
|
|
402
|
+
handleCirclePaint.color = handleColor
|
|
403
|
+
|
|
404
|
+
// Only draw start handle if valid char
|
|
405
|
+
if (s in textChars.indices) {
|
|
406
|
+
drawHandle(canvas, s, true)
|
|
407
|
+
}
|
|
408
|
+
// Only draw end handle if valid char
|
|
409
|
+
if (e in textChars.indices) {
|
|
410
|
+
drawHandle(canvas, e, false)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private fun drawHighlightRange(canvas: Canvas, start: Int, end: Int, paint: Paint) {
|
|
416
|
+
val safeStart = max(0, start)
|
|
417
|
+
val safeEnd = min(textChars.size - 1, end)
|
|
418
|
+
|
|
419
|
+
if (safeStart <= safeEnd) {
|
|
420
|
+
for (i in safeStart..safeEnd) {
|
|
421
|
+
if (i in textChars.indices) {
|
|
422
|
+
val r = textChars[i].rect
|
|
423
|
+
val rect = RectF(
|
|
424
|
+
r.left * scale,
|
|
425
|
+
r.top * scale,
|
|
426
|
+
r.right * scale, // r.right is x+w
|
|
427
|
+
r.bottom * scale // r.bottom is y+h
|
|
428
|
+
)
|
|
429
|
+
canvas.drawRect(rect, paint)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private fun drawHandle(canvas: Canvas, index: Int, isStart: Boolean) {
|
|
436
|
+
if (index !in textChars.indices) return
|
|
437
|
+
|
|
438
|
+
val pos = getHandlePosition(index, isStart)
|
|
439
|
+
val r = textChars[index].rect
|
|
440
|
+
val topY = r.top * scale
|
|
441
|
+
|
|
442
|
+
// Line from bottom (pos.y) to top (topY)
|
|
443
|
+
canvas.drawLine(pos.x, pos.y, pos.x, topY, handleLinePaint)
|
|
444
|
+
canvas.drawCircle(pos.x, pos.y, 20f, handleCirclePaint)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private fun getHandlePosition(index: Int, isStart: Boolean): PointF {
|
|
448
|
+
if (index !in textChars.indices) return PointF(0f, 0f)
|
|
449
|
+
val r = textChars[index].rect
|
|
450
|
+
val x = if (isStart) r.left else r.right
|
|
451
|
+
val y = r.bottom
|
|
452
|
+
return PointF(x * scale, y * scale)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private fun getCharIndexAt(x: Float, y: Float): Int? {
|
|
456
|
+
if (textChars.isEmpty()) return null
|
|
457
|
+
val pdfX = x / scale
|
|
458
|
+
val pdfY = y / scale
|
|
459
|
+
val padding = 10f
|
|
460
|
+
|
|
461
|
+
// Exact match
|
|
462
|
+
val exactMatch = textChars.indexOfFirst { char ->
|
|
463
|
+
val r = char.rect
|
|
464
|
+
// rect in textChars is PDF coords
|
|
465
|
+
pdfX >= r.left - padding && pdfX <= r.right + padding &&
|
|
466
|
+
pdfY >= r.top - padding && pdfY <= r.bottom + padding
|
|
467
|
+
}
|
|
468
|
+
if (exactMatch != -1) return exactMatch
|
|
469
|
+
|
|
470
|
+
// Proximity match
|
|
471
|
+
var minDistance = Float.MAX_VALUE
|
|
472
|
+
var closestIndex = -1
|
|
473
|
+
val maxDistSq = 25f * 25f
|
|
474
|
+
|
|
475
|
+
textChars.forEachIndexed { index, char ->
|
|
476
|
+
val cx = char.rect.centerX()
|
|
477
|
+
val cy = char.rect.centerY()
|
|
478
|
+
val dx = cx - pdfX
|
|
479
|
+
val dy = cy - pdfY
|
|
480
|
+
val distSq = dx * dx + dy * dy
|
|
481
|
+
if (distSq < minDistance) {
|
|
482
|
+
minDistance = distSq
|
|
483
|
+
closestIndex = index
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return if (minDistance < maxDistSq) closestIndex else null
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private fun getHighlightAt(index: Int): String? {
|
|
490
|
+
return preDefinedHighlights.firstOrNull { highlight ->
|
|
491
|
+
index >= highlight.startIndex && index <= highlight.endIndex
|
|
492
|
+
}?.id
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private fun updateSelection(s: Int, e: Int) {
|
|
496
|
+
val start = min(s, e)
|
|
497
|
+
val end = max(s, e)
|
|
498
|
+
|
|
499
|
+
if (selectionStart == start && selectionEnd == end) return
|
|
500
|
+
|
|
501
|
+
selectionStart = start
|
|
502
|
+
selectionEnd = end
|
|
503
|
+
|
|
504
|
+
val sb = StringBuilder()
|
|
505
|
+
for (i in start..end) {
|
|
506
|
+
if (i in textChars.indices) {
|
|
507
|
+
sb.append(textChars[i].text)
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
val startChar = textChars.getOrNull(start)
|
|
512
|
+
val endChar = textChars.getOrNull(end)
|
|
513
|
+
|
|
514
|
+
val startPdf = if (startChar != null) PointF(startChar.rect.left, startChar.rect.bottom) else null
|
|
515
|
+
val endPdf = if (endChar != null) PointF(endChar.rect.right, endChar.rect.bottom) else null
|
|
516
|
+
|
|
517
|
+
var startScreen: PointF? = null
|
|
518
|
+
var endScreen: PointF? = null
|
|
519
|
+
|
|
520
|
+
// Screen coords relative to this view
|
|
521
|
+
if (startPdf != null) {
|
|
522
|
+
startScreen = PointF(startPdf.x * scale, startPdf.y * scale)
|
|
523
|
+
}
|
|
524
|
+
if (endPdf != null) {
|
|
525
|
+
endScreen = PointF(endPdf.x * scale, endPdf.y * scale)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
val location = IntArray(2)
|
|
529
|
+
getLocationOnScreen(location)
|
|
530
|
+
if (startScreen != null) {
|
|
531
|
+
startScreen.offset(location[0].toFloat(), location[1].toFloat())
|
|
532
|
+
}
|
|
533
|
+
if (endScreen != null) {
|
|
534
|
+
endScreen.offset(location[0].toFloat(), location[1].toFloat())
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
onSelectionChanged?.invoke(sb.toString(), start, end, startPdf, endPdf, startScreen, endScreen)
|
|
538
|
+
invalidate()
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private fun handleTap(x: Float, y: Float) {
|
|
542
|
+
val index = getCharIndexAt(x, y)
|
|
543
|
+
if (index != null) {
|
|
544
|
+
val highlightId = getHighlightAt(index)
|
|
545
|
+
if (highlightId != null) {
|
|
546
|
+
onHighlightClick?.invoke(highlightId)
|
|
547
|
+
return
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (selectionStart != null || selectionEnd != null) {
|
|
551
|
+
selectionStart = null
|
|
552
|
+
selectionEnd = null
|
|
553
|
+
onSelectionChanged?.invoke("", null, null, null, null, null, null)
|
|
554
|
+
onSelectionEnd?.invoke()
|
|
555
|
+
invalidate()
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private fun handleLongPress(x: Float, y: Float) {
|
|
560
|
+
if (isPanning) return
|
|
561
|
+
val index = getCharIndexAt(x, y) ?: return
|
|
562
|
+
|
|
563
|
+
var s = index
|
|
564
|
+
while (s > 0 && s < textChars.size && textChars[s - 1].text.isNotBlank() && textChars[s - 1].text != " ") {
|
|
565
|
+
s--
|
|
566
|
+
}
|
|
567
|
+
var e = index
|
|
568
|
+
while (e < textChars.size - 1 && textChars[e + 1].text.isNotBlank() && textChars[e + 1].text != " ") {
|
|
569
|
+
e++
|
|
570
|
+
}
|
|
571
|
+
updateSelection(s, e)
|
|
572
|
+
isInteractingWithSelection = true
|
|
573
|
+
onSelectionStart?.invoke()
|
|
574
|
+
parent?.requestDisallowInterceptTouchEvent(true)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
override fun onTouchEvent(event: MotionEvent): Boolean {
|
|
578
|
+
// Multi-touch handling (pinch zoom)
|
|
579
|
+
if (event.pointerCount > 1) {
|
|
580
|
+
if (draggingHandle != null || isInteractingWithSelection) {
|
|
581
|
+
clearSelectionState()
|
|
582
|
+
}
|
|
583
|
+
return false // Let parent handle pinch
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
val gestureHandled = gestureDetector.onTouchEvent(event)
|
|
587
|
+
|
|
588
|
+
when (event.action) {
|
|
589
|
+
MotionEvent.ACTION_DOWN -> {
|
|
590
|
+
startRawX = event.rawX
|
|
591
|
+
startRawY = event.rawY
|
|
592
|
+
isPanning = false
|
|
593
|
+
|
|
594
|
+
val x = event.x
|
|
595
|
+
val y = event.y
|
|
596
|
+
|
|
597
|
+
// Check if we hit a selection handle
|
|
598
|
+
if (selectionEnabled) {
|
|
599
|
+
val s = selectionStart
|
|
600
|
+
val e = selectionEnd
|
|
601
|
+
val touchRadius = 60f
|
|
602
|
+
|
|
603
|
+
if (s != null && s in textChars.indices) {
|
|
604
|
+
val pos = getHandlePosition(s, true)
|
|
605
|
+
if (sqrt((x - pos.x).pow(2) + (y - pos.y).pow(2)) < touchRadius) {
|
|
606
|
+
draggingHandle = HandleType.START
|
|
607
|
+
isInteractingWithSelection = true
|
|
608
|
+
onSelectionStart?.invoke()
|
|
609
|
+
parent?.requestDisallowInterceptTouchEvent(true)
|
|
610
|
+
return true
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (e != null && e in textChars.indices) {
|
|
615
|
+
val pos = getHandlePosition(e, false)
|
|
616
|
+
if (sqrt((x - pos.x).pow(2) + (y - pos.y).pow(2)) < touchRadius) {
|
|
617
|
+
draggingHandle = HandleType.END
|
|
618
|
+
isInteractingWithSelection = true
|
|
619
|
+
onSelectionStart?.invoke()
|
|
620
|
+
parent?.requestDisallowInterceptTouchEvent(true)
|
|
621
|
+
return true
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return true // Always return true on DOWN to receive MOVE/UP and long press
|
|
627
|
+
}
|
|
628
|
+
MotionEvent.ACTION_MOVE -> {
|
|
629
|
+
val dxRaw = abs(event.rawX - startRawX)
|
|
630
|
+
val dyRaw = abs(event.rawY - startRawY)
|
|
631
|
+
|
|
632
|
+
if (dxRaw > touchSlop || dyRaw > touchSlop) {
|
|
633
|
+
if (!isInteractingWithSelection) {
|
|
634
|
+
isPanning = true
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (isInteractingWithSelection && selectionEnabled) {
|
|
639
|
+
val index = getCharIndexAt(event.x, event.y)
|
|
640
|
+
if (index != null) {
|
|
641
|
+
val s = selectionStart ?: index
|
|
642
|
+
val e = selectionEnd ?: index
|
|
643
|
+
|
|
644
|
+
if (draggingHandle == HandleType.START) {
|
|
645
|
+
if (index > e) {
|
|
646
|
+
updateSelection(e, index)
|
|
647
|
+
draggingHandle = HandleType.END
|
|
648
|
+
} else {
|
|
649
|
+
updateSelection(index, e)
|
|
650
|
+
}
|
|
651
|
+
} else {
|
|
652
|
+
if (index < s) {
|
|
653
|
+
updateSelection(index, s)
|
|
654
|
+
draggingHandle = HandleType.START
|
|
655
|
+
} else {
|
|
656
|
+
updateSelection(s, index)
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return true
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// If we are panning and not selecting, return false to encourage parent interception (scrolling)
|
|
664
|
+
return !isPanning
|
|
665
|
+
}
|
|
666
|
+
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
|
667
|
+
if (isInteractingWithSelection) {
|
|
668
|
+
isInteractingWithSelection = false
|
|
669
|
+
draggingHandle = null
|
|
670
|
+
onSelectionEnd?.invoke()
|
|
671
|
+
parent?.requestDisallowInterceptTouchEvent(false)
|
|
672
|
+
return true
|
|
673
|
+
}
|
|
674
|
+
isPanning = false
|
|
675
|
+
return gestureHandled
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return gestureHandled
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
private fun clearSelectionState() {
|
|
682
|
+
draggingHandle = null
|
|
683
|
+
isInteractingWithSelection = false
|
|
684
|
+
selectionStart = null
|
|
685
|
+
selectionEnd = null
|
|
686
|
+
onSelectionChanged?.invoke("", null, null, null, null, null, null)
|
|
687
|
+
onSelectionEnd?.invoke()
|
|
688
|
+
invalidate()
|
|
689
|
+
parent?.requestDisallowInterceptTouchEvent(false)
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
private fun Float.pow(n: Int): Float = Math.pow(this.toDouble(), n.toDouble()).toFloat()
|
|
693
|
+
}
|