react-native-photo-video-editor 0.1.0

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.
Files changed (162) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/CONTRIBUTING.md +101 -0
  3. package/LICENSE +20 -0
  4. package/PhotoVideoEditor.podspec +23 -0
  5. package/README.md +53 -0
  6. package/android/build.gradle +61 -0
  7. package/android/src/main/AndroidManifest.xml +12 -0
  8. package/android/src/main/java/com/photovideoeditor/PhotoVideoEditorModule.kt +126 -0
  9. package/android/src/main/java/com/photovideoeditor/PhotoVideoEditorPackage.kt +31 -0
  10. package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +2521 -0
  11. package/android/src/main/java/com/photovideoeditor/editor/README.md +5 -0
  12. package/android/src/main/java/com/photovideoeditor/files/SourceResolver.kt +29 -0
  13. package/android/src/main/java/com/photovideoeditor/media/BitmapSampling.kt +11 -0
  14. package/android/src/main/java/com/photovideoeditor/model/EditorRequest.kt +3 -0
  15. package/android/src/main/java/com/photovideoeditor/photo/export/PhotoExporter.kt +201 -0
  16. package/android/src/main/java/com/photovideoeditor/photo/render/OverlayGeometry.kt +22 -0
  17. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoAdjustmentRenderer.kt +160 -0
  18. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoAdjustments.kt +55 -0
  19. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoEditSession.kt +121 -0
  20. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoFilterPresets.kt +64 -0
  21. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayer.kt +54 -0
  22. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerRenderer.kt +108 -0
  23. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerStack.kt +60 -0
  24. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformState.kt +52 -0
  25. package/android/src/main/java/com/photovideoeditor/photo/ui/CropOverlayView.kt +168 -0
  26. package/android/src/main/java/com/photovideoeditor/photo/ui/FilterThumbnailLoader.kt +57 -0
  27. package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +202 -0
  28. package/android/src/main/java/com/photovideoeditor/photo/ui/PhotoPreviewRenderCoordinator.kt +57 -0
  29. package/android/src/main/java/com/photovideoeditor/photo/ui/ZoomableImageView.kt +96 -0
  30. package/android/src/main/java/com/photovideoeditor/stickers/OnlineStickerSheet.kt +310 -0
  31. package/android/src/main/java/com/photovideoeditor/stickers/OpenMojiCatalog.kt +172 -0
  32. package/android/src/main/java/com/photovideoeditor/stickers/RemoteImageLoader.kt +110 -0
  33. package/android/src/main/java/com/photovideoeditor/ui/DesignTokens.kt +93 -0
  34. package/android/src/main/java/com/photovideoeditor/ui/components/EditorComponents.kt +119 -0
  35. package/android/src/main/java/com/photovideoeditor/validation/EditorRequestValidator.kt +11 -0
  36. package/android/src/main/java/com/photovideoeditor/video/cache/TimelineThumbnailRepository.kt +130 -0
  37. package/android/src/main/java/com/photovideoeditor/video/export/VideoExporter.kt +319 -0
  38. package/android/src/main/java/com/photovideoeditor/video/render/VideoClip.kt +20 -0
  39. package/android/src/main/java/com/photovideoeditor/video/render/VideoEditSession.kt +136 -0
  40. package/android/src/main/java/com/photovideoeditor/video/render/VideoThumbnailGenerator.kt +31 -0
  41. package/android/src/main/java/com/photovideoeditor/video/render/VideoTransformState.kt +48 -0
  42. package/android/src/main/java/com/photovideoeditor/video/ui/TrimRangeView.kt +104 -0
  43. package/android/src/main/res/drawable/ic_add.xml +9 -0
  44. package/android/src/main/res/drawable/ic_arrow_back.xml +9 -0
  45. package/android/src/main/res/drawable/ic_aspect_ratio.xml +9 -0
  46. package/android/src/main/res/drawable/ic_auto_fix_high.xml +9 -0
  47. package/android/src/main/res/drawable/ic_check.xml +9 -0
  48. package/android/src/main/res/drawable/ic_chevron_left.xml +9 -0
  49. package/android/src/main/res/drawable/ic_chevron_right.xml +9 -0
  50. package/android/src/main/res/drawable/ic_close.xml +9 -0
  51. package/android/src/main/res/drawable/ic_compare.xml +9 -0
  52. package/android/src/main/res/drawable/ic_content_copy.xml +9 -0
  53. package/android/src/main/res/drawable/ic_content_cut.xml +9 -0
  54. package/android/src/main/res/drawable/ic_crop.xml +9 -0
  55. package/android/src/main/res/drawable/ic_crop_square.xml +9 -0
  56. package/android/src/main/res/drawable/ic_delete.xml +9 -0
  57. package/android/src/main/res/drawable/ic_drag_indicator.xml +9 -0
  58. package/android/src/main/res/drawable/ic_face.xml +9 -0
  59. package/android/src/main/res/drawable/ic_filter_center_focus.xml +9 -0
  60. package/android/src/main/res/drawable/ic_flare.xml +9 -0
  61. package/android/src/main/res/drawable/ic_format_size.xml +9 -0
  62. package/android/src/main/res/drawable/ic_fullscreen.xml +9 -0
  63. package/android/src/main/res/drawable/ic_layers.xml +9 -0
  64. package/android/src/main/res/drawable/ic_light_mode.xml +9 -0
  65. package/android/src/main/res/drawable/ic_lock.xml +9 -0
  66. package/android/src/main/res/drawable/ic_magic_button.xml +9 -0
  67. package/android/src/main/res/drawable/ic_movie.xml +9 -0
  68. package/android/src/main/res/drawable/ic_opacity.xml +9 -0
  69. package/android/src/main/res/drawable/ic_palette.xml +9 -0
  70. package/android/src/main/res/drawable/ic_photo_filter.xml +9 -0
  71. package/android/src/main/res/drawable/ic_redo.xml +9 -0
  72. package/android/src/main/res/drawable/ic_rotate_90_degrees_ccw.xml +9 -0
  73. package/android/src/main/res/drawable/ic_rotate_left.xml +9 -0
  74. package/android/src/main/res/drawable/ic_rotate_right.xml +9 -0
  75. package/android/src/main/res/drawable/ic_sentiment_satisfied.xml +9 -0
  76. package/android/src/main/res/drawable/ic_share.xml +9 -0
  77. package/android/src/main/res/drawable/ic_speed.xml +9 -0
  78. package/android/src/main/res/drawable/ic_straighten.xml +9 -0
  79. package/android/src/main/res/drawable/ic_title.xml +9 -0
  80. package/android/src/main/res/drawable/ic_tune.xml +9 -0
  81. package/android/src/main/res/drawable/ic_undo.xml +9 -0
  82. package/android/src/main/res/drawable/ic_visibility_off.xml +9 -0
  83. package/android/src/main/res/drawable/ic_wallpaper.xml +9 -0
  84. package/android/src/main/res/values/styles.xml +10 -0
  85. package/docs/api.md +9 -0
  86. package/docs/architecture.md +9 -0
  87. package/docs/customization.md +18 -0
  88. package/docs/development.md +21 -0
  89. package/docs/formats.md +27 -0
  90. package/docs/performance.md +3 -0
  91. package/docs/permissions.md +3 -0
  92. package/docs/photo-editor.md +41 -0
  93. package/docs/roadmap.md +12 -0
  94. package/docs/technology-decisions.md +17 -0
  95. package/docs/video-editor.md +76 -0
  96. package/ios/Editor/DesignTokens.swift +75 -0
  97. package/ios/Editor/PhotoVideoEditorOutcome.swift +9 -0
  98. package/ios/Editor/PhotoVideoEditorViewController.swift +2064 -0
  99. package/ios/Editor/README.md +5 -0
  100. package/ios/Editor/UI/EditorComponents.swift +138 -0
  101. package/ios/Files/SourceResolver.swift +13 -0
  102. package/ios/Models/EditorRequest.swift +3 -0
  103. package/ios/Photo/Export/PhotoExporter.swift +144 -0
  104. package/ios/Photo/Rendering/OverlayGeometry.swift +22 -0
  105. package/ios/Photo/Rendering/PhotoAdjustmentRenderer.swift +141 -0
  106. package/ios/Photo/Rendering/PhotoAdjustments.swift +57 -0
  107. package/ios/Photo/Rendering/PhotoEditSession.swift +121 -0
  108. package/ios/Photo/Rendering/PhotoFilterPresets.swift +45 -0
  109. package/ios/Photo/Rendering/PhotoLayer.swift +59 -0
  110. package/ios/Photo/Rendering/PhotoLayerRenderer.swift +94 -0
  111. package/ios/Photo/Rendering/PhotoLayerStack.swift +60 -0
  112. package/ios/Photo/Rendering/PhotoTransformState.swift +49 -0
  113. package/ios/Photo/Rendering/PixelBuffer.swift +68 -0
  114. package/ios/Photo/UI/CropOverlayView.swift +161 -0
  115. package/ios/Photo/UI/LayerOverlayView.swift +176 -0
  116. package/ios/Photo/UI/ZoomableImageView.swift +79 -0
  117. package/ios/PhotoVideoEditor.mm +20 -0
  118. package/ios/PhotoVideoEditor.swift +70 -0
  119. package/ios/Stickers/OnlineStickerSheetViewController.swift +418 -0
  120. package/ios/Stickers/OpenMojiCatalog.swift +188 -0
  121. package/ios/Stickers/RemoteImageLoader.swift +127 -0
  122. package/ios/Validation/EditorRequestValidator.swift +9 -0
  123. package/ios/Video/Cache/TimelineThumbnailRepository.swift +148 -0
  124. package/ios/Video/Export/VideoExporter.swift +275 -0
  125. package/ios/Video/Render/VideoClip.swift +21 -0
  126. package/ios/Video/Render/VideoEditSession.swift +99 -0
  127. package/ios/Video/Render/VideoThumbnailGenerator.swift +25 -0
  128. package/ios/Video/Render/VideoTransformState.swift +23 -0
  129. package/ios/Video/UI/TrimRangeView.swift +91 -0
  130. package/lib/module/NativePhotoVideoEditor.js +5 -0
  131. package/lib/module/NativePhotoVideoEditor.js.map +1 -0
  132. package/lib/module/errors.js +29 -0
  133. package/lib/module/errors.js.map +1 -0
  134. package/lib/module/events.js +27 -0
  135. package/lib/module/events.js.map +1 -0
  136. package/lib/module/index.js +91 -0
  137. package/lib/module/index.js.map +1 -0
  138. package/lib/module/package.json +1 -0
  139. package/lib/module/project.js +4 -0
  140. package/lib/module/project.js.map +1 -0
  141. package/lib/module/types.js +2 -0
  142. package/lib/module/types.js.map +1 -0
  143. package/lib/typescript/package.json +1 -0
  144. package/lib/typescript/src/NativePhotoVideoEditor.d.ts +11 -0
  145. package/lib/typescript/src/NativePhotoVideoEditor.d.ts.map +1 -0
  146. package/lib/typescript/src/errors.d.ts +11 -0
  147. package/lib/typescript/src/errors.d.ts.map +1 -0
  148. package/lib/typescript/src/events.d.ts +5 -0
  149. package/lib/typescript/src/events.d.ts.map +1 -0
  150. package/lib/typescript/src/index.d.ts +15 -0
  151. package/lib/typescript/src/index.d.ts.map +1 -0
  152. package/lib/typescript/src/project.d.ts +24 -0
  153. package/lib/typescript/src/project.d.ts.map +1 -0
  154. package/lib/typescript/src/types.d.ts +108 -0
  155. package/lib/typescript/src/types.d.ts.map +1 -0
  156. package/package.json +154 -0
  157. package/src/NativePhotoVideoEditor.ts +12 -0
  158. package/src/errors.ts +78 -0
  159. package/src/events.ts +43 -0
  160. package/src/index.ts +123 -0
  161. package/src/project.ts +17 -0
  162. package/src/types.ts +121 -0
@@ -0,0 +1,176 @@
1
+ import UIKit
2
+
3
+ /// Reusable native move/scale/rotate surface shared by every overlay type.
4
+ final class LayerOverlayView: UIView {
5
+ private enum Gesture { case none, drag, handle, multi }
6
+ private var imageBounds: CGRect = .zero
7
+ var minScale: CGFloat = 0.2
8
+ var maxScale: CGFloat = 8
9
+ var layers: [PhotoLayer] = [] { didSet { setNeedsDisplay() } }
10
+ var selectedLayerID: String? { didSet { setNeedsDisplay() } }
11
+ var onLayerTapped: ((String?) -> Void)?
12
+ var onLayerTransformChanged: ((_ id: String, _ x: CGFloat, _ y: CGFloat, _ scale: CGFloat, _ rotationDegrees: CGFloat) -> Void)?
13
+ var onLayerTransformEnded: ((String) -> Void)?
14
+
15
+ private var gesture: Gesture = .none
16
+ private var gestureID: String?
17
+ private var startLayer: PhotoLayer?
18
+ private var primaryTouch: UITouch?
19
+ private var secondaryTouch: UITouch?
20
+ private var startPoint: CGPoint = .zero
21
+ private var startDistance: CGFloat = 1
22
+ private var startAngle: CGFloat = 0
23
+ private var startMidpoint: CGPoint = .zero
24
+ private let handleRadius: CGFloat = 11
25
+ private let handleTouchRadius: CGFloat = 30
26
+
27
+ override init(frame: CGRect) {
28
+ super.init(frame: frame)
29
+ backgroundColor = .clear
30
+ isOpaque = false
31
+ isMultipleTouchEnabled = true
32
+ }
33
+ required init?(coder: NSCoder) { nil }
34
+
35
+ func setImageBounds(_ bounds: CGRect) { imageBounds = bounds; setNeedsDisplay() }
36
+
37
+ override func draw(_ rect: CGRect) {
38
+ guard let layer = selectedLayer(), let context = UIGraphicsGetCurrentContext() else { return }
39
+ let center = center(of: layer); let half = selectionHalfExtents(layer)
40
+ context.saveGState()
41
+ context.translateBy(x: center.x, y: center.y)
42
+ context.rotate(by: layer.rotationDegrees * .pi / 180)
43
+ context.setStrokeColor(UIColor.white.cgColor)
44
+ context.setLineWidth(2)
45
+ context.setLineDash(phase: 0, lengths: [8, 5])
46
+ context.stroke(CGRect(x: -half.width, y: -half.height, width: half.width * 2, height: half.height * 2))
47
+ context.setLineDash(phase: 0, lengths: [])
48
+ context.setFillColor(UIColor.white.cgColor)
49
+ context.fillEllipse(in: CGRect(x: half.width - handleRadius, y: half.height - handleRadius, width: handleRadius * 2, height: handleRadius * 2))
50
+ context.setStrokeColor(UIColor(red: 87/255, green: 55/255, blue: 245/255, alpha: 1).cgColor)
51
+ context.setLineWidth(2)
52
+ let glyph = handleRadius * 0.52
53
+ context.move(to: CGPoint(x: half.width - glyph, y: half.height + glyph)); context.addLine(to: CGPoint(x: half.width + glyph, y: half.height - glyph)); context.strokePath()
54
+ context.restoreGState()
55
+ }
56
+
57
+ override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
58
+ guard imageBounds.width > 0 else { return }
59
+ for touch in touches {
60
+ if primaryTouch == nil { primaryTouch = touch }
61
+ else if secondaryTouch == nil { secondaryTouch = touch }
62
+ }
63
+ guard let primaryTouch else { return }
64
+ let point = primaryTouch.location(in: self)
65
+ if let secondaryTouch, let current = currentGestureLayer() {
66
+ startLayer = current
67
+ let metrics = multiMetrics(primaryTouch, secondaryTouch)
68
+ startDistance = max(1, metrics.distance); startAngle = metrics.angle; startMidpoint = metrics.midpoint
69
+ gesture = .multi
70
+ return
71
+ }
72
+ let selected = selectedLayer()
73
+ let handleHit = selected.flatMap { !$0.locked && isHandleHit($0, point) ? $0 : nil }
74
+ let hit = handleHit ?? hitTest(point)
75
+ onLayerTapped?(hit?.id)
76
+ guard let hit, !hit.locked else { resetGesture(); return }
77
+ gestureID = hit.id; startLayer = hit; startPoint = point
78
+ if handleHit != nil {
79
+ gesture = .handle
80
+ let center = center(of: hit)
81
+ startDistance = max(1, distance(center, point)); startAngle = angle(center, point)
82
+ } else { gesture = .drag }
83
+ }
84
+
85
+ override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
86
+ guard let initial = startLayer, let primaryTouch else { return }
87
+ let point = primaryTouch.location(in: self)
88
+ switch gesture {
89
+ case .drag:
90
+ let x = clamp(initial.x + (point.x - startPoint.x) / imageBounds.width, 0, 1)
91
+ let y = clamp(initial.y + (point.y - startPoint.y) / imageBounds.height, 0, 1)
92
+ emit(initial, x: x, y: y, scale: initial.scale, rotation: initial.rotationDegrees)
93
+ case .handle:
94
+ let center = center(of: initial)
95
+ let scale = clamp(initial.scale * distance(center, point) / startDistance, minScale, maxScale)
96
+ let rotation = normalizeDegrees(initial.rotationDegrees + angleDelta(from: startAngle, to: angle(center, point)))
97
+ emit(initial, x: initial.x, y: initial.y, scale: scale, rotation: rotation)
98
+ case .multi:
99
+ guard let secondaryTouch else { return }
100
+ let metrics = multiMetrics(primaryTouch, secondaryTouch)
101
+ let scale = clamp(initial.scale * metrics.distance / startDistance, minScale, maxScale)
102
+ let rotation = normalizeDegrees(initial.rotationDegrees + angleDelta(from: startAngle, to: metrics.angle))
103
+ let x = clamp(initial.x + (metrics.midpoint.x - startMidpoint.x) / imageBounds.width, 0, 1)
104
+ let y = clamp(initial.y + (metrics.midpoint.y - startMidpoint.y) / imageBounds.height, 0, 1)
105
+ emit(initial, x: x, y: y, scale: scale, rotation: rotation)
106
+ case .none: break
107
+ }
108
+ }
109
+
110
+ override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { endTouches(touches) }
111
+ override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { endTouches(touches) }
112
+
113
+ private func endTouches(_ touches: Set<UITouch>) {
114
+ let endedPrimary = primaryTouch.map { touches.contains($0) } ?? false
115
+ let endedSecondary = secondaryTouch.map { touches.contains($0) } ?? false
116
+ if endedPrimary || endedSecondary { finishGesture() }
117
+ }
118
+ private func emit(_ layer: PhotoLayer, x: CGFloat, y: CGFloat, scale: CGFloat, rotation: CGFloat) {
119
+ onLayerTransformChanged?(layer.id, x, y, scale, rotation)
120
+ }
121
+ private func finishGesture() {
122
+ if let id = gestureID { onLayerTransformEnded?(id) }
123
+ resetGesture()
124
+ }
125
+ private func resetGesture() {
126
+ gesture = .none; gestureID = nil; startLayer = nil; primaryTouch = nil; secondaryTouch = nil
127
+ }
128
+ private func selectedLayer() -> PhotoLayer? { layers.first { $0.id == selectedLayerID && $0.visible } }
129
+ private func currentGestureLayer() -> PhotoLayer? { layers.first { $0.id == gestureID && $0.visible && !$0.locked } }
130
+ private func center(of layer: PhotoLayer) -> CGPoint { CGPoint(x: imageBounds.minX + layer.x * imageBounds.width, y: imageBounds.minY + layer.y * imageBounds.height) }
131
+ private func selectionHalfExtents(_ layer: PhotoLayer) -> CGSize {
132
+ let shortSide = min(imageBounds.width, imageBounds.height)
133
+ let base: CGSize
134
+ switch layer.type {
135
+ case .text:
136
+ let pointSize = layer.fontSize / 48 * shortSide * 0.06
137
+ let font = layer.fontFamily.flatMap { UIFont(name: $0, size: pointSize) } ?? UIFont.systemFont(ofSize: pointSize)
138
+ let lines = layer.text.isEmpty ? [" "] : layer.text.components(separatedBy: "\n")
139
+ let width = lines.map { ($0 as NSString).size(withAttributes: [.font: font]).width }.max() ?? pointSize
140
+ base = CGSize(width: width / 2 + 8, height: font.lineHeight * CGFloat(lines.count) / 2 + 6)
141
+ case .sticker:
142
+ // Mirrors `PhotoLayerRenderer.drawSticker`'s `shortSide * 0.18` box (half-extent = 0.09).
143
+ let half = shortSide * 0.09
144
+ base = CGSize(width: half, height: half)
145
+ case .overlay:
146
+ // Mirrors `PhotoLayerRenderer.drawOverlay`'s box: full canvas width, height from aspect ratio.
147
+ let aspectRatio = layer.overlayAspectRatio.flatMap { $0 > 0 ? $0 : nil } ?? 1
148
+ base = CGSize(width: imageBounds.width / 2, height: imageBounds.width / (2 * aspectRatio))
149
+ }
150
+ return CGSize(width: max(handleRadius, base.width * layer.scale), height: max(handleRadius, base.height * layer.scale))
151
+ }
152
+ private func handlePoint(_ layer: PhotoLayer) -> CGPoint {
153
+ let center = center(of: layer); let half = selectionHalfExtents(layer); let radians = layer.rotationDegrees * .pi / 180
154
+ return CGPoint(x: center.x + half.width * cos(radians) - half.height * sin(radians), y: center.y + half.width * sin(radians) + half.height * cos(radians))
155
+ }
156
+ private func isHandleHit(_ layer: PhotoLayer, _ point: CGPoint) -> Bool { distance(handlePoint(layer), point) <= handleTouchRadius }
157
+ private func hitTest(_ point: CGPoint) -> PhotoLayer? {
158
+ for layer in layers.reversed() where layer.visible {
159
+ let center = center(of: layer); let radians = -layer.rotationDegrees * .pi / 180
160
+ let dx = point.x - center.x; let dy = point.y - center.y
161
+ let localX = dx * cos(radians) - dy * sin(radians); let localY = dx * sin(radians) + dy * cos(radians)
162
+ let half = selectionHalfExtents(layer)
163
+ if abs(localX) <= half.width && abs(localY) <= half.height { return layer }
164
+ }
165
+ return nil
166
+ }
167
+ private func multiMetrics(_ first: UITouch, _ second: UITouch) -> (midpoint: CGPoint, distance: CGFloat, angle: CGFloat) {
168
+ let a = first.location(in: self); let b = second.location(in: self)
169
+ return (CGPoint(x: (a.x + b.x) / 2, y: (a.y + b.y) / 2), distance(a, b), angle(a, b))
170
+ }
171
+ private func distance(_ a: CGPoint, _ b: CGPoint) -> CGFloat { hypot(b.x - a.x, b.y - a.y) }
172
+ private func angle(_ a: CGPoint, _ b: CGPoint) -> CGFloat { atan2(b.y - a.y, b.x - a.x) * 180 / .pi }
173
+ private func angleDelta(from: CGFloat, to: CGFloat) -> CGFloat { (to - from + 540).truncatingRemainder(dividingBy: 360) - 180 }
174
+ private func normalizeDegrees(_ value: CGFloat) -> CGFloat { (value.truncatingRemainder(dividingBy: 360) + 360).truncatingRemainder(dividingBy: 360) }
175
+ private func clamp(_ value: CGFloat, _ minimum: CGFloat, _ maximum: CGFloat) -> CGFloat { min(max(value, minimum), maximum) }
176
+ }
@@ -0,0 +1,79 @@
1
+ import UIKit
2
+
3
+ /// UIImageView supporting pinch-to-zoom and single-finger pan for the photo
4
+ /// preview, reporting the current on-screen image rect via `onBoundsChanged`
5
+ /// so the crop overlay can stay aligned to it.
6
+ final class ZoomableImageView: UIImageView {
7
+ var onBoundsChanged: ((CGRect) -> Void)?
8
+ var panZoomEnabled = true {
9
+ didSet {
10
+ pinchRecognizer.isEnabled = panZoomEnabled
11
+ panRecognizer.isEnabled = panZoomEnabled
12
+ }
13
+ }
14
+
15
+ private let pinchRecognizer = UIPinchGestureRecognizer()
16
+ private let panRecognizer = UIPanGestureRecognizer()
17
+ private var minScale: CGFloat = 1
18
+ private var maxScale: CGFloat = 4
19
+
20
+ override init(frame: CGRect) {
21
+ super.init(frame: frame)
22
+ contentMode = .scaleAspectFit
23
+ isUserInteractionEnabled = true
24
+ pinchRecognizer.addTarget(self, action: #selector(handlePinch(_:)))
25
+ panRecognizer.addTarget(self, action: #selector(handlePan(_:)))
26
+ addGestureRecognizer(pinchRecognizer)
27
+ addGestureRecognizer(panRecognizer)
28
+ }
29
+
30
+ required init?(coder: NSCoder) { nil }
31
+
32
+ /// Fits the current image to the view bounds and resets zoom/pan.
33
+ func resetToFit() {
34
+ transform = .identity
35
+ minScale = 1
36
+ maxScale = 4
37
+ reportBounds()
38
+ }
39
+
40
+ func currentImageBounds() -> CGRect {
41
+ guard let image = image, bounds.width > 0, bounds.height > 0, image.size.width > 0, image.size.height > 0 else { return bounds }
42
+ let scale = min(bounds.width / image.size.width, bounds.height / image.size.height)
43
+ let fittedSize = CGSize(width: image.size.width * scale, height: image.size.height * scale)
44
+ let origin = CGPoint(x: (bounds.width - fittedSize.width) / 2, y: (bounds.height - fittedSize.height) / 2)
45
+ return CGRect(origin: origin, size: fittedSize).applying(transform)
46
+ }
47
+
48
+ private func reportBounds() {
49
+ onBoundsChanged?(currentImageBounds())
50
+ }
51
+
52
+ private func currentScaleFactor() -> CGFloat {
53
+ sqrt(transform.a * transform.a + transform.c * transform.c)
54
+ }
55
+
56
+ @objc private func handlePinch(_ gesture: UIPinchGestureRecognizer) {
57
+ guard panZoomEnabled else { return }
58
+ let currentScale = currentScaleFactor()
59
+ let targetScale = max(minScale, min(maxScale, currentScale * gesture.scale))
60
+ let appliedFactor = targetScale / currentScale
61
+ let location = gesture.location(in: self)
62
+ let anchor = CGPoint(x: location.x - bounds.midX, y: location.y - bounds.midY)
63
+ transform = transform
64
+ .translatedBy(x: anchor.x, y: anchor.y)
65
+ .scaledBy(x: appliedFactor, y: appliedFactor)
66
+ .translatedBy(x: -anchor.x, y: -anchor.y)
67
+ gesture.scale = 1
68
+ reportBounds()
69
+ }
70
+
71
+ @objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
72
+ guard panZoomEnabled else { return }
73
+ let translation = gesture.translation(in: self)
74
+ let scale = currentScaleFactor()
75
+ transform = transform.translatedBy(x: translation.x / scale, y: translation.y / scale)
76
+ gesture.setTranslation(.zero, in: self)
77
+ reportBounds()
78
+ }
79
+ }
@@ -0,0 +1,20 @@
1
+ #import <PhotoVideoEditorSpec/PhotoVideoEditorSpec.h>
2
+ #import "PhotoVideoEditor-Swift.h"
3
+
4
+ @interface PhotoVideoEditor : PhotoVideoEditorSwift <NativePhotoVideoEditorSpec>
5
+ @end
6
+
7
+ @implementation PhotoVideoEditor
8
+
9
+ - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
10
+ (const facebook::react::ObjCTurboModule::InitParams &)params
11
+ {
12
+ return std::make_shared<facebook::react::NativePhotoVideoEditorSpecJSI>(params);
13
+ }
14
+
15
+ + (NSString *)moduleName
16
+ {
17
+ return @"PhotoVideoEditor";
18
+ }
19
+
20
+ @end
@@ -0,0 +1,70 @@
1
+ import Foundation
2
+ import React
3
+ import UIKit
4
+
5
+ @objc(PhotoVideoEditorSwift)
6
+ public class PhotoVideoEditorSwift: NSObject {
7
+ private var editorOpen = false
8
+
9
+ @objc public func openEditor(
10
+ _ request: String,
11
+ resolve: @escaping RCTPromiseResolveBlock,
12
+ reject: @escaping RCTPromiseRejectBlock
13
+ ) {
14
+ guard let data = request.data(using: .utf8),
15
+ let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
16
+ let source = payload["source"] as? [String: Any] else {
17
+ reject("E_INVALID_OPTIONS", "options.source is required.", nil); return
18
+ }
19
+ let uri = (source["uri"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
20
+ guard !uri.isEmpty else { reject("E_INVALID_URI", "source.uri must be a non-empty string.", nil); return }
21
+ guard let type = source["type"] as? String, type == "photo" || type == "video" else {
22
+ reject("E_UNSUPPORTED_MEDIA_TYPE", "source.type must be photo or video.", nil); return
23
+ }
24
+ guard !editorOpen else { reject("E_EDITOR_ALREADY_OPEN", "Another editor is already open.", nil); return }
25
+ guard let presenter = RCTPresentedViewController() else { reject("E_EDITOR_UNAVAILABLE", "The editor requires a foreground view controller.", nil); return }
26
+ editorOpen = true
27
+ let controller = PhotoVideoEditorViewController(
28
+ uri: uri,
29
+ mediaType: type,
30
+ features: payload["features"] as? [String: Any] ?? [:],
31
+ theme: payload["theme"] as? [String: Any] ?? [:],
32
+ exportOptions: payload["export"] as? [String: Any] ?? [:],
33
+ stickerAssets: payload["stickerAssets"] as? [[String: Any]] ?? []
34
+ )
35
+ controller.completion = { [weak self, weak controller] outcome in
36
+ guard let self else { return }
37
+ self.editorOpen = false
38
+ controller?.dismiss(animated: true)
39
+ switch outcome {
40
+ case .cancelled:
41
+ var result: [String: Any] = ["uri": uri, "type": type, "cancelled": true]
42
+ if let mimeType = source["mimeType"] as? String, !mimeType.isEmpty { result["mimeType"] = mimeType }
43
+ PhotoVideoEditorSwift.resolveResult(result, resolve: resolve, reject: reject)
44
+ case let .success(resultUri, mimeType, width, height, fileSize, durationMs):
45
+ var result: [String: Any] = ["uri": resultUri, "type": type, "cancelled": false]
46
+ let resolvedMimeType = mimeType ?? (source["mimeType"] as? String)
47
+ if let resolvedMimeType, !resolvedMimeType.isEmpty { result["mimeType"] = resolvedMimeType }
48
+ if let width { result["width"] = width }
49
+ if let height { result["height"] = height }
50
+ if let fileSize { result["fileSize"] = fileSize }
51
+ if let durationMs { result["duration"] = durationMs }
52
+ PhotoVideoEditorSwift.resolveResult(result, resolve: resolve, reject: reject)
53
+ case let .failure(code, message):
54
+ reject(code, message, nil)
55
+ }
56
+ }
57
+ DispatchQueue.main.async { presenter.present(controller, animated: true) }
58
+ }
59
+
60
+ @objc public func cancelExport(_ jobId: String?, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { resolve(false) }
61
+ @objc public func isAvailable() -> NSNumber { true }
62
+
63
+ private static func resolveResult(_ result: [String: Any], resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
64
+ guard let output = try? JSONSerialization.data(withJSONObject: result), let json = String(data: output, encoding: .utf8) else {
65
+ reject("E_INTERNAL", "Unable to serialize editor result.", nil)
66
+ return
67
+ }
68
+ resolve(json)
69
+ }
70
+ }