@june24/expo-pdf-reader 0.1.26 → 0.1.28

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