@june24/expo-pdf-reader 0.1.23 → 0.1.25

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