react-native-photo-video-editor 0.1.0 → 0.2.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 +20 -0
- package/PhotoVideoEditor.podspec +1 -0
- package/README.md +10 -6
- package/THIRD_PARTY_NOTICES.md +20 -0
- package/android/build.gradle +1 -1
- package/android/src/main/AndroidManifest.xml +1 -1
- package/android/src/main/assets/Stickers/AUTUMN_1.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_10.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_11.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_12.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_13.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_14.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_15.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_16.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_17.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_18.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_2.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_3.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_4.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_5.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_6.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_7.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_8.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_9.png +0 -0
- package/android/src/main/assets/Stickers/LOL_1.png +0 -0
- package/android/src/main/assets/Stickers/LOL_10.png +0 -0
- package/android/src/main/assets/Stickers/LOL_11.png +0 -0
- package/android/src/main/assets/Stickers/LOL_12.png +0 -0
- package/android/src/main/assets/Stickers/LOL_13.png +0 -0
- package/android/src/main/assets/Stickers/LOL_14.png +0 -0
- package/android/src/main/assets/Stickers/LOL_15.png +0 -0
- package/android/src/main/assets/Stickers/LOL_16.png +0 -0
- package/android/src/main/assets/Stickers/LOL_17.png +0 -0
- package/android/src/main/assets/Stickers/LOL_18.png +0 -0
- package/android/src/main/assets/Stickers/LOL_19.png +0 -0
- package/android/src/main/assets/Stickers/LOL_2.png +0 -0
- package/android/src/main/assets/Stickers/LOL_3.png +0 -0
- package/android/src/main/assets/Stickers/LOL_4.png +0 -0
- package/android/src/main/assets/Stickers/LOL_5.png +0 -0
- package/android/src/main/assets/Stickers/LOL_6.png +0 -0
- package/android/src/main/assets/Stickers/LOL_7.png +0 -0
- package/android/src/main/assets/Stickers/LOL_8.png +0 -0
- package/android/src/main/assets/Stickers/LOL_9.png +0 -0
- package/android/src/main/assets/Stickers/SPRING_1.png +0 -0
- package/android/src/main/assets/Stickers/SPRING_2.png +0 -0
- package/android/src/main/assets/Stickers/SPRING_3.png +0 -0
- package/android/src/main/assets/Stickers/SPRING_4.png +0 -0
- package/android/src/main/assets/Stickers/SPRING_5.png +0 -0
- package/android/src/main/assets/Stickers/SPRING_6.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_1.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_10.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_11.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_12.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_13.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_14.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_15.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_16.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_17.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_18.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_19.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_2.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_20.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_21.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_3.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_4.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_5.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_6.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_7.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_8.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_9.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_1.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_12.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_13.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_14.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_15.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_16.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_2.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_25.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_26.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_27.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_28.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_29.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_3.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_30.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_31.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_32.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_7.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_8.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_9.png +0 -0
- package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +901 -651
- package/android/src/main/java/com/photovideoeditor/photo/export/PhotoExporter.kt +8 -23
- package/android/src/main/java/com/photovideoeditor/photo/render/CropGeometry.kt +71 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/OverlayGeometry.kt +36 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoEditSession.kt +5 -7
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoFilterPresets.kt +71 -41
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayer.kt +5 -1
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerRenderer.kt +24 -1
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformRenderer.kt +33 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformState.kt +6 -1
- package/android/src/main/java/com/photovideoeditor/photo/ui/CropOverlayView.kt +112 -122
- package/android/src/main/java/com/photovideoeditor/photo/ui/DrawOverlayView.kt +66 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +63 -14
- package/android/src/main/java/com/photovideoeditor/photo/ui/ZoomableImageView.kt +10 -0
- package/android/src/main/java/com/photovideoeditor/stickers/OnlineStickerSheet.kt +135 -104
- package/android/src/main/java/com/photovideoeditor/ui/components/CropPanel.kt +122 -0
- package/android/src/main/java/com/photovideoeditor/ui/components/EditorPanel.kt +250 -0
- package/android/src/main/java/com/photovideoeditor/ui/components/EditorToolSlider.kt +103 -0
- package/android/src/main/java/com/photovideoeditor/video/export/VideoExporter.kt +38 -11
- package/android/src/main/java/com/photovideoeditor/video/render/VideoCropEffects.kt +35 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoEditSession.kt +10 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoTransformState.kt +1 -0
- package/android/src/main/res/drawable/ic_draw.xml +6 -0
- package/android/src/main/res/drawable/ic_pause.xml +7 -0
- package/android/src/main/res/drawable/ic_play.xml +7 -0
- package/docs/api.md +1 -1
- package/docs/editor-ui-validation.md +22 -0
- package/docs/photo-editor.md +5 -6
- package/ios/Editor/PhotoVideoEditorViewController.swift +918 -494
- package/ios/Editor/UI/CropPanel.swift +97 -0
- package/ios/Editor/UI/EditorPanel.swift +334 -0
- package/ios/Editor/UI/EditorToolSlider.swift +22 -0
- package/ios/Editor/UI/TextEditorSheet.swift +84 -0
- package/ios/Photo/Export/PhotoExporter.swift +3 -3
- package/ios/Photo/Rendering/CropGeometry.swift +48 -0
- package/ios/Photo/Rendering/PhotoEditSession.swift +21 -10
- package/ios/Photo/Rendering/PhotoFilterPresets.swift +53 -23
- package/ios/Photo/Rendering/PhotoLayer.swift +14 -2
- package/ios/Photo/Rendering/PhotoLayerRenderer.swift +16 -1
- package/ios/Photo/Rendering/PhotoTransformState.swift +5 -0
- package/ios/Photo/UI/CropOverlayView.swift +98 -134
- package/ios/Photo/UI/DrawOverlayView.swift +48 -0
- package/ios/Photo/UI/FilterThumbnailLoader.swift +62 -0
- package/ios/Photo/UI/LayerOverlayView.swift +36 -11
- package/ios/Photo/UI/ZoomableImageView.swift +19 -1
- package/ios/Resources/Stickers/AUTUMN_1.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_10.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_11.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_12.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_13.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_14.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_15.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_16.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_17.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_18.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_2.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_3.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_4.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_5.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_6.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_7.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_8.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_9.png +0 -0
- package/ios/Resources/Stickers/LOL_1.png +0 -0
- package/ios/Resources/Stickers/LOL_10.png +0 -0
- package/ios/Resources/Stickers/LOL_11.png +0 -0
- package/ios/Resources/Stickers/LOL_12.png +0 -0
- package/ios/Resources/Stickers/LOL_13.png +0 -0
- package/ios/Resources/Stickers/LOL_14.png +0 -0
- package/ios/Resources/Stickers/LOL_15.png +0 -0
- package/ios/Resources/Stickers/LOL_16.png +0 -0
- package/ios/Resources/Stickers/LOL_17.png +0 -0
- package/ios/Resources/Stickers/LOL_18.png +0 -0
- package/ios/Resources/Stickers/LOL_19.png +0 -0
- package/ios/Resources/Stickers/LOL_2.png +0 -0
- package/ios/Resources/Stickers/LOL_3.png +0 -0
- package/ios/Resources/Stickers/LOL_4.png +0 -0
- package/ios/Resources/Stickers/LOL_5.png +0 -0
- package/ios/Resources/Stickers/LOL_6.png +0 -0
- package/ios/Resources/Stickers/LOL_7.png +0 -0
- package/ios/Resources/Stickers/LOL_8.png +0 -0
- package/ios/Resources/Stickers/LOL_9.png +0 -0
- package/ios/Resources/Stickers/SPRING_1.png +0 -0
- package/ios/Resources/Stickers/SPRING_2.png +0 -0
- package/ios/Resources/Stickers/SPRING_3.png +0 -0
- package/ios/Resources/Stickers/SPRING_4.png +0 -0
- package/ios/Resources/Stickers/SPRING_5.png +0 -0
- package/ios/Resources/Stickers/SPRING_6.png +0 -0
- package/ios/Resources/Stickers/SUMMER_1.png +0 -0
- package/ios/Resources/Stickers/SUMMER_10.png +0 -0
- package/ios/Resources/Stickers/SUMMER_11.png +0 -0
- package/ios/Resources/Stickers/SUMMER_12.png +0 -0
- package/ios/Resources/Stickers/SUMMER_13.png +0 -0
- package/ios/Resources/Stickers/SUMMER_14.png +0 -0
- package/ios/Resources/Stickers/SUMMER_15.png +0 -0
- package/ios/Resources/Stickers/SUMMER_16.png +0 -0
- package/ios/Resources/Stickers/SUMMER_17.png +0 -0
- package/ios/Resources/Stickers/SUMMER_18.png +0 -0
- package/ios/Resources/Stickers/SUMMER_19.png +0 -0
- package/ios/Resources/Stickers/SUMMER_2.png +0 -0
- package/ios/Resources/Stickers/SUMMER_20.png +0 -0
- package/ios/Resources/Stickers/SUMMER_21.png +0 -0
- package/ios/Resources/Stickers/SUMMER_3.png +0 -0
- package/ios/Resources/Stickers/SUMMER_4.png +0 -0
- package/ios/Resources/Stickers/SUMMER_5.png +0 -0
- package/ios/Resources/Stickers/SUMMER_6.png +0 -0
- package/ios/Resources/Stickers/SUMMER_7.png +0 -0
- package/ios/Resources/Stickers/SUMMER_8.png +0 -0
- package/ios/Resources/Stickers/SUMMER_9.png +0 -0
- package/ios/Resources/Stickers/TEXT_1.png +0 -0
- package/ios/Resources/Stickers/TEXT_12.png +0 -0
- package/ios/Resources/Stickers/TEXT_13.png +0 -0
- package/ios/Resources/Stickers/TEXT_14.png +0 -0
- package/ios/Resources/Stickers/TEXT_15.png +0 -0
- package/ios/Resources/Stickers/TEXT_16.png +0 -0
- package/ios/Resources/Stickers/TEXT_2.png +0 -0
- package/ios/Resources/Stickers/TEXT_25.png +0 -0
- package/ios/Resources/Stickers/TEXT_26.png +0 -0
- package/ios/Resources/Stickers/TEXT_27.png +0 -0
- package/ios/Resources/Stickers/TEXT_28.png +0 -0
- package/ios/Resources/Stickers/TEXT_29.png +0 -0
- package/ios/Resources/Stickers/TEXT_3.png +0 -0
- package/ios/Resources/Stickers/TEXT_30.png +0 -0
- package/ios/Resources/Stickers/TEXT_31.png +0 -0
- package/ios/Resources/Stickers/TEXT_32.png +0 -0
- package/ios/Resources/Stickers/TEXT_7.png +0 -0
- package/ios/Resources/Stickers/TEXT_8.png +0 -0
- package/ios/Resources/Stickers/TEXT_9.png +0 -0
- package/ios/Resources/Stickers/a_pety_icon.png +0 -0
- package/ios/Stickers/OnlineStickerSheetViewController.swift +102 -159
- package/ios/Video/Export/VideoExporter.swift +10 -1
- package/ios/Video/Render/VideoCropGeometry.swift +48 -0
- package/ios/Video/Render/VideoTransformState.swift +1 -0
- package/lib/module/index.js +2 -2
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/types.d.ts +2 -2
- package/lib/typescript/src/types.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/index.ts +1 -1
- package/src/types.ts +2 -2
- package/android/src/main/java/com/photovideoeditor/stickers/OpenMojiCatalog.kt +0 -172
- package/android/src/main/java/com/photovideoeditor/stickers/RemoteImageLoader.kt +0 -110
- package/ios/Stickers/OpenMojiCatalog.swift +0 -188
- package/ios/Stickers/RemoteImageLoader.swift +0 -127
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// Shared compact photo/video crop panel. Draft edits remain in the session, never in this view.
|
|
4
|
+
final class CropPanel: UIStackView {
|
|
5
|
+
private let slider = CropRulerSlider()
|
|
6
|
+
private let degrees = UILabel()
|
|
7
|
+
private var presets: [String:UIButton] = [:]
|
|
8
|
+
private let accent: UIColor
|
|
9
|
+
private let foreground: UIColor
|
|
10
|
+
|
|
11
|
+
init(accent: UIColor, surface: UIColor, foreground: UIColor, originalRatio: @escaping () -> CGFloat,
|
|
12
|
+
onReset: @escaping () -> Void, onDone: @escaping () -> Void, onCancel: @escaping () -> Void,
|
|
13
|
+
onStraighten: @escaping (CGFloat) -> Void, onRotate: @escaping () -> Void,
|
|
14
|
+
onRatio: @escaping (String, CGFloat?) -> Void) {
|
|
15
|
+
self.accent = accent; self.foreground = foreground
|
|
16
|
+
super.init(frame:.zero)
|
|
17
|
+
axis = .vertical; backgroundColor = surface
|
|
18
|
+
layer.cornerRadius = 20; layer.maskedCorners = [.layerMinXMinYCorner,.layerMaxXMinYCorner]
|
|
19
|
+
isLayoutMarginsRelativeArrangement = true; layoutMargins = UIEdgeInsets(top:8,left:12,bottom:8,right:12)
|
|
20
|
+
func icon(_ symbol: String, label: String, action: @escaping () -> Void) -> UIButton {
|
|
21
|
+
let button = UIButton(type:.system)
|
|
22
|
+
button.setImage(UIImage(systemName:symbol),for:.normal); button.tintColor = foreground; button.accessibilityLabel = label
|
|
23
|
+
button.widthAnchor.constraint(equalToConstant:44).isActive = true
|
|
24
|
+
button.addAction(UIAction { _ in action() },for:.touchUpInside); button.applyPressScale()
|
|
25
|
+
return button
|
|
26
|
+
}
|
|
27
|
+
let reset = UIButton(type:.system)
|
|
28
|
+
var resetStyle = UIButton.Configuration.plain(); resetStyle.title = "Reset"; resetStyle.image = UIImage(systemName:"arrow.counterclockwise")
|
|
29
|
+
resetStyle.imagePadding = 6; resetStyle.baseForegroundColor = foreground
|
|
30
|
+
reset.configuration = resetStyle; reset.addAction(UIAction { _ in onReset() },for:.touchUpInside)
|
|
31
|
+
reset.widthAnchor.constraint(equalToConstant:88).isActive = true
|
|
32
|
+
let title = UILabel(); title.text = "Crop"; title.font = .systemFont(ofSize:17,weight:.semibold); title.textColor = foreground; title.textAlignment = .center
|
|
33
|
+
let done = icon("checkmark",label:"Apply crop",action:onDone); done.tintColor = accent
|
|
34
|
+
let header = UIStackView(arrangedSubviews:[reset,title,icon("xmark",label:"Cancel crop",action:onCancel),done]); header.axis = .horizontal
|
|
35
|
+
header.heightAnchor.constraint(equalToConstant:48).isActive = true; addArrangedSubview(header)
|
|
36
|
+
let label = UILabel(); label.text = "Straighten"; label.font = .systemFont(ofSize:13); label.textColor = foreground
|
|
37
|
+
degrees.font = .monospacedDigitSystemFont(ofSize:13,weight:.medium); degrees.textColor = accent; degrees.textAlignment = .center
|
|
38
|
+
degrees.widthAnchor.constraint(equalToConstant:56).isActive = true
|
|
39
|
+
let labels = UIStackView(arrangedSubviews:[label,degrees,icon("arrow.counterclockwise",label:"Reset straighten") { onStraighten(0) },icon("rotate.right",label:"Rotate 90 degrees",action:onRotate)])
|
|
40
|
+
labels.axis = .horizontal; labels.heightAnchor.constraint(equalToConstant:44).isActive = true; addArrangedSubview(labels)
|
|
41
|
+
slider.minimumValue = -45; slider.maximumValue = 45; slider.minimumTrackTintColor = accent; slider.thumbTintColor = accent
|
|
42
|
+
slider.accessibilityLabel = "Straighten"
|
|
43
|
+
slider.addAction(UIAction { [weak slider] _ in if let slider { onStraighten(CGFloat((slider.value*10).rounded()/10)) } },for:.valueChanged)
|
|
44
|
+
slider.heightAnchor.constraint(equalToConstant:48).isActive = true; addArrangedSubview(slider)
|
|
45
|
+
let rail = UIStackView(); rail.axis = .horizontal
|
|
46
|
+
let ratios: [(String,CGFloat?)] = [("Free",nil),("Original",nil),("1:1",1),("9:16",9/16),("16:9",16/9),("10:16",10/16),("16:10",16/10),("4:5",4/5),("5:4",5/4),("3:4",3/4),("4:3",4/3)]
|
|
47
|
+
for (name,ratio) in ratios {
|
|
48
|
+
let control = UIButton(type:.system)
|
|
49
|
+
var config = UIButton.Configuration.plain(); config.title = name; config.imagePlacement = .top; config.imagePadding = 6
|
|
50
|
+
config.baseForegroundColor = foreground
|
|
51
|
+
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { value in var output = value; output.font = .systemFont(ofSize:11); return output }
|
|
52
|
+
let aspect = name == "Original" ? max(0.01,originalRatio()) : ratio ?? 1.25
|
|
53
|
+
config.image = UIGraphicsImageRenderer(size:CGSize(width:30,height:30)).image { _ in
|
|
54
|
+
let w = min(24,24*aspect), h = w/aspect
|
|
55
|
+
let path = UIBezierPath(roundedRect:CGRect(x:(30-w)/2,y:(30-h)/2,width:w,height:h),cornerRadius:2)
|
|
56
|
+
UIColor.white.setStroke(); path.lineWidth = 1.5; path.stroke()
|
|
57
|
+
}.withRenderingMode(.alwaysTemplate)
|
|
58
|
+
control.configuration = config; control.layer.cornerRadius = 12
|
|
59
|
+
control.accessibilityLabel = "\(name) crop ratio"
|
|
60
|
+
control.widthAnchor.constraint(equalToConstant:64).isActive = true
|
|
61
|
+
control.addAction(UIAction { _ in onRatio(name,name == "Original" ? originalRatio() : ratio) },for:.touchUpInside)
|
|
62
|
+
control.applyPressScale(); rail.addArrangedSubview(control); presets[name] = control
|
|
63
|
+
}
|
|
64
|
+
let scroll = UIScrollView(); scroll.showsHorizontalScrollIndicator = false; scroll.addSubview(rail)
|
|
65
|
+
rail.translatesAutoresizingMaskIntoConstraints = false
|
|
66
|
+
NSLayoutConstraint.activate([
|
|
67
|
+
rail.leadingAnchor.constraint(equalTo:scroll.contentLayoutGuide.leadingAnchor), rail.trailingAnchor.constraint(equalTo:scroll.contentLayoutGuide.trailingAnchor),
|
|
68
|
+
rail.topAnchor.constraint(equalTo:scroll.contentLayoutGuide.topAnchor),rail.bottomAnchor.constraint(equalTo:scroll.contentLayoutGuide.bottomAnchor),
|
|
69
|
+
rail.heightAnchor.constraint(equalTo:scroll.frameLayoutGuide.heightAnchor), scroll.heightAnchor.constraint(equalToConstant:72)
|
|
70
|
+
])
|
|
71
|
+
addArrangedSubview(scroll)
|
|
72
|
+
}
|
|
73
|
+
required init(coder:NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
74
|
+
func sync(_ state: PhotoTransformState) {
|
|
75
|
+
slider.value = Float(state.straightenDegrees); degrees.text = String(format:"%.1f°",Double(state.straightenDegrees))
|
|
76
|
+
for (name,control) in presets {
|
|
77
|
+
let selected = name == state.aspectPreset
|
|
78
|
+
control.configuration?.baseForegroundColor = selected ? accent : foreground
|
|
79
|
+
control.backgroundColor = selected ? accent.withAlphaComponent(0.13) : .clear
|
|
80
|
+
control.accessibilityTraits = selected ? [.button,.selected] : .button
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private final class CropRulerSlider: UISlider {
|
|
86
|
+
override func draw(_ rect:CGRect) {
|
|
87
|
+
if let context = UIGraphicsGetCurrentContext() {
|
|
88
|
+
context.setLineWidth(0.5)
|
|
89
|
+
for i in 0...90 {
|
|
90
|
+
let x = 12+(bounds.width-24)*CGFloat(i)/90
|
|
91
|
+
context.setStrokeColor(UIColor.white.withAlphaComponent(i % 5 == 0 ? 0.5 : 0.2).cgColor)
|
|
92
|
+
context.move(to:CGPoint(x:x,y:2)); context.addLine(to:CGPoint(x:x,y:i % 5 == 0 ? 12 : 7)); context.strokePath()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
super.draw(rect)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// Shared building blocks for the editor's bottom panels (Crop / Adjust / Filters / Text / Stickers).
|
|
4
|
+
/// Mirrors `EditorPanel.kt` — keep the two in step so the platforms don't drift apart again.
|
|
5
|
+
///
|
|
6
|
+
/// Before this existed, Adjust and Filters shared one bar of plain text buttons. Every panel now
|
|
7
|
+
/// composes the same three pieces — `editorPanelHeader`, one slider, one strip — so heights,
|
|
8
|
+
/// padding, radii, typography and selected states stay consistent.
|
|
9
|
+
enum EditorPanelMetrics {
|
|
10
|
+
static let headerHeight: CGFloat = 40
|
|
11
|
+
static let sliderHeight: CGFloat = 48
|
|
12
|
+
static let stripHeight: CGFloat = 76
|
|
13
|
+
static let stripItemWidth: CGFloat = 68
|
|
14
|
+
static let iconSize: CGFloat = 22
|
|
15
|
+
static let thumbnailWidth: CGFloat = 64
|
|
16
|
+
static let thumbnailHeight: CGFloat = 72
|
|
17
|
+
static var totalHeight: CGFloat { headerHeight + sliderHeight + stripHeight }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// Compact panel header: `Title [Reset] Done`.
|
|
21
|
+
/// "Done" is an action, not a tool — it gets a 44pt touch target while staying visually small.
|
|
22
|
+
func editorPanelHeader(
|
|
23
|
+
title: String,
|
|
24
|
+
accentColor: UIColor,
|
|
25
|
+
secondaryLabel: String? = nil,
|
|
26
|
+
onSecondary: (() -> Void)? = nil,
|
|
27
|
+
onDone: @escaping () -> Void
|
|
28
|
+
) -> UIView {
|
|
29
|
+
let titleLabel = UILabel()
|
|
30
|
+
titleLabel.text = title
|
|
31
|
+
titleLabel.font = .systemFont(ofSize: 14, weight: .bold)
|
|
32
|
+
titleLabel.textColor = DesignTokens.textPrimary
|
|
33
|
+
|
|
34
|
+
let row = UIStackView(arrangedSubviews: [titleLabel])
|
|
35
|
+
row.axis = .horizontal
|
|
36
|
+
row.alignment = .center
|
|
37
|
+
row.spacing = DesignTokens.spaceSm
|
|
38
|
+
row.isLayoutMarginsRelativeArrangement = true
|
|
39
|
+
row.layoutMargins = UIEdgeInsets(top: 0, left: DesignTokens.spaceLg, bottom: 0, right: DesignTokens.spaceSm)
|
|
40
|
+
|
|
41
|
+
let spacer = UIView()
|
|
42
|
+
spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
43
|
+
row.addArrangedSubview(spacer)
|
|
44
|
+
|
|
45
|
+
if let secondaryLabel, let onSecondary {
|
|
46
|
+
row.addArrangedSubview(editorHeaderAction(label: secondaryLabel, color: DesignTokens.textSecondary, action: onSecondary))
|
|
47
|
+
}
|
|
48
|
+
row.addArrangedSubview(editorHeaderAction(label: "Done", color: accentColor, action: onDone))
|
|
49
|
+
return row
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Small text action sized for touch (44pt) without looking like a button.
|
|
53
|
+
private func editorHeaderAction(label: String, color: UIColor, action: @escaping () -> Void) -> UIButton {
|
|
54
|
+
let control = UIButton(type: .system)
|
|
55
|
+
var config = UIButton.Configuration.plain()
|
|
56
|
+
config.title = label
|
|
57
|
+
config.baseForegroundColor = color
|
|
58
|
+
config.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: DesignTokens.spaceMd, bottom: 0, trailing: DesignTokens.spaceMd)
|
|
59
|
+
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
|
|
60
|
+
var value = incoming; value.font = .systemFont(ofSize: 13, weight: .bold); return value
|
|
61
|
+
}
|
|
62
|
+
control.configuration = config
|
|
63
|
+
control.widthAnchor.constraint(greaterThanOrEqualToConstant: DesignTokens.touchTargetMin).isActive = true
|
|
64
|
+
control.heightAnchor.constraint(equalToConstant: DesignTokens.touchTargetMin).isActive = true
|
|
65
|
+
control.addAction(UIAction { _ in action() }, for: .touchUpInside)
|
|
66
|
+
control.applyPressScale()
|
|
67
|
+
return control
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// Horizontally scrollable strip; the container every panel's list sits in.
|
|
71
|
+
func editorToolStrip(_ content: UIStackView) -> UIScrollView {
|
|
72
|
+
let scroll = UIScrollView()
|
|
73
|
+
scroll.showsHorizontalScrollIndicator = false
|
|
74
|
+
content.axis = .horizontal
|
|
75
|
+
content.alignment = .center
|
|
76
|
+
scroll.addSubview(content)
|
|
77
|
+
content.translatesAutoresizingMaskIntoConstraints = false
|
|
78
|
+
NSLayoutConstraint.activate([
|
|
79
|
+
content.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: DesignTokens.spaceMd),
|
|
80
|
+
content.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -DesignTokens.spaceMd),
|
|
81
|
+
content.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
|
|
82
|
+
content.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
|
|
83
|
+
content.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
|
|
84
|
+
])
|
|
85
|
+
return scroll
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// An icon + single-line label entry in a strip, with an optional "value changed" dot.
|
|
89
|
+
final class EditorStripItem {
|
|
90
|
+
let root: UIView
|
|
91
|
+
private let iconHolder: UIView
|
|
92
|
+
private let icon: UIImageView
|
|
93
|
+
private let label: UILabel
|
|
94
|
+
private let dot: UIView
|
|
95
|
+
|
|
96
|
+
init(root: UIView, iconHolder: UIView, icon: UIImageView, label: UILabel, dot: UIView) {
|
|
97
|
+
self.root = root
|
|
98
|
+
self.iconHolder = iconHolder
|
|
99
|
+
self.icon = icon
|
|
100
|
+
self.label = label
|
|
101
|
+
self.dot = dot
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// Selected state is a purple icon + label over a faint purple disc — never a filled purple tile,
|
|
105
|
+
/// which at this size reads as a giant button rather than a selection.
|
|
106
|
+
func setSelected(_ selected: Bool, accentColor: UIColor) {
|
|
107
|
+
icon.tintColor = selected ? accentColor : DesignTokens.textSecondary
|
|
108
|
+
label.textColor = selected ? accentColor : DesignTokens.textSecondary
|
|
109
|
+
iconHolder.backgroundColor = selected ? accentColor.withAlphaComponent(0.18) : .clear
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// Tiny dot marking an adjustment that is no longer at its default value.
|
|
113
|
+
func setModified(_ modified: Bool, accentColor: UIColor) {
|
|
114
|
+
dot.isHidden = !modified
|
|
115
|
+
dot.backgroundColor = accentColor
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
func editorStripItem(systemName: String, labelText: String, action: @escaping () -> Void) -> EditorStripItem {
|
|
120
|
+
let icon = UIImageView(image: UIImage(systemName: systemName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .regular)))
|
|
121
|
+
icon.tintColor = DesignTokens.textSecondary
|
|
122
|
+
icon.contentMode = .scaleAspectFit
|
|
123
|
+
|
|
124
|
+
let iconHolder = UIView()
|
|
125
|
+
iconHolder.layer.cornerRadius = 16
|
|
126
|
+
iconHolder.addSubview(icon)
|
|
127
|
+
icon.translatesAutoresizingMaskIntoConstraints = false
|
|
128
|
+
NSLayoutConstraint.activate([
|
|
129
|
+
iconHolder.widthAnchor.constraint(equalToConstant: 32),
|
|
130
|
+
iconHolder.heightAnchor.constraint(equalToConstant: 32),
|
|
131
|
+
icon.centerXAnchor.constraint(equalTo: iconHolder.centerXAnchor),
|
|
132
|
+
icon.centerYAnchor.constraint(equalTo: iconHolder.centerYAnchor),
|
|
133
|
+
icon.widthAnchor.constraint(equalToConstant: EditorPanelMetrics.iconSize),
|
|
134
|
+
icon.heightAnchor.constraint(equalToConstant: EditorPanelMetrics.iconSize),
|
|
135
|
+
])
|
|
136
|
+
|
|
137
|
+
let label = UILabel()
|
|
138
|
+
label.text = labelText
|
|
139
|
+
label.font = .systemFont(ofSize: 11, weight: .medium)
|
|
140
|
+
label.textColor = DesignTokens.textSecondary
|
|
141
|
+
label.textAlignment = .center
|
|
142
|
+
// Labels like "Brightness" and "Saturation" must never wrap to a second line.
|
|
143
|
+
label.numberOfLines = 1
|
|
144
|
+
label.adjustsFontSizeToFitWidth = true
|
|
145
|
+
label.minimumScaleFactor = 0.85
|
|
146
|
+
|
|
147
|
+
let dot = UIView()
|
|
148
|
+
dot.layer.cornerRadius = 2
|
|
149
|
+
dot.isHidden = true
|
|
150
|
+
dot.widthAnchor.constraint(equalToConstant: 4).isActive = true
|
|
151
|
+
dot.heightAnchor.constraint(equalToConstant: 4).isActive = true
|
|
152
|
+
|
|
153
|
+
let column = UIStackView(arrangedSubviews: [iconHolder, label, dot])
|
|
154
|
+
column.axis = .vertical
|
|
155
|
+
column.alignment = .center
|
|
156
|
+
column.spacing = DesignTokens.spaceXs
|
|
157
|
+
|
|
158
|
+
let root = UIView()
|
|
159
|
+
root.addSubview(column)
|
|
160
|
+
column.translatesAutoresizingMaskIntoConstraints = false
|
|
161
|
+
NSLayoutConstraint.activate([
|
|
162
|
+
root.widthAnchor.constraint(equalToConstant: EditorPanelMetrics.stripItemWidth),
|
|
163
|
+
column.centerXAnchor.constraint(equalTo: root.centerXAnchor),
|
|
164
|
+
column.centerYAnchor.constraint(equalTo: root.centerYAnchor),
|
|
165
|
+
column.leadingAnchor.constraint(greaterThanOrEqualTo: root.leadingAnchor),
|
|
166
|
+
column.trailingAnchor.constraint(lessThanOrEqualTo: root.trailingAnchor),
|
|
167
|
+
])
|
|
168
|
+
root.isAccessibilityElement = true
|
|
169
|
+
root.accessibilityLabel = labelText
|
|
170
|
+
root.accessibilityTraits = .button
|
|
171
|
+
root.addGestureRecognizer(ClosureTapGestureRecognizer(action: action))
|
|
172
|
+
root.applyPressScale()
|
|
173
|
+
return EditorStripItem(root: root, iconHolder: iconHolder, icon: icon, label: label, dot: dot)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/// Filter thumbnail: a preview of the current photo, outlined in purple when selected.
|
|
177
|
+
/// A thin outline rather than a filled container, so the photo stays readable.
|
|
178
|
+
final class EditorFilterThumbnail {
|
|
179
|
+
let root: UIView
|
|
180
|
+
let image: UIImageView
|
|
181
|
+
private let frame: UIView
|
|
182
|
+
private let label: UILabel
|
|
183
|
+
|
|
184
|
+
init(root: UIView, image: UIImageView, frame: UIView, label: UILabel) {
|
|
185
|
+
self.root = root
|
|
186
|
+
self.image = image
|
|
187
|
+
self.frame = frame
|
|
188
|
+
self.label = label
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
func setSelected(_ selected: Bool, accentColor: UIColor) {
|
|
192
|
+
frame.layer.borderColor = (selected ? accentColor : UIColor.clear).cgColor
|
|
193
|
+
frame.layer.borderWidth = 2
|
|
194
|
+
label.textColor = selected ? accentColor : DesignTokens.textSecondary
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
func editorFilterThumbnail(labelText: String, action: @escaping () -> Void) -> EditorFilterThumbnail {
|
|
199
|
+
let image = UIImageView()
|
|
200
|
+
image.contentMode = .scaleAspectFill
|
|
201
|
+
image.clipsToBounds = true
|
|
202
|
+
image.layer.cornerRadius = DesignTokens.radiusMd
|
|
203
|
+
image.backgroundColor = DesignTokens.surfaceContainerHigh
|
|
204
|
+
|
|
205
|
+
// The outline lives on a frame *around* the image so the stroke never crops the preview.
|
|
206
|
+
let frame = UIView()
|
|
207
|
+
frame.layer.cornerRadius = DesignTokens.radiusMd + 2
|
|
208
|
+
frame.addSubview(image)
|
|
209
|
+
image.translatesAutoresizingMaskIntoConstraints = false
|
|
210
|
+
NSLayoutConstraint.activate([
|
|
211
|
+
image.widthAnchor.constraint(equalToConstant: EditorPanelMetrics.thumbnailWidth),
|
|
212
|
+
image.heightAnchor.constraint(equalToConstant: EditorPanelMetrics.thumbnailHeight),
|
|
213
|
+
image.leadingAnchor.constraint(equalTo: frame.leadingAnchor, constant: 2),
|
|
214
|
+
image.trailingAnchor.constraint(equalTo: frame.trailingAnchor, constant: -2),
|
|
215
|
+
image.topAnchor.constraint(equalTo: frame.topAnchor, constant: 2),
|
|
216
|
+
image.bottomAnchor.constraint(equalTo: frame.bottomAnchor, constant: -2),
|
|
217
|
+
])
|
|
218
|
+
|
|
219
|
+
let label = UILabel()
|
|
220
|
+
label.text = labelText
|
|
221
|
+
label.font = .systemFont(ofSize: 11, weight: .medium)
|
|
222
|
+
label.textColor = DesignTokens.textSecondary
|
|
223
|
+
label.textAlignment = .center
|
|
224
|
+
label.numberOfLines = 1
|
|
225
|
+
label.adjustsFontSizeToFitWidth = true
|
|
226
|
+
label.minimumScaleFactor = 0.8
|
|
227
|
+
|
|
228
|
+
let column = UIStackView(arrangedSubviews: [frame, label])
|
|
229
|
+
column.axis = .vertical
|
|
230
|
+
column.alignment = .center
|
|
231
|
+
column.spacing = DesignTokens.spaceXs
|
|
232
|
+
|
|
233
|
+
let root = UIView()
|
|
234
|
+
root.addSubview(column)
|
|
235
|
+
column.translatesAutoresizingMaskIntoConstraints = false
|
|
236
|
+
NSLayoutConstraint.activate([
|
|
237
|
+
root.widthAnchor.constraint(equalToConstant: EditorPanelMetrics.thumbnailWidth + 12),
|
|
238
|
+
column.centerXAnchor.constraint(equalTo: root.centerXAnchor),
|
|
239
|
+
column.centerYAnchor.constraint(equalTo: root.centerYAnchor),
|
|
240
|
+
label.widthAnchor.constraint(equalTo: root.widthAnchor),
|
|
241
|
+
])
|
|
242
|
+
root.isAccessibilityElement = true
|
|
243
|
+
root.accessibilityLabel = labelText
|
|
244
|
+
root.accessibilityTraits = .button
|
|
245
|
+
root.addGestureRecognizer(ClosureTapGestureRecognizer(action: action))
|
|
246
|
+
root.applyPressScale()
|
|
247
|
+
return EditorFilterThumbnail(root: root, image: image, frame: frame, label: label)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/// The editor's one slider: a `Label ......... value` line above a thin track, so a panel never
|
|
251
|
+
/// needs a separate caption row and the same instance can be re-pointed at whichever adjustment is
|
|
252
|
+
/// selected. Mirrors `EditorToolSlider.kt`.
|
|
253
|
+
final class EditorValueSlider: UIView {
|
|
254
|
+
let slider = UISlider()
|
|
255
|
+
private let titleLabel = UILabel()
|
|
256
|
+
private let valueLabel = UILabel()
|
|
257
|
+
private let neutralTick = UIView()
|
|
258
|
+
|
|
259
|
+
/// Value at which the underlying adjustment is neutral, for bipolar ranges. When set, a tick is
|
|
260
|
+
/// drawn there so it is obvious where "no change" sits.
|
|
261
|
+
var neutralValue: Float? {
|
|
262
|
+
didSet { updateNeutralTick() }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
var titleText: String? {
|
|
266
|
+
get { titleLabel.text }
|
|
267
|
+
set { titleLabel.text = newValue }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
var valueText: String? {
|
|
271
|
+
get { valueLabel.text }
|
|
272
|
+
set { valueLabel.text = newValue }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
init(accentColor: UIColor) {
|
|
276
|
+
super.init(frame: .zero)
|
|
277
|
+
titleLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
|
278
|
+
titleLabel.textColor = DesignTokens.textSecondary
|
|
279
|
+
valueLabel.font = .monospacedDigitSystemFont(ofSize: 12, weight: .medium)
|
|
280
|
+
valueLabel.textColor = DesignTokens.textSecondary
|
|
281
|
+
valueLabel.textAlignment = .right
|
|
282
|
+
|
|
283
|
+
slider.minimumTrackTintColor = accentColor
|
|
284
|
+
slider.maximumTrackTintColor = UIColor.white.withAlphaComponent(0.28)
|
|
285
|
+
if let thumb = UIImage(systemName: "circle.fill")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 12)) {
|
|
286
|
+
slider.setThumbImage(thumb.withTintColor(.white, renderingMode: .alwaysOriginal), for: .normal)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
neutralTick.backgroundColor = DesignTokens.textSubtle
|
|
290
|
+
neutralTick.isHidden = true
|
|
291
|
+
// Sits above the track (added last) but must never swallow a drag toward it.
|
|
292
|
+
neutralTick.isUserInteractionEnabled = false
|
|
293
|
+
|
|
294
|
+
let caption = UIStackView(arrangedSubviews: [titleLabel, valueLabel])
|
|
295
|
+
caption.axis = .horizontal
|
|
296
|
+
caption.distribution = .fill
|
|
297
|
+
valueLabel.setContentHuggingPriority(.required, for: .horizontal)
|
|
298
|
+
|
|
299
|
+
let column = UIStackView(arrangedSubviews: [caption, slider])
|
|
300
|
+
column.axis = .vertical
|
|
301
|
+
column.spacing = 2
|
|
302
|
+
column.isLayoutMarginsRelativeArrangement = true
|
|
303
|
+
column.layoutMargins = UIEdgeInsets(top: 4, left: DesignTokens.spaceLg, bottom: 4, right: DesignTokens.spaceLg)
|
|
304
|
+
|
|
305
|
+
addSubview(column)
|
|
306
|
+
addSubview(neutralTick)
|
|
307
|
+
column.translatesAutoresizingMaskIntoConstraints = false
|
|
308
|
+
NSLayoutConstraint.activate([
|
|
309
|
+
column.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
310
|
+
column.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
311
|
+
column.topAnchor.constraint(equalTo: topAnchor),
|
|
312
|
+
column.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
313
|
+
])
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
|
|
317
|
+
|
|
318
|
+
override func layoutSubviews() {
|
|
319
|
+
super.layoutSubviews()
|
|
320
|
+
updateNeutralTick()
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private func updateNeutralTick() {
|
|
324
|
+
guard let neutralValue, slider.maximumValue > slider.minimumValue, slider.bounds.width > 0 else {
|
|
325
|
+
neutralTick.isHidden = true
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
neutralTick.isHidden = false
|
|
329
|
+
let fraction = CGFloat((neutralValue - slider.minimumValue) / (slider.maximumValue - slider.minimumValue))
|
|
330
|
+
let trackFrame = slider.convert(slider.bounds, to: self)
|
|
331
|
+
let x = trackFrame.minX + trackFrame.width * fraction
|
|
332
|
+
neutralTick.frame = CGRect(x: x - 0.5, y: trackFrame.midY - 5, width: 1, height: 10)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// Native slider with a compact property title/value above its track.
|
|
4
|
+
final class EditorToolSlider: UISlider {
|
|
5
|
+
var propertyKey = "" { didSet { accessibilityLabel = propertyKey; setNeedsDisplay() } }
|
|
6
|
+
override var intrinsicContentSize: CGSize { CGSize(width: UIView.noIntrinsicMetric, height: 64) }
|
|
7
|
+
override func trackRect(forBounds bounds: CGRect) -> CGRect {
|
|
8
|
+
var rect = super.trackRect(forBounds: bounds)
|
|
9
|
+
rect.origin.y = bounds.height * 0.65
|
|
10
|
+
return rect
|
|
11
|
+
}
|
|
12
|
+
override func draw(_ rect: CGRect) {
|
|
13
|
+
super.draw(rect)
|
|
14
|
+
let title = propertyKey == "fontSize" ? "Size" : (propertyKey == "rotation" ? "Rotate" : propertyKey.capitalized)
|
|
15
|
+
let suffix = propertyKey == "rotation" ? "°" : (propertyKey == "fontSize" ? "" : "%")
|
|
16
|
+
let valueText = "\(Int(value.rounded()))\(suffix)"
|
|
17
|
+
let attributes: [NSAttributedString.Key: Any] = [.font: UIFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium), .foregroundColor: DesignTokens.textSecondary]
|
|
18
|
+
(title as NSString).draw(at: CGPoint(x: 4, y: 4), withAttributes: attributes)
|
|
19
|
+
let size = (valueText as NSString).size(withAttributes: attributes)
|
|
20
|
+
(valueText as NSString).draw(at: CGPoint(x: bounds.width - size.width - 4, y: 4), withAttributes: attributes)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// Keyboard-anchored text entry shared by photo and video. It only commits on Add/Done.
|
|
4
|
+
final class TextEditorSheet: UIViewController, UITextViewDelegate {
|
|
5
|
+
private let input = UITextView()
|
|
6
|
+
private let placeholder = UILabel()
|
|
7
|
+
private let initialText: String
|
|
8
|
+
private let editing: Bool
|
|
9
|
+
private let accent: UIColor
|
|
10
|
+
private let onSave: (String) -> Void
|
|
11
|
+
private var inputHeight: NSLayoutConstraint?
|
|
12
|
+
|
|
13
|
+
init(text: String, editing: Bool, accent: UIColor, onSave: @escaping (String) -> Void) {
|
|
14
|
+
initialText = text; self.editing = editing; self.accent = accent; self.onSave = onSave
|
|
15
|
+
super.init(nibName: nil, bundle: nil)
|
|
16
|
+
modalPresentationStyle = .overFullScreen
|
|
17
|
+
modalTransitionStyle = .crossDissolve
|
|
18
|
+
}
|
|
19
|
+
required init?(coder: NSCoder) { nil }
|
|
20
|
+
|
|
21
|
+
override func viewDidLoad() {
|
|
22
|
+
super.viewDidLoad()
|
|
23
|
+
view.backgroundColor = UIColor.black.withAlphaComponent(0.7)
|
|
24
|
+
let panel = UIStackView(); panel.axis = .vertical; panel.spacing = 16
|
|
25
|
+
panel.backgroundColor = DesignTokens.surfaceContainerHigh
|
|
26
|
+
panel.layer.cornerRadius = 16
|
|
27
|
+
panel.isLayoutMarginsRelativeArrangement = true
|
|
28
|
+
panel.layoutMargins = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
|
|
29
|
+
let title = UILabel(); title.text = editing ? "Edit Text" : "Add Text"
|
|
30
|
+
title.textColor = .white; title.font = .systemFont(ofSize: 20, weight: .semibold)
|
|
31
|
+
panel.addArrangedSubview(title)
|
|
32
|
+
input.text = initialText; input.textColor = .white; input.tintColor = accent
|
|
33
|
+
input.font = .systemFont(ofSize: 22)
|
|
34
|
+
input.backgroundColor = DesignTokens.surfaceContainerLow
|
|
35
|
+
input.layer.cornerRadius = 12
|
|
36
|
+
input.textContainerInset = UIEdgeInsets(top: 12, left: 8, bottom: 12, right: 8)
|
|
37
|
+
input.delegate = self
|
|
38
|
+
input.accessibilityLabel = "Text"
|
|
39
|
+
placeholder.text = "Type something..."; placeholder.textColor = DesignTokens.outline
|
|
40
|
+
placeholder.font = input.font; placeholder.isUserInteractionEnabled = false
|
|
41
|
+
input.addSubview(placeholder); placeholder.translatesAutoresizingMaskIntoConstraints = false
|
|
42
|
+
NSLayoutConstraint.activate([
|
|
43
|
+
placeholder.leadingAnchor.constraint(equalTo: input.leadingAnchor, constant: 13),
|
|
44
|
+
placeholder.topAnchor.constraint(equalTo: input.topAnchor, constant: 12),
|
|
45
|
+
])
|
|
46
|
+
panel.addArrangedSubview(input)
|
|
47
|
+
inputHeight = input.heightAnchor.constraint(equalToConstant: 100); inputHeight?.priority = .defaultHigh; inputHeight?.isActive = true
|
|
48
|
+
let actions = UIStackView(); actions.axis = .horizontal; actions.spacing = 12
|
|
49
|
+
actions.addArrangedSubview(UIView())
|
|
50
|
+
let cancel = UIButton(type: .system); cancel.setTitle("Cancel", for: .normal); cancel.tintColor = .white
|
|
51
|
+
cancel.addAction(UIAction { [weak self] _ in self?.close() }, for: .touchUpInside)
|
|
52
|
+
let save = UIButton(type: .system)
|
|
53
|
+
save.setTitle(editing ? "Done" : "Add", for: .normal); save.tintColor = .white
|
|
54
|
+
save.backgroundColor = accent; save.layer.cornerRadius = 12
|
|
55
|
+
save.addAction(UIAction { [weak self] _ in
|
|
56
|
+
guard let self, !self.input.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
|
57
|
+
self.onSave(self.input.text); self.close()
|
|
58
|
+
}, for: .touchUpInside)
|
|
59
|
+
for button in [cancel, save] {
|
|
60
|
+
button.widthAnchor.constraint(equalToConstant: 80).isActive = true
|
|
61
|
+
button.heightAnchor.constraint(equalToConstant: 48).isActive = true
|
|
62
|
+
actions.addArrangedSubview(button)
|
|
63
|
+
}
|
|
64
|
+
panel.addArrangedSubview(actions)
|
|
65
|
+
view.addSubview(panel); panel.translatesAutoresizingMaskIntoConstraints = false
|
|
66
|
+
NSLayoutConstraint.activate([
|
|
67
|
+
panel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 12),
|
|
68
|
+
panel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12),
|
|
69
|
+
panel.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -12),
|
|
70
|
+
panel.topAnchor.constraint(greaterThanOrEqualTo: view.safeAreaLayoutGuide.topAnchor, constant: 8),
|
|
71
|
+
])
|
|
72
|
+
textViewDidChange(input)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated); input.becomeFirstResponder() }
|
|
76
|
+
func textViewDidChange(_ textView: UITextView) {
|
|
77
|
+
placeholder.isHidden = !textView.text.isEmpty
|
|
78
|
+
let width = max(view.bounds.width - 64, 100)
|
|
79
|
+
let height = textView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)).height
|
|
80
|
+
inputHeight?.constant = min(max(height, 80), 180)
|
|
81
|
+
textView.isScrollEnabled = height > 180
|
|
82
|
+
}
|
|
83
|
+
private func close() { input.resignFirstResponder(); dismiss(animated: true) }
|
|
84
|
+
}
|
|
@@ -52,9 +52,9 @@ enum PhotoExporter {
|
|
|
52
52
|
|
|
53
53
|
let transformed = PhotoEditSession.applyTransform(upright, state: transform)
|
|
54
54
|
let adjusted = PhotoAdjustmentRenderer.apply(transformed, adjustments: adjustments)
|
|
55
|
-
let
|
|
56
|
-
let
|
|
57
|
-
let resized = resize(
|
|
55
|
+
let cropped = PhotoEditSession.crop(adjusted, state: transform)
|
|
56
|
+
let layered = PhotoLayerRenderer.render(cropped, layers: layers) { uri in resolveImageLayer(uri) }
|
|
57
|
+
let resized = resize(layered, maxWidth: maxWidth, maxHeight: maxHeight)
|
|
58
58
|
|
|
59
59
|
let format = (exportOptions?["imageFormat"] as? String)?.lowercased() ?? "jpeg"
|
|
60
60
|
let quality = qualityFor(exportOptions?["quality"] as? String ?? "high")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import CoreGraphics
|
|
2
|
+
|
|
3
|
+
/// Resolution-independent constraints; mirrors Android CropGeometry.
|
|
4
|
+
enum CropGeometry {
|
|
5
|
+
enum Handle { case move, top, right, bottom, left, topLeft, topRight, bottomLeft, bottomRight }
|
|
6
|
+
static func fillScale(width: CGFloat, height: CGFloat, degrees: CGFloat) -> CGFloat {
|
|
7
|
+
let radians = degrees * .pi / 180
|
|
8
|
+
let c = abs(cos(radians)), s = abs(sin(radians))
|
|
9
|
+
return max(c + height / width * s, c + width / height * s)
|
|
10
|
+
}
|
|
11
|
+
static func fit(_ bounds: CGRect, ratio: CGFloat?) -> CGRect {
|
|
12
|
+
guard let ratio, ratio > 0 else { return bounds }
|
|
13
|
+
let w = min(bounds.width, bounds.height * ratio), h = min(bounds.width / ratio, bounds.height)
|
|
14
|
+
return CGRect(x: bounds.midX - w / 2, y: bounds.midY - h / 2, width: w, height: h)
|
|
15
|
+
}
|
|
16
|
+
static func resize(_ r: CGRect, bounds: CGRect, handle: Handle, dx: CGFloat, dy: CGFloat, ratio: CGFloat?, minimum: CGFloat) -> CGRect {
|
|
17
|
+
if handle == .move {
|
|
18
|
+
return r.offsetBy(dx: max(bounds.minX-r.minX, min(bounds.maxX-r.maxX, dx)), dy: max(bounds.minY-r.minY, min(bounds.maxY-r.maxY, dy)))
|
|
19
|
+
}
|
|
20
|
+
let left = [Handle.left, .topLeft, .bottomLeft].contains(handle)
|
|
21
|
+
let right = [Handle.right, .topRight, .bottomRight].contains(handle)
|
|
22
|
+
let top = [Handle.top, .topLeft, .topRight].contains(handle)
|
|
23
|
+
let bottom = [Handle.bottom, .bottomLeft, .bottomRight].contains(handle)
|
|
24
|
+
var l = r.minX + (left ? dx : 0), t = r.minY + (top ? dy : 0)
|
|
25
|
+
var rr = r.maxX + (right ? dx : 0), b = r.maxY + (bottom ? dy : 0)
|
|
26
|
+
if let ratio, ratio > 0 {
|
|
27
|
+
if (left || right) && (top || bottom) {
|
|
28
|
+
let dw = left ? -dx : dx, dh = top ? -dy : dy
|
|
29
|
+
let w = r.width + (abs(dw) > abs(dh * ratio) ? dw : dh * ratio)
|
|
30
|
+
if left { l = r.maxX - w } else { rr = r.minX + w }
|
|
31
|
+
if top { t = r.maxY - w / ratio } else { b = r.minY + w / ratio }
|
|
32
|
+
} else if left || right {
|
|
33
|
+
let h = (rr-l)/ratio; t = r.midY-h/2; b = t+h
|
|
34
|
+
} else {
|
|
35
|
+
let w = (b-t)*ratio; l = r.midX-w/2; rr = l+w
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
var fraction: CGFloat = 1
|
|
39
|
+
func limit(_ before: CGFloat, _ after: CGFloat, _ minimum: CGFloat) {
|
|
40
|
+
if after < minimum && after < before { fraction = min(fraction, max(0, min(1, (before-minimum)/(before-after)))) }
|
|
41
|
+
}
|
|
42
|
+
limit(r.minX,l,bounds.minX); limit(r.minY,t,bounds.minY)
|
|
43
|
+
limit(-r.maxX,-rr,-bounds.maxX); limit(-r.maxY,-b,-bounds.maxY)
|
|
44
|
+
limit(r.width,rr-l,min(minimum,r.width)); limit(r.height,b-t,min(minimum,r.height))
|
|
45
|
+
let x = r.minX+(l-r.minX)*fraction, y = r.minY+(t-r.minY)*fraction
|
|
46
|
+
return CGRect(x: x, y: y, width: r.maxX+(rr-r.maxX)*fraction-x, height: r.maxY+(b-r.maxY)*fraction-y)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -52,8 +52,10 @@ final class PhotoEditSession {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/// Renders rotate/flip/straighten + adjustments + layers — the crop overlay draws the crop live on top of this.
|
|
55
|
-
func renderPreview() -> UIImage? {
|
|
56
|
-
guard let
|
|
55
|
+
func renderPreview(cropping: Bool = false) -> UIImage? {
|
|
56
|
+
guard let transformed = computeBase() else { return nil }
|
|
57
|
+
if cropping { return transformed }
|
|
58
|
+
let base = Self.crop(transformed, state: state)
|
|
57
59
|
if cachedPreviewBaseImage === base,
|
|
58
60
|
cachedPreviewLayerRevision == layerStack.revision,
|
|
59
61
|
let cachedPreviewImage {
|
|
@@ -94,18 +96,27 @@ final class PhotoEditSession {
|
|
|
94
96
|
|
|
95
97
|
static func applyTransform(_ image: UIImage, state: PhotoTransformState) -> UIImage {
|
|
96
98
|
guard state.rotationDegrees != 0 || state.straightenDegrees != 0 else { return image }
|
|
97
|
-
let
|
|
98
|
-
let
|
|
99
|
-
let
|
|
100
|
-
|
|
101
|
-
return renderer.image { context in
|
|
99
|
+
let swap = state.rotationDegrees % 180 != 0
|
|
100
|
+
let canvasSize = swap ? CGSize(width: image.size.height, height: image.size.width) : image.size
|
|
101
|
+
let fill = CropGeometry.fillScale(width: canvasSize.width, height: canvasSize.height, degrees: state.straightenDegrees)
|
|
102
|
+
return pixelRenderer(size: canvasSize).image { context in
|
|
102
103
|
let ctx = context.cgContext
|
|
103
|
-
ctx.translateBy(x: canvasSize.width
|
|
104
|
-
ctx.rotate(by:
|
|
105
|
-
image.draw(in:
|
|
104
|
+
ctx.translateBy(x: canvasSize.width/2,y:canvasSize.height/2)
|
|
105
|
+
ctx.rotate(by:state.totalRotationRadians); ctx.scaleBy(x:fill,y:fill)
|
|
106
|
+
image.draw(in:CGRect(x:-image.size.width/2,y:-image.size.height/2,width:image.size.width,height:image.size.height))
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
static func crop(_ image: UIImage, state: PhotoTransformState) -> UIImage {
|
|
111
|
+
guard let cg = image.cgImage else { return image }
|
|
112
|
+
let w = CGFloat(cg.width), h = CGFloat(cg.height)
|
|
113
|
+
let left = max(0,min(w-1,(state.cropLeft*w).rounded())), top = max(0,min(h-1,(state.cropTop*h).rounded()))
|
|
114
|
+
let right = max(left+1,min(w,(state.cropRight*w).rounded())), bottom = max(top+1,min(h,(state.cropBottom*h).rounded()))
|
|
115
|
+
if left == 0 && top == 0 && right == w && bottom == h { return image }
|
|
116
|
+
guard let cropped = cg.cropping(to:CGRect(x:left,y:top,width:right-left,height:bottom-top)) else { return image }
|
|
117
|
+
return UIImage(cgImage:cropped)
|
|
118
|
+
}
|
|
119
|
+
|
|
109
120
|
private static func decodeUpright(sourceUri: String, maxDimension: CGFloat) -> UIImage? {
|
|
110
121
|
guard let path = SourceResolver.resolvePath(sourceUri: sourceUri, tempPrefix: "pve_preview") else { return nil }
|
|
111
122
|
let url = URL(fileURLWithPath: path)
|