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
@@ -20,21 +20,36 @@ final class PhotoVideoEditorViewController: UIViewController {
20
20
 
21
21
  private var photoSession: PhotoEditSession?
22
22
  private var cropMode = false
23
+ private var cropEntryState: PhotoTransformState?
24
+ private var photoCropPanel: CropPanel?
23
25
  private var filtersMode = false
26
+ private var drawMode = false
24
27
  private var stickersMode = false
25
28
  private var pendingPickPurpose: ImagePickPurpose?
26
29
  private var activeAdjustmentKey = "brightness"
27
30
  private var selectedLayerID: String?
28
- private var activeLayerPropertyKey = "scale"
31
+ private var activeLayerPropertyKey = ""
29
32
  private var dragStartSnapshot: [PhotoLayer]?
30
33
  private var photoImageView: ZoomableImageView?
31
34
  private var cropOverlay: CropOverlayView?
32
35
  private var layerOverlay: LayerOverlayView?
36
+ private var drawOverlay: DrawOverlayView?
33
37
  private var straightenSlider: UISlider?
34
38
  private var cropSubBar: UIView?
35
- private var adjustmentSlider: UISlider?
39
+ /// Drives whichever adjustment is selected in the Adjust panel.
40
+ private var adjustmentSliderControl: EditorValueSlider?
41
+ /// Filter strength, owned by the Filters panel (a view has one superview, so not shared).
42
+ private var filterIntensityControl: EditorValueSlider?
43
+ private var adjustSubBar: UIView?
36
44
  private var filtersSubBar: UIView?
45
+ private var adjustMode = false
46
+ /// Adjustment key (plus "mirror") -> its entry in the Adjust panel's strip.
47
+ private var adjustmentStripItems: [String: EditorStripItem] = [:]
48
+ /// Preset id ("" = Original) -> its thumbnail in the Filters carousel.
49
+ private var filterThumbnails: [String: EditorFilterThumbnail] = [:]
50
+ private let filterThumbnailLoader = FilterThumbnailLoader()
37
51
  private var stickersSubBar: UIView?
52
+ private var drawSubBar: UIView?
38
53
  private var layerToolBar: UIView?
39
54
  private var layerPropertySlider: UISlider?
40
55
  private var layerColorSwatchRow: UIView?
@@ -49,6 +64,12 @@ final class PhotoVideoEditorViewController: UIViewController {
49
64
  private var videoSession: VideoEditSession?
50
65
  private var videoExporter: VideoExporter?
51
66
  private var videoAspectMode = false
67
+ private var videoCropMode = false
68
+ private var videoCropEntryState: PhotoTransformState?
69
+ private var videoCropPanel: CropPanel?
70
+ private var videoCropOverlay: CropOverlayView?
71
+ private weak var cropPlayerView: UIView?
72
+ private weak var videoPreviewContainer: UIView?
52
73
  private var trimRangeView: TrimRangeView?
53
74
  private var positionSlider: UISlider?
54
75
  private var playPauseButton: UIButton?
@@ -64,10 +85,17 @@ final class PhotoVideoEditorViewController: UIViewController {
64
85
  private var videoLayerToolBar: UIView?
65
86
  private var videoLayerPropertySlider: UISlider?
66
87
  private var selectedVideoLayerID: String?
67
- private var activeVideoLayerPropertyKey = "scale"
88
+ private var activeVideoLayerPropertyKey = ""
68
89
  private var videoDragStartSnapshot: [PhotoLayer]?
90
+ private var videoColorRow: UIView?
91
+ private var textSwatches: [(UIColor, UIView)] = []
92
+ private var videoTextSwatches: [(UIColor, UIView)] = []
93
+ private var sliderSnapshot: [PhotoLayer]?
69
94
  private var videoLayerPropertyButtons: [String: UIButton] = [:]
70
95
  private var seekingPosition = false
96
+ private weak var videoControlsOverlay: UIView?
97
+ private var videoControlsVisible = true
98
+ private var hideVideoControlsTimer: Timer?
71
99
  private var selectedClipIndex = 0
72
100
  private var clipStripBar: UIStackView?
73
101
  private var clipStripScroll: UIScrollView?
@@ -76,10 +104,7 @@ final class PhotoVideoEditorViewController: UIViewController {
76
104
  private var timelineThumbnailFrames = [UIImage?](repeating: nil, count: 12)
77
105
 
78
106
  private var cropAspectButtons: [String: UIButton] = [:]
79
- private var filterAdjustmentButtons: [String: UIButton] = [:]
80
- private var filterPresetButtons: [String: UIButton] = [:]
81
107
  private var layerPropertyButtons: [String: UIButton] = [:]
82
- private var layerIconButtons: [String: UIButton] = [:]
83
108
  private var videoToolButtons: [String: UIButton] = [:]
84
109
  private var videoAspectButtons: [String: UIButton] = [:]
85
110
 
@@ -186,30 +211,39 @@ final class PhotoVideoEditorViewController: UIViewController {
186
211
  layers.bottomAnchor.constraint(equalTo: preview.bottomAnchor),
187
212
  ])
188
213
 
189
- let metadataBadge = UILabel()
190
- metadataBadge.text = " ● ORIGINAL • 100% "
191
- metadataBadge.font = .systemFont(ofSize: 10, weight: .semibold)
192
- metadataBadge.textColor = DesignTokens.onSurfaceVariant
193
- metadataBadge.backgroundColor = DesignTokens.surfaceContainerHigh.withAlphaComponent(0.92)
194
- metadataBadge.layer.cornerRadius = DesignTokens.radiusLg
195
- metadataBadge.clipsToBounds = true
196
- preview.addSubview(metadataBadge)
197
- metadataBadge.translatesAutoresizingMaskIntoConstraints = false
214
+ let drawing = DrawOverlayView()
215
+ drawing.isHidden = true
216
+ drawOverlay = drawing
217
+ preview.addSubview(drawing)
218
+ drawing.translatesAutoresizingMaskIntoConstraints = false
198
219
  NSLayoutConstraint.activate([
199
- metadataBadge.centerXAnchor.constraint(equalTo: preview.centerXAnchor),
200
- metadataBadge.topAnchor.constraint(equalTo: preview.topAnchor, constant: DesignTokens.spaceSm),
201
- metadataBadge.heightAnchor.constraint(equalToConstant: 28),
220
+ drawing.leadingAnchor.constraint(equalTo: preview.leadingAnchor),
221
+ drawing.trailingAnchor.constraint(equalTo: preview.trailingAnchor),
222
+ drawing.topAnchor.constraint(equalTo: preview.topAnchor),
223
+ drawing.bottomAnchor.constraint(equalTo: preview.bottomAnchor),
202
224
  ])
203
225
 
204
226
  imageView.onBoundsChanged = { [weak self] bounds in
205
227
  guard let self else { return }
206
- if self.cropMode { overlay.setImageBounds(bounds, resetCrop: false) }
228
+ if self.cropMode { overlay.restore(bounds:bounds,state:session.state) }
207
229
  layers.setImageBounds(bounds)
230
+ drawing.setImageBounds(bounds)
208
231
  }
209
232
  overlay.onCropChanged = { left, top, right, bottom in
210
233
  session.update { $0.setCrop(left: left, top: top, right: right, bottom: bottom) }
211
234
  }
235
+ overlay.onMediaBoundsChanged = { [weak imageView] bounds in imageView?.setRenderedBounds(bounds) }
236
+ overlay.onViewportChanged = { zoom,x,y in session.update { $0.zoom = zoom; $0.panX = x; $0.panY = y } }
237
+
238
+ layers.onLayerDelete = { [weak self] id in
239
+ session.layerStack.commit { $0.filter { $0.id != id } }
240
+ self?.selectLayer(nil); self?.onLayerStackChanged()
241
+ }
212
242
  layers.onLayerTapped = { [weak self] id in self?.selectLayer(id) }
243
+ layers.onLayerDoubleTapped = { [weak self] id in
244
+ guard let self, let layer = session.layerStack.layers.first(where: { $0.id == id && $0.type == .text }) else { return }
245
+ self.showTextInputDialog(session: session, editingLayer: layer)
246
+ }
213
247
  layers.onLayerTransformChanged = { [weak self] id, x, y, scale, rotation in
214
248
  guard let self, let session = self.photoSession else { return }
215
249
  if self.dragStartSnapshot == nil { self.dragStartSnapshot = session.layerStack.layers }
@@ -238,12 +272,20 @@ final class PhotoVideoEditorViewController: UIViewController {
238
272
  cropBar.isHidden = true
239
273
  cropSubBar = cropBar
240
274
 
241
- let adjustmentSlider = UISlider()
242
- adjustmentSlider.isHidden = true
243
- adjustmentSlider.addTarget(self, action: #selector(adjustmentChanged(_:)), for: .valueChanged)
244
- self.adjustmentSlider = adjustmentSlider
275
+ let adjustmentControl = EditorValueSlider(accentColor: primaryColor)
276
+ adjustmentControl.slider.addTarget(self, action: #selector(adjustmentChanged(_:)), for: .valueChanged)
277
+ adjustmentSliderControl = adjustmentControl
278
+
279
+ let intensityControl = EditorValueSlider(accentColor: primaryColor)
280
+ intensityControl.titleText = "Intensity"
281
+ intensityControl.slider.addTarget(self, action: #selector(filterIntensityChanged(_:)), for: .valueChanged)
282
+ filterIntensityControl = intensityControl
283
+
284
+ let adjustBar = makeAdjustSubBar(session: session, primaryColor: primaryColor)
285
+ adjustBar.isHidden = true
286
+ adjustSubBar = adjustBar
245
287
 
246
- let filtersBar = makeFiltersSubBar(session: session, imageView: imageView, textColor: textColor, primaryColor: primaryColor, toolbarColor: toolbarColor)
288
+ let filtersBar = makeFiltersSubBar(session: session, primaryColor: primaryColor)
247
289
  filtersBar.isHidden = true
248
290
  filtersSubBar = filtersBar
249
291
 
@@ -251,9 +293,14 @@ final class PhotoVideoEditorViewController: UIViewController {
251
293
  stickersBar.isHidden = true
252
294
  stickersSubBar = stickersBar
253
295
 
254
- let layerSlider = UISlider()
296
+ let drawBar = makeDrawSubBar(textColor: textColor, primaryColor: primaryColor, toolbarColor: toolbarColor)
297
+ drawBar.isHidden = true
298
+ drawSubBar = drawBar
299
+
300
+ let layerSlider = EditorToolSlider()
255
301
  layerSlider.isHidden = true
256
302
  layerSlider.addTarget(self, action: #selector(layerPropertyChanged(_:)), for: .valueChanged)
303
+ configureLayerSlider(layerSlider, video: false)
257
304
  layerPropertySlider = layerSlider
258
305
 
259
306
  let layerBar = makeLayerToolBar(session: session, textColor: textColor, primaryColor: primaryColor, toolbarColor: toolbarColor)
@@ -267,7 +314,7 @@ final class PhotoVideoEditorViewController: UIViewController {
267
314
  let toolbar = makePhotoToolBar(textColor: textColor, toolbarColor: toolbarColor)
268
315
  mainToolBar = toolbar
269
316
 
270
- let root = UIStackView(arrangedSubviews: [header, preview, straighten, cropBar, adjustmentSlider, filtersBar, stickersBar, layerSlider, colorSwatchRow, layerBar, toolbar])
317
+ let root = UIStackView(arrangedSubviews: [header, preview, cropBar, adjustBar, filtersBar, stickersBar, drawBar, layerSlider, colorSwatchRow, layerBar, toolbar])
271
318
  root.axis = .vertical
272
319
  root.translatesAutoresizingMaskIntoConstraints = false
273
320
  view.addSubview(root)
@@ -278,13 +325,18 @@ final class PhotoVideoEditorViewController: UIViewController {
278
325
  root.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
279
326
  header.heightAnchor.constraint(equalToConstant: 60),
280
327
  toolbar.heightAnchor.constraint(equalToConstant: 72),
281
- cropBar.heightAnchor.constraint(equalToConstant: 100), // rotate row (44pt) + aspect chip row (56pt)
282
- filtersBar.heightAnchor.constraint(equalToConstant: 56),
328
+ cropBar.heightAnchor.constraint(equalToConstant: 232), // rotate row (44pt) + aspect chip row (56pt)
329
+ adjustBar.heightAnchor.constraint(equalToConstant: EditorPanelMetrics.totalHeight),
330
+ filtersBar.heightAnchor.constraint(equalToConstant: EditorPanelMetrics.totalHeight + 24),
283
331
  stickersBar.heightAnchor.constraint(equalToConstant: 56),
284
- layerBar.heightAnchor.constraint(equalToConstant: 56),
332
+ drawBar.heightAnchor.constraint(equalToConstant: 56),
333
+ layerBar.heightAnchor.constraint(equalToConstant: 76),
285
334
  colorSwatchRow.heightAnchor.constraint(equalToConstant: 56),
286
335
  ])
287
336
 
337
+ root.arrangedSubviews.forEach { child in
338
+ child.constraints.filter { $0.firstAttribute == .height && $0.secondItem == nil }.forEach { $0.priority = UILayoutPriority(999) }
339
+ }
288
340
  let progress = UIView()
289
341
  progress.backgroundColor = UIColor.black.withAlphaComponent(0.6)
290
342
  progress.isHidden = true
@@ -329,11 +381,11 @@ final class PhotoVideoEditorViewController: UIViewController {
329
381
 
330
382
  private func createToolNode(systemName: String, labelText: String, onTap: @escaping () -> Void) -> ToolNode {
331
383
  let icon = UIImageView(image: UIImage(systemName: systemName))
332
- icon.tintColor = DesignTokens.outline
384
+ icon.tintColor = DesignTokens.textSecondary
333
385
  icon.contentMode = .scaleAspectFit
334
386
  let tile = UIView()
335
- tile.backgroundColor = DesignTokens.surfaceContainerLow
336
- tile.layer.cornerRadius = DesignTokens.radiusLg
387
+ tile.backgroundColor = .clear
388
+ tile.layer.cornerRadius = DesignTokens.touchTargetMin / 2
337
389
  tile.addSubview(icon)
338
390
  icon.translatesAutoresizingMaskIntoConstraints = false
339
391
  NSLayoutConstraint.activate([
@@ -344,9 +396,13 @@ final class PhotoVideoEditorViewController: UIViewController {
344
396
  ])
345
397
  let label = UILabel()
346
398
  label.text = labelText
347
- label.font = .systemFont(ofSize: 11)
348
- label.textColor = DesignTokens.outline
399
+ label.font = .systemFont(ofSize: 11, weight: .medium)
400
+ label.textColor = DesignTokens.textSecondary
349
401
  label.textAlignment = .center
402
+ // Labels like "Stickers" must never wrap to a second line.
403
+ label.numberOfLines = 1
404
+ label.adjustsFontSizeToFitWidth = true
405
+ label.minimumScaleFactor = 0.85
350
406
  let root = UIStackView(arrangedSubviews: [tile, label])
351
407
  root.axis = .vertical
352
408
  root.alignment = .center
@@ -355,6 +411,9 @@ final class PhotoVideoEditorViewController: UIViewController {
355
411
  toolNodeActions[ObjectIdentifier(root)] = onTap
356
412
  root.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleToolNodeTap(_:))))
357
413
  root.applyPressScale()
414
+ root.isAccessibilityElement = true
415
+ root.accessibilityLabel = labelText
416
+ root.accessibilityTraits = .button
358
417
  NSLayoutConstraint.activate([
359
418
  tile.widthAnchor.constraint(equalToConstant: DesignTokens.touchTargetMin),
360
419
  tile.heightAnchor.constraint(equalToConstant: DesignTokens.touchTargetMin),
@@ -362,10 +421,14 @@ final class PhotoVideoEditorViewController: UIViewController {
362
421
  return ToolNode(root: root, tile: tile, icon: icon, label: label)
363
422
  }
364
423
 
424
+ /// Selection is a purple icon + label over a faint purple disc — not a filled tile, which at dock
425
+ /// size reads as a large button and fights the photo for attention.
365
426
  private func setToolNodeSelected(_ node: ToolNode, selected: Bool, textColor: UIColor) {
366
- node.tile.backgroundColor = selected ? DesignTokens.surfaceContainer : DesignTokens.surfaceContainerLow
367
- node.icon.tintColor = selected ? textColor : DesignTokens.outline
368
- node.label.textColor = selected ? textColor : DesignTokens.outline
427
+ let accent = color("primaryColor") ?? DesignTokens.primaryContainer
428
+ node.root.accessibilityTraits = selected ? [.button, .selected] : .button
429
+ node.tile.backgroundColor = selected ? accent.withAlphaComponent(0.18) : .clear
430
+ node.icon.tintColor = selected ? accent : DesignTokens.textSecondary
431
+ node.label.textColor = selected ? accent : DesignTokens.textSecondary
369
432
  }
370
433
 
371
434
  /// Full Studio Violet tool rail (see main_photo_editor_default_state mockup). Rotate lives inside
@@ -377,19 +440,32 @@ final class PhotoVideoEditorViewController: UIViewController {
377
440
  ("filters", "Filters", "camera.filters"),
378
441
  ("text", "Text", "textformat"),
379
442
  ("stickers", "Stickers", "face.smiling"),
380
- ("overlay", "Overlay", "photo.on.rectangle.angled"),
381
- ("retouch", "Retouch", "wand.and.rays"),
382
- ("layers", "Layers", "square.3.layers.3d"),
443
+ ("draw", "Draw", "pencil.tip"),
383
444
  ("resize", "Resize", "aspectratio"),
384
445
  ]
385
446
  let scroll = UIScrollView(); scroll.backgroundColor = toolbarColor
447
+ scroll.showsHorizontalScrollIndicator = false
386
448
  photoToolScroll = scroll
387
- let stack = UIStackView(); stack.axis = .horizontal; stack.spacing = 12
449
+ let stack = UIStackView(); stack.axis = .horizontal; stack.spacing = DesignTokens.spaceXs
450
+ // Width that shows about five tools at once, so the dock fills the screen evenly on a small
451
+ // phone and does not leave large gaps on a large one.
452
+ let itemWidth = Swift.min(Swift.max(UIScreen.main.bounds.width / 5, 64), 88)
388
453
  tools.filter { features[$0.0] as? Bool != false }.forEach { key, label, symbol in
389
454
  let node = createToolNode(systemName: symbol, labelText: label) { [weak self] in self?.onPhotoToolTapped(key) }
455
+ node.root.widthAnchor.constraint(equalToConstant: itemWidth).isActive = true
390
456
  photoToolNodes[key] = node
391
457
  stack.addArrangedSubview(node.root)
392
458
  }
459
+ // Built with the same node factory as the other seven so the dock has one visual language;
460
+ // a zero-duration long press gives it the press-and-hold behaviour a tap gesture cannot.
461
+ let compare = createToolNode(systemName: "rectangle.split.2x1", labelText: "Original") {}
462
+ compare.root.accessibilityLabel = "Hold to compare with original"
463
+ compare.root.widthAnchor.constraint(equalToConstant: itemWidth).isActive = true
464
+ let hold = UILongPressGestureRecognizer(target: self, action: #selector(compareHeld(_:)))
465
+ hold.minimumPressDuration = 0
466
+ compare.root.addGestureRecognizer(hold)
467
+ photoToolNodes["compare"] = compare
468
+ stack.addArrangedSubview(compare.root)
393
469
  scroll.addSubview(stack)
394
470
  stack.translatesAutoresizingMaskIntoConstraints = false
395
471
  NSLayoutConstraint.activate([
@@ -403,66 +479,24 @@ final class PhotoVideoEditorViewController: UIViewController {
403
479
  }
404
480
 
405
481
  private func makeCropSubBar(session: PhotoEditSession, textColor: UIColor, primaryColor: UIColor, toolbarColor: UIColor) -> UIView {
406
- let presets: [(String, CGFloat?)] = [("Free", nil), ("1:1", 1), ("4:5", 4 / 5), ("3:4", 3 / 4), ("9:16", 9 / 16), ("16:9", 16 / 9)]
407
- let outer = UIStackView(); outer.axis = .vertical
408
- outer.backgroundColor = toolbarColor
409
-
410
- // Rotate row — moved here from the main tool rail to match the crop_transform_editor mockup.
411
- let transformRow = UIStackView(arrangedSubviews: [
412
- iconButton(systemName: "rotate.left", tint: textColor) { [weak self] in
413
- guard let self, let session = self.photoSession, let imageView = self.photoImageView else { return }
414
- session.update { $0.rotateRight(); $0.rotateRight(); $0.rotateRight() }
415
- self.schedulePhotoPreviewRender()
416
- DispatchQueue.main.async {
417
- imageView.resetToFit()
418
- if self.cropMode { self.cropOverlay?.setImageBounds(imageView.currentImageBounds(), resetCrop: true) }
419
- }
482
+ let panel = CropPanel(accent:primaryColor,surface:toolbarColor,foreground:textColor,
483
+ originalRatio: { session.baseImage.map { $0.size.width/$0.size.height } ?? 1 },
484
+ onReset: { [weak self] in self?.resetPhotoEdits() },
485
+ onDone: { [weak self] in self?.setCropMode(false) },
486
+ onCancel: { [weak self] in self?.cancelCropMode() },
487
+ onStraighten: { [weak self] value in
488
+ session.update { $0.setStraighten(value) }; self?.photoCropPanel?.sync(session.state); self?.schedulePhotoPreviewRender()
420
489
  },
421
- iconButton(systemName: "rotate.right", tint: textColor) { [weak self] in
422
- guard let self, let session = self.photoSession, let imageView = self.photoImageView else { return }
423
- session.update { $0.rotateRight() }
424
- self.schedulePhotoPreviewRender()
425
- DispatchQueue.main.async {
426
- imageView.resetToFit()
427
- if self.cropMode { self.cropOverlay?.setImageBounds(imageView.currentImageBounds(), resetCrop: true) }
428
- }
490
+ onRotate: { [weak self] in
491
+ session.update { $0.rotateRight(); $0.zoom = 1; $0.panX = 0; $0.panY = 0; $0.aspectRatio = nil; $0.aspectPreset = "Free" }
492
+ self?.photoCropPanel?.sync(session.state); self?.schedulePhotoPreviewRender()
429
493
  },
430
- ])
431
- transformRow.axis = .horizontal
432
- transformRow.alignment = .center
433
- transformRow.distribution = .equalCentering
434
- transformRow.heightAnchor.constraint(equalToConstant: DesignTokens.touchTargetMin).isActive = true
435
- outer.addArrangedSubview(transformRow)
436
-
437
- let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
438
- let scroll = UIScrollView()
439
- let presetStack = UIStackView(); presetStack.axis = .horizontal; presetStack.spacing = 4
440
- presets.forEach { label, ratio in
441
- let chip = button(label, color: textColor) { [weak self] in
442
- self?.cropOverlay?.setAspectRatio(ratio)
443
- session.update { $0.aspectRatio = ratio }
444
- if let self { self.refreshSelection(self.cropAspectButtons, activeKey: label, textColor: textColor, primaryColor: primaryColor) }
445
- }
446
- cropAspectButtons[label] = chip
447
- presetStack.addArrangedSubview(chip)
448
- }
449
- presetStack.addArrangedSubview(button("Reset", color: textColor) { [weak self] in self?.resetPhotoEdits() })
450
- scroll.addSubview(presetStack)
451
- presetStack.translatesAutoresizingMaskIntoConstraints = false
452
- NSLayoutConstraint.activate([
453
- presetStack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
454
- presetStack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
455
- presetStack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
456
- presetStack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
457
- presetStack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
458
- ])
459
- bar.addArrangedSubview(scroll)
460
- bar.addArrangedSubview(button("Done", color: primaryColor) { [weak self] in self?.setCropMode(false) })
461
- bar.isLayoutMarginsRelativeArrangement = true
462
- bar.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
463
- bar.heightAnchor.constraint(equalToConstant: 56).isActive = true
464
- outer.addArrangedSubview(bar)
465
- return outer
494
+ onRatio: { [weak self] label,ratio in
495
+ session.update { $0.aspectRatio = ratio; $0.aspectPreset = label }
496
+ self?.cropOverlay?.setAspectRatio(ratio); self?.photoCropPanel?.sync(session.state)
497
+ })
498
+ photoCropPanel = panel; panel.sync(session.state)
499
+ return panel
466
500
  }
467
501
 
468
502
  private func onPhotoToolTapped(_ key: String) {
@@ -470,19 +504,20 @@ final class PhotoVideoEditorViewController: UIViewController {
470
504
  switch key {
471
505
  case "crop":
472
506
  setCropMode(true)
473
- case "adjust", "filters", "retouch":
507
+ case "adjust":
508
+ setAdjustMode(true, session: session)
509
+ case "filters":
474
510
  setFiltersMode(true, session: session)
475
511
  case "text":
476
512
  showTextInputDialog(session: session)
477
513
  case "stickers":
478
- setStickersMode(true)
479
- case "overlay":
480
- presentImagePicker(purpose: .photoOverlay)
514
+ stickersMode = true
515
+ refreshPhotoToolSelection()
516
+ presentOnlineStickerSheet(purpose: .photoSticker)
517
+ case "draw":
518
+ setDrawMode(true, session: session)
481
519
  case "resize":
482
520
  setCropMode(true)
483
- case "layers":
484
- if let top = session.layerStack.layers.last { selectLayer(top.id) }
485
- else { showToast("Add content to start a layer stack.") }
486
521
  default:
487
522
  showToast("\(key) is coming in a later milestone.")
488
523
  }
@@ -491,24 +526,31 @@ final class PhotoVideoEditorViewController: UIViewController {
491
526
  // MARK: - Layers: text, selection, and transform/duplicate/reorder/lock/hide/delete
492
527
  // with undo/redo (Milestone 4)
493
528
 
494
- private func showTextInputDialog(session: PhotoEditSession) {
495
- let alert = UIAlertController(title: "Add text", message: nil, preferredStyle: .alert)
496
- alert.addTextField { $0.placeholder = "Text" }
497
- alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
498
- alert.addAction(UIAlertAction(title: "Add", style: .default) { [weak self, weak alert] _ in
499
- guard let text = alert?.textFields?.first?.text, !text.trimmingCharacters(in: .whitespaces).isEmpty else { return }
500
- var layer = PhotoLayer(type: .text)
529
+ private func showTextInputDialog(session: PhotoEditSession, editingLayer: PhotoLayer? = nil) {
530
+ let sheet = TextEditorSheet(text: editingLayer?.text ?? "", editing: editingLayer != nil, accent: color("primaryColor") ?? DesignTokens.primaryContainer) { [weak self] text in
531
+ var layer = editingLayer ?? PhotoLayer(type: .text)
501
532
  layer.text = text
502
- session.layerStack.commit { $0 + [layer] }
533
+ session.layerStack.commit { list in editingLayer == nil ? list + [layer] : list.map { $0.id == layer.id ? layer : $0 } }
503
534
  self?.selectLayer(layer.id)
504
- })
505
- present(alert, animated: true)
535
+ self?.onLayerStackChanged()
536
+ }
537
+ present(sheet, animated: true)
538
+ }
539
+
540
+ private func newSticker() -> PhotoLayer {
541
+ var layer = PhotoLayer(type: .sticker)
542
+ let bounds: CGRect
543
+ if let imageView = photoImageView { bounds = imageView.currentImageBounds() }
544
+ else if let session = videoSession, let overlay = videoOverlayImageView { bounds = computeVideoLetterboxBounds(session: session, in: overlay) }
545
+ else { bounds = CGRect(x: 0, y: 0, width: 1, height: 1) }
546
+ layer.scale = 0.3 * bounds.width / max(0.18 * min(bounds.width, bounds.height), 0.001)
547
+ return layer
506
548
  }
507
549
 
508
550
  private func setStickersMode(_ enabled: Bool) {
509
551
  stickersMode = enabled
510
552
  stickersSubBar?.isHidden = !enabled
511
- mainToolBar?.isHidden = enabled
553
+ mainToolBar?.isHidden = enabled || selectedLayerID != nil
512
554
  refreshPhotoToolSelection()
513
555
  }
514
556
 
@@ -518,30 +560,28 @@ final class PhotoVideoEditorViewController: UIViewController {
518
560
  let chipStack = UIStackView(); chipStack.axis = .horizontal; chipStack.spacing = 4
519
561
  PhotoLayerRenderer.builtinStickerIDs().forEach { id in
520
562
  chipStack.addArrangedSubview(button(PhotoLayerRenderer.glyph(for: id)) { [weak self] in
521
- var layer = PhotoLayer(type: .sticker)
563
+ guard let self else { return }
564
+ var layer = self.newSticker()
522
565
  layer.stickerId = id
523
566
  session.layerStack.commit { $0 + [layer] }
524
- self?.selectLayer(layer.id)
525
- self?.setStickersMode(false)
567
+ self.selectLayer(layer.id)
568
+ self.setStickersMode(false)
526
569
  })
527
570
  }
528
571
  consumerStickerAssets().forEach { asset in
529
572
  chipStack.addArrangedSubview(button(asset.id, color: textColor) { [weak self] in
530
- var layer = PhotoLayer(type: .sticker)
573
+ guard let self else { return }
574
+ var layer = self.newSticker()
531
575
  layer.stickerUri = asset.uri
576
+ if let image = self.resolveVideoImageLayer(asset.uri) { layer.overlayAspectRatio = image.size.width / max(image.size.height, 1) }
532
577
  session.layerStack.commit { $0 + [layer] }
533
- self?.selectLayer(layer.id)
534
- self?.setStickersMode(false)
578
+ self.selectLayer(layer.id)
579
+ self.setStickersMode(false)
535
580
  })
536
581
  }
537
582
  chipStack.addArrangedSubview(button("Upload", color: textColor) { [weak self] in
538
583
  self?.presentImagePicker(purpose: .photoSticker)
539
584
  })
540
- if features["onlineStickers"] as? Bool != false {
541
- chipStack.addArrangedSubview(button("Browse", color: textColor) { [weak self] in
542
- self?.presentOnlineStickerSheet(purpose: .photoSticker)
543
- })
544
- }
545
585
  scroll.addSubview(chipStack)
546
586
  chipStack.translatesAutoresizingMaskIntoConstraints = false
547
587
  NSLayoutConstraint.activate([
@@ -559,6 +599,71 @@ final class PhotoVideoEditorViewController: UIViewController {
559
599
  return bar
560
600
  }
561
601
 
602
+ private func setDrawMode(_ enabled: Bool, session: PhotoEditSession) {
603
+ drawMode = enabled
604
+ drawOverlay?.isHidden = !enabled
605
+ drawSubBar?.isHidden = !enabled
606
+ mainToolBar?.isHidden = enabled
607
+ layerOverlay?.isHidden = enabled
608
+ photoImageView?.panZoomEnabled = !enabled
609
+ if enabled {
610
+ drawOverlay?.strokeColor = .red
611
+ drawOverlay?.strokeWidthPx = 4
612
+ if let bounds = photoImageView?.currentImageBounds() { drawOverlay?.setImageBounds(bounds) }
613
+ } else {
614
+ commitDrawStrokes(session: session)
615
+ drawOverlay?.clearStrokes()
616
+ }
617
+ refreshPhotoToolSelection()
618
+ }
619
+
620
+ private func commitDrawStrokes(session: PhotoEditSession) {
621
+ guard let drawOverlay, drawOverlay.hasStrokes, let bounds = photoImageView?.currentImageBounds(), bounds.width > 0 else { return }
622
+ let shortSide = min(bounds.width, bounds.height)
623
+ let newLayers: [PhotoLayer] = drawOverlay.normalizedStrokes().compactMap { points, stroke in
624
+ guard points.count >= 2 else { return nil }
625
+ let centerX = points.reduce(0) { $0 + $1.0 } / CGFloat(points.count)
626
+ let centerY = points.reduce(0) { $0 + $1.1 } / CGFloat(points.count)
627
+ var layer = PhotoLayer(type: .drawing)
628
+ layer.x = centerX; layer.y = centerY
629
+ layer.drawColor = stroke.color
630
+ layer.drawStrokeWidth = stroke.widthPx / shortSide
631
+ layer.drawPoints = points.map { ($0.0 - centerX, $0.1 - centerY) }
632
+ return layer
633
+ }
634
+ if !newLayers.isEmpty { session.layerStack.commit { $0 + newLayers } }
635
+ onLayerStackChanged()
636
+ }
637
+
638
+ private func makeDrawSubBar(textColor: UIColor, primaryColor: UIColor, toolbarColor: UIColor) -> UIView {
639
+ let colors: [(String, UIColor)] = [("Red", .red), ("Yellow", .yellow), ("Green", .green), ("Blue", .systemBlue), ("White", .white), ("Black", .black)]
640
+ let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
641
+ let scroll = UIScrollView()
642
+ let controls = UIStackView(); controls.axis = .horizontal; controls.spacing = 4
643
+ colors.forEach { label, value in controls.addArrangedSubview(button(label, color: value) { [weak self] in self?.drawOverlay?.strokeColor = value }) }
644
+ controls.addArrangedSubview(button("Thin", color: textColor) { [weak self] in self?.drawOverlay?.strokeWidthPx = 2 })
645
+ controls.addArrangedSubview(button("Thick", color: textColor) { [weak self] in self?.drawOverlay?.strokeWidthPx = 10 })
646
+ controls.addArrangedSubview(button("Undo", color: textColor) { [weak self] in self?.drawOverlay?.undoLastStroke() })
647
+ controls.addArrangedSubview(button("Clear", color: textColor) { [weak self] in self?.drawOverlay?.clearStrokes() })
648
+ scroll.addSubview(controls); controls.translatesAutoresizingMaskIntoConstraints = false
649
+ NSLayoutConstraint.activate([
650
+ controls.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
651
+ controls.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
652
+ controls.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
653
+ controls.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
654
+ controls.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
655
+ ])
656
+ bar.addArrangedSubview(scroll)
657
+ bar.addArrangedSubview(button("Done", color: primaryColor) { [weak self] in
658
+ guard let self, let session = self.photoSession else { return }
659
+ self.setDrawMode(false, session: session)
660
+ })
661
+ bar.backgroundColor = toolbarColor
662
+ bar.isLayoutMarginsRelativeArrangement = true
663
+ bar.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
664
+ return bar
665
+ }
666
+
562
667
  private func consumerStickerAssets() -> [(id: String, uri: String)] {
563
668
  stickerAssets.compactMap { asset in
564
669
  guard let uri = asset["uri"] as? String, !uri.isEmpty else { return nil }
@@ -578,14 +683,24 @@ final class PhotoVideoEditorViewController: UIViewController {
578
683
  present(picker, animated: true)
579
684
  }
580
685
 
581
- /// Presents the OpenMoji "Browse online" bottom sheet, reusing `handlePickedImage` as the
582
- /// insertion point once the user picks a sticker — same shared path the Upload flow uses.
686
+ /// Presents the bundled sticker sheet, reusing `handlePickedImage` as the insertion point.
583
687
  private func presentOnlineStickerSheet(purpose: ImagePickPurpose) {
584
- let sheet = OnlineStickerSheetViewController()
688
+ let runtimeStickers = consumerStickerAssets().map { OnlineStickerSheetViewController.RuntimeSticker(id: $0.id, uri: $0.uri) }
689
+ let sheet = OnlineStickerSheetViewController(runtimeStickers: runtimeStickers)
690
+ sheet.isModalInPresentation = false
691
+ sheet.onDismiss = { [weak self] in
692
+ if case .photoSticker = purpose {
693
+ self?.stickersMode = false
694
+ self?.refreshPhotoToolSelection()
695
+ }
696
+ }
585
697
  sheet.onStickerPicked = { [weak self] fileURL, image in
586
698
  self?.handlePickedImage(fileURL, image: image, purpose: purpose)
587
699
  }
588
700
  sheet.sheetPresentationController?.detents = [.medium(), .large()]
701
+ sheet.sheetPresentationController?.selectedDetentIdentifier = .large
702
+ sheet.sheetPresentationController?.prefersGrabberVisible = true
703
+ sheet.sheetPresentationController?.preferredCornerRadius = DesignTokens.radiusSheet
589
704
  present(sheet, animated: true)
590
705
  }
591
706
 
@@ -608,8 +723,9 @@ final class PhotoVideoEditorViewController: UIViewController {
608
723
  switch purpose {
609
724
  case .photoSticker:
610
725
  guard let session = photoSession else { return }
611
- var layer = PhotoLayer(type: .sticker)
726
+ var layer = newSticker()
612
727
  layer.stickerUri = uri
728
+ layer.overlayAspectRatio = aspectRatio
613
729
  session.layerStack.commit { $0 + [layer] }
614
730
  selectLayer(layer.id)
615
731
  setStickersMode(false)
@@ -623,8 +739,9 @@ final class PhotoVideoEditorViewController: UIViewController {
623
739
  selectLayer(layer.id)
624
740
  case .videoSticker:
625
741
  guard let session = videoSession else { return }
626
- var layer = PhotoLayer(type: .sticker)
742
+ var layer = newSticker()
627
743
  layer.stickerUri = uri
744
+ layer.overlayAspectRatio = aspectRatio
628
745
  session.layerStack.commit { $0 + [layer] }
629
746
  onVideoLayerStackChanged()
630
747
  selectVideoLayer(layer.id, session: session)
@@ -646,8 +763,10 @@ final class PhotoVideoEditorViewController: UIViewController {
646
763
  let selected: Bool
647
764
  switch key {
648
765
  case "crop": selected = cropMode
649
- case "adjust", "filters": selected = filtersMode
766
+ case "adjust": selected = adjustMode
767
+ case "filters": selected = filtersMode
650
768
  case "stickers": selected = stickersMode
769
+ case "draw": selected = drawMode
651
770
  default: selected = false
652
771
  }
653
772
  setToolNodeSelected(node, selected: selected, textColor: textColor)
@@ -665,46 +784,35 @@ final class PhotoVideoEditorViewController: UIViewController {
665
784
  }
666
785
 
667
786
  private func selectLayer(_ id: String?) {
787
+ if selectedLayerID != id { activeLayerPropertyKey = "" }
668
788
  selectedLayerID = id
669
789
  layerOverlay?.selectedLayerID = id
670
790
  if let session = photoSession {
671
791
  layerOverlay?.layers = session.layerStack.layers
672
792
  schedulePhotoPreviewRender()
673
793
  }
794
+ if id != nil {
795
+ if drawMode, let session = photoSession { setDrawMode(false, session: session) }
796
+ if cropMode { cancelCropMode() }
797
+ filtersMode = false; stickersMode = false; adjustMode = false
798
+ filtersSubBar?.isHidden = true; stickersSubBar?.isHidden = true; adjustSubBar?.isHidden = true
799
+ }
674
800
  setLayerToolBarVisible(id != nil)
675
801
  }
676
802
 
677
803
  private func setLayerToolBarVisible(_ visible: Bool) {
678
804
  layerToolBar?.isHidden = !visible
679
805
  mainToolBar?.isHidden = visible
680
- if visible {
681
- // TEXT layers show only Opacity/Colors/Duplicate/Delete, and STICKER layers show only
682
- // Opacity/Duplicate/Delete (no Colors) — Scale/Rotate and the lock/visibility/reorder
683
- // icons don't apply to either and are hidden per the reduced action list.
684
- let isText = currentSelectedLayer()?.type == .text
685
- let isSticker = currentSelectedLayer()?.type == .sticker
686
- let reducedControls = isText || isSticker
687
- layerPropertyButtons["scale"]?.isHidden = reducedControls
688
- layerPropertyButtons["rotation"]?.isHidden = reducedControls
689
- layerPropertyButtons["color"]?.isHidden = !isText
690
- ["lock", "visibility", "front", "back"].forEach { key in
691
- layerIconButtons[key]?.isHidden = reducedControls
692
- }
693
- if reducedControls, ["scale", "rotation"].contains(activeLayerPropertyKey) {
694
- activeLayerPropertyKey = "opacity"
695
- } else if !isText, activeLayerPropertyKey == "color" {
696
- activeLayerPropertyKey = reducedControls ? "opacity" : "scale"
697
- }
698
- layerPropertySlider?.isHidden = activeLayerPropertyKey == "color"
699
- layerColorSwatchRow?.isHidden = activeLayerPropertyKey != "color"
700
- if activeLayerPropertyKey != "color" { syncLayerPropertySlider() }
701
- let textColor = color("textColor") ?? DesignTokens.onSurface
702
- let primaryColor = color("primaryColor") ?? DesignTokens.primaryContainer
703
- refreshSelection(layerPropertyButtons, activeKey: activeLayerPropertyKey, textColor: textColor, primaryColor: primaryColor)
704
- } else {
705
- layerPropertySlider?.isHidden = true
706
- layerColorSwatchRow?.isHidden = true
707
- }
806
+ let isText = currentSelectedLayer()?.type == .text
807
+ let simple = isText || currentSelectedLayer()?.type == .sticker
808
+ ["scale", "lock", "visibility", "front", "back"].forEach { layerPropertyButtons[$0]?.isHidden = simple }
809
+ ["edit", "color", "fontSize"].forEach { layerPropertyButtons[$0]?.isHidden = !isText }
810
+ if !isText && ["color", "fontSize"].contains(activeLayerPropertyKey) { activeLayerPropertyKey = "" }
811
+ layerPropertySlider?.isHidden = !visible || !["scale", "rotation", "opacity", "fontSize"].contains(activeLayerPropertyKey)
812
+ layerColorSwatchRow?.isHidden = !visible || activeLayerPropertyKey != "color"
813
+ if visible { syncLayerPropertySlider() }
814
+ refreshContextSelection(layerPropertyButtons, active: activeLayerPropertyKey)
815
+ updateSwatches(textSwatches, selected: currentSelectedLayer()?.textColor)
708
816
  }
709
817
 
710
818
  private func currentSelectedLayer() -> PhotoLayer? {
@@ -712,14 +820,30 @@ final class PhotoVideoEditorViewController: UIViewController {
712
820
  return session.layerStack.layers.first { $0.id == selectedLayerID }
713
821
  }
714
822
 
823
+ private func configureLayerSlider(_ slider: UISlider, video: Bool) {
824
+ slider.minimumTrackTintColor = color("primaryColor") ?? DesignTokens.primaryContainer
825
+ slider.addAction(UIAction { [weak self] _ in
826
+ guard let self else { return }
827
+ self.sliderSnapshot = video ? self.videoSession?.layerStack.layers : self.photoSession?.layerStack.layers
828
+ }, for: .touchDown)
829
+ slider.addAction(UIAction { [weak self] _ in
830
+ guard let self, let snapshot = self.sliderSnapshot else { return }
831
+ if video { self.videoSession?.layerStack.commitSnapshot(snapshot); self.onVideoLayerStackChanged() }
832
+ else { self.photoSession?.layerStack.commitSnapshot(snapshot); self.onLayerStackChanged() }
833
+ self.sliderSnapshot = nil
834
+ }, for: [.touchUpInside, .touchUpOutside, .touchCancel])
835
+ }
836
+
715
837
  private func syncLayerPropertySlider() {
716
838
  guard let slider = layerPropertySlider, let layer = currentSelectedLayer() else { return }
717
839
  let (min, max, value): (CGFloat, CGFloat, CGFloat)
718
840
  switch activeLayerPropertyKey {
719
- case "rotation": (min, max, value) = (-180, 180, layer.rotationDegrees)
841
+ case "rotation": (min, max, value) = (-180, 180, (layer.rotationDegrees.truncatingRemainder(dividingBy: 360) + 540).truncatingRemainder(dividingBy: 360) - 180)
720
842
  case "opacity": (min, max, value) = (0, 100, layer.opacity * 100)
843
+ case "fontSize": (min, max, value) = (12, 160, layer.fontSize)
721
844
  default: (min, max, value) = (20, 800, layer.scale * 100)
722
845
  }
846
+ (slider as? EditorToolSlider)?.propertyKey = slider === layerPropertySlider ? activeLayerPropertyKey : activeVideoLayerPropertyKey
723
847
  slider.minimumValue = Float(min)
724
848
  slider.maximumValue = Float(max)
725
849
  slider.value = Float(value)
@@ -727,6 +851,7 @@ final class PhotoVideoEditorViewController: UIViewController {
727
851
 
728
852
  @objc private func layerPropertyChanged(_ slider: UISlider) {
729
853
  guard let session = photoSession, let id = selectedLayerID else { return }
854
+ slider.setNeedsDisplay()
730
855
  let value = CGFloat(slider.value)
731
856
  let key = activeLayerPropertyKey
732
857
  session.layerStack.updateLive { list in
@@ -736,6 +861,7 @@ final class PhotoVideoEditorViewController: UIViewController {
736
861
  switch key {
737
862
  case "rotation": updated.rotationDegrees = value
738
863
  case "opacity": updated.opacity = value / 100
864
+ case "fontSize": updated.fontSize = value
739
865
  default: updated.scale = value / 100
740
866
  }
741
867
  return updated
@@ -746,99 +872,100 @@ final class PhotoVideoEditorViewController: UIViewController {
746
872
  }
747
873
 
748
874
  private func makeLayerToolBar(session: PhotoEditSession, textColor: UIColor, primaryColor: UIColor, toolbarColor: UIColor) -> UIView {
749
- // "color" only makes sense for TEXT layers; its chip is hidden/shown per-selection in `setLayerToolBarVisible`.
750
- let properties: [(String, String)] = [("scale", "Scale"), ("rotation", "Rotate"), ("opacity", "Opacity"), ("color", "Colors")]
751
- let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
752
- let scroll = UIScrollView()
753
- let chipStack = UIStackView(); chipStack.axis = .horizontal; chipStack.spacing = 4
754
- properties.forEach { key, label in
755
- let chip = button(label, color: textColor) { [weak self] in
756
- self?.activeLayerPropertyKey = key
757
- self?.layerPropertySlider?.isHidden = key == "color"
758
- self?.layerColorSwatchRow?.isHidden = key != "color"
759
- if key != "color" { self?.syncLayerPropertySlider() }
760
- if let self { self.refreshSelection(self.layerPropertyButtons, activeKey: self.activeLayerPropertyKey, textColor: textColor, primaryColor: primaryColor) }
875
+ makeContextToolbar(video: false, toolbarColor: toolbarColor) { [weak self] key in
876
+ guard let self, let layer = self.currentSelectedLayer() else { return }
877
+ switch key {
878
+ case "edit": self.showTextInputDialog(session: session, editingLayer: layer)
879
+ case "done": self.selectLayer(nil)
880
+ case "delete":
881
+ session.layerStack.commit { $0.filter { $0.id != layer.id } }
882
+ self.selectLayer(nil); self.onLayerStackChanged()
883
+ case "duplicate":
884
+ let copy = layer.duplicated()
885
+ session.layerStack.commit { $0 + [copy] }; self.selectLayer(copy.id); self.onLayerStackChanged()
886
+ case "lock", "visibility", "front", "back":
887
+ session.layerStack.commit { list in
888
+ if key == "front" { return list.filter { $0.id != layer.id } + [layer] }
889
+ if key == "back" { return [layer] + list.filter { $0.id != layer.id } }
890
+ return list.map { item in
891
+ guard item.id == layer.id else { return item }
892
+ var updated = item
893
+ if key == "lock" { updated.locked.toggle() } else { updated.visible.toggle() }
894
+ return updated
895
+ }
896
+ }
897
+ self.onLayerStackChanged()
898
+ default:
899
+ self.activeLayerPropertyKey = self.activeLayerPropertyKey == key ? "" : key
900
+ self.setLayerToolBarVisible(true)
761
901
  }
762
- layerPropertyButtons[key] = chip
763
- chipStack.addArrangedSubview(chip)
764
- }
765
- chipStack.addArrangedSubview(button("Duplicate", color: textColor) { [weak self] in
766
- guard let self, let original = self.currentSelectedLayer() else { return }
767
- var copy = PhotoLayer(id: UUID().uuidString, type: original.type)
768
- copy.x = min(max(original.x + 0.04, 0), 1)
769
- copy.y = min(max(original.y + 0.04, 0), 1)
770
- copy.scale = original.scale
771
- copy.rotationDegrees = original.rotationDegrees
772
- copy.opacity = original.opacity
773
- copy.text = original.text
774
- copy.textColor = original.textColor
775
- copy.fontSize = original.fontSize
776
- copy.fontFamily = original.fontFamily
777
- copy.stickerId = original.stickerId
778
- copy.stickerUri = original.stickerUri
779
- copy.overlayUri = original.overlayUri
780
- copy.overlayAspectRatio = original.overlayAspectRatio
781
- session.layerStack.commit { $0 + [copy] }
782
- self.selectLayer(copy.id)
783
- })
784
- let lockButton = button("Lock", color: textColor) { [weak self] in
785
- guard let self, let id = self.selectedLayerID else { return }
786
- session.layerStack.commit { list in list.map { $0.id == id ? { var l = $0; l.locked.toggle(); return l }() : $0 } }
787
- self.onLayerStackChanged()
788
- }
789
- layerIconButtons["lock"] = lockButton
790
- chipStack.addArrangedSubview(lockButton)
791
- let visibilityButton = button("Hide", color: textColor) { [weak self] in
792
- guard let self, let id = self.selectedLayerID else { return }
793
- session.layerStack.commit { list in list.map { $0.id == id ? { var l = $0; l.visible.toggle(); return l }() : $0 } }
794
- self.onLayerStackChanged()
795
902
  }
796
- layerIconButtons["visibility"] = visibilityButton
797
- chipStack.addArrangedSubview(visibilityButton)
798
- let frontButton = button("Front", color: textColor) { [weak self] in
799
- guard let self, let id = self.selectedLayerID else { return }
800
- session.layerStack.commit { list in list.filter { $0.id != id } + list.filter { $0.id == id } }
801
- self.onLayerStackChanged()
903
+ }
904
+
905
+ private func toolButton(_ label: String, symbol: String, action: @escaping () -> Void) -> UIButton {
906
+ let control = UIButton(type: .system)
907
+ var config = UIButton.Configuration.plain()
908
+ config.title = label
909
+ config.image = UIImage(systemName: symbol, withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular))
910
+ config.imagePlacement = .top
911
+ config.imagePadding = 6
912
+ config.baseForegroundColor = DesignTokens.outline
913
+ config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
914
+ var value = incoming; value.font = .systemFont(ofSize: 11, weight: .medium); return value
802
915
  }
803
- layerIconButtons["front"] = frontButton
804
- chipStack.addArrangedSubview(frontButton)
805
- let backButton = button("Back", color: textColor) { [weak self] in
806
- guard let self, let id = self.selectedLayerID else { return }
807
- session.layerStack.commit { list in list.filter { $0.id == id } + list.filter { $0.id != id } }
808
- self.onLayerStackChanged()
916
+ config.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 4, bottom: 8, trailing: 4)
917
+ control.configuration = config
918
+ control.accessibilityLabel = label
919
+ let width = control.widthAnchor.constraint(equalToConstant: 68); width.priority = .defaultHigh; width.isActive = true
920
+ control.heightAnchor.constraint(equalToConstant: 72).isActive = true
921
+ control.addAction(UIAction { _ in action() }, for: .touchUpInside)
922
+ control.applyPressScale()
923
+ return control
924
+ }
925
+
926
+ private func refreshContextSelection(_ buttons: [String: UIButton], active: String) {
927
+ buttons.forEach { key, control in
928
+ let selected = key == active
929
+ control.configuration?.baseForegroundColor = key == "delete" ? .systemRed : (selected ? (color("primaryColor") ?? DesignTokens.primaryContainer) : DesignTokens.outline)
930
+ control.backgroundColor = selected ? DesignTokens.surfaceContainerHigh : .clear
931
+ control.layer.cornerRadius = 12
932
+ control.accessibilityTraits = selected ? [.button, .selected] : .button
809
933
  }
810
- layerIconButtons["back"] = backButton
811
- chipStack.addArrangedSubview(backButton)
812
- chipStack.addArrangedSubview(button("Delete", color: textColor) { [weak self] in
813
- guard let self, let id = self.selectedLayerID else { return }
814
- session.layerStack.commit { list in list.filter { $0.id != id } }
815
- self.selectLayer(nil)
816
- self.onLayerStackChanged()
817
- })
818
- scroll.addSubview(chipStack)
819
- chipStack.translatesAutoresizingMaskIntoConstraints = false
934
+ }
935
+
936
+ private func makeContextToolbar(video: Bool, toolbarColor: UIColor, action: @escaping (String) -> Void) -> UIView {
937
+ var actions = [("edit", "Edit", "pencil"), ("color", "Color", "paintpalette"), ("fontSize", "Size", "textformat.size"), ("rotation", "Rotate", "arrow.clockwise"), ("opacity", "Opacity", "circle.lefthalf.filled"), ("scale", "Scale", "arrow.up.left.and.arrow.down.right"), ("duplicate", "Duplicate", "square.on.square"), ("delete", "Delete", "trash")]
938
+ if !video { actions += [("lock", "Lock", "lock"), ("visibility", "Hide", "eye.slash"), ("front", "Front", "square.3.layers.3d.top.filled"), ("back", "Back", "square.3.layers.3d.bottom.filled")] }
939
+ let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
940
+ let scroll = UIScrollView(); scroll.showsHorizontalScrollIndicator = false
941
+ let stack = UIStackView(); stack.axis = .horizontal
942
+ actions.forEach { key, label, symbol in
943
+ let control = toolButton(label, symbol: symbol) { action(key) }
944
+ if video { videoLayerPropertyButtons[key] = control } else { layerPropertyButtons[key] = control }
945
+ stack.addArrangedSubview(control)
946
+ }
947
+ scroll.addSubview(stack); stack.translatesAutoresizingMaskIntoConstraints = false
820
948
  NSLayoutConstraint.activate([
821
- chipStack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
822
- chipStack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
823
- chipStack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
824
- chipStack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
825
- chipStack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
949
+ stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
950
+ stack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
951
+ stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
952
+ stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
953
+ stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
954
+ scroll.heightAnchor.constraint(equalToConstant: 72),
826
955
  ])
827
956
  bar.addArrangedSubview(scroll)
828
- bar.addArrangedSubview(button("Done", color: primaryColor) { [weak self] in self?.selectLayer(nil) })
829
- bar.backgroundColor = toolbarColor
830
- bar.isLayoutMarginsRelativeArrangement = true
831
- bar.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
957
+ let done = toolButton("Done", symbol: "checkmark") { action("done") }
958
+ done.configuration?.baseForegroundColor = color("primaryColor") ?? DesignTokens.primaryContainer
959
+ bar.addArrangedSubview(done); bar.backgroundColor = toolbarColor
832
960
  return bar
833
961
  }
834
962
 
835
- /// Circular color swatch, matching the Android drawing_brush_studio mockup's palette dots.
836
963
  private func createColorSwatch(color: UIColor, onTap: @escaping () -> Void) -> UIView {
837
964
  let outer = UIButton(type: .custom)
838
965
  outer.backgroundColor = DesignTokens.surfaceContainerHigh
839
- outer.layer.cornerRadius = 16
840
- outer.widthAnchor.constraint(equalToConstant: 32).isActive = true
841
- outer.heightAnchor.constraint(equalToConstant: 32).isActive = true
966
+ outer.layer.cornerRadius = 22
967
+ outer.widthAnchor.constraint(equalToConstant: 44).isActive = true
968
+ outer.heightAnchor.constraint(equalToConstant: 44).isActive = true
842
969
  let inner = UIView()
843
970
  inner.backgroundColor = color
844
971
  inner.layer.cornerRadius = 12
@@ -857,41 +984,45 @@ final class PhotoVideoEditorViewController: UIViewController {
857
984
 
858
985
  /// Text color swatch row, shown when a TEXT layer's "Colors" property is active.
859
986
  private func makeTextColorSwatchRow(session: PhotoEditSession) -> UIView {
860
- let toolbarColor = color("toolbarColor") ?? DesignTokens.surfaceContainerLow
861
- let primaryColor = color("primaryColor") ?? DesignTokens.primaryContainer
862
- let colors: [UIColor] = [
863
- .white, .black, .red, UIColor(red: 255 / 255, green: 214 / 255, blue: 51 / 255, alpha: 1),
864
- UIColor(red: 76 / 255, green: 217 / 255, blue: 100 / 255, alpha: 1), UIColor(red: 0, green: 145 / 255, blue: 255 / 255, alpha: 1),
865
- DesignTokens.primary, DesignTokens.tertiary,
866
- ]
867
- let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
868
- let scroll = UIScrollView()
869
- let swatchStack = UIStackView(); swatchStack.axis = .horizontal; swatchStack.spacing = 8
870
- colors.forEach { swatchColor in
871
- swatchStack.addArrangedSubview(createColorSwatch(color: swatchColor) { [weak self] in
872
- guard let self, let id = self.selectedLayerID else { return }
873
- session.layerStack.commit { list in list.map { $0.id == id ? { var l = $0; l.textColor = swatchColor; return l }() : $0 } }
874
- self.onLayerStackChanged()
875
- })
987
+ makeTextPalette(video: false) { [weak self] color in
988
+ guard let self, let id = self.selectedLayerID else { return }
989
+ session.layerStack.commit { list in list.map { item in
990
+ var updated = item; if item.id == id { updated.textColor = color }; return updated
991
+ } }
992
+ self.onLayerStackChanged(); self.setLayerToolBarVisible(true)
993
+ }
994
+ }
995
+
996
+ private func updateSwatches(_ swatches: [(UIColor, UIView)], selected: UIColor?) {
997
+ swatches.forEach { color, swatch in
998
+ swatch.layer.borderWidth = color == selected ? 2 : 0
999
+ swatch.layer.borderColor = (self.color("primaryColor") ?? DesignTokens.primaryContainer).cgColor
1000
+ swatch.accessibilityTraits = color == selected ? [.button, .selected] : .button
876
1001
  }
877
- scroll.addSubview(swatchStack)
878
- swatchStack.translatesAutoresizingMaskIntoConstraints = false
1002
+ }
1003
+
1004
+ private func makeTextPalette(video: Bool, onColor: @escaping (UIColor) -> Void) -> UIView {
1005
+ let scroll = UIScrollView(); scroll.showsHorizontalScrollIndicator = false
1006
+ scroll.backgroundColor = color("toolbarColor") ?? DesignTokens.surfaceContainerLow
1007
+ let stack = UIStackView(); stack.axis = .horizontal; stack.spacing = 4
1008
+ let colors: [(String, UIColor)] = [("White", .white), ("Black", .black), ("Red", .systemRed), ("Orange", .systemOrange), ("Yellow", .systemYellow), ("Green", .systemGreen), ("Blue", .systemBlue), ("Purple", DesignTokens.primary), ("Pink", .systemPink)]
1009
+ colors.forEach { name, color in
1010
+ let swatch = createColorSwatch(color: color) { onColor(color) }
1011
+ swatch.accessibilityLabel = name
1012
+ if video { videoTextSwatches.append((color, swatch)) } else { textSwatches.append((color, swatch)) }
1013
+ stack.addArrangedSubview(swatch)
1014
+ }
1015
+ scroll.addSubview(stack); stack.translatesAutoresizingMaskIntoConstraints = false
879
1016
  NSLayoutConstraint.activate([
880
- swatchStack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
881
- swatchStack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
882
- swatchStack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
883
- swatchStack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
884
- swatchStack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
1017
+ stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 12),
1018
+ stack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -12),
1019
+ stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
1020
+ stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
1021
+ stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
885
1022
  ])
886
- bar.addArrangedSubview(scroll)
887
- bar.addArrangedSubview(button("Done", color: primaryColor) { [weak self] in self?.selectLayer(nil) })
888
- bar.backgroundColor = toolbarColor
889
- bar.isLayoutMarginsRelativeArrangement = true
890
- bar.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
891
- return bar
1023
+ return scroll
892
1024
  }
893
1025
 
894
- /// Refreshes the preview + selection + undo/redo enabled state after any layer-stack change.
895
1026
  private func onLayerStackChanged() {
896
1027
  guard let session = photoSession else { return }
897
1028
  if let id = selectedLayerID, !session.layerStack.layers.contains(where: { $0.id == id }) {
@@ -907,120 +1038,245 @@ final class PhotoVideoEditorViewController: UIViewController {
907
1038
  if selectedLayerID == nil, layerToolBar?.isHidden == false { setLayerToolBarVisible(false) }
908
1039
  }
909
1040
 
1041
+ private func setAdjustMode(_ enabled: Bool, session: PhotoEditSession) {
1042
+ adjustMode = enabled
1043
+ // The slider lives inside the panel now, so showing the panel shows it.
1044
+ adjustSubBar?.isHidden = !enabled
1045
+ mainToolBar?.isHidden = enabled
1046
+ layerOverlay?.isHidden = enabled
1047
+ // Entering Adjust selects a control to scrub but never resets the values themselves, so
1048
+ // Adjust -> Filters -> Adjust round-trips keep whatever the user already dialled in.
1049
+ if enabled {
1050
+ activeAdjustmentKey = "brightness"
1051
+ syncAdjustmentSlider(session: session)
1052
+ if let adjustSubBar { fadeInPanel(adjustSubBar) }
1053
+ }
1054
+ refreshPhotoToolSelection()
1055
+ refreshAdjustSelection(session: session)
1056
+ }
1057
+
910
1058
  private func setFiltersMode(_ enabled: Bool, session: PhotoEditSession) {
911
1059
  filtersMode = enabled
912
1060
  filtersSubBar?.isHidden = !enabled
913
- adjustmentSlider?.isHidden = !enabled
914
1061
  mainToolBar?.isHidden = enabled
915
1062
  layerOverlay?.isHidden = enabled
916
- if enabled { syncAdjustmentSlider(session: session) }
1063
+ if enabled {
1064
+ syncAdjustmentSlider(session: session)
1065
+ if let filtersSubBar { fadeInPanel(filtersSubBar) }
1066
+ }
917
1067
  refreshPhotoToolSelection()
918
1068
  refreshFiltersSelection(session: session)
919
1069
  }
920
1070
 
1071
+ /// Short cross-fade when a panel opens, per the "subtle transition" spec (no slide/scale drama).
1072
+ private func fadeInPanel(_ panel: UIView) {
1073
+ panel.alpha = 0
1074
+ UIView.animate(withDuration: 0.18) { panel.alpha = 1 }
1075
+ }
1076
+
1077
+ private func refreshAdjustSelection(session: PhotoEditSession) {
1078
+ let primaryColor = color("primaryColor") ?? DesignTokens.primaryContainer
1079
+ let defaults = PhotoAdjustments()
1080
+ adjustmentStripItems.forEach { key, item in
1081
+ if key == "mirror" {
1082
+ // A toggle, so "selected" means "on" rather than "being scrubbed".
1083
+ item.setSelected(session.adjustments.mirror, accentColor: primaryColor)
1084
+ item.setModified(false, accentColor: primaryColor)
1085
+ } else {
1086
+ item.setSelected(key == activeAdjustmentKey, accentColor: primaryColor)
1087
+ item.setModified(session.adjustments.value(key) != defaults.value(key), accentColor: primaryColor)
1088
+ }
1089
+ }
1090
+ }
1091
+
921
1092
  private func refreshFiltersSelection(session: PhotoEditSession) {
922
- let textColor = color("textColor") ?? DesignTokens.onSurface
923
1093
  let primaryColor = color("primaryColor") ?? DesignTokens.primaryContainer
924
- refreshSelection(filterAdjustmentButtons, activeKey: activeAdjustmentKey, textColor: textColor, primaryColor: primaryColor)
925
- refreshSelection(filterPresetButtons, activeKey: session.adjustments.filterPreset, textColor: textColor, primaryColor: primaryColor)
1094
+ let activePreset = session.adjustments.filterPreset ?? ""
1095
+ filterThumbnails.forEach { id, thumbnail in
1096
+ thumbnail.setSelected(id == activePreset, accentColor: primaryColor)
1097
+ }
1098
+ // Intensity only means something once a preset is applied.
1099
+ filterIntensityControl?.alpha = activePreset.isEmpty ? 0 : 1
1100
+ filterIntensityControl?.isUserInteractionEnabled = !activePreset.isEmpty
926
1101
  }
927
1102
 
1103
+ /// Points whichever slider is on screen at the currently selected value.
928
1104
  private func syncAdjustmentSlider(session: PhotoEditSession) {
929
- guard let slider = adjustmentSlider else { return }
930
1105
  let (min, max) = PhotoAdjustments.range(for: activeAdjustmentKey)
931
- slider.minimumValue = Float(min)
932
- slider.maximumValue = Float(max)
933
- slider.value = Float(session.adjustments.value(activeAdjustmentKey))
1106
+ if let control = adjustmentSliderControl {
1107
+ control.slider.minimumValue = Float(min)
1108
+ control.slider.maximumValue = Float(max)
1109
+ control.slider.value = Float(session.adjustments.value(activeAdjustmentKey))
1110
+ control.titleText = photoAdjustmentDefinitions.first { $0.0 == activeAdjustmentKey }?.1
1111
+ control.valueText = formatAdjustmentValue(activeAdjustmentKey, session.adjustments.value(activeAdjustmentKey))
1112
+ // Bipolar adjustments get a centre tick at their neutral value; unipolar ones (blur) do not.
1113
+ control.neutralValue = min < 0 ? 0 : nil
1114
+ }
1115
+ if let intensity = filterIntensityControl {
1116
+ let (strengthMin, strengthMax) = PhotoAdjustments.range(for: "filterStrength")
1117
+ intensity.slider.minimumValue = Float(strengthMin)
1118
+ intensity.slider.maximumValue = Float(strengthMax)
1119
+ intensity.slider.value = Float(session.adjustments.filterStrength)
1120
+ intensity.valueText = "\(Int(session.adjustments.filterStrength.rounded()))"
1121
+ }
1122
+ }
1123
+
1124
+ private func formatAdjustmentValue(_ key: String, _ value: Double) -> String {
1125
+ let rounded = Int(value.rounded())
1126
+ if key == "blurRadius" { return "\(rounded)" }
1127
+ return rounded > 0 ? "+\(rounded)" : "\(rounded)"
934
1128
  }
935
1129
 
936
1130
  @objc private func adjustmentChanged(_ slider: UISlider) {
937
- guard let session = photoSession, let imageView = photoImageView else { return }
1131
+ guard let session = photoSession else { return }
938
1132
  let key = activeAdjustmentKey
939
1133
  session.updateAdjustments { $0.setValue(key, Double(slider.value)) }
1134
+ adjustmentSliderControl?.valueText = formatAdjustmentValue(key, Double(slider.value))
1135
+ // Coalesced by the preview renderer, so dragging does not queue a render per pixel.
940
1136
  schedulePhotoPreviewRender()
941
1137
  }
942
1138
 
943
- private func makeFiltersSubBar(
944
- session: PhotoEditSession,
945
- imageView: ZoomableImageView,
946
- textColor: UIColor,
947
- primaryColor: UIColor,
948
- toolbarColor: UIColor
949
- ) -> UIView {
950
- let adjustmentChips: [(String, String)] = [
951
- ("brightness", "Brightness"), ("contrast", "Contrast"), ("saturation", "Saturation"),
952
- ("exposure", "Exposure"), ("temperature", "Temp"), ("blurRadius", "Blur"),
1139
+ @objc private func filterIntensityChanged(_ slider: UISlider) {
1140
+ guard let session = photoSession else { return }
1141
+ session.updateAdjustments { $0.filterStrength = Double(slider.value) }
1142
+ filterIntensityControl?.valueText = "\(Int(slider.value.rounded()))"
1143
+ schedulePhotoPreviewRender()
1144
+ }
1145
+
1146
+ /// Adjustments the processing engine actually implements, in carousel order. Deliberately not a
1147
+ /// wishlist: every entry maps to a real `PhotoAdjustments` field, so there are no controls that
1148
+ /// move a slider without changing the photo. (Highlights/shadows/tint/sharpness were removed from
1149
+ /// the engine in an earlier pass and would need that processing restored before they could return.)
1150
+ private var photoAdjustmentDefinitions: [(String, String, String)] {
1151
+ [
1152
+ ("exposure", "Exposure", "plusminus.circle"),
1153
+ ("brightness", "Brightness", "sun.max"),
1154
+ ("contrast", "Contrast", "circle.lefthalf.filled"),
1155
+ ("saturation", "Saturation", "drop"),
1156
+ ("temperature", "Warmth", "thermometer"),
1157
+ ("blurRadius", "Blur", "camera.filters"),
953
1158
  ]
954
- let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
955
- let scroll = UIScrollView()
956
- let chipStack = UIStackView(); chipStack.axis = .horizontal; chipStack.spacing = 4
957
- adjustmentChips.forEach { key, label in
958
- let chip = button(label, color: textColor) { [weak self] in
1159
+ }
1160
+
1161
+ /// Adjust panel: `Adjust / Reset / Done` header, one shared slider bound to whichever adjustment
1162
+ /// is selected, then a compact scrollable strip. Replaces the row of plain text buttons that
1163
+ /// previously mixed adjustments, presets and actions into a single undifferentiated bar.
1164
+ private func makeAdjustSubBar(session: PhotoEditSession, primaryColor: UIColor) -> UIView {
1165
+ let header = editorPanelHeader(
1166
+ title: "Adjust",
1167
+ accentColor: primaryColor,
1168
+ secondaryLabel: "Reset",
1169
+ onSecondary: { [weak self] in
1170
+ session.updateAdjustments {
1171
+ // Preserves the chosen filter preset — Reset here means "reset adjustments".
1172
+ let preset = $0.filterPreset
1173
+ let strength = $0.filterStrength
1174
+ $0 = PhotoAdjustments()
1175
+ $0.filterPreset = preset
1176
+ $0.filterStrength = strength
1177
+ }
1178
+ self?.syncAdjustmentSlider(session: session)
1179
+ self?.schedulePhotoPreviewRender()
1180
+ self?.refreshAdjustSelection(session: session)
1181
+ },
1182
+ onDone: { [weak self] in self?.setAdjustMode(false, session: session) }
1183
+ )
1184
+
1185
+ let strip = UIStackView()
1186
+ strip.spacing = DesignTokens.spaceXs
1187
+ photoAdjustmentDefinitions.forEach { key, label, symbol in
1188
+ let item = editorStripItem(systemName: symbol, labelText: label) { [weak self] in
959
1189
  self?.activeAdjustmentKey = key
960
1190
  self?.syncAdjustmentSlider(session: session)
961
- if let self { self.refreshFiltersSelection(session: session) }
1191
+ self?.refreshAdjustSelection(session: session)
962
1192
  }
963
- filterAdjustmentButtons[key] = chip
964
- chipStack.addArrangedSubview(chip)
1193
+ adjustmentStripItems[key] = item
1194
+ strip.addArrangedSubview(item.root)
965
1195
  }
966
- chipStack.addArrangedSubview(button("Mirror", color: textColor) { [weak self] in
1196
+ // Mirror is a toggle, not a slider target, but it lives in the same strip so the panel has one
1197
+ // visual language rather than a stray button.
1198
+ let mirror = editorStripItem(systemName: "flip.horizontal", labelText: "Mirror") { [weak self] in
967
1199
  session.updateAdjustments { $0.mirror.toggle() }
968
1200
  self?.schedulePhotoPreviewRender()
969
- })
1201
+ self?.refreshAdjustSelection(session: session)
1202
+ }
1203
+ adjustmentStripItems["mirror"] = mirror
1204
+ strip.addArrangedSubview(mirror.root)
1205
+
1206
+ let panel = UIStackView(arrangedSubviews: [header, adjustmentSliderControl ?? UIView(), editorToolStrip(strip)])
1207
+ panel.axis = .vertical
1208
+ panel.backgroundColor = DesignTokens.editorSurface
1209
+ NSLayoutConstraint.activate([
1210
+ header.heightAnchor.constraint(equalToConstant: EditorPanelMetrics.headerHeight),
1211
+ panel.arrangedSubviews[1].heightAnchor.constraint(equalToConstant: EditorPanelMetrics.sliderHeight),
1212
+ ])
1213
+ return panel
1214
+ }
1215
+
1216
+ /// Filters panel: `Filters / Done` header, an intensity slider shown once a preset is applied,
1217
+ /// then the preset carousel. Same skeleton as Adjust so the two read as one system.
1218
+ private func makeFiltersSubBar(session: PhotoEditSession, primaryColor: UIColor) -> UIView {
1219
+ let header = editorPanelHeader(
1220
+ title: "Filters",
1221
+ accentColor: primaryColor,
1222
+ onDone: { [weak self] in self?.setFiltersMode(false, session: session) }
1223
+ )
1224
+
1225
+ let strip = UIStackView()
1226
+ strip.spacing = DesignTokens.spaceSm
1227
+ let original = editorFilterThumbnail(labelText: "Original") { [weak self] in
1228
+ session.updateAdjustments { $0.filterPreset = nil; $0.filterStrength = 100 }
1229
+ self?.syncAdjustmentSlider(session: session)
1230
+ self?.schedulePhotoPreviewRender()
1231
+ self?.refreshFiltersSelection(session: session)
1232
+ }
1233
+ original.image.image = session.baseImage
1234
+ filterThumbnails[""] = original
1235
+ strip.addArrangedSubview(original.root)
1236
+
970
1237
  PhotoFilterPresets.presetIDs.forEach { id in
971
- let chip = button(PhotoFilterPresets.label(for: id), color: textColor) { [weak self] in
1238
+ let thumbnail = editorFilterThumbnail(labelText: PhotoFilterPresets.label(for: id)) { [weak self] in
972
1239
  session.updateAdjustments { $0.filterPreset = $0.filterPreset == id ? nil : id }
973
- self?.activeAdjustmentKey = "filterStrength"
974
1240
  self?.syncAdjustmentSlider(session: session)
975
1241
  self?.schedulePhotoPreviewRender()
976
- if let self { self.refreshFiltersSelection(session: session) }
1242
+ self?.refreshFiltersSelection(session: session)
977
1243
  }
978
- filterPresetButtons[id] = chip
979
- chipStack.addArrangedSubview(chip)
1244
+ // Thumbnails render off a cached downscaled copy of the photo, never the full-resolution
1245
+ // image — a preset per look at full size would stall the panel on open.
1246
+ filterThumbnailLoader.load(source: session.baseImage, presetID: id, size: EditorPanelMetrics.thumbnailWidth) { image in
1247
+ thumbnail.image.image = image
1248
+ }
1249
+ filterThumbnails[id] = thumbnail
1250
+ strip.addArrangedSubview(thumbnail.root)
980
1251
  }
981
- chipStack.addArrangedSubview(button("Reset", color: textColor) { [weak self] in
982
- session.updateAdjustments { $0 = PhotoAdjustments() }
983
- self?.syncAdjustmentSlider(session: session)
984
- self?.schedulePhotoPreviewRender()
985
- if let self { self.refreshFiltersSelection(session: session) }
986
- })
987
- scroll.addSubview(chipStack)
988
- chipStack.translatesAutoresizingMaskIntoConstraints = false
1252
+
1253
+ let panel = UIStackView(arrangedSubviews: [header, filterIntensityControl ?? UIView(), editorToolStrip(strip)])
1254
+ panel.axis = .vertical
1255
+ panel.backgroundColor = DesignTokens.editorSurface
989
1256
  NSLayoutConstraint.activate([
990
- chipStack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
991
- chipStack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
992
- chipStack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
993
- chipStack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
994
- chipStack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
1257
+ header.heightAnchor.constraint(equalToConstant: EditorPanelMetrics.headerHeight),
1258
+ panel.arrangedSubviews[1].heightAnchor.constraint(equalToConstant: EditorPanelMetrics.sliderHeight),
995
1259
  ])
996
- bar.addArrangedSubview(scroll)
997
- bar.addArrangedSubview(button("Done", color: primaryColor) { [weak self] in self?.setFiltersMode(false, session: session) })
998
- bar.backgroundColor = toolbarColor
999
- bar.isLayoutMarginsRelativeArrangement = true
1000
- bar.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
1001
- return bar
1260
+ return panel
1002
1261
  }
1003
1262
 
1004
1263
  private func setCropMode(_ enabled: Bool) {
1005
- guard let imageView = photoImageView, let overlay = cropOverlay, let session = photoSession else { return }
1264
+ guard let session = photoSession else { return }
1265
+ if enabled && !cropMode { cropEntryState = session.state }
1006
1266
  cropMode = enabled
1007
- imageView.panZoomEnabled = !enabled
1008
- overlay.isHidden = !enabled
1009
- straightenSlider?.isHidden = !enabled
1010
- cropSubBar?.isHidden = !enabled
1011
- mainToolBar?.isHidden = enabled
1012
- layerOverlay?.isHidden = enabled
1013
- if enabled {
1014
- imageView.resetToFit()
1015
- overlay.setImageBounds(imageView.currentImageBounds(), resetCrop: session.state.isIdentity)
1016
- }
1017
- refreshPhotoToolSelection()
1018
- let textColor = color("textColor") ?? DesignTokens.onSurface
1019
- let primaryColor = color("primaryColor") ?? DesignTokens.primaryContainer
1020
- refreshSelection(cropAspectButtons, activeKey: aspectRatioLabel(session.state.aspectRatio), textColor: textColor, primaryColor: primaryColor)
1267
+ photoImageView?.panZoomEnabled = !enabled
1268
+ cropOverlay?.isHidden = !enabled; cropSubBar?.isHidden = !enabled
1269
+ mainToolBar?.isHidden = enabled; layerOverlay?.isHidden = enabled
1270
+ if !enabled { cropEntryState = nil }
1271
+ photoCropPanel?.sync(session.state); schedulePhotoPreviewRender(); refreshPhotoToolSelection()
1272
+ }
1273
+
1274
+ private func cancelCropMode() {
1275
+ guard let session = photoSession else { return }
1276
+ if let saved = cropEntryState { session.update { $0 = saved } }
1277
+ setCropMode(false)
1021
1278
  }
1022
1279
 
1023
- /// Maps an aspect ratio back to its preset label (matching the presets defined in `makeCropSubBar`/`makeVideoAspectSubBar`) so the correct chip can be highlighted.
1024
1280
  private func aspectRatioLabel(_ ratio: CGFloat?) -> String {
1025
1281
  guard let ratio else { return "Free" }
1026
1282
  let knownRatios: [(String, CGFloat)] = [("1:1", 1), ("4:5", 4 / 5), ("3:4", 3 / 4), ("9:16", 9 / 16), ("16:9", 16 / 9)]
@@ -1028,16 +1284,9 @@ final class PhotoVideoEditorViewController: UIViewController {
1028
1284
  }
1029
1285
 
1030
1286
  private func resetPhotoEdits() {
1031
- guard let imageView = photoImageView, let overlay = cropOverlay, let session = photoSession else { return }
1287
+ guard let session = photoSession else { return }
1032
1288
  session.update { $0.reset() }
1033
- overlay.setAspectRatio(nil)
1034
- straightenSlider?.value = 0
1035
- schedulePhotoPreviewRender()
1036
- imageView.resetToFit()
1037
- overlay.setImageBounds(imageView.currentImageBounds(), resetCrop: true)
1038
- let textColor = color("textColor") ?? DesignTokens.onSurface
1039
- let primaryColor = color("primaryColor") ?? DesignTokens.primaryContainer
1040
- refreshSelection(cropAspectButtons, activeKey: "Free", textColor: textColor, primaryColor: primaryColor)
1289
+ photoCropPanel?.sync(session.state); schedulePhotoPreviewRender()
1041
1290
  }
1042
1291
 
1043
1292
  @objc private func straightenChanged(_ slider: UISlider) {
@@ -1051,6 +1300,7 @@ final class PhotoVideoEditorViewController: UIViewController {
1051
1300
  completion?(.failure(code: "E_INTERNAL", message: "Nothing to export."))
1052
1301
  return
1053
1302
  }
1303
+ if drawMode { setDrawMode(false, session: session) }
1054
1304
  progressOverlay?.isHidden = false
1055
1305
  let state = session.state
1056
1306
  let adjustments = session.adjustments
@@ -1086,9 +1336,12 @@ final class PhotoVideoEditorViewController: UIViewController {
1086
1336
  videoSession = session
1087
1337
 
1088
1338
  let header = makeHeader(textColor: textColor, primaryColor: primaryColor, toolbarColor: toolbarColor)
1089
- let preview = UIView(); preview.backgroundColor = backgroundColor
1339
+ let preview = UIView(); preview.backgroundColor = .black
1340
+ preview.clipsToBounds = true; videoPreviewContainer = preview
1090
1341
 
1091
1342
  let playerController = AVPlayerViewController()
1343
+ cropPlayerView = playerController.view
1344
+ playerController.videoGravity = .resizeAspect
1092
1345
  playerController.player = session.player
1093
1346
  playerController.showsPlaybackControls = false
1094
1347
  addChild(playerController)
@@ -1114,6 +1367,21 @@ final class PhotoVideoEditorViewController: UIViewController {
1114
1367
  overlayView.bottomAnchor.constraint(equalTo: preview.bottomAnchor),
1115
1368
  ])
1116
1369
 
1370
+ let cropCanvas = CropOverlayView(); cropCanvas.isHidden = true
1371
+ videoCropOverlay = cropCanvas
1372
+ preview.addSubview(cropCanvas); cropCanvas.translatesAutoresizingMaskIntoConstraints = false
1373
+ NSLayoutConstraint.activate([
1374
+ cropCanvas.leadingAnchor.constraint(equalTo:preview.leadingAnchor),cropCanvas.trailingAnchor.constraint(equalTo:preview.trailingAnchor),
1375
+ cropCanvas.topAnchor.constraint(equalTo:preview.topAnchor),cropCanvas.bottomAnchor.constraint(equalTo:preview.bottomAnchor)
1376
+ ])
1377
+ cropCanvas.onCropChanged = { l,t,r,b in session.update { $0.crop.setCrop(left:l,top:t,right:r,bottom:b) } }
1378
+ cropCanvas.onViewportChanged = { zoom,x,y in session.update { $0.crop.zoom = zoom; $0.crop.panX = x; $0.crop.panY = y } }
1379
+ cropCanvas.onMediaBoundsChanged = { [weak self] bounds in
1380
+ guard let self,let playerView = self.cropPlayerView else { return }
1381
+ let fitted = self.videoMediaBounds(session:session,in:preview,cropped:false)
1382
+ let scale = bounds.width/max(fitted.width,0.001)
1383
+ playerView.transform = CGAffineTransform(translationX:bounds.midX-fitted.midX,y:bounds.midY-fitted.midY).scaledBy(x:scale,y:scale)
1384
+ }
1117
1385
  let videoLayers = LayerOverlayView()
1118
1386
  videoLayerOverlay = videoLayers
1119
1387
  preview.addSubview(videoLayers)
@@ -1124,6 +1392,13 @@ final class PhotoVideoEditorViewController: UIViewController {
1124
1392
  videoLayers.topAnchor.constraint(equalTo: preview.topAnchor),
1125
1393
  videoLayers.bottomAnchor.constraint(equalTo: preview.bottomAnchor),
1126
1394
  ])
1395
+ videoLayers.onLayerDelete = { [weak self] id in
1396
+ session.layerStack.commit { $0.filter { $0.id != id } }
1397
+ self?.selectVideoLayer(nil, session: session); self?.onVideoLayerStackChanged()
1398
+ }
1399
+ videoLayers.onLayerDoubleTapped = { [weak self] id in
1400
+ if let layer = session.layerStack.layers.first(where: { $0.id == id && $0.type == .text }) { self?.showVideoTextInputDialog(session: session, editingLayer: layer) }
1401
+ }
1127
1402
  videoLayers.onLayerTapped = { [weak self] id in self?.selectVideoLayer(id, session: session) }
1128
1403
  videoLayers.onLayerTransformChanged = { [weak self] id, x, y, scale, rotation in
1129
1404
  guard let self else { return }
@@ -1141,26 +1416,51 @@ final class PhotoVideoEditorViewController: UIViewController {
1141
1416
  self.onVideoLayerStackChanged()
1142
1417
  }
1143
1418
 
1144
- let playPause = button("Play", color: textColor) { [weak self] in
1419
+ let playPause = UIButton(type: .system)
1420
+ playPause.widthAnchor.constraint(equalToConstant: 44).isActive = true
1421
+ playPause.heightAnchor.constraint(equalToConstant: 44).isActive = true
1422
+ playPause.tintColor = .white
1423
+ playPause.backgroundColor = UIColor.black.withAlphaComponent(0.55)
1424
+ playPause.layer.cornerRadius = 20
1425
+ playPause.setImage(UIImage(systemName: "play.fill"), for: .normal)
1426
+ playPause.accessibilityLabel = "Play video"
1427
+ playPause.applyPressScale()
1428
+ playPause.addAction(UIAction { [weak self] _ in
1145
1429
  guard let self, let session = self.videoSession else { return }
1146
- // `.rate = x` (rather than `.play()`) so playback always resumes at the currently selected speed.
1147
- if session.player.timeControlStatus == .playing { session.player.pause() } else { session.player.rate = session.state.speed }
1148
- }
1430
+ if session.player.timeControlStatus == .playing {
1431
+ session.player.pause()
1432
+ } else {
1433
+ let current = CMTimeGetSeconds(session.player.currentTime())
1434
+ let duration = Double(session.durationMs) / 1000
1435
+ if current.isFinite, duration > 0, current >= duration - 0.05 {
1436
+ session.player.seek(to: .zero) { finished in
1437
+ if finished { session.player.rate = session.state.speed }
1438
+ }
1439
+ } else {
1440
+ session.player.rate = session.state.speed
1441
+ }
1442
+ }
1443
+ self.setVideoControlsVisible(true)
1444
+ }, for: .touchUpInside)
1149
1445
  playPauseButton = playPause
1150
1446
  let time = UILabel()
1151
1447
  time.text = "0:00 / 0:00"
1152
- time.textColor = textColor
1153
- time.font = .systemFont(ofSize: 12)
1448
+ time.textColor = .white
1449
+ time.font = .monospacedDigitSystemFont(ofSize: 11, weight: .medium)
1154
1450
  timeLabel = time
1155
1451
  let transport = UIStackView(arrangedSubviews: [playPause, time])
1156
1452
  transport.axis = .horizontal
1157
1453
  transport.alignment = .center
1158
1454
  transport.spacing = 8
1159
- transport.backgroundColor = toolbarColor
1160
- transport.isLayoutMarginsRelativeArrangement = true
1161
- transport.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
1162
1455
 
1163
1456
  let position = UISlider()
1457
+ position.accessibilityLabel = "Playback position"
1458
+ position.minimumTrackTintColor = primaryColor
1459
+ position.maximumTrackTintColor = UIColor.white.withAlphaComponent(0.35)
1460
+ if let thumb = UIImage(systemName: "circle.fill")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 11)) {
1461
+ position.setThumbImage(thumb.withTintColor(.white, renderingMode: .alwaysOriginal), for: .normal)
1462
+ }
1463
+ position.heightAnchor.constraint(equalToConstant: 20).isActive = true
1164
1464
  position.minimumValue = 0
1165
1465
  position.maximumValue = 1000
1166
1466
  position.addTarget(self, action: #selector(positionSliderChanged(_:)), for: .valueChanged)
@@ -1168,6 +1468,22 @@ final class PhotoVideoEditorViewController: UIViewController {
1168
1468
  position.addTarget(self, action: #selector(positionSliderTouchUp(_:)), for: [.touchUpInside, .touchUpOutside])
1169
1469
  positionSlider = position
1170
1470
 
1471
+ let controlsOverlay = UIStackView(arrangedSubviews: [transport, position])
1472
+ controlsOverlay.axis = .vertical
1473
+ controlsOverlay.spacing = 2
1474
+ controlsOverlay.backgroundColor = UIColor.black.withAlphaComponent(0.45)
1475
+ controlsOverlay.isLayoutMarginsRelativeArrangement = true
1476
+ controlsOverlay.layoutMargins = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
1477
+ videoControlsOverlay = controlsOverlay
1478
+ preview.addSubview(controlsOverlay)
1479
+ controlsOverlay.translatesAutoresizingMaskIntoConstraints = false
1480
+ NSLayoutConstraint.activate([
1481
+ controlsOverlay.leadingAnchor.constraint(equalTo: preview.leadingAnchor),
1482
+ controlsOverlay.trailingAnchor.constraint(equalTo: preview.trailingAnchor),
1483
+ controlsOverlay.bottomAnchor.constraint(equalTo: preview.bottomAnchor),
1484
+ ])
1485
+ preview.addGestureRecognizer(ClosureTapGestureRecognizer(action: { [weak self] in self?.toggleVideoControls() }))
1486
+
1171
1487
  let trim = TrimRangeView()
1172
1488
  // Bound to the currently selected clip (Milestone 7): fractions are relative to that clip's
1173
1489
  // own `originalDurationMs`, not the composed multi-clip timeline.
@@ -1213,24 +1529,75 @@ final class PhotoVideoEditorViewController: UIViewController {
1213
1529
  }
1214
1530
  }
1215
1531
 
1532
+ let cropPanel = CropPanel(accent:primaryColor,surface:toolbarColor,foreground:textColor,
1533
+ originalRatio: { [weak self] in let size = self?.videoSourceSize(session:session) ?? CGSize(width:1,height:1); return size.width/size.height },
1534
+ onReset: { [weak self] in session.update { $0.crop = PhotoTransformState() }; self?.refreshVideoCrop(session:session) },
1535
+ onDone: { [weak self] in self?.setVideoCropMode(false,session:session) },
1536
+ onCancel: { [weak self] in if let saved = self?.videoCropEntryState { session.update { $0.crop = saved } }; self?.setVideoCropMode(false,session:session) },
1537
+ onStraighten: { [weak self] value in session.update { $0.crop.setStraighten(value) }; self?.refreshVideoCrop(session:session) },
1538
+ onRotate: { [weak self] in
1539
+ session.update { $0.crop.rotateRight(); $0.crop.zoom = 1; $0.crop.panX = 0; $0.crop.panY = 0; $0.crop.aspectRatio = nil; $0.crop.aspectPreset = "Free" }
1540
+ self?.refreshVideoCrop(session:session)
1541
+ },
1542
+ onRatio: { [weak self] label,ratio in
1543
+ session.update { $0.crop.aspectRatio = ratio; $0.crop.aspectPreset = label }
1544
+ cropCanvas.setAspectRatio(ratio); self?.videoCropPanel?.sync(session.state.crop)
1545
+ })
1546
+ cropPanel.isHidden = true; videoCropPanel = cropPanel
1547
+ cropPanel.heightAnchor.constraint(equalToConstant:232).isActive = true
1216
1548
  let aspectBar = makeVideoAspectSubBar(session: session, textColor: textColor, primaryColor: primaryColor, toolbarColor: toolbarColor)
1217
1549
  aspectBar.isHidden = true
1218
1550
  videoAspectSubBar = aspectBar
1219
1551
 
1220
- let layerSlider = UISlider()
1552
+ let layerSlider = EditorToolSlider()
1221
1553
  layerSlider.isHidden = true
1222
1554
  layerSlider.addTarget(self, action: #selector(videoLayerPropertyChanged(_:)), for: .valueChanged)
1555
+ configureLayerSlider(layerSlider, video: true)
1223
1556
  videoLayerPropertySlider = layerSlider
1224
1557
 
1558
+ let palette = makeTextPalette(video: true) { [weak self] color in
1559
+ guard let self, let id = self.selectedVideoLayerID else { return }
1560
+ session.layerStack.commit { list in list.map { item in
1561
+ var updated = item; if item.id == id { updated.textColor = color }; return updated
1562
+ } }
1563
+ self.onVideoLayerStackChanged(); self.setVideoLayerToolBarVisible(true, session: session)
1564
+ }
1565
+ palette.isHidden = true; videoColorRow = palette
1566
+ palette.heightAnchor.constraint(equalToConstant: 48).isActive = true
1225
1567
  let layerBar = makeVideoLayerToolBar(session: session, textColor: textColor, primaryColor: primaryColor, toolbarColor: toolbarColor)
1226
1568
  layerBar.isHidden = true
1227
1569
  videoLayerToolBar = layerBar
1228
1570
 
1229
- let toolbar = makeVideoToolBar(session: session, playerController: playerController, textColor: textColor, toolbarColor: toolbarColor)
1571
+ let toolbar = makeVideoToolBar(session: session, playerController: playerController)
1230
1572
  videoToolBar = toolbar
1231
1573
 
1232
- let root = UIStackView(arrangedSubviews: [header, preview, transport, position, trim, stripScroll, aspectBar, layerSlider, layerBar, toolbar])
1574
+ let previewWrapper = UIView()
1575
+ previewWrapper.addSubview(preview)
1576
+ preview.translatesAutoresizingMaskIntoConstraints = false
1577
+ NSLayoutConstraint.activate([
1578
+ preview.leadingAnchor.constraint(equalTo: previewWrapper.leadingAnchor),
1579
+ preview.trailingAnchor.constraint(equalTo: previewWrapper.trailingAnchor),
1580
+ preview.topAnchor.constraint(equalTo: previewWrapper.topAnchor, constant: DesignTokens.spaceXs),
1581
+ preview.bottomAnchor.constraint(equalTo: previewWrapper.bottomAnchor, constant: -DesignTokens.spaceXs),
1582
+ ])
1583
+
1584
+ // Transparent holder so the safe-area inset lands on empty space rather than stretching the pill.
1585
+ let toolbarWrapper = UIView()
1586
+ toolbarWrapper.addSubview(toolbar)
1587
+ toolbar.translatesAutoresizingMaskIntoConstraints = false
1588
+ NSLayoutConstraint.activate([
1589
+ toolbar.leadingAnchor.constraint(equalTo: toolbarWrapper.leadingAnchor, constant: DesignTokens.spaceMd),
1590
+ toolbar.trailingAnchor.constraint(equalTo: toolbarWrapper.trailingAnchor, constant: -DesignTokens.spaceMd),
1591
+ toolbar.topAnchor.constraint(equalTo: toolbarWrapper.topAnchor),
1592
+ toolbar.bottomAnchor.constraint(equalTo: toolbarWrapper.bottomAnchor, constant: -DesignTokens.spaceSm),
1593
+ ])
1594
+
1595
+ // The editor is intentionally single-video: omit the multi-clip strip and
1596
+ // its Split/Copy/Delete/Reorder controls from the visible layout. Playback
1597
+ // controls (transport/position) live inside `preview` itself, not in this stack.
1598
+ let root = UIStackView(arrangedSubviews: [header, previewWrapper, cropPanel, aspectBar, layerSlider, palette, layerBar, toolbarWrapper])
1233
1599
  root.axis = .vertical
1600
+ root.spacing = DesignTokens.spaceSm
1234
1601
  root.translatesAutoresizingMaskIntoConstraints = false
1235
1602
  view.addSubview(root)
1236
1603
  NSLayoutConstraint.activate([
@@ -1239,14 +1606,16 @@ final class PhotoVideoEditorViewController: UIViewController {
1239
1606
  root.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
1240
1607
  root.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
1241
1608
  header.heightAnchor.constraint(equalToConstant: 60),
1242
- transport.heightAnchor.constraint(equalToConstant: 40),
1243
1609
  trim.heightAnchor.constraint(equalToConstant: 48),
1244
1610
  stripScroll.heightAnchor.constraint(equalToConstant: 40),
1245
1611
  aspectBar.heightAnchor.constraint(equalToConstant: 56),
1246
- layerBar.heightAnchor.constraint(equalToConstant: 56),
1247
- toolbar.heightAnchor.constraint(equalToConstant: 72),
1612
+ layerBar.heightAnchor.constraint(equalToConstant: 76),
1613
+ toolbar.heightAnchor.constraint(equalToConstant: 52),
1248
1614
  ])
1249
1615
 
1616
+ root.arrangedSubviews.forEach { child in
1617
+ child.constraints.filter { $0.firstAttribute == .height && $0.secondItem == nil }.forEach { $0.priority = UILayoutPriority(999) }
1618
+ }
1250
1619
  let progress = UIView()
1251
1620
  progress.backgroundColor = UIColor.black.withAlphaComponent(0.6)
1252
1621
  progress.isHidden = true
@@ -1276,6 +1645,7 @@ final class PhotoVideoEditorViewController: UIViewController {
1276
1645
  }
1277
1646
 
1278
1647
  private func startPositionPolling() {
1648
+ var wasPlaying = false
1279
1649
  positionPollTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in
1280
1650
  guard let self, let session = self.videoSession else { return }
1281
1651
  if !self.seekingPosition, session.durationMs > 0 {
@@ -1283,11 +1653,44 @@ final class PhotoVideoEditorViewController: UIViewController {
1283
1653
  self.positionSlider?.value = Float(min(max(Double(currentMs) / Double(session.durationMs), 0), 1)) * 1000
1284
1654
  self.updateTimeLabel()
1285
1655
  }
1286
- self.playPauseButton?.setTitle(session.player.timeControlStatus == .playing ? "Pause" : "Play", for: .normal)
1656
+ let playing = session.player.timeControlStatus == .playing
1657
+ self.playPauseButton?.setImage(UIImage(systemName: playing ? "pause.fill" : "play.fill"), for: .normal)
1658
+ self.playPauseButton?.accessibilityLabel = playing ? "Pause video" : "Play video"
1659
+ if playing != wasPlaying {
1660
+ wasPlaying = playing
1661
+ if playing { self.scheduleAutoHideVideoControls() } else { self.setVideoControlsVisible(true) }
1662
+ }
1287
1663
  self.refreshVideoOverlayPreview(session: session)
1288
1664
  }
1289
1665
  }
1290
1666
 
1667
+ /// Shows/hides the in-preview playback controls with a subtle fade (spec: auto-hide while playing).
1668
+ private func setVideoControlsVisible(_ visible: Bool) {
1669
+ guard let overlay = videoControlsOverlay else { return }
1670
+ hideVideoControlsTimer?.invalidate()
1671
+ videoControlsVisible = visible
1672
+ UIView.animate(withDuration: 0.2) {
1673
+ overlay.alpha = visible ? 1 : 0
1674
+ }
1675
+ }
1676
+
1677
+ private func scheduleAutoHideVideoControls() {
1678
+ hideVideoControlsTimer?.invalidate()
1679
+ guard let session = videoSession, session.player.timeControlStatus == .playing else { return }
1680
+ hideVideoControlsTimer = Timer.scheduledTimer(withTimeInterval: 2.5, repeats: false) { [weak self] _ in
1681
+ self?.setVideoControlsVisible(false)
1682
+ }
1683
+ }
1684
+
1685
+ private func toggleVideoControls() {
1686
+ if videoControlsVisible {
1687
+ setVideoControlsVisible(false)
1688
+ } else {
1689
+ setVideoControlsVisible(true)
1690
+ scheduleAutoHideVideoControls()
1691
+ }
1692
+ }
1693
+
1291
1694
  private func updateTimeLabel() {
1292
1695
  guard let session = videoSession else { return }
1293
1696
  let currentMs = Int64(CMTimeGetSeconds(session.player.currentTime()) * 1000)
@@ -1341,23 +1744,51 @@ final class PhotoVideoEditorViewController: UIViewController {
1341
1744
  }
1342
1745
 
1343
1746
  /// Where the video actually renders within `view` under aspect-fit letterboxing (mirrors `ZoomableImageView.currentImageBounds()`'s math).
1344
- private func computeVideoLetterboxBounds(session: VideoEditSession, in view: UIView) -> CGRect {
1345
- guard let clip = session.clips.first, let track = session.asset(for: clip).tracks(withMediaType: .video).first else { return view.bounds }
1346
- let naturalSize = track.naturalSize.applying(track.preferredTransform)
1347
- let videoWidth = abs(naturalSize.width)
1348
- let videoHeight = abs(naturalSize.height)
1349
- let viewWidth = view.bounds.width
1350
- let viewHeight = view.bounds.height
1351
- guard videoWidth > 0, videoHeight > 0, viewWidth > 0, viewHeight > 0 else { return view.bounds }
1352
- let scale = min(viewWidth / videoWidth, viewHeight / videoHeight)
1353
- let fittedWidth = videoWidth * scale
1354
- let fittedHeight = videoHeight * scale
1355
- let x = (viewWidth - fittedWidth) / 2
1356
- let y = (viewHeight - fittedHeight) / 2
1357
- return CGRect(x: x, y: y, width: fittedWidth, height: fittedHeight)
1358
- }
1359
-
1360
- /// Mirrors `onLayerStackChanged` for the video overlay stack (Milestone 6).
1747
+ private func videoSourceSize(session:VideoEditSession) -> CGSize {
1748
+ guard let clip = session.clips.first,let track = session.asset(for:clip).tracks(withMediaType:.video).first else { return CGSize(width:1,height:1) }
1749
+ let size = track.naturalSize.applying(track.preferredTransform)
1750
+ return CGSize(width:max(1,abs(size.width)),height:max(1,abs(size.height)))
1751
+ }
1752
+
1753
+ private func videoMediaBounds(session:VideoEditSession,in view:UIView,cropped:Bool) -> CGRect {
1754
+ let size = VideoCropGeometry.plan(size:videoSourceSize(session:session),state:session.state.crop,includeCrop:cropped).size
1755
+ return AVMakeRect(aspectRatio:size,insideRect:view.bounds)
1756
+ }
1757
+
1758
+ private func computeVideoLetterboxBounds(session:VideoEditSession,in view:UIView) -> CGRect {
1759
+ videoMediaBounds(session:session,in:view,cropped:!videoCropMode)
1760
+ }
1761
+
1762
+ private func refreshVideoCrop(session:VideoEditSession) {
1763
+ if let item = session.player.currentItem {
1764
+ item.videoComposition = VideoCropGeometry.previewComposition(asset:item.asset,state:session.state.crop,includeCrop:!videoCropMode)
1765
+ // Refresh the paused frame after changing a composition.
1766
+ session.player.seek(to:session.player.currentTime(),toleranceBefore:.zero,toleranceAfter:.zero)
1767
+ }
1768
+ cropPlayerView?.transform = .identity
1769
+ videoCropPanel?.sync(session.state.crop)
1770
+ view.layoutIfNeeded()
1771
+ if videoCropMode,let preview = videoPreviewContainer { videoCropOverlay?.restore(bounds:videoMediaBounds(session:session,in:preview,cropped:false),state:session.state.crop) }
1772
+ videoNaturalSize = VideoCropGeometry.plan(size:videoSourceSize(session:session),state:session.state.crop,includeCrop:true).size
1773
+ videoOverlayRenderKey = nil; refreshVideoOverlayPreview(session:session)
1774
+ }
1775
+
1776
+ private func setVideoCropMode(_ enabled:Bool,session:VideoEditSession) {
1777
+ if enabled && !videoCropMode { videoCropEntryState = session.state.crop; session.player.pause(); selectVideoLayer(nil,session:session) }
1778
+ videoCropMode = enabled
1779
+ videoCropPanel?.isHidden = !enabled; videoCropOverlay?.isHidden = !enabled
1780
+ videoLayerOverlay?.isHidden = enabled; videoOverlayImageView?.isHidden = enabled; videoToolBar?.isHidden = enabled
1781
+ if !enabled { videoCropEntryState = nil }
1782
+ refreshVideoCrop(session:session)
1783
+ }
1784
+
1785
+ override func viewDidLayoutSubviews() {
1786
+ super.viewDidLayoutSubviews()
1787
+ if videoCropMode,let session = videoSession,let preview = videoPreviewContainer {
1788
+ videoCropOverlay?.restore(bounds:videoMediaBounds(session:session,in:preview,cropped:false),state:session.state.crop)
1789
+ }
1790
+ }
1791
+
1361
1792
  private func onVideoLayerStackChanged() {
1362
1793
  guard let session = videoSession else { return }
1363
1794
  if let id = selectedVideoLayerID, !session.layerStack.layers.contains(where: { $0.id == id }) {
@@ -1373,16 +1804,25 @@ final class PhotoVideoEditorViewController: UIViewController {
1373
1804
  }
1374
1805
 
1375
1806
  private func selectVideoLayer(_ id: String?, session: VideoEditSession) {
1807
+ if selectedVideoLayerID != id { activeVideoLayerPropertyKey = "" }
1376
1808
  selectedVideoLayerID = id
1377
1809
  videoLayerOverlay?.selectedLayerID = id
1378
1810
  setVideoLayerToolBarVisible(id != nil, session: session)
1379
1811
  }
1380
1812
 
1381
1813
  private func setVideoLayerToolBarVisible(_ visible: Bool, session: VideoEditSession) {
1814
+ if visible { setVideoControlsVisible(true) }
1382
1815
  videoLayerToolBar?.isHidden = !visible
1383
- videoLayerPropertySlider?.isHidden = !visible
1384
1816
  videoToolBar?.isHidden = visible
1817
+ let isText = currentSelectedVideoLayer(session: session)?.type == .text
1818
+ videoLayerPropertyButtons["scale"]?.isHidden = isText || currentSelectedVideoLayer(session: session)?.type == .sticker
1819
+ ["edit", "color", "fontSize"].forEach { videoLayerPropertyButtons[$0]?.isHidden = !isText }
1820
+ if !isText && ["color", "fontSize"].contains(activeVideoLayerPropertyKey) { activeVideoLayerPropertyKey = "" }
1821
+ videoLayerPropertySlider?.isHidden = !visible || !["scale", "rotation", "opacity", "fontSize"].contains(activeVideoLayerPropertyKey)
1822
+ videoColorRow?.isHidden = !visible || activeVideoLayerPropertyKey != "color"
1385
1823
  if visible { syncVideoLayerPropertySlider(session: session) }
1824
+ refreshContextSelection(videoLayerPropertyButtons, active: activeVideoLayerPropertyKey)
1825
+ updateSwatches(videoTextSwatches, selected: currentSelectedVideoLayer(session: session)?.textColor)
1386
1826
  }
1387
1827
 
1388
1828
  private func currentSelectedVideoLayer(session: VideoEditSession) -> PhotoLayer? {
@@ -1393,14 +1833,16 @@ final class PhotoVideoEditorViewController: UIViewController {
1393
1833
  guard let slider = videoLayerPropertySlider, let layer = currentSelectedVideoLayer(session: session) else { return }
1394
1834
  let (min, max, value): (CGFloat, CGFloat, CGFloat)
1395
1835
  switch activeVideoLayerPropertyKey {
1396
- case "rotation": (min, max, value) = (-180, 180, layer.rotationDegrees)
1836
+ case "rotation": (min, max, value) = (-180, 180, (layer.rotationDegrees.truncatingRemainder(dividingBy: 360) + 540).truncatingRemainder(dividingBy: 360) - 180)
1397
1837
  case "opacity": (min, max, value) = (0, 100, layer.opacity * 100)
1838
+ case "fontSize": (min, max, value) = (12, 160, layer.fontSize)
1398
1839
  case "start": (min, max, value) = (0, CGFloat(session.durationMs), CGFloat(layer.startMs))
1399
1840
  case "end":
1400
1841
  let effectiveEnd = (layer.endMs > 0 && layer.endMs <= session.durationMs) ? layer.endMs : session.durationMs
1401
1842
  (min, max, value) = (0, CGFloat(session.durationMs), CGFloat(effectiveEnd))
1402
1843
  default: (min, max, value) = (20, 800, layer.scale * 100)
1403
1844
  }
1845
+ (slider as? EditorToolSlider)?.propertyKey = slider === layerPropertySlider ? activeLayerPropertyKey : activeVideoLayerPropertyKey
1404
1846
  slider.minimumValue = Float(min)
1405
1847
  slider.maximumValue = Float(max)
1406
1848
  slider.value = Float(value)
@@ -1408,6 +1850,7 @@ final class PhotoVideoEditorViewController: UIViewController {
1408
1850
 
1409
1851
  @objc private func videoLayerPropertyChanged(_ slider: UISlider) {
1410
1852
  guard let session = videoSession, let id = selectedVideoLayerID else { return }
1853
+ slider.setNeedsDisplay()
1411
1854
  let value = CGFloat(slider.value)
1412
1855
  let key = activeVideoLayerPropertyKey
1413
1856
  session.layerStack.updateLive { list in
@@ -1417,6 +1860,7 @@ final class PhotoVideoEditorViewController: UIViewController {
1417
1860
  switch key {
1418
1861
  case "rotation": updated.rotationDegrees = value
1419
1862
  case "opacity": updated.opacity = value / 100
1863
+ case "fontSize": updated.fontSize = value
1420
1864
  case "start": updated.startMs = min(Int64(value), (layer.endMs > 0 ? layer.endMs : session.durationMs))
1421
1865
  case "end": updated.endMs = max(Int64(value), layer.startMs)
1422
1866
  default: updated.scale = value / 100
@@ -1428,61 +1872,37 @@ final class PhotoVideoEditorViewController: UIViewController {
1428
1872
  }
1429
1873
 
1430
1874
  private func makeVideoLayerToolBar(session: VideoEditSession, textColor: UIColor, primaryColor: UIColor, toolbarColor: UIColor) -> UIView {
1431
- let properties: [(String, String)] = [("scale", "Scale"), ("rotation", "Rotate"), ("opacity", "Opacity"), ("start", "Start"), ("end", "End")]
1432
- let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
1433
- let scroll = UIScrollView()
1434
- let chipStack = UIStackView(); chipStack.axis = .horizontal; chipStack.spacing = 4
1435
- properties.forEach { key, label in
1436
- let chip = button(label, color: textColor) { [weak self] in
1437
- self?.activeVideoLayerPropertyKey = key
1438
- if let self { self.syncVideoLayerPropertySlider(session: session) }
1439
- if let self { self.refreshSelection(self.videoLayerPropertyButtons, activeKey: self.activeVideoLayerPropertyKey, textColor: textColor, primaryColor: primaryColor) }
1875
+ makeContextToolbar(video: true, toolbarColor: toolbarColor) { [weak self] key in
1876
+ guard let self, let layer = self.currentSelectedVideoLayer(session: session) else { return }
1877
+ switch key {
1878
+ case "edit": self.showVideoTextInputDialog(session: session, editingLayer: layer)
1879
+ case "done": self.selectVideoLayer(nil, session: session)
1880
+ case "delete":
1881
+ session.layerStack.commit { $0.filter { $0.id != layer.id } }; self.selectVideoLayer(nil, session: session); self.onVideoLayerStackChanged()
1882
+ case "duplicate":
1883
+ let copy = layer.duplicated()
1884
+ session.layerStack.commit { $0 + [copy] }; self.selectVideoLayer(copy.id, session: session); self.onVideoLayerStackChanged()
1885
+ default:
1886
+ self.activeVideoLayerPropertyKey = self.activeVideoLayerPropertyKey == key ? "" : key
1887
+ self.setVideoLayerToolBarVisible(true, session: session)
1440
1888
  }
1441
- videoLayerPropertyButtons[key] = chip
1442
- chipStack.addArrangedSubview(chip)
1443
1889
  }
1444
- chipStack.addArrangedSubview(button("Delete", color: textColor) { [weak self] in
1445
- guard let self, let id = self.selectedVideoLayerID else { return }
1446
- session.layerStack.commit { $0.filter { $0.id != id } }
1447
- self.selectVideoLayer(nil, session: session)
1448
- self.onVideoLayerStackChanged()
1449
- })
1450
- scroll.addSubview(chipStack)
1451
- chipStack.translatesAutoresizingMaskIntoConstraints = false
1452
- NSLayoutConstraint.activate([
1453
- chipStack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
1454
- chipStack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
1455
- chipStack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
1456
- chipStack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
1457
- chipStack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
1458
- ])
1459
- bar.addArrangedSubview(scroll)
1460
- bar.addArrangedSubview(button("Done", color: primaryColor) { [weak self] in self?.selectVideoLayer(nil, session: session) })
1461
- bar.backgroundColor = toolbarColor
1462
- bar.isLayoutMarginsRelativeArrangement = true
1463
- bar.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
1464
- return bar
1465
1890
  }
1466
1891
 
1467
- private func showVideoTextInputDialog(session: VideoEditSession) {
1468
- let alert = UIAlertController(title: "Add text", message: nil, preferredStyle: .alert)
1469
- alert.addTextField { $0.placeholder = "Text" }
1470
- alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
1471
- alert.addAction(UIAlertAction(title: "Add", style: .default) { [weak self, weak alert] _ in
1472
- guard let text = alert?.textFields?.first?.text, !text.trimmingCharacters(in: .whitespaces).isEmpty else { return }
1473
- var layer = PhotoLayer(type: .text)
1474
- layer.text = text
1475
- session.layerStack.commit { $0 + [layer] }
1476
- self?.onVideoLayerStackChanged()
1477
- self?.selectVideoLayer(layer.id, session: session)
1478
- })
1479
- present(alert, animated: true)
1892
+ private func showVideoTextInputDialog(session: VideoEditSession, editingLayer: PhotoLayer? = nil) {
1893
+ let sheet = TextEditorSheet(text: editingLayer?.text ?? "", editing: editingLayer != nil, accent: color("primaryColor") ?? DesignTokens.primaryContainer) { [weak self] text in
1894
+ var layer = editingLayer ?? PhotoLayer(type: .text); layer.text = text
1895
+ session.layerStack.commit { list in editingLayer == nil ? list + [layer] : list.map { $0.id == layer.id ? layer : $0 } }
1896
+ self?.onVideoLayerStackChanged(); self?.selectVideoLayer(layer.id, session: session)
1897
+ }
1898
+ present(sheet, animated: true)
1480
1899
  }
1481
1900
 
1482
- @objc private func positionSliderTouchDown(_ slider: UISlider) { seekingPosition = true }
1901
+ @objc private func positionSliderTouchDown(_ slider: UISlider) { seekingPosition = true; setVideoControlsVisible(true) }
1483
1902
 
1484
1903
  @objc private func positionSliderTouchUp(_ slider: UISlider) {
1485
1904
  seekingPosition = false
1905
+ scheduleAutoHideVideoControls()
1486
1906
  guard let session = videoSession, session.durationMs > 0 else { return }
1487
1907
  let targetMs = Double(slider.value / 1000) * Double(session.durationMs)
1488
1908
  session.player.seek(to: CMTime(value: Int64(targetMs), timescale: 1000))
@@ -1494,30 +1914,50 @@ final class PhotoVideoEditorViewController: UIViewController {
1494
1914
  session.player.seek(to: CMTime(value: Int64(targetMs), timescale: 1000))
1495
1915
  }
1496
1916
 
1497
- private func makeVideoToolBar(session: VideoEditSession, playerController: AVPlayerViewController, textColor: UIColor, toolbarColor: UIColor) -> UIView {
1498
- let tools: [(String, String)] = [
1499
- ("cover", "Cover"), ("speed", "Speed 1x"), ("crop", "Crop"), ("rotate", "Rotate"),
1500
- ("filters", "Filters"), ("text", "Text"), ("stickers", "Stickers"), ("overlay", "Overlay"),
1917
+ /// Compact video dock: two equal-width icon+label actions in one rounded pill. Deliberately not
1918
+ /// `toolButton` — that rail stacks a symbol above a label (72pt of chrome), far more than two
1919
+ /// actions warrant. `textformat` renders as "Aa", which reads as typography where a lone "T" does not.
1920
+ private func makeVideoToolBar(session: VideoEditSession, playerController: AVPlayerViewController) -> UIView {
1921
+ let tools: [(String, String, String)] = [
1922
+ ("text", "Text", "textformat"), ("stickers", "Stickers", "face.smiling"),
1501
1923
  ]
1502
- let scroll = UIScrollView(); scroll.backgroundColor = toolbarColor
1503
- let stack = UIStackView(); stack.axis = .horizontal; stack.spacing = 8
1504
- tools.filter { features[$0.0] as? Bool != false }.forEach { key, label in
1505
- let toolButton = button(label, color: textColor) { [weak self, weak playerController] in
1924
+ let dock = UIStackView()
1925
+ dock.axis = .horizontal
1926
+ dock.distribution = .fillEqually
1927
+ dock.backgroundColor = DesignTokens.surfaceContainer
1928
+ dock.layer.cornerRadius = 18
1929
+ dock.clipsToBounds = true
1930
+ tools.filter { features[$0.0] as? Bool != false }.forEach { key, label, symbol in
1931
+ dock.addArrangedSubview(makeVideoDockAction(label: label, symbol: symbol) { [weak self, weak playerController] in
1506
1932
  self?.onVideoToolTapped(key, session: session, playerView: playerController?.view)
1507
- }
1508
- videoToolButtons[key] = toolButton
1509
- stack.addArrangedSubview(toolButton)
1933
+ })
1510
1934
  }
1511
- scroll.addSubview(stack)
1512
- stack.translatesAutoresizingMaskIntoConstraints = false
1513
- NSLayoutConstraint.activate([
1514
- stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 12),
1515
- stack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -12),
1516
- stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
1517
- stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
1518
- stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
1519
- ])
1520
- return scroll
1935
+ return dock
1936
+ }
1937
+
1938
+ /// One dock action: symbol + label on a single row, purple pill while pressed.
1939
+ private func makeVideoDockAction(label: String, symbol: String, action: @escaping () -> Void) -> UIButton {
1940
+ let control = UIButton(type: .system)
1941
+ var config = UIButton.Configuration.plain()
1942
+ config.title = label
1943
+ config.image = UIImage(systemName: symbol, withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .medium))
1944
+ config.imagePlacement = .leading
1945
+ config.imagePadding = DesignTokens.spaceSm
1946
+ config.baseForegroundColor = DesignTokens.textPrimary
1947
+ config.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: DesignTokens.spaceSm, bottom: 0, trailing: DesignTokens.spaceSm)
1948
+ config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
1949
+ var value = incoming; value.font = .systemFont(ofSize: 13, weight: .semibold); return value
1950
+ }
1951
+ control.configuration = config
1952
+ control.accessibilityLabel = label
1953
+ let accent = color("primaryColor") ?? DesignTokens.primaryContainer
1954
+ control.configurationUpdateHandler = { button in
1955
+ button.configuration?.background.backgroundColor = button.isHighlighted ? accent : .clear
1956
+ button.configuration?.background.cornerRadius = 18
1957
+ }
1958
+ control.addAction(UIAction { _ in action() }, for: .touchUpInside)
1959
+ control.applyPressScale()
1960
+ return control
1521
1961
  }
1522
1962
 
1523
1963
  private func refreshVideoToolSelection(session: VideoEditSession) {
@@ -1535,6 +1975,7 @@ final class PhotoVideoEditorViewController: UIViewController {
1535
1975
 
1536
1976
  private func onVideoToolTapped(_ key: String, session: VideoEditSession, playerView: UIView?) {
1537
1977
  switch key {
1978
+ case "crop": setVideoCropMode(true,session:session)
1538
1979
  case "cover":
1539
1980
  let atMs = Int64(CMTimeGetSeconds(session.player.currentTime()) * 1000)
1540
1981
  session.update { $0.coverFrameMs = atMs }
@@ -1560,37 +2001,7 @@ final class PhotoVideoEditorViewController: UIViewController {
1560
2001
  }
1561
2002
 
1562
2003
  private func showVideoStickerPicker(session: VideoEditSession) {
1563
- let alert = UIAlertController(title: "Add sticker", message: nil, preferredStyle: .actionSheet)
1564
- PhotoLayerRenderer.builtinStickerIDs().forEach { id in
1565
- alert.addAction(UIAlertAction(title: PhotoLayerRenderer.glyph(for: id), style: .default) { [weak self] _ in
1566
- var layer = PhotoLayer(type: .sticker)
1567
- layer.stickerId = id
1568
- session.layerStack.commit { $0 + [layer] }
1569
- self?.onVideoLayerStackChanged()
1570
- })
1571
- }
1572
- consumerStickerAssets().forEach { asset in
1573
- alert.addAction(UIAlertAction(title: asset.id, style: .default) { [weak self] _ in
1574
- var layer = PhotoLayer(type: .sticker)
1575
- layer.stickerUri = asset.uri
1576
- session.layerStack.commit { $0 + [layer] }
1577
- self?.onVideoLayerStackChanged()
1578
- })
1579
- }
1580
- alert.addAction(UIAlertAction(title: "Upload", style: .default) { [weak self] _ in
1581
- self?.presentImagePicker(purpose: .videoSticker)
1582
- })
1583
- if features["onlineStickers"] as? Bool != false {
1584
- alert.addAction(UIAlertAction(title: "Browse online", style: .default) { [weak self] _ in
1585
- self?.presentOnlineStickerSheet(purpose: .videoSticker)
1586
- })
1587
- }
1588
- alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
1589
- if let popover = alert.popoverPresentationController {
1590
- popover.sourceView = view
1591
- popover.sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.maxY - 1, width: 1, height: 1)
1592
- }
1593
- present(alert, animated: true)
2004
+ presentOnlineStickerSheet(purpose: .videoSticker)
1594
2005
  }
1595
2006
 
1596
2007
  private func formatSpeedLabel(_ speed: Float) -> String {
@@ -1842,7 +2253,9 @@ final class PhotoVideoEditorViewController: UIViewController {
1842
2253
  let title = UILabel()
1843
2254
  title.text = mediaType == "photo" ? "Photo Editor" : "Video Editor"
1844
2255
  title.textColor = textColor
1845
- title.font = .systemFont(ofSize: 16, weight: .bold)
2256
+ title.font = .systemFont(ofSize: 14, weight: .semibold)
2257
+ title.adjustsFontSizeToFitWidth = true
2258
+ title.minimumScaleFactor = 0.75
1846
2259
  title.textAlignment = .center
1847
2260
 
1848
2261
  header.addArrangedSubview(close)
@@ -1857,15 +2270,12 @@ final class PhotoVideoEditorViewController: UIViewController {
1857
2270
  self?.photoSession?.layerStack.redo()
1858
2271
  self?.onLayerStackChanged()
1859
2272
  }
2273
+ undo.isEnabled = false; undo.alpha = 0.4
2274
+ redo.isEnabled = false; redo.alpha = 0.4
1860
2275
  undoButton = undo
1861
2276
  redoButton = redo
1862
2277
  header.addArrangedSubview(undo)
1863
2278
  header.addArrangedSubview(redo)
1864
- let compare = iconButton(systemName: "rectangle.split.2x1", tint: textColor, handler: nil)
1865
- compare.accessibilityLabel = "Hold to compare with original"
1866
- compare.addTarget(self, action: #selector(compareTouchDown), for: .touchDown)
1867
- compare.addTarget(self, action: #selector(compareTouchUp), for: [.touchUpInside, .touchUpOutside, .touchCancel])
1868
- header.addArrangedSubview(compare)
1869
2279
  } else if mediaType == "video" {
1870
2280
  let undo = iconButton(systemName: "arrow.uturn.backward", tint: textColor) { [weak self] in
1871
2281
  self?.videoSession?.layerStack.undo()
@@ -1875,6 +2285,8 @@ final class PhotoVideoEditorViewController: UIViewController {
1875
2285
  self?.videoSession?.layerStack.redo()
1876
2286
  self?.onVideoLayerStackChanged()
1877
2287
  }
2288
+ undo.isEnabled = false; undo.alpha = 0.4
2289
+ redo.isEnabled = false; redo.alpha = 0.4
1878
2290
  undoButton = undo
1879
2291
  redoButton = redo
1880
2292
  header.addArrangedSubview(undo)
@@ -1884,7 +2296,7 @@ final class PhotoVideoEditorViewController: UIViewController {
1884
2296
  let onPrimaryColor = color("onPrimaryColor") ?? DesignTokens.onPrimaryContainer
1885
2297
  let export = EditorComponents.primaryButton(
1886
2298
  text: "Export",
1887
- systemImage: "square.and.arrow.up",
2299
+ systemImage: nil,
1888
2300
  fillColor: primaryColor,
1889
2301
  textColor: onPrimaryColor
1890
2302
  ) { [weak self] in
@@ -1956,6 +2368,14 @@ final class PhotoVideoEditorViewController: UIViewController {
1956
2368
  }
1957
2369
  }
1958
2370
 
2371
+ @objc private func compareHeld(_ recognizer: UILongPressGestureRecognizer) {
2372
+ switch recognizer.state {
2373
+ case .began: compareTouchDown()
2374
+ case .ended, .cancelled, .failed: compareTouchUp()
2375
+ default: break
2376
+ }
2377
+ }
2378
+
1959
2379
  @objc private func compareTouchDown() {
1960
2380
  guard !comparingOriginal, let session = photoSession else { return }
1961
2381
  comparingOriginal = true
@@ -1998,7 +2418,11 @@ final class PhotoVideoEditorViewController: UIViewController {
1998
2418
  guard self.viewIfLoaded?.window != nil,
1999
2419
  let session = self.photoSession,
2000
2420
  let imageView = self.photoImageView else { return }
2001
- autoreleasepool { imageView.image = session.renderPreview() }
2421
+ autoreleasepool {
2422
+ imageView.image = session.renderPreview(cropping:self.cropMode)
2423
+ imageView.resetToFit()
2424
+ if self.cropMode { self.cropOverlay?.restore(bounds:imageView.currentImageBounds(),state:session.state) }
2425
+ }
2002
2426
  }
2003
2427
  }
2004
2428