@june24/expo-pdf-reader 0.1.24 → 0.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -91,13 +91,15 @@ class ExpoPdfReaderView(
91
91
  private set
92
92
 
93
93
  // ── Lazy / chunked rendering ──────────────────────────────────────────────
94
- private var renderedUpTo = -1 // last rendered page index
94
+ private var renderedUpTo = -1 // last rendered page index (absolute)
95
+ private var windowStart = 0 // pageEntries[0]-н absolute page index
95
96
  private var chunkLoading = false // chunk дуусах хүртэл дахин trigger хийхгүй
96
97
  private var currentViewWidth = 0 // chunk load-д дахин хэрэглэнэ
97
98
  private var loadingFooter: View? = null
98
99
 
99
100
  companion object {
100
- private const val PAGE_CHUNK = 10 // нэг удаа render хийх хуудасны тоо
101
+ private const val PAGE_CHUNK = 10 // нэг удаа render хийх хуудасны тоо
102
+ private const val MAX_RENDERED = PAGE_CHUNK * 2 // санах ойд байлгах дээд хязгаар (20 хуудас)
101
103
  }
102
104
 
103
105
  // ── Annotation state ─────────────────────────────────────────────────────
@@ -117,7 +119,8 @@ class ExpoPdfReaderView(
117
119
  private var appliedFingerprint: String? = null
118
120
 
119
121
  // ── Page view entries (continuous/twoUp modes) ───────────────────────────
120
- data class PageEntry(val canvasView: AnnotationCanvasView)
122
+ // frame: container-аас устгахад шууд reference, bmpHeight: layout болохоос өмнө scroll тооцооны тулд
123
+ data class PageEntry(val frame: FrameLayout, val canvasView: AnnotationCanvasView, val bmpHeight: Int)
121
124
  val pageEntries = mutableListOf<PageEntry>()
122
125
 
123
126
  // ── Single-page pager (single mode) ──────────────────────────────────────
@@ -160,10 +163,10 @@ class ExpoPdfReaderView(
160
163
  maybeTriggerChunkLoad(scrollY)
161
164
  }
162
165
 
163
- // Single-page pager: RecyclerView + PagerSnapHelper (horizontal)
164
- pager = RecyclerView(context).apply {
166
+ // Single-page pager: ZoomRecyclerView + PagerSnapHelper (vertical)
167
+ pager = ZoomRecyclerView(context).apply {
165
168
  layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
166
- layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
169
+ layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
167
170
  PagerSnapHelper().attachToRecyclerView(this)
168
171
  setBackgroundColor(Color.parseColor("#E8E8E8"))
169
172
  visibility = View.GONE
@@ -175,6 +178,8 @@ class ExpoPdfReaderView(
175
178
  .takeIf { it >= 0 } ?: return
176
179
  if (pos != currentPageIndex) {
177
180
  currentPageIndex = pos
181
+ // Хуудас солигдоход zoom-г reset хийнэ
182
+ (pager as? ZoomRecyclerView)?.resetZoom()
178
183
  onPageChange(mapOf("currentPage" to pos, "totalPage" to totalPages))
179
184
  }
180
185
  }
@@ -248,7 +253,18 @@ class ExpoPdfReaderView(
248
253
  fun setInitialPage(page: Int) {
249
254
  if (page < 0 || page >= totalPages) return
250
255
  currentPageIndex = page
251
- post { scrollToPage(page, smooth = false) }
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
+ }
252
268
  }
253
269
 
254
270
  fun setMinZoom(v: Double) { minZoom = v.toFloat() }
@@ -405,7 +421,8 @@ class ExpoPdfReaderView(
405
421
  if (displayMode == "single") {
406
422
  singlePageCanvases[pageIndex]?.invalidate()
407
423
  } else {
408
- pageEntries.getOrNull(pageIndex)?.canvasView?.invalidate()
424
+ // continuous mode: pageEntries[0] = page windowStart → relative index
425
+ pageEntries.getOrNull(pageIndex - windowStart)?.canvasView?.invalidate()
409
426
  }
410
427
  }
411
428
 
@@ -466,17 +483,86 @@ class ExpoPdfReaderView(
466
483
  // Scroll / page detection
467
484
  // ─────────────────────────────────────────────────────────────────────────
468
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
+
469
525
  private fun detectCurrentPage(scrollY: Int) {
470
526
  if (pageEntries.isEmpty()) return
471
- val centerY = scrollY + height / 2
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 бүр нэг босоо мөр
472
557
  var accY = 0
473
558
  for (i in pageEntries.indices) {
474
- val h = pageEntries[i].canvasView.height.takeIf { it > 0 } ?: continue
559
+ val h = pageEntries[i].bmpHeight.takeIf { it > 0 } ?: continue
475
560
  accY += h + 12
476
561
  if (centerY <= accY || i == pageEntries.size - 1) {
477
- if (currentPageIndex != i) {
478
- currentPageIndex = i
479
- onPageChange(mapOf("currentPage" to i, "totalPage" to totalPages))
562
+ val absolutePage = windowStart + i
563
+ if (currentPageIndex != absolutePage) {
564
+ currentPageIndex = absolutePage
565
+ onPageChange(mapOf("currentPage" to absolutePage, "totalPage" to totalPages))
480
566
  }
481
567
  break
482
568
  }
@@ -492,10 +578,7 @@ class ExpoPdfReaderView(
492
578
  return
493
579
  }
494
580
  if (pageEntries.isEmpty()) return
495
- var y = 0
496
- for (i in 0 until pageIndex.coerceAtMost(pageEntries.size - 1)) {
497
- y += (pageEntries[i].canvasView.height.takeIf { it > 0 } ?: 0) + 12
498
- }
581
+ val y = scrollYForScrollModePage(pageIndex)
499
582
  if (smooth) scrollView.smoothScrollTo(0, y) else scrollView.scrollTo(0, y)
500
583
  currentPageIndex = pageIndex
501
584
  onPageChange(mapOf("currentPage" to pageIndex, "totalPage" to totalPages))
@@ -517,8 +600,9 @@ class ExpoPdfReaderView(
517
600
  renderer = PdfRenderer(fileDescriptor!!)
518
601
  val r = renderer!!
519
602
  totalPages = r.pageCount
520
- pagePdfW = IntArray(totalPages) { i -> r.openPage(i).use { it.width } }
521
- pagePdfH = IntArray(totalPages) { i -> r.openPage(i).use { it.height } }
603
+ // Хэмжээг энд бүгдийг нь уншихгүй — renderPageBitmap нээх бүрт бөглөнө (анхны ачаалал хурдан).
604
+ pagePdfW = IntArray(totalPages) { 0 }
605
+ pagePdfH = IntArray(totalPages) { 0 }
522
606
  }
523
607
  // annotationMap is intentionally NOT cleared here.
524
608
  // setAnnotations() may have already populated it before startLoad() runs
@@ -559,6 +643,7 @@ class ExpoPdfReaderView(
559
643
  // Chunk state reset
560
644
  currentViewWidth = viewWidth
561
645
  renderedUpTo = -1
646
+ windowStart = 0
562
647
  chunkLoading = false
563
648
 
564
649
  withContext(Dispatchers.Main) {
@@ -570,7 +655,12 @@ class ExpoPdfReaderView(
570
655
  when (displayMode) {
571
656
  "single" -> renderSingleMode()
572
657
  "twoup" -> renderAllPages(pdf, pageCount, viewWidth)
573
- else -> renderFirstChunk(pdf, pageCount, viewWidth) // continuous / twoUpContinuous
658
+ else -> renderScrollModeAround(
659
+ pdf,
660
+ pageCount,
661
+ viewWidth,
662
+ currentPageIndex.coerceIn(0, pageCount - 1)
663
+ )
574
664
  }
575
665
 
576
666
  // onReady: Main thread дээр шууд дуудна
@@ -623,34 +713,53 @@ class ExpoPdfReaderView(
623
713
  withContext(Dispatchers.Main) { renderedUpTo = pageCount - 1 }
624
714
  }
625
715
 
626
- // continuous / twoUpContinuous — эхний PAGE_CHUNK хуудсыг render хийнэ
627
- private suspend fun renderFirstChunk(pdf: PdfRenderer, pageCount: Int, viewWidth: Int) {
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
+ ) {
628
726
  withContext(Dispatchers.Main) {
629
727
  scrollView.visibility = View.VISIBLE
630
728
  pager.visibility = View.GONE
631
729
  }
632
- val end = minOf(PAGE_CHUNK, pageCount)
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
+ }
633
737
  if (displayMode == "twoupcontinuous") {
634
- renderTwoUpRange(pdf, pageCount, viewWidth, 0, end)
635
- // Хос тул renderedUpTo-г тэгш болгоно
738
+ renderTwoUpRange(pdf, pageCount, viewWidth, newStart, newEnd)
636
739
  withContext(Dispatchers.Main) {
637
- renderedUpTo = if (end % 2 == 0) end - 1 else end - 2
740
+ val rawUpTo = if (newEnd % 2 == 0) newEnd - 1 else newEnd - 2
741
+ renderedUpTo = rawUpTo.coerceIn(0, pageCount - 1)
638
742
  if (renderedUpTo < pageCount - 1) showLoadingFooter()
639
743
  forceLayoutScrollView()
744
+ post { scrollToPage(target, smooth = false) }
640
745
  }
641
746
  } else {
642
- for (i in 0 until end) {
747
+ for (i in newStart until newEnd) {
643
748
  val bmp = renderPageBitmap(pdf, i, viewWidth)
644
749
  val idx = i
645
750
  withContext(Dispatchers.Main) {
646
- addPageRow(bmp, idx, pagePdfW.getOrElse(idx) { 1 }.toFloat(),
647
- pagePdfH.getOrElse(idx) { 1 }.toFloat())
751
+ addPageRow(
752
+ bmp, idx,
753
+ pagePdfW.getOrElse(idx) { 1 }.toFloat(),
754
+ pagePdfH.getOrElse(idx) { 1 }.toFloat()
755
+ )
648
756
  }
649
757
  }
650
758
  withContext(Dispatchers.Main) {
651
- renderedUpTo = end - 1
652
- if (end < pageCount) showLoadingFooter()
653
- forceLayoutScrollView() // pages-г шууд харагдуулна
759
+ renderedUpTo = (newEnd - 1).coerceIn(0, pageCount - 1)
760
+ if (newEnd < pageCount) showLoadingFooter()
761
+ forceLayoutScrollView()
762
+ post { scrollToPage(target, smooth = false) }
654
763
  }
655
764
  }
656
765
  }
@@ -692,18 +801,32 @@ class ExpoPdfReaderView(
692
801
 
693
802
  private fun maybeTriggerChunkLoad(scrollY: Int) {
694
803
  if (chunkLoading) return
695
- if (renderedUpTo >= totalPages - 1) return
696
804
  if (displayMode !in listOf("continuous", "twoupcontinuous")) return
697
805
 
698
- // Render хийгдсэн хэсгийн 2 дэлгэцийн өмнөөс дараагийн chunk-г эхлүүлнэ
699
806
  val visibleBottom = scrollY + scrollView.height
700
- val trigger = container.height - scrollView.height * 2
701
- if (visibleBottom >= trigger) {
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) {
702
825
  chunkLoading = true
703
826
  scope.launch {
704
- try { loadNextChunk() }
827
+ try { loadPrevChunk() }
705
828
  catch (e: CancellationException) { throw e }
706
- catch (e: Exception) { Log.e("ExpoPdfReader", "Chunk load error", e) }
829
+ catch (e: Exception) { Log.e("ExpoPdfReader", "Prev chunk load error", e) }
707
830
  finally { chunkLoading = false }
708
831
  }
709
832
  }
@@ -736,12 +859,271 @@ class ExpoPdfReaderView(
736
859
  }
737
860
  withContext(Dispatchers.Main) {
738
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
+ }
739
868
  if (end < totalPages) showLoadingFooter()
740
869
  forceLayoutScrollView()
741
870
  }
742
871
  }
743
872
  }
744
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
+
745
1127
  /**
746
1128
  * React Native Fabric нь child view-н requestLayout()-г таслах тул
747
1129
  * addView() дараа layout pass автоматаар ажиллахгүй.
@@ -777,9 +1159,9 @@ class ExpoPdfReaderView(
777
1159
  private fun addPageRow(bmp: Bitmap, pageIdx: Int, pdfW: Float, pdfH: Float) =
778
1160
  container.addView(buildPageFrame(bmp, pageIdx, pdfW, pdfH))
779
1161
 
780
- private fun buildPageFrame(bmp: Bitmap, pageIdx: Int, pdfW: Float, pdfH: Float, weight: Float = 0f): FrameLayout {
781
- // MATCH_PARENT нь React Native managed view дотор layout pass болохоос өмнө
782
- // width = 0 авдаг тул explicit pixel dimension ашиглана → шууд харагдана
1162
+ private fun buildPageFrameDetached(
1163
+ bmp: Bitmap, pageIdx: Int, pdfW: Float, pdfH: Float, weight: Float = 0f
1164
+ ): Pair<FrameLayout, PageEntry> {
783
1165
  val bmpW = bmp.width
784
1166
  val bmpH = bmp.height
785
1167
  val frame = FrameLayout(context).apply {
@@ -800,13 +1182,22 @@ class ExpoPdfReaderView(
800
1182
  }
801
1183
  frame.addView(iv)
802
1184
  frame.addView(canvasView)
803
- pageEntries.add(PageEntry(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)
804
1191
  return frame
805
1192
  }
806
1193
 
807
1194
  private suspend fun renderPageBitmap(pdf: PdfRenderer, index: Int, targetWidth: Int): Bitmap =
808
1195
  withContext(pdfDispatcher) {
809
1196
  pdf.openPage(index).use { page ->
1197
+ if (index < pagePdfW.size) {
1198
+ pagePdfW[index] = page.width
1199
+ pagePdfH[index] = page.height
1200
+ }
810
1201
  val s = targetWidth.toFloat() / page.width.toFloat()
811
1202
  val h = (page.height * s).toInt().coerceAtLeast(1)
812
1203
  val bmp = Bitmap.createBitmap(targetWidth, h, Bitmap.Config.ARGB_8888)
@@ -844,8 +1235,9 @@ class ExpoPdfReaderView(
844
1235
 
845
1236
  // ─────────────────────────────────────────────────────────────────────────
846
1237
  // SinglePageAdapter — RecyclerView adapter for "single" display mode
847
- // • horizontal PagerSnapHelper iOS usePageViewController(true) шиг
848
- // • lazy bitmap loading (зөвхөн харагдаж буй + хажуугийн хуудсуудыг)
1238
+ // • vertical PagerSnapHelper босоо scroll-оор хуудас солих
1239
+ // • ZoomRecyclerView zoom-г удирдана (adapter энгийн FrameLayout ашиглана)
1240
+ // • lazy bitmap loading
849
1241
  // ─────────────────────────────────────────────────────────────────────────
850
1242
 
851
1243
  inner class SinglePageAdapter : RecyclerView.Adapter<SinglePageAdapter.Holder>() {
@@ -867,9 +1259,6 @@ class ExpoPdfReaderView(
867
1259
  )
868
1260
  setBackgroundColor(Color.parseColor("#E8E8E8"))
869
1261
  }
870
- // ImageView starts WRAP_CONTENT; sized to bitmap in onBindViewHolder so that
871
- // the AnnotationCanvasView can use the same pixel dimensions — no FIT_CENTER
872
- // offset mis-alignment.
873
1262
  val iv = ImageView(context).apply {
874
1263
  layoutParams = FrameLayout.LayoutParams(
875
1264
  FrameLayout.LayoutParams.WRAP_CONTENT,
@@ -886,51 +1275,43 @@ class ExpoPdfReaderView(
886
1275
  holder.boundPage = position
887
1276
  holder.loadJob?.cancel()
888
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
889
1281
 
890
- // Remove old canvas and register new one for this page
891
1282
  holder.currentCanvas?.let {
892
1283
  singlePageCanvases.remove(it.pageIndex)
893
1284
  holder.frame.removeView(it)
894
1285
  }
895
- val pdfW = pagePdfW.getOrElse(position) { 1 }.toFloat()
896
- val pdfH = pagePdfH.getOrElse(position) { 1 }.toFloat()
897
- // Canvas starts with placeholder size; resized to exact bitmap dims after load.
898
- // translationZ ensures canvas always renders above ImageView regardless of layout order.
899
- val canvas = AnnotationCanvasView(context, position, pdfW, pdfH).apply {
900
- layoutParams = FrameLayout.LayoutParams(
901
- FrameLayout.LayoutParams.WRAP_CONTENT,
902
- FrameLayout.LayoutParams.WRAP_CONTENT,
903
- android.view.Gravity.CENTER
904
- )
905
- translationZ = 1f
906
- }
907
- holder.frame.addView(canvas)
908
- holder.currentCanvas = canvas
909
- singlePageCanvases[position] = canvas
1286
+ holder.currentCanvas = null
910
1287
 
911
- // Lazy bitmap rendering
912
1288
  holder.loadJob = scope.launch {
913
1289
  val pdf = renderer ?: return@launch
914
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()
915
1293
  withContext(Dispatchers.Main) {
916
- if (holder.boundPage == position) {
917
- // Size both ImageView and AnnotationCanvasView to the exact bitmap
918
- // pixel dimensions so annotation coordinates match perfectly.
919
- val lp = FrameLayout.LayoutParams(bmp.width, bmp.height, android.view.Gravity.CENTER)
920
- holder.imageView.layoutParams = lp
921
- holder.imageView.setImageBitmap(bmp)
922
- holder.currentCanvas?.layoutParams = FrameLayout.LayoutParams(bmp.width, bmp.height, android.view.Gravity.CENTER)
923
- // React Native Fabric blocks requestLayout() on child views.
924
- // Manually measure+layout the frame so the updated layoutParams
925
- // take effect immediately without waiting for the next Fabric pass.
926
- val fw = this@ExpoPdfReaderView.width.takeIf { it > 0 } ?: return@withContext
927
- val fh = this@ExpoPdfReaderView.height.takeIf { it > 0 } ?: return@withContext
928
- holder.frame.measure(
929
- MeasureSpec.makeMeasureSpec(fw, MeasureSpec.EXACTLY),
930
- MeasureSpec.makeMeasureSpec(fh, MeasureSpec.EXACTLY)
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
931
1299
  )
932
- holder.frame.layout(0, 0, fw, fh)
1300
+ translationZ = 1f
933
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)
934
1315
  }
935
1316
  }
936
1317
  }
@@ -946,6 +1327,101 @@ class ExpoPdfReaderView(
946
1327
  }
947
1328
  }
948
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
+
949
1425
  // ─────────────────────────────────────────────────────────────────────────
950
1426
  // AnnotationCanvasView (inner class — full access to outer view state)
951
1427
  // ─────────────────────────────────────────────────────────────────────────
@@ -53,6 +53,9 @@ final class ExpoPdfReaderView: ExpoView {
53
53
  private var draggingPage: PDFPage?
54
54
  private var dragStartPagePoint: CGPoint?
55
55
  private var dragStartBounds: CGRect?
56
+ // Zoom range: minZoom=1.0 (fit width), maxZoom=5.0 → pinch-to-zoom дэмжинэ
57
+ private var minZoom: CGFloat = 1.0
58
+ private var maxZoom: CGFloat = 5.0
56
59
 
57
60
  required init(appContext: AppContext? = nil) {
58
61
  super.init(appContext: appContext)
@@ -141,11 +144,12 @@ final class ExpoPdfReaderView: ExpoView {
141
144
 
142
145
  guard viewWidth > 0, pageBounds.width > 0 else { return }
143
146
 
144
- let scale = viewWidth / pageBounds.width
145
- // Lock both min/max to same scale so user cannot zoom no horizontal scroll
146
- pdfView.minScaleFactor = scale
147
- pdfView.maxScaleFactor = scale
148
- pdfView.scaleFactor = scale
147
+ let fitScale = viewWidth / pageBounds.width
148
+ // min/max өөр байх ёстой pinch-to-zoom ажиллана (Android-тай ижил)
149
+ pdfView.minScaleFactor = fitScale * minZoom
150
+ pdfView.maxScaleFactor = fitScale * maxZoom
151
+ // Анхны scale = fit width. PDFView default scaleFactor=1.0 байдаг тул үргэлж fitScale тохируулна
152
+ pdfView.scaleFactor = fitScale
149
153
 
150
154
  // Also make sure the underlying scroll view never scrolls horizontally
151
155
  if let scrollView = findScrollView(in: pdfView) {
@@ -291,8 +295,19 @@ final class ExpoPdfReaderView: ExpoView {
291
295
  lastPageIndex = index
292
296
  }
293
297
 
294
- func setMinZoom(_ v: Double) { pdfView.minScaleFactor = CGFloat(v) }
295
- func setMaxZoom(_ v: Double) { pdfView.maxScaleFactor = CGFloat(v) }
298
+ func setMinZoom(_ v: Double) {
299
+ minZoom = CGFloat(v)
300
+ // scaleToFitWidth дахин дуудагдаагүй бол одоогийн fitScale-аар тооцоо
301
+ if pdfView.document != nil {
302
+ scaleToFitWidth()
303
+ }
304
+ }
305
+ func setMaxZoom(_ v: Double) {
306
+ maxZoom = CGFloat(v)
307
+ if pdfView.document != nil {
308
+ scaleToFitWidth()
309
+ }
310
+ }
296
311
 
297
312
  func setTool(_ tool: String?) {
298
313
  currentTool = tool != nil ? AnnotationTool(rawValue: tool!) : nil
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@june24/expo-pdf-reader",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "A PDF reader for Expo apps with annotation and zoom controls",
5
5
  "homepage": "git@gitlab.com:june_241/expo-pdf-reader.git",
6
6
  "main": "build/index.js",