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.
Files changed (84) hide show
  1. package/.eslintrc.js +5 -0
  2. package/README.md +148 -0
  3. package/android/build.gradle +55 -0
  4. package/android/src/main/AndroidManifest.xml +2 -0
  5. package/android/src/main/java/expo/modules/rnpdfking/PdfKing.kt +693 -0
  6. package/android/src/main/java/expo/modules/rnpdfking/RnPdfKingModule.kt +163 -0
  7. package/android/src/main/java/expo/modules/rnpdfking/RnPdfKingView.kt +184 -0
  8. package/build/PdfDocument.d.ts +19 -0
  9. package/build/PdfDocument.d.ts.map +1 -0
  10. package/build/PdfDocument.js +81 -0
  11. package/build/PdfDocument.js.map +1 -0
  12. package/build/PdfPage.d.ts +24 -0
  13. package/build/PdfPage.d.ts.map +1 -0
  14. package/build/PdfPage.js +13 -0
  15. package/build/PdfPage.js.map +1 -0
  16. package/build/RnPdfKing.types.d.ts +48 -0
  17. package/build/RnPdfKing.types.d.ts.map +1 -0
  18. package/build/RnPdfKing.types.js +2 -0
  19. package/build/RnPdfKing.types.js.map +1 -0
  20. package/build/RnPdfKingModule.d.ts +13 -0
  21. package/build/RnPdfKingModule.d.ts.map +1 -0
  22. package/build/RnPdfKingModule.js +4 -0
  23. package/build/RnPdfKingModule.js.map +1 -0
  24. package/build/RnPdfKingModule.web.d.ts +13 -0
  25. package/build/RnPdfKingModule.web.d.ts.map +1 -0
  26. package/build/RnPdfKingModule.web.js +21 -0
  27. package/build/RnPdfKingModule.web.js.map +1 -0
  28. package/build/RnPdfKingView.d.ts +4 -0
  29. package/build/RnPdfKingView.d.ts.map +1 -0
  30. package/build/RnPdfKingView.js +7 -0
  31. package/build/RnPdfKingView.js.map +1 -0
  32. package/build/RnPdfKingView.web.d.ts +4 -0
  33. package/build/RnPdfKingView.web.d.ts.map +1 -0
  34. package/build/RnPdfKingView.web.js +7 -0
  35. package/build/RnPdfKingView.web.js.map +1 -0
  36. package/build/ZoomableList.d.ts +37 -0
  37. package/build/ZoomableList.d.ts.map +1 -0
  38. package/build/ZoomableList.js +289 -0
  39. package/build/ZoomableList.js.map +1 -0
  40. package/build/ZoomablePage.d.ts +10 -0
  41. package/build/ZoomablePage.d.ts.map +1 -0
  42. package/build/ZoomablePage.js +15 -0
  43. package/build/ZoomablePage.js.map +1 -0
  44. package/build/ZoomablePdfPage.d.ts +10 -0
  45. package/build/ZoomablePdfPage.d.ts.map +1 -0
  46. package/build/ZoomablePdfPage.js +17 -0
  47. package/build/ZoomablePdfPage.js.map +1 -0
  48. package/build/index.d.ts +8 -0
  49. package/build/index.d.ts.map +1 -0
  50. package/build/index.js +10 -0
  51. package/build/index.js.map +1 -0
  52. package/build/zoom/constants.d.ts +36 -0
  53. package/build/zoom/constants.d.ts.map +1 -0
  54. package/build/zoom/constants.js +36 -0
  55. package/build/zoom/constants.js.map +1 -0
  56. package/build/zoom/index.d.ts +255 -0
  57. package/build/zoom/index.d.ts.map +1 -0
  58. package/build/zoom/index.js +783 -0
  59. package/build/zoom/index.js.map +1 -0
  60. package/build/zoom/utils.d.ts +55 -0
  61. package/build/zoom/utils.d.ts.map +1 -0
  62. package/build/zoom/utils.js +66 -0
  63. package/build/zoom/utils.js.map +1 -0
  64. package/bun.lock +2217 -0
  65. package/expo-module.config.json +9 -0
  66. package/ios/RnPdfKing.podspec +29 -0
  67. package/ios/RnPdfKingModule.swift +48 -0
  68. package/ios/RnPdfKingView.swift +38 -0
  69. package/package.json +45 -0
  70. package/src/PdfDocument.tsx +115 -0
  71. package/src/PdfPage.tsx +57 -0
  72. package/src/RnPdfKing.types.ts +32 -0
  73. package/src/RnPdfKingModule.ts +15 -0
  74. package/src/RnPdfKingModule.web.ts +24 -0
  75. package/src/RnPdfKingView.tsx +11 -0
  76. package/src/RnPdfKingView.web.tsx +15 -0
  77. package/src/ZoomableList.tsx +438 -0
  78. package/src/ZoomablePage.tsx +31 -0
  79. package/src/ZoomablePdfPage.tsx +34 -0
  80. package/src/index.ts +9 -0
  81. package/src/zoom/constants.ts +40 -0
  82. package/src/zoom/index.tsx +1267 -0
  83. package/src/zoom/utils.ts +96 -0
  84. 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
+ }