@june24/expo-pdf-reader 0.1.26 → 0.1.27
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.
- package/README.md +50 -50
- package/android/build.gradle +24 -24
- package/android/src/main/AndroidManifest.xml +2 -2
- package/android/src/main/java/expo/modules/pdfreader/ExpoPdfReaderModule.kt +53 -53
- package/android/src/main/java/expo/modules/pdfreader/ExpoPdfReaderView.kt +1881 -1765
- package/build/ExpoPdfReader.types.js.map +1 -1
- package/build/ExpoPdfReaderView.js.map +1 -1
- package/build/index.js.map +1 -1
- package/expo-module.config.json +16 -16
- package/ios/ExpoPdfReader.podspec +27 -27
- package/ios/ExpoPdfReaderModule.swift +68 -68
- package/ios/ExpoPdfReaderView.swift +1072 -1072
- package/package.json +39 -39
- package/src/ExpoPdfReader.types.ts +49 -49
- package/src/ExpoPdfReaderView.tsx +17 -17
- package/src/index.ts +11 -11
- package/tsconfig.json +9 -9
|
@@ -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
|
}
|