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.
- package/CHANGELOG.md +27 -0
- package/CONTRIBUTING.md +101 -0
- package/LICENSE +20 -0
- package/PhotoVideoEditor.podspec +23 -0
- package/README.md +53 -0
- package/android/build.gradle +61 -0
- package/android/src/main/AndroidManifest.xml +12 -0
- package/android/src/main/java/com/photovideoeditor/PhotoVideoEditorModule.kt +126 -0
- package/android/src/main/java/com/photovideoeditor/PhotoVideoEditorPackage.kt +31 -0
- package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +2521 -0
- package/android/src/main/java/com/photovideoeditor/editor/README.md +5 -0
- package/android/src/main/java/com/photovideoeditor/files/SourceResolver.kt +29 -0
- package/android/src/main/java/com/photovideoeditor/media/BitmapSampling.kt +11 -0
- package/android/src/main/java/com/photovideoeditor/model/EditorRequest.kt +3 -0
- package/android/src/main/java/com/photovideoeditor/photo/export/PhotoExporter.kt +201 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/OverlayGeometry.kt +22 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoAdjustmentRenderer.kt +160 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoAdjustments.kt +55 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoEditSession.kt +121 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoFilterPresets.kt +64 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayer.kt +54 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerRenderer.kt +108 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerStack.kt +60 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformState.kt +52 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/CropOverlayView.kt +168 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/FilterThumbnailLoader.kt +57 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +202 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/PhotoPreviewRenderCoordinator.kt +57 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/ZoomableImageView.kt +96 -0
- package/android/src/main/java/com/photovideoeditor/stickers/OnlineStickerSheet.kt +310 -0
- package/android/src/main/java/com/photovideoeditor/stickers/OpenMojiCatalog.kt +172 -0
- package/android/src/main/java/com/photovideoeditor/stickers/RemoteImageLoader.kt +110 -0
- package/android/src/main/java/com/photovideoeditor/ui/DesignTokens.kt +93 -0
- package/android/src/main/java/com/photovideoeditor/ui/components/EditorComponents.kt +119 -0
- package/android/src/main/java/com/photovideoeditor/validation/EditorRequestValidator.kt +11 -0
- package/android/src/main/java/com/photovideoeditor/video/cache/TimelineThumbnailRepository.kt +130 -0
- package/android/src/main/java/com/photovideoeditor/video/export/VideoExporter.kt +319 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoClip.kt +20 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoEditSession.kt +136 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoThumbnailGenerator.kt +31 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoTransformState.kt +48 -0
- package/android/src/main/java/com/photovideoeditor/video/ui/TrimRangeView.kt +104 -0
- package/android/src/main/res/drawable/ic_add.xml +9 -0
- package/android/src/main/res/drawable/ic_arrow_back.xml +9 -0
- package/android/src/main/res/drawable/ic_aspect_ratio.xml +9 -0
- package/android/src/main/res/drawable/ic_auto_fix_high.xml +9 -0
- package/android/src/main/res/drawable/ic_check.xml +9 -0
- package/android/src/main/res/drawable/ic_chevron_left.xml +9 -0
- package/android/src/main/res/drawable/ic_chevron_right.xml +9 -0
- package/android/src/main/res/drawable/ic_close.xml +9 -0
- package/android/src/main/res/drawable/ic_compare.xml +9 -0
- package/android/src/main/res/drawable/ic_content_copy.xml +9 -0
- package/android/src/main/res/drawable/ic_content_cut.xml +9 -0
- package/android/src/main/res/drawable/ic_crop.xml +9 -0
- package/android/src/main/res/drawable/ic_crop_square.xml +9 -0
- package/android/src/main/res/drawable/ic_delete.xml +9 -0
- package/android/src/main/res/drawable/ic_drag_indicator.xml +9 -0
- package/android/src/main/res/drawable/ic_face.xml +9 -0
- package/android/src/main/res/drawable/ic_filter_center_focus.xml +9 -0
- package/android/src/main/res/drawable/ic_flare.xml +9 -0
- package/android/src/main/res/drawable/ic_format_size.xml +9 -0
- package/android/src/main/res/drawable/ic_fullscreen.xml +9 -0
- package/android/src/main/res/drawable/ic_layers.xml +9 -0
- package/android/src/main/res/drawable/ic_light_mode.xml +9 -0
- package/android/src/main/res/drawable/ic_lock.xml +9 -0
- package/android/src/main/res/drawable/ic_magic_button.xml +9 -0
- package/android/src/main/res/drawable/ic_movie.xml +9 -0
- package/android/src/main/res/drawable/ic_opacity.xml +9 -0
- package/android/src/main/res/drawable/ic_palette.xml +9 -0
- package/android/src/main/res/drawable/ic_photo_filter.xml +9 -0
- package/android/src/main/res/drawable/ic_redo.xml +9 -0
- package/android/src/main/res/drawable/ic_rotate_90_degrees_ccw.xml +9 -0
- package/android/src/main/res/drawable/ic_rotate_left.xml +9 -0
- package/android/src/main/res/drawable/ic_rotate_right.xml +9 -0
- package/android/src/main/res/drawable/ic_sentiment_satisfied.xml +9 -0
- package/android/src/main/res/drawable/ic_share.xml +9 -0
- package/android/src/main/res/drawable/ic_speed.xml +9 -0
- package/android/src/main/res/drawable/ic_straighten.xml +9 -0
- package/android/src/main/res/drawable/ic_title.xml +9 -0
- package/android/src/main/res/drawable/ic_tune.xml +9 -0
- package/android/src/main/res/drawable/ic_undo.xml +9 -0
- package/android/src/main/res/drawable/ic_visibility_off.xml +9 -0
- package/android/src/main/res/drawable/ic_wallpaper.xml +9 -0
- package/android/src/main/res/values/styles.xml +10 -0
- package/docs/api.md +9 -0
- package/docs/architecture.md +9 -0
- package/docs/customization.md +18 -0
- package/docs/development.md +21 -0
- package/docs/formats.md +27 -0
- package/docs/performance.md +3 -0
- package/docs/permissions.md +3 -0
- package/docs/photo-editor.md +41 -0
- package/docs/roadmap.md +12 -0
- package/docs/technology-decisions.md +17 -0
- package/docs/video-editor.md +76 -0
- package/ios/Editor/DesignTokens.swift +75 -0
- package/ios/Editor/PhotoVideoEditorOutcome.swift +9 -0
- package/ios/Editor/PhotoVideoEditorViewController.swift +2064 -0
- package/ios/Editor/README.md +5 -0
- package/ios/Editor/UI/EditorComponents.swift +138 -0
- package/ios/Files/SourceResolver.swift +13 -0
- package/ios/Models/EditorRequest.swift +3 -0
- package/ios/Photo/Export/PhotoExporter.swift +144 -0
- package/ios/Photo/Rendering/OverlayGeometry.swift +22 -0
- package/ios/Photo/Rendering/PhotoAdjustmentRenderer.swift +141 -0
- package/ios/Photo/Rendering/PhotoAdjustments.swift +57 -0
- package/ios/Photo/Rendering/PhotoEditSession.swift +121 -0
- package/ios/Photo/Rendering/PhotoFilterPresets.swift +45 -0
- package/ios/Photo/Rendering/PhotoLayer.swift +59 -0
- package/ios/Photo/Rendering/PhotoLayerRenderer.swift +94 -0
- package/ios/Photo/Rendering/PhotoLayerStack.swift +60 -0
- package/ios/Photo/Rendering/PhotoTransformState.swift +49 -0
- package/ios/Photo/Rendering/PixelBuffer.swift +68 -0
- package/ios/Photo/UI/CropOverlayView.swift +161 -0
- package/ios/Photo/UI/LayerOverlayView.swift +176 -0
- package/ios/Photo/UI/ZoomableImageView.swift +79 -0
- package/ios/PhotoVideoEditor.mm +20 -0
- package/ios/PhotoVideoEditor.swift +70 -0
- package/ios/Stickers/OnlineStickerSheetViewController.swift +418 -0
- package/ios/Stickers/OpenMojiCatalog.swift +188 -0
- package/ios/Stickers/RemoteImageLoader.swift +127 -0
- package/ios/Validation/EditorRequestValidator.swift +9 -0
- package/ios/Video/Cache/TimelineThumbnailRepository.swift +148 -0
- package/ios/Video/Export/VideoExporter.swift +275 -0
- package/ios/Video/Render/VideoClip.swift +21 -0
- package/ios/Video/Render/VideoEditSession.swift +99 -0
- package/ios/Video/Render/VideoThumbnailGenerator.swift +25 -0
- package/ios/Video/Render/VideoTransformState.swift +23 -0
- package/ios/Video/UI/TrimRangeView.swift +91 -0
- package/lib/module/NativePhotoVideoEditor.js +5 -0
- package/lib/module/NativePhotoVideoEditor.js.map +1 -0
- package/lib/module/errors.js +29 -0
- package/lib/module/errors.js.map +1 -0
- package/lib/module/events.js +27 -0
- package/lib/module/events.js.map +1 -0
- package/lib/module/index.js +91 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/project.js +4 -0
- package/lib/module/project.js.map +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativePhotoVideoEditor.d.ts +11 -0
- package/lib/typescript/src/NativePhotoVideoEditor.d.ts.map +1 -0
- package/lib/typescript/src/errors.d.ts +11 -0
- package/lib/typescript/src/errors.d.ts.map +1 -0
- package/lib/typescript/src/events.d.ts +5 -0
- package/lib/typescript/src/events.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +15 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/project.d.ts +24 -0
- package/lib/typescript/src/project.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +108 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +154 -0
- package/src/NativePhotoVideoEditor.ts +12 -0
- package/src/errors.ts +78 -0
- package/src/events.ts +43 -0
- package/src/index.ts +123 -0
- package/src/project.ts +17 -0
- 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
|
+
}
|