@june24/expo-pdf-reader 0.1.25 → 0.1.26

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.
@@ -1,1555 +1,1765 @@
1
- package expo.modules.pdfreader
2
-
3
- import android.content.Context
4
- import android.graphics.*
5
- import android.graphics.pdf.PdfRenderer
6
- import android.os.ParcelFileDescriptor
7
- import android.text.Layout
8
- import android.text.StaticLayout
9
- import android.text.TextPaint
10
- import android.util.Log
11
- import android.view.*
12
- import android.widget.*
13
- import androidx.recyclerview.widget.LinearLayoutManager
14
- import androidx.recyclerview.widget.PagerSnapHelper
15
- import androidx.recyclerview.widget.RecyclerView
16
- import expo.modules.kotlin.AppContext
17
- import expo.modules.kotlin.viewevent.EventDispatcher
18
- import expo.modules.kotlin.views.ExpoView
19
- import kotlinx.coroutines.*
20
- import java.io.File
21
- import java.io.FileOutputStream
22
- import java.net.URL
23
-
24
- // ─────────────────────────────────────────────────────────────────────────────
25
- // Data classes
26
- // ─────────────────────────────────────────────────────────────────────────────
27
-
28
- data class AnnPath(val points: MutableList<PointF> = mutableListOf())
29
-
30
- /**
31
- * Bounds stored in PDF page coordinate space:
32
- * origin = bottom-left of the page, y-axis points UP.
33
- *
34
- * RectF.left = left edge (PDF x)
35
- * RectF.top = lower y in PDF space (screen-bottom side, smaller PDF-y value)
36
- * RectF.right = right edge (PDF x)
37
- * RectF.bottom = upper y in PDF space (screen-top side, larger PDF-y value)
38
- *
39
- * Matches iOS PDFKit: CGRect(x, y=lowerPdfY, width, height).
40
- */
41
- data class PdfAnnotation(
42
- val type: String, // "pen" | "highlighter" | "line" | "text" | "note"
43
- val pageIndex: Int,
44
- var bounds: RectF,
45
- val paths: MutableList<AnnPath> = mutableListOf(),
46
- var color: String = "#FF0000",
47
- var strokeWidth: Float = 2f,
48
- var contents: String = "",
49
- var fontSize: Float = 14f,
50
- var bold: Boolean = false,
51
- var italic: Boolean = false
52
- )
53
-
54
- data class UndoEntry(val annotation: PdfAnnotation)
55
-
56
- // ─────────────────────────────────────────────────────────────────────────────
57
- // Main view
58
- // ─────────────────────────────────────────────────────────────────────────────
59
-
60
- class ExpoPdfReaderView(
61
- context: Context,
62
- appContext: AppContext
63
- ) : ExpoView(context, appContext) {
64
-
65
- // ── Expo Events (ViewEventDelegate — Expo Modules-ын зөв механизм) ────────
66
- val onReady by EventDispatcher()
67
- val onPageChange by EventDispatcher()
68
- val onAnnotationChange by EventDispatcher()
69
- val onUndoRedoStateChange by EventDispatcher()
70
- val onNotePress by EventDispatcher()
71
- val onTextPress by EventDispatcher()
72
-
73
- // ── Coroutines ───────────────────────────────────────────────────────────
74
- private val pdfDispatcher = Dispatchers.IO.limitedParallelism(1)
75
- private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
76
- private var renderJob: Job? = null
77
-
78
- // ── PDF state ────────────────────────────────────────────────────────────
79
- private var fileDescriptor: ParcelFileDescriptor? = null
80
- private var renderer: PdfRenderer? = null
81
- private var pagePdfW = intArrayOf() // PDF points per page
82
- private var pagePdfH = intArrayOf()
83
- var totalPages = 0
84
- private set
85
-
86
- // ── Display state ────────────────────────────────────────────────────────
87
- private var displayMode = "continuous"
88
- private var pendingUrl: String? = null
89
- private var isLayoutReady = false
90
- var currentPageIndex = 0
91
- private set
92
-
93
- // ── Lazy / chunked rendering ──────────────────────────────────────────────
94
- private var renderedUpTo = -1 // last rendered page index (absolute)
95
- private var windowStart = 0 // pageEntries[0]-н absolute page index
96
- private var chunkLoading = false // chunk дуусах хүртэл дахин trigger хийхгүй
97
- private var currentViewWidth = 0 // chunk load-д дахин хэрэглэнэ
98
- private var loadingFooter: View? = null
99
-
100
- companion object {
101
- private const val PAGE_CHUNK = 10 // нэг удаа render хийх хуудасны тоо
102
- private const val MAX_RENDERED = PAGE_CHUNK * 2 // санах ойд байлгах дээд хязгаар (20 хуудас)
103
- }
104
-
105
- // ── Annotation state ─────────────────────────────────────────────────────
106
- val annotationMap = mutableMapOf<Int, MutableList<PdfAnnotation>>()
107
- var activeTool: String? = null
108
- var strokeColorVal = "#FF0000"
109
- var strokeWidthVal = 2f
110
- var textContentVal = ""
111
- var textColorVal = "#000000"
112
- var textFontSizeVal = 14f
113
- var textBoldVal = false
114
- var textItalicVal = false
115
- var noteColorVal = "#FFFF00"
116
-
117
- val undoStack = ArrayDeque<UndoEntry>()
118
- val redoStack = ArrayDeque<UndoEntry>()
119
- private var appliedFingerprint: String? = null
120
-
121
- // ── Page view entries (continuous/twoUp modes) ───────────────────────────
122
- // frame: container-аас устгахад шууд reference, bmpHeight: layout болохоос өмнө scroll тооцооны тулд
123
- data class PageEntry(val frame: FrameLayout, val canvasView: AnnotationCanvasView, val bmpHeight: Int)
124
- val pageEntries = mutableListOf<PageEntry>()
125
-
126
- // ── Single-page pager (single mode) ──────────────────────────────────────
127
- private lateinit var pager: RecyclerView
128
- private var singleAdapter: SinglePageAdapter? = null
129
- private val singlePageCanvases = mutableMapOf<Int, AnnotationCanvasView>()
130
-
131
- // ── Zoom ─────────────────────────────────────────────────────────────────
132
- private var minZoom = 1.0f
133
- private var maxZoom = 5.0f
134
- private var currentZoom = 1.0f
135
- private val scaleDetector = ScaleGestureDetector(
136
- context,
137
- object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
138
- override fun onScale(d: ScaleGestureDetector): Boolean {
139
- if (activeTool != null) return true
140
- applyZoom((currentZoom * d.scaleFactor).coerceIn(minZoom, maxZoom))
141
- return true
142
- }
143
- }
144
- )
145
-
146
- // ── UI ───────────────────────────────────────────────────────────────────
147
- val scrollView = ScrollView(context).apply {
148
- layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
149
- isFillViewport = true
150
- }
151
- private val container = LinearLayout(context).apply {
152
- orientation = LinearLayout.VERTICAL
153
- layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
154
- setBackgroundColor(Color.parseColor("#E8E8E8"))
155
- }
156
-
157
- init {
158
- setBackgroundColor(Color.parseColor("#E8E8E8"))
159
- scrollView.addView(container)
160
- addView(scrollView)
161
- scrollView.setOnScrollChangeListener { _, _, scrollY, _, _ ->
162
- detectCurrentPage(scrollY)
163
- maybeTriggerChunkLoad(scrollY)
164
- }
165
-
166
- // Single-page pager: ZoomRecyclerView + PagerSnapHelper (vertical)
167
- pager = ZoomRecyclerView(context).apply {
168
- layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
169
- layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
170
- PagerSnapHelper().attachToRecyclerView(this)
171
- setBackgroundColor(Color.parseColor("#E8E8E8"))
172
- visibility = View.GONE
173
- addOnScrollListener(object : RecyclerView.OnScrollListener() {
174
- override fun onScrollStateChanged(rv: RecyclerView, newState: Int) {
175
- if (newState == RecyclerView.SCROLL_STATE_IDLE) {
176
- val lm = rv.layoutManager as? LinearLayoutManager ?: return
177
- val pos = lm.findFirstCompletelyVisibleItemPosition()
178
- .takeIf { it >= 0 } ?: return
179
- if (pos != currentPageIndex) {
180
- currentPageIndex = pos
181
- // Хуудас солигдоход zoom-г reset хийнэ
182
- (pager as? ZoomRecyclerView)?.resetZoom()
183
- onPageChange(mapOf("currentPage" to pos, "totalPage" to totalPages))
184
- }
185
- }
186
- }
187
- })
188
- }
189
- addView(pager)
190
- }
191
-
192
- override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
193
- super.onLayout(changed, l, t, r, b)
194
- if (width > 0 && !isLayoutReady) {
195
- isLayoutReady = true
196
- pendingUrl?.let {
197
- pendingUrl = null
198
- startLoad(it)
199
- }
200
- }
201
- // Note: width өөрчлөгдөхөд triggerRender дуудахгүй —
202
- // forced layout (forceLayoutScrollView) нь pages-г шууд харагдуулдаг
203
- }
204
-
205
- override fun onDetachedFromWindow() {
206
- super.onDetachedFromWindow()
207
- renderJob?.cancel()
208
- scope.cancel()
209
- safeCloseRenderer()
210
- }
211
-
212
- override fun onTouchEvent(event: MotionEvent): Boolean {
213
- scaleDetector.onTouchEvent(event)
214
- return super.onTouchEvent(event)
215
- }
216
-
217
- // ─────────────────────────────────────────────────────────────────────────
218
- // Props
219
- // ─────────────────────────────────────────────────────────────────────────
220
-
221
- fun setUrl(url: String?) {
222
- if (url.isNullOrBlank()) return
223
- pendingUrl = url
224
- if (isLayoutReady) {
225
- startLoad(url)
226
- } else {
227
- // Layout ирээгүй байж болно — дараагийн frame-д шалгана
228
- post { tryStartLoad() }
229
- }
230
- }
231
-
232
- private fun tryStartLoad() {
233
- val url = pendingUrl ?: return // onLayout аль хэдийн startLoad дуудсан бол pendingUrl = null → exit
234
- if (width > 0) {
235
- isLayoutReady = true
236
- pendingUrl = null // давхар дуудлагаас сэргийлнэ
237
- startLoad(url)
238
- } else {
239
- scope.launch {
240
- delay(50)
241
- tryStartLoad()
242
- }
243
- }
244
- }
245
-
246
- fun setDisplayMode(mode: String?) {
247
- val newMode = mode?.lowercase() ?: "continuous"
248
- if (newMode == displayMode) return
249
- displayMode = newMode
250
- if (renderer != null && isLayoutReady) triggerRender()
251
- }
252
-
253
- fun setInitialPage(page: Int) {
254
- if (page < 0 || page >= totalPages) return
255
- currentPageIndex = page
256
- if (displayMode == "single") {
257
- post { scrollToPage(page, smooth = false) }
258
- return
259
- }
260
- val windowEnd = windowStart + pageEntries.size - 1
261
- if (page in windowStart..windowEnd) {
262
- // Хуудас render хийгдсэн window дотор байна → зүгээр scroll хийнэ
263
- post { scrollToPage(page, smooth = false) }
264
- } else {
265
- // Хуудас render хийгдээгүй → window-г тухайн хуудас руу шилжүүлнэ
266
- jumpToPage(page)
267
- }
268
- }
269
-
270
- fun setMinZoom(v: Double) { minZoom = v.toFloat() }
271
- fun setMaxZoom(v: Double) { maxZoom = v.toFloat() }
272
-
273
- fun setTool(tool: String?) {
274
- activeTool = tool
275
- if (displayMode == "single") {
276
- pager.requestDisallowInterceptTouchEvent(tool != null)
277
- } else {
278
- scrollView.requestDisallowInterceptTouchEvent(tool != null)
279
- }
280
- }
281
-
282
- fun setStrokeColor(hex: String) { strokeColorVal = hex }
283
- fun setStrokeWidth(w: Double) { strokeWidthVal = w.toFloat() }
284
- fun setTextContent(t: String) { textContentVal = t }
285
- fun setTextColor(hex: String) { textColorVal = hex }
286
- fun setTextFontSize(size: Double) { textFontSizeVal = size.toFloat() }
287
- fun setTextBold(v: Boolean) { textBoldVal = v }
288
- fun setTextItalic(v: Boolean) { textItalicVal = v }
289
- fun setNoteColor(hex: String) { noteColorVal = hex }
290
-
291
- // ─────────────────────────────────────────────────────────────────────────
292
- // Commands
293
- // ─────────────────────────────────────────────────────────────────────────
294
-
295
- fun undo() {
296
- val e = undoStack.removeLastOrNull() ?: return
297
- redoStack.addLast(e)
298
- annotationMap[e.annotation.pageIndex]?.remove(e.annotation)
299
- invalidateCanvas(e.annotation.pageIndex)
300
- notifyAnnotationChange()
301
- notifyUndoRedoState()
302
- }
303
-
304
- fun redo() {
305
- val e = redoStack.removeLastOrNull() ?: return
306
- undoStack.addLast(e)
307
- annotationMap.getOrPut(e.annotation.pageIndex) { mutableListOf() }.add(e.annotation)
308
- invalidateCanvas(e.annotation.pageIndex)
309
- notifyAnnotationChange()
310
- notifyUndoRedoState()
311
- }
312
-
313
- fun setAnnotations(data: List<Map<String, Any?>>) {
314
- val fp = buildFingerprint(data)
315
- if (fp == appliedFingerprint) return
316
- appliedFingerprint = fp
317
- annotationMap.clear()
318
-
319
- for (item in data) {
320
- val pageIndex = (item["page"] as? Number)?.toInt() ?: continue
321
- val typeStr = item["type"] as? String ?: continue
322
- val b = item["bounds"] as? Map<*, *> ?: continue
323
- val bX = (b["x"] as? Number)?.toFloat() ?: 0f
324
- val bY = (b["y"] as? Number)?.toFloat() ?: 0f
325
- val bW = (b["width"] as? Number)?.toFloat() ?: 0f
326
- val bH = (b["height"] as? Number)?.toFloat() ?: 0f
327
-
328
- val bounds = RectF(bX, bY, bX + bW, bY + bH)
329
- val ann = PdfAnnotation(
330
- type = typeStr, pageIndex = pageIndex, bounds = bounds,
331
- color = item["color"] as? String ?: strokeColorVal,
332
- strokeWidth = (item["strokeWidth"] as? Number)?.toFloat() ?: strokeWidthVal,
333
- contents = item["contents"] as? String ?: "",
334
- fontSize = (item["fontSize"] as? Number)?.toFloat() ?: textFontSizeVal,
335
- bold = item["bold"] as? Boolean ?: false,
336
- italic = item["italic"] as? Boolean ?: false
337
- )
338
-
339
- (item["paths"] as? List<*>)?.forEach { pathData ->
340
- val pts = (pathData as? List<*>)?.mapNotNull { pt ->
341
- val m = pt as? Map<*, *> ?: return@mapNotNull null
342
- PointF(
343
- (m["x"] as? Number)?.toFloat() ?: 0f,
344
- (m["y"] as? Number)?.toFloat() ?: 0f
345
- )
346
- }
347
- if (!pts.isNullOrEmpty()) ann.paths.add(AnnPath(pts.toMutableList()))
348
- }
349
-
350
- annotationMap.getOrPut(pageIndex) { mutableListOf() }.add(ann)
351
- }
352
- if (displayMode == "single") {
353
- singlePageCanvases.values.forEach { it.invalidate() }
354
- } else {
355
- pageEntries.forEach { it.canvasView.invalidate() }
356
- }
357
- }
358
-
359
- fun updateText(pageIndex: Int, index: Int, contents: String) {
360
- val anns = annotationMap[pageIndex]?.filter { it.type == "text" } ?: return
361
- if (index >= anns.size) return
362
- val ann = anns[index]
363
- ann.contents = contents.ifBlank { " " }
364
- autoSizeTextAnnotation(ann)
365
- invalidateCanvas(pageIndex)
366
- notifyAnnotationChange()
367
- }
368
-
369
- /**
370
- * Recalculate the annotation's bounds so the text fits without clipping.
371
- * Width is capped at 70% of the view width; height grows with line count.
372
- */
373
- private fun autoSizeTextAnnotation(ann: PdfAnnotation) {
374
- val text = ann.contents.trim().ifEmpty { return }
375
- val pageW = pagePdfW.getOrElse(ann.pageIndex) { 0 }
376
- if (pageW <= 0 || width <= 0) return
377
- val s = width.toFloat() / pageW.toFloat()
378
- val tp = android.text.TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
379
- textSize = ann.fontSize * s
380
- typeface = when {
381
- ann.bold && ann.italic -> Typeface.create(Typeface.DEFAULT, Typeface.BOLD_ITALIC)
382
- ann.bold -> Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
383
- ann.italic -> Typeface.create(Typeface.DEFAULT, Typeface.ITALIC)
384
- else -> Typeface.DEFAULT
385
- }
386
- }
387
- val pad = 16f // screen px total padding (8px each side)
388
- val maxW = (width * 0.7f - pad).toInt().coerceAtLeast(80)
389
- val layout = StaticLayout.Builder
390
- .obtain(text, 0, text.length, tp, maxW)
391
- .setAlignment(android.text.Layout.Alignment.ALIGN_NORMAL)
392
- .build()
393
- val newW = (layout.width + pad) / s
394
- val newH = (layout.height + pad) / s
395
- // Keep top-left anchor fixed
396
- ann.bounds = RectF(ann.bounds.left, ann.bounds.top, ann.bounds.left + newW, ann.bounds.top + newH)
397
- }
398
-
399
- fun updateNote(pageIndex: Int, index: Int, contents: String) {
400
- val anns = annotationMap[pageIndex]?.filter { it.type == "note" } ?: return
401
- if (index >= anns.size) return
402
- anns[index].contents = contents.ifBlank { " " }
403
- invalidateCanvas(pageIndex)
404
- notifyAnnotationChange()
405
- }
406
-
407
- // ─────────────────────────────────────────────────────────────────────────
408
- // Internal helpers (used by inner AnnotationCanvasView)
409
- // ─────────────────────────────────────────────────────────────────────────
410
-
411
- fun addAnnotationAndCommit(ann: PdfAnnotation) {
412
- annotationMap.getOrPut(ann.pageIndex) { mutableListOf() }.add(ann)
413
- undoStack.addLast(UndoEntry(ann))
414
- redoStack.clear()
415
- invalidateCanvas(ann.pageIndex)
416
- notifyAnnotationChange()
417
- notifyUndoRedoState()
418
- }
419
-
420
- fun invalidateCanvas(pageIndex: Int) {
421
- if (displayMode == "single") {
422
- singlePageCanvases[pageIndex]?.invalidate()
423
- } else {
424
- // continuous mode: pageEntries[0] = page windowStart → relative index
425
- pageEntries.getOrNull(pageIndex - windowStart)?.canvasView?.invalidate()
426
- }
427
- }
428
-
429
- fun notifyAnnotationChange() {
430
- val all = mutableListOf<Map<String, Any>>()
431
- for ((pageIndex, anns) in annotationMap) {
432
- for (ann in anns) {
433
- val m = mutableMapOf<String, Any>(
434
- "type" to ann.type,
435
- "page" to pageIndex,
436
- "bounds" to mapOf(
437
- "x" to ann.bounds.left.toDouble(),
438
- "y" to ann.bounds.top.toDouble(),
439
- "width" to ann.bounds.width().toDouble(),
440
- "height" to ann.bounds.height().toDouble()
441
- ),
442
- "color" to ann.color,
443
- "strokeWidth" to ann.strokeWidth.toDouble(),
444
- "contents" to ann.contents
445
- )
446
- if (ann.type == "text") {
447
- m["fontSize"] = ann.fontSize.toDouble()
448
- m["bold"] = ann.bold
449
- m["italic"] = ann.italic
450
- }
451
- if (ann.paths.isNotEmpty()) {
452
- m["paths"] = ann.paths.map { p ->
453
- p.points.map { pt ->
454
- mapOf("x" to pt.x.toDouble(), "y" to pt.y.toDouble())
455
- }
456
- }
457
- }
458
- all.add(m)
459
- }
460
- }
461
- onAnnotationChange(mapOf("annotations" to all))
462
- }
463
-
464
- fun notifyUndoRedoState() {
465
- onUndoRedoStateChange(
466
- mapOf("canUndo" to undoStack.isNotEmpty(), "canRedo" to redoStack.isNotEmpty())
467
- )
468
- }
469
-
470
- // ─────────────────────────────────────────────────────────────────────────
471
- // Zoom
472
- // ─────────────────────────────────────────────────────────────────────────
473
-
474
- private fun applyZoom(newZoom: Float) {
475
- currentZoom = newZoom
476
- container.pivotX = 0f
477
- container.pivotY = 0f
478
- container.scaleX = newZoom
479
- container.scaleY = newZoom
480
- }
481
-
482
- // ─────────────────────────────────────────────────────────────────────────
483
- // Scroll / page detection
484
- // ─────────────────────────────────────────────────────────────────────────
485
-
486
- private fun detectCurrentPage(scrollY: Int) {
487
- if (pageEntries.isEmpty()) return
488
- val centerY = scrollY + height / 2
489
- var accY = 0
490
- for (i in pageEntries.indices) {
491
- val h = pageEntries[i].bmpHeight.takeIf { it > 0 } ?: continue
492
- accY += h + 12
493
- if (centerY <= accY || i == pageEntries.size - 1) {
494
- val absolutePage = windowStart + i
495
- if (currentPageIndex != absolutePage) {
496
- currentPageIndex = absolutePage
497
- onPageChange(mapOf("currentPage" to absolutePage, "totalPage" to totalPages))
498
- }
499
- break
500
- }
501
- }
502
- }
503
-
504
- private fun scrollToPage(pageIndex: Int, smooth: Boolean = true) {
505
- if (displayMode == "single") {
506
- if (smooth) pager.smoothScrollToPosition(pageIndex)
507
- else pager.scrollToPosition(pageIndex)
508
- currentPageIndex = pageIndex
509
- onPageChange(mapOf("currentPage" to pageIndex, "totalPage" to totalPages))
510
- return
511
- }
512
- if (pageEntries.isEmpty()) return
513
- // pageEntries[0] нь windowStart-р эхэлдэг тул relative index ашиглана
514
- val relativeIndex = (pageIndex - windowStart).coerceIn(0, pageEntries.size - 1)
515
- var y = 0
516
- for (i in 0 until relativeIndex) {
517
- y += (pageEntries[i].bmpHeight.takeIf { it > 0 } ?: 0) + 12
518
- }
519
- if (smooth) scrollView.smoothScrollTo(0, y) else scrollView.scrollTo(0, y)
520
- currentPageIndex = pageIndex
521
- onPageChange(mapOf("currentPage" to pageIndex, "totalPage" to totalPages))
522
- }
523
-
524
- // ─────────────────────────────────────────────────────────────────────────
525
- // Load & Render
526
- // ─────────────────────────────────────────────────────────────────────────
527
-
528
- private fun startLoad(url: String) {
529
- Log.d("ExpoPdfReader", "startLoad: $url")
530
- renderJob?.cancel()
531
- renderJob = scope.launch {
532
- try {
533
- withContext(pdfDispatcher) { safeCloseRenderer() }
534
- val file = withContext(pdfDispatcher) { resolveFile(url) }
535
- withContext(pdfDispatcher) {
536
- fileDescriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
537
- renderer = PdfRenderer(fileDescriptor!!)
538
- val r = renderer!!
539
- totalPages = r.pageCount
540
- pagePdfW = IntArray(totalPages) { i -> r.openPage(i).use { it.width } }
541
- pagePdfH = IntArray(totalPages) { i -> r.openPage(i).use { it.height } }
542
- }
543
- // annotationMap is intentionally NOT cleared here.
544
- // setAnnotations() may have already populated it before startLoad() runs
545
- // (Expo prop ordering: url first, then initialAnnotations).
546
- // Canvases created in renderDocument() read annotationMap on their first onDraw().
547
- undoStack.clear(); redoStack.clear()
548
- appliedFingerprint = null; currentPageIndex = 0
549
- renderDocument()
550
- // After canvases are created, re-invalidate all of them so initialAnnotations appear.
551
- withContext(Dispatchers.Main) {
552
- if (displayMode == "single") {
553
- singlePageCanvases.values.forEach { it.invalidate() }
554
- } else {
555
- pageEntries.forEach { it.canvasView.invalidate() }
556
- }
557
- }
558
- } catch (e: CancellationException) { throw e }
559
- catch (e: Exception) { Log.e("ExpoPdfReader", "Load error", e) }
560
- }
561
- }
562
-
563
- private fun triggerRender() {
564
- renderJob?.cancel()
565
- renderJob = scope.launch {
566
- try { renderDocument() }
567
- catch (e: CancellationException) { throw e }
568
- catch (e: Exception) { Log.e("ExpoPdfReader", "Render error", e) }
569
- }
570
- }
571
-
572
- private suspend fun renderDocument() {
573
- val pdf = renderer ?: return
574
- val pageCount = withContext(pdfDispatcher) { pdf.pageCount }
575
- if (pageCount <= 0) return
576
- val viewWidth = width.takeIf { it > 0 } ?: resources.displayMetrics.widthPixels
577
- if (viewWidth <= 0) return
578
-
579
- // Chunk state reset
580
- currentViewWidth = viewWidth
581
- renderedUpTo = -1
582
- windowStart = 0
583
- chunkLoading = false
584
-
585
- withContext(Dispatchers.Main) {
586
- container.removeAllViews()
587
- pageEntries.clear()
588
- loadingFooter = null
589
- }
590
-
591
- when (displayMode) {
592
- "single" -> renderSingleMode()
593
- "twoup" -> renderAllPages(pdf, pageCount, viewWidth)
594
- else -> renderFirstChunk(pdf, pageCount, viewWidth) // continuous / twoUpContinuous
595
- }
596
-
597
- // onReady: Main thread дээр шууд дуудна
598
- withContext(Dispatchers.Main) {
599
- val density = resources.displayMetrics.density
600
- Log.d("ExpoPdfReader", "onReady firing: totalPages=$pageCount")
601
- onReady(
602
- mapOf(
603
- "totalPages" to pageCount,
604
- "width" to (this@ExpoPdfReaderView.width / density).toDouble(),
605
- "height" to (this@ExpoPdfReaderView.height / density).toDouble()
606
- )
607
- )
608
- }
609
- }
610
-
611
- // single mode: RecyclerView + PagerSnapHelper — нэг хуудас бүрэн харагдана
612
- private suspend fun renderSingleMode() {
613
- withContext(Dispatchers.Main) {
614
- singlePageCanvases.clear()
615
- scrollView.visibility = View.GONE
616
- pager.visibility = View.VISIBLE
617
- singleAdapter = SinglePageAdapter()
618
- pager.adapter = singleAdapter
619
- pager.scrollToPosition(currentPageIndex)
620
- // React Native Fabric blocks child requestLayout() calls, so the RecyclerView
621
- // never binds its items until the next Fabric layout pass (e.g. thumbnail panel
622
- // opening/closing). Force a manual measure+layout to make items bind immediately.
623
- forcePagerLayout()
624
- }
625
- }
626
-
627
- private fun forcePagerLayout() {
628
- val w = width.takeIf { it > 0 } ?: return
629
- val h = height.takeIf { it > 0 } ?: return
630
- pager.measure(
631
- MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY),
632
- MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY)
633
- )
634
- pager.layout(0, 0, w, h)
635
- }
636
-
637
- // twoup бүх хуудсыг render хийнэ
638
- private suspend fun renderAllPages(pdf: PdfRenderer, pageCount: Int, viewWidth: Int) {
639
- withContext(Dispatchers.Main) {
640
- scrollView.visibility = View.VISIBLE
641
- pager.visibility = View.GONE
642
- }
643
- renderTwoUpRange(pdf, pageCount, viewWidth, 0, pageCount)
644
- withContext(Dispatchers.Main) { renderedUpTo = pageCount - 1 }
645
- }
646
-
647
- // continuous / twoUpContinuous — эхний PAGE_CHUNK хуудсыг render хийнэ
648
- private suspend fun renderFirstChunk(pdf: PdfRenderer, pageCount: Int, viewWidth: Int) {
649
- withContext(Dispatchers.Main) {
650
- scrollView.visibility = View.VISIBLE
651
- pager.visibility = View.GONE
652
- }
653
- val end = minOf(PAGE_CHUNK, pageCount)
654
- if (displayMode == "twoupcontinuous") {
655
- renderTwoUpRange(pdf, pageCount, viewWidth, 0, end)
656
- // Хос тул renderedUpTo-г тэгш болгоно
657
- withContext(Dispatchers.Main) {
658
- renderedUpTo = if (end % 2 == 0) end - 1 else end - 2
659
- if (renderedUpTo < pageCount - 1) showLoadingFooter()
660
- forceLayoutScrollView()
661
- }
662
- } else {
663
- for (i in 0 until end) {
664
- val bmp = renderPageBitmap(pdf, i, viewWidth)
665
- val idx = i
666
- withContext(Dispatchers.Main) {
667
- addPageRow(bmp, idx, pagePdfW.getOrElse(idx) { 1 }.toFloat(),
668
- pagePdfH.getOrElse(idx) { 1 }.toFloat())
669
- }
670
- }
671
- withContext(Dispatchers.Main) {
672
- renderedUpTo = end - 1
673
- if (end < pageCount) showLoadingFooter()
674
- forceLayoutScrollView() // pages-г шууд харагдуулна
675
- }
676
- }
677
- }
678
-
679
- // twoUp row-г render хийх helper (start..end page range, pairs)
680
- private suspend fun renderTwoUpRange(
681
- pdf: PdfRenderer, pageCount: Int, viewWidth: Int, startPage: Int, endPage: Int
682
- ) {
683
- val halfWidth = viewWidth / 2
684
- var i = startPage
685
- while (i < endPage) {
686
- val leftBmp = renderPageBitmap(pdf, i, halfWidth)
687
- val rightBmp = if (i + 1 < minOf(endPage, pageCount))
688
- renderPageBitmap(pdf, i + 1, halfWidth) else null
689
- val li = i; val ri = i + 1
690
- withContext(Dispatchers.Main) {
691
- // twoUp row-ын өндрийг зүүн хуудасны bitmap-аас авна (хоёр хуудас ижил scale-тай)
692
- val rowH = leftBmp.height
693
- val row = LinearLayout(context).apply {
694
- orientation = LinearLayout.HORIZONTAL
695
- layoutParams = LinearLayout.LayoutParams(viewWidth, rowH)
696
- .apply { setMargins(0, 6, 0, 6) }
697
- }
698
- row.addView(buildPageFrame(leftBmp, li,
699
- pagePdfW.getOrElse(li) { 1 }.toFloat(), pagePdfH.getOrElse(li) { 1 }.toFloat(), 1f))
700
- if (rightBmp != null) {
701
- row.addView(buildPageFrame(rightBmp, ri,
702
- pagePdfW.getOrElse(ri) { 1 }.toFloat(), pagePdfH.getOrElse(ri) { 1 }.toFloat(), 1f))
703
- } else {
704
- row.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(0, rowH, 1f) })
705
- }
706
- container.addView(row)
707
- }
708
- i += 2
709
- }
710
- }
711
-
712
- // ── Lazy chunk helpers ────────────────────────────────────────────────────
713
-
714
- private fun maybeTriggerChunkLoad(scrollY: Int) {
715
- if (chunkLoading) return
716
- if (displayMode !in listOf("continuous", "twoupcontinuous")) return
717
-
718
- val visibleBottom = scrollY + scrollView.height
719
-
720
- // Доош scroll: дараагийн chunk ачаална
721
- if (renderedUpTo < totalPages - 1) {
722
- val forwardTrigger = container.height - scrollView.height * 2
723
- if (visibleBottom >= forwardTrigger) {
724
- chunkLoading = true
725
- scope.launch {
726
- try { loadNextChunk() }
727
- catch (e: CancellationException) { throw e }
728
- catch (e: Exception) { Log.e("ExpoPdfReader", "Chunk load error", e) }
729
- finally { chunkLoading = false }
730
- }
731
- return
732
- }
733
- }
734
-
735
- // Дээш scroll: өмнөх chunk ачаална
736
- if (windowStart > 0 && scrollY <= scrollView.height * 2) {
737
- chunkLoading = true
738
- scope.launch {
739
- try { loadPrevChunk() }
740
- catch (e: CancellationException) { throw e }
741
- catch (e: Exception) { Log.e("ExpoPdfReader", "Prev chunk load error", e) }
742
- finally { chunkLoading = false }
743
- }
744
- }
745
- }
746
-
747
- private suspend fun loadNextChunk() {
748
- val pdf = renderer ?: return
749
- val start = renderedUpTo + 1
750
- if (start >= totalPages) return
751
- val end = minOf(start + PAGE_CHUNK, totalPages)
752
- val viewWidth = currentViewWidth
753
-
754
- withContext(Dispatchers.Main) { removeLoadingFooter() }
755
-
756
- if (displayMode == "twoupcontinuous") {
757
- renderTwoUpRange(pdf, totalPages, viewWidth, start, end)
758
- withContext(Dispatchers.Main) {
759
- renderedUpTo = if (end % 2 == 0) end - 1 else end - 2
760
- if (renderedUpTo < totalPages - 1) showLoadingFooter()
761
- forceLayoutScrollView()
762
- }
763
- } else {
764
- for (i in start until end) {
765
- val bmp = renderPageBitmap(pdf, i, viewWidth)
766
- val idx = i
767
- withContext(Dispatchers.Main) {
768
- addPageRow(bmp, idx, pagePdfW.getOrElse(idx) { 1 }.toFloat(),
769
- pagePdfH.getOrElse(idx) { 1 }.toFloat())
770
- }
771
- }
772
- withContext(Dispatchers.Main) {
773
- renderedUpTo = end - 1
774
- // Sliding window: MAX_RENDERED-с илүү болвол дээд хэсгийг устга
775
- val excess = pageEntries.size - MAX_RENDERED
776
- if (excess > 0) {
777
- val removed = pruneTopPages(excess)
778
- scrollView.scrollBy(0, -removed)
779
- }
780
- if (end < totalPages) showLoadingFooter()
781
- forceLayoutScrollView()
782
- }
783
- }
784
- }
785
-
786
- /**
787
- * Дээд хэсгийн [count] хуудсыг устгаж, bitmap санах ойг чөлөөлнэ.
788
- * @return устгагдсан хуудсуудын нийт өндөр (px) — scroll тохируулахад ашиглана
789
- */
790
- private fun pruneTopPages(count: Int): Int {
791
- var removedHeight = 0
792
- repeat(count) {
793
- val entry = pageEntries.removeFirstOrNull() ?: return@repeat
794
- removedHeight += entry.bmpHeight + 12 // +12 нь margin (дээш 6 + доош 6)
795
- // Bitmap-г чөлөөлнэ
796
- (entry.frame.getChildAt(0) as? ImageView)?.setImageBitmap(null)
797
- container.removeView(entry.frame)
798
- }
799
- windowStart += count
800
- return removedHeight
801
- }
802
-
803
- /**
804
- * Өмнөх [PAGE_CHUNK] хуудсыг дээр нэмж, доод хэсгийг цэвэрлэнэ.
805
- */
806
- private suspend fun loadPrevChunk() {
807
- val pdf = renderer ?: return
808
- val prevStart = maxOf(0, windowStart - PAGE_CHUNK)
809
- val prevEnd = windowStart
810
- if (prevStart >= prevEnd) return
811
- val viewWidth = currentViewWidth
812
-
813
- data class PageBmp(val bmp: Bitmap, val idx: Int, val pdfW: Float, val pdfH: Float)
814
- val pages = mutableListOf<PageBmp>()
815
- for (i in prevStart until prevEnd) {
816
- val bmp = renderPageBitmap(pdf, i, viewWidth)
817
- pages.add(PageBmp(bmp, i,
818
- pagePdfW.getOrElse(i) { 1 }.toFloat(),
819
- pagePdfH.getOrElse(i) { 1 }.toFloat()))
820
- }
821
-
822
- withContext(Dispatchers.Main) {
823
- var insertedHeight = 0
824
- // Reverse order-оор insert хийж эцэст нь зөв дараалал гарна
825
- // (бүгд index 0-д insert хийгддэг тул)
826
- for (page in pages.reversed()) {
827
- prependPageFrame(page.bmp, page.idx, page.pdfW, page.pdfH)
828
- insertedHeight += page.bmp.height + 12
829
- }
830
- windowStart = prevStart
831
- renderedUpTo = maxOf(renderedUpTo, windowStart + pageEntries.size - 1)
832
-
833
- // Доод хэсгийг цэвэрлэнэ
834
- val excess = pageEntries.size - MAX_RENDERED
835
- if (excess > 0) pruneBottomPages(excess)
836
-
837
- // Дээр нэмсэн өндрийг scroll-д нэмж харагдах хэсгийг хэвээр үлдээнэ
838
- scrollView.scrollBy(0, insertedHeight)
839
- forceLayoutScrollView()
840
- }
841
- }
842
-
843
- /**
844
- * Дээр (index 0) шинэ хуудас оруулна. windowStart-г дуудагч тал шинэчилнэ.
845
- */
846
- private fun prependPageFrame(bmp: Bitmap, pageIdx: Int, pdfW: Float, pdfH: Float) {
847
- val bmpW = bmp.width
848
- val bmpH = bmp.height
849
- val frame = FrameLayout(context).apply {
850
- layoutParams = LinearLayout.LayoutParams(bmpW, bmpH).apply { setMargins(0, 6, 0, 6) }
851
- }
852
- val iv = ImageView(context).apply {
853
- layoutParams = FrameLayout.LayoutParams(bmpW, bmpH)
854
- scaleType = ImageView.ScaleType.FIT_XY
855
- setImageBitmap(bmp)
856
- }
857
- val canvasView = AnnotationCanvasView(context, pageIdx, pdfW, pdfH).apply {
858
- layoutParams = FrameLayout.LayoutParams(bmpW, bmpH)
859
- translationZ = 1f
860
- }
861
- frame.addView(iv)
862
- frame.addView(canvasView)
863
- container.addView(frame, 0)
864
- pageEntries.add(0, PageEntry(frame, canvasView, bmpH))
865
- }
866
-
867
- /**
868
- * Доод хэсгийн [count] хуудсыг устгаж bitmap санах ойг чөлөөлнэ.
869
- * renderedUpTo-г буулгаж дараагийн доош scroll-д re-render хийгдэхийг зөвшөөрнэ.
870
- */
871
- private fun pruneBottomPages(count: Int) {
872
- repeat(count) {
873
- val entry = pageEntries.removeLastOrNull() ?: return@repeat
874
- (entry.frame.getChildAt(0) as? ImageView)?.setImageBitmap(null)
875
- container.removeView(entry.frame)
876
- }
877
- renderedUpTo = windowStart + pageEntries.size - 1
878
- }
879
-
880
- /**
881
- * Render хийгдээгүй хуудас руу үсрэх.
882
- * Бүх одоогийн view-г цэвэрлэж, targetPage-ийн эргэн тойрны хуудсуудыг render хийнэ.
883
- */
884
- private fun jumpToPage(targetPage: Int) {
885
- val pdf = renderer ?: return
886
- val viewWidth = currentViewWidth.takeIf { it > 0 } ?: width.takeIf { it > 0 } ?: return
887
- renderJob?.cancel()
888
- renderJob = scope.launch {
889
- try {
890
- // Шинэ window: targetPage-ийн хагас chunk өмнөөс эхэлнэ
891
- val newStart = maxOf(0, targetPage - PAGE_CHUNK / 2)
892
- val newEnd = minOf(totalPages, newStart + MAX_RENDERED)
893
-
894
- withContext(Dispatchers.Main) {
895
- container.removeAllViews()
896
- pageEntries.clear()
897
- loadingFooter = null
898
- windowStart = newStart
899
- renderedUpTo = newStart - 1
900
- chunkLoading = false
901
- }
902
-
903
- for (i in newStart until newEnd) {
904
- val bmp = renderPageBitmap(pdf, i, viewWidth)
905
- val idx = i
906
- withContext(Dispatchers.Main) {
907
- addPageRow(bmp, idx, pagePdfW.getOrElse(idx) { 1 }.toFloat(),
908
- pagePdfH.getOrElse(idx) { 1 }.toFloat())
909
- }
910
- }
911
-
912
- withContext(Dispatchers.Main) {
913
- renderedUpTo = newEnd - 1
914
- if (newEnd < totalPages) showLoadingFooter()
915
- forceLayoutScrollView()
916
- // Layout болсны дараа scroll хийнэ
917
- post { scrollToPage(targetPage, smooth = false) }
918
- }
919
- } catch (e: CancellationException) { throw e }
920
- catch (e: Exception) { Log.e("ExpoPdfReader", "JumpToPage error", e) }
921
- }
922
- }
923
-
924
- /**
925
- * React Native Fabric нь child view-н requestLayout()-г таслах тул
926
- * addView() дараа layout pass автоматаар ажиллахгүй.
927
- * Энэ функц scrollView-г шууд measure + layout хийж pages-г харагдуулна.
928
- */
929
- private fun forceLayoutScrollView() {
930
- val w = width.takeIf { it > 0 } ?: return
931
- val h = height.takeIf { it > 0 } ?: return
932
- scrollView.measure(
933
- MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY),
934
- MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY)
935
- )
936
- scrollView.layout(0, 0, w, h)
937
- }
938
-
939
- private fun showLoadingFooter() {
940
- if (loadingFooter != null) return
941
- val footer = ProgressBar(context, null, android.R.attr.progressBarStyleSmall).apply {
942
- layoutParams = LinearLayout.LayoutParams(
943
- LinearLayout.LayoutParams.MATCH_PARENT, 120
944
- ).apply { setMargins(0, 16, 0, 16) }
945
- isIndeterminate = true
946
- }
947
- container.addView(footer)
948
- loadingFooter = footer
949
- }
950
-
951
- private fun removeLoadingFooter() {
952
- loadingFooter?.let { container.removeView(it) }
953
- loadingFooter = null
954
- }
955
-
956
- private fun addPageRow(bmp: Bitmap, pageIdx: Int, pdfW: Float, pdfH: Float) =
957
- container.addView(buildPageFrame(bmp, pageIdx, pdfW, pdfH))
958
-
959
- private fun buildPageFrame(bmp: Bitmap, pageIdx: Int, pdfW: Float, pdfH: Float, weight: Float = 0f): FrameLayout {
960
- // MATCH_PARENT нь React Native managed view дотор layout pass болохоос өмнө
961
- // width = 0 авдаг тул explicit pixel dimension ашиглана → шууд харагдана
962
- val bmpW = bmp.width
963
- val bmpH = bmp.height
964
- val frame = FrameLayout(context).apply {
965
- layoutParams = if (weight > 0f)
966
- LinearLayout.LayoutParams(0, bmpH, weight)
967
- else
968
- LinearLayout.LayoutParams(bmpW, bmpH)
969
- .apply { setMargins(0, 6, 0, 6) }
970
- }
971
- val iv = ImageView(context).apply {
972
- layoutParams = FrameLayout.LayoutParams(bmpW, bmpH)
973
- scaleType = ImageView.ScaleType.FIT_XY
974
- setImageBitmap(bmp)
975
- }
976
- val canvasView = AnnotationCanvasView(context, pageIdx, pdfW, pdfH).apply {
977
- layoutParams = FrameLayout.LayoutParams(bmpW, bmpH)
978
- translationZ = 1f
979
- }
980
- frame.addView(iv)
981
- frame.addView(canvasView)
982
- pageEntries.add(PageEntry(frame, canvasView, bmpH))
983
- return frame
984
- }
985
-
986
- private suspend fun renderPageBitmap(pdf: PdfRenderer, index: Int, targetWidth: Int): Bitmap =
987
- withContext(pdfDispatcher) {
988
- pdf.openPage(index).use { page ->
989
- val s = targetWidth.toFloat() / page.width.toFloat()
990
- val h = (page.height * s).toInt().coerceAtLeast(1)
991
- val bmp = Bitmap.createBitmap(targetWidth, h, Bitmap.Config.ARGB_8888)
992
- bmp.eraseColor(Color.WHITE)
993
- page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
994
- bmp
995
- }
996
- }
997
-
998
- private fun safeCloseRenderer() {
999
- try { renderer?.close(); fileDescriptor?.close() }
1000
- catch (_: Exception) { }
1001
- finally { renderer = null; fileDescriptor = null }
1002
- }
1003
-
1004
- private suspend fun resolveFile(url: String): File = withContext(pdfDispatcher) {
1005
- when {
1006
- url.startsWith("file://") -> File(url.removePrefix("file://"))
1007
- url.startsWith("/") -> File(url)
1008
- else -> {
1009
- val f = File.createTempFile("pdf_", ".pdf", context.cacheDir)
1010
- URL(url).openStream().use { inp -> FileOutputStream(f).use { inp.copyTo(it) } }
1011
- f
1012
- }
1013
- }
1014
- }
1015
-
1016
- private fun buildFingerprint(data: List<Map<String, Any?>>): String =
1017
- data.mapNotNull { item ->
1018
- val p = (item["page"] as? Number)?.toInt() ?: return@mapNotNull null
1019
- val t = item["type"] as? String ?: return@mapNotNull null
1020
- val b = item["bounds"] as? Map<*, *> ?: return@mapNotNull null
1021
- "$p|$t|${b["x"]},${b["y"]},${b["width"]},${b["height"]}"
1022
- }.sorted().joinToString("||")
1023
-
1024
- // ─────────────────────────────────────────────────────────────────────────
1025
- // SinglePageAdapter — RecyclerView adapter for "single" display mode
1026
- // • vertical PagerSnapHelper — босоо scroll-оор хуудас солих
1027
- // • ZoomRecyclerView zoom-г удирдана (adapter энгийн FrameLayout ашиглана)
1028
- // • lazy bitmap loading
1029
- // ─────────────────────────────────────────────────────────────────────────
1030
-
1031
- inner class SinglePageAdapter : RecyclerView.Adapter<SinglePageAdapter.Holder>() {
1032
-
1033
- inner class Holder(val frame: FrameLayout, val imageView: ImageView) :
1034
- RecyclerView.ViewHolder(frame) {
1035
- var boundPage = -1
1036
- var loadJob: Job? = null
1037
- var currentCanvas: AnnotationCanvasView? = null
1038
- }
1039
-
1040
- override fun getItemCount(): Int = totalPages
1041
-
1042
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
1043
- val frame = FrameLayout(context).apply {
1044
- layoutParams = RecyclerView.LayoutParams(
1045
- RecyclerView.LayoutParams.MATCH_PARENT,
1046
- RecyclerView.LayoutParams.MATCH_PARENT
1047
- )
1048
- setBackgroundColor(Color.parseColor("#E8E8E8"))
1049
- }
1050
- val iv = ImageView(context).apply {
1051
- layoutParams = FrameLayout.LayoutParams(
1052
- FrameLayout.LayoutParams.WRAP_CONTENT,
1053
- FrameLayout.LayoutParams.WRAP_CONTENT,
1054
- android.view.Gravity.CENTER
1055
- )
1056
- scaleType = ImageView.ScaleType.FIT_XY
1057
- }
1058
- frame.addView(iv)
1059
- return Holder(frame, iv)
1060
- }
1061
-
1062
- override fun onBindViewHolder(holder: Holder, position: Int) {
1063
- holder.boundPage = position
1064
- holder.loadJob?.cancel()
1065
- holder.imageView.setImageBitmap(null)
1066
- // Recycle хийгдсэн view-н zoom transform-г цэвэрлэнэ
1067
- holder.frame.scaleX = 1f; holder.frame.scaleY = 1f
1068
- holder.frame.translationX = 0f; holder.frame.translationY = 0f
1069
-
1070
- holder.currentCanvas?.let {
1071
- singlePageCanvases.remove(it.pageIndex)
1072
- holder.frame.removeView(it)
1073
- }
1074
- val pdfW = pagePdfW.getOrElse(position) { 1 }.toFloat()
1075
- val pdfH = pagePdfH.getOrElse(position) { 1 }.toFloat()
1076
- val canvas = AnnotationCanvasView(context, position, pdfW, pdfH).apply {
1077
- layoutParams = FrameLayout.LayoutParams(
1078
- FrameLayout.LayoutParams.WRAP_CONTENT,
1079
- FrameLayout.LayoutParams.WRAP_CONTENT,
1080
- android.view.Gravity.CENTER
1081
- )
1082
- translationZ = 1f
1083
- }
1084
- holder.frame.addView(canvas)
1085
- holder.currentCanvas = canvas
1086
- singlePageCanvases[position] = canvas
1087
-
1088
- holder.loadJob = scope.launch {
1089
- val pdf = renderer ?: return@launch
1090
- val bmp = renderPageBitmap(pdf, position, this@ExpoPdfReaderView.width)
1091
- withContext(Dispatchers.Main) {
1092
- if (holder.boundPage == position) {
1093
- val lp = FrameLayout.LayoutParams(bmp.width, bmp.height, android.view.Gravity.CENTER)
1094
- holder.imageView.layoutParams = lp
1095
- holder.imageView.setImageBitmap(bmp)
1096
- holder.currentCanvas?.layoutParams = FrameLayout.LayoutParams(bmp.width, bmp.height, android.view.Gravity.CENTER)
1097
- val fw = this@ExpoPdfReaderView.width.takeIf { it > 0 } ?: return@withContext
1098
- val fh = this@ExpoPdfReaderView.height.takeIf { it > 0 } ?: return@withContext
1099
- holder.frame.measure(
1100
- MeasureSpec.makeMeasureSpec(fw, MeasureSpec.EXACTLY),
1101
- MeasureSpec.makeMeasureSpec(fh, MeasureSpec.EXACTLY)
1102
- )
1103
- holder.frame.layout(0, 0, fw, fh)
1104
- }
1105
- }
1106
- }
1107
- }
1108
-
1109
- override fun onViewRecycled(holder: Holder) {
1110
- holder.loadJob?.cancel()
1111
- holder.currentCanvas?.let {
1112
- singlePageCanvases.remove(it.pageIndex)
1113
- holder.frame.removeView(it)
1114
- }
1115
- holder.currentCanvas = null
1116
- holder.imageView.setImageBitmap(null)
1117
- }
1118
- }
1119
-
1120
- // ─────────────────────────────────────────────────────────────────────────
1121
- // ZoomRecyclerView — single mode-д pinch-to-zoom + pan дэмждэг RecyclerView
1122
- //
1123
- // Яагаад RecyclerView subclass хийх шаардлагатай вэ:
1124
- // AnnotationCanvasView ACTION_DOWN-д false буцаана → RecyclerView
1125
- // gesture-г эзэмшиж авна → дараагийн ACTION_POINTER_DOWN (2-р хуруу)
1126
- // шууд RecyclerView.onTouchEvent-д очдог.
1127
- // RecyclerView subclass хийснээр onTouchEvent-д multi-touch-г зохицуулна.
1128
- //
1129
- // Zoom тооцоо (pivot=0,0 + translation):
1130
- // Item view point (px,py) дэлгэц: (px*scale + tx, py*scale + ty)
1131
- // Focus (fx,fy) тогтвортой байхад: new_tx = fx - (fx - tx) * scaleFactor
1132
- // ─────────────────────────────────────────────────────────────────────────
1133
-
1134
- inner class ZoomRecyclerView(context: Context) : RecyclerView(context) {
1135
- private var currentScale = 1f
1136
- private var tx = 0f
1137
- private var ty = 0f
1138
- private var panX = 0f
1139
- private var panY = 0f
1140
-
1141
- private val zoomDetector = ScaleGestureDetector(context,
1142
- object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
1143
- override fun onScale(d: ScaleGestureDetector): Boolean {
1144
- val newScale = (currentScale * d.scaleFactor).coerceIn(minZoom, maxZoom)
1145
- val af = newScale / currentScale
1146
- // Focus point тогтвортой байхын zoom-to-point тооцоо
1147
- tx = d.focusX - (d.focusX - tx) * af
1148
- ty = d.focusY - (d.focusY - ty) * af
1149
- currentScale = newScale
1150
- clampAndApply()
1151
- return true
1152
- }
1153
- }
1154
- )
1155
-
1156
- override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
1157
- // Zoom хийгдсэн + tool идэвхгүй → pan хийхийн тулд ACTION_DOWN-г intercept
1158
- if (currentScale > 1f && activeTool == null && ev.action == MotionEvent.ACTION_DOWN) {
1159
- return true
1160
- }
1161
- return super.onInterceptTouchEvent(ev)
1162
- }
1163
-
1164
- override fun onTouchEvent(ev: MotionEvent): Boolean {
1165
- zoomDetector.onTouchEvent(ev)
1166
-
1167
- // 2 хуруу байвал RecyclerView scroll хийхгүйгээр zoom-г л хийнэ
1168
- if (ev.pointerCount >= 2 || zoomDetector.isInProgress) {
1169
- return true
1170
- }
1171
-
1172
- // Zoom хийгдсэн үед нэг хуруугаар pan (tool идэвхгүй)
1173
- if (currentScale > 1f && activeTool == null) {
1174
- when (ev.actionMasked) {
1175
- MotionEvent.ACTION_DOWN -> { panX = ev.x; panY = ev.y }
1176
- MotionEvent.ACTION_MOVE -> {
1177
- tx += ev.x - panX
1178
- ty += ev.y - panY
1179
- panX = ev.x; panY = ev.y
1180
- clampAndApply()
1181
- }
1182
- MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { }
1183
- }
1184
- return true
1185
- }
1186
-
1187
- return super.onTouchEvent(ev)
1188
- }
1189
-
1190
- fun resetZoom() {
1191
- currentScale = 1f; tx = 0f; ty = 0f
1192
- applyToCurrentItem()
1193
- }
1194
-
1195
- private fun clampAndApply() {
1196
- val w = width.toFloat(); val h = height.toFloat()
1197
- if (w > 0f && h > 0f) {
1198
- val sw = w * currentScale; val sh = h * currentScale
1199
- tx = if (sw > w) tx.coerceIn(w - sw, 0f) else 0f
1200
- ty = if (sh > h) ty.coerceIn(h - sh, 0f) else 0f
1201
- }
1202
- applyToCurrentItem()
1203
- }
1204
-
1205
- private fun applyToCurrentItem() {
1206
- val lm = layoutManager as? LinearLayoutManager ?: return
1207
- val pos = lm.findFirstVisibleItemPosition().takeIf { it >= 0 } ?: return
1208
- val itemView = lm.findViewByPosition(pos) ?: return
1209
- itemView.pivotX = 0f; itemView.pivotY = 0f
1210
- itemView.scaleX = currentScale; itemView.scaleY = currentScale
1211
- itemView.translationX = tx; itemView.translationY = ty
1212
- }
1213
- }
1214
-
1215
- // ─────────────────────────────────────────────────────────────────────────
1216
- // AnnotationCanvasView (inner class full access to outer view state)
1217
- // ─────────────────────────────────────────────────────────────────────────
1218
-
1219
- inner class AnnotationCanvasView(
1220
- context: Context,
1221
- val pageIndex: Int,
1222
- private val pdfWidth: Float,
1223
- private val pdfHeight: Float
1224
- ) : View(context) {
1225
-
1226
- private var drawTool: String? = null
1227
- private val drawPoints = mutableListOf<PointF>() // PDF coords
1228
- private var drawStart: PointF? = null
1229
-
1230
- // ── No-tool touch state (tap event, long-press → drag) ─────────────
1231
- private var noToolDown = false
1232
- private var noToolDownScreen = PointF()
1233
- private var isDragging = false
1234
- private var dragAnn: PdfAnnotation? = null
1235
- private var dragOrigBounds = RectF()
1236
- private val LP_TIMEOUT = android.view.ViewConfiguration.getLongPressTimeout().toLong()
1237
- private val TOUCH_SLOP by lazy { android.view.ViewConfiguration.get(context).scaledTouchSlop.toFloat() }
1238
- private val lpHandler = android.os.Handler(android.os.Looper.getMainLooper())
1239
- private val lpRunnable = Runnable {
1240
- if (dragAnn?.type != "text" && dragAnn?.type != "note") return@Runnable
1241
- isDragging = true
1242
- pager.requestDisallowInterceptTouchEvent(true)
1243
- scrollView.requestDisallowInterceptTouchEvent(true)
1244
- invalidate()
1245
- }
1246
-
1247
- // Reusable objects — no allocation in onDraw
1248
- private val inkPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
1249
- style = Paint.Style.STROKE; strokeCap = Paint.Cap.ROUND; strokeJoin = Paint.Join.ROUND
1250
- }
1251
- private val bgFill = Paint().apply { style = Paint.Style.FILL }
1252
- private val bgStroke = Paint().apply {
1253
- style = Paint.Style.STROKE; strokeWidth = 1.5f; color = Color.DKGRAY
1254
- }
1255
- private val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
1256
- private val drawPath = Path()
1257
-
1258
- // ── Lifecycle ─────────────────────────────────────────────────────────
1259
-
1260
- override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
1261
- super.onSizeChanged(w, h, oldw, oldh)
1262
- // Single-mode canvases start with MATCH_PARENT (width=0) and receive their real
1263
- // size after RecyclerView lays out. Re-draw so initialAnnotations become visible.
1264
- if (w > 0 && oldw == 0) invalidate()
1265
- }
1266
-
1267
- // ── Coordinate helpers ────────────────────────────────────────────────
1268
-
1269
- private fun scale() = if (width > 0 && pdfWidth > 0) width.toFloat() / pdfWidth else 1f
1270
-
1271
- /** View-local screen → PDF. */
1272
- private fun toPdf(sx: Float, sy: Float): PointF {
1273
- val s = scale(); return PointF(sx / s, pdfHeight - sy / s)
1274
- }
1275
-
1276
- /** PDF → view-local screen. */
1277
- private fun toScreen(px: Float, py: Float): PointF {
1278
- val s = scale(); return PointF(px * s, (pdfHeight - py) * s)
1279
- }
1280
-
1281
- /**
1282
- * PDF RectF → screen RectF.
1283
- * bounds.top = lower PDF y → screen bottom
1284
- * bounds.bottom = upper PDF y → screen top
1285
- */
1286
- private fun toScreenRect(b: RectF): RectF {
1287
- val s = scale()
1288
- return RectF(b.left * s, (pdfHeight - b.bottom) * s, b.right * s, (pdfHeight - b.top) * s)
1289
- }
1290
-
1291
- // ── Touch ─────────────────────────────────────────────────────────────
1292
-
1293
- override fun onTouchEvent(event: MotionEvent): Boolean {
1294
- val tool = activeTool
1295
- if (tool != null) {
1296
- // Drawing tool active — consume all events
1297
- scrollView.requestDisallowInterceptTouchEvent(true)
1298
- pager.requestDisallowInterceptTouchEvent(true)
1299
- val pdfPt = toPdf(event.x, event.y)
1300
- when (tool) {
1301
- "eraser" -> {
1302
- if (event.action == MotionEvent.ACTION_DOWN || event.action == MotionEvent.ACTION_MOVE)
1303
- eraseAt(pdfPt)
1304
- }
1305
- "text", "note" -> { if (event.action == MotionEvent.ACTION_DOWN) addInstantAnnotation(pdfPt, tool) }
1306
- else -> handleDraw(event, pdfPt, tool)
1307
- }
1308
- return true
1309
- }
1310
- return handleNoToolTouch(event)
1311
- }
1312
-
1313
- /**
1314
- * Touch handling when no drawing tool is active.
1315
- * - Single tap on text/note → fire onTextPress / onNotePress
1316
- * - Long press on text/note → drag the annotation
1317
- * The key issue: returning false on ACTION_DOWN means we never receive ACTION_UP.
1318
- * So we return true only when we detect a tap/drag target on ACTION_DOWN.
1319
- */
1320
- private fun handleNoToolTouch(event: MotionEvent): Boolean {
1321
- when (event.action) {
1322
- MotionEvent.ACTION_DOWN -> {
1323
- val pdfPt = toPdf(event.x, event.y)
1324
- val ann = tapTargetAt(pdfPt) ?: return false
1325
- dragAnn = ann
1326
- dragOrigBounds = RectF(ann.bounds)
1327
- noToolDownScreen = PointF(event.x, event.y)
1328
- noToolDown = true
1329
- lpHandler.postDelayed(lpRunnable, LP_TIMEOUT)
1330
- return true
1331
- }
1332
- MotionEvent.ACTION_MOVE -> {
1333
- if (!noToolDown) return false
1334
- val dx = event.x - noToolDownScreen.x
1335
- val dy = event.y - noToolDownScreen.y
1336
- if (isDragging) {
1337
- val ann = dragAnn ?: return true
1338
- val s = scale()
1339
- val annW = dragOrigBounds.width()
1340
- val annH = dragOrigBounds.height()
1341
- // PDF y is inverted: screen down (dy>0) PDF y decreases
1342
- val newLeft = (dragOrigBounds.left + dx / s).coerceIn(0f, pdfWidth - annW)
1343
- val newBottom = (dragOrigBounds.bottom - dy / s).coerceIn(annH, pdfHeight)
1344
- ann.bounds = RectF(newLeft, newBottom - annH, newLeft + annW, newBottom)
1345
- invalidate()
1346
- return true
1347
- }
1348
- // Cancel long-press if finger moved beyond touch slop
1349
- if (Math.hypot(dx.toDouble(), dy.toDouble()) > TOUCH_SLOP) {
1350
- lpHandler.removeCallbacks(lpRunnable)
1351
- noToolDown = false
1352
- dragAnn = null
1353
- return false
1354
- }
1355
- return true
1356
- }
1357
- MotionEvent.ACTION_UP -> {
1358
- lpHandler.removeCallbacks(lpRunnable)
1359
- val wasDown = noToolDown
1360
- val wasDragging = isDragging
1361
- noToolDown = false; isDragging = false
1362
- pager.requestDisallowInterceptTouchEvent(false)
1363
- scrollView.requestDisallowInterceptTouchEvent(false)
1364
- if (!wasDown) { dragAnn = null; return false }
1365
- if (wasDragging) {
1366
- dragAnn = null
1367
- notifyAnnotationChange()
1368
- } else {
1369
- dragAnn = null
1370
- handleTap(event.x, event.y)
1371
- }
1372
- return true
1373
- }
1374
- MotionEvent.ACTION_CANCEL -> {
1375
- lpHandler.removeCallbacks(lpRunnable)
1376
- if (isDragging) { dragAnn?.bounds = RectF(dragOrigBounds); invalidate() }
1377
- noToolDown = false; isDragging = false; dragAnn = null
1378
- pager.requestDisallowInterceptTouchEvent(false)
1379
- scrollView.requestDisallowInterceptTouchEvent(false)
1380
- return false
1381
- }
1382
- }
1383
- return noToolDown
1384
- }
1385
-
1386
- /** Returns the first text or note annotation under [pdfPt], or null. */
1387
- private fun tapTargetAt(pdfPt: PointF): PdfAnnotation? {
1388
- val pageAnns = annotationMap[pageIndex] ?: return null
1389
- return pageAnns.firstOrNull { (it.type == "text" || it.type == "note") && it.bounds.contains(pdfPt.x, pdfPt.y) }
1390
- }
1391
-
1392
- private fun handleDraw(event: MotionEvent, pdfPt: PointF, tool: String) {
1393
- when (event.action) {
1394
- MotionEvent.ACTION_DOWN -> {
1395
- drawTool = tool; drawPoints.clear()
1396
- drawStart = PointF(pdfPt.x, pdfPt.y); drawPoints.add(PointF(pdfPt.x, pdfPt.y))
1397
- invalidate()
1398
- }
1399
- MotionEvent.ACTION_MOVE -> {
1400
- if (tool == "line") {
1401
- drawPoints.clear(); drawStart?.let { drawPoints.add(PointF(it.x, it.y)) }
1402
- }
1403
- drawPoints.add(PointF(pdfPt.x, pdfPt.y)); invalidate()
1404
- }
1405
- MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
1406
- if (drawPoints.size >= 2) commitStroke(tool)
1407
- drawTool = null; drawPoints.clear(); drawStart = null; invalidate()
1408
- }
1409
- }
1410
- }
1411
-
1412
- private fun commitStroke(tool: String) {
1413
- val pad = strokeWidthVal / 2f
1414
- val ann = PdfAnnotation(
1415
- type = tool, pageIndex = pageIndex,
1416
- bounds = RectF(
1417
- drawPoints.minOf { it.x } - pad, drawPoints.minOf { it.y } - pad,
1418
- drawPoints.maxOf { it.x } + pad, drawPoints.maxOf { it.y } + pad
1419
- ),
1420
- color = strokeColorVal, strokeWidth = strokeWidthVal
1421
- )
1422
- ann.paths.add(AnnPath(drawPoints.map { PointF(it.x, it.y) }.toMutableList()))
1423
- addAnnotationAndCommit(ann)
1424
- }
1425
-
1426
- private fun eraseAt(pdfPt: PointF) {
1427
- val pageAnns = annotationMap[pageIndex] ?: return
1428
- val r = 10f / scale()
1429
- val hit = RectF(pdfPt.x - r, pdfPt.y - r, pdfPt.x + r, pdfPt.y + r)
1430
- val toRemove = pageAnns.filter { RectF.intersects(it.bounds, hit) }
1431
- if (toRemove.isNotEmpty()) {
1432
- pageAnns.removeAll(toRemove.toSet())
1433
- toRemove.forEach { ann -> undoStack.removeAll { it.annotation === ann } }
1434
- invalidate(); notifyAnnotationChange(); notifyUndoRedoState()
1435
- }
1436
- }
1437
-
1438
- private fun addInstantAnnotation(pdfPt: PointF, tool: String) {
1439
- val s = scale()
1440
- val ann = if (tool == "note") {
1441
- PdfAnnotation("note", pageIndex,
1442
- RectF(pdfPt.x, pdfPt.y, pdfPt.x + 32f / s, pdfPt.y + 32f / s),
1443
- color = noteColorVal, contents = textContentVal.ifBlank { " " })
1444
- } else {
1445
- val w = 180f / s; val h = 40f / s
1446
- PdfAnnotation("text", pageIndex,
1447
- RectF(pdfPt.x, pdfPt.y - h, pdfPt.x + w, pdfPt.y),
1448
- color = textColorVal, contents = textContentVal.ifBlank { " " },
1449
- fontSize = textFontSizeVal, bold = textBoldVal, italic = textItalicVal)
1450
- }
1451
- addAnnotationAndCommit(ann)
1452
- }
1453
-
1454
- private fun handleTap(screenX: Float, screenY: Float) {
1455
- val pdfPt = toPdf(screenX, screenY)
1456
- val pageAnns = annotationMap[pageIndex] ?: return
1457
-
1458
- pageAnns.filter { it.type == "text" }.forEachIndexed { idx, ann ->
1459
- if (ann.bounds.contains(pdfPt.x, pdfPt.y)) {
1460
- onTextPress(mapOf("page" to pageIndex, "index" to idx,
1461
- "bounds" to boundsMap(ann), "contents" to ann.contents)); return
1462
- }
1463
- }
1464
- pageAnns.filter { it.type == "note" }.forEachIndexed { idx, ann ->
1465
- if (ann.bounds.contains(pdfPt.x, pdfPt.y)) {
1466
- onNotePress(mapOf("page" to pageIndex, "index" to idx,
1467
- "bounds" to boundsMap(ann), "contents" to ann.contents)); return
1468
- }
1469
- }
1470
- }
1471
-
1472
- private fun boundsMap(ann: PdfAnnotation) = mapOf(
1473
- "x" to ann.bounds.left.toDouble(), "y" to ann.bounds.top.toDouble(),
1474
- "width" to ann.bounds.width().toDouble(), "height" to ann.bounds.height().toDouble()
1475
- )
1476
-
1477
- // ── Draw ──────────────────────────────────────────────────────────────
1478
-
1479
- override fun onDraw(canvas: Canvas) {
1480
- for (ann in annotationMap[pageIndex] ?: emptyList()) drawAnn(canvas, ann)
1481
- drawTool?.let { drawActiveStroke(canvas, it) }
1482
- }
1483
-
1484
- private fun drawAnn(canvas: Canvas, ann: PdfAnnotation) = when (ann.type) {
1485
- "pen", "highlighter", "line" -> drawInk(canvas, ann)
1486
- "text" -> drawTextAnn(canvas, ann)
1487
- "note" -> drawNoteAnn(canvas, ann)
1488
- else -> Unit
1489
- }
1490
-
1491
- private fun drawInk(canvas: Canvas, ann: PdfAnnotation) {
1492
- val s = scale(); val base = safeColor(ann.color)
1493
- inkPaint.strokeWidth = ann.strokeWidth * s
1494
- inkPaint.color = if (ann.type == "highlighter")
1495
- Color.argb(102, Color.red(base), Color.green(base), Color.blue(base)) else base
1496
- for (ap in ann.paths) {
1497
- if (ap.points.isEmpty()) continue
1498
- drawPath.reset()
1499
- ap.points.forEachIndexed { i, pt ->
1500
- val sp = toScreen(pt.x, pt.y)
1501
- if (i == 0) drawPath.moveTo(sp.x, sp.y) else drawPath.lineTo(sp.x, sp.y)
1502
- }
1503
- canvas.drawPath(drawPath, inkPaint)
1504
- }
1505
- }
1506
-
1507
- private fun drawTextAnn(canvas: Canvas, ann: PdfAnnotation) {
1508
- val text = ann.contents.trim().ifEmpty { return }
1509
- val rect = toScreenRect(ann.bounds)
1510
- val s = scale()
1511
- textPaint.textSize = ann.fontSize * s
1512
- textPaint.color = safeColor(ann.color)
1513
- textPaint.typeface = when {
1514
- ann.bold && ann.italic -> Typeface.create(Typeface.DEFAULT, Typeface.BOLD_ITALIC)
1515
- ann.bold -> Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
1516
- ann.italic -> Typeface.create(Typeface.DEFAULT, Typeface.ITALIC)
1517
- else -> Typeface.DEFAULT
1518
- }
1519
- // No background — fully transparent
1520
- val maxW = (rect.width() - 8f).toInt().coerceAtLeast(1)
1521
- canvas.save()
1522
- canvas.translate(rect.left + 4f, rect.top + 4f)
1523
- StaticLayout.Builder.obtain(text, 0, text.length, textPaint, maxW)
1524
- .setAlignment(Layout.Alignment.ALIGN_NORMAL).build().draw(canvas)
1525
- canvas.restore()
1526
- }
1527
-
1528
- private fun drawNoteAnn(canvas: Canvas, ann: PdfAnnotation) {
1529
- val rect = toScreenRect(ann.bounds)
1530
- bgFill.color = try { Color.parseColor(ann.color) } catch (_: Exception) { Color.YELLOW }
1531
- canvas.drawRect(rect, bgFill); canvas.drawRect(rect, bgStroke)
1532
- textPaint.textSize = rect.height() * 0.55f; textPaint.color = Color.DKGRAY
1533
- textPaint.textAlign = Paint.Align.CENTER; textPaint.typeface = Typeface.DEFAULT
1534
- canvas.drawText("✎", rect.centerX(), rect.centerY() + textPaint.textSize * 0.35f, textPaint)
1535
- textPaint.textAlign = Paint.Align.LEFT
1536
- }
1537
-
1538
- private fun drawActiveStroke(canvas: Canvas, tool: String) {
1539
- if (drawPoints.isEmpty()) return
1540
- val s = scale(); val base = safeColor(strokeColorVal)
1541
- inkPaint.strokeWidth = strokeWidthVal * s
1542
- inkPaint.color = if (tool == "highlighter")
1543
- Color.argb(102, Color.red(base), Color.green(base), Color.blue(base)) else base
1544
- drawPath.reset()
1545
- drawPoints.forEachIndexed { i, pt ->
1546
- val sp = toScreen(pt.x, pt.y)
1547
- if (i == 0) drawPath.moveTo(sp.x, sp.y) else drawPath.lineTo(sp.x, sp.y)
1548
- }
1549
- canvas.drawPath(drawPath, inkPaint)
1550
- }
1551
-
1552
- private fun safeColor(hex: String): Int =
1553
- try { Color.parseColor(hex) } catch (_: Exception) { Color.RED }
1554
- }
1555
- }
1
+ package expo.modules.pdfreader
2
+
3
+ import android.content.Context
4
+ import android.graphics.*
5
+ import android.graphics.pdf.PdfRenderer
6
+ import android.os.ParcelFileDescriptor
7
+ import android.text.Layout
8
+ import android.text.StaticLayout
9
+ import android.text.TextPaint
10
+ import android.util.Log
11
+ import android.view.*
12
+ import android.widget.*
13
+ import androidx.recyclerview.widget.LinearLayoutManager
14
+ import androidx.recyclerview.widget.PagerSnapHelper
15
+ import androidx.recyclerview.widget.RecyclerView
16
+ import expo.modules.kotlin.AppContext
17
+ import expo.modules.kotlin.viewevent.EventDispatcher
18
+ import expo.modules.kotlin.views.ExpoView
19
+ import kotlinx.coroutines.*
20
+ import java.io.File
21
+ import java.io.FileOutputStream
22
+ import java.net.URL
23
+
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+ // Data classes
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+
28
+ data class AnnPath(val points: MutableList<PointF> = mutableListOf())
29
+
30
+ /**
31
+ * Bounds stored in PDF page coordinate space:
32
+ * origin = bottom-left of the page, y-axis points UP.
33
+ *
34
+ * RectF.left = left edge (PDF x)
35
+ * RectF.top = lower y in PDF space (screen-bottom side, smaller PDF-y value)
36
+ * RectF.right = right edge (PDF x)
37
+ * RectF.bottom = upper y in PDF space (screen-top side, larger PDF-y value)
38
+ *
39
+ * Matches iOS PDFKit: CGRect(x, y=lowerPdfY, width, height).
40
+ */
41
+ data class PdfAnnotation(
42
+ val type: String, // "pen" | "highlighter" | "line" | "text" | "note"
43
+ val pageIndex: Int,
44
+ var bounds: RectF,
45
+ val paths: MutableList<AnnPath> = mutableListOf(),
46
+ var color: String = "#FF0000",
47
+ var strokeWidth: Float = 2f,
48
+ var contents: String = "",
49
+ var fontSize: Float = 14f,
50
+ var bold: Boolean = false,
51
+ var italic: Boolean = false
52
+ )
53
+
54
+ data class UndoEntry(val annotation: PdfAnnotation)
55
+
56
+ // ─────────────────────────────────────────────────────────────────────────────
57
+ // Main view
58
+ // ─────────────────────────────────────────────────────────────────────────────
59
+
60
+ class ExpoPdfReaderView(
61
+ context: Context,
62
+ appContext: AppContext
63
+ ) : ExpoView(context, appContext) {
64
+
65
+ // ── Expo Events (ViewEventDelegate — Expo Modules-ын зөв механизм) ────────
66
+ val onReady by EventDispatcher()
67
+ val onPageChange by EventDispatcher()
68
+ val onAnnotationChange by EventDispatcher()
69
+ val onUndoRedoStateChange by EventDispatcher()
70
+ val onNotePress by EventDispatcher()
71
+ val onTextPress by EventDispatcher()
72
+
73
+ // ── Coroutines ───────────────────────────────────────────────────────────
74
+ private val pdfDispatcher = Dispatchers.IO.limitedParallelism(1)
75
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
76
+ private var renderJob: Job? = null
77
+
78
+ // ── PDF state ────────────────────────────────────────────────────────────
79
+ private var fileDescriptor: ParcelFileDescriptor? = null
80
+ private var renderer: PdfRenderer? = null
81
+ private var pagePdfW = intArrayOf() // PDF points per page
82
+ private var pagePdfH = intArrayOf()
83
+ var totalPages = 0
84
+ private set
85
+
86
+ // ── Display state ────────────────────────────────────────────────────────
87
+ private var displayMode = "continuous"
88
+ private var pendingUrl: String? = null
89
+ private var isLayoutReady = false
90
+ var currentPageIndex = 0
91
+ private set
92
+
93
+ // ── Lazy / chunked rendering ──────────────────────────────────────────────
94
+ private var renderedUpTo = -1 // last rendered page index (absolute)
95
+ private var windowStart = 0 // pageEntries[0]-н absolute page index
96
+ private var chunkLoading = false // chunk дуусах хүртэл дахин trigger хийхгүй
97
+ private var currentViewWidth = 0 // chunk load-д дахин хэрэглэнэ
98
+ private var loadingFooter: View? = null
99
+
100
+ companion object {
101
+ private const val PAGE_CHUNK = 10 // нэг удаа render хийх хуудасны тоо
102
+ private const val MAX_RENDERED = PAGE_CHUNK * 2 // санах ойд байлгах дээд хязгаар (20 хуудас)
103
+ }
104
+
105
+ // ── Annotation state ─────────────────────────────────────────────────────
106
+ val annotationMap = mutableMapOf<Int, MutableList<PdfAnnotation>>()
107
+ var activeTool: String? = null
108
+ var strokeColorVal = "#FF0000"
109
+ var strokeWidthVal = 2f
110
+ var textContentVal = ""
111
+ var textColorVal = "#000000"
112
+ var textFontSizeVal = 14f
113
+ var textBoldVal = false
114
+ var textItalicVal = false
115
+ var noteColorVal = "#FFFF00"
116
+
117
+ val undoStack = ArrayDeque<UndoEntry>()
118
+ val redoStack = ArrayDeque<UndoEntry>()
119
+ private var appliedFingerprint: String? = null
120
+
121
+ // ── Page view entries (continuous/twoUp modes) ───────────────────────────
122
+ // frame: container-аас устгахад шууд reference, bmpHeight: layout болохоос өмнө scroll тооцооны тулд
123
+ data class PageEntry(val frame: FrameLayout, val canvasView: AnnotationCanvasView, val bmpHeight: Int)
124
+ val pageEntries = mutableListOf<PageEntry>()
125
+
126
+ // ── Single-page pager (single mode) ──────────────────────────────────────
127
+ private lateinit var pager: RecyclerView
128
+ private var singleAdapter: SinglePageAdapter? = null
129
+ private val singlePageCanvases = mutableMapOf<Int, AnnotationCanvasView>()
130
+
131
+ // ── Zoom ─────────────────────────────────────────────────────────────────
132
+ private var minZoom = 1.0f
133
+ private var maxZoom = 5.0f
134
+ private var currentZoom = 1.0f
135
+ private val scaleDetector = ScaleGestureDetector(
136
+ context,
137
+ object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
138
+ override fun onScale(d: ScaleGestureDetector): Boolean {
139
+ if (activeTool != null) return true
140
+ applyZoom((currentZoom * d.scaleFactor).coerceIn(minZoom, maxZoom))
141
+ return true
142
+ }
143
+ }
144
+ )
145
+
146
+ // ── UI ───────────────────────────────────────────────────────────────────
147
+ val scrollView = ScrollView(context).apply {
148
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
149
+ isFillViewport = true
150
+ }
151
+ private val container = LinearLayout(context).apply {
152
+ orientation = LinearLayout.VERTICAL
153
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
154
+ setBackgroundColor(Color.parseColor("#E8E8E8"))
155
+ }
156
+
157
+ init {
158
+ setBackgroundColor(Color.parseColor("#E8E8E8"))
159
+ scrollView.addView(container)
160
+ addView(scrollView)
161
+ scrollView.setOnScrollChangeListener { _, _, scrollY, _, _ ->
162
+ detectCurrentPage(scrollY)
163
+ maybeTriggerChunkLoad(scrollY)
164
+ }
165
+
166
+ // Single-page pager: ZoomRecyclerView + PagerSnapHelper (vertical)
167
+ pager = ZoomRecyclerView(context).apply {
168
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
169
+ layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
170
+ PagerSnapHelper().attachToRecyclerView(this)
171
+ setBackgroundColor(Color.parseColor("#E8E8E8"))
172
+ visibility = View.GONE
173
+ addOnScrollListener(object : RecyclerView.OnScrollListener() {
174
+ override fun onScrollStateChanged(rv: RecyclerView, newState: Int) {
175
+ if (newState == RecyclerView.SCROLL_STATE_IDLE) {
176
+ val lm = rv.layoutManager as? LinearLayoutManager ?: return
177
+ val pos = lm.findFirstCompletelyVisibleItemPosition()
178
+ .takeIf { it >= 0 } ?: return
179
+ if (pos != currentPageIndex) {
180
+ currentPageIndex = pos
181
+ // Хуудас солигдоход zoom-г reset хийнэ
182
+ (pager as? ZoomRecyclerView)?.resetZoom()
183
+ onPageChange(mapOf("currentPage" to pos, "totalPage" to totalPages))
184
+ }
185
+ }
186
+ }
187
+ })
188
+ }
189
+ addView(pager)
190
+ }
191
+
192
+ override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
193
+ super.onLayout(changed, l, t, r, b)
194
+ if (width > 0 && !isLayoutReady) {
195
+ isLayoutReady = true
196
+ pendingUrl?.let {
197
+ pendingUrl = null
198
+ startLoad(it)
199
+ }
200
+ }
201
+ // Note: width өөрчлөгдөхөд triggerRender дуудахгүй —
202
+ // forced layout (forceLayoutScrollView) нь pages-г шууд харагдуулдаг
203
+ }
204
+
205
+ override fun onDetachedFromWindow() {
206
+ super.onDetachedFromWindow()
207
+ renderJob?.cancel()
208
+ scope.cancel()
209
+ safeCloseRenderer()
210
+ }
211
+
212
+ override fun onTouchEvent(event: MotionEvent): Boolean {
213
+ scaleDetector.onTouchEvent(event)
214
+ return super.onTouchEvent(event)
215
+ }
216
+
217
+ // ─────────────────────────────────────────────────────────────────────────
218
+ // Props
219
+ // ─────────────────────────────────────────────────────────────────────────
220
+
221
+ fun setUrl(url: String?) {
222
+ if (url.isNullOrBlank()) return
223
+ pendingUrl = url
224
+ if (isLayoutReady) {
225
+ startLoad(url)
226
+ } else {
227
+ // Layout ирээгүй байж болно — дараагийн frame-д шалгана
228
+ post { tryStartLoad() }
229
+ }
230
+ }
231
+
232
+ private fun tryStartLoad() {
233
+ val url = pendingUrl ?: return // onLayout аль хэдийн startLoad дуудсан бол pendingUrl = null → exit
234
+ if (width > 0) {
235
+ isLayoutReady = true
236
+ pendingUrl = null // давхар дуудлагаас сэргийлнэ
237
+ startLoad(url)
238
+ } else {
239
+ scope.launch {
240
+ delay(50)
241
+ tryStartLoad()
242
+ }
243
+ }
244
+ }
245
+
246
+ fun setDisplayMode(mode: String?) {
247
+ val newMode = mode?.lowercase() ?: "continuous"
248
+ if (newMode == displayMode) return
249
+ displayMode = newMode
250
+ if (renderer != null && isLayoutReady) triggerRender()
251
+ }
252
+
253
+ fun setInitialPage(page: Int) {
254
+ if (page < 0 || page >= totalPages) return
255
+ currentPageIndex = page
256
+ if (displayMode == "single") {
257
+ post { scrollToPage(page, smooth = false) }
258
+ return
259
+ }
260
+ val windowEnd = windowStart + pageEntries.size - 1
261
+ if (page in windowStart..windowEnd) {
262
+ // Хуудас render хийгдсэн window дотор байна → зүгээр scroll хийнэ
263
+ post { scrollToPage(page, smooth = false) }
264
+ } else {
265
+ // Хуудас render хийгдээгүй → window-г тухайн хуудас руу шилжүүлнэ
266
+ jumpToPage(page)
267
+ }
268
+ }
269
+
270
+ fun setMinZoom(v: Double) { minZoom = v.toFloat() }
271
+ fun setMaxZoom(v: Double) { maxZoom = v.toFloat() }
272
+
273
+ fun setTool(tool: String?) {
274
+ activeTool = tool
275
+ if (displayMode == "single") {
276
+ pager.requestDisallowInterceptTouchEvent(tool != null)
277
+ } else {
278
+ scrollView.requestDisallowInterceptTouchEvent(tool != null)
279
+ }
280
+ }
281
+
282
+ fun setStrokeColor(hex: String) { strokeColorVal = hex }
283
+ fun setStrokeWidth(w: Double) { strokeWidthVal = w.toFloat() }
284
+ fun setTextContent(t: String) { textContentVal = t }
285
+ fun setTextColor(hex: String) { textColorVal = hex }
286
+ fun setTextFontSize(size: Double) { textFontSizeVal = size.toFloat() }
287
+ fun setTextBold(v: Boolean) { textBoldVal = v }
288
+ fun setTextItalic(v: Boolean) { textItalicVal = v }
289
+ fun setNoteColor(hex: String) { noteColorVal = hex }
290
+
291
+ // ─────────────────────────────────────────────────────────────────────────
292
+ // Commands
293
+ // ─────────────────────────────────────────────────────────────────────────
294
+
295
+ fun undo() {
296
+ val e = undoStack.removeLastOrNull() ?: return
297
+ redoStack.addLast(e)
298
+ annotationMap[e.annotation.pageIndex]?.remove(e.annotation)
299
+ invalidateCanvas(e.annotation.pageIndex)
300
+ notifyAnnotationChange()
301
+ notifyUndoRedoState()
302
+ }
303
+
304
+ fun redo() {
305
+ val e = redoStack.removeLastOrNull() ?: return
306
+ undoStack.addLast(e)
307
+ annotationMap.getOrPut(e.annotation.pageIndex) { mutableListOf() }.add(e.annotation)
308
+ invalidateCanvas(e.annotation.pageIndex)
309
+ notifyAnnotationChange()
310
+ notifyUndoRedoState()
311
+ }
312
+
313
+ fun setAnnotations(data: List<Map<String, Any?>>) {
314
+ val fp = buildFingerprint(data)
315
+ if (fp == appliedFingerprint) return
316
+ appliedFingerprint = fp
317
+ annotationMap.clear()
318
+
319
+ for (item in data) {
320
+ val pageIndex = (item["page"] as? Number)?.toInt() ?: continue
321
+ val typeStr = item["type"] as? String ?: continue
322
+ val b = item["bounds"] as? Map<*, *> ?: continue
323
+ val bX = (b["x"] as? Number)?.toFloat() ?: 0f
324
+ val bY = (b["y"] as? Number)?.toFloat() ?: 0f
325
+ val bW = (b["width"] as? Number)?.toFloat() ?: 0f
326
+ val bH = (b["height"] as? Number)?.toFloat() ?: 0f
327
+
328
+ val bounds = RectF(bX, bY, bX + bW, bY + bH)
329
+ val ann = PdfAnnotation(
330
+ type = typeStr, pageIndex = pageIndex, bounds = bounds,
331
+ color = item["color"] as? String ?: strokeColorVal,
332
+ strokeWidth = (item["strokeWidth"] as? Number)?.toFloat() ?: strokeWidthVal,
333
+ contents = item["contents"] as? String ?: "",
334
+ fontSize = (item["fontSize"] as? Number)?.toFloat() ?: textFontSizeVal,
335
+ bold = item["bold"] as? Boolean ?: false,
336
+ italic = item["italic"] as? Boolean ?: false
337
+ )
338
+
339
+ (item["paths"] as? List<*>)?.forEach { pathData ->
340
+ val pts = (pathData as? List<*>)?.mapNotNull { pt ->
341
+ val m = pt as? Map<*, *> ?: return@mapNotNull null
342
+ PointF(
343
+ (m["x"] as? Number)?.toFloat() ?: 0f,
344
+ (m["y"] as? Number)?.toFloat() ?: 0f
345
+ )
346
+ }
347
+ if (!pts.isNullOrEmpty()) ann.paths.add(AnnPath(pts.toMutableList()))
348
+ }
349
+
350
+ annotationMap.getOrPut(pageIndex) { mutableListOf() }.add(ann)
351
+ }
352
+ if (displayMode == "single") {
353
+ singlePageCanvases.values.forEach { it.invalidate() }
354
+ } else {
355
+ pageEntries.forEach { it.canvasView.invalidate() }
356
+ }
357
+ }
358
+
359
+ fun updateText(pageIndex: Int, index: Int, contents: String) {
360
+ val anns = annotationMap[pageIndex]?.filter { it.type == "text" } ?: return
361
+ if (index >= anns.size) return
362
+ val ann = anns[index]
363
+ ann.contents = contents.ifBlank { " " }
364
+ autoSizeTextAnnotation(ann)
365
+ invalidateCanvas(pageIndex)
366
+ notifyAnnotationChange()
367
+ }
368
+
369
+ /**
370
+ * Recalculate the annotation's bounds so the text fits without clipping.
371
+ * Width is capped at 70% of the view width; height grows with line count.
372
+ */
373
+ private fun autoSizeTextAnnotation(ann: PdfAnnotation) {
374
+ val text = ann.contents.trim().ifEmpty { return }
375
+ val pageW = pagePdfW.getOrElse(ann.pageIndex) { 0 }
376
+ if (pageW <= 0 || width <= 0) return
377
+ val s = width.toFloat() / pageW.toFloat()
378
+ val tp = android.text.TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
379
+ textSize = ann.fontSize * s
380
+ typeface = when {
381
+ ann.bold && ann.italic -> Typeface.create(Typeface.DEFAULT, Typeface.BOLD_ITALIC)
382
+ ann.bold -> Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
383
+ ann.italic -> Typeface.create(Typeface.DEFAULT, Typeface.ITALIC)
384
+ else -> Typeface.DEFAULT
385
+ }
386
+ }
387
+ val pad = 16f // screen px total padding (8px each side)
388
+ val maxW = (width * 0.7f - pad).toInt().coerceAtLeast(80)
389
+ val layout = StaticLayout.Builder
390
+ .obtain(text, 0, text.length, tp, maxW)
391
+ .setAlignment(android.text.Layout.Alignment.ALIGN_NORMAL)
392
+ .build()
393
+ val newW = (layout.width + pad) / s
394
+ val newH = (layout.height + pad) / s
395
+ // Keep top-left anchor fixed
396
+ ann.bounds = RectF(ann.bounds.left, ann.bounds.top, ann.bounds.left + newW, ann.bounds.top + newH)
397
+ }
398
+
399
+ fun updateNote(pageIndex: Int, index: Int, contents: String) {
400
+ val anns = annotationMap[pageIndex]?.filter { it.type == "note" } ?: return
401
+ if (index >= anns.size) return
402
+ anns[index].contents = contents.ifBlank { " " }
403
+ invalidateCanvas(pageIndex)
404
+ notifyAnnotationChange()
405
+ }
406
+
407
+ // ─────────────────────────────────────────────────────────────────────────
408
+ // Internal helpers (used by inner AnnotationCanvasView)
409
+ // ─────────────────────────────────────────────────────────────────────────
410
+
411
+ fun addAnnotationAndCommit(ann: PdfAnnotation) {
412
+ annotationMap.getOrPut(ann.pageIndex) { mutableListOf() }.add(ann)
413
+ undoStack.addLast(UndoEntry(ann))
414
+ redoStack.clear()
415
+ invalidateCanvas(ann.pageIndex)
416
+ notifyAnnotationChange()
417
+ notifyUndoRedoState()
418
+ }
419
+
420
+ fun invalidateCanvas(pageIndex: Int) {
421
+ if (displayMode == "single") {
422
+ singlePageCanvases[pageIndex]?.invalidate()
423
+ } else {
424
+ // continuous mode: pageEntries[0] = page windowStart → relative index
425
+ pageEntries.getOrNull(pageIndex - windowStart)?.canvasView?.invalidate()
426
+ }
427
+ }
428
+
429
+ fun notifyAnnotationChange() {
430
+ val all = mutableListOf<Map<String, Any>>()
431
+ for ((pageIndex, anns) in annotationMap) {
432
+ for (ann in anns) {
433
+ val m = mutableMapOf<String, Any>(
434
+ "type" to ann.type,
435
+ "page" to pageIndex,
436
+ "bounds" to mapOf(
437
+ "x" to ann.bounds.left.toDouble(),
438
+ "y" to ann.bounds.top.toDouble(),
439
+ "width" to ann.bounds.width().toDouble(),
440
+ "height" to ann.bounds.height().toDouble()
441
+ ),
442
+ "color" to ann.color,
443
+ "strokeWidth" to ann.strokeWidth.toDouble(),
444
+ "contents" to ann.contents
445
+ )
446
+ if (ann.type == "text") {
447
+ m["fontSize"] = ann.fontSize.toDouble()
448
+ m["bold"] = ann.bold
449
+ m["italic"] = ann.italic
450
+ }
451
+ if (ann.paths.isNotEmpty()) {
452
+ m["paths"] = ann.paths.map { p ->
453
+ p.points.map { pt ->
454
+ mapOf("x" to pt.x.toDouble(), "y" to pt.y.toDouble())
455
+ }
456
+ }
457
+ }
458
+ all.add(m)
459
+ }
460
+ }
461
+ onAnnotationChange(mapOf("annotations" to all))
462
+ }
463
+
464
+ fun notifyUndoRedoState() {
465
+ onUndoRedoStateChange(
466
+ mapOf("canUndo" to undoStack.isNotEmpty(), "canRedo" to redoStack.isNotEmpty())
467
+ )
468
+ }
469
+
470
+ // ─────────────────────────────────────────────────────────────────────────
471
+ // Zoom
472
+ // ─────────────────────────────────────────────────────────────────────────
473
+
474
+ private fun applyZoom(newZoom: Float) {
475
+ currentZoom = newZoom
476
+ container.pivotX = 0f
477
+ container.pivotY = 0f
478
+ container.scaleX = newZoom
479
+ container.scaleY = newZoom
480
+ }
481
+
482
+ // ─────────────────────────────────────────────────────────────────────────
483
+ // Scroll / page detection
484
+ // ─────────────────────────────────────────────────────────────────────────
485
+
486
+ /** twoupcontinuous: нэг мөр = хос entry (хуудас дараалсан), эсвэл эцсийн ганц entry. */
487
+ private fun twoupStrideAt(entryIdx: Int): Int {
488
+ if (displayMode != "twoupcontinuous") return 1
489
+ if (entryIdx >= pageEntries.size) return 0
490
+ if (entryIdx + 1 < pageEntries.size) {
491
+ val a = pageEntries[entryIdx].canvasView.pageIndex
492
+ val b = pageEntries[entryIdx + 1].canvasView.pageIndex
493
+ if (b == a + 1) return 2
494
+ }
495
+ return 1
496
+ }
497
+
498
+ /** scrollView доторх pageIndex хуудасны мөрийн дээд ирмэгийн Y (continuous / twoupcontinuous). */
499
+ private fun scrollYForScrollModePage(pageIndex: Int): Int {
500
+ if (pageEntries.isEmpty()) return 0
501
+ val lastAbsPage = windowStart + pageEntries.size - 1
502
+ val clamped = pageIndex.coerceIn(windowStart, lastAbsPage.coerceAtLeast(windowStart))
503
+ if (displayMode != "twoupcontinuous") {
504
+ val rel = (clamped - windowStart).coerceIn(0, maxOf(0, pageEntries.size - 1))
505
+ var y = 0
506
+ for (i in 0 until rel) {
507
+ y += (pageEntries[i].bmpHeight.takeIf { it > 0 } ?: 0) + 12
508
+ }
509
+ return y
510
+ }
511
+ var y = 0
512
+ var idx = 0
513
+ while (idx < pageEntries.size) {
514
+ val stride = twoupStrideAt(idx)
515
+ val rowH = pageEntries[idx].bmpHeight.takeIf { it > 0 } ?: 0
516
+ val p0 = pageEntries[idx].canvasView.pageIndex
517
+ val p1 = if (stride == 2) pageEntries[idx + 1].canvasView.pageIndex else p0
518
+ if (clamped in p0..p1) return y
519
+ y += rowH + 12
520
+ idx += stride
521
+ }
522
+ return y
523
+ }
524
+
525
+ private fun detectCurrentPage(scrollY: Int) {
526
+ if (pageEntries.isEmpty()) return
527
+ val viewH = scrollView.height.takeIf { it > 0 } ?: height
528
+ val centerY = scrollY + viewH / 2
529
+ if (displayMode == "twoupcontinuous") {
530
+ var accY = 0
531
+ var idx = 0
532
+ while (idx < pageEntries.size) {
533
+ val stride = twoupStrideAt(idx)
534
+ val rowH = pageEntries[idx].bmpHeight.takeIf { it > 0 } ?: 0
535
+ val nextY = accY + rowH + 12
536
+ val p0 = pageEntries[idx].canvasView.pageIndex
537
+ val p1 = if (stride == 2) pageEntries[idx + 1].canvasView.pageIndex else p0
538
+ if (centerY <= nextY || idx + stride >= pageEntries.size) {
539
+ val vx = scrollView.width.takeIf { it > 0 } ?: width
540
+ val absPage = if (stride == 2 && p1 > p0 && vx > 0) {
541
+ val midPix = vx / 2
542
+ val centerX = scrollView.scrollX + midPix
543
+ if (centerX < midPix) p0 else p1
544
+ } else p0
545
+ if (currentPageIndex != absPage) {
546
+ currentPageIndex = absPage
547
+ onPageChange(mapOf("currentPage" to absPage, "totalPage" to totalPages))
548
+ }
549
+ break
550
+ }
551
+ accY = nextY
552
+ idx += stride
553
+ }
554
+ return
555
+ }
556
+ // continuous — entry бүр нэг босоо мөр
557
+ var accY = 0
558
+ for (i in pageEntries.indices) {
559
+ val h = pageEntries[i].bmpHeight.takeIf { it > 0 } ?: continue
560
+ accY += h + 12
561
+ if (centerY <= accY || i == pageEntries.size - 1) {
562
+ val absolutePage = windowStart + i
563
+ if (currentPageIndex != absolutePage) {
564
+ currentPageIndex = absolutePage
565
+ onPageChange(mapOf("currentPage" to absolutePage, "totalPage" to totalPages))
566
+ }
567
+ break
568
+ }
569
+ }
570
+ }
571
+
572
+ private fun scrollToPage(pageIndex: Int, smooth: Boolean = true) {
573
+ if (displayMode == "single") {
574
+ if (smooth) pager.smoothScrollToPosition(pageIndex)
575
+ else pager.scrollToPosition(pageIndex)
576
+ currentPageIndex = pageIndex
577
+ onPageChange(mapOf("currentPage" to pageIndex, "totalPage" to totalPages))
578
+ return
579
+ }
580
+ if (pageEntries.isEmpty()) return
581
+ val y = scrollYForScrollModePage(pageIndex)
582
+ if (smooth) scrollView.smoothScrollTo(0, y) else scrollView.scrollTo(0, y)
583
+ currentPageIndex = pageIndex
584
+ onPageChange(mapOf("currentPage" to pageIndex, "totalPage" to totalPages))
585
+ }
586
+
587
+ // ─────────────────────────────────────────────────────────────────────────
588
+ // Load & Render
589
+ // ─────────────────────────────────────────────────────────────────────────
590
+
591
+ private fun startLoad(url: String) {
592
+ Log.d("ExpoPdfReader", "startLoad: $url")
593
+ renderJob?.cancel()
594
+ renderJob = scope.launch {
595
+ try {
596
+ withContext(pdfDispatcher) { safeCloseRenderer() }
597
+ val file = withContext(pdfDispatcher) { resolveFile(url) }
598
+ withContext(pdfDispatcher) {
599
+ fileDescriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
600
+ renderer = PdfRenderer(fileDescriptor!!)
601
+ val r = renderer!!
602
+ totalPages = r.pageCount
603
+ // Хэмжээг энд бүгдийг нь уншихгүй — renderPageBitmap нээх бүрт бөглөнө (анхны ачаалал хурдан).
604
+ pagePdfW = IntArray(totalPages) { 0 }
605
+ pagePdfH = IntArray(totalPages) { 0 }
606
+ }
607
+ // annotationMap is intentionally NOT cleared here.
608
+ // setAnnotations() may have already populated it before startLoad() runs
609
+ // (Expo prop ordering: url first, then initialAnnotations).
610
+ // Canvases created in renderDocument() read annotationMap on their first onDraw().
611
+ undoStack.clear(); redoStack.clear()
612
+ appliedFingerprint = null; currentPageIndex = 0
613
+ renderDocument()
614
+ // After canvases are created, re-invalidate all of them so initialAnnotations appear.
615
+ withContext(Dispatchers.Main) {
616
+ if (displayMode == "single") {
617
+ singlePageCanvases.values.forEach { it.invalidate() }
618
+ } else {
619
+ pageEntries.forEach { it.canvasView.invalidate() }
620
+ }
621
+ }
622
+ } catch (e: CancellationException) { throw e }
623
+ catch (e: Exception) { Log.e("ExpoPdfReader", "Load error", e) }
624
+ }
625
+ }
626
+
627
+ private fun triggerRender() {
628
+ renderJob?.cancel()
629
+ renderJob = scope.launch {
630
+ try { renderDocument() }
631
+ catch (e: CancellationException) { throw e }
632
+ catch (e: Exception) { Log.e("ExpoPdfReader", "Render error", e) }
633
+ }
634
+ }
635
+
636
+ private suspend fun renderDocument() {
637
+ val pdf = renderer ?: return
638
+ val pageCount = withContext(pdfDispatcher) { pdf.pageCount }
639
+ if (pageCount <= 0) return
640
+ val viewWidth = width.takeIf { it > 0 } ?: resources.displayMetrics.widthPixels
641
+ if (viewWidth <= 0) return
642
+
643
+ // Chunk state reset
644
+ currentViewWidth = viewWidth
645
+ renderedUpTo = -1
646
+ windowStart = 0
647
+ chunkLoading = false
648
+
649
+ withContext(Dispatchers.Main) {
650
+ container.removeAllViews()
651
+ pageEntries.clear()
652
+ loadingFooter = null
653
+ }
654
+
655
+ when (displayMode) {
656
+ "single" -> renderSingleMode()
657
+ "twoup" -> renderAllPages(pdf, pageCount, viewWidth)
658
+ else -> renderScrollModeAround(
659
+ pdf,
660
+ pageCount,
661
+ viewWidth,
662
+ currentPageIndex.coerceIn(0, pageCount - 1)
663
+ )
664
+ }
665
+
666
+ // onReady: Main thread дээр шууд дуудна
667
+ withContext(Dispatchers.Main) {
668
+ val density = resources.displayMetrics.density
669
+ Log.d("ExpoPdfReader", "onReady firing: totalPages=$pageCount")
670
+ onReady(
671
+ mapOf(
672
+ "totalPages" to pageCount,
673
+ "width" to (this@ExpoPdfReaderView.width / density).toDouble(),
674
+ "height" to (this@ExpoPdfReaderView.height / density).toDouble()
675
+ )
676
+ )
677
+ }
678
+ }
679
+
680
+ // single mode: RecyclerView + PagerSnapHelper — нэг хуудас бүрэн харагдана
681
+ private suspend fun renderSingleMode() {
682
+ withContext(Dispatchers.Main) {
683
+ singlePageCanvases.clear()
684
+ scrollView.visibility = View.GONE
685
+ pager.visibility = View.VISIBLE
686
+ singleAdapter = SinglePageAdapter()
687
+ pager.adapter = singleAdapter
688
+ pager.scrollToPosition(currentPageIndex)
689
+ // React Native Fabric blocks child requestLayout() calls, so the RecyclerView
690
+ // never binds its items until the next Fabric layout pass (e.g. thumbnail panel
691
+ // opening/closing). Force a manual measure+layout to make items bind immediately.
692
+ forcePagerLayout()
693
+ }
694
+ }
695
+
696
+ private fun forcePagerLayout() {
697
+ val w = width.takeIf { it > 0 } ?: return
698
+ val h = height.takeIf { it > 0 } ?: return
699
+ pager.measure(
700
+ MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY),
701
+ MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY)
702
+ )
703
+ pager.layout(0, 0, w, h)
704
+ }
705
+
706
+ // twoup — бүх хуудсыг render хийнэ
707
+ private suspend fun renderAllPages(pdf: PdfRenderer, pageCount: Int, viewWidth: Int) {
708
+ withContext(Dispatchers.Main) {
709
+ scrollView.visibility = View.VISIBLE
710
+ pager.visibility = View.GONE
711
+ }
712
+ renderTwoUpRange(pdf, pageCount, viewWidth, 0, pageCount)
713
+ withContext(Dispatchers.Main) { renderedUpTo = pageCount - 1 }
714
+ }
715
+
716
+ /**
717
+ * continuous / twoupcontinuous — [anchorPage]-ийн эргэн тойронд MAX_RENDERED хүртэлх цонх.
718
+ * Горим солиход одоогийн хуудас хадгалагдана.
719
+ */
720
+ private suspend fun renderScrollModeAround(
721
+ pdf: PdfRenderer,
722
+ pageCount: Int,
723
+ viewWidth: Int,
724
+ anchorPage: Int
725
+ ) {
726
+ withContext(Dispatchers.Main) {
727
+ scrollView.visibility = View.VISIBLE
728
+ pager.visibility = View.GONE
729
+ }
730
+ val target = anchorPage.coerceIn(0, pageCount - 1)
731
+ val newStart = maxOf(0, target - PAGE_CHUNK / 2)
732
+ val newEnd = minOf(pageCount, newStart + MAX_RENDERED)
733
+ withContext(Dispatchers.Main) {
734
+ windowStart = newStart
735
+ renderedUpTo = newStart - 1
736
+ }
737
+ if (displayMode == "twoupcontinuous") {
738
+ renderTwoUpRange(pdf, pageCount, viewWidth, newStart, newEnd)
739
+ withContext(Dispatchers.Main) {
740
+ val rawUpTo = if (newEnd % 2 == 0) newEnd - 1 else newEnd - 2
741
+ renderedUpTo = rawUpTo.coerceIn(0, pageCount - 1)
742
+ if (renderedUpTo < pageCount - 1) showLoadingFooter()
743
+ forceLayoutScrollView()
744
+ post { scrollToPage(target, smooth = false) }
745
+ }
746
+ } else {
747
+ for (i in newStart until newEnd) {
748
+ val bmp = renderPageBitmap(pdf, i, viewWidth)
749
+ val idx = i
750
+ withContext(Dispatchers.Main) {
751
+ addPageRow(
752
+ bmp, idx,
753
+ pagePdfW.getOrElse(idx) { 1 }.toFloat(),
754
+ pagePdfH.getOrElse(idx) { 1 }.toFloat()
755
+ )
756
+ }
757
+ }
758
+ withContext(Dispatchers.Main) {
759
+ renderedUpTo = (newEnd - 1).coerceIn(0, pageCount - 1)
760
+ if (newEnd < pageCount) showLoadingFooter()
761
+ forceLayoutScrollView()
762
+ post { scrollToPage(target, smooth = false) }
763
+ }
764
+ }
765
+ }
766
+
767
+ // twoUp row-г render хийх helper (start..end page range, pairs)
768
+ private suspend fun renderTwoUpRange(
769
+ pdf: PdfRenderer, pageCount: Int, viewWidth: Int, startPage: Int, endPage: Int
770
+ ) {
771
+ val halfWidth = viewWidth / 2
772
+ var i = startPage
773
+ while (i < endPage) {
774
+ val leftBmp = renderPageBitmap(pdf, i, halfWidth)
775
+ val rightBmp = if (i + 1 < minOf(endPage, pageCount))
776
+ renderPageBitmap(pdf, i + 1, halfWidth) else null
777
+ val li = i; val ri = i + 1
778
+ withContext(Dispatchers.Main) {
779
+ // twoUp row-ын өндрийг зүүн хуудасны bitmap-аас авна (хоёр хуудас ижил scale-тай)
780
+ val rowH = leftBmp.height
781
+ val row = LinearLayout(context).apply {
782
+ orientation = LinearLayout.HORIZONTAL
783
+ layoutParams = LinearLayout.LayoutParams(viewWidth, rowH)
784
+ .apply { setMargins(0, 6, 0, 6) }
785
+ }
786
+ row.addView(buildPageFrame(leftBmp, li,
787
+ pagePdfW.getOrElse(li) { 1 }.toFloat(), pagePdfH.getOrElse(li) { 1 }.toFloat(), 1f))
788
+ if (rightBmp != null) {
789
+ row.addView(buildPageFrame(rightBmp, ri,
790
+ pagePdfW.getOrElse(ri) { 1 }.toFloat(), pagePdfH.getOrElse(ri) { 1 }.toFloat(), 1f))
791
+ } else {
792
+ row.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(0, rowH, 1f) })
793
+ }
794
+ container.addView(row)
795
+ }
796
+ i += 2
797
+ }
798
+ }
799
+
800
+ // ── Lazy chunk helpers ────────────────────────────────────────────────────
801
+
802
+ private fun maybeTriggerChunkLoad(scrollY: Int) {
803
+ if (chunkLoading) return
804
+ if (displayMode !in listOf("continuous", "twoupcontinuous")) return
805
+
806
+ val visibleBottom = scrollY + scrollView.height
807
+
808
+ // Доош scroll: дараагийн chunk ачаална
809
+ if (renderedUpTo < totalPages - 1) {
810
+ val forwardTrigger = container.height - scrollView.height * 2
811
+ if (visibleBottom >= forwardTrigger) {
812
+ chunkLoading = true
813
+ scope.launch {
814
+ try { loadNextChunk() }
815
+ catch (e: CancellationException) { throw e }
816
+ catch (e: Exception) { Log.e("ExpoPdfReader", "Chunk load error", e) }
817
+ finally { chunkLoading = false }
818
+ }
819
+ return
820
+ }
821
+ }
822
+
823
+ // Дээш scroll: өмнөх chunk (continuous = нэг багана; twoupcontinuous = хос мөр prepend).
824
+ if (windowStart > 0 && scrollY <= scrollView.height * 2) {
825
+ chunkLoading = true
826
+ scope.launch {
827
+ try { loadPrevChunk() }
828
+ catch (e: CancellationException) { throw e }
829
+ catch (e: Exception) { Log.e("ExpoPdfReader", "Prev chunk load error", e) }
830
+ finally { chunkLoading = false }
831
+ }
832
+ }
833
+ }
834
+
835
+ private suspend fun loadNextChunk() {
836
+ val pdf = renderer ?: return
837
+ val start = renderedUpTo + 1
838
+ if (start >= totalPages) return
839
+ val end = minOf(start + PAGE_CHUNK, totalPages)
840
+ val viewWidth = currentViewWidth
841
+
842
+ withContext(Dispatchers.Main) { removeLoadingFooter() }
843
+
844
+ if (displayMode == "twoupcontinuous") {
845
+ renderTwoUpRange(pdf, totalPages, viewWidth, start, end)
846
+ withContext(Dispatchers.Main) {
847
+ renderedUpTo = if (end % 2 == 0) end - 1 else end - 2
848
+ if (renderedUpTo < totalPages - 1) showLoadingFooter()
849
+ forceLayoutScrollView()
850
+ }
851
+ } else {
852
+ for (i in start until end) {
853
+ val bmp = renderPageBitmap(pdf, i, viewWidth)
854
+ val idx = i
855
+ withContext(Dispatchers.Main) {
856
+ addPageRow(bmp, idx, pagePdfW.getOrElse(idx) { 1 }.toFloat(),
857
+ pagePdfH.getOrElse(idx) { 1 }.toFloat())
858
+ }
859
+ }
860
+ withContext(Dispatchers.Main) {
861
+ renderedUpTo = end - 1
862
+ // Sliding window: MAX_RENDERED-с илүү болвол дээд хэсгийг устга
863
+ val excess = pageEntries.size - MAX_RENDERED
864
+ if (excess > 0) {
865
+ val removed = pruneTopPages(excess)
866
+ scrollView.scrollBy(0, -removed)
867
+ }
868
+ if (end < totalPages) showLoadingFooter()
869
+ forceLayoutScrollView()
870
+ }
871
+ }
872
+ }
873
+
874
+ /**
875
+ * Дээд хэсгийн [count] хуудсыг устгаж, bitmap санах ойг чөлөөлнэ.
876
+ * @return устгагдсан хуудсуудын нийт өндөр (px) — scroll тохируулахад ашиглана
877
+ */
878
+ private fun pruneTopPages(count: Int): Int {
879
+ var removedHeight = 0
880
+ repeat(count) {
881
+ val entry = pageEntries.removeFirstOrNull() ?: return@repeat
882
+ removedHeight += entry.bmpHeight + 12 // +12 нь margin (дээш 6 + доош 6)
883
+ // Bitmap-г чөлөөлнэ
884
+ (entry.frame.getChildAt(0) as? ImageView)?.setImageBitmap(null)
885
+ container.removeView(entry.frame)
886
+ }
887
+ windowStart += count
888
+ return removedHeight
889
+ }
890
+
891
+ /**
892
+ * Өмнөх [PAGE_CHUNK] хуудсыг дээр нэмж, доод хэсгийг цэвэрлэнэ.
893
+ */
894
+ private suspend fun loadPrevChunk() {
895
+ val pdf = renderer ?: return
896
+ if (displayMode == "twoupcontinuous") {
897
+ loadPrevChunkTwoup(pdf)
898
+ return
899
+ }
900
+ val prevStart = maxOf(0, windowStart - PAGE_CHUNK)
901
+ val prevEnd = windowStart
902
+ if (prevStart >= prevEnd) return
903
+ val viewWidth = currentViewWidth
904
+
905
+ data class PageBmp(val bmp: Bitmap, val idx: Int, val pdfW: Float, val pdfH: Float)
906
+ val pages = mutableListOf<PageBmp>()
907
+ for (i in prevStart until prevEnd) {
908
+ val bmp = renderPageBitmap(pdf, i, viewWidth)
909
+ pages.add(PageBmp(bmp, i,
910
+ pagePdfW.getOrElse(i) { 1 }.toFloat(),
911
+ pagePdfH.getOrElse(i) { 1 }.toFloat()))
912
+ }
913
+
914
+ withContext(Dispatchers.Main) {
915
+ var insertedHeight = 0
916
+ // Reverse order-оор insert хийж эцэст нь зөв дараалал гарна
917
+ // (бүгд index 0-д insert хийгддэг тул)
918
+ for (page in pages.reversed()) {
919
+ prependPageFrame(page.bmp, page.idx, page.pdfW, page.pdfH)
920
+ insertedHeight += page.bmp.height + 12
921
+ }
922
+ windowStart = prevStart
923
+ renderedUpTo = maxOf(renderedUpTo, windowStart + pageEntries.size - 1)
924
+
925
+ // Доод хэсгийг цэвэрлэнэ
926
+ val excess = pageEntries.size - MAX_RENDERED
927
+ if (excess > 0) pruneBottomPages(excess)
928
+
929
+ // Дээр нэмсэн өндрийг scroll-д нэмж харагдах хэсгийг хэвээр үлдээнэ
930
+ scrollView.scrollBy(0, insertedHeight)
931
+ forceLayoutScrollView()
932
+ }
933
+ }
934
+
935
+ /** twoupcontinuous: өмнөх chunk-ийг хос мөрөөр дээр нэмнэ. */
936
+ private suspend fun loadPrevChunkTwoup(pdf: PdfRenderer) {
937
+ val prevStart = maxOf(0, windowStart - PAGE_CHUNK)
938
+ val prevEnd = windowStart
939
+ if (prevStart >= prevEnd) return
940
+ val viewWidth = currentViewWidth
941
+ val halfW = viewWidth / 2
942
+
943
+ data class RowBmps(val leftIdx: Int, val leftBmp: Bitmap, val rightBmp: Bitmap?)
944
+ val rows = mutableListOf<RowBmps>()
945
+ var i = prevStart
946
+ while (i < prevEnd) {
947
+ val leftBmp = renderPageBitmap(pdf, i, halfW)
948
+ val rightBmp = if (i + 1 < minOf(prevEnd, totalPages))
949
+ renderPageBitmap(pdf, i + 1, halfW) else null
950
+ rows.add(RowBmps(i, leftBmp, rightBmp))
951
+ i += 2
952
+ }
953
+
954
+ withContext(Dispatchers.Main) {
955
+ var insertedHeight = 0
956
+ for (rb in rows.asReversed()) {
957
+ prependTwoupRowToContainer(
958
+ rb.leftBmp, rb.leftIdx,
959
+ rb.rightBmp,
960
+ if (rb.rightBmp != null) rb.leftIdx + 1 else null,
961
+ viewWidth
962
+ )
963
+ insertedHeight += rb.leftBmp.height + 12
964
+ }
965
+ windowStart = prevStart
966
+ renderedUpTo = windowStart + pageEntries.size - 1
967
+
968
+ while (pageEntries.size > MAX_RENDERED) {
969
+ pruneBottomTwoupOneRow()
970
+ }
971
+
972
+ scrollView.scrollBy(0, insertedHeight)
973
+ forceLayoutScrollView()
974
+ }
975
+ }
976
+
977
+ /** twoup мөрийг container + pageEntries-ийн эхэнд оруулна (дээд chunk). */
978
+ private fun prependTwoupRowToContainer(
979
+ leftBmp: Bitmap, li: Int,
980
+ rightBmp: Bitmap?, ri: Int?,
981
+ viewWidth: Int
982
+ ) {
983
+ val rowH = leftBmp.height
984
+ val row = LinearLayout(context).apply {
985
+ orientation = LinearLayout.HORIZONTAL
986
+ layoutParams = LinearLayout.LayoutParams(viewWidth, rowH)
987
+ .apply { setMargins(0, 6, 0, 6) }
988
+ }
989
+ val (leftFrame, leftEntry) = buildPageFrameDetached(
990
+ leftBmp, li,
991
+ pagePdfW.getOrElse(li) { 1 }.toFloat(),
992
+ pagePdfH.getOrElse(li) { 1 }.toFloat(),
993
+ 1f
994
+ )
995
+ row.addView(leftFrame)
996
+ if (rightBmp != null && ri != null) {
997
+ val (rightFrame, rightEntry) = buildPageFrameDetached(
998
+ rightBmp, ri,
999
+ pagePdfW.getOrElse(ri) { 1 }.toFloat(),
1000
+ pagePdfH.getOrElse(ri) { 1 }.toFloat(),
1001
+ 1f
1002
+ )
1003
+ row.addView(rightFrame)
1004
+ pageEntries.add(0, rightEntry)
1005
+ pageEntries.add(0, leftEntry)
1006
+ } else {
1007
+ row.addView(View(context).apply {
1008
+ layoutParams = LinearLayout.LayoutParams(0, rowH, 1f)
1009
+ })
1010
+ pageEntries.add(0, leftEntry)
1011
+ }
1012
+ container.addView(row, 0)
1013
+ }
1014
+
1015
+ /** twoupcontinuous: доод талын нэг мөрийг (эсвэл сүүлийн entry) устгана. */
1016
+ private fun pruneBottomTwoupOneRow() {
1017
+ val last = pageEntries.lastOrNull() ?: return
1018
+ val row = last.frame.parent as? LinearLayout
1019
+ if (row != null && row.parent === container && row.childCount > 0) {
1020
+ val removeList = pageEntries.filter { it.frame.parent === row }
1021
+ for (e in removeList) {
1022
+ (e.frame.getChildAt(0) as? ImageView)?.setImageBitmap(null)
1023
+ pageEntries.remove(e)
1024
+ }
1025
+ container.removeView(row)
1026
+ } else {
1027
+ val e = pageEntries.removeLastOrNull() ?: return
1028
+ (e.frame.getChildAt(0) as? ImageView)?.setImageBitmap(null)
1029
+ container.removeView(e.frame)
1030
+ }
1031
+ renderedUpTo = windowStart + pageEntries.size - 1
1032
+ }
1033
+
1034
+ /**
1035
+ * Дээр (index 0) шинэ хуудас оруулна. windowStart-г дуудагч тал шинэчилнэ.
1036
+ */
1037
+ private fun prependPageFrame(bmp: Bitmap, pageIdx: Int, pdfW: Float, pdfH: Float) {
1038
+ val bmpW = bmp.width
1039
+ val bmpH = bmp.height
1040
+ val frame = FrameLayout(context).apply {
1041
+ layoutParams = LinearLayout.LayoutParams(bmpW, bmpH).apply { setMargins(0, 6, 0, 6) }
1042
+ }
1043
+ val iv = ImageView(context).apply {
1044
+ layoutParams = FrameLayout.LayoutParams(bmpW, bmpH)
1045
+ scaleType = ImageView.ScaleType.FIT_XY
1046
+ setImageBitmap(bmp)
1047
+ }
1048
+ val canvasView = AnnotationCanvasView(context, pageIdx, pdfW, pdfH).apply {
1049
+ layoutParams = FrameLayout.LayoutParams(bmpW, bmpH)
1050
+ translationZ = 1f
1051
+ }
1052
+ frame.addView(iv)
1053
+ frame.addView(canvasView)
1054
+ container.addView(frame, 0)
1055
+ pageEntries.add(0, PageEntry(frame, canvasView, bmpH))
1056
+ }
1057
+
1058
+ /**
1059
+ * Доод хэсгийн [count] хуудсыг устгаж bitmap санах ойг чөлөөлнэ.
1060
+ * renderedUpTo-г буулгаж дараагийн доош scroll-д re-render хийгдэхийг зөвшөөрнэ.
1061
+ */
1062
+ private fun pruneBottomPages(count: Int) {
1063
+ repeat(count) {
1064
+ val entry = pageEntries.removeLastOrNull() ?: return@repeat
1065
+ (entry.frame.getChildAt(0) as? ImageView)?.setImageBitmap(null)
1066
+ container.removeView(entry.frame)
1067
+ }
1068
+ renderedUpTo = windowStart + pageEntries.size - 1
1069
+ }
1070
+
1071
+ /**
1072
+ * Render хийгдээгүй хуудас руу үсрэх.
1073
+ * Бүх одоогийн view-г цэвэрлэж, targetPage-ийн эргэн тойрны хуудсуудыг render хийнэ.
1074
+ */
1075
+ private fun jumpToPage(targetPage: Int) {
1076
+ val pdf = renderer ?: return
1077
+ val viewWidth = currentViewWidth.takeIf { it > 0 } ?: width.takeIf { it > 0 } ?: return
1078
+ renderJob?.cancel()
1079
+ renderJob = scope.launch {
1080
+ try {
1081
+ // Шинэ window: targetPage-ийн хагас chunk өмнөөс эхэлнэ
1082
+ val newStart = maxOf(0, targetPage - PAGE_CHUNK / 2)
1083
+ val newEnd = minOf(totalPages, newStart + MAX_RENDERED)
1084
+
1085
+ withContext(Dispatchers.Main) {
1086
+ container.removeAllViews()
1087
+ pageEntries.clear()
1088
+ loadingFooter = null
1089
+ windowStart = newStart
1090
+ renderedUpTo = newStart - 1
1091
+ chunkLoading = false
1092
+ }
1093
+
1094
+ if (displayMode == "twoupcontinuous") {
1095
+ renderTwoUpRange(pdf, totalPages, viewWidth, newStart, newEnd)
1096
+ withContext(Dispatchers.Main) {
1097
+ val rawUpTo = if (newEnd % 2 == 0) newEnd - 1 else newEnd - 2
1098
+ renderedUpTo = rawUpTo.coerceIn(0, totalPages - 1)
1099
+ if (renderedUpTo < totalPages - 1) showLoadingFooter()
1100
+ forceLayoutScrollView()
1101
+ post { scrollToPage(targetPage, smooth = false) }
1102
+ }
1103
+ } else {
1104
+ for (i in newStart until newEnd) {
1105
+ val bmp = renderPageBitmap(pdf, i, viewWidth)
1106
+ val idx = i
1107
+ withContext(Dispatchers.Main) {
1108
+ addPageRow(
1109
+ bmp, idx,
1110
+ pagePdfW.getOrElse(idx) { 1 }.toFloat(),
1111
+ pagePdfH.getOrElse(idx) { 1 }.toFloat()
1112
+ )
1113
+ }
1114
+ }
1115
+ withContext(Dispatchers.Main) {
1116
+ renderedUpTo = newEnd - 1
1117
+ if (newEnd < totalPages) showLoadingFooter()
1118
+ forceLayoutScrollView()
1119
+ post { scrollToPage(targetPage, smooth = false) }
1120
+ }
1121
+ }
1122
+ } catch (e: CancellationException) { throw e }
1123
+ catch (e: Exception) { Log.e("ExpoPdfReader", "JumpToPage error", e) }
1124
+ }
1125
+ }
1126
+
1127
+ /**
1128
+ * React Native Fabric нь child view-н requestLayout()-г таслах тул
1129
+ * addView() дараа layout pass автоматаар ажиллахгүй.
1130
+ * Энэ функц scrollView-г шууд measure + layout хийж pages-г харагдуулна.
1131
+ */
1132
+ private fun forceLayoutScrollView() {
1133
+ val w = width.takeIf { it > 0 } ?: return
1134
+ val h = height.takeIf { it > 0 } ?: return
1135
+ scrollView.measure(
1136
+ MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY),
1137
+ MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY)
1138
+ )
1139
+ scrollView.layout(0, 0, w, h)
1140
+ }
1141
+
1142
+ private fun showLoadingFooter() {
1143
+ if (loadingFooter != null) return
1144
+ val footer = ProgressBar(context, null, android.R.attr.progressBarStyleSmall).apply {
1145
+ layoutParams = LinearLayout.LayoutParams(
1146
+ LinearLayout.LayoutParams.MATCH_PARENT, 120
1147
+ ).apply { setMargins(0, 16, 0, 16) }
1148
+ isIndeterminate = true
1149
+ }
1150
+ container.addView(footer)
1151
+ loadingFooter = footer
1152
+ }
1153
+
1154
+ private fun removeLoadingFooter() {
1155
+ loadingFooter?.let { container.removeView(it) }
1156
+ loadingFooter = null
1157
+ }
1158
+
1159
+ private fun addPageRow(bmp: Bitmap, pageIdx: Int, pdfW: Float, pdfH: Float) =
1160
+ container.addView(buildPageFrame(bmp, pageIdx, pdfW, pdfH))
1161
+
1162
+ private fun buildPageFrameDetached(
1163
+ bmp: Bitmap, pageIdx: Int, pdfW: Float, pdfH: Float, weight: Float = 0f
1164
+ ): Pair<FrameLayout, PageEntry> {
1165
+ val bmpW = bmp.width
1166
+ val bmpH = bmp.height
1167
+ val frame = FrameLayout(context).apply {
1168
+ layoutParams = if (weight > 0f)
1169
+ LinearLayout.LayoutParams(0, bmpH, weight)
1170
+ else
1171
+ LinearLayout.LayoutParams(bmpW, bmpH)
1172
+ .apply { setMargins(0, 6, 0, 6) }
1173
+ }
1174
+ val iv = ImageView(context).apply {
1175
+ layoutParams = FrameLayout.LayoutParams(bmpW, bmpH)
1176
+ scaleType = ImageView.ScaleType.FIT_XY
1177
+ setImageBitmap(bmp)
1178
+ }
1179
+ val canvasView = AnnotationCanvasView(context, pageIdx, pdfW, pdfH).apply {
1180
+ layoutParams = FrameLayout.LayoutParams(bmpW, bmpH)
1181
+ translationZ = 1f
1182
+ }
1183
+ frame.addView(iv)
1184
+ frame.addView(canvasView)
1185
+ return frame to PageEntry(frame, canvasView, bmpH)
1186
+ }
1187
+
1188
+ private fun buildPageFrame(bmp: Bitmap, pageIdx: Int, pdfW: Float, pdfH: Float, weight: Float = 0f): FrameLayout {
1189
+ val (frame, entry) = buildPageFrameDetached(bmp, pageIdx, pdfW, pdfH, weight)
1190
+ pageEntries.add(entry)
1191
+ return frame
1192
+ }
1193
+
1194
+ private suspend fun renderPageBitmap(pdf: PdfRenderer, index: Int, targetWidth: Int): Bitmap =
1195
+ withContext(pdfDispatcher) {
1196
+ pdf.openPage(index).use { page ->
1197
+ if (index < pagePdfW.size) {
1198
+ pagePdfW[index] = page.width
1199
+ pagePdfH[index] = page.height
1200
+ }
1201
+ val s = targetWidth.toFloat() / page.width.toFloat()
1202
+ val h = (page.height * s).toInt().coerceAtLeast(1)
1203
+ val bmp = Bitmap.createBitmap(targetWidth, h, Bitmap.Config.ARGB_8888)
1204
+ bmp.eraseColor(Color.WHITE)
1205
+ page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
1206
+ bmp
1207
+ }
1208
+ }
1209
+
1210
+ private fun safeCloseRenderer() {
1211
+ try { renderer?.close(); fileDescriptor?.close() }
1212
+ catch (_: Exception) { }
1213
+ finally { renderer = null; fileDescriptor = null }
1214
+ }
1215
+
1216
+ private suspend fun resolveFile(url: String): File = withContext(pdfDispatcher) {
1217
+ when {
1218
+ url.startsWith("file://") -> File(url.removePrefix("file://"))
1219
+ url.startsWith("/") -> File(url)
1220
+ else -> {
1221
+ val f = File.createTempFile("pdf_", ".pdf", context.cacheDir)
1222
+ URL(url).openStream().use { inp -> FileOutputStream(f).use { inp.copyTo(it) } }
1223
+ f
1224
+ }
1225
+ }
1226
+ }
1227
+
1228
+ private fun buildFingerprint(data: List<Map<String, Any?>>): String =
1229
+ data.mapNotNull { item ->
1230
+ val p = (item["page"] as? Number)?.toInt() ?: return@mapNotNull null
1231
+ val t = item["type"] as? String ?: return@mapNotNull null
1232
+ val b = item["bounds"] as? Map<*, *> ?: return@mapNotNull null
1233
+ "$p|$t|${b["x"]},${b["y"]},${b["width"]},${b["height"]}"
1234
+ }.sorted().joinToString("||")
1235
+
1236
+ // ─────────────────────────────────────────────────────────────────────────
1237
+ // SinglePageAdapter — RecyclerView adapter for "single" display mode
1238
+ // • vertical PagerSnapHelper босоо scroll-оор хуудас солих
1239
+ // • ZoomRecyclerView zoom-г удирдана (adapter энгийн FrameLayout ашиглана)
1240
+ // • lazy bitmap loading
1241
+ // ─────────────────────────────────────────────────────────────────────────
1242
+
1243
+ inner class SinglePageAdapter : RecyclerView.Adapter<SinglePageAdapter.Holder>() {
1244
+
1245
+ inner class Holder(val frame: FrameLayout, val imageView: ImageView) :
1246
+ RecyclerView.ViewHolder(frame) {
1247
+ var boundPage = -1
1248
+ var loadJob: Job? = null
1249
+ var currentCanvas: AnnotationCanvasView? = null
1250
+ }
1251
+
1252
+ override fun getItemCount(): Int = totalPages
1253
+
1254
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
1255
+ val frame = FrameLayout(context).apply {
1256
+ layoutParams = RecyclerView.LayoutParams(
1257
+ RecyclerView.LayoutParams.MATCH_PARENT,
1258
+ RecyclerView.LayoutParams.MATCH_PARENT
1259
+ )
1260
+ setBackgroundColor(Color.parseColor("#E8E8E8"))
1261
+ }
1262
+ val iv = ImageView(context).apply {
1263
+ layoutParams = FrameLayout.LayoutParams(
1264
+ FrameLayout.LayoutParams.WRAP_CONTENT,
1265
+ FrameLayout.LayoutParams.WRAP_CONTENT,
1266
+ android.view.Gravity.CENTER
1267
+ )
1268
+ scaleType = ImageView.ScaleType.FIT_XY
1269
+ }
1270
+ frame.addView(iv)
1271
+ return Holder(frame, iv)
1272
+ }
1273
+
1274
+ override fun onBindViewHolder(holder: Holder, position: Int) {
1275
+ holder.boundPage = position
1276
+ holder.loadJob?.cancel()
1277
+ holder.imageView.setImageBitmap(null)
1278
+ // Recycle хийгдсэн view-н zoom transform-г цэвэрлэнэ
1279
+ holder.frame.scaleX = 1f; holder.frame.scaleY = 1f
1280
+ holder.frame.translationX = 0f; holder.frame.translationY = 0f
1281
+
1282
+ holder.currentCanvas?.let {
1283
+ singlePageCanvases.remove(it.pageIndex)
1284
+ holder.frame.removeView(it)
1285
+ }
1286
+ holder.currentCanvas = null
1287
+
1288
+ holder.loadJob = scope.launch {
1289
+ val pdf = renderer ?: return@launch
1290
+ val bmp = renderPageBitmap(pdf, position, this@ExpoPdfReaderView.width)
1291
+ val pdfW = pagePdfW.getOrElse(position) { 1 }.toFloat()
1292
+ val pdfH = pagePdfH.getOrElse(position) { 1 }.toFloat()
1293
+ withContext(Dispatchers.Main) {
1294
+ if (holder.boundPage != position) return@withContext
1295
+ val canvas = AnnotationCanvasView(context, position, pdfW, pdfH).apply {
1296
+ layoutParams = FrameLayout.LayoutParams(
1297
+ bmp.width, bmp.height,
1298
+ android.view.Gravity.CENTER
1299
+ )
1300
+ translationZ = 1f
1301
+ }
1302
+ holder.frame.addView(canvas)
1303
+ holder.currentCanvas = canvas
1304
+ singlePageCanvases[position] = canvas
1305
+ val lp = FrameLayout.LayoutParams(bmp.width, bmp.height, android.view.Gravity.CENTER)
1306
+ holder.imageView.layoutParams = lp
1307
+ holder.imageView.setImageBitmap(bmp)
1308
+ val fw = this@ExpoPdfReaderView.width.takeIf { it > 0 } ?: return@withContext
1309
+ val fh = this@ExpoPdfReaderView.height.takeIf { it > 0 } ?: return@withContext
1310
+ holder.frame.measure(
1311
+ MeasureSpec.makeMeasureSpec(fw, MeasureSpec.EXACTLY),
1312
+ MeasureSpec.makeMeasureSpec(fh, MeasureSpec.EXACTLY)
1313
+ )
1314
+ holder.frame.layout(0, 0, fw, fh)
1315
+ }
1316
+ }
1317
+ }
1318
+
1319
+ override fun onViewRecycled(holder: Holder) {
1320
+ holder.loadJob?.cancel()
1321
+ holder.currentCanvas?.let {
1322
+ singlePageCanvases.remove(it.pageIndex)
1323
+ holder.frame.removeView(it)
1324
+ }
1325
+ holder.currentCanvas = null
1326
+ holder.imageView.setImageBitmap(null)
1327
+ }
1328
+ }
1329
+
1330
+ // ─────────────────────────────────────────────────────────────────────────
1331
+ // ZoomRecyclerView — single mode-д pinch-to-zoom + pan дэмждэг RecyclerView
1332
+ //
1333
+ // Яагаад RecyclerView subclass хийх шаардлагатай вэ:
1334
+ // AnnotationCanvasView ACTION_DOWN-д false буцаана RecyclerView
1335
+ // gesture-г эзэмшиж авна дараагийн ACTION_POINTER_DOWN (2-р хуруу)
1336
+ // шууд RecyclerView.onTouchEvent-д очдог.
1337
+ // RecyclerView subclass хийснээр onTouchEvent-д multi-touch-г зохицуулна.
1338
+ //
1339
+ // Zoom тооцоо (pivot=0,0 + translation):
1340
+ // Item view point (px,py) → дэлгэц: (px*scale + tx, py*scale + ty)
1341
+ // Focus (fx,fy) тогтвортой байхад: new_tx = fx - (fx - tx) * scaleFactor
1342
+ // ─────────────────────────────────────────────────────────────────────────
1343
+
1344
+ inner class ZoomRecyclerView(context: Context) : RecyclerView(context) {
1345
+ private var currentScale = 1f
1346
+ private var tx = 0f
1347
+ private var ty = 0f
1348
+ private var panX = 0f
1349
+ private var panY = 0f
1350
+
1351
+ private val zoomDetector = ScaleGestureDetector(context,
1352
+ object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
1353
+ override fun onScale(d: ScaleGestureDetector): Boolean {
1354
+ val newScale = (currentScale * d.scaleFactor).coerceIn(minZoom, maxZoom)
1355
+ val af = newScale / currentScale
1356
+ // Focus point тогтвортой байхын zoom-to-point тооцоо
1357
+ tx = d.focusX - (d.focusX - tx) * af
1358
+ ty = d.focusY - (d.focusY - ty) * af
1359
+ currentScale = newScale
1360
+ clampAndApply()
1361
+ return true
1362
+ }
1363
+ }
1364
+ )
1365
+
1366
+ override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
1367
+ // Zoom хийгдсэн + tool идэвхгүй → pan хийхийн тулд ACTION_DOWN-г intercept
1368
+ if (currentScale > 1f && activeTool == null && ev.action == MotionEvent.ACTION_DOWN) {
1369
+ return true
1370
+ }
1371
+ return super.onInterceptTouchEvent(ev)
1372
+ }
1373
+
1374
+ override fun onTouchEvent(ev: MotionEvent): Boolean {
1375
+ zoomDetector.onTouchEvent(ev)
1376
+
1377
+ // 2 хуруу байвал RecyclerView scroll хийхгүйгээр zoom-г л хийнэ
1378
+ if (ev.pointerCount >= 2 || zoomDetector.isInProgress) {
1379
+ return true
1380
+ }
1381
+
1382
+ // Zoom хийгдсэн үед нэг хуруугаар pan (tool идэвхгүй)
1383
+ if (currentScale > 1f && activeTool == null) {
1384
+ when (ev.actionMasked) {
1385
+ MotionEvent.ACTION_DOWN -> { panX = ev.x; panY = ev.y }
1386
+ MotionEvent.ACTION_MOVE -> {
1387
+ tx += ev.x - panX
1388
+ ty += ev.y - panY
1389
+ panX = ev.x; panY = ev.y
1390
+ clampAndApply()
1391
+ }
1392
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { }
1393
+ }
1394
+ return true
1395
+ }
1396
+
1397
+ return super.onTouchEvent(ev)
1398
+ }
1399
+
1400
+ fun resetZoom() {
1401
+ currentScale = 1f; tx = 0f; ty = 0f
1402
+ applyToCurrentItem()
1403
+ }
1404
+
1405
+ private fun clampAndApply() {
1406
+ val w = width.toFloat(); val h = height.toFloat()
1407
+ if (w > 0f && h > 0f) {
1408
+ val sw = w * currentScale; val sh = h * currentScale
1409
+ tx = if (sw > w) tx.coerceIn(w - sw, 0f) else 0f
1410
+ ty = if (sh > h) ty.coerceIn(h - sh, 0f) else 0f
1411
+ }
1412
+ applyToCurrentItem()
1413
+ }
1414
+
1415
+ private fun applyToCurrentItem() {
1416
+ val lm = layoutManager as? LinearLayoutManager ?: return
1417
+ val pos = lm.findFirstVisibleItemPosition().takeIf { it >= 0 } ?: return
1418
+ val itemView = lm.findViewByPosition(pos) ?: return
1419
+ itemView.pivotX = 0f; itemView.pivotY = 0f
1420
+ itemView.scaleX = currentScale; itemView.scaleY = currentScale
1421
+ itemView.translationX = tx; itemView.translationY = ty
1422
+ }
1423
+ }
1424
+
1425
+ // ─────────────────────────────────────────────────────────────────────────
1426
+ // AnnotationCanvasView (inner class — full access to outer view state)
1427
+ // ─────────────────────────────────────────────────────────────────────────
1428
+
1429
+ inner class AnnotationCanvasView(
1430
+ context: Context,
1431
+ val pageIndex: Int,
1432
+ private val pdfWidth: Float,
1433
+ private val pdfHeight: Float
1434
+ ) : View(context) {
1435
+
1436
+ private var drawTool: String? = null
1437
+ private val drawPoints = mutableListOf<PointF>() // PDF coords
1438
+ private var drawStart: PointF? = null
1439
+
1440
+ // ── No-tool touch state (tap event, long-press → drag) ─────────────
1441
+ private var noToolDown = false
1442
+ private var noToolDownScreen = PointF()
1443
+ private var isDragging = false
1444
+ private var dragAnn: PdfAnnotation? = null
1445
+ private var dragOrigBounds = RectF()
1446
+ private val LP_TIMEOUT = android.view.ViewConfiguration.getLongPressTimeout().toLong()
1447
+ private val TOUCH_SLOP by lazy { android.view.ViewConfiguration.get(context).scaledTouchSlop.toFloat() }
1448
+ private val lpHandler = android.os.Handler(android.os.Looper.getMainLooper())
1449
+ private val lpRunnable = Runnable {
1450
+ if (dragAnn?.type != "text" && dragAnn?.type != "note") return@Runnable
1451
+ isDragging = true
1452
+ pager.requestDisallowInterceptTouchEvent(true)
1453
+ scrollView.requestDisallowInterceptTouchEvent(true)
1454
+ invalidate()
1455
+ }
1456
+
1457
+ // Reusable objects — no allocation in onDraw
1458
+ private val inkPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
1459
+ style = Paint.Style.STROKE; strokeCap = Paint.Cap.ROUND; strokeJoin = Paint.Join.ROUND
1460
+ }
1461
+ private val bgFill = Paint().apply { style = Paint.Style.FILL }
1462
+ private val bgStroke = Paint().apply {
1463
+ style = Paint.Style.STROKE; strokeWidth = 1.5f; color = Color.DKGRAY
1464
+ }
1465
+ private val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
1466
+ private val drawPath = Path()
1467
+
1468
+ // ── Lifecycle ─────────────────────────────────────────────────────────
1469
+
1470
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
1471
+ super.onSizeChanged(w, h, oldw, oldh)
1472
+ // Single-mode canvases start with MATCH_PARENT (width=0) and receive their real
1473
+ // size after RecyclerView lays out. Re-draw so initialAnnotations become visible.
1474
+ if (w > 0 && oldw == 0) invalidate()
1475
+ }
1476
+
1477
+ // ── Coordinate helpers ────────────────────────────────────────────────
1478
+
1479
+ private fun scale() = if (width > 0 && pdfWidth > 0) width.toFloat() / pdfWidth else 1f
1480
+
1481
+ /** View-local screen PDF. */
1482
+ private fun toPdf(sx: Float, sy: Float): PointF {
1483
+ val s = scale(); return PointF(sx / s, pdfHeight - sy / s)
1484
+ }
1485
+
1486
+ /** PDF view-local screen. */
1487
+ private fun toScreen(px: Float, py: Float): PointF {
1488
+ val s = scale(); return PointF(px * s, (pdfHeight - py) * s)
1489
+ }
1490
+
1491
+ /**
1492
+ * PDF RectF screen RectF.
1493
+ * bounds.top = lower PDF y → screen bottom
1494
+ * bounds.bottom = upper PDF y → screen top
1495
+ */
1496
+ private fun toScreenRect(b: RectF): RectF {
1497
+ val s = scale()
1498
+ return RectF(b.left * s, (pdfHeight - b.bottom) * s, b.right * s, (pdfHeight - b.top) * s)
1499
+ }
1500
+
1501
+ // ── Touch ─────────────────────────────────────────────────────────────
1502
+
1503
+ override fun onTouchEvent(event: MotionEvent): Boolean {
1504
+ val tool = activeTool
1505
+ if (tool != null) {
1506
+ // Drawing tool active — consume all events
1507
+ scrollView.requestDisallowInterceptTouchEvent(true)
1508
+ pager.requestDisallowInterceptTouchEvent(true)
1509
+ val pdfPt = toPdf(event.x, event.y)
1510
+ when (tool) {
1511
+ "eraser" -> {
1512
+ if (event.action == MotionEvent.ACTION_DOWN || event.action == MotionEvent.ACTION_MOVE)
1513
+ eraseAt(pdfPt)
1514
+ }
1515
+ "text", "note" -> { if (event.action == MotionEvent.ACTION_DOWN) addInstantAnnotation(pdfPt, tool) }
1516
+ else -> handleDraw(event, pdfPt, tool)
1517
+ }
1518
+ return true
1519
+ }
1520
+ return handleNoToolTouch(event)
1521
+ }
1522
+
1523
+ /**
1524
+ * Touch handling when no drawing tool is active.
1525
+ * - Single tap on text/note → fire onTextPress / onNotePress
1526
+ * - Long press on text/note → drag the annotation
1527
+ * The key issue: returning false on ACTION_DOWN means we never receive ACTION_UP.
1528
+ * So we return true only when we detect a tap/drag target on ACTION_DOWN.
1529
+ */
1530
+ private fun handleNoToolTouch(event: MotionEvent): Boolean {
1531
+ when (event.action) {
1532
+ MotionEvent.ACTION_DOWN -> {
1533
+ val pdfPt = toPdf(event.x, event.y)
1534
+ val ann = tapTargetAt(pdfPt) ?: return false
1535
+ dragAnn = ann
1536
+ dragOrigBounds = RectF(ann.bounds)
1537
+ noToolDownScreen = PointF(event.x, event.y)
1538
+ noToolDown = true
1539
+ lpHandler.postDelayed(lpRunnable, LP_TIMEOUT)
1540
+ return true
1541
+ }
1542
+ MotionEvent.ACTION_MOVE -> {
1543
+ if (!noToolDown) return false
1544
+ val dx = event.x - noToolDownScreen.x
1545
+ val dy = event.y - noToolDownScreen.y
1546
+ if (isDragging) {
1547
+ val ann = dragAnn ?: return true
1548
+ val s = scale()
1549
+ val annW = dragOrigBounds.width()
1550
+ val annH = dragOrigBounds.height()
1551
+ // PDF y is inverted: screen down (dy>0) → PDF y decreases
1552
+ val newLeft = (dragOrigBounds.left + dx / s).coerceIn(0f, pdfWidth - annW)
1553
+ val newBottom = (dragOrigBounds.bottom - dy / s).coerceIn(annH, pdfHeight)
1554
+ ann.bounds = RectF(newLeft, newBottom - annH, newLeft + annW, newBottom)
1555
+ invalidate()
1556
+ return true
1557
+ }
1558
+ // Cancel long-press if finger moved beyond touch slop
1559
+ if (Math.hypot(dx.toDouble(), dy.toDouble()) > TOUCH_SLOP) {
1560
+ lpHandler.removeCallbacks(lpRunnable)
1561
+ noToolDown = false
1562
+ dragAnn = null
1563
+ return false
1564
+ }
1565
+ return true
1566
+ }
1567
+ MotionEvent.ACTION_UP -> {
1568
+ lpHandler.removeCallbacks(lpRunnable)
1569
+ val wasDown = noToolDown
1570
+ val wasDragging = isDragging
1571
+ noToolDown = false; isDragging = false
1572
+ pager.requestDisallowInterceptTouchEvent(false)
1573
+ scrollView.requestDisallowInterceptTouchEvent(false)
1574
+ if (!wasDown) { dragAnn = null; return false }
1575
+ if (wasDragging) {
1576
+ dragAnn = null
1577
+ notifyAnnotationChange()
1578
+ } else {
1579
+ dragAnn = null
1580
+ handleTap(event.x, event.y)
1581
+ }
1582
+ return true
1583
+ }
1584
+ MotionEvent.ACTION_CANCEL -> {
1585
+ lpHandler.removeCallbacks(lpRunnable)
1586
+ if (isDragging) { dragAnn?.bounds = RectF(dragOrigBounds); invalidate() }
1587
+ noToolDown = false; isDragging = false; dragAnn = null
1588
+ pager.requestDisallowInterceptTouchEvent(false)
1589
+ scrollView.requestDisallowInterceptTouchEvent(false)
1590
+ return false
1591
+ }
1592
+ }
1593
+ return noToolDown
1594
+ }
1595
+
1596
+ /** Returns the first text or note annotation under [pdfPt], or null. */
1597
+ private fun tapTargetAt(pdfPt: PointF): PdfAnnotation? {
1598
+ val pageAnns = annotationMap[pageIndex] ?: return null
1599
+ return pageAnns.firstOrNull { (it.type == "text" || it.type == "note") && it.bounds.contains(pdfPt.x, pdfPt.y) }
1600
+ }
1601
+
1602
+ private fun handleDraw(event: MotionEvent, pdfPt: PointF, tool: String) {
1603
+ when (event.action) {
1604
+ MotionEvent.ACTION_DOWN -> {
1605
+ drawTool = tool; drawPoints.clear()
1606
+ drawStart = PointF(pdfPt.x, pdfPt.y); drawPoints.add(PointF(pdfPt.x, pdfPt.y))
1607
+ invalidate()
1608
+ }
1609
+ MotionEvent.ACTION_MOVE -> {
1610
+ if (tool == "line") {
1611
+ drawPoints.clear(); drawStart?.let { drawPoints.add(PointF(it.x, it.y)) }
1612
+ }
1613
+ drawPoints.add(PointF(pdfPt.x, pdfPt.y)); invalidate()
1614
+ }
1615
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
1616
+ if (drawPoints.size >= 2) commitStroke(tool)
1617
+ drawTool = null; drawPoints.clear(); drawStart = null; invalidate()
1618
+ }
1619
+ }
1620
+ }
1621
+
1622
+ private fun commitStroke(tool: String) {
1623
+ val pad = strokeWidthVal / 2f
1624
+ val ann = PdfAnnotation(
1625
+ type = tool, pageIndex = pageIndex,
1626
+ bounds = RectF(
1627
+ drawPoints.minOf { it.x } - pad, drawPoints.minOf { it.y } - pad,
1628
+ drawPoints.maxOf { it.x } + pad, drawPoints.maxOf { it.y } + pad
1629
+ ),
1630
+ color = strokeColorVal, strokeWidth = strokeWidthVal
1631
+ )
1632
+ ann.paths.add(AnnPath(drawPoints.map { PointF(it.x, it.y) }.toMutableList()))
1633
+ addAnnotationAndCommit(ann)
1634
+ }
1635
+
1636
+ private fun eraseAt(pdfPt: PointF) {
1637
+ val pageAnns = annotationMap[pageIndex] ?: return
1638
+ val r = 10f / scale()
1639
+ val hit = RectF(pdfPt.x - r, pdfPt.y - r, pdfPt.x + r, pdfPt.y + r)
1640
+ val toRemove = pageAnns.filter { RectF.intersects(it.bounds, hit) }
1641
+ if (toRemove.isNotEmpty()) {
1642
+ pageAnns.removeAll(toRemove.toSet())
1643
+ toRemove.forEach { ann -> undoStack.removeAll { it.annotation === ann } }
1644
+ invalidate(); notifyAnnotationChange(); notifyUndoRedoState()
1645
+ }
1646
+ }
1647
+
1648
+ private fun addInstantAnnotation(pdfPt: PointF, tool: String) {
1649
+ val s = scale()
1650
+ val ann = if (tool == "note") {
1651
+ PdfAnnotation("note", pageIndex,
1652
+ RectF(pdfPt.x, pdfPt.y, pdfPt.x + 32f / s, pdfPt.y + 32f / s),
1653
+ color = noteColorVal, contents = textContentVal.ifBlank { " " })
1654
+ } else {
1655
+ val w = 180f / s; val h = 40f / s
1656
+ PdfAnnotation("text", pageIndex,
1657
+ RectF(pdfPt.x, pdfPt.y - h, pdfPt.x + w, pdfPt.y),
1658
+ color = textColorVal, contents = textContentVal.ifBlank { " " },
1659
+ fontSize = textFontSizeVal, bold = textBoldVal, italic = textItalicVal)
1660
+ }
1661
+ addAnnotationAndCommit(ann)
1662
+ }
1663
+
1664
+ private fun handleTap(screenX: Float, screenY: Float) {
1665
+ val pdfPt = toPdf(screenX, screenY)
1666
+ val pageAnns = annotationMap[pageIndex] ?: return
1667
+
1668
+ pageAnns.filter { it.type == "text" }.forEachIndexed { idx, ann ->
1669
+ if (ann.bounds.contains(pdfPt.x, pdfPt.y)) {
1670
+ onTextPress(mapOf("page" to pageIndex, "index" to idx,
1671
+ "bounds" to boundsMap(ann), "contents" to ann.contents)); return
1672
+ }
1673
+ }
1674
+ pageAnns.filter { it.type == "note" }.forEachIndexed { idx, ann ->
1675
+ if (ann.bounds.contains(pdfPt.x, pdfPt.y)) {
1676
+ onNotePress(mapOf("page" to pageIndex, "index" to idx,
1677
+ "bounds" to boundsMap(ann), "contents" to ann.contents)); return
1678
+ }
1679
+ }
1680
+ }
1681
+
1682
+ private fun boundsMap(ann: PdfAnnotation) = mapOf(
1683
+ "x" to ann.bounds.left.toDouble(), "y" to ann.bounds.top.toDouble(),
1684
+ "width" to ann.bounds.width().toDouble(), "height" to ann.bounds.height().toDouble()
1685
+ )
1686
+
1687
+ // ── Draw ──────────────────────────────────────────────────────────────
1688
+
1689
+ override fun onDraw(canvas: Canvas) {
1690
+ for (ann in annotationMap[pageIndex] ?: emptyList()) drawAnn(canvas, ann)
1691
+ drawTool?.let { drawActiveStroke(canvas, it) }
1692
+ }
1693
+
1694
+ private fun drawAnn(canvas: Canvas, ann: PdfAnnotation) = when (ann.type) {
1695
+ "pen", "highlighter", "line" -> drawInk(canvas, ann)
1696
+ "text" -> drawTextAnn(canvas, ann)
1697
+ "note" -> drawNoteAnn(canvas, ann)
1698
+ else -> Unit
1699
+ }
1700
+
1701
+ private fun drawInk(canvas: Canvas, ann: PdfAnnotation) {
1702
+ val s = scale(); val base = safeColor(ann.color)
1703
+ inkPaint.strokeWidth = ann.strokeWidth * s
1704
+ inkPaint.color = if (ann.type == "highlighter")
1705
+ Color.argb(102, Color.red(base), Color.green(base), Color.blue(base)) else base
1706
+ for (ap in ann.paths) {
1707
+ if (ap.points.isEmpty()) continue
1708
+ drawPath.reset()
1709
+ ap.points.forEachIndexed { i, pt ->
1710
+ val sp = toScreen(pt.x, pt.y)
1711
+ if (i == 0) drawPath.moveTo(sp.x, sp.y) else drawPath.lineTo(sp.x, sp.y)
1712
+ }
1713
+ canvas.drawPath(drawPath, inkPaint)
1714
+ }
1715
+ }
1716
+
1717
+ private fun drawTextAnn(canvas: Canvas, ann: PdfAnnotation) {
1718
+ val text = ann.contents.trim().ifEmpty { return }
1719
+ val rect = toScreenRect(ann.bounds)
1720
+ val s = scale()
1721
+ textPaint.textSize = ann.fontSize * s
1722
+ textPaint.color = safeColor(ann.color)
1723
+ textPaint.typeface = when {
1724
+ ann.bold && ann.italic -> Typeface.create(Typeface.DEFAULT, Typeface.BOLD_ITALIC)
1725
+ ann.bold -> Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
1726
+ ann.italic -> Typeface.create(Typeface.DEFAULT, Typeface.ITALIC)
1727
+ else -> Typeface.DEFAULT
1728
+ }
1729
+ // No background — fully transparent
1730
+ val maxW = (rect.width() - 8f).toInt().coerceAtLeast(1)
1731
+ canvas.save()
1732
+ canvas.translate(rect.left + 4f, rect.top + 4f)
1733
+ StaticLayout.Builder.obtain(text, 0, text.length, textPaint, maxW)
1734
+ .setAlignment(Layout.Alignment.ALIGN_NORMAL).build().draw(canvas)
1735
+ canvas.restore()
1736
+ }
1737
+
1738
+ private fun drawNoteAnn(canvas: Canvas, ann: PdfAnnotation) {
1739
+ val rect = toScreenRect(ann.bounds)
1740
+ bgFill.color = try { Color.parseColor(ann.color) } catch (_: Exception) { Color.YELLOW }
1741
+ canvas.drawRect(rect, bgFill); canvas.drawRect(rect, bgStroke)
1742
+ textPaint.textSize = rect.height() * 0.55f; textPaint.color = Color.DKGRAY
1743
+ textPaint.textAlign = Paint.Align.CENTER; textPaint.typeface = Typeface.DEFAULT
1744
+ canvas.drawText("✎", rect.centerX(), rect.centerY() + textPaint.textSize * 0.35f, textPaint)
1745
+ textPaint.textAlign = Paint.Align.LEFT
1746
+ }
1747
+
1748
+ private fun drawActiveStroke(canvas: Canvas, tool: String) {
1749
+ if (drawPoints.isEmpty()) return
1750
+ val s = scale(); val base = safeColor(strokeColorVal)
1751
+ inkPaint.strokeWidth = strokeWidthVal * s
1752
+ inkPaint.color = if (tool == "highlighter")
1753
+ Color.argb(102, Color.red(base), Color.green(base), Color.blue(base)) else base
1754
+ drawPath.reset()
1755
+ drawPoints.forEachIndexed { i, pt ->
1756
+ val sp = toScreen(pt.x, pt.y)
1757
+ if (i == 0) drawPath.moveTo(sp.x, sp.y) else drawPath.lineTo(sp.x, sp.y)
1758
+ }
1759
+ canvas.drawPath(drawPath, inkPaint)
1760
+ }
1761
+
1762
+ private fun safeColor(hex: String): Int =
1763
+ try { Color.parseColor(hex) } catch (_: Exception) { Color.RED }
1764
+ }
1765
+ }