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.
Files changed (233) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/PhotoVideoEditor.podspec +1 -0
  3. package/README.md +10 -6
  4. package/THIRD_PARTY_NOTICES.md +20 -0
  5. package/android/build.gradle +1 -1
  6. package/android/src/main/AndroidManifest.xml +1 -1
  7. package/android/src/main/assets/Stickers/AUTUMN_1.png +0 -0
  8. package/android/src/main/assets/Stickers/AUTUMN_10.png +0 -0
  9. package/android/src/main/assets/Stickers/AUTUMN_11.png +0 -0
  10. package/android/src/main/assets/Stickers/AUTUMN_12.png +0 -0
  11. package/android/src/main/assets/Stickers/AUTUMN_13.png +0 -0
  12. package/android/src/main/assets/Stickers/AUTUMN_14.png +0 -0
  13. package/android/src/main/assets/Stickers/AUTUMN_15.png +0 -0
  14. package/android/src/main/assets/Stickers/AUTUMN_16.png +0 -0
  15. package/android/src/main/assets/Stickers/AUTUMN_17.png +0 -0
  16. package/android/src/main/assets/Stickers/AUTUMN_18.png +0 -0
  17. package/android/src/main/assets/Stickers/AUTUMN_2.png +0 -0
  18. package/android/src/main/assets/Stickers/AUTUMN_3.png +0 -0
  19. package/android/src/main/assets/Stickers/AUTUMN_4.png +0 -0
  20. package/android/src/main/assets/Stickers/AUTUMN_5.png +0 -0
  21. package/android/src/main/assets/Stickers/AUTUMN_6.png +0 -0
  22. package/android/src/main/assets/Stickers/AUTUMN_7.png +0 -0
  23. package/android/src/main/assets/Stickers/AUTUMN_8.png +0 -0
  24. package/android/src/main/assets/Stickers/AUTUMN_9.png +0 -0
  25. package/android/src/main/assets/Stickers/LOL_1.png +0 -0
  26. package/android/src/main/assets/Stickers/LOL_10.png +0 -0
  27. package/android/src/main/assets/Stickers/LOL_11.png +0 -0
  28. package/android/src/main/assets/Stickers/LOL_12.png +0 -0
  29. package/android/src/main/assets/Stickers/LOL_13.png +0 -0
  30. package/android/src/main/assets/Stickers/LOL_14.png +0 -0
  31. package/android/src/main/assets/Stickers/LOL_15.png +0 -0
  32. package/android/src/main/assets/Stickers/LOL_16.png +0 -0
  33. package/android/src/main/assets/Stickers/LOL_17.png +0 -0
  34. package/android/src/main/assets/Stickers/LOL_18.png +0 -0
  35. package/android/src/main/assets/Stickers/LOL_19.png +0 -0
  36. package/android/src/main/assets/Stickers/LOL_2.png +0 -0
  37. package/android/src/main/assets/Stickers/LOL_3.png +0 -0
  38. package/android/src/main/assets/Stickers/LOL_4.png +0 -0
  39. package/android/src/main/assets/Stickers/LOL_5.png +0 -0
  40. package/android/src/main/assets/Stickers/LOL_6.png +0 -0
  41. package/android/src/main/assets/Stickers/LOL_7.png +0 -0
  42. package/android/src/main/assets/Stickers/LOL_8.png +0 -0
  43. package/android/src/main/assets/Stickers/LOL_9.png +0 -0
  44. package/android/src/main/assets/Stickers/SPRING_1.png +0 -0
  45. package/android/src/main/assets/Stickers/SPRING_2.png +0 -0
  46. package/android/src/main/assets/Stickers/SPRING_3.png +0 -0
  47. package/android/src/main/assets/Stickers/SPRING_4.png +0 -0
  48. package/android/src/main/assets/Stickers/SPRING_5.png +0 -0
  49. package/android/src/main/assets/Stickers/SPRING_6.png +0 -0
  50. package/android/src/main/assets/Stickers/SUMMER_1.png +0 -0
  51. package/android/src/main/assets/Stickers/SUMMER_10.png +0 -0
  52. package/android/src/main/assets/Stickers/SUMMER_11.png +0 -0
  53. package/android/src/main/assets/Stickers/SUMMER_12.png +0 -0
  54. package/android/src/main/assets/Stickers/SUMMER_13.png +0 -0
  55. package/android/src/main/assets/Stickers/SUMMER_14.png +0 -0
  56. package/android/src/main/assets/Stickers/SUMMER_15.png +0 -0
  57. package/android/src/main/assets/Stickers/SUMMER_16.png +0 -0
  58. package/android/src/main/assets/Stickers/SUMMER_17.png +0 -0
  59. package/android/src/main/assets/Stickers/SUMMER_18.png +0 -0
  60. package/android/src/main/assets/Stickers/SUMMER_19.png +0 -0
  61. package/android/src/main/assets/Stickers/SUMMER_2.png +0 -0
  62. package/android/src/main/assets/Stickers/SUMMER_20.png +0 -0
  63. package/android/src/main/assets/Stickers/SUMMER_21.png +0 -0
  64. package/android/src/main/assets/Stickers/SUMMER_3.png +0 -0
  65. package/android/src/main/assets/Stickers/SUMMER_4.png +0 -0
  66. package/android/src/main/assets/Stickers/SUMMER_5.png +0 -0
  67. package/android/src/main/assets/Stickers/SUMMER_6.png +0 -0
  68. package/android/src/main/assets/Stickers/SUMMER_7.png +0 -0
  69. package/android/src/main/assets/Stickers/SUMMER_8.png +0 -0
  70. package/android/src/main/assets/Stickers/SUMMER_9.png +0 -0
  71. package/android/src/main/assets/Stickers/TEXT_1.png +0 -0
  72. package/android/src/main/assets/Stickers/TEXT_12.png +0 -0
  73. package/android/src/main/assets/Stickers/TEXT_13.png +0 -0
  74. package/android/src/main/assets/Stickers/TEXT_14.png +0 -0
  75. package/android/src/main/assets/Stickers/TEXT_15.png +0 -0
  76. package/android/src/main/assets/Stickers/TEXT_16.png +0 -0
  77. package/android/src/main/assets/Stickers/TEXT_2.png +0 -0
  78. package/android/src/main/assets/Stickers/TEXT_25.png +0 -0
  79. package/android/src/main/assets/Stickers/TEXT_26.png +0 -0
  80. package/android/src/main/assets/Stickers/TEXT_27.png +0 -0
  81. package/android/src/main/assets/Stickers/TEXT_28.png +0 -0
  82. package/android/src/main/assets/Stickers/TEXT_29.png +0 -0
  83. package/android/src/main/assets/Stickers/TEXT_3.png +0 -0
  84. package/android/src/main/assets/Stickers/TEXT_30.png +0 -0
  85. package/android/src/main/assets/Stickers/TEXT_31.png +0 -0
  86. package/android/src/main/assets/Stickers/TEXT_32.png +0 -0
  87. package/android/src/main/assets/Stickers/TEXT_7.png +0 -0
  88. package/android/src/main/assets/Stickers/TEXT_8.png +0 -0
  89. package/android/src/main/assets/Stickers/TEXT_9.png +0 -0
  90. package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +901 -651
  91. package/android/src/main/java/com/photovideoeditor/photo/export/PhotoExporter.kt +8 -23
  92. package/android/src/main/java/com/photovideoeditor/photo/render/CropGeometry.kt +71 -0
  93. package/android/src/main/java/com/photovideoeditor/photo/render/OverlayGeometry.kt +36 -0
  94. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoEditSession.kt +5 -7
  95. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoFilterPresets.kt +71 -41
  96. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayer.kt +5 -1
  97. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerRenderer.kt +24 -1
  98. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformRenderer.kt +33 -0
  99. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformState.kt +6 -1
  100. package/android/src/main/java/com/photovideoeditor/photo/ui/CropOverlayView.kt +112 -122
  101. package/android/src/main/java/com/photovideoeditor/photo/ui/DrawOverlayView.kt +66 -0
  102. package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +63 -14
  103. package/android/src/main/java/com/photovideoeditor/photo/ui/ZoomableImageView.kt +10 -0
  104. package/android/src/main/java/com/photovideoeditor/stickers/OnlineStickerSheet.kt +135 -104
  105. package/android/src/main/java/com/photovideoeditor/ui/components/CropPanel.kt +122 -0
  106. package/android/src/main/java/com/photovideoeditor/ui/components/EditorPanel.kt +250 -0
  107. package/android/src/main/java/com/photovideoeditor/ui/components/EditorToolSlider.kt +103 -0
  108. package/android/src/main/java/com/photovideoeditor/video/export/VideoExporter.kt +38 -11
  109. package/android/src/main/java/com/photovideoeditor/video/render/VideoCropEffects.kt +35 -0
  110. package/android/src/main/java/com/photovideoeditor/video/render/VideoEditSession.kt +10 -0
  111. package/android/src/main/java/com/photovideoeditor/video/render/VideoTransformState.kt +1 -0
  112. package/android/src/main/res/drawable/ic_draw.xml +6 -0
  113. package/android/src/main/res/drawable/ic_pause.xml +7 -0
  114. package/android/src/main/res/drawable/ic_play.xml +7 -0
  115. package/docs/api.md +1 -1
  116. package/docs/editor-ui-validation.md +22 -0
  117. package/docs/photo-editor.md +5 -6
  118. package/ios/Editor/PhotoVideoEditorViewController.swift +918 -494
  119. package/ios/Editor/UI/CropPanel.swift +97 -0
  120. package/ios/Editor/UI/EditorPanel.swift +334 -0
  121. package/ios/Editor/UI/EditorToolSlider.swift +22 -0
  122. package/ios/Editor/UI/TextEditorSheet.swift +84 -0
  123. package/ios/Photo/Export/PhotoExporter.swift +3 -3
  124. package/ios/Photo/Rendering/CropGeometry.swift +48 -0
  125. package/ios/Photo/Rendering/PhotoEditSession.swift +21 -10
  126. package/ios/Photo/Rendering/PhotoFilterPresets.swift +53 -23
  127. package/ios/Photo/Rendering/PhotoLayer.swift +14 -2
  128. package/ios/Photo/Rendering/PhotoLayerRenderer.swift +16 -1
  129. package/ios/Photo/Rendering/PhotoTransformState.swift +5 -0
  130. package/ios/Photo/UI/CropOverlayView.swift +98 -134
  131. package/ios/Photo/UI/DrawOverlayView.swift +48 -0
  132. package/ios/Photo/UI/FilterThumbnailLoader.swift +62 -0
  133. package/ios/Photo/UI/LayerOverlayView.swift +36 -11
  134. package/ios/Photo/UI/ZoomableImageView.swift +19 -1
  135. package/ios/Resources/Stickers/AUTUMN_1.png +0 -0
  136. package/ios/Resources/Stickers/AUTUMN_10.png +0 -0
  137. package/ios/Resources/Stickers/AUTUMN_11.png +0 -0
  138. package/ios/Resources/Stickers/AUTUMN_12.png +0 -0
  139. package/ios/Resources/Stickers/AUTUMN_13.png +0 -0
  140. package/ios/Resources/Stickers/AUTUMN_14.png +0 -0
  141. package/ios/Resources/Stickers/AUTUMN_15.png +0 -0
  142. package/ios/Resources/Stickers/AUTUMN_16.png +0 -0
  143. package/ios/Resources/Stickers/AUTUMN_17.png +0 -0
  144. package/ios/Resources/Stickers/AUTUMN_18.png +0 -0
  145. package/ios/Resources/Stickers/AUTUMN_2.png +0 -0
  146. package/ios/Resources/Stickers/AUTUMN_3.png +0 -0
  147. package/ios/Resources/Stickers/AUTUMN_4.png +0 -0
  148. package/ios/Resources/Stickers/AUTUMN_5.png +0 -0
  149. package/ios/Resources/Stickers/AUTUMN_6.png +0 -0
  150. package/ios/Resources/Stickers/AUTUMN_7.png +0 -0
  151. package/ios/Resources/Stickers/AUTUMN_8.png +0 -0
  152. package/ios/Resources/Stickers/AUTUMN_9.png +0 -0
  153. package/ios/Resources/Stickers/LOL_1.png +0 -0
  154. package/ios/Resources/Stickers/LOL_10.png +0 -0
  155. package/ios/Resources/Stickers/LOL_11.png +0 -0
  156. package/ios/Resources/Stickers/LOL_12.png +0 -0
  157. package/ios/Resources/Stickers/LOL_13.png +0 -0
  158. package/ios/Resources/Stickers/LOL_14.png +0 -0
  159. package/ios/Resources/Stickers/LOL_15.png +0 -0
  160. package/ios/Resources/Stickers/LOL_16.png +0 -0
  161. package/ios/Resources/Stickers/LOL_17.png +0 -0
  162. package/ios/Resources/Stickers/LOL_18.png +0 -0
  163. package/ios/Resources/Stickers/LOL_19.png +0 -0
  164. package/ios/Resources/Stickers/LOL_2.png +0 -0
  165. package/ios/Resources/Stickers/LOL_3.png +0 -0
  166. package/ios/Resources/Stickers/LOL_4.png +0 -0
  167. package/ios/Resources/Stickers/LOL_5.png +0 -0
  168. package/ios/Resources/Stickers/LOL_6.png +0 -0
  169. package/ios/Resources/Stickers/LOL_7.png +0 -0
  170. package/ios/Resources/Stickers/LOL_8.png +0 -0
  171. package/ios/Resources/Stickers/LOL_9.png +0 -0
  172. package/ios/Resources/Stickers/SPRING_1.png +0 -0
  173. package/ios/Resources/Stickers/SPRING_2.png +0 -0
  174. package/ios/Resources/Stickers/SPRING_3.png +0 -0
  175. package/ios/Resources/Stickers/SPRING_4.png +0 -0
  176. package/ios/Resources/Stickers/SPRING_5.png +0 -0
  177. package/ios/Resources/Stickers/SPRING_6.png +0 -0
  178. package/ios/Resources/Stickers/SUMMER_1.png +0 -0
  179. package/ios/Resources/Stickers/SUMMER_10.png +0 -0
  180. package/ios/Resources/Stickers/SUMMER_11.png +0 -0
  181. package/ios/Resources/Stickers/SUMMER_12.png +0 -0
  182. package/ios/Resources/Stickers/SUMMER_13.png +0 -0
  183. package/ios/Resources/Stickers/SUMMER_14.png +0 -0
  184. package/ios/Resources/Stickers/SUMMER_15.png +0 -0
  185. package/ios/Resources/Stickers/SUMMER_16.png +0 -0
  186. package/ios/Resources/Stickers/SUMMER_17.png +0 -0
  187. package/ios/Resources/Stickers/SUMMER_18.png +0 -0
  188. package/ios/Resources/Stickers/SUMMER_19.png +0 -0
  189. package/ios/Resources/Stickers/SUMMER_2.png +0 -0
  190. package/ios/Resources/Stickers/SUMMER_20.png +0 -0
  191. package/ios/Resources/Stickers/SUMMER_21.png +0 -0
  192. package/ios/Resources/Stickers/SUMMER_3.png +0 -0
  193. package/ios/Resources/Stickers/SUMMER_4.png +0 -0
  194. package/ios/Resources/Stickers/SUMMER_5.png +0 -0
  195. package/ios/Resources/Stickers/SUMMER_6.png +0 -0
  196. package/ios/Resources/Stickers/SUMMER_7.png +0 -0
  197. package/ios/Resources/Stickers/SUMMER_8.png +0 -0
  198. package/ios/Resources/Stickers/SUMMER_9.png +0 -0
  199. package/ios/Resources/Stickers/TEXT_1.png +0 -0
  200. package/ios/Resources/Stickers/TEXT_12.png +0 -0
  201. package/ios/Resources/Stickers/TEXT_13.png +0 -0
  202. package/ios/Resources/Stickers/TEXT_14.png +0 -0
  203. package/ios/Resources/Stickers/TEXT_15.png +0 -0
  204. package/ios/Resources/Stickers/TEXT_16.png +0 -0
  205. package/ios/Resources/Stickers/TEXT_2.png +0 -0
  206. package/ios/Resources/Stickers/TEXT_25.png +0 -0
  207. package/ios/Resources/Stickers/TEXT_26.png +0 -0
  208. package/ios/Resources/Stickers/TEXT_27.png +0 -0
  209. package/ios/Resources/Stickers/TEXT_28.png +0 -0
  210. package/ios/Resources/Stickers/TEXT_29.png +0 -0
  211. package/ios/Resources/Stickers/TEXT_3.png +0 -0
  212. package/ios/Resources/Stickers/TEXT_30.png +0 -0
  213. package/ios/Resources/Stickers/TEXT_31.png +0 -0
  214. package/ios/Resources/Stickers/TEXT_32.png +0 -0
  215. package/ios/Resources/Stickers/TEXT_7.png +0 -0
  216. package/ios/Resources/Stickers/TEXT_8.png +0 -0
  217. package/ios/Resources/Stickers/TEXT_9.png +0 -0
  218. package/ios/Resources/Stickers/a_pety_icon.png +0 -0
  219. package/ios/Stickers/OnlineStickerSheetViewController.swift +102 -159
  220. package/ios/Video/Export/VideoExporter.swift +10 -1
  221. package/ios/Video/Render/VideoCropGeometry.swift +48 -0
  222. package/ios/Video/Render/VideoTransformState.swift +1 -0
  223. package/lib/module/index.js +2 -2
  224. package/lib/module/index.js.map +1 -1
  225. package/lib/typescript/src/types.d.ts +2 -2
  226. package/lib/typescript/src/types.d.ts.map +1 -1
  227. package/package.json +2 -1
  228. package/src/index.ts +1 -1
  229. package/src/types.ts +2 -2
  230. package/android/src/main/java/com/photovideoeditor/stickers/OpenMojiCatalog.kt +0 -172
  231. package/android/src/main/java/com/photovideoeditor/stickers/RemoteImageLoader.kt +0 -110
  232. package/ios/Stickers/OpenMojiCatalog.swift +0 -188
  233. 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 layered = PhotoLayerRenderer.render(adjusted, layers: layers) { uri in resolveImageLayer(uri) }
56
- let cropped = crop(layered, transform: transform)
57
- let resized = resize(cropped, maxWidth: maxWidth, maxHeight: maxHeight)
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 base = computeBase() else { return nil }
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 radians = state.totalRotationRadians
98
- let rotatedBounds = CGRect(origin: .zero, size: image.size).applying(CGAffineTransform(rotationAngle: radians))
99
- let canvasSize = CGSize(width: abs(rotatedBounds.width), height: abs(rotatedBounds.height))
100
- let renderer = pixelRenderer(size: canvasSize)
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 / 2, y: canvasSize.height / 2)
104
- ctx.rotate(by: radians)
105
- image.draw(in: CGRect(x: -image.size.width / 2, y: -image.size.height / 2, width: image.size.width, height: image.size.height))
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)