@june24/expo-pdf-reader 0.1.25 → 0.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1073 +1,1073 @@
1
- import ExpoModulesCore
2
- import PDFKit
3
- import UIKit
4
-
5
- enum AnnotationTool: String {
6
- case pen, highlighter, text, note, eraser, line
7
- }
8
-
9
- final class ExpoPdfReaderView: ExpoView {
10
- private let pdfView = PDFView()
11
- private var currentTool: AnnotationTool? = nil
12
- private var strokeColor: UIColor = .red
13
- private var strokeWidth: CGFloat = 2.0
14
- private var textContent: String = ""
15
- private var textColor: UIColor = .black
16
- private var textFontSize: CGFloat = 14.0
17
- private var textBold: Bool = false
18
- private var textItalic: Bool = false
19
- private var noteColor: UIColor = .yellow
20
- // Custom memo icon name (PNG in iOS bundle). If nil, default PDFKit icon is used.
21
- private var memoIconName: String? = nil
22
-
23
- let onReady = EventDispatcher()
24
- let onPageChange = EventDispatcher()
25
- let onAnnotationChange = EventDispatcher()
26
- let onLoadingAnnotation = EventDispatcher()
27
- let onUndoRedoStateChange = EventDispatcher()
28
- let onNotePress = EventDispatcher()
29
- let onTextPress = EventDispatcher()
30
- private struct UndoEntry {
31
- let annotation: PDFAnnotation
32
- let pageIndex: Int
33
- }
34
- private var undoStack: [UndoEntry] = []
35
- private var redoStack: [UndoEntry] = []
36
-
37
- // Зурах үед шууд PDFAnnotation үүсгээд байх нь удаан тул
38
- // эхлээд CAShapeLayer дээр preview хийгээд, төгсгөөд нэг annotation болгож commit хийнэ.
39
- private var currentPath: UIBezierPath?
40
- private var activePage: PDFPage?
41
- private var startPoint: CGPoint?
42
- private var previewLayer: CAShapeLayer?
43
- private var lastPreviewUpdateTime: CFTimeInterval = 0
44
- private let previewUpdateInterval: CFTimeInterval = 0.016 // ~60fps throttling
45
- private var previewMutablePath: CGMutablePath?
46
- private var pendingDisplayUpdate: Bool = false
47
- private var appliedAnnotationsFingerprint: String? = nil
48
- // Always keep track of last known page index (for restoring after displayMode changes)
49
- private var lastPageIndex: Int = 0
50
- private var annotationTapRecognizer: UITapGestureRecognizer?
51
- private var annotationLongPressRecognizer: UILongPressGestureRecognizer?
52
- private var draggingAnnotation: PDFAnnotation?
53
- private var draggingPage: PDFPage?
54
- private var dragStartPagePoint: CGPoint?
55
- private var dragStartBounds: CGRect?
56
- // Zoom range: minZoom=1.0 (fit width), maxZoom=5.0 → pinch-to-zoom дэмжинэ
57
- private var minZoom: CGFloat = 1.0
58
- private var maxZoom: CGFloat = 5.0
59
-
60
- required init(appContext: AppContext? = nil) {
61
- super.init(appContext: appContext)
62
- setupView()
63
- setupNotifications()
64
- setupAppLifecycle()
65
- }
66
-
67
- private func setupView() {
68
- // We'll manage scaling manually to always fit width
69
- pdfView.autoScales = false
70
- pdfView.displayMode = .singlePageContinuous
71
- pdfView.displayDirection = .vertical
72
- pdfView.backgroundColor = .white
73
- pdfView.usePageViewController(true)
74
- addSubview(pdfView)
75
-
76
- // Single tap дээр text / note annotation‑ийг барьж аваад JS‑д event илгээх gesture
77
- let tap = UITapGestureRecognizer(target: self, action: #selector(handleAnnotationTap(_:)))
78
- tap.numberOfTapsRequired = 1
79
- tap.cancelsTouchesInView = true // annotation дээр дарвал PDFKit‑ийн өөрийн popup/BottomSheet‑ийг блоклоно
80
- pdfView.addGestureRecognizer(tap)
81
- annotationTapRecognizer = tap
82
-
83
- // Long press + drag дээр annotation‑ийн байрлалыг солих gesture
84
- let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleAnnotationLongPress(_:)))
85
- longPress.minimumPressDuration = 0.3
86
- longPress.cancelsTouchesInView = true
87
- pdfView.addGestureRecognizer(longPress)
88
- annotationLongPressRecognizer = longPress
89
- }
90
-
91
- private func setupNotifications() {
92
- NotificationCenter.default.addObserver(self, selector: #selector(handlePageChange), name: .PDFViewPageChanged, object: pdfView)
93
- }
94
-
95
- private func setupAppLifecycle() {
96
- NotificationCenter.default.addObserver(
97
- self,
98
- selector: #selector(handleAppWillEnterForeground),
99
- name: UIApplication.willEnterForegroundNotification,
100
- object: nil
101
- )
102
- }
103
- @objc private func handleAppWillEnterForeground() {
104
- // App foreground руу буцаж ирхэд PDF view-ийг дахин render хийх
105
- DispatchQueue.main.async { [weak self] in
106
- guard let self = self else { return }
107
- // Зөвхөн display-ийг шинэчлэх, document-ийг дахин set хийхгүй (memory leak үүсгэж болно)
108
- self.requestDisplayUpdate()
109
- }
110
- }
111
-
112
- @objc private func handlePageChange() {
113
- guard let currentPage = pdfView.currentPage, let document = pdfView.document else { return }
114
- let pageIndex = document.index(for: currentPage)
115
- lastPageIndex = pageIndex
116
- onPageChange(["currentPage": pageIndex, "totalPage": document.pageCount])
117
- }
118
-
119
- private func requestDisplayUpdate() {
120
- // Coalesce multiple redraw requests into one runloop tick.
121
- guard !pendingDisplayUpdate else { return }
122
- pendingDisplayUpdate = true
123
- DispatchQueue.main.async { [weak self] in
124
- guard let self = self else { return }
125
- self.pendingDisplayUpdate = false
126
- self.pdfView.setNeedsDisplay()
127
- }
128
- }
129
-
130
- // MARK: - Scaling Helpers
131
-
132
- /// Scale the current document so that PDF width fits the view width (no horizontal scroll)
133
- private func scaleToFitWidth() {
134
- guard let document = pdfView.document,
135
- let firstPage = document.page(at: 0) else { return }
136
-
137
- let pageBounds = firstPage.bounds(for: .mediaBox)
138
- var viewWidth = pdfView.bounds.width
139
-
140
- // In two-page modes each page takes half of the width
141
- if pdfView.displayMode == .twoUp || pdfView.displayMode == .twoUpContinuous {
142
- viewWidth /= 2.0
143
- }
144
-
145
- guard viewWidth > 0, pageBounds.width > 0 else { return }
146
-
147
- let fitScale = viewWidth / pageBounds.width
148
- // min/max өөр байх ёстой — pinch-to-zoom ажиллана (Android-тай ижил)
149
- pdfView.minScaleFactor = fitScale * minZoom
150
- pdfView.maxScaleFactor = fitScale * maxZoom
151
- // Анхны scale = fit width. PDFView default scaleFactor=1.0 байдаг тул үргэлж fitScale тохируулна
152
- pdfView.scaleFactor = fitScale
153
-
154
- // Also make sure the underlying scroll view never scrolls horizontally
155
- if let scrollView = findScrollView(in: pdfView) {
156
- scrollView.alwaysBounceHorizontal = false
157
- scrollView.showsHorizontalScrollIndicator = false
158
- // Content is exactly fit‑width so any horizontal offset is pointless
159
- scrollView.contentOffset.x = 0
160
- }
161
- }
162
-
163
- override func layoutSubviews() {
164
- super.layoutSubviews()
165
- pdfView.frame = bounds
166
-
167
- // Only auto-scale when there is a document.
168
- // Do NOT constantly override user zoom – only when scaleFactor is "uninitialized".
169
- if pdfView.document != nil, pdfView.scaleFactor == 0 {
170
- scaleToFitWidth()
171
- }
172
- }
173
-
174
- /// Custom hitTest:
175
- /// - Tool байхгүй үед: PDFView өөрийн default scroll/zoom зан төлөвөө хадгална.
176
- /// - Tool идэвхтэй үед: Зөвхөн PDF‑ийн контент хэсэг дээрхи touch‑ийг энэ view рүү (self) шиднэ.
177
- /// Ингэснээр гадна талын React Native toolbar, button гэх мэт touch‑ууд блоклохгүй.
178
- override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
179
- // Default‑оор аль view онох байсан бэ?
180
- let defaultHitView = super.hitTest(point, with: event)
181
-
182
- // Tool сонгогдоогүй → бүхнийг default‑оор нь үлдээнэ
183
- guard currentTool != nil else {
184
- return defaultHitView
185
- }
186
-
187
- // Хэрвээ энэ touch нь pdfView эсвэл түүний дэд view дээр таарсан бол
188
- // drawing‑ийн логик ажиллуулахын тулд self буцаана.
189
- if let v = defaultHitView, (v == pdfView || v.isDescendant(of: pdfView)) {
190
- return self
191
- }
192
-
193
- // Харин түүнээс өөр (toolbar г.м.) бол default view‑г буцааж, гадна талын control‑уудыг хэвийн ажиллуулна.
194
- return defaultHitView
195
- }
196
-
197
- private var pendingAnnotations: [[String: Any]]? = nil
198
-
199
- func load(url: URL) {
200
- if pdfView.document?.documentURL == url { return }
201
- if let document = PDFDocument(url: url) {
202
- pdfView.document = document
203
- // New document → start from first page logically
204
- lastPageIndex = 0
205
- undoStack.removeAll()
206
- redoStack.removeAll()
207
- notifyUndoRedoStateChange()
208
- appliedAnnotationsFingerprint = nil
209
- // Defer scaling until the view has correct bounds
210
- DispatchQueue.main.async {
211
- self.scaleToFitWidth()
212
- // Document load хийгдсэний дараа pending annotation-уудыг load хийх
213
- if let pending = self.pendingAnnotations {
214
- self.pendingAnnotations = nil
215
- self.setAnnotations(pending)
216
- }
217
- // PDF бэлэн болсныг JS-д мэдэгдэнэ
218
- let totalPages = document.pageCount
219
- let viewW = Double(self.bounds.width)
220
- let viewH = Double(self.bounds.height)
221
- self.onReady([
222
- "totalPages": totalPages,
223
- "width": viewW,
224
- "height": viewH
225
- ])
226
- }
227
- }
228
- }
229
-
230
- private func fingerprintAnnotations(_ annotationsArray: [[String: Any]]) -> String {
231
- // Stable-ish fingerprint to prevent re-applying same "initialAnnotations" on every prop update.
232
- // We only need to detect "no change" from JS; not cryptographic.
233
- let items: [String] = annotationsArray.compactMap { data in
234
- guard
235
- let pageIndex = data["page"] as? Int,
236
- let typeStr = data["type"] as? String,
237
- let b = data["bounds"] as? [String: Any]
238
- else { return nil }
239
-
240
- let x = b["x"] as? Double ?? 0
241
- let y = b["y"] as? Double ?? 0
242
- let w = b["width"] as? Double ?? 0
243
- let h = b["height"] as? Double ?? 0
244
- let color = data["color"] as? String ?? ""
245
- let strokeWidth = data["strokeWidth"] as? Double ?? 0
246
- let contents = data["contents"] as? String ?? ""
247
-
248
- var pointsCount = 0
249
- if let paths = data["paths"] as? [[[String: Any]]] {
250
- for p in paths { pointsCount += p.count }
251
- }
252
-
253
- return "\(pageIndex)|\(typeStr)|\(x),\(y),\(w),\(h)|\(color)|\(strokeWidth)|\(contents)|pts:\(pointsCount)"
254
- }
255
-
256
- return items.sorted().joined(separator: "||")
257
- }
258
-
259
- func setDisplayMode(_ mode: String) {
260
- // Always use our own tracked index (lastPageIndex),
261
- // which is updated via PDFViewPageChanged + setInitialPage.
262
- let currentIndex = lastPageIndex
263
-
264
- switch mode {
265
- case "single":
266
- pdfView.displayMode = .singlePage
267
- pdfView.usePageViewController(true)
268
- case "continuous":
269
- pdfView.displayMode = .singlePageContinuous
270
- pdfView.usePageViewController(false)
271
- case "twoUp":
272
- pdfView.displayMode = .twoUp
273
- pdfView.usePageViewController(true)
274
- case "twoUpContinuous":
275
- pdfView.displayMode = .twoUpContinuous
276
- pdfView.usePageViewController(false)
277
- default:
278
- pdfView.displayMode = .singlePageContinuous
279
- }
280
-
281
- // Recompute scaling and restore page when layout for this mode is ready
282
- DispatchQueue.main.async {
283
- if let doc = self.pdfView.document,
284
- currentIndex >= 0, currentIndex < doc.pageCount,
285
- let page = doc.page(at: currentIndex) {
286
- self.pdfView.go(to: page)
287
- }
288
- self.scaleToFitWidth()
289
- }
290
- }
291
-
292
- func setInitialPage(_ index: Int) {
293
- guard let doc = pdfView.document, index < doc.pageCount, let page = doc.page(at: index) else { return }
294
- pdfView.go(to: page)
295
- lastPageIndex = index
296
- }
297
-
298
- func setMinZoom(_ v: Double) {
299
- minZoom = CGFloat(v)
300
- // scaleToFitWidth дахин дуудагдаагүй бол одоогийн fitScale-аар тооцоо
301
- if pdfView.document != nil {
302
- scaleToFitWidth()
303
- }
304
- }
305
- func setMaxZoom(_ v: Double) {
306
- maxZoom = CGFloat(v)
307
- if pdfView.document != nil {
308
- scaleToFitWidth()
309
- }
310
- }
311
-
312
- func setTool(_ tool: String?) {
313
- currentTool = tool != nil ? AnnotationTool(rawValue: tool!) : nil
314
- let interactionsEnabled = (currentTool == nil)
315
-
316
- // 1) ScrollView‑ийн scroll‑ийг ON/OFF
317
- if let scrollView = findScrollView(in: pdfView) {
318
- scrollView.isScrollEnabled = interactionsEnabled
319
- }
320
-
321
- // 2) PDFView болон түүний бүх дэд view‑үүдийн pan / swipe gesture‑үүдийг ON/OFF
322
- func updateGestures(in view: UIView) {
323
- if let gestures = view.gestureRecognizers {
324
- for gesture in gestures {
325
- if gesture is UIPanGestureRecognizer || gesture is UISwipeGestureRecognizer {
326
- gesture.isEnabled = interactionsEnabled
327
- }
328
- }
329
- }
330
- for sub in view.subviews {
331
- updateGestures(in: sub)
332
- }
333
- }
334
- updateGestures(in: pdfView)
335
-
336
- // Annotation tap gesture зөвхөн tool сонгогдоогүй үед идэвхтэй байг
337
- annotationTapRecognizer?.isEnabled = interactionsEnabled
338
- annotationLongPressRecognizer?.isEnabled = interactionsEnabled
339
- }
340
-
341
- func setStrokeColor(_ hex: String) { self.strokeColor = UIColor(hex: hex) }
342
- func setStrokeWidth(_ width: Double) { self.strokeWidth = CGFloat(width) }
343
- func setTextContent(_ text: String) { self.textContent = text }
344
- func setTextColor(_ hex: String) { self.textColor = UIColor(hex: hex) }
345
- func setTextFontSize(_ size: Double) { self.textFontSize = CGFloat(size) }
346
- func setTextBold(_ value: Bool) { self.textBold = value }
347
- func setTextItalic(_ value: Bool) { self.textItalic = value }
348
- func setNoteColor(_ hex: String) { self.noteColor = UIColor(hex: hex) }
349
- func setMemoIconName(_ name: String?) { self.memoIconName = name }
350
-
351
- func undo() {
352
- guard let last = undoStack.popLast() else { return }
353
- redoStack.append(last)
354
- if let doc = pdfView.document, let page = doc.page(at: last.pageIndex) {
355
- page.removeAnnotation(last.annotation)
356
- } else {
357
- last.annotation.page?.removeAnnotation(last.annotation)
358
- }
359
- requestDisplayUpdate()
360
- notifyAnnotationChange()
361
- notifyUndoRedoStateChange()
362
- }
363
-
364
- func redo() {
365
- guard let last = redoStack.popLast() else { return }
366
- undoStack.append(last)
367
- if let doc = pdfView.document, let page = doc.page(at: last.pageIndex) {
368
- page.addAnnotation(last.annotation)
369
- }
370
- requestDisplayUpdate()
371
- notifyAnnotationChange()
372
- notifyUndoRedoStateChange()
373
- }
374
-
375
- private func notifyUndoRedoStateChange() {
376
- onUndoRedoStateChange([
377
- "canUndo": !undoStack.isEmpty,
378
- "canRedo": !redoStack.isEmpty
379
- ])
380
- }
381
-
382
- private func topViewController(from root: UIViewController?) -> UIViewController? {
383
- if let nav = root as? UINavigationController {
384
- return topViewController(from: nav.visibleViewController)
385
- }
386
- if let tab = root as? UITabBarController {
387
- return topViewController(from: tab.selectedViewController)
388
- }
389
- if let presented = root?.presentedViewController {
390
- return topViewController(from: presented)
391
- }
392
- return root
393
- }
394
-
395
- private func presentTextEdit(for annotation: PDFAnnotation, on page: PDFPage) {
396
- let currentText = annotation.contents ?? ""
397
-
398
- let alert = UIAlertController(title: "텍스트 편집", message: nil, preferredStyle: .alert)
399
- alert.addTextField { tf in
400
- tf.text = currentText
401
- }
402
- alert.addAction(UIAlertAction(title: "취소", style: .cancel, handler: nil))
403
- alert.addAction(UIAlertAction(title: "저장", style: .default, handler: { [weak self] _ in
404
- guard let self = self else { return }
405
- let newText = alert.textFields?.first?.text ?? ""
406
- annotation.contents = newText.isEmpty ? " " : newText
407
- self.requestDisplayUpdate()
408
- self.notifyAnnotationChange()
409
- }))
410
-
411
- if let root = UIApplication.shared.connectedScenes
412
- .compactMap({ $0 as? UIWindowScene })
413
- .first?.windows.first(where: { $0.isKeyWindow })?.rootViewController,
414
- let top = topViewController(from: root) {
415
- top.present(alert, animated: true, completion: nil)
416
- }
417
- }
418
-
419
- // Single tap дээр annotation дарсан эсэхийг шалгаж, JS‑д event илгээнэ.
420
- @objc private func handleAnnotationTap(_ gesture: UITapGestureRecognizer) {
421
- guard gesture.state == .ended else { return }
422
- // Зөвхөн tool сонгогдоогүй үед edit хийх боломжтой
423
- guard currentTool == nil else { return }
424
-
425
- let locationInPdf = gesture.location(in: pdfView)
426
- guard let page = pdfView.page(for: locationInPdf, nearest: true) else { return }
427
- let pagePoint = pdfView.convert(locationInPdf, to: page)
428
-
429
- // Эхлээд FreeText (text tool) шалгана
430
- if let (textIndex, textAnn) = page.annotations.enumerated().first(where: { $0.element.type == "FreeText" && $0.element.bounds.contains(pagePoint) }) {
431
- let pageIndex = pdfView.document?.index(for: page) ?? 0
432
- let b = textAnn.bounds
433
- onTextPress([
434
- "page": pageIndex,
435
- "index": textIndex,
436
- "bounds": [
437
- "x": b.origin.x,
438
- "y": b.origin.y,
439
- "width": b.width,
440
- "height": b.height
441
- ],
442
- "contents": textAnn.contents ?? ""
443
- ])
444
- return
445
- }
446
-
447
- // Дараа нь sticky note (Text subtype) шалгана
448
- if let (noteIndex, noteAnn) = page.annotations.enumerated().first(where: { $0.element.type == "Text" && $0.element.bounds.contains(pagePoint) }) {
449
- let pageIndex = pdfView.document?.index(for: page) ?? 0
450
- let b = noteAnn.bounds
451
- onNotePress([
452
- "page": pageIndex,
453
- "index": noteIndex,
454
- "bounds": [
455
- "x": b.origin.x,
456
- "y": b.origin.y,
457
- "width": b.width,
458
- "height": b.height
459
- ],
460
- "contents": noteAnn.contents ?? ""
461
- ])
462
- return
463
- }
464
- }
465
-
466
- // Long press + drag дээр text / note annotation‑ийн байрлалыг өөрчилнө.
467
- @objc private func handleAnnotationLongPress(_ gesture: UILongPressGestureRecognizer) {
468
- // Зөвхөн tool сонгогдоогүй үед л annotation зөөх боломжтой
469
- guard currentTool == nil else { return }
470
-
471
- let locationInPdf = gesture.location(in: pdfView)
472
- guard let page = pdfView.page(for: locationInPdf, nearest: true) else { return }
473
- let pagePoint = pdfView.convert(locationInPdf, to: page)
474
-
475
- switch gesture.state {
476
- case .began:
477
- // Эхлээд FreeText (text tool) annotation хайна
478
- if let (_, ann) = page.annotations.enumerated().first(where: { $0.element.type == "FreeText" && $0.element.bounds.contains(pagePoint) }) {
479
- draggingAnnotation = ann
480
- draggingPage = page
481
- dragStartPagePoint = pagePoint
482
- dragStartBounds = ann.bounds
483
- return
484
- }
485
- // Дараа нь sticky note (Text subtype) annotation хайна
486
- if let (_, ann) = page.annotations.enumerated().first(where: { $0.element.type == "Text" && $0.element.bounds.contains(pagePoint) }) {
487
- draggingAnnotation = ann
488
- draggingPage = page
489
- dragStartPagePoint = pagePoint
490
- dragStartBounds = ann.bounds
491
- return
492
- }
493
- case .changed:
494
- guard
495
- let ann = draggingAnnotation,
496
- let startPoint = dragStartPagePoint,
497
- let startBounds = dragStartBounds,
498
- let dragPage = draggingPage,
499
- dragPage == page
500
- else { return }
501
-
502
- let dx = pagePoint.x - startPoint.x
503
- let dy = pagePoint.y - startPoint.y
504
- var newBounds = startBounds
505
- newBounds.origin.x += dx
506
- newBounds.origin.y += dy
507
- ann.bounds = newBounds
508
- requestDisplayUpdate()
509
-
510
- case .ended, .cancelled, .failed:
511
- if draggingAnnotation != nil {
512
- notifyAnnotationChange()
513
- notifyUndoRedoStateChange()
514
- }
515
- draggingAnnotation = nil
516
- draggingPage = nil
517
- dragStartPagePoint = nil
518
- dragStartBounds = nil
519
-
520
- default:
521
- break
522
- }
523
- }
524
-
525
- // Update text (FreeText) contents from JS (TextModal)
526
- func updateText(pageIndex: Int, index: Int, contents: String) {
527
- guard let doc = pdfView.document,
528
- pageIndex >= 0, pageIndex < doc.pageCount,
529
- let page = doc.page(at: pageIndex),
530
- index >= 0, index < page.annotations.count else { return }
531
-
532
- let ann = page.annotations[index]
533
- guard ann.type == "FreeText" else { return }
534
-
535
- let text = contents.isEmpty ? " " : contents
536
- ann.contents = text
537
-
538
- // Урт текст нэмэхэд box‑оосоо гарч харагдахгүй болохоос сэргийлж bounds‑ийг text‑ийн хэмжээтэй тааруулж томруулна.
539
- if let font = ann.font {
540
- // Өмнөх өргөнийг хадгалж, өндөр талдаа автоматаар томруулна
541
- let maxWidth = max(ann.bounds.width, 180)
542
- let constraintSize = CGSize(width: maxWidth, height: CGFloat.greatestFiniteMagnitude)
543
- let rect = (text as NSString).boundingRect(
544
- with: constraintSize,
545
- options: [.usesLineFragmentOrigin, .usesFontLeading],
546
- attributes: [.font: font],
547
- context: nil
548
- )
549
- let padding: CGFloat = 8
550
- var newBounds = ann.bounds
551
- newBounds.size.width = max(maxWidth, ceil(rect.width) + padding * 2)
552
- newBounds.size.height = ceil(rect.height) + padding * 2
553
- ann.bounds = newBounds
554
- }
555
-
556
- requestDisplayUpdate()
557
- notifyAnnotationChange()
558
- notifyUndoRedoStateChange()
559
- }
560
-
561
- // Update memo/note contents from JS (MemoModal)
562
- func updateNote(pageIndex: Int, index: Int, contents: String) {
563
- guard let doc = pdfView.document,
564
- pageIndex >= 0, pageIndex < doc.pageCount,
565
- let page = doc.page(at: pageIndex),
566
- index >= 0, index < page.annotations.count else { return }
567
-
568
- let ann = page.annotations[index]
569
- guard ann.type == "Text" else { return } // only sticky notes
570
-
571
- ann.contents = contents.isEmpty ? " " : contents
572
- requestDisplayUpdate()
573
- notifyAnnotationChange()
574
- notifyUndoRedoStateChange()
575
- }
576
-
577
- override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
578
- guard let touch = touches.first else {
579
- super.touchesBegan(touches, with: event)
580
- return
581
- }
582
-
583
- let locationInPdf = touch.location(in: pdfView)
584
- guard let page = pdfView.page(for: locationInPdf, nearest: true) else {
585
- super.touchesBegan(touches, with: event)
586
- return
587
- }
588
- let pagePoint = pdfView.convert(locationInPdf, to: page)
589
-
590
- // Tool байхгүй үед: scroll/zoom‑ийг PDFView өөрөө хариуцах ёстой
591
- if currentTool == nil {
592
- super.touchesBegan(touches, with: event)
593
- return
594
- }
595
-
596
- guard let tool = currentTool else {
597
- super.touchesBegan(touches, with: event)
598
- return
599
- }
600
-
601
- activePage = page
602
- startPoint = pagePoint
603
-
604
- if tool == .pen || tool == .highlighter || tool == .line {
605
- // Preview path
606
- let path = UIBezierPath()
607
- path.lineWidth = strokeWidth
608
- path.move(to: pagePoint)
609
- currentPath = path
610
-
611
- // CAShapeLayer дээр realtime preview
612
- previewLayer?.removeFromSuperlayer()
613
- previewMutablePath = CGMutablePath()
614
-
615
- let layer = CAShapeLayer()
616
- let viewPoint = pdfView.convert(pagePoint, from: page)
617
- previewMutablePath?.move(to: viewPoint)
618
- layer.path = previewMutablePath
619
-
620
- layer.strokeColor = (tool == .highlighter
621
- ? strokeColor.withAlphaComponent(0.4).cgColor
622
- : strokeColor.cgColor)
623
- layer.fillColor = UIColor.clear.cgColor
624
- layer.lineWidth = strokeWidth
625
- layer.frame = pdfView.bounds
626
- layer.contentsScale = UIScreen.main.scale
627
-
628
- previewLayer = layer
629
- pdfView.layer.addSublayer(layer)
630
- lastPreviewUpdateTime = CACurrentMediaTime()
631
- } else if tool == .text || tool == .note {
632
- addInstantAnnotation(at: pagePoint, on: page, tool: tool)
633
- } else if tool == .eraser {
634
- eraseAt(point: pagePoint, on: page)
635
- }
636
- }
637
-
638
- override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
639
- guard let tool = currentTool,
640
- let touch = touches.first,
641
- let page = activePage else {
642
- super.touchesMoved(touches, with: event)
643
- return
644
- }
645
-
646
- let pointInPdf = touch.location(in: pdfView)
647
- let pagePoint = pdfView.convert(pointInPdf, to: page)
648
-
649
- if tool == .eraser {
650
- eraseAt(point: pagePoint, on: page)
651
- return
652
- }
653
-
654
- guard let path = currentPath else {
655
- super.touchesMoved(touches, with: event)
656
- return
657
- }
658
-
659
- if tool == .pen || tool == .highlighter {
660
- path.addLine(to: pagePoint)
661
- } else if tool == .line, let start = startPoint {
662
- let linePath = UIBezierPath()
663
- linePath.lineWidth = strokeWidth
664
- linePath.move(to: start)
665
- linePath.addLine(to: pagePoint)
666
- currentPath = linePath
667
- }
668
-
669
- // Preview layer‑ийн замыг шинэчилнэ (throttling хийж, performance сайжруулах)
670
- let currentTime = CACurrentMediaTime()
671
- guard currentTime - lastPreviewUpdateTime >= previewUpdateInterval else { return }
672
- lastPreviewUpdateTime = currentTime
673
-
674
- guard let layer = previewLayer else { return }
675
-
676
- let lastPoint = path.cgPath.currentPoint
677
- let viewPoint = pdfView.convert(lastPoint, from: page)
678
-
679
- // Line tool: rebuild just a 2-point path (cheap).
680
- if tool == .line, let start = startPoint {
681
- let startView = pdfView.convert(start, from: page)
682
- let line = CGMutablePath()
683
- line.move(to: startView)
684
- line.addLine(to: viewPoint)
685
- layer.path = line
686
- previewMutablePath = nil
687
- return
688
- }
689
-
690
- // Pen/highlighter: append to a single mutable path (no path copying).
691
- if previewMutablePath == nil {
692
- previewMutablePath = CGMutablePath()
693
- previewMutablePath?.move(to: viewPoint)
694
- } else {
695
- previewMutablePath?.addLine(to: viewPoint)
696
- }
697
- layer.path = previewMutablePath
698
- }
699
-
700
- override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
701
- defer {
702
- currentPath = nil
703
- activePage = nil
704
- startPoint = nil
705
- previewLayer?.removeFromSuperlayer()
706
- previewLayer = nil
707
- previewMutablePath = nil
708
- }
709
-
710
- guard let tool = currentTool,
711
- let page = activePage,
712
- let path = currentPath else {
713
- super.touchesEnded(touches, with: event)
714
- return
715
- }
716
-
717
- // Stroke‑ийг эцсийн байдлаар PDFAnnotation болгон commit хийнэ.
718
- // PDFAnnotation дотор path‑ын координат нь annotation.bounds.origin‑оос эхлэх ёстой тул
719
- // эхний path‑аа bounds‑ынх нь origin‑оор translate хийж өгнө.
720
- let pathBounds = path.bounds.insetBy(dx: -strokeWidth, dy: -strokeWidth)
721
- let ann = PDFAnnotation(bounds: pathBounds, forType: .ink, withProperties: nil)
722
- ann.color = (tool == .highlighter) ? strokeColor.withAlphaComponent(0.4) : strokeColor
723
- ann.border = PDFBorder()
724
- ann.border?.lineWidth = strokeWidth
725
- ann.border?.style = .solid
726
-
727
- let annotationPath = UIBezierPath(cgPath: path.cgPath)
728
- let translate = CGAffineTransform(translationX: -pathBounds.origin.x,
729
- y: -pathBounds.origin.y)
730
- annotationPath.apply(translate)
731
- ann.add(annotationPath)
732
-
733
- page.addAnnotation(ann)
734
- let pageIndex = pdfView.document?.index(for: page) ?? 0
735
- undoStack.append(UndoEntry(annotation: ann, pageIndex: pageIndex))
736
- redoStack.removeAll()
737
- requestDisplayUpdate()
738
- notifyAnnotationChange()
739
- notifyUndoRedoStateChange()
740
- }
741
-
742
- override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
743
- if currentTool == nil {
744
- super.touchesCancelled(touches, with: event)
745
- }
746
- currentPath = nil
747
- activePage = nil
748
- startPoint = nil
749
- previewLayer?.removeFromSuperlayer()
750
- previewLayer = nil
751
- }
752
-
753
- private func eraseAt(point: CGPoint, on page: PDFPage) {
754
- var erased = false
755
- let annotationsToRemove = page.annotations.filter { ann in
756
- ann.bounds.contains(point)
757
- }
758
-
759
- for ann in annotationsToRemove {
760
- page.removeAnnotation(ann)
761
- if let index = undoStack.firstIndex(where: { $0.annotation === ann }) {
762
- undoStack.remove(at: index)
763
- }
764
- erased = true
765
- }
766
-
767
- if erased {
768
- requestDisplayUpdate()
769
- notifyAnnotationChange()
770
- notifyUndoRedoStateChange()
771
- }
772
- }
773
-
774
- private func addInstantAnnotation(at pt: CGPoint, on page: PDFPage, tool: AnnotationTool) {
775
- let isNote = (tool == .note)
776
- let rect = isNote
777
- ? CGRect(x: pt.x, y: pt.y, width: 32, height: 32) // sticky note icon
778
- : CGRect(x: pt.x, y: pt.y, width: 180, height: 40) // text box
779
-
780
- let type: PDFAnnotationSubtype = isNote ? .text : .freeText
781
- let ann = PDFAnnotation(bounds: rect, forType: type, withProperties: nil)
782
-
783
- // Text/Memo контент
784
- ann.contents = textContent.isEmpty ? " " : textContent
785
-
786
- if isNote {
787
- // Sticky note: background өнгийг noteColor‑оор удирдана
788
- ann.color = noteColor
789
- } else {
790
- // Free text: тунгалаг background + textColor‑той текст
791
- ann.color = .clear
792
-
793
- let baseFont = UIFont.systemFont(ofSize: textFontSize)
794
- var traits: UIFontDescriptor.SymbolicTraits = []
795
- if textBold { traits.insert(.traitBold) }
796
- if textItalic { traits.insert(.traitItalic) }
797
-
798
- if let descriptor = baseFont.fontDescriptor.withSymbolicTraits(traits) {
799
- ann.font = UIFont(descriptor: descriptor, size: textFontSize)
800
- } else {
801
- ann.font = baseFont
802
- }
803
-
804
- ann.fontColor = textColor
805
- }
806
- page.addAnnotation(ann)
807
- let pageIndex = pdfView.document?.index(for: page) ?? 0
808
- undoStack.append(UndoEntry(annotation: ann, pageIndex: pageIndex))
809
- redoStack.removeAll()
810
- requestDisplayUpdate()
811
- notifyAnnotationChange()
812
- notifyUndoRedoStateChange()
813
- }
814
-
815
- func setAnnotations(_ annotationsArray: [[String: Any]]) {
816
- // Хэрвээ document load хийгдээгүй бол pending-д хадгална
817
- guard let document = pdfView.document else {
818
- print("⚠️ setAnnotations: document is nil, storing as pending")
819
- pendingAnnotations = annotationsArray
820
- return
821
- }
822
-
823
- // Prevent re-applying the exact same initialAnnotations on every prop update.
824
- let fp = fingerprintAnnotations(annotationsArray)
825
- if let prev = appliedAnnotationsFingerprint, prev == fp {
826
- return
827
- }
828
- appliedAnnotationsFingerprint = fp
829
-
830
- for data in annotationsArray {
831
- guard let pageIndex = data["page"] as? Int, let page = document.page(at: pageIndex),
832
- let typeStr = data["type"] as? String, let b = data["bounds"] as? [String: Any] else {
833
- print("⚠️ setAnnotations: skipping invalid annotation data")
834
- continue
835
- }
836
-
837
- let bounds = CGRect(x: b["x"] as? Double ?? 0,
838
- y: b["y"] as? Double ?? 0,
839
- width: b["width"] as? Double ?? 0,
840
- height: b["height"] as? Double ?? 0)
841
- // note → .text (sticky note), text → .freeText, бусад нь .ink
842
- let type: PDFAnnotationSubtype = (typeStr == "note") ? .text : (typeStr == "text" ? .freeText : .ink)
843
- let ann = PDFAnnotation(bounds: bounds, forType: type, withProperties: nil)
844
-
845
- // Color-ийг load хийх
846
- if let hex = data["color"] as? String {
847
- let baseColor = UIColor(hex: hex)
848
- if typeStr == "highlighter" {
849
- // Highlighter-ийн хувьд alpha-г 0.4 болгох
850
- ann.color = baseColor.withAlphaComponent(0.4)
851
- } else if typeStr == "text" {
852
- // Text annotation → фон тунгалаг, fontColor нь дамжуулсан өнгө
853
- ann.color = .clear
854
- ann.fontColor = baseColor
855
- } else {
856
- // pen / line / note
857
- ann.color = baseColor
858
- }
859
- }
860
-
861
- // Text annotation-ийн font size, bold/italic-ийг restore хийх
862
- if typeStr == "text" {
863
- let size = (data["fontSize"] as? Double).map { CGFloat($0) } ?? textFontSize
864
- let isBold = data["bold"] as? Bool ?? false
865
- let isItalic = data["italic"] as? Bool ?? false
866
-
867
- let baseFont = UIFont.systemFont(ofSize: size)
868
- var traits: UIFontDescriptor.SymbolicTraits = []
869
- if isBold { traits.insert(.traitBold) }
870
- if isItalic { traits.insert(.traitItalic) }
871
-
872
- if let descriptor = baseFont.fontDescriptor.withSymbolicTraits(traits) {
873
- ann.font = UIFont(descriptor: descriptor, size: size)
874
- } else {
875
- ann.font = baseFont
876
- }
877
- }
878
- if let contents = data["contents"] as? String { ann.contents = contents }
879
-
880
- // Path-ууд нь absolute coordinate-аар хадгалагдсан байдаг
881
- // PDFAnnotation дээр path-ууд нь annotation bounds-ын origin-оос харьцангуй координатаар байх ёстой.
882
- // (memo/text-д path байхгүй – зөвхөн ink төрлүүдэд хэрэглэнэ)
883
- if type == .ink, let paths = data["paths"] as? [[[String: Any]]] {
884
- for pathData in paths {
885
- let path = UIBezierPath()
886
- var lastRelPoint: CGPoint?
887
- for (i, pt) in pathData.enumerated() {
888
- // Absolute coordinate-аас харьцангуй координат руу хөрвүүлэх
889
- let absX = pt["x"] as? Double ?? 0
890
- let absY = pt["y"] as? Double ?? 0
891
- let relX = absX - bounds.origin.x
892
- let relY = absY - bounds.origin.y
893
- let p = CGPoint(x: relX, y: relY)
894
-
895
- // Ойрхон цэгүүдийг алгасах (маш урт замыг сийрэгжүүлнэ)
896
- if let last = lastRelPoint {
897
- let dx = p.x - last.x
898
- let dy = p.y - last.y
899
- // 4pt-аас бага зайтай цэгийг алгасна (performance-д тусална)
900
- let minDist: CGFloat = 4.0
901
- if (dx * dx + dy * dy) < (minDist * minDist) { continue }
902
- }
903
-
904
- if lastRelPoint == nil {
905
- path.move(to: p)
906
- } else {
907
- path.addLine(to: p)
908
- }
909
- lastRelPoint = p
910
- }
911
- // Цэгүүд бүгд алгасагдаагүй бол л annotation-д нэмнэ
912
- if !path.isEmpty {
913
- ann.add(path)
914
- }
915
- }
916
- }
917
-
918
- // Stroke width – зөвхөн ink, freeText-д утга учиртай
919
- if let strokeWidth = data["strokeWidth"] as? Double, type != .text {
920
- ann.border = PDFBorder()
921
- ann.border?.lineWidth = CGFloat(strokeWidth)
922
- }
923
-
924
- page.addAnnotation(ann)
925
-
926
- }
927
-
928
- requestDisplayUpdate()
929
- }
930
-
931
- private func findScrollView(in view: UIView) -> UIScrollView? {
932
- if let sv = view as? UIScrollView { return sv }
933
- for sub in view.subviews { if let sv = findScrollView(in: sub) { return sv } }
934
- return nil
935
- }
936
-
937
- /// Бүх annotation-уудыг serialize хийж event dispatch хийх
938
- private func notifyAnnotationChange() {
939
- guard let document = pdfView.document else {
940
- print("⚠️ notifyAnnotationChange: document is nil")
941
- return
942
- }
943
-
944
- #if DEBUG
945
- print("📝 notifyAnnotationChange: starting...")
946
- #endif
947
-
948
- var allAnnotations: [[String: Any]] = []
949
-
950
- for pageIndex in 0..<document.pageCount {
951
- guard let page = document.page(at: pageIndex) else { continue }
952
-
953
- for ann in page.annotations {
954
- var annotationData: [String: Any] = [
955
- "page": pageIndex,
956
- "bounds": [
957
- "x": ann.bounds.origin.x,
958
- "y": ann.bounds.origin.y,
959
- "width": ann.bounds.width,
960
- "height": ann.bounds.height
961
- ]
962
- ]
963
-
964
- // Annotation type-ийг тодорхойлох
965
- if ann.type == "Ink" {
966
- // Pen, highlighter, line-ийг ялгах
967
- let alpha = ann.color.cgColor.alpha
968
- if alpha < 0.5 {
969
- annotationData["type"] = "highlighter"
970
- } else {
971
- // Line vs pen-ийг ялгах нь хэцүү, одоогоор pen гэж үзье
972
- // (хэрвээ path нь 2 цэгтэй бол line байж болно)
973
- annotationData["type"] = "pen"
974
- }
975
- } else if ann.type == "Stamp" || ann.type == "Text" {
976
- annotationData["type"] = "note"
977
- } else if ann.type == "FreeText" {
978
- annotationData["type"] = "text"
979
- } else {
980
- annotationData["type"] = "pen" // default
981
- }
982
-
983
- // Color
984
- if ann.type == "FreeText" {
985
- // Text annotation → fontColor-ийг хадгална (background .clear байж болно)
986
- let color = ann.fontColor ?? ann.color
987
- annotationData["color"] = colorToHex(color)
988
-
989
- // FreeText-ийн font size, bold/italic байдлыг хадгална
990
- if let font = ann.font {
991
- annotationData["fontSize"] = Double(font.pointSize)
992
- let traits = font.fontDescriptor.symbolicTraits
993
- annotationData["bold"] = traits.contains(.traitBold)
994
- annotationData["italic"] = traits.contains(.traitItalic)
995
- }
996
- } else {
997
- annotationData["color"] = colorToHex(ann.color)
998
- }
999
-
1000
- // Contents (text/note-д)
1001
- if let contents = ann.contents {
1002
- annotationData["contents"] = contents
1003
- }
1004
-
1005
- // Paths (ink annotation-уудад)
1006
- // PDFAnnotation дээр path-ууд нь annotation bounds-ын origin-оос харьцангуй координатаар байдаг
1007
- if ann.type == "Ink", let paths = ann.paths {
1008
- var pathArray: [[[String: Any]]] = []
1009
- for path in paths {
1010
- var points: [[String: Any]] = []
1011
- path.cgPath.applyWithBlock { elementPointer in
1012
- let element = elementPointer.pointee
1013
- let pts = element.points
1014
- switch element.type {
1015
- case .moveToPoint, .addLineToPoint:
1016
- // Path-ууд нь annotation bounds-ын origin-оос харьцангуй координатаар байдаг
1017
- // Absolute coordinate-аар хадгалахын тулд bounds.origin-ийг нэмнэ
1018
- let x = ann.bounds.origin.x + pts[0].x
1019
- let y = ann.bounds.origin.y + pts[0].y
1020
- points.append(["x": x, "y": y])
1021
- default:
1022
- break
1023
- }
1024
- }
1025
- if !points.isEmpty {
1026
- pathArray.append(points)
1027
- }
1028
- }
1029
- if !pathArray.isEmpty {
1030
- annotationData["paths"] = pathArray
1031
- }
1032
- }
1033
-
1034
- // Stroke width
1035
- if let border = ann.border {
1036
- annotationData["strokeWidth"] = border.lineWidth
1037
- }
1038
-
1039
- allAnnotations.append(annotationData)
1040
- }
1041
- }
1042
-
1043
- #if DEBUG
1044
- print("📝 notifyAnnotationChange: dispatching \(allAnnotations.count) annotations")
1045
- #endif
1046
- onAnnotationChange(["annotations": allAnnotations])
1047
- #if DEBUG
1048
- print("✅ notifyAnnotationChange: event dispatched")
1049
- #endif
1050
- }
1051
-
1052
- /// UIColor-ийг hex string руу хөрвүүлэх
1053
- private func colorToHex(_ color: UIColor) -> String {
1054
- var r: CGFloat = 0
1055
- var g: CGFloat = 0
1056
- var b: CGFloat = 0
1057
- var a: CGFloat = 0
1058
- color.getRed(&r, green: &g, blue: &b, alpha: &a)
1059
-
1060
- let rgb: Int = (Int)(r * 255) << 16 | (Int)(g * 255) << 8 | (Int)(b * 255) << 0
1061
- return String(format: "#%06x", rgb)
1062
- }
1063
- }
1064
-
1065
- extension UIColor {
1066
- convenience init(hex: String) {
1067
- var c = hex.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
1068
- if c.hasPrefix("#") { c.remove(at: c.startIndex) }
1069
- var rgb: UInt64 = 0
1070
- Scanner(string: c).scanHexInt64(&rgb)
1071
- self.init(red: CGFloat((rgb & 0xFF0000) >> 16)/255.0, green: CGFloat((rgb & 0x00FF00) >> 8)/255.0, blue: CGFloat(rgb & 0x0000FF)/255.0, alpha: 1.0)
1072
- }
1
+ import ExpoModulesCore
2
+ import PDFKit
3
+ import UIKit
4
+
5
+ enum AnnotationTool: String {
6
+ case pen, highlighter, text, note, eraser, line
7
+ }
8
+
9
+ final class ExpoPdfReaderView: ExpoView {
10
+ private let pdfView = PDFView()
11
+ private var currentTool: AnnotationTool? = nil
12
+ private var strokeColor: UIColor = .red
13
+ private var strokeWidth: CGFloat = 2.0
14
+ private var textContent: String = ""
15
+ private var textColor: UIColor = .black
16
+ private var textFontSize: CGFloat = 14.0
17
+ private var textBold: Bool = false
18
+ private var textItalic: Bool = false
19
+ private var noteColor: UIColor = .yellow
20
+ // Custom memo icon name (PNG in iOS bundle). If nil, default PDFKit icon is used.
21
+ private var memoIconName: String? = nil
22
+
23
+ let onReady = EventDispatcher()
24
+ let onPageChange = EventDispatcher()
25
+ let onAnnotationChange = EventDispatcher()
26
+ let onLoadingAnnotation = EventDispatcher()
27
+ let onUndoRedoStateChange = EventDispatcher()
28
+ let onNotePress = EventDispatcher()
29
+ let onTextPress = EventDispatcher()
30
+ private struct UndoEntry {
31
+ let annotation: PDFAnnotation
32
+ let pageIndex: Int
33
+ }
34
+ private var undoStack: [UndoEntry] = []
35
+ private var redoStack: [UndoEntry] = []
36
+
37
+ // Зурах үед шууд PDFAnnotation үүсгээд байх нь удаан тул
38
+ // эхлээд CAShapeLayer дээр preview хийгээд, төгсгөөд нэг annotation болгож commit хийнэ.
39
+ private var currentPath: UIBezierPath?
40
+ private var activePage: PDFPage?
41
+ private var startPoint: CGPoint?
42
+ private var previewLayer: CAShapeLayer?
43
+ private var lastPreviewUpdateTime: CFTimeInterval = 0
44
+ private let previewUpdateInterval: CFTimeInterval = 0.016 // ~60fps throttling
45
+ private var previewMutablePath: CGMutablePath?
46
+ private var pendingDisplayUpdate: Bool = false
47
+ private var appliedAnnotationsFingerprint: String? = nil
48
+ // Always keep track of last known page index (for restoring after displayMode changes)
49
+ private var lastPageIndex: Int = 0
50
+ private var annotationTapRecognizer: UITapGestureRecognizer?
51
+ private var annotationLongPressRecognizer: UILongPressGestureRecognizer?
52
+ private var draggingAnnotation: PDFAnnotation?
53
+ private var draggingPage: PDFPage?
54
+ private var dragStartPagePoint: CGPoint?
55
+ private var dragStartBounds: CGRect?
56
+ // Zoom range: minZoom=1.0 (fit width), maxZoom=5.0 → pinch-to-zoom дэмжинэ
57
+ private var minZoom: CGFloat = 1.0
58
+ private var maxZoom: CGFloat = 5.0
59
+
60
+ required init(appContext: AppContext? = nil) {
61
+ super.init(appContext: appContext)
62
+ setupView()
63
+ setupNotifications()
64
+ setupAppLifecycle()
65
+ }
66
+
67
+ private func setupView() {
68
+ // We'll manage scaling manually to always fit width
69
+ pdfView.autoScales = false
70
+ pdfView.displayMode = .singlePageContinuous
71
+ pdfView.displayDirection = .vertical
72
+ pdfView.backgroundColor = .white
73
+ pdfView.usePageViewController(true)
74
+ addSubview(pdfView)
75
+
76
+ // Single tap дээр text / note annotation‑ийг барьж аваад JS‑д event илгээх gesture
77
+ let tap = UITapGestureRecognizer(target: self, action: #selector(handleAnnotationTap(_:)))
78
+ tap.numberOfTapsRequired = 1
79
+ tap.cancelsTouchesInView = true // annotation дээр дарвал PDFKit‑ийн өөрийн popup/BottomSheet‑ийг блоклоно
80
+ pdfView.addGestureRecognizer(tap)
81
+ annotationTapRecognizer = tap
82
+
83
+ // Long press + drag дээр annotation‑ийн байрлалыг солих gesture
84
+ let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleAnnotationLongPress(_:)))
85
+ longPress.minimumPressDuration = 0.3
86
+ longPress.cancelsTouchesInView = true
87
+ pdfView.addGestureRecognizer(longPress)
88
+ annotationLongPressRecognizer = longPress
89
+ }
90
+
91
+ private func setupNotifications() {
92
+ NotificationCenter.default.addObserver(self, selector: #selector(handlePageChange), name: .PDFViewPageChanged, object: pdfView)
93
+ }
94
+
95
+ private func setupAppLifecycle() {
96
+ NotificationCenter.default.addObserver(
97
+ self,
98
+ selector: #selector(handleAppWillEnterForeground),
99
+ name: UIApplication.willEnterForegroundNotification,
100
+ object: nil
101
+ )
102
+ }
103
+ @objc private func handleAppWillEnterForeground() {
104
+ // App foreground руу буцаж ирхэд PDF view-ийг дахин render хийх
105
+ DispatchQueue.main.async { [weak self] in
106
+ guard let self = self else { return }
107
+ // Зөвхөн display-ийг шинэчлэх, document-ийг дахин set хийхгүй (memory leak үүсгэж болно)
108
+ self.requestDisplayUpdate()
109
+ }
110
+ }
111
+
112
+ @objc private func handlePageChange() {
113
+ guard let currentPage = pdfView.currentPage, let document = pdfView.document else { return }
114
+ let pageIndex = document.index(for: currentPage)
115
+ lastPageIndex = pageIndex
116
+ onPageChange(["currentPage": pageIndex, "totalPage": document.pageCount])
117
+ }
118
+
119
+ private func requestDisplayUpdate() {
120
+ // Coalesce multiple redraw requests into one runloop tick.
121
+ guard !pendingDisplayUpdate else { return }
122
+ pendingDisplayUpdate = true
123
+ DispatchQueue.main.async { [weak self] in
124
+ guard let self = self else { return }
125
+ self.pendingDisplayUpdate = false
126
+ self.pdfView.setNeedsDisplay()
127
+ }
128
+ }
129
+
130
+ // MARK: - Scaling Helpers
131
+
132
+ /// Scale the current document so that PDF width fits the view width (no horizontal scroll)
133
+ private func scaleToFitWidth() {
134
+ guard let document = pdfView.document,
135
+ let firstPage = document.page(at: 0) else { return }
136
+
137
+ let pageBounds = firstPage.bounds(for: .mediaBox)
138
+ var viewWidth = pdfView.bounds.width
139
+
140
+ // In two-page modes each page takes half of the width
141
+ if pdfView.displayMode == .twoUp || pdfView.displayMode == .twoUpContinuous {
142
+ viewWidth /= 2.0
143
+ }
144
+
145
+ guard viewWidth > 0, pageBounds.width > 0 else { return }
146
+
147
+ let fitScale = viewWidth / pageBounds.width
148
+ // min/max өөр байх ёстой — pinch-to-zoom ажиллана (Android-тай ижил)
149
+ pdfView.minScaleFactor = fitScale * minZoom
150
+ pdfView.maxScaleFactor = fitScale * maxZoom
151
+ // Анхны scale = fit width. PDFView default scaleFactor=1.0 байдаг тул үргэлж fitScale тохируулна
152
+ pdfView.scaleFactor = fitScale
153
+
154
+ // Also make sure the underlying scroll view never scrolls horizontally
155
+ if let scrollView = findScrollView(in: pdfView) {
156
+ scrollView.alwaysBounceHorizontal = false
157
+ scrollView.showsHorizontalScrollIndicator = false
158
+ // Content is exactly fit‑width so any horizontal offset is pointless
159
+ scrollView.contentOffset.x = 0
160
+ }
161
+ }
162
+
163
+ override func layoutSubviews() {
164
+ super.layoutSubviews()
165
+ pdfView.frame = bounds
166
+
167
+ // Only auto-scale when there is a document.
168
+ // Do NOT constantly override user zoom – only when scaleFactor is "uninitialized".
169
+ if pdfView.document != nil, pdfView.scaleFactor == 0 {
170
+ scaleToFitWidth()
171
+ }
172
+ }
173
+
174
+ /// Custom hitTest:
175
+ /// - Tool байхгүй үед: PDFView өөрийн default scroll/zoom зан төлөвөө хадгална.
176
+ /// - Tool идэвхтэй үед: Зөвхөн PDF‑ийн контент хэсэг дээрхи touch‑ийг энэ view рүү (self) шиднэ.
177
+ /// Ингэснээр гадна талын React Native toolbar, button гэх мэт touch‑ууд блоклохгүй.
178
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
179
+ // Default‑оор аль view онох байсан бэ?
180
+ let defaultHitView = super.hitTest(point, with: event)
181
+
182
+ // Tool сонгогдоогүй → бүхнийг default‑оор нь үлдээнэ
183
+ guard currentTool != nil else {
184
+ return defaultHitView
185
+ }
186
+
187
+ // Хэрвээ энэ touch нь pdfView эсвэл түүний дэд view дээр таарсан бол
188
+ // drawing‑ийн логик ажиллуулахын тулд self буцаана.
189
+ if let v = defaultHitView, (v == pdfView || v.isDescendant(of: pdfView)) {
190
+ return self
191
+ }
192
+
193
+ // Харин түүнээс өөр (toolbar г.м.) бол default view‑г буцааж, гадна талын control‑уудыг хэвийн ажиллуулна.
194
+ return defaultHitView
195
+ }
196
+
197
+ private var pendingAnnotations: [[String: Any]]? = nil
198
+
199
+ func load(url: URL) {
200
+ if pdfView.document?.documentURL == url { return }
201
+ if let document = PDFDocument(url: url) {
202
+ pdfView.document = document
203
+ // New document → start from first page logically
204
+ lastPageIndex = 0
205
+ undoStack.removeAll()
206
+ redoStack.removeAll()
207
+ notifyUndoRedoStateChange()
208
+ appliedAnnotationsFingerprint = nil
209
+ // Defer scaling until the view has correct bounds
210
+ DispatchQueue.main.async {
211
+ self.scaleToFitWidth()
212
+ // Document load хийгдсэний дараа pending annotation-уудыг load хийх
213
+ if let pending = self.pendingAnnotations {
214
+ self.pendingAnnotations = nil
215
+ self.setAnnotations(pending)
216
+ }
217
+ // PDF бэлэн болсныг JS-д мэдэгдэнэ
218
+ let totalPages = document.pageCount
219
+ let viewW = Double(self.bounds.width)
220
+ let viewH = Double(self.bounds.height)
221
+ self.onReady([
222
+ "totalPages": totalPages,
223
+ "width": viewW,
224
+ "height": viewH
225
+ ])
226
+ }
227
+ }
228
+ }
229
+
230
+ private func fingerprintAnnotations(_ annotationsArray: [[String: Any]]) -> String {
231
+ // Stable-ish fingerprint to prevent re-applying same "initialAnnotations" on every prop update.
232
+ // We only need to detect "no change" from JS; not cryptographic.
233
+ let items: [String] = annotationsArray.compactMap { data in
234
+ guard
235
+ let pageIndex = data["page"] as? Int,
236
+ let typeStr = data["type"] as? String,
237
+ let b = data["bounds"] as? [String: Any]
238
+ else { return nil }
239
+
240
+ let x = b["x"] as? Double ?? 0
241
+ let y = b["y"] as? Double ?? 0
242
+ let w = b["width"] as? Double ?? 0
243
+ let h = b["height"] as? Double ?? 0
244
+ let color = data["color"] as? String ?? ""
245
+ let strokeWidth = data["strokeWidth"] as? Double ?? 0
246
+ let contents = data["contents"] as? String ?? ""
247
+
248
+ var pointsCount = 0
249
+ if let paths = data["paths"] as? [[[String: Any]]] {
250
+ for p in paths { pointsCount += p.count }
251
+ }
252
+
253
+ return "\(pageIndex)|\(typeStr)|\(x),\(y),\(w),\(h)|\(color)|\(strokeWidth)|\(contents)|pts:\(pointsCount)"
254
+ }
255
+
256
+ return items.sorted().joined(separator: "||")
257
+ }
258
+
259
+ func setDisplayMode(_ mode: String) {
260
+ // Always use our own tracked index (lastPageIndex),
261
+ // which is updated via PDFViewPageChanged + setInitialPage.
262
+ let currentIndex = lastPageIndex
263
+
264
+ switch mode {
265
+ case "single":
266
+ pdfView.displayMode = .singlePage
267
+ pdfView.usePageViewController(true)
268
+ case "continuous":
269
+ pdfView.displayMode = .singlePageContinuous
270
+ pdfView.usePageViewController(false)
271
+ case "twoUp":
272
+ pdfView.displayMode = .twoUp
273
+ pdfView.usePageViewController(true)
274
+ case "twoUpContinuous":
275
+ pdfView.displayMode = .twoUpContinuous
276
+ pdfView.usePageViewController(false)
277
+ default:
278
+ pdfView.displayMode = .singlePageContinuous
279
+ }
280
+
281
+ // Recompute scaling and restore page when layout for this mode is ready
282
+ DispatchQueue.main.async {
283
+ if let doc = self.pdfView.document,
284
+ currentIndex >= 0, currentIndex < doc.pageCount,
285
+ let page = doc.page(at: currentIndex) {
286
+ self.pdfView.go(to: page)
287
+ }
288
+ self.scaleToFitWidth()
289
+ }
290
+ }
291
+
292
+ func setInitialPage(_ index: Int) {
293
+ guard let doc = pdfView.document, index < doc.pageCount, let page = doc.page(at: index) else { return }
294
+ pdfView.go(to: page)
295
+ lastPageIndex = index
296
+ }
297
+
298
+ func setMinZoom(_ v: Double) {
299
+ minZoom = CGFloat(v)
300
+ // scaleToFitWidth дахин дуудагдаагүй бол одоогийн fitScale-аар тооцоо
301
+ if pdfView.document != nil {
302
+ scaleToFitWidth()
303
+ }
304
+ }
305
+ func setMaxZoom(_ v: Double) {
306
+ maxZoom = CGFloat(v)
307
+ if pdfView.document != nil {
308
+ scaleToFitWidth()
309
+ }
310
+ }
311
+
312
+ func setTool(_ tool: String?) {
313
+ currentTool = tool != nil ? AnnotationTool(rawValue: tool!) : nil
314
+ let interactionsEnabled = (currentTool == nil)
315
+
316
+ // 1) ScrollView‑ийн scroll‑ийг ON/OFF
317
+ if let scrollView = findScrollView(in: pdfView) {
318
+ scrollView.isScrollEnabled = interactionsEnabled
319
+ }
320
+
321
+ // 2) PDFView болон түүний бүх дэд view‑үүдийн pan / swipe gesture‑үүдийг ON/OFF
322
+ func updateGestures(in view: UIView) {
323
+ if let gestures = view.gestureRecognizers {
324
+ for gesture in gestures {
325
+ if gesture is UIPanGestureRecognizer || gesture is UISwipeGestureRecognizer {
326
+ gesture.isEnabled = interactionsEnabled
327
+ }
328
+ }
329
+ }
330
+ for sub in view.subviews {
331
+ updateGestures(in: sub)
332
+ }
333
+ }
334
+ updateGestures(in: pdfView)
335
+
336
+ // Annotation tap gesture зөвхөн tool сонгогдоогүй үед идэвхтэй байг
337
+ annotationTapRecognizer?.isEnabled = interactionsEnabled
338
+ annotationLongPressRecognizer?.isEnabled = interactionsEnabled
339
+ }
340
+
341
+ func setStrokeColor(_ hex: String) { self.strokeColor = UIColor(hex: hex) }
342
+ func setStrokeWidth(_ width: Double) { self.strokeWidth = CGFloat(width) }
343
+ func setTextContent(_ text: String) { self.textContent = text }
344
+ func setTextColor(_ hex: String) { self.textColor = UIColor(hex: hex) }
345
+ func setTextFontSize(_ size: Double) { self.textFontSize = CGFloat(size) }
346
+ func setTextBold(_ value: Bool) { self.textBold = value }
347
+ func setTextItalic(_ value: Bool) { self.textItalic = value }
348
+ func setNoteColor(_ hex: String) { self.noteColor = UIColor(hex: hex) }
349
+ func setMemoIconName(_ name: String?) { self.memoIconName = name }
350
+
351
+ func undo() {
352
+ guard let last = undoStack.popLast() else { return }
353
+ redoStack.append(last)
354
+ if let doc = pdfView.document, let page = doc.page(at: last.pageIndex) {
355
+ page.removeAnnotation(last.annotation)
356
+ } else {
357
+ last.annotation.page?.removeAnnotation(last.annotation)
358
+ }
359
+ requestDisplayUpdate()
360
+ notifyAnnotationChange()
361
+ notifyUndoRedoStateChange()
362
+ }
363
+
364
+ func redo() {
365
+ guard let last = redoStack.popLast() else { return }
366
+ undoStack.append(last)
367
+ if let doc = pdfView.document, let page = doc.page(at: last.pageIndex) {
368
+ page.addAnnotation(last.annotation)
369
+ }
370
+ requestDisplayUpdate()
371
+ notifyAnnotationChange()
372
+ notifyUndoRedoStateChange()
373
+ }
374
+
375
+ private func notifyUndoRedoStateChange() {
376
+ onUndoRedoStateChange([
377
+ "canUndo": !undoStack.isEmpty,
378
+ "canRedo": !redoStack.isEmpty
379
+ ])
380
+ }
381
+
382
+ private func topViewController(from root: UIViewController?) -> UIViewController? {
383
+ if let nav = root as? UINavigationController {
384
+ return topViewController(from: nav.visibleViewController)
385
+ }
386
+ if let tab = root as? UITabBarController {
387
+ return topViewController(from: tab.selectedViewController)
388
+ }
389
+ if let presented = root?.presentedViewController {
390
+ return topViewController(from: presented)
391
+ }
392
+ return root
393
+ }
394
+
395
+ private func presentTextEdit(for annotation: PDFAnnotation, on page: PDFPage) {
396
+ let currentText = annotation.contents ?? ""
397
+
398
+ let alert = UIAlertController(title: "텍스트 편집", message: nil, preferredStyle: .alert)
399
+ alert.addTextField { tf in
400
+ tf.text = currentText
401
+ }
402
+ alert.addAction(UIAlertAction(title: "취소", style: .cancel, handler: nil))
403
+ alert.addAction(UIAlertAction(title: "저장", style: .default, handler: { [weak self] _ in
404
+ guard let self = self else { return }
405
+ let newText = alert.textFields?.first?.text ?? ""
406
+ annotation.contents = newText.isEmpty ? " " : newText
407
+ self.requestDisplayUpdate()
408
+ self.notifyAnnotationChange()
409
+ }))
410
+
411
+ if let root = UIApplication.shared.connectedScenes
412
+ .compactMap({ $0 as? UIWindowScene })
413
+ .first?.windows.first(where: { $0.isKeyWindow })?.rootViewController,
414
+ let top = topViewController(from: root) {
415
+ top.present(alert, animated: true, completion: nil)
416
+ }
417
+ }
418
+
419
+ // Single tap дээр annotation дарсан эсэхийг шалгаж, JS‑д event илгээнэ.
420
+ @objc private func handleAnnotationTap(_ gesture: UITapGestureRecognizer) {
421
+ guard gesture.state == .ended else { return }
422
+ // Зөвхөн tool сонгогдоогүй үед edit хийх боломжтой
423
+ guard currentTool == nil else { return }
424
+
425
+ let locationInPdf = gesture.location(in: pdfView)
426
+ guard let page = pdfView.page(for: locationInPdf, nearest: true) else { return }
427
+ let pagePoint = pdfView.convert(locationInPdf, to: page)
428
+
429
+ // Эхлээд FreeText (text tool) шалгана
430
+ if let (textIndex, textAnn) = page.annotations.enumerated().first(where: { $0.element.type == "FreeText" && $0.element.bounds.contains(pagePoint) }) {
431
+ let pageIndex = pdfView.document?.index(for: page) ?? 0
432
+ let b = textAnn.bounds
433
+ onTextPress([
434
+ "page": pageIndex,
435
+ "index": textIndex,
436
+ "bounds": [
437
+ "x": b.origin.x,
438
+ "y": b.origin.y,
439
+ "width": b.width,
440
+ "height": b.height
441
+ ],
442
+ "contents": textAnn.contents ?? ""
443
+ ])
444
+ return
445
+ }
446
+
447
+ // Дараа нь sticky note (Text subtype) шалгана
448
+ if let (noteIndex, noteAnn) = page.annotations.enumerated().first(where: { $0.element.type == "Text" && $0.element.bounds.contains(pagePoint) }) {
449
+ let pageIndex = pdfView.document?.index(for: page) ?? 0
450
+ let b = noteAnn.bounds
451
+ onNotePress([
452
+ "page": pageIndex,
453
+ "index": noteIndex,
454
+ "bounds": [
455
+ "x": b.origin.x,
456
+ "y": b.origin.y,
457
+ "width": b.width,
458
+ "height": b.height
459
+ ],
460
+ "contents": noteAnn.contents ?? ""
461
+ ])
462
+ return
463
+ }
464
+ }
465
+
466
+ // Long press + drag дээр text / note annotation‑ийн байрлалыг өөрчилнө.
467
+ @objc private func handleAnnotationLongPress(_ gesture: UILongPressGestureRecognizer) {
468
+ // Зөвхөн tool сонгогдоогүй үед л annotation зөөх боломжтой
469
+ guard currentTool == nil else { return }
470
+
471
+ let locationInPdf = gesture.location(in: pdfView)
472
+ guard let page = pdfView.page(for: locationInPdf, nearest: true) else { return }
473
+ let pagePoint = pdfView.convert(locationInPdf, to: page)
474
+
475
+ switch gesture.state {
476
+ case .began:
477
+ // Эхлээд FreeText (text tool) annotation хайна
478
+ if let (_, ann) = page.annotations.enumerated().first(where: { $0.element.type == "FreeText" && $0.element.bounds.contains(pagePoint) }) {
479
+ draggingAnnotation = ann
480
+ draggingPage = page
481
+ dragStartPagePoint = pagePoint
482
+ dragStartBounds = ann.bounds
483
+ return
484
+ }
485
+ // Дараа нь sticky note (Text subtype) annotation хайна
486
+ if let (_, ann) = page.annotations.enumerated().first(where: { $0.element.type == "Text" && $0.element.bounds.contains(pagePoint) }) {
487
+ draggingAnnotation = ann
488
+ draggingPage = page
489
+ dragStartPagePoint = pagePoint
490
+ dragStartBounds = ann.bounds
491
+ return
492
+ }
493
+ case .changed:
494
+ guard
495
+ let ann = draggingAnnotation,
496
+ let startPoint = dragStartPagePoint,
497
+ let startBounds = dragStartBounds,
498
+ let dragPage = draggingPage,
499
+ dragPage == page
500
+ else { return }
501
+
502
+ let dx = pagePoint.x - startPoint.x
503
+ let dy = pagePoint.y - startPoint.y
504
+ var newBounds = startBounds
505
+ newBounds.origin.x += dx
506
+ newBounds.origin.y += dy
507
+ ann.bounds = newBounds
508
+ requestDisplayUpdate()
509
+
510
+ case .ended, .cancelled, .failed:
511
+ if draggingAnnotation != nil {
512
+ notifyAnnotationChange()
513
+ notifyUndoRedoStateChange()
514
+ }
515
+ draggingAnnotation = nil
516
+ draggingPage = nil
517
+ dragStartPagePoint = nil
518
+ dragStartBounds = nil
519
+
520
+ default:
521
+ break
522
+ }
523
+ }
524
+
525
+ // Update text (FreeText) contents from JS (TextModal)
526
+ func updateText(pageIndex: Int, index: Int, contents: String) {
527
+ guard let doc = pdfView.document,
528
+ pageIndex >= 0, pageIndex < doc.pageCount,
529
+ let page = doc.page(at: pageIndex),
530
+ index >= 0, index < page.annotations.count else { return }
531
+
532
+ let ann = page.annotations[index]
533
+ guard ann.type == "FreeText" else { return }
534
+
535
+ let text = contents.isEmpty ? " " : contents
536
+ ann.contents = text
537
+
538
+ // Урт текст нэмэхэд box‑оосоо гарч харагдахгүй болохоос сэргийлж bounds‑ийг text‑ийн хэмжээтэй тааруулж томруулна.
539
+ if let font = ann.font {
540
+ // Өмнөх өргөнийг хадгалж, өндөр талдаа автоматаар томруулна
541
+ let maxWidth = max(ann.bounds.width, 180)
542
+ let constraintSize = CGSize(width: maxWidth, height: CGFloat.greatestFiniteMagnitude)
543
+ let rect = (text as NSString).boundingRect(
544
+ with: constraintSize,
545
+ options: [.usesLineFragmentOrigin, .usesFontLeading],
546
+ attributes: [.font: font],
547
+ context: nil
548
+ )
549
+ let padding: CGFloat = 8
550
+ var newBounds = ann.bounds
551
+ newBounds.size.width = max(maxWidth, ceil(rect.width) + padding * 2)
552
+ newBounds.size.height = ceil(rect.height) + padding * 2
553
+ ann.bounds = newBounds
554
+ }
555
+
556
+ requestDisplayUpdate()
557
+ notifyAnnotationChange()
558
+ notifyUndoRedoStateChange()
559
+ }
560
+
561
+ // Update memo/note contents from JS (MemoModal)
562
+ func updateNote(pageIndex: Int, index: Int, contents: String) {
563
+ guard let doc = pdfView.document,
564
+ pageIndex >= 0, pageIndex < doc.pageCount,
565
+ let page = doc.page(at: pageIndex),
566
+ index >= 0, index < page.annotations.count else { return }
567
+
568
+ let ann = page.annotations[index]
569
+ guard ann.type == "Text" else { return } // only sticky notes
570
+
571
+ ann.contents = contents.isEmpty ? " " : contents
572
+ requestDisplayUpdate()
573
+ notifyAnnotationChange()
574
+ notifyUndoRedoStateChange()
575
+ }
576
+
577
+ override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
578
+ guard let touch = touches.first else {
579
+ super.touchesBegan(touches, with: event)
580
+ return
581
+ }
582
+
583
+ let locationInPdf = touch.location(in: pdfView)
584
+ guard let page = pdfView.page(for: locationInPdf, nearest: true) else {
585
+ super.touchesBegan(touches, with: event)
586
+ return
587
+ }
588
+ let pagePoint = pdfView.convert(locationInPdf, to: page)
589
+
590
+ // Tool байхгүй үед: scroll/zoom‑ийг PDFView өөрөө хариуцах ёстой
591
+ if currentTool == nil {
592
+ super.touchesBegan(touches, with: event)
593
+ return
594
+ }
595
+
596
+ guard let tool = currentTool else {
597
+ super.touchesBegan(touches, with: event)
598
+ return
599
+ }
600
+
601
+ activePage = page
602
+ startPoint = pagePoint
603
+
604
+ if tool == .pen || tool == .highlighter || tool == .line {
605
+ // Preview path
606
+ let path = UIBezierPath()
607
+ path.lineWidth = strokeWidth
608
+ path.move(to: pagePoint)
609
+ currentPath = path
610
+
611
+ // CAShapeLayer дээр realtime preview
612
+ previewLayer?.removeFromSuperlayer()
613
+ previewMutablePath = CGMutablePath()
614
+
615
+ let layer = CAShapeLayer()
616
+ let viewPoint = pdfView.convert(pagePoint, from: page)
617
+ previewMutablePath?.move(to: viewPoint)
618
+ layer.path = previewMutablePath
619
+
620
+ layer.strokeColor = (tool == .highlighter
621
+ ? strokeColor.withAlphaComponent(0.4).cgColor
622
+ : strokeColor.cgColor)
623
+ layer.fillColor = UIColor.clear.cgColor
624
+ layer.lineWidth = strokeWidth
625
+ layer.frame = pdfView.bounds
626
+ layer.contentsScale = UIScreen.main.scale
627
+
628
+ previewLayer = layer
629
+ pdfView.layer.addSublayer(layer)
630
+ lastPreviewUpdateTime = CACurrentMediaTime()
631
+ } else if tool == .text || tool == .note {
632
+ addInstantAnnotation(at: pagePoint, on: page, tool: tool)
633
+ } else if tool == .eraser {
634
+ eraseAt(point: pagePoint, on: page)
635
+ }
636
+ }
637
+
638
+ override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
639
+ guard let tool = currentTool,
640
+ let touch = touches.first,
641
+ let page = activePage else {
642
+ super.touchesMoved(touches, with: event)
643
+ return
644
+ }
645
+
646
+ let pointInPdf = touch.location(in: pdfView)
647
+ let pagePoint = pdfView.convert(pointInPdf, to: page)
648
+
649
+ if tool == .eraser {
650
+ eraseAt(point: pagePoint, on: page)
651
+ return
652
+ }
653
+
654
+ guard let path = currentPath else {
655
+ super.touchesMoved(touches, with: event)
656
+ return
657
+ }
658
+
659
+ if tool == .pen || tool == .highlighter {
660
+ path.addLine(to: pagePoint)
661
+ } else if tool == .line, let start = startPoint {
662
+ let linePath = UIBezierPath()
663
+ linePath.lineWidth = strokeWidth
664
+ linePath.move(to: start)
665
+ linePath.addLine(to: pagePoint)
666
+ currentPath = linePath
667
+ }
668
+
669
+ // Preview layer‑ийн замыг шинэчилнэ (throttling хийж, performance сайжруулах)
670
+ let currentTime = CACurrentMediaTime()
671
+ guard currentTime - lastPreviewUpdateTime >= previewUpdateInterval else { return }
672
+ lastPreviewUpdateTime = currentTime
673
+
674
+ guard let layer = previewLayer else { return }
675
+
676
+ let lastPoint = path.cgPath.currentPoint
677
+ let viewPoint = pdfView.convert(lastPoint, from: page)
678
+
679
+ // Line tool: rebuild just a 2-point path (cheap).
680
+ if tool == .line, let start = startPoint {
681
+ let startView = pdfView.convert(start, from: page)
682
+ let line = CGMutablePath()
683
+ line.move(to: startView)
684
+ line.addLine(to: viewPoint)
685
+ layer.path = line
686
+ previewMutablePath = nil
687
+ return
688
+ }
689
+
690
+ // Pen/highlighter: append to a single mutable path (no path copying).
691
+ if previewMutablePath == nil {
692
+ previewMutablePath = CGMutablePath()
693
+ previewMutablePath?.move(to: viewPoint)
694
+ } else {
695
+ previewMutablePath?.addLine(to: viewPoint)
696
+ }
697
+ layer.path = previewMutablePath
698
+ }
699
+
700
+ override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
701
+ defer {
702
+ currentPath = nil
703
+ activePage = nil
704
+ startPoint = nil
705
+ previewLayer?.removeFromSuperlayer()
706
+ previewLayer = nil
707
+ previewMutablePath = nil
708
+ }
709
+
710
+ guard let tool = currentTool,
711
+ let page = activePage,
712
+ let path = currentPath else {
713
+ super.touchesEnded(touches, with: event)
714
+ return
715
+ }
716
+
717
+ // Stroke‑ийг эцсийн байдлаар PDFAnnotation болгон commit хийнэ.
718
+ // PDFAnnotation дотор path‑ын координат нь annotation.bounds.origin‑оос эхлэх ёстой тул
719
+ // эхний path‑аа bounds‑ынх нь origin‑оор translate хийж өгнө.
720
+ let pathBounds = path.bounds.insetBy(dx: -strokeWidth, dy: -strokeWidth)
721
+ let ann = PDFAnnotation(bounds: pathBounds, forType: .ink, withProperties: nil)
722
+ ann.color = (tool == .highlighter) ? strokeColor.withAlphaComponent(0.4) : strokeColor
723
+ ann.border = PDFBorder()
724
+ ann.border?.lineWidth = strokeWidth
725
+ ann.border?.style = .solid
726
+
727
+ let annotationPath = UIBezierPath(cgPath: path.cgPath)
728
+ let translate = CGAffineTransform(translationX: -pathBounds.origin.x,
729
+ y: -pathBounds.origin.y)
730
+ annotationPath.apply(translate)
731
+ ann.add(annotationPath)
732
+
733
+ page.addAnnotation(ann)
734
+ let pageIndex = pdfView.document?.index(for: page) ?? 0
735
+ undoStack.append(UndoEntry(annotation: ann, pageIndex: pageIndex))
736
+ redoStack.removeAll()
737
+ requestDisplayUpdate()
738
+ notifyAnnotationChange()
739
+ notifyUndoRedoStateChange()
740
+ }
741
+
742
+ override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
743
+ if currentTool == nil {
744
+ super.touchesCancelled(touches, with: event)
745
+ }
746
+ currentPath = nil
747
+ activePage = nil
748
+ startPoint = nil
749
+ previewLayer?.removeFromSuperlayer()
750
+ previewLayer = nil
751
+ }
752
+
753
+ private func eraseAt(point: CGPoint, on page: PDFPage) {
754
+ var erased = false
755
+ let annotationsToRemove = page.annotations.filter { ann in
756
+ ann.bounds.contains(point)
757
+ }
758
+
759
+ for ann in annotationsToRemove {
760
+ page.removeAnnotation(ann)
761
+ if let index = undoStack.firstIndex(where: { $0.annotation === ann }) {
762
+ undoStack.remove(at: index)
763
+ }
764
+ erased = true
765
+ }
766
+
767
+ if erased {
768
+ requestDisplayUpdate()
769
+ notifyAnnotationChange()
770
+ notifyUndoRedoStateChange()
771
+ }
772
+ }
773
+
774
+ private func addInstantAnnotation(at pt: CGPoint, on page: PDFPage, tool: AnnotationTool) {
775
+ let isNote = (tool == .note)
776
+ let rect = isNote
777
+ ? CGRect(x: pt.x, y: pt.y, width: 32, height: 32) // sticky note icon
778
+ : CGRect(x: pt.x, y: pt.y, width: 180, height: 40) // text box
779
+
780
+ let type: PDFAnnotationSubtype = isNote ? .text : .freeText
781
+ let ann = PDFAnnotation(bounds: rect, forType: type, withProperties: nil)
782
+
783
+ // Text/Memo контент
784
+ ann.contents = textContent.isEmpty ? " " : textContent
785
+
786
+ if isNote {
787
+ // Sticky note: background өнгийг noteColor‑оор удирдана
788
+ ann.color = noteColor
789
+ } else {
790
+ // Free text: тунгалаг background + textColor‑той текст
791
+ ann.color = .clear
792
+
793
+ let baseFont = UIFont.systemFont(ofSize: textFontSize)
794
+ var traits: UIFontDescriptor.SymbolicTraits = []
795
+ if textBold { traits.insert(.traitBold) }
796
+ if textItalic { traits.insert(.traitItalic) }
797
+
798
+ if let descriptor = baseFont.fontDescriptor.withSymbolicTraits(traits) {
799
+ ann.font = UIFont(descriptor: descriptor, size: textFontSize)
800
+ } else {
801
+ ann.font = baseFont
802
+ }
803
+
804
+ ann.fontColor = textColor
805
+ }
806
+ page.addAnnotation(ann)
807
+ let pageIndex = pdfView.document?.index(for: page) ?? 0
808
+ undoStack.append(UndoEntry(annotation: ann, pageIndex: pageIndex))
809
+ redoStack.removeAll()
810
+ requestDisplayUpdate()
811
+ notifyAnnotationChange()
812
+ notifyUndoRedoStateChange()
813
+ }
814
+
815
+ func setAnnotations(_ annotationsArray: [[String: Any]]) {
816
+ // Хэрвээ document load хийгдээгүй бол pending-д хадгална
817
+ guard let document = pdfView.document else {
818
+ print("⚠️ setAnnotations: document is nil, storing as pending")
819
+ pendingAnnotations = annotationsArray
820
+ return
821
+ }
822
+
823
+ // Prevent re-applying the exact same initialAnnotations on every prop update.
824
+ let fp = fingerprintAnnotations(annotationsArray)
825
+ if let prev = appliedAnnotationsFingerprint, prev == fp {
826
+ return
827
+ }
828
+ appliedAnnotationsFingerprint = fp
829
+
830
+ for data in annotationsArray {
831
+ guard let pageIndex = data["page"] as? Int, let page = document.page(at: pageIndex),
832
+ let typeStr = data["type"] as? String, let b = data["bounds"] as? [String: Any] else {
833
+ print("⚠️ setAnnotations: skipping invalid annotation data")
834
+ continue
835
+ }
836
+
837
+ let bounds = CGRect(x: b["x"] as? Double ?? 0,
838
+ y: b["y"] as? Double ?? 0,
839
+ width: b["width"] as? Double ?? 0,
840
+ height: b["height"] as? Double ?? 0)
841
+ // note → .text (sticky note), text → .freeText, бусад нь .ink
842
+ let type: PDFAnnotationSubtype = (typeStr == "note") ? .text : (typeStr == "text" ? .freeText : .ink)
843
+ let ann = PDFAnnotation(bounds: bounds, forType: type, withProperties: nil)
844
+
845
+ // Color-ийг load хийх
846
+ if let hex = data["color"] as? String {
847
+ let baseColor = UIColor(hex: hex)
848
+ if typeStr == "highlighter" {
849
+ // Highlighter-ийн хувьд alpha-г 0.4 болгох
850
+ ann.color = baseColor.withAlphaComponent(0.4)
851
+ } else if typeStr == "text" {
852
+ // Text annotation → фон тунгалаг, fontColor нь дамжуулсан өнгө
853
+ ann.color = .clear
854
+ ann.fontColor = baseColor
855
+ } else {
856
+ // pen / line / note
857
+ ann.color = baseColor
858
+ }
859
+ }
860
+
861
+ // Text annotation-ийн font size, bold/italic-ийг restore хийх
862
+ if typeStr == "text" {
863
+ let size = (data["fontSize"] as? Double).map { CGFloat($0) } ?? textFontSize
864
+ let isBold = data["bold"] as? Bool ?? false
865
+ let isItalic = data["italic"] as? Bool ?? false
866
+
867
+ let baseFont = UIFont.systemFont(ofSize: size)
868
+ var traits: UIFontDescriptor.SymbolicTraits = []
869
+ if isBold { traits.insert(.traitBold) }
870
+ if isItalic { traits.insert(.traitItalic) }
871
+
872
+ if let descriptor = baseFont.fontDescriptor.withSymbolicTraits(traits) {
873
+ ann.font = UIFont(descriptor: descriptor, size: size)
874
+ } else {
875
+ ann.font = baseFont
876
+ }
877
+ }
878
+ if let contents = data["contents"] as? String { ann.contents = contents }
879
+
880
+ // Path-ууд нь absolute coordinate-аар хадгалагдсан байдаг
881
+ // PDFAnnotation дээр path-ууд нь annotation bounds-ын origin-оос харьцангуй координатаар байх ёстой.
882
+ // (memo/text-д path байхгүй – зөвхөн ink төрлүүдэд хэрэглэнэ)
883
+ if type == .ink, let paths = data["paths"] as? [[[String: Any]]] {
884
+ for pathData in paths {
885
+ let path = UIBezierPath()
886
+ var lastRelPoint: CGPoint?
887
+ for (i, pt) in pathData.enumerated() {
888
+ // Absolute coordinate-аас харьцангуй координат руу хөрвүүлэх
889
+ let absX = pt["x"] as? Double ?? 0
890
+ let absY = pt["y"] as? Double ?? 0
891
+ let relX = absX - bounds.origin.x
892
+ let relY = absY - bounds.origin.y
893
+ let p = CGPoint(x: relX, y: relY)
894
+
895
+ // Ойрхон цэгүүдийг алгасах (маш урт замыг сийрэгжүүлнэ)
896
+ if let last = lastRelPoint {
897
+ let dx = p.x - last.x
898
+ let dy = p.y - last.y
899
+ // 4pt-аас бага зайтай цэгийг алгасна (performance-д тусална)
900
+ let minDist: CGFloat = 4.0
901
+ if (dx * dx + dy * dy) < (minDist * minDist) { continue }
902
+ }
903
+
904
+ if lastRelPoint == nil {
905
+ path.move(to: p)
906
+ } else {
907
+ path.addLine(to: p)
908
+ }
909
+ lastRelPoint = p
910
+ }
911
+ // Цэгүүд бүгд алгасагдаагүй бол л annotation-д нэмнэ
912
+ if !path.isEmpty {
913
+ ann.add(path)
914
+ }
915
+ }
916
+ }
917
+
918
+ // Stroke width – зөвхөн ink, freeText-д утга учиртай
919
+ if let strokeWidth = data["strokeWidth"] as? Double, type != .text {
920
+ ann.border = PDFBorder()
921
+ ann.border?.lineWidth = CGFloat(strokeWidth)
922
+ }
923
+
924
+ page.addAnnotation(ann)
925
+
926
+ }
927
+
928
+ requestDisplayUpdate()
929
+ }
930
+
931
+ private func findScrollView(in view: UIView) -> UIScrollView? {
932
+ if let sv = view as? UIScrollView { return sv }
933
+ for sub in view.subviews { if let sv = findScrollView(in: sub) { return sv } }
934
+ return nil
935
+ }
936
+
937
+ /// Бүх annotation-уудыг serialize хийж event dispatch хийх
938
+ private func notifyAnnotationChange() {
939
+ guard let document = pdfView.document else {
940
+ print("⚠️ notifyAnnotationChange: document is nil")
941
+ return
942
+ }
943
+
944
+ #if DEBUG
945
+ print("📝 notifyAnnotationChange: starting...")
946
+ #endif
947
+
948
+ var allAnnotations: [[String: Any]] = []
949
+
950
+ for pageIndex in 0..<document.pageCount {
951
+ guard let page = document.page(at: pageIndex) else { continue }
952
+
953
+ for ann in page.annotations {
954
+ var annotationData: [String: Any] = [
955
+ "page": pageIndex,
956
+ "bounds": [
957
+ "x": ann.bounds.origin.x,
958
+ "y": ann.bounds.origin.y,
959
+ "width": ann.bounds.width,
960
+ "height": ann.bounds.height
961
+ ]
962
+ ]
963
+
964
+ // Annotation type-ийг тодорхойлох
965
+ if ann.type == "Ink" {
966
+ // Pen, highlighter, line-ийг ялгах
967
+ let alpha = ann.color.cgColor.alpha
968
+ if alpha < 0.5 {
969
+ annotationData["type"] = "highlighter"
970
+ } else {
971
+ // Line vs pen-ийг ялгах нь хэцүү, одоогоор pen гэж үзье
972
+ // (хэрвээ path нь 2 цэгтэй бол line байж болно)
973
+ annotationData["type"] = "pen"
974
+ }
975
+ } else if ann.type == "Stamp" || ann.type == "Text" {
976
+ annotationData["type"] = "note"
977
+ } else if ann.type == "FreeText" {
978
+ annotationData["type"] = "text"
979
+ } else {
980
+ annotationData["type"] = "pen" // default
981
+ }
982
+
983
+ // Color
984
+ if ann.type == "FreeText" {
985
+ // Text annotation → fontColor-ийг хадгална (background .clear байж болно)
986
+ let color = ann.fontColor ?? ann.color
987
+ annotationData["color"] = colorToHex(color)
988
+
989
+ // FreeText-ийн font size, bold/italic байдлыг хадгална
990
+ if let font = ann.font {
991
+ annotationData["fontSize"] = Double(font.pointSize)
992
+ let traits = font.fontDescriptor.symbolicTraits
993
+ annotationData["bold"] = traits.contains(.traitBold)
994
+ annotationData["italic"] = traits.contains(.traitItalic)
995
+ }
996
+ } else {
997
+ annotationData["color"] = colorToHex(ann.color)
998
+ }
999
+
1000
+ // Contents (text/note-д)
1001
+ if let contents = ann.contents {
1002
+ annotationData["contents"] = contents
1003
+ }
1004
+
1005
+ // Paths (ink annotation-уудад)
1006
+ // PDFAnnotation дээр path-ууд нь annotation bounds-ын origin-оос харьцангуй координатаар байдаг
1007
+ if ann.type == "Ink", let paths = ann.paths {
1008
+ var pathArray: [[[String: Any]]] = []
1009
+ for path in paths {
1010
+ var points: [[String: Any]] = []
1011
+ path.cgPath.applyWithBlock { elementPointer in
1012
+ let element = elementPointer.pointee
1013
+ let pts = element.points
1014
+ switch element.type {
1015
+ case .moveToPoint, .addLineToPoint:
1016
+ // Path-ууд нь annotation bounds-ын origin-оос харьцангуй координатаар байдаг
1017
+ // Absolute coordinate-аар хадгалахын тулд bounds.origin-ийг нэмнэ
1018
+ let x = ann.bounds.origin.x + pts[0].x
1019
+ let y = ann.bounds.origin.y + pts[0].y
1020
+ points.append(["x": x, "y": y])
1021
+ default:
1022
+ break
1023
+ }
1024
+ }
1025
+ if !points.isEmpty {
1026
+ pathArray.append(points)
1027
+ }
1028
+ }
1029
+ if !pathArray.isEmpty {
1030
+ annotationData["paths"] = pathArray
1031
+ }
1032
+ }
1033
+
1034
+ // Stroke width
1035
+ if let border = ann.border {
1036
+ annotationData["strokeWidth"] = border.lineWidth
1037
+ }
1038
+
1039
+ allAnnotations.append(annotationData)
1040
+ }
1041
+ }
1042
+
1043
+ #if DEBUG
1044
+ print("📝 notifyAnnotationChange: dispatching \(allAnnotations.count) annotations")
1045
+ #endif
1046
+ onAnnotationChange(["annotations": allAnnotations])
1047
+ #if DEBUG
1048
+ print("✅ notifyAnnotationChange: event dispatched")
1049
+ #endif
1050
+ }
1051
+
1052
+ /// UIColor-ийг hex string руу хөрвүүлэх
1053
+ private func colorToHex(_ color: UIColor) -> String {
1054
+ var r: CGFloat = 0
1055
+ var g: CGFloat = 0
1056
+ var b: CGFloat = 0
1057
+ var a: CGFloat = 0
1058
+ color.getRed(&r, green: &g, blue: &b, alpha: &a)
1059
+
1060
+ let rgb: Int = (Int)(r * 255) << 16 | (Int)(g * 255) << 8 | (Int)(b * 255) << 0
1061
+ return String(format: "#%06x", rgb)
1062
+ }
1063
+ }
1064
+
1065
+ extension UIColor {
1066
+ convenience init(hex: String) {
1067
+ var c = hex.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
1068
+ if c.hasPrefix("#") { c.remove(at: c.startIndex) }
1069
+ var rgb: UInt64 = 0
1070
+ Scanner(string: c).scanHexInt64(&rgb)
1071
+ self.init(red: CGFloat((rgb & 0xFF0000) >> 16)/255.0, green: CGFloat((rgb & 0x00FF00) >> 8)/255.0, blue: CGFloat(rgb & 0x0000FF)/255.0, alpha: 1.0)
1072
+ }
1073
1073
  }