@june24/expo-pdf-reader 0.1.26 → 0.1.28

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