react-native-photo-video-editor 0.1.0 → 0.1.1

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 (203) hide show
  1. package/CHANGELOG.md +9 -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 +188 -108
  91. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayer.kt +5 -1
  92. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerRenderer.kt +21 -0
  93. package/android/src/main/java/com/photovideoeditor/photo/ui/CropOverlayView.kt +34 -2
  94. package/android/src/main/java/com/photovideoeditor/photo/ui/DrawOverlayView.kt +66 -0
  95. package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +21 -1
  96. package/android/src/main/java/com/photovideoeditor/stickers/OnlineStickerSheet.kt +135 -104
  97. package/android/src/main/res/drawable/ic_draw.xml +6 -0
  98. package/android/src/main/res/drawable/ic_pause.xml +7 -0
  99. package/android/src/main/res/drawable/ic_play.xml +7 -0
  100. package/docs/api.md +1 -1
  101. package/docs/photo-editor.md +5 -6
  102. package/ios/Editor/PhotoVideoEditorViewController.swift +166 -77
  103. package/ios/Photo/Rendering/PhotoLayer.swift +5 -1
  104. package/ios/Photo/Rendering/PhotoLayerRenderer.swift +14 -0
  105. package/ios/Photo/UI/CropOverlayView.swift +38 -1
  106. package/ios/Photo/UI/DrawOverlayView.swift +48 -0
  107. package/ios/Photo/UI/LayerOverlayView.swift +9 -1
  108. package/ios/Resources/Stickers/AUTUMN_1.png +0 -0
  109. package/ios/Resources/Stickers/AUTUMN_10.png +0 -0
  110. package/ios/Resources/Stickers/AUTUMN_11.png +0 -0
  111. package/ios/Resources/Stickers/AUTUMN_12.png +0 -0
  112. package/ios/Resources/Stickers/AUTUMN_13.png +0 -0
  113. package/ios/Resources/Stickers/AUTUMN_14.png +0 -0
  114. package/ios/Resources/Stickers/AUTUMN_15.png +0 -0
  115. package/ios/Resources/Stickers/AUTUMN_16.png +0 -0
  116. package/ios/Resources/Stickers/AUTUMN_17.png +0 -0
  117. package/ios/Resources/Stickers/AUTUMN_18.png +0 -0
  118. package/ios/Resources/Stickers/AUTUMN_2.png +0 -0
  119. package/ios/Resources/Stickers/AUTUMN_3.png +0 -0
  120. package/ios/Resources/Stickers/AUTUMN_4.png +0 -0
  121. package/ios/Resources/Stickers/AUTUMN_5.png +0 -0
  122. package/ios/Resources/Stickers/AUTUMN_6.png +0 -0
  123. package/ios/Resources/Stickers/AUTUMN_7.png +0 -0
  124. package/ios/Resources/Stickers/AUTUMN_8.png +0 -0
  125. package/ios/Resources/Stickers/AUTUMN_9.png +0 -0
  126. package/ios/Resources/Stickers/LOL_1.png +0 -0
  127. package/ios/Resources/Stickers/LOL_10.png +0 -0
  128. package/ios/Resources/Stickers/LOL_11.png +0 -0
  129. package/ios/Resources/Stickers/LOL_12.png +0 -0
  130. package/ios/Resources/Stickers/LOL_13.png +0 -0
  131. package/ios/Resources/Stickers/LOL_14.png +0 -0
  132. package/ios/Resources/Stickers/LOL_15.png +0 -0
  133. package/ios/Resources/Stickers/LOL_16.png +0 -0
  134. package/ios/Resources/Stickers/LOL_17.png +0 -0
  135. package/ios/Resources/Stickers/LOL_18.png +0 -0
  136. package/ios/Resources/Stickers/LOL_19.png +0 -0
  137. package/ios/Resources/Stickers/LOL_2.png +0 -0
  138. package/ios/Resources/Stickers/LOL_3.png +0 -0
  139. package/ios/Resources/Stickers/LOL_4.png +0 -0
  140. package/ios/Resources/Stickers/LOL_5.png +0 -0
  141. package/ios/Resources/Stickers/LOL_6.png +0 -0
  142. package/ios/Resources/Stickers/LOL_7.png +0 -0
  143. package/ios/Resources/Stickers/LOL_8.png +0 -0
  144. package/ios/Resources/Stickers/LOL_9.png +0 -0
  145. package/ios/Resources/Stickers/SPRING_1.png +0 -0
  146. package/ios/Resources/Stickers/SPRING_2.png +0 -0
  147. package/ios/Resources/Stickers/SPRING_3.png +0 -0
  148. package/ios/Resources/Stickers/SPRING_4.png +0 -0
  149. package/ios/Resources/Stickers/SPRING_5.png +0 -0
  150. package/ios/Resources/Stickers/SPRING_6.png +0 -0
  151. package/ios/Resources/Stickers/SUMMER_1.png +0 -0
  152. package/ios/Resources/Stickers/SUMMER_10.png +0 -0
  153. package/ios/Resources/Stickers/SUMMER_11.png +0 -0
  154. package/ios/Resources/Stickers/SUMMER_12.png +0 -0
  155. package/ios/Resources/Stickers/SUMMER_13.png +0 -0
  156. package/ios/Resources/Stickers/SUMMER_14.png +0 -0
  157. package/ios/Resources/Stickers/SUMMER_15.png +0 -0
  158. package/ios/Resources/Stickers/SUMMER_16.png +0 -0
  159. package/ios/Resources/Stickers/SUMMER_17.png +0 -0
  160. package/ios/Resources/Stickers/SUMMER_18.png +0 -0
  161. package/ios/Resources/Stickers/SUMMER_19.png +0 -0
  162. package/ios/Resources/Stickers/SUMMER_2.png +0 -0
  163. package/ios/Resources/Stickers/SUMMER_20.png +0 -0
  164. package/ios/Resources/Stickers/SUMMER_21.png +0 -0
  165. package/ios/Resources/Stickers/SUMMER_3.png +0 -0
  166. package/ios/Resources/Stickers/SUMMER_4.png +0 -0
  167. package/ios/Resources/Stickers/SUMMER_5.png +0 -0
  168. package/ios/Resources/Stickers/SUMMER_6.png +0 -0
  169. package/ios/Resources/Stickers/SUMMER_7.png +0 -0
  170. package/ios/Resources/Stickers/SUMMER_8.png +0 -0
  171. package/ios/Resources/Stickers/SUMMER_9.png +0 -0
  172. package/ios/Resources/Stickers/TEXT_1.png +0 -0
  173. package/ios/Resources/Stickers/TEXT_12.png +0 -0
  174. package/ios/Resources/Stickers/TEXT_13.png +0 -0
  175. package/ios/Resources/Stickers/TEXT_14.png +0 -0
  176. package/ios/Resources/Stickers/TEXT_15.png +0 -0
  177. package/ios/Resources/Stickers/TEXT_16.png +0 -0
  178. package/ios/Resources/Stickers/TEXT_2.png +0 -0
  179. package/ios/Resources/Stickers/TEXT_25.png +0 -0
  180. package/ios/Resources/Stickers/TEXT_26.png +0 -0
  181. package/ios/Resources/Stickers/TEXT_27.png +0 -0
  182. package/ios/Resources/Stickers/TEXT_28.png +0 -0
  183. package/ios/Resources/Stickers/TEXT_29.png +0 -0
  184. package/ios/Resources/Stickers/TEXT_3.png +0 -0
  185. package/ios/Resources/Stickers/TEXT_30.png +0 -0
  186. package/ios/Resources/Stickers/TEXT_31.png +0 -0
  187. package/ios/Resources/Stickers/TEXT_32.png +0 -0
  188. package/ios/Resources/Stickers/TEXT_7.png +0 -0
  189. package/ios/Resources/Stickers/TEXT_8.png +0 -0
  190. package/ios/Resources/Stickers/TEXT_9.png +0 -0
  191. package/ios/Resources/Stickers/a_pety_icon.png +0 -0
  192. package/ios/Stickers/OnlineStickerSheetViewController.swift +102 -159
  193. package/lib/module/index.js +2 -2
  194. package/lib/module/index.js.map +1 -1
  195. package/lib/typescript/src/types.d.ts +2 -2
  196. package/lib/typescript/src/types.d.ts.map +1 -1
  197. package/package.json +2 -1
  198. package/src/index.ts +1 -1
  199. package/src/types.ts +2 -2
  200. package/android/src/main/java/com/photovideoeditor/stickers/OpenMojiCatalog.kt +0 -172
  201. package/android/src/main/java/com/photovideoeditor/stickers/RemoteImageLoader.kt +0 -110
  202. package/ios/Stickers/OpenMojiCatalog.swift +0 -188
  203. package/ios/Stickers/RemoteImageLoader.swift +0 -127
@@ -20,7 +20,9 @@ final class PhotoVideoEditorViewController: UIViewController {
20
20
 
21
21
  private var photoSession: PhotoEditSession?
22
22
  private var cropMode = false
23
+ private var cropEntryState: PhotoTransformState?
23
24
  private var filtersMode = false
25
+ private var drawMode = false
24
26
  private var stickersMode = false
25
27
  private var pendingPickPurpose: ImagePickPurpose?
26
28
  private var activeAdjustmentKey = "brightness"
@@ -30,11 +32,13 @@ final class PhotoVideoEditorViewController: UIViewController {
30
32
  private var photoImageView: ZoomableImageView?
31
33
  private var cropOverlay: CropOverlayView?
32
34
  private var layerOverlay: LayerOverlayView?
35
+ private var drawOverlay: DrawOverlayView?
33
36
  private var straightenSlider: UISlider?
34
37
  private var cropSubBar: UIView?
35
38
  private var adjustmentSlider: UISlider?
36
39
  private var filtersSubBar: UIView?
37
40
  private var stickersSubBar: UIView?
41
+ private var drawSubBar: UIView?
38
42
  private var layerToolBar: UIView?
39
43
  private var layerPropertySlider: UISlider?
40
44
  private var layerColorSwatchRow: UIView?
@@ -186,30 +190,32 @@ final class PhotoVideoEditorViewController: UIViewController {
186
190
  layers.bottomAnchor.constraint(equalTo: preview.bottomAnchor),
187
191
  ])
188
192
 
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
193
+ let drawing = DrawOverlayView()
194
+ drawing.isHidden = true
195
+ drawOverlay = drawing
196
+ preview.addSubview(drawing)
197
+ drawing.translatesAutoresizingMaskIntoConstraints = false
198
198
  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),
199
+ drawing.leadingAnchor.constraint(equalTo: preview.leadingAnchor),
200
+ drawing.trailingAnchor.constraint(equalTo: preview.trailingAnchor),
201
+ drawing.topAnchor.constraint(equalTo: preview.topAnchor),
202
+ drawing.bottomAnchor.constraint(equalTo: preview.bottomAnchor),
202
203
  ])
203
204
 
204
205
  imageView.onBoundsChanged = { [weak self] bounds in
205
206
  guard let self else { return }
206
207
  if self.cropMode { overlay.setImageBounds(bounds, resetCrop: false) }
207
208
  layers.setImageBounds(bounds)
209
+ drawing.setImageBounds(bounds)
208
210
  }
209
211
  overlay.onCropChanged = { left, top, right, bottom in
210
212
  session.update { $0.setCrop(left: left, top: top, right: right, bottom: bottom) }
211
213
  }
212
214
  layers.onLayerTapped = { [weak self] id in self?.selectLayer(id) }
215
+ layers.onLayerDoubleTapped = { [weak self] id in
216
+ guard let self, let layer = session.layerStack.layers.first(where: { $0.id == id && $0.type == .text }) else { return }
217
+ self.showTextInputDialog(session: session, editingLayer: layer)
218
+ }
213
219
  layers.onLayerTransformChanged = { [weak self] id, x, y, scale, rotation in
214
220
  guard let self, let session = self.photoSession else { return }
215
221
  if self.dragStartSnapshot == nil { self.dragStartSnapshot = session.layerStack.layers }
@@ -251,6 +257,10 @@ final class PhotoVideoEditorViewController: UIViewController {
251
257
  stickersBar.isHidden = true
252
258
  stickersSubBar = stickersBar
253
259
 
260
+ let drawBar = makeDrawSubBar(textColor: textColor, primaryColor: primaryColor, toolbarColor: toolbarColor)
261
+ drawBar.isHidden = true
262
+ drawSubBar = drawBar
263
+
254
264
  let layerSlider = UISlider()
255
265
  layerSlider.isHidden = true
256
266
  layerSlider.addTarget(self, action: #selector(layerPropertyChanged(_:)), for: .valueChanged)
@@ -267,7 +277,7 @@ final class PhotoVideoEditorViewController: UIViewController {
267
277
  let toolbar = makePhotoToolBar(textColor: textColor, toolbarColor: toolbarColor)
268
278
  mainToolBar = toolbar
269
279
 
270
- let root = UIStackView(arrangedSubviews: [header, preview, straighten, cropBar, adjustmentSlider, filtersBar, stickersBar, layerSlider, colorSwatchRow, layerBar, toolbar])
280
+ let root = UIStackView(arrangedSubviews: [header, preview, straighten, cropBar, adjustmentSlider, filtersBar, stickersBar, drawBar, layerSlider, colorSwatchRow, layerBar, toolbar])
271
281
  root.axis = .vertical
272
282
  root.translatesAutoresizingMaskIntoConstraints = false
273
283
  view.addSubview(root)
@@ -281,6 +291,7 @@ final class PhotoVideoEditorViewController: UIViewController {
281
291
  cropBar.heightAnchor.constraint(equalToConstant: 100), // rotate row (44pt) + aspect chip row (56pt)
282
292
  filtersBar.heightAnchor.constraint(equalToConstant: 56),
283
293
  stickersBar.heightAnchor.constraint(equalToConstant: 56),
294
+ drawBar.heightAnchor.constraint(equalToConstant: 56),
284
295
  layerBar.heightAnchor.constraint(equalToConstant: 56),
285
296
  colorSwatchRow.heightAnchor.constraint(equalToConstant: 56),
286
297
  ])
@@ -377,9 +388,7 @@ final class PhotoVideoEditorViewController: UIViewController {
377
388
  ("filters", "Filters", "camera.filters"),
378
389
  ("text", "Text", "textformat"),
379
390
  ("stickers", "Stickers", "face.smiling"),
380
- ("overlay", "Overlay", "photo.on.rectangle.angled"),
381
- ("retouch", "Retouch", "wand.and.rays"),
382
- ("layers", "Layers", "square.3.layers.3d"),
391
+ ("draw", "Draw", "pencil.tip"),
383
392
  ("resize", "Resize", "aspectratio"),
384
393
  ]
385
394
  let scroll = UIScrollView(); scroll.backgroundColor = toolbarColor
@@ -435,6 +444,7 @@ final class PhotoVideoEditorViewController: UIViewController {
435
444
  outer.addArrangedSubview(transformRow)
436
445
 
437
446
  let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
447
+ bar.addArrangedSubview(button("Cancel", color: textColor) { [weak self] in self?.cancelCropMode() })
438
448
  let scroll = UIScrollView()
439
449
  let presetStack = UIStackView(); presetStack.axis = .horizontal; presetStack.spacing = 4
440
450
  presets.forEach { label, ratio in
@@ -470,19 +480,18 @@ final class PhotoVideoEditorViewController: UIViewController {
470
480
  switch key {
471
481
  case "crop":
472
482
  setCropMode(true)
473
- case "adjust", "filters", "retouch":
483
+ case "adjust", "filters":
474
484
  setFiltersMode(true, session: session)
475
485
  case "text":
476
486
  showTextInputDialog(session: session)
477
487
  case "stickers":
478
- setStickersMode(true)
479
- case "overlay":
480
- presentImagePicker(purpose: .photoOverlay)
488
+ stickersMode = true
489
+ refreshPhotoToolSelection()
490
+ presentOnlineStickerSheet(purpose: .photoSticker)
491
+ case "draw":
492
+ setDrawMode(true, session: session)
481
493
  case "resize":
482
494
  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
495
  default:
487
496
  showToast("\(key) is coming in a later milestone.")
488
497
  }
@@ -491,15 +500,15 @@ final class PhotoVideoEditorViewController: UIViewController {
491
500
  // MARK: - Layers: text, selection, and transform/duplicate/reorder/lock/hide/delete
492
501
  // with undo/redo (Milestone 4)
493
502
 
494
- private func showTextInputDialog(session: PhotoEditSession) {
495
- let alert = UIAlertController(title: "Add text", message: nil, preferredStyle: .alert)
496
- alert.addTextField { $0.placeholder = "Text" }
503
+ private func showTextInputDialog(session: PhotoEditSession, editingLayer: PhotoLayer? = nil) {
504
+ let alert = UIAlertController(title: editingLayer == nil ? "Add text" : "Edit text", message: "Pinch to resize and rotate after adding.", preferredStyle: .alert)
505
+ alert.addTextField { $0.placeholder = "Text"; $0.text = editingLayer?.text; $0.textColor = editingLayer?.textColor ?? .white }
497
506
  alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
498
507
  alert.addAction(UIAlertAction(title: "Add", style: .default) { [weak self, weak alert] _ in
499
508
  guard let text = alert?.textFields?.first?.text, !text.trimmingCharacters(in: .whitespaces).isEmpty else { return }
500
- var layer = PhotoLayer(type: .text)
509
+ var layer = editingLayer ?? PhotoLayer(type: .text)
501
510
  layer.text = text
502
- session.layerStack.commit { $0 + [layer] }
511
+ session.layerStack.commit { list in editingLayer == nil ? list + [layer] : list.map { $0.id == layer.id ? layer : $0 } }
503
512
  self?.selectLayer(layer.id)
504
513
  })
505
514
  present(alert, animated: true)
@@ -537,11 +546,6 @@ final class PhotoVideoEditorViewController: UIViewController {
537
546
  chipStack.addArrangedSubview(button("Upload", color: textColor) { [weak self] in
538
547
  self?.presentImagePicker(purpose: .photoSticker)
539
548
  })
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
549
  scroll.addSubview(chipStack)
546
550
  chipStack.translatesAutoresizingMaskIntoConstraints = false
547
551
  NSLayoutConstraint.activate([
@@ -559,6 +563,71 @@ final class PhotoVideoEditorViewController: UIViewController {
559
563
  return bar
560
564
  }
561
565
 
566
+ private func setDrawMode(_ enabled: Bool, session: PhotoEditSession) {
567
+ drawMode = enabled
568
+ drawOverlay?.isHidden = !enabled
569
+ drawSubBar?.isHidden = !enabled
570
+ mainToolBar?.isHidden = enabled
571
+ layerOverlay?.isHidden = enabled
572
+ photoImageView?.panZoomEnabled = !enabled
573
+ if enabled {
574
+ drawOverlay?.strokeColor = .red
575
+ drawOverlay?.strokeWidthPx = 4
576
+ if let bounds = photoImageView?.currentImageBounds() { drawOverlay?.setImageBounds(bounds) }
577
+ } else {
578
+ commitDrawStrokes(session: session)
579
+ drawOverlay?.clearStrokes()
580
+ }
581
+ refreshPhotoToolSelection()
582
+ }
583
+
584
+ private func commitDrawStrokes(session: PhotoEditSession) {
585
+ guard let drawOverlay, drawOverlay.hasStrokes, let bounds = photoImageView?.currentImageBounds(), bounds.width > 0 else { return }
586
+ let shortSide = min(bounds.width, bounds.height)
587
+ let newLayers: [PhotoLayer] = drawOverlay.normalizedStrokes().compactMap { points, stroke in
588
+ guard points.count >= 2 else { return nil }
589
+ let centerX = points.reduce(0) { $0 + $1.0 } / CGFloat(points.count)
590
+ let centerY = points.reduce(0) { $0 + $1.1 } / CGFloat(points.count)
591
+ var layer = PhotoLayer(type: .drawing)
592
+ layer.x = centerX; layer.y = centerY
593
+ layer.drawColor = stroke.color
594
+ layer.drawStrokeWidth = stroke.widthPx / shortSide
595
+ layer.drawPoints = points.map { ($0.0 - centerX, $0.1 - centerY) }
596
+ return layer
597
+ }
598
+ if !newLayers.isEmpty { session.layerStack.commit { $0 + newLayers } }
599
+ onLayerStackChanged()
600
+ }
601
+
602
+ private func makeDrawSubBar(textColor: UIColor, primaryColor: UIColor, toolbarColor: UIColor) -> UIView {
603
+ let colors: [(String, UIColor)] = [("Red", .red), ("Yellow", .yellow), ("Green", .green), ("Blue", .systemBlue), ("White", .white), ("Black", .black)]
604
+ let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
605
+ let scroll = UIScrollView()
606
+ let controls = UIStackView(); controls.axis = .horizontal; controls.spacing = 4
607
+ colors.forEach { label, value in controls.addArrangedSubview(button(label, color: value) { [weak self] in self?.drawOverlay?.strokeColor = value }) }
608
+ controls.addArrangedSubview(button("Thin", color: textColor) { [weak self] in self?.drawOverlay?.strokeWidthPx = 2 })
609
+ controls.addArrangedSubview(button("Thick", color: textColor) { [weak self] in self?.drawOverlay?.strokeWidthPx = 10 })
610
+ controls.addArrangedSubview(button("Undo", color: textColor) { [weak self] in self?.drawOverlay?.undoLastStroke() })
611
+ controls.addArrangedSubview(button("Clear", color: textColor) { [weak self] in self?.drawOverlay?.clearStrokes() })
612
+ scroll.addSubview(controls); controls.translatesAutoresizingMaskIntoConstraints = false
613
+ NSLayoutConstraint.activate([
614
+ controls.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
615
+ controls.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
616
+ controls.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
617
+ controls.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
618
+ controls.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
619
+ ])
620
+ bar.addArrangedSubview(scroll)
621
+ bar.addArrangedSubview(button("Done", color: primaryColor) { [weak self] in
622
+ guard let self, let session = self.photoSession else { return }
623
+ self.setDrawMode(false, session: session)
624
+ })
625
+ bar.backgroundColor = toolbarColor
626
+ bar.isLayoutMarginsRelativeArrangement = true
627
+ bar.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
628
+ return bar
629
+ }
630
+
562
631
  private func consumerStickerAssets() -> [(id: String, uri: String)] {
563
632
  stickerAssets.compactMap { asset in
564
633
  guard let uri = asset["uri"] as? String, !uri.isEmpty else { return nil }
@@ -578,14 +647,24 @@ final class PhotoVideoEditorViewController: UIViewController {
578
647
  present(picker, animated: true)
579
648
  }
580
649
 
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.
650
+ /// Presents the bundled sticker sheet, reusing `handlePickedImage` as the insertion point.
583
651
  private func presentOnlineStickerSheet(purpose: ImagePickPurpose) {
584
- let sheet = OnlineStickerSheetViewController()
652
+ let runtimeStickers = consumerStickerAssets().map { OnlineStickerSheetViewController.RuntimeSticker(id: $0.id, uri: $0.uri) }
653
+ let sheet = OnlineStickerSheetViewController(runtimeStickers: runtimeStickers)
654
+ sheet.isModalInPresentation = false
655
+ sheet.onDismiss = { [weak self] in
656
+ if case .photoSticker = purpose {
657
+ self?.stickersMode = false
658
+ self?.refreshPhotoToolSelection()
659
+ }
660
+ }
585
661
  sheet.onStickerPicked = { [weak self] fileURL, image in
586
662
  self?.handlePickedImage(fileURL, image: image, purpose: purpose)
587
663
  }
588
664
  sheet.sheetPresentationController?.detents = [.medium(), .large()]
665
+ sheet.sheetPresentationController?.selectedDetentIdentifier = .large
666
+ sheet.sheetPresentationController?.prefersGrabberVisible = true
667
+ sheet.sheetPresentationController?.preferredCornerRadius = DesignTokens.radiusSheet
589
668
  present(sheet, animated: true)
590
669
  }
591
670
 
@@ -648,6 +727,7 @@ final class PhotoVideoEditorViewController: UIViewController {
648
727
  case "crop": selected = cropMode
649
728
  case "adjust", "filters": selected = filtersMode
650
729
  case "stickers": selected = stickersMode
730
+ case "draw": selected = drawMode
651
731
  default: selected = false
652
732
  }
653
733
  setToolNodeSelected(node, selected: selected, textColor: textColor)
@@ -1003,6 +1083,7 @@ final class PhotoVideoEditorViewController: UIViewController {
1003
1083
 
1004
1084
  private func setCropMode(_ enabled: Bool) {
1005
1085
  guard let imageView = photoImageView, let overlay = cropOverlay, let session = photoSession else { return }
1086
+ if enabled && !cropMode { cropEntryState = session.state }
1006
1087
  cropMode = enabled
1007
1088
  imageView.panZoomEnabled = !enabled
1008
1089
  overlay.isHidden = !enabled
@@ -1013,6 +1094,10 @@ final class PhotoVideoEditorViewController: UIViewController {
1013
1094
  if enabled {
1014
1095
  imageView.resetToFit()
1015
1096
  overlay.setImageBounds(imageView.currentImageBounds(), resetCrop: session.state.isIdentity)
1097
+ overlay.setCrop(left: session.state.cropLeft, top: session.state.cropTop, right: session.state.cropRight, bottom: session.state.cropBottom)
1098
+ overlay.setAspectRatio(session.state.aspectRatio)
1099
+ } else {
1100
+ cropEntryState = nil
1016
1101
  }
1017
1102
  refreshPhotoToolSelection()
1018
1103
  let textColor = color("textColor") ?? DesignTokens.onSurface
@@ -1020,6 +1105,15 @@ final class PhotoVideoEditorViewController: UIViewController {
1020
1105
  refreshSelection(cropAspectButtons, activeKey: aspectRatioLabel(session.state.aspectRatio), textColor: textColor, primaryColor: primaryColor)
1021
1106
  }
1022
1107
 
1108
+ private func cancelCropMode() {
1109
+ guard let session = photoSession else { return }
1110
+ if let snapshot = cropEntryState { session.update { $0 = snapshot } }
1111
+ straightenSlider?.value = Float(cropEntryState?.straightenDegrees ?? 0)
1112
+ cropEntryState = nil
1113
+ schedulePhotoPreviewRender()
1114
+ setCropMode(false)
1115
+ }
1116
+
1023
1117
  /// Maps an aspect ratio back to its preset label (matching the presets defined in `makeCropSubBar`/`makeVideoAspectSubBar`) so the correct chip can be highlighted.
1024
1118
  private func aspectRatioLabel(_ ratio: CGFloat?) -> String {
1025
1119
  guard let ratio else { return "Free" }
@@ -1051,6 +1145,7 @@ final class PhotoVideoEditorViewController: UIViewController {
1051
1145
  completion?(.failure(code: "E_INTERNAL", message: "Nothing to export."))
1052
1146
  return
1053
1147
  }
1148
+ if drawMode { setDrawMode(false, session: session) }
1054
1149
  progressOverlay?.isHidden = false
1055
1150
  let state = session.state
1056
1151
  let adjustments = session.adjustments
@@ -1141,11 +1236,26 @@ final class PhotoVideoEditorViewController: UIViewController {
1141
1236
  self.onVideoLayerStackChanged()
1142
1237
  }
1143
1238
 
1144
- let playPause = button("Play", color: textColor) { [weak self] in
1239
+ let playPause = UIButton(type: .system)
1240
+ playPause.tintColor = textColor
1241
+ playPause.setImage(UIImage(systemName: "play.fill"), for: .normal)
1242
+ playPause.accessibilityLabel = "Play video"
1243
+ playPause.addAction(UIAction { [weak self] _ in
1145
1244
  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
- }
1245
+ if session.player.timeControlStatus == .playing {
1246
+ session.player.pause()
1247
+ } else {
1248
+ let current = CMTimeGetSeconds(session.player.currentTime())
1249
+ let duration = Double(session.durationMs) / 1000
1250
+ if current.isFinite, duration > 0, current >= duration - 0.05 {
1251
+ session.player.seek(to: .zero) { finished in
1252
+ if finished { session.player.rate = session.state.speed }
1253
+ }
1254
+ } else {
1255
+ session.player.rate = session.state.speed
1256
+ }
1257
+ }
1258
+ }, for: .touchUpInside)
1149
1259
  playPauseButton = playPause
1150
1260
  let time = UILabel()
1151
1261
  time.text = "0:00 / 0:00"
@@ -1229,7 +1339,9 @@ final class PhotoVideoEditorViewController: UIViewController {
1229
1339
  let toolbar = makeVideoToolBar(session: session, playerController: playerController, textColor: textColor, toolbarColor: toolbarColor)
1230
1340
  videoToolBar = toolbar
1231
1341
 
1232
- let root = UIStackView(arrangedSubviews: [header, preview, transport, position, trim, stripScroll, aspectBar, layerSlider, layerBar, toolbar])
1342
+ // The editor is intentionally single-video: omit the multi-clip strip and
1343
+ // its Split/Copy/Delete/Reorder controls from the visible layout.
1344
+ let root = UIStackView(arrangedSubviews: [header, preview, transport, position, aspectBar, layerSlider, layerBar, toolbar])
1233
1345
  root.axis = .vertical
1234
1346
  root.translatesAutoresizingMaskIntoConstraints = false
1235
1347
  view.addSubview(root)
@@ -1283,7 +1395,9 @@ final class PhotoVideoEditorViewController: UIViewController {
1283
1395
  self.positionSlider?.value = Float(min(max(Double(currentMs) / Double(session.durationMs), 0), 1)) * 1000
1284
1396
  self.updateTimeLabel()
1285
1397
  }
1286
- self.playPauseButton?.setTitle(session.player.timeControlStatus == .playing ? "Pause" : "Play", for: .normal)
1398
+ let playing = session.player.timeControlStatus == .playing
1399
+ self.playPauseButton?.setImage(UIImage(systemName: playing ? "pause.fill" : "play.fill"), for: .normal)
1400
+ self.playPauseButton?.accessibilityLabel = playing ? "Pause video" : "Play video"
1287
1401
  self.refreshVideoOverlayPreview(session: session)
1288
1402
  }
1289
1403
  }
@@ -1382,7 +1496,13 @@ final class PhotoVideoEditorViewController: UIViewController {
1382
1496
  videoLayerToolBar?.isHidden = !visible
1383
1497
  videoLayerPropertySlider?.isHidden = !visible
1384
1498
  videoToolBar?.isHidden = visible
1385
- if visible { syncVideoLayerPropertySlider(session: session) }
1499
+ if visible {
1500
+ if !["rotation", "opacity"].contains(activeVideoLayerPropertyKey) { activeVideoLayerPropertyKey = "rotation" }
1501
+ syncVideoLayerPropertySlider(session: session)
1502
+ let textColor = color("textColor") ?? DesignTokens.onSurface
1503
+ let primaryColor = color("primaryColor") ?? DesignTokens.primaryContainer
1504
+ refreshSelection(videoLayerPropertyButtons, activeKey: activeVideoLayerPropertyKey, textColor: textColor, primaryColor: primaryColor)
1505
+ }
1386
1506
  }
1387
1507
 
1388
1508
  private func currentSelectedVideoLayer(session: VideoEditSession) -> PhotoLayer? {
@@ -1428,7 +1548,7 @@ final class PhotoVideoEditorViewController: UIViewController {
1428
1548
  }
1429
1549
 
1430
1550
  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")]
1551
+ let properties: [(String, String)] = [("rotation", "Rotate"), ("opacity", "Opacity")]
1432
1552
  let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
1433
1553
  let scroll = UIScrollView()
1434
1554
  let chipStack = UIStackView(); chipStack.axis = .horizontal; chipStack.spacing = 4
@@ -1496,8 +1616,7 @@ final class PhotoVideoEditorViewController: UIViewController {
1496
1616
 
1497
1617
  private func makeVideoToolBar(session: VideoEditSession, playerController: AVPlayerViewController, textColor: UIColor, toolbarColor: UIColor) -> UIView {
1498
1618
  let tools: [(String, String)] = [
1499
- ("cover", "Cover"), ("speed", "Speed 1x"), ("crop", "Crop"), ("rotate", "Rotate"),
1500
- ("filters", "Filters"), ("text", "Text"), ("stickers", "Stickers"), ("overlay", "Overlay"),
1619
+ ("text", "Text"), ("stickers", "Stickers"),
1501
1620
  ]
1502
1621
  let scroll = UIScrollView(); scroll.backgroundColor = toolbarColor
1503
1622
  let stack = UIStackView(); stack.axis = .horizontal; stack.spacing = 8
@@ -1560,37 +1679,7 @@ final class PhotoVideoEditorViewController: UIViewController {
1560
1679
  }
1561
1680
 
1562
1681
  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)
1682
+ presentOnlineStickerSheet(purpose: .videoSticker)
1594
1683
  }
1595
1684
 
1596
1685
  private func formatSpeedLabel(_ speed: Float) -> String {
@@ -1,7 +1,7 @@
1
1
  import UIKit
2
2
 
3
3
  enum LayerType {
4
- case text, sticker, overlay
4
+ case text, sticker, overlay, drawing
5
5
  }
6
6
 
7
7
  /// A single non-destructive overlay layer (text, sticker, or a user-uploaded
@@ -31,6 +31,10 @@ struct PhotoLayer {
31
31
  // Overlay (user-uploaded image, placed as a fully generic movable/resizable/rotatable layer)
32
32
  var overlayUri: String?
33
33
  var overlayAspectRatio: CGFloat?
34
+ // Drawing (points are normalized offsets from x/y)
35
+ var drawColor: UIColor = .red
36
+ var drawStrokeWidth: CGFloat = 0.012
37
+ var drawPoints: [(CGFloat, CGFloat)] = []
34
38
  // Timing (video overlays only; ignored for photo layers). endMs <= 0 means "to end of video".
35
39
  var startMs: Int64 = 0
36
40
  var endMs: Int64 = 0
@@ -29,6 +29,7 @@ enum PhotoLayerRenderer {
29
29
  case .text: drawText(layer, shortSide: geometry.shortSide)
30
30
  case .sticker: drawSticker(layer, shortSide: geometry.shortSide, resolver: imageResolver)
31
31
  case .overlay: drawOverlay(layer, canvasWidth: base.size.width, resolver: imageResolver)
32
+ case .drawing: drawStroke(ctx, layer, width: base.size.width, height: base.size.height, shortSide: geometry.shortSide)
32
33
  }
33
34
  ctx.restoreGState()
34
35
  }
@@ -80,6 +81,19 @@ enum PhotoLayerRenderer {
80
81
  image.draw(in: rect, blendMode: .normal, alpha: layer.opacity)
81
82
  }
82
83
 
84
+ private static func drawStroke(_ ctx: CGContext, _ layer: PhotoLayer, width: CGFloat, height: CGFloat, shortSide: CGFloat) {
85
+ guard layer.drawPoints.count >= 2 else { return }
86
+ ctx.setAlpha(layer.opacity)
87
+ ctx.setStrokeColor(layer.drawColor.cgColor)
88
+ ctx.setLineWidth(layer.drawStrokeWidth * shortSide)
89
+ ctx.setLineCap(.round)
90
+ ctx.setLineJoin(.round)
91
+ let points = layer.drawPoints.map { CGPoint(x: $0.0 * width, y: $0.1 * height) }
92
+ ctx.move(to: points[0])
93
+ points.dropFirst().forEach { ctx.addLine(to: $0) }
94
+ ctx.strokePath()
95
+ }
96
+
83
97
  }
84
98
 
85
99
  private extension NSAttributedString {
@@ -12,10 +12,11 @@ final class CropOverlayView: UIView {
12
12
 
13
13
  private let dimLayer = CAShapeLayer()
14
14
  private let borderLayer = CAShapeLayer()
15
+ private let handlesLayer = CAShapeLayer()
15
16
  private let handleRadius: CGFloat = 32
16
17
  private let minSize: CGFloat = 60
17
18
 
18
- private enum Handle { case move, topLeft, topRight, bottomLeft, bottomRight }
19
+ private enum Handle { case move, top, right, bottom, left, topLeft, topRight, bottomLeft, bottomRight }
19
20
  private var activeHandle: Handle?
20
21
  private var lastPoint: CGPoint = .zero
21
22
 
@@ -29,6 +30,8 @@ final class CropOverlayView: UIView {
29
30
  borderLayer.fillColor = UIColor.clear.cgColor
30
31
  borderLayer.lineWidth = 2
31
32
  layer.addSublayer(borderLayer)
33
+ handlesLayer.fillColor = UIColor.white.cgColor
34
+ layer.addSublayer(handlesLayer)
32
35
  }
33
36
 
34
37
  required init?(coder: NSCoder) { nil }
@@ -43,6 +46,18 @@ final class CropOverlayView: UIView {
43
46
  setNeedsLayout()
44
47
  }
45
48
 
49
+ /// Restores a normalized crop rectangle, used when reopening or cancelling crop mode.
50
+ func setCrop(left: CGFloat, top: CGFloat, right: CGFloat, bottom: CGFloat) {
51
+ guard imageBounds.width > 0, imageBounds.height > 0 else { return }
52
+ cropRect = CGRect(
53
+ x: imageBounds.minX + left * imageBounds.width,
54
+ y: imageBounds.minY + top * imageBounds.height,
55
+ width: (right - left) * imageBounds.width,
56
+ height: (bottom - top) * imageBounds.height
57
+ )
58
+ clampToImageBounds(); setNeedsLayout()
59
+ }
60
+
46
61
  func setAspectRatio(_ ratio: CGFloat?) {
47
62
  aspectRatio = ratio
48
63
  if let ratio, imageBounds.width > 0, imageBounds.height > 0 {
@@ -79,6 +94,16 @@ final class CropOverlayView: UIView {
79
94
  borderPath.addLine(to: CGPoint(x: cropRect.maxX, y: y))
80
95
  }
81
96
  borderLayer.path = borderPath.cgPath
97
+
98
+ let handlesPath = UIBezierPath()
99
+ let points = [
100
+ CGPoint(x: cropRect.minX, y: cropRect.minY), CGPoint(x: cropRect.midX, y: cropRect.minY),
101
+ CGPoint(x: cropRect.maxX, y: cropRect.minY), CGPoint(x: cropRect.maxX, y: cropRect.midY),
102
+ CGPoint(x: cropRect.maxX, y: cropRect.maxY), CGPoint(x: cropRect.midX, y: cropRect.maxY),
103
+ CGPoint(x: cropRect.minX, y: cropRect.maxY), CGPoint(x: cropRect.minX, y: cropRect.midY),
104
+ ]
105
+ points.forEach { point in handlesPath.append(UIBezierPath(ovalIn: CGRect(x: point.x - 4, y: point.y - 4, width: 8, height: 8))) }
106
+ handlesLayer.path = handlesPath.cgPath
82
107
  }
83
108
 
84
109
  override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
@@ -108,6 +133,10 @@ final class CropOverlayView: UIView {
108
133
  if near(CGPoint(x: cropRect.maxX, y: cropRect.minY)) { return .topRight }
109
134
  if near(CGPoint(x: cropRect.minX, y: cropRect.maxY)) { return .bottomLeft }
110
135
  if near(CGPoint(x: cropRect.maxX, y: cropRect.maxY)) { return .bottomRight }
136
+ if near(CGPoint(x: cropRect.midX, y: cropRect.minY)) { return .top }
137
+ if near(CGPoint(x: cropRect.maxX, y: cropRect.midY)) { return .right }
138
+ if near(CGPoint(x: cropRect.midX, y: cropRect.maxY)) { return .bottom }
139
+ if near(CGPoint(x: cropRect.minX, y: cropRect.midY)) { return .left }
111
140
  if cropRect.contains(point) { return .move }
112
141
  return nil
113
142
  }
@@ -125,6 +154,10 @@ final class CropOverlayView: UIView {
125
154
  rect = CGRect(x: rect.minX + dx, y: rect.minY, width: rect.width - dx, height: rect.height + dy)
126
155
  case .bottomRight:
127
156
  rect = CGRect(x: rect.minX, y: rect.minY, width: rect.width + dx, height: rect.height + dy)
157
+ case .top: rect = CGRect(x: rect.minX, y: rect.minY + dy, width: rect.width, height: rect.height - dy)
158
+ case .right: rect.size.width += dx
159
+ case .bottom: rect.size.height += dy
160
+ case .left: rect = CGRect(x: rect.minX + dx, y: rect.minY, width: rect.width - dx, height: rect.height)
128
161
  }
129
162
  guard rect.width >= minSize, rect.height >= minSize else { return }
130
163
  if let ratio = aspectRatio {
@@ -137,6 +170,10 @@ final class CropOverlayView: UIView {
137
170
  rect.size.height = newHeight
138
171
  case .move:
139
172
  break
173
+ case .left, .right:
174
+ let centerY = cropRect.midY; rect.size.height = rect.width / ratio; rect.origin.y = centerY - rect.height / 2
175
+ case .top, .bottom:
176
+ let centerX = cropRect.midX; rect.size.width = rect.height * ratio; rect.origin.x = centerX - rect.width / 2
140
177
  }
141
178
  }
142
179
  cropRect = rect
@@ -0,0 +1,48 @@
1
+ import UIKit
2
+
3
+ struct DrawStroke {
4
+ var points: [CGPoint]
5
+ let color: UIColor
6
+ let widthPx: CGFloat
7
+ }
8
+
9
+ /** Captures freehand strokes over the visible image while Draw mode is active. */
10
+ final class DrawOverlayView: UIView {
11
+ private var imageBounds: CGRect = .zero
12
+ var strokeColor: UIColor = .red
13
+ var strokeWidthPx: CGFloat = 12
14
+ private var strokes: [DrawStroke] = []
15
+ private var currentIndex: Int?
16
+
17
+ override init(frame: CGRect) { super.init(frame: frame); backgroundColor = .clear; isOpaque = false }
18
+ required init?(coder: NSCoder) { nil }
19
+ func setImageBounds(_ bounds: CGRect) { imageBounds = bounds }
20
+ func clearStrokes() { strokes.removeAll(); currentIndex = nil; setNeedsDisplay() }
21
+ func undoLastStroke() { if !strokes.isEmpty { strokes.removeLast() }; setNeedsDisplay() }
22
+ var hasStrokes: Bool { !strokes.isEmpty }
23
+ func normalizedStrokes() -> [(points: [(CGFloat, CGFloat)], stroke: DrawStroke)] {
24
+ guard imageBounds.width > 0, imageBounds.height > 0 else { return [] }
25
+ return strokes.map { stroke in
26
+ (stroke.points.map { (($0.x - imageBounds.minX) / imageBounds.width, ($0.y - imageBounds.minY) / imageBounds.height) }, stroke)
27
+ }
28
+ }
29
+
30
+ override func draw(_ rect: CGRect) {
31
+ guard let ctx = UIGraphicsGetCurrentContext() else { return }
32
+ ctx.setLineCap(.round); ctx.setLineJoin(.round)
33
+ for stroke in strokes where stroke.points.count >= 2 {
34
+ ctx.setStrokeColor(stroke.color.cgColor); ctx.setLineWidth(stroke.widthPx)
35
+ ctx.move(to: stroke.points[0]); stroke.points.dropFirst().forEach { ctx.addLine(to: $0) }; ctx.strokePath()
36
+ }
37
+ }
38
+ override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
39
+ guard let point = touches.first?.location(in: self), imageBounds.contains(point) else { return }
40
+ strokes.append(DrawStroke(points: [point], color: strokeColor, widthPx: strokeWidthPx)); currentIndex = strokes.count - 1; setNeedsDisplay()
41
+ }
42
+ override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
43
+ guard let index = currentIndex, let point = touches.first?.location(in: self) else { return }
44
+ strokes[index].points.append(point); setNeedsDisplay()
45
+ }
46
+ override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { currentIndex = nil }
47
+ override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { currentIndex = nil }
48
+ }
@@ -9,6 +9,7 @@ final class LayerOverlayView: UIView {
9
9
  var layers: [PhotoLayer] = [] { didSet { setNeedsDisplay() } }
10
10
  var selectedLayerID: String? { didSet { setNeedsDisplay() } }
11
11
  var onLayerTapped: ((String?) -> Void)?
12
+ var onLayerDoubleTapped: ((String) -> Void)?
12
13
  var onLayerTransformChanged: ((_ id: String, _ x: CGFloat, _ y: CGFloat, _ scale: CGFloat, _ rotationDegrees: CGFloat) -> Void)?
13
14
  var onLayerTransformEnded: ((String) -> Void)?
14
15
 
@@ -107,7 +108,10 @@ final class LayerOverlayView: UIView {
107
108
  }
108
109
  }
109
110
 
110
- override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { endTouches(touches) }
111
+ override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
112
+ if let touch = touches.first, touch.tapCount >= 2, let id = gestureID { onLayerDoubleTapped?(id) }
113
+ endTouches(touches)
114
+ }
111
115
  override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { endTouches(touches) }
112
116
 
113
117
  private func endTouches(_ touches: Set<UITouch>) {
@@ -146,6 +150,10 @@ final class LayerOverlayView: UIView {
146
150
  // Mirrors `PhotoLayerRenderer.drawOverlay`'s box: full canvas width, height from aspect ratio.
147
151
  let aspectRatio = layer.overlayAspectRatio.flatMap { $0 > 0 ? $0 : nil } ?? 1
148
152
  base = CGSize(width: imageBounds.width / 2, height: imageBounds.width / (2 * aspectRatio))
153
+ case .drawing:
154
+ let maxX = layer.drawPoints.map { abs($0.0) }.max() ?? 0.08
155
+ let maxY = layer.drawPoints.map { abs($0.1) }.max() ?? 0.08
156
+ base = CGSize(width: maxX * imageBounds.width + handleRadius, height: maxY * imageBounds.height + handleRadius)
149
157
  }
150
158
  return CGSize(width: max(handleRadius, base.width * layer.scale), height: max(handleRadius, base.height * layer.scale))
151
159
  }