react-native-photo-video-editor 0.1.1 → 0.2.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 (41) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +760 -590
  3. package/android/src/main/java/com/photovideoeditor/photo/export/PhotoExporter.kt +8 -23
  4. package/android/src/main/java/com/photovideoeditor/photo/render/CropGeometry.kt +71 -0
  5. package/android/src/main/java/com/photovideoeditor/photo/render/OverlayGeometry.kt +36 -0
  6. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoEditSession.kt +5 -7
  7. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoFilterPresets.kt +71 -41
  8. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerRenderer.kt +3 -1
  9. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformRenderer.kt +33 -0
  10. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformState.kt +6 -1
  11. package/android/src/main/java/com/photovideoeditor/photo/ui/CropOverlayView.kt +109 -151
  12. package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +42 -13
  13. package/android/src/main/java/com/photovideoeditor/photo/ui/ZoomableImageView.kt +10 -0
  14. package/android/src/main/java/com/photovideoeditor/ui/components/CropPanel.kt +122 -0
  15. package/android/src/main/java/com/photovideoeditor/ui/components/EditorPanel.kt +250 -0
  16. package/android/src/main/java/com/photovideoeditor/ui/components/EditorToolSlider.kt +103 -0
  17. package/android/src/main/java/com/photovideoeditor/video/export/VideoExporter.kt +38 -11
  18. package/android/src/main/java/com/photovideoeditor/video/render/VideoCropEffects.kt +35 -0
  19. package/android/src/main/java/com/photovideoeditor/video/render/VideoEditSession.kt +10 -0
  20. package/android/src/main/java/com/photovideoeditor/video/render/VideoTransformState.kt +1 -0
  21. package/docs/editor-ui-validation.md +22 -0
  22. package/ios/Editor/PhotoVideoEditorViewController.swift +778 -443
  23. package/ios/Editor/UI/CropPanel.swift +97 -0
  24. package/ios/Editor/UI/EditorPanel.swift +334 -0
  25. package/ios/Editor/UI/EditorToolSlider.swift +22 -0
  26. package/ios/Editor/UI/TextEditorSheet.swift +84 -0
  27. package/ios/Photo/Export/PhotoExporter.swift +3 -3
  28. package/ios/Photo/Rendering/CropGeometry.swift +48 -0
  29. package/ios/Photo/Rendering/PhotoEditSession.swift +21 -10
  30. package/ios/Photo/Rendering/PhotoFilterPresets.swift +53 -23
  31. package/ios/Photo/Rendering/PhotoLayer.swift +9 -1
  32. package/ios/Photo/Rendering/PhotoLayerRenderer.swift +2 -1
  33. package/ios/Photo/Rendering/PhotoTransformState.swift +5 -0
  34. package/ios/Photo/UI/CropOverlayView.swift +96 -169
  35. package/ios/Photo/UI/FilterThumbnailLoader.swift +62 -0
  36. package/ios/Photo/UI/LayerOverlayView.swift +27 -10
  37. package/ios/Photo/UI/ZoomableImageView.swift +19 -1
  38. package/ios/Video/Export/VideoExporter.swift +10 -1
  39. package/ios/Video/Render/VideoCropGeometry.swift +48 -0
  40. package/ios/Video/Render/VideoTransformState.swift +1 -0
  41. package/package.json +1 -1
@@ -21,13 +21,14 @@ final class PhotoVideoEditorViewController: UIViewController {
21
21
  private var photoSession: PhotoEditSession?
22
22
  private var cropMode = false
23
23
  private var cropEntryState: PhotoTransformState?
24
+ private var photoCropPanel: CropPanel?
24
25
  private var filtersMode = false
25
26
  private var drawMode = false
26
27
  private var stickersMode = false
27
28
  private var pendingPickPurpose: ImagePickPurpose?
28
29
  private var activeAdjustmentKey = "brightness"
29
30
  private var selectedLayerID: String?
30
- private var activeLayerPropertyKey = "scale"
31
+ private var activeLayerPropertyKey = ""
31
32
  private var dragStartSnapshot: [PhotoLayer]?
32
33
  private var photoImageView: ZoomableImageView?
33
34
  private var cropOverlay: CropOverlayView?
@@ -35,8 +36,18 @@ final class PhotoVideoEditorViewController: UIViewController {
35
36
  private var drawOverlay: DrawOverlayView?
36
37
  private var straightenSlider: UISlider?
37
38
  private var cropSubBar: UIView?
38
- 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?
39
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()
40
51
  private var stickersSubBar: UIView?
41
52
  private var drawSubBar: UIView?
42
53
  private var layerToolBar: UIView?
@@ -53,6 +64,12 @@ final class PhotoVideoEditorViewController: UIViewController {
53
64
  private var videoSession: VideoEditSession?
54
65
  private var videoExporter: VideoExporter?
55
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?
56
73
  private var trimRangeView: TrimRangeView?
57
74
  private var positionSlider: UISlider?
58
75
  private var playPauseButton: UIButton?
@@ -68,10 +85,17 @@ final class PhotoVideoEditorViewController: UIViewController {
68
85
  private var videoLayerToolBar: UIView?
69
86
  private var videoLayerPropertySlider: UISlider?
70
87
  private var selectedVideoLayerID: String?
71
- private var activeVideoLayerPropertyKey = "scale"
88
+ private var activeVideoLayerPropertyKey = ""
72
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]?
73
94
  private var videoLayerPropertyButtons: [String: UIButton] = [:]
74
95
  private var seekingPosition = false
96
+ private weak var videoControlsOverlay: UIView?
97
+ private var videoControlsVisible = true
98
+ private var hideVideoControlsTimer: Timer?
75
99
  private var selectedClipIndex = 0
76
100
  private var clipStripBar: UIStackView?
77
101
  private var clipStripScroll: UIScrollView?
@@ -80,10 +104,7 @@ final class PhotoVideoEditorViewController: UIViewController {
80
104
  private var timelineThumbnailFrames = [UIImage?](repeating: nil, count: 12)
81
105
 
82
106
  private var cropAspectButtons: [String: UIButton] = [:]
83
- private var filterAdjustmentButtons: [String: UIButton] = [:]
84
- private var filterPresetButtons: [String: UIButton] = [:]
85
107
  private var layerPropertyButtons: [String: UIButton] = [:]
86
- private var layerIconButtons: [String: UIButton] = [:]
87
108
  private var videoToolButtons: [String: UIButton] = [:]
88
109
  private var videoAspectButtons: [String: UIButton] = [:]
89
110
 
@@ -204,13 +225,20 @@ final class PhotoVideoEditorViewController: UIViewController {
204
225
 
205
226
  imageView.onBoundsChanged = { [weak self] bounds in
206
227
  guard let self else { return }
207
- if self.cropMode { overlay.setImageBounds(bounds, resetCrop: false) }
228
+ if self.cropMode { overlay.restore(bounds:bounds,state:session.state) }
208
229
  layers.setImageBounds(bounds)
209
230
  drawing.setImageBounds(bounds)
210
231
  }
211
232
  overlay.onCropChanged = { left, top, right, bottom in
212
233
  session.update { $0.setCrop(left: left, top: top, right: right, bottom: bottom) }
213
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
+ }
214
242
  layers.onLayerTapped = { [weak self] id in self?.selectLayer(id) }
215
243
  layers.onLayerDoubleTapped = { [weak self] id in
216
244
  guard let self, let layer = session.layerStack.layers.first(where: { $0.id == id && $0.type == .text }) else { return }
@@ -244,12 +272,20 @@ final class PhotoVideoEditorViewController: UIViewController {
244
272
  cropBar.isHidden = true
245
273
  cropSubBar = cropBar
246
274
 
247
- let adjustmentSlider = UISlider()
248
- adjustmentSlider.isHidden = true
249
- adjustmentSlider.addTarget(self, action: #selector(adjustmentChanged(_:)), for: .valueChanged)
250
- 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
251
287
 
252
- let filtersBar = makeFiltersSubBar(session: session, imageView: imageView, textColor: textColor, primaryColor: primaryColor, toolbarColor: toolbarColor)
288
+ let filtersBar = makeFiltersSubBar(session: session, primaryColor: primaryColor)
253
289
  filtersBar.isHidden = true
254
290
  filtersSubBar = filtersBar
255
291
 
@@ -261,9 +297,10 @@ final class PhotoVideoEditorViewController: UIViewController {
261
297
  drawBar.isHidden = true
262
298
  drawSubBar = drawBar
263
299
 
264
- let layerSlider = UISlider()
300
+ let layerSlider = EditorToolSlider()
265
301
  layerSlider.isHidden = true
266
302
  layerSlider.addTarget(self, action: #selector(layerPropertyChanged(_:)), for: .valueChanged)
303
+ configureLayerSlider(layerSlider, video: false)
267
304
  layerPropertySlider = layerSlider
268
305
 
269
306
  let layerBar = makeLayerToolBar(session: session, textColor: textColor, primaryColor: primaryColor, toolbarColor: toolbarColor)
@@ -277,7 +314,7 @@ final class PhotoVideoEditorViewController: UIViewController {
277
314
  let toolbar = makePhotoToolBar(textColor: textColor, toolbarColor: toolbarColor)
278
315
  mainToolBar = toolbar
279
316
 
280
- let root = UIStackView(arrangedSubviews: [header, preview, straighten, cropBar, adjustmentSlider, filtersBar, stickersBar, drawBar, layerSlider, colorSwatchRow, layerBar, toolbar])
317
+ let root = UIStackView(arrangedSubviews: [header, preview, cropBar, adjustBar, filtersBar, stickersBar, drawBar, layerSlider, colorSwatchRow, layerBar, toolbar])
281
318
  root.axis = .vertical
282
319
  root.translatesAutoresizingMaskIntoConstraints = false
283
320
  view.addSubview(root)
@@ -288,14 +325,18 @@ final class PhotoVideoEditorViewController: UIViewController {
288
325
  root.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
289
326
  header.heightAnchor.constraint(equalToConstant: 60),
290
327
  toolbar.heightAnchor.constraint(equalToConstant: 72),
291
- cropBar.heightAnchor.constraint(equalToConstant: 100), // rotate row (44pt) + aspect chip row (56pt)
292
- 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),
293
331
  stickersBar.heightAnchor.constraint(equalToConstant: 56),
294
332
  drawBar.heightAnchor.constraint(equalToConstant: 56),
295
- layerBar.heightAnchor.constraint(equalToConstant: 56),
333
+ layerBar.heightAnchor.constraint(equalToConstant: 76),
296
334
  colorSwatchRow.heightAnchor.constraint(equalToConstant: 56),
297
335
  ])
298
336
 
337
+ root.arrangedSubviews.forEach { child in
338
+ child.constraints.filter { $0.firstAttribute == .height && $0.secondItem == nil }.forEach { $0.priority = UILayoutPriority(999) }
339
+ }
299
340
  let progress = UIView()
300
341
  progress.backgroundColor = UIColor.black.withAlphaComponent(0.6)
301
342
  progress.isHidden = true
@@ -340,11 +381,11 @@ final class PhotoVideoEditorViewController: UIViewController {
340
381
 
341
382
  private func createToolNode(systemName: String, labelText: String, onTap: @escaping () -> Void) -> ToolNode {
342
383
  let icon = UIImageView(image: UIImage(systemName: systemName))
343
- icon.tintColor = DesignTokens.outline
384
+ icon.tintColor = DesignTokens.textSecondary
344
385
  icon.contentMode = .scaleAspectFit
345
386
  let tile = UIView()
346
- tile.backgroundColor = DesignTokens.surfaceContainerLow
347
- tile.layer.cornerRadius = DesignTokens.radiusLg
387
+ tile.backgroundColor = .clear
388
+ tile.layer.cornerRadius = DesignTokens.touchTargetMin / 2
348
389
  tile.addSubview(icon)
349
390
  icon.translatesAutoresizingMaskIntoConstraints = false
350
391
  NSLayoutConstraint.activate([
@@ -355,9 +396,13 @@ final class PhotoVideoEditorViewController: UIViewController {
355
396
  ])
356
397
  let label = UILabel()
357
398
  label.text = labelText
358
- label.font = .systemFont(ofSize: 11)
359
- label.textColor = DesignTokens.outline
399
+ label.font = .systemFont(ofSize: 11, weight: .medium)
400
+ label.textColor = DesignTokens.textSecondary
360
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
361
406
  let root = UIStackView(arrangedSubviews: [tile, label])
362
407
  root.axis = .vertical
363
408
  root.alignment = .center
@@ -366,6 +411,9 @@ final class PhotoVideoEditorViewController: UIViewController {
366
411
  toolNodeActions[ObjectIdentifier(root)] = onTap
367
412
  root.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleToolNodeTap(_:))))
368
413
  root.applyPressScale()
414
+ root.isAccessibilityElement = true
415
+ root.accessibilityLabel = labelText
416
+ root.accessibilityTraits = .button
369
417
  NSLayoutConstraint.activate([
370
418
  tile.widthAnchor.constraint(equalToConstant: DesignTokens.touchTargetMin),
371
419
  tile.heightAnchor.constraint(equalToConstant: DesignTokens.touchTargetMin),
@@ -373,10 +421,14 @@ final class PhotoVideoEditorViewController: UIViewController {
373
421
  return ToolNode(root: root, tile: tile, icon: icon, label: label)
374
422
  }
375
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.
376
426
  private func setToolNodeSelected(_ node: ToolNode, selected: Bool, textColor: UIColor) {
377
- node.tile.backgroundColor = selected ? DesignTokens.surfaceContainer : DesignTokens.surfaceContainerLow
378
- node.icon.tintColor = selected ? textColor : DesignTokens.outline
379
- 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
380
432
  }
381
433
 
382
434
  /// Full Studio Violet tool rail (see main_photo_editor_default_state mockup). Rotate lives inside
@@ -392,13 +444,28 @@ final class PhotoVideoEditorViewController: UIViewController {
392
444
  ("resize", "Resize", "aspectratio"),
393
445
  ]
394
446
  let scroll = UIScrollView(); scroll.backgroundColor = toolbarColor
447
+ scroll.showsHorizontalScrollIndicator = false
395
448
  photoToolScroll = scroll
396
- 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)
397
453
  tools.filter { features[$0.0] as? Bool != false }.forEach { key, label, symbol in
398
454
  let node = createToolNode(systemName: symbol, labelText: label) { [weak self] in self?.onPhotoToolTapped(key) }
455
+ node.root.widthAnchor.constraint(equalToConstant: itemWidth).isActive = true
399
456
  photoToolNodes[key] = node
400
457
  stack.addArrangedSubview(node.root)
401
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)
402
469
  scroll.addSubview(stack)
403
470
  stack.translatesAutoresizingMaskIntoConstraints = false
404
471
  NSLayoutConstraint.activate([
@@ -412,67 +479,24 @@ final class PhotoVideoEditorViewController: UIViewController {
412
479
  }
413
480
 
414
481
  private func makeCropSubBar(session: PhotoEditSession, textColor: UIColor, primaryColor: UIColor, toolbarColor: UIColor) -> UIView {
415
- 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)]
416
- let outer = UIStackView(); outer.axis = .vertical
417
- outer.backgroundColor = toolbarColor
418
-
419
- // Rotate row — moved here from the main tool rail to match the crop_transform_editor mockup.
420
- let transformRow = UIStackView(arrangedSubviews: [
421
- iconButton(systemName: "rotate.left", tint: textColor) { [weak self] in
422
- guard let self, let session = self.photoSession, let imageView = self.photoImageView else { return }
423
- session.update { $0.rotateRight(); $0.rotateRight(); $0.rotateRight() }
424
- self.schedulePhotoPreviewRender()
425
- DispatchQueue.main.async {
426
- imageView.resetToFit()
427
- if self.cropMode { self.cropOverlay?.setImageBounds(imageView.currentImageBounds(), resetCrop: true) }
428
- }
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()
429
489
  },
430
- iconButton(systemName: "rotate.right", tint: textColor) { [weak self] in
431
- guard let self, let session = self.photoSession, let imageView = self.photoImageView else { return }
432
- session.update { $0.rotateRight() }
433
- self.schedulePhotoPreviewRender()
434
- DispatchQueue.main.async {
435
- imageView.resetToFit()
436
- if self.cropMode { self.cropOverlay?.setImageBounds(imageView.currentImageBounds(), resetCrop: true) }
437
- }
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()
438
493
  },
439
- ])
440
- transformRow.axis = .horizontal
441
- transformRow.alignment = .center
442
- transformRow.distribution = .equalCentering
443
- transformRow.heightAnchor.constraint(equalToConstant: DesignTokens.touchTargetMin).isActive = true
444
- outer.addArrangedSubview(transformRow)
445
-
446
- let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
447
- bar.addArrangedSubview(button("Cancel", color: textColor) { [weak self] in self?.cancelCropMode() })
448
- let scroll = UIScrollView()
449
- let presetStack = UIStackView(); presetStack.axis = .horizontal; presetStack.spacing = 4
450
- presets.forEach { label, ratio in
451
- let chip = button(label, color: textColor) { [weak self] in
452
- self?.cropOverlay?.setAspectRatio(ratio)
453
- session.update { $0.aspectRatio = ratio }
454
- if let self { self.refreshSelection(self.cropAspectButtons, activeKey: label, textColor: textColor, primaryColor: primaryColor) }
455
- }
456
- cropAspectButtons[label] = chip
457
- presetStack.addArrangedSubview(chip)
458
- }
459
- presetStack.addArrangedSubview(button("Reset", color: textColor) { [weak self] in self?.resetPhotoEdits() })
460
- scroll.addSubview(presetStack)
461
- presetStack.translatesAutoresizingMaskIntoConstraints = false
462
- NSLayoutConstraint.activate([
463
- presetStack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
464
- presetStack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
465
- presetStack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
466
- presetStack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
467
- presetStack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
468
- ])
469
- bar.addArrangedSubview(scroll)
470
- bar.addArrangedSubview(button("Done", color: primaryColor) { [weak self] in self?.setCropMode(false) })
471
- bar.isLayoutMarginsRelativeArrangement = true
472
- bar.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
473
- bar.heightAnchor.constraint(equalToConstant: 56).isActive = true
474
- outer.addArrangedSubview(bar)
475
- 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
476
500
  }
477
501
 
478
502
  private func onPhotoToolTapped(_ key: String) {
@@ -480,7 +504,9 @@ final class PhotoVideoEditorViewController: UIViewController {
480
504
  switch key {
481
505
  case "crop":
482
506
  setCropMode(true)
483
- case "adjust", "filters":
507
+ case "adjust":
508
+ setAdjustMode(true, session: session)
509
+ case "filters":
484
510
  setFiltersMode(true, session: session)
485
511
  case "text":
486
512
  showTextInputDialog(session: session)
@@ -501,23 +527,30 @@ final class PhotoVideoEditorViewController: UIViewController {
501
527
  // with undo/redo (Milestone 4)
502
528
 
503
529
  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 }
506
- alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
507
- alert.addAction(UIAlertAction(title: "Add", style: .default) { [weak self, weak alert] _ in
508
- guard let text = alert?.textFields?.first?.text, !text.trimmingCharacters(in: .whitespaces).isEmpty else { return }
530
+ let sheet = TextEditorSheet(text: editingLayer?.text ?? "", editing: editingLayer != nil, accent: color("primaryColor") ?? DesignTokens.primaryContainer) { [weak self] text in
509
531
  var layer = editingLayer ?? PhotoLayer(type: .text)
510
532
  layer.text = text
511
533
  session.layerStack.commit { list in editingLayer == nil ? list + [layer] : list.map { $0.id == layer.id ? layer : $0 } }
512
534
  self?.selectLayer(layer.id)
513
- })
514
- 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
515
548
  }
516
549
 
517
550
  private func setStickersMode(_ enabled: Bool) {
518
551
  stickersMode = enabled
519
552
  stickersSubBar?.isHidden = !enabled
520
- mainToolBar?.isHidden = enabled
553
+ mainToolBar?.isHidden = enabled || selectedLayerID != nil
521
554
  refreshPhotoToolSelection()
522
555
  }
523
556
 
@@ -527,20 +560,23 @@ final class PhotoVideoEditorViewController: UIViewController {
527
560
  let chipStack = UIStackView(); chipStack.axis = .horizontal; chipStack.spacing = 4
528
561
  PhotoLayerRenderer.builtinStickerIDs().forEach { id in
529
562
  chipStack.addArrangedSubview(button(PhotoLayerRenderer.glyph(for: id)) { [weak self] in
530
- var layer = PhotoLayer(type: .sticker)
563
+ guard let self else { return }
564
+ var layer = self.newSticker()
531
565
  layer.stickerId = id
532
566
  session.layerStack.commit { $0 + [layer] }
533
- self?.selectLayer(layer.id)
534
- self?.setStickersMode(false)
567
+ self.selectLayer(layer.id)
568
+ self.setStickersMode(false)
535
569
  })
536
570
  }
537
571
  consumerStickerAssets().forEach { asset in
538
572
  chipStack.addArrangedSubview(button(asset.id, color: textColor) { [weak self] in
539
- var layer = PhotoLayer(type: .sticker)
573
+ guard let self else { return }
574
+ var layer = self.newSticker()
540
575
  layer.stickerUri = asset.uri
576
+ if let image = self.resolveVideoImageLayer(asset.uri) { layer.overlayAspectRatio = image.size.width / max(image.size.height, 1) }
541
577
  session.layerStack.commit { $0 + [layer] }
542
- self?.selectLayer(layer.id)
543
- self?.setStickersMode(false)
578
+ self.selectLayer(layer.id)
579
+ self.setStickersMode(false)
544
580
  })
545
581
  }
546
582
  chipStack.addArrangedSubview(button("Upload", color: textColor) { [weak self] in
@@ -687,8 +723,9 @@ final class PhotoVideoEditorViewController: UIViewController {
687
723
  switch purpose {
688
724
  case .photoSticker:
689
725
  guard let session = photoSession else { return }
690
- var layer = PhotoLayer(type: .sticker)
726
+ var layer = newSticker()
691
727
  layer.stickerUri = uri
728
+ layer.overlayAspectRatio = aspectRatio
692
729
  session.layerStack.commit { $0 + [layer] }
693
730
  selectLayer(layer.id)
694
731
  setStickersMode(false)
@@ -702,8 +739,9 @@ final class PhotoVideoEditorViewController: UIViewController {
702
739
  selectLayer(layer.id)
703
740
  case .videoSticker:
704
741
  guard let session = videoSession else { return }
705
- var layer = PhotoLayer(type: .sticker)
742
+ var layer = newSticker()
706
743
  layer.stickerUri = uri
744
+ layer.overlayAspectRatio = aspectRatio
707
745
  session.layerStack.commit { $0 + [layer] }
708
746
  onVideoLayerStackChanged()
709
747
  selectVideoLayer(layer.id, session: session)
@@ -725,7 +763,8 @@ final class PhotoVideoEditorViewController: UIViewController {
725
763
  let selected: Bool
726
764
  switch key {
727
765
  case "crop": selected = cropMode
728
- case "adjust", "filters": selected = filtersMode
766
+ case "adjust": selected = adjustMode
767
+ case "filters": selected = filtersMode
729
768
  case "stickers": selected = stickersMode
730
769
  case "draw": selected = drawMode
731
770
  default: selected = false
@@ -745,46 +784,35 @@ final class PhotoVideoEditorViewController: UIViewController {
745
784
  }
746
785
 
747
786
  private func selectLayer(_ id: String?) {
787
+ if selectedLayerID != id { activeLayerPropertyKey = "" }
748
788
  selectedLayerID = id
749
789
  layerOverlay?.selectedLayerID = id
750
790
  if let session = photoSession {
751
791
  layerOverlay?.layers = session.layerStack.layers
752
792
  schedulePhotoPreviewRender()
753
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
+ }
754
800
  setLayerToolBarVisible(id != nil)
755
801
  }
756
802
 
757
803
  private func setLayerToolBarVisible(_ visible: Bool) {
758
804
  layerToolBar?.isHidden = !visible
759
805
  mainToolBar?.isHidden = visible
760
- if visible {
761
- // TEXT layers show only Opacity/Colors/Duplicate/Delete, and STICKER layers show only
762
- // Opacity/Duplicate/Delete (no Colors) — Scale/Rotate and the lock/visibility/reorder
763
- // icons don't apply to either and are hidden per the reduced action list.
764
- let isText = currentSelectedLayer()?.type == .text
765
- let isSticker = currentSelectedLayer()?.type == .sticker
766
- let reducedControls = isText || isSticker
767
- layerPropertyButtons["scale"]?.isHidden = reducedControls
768
- layerPropertyButtons["rotation"]?.isHidden = reducedControls
769
- layerPropertyButtons["color"]?.isHidden = !isText
770
- ["lock", "visibility", "front", "back"].forEach { key in
771
- layerIconButtons[key]?.isHidden = reducedControls
772
- }
773
- if reducedControls, ["scale", "rotation"].contains(activeLayerPropertyKey) {
774
- activeLayerPropertyKey = "opacity"
775
- } else if !isText, activeLayerPropertyKey == "color" {
776
- activeLayerPropertyKey = reducedControls ? "opacity" : "scale"
777
- }
778
- layerPropertySlider?.isHidden = activeLayerPropertyKey == "color"
779
- layerColorSwatchRow?.isHidden = activeLayerPropertyKey != "color"
780
- if activeLayerPropertyKey != "color" { syncLayerPropertySlider() }
781
- let textColor = color("textColor") ?? DesignTokens.onSurface
782
- let primaryColor = color("primaryColor") ?? DesignTokens.primaryContainer
783
- refreshSelection(layerPropertyButtons, activeKey: activeLayerPropertyKey, textColor: textColor, primaryColor: primaryColor)
784
- } else {
785
- layerPropertySlider?.isHidden = true
786
- layerColorSwatchRow?.isHidden = true
787
- }
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)
788
816
  }
789
817
 
790
818
  private func currentSelectedLayer() -> PhotoLayer? {
@@ -792,14 +820,30 @@ final class PhotoVideoEditorViewController: UIViewController {
792
820
  return session.layerStack.layers.first { $0.id == selectedLayerID }
793
821
  }
794
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
+
795
837
  private func syncLayerPropertySlider() {
796
838
  guard let slider = layerPropertySlider, let layer = currentSelectedLayer() else { return }
797
839
  let (min, max, value): (CGFloat, CGFloat, CGFloat)
798
840
  switch activeLayerPropertyKey {
799
- 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)
800
842
  case "opacity": (min, max, value) = (0, 100, layer.opacity * 100)
843
+ case "fontSize": (min, max, value) = (12, 160, layer.fontSize)
801
844
  default: (min, max, value) = (20, 800, layer.scale * 100)
802
845
  }
846
+ (slider as? EditorToolSlider)?.propertyKey = slider === layerPropertySlider ? activeLayerPropertyKey : activeVideoLayerPropertyKey
803
847
  slider.minimumValue = Float(min)
804
848
  slider.maximumValue = Float(max)
805
849
  slider.value = Float(value)
@@ -807,6 +851,7 @@ final class PhotoVideoEditorViewController: UIViewController {
807
851
 
808
852
  @objc private func layerPropertyChanged(_ slider: UISlider) {
809
853
  guard let session = photoSession, let id = selectedLayerID else { return }
854
+ slider.setNeedsDisplay()
810
855
  let value = CGFloat(slider.value)
811
856
  let key = activeLayerPropertyKey
812
857
  session.layerStack.updateLive { list in
@@ -816,6 +861,7 @@ final class PhotoVideoEditorViewController: UIViewController {
816
861
  switch key {
817
862
  case "rotation": updated.rotationDegrees = value
818
863
  case "opacity": updated.opacity = value / 100
864
+ case "fontSize": updated.fontSize = value
819
865
  default: updated.scale = value / 100
820
866
  }
821
867
  return updated
@@ -826,99 +872,100 @@ final class PhotoVideoEditorViewController: UIViewController {
826
872
  }
827
873
 
828
874
  private func makeLayerToolBar(session: PhotoEditSession, textColor: UIColor, primaryColor: UIColor, toolbarColor: UIColor) -> UIView {
829
- // "color" only makes sense for TEXT layers; its chip is hidden/shown per-selection in `setLayerToolBarVisible`.
830
- let properties: [(String, String)] = [("scale", "Scale"), ("rotation", "Rotate"), ("opacity", "Opacity"), ("color", "Colors")]
831
- let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
832
- let scroll = UIScrollView()
833
- let chipStack = UIStackView(); chipStack.axis = .horizontal; chipStack.spacing = 4
834
- properties.forEach { key, label in
835
- let chip = button(label, color: textColor) { [weak self] in
836
- self?.activeLayerPropertyKey = key
837
- self?.layerPropertySlider?.isHidden = key == "color"
838
- self?.layerColorSwatchRow?.isHidden = key != "color"
839
- if key != "color" { self?.syncLayerPropertySlider() }
840
- 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)
841
901
  }
842
- layerPropertyButtons[key] = chip
843
- chipStack.addArrangedSubview(chip)
844
- }
845
- chipStack.addArrangedSubview(button("Duplicate", color: textColor) { [weak self] in
846
- guard let self, let original = self.currentSelectedLayer() else { return }
847
- var copy = PhotoLayer(id: UUID().uuidString, type: original.type)
848
- copy.x = min(max(original.x + 0.04, 0), 1)
849
- copy.y = min(max(original.y + 0.04, 0), 1)
850
- copy.scale = original.scale
851
- copy.rotationDegrees = original.rotationDegrees
852
- copy.opacity = original.opacity
853
- copy.text = original.text
854
- copy.textColor = original.textColor
855
- copy.fontSize = original.fontSize
856
- copy.fontFamily = original.fontFamily
857
- copy.stickerId = original.stickerId
858
- copy.stickerUri = original.stickerUri
859
- copy.overlayUri = original.overlayUri
860
- copy.overlayAspectRatio = original.overlayAspectRatio
861
- session.layerStack.commit { $0 + [copy] }
862
- self.selectLayer(copy.id)
863
- })
864
- let lockButton = button("Lock", color: textColor) { [weak self] in
865
- guard let self, let id = self.selectedLayerID else { return }
866
- session.layerStack.commit { list in list.map { $0.id == id ? { var l = $0; l.locked.toggle(); return l }() : $0 } }
867
- self.onLayerStackChanged()
868
- }
869
- layerIconButtons["lock"] = lockButton
870
- chipStack.addArrangedSubview(lockButton)
871
- let visibilityButton = button("Hide", color: textColor) { [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.visible.toggle(); return l }() : $0 } }
874
- self.onLayerStackChanged()
875
902
  }
876
- layerIconButtons["visibility"] = visibilityButton
877
- chipStack.addArrangedSubview(visibilityButton)
878
- let frontButton = button("Front", color: textColor) { [weak self] in
879
- guard let self, let id = self.selectedLayerID else { return }
880
- session.layerStack.commit { list in list.filter { $0.id != id } + list.filter { $0.id == id } }
881
- 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
882
915
  }
883
- layerIconButtons["front"] = frontButton
884
- chipStack.addArrangedSubview(frontButton)
885
- let backButton = button("Back", color: textColor) { [weak self] in
886
- guard let self, let id = self.selectedLayerID else { return }
887
- session.layerStack.commit { list in list.filter { $0.id == id } + list.filter { $0.id != id } }
888
- 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
889
933
  }
890
- layerIconButtons["back"] = backButton
891
- chipStack.addArrangedSubview(backButton)
892
- chipStack.addArrangedSubview(button("Delete", color: textColor) { [weak self] in
893
- guard let self, let id = self.selectedLayerID else { return }
894
- session.layerStack.commit { list in list.filter { $0.id != id } }
895
- self.selectLayer(nil)
896
- self.onLayerStackChanged()
897
- })
898
- scroll.addSubview(chipStack)
899
- 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
900
948
  NSLayoutConstraint.activate([
901
- chipStack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
902
- chipStack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
903
- chipStack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
904
- chipStack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
905
- 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),
906
955
  ])
907
956
  bar.addArrangedSubview(scroll)
908
- bar.addArrangedSubview(button("Done", color: primaryColor) { [weak self] in self?.selectLayer(nil) })
909
- bar.backgroundColor = toolbarColor
910
- bar.isLayoutMarginsRelativeArrangement = true
911
- 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
912
960
  return bar
913
961
  }
914
962
 
915
- /// Circular color swatch, matching the Android drawing_brush_studio mockup's palette dots.
916
963
  private func createColorSwatch(color: UIColor, onTap: @escaping () -> Void) -> UIView {
917
964
  let outer = UIButton(type: .custom)
918
965
  outer.backgroundColor = DesignTokens.surfaceContainerHigh
919
- outer.layer.cornerRadius = 16
920
- outer.widthAnchor.constraint(equalToConstant: 32).isActive = true
921
- 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
922
969
  let inner = UIView()
923
970
  inner.backgroundColor = color
924
971
  inner.layer.cornerRadius = 12
@@ -937,41 +984,45 @@ final class PhotoVideoEditorViewController: UIViewController {
937
984
 
938
985
  /// Text color swatch row, shown when a TEXT layer's "Colors" property is active.
939
986
  private func makeTextColorSwatchRow(session: PhotoEditSession) -> UIView {
940
- let toolbarColor = color("toolbarColor") ?? DesignTokens.surfaceContainerLow
941
- let primaryColor = color("primaryColor") ?? DesignTokens.primaryContainer
942
- let colors: [UIColor] = [
943
- .white, .black, .red, UIColor(red: 255 / 255, green: 214 / 255, blue: 51 / 255, alpha: 1),
944
- UIColor(red: 76 / 255, green: 217 / 255, blue: 100 / 255, alpha: 1), UIColor(red: 0, green: 145 / 255, blue: 255 / 255, alpha: 1),
945
- DesignTokens.primary, DesignTokens.tertiary,
946
- ]
947
- let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
948
- let scroll = UIScrollView()
949
- let swatchStack = UIStackView(); swatchStack.axis = .horizontal; swatchStack.spacing = 8
950
- colors.forEach { swatchColor in
951
- swatchStack.addArrangedSubview(createColorSwatch(color: swatchColor) { [weak self] in
952
- guard let self, let id = self.selectedLayerID else { return }
953
- session.layerStack.commit { list in list.map { $0.id == id ? { var l = $0; l.textColor = swatchColor; return l }() : $0 } }
954
- self.onLayerStackChanged()
955
- })
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
956
1001
  }
957
- scroll.addSubview(swatchStack)
958
- 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
959
1016
  NSLayoutConstraint.activate([
960
- swatchStack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
961
- swatchStack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
962
- swatchStack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
963
- swatchStack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
964
- 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),
965
1022
  ])
966
- bar.addArrangedSubview(scroll)
967
- bar.addArrangedSubview(button("Done", color: primaryColor) { [weak self] in self?.selectLayer(nil) })
968
- bar.backgroundColor = toolbarColor
969
- bar.isLayoutMarginsRelativeArrangement = true
970
- bar.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
971
- return bar
1023
+ return scroll
972
1024
  }
973
1025
 
974
- /// Refreshes the preview + selection + undo/redo enabled state after any layer-stack change.
975
1026
  private func onLayerStackChanged() {
976
1027
  guard let session = photoSession else { return }
977
1028
  if let id = selectedLayerID, !session.layerStack.layers.contains(where: { $0.id == id }) {
@@ -987,134 +1038,245 @@ final class PhotoVideoEditorViewController: UIViewController {
987
1038
  if selectedLayerID == nil, layerToolBar?.isHidden == false { setLayerToolBarVisible(false) }
988
1039
  }
989
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
+
990
1058
  private func setFiltersMode(_ enabled: Bool, session: PhotoEditSession) {
991
1059
  filtersMode = enabled
992
1060
  filtersSubBar?.isHidden = !enabled
993
- adjustmentSlider?.isHidden = !enabled
994
1061
  mainToolBar?.isHidden = enabled
995
1062
  layerOverlay?.isHidden = enabled
996
- if enabled { syncAdjustmentSlider(session: session) }
1063
+ if enabled {
1064
+ syncAdjustmentSlider(session: session)
1065
+ if let filtersSubBar { fadeInPanel(filtersSubBar) }
1066
+ }
997
1067
  refreshPhotoToolSelection()
998
1068
  refreshFiltersSelection(session: session)
999
1069
  }
1000
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
+
1001
1092
  private func refreshFiltersSelection(session: PhotoEditSession) {
1002
- let textColor = color("textColor") ?? DesignTokens.onSurface
1003
1093
  let primaryColor = color("primaryColor") ?? DesignTokens.primaryContainer
1004
- refreshSelection(filterAdjustmentButtons, activeKey: activeAdjustmentKey, textColor: textColor, primaryColor: primaryColor)
1005
- 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
1006
1101
  }
1007
1102
 
1103
+ /// Points whichever slider is on screen at the currently selected value.
1008
1104
  private func syncAdjustmentSlider(session: PhotoEditSession) {
1009
- guard let slider = adjustmentSlider else { return }
1010
1105
  let (min, max) = PhotoAdjustments.range(for: activeAdjustmentKey)
1011
- slider.minimumValue = Float(min)
1012
- slider.maximumValue = Float(max)
1013
- 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)"
1014
1128
  }
1015
1129
 
1016
1130
  @objc private func adjustmentChanged(_ slider: UISlider) {
1017
- guard let session = photoSession, let imageView = photoImageView else { return }
1131
+ guard let session = photoSession else { return }
1018
1132
  let key = activeAdjustmentKey
1019
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.
1136
+ schedulePhotoPreviewRender()
1137
+ }
1138
+
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()))"
1020
1143
  schedulePhotoPreviewRender()
1021
1144
  }
1022
1145
 
1023
- private func makeFiltersSubBar(
1024
- session: PhotoEditSession,
1025
- imageView: ZoomableImageView,
1026
- textColor: UIColor,
1027
- primaryColor: UIColor,
1028
- toolbarColor: UIColor
1029
- ) -> UIView {
1030
- let adjustmentChips: [(String, String)] = [
1031
- ("brightness", "Brightness"), ("contrast", "Contrast"), ("saturation", "Saturation"),
1032
- ("exposure", "Exposure"), ("temperature", "Temp"), ("blurRadius", "Blur"),
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"),
1033
1158
  ]
1034
- let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
1035
- let scroll = UIScrollView()
1036
- let chipStack = UIStackView(); chipStack.axis = .horizontal; chipStack.spacing = 4
1037
- adjustmentChips.forEach { key, label in
1038
- 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
1039
1189
  self?.activeAdjustmentKey = key
1040
1190
  self?.syncAdjustmentSlider(session: session)
1041
- if let self { self.refreshFiltersSelection(session: session) }
1191
+ self?.refreshAdjustSelection(session: session)
1042
1192
  }
1043
- filterAdjustmentButtons[key] = chip
1044
- chipStack.addArrangedSubview(chip)
1193
+ adjustmentStripItems[key] = item
1194
+ strip.addArrangedSubview(item.root)
1045
1195
  }
1046
- 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
1047
1199
  session.updateAdjustments { $0.mirror.toggle() }
1048
1200
  self?.schedulePhotoPreviewRender()
1049
- })
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
+
1050
1237
  PhotoFilterPresets.presetIDs.forEach { id in
1051
- let chip = button(PhotoFilterPresets.label(for: id), color: textColor) { [weak self] in
1238
+ let thumbnail = editorFilterThumbnail(labelText: PhotoFilterPresets.label(for: id)) { [weak self] in
1052
1239
  session.updateAdjustments { $0.filterPreset = $0.filterPreset == id ? nil : id }
1053
- self?.activeAdjustmentKey = "filterStrength"
1054
1240
  self?.syncAdjustmentSlider(session: session)
1055
1241
  self?.schedulePhotoPreviewRender()
1056
- if let self { self.refreshFiltersSelection(session: session) }
1242
+ self?.refreshFiltersSelection(session: session)
1057
1243
  }
1058
- filterPresetButtons[id] = chip
1059
- 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)
1060
1251
  }
1061
- chipStack.addArrangedSubview(button("Reset", color: textColor) { [weak self] in
1062
- session.updateAdjustments { $0 = PhotoAdjustments() }
1063
- self?.syncAdjustmentSlider(session: session)
1064
- self?.schedulePhotoPreviewRender()
1065
- if let self { self.refreshFiltersSelection(session: session) }
1066
- })
1067
- scroll.addSubview(chipStack)
1068
- chipStack.translatesAutoresizingMaskIntoConstraints = false
1252
+
1253
+ let panel = UIStackView(arrangedSubviews: [header, filterIntensityControl ?? UIView(), editorToolStrip(strip)])
1254
+ panel.axis = .vertical
1255
+ panel.backgroundColor = DesignTokens.editorSurface
1069
1256
  NSLayoutConstraint.activate([
1070
- chipStack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
1071
- chipStack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
1072
- chipStack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
1073
- chipStack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
1074
- chipStack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
1257
+ header.heightAnchor.constraint(equalToConstant: EditorPanelMetrics.headerHeight),
1258
+ panel.arrangedSubviews[1].heightAnchor.constraint(equalToConstant: EditorPanelMetrics.sliderHeight),
1075
1259
  ])
1076
- bar.addArrangedSubview(scroll)
1077
- bar.addArrangedSubview(button("Done", color: primaryColor) { [weak self] in self?.setFiltersMode(false, session: session) })
1078
- bar.backgroundColor = toolbarColor
1079
- bar.isLayoutMarginsRelativeArrangement = true
1080
- bar.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
1081
- return bar
1260
+ return panel
1082
1261
  }
1083
1262
 
1084
1263
  private func setCropMode(_ enabled: Bool) {
1085
- guard let imageView = photoImageView, let overlay = cropOverlay, let session = photoSession else { return }
1264
+ guard let session = photoSession else { return }
1086
1265
  if enabled && !cropMode { cropEntryState = session.state }
1087
1266
  cropMode = enabled
1088
- imageView.panZoomEnabled = !enabled
1089
- overlay.isHidden = !enabled
1090
- straightenSlider?.isHidden = !enabled
1091
- cropSubBar?.isHidden = !enabled
1092
- mainToolBar?.isHidden = enabled
1093
- layerOverlay?.isHidden = enabled
1094
- if enabled {
1095
- imageView.resetToFit()
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
1101
- }
1102
- refreshPhotoToolSelection()
1103
- let textColor = color("textColor") ?? DesignTokens.onSurface
1104
- let primaryColor = color("primaryColor") ?? DesignTokens.primaryContainer
1105
- 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()
1106
1272
  }
1107
1273
 
1108
1274
  private func cancelCropMode() {
1109
1275
  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()
1276
+ if let saved = cropEntryState { session.update { $0 = saved } }
1114
1277
  setCropMode(false)
1115
1278
  }
1116
1279
 
1117
- /// Maps an aspect ratio back to its preset label (matching the presets defined in `makeCropSubBar`/`makeVideoAspectSubBar`) so the correct chip can be highlighted.
1118
1280
  private func aspectRatioLabel(_ ratio: CGFloat?) -> String {
1119
1281
  guard let ratio else { return "Free" }
1120
1282
  let knownRatios: [(String, CGFloat)] = [("1:1", 1), ("4:5", 4 / 5), ("3:4", 3 / 4), ("9:16", 9 / 16), ("16:9", 16 / 9)]
@@ -1122,16 +1284,9 @@ final class PhotoVideoEditorViewController: UIViewController {
1122
1284
  }
1123
1285
 
1124
1286
  private func resetPhotoEdits() {
1125
- guard let imageView = photoImageView, let overlay = cropOverlay, let session = photoSession else { return }
1287
+ guard let session = photoSession else { return }
1126
1288
  session.update { $0.reset() }
1127
- overlay.setAspectRatio(nil)
1128
- straightenSlider?.value = 0
1129
- schedulePhotoPreviewRender()
1130
- imageView.resetToFit()
1131
- overlay.setImageBounds(imageView.currentImageBounds(), resetCrop: true)
1132
- let textColor = color("textColor") ?? DesignTokens.onSurface
1133
- let primaryColor = color("primaryColor") ?? DesignTokens.primaryContainer
1134
- refreshSelection(cropAspectButtons, activeKey: "Free", textColor: textColor, primaryColor: primaryColor)
1289
+ photoCropPanel?.sync(session.state); schedulePhotoPreviewRender()
1135
1290
  }
1136
1291
 
1137
1292
  @objc private func straightenChanged(_ slider: UISlider) {
@@ -1181,9 +1336,12 @@ final class PhotoVideoEditorViewController: UIViewController {
1181
1336
  videoSession = session
1182
1337
 
1183
1338
  let header = makeHeader(textColor: textColor, primaryColor: primaryColor, toolbarColor: toolbarColor)
1184
- let preview = UIView(); preview.backgroundColor = backgroundColor
1339
+ let preview = UIView(); preview.backgroundColor = .black
1340
+ preview.clipsToBounds = true; videoPreviewContainer = preview
1185
1341
 
1186
1342
  let playerController = AVPlayerViewController()
1343
+ cropPlayerView = playerController.view
1344
+ playerController.videoGravity = .resizeAspect
1187
1345
  playerController.player = session.player
1188
1346
  playerController.showsPlaybackControls = false
1189
1347
  addChild(playerController)
@@ -1209,6 +1367,21 @@ final class PhotoVideoEditorViewController: UIViewController {
1209
1367
  overlayView.bottomAnchor.constraint(equalTo: preview.bottomAnchor),
1210
1368
  ])
1211
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
+ }
1212
1385
  let videoLayers = LayerOverlayView()
1213
1386
  videoLayerOverlay = videoLayers
1214
1387
  preview.addSubview(videoLayers)
@@ -1219,6 +1392,13 @@ final class PhotoVideoEditorViewController: UIViewController {
1219
1392
  videoLayers.topAnchor.constraint(equalTo: preview.topAnchor),
1220
1393
  videoLayers.bottomAnchor.constraint(equalTo: preview.bottomAnchor),
1221
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
+ }
1222
1402
  videoLayers.onLayerTapped = { [weak self] id in self?.selectVideoLayer(id, session: session) }
1223
1403
  videoLayers.onLayerTransformChanged = { [weak self] id, x, y, scale, rotation in
1224
1404
  guard let self else { return }
@@ -1237,9 +1417,14 @@ final class PhotoVideoEditorViewController: UIViewController {
1237
1417
  }
1238
1418
 
1239
1419
  let playPause = UIButton(type: .system)
1240
- playPause.tintColor = textColor
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
1241
1425
  playPause.setImage(UIImage(systemName: "play.fill"), for: .normal)
1242
1426
  playPause.accessibilityLabel = "Play video"
1427
+ playPause.applyPressScale()
1243
1428
  playPause.addAction(UIAction { [weak self] _ in
1244
1429
  guard let self, let session = self.videoSession else { return }
1245
1430
  if session.player.timeControlStatus == .playing {
@@ -1255,22 +1440,27 @@ final class PhotoVideoEditorViewController: UIViewController {
1255
1440
  session.player.rate = session.state.speed
1256
1441
  }
1257
1442
  }
1443
+ self.setVideoControlsVisible(true)
1258
1444
  }, for: .touchUpInside)
1259
1445
  playPauseButton = playPause
1260
1446
  let time = UILabel()
1261
1447
  time.text = "0:00 / 0:00"
1262
- time.textColor = textColor
1263
- time.font = .systemFont(ofSize: 12)
1448
+ time.textColor = .white
1449
+ time.font = .monospacedDigitSystemFont(ofSize: 11, weight: .medium)
1264
1450
  timeLabel = time
1265
1451
  let transport = UIStackView(arrangedSubviews: [playPause, time])
1266
1452
  transport.axis = .horizontal
1267
1453
  transport.alignment = .center
1268
1454
  transport.spacing = 8
1269
- transport.backgroundColor = toolbarColor
1270
- transport.isLayoutMarginsRelativeArrangement = true
1271
- transport.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
1272
1455
 
1273
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
1274
1464
  position.minimumValue = 0
1275
1465
  position.maximumValue = 1000
1276
1466
  position.addTarget(self, action: #selector(positionSliderChanged(_:)), for: .valueChanged)
@@ -1278,6 +1468,22 @@ final class PhotoVideoEditorViewController: UIViewController {
1278
1468
  position.addTarget(self, action: #selector(positionSliderTouchUp(_:)), for: [.touchUpInside, .touchUpOutside])
1279
1469
  positionSlider = position
1280
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
+
1281
1487
  let trim = TrimRangeView()
1282
1488
  // Bound to the currently selected clip (Milestone 7): fractions are relative to that clip's
1283
1489
  // own `originalDurationMs`, not the composed multi-clip timeline.
@@ -1323,26 +1529,75 @@ final class PhotoVideoEditorViewController: UIViewController {
1323
1529
  }
1324
1530
  }
1325
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
1326
1548
  let aspectBar = makeVideoAspectSubBar(session: session, textColor: textColor, primaryColor: primaryColor, toolbarColor: toolbarColor)
1327
1549
  aspectBar.isHidden = true
1328
1550
  videoAspectSubBar = aspectBar
1329
1551
 
1330
- let layerSlider = UISlider()
1552
+ let layerSlider = EditorToolSlider()
1331
1553
  layerSlider.isHidden = true
1332
1554
  layerSlider.addTarget(self, action: #selector(videoLayerPropertyChanged(_:)), for: .valueChanged)
1555
+ configureLayerSlider(layerSlider, video: true)
1333
1556
  videoLayerPropertySlider = layerSlider
1334
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
1335
1567
  let layerBar = makeVideoLayerToolBar(session: session, textColor: textColor, primaryColor: primaryColor, toolbarColor: toolbarColor)
1336
1568
  layerBar.isHidden = true
1337
1569
  videoLayerToolBar = layerBar
1338
1570
 
1339
- let toolbar = makeVideoToolBar(session: session, playerController: playerController, textColor: textColor, toolbarColor: toolbarColor)
1571
+ let toolbar = makeVideoToolBar(session: session, playerController: playerController)
1340
1572
  videoToolBar = toolbar
1341
1573
 
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
+
1342
1595
  // 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])
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])
1345
1599
  root.axis = .vertical
1600
+ root.spacing = DesignTokens.spaceSm
1346
1601
  root.translatesAutoresizingMaskIntoConstraints = false
1347
1602
  view.addSubview(root)
1348
1603
  NSLayoutConstraint.activate([
@@ -1351,14 +1606,16 @@ final class PhotoVideoEditorViewController: UIViewController {
1351
1606
  root.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
1352
1607
  root.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
1353
1608
  header.heightAnchor.constraint(equalToConstant: 60),
1354
- transport.heightAnchor.constraint(equalToConstant: 40),
1355
1609
  trim.heightAnchor.constraint(equalToConstant: 48),
1356
1610
  stripScroll.heightAnchor.constraint(equalToConstant: 40),
1357
1611
  aspectBar.heightAnchor.constraint(equalToConstant: 56),
1358
- layerBar.heightAnchor.constraint(equalToConstant: 56),
1359
- toolbar.heightAnchor.constraint(equalToConstant: 72),
1612
+ layerBar.heightAnchor.constraint(equalToConstant: 76),
1613
+ toolbar.heightAnchor.constraint(equalToConstant: 52),
1360
1614
  ])
1361
1615
 
1616
+ root.arrangedSubviews.forEach { child in
1617
+ child.constraints.filter { $0.firstAttribute == .height && $0.secondItem == nil }.forEach { $0.priority = UILayoutPriority(999) }
1618
+ }
1362
1619
  let progress = UIView()
1363
1620
  progress.backgroundColor = UIColor.black.withAlphaComponent(0.6)
1364
1621
  progress.isHidden = true
@@ -1388,6 +1645,7 @@ final class PhotoVideoEditorViewController: UIViewController {
1388
1645
  }
1389
1646
 
1390
1647
  private func startPositionPolling() {
1648
+ var wasPlaying = false
1391
1649
  positionPollTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in
1392
1650
  guard let self, let session = self.videoSession else { return }
1393
1651
  if !self.seekingPosition, session.durationMs > 0 {
@@ -1398,10 +1656,41 @@ final class PhotoVideoEditorViewController: UIViewController {
1398
1656
  let playing = session.player.timeControlStatus == .playing
1399
1657
  self.playPauseButton?.setImage(UIImage(systemName: playing ? "pause.fill" : "play.fill"), for: .normal)
1400
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
+ }
1401
1663
  self.refreshVideoOverlayPreview(session: session)
1402
1664
  }
1403
1665
  }
1404
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
+
1405
1694
  private func updateTimeLabel() {
1406
1695
  guard let session = videoSession else { return }
1407
1696
  let currentMs = Int64(CMTimeGetSeconds(session.player.currentTime()) * 1000)
@@ -1455,23 +1744,51 @@ final class PhotoVideoEditorViewController: UIViewController {
1455
1744
  }
1456
1745
 
1457
1746
  /// Where the video actually renders within `view` under aspect-fit letterboxing (mirrors `ZoomableImageView.currentImageBounds()`'s math).
1458
- private func computeVideoLetterboxBounds(session: VideoEditSession, in view: UIView) -> CGRect {
1459
- guard let clip = session.clips.first, let track = session.asset(for: clip).tracks(withMediaType: .video).first else { return view.bounds }
1460
- let naturalSize = track.naturalSize.applying(track.preferredTransform)
1461
- let videoWidth = abs(naturalSize.width)
1462
- let videoHeight = abs(naturalSize.height)
1463
- let viewWidth = view.bounds.width
1464
- let viewHeight = view.bounds.height
1465
- guard videoWidth > 0, videoHeight > 0, viewWidth > 0, viewHeight > 0 else { return view.bounds }
1466
- let scale = min(viewWidth / videoWidth, viewHeight / videoHeight)
1467
- let fittedWidth = videoWidth * scale
1468
- let fittedHeight = videoHeight * scale
1469
- let x = (viewWidth - fittedWidth) / 2
1470
- let y = (viewHeight - fittedHeight) / 2
1471
- return CGRect(x: x, y: y, width: fittedWidth, height: fittedHeight)
1472
- }
1473
-
1474
- /// 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
+
1475
1792
  private func onVideoLayerStackChanged() {
1476
1793
  guard let session = videoSession else { return }
1477
1794
  if let id = selectedVideoLayerID, !session.layerStack.layers.contains(where: { $0.id == id }) {
@@ -1487,22 +1804,25 @@ final class PhotoVideoEditorViewController: UIViewController {
1487
1804
  }
1488
1805
 
1489
1806
  private func selectVideoLayer(_ id: String?, session: VideoEditSession) {
1807
+ if selectedVideoLayerID != id { activeVideoLayerPropertyKey = "" }
1490
1808
  selectedVideoLayerID = id
1491
1809
  videoLayerOverlay?.selectedLayerID = id
1492
1810
  setVideoLayerToolBarVisible(id != nil, session: session)
1493
1811
  }
1494
1812
 
1495
1813
  private func setVideoLayerToolBarVisible(_ visible: Bool, session: VideoEditSession) {
1814
+ if visible { setVideoControlsVisible(true) }
1496
1815
  videoLayerToolBar?.isHidden = !visible
1497
- videoLayerPropertySlider?.isHidden = !visible
1498
1816
  videoToolBar?.isHidden = visible
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
- }
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"
1823
+ if visible { syncVideoLayerPropertySlider(session: session) }
1824
+ refreshContextSelection(videoLayerPropertyButtons, active: activeVideoLayerPropertyKey)
1825
+ updateSwatches(videoTextSwatches, selected: currentSelectedVideoLayer(session: session)?.textColor)
1506
1826
  }
1507
1827
 
1508
1828
  private func currentSelectedVideoLayer(session: VideoEditSession) -> PhotoLayer? {
@@ -1513,14 +1833,16 @@ final class PhotoVideoEditorViewController: UIViewController {
1513
1833
  guard let slider = videoLayerPropertySlider, let layer = currentSelectedVideoLayer(session: session) else { return }
1514
1834
  let (min, max, value): (CGFloat, CGFloat, CGFloat)
1515
1835
  switch activeVideoLayerPropertyKey {
1516
- 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)
1517
1837
  case "opacity": (min, max, value) = (0, 100, layer.opacity * 100)
1838
+ case "fontSize": (min, max, value) = (12, 160, layer.fontSize)
1518
1839
  case "start": (min, max, value) = (0, CGFloat(session.durationMs), CGFloat(layer.startMs))
1519
1840
  case "end":
1520
1841
  let effectiveEnd = (layer.endMs > 0 && layer.endMs <= session.durationMs) ? layer.endMs : session.durationMs
1521
1842
  (min, max, value) = (0, CGFloat(session.durationMs), CGFloat(effectiveEnd))
1522
1843
  default: (min, max, value) = (20, 800, layer.scale * 100)
1523
1844
  }
1845
+ (slider as? EditorToolSlider)?.propertyKey = slider === layerPropertySlider ? activeLayerPropertyKey : activeVideoLayerPropertyKey
1524
1846
  slider.minimumValue = Float(min)
1525
1847
  slider.maximumValue = Float(max)
1526
1848
  slider.value = Float(value)
@@ -1528,6 +1850,7 @@ final class PhotoVideoEditorViewController: UIViewController {
1528
1850
 
1529
1851
  @objc private func videoLayerPropertyChanged(_ slider: UISlider) {
1530
1852
  guard let session = videoSession, let id = selectedVideoLayerID else { return }
1853
+ slider.setNeedsDisplay()
1531
1854
  let value = CGFloat(slider.value)
1532
1855
  let key = activeVideoLayerPropertyKey
1533
1856
  session.layerStack.updateLive { list in
@@ -1537,6 +1860,7 @@ final class PhotoVideoEditorViewController: UIViewController {
1537
1860
  switch key {
1538
1861
  case "rotation": updated.rotationDegrees = value
1539
1862
  case "opacity": updated.opacity = value / 100
1863
+ case "fontSize": updated.fontSize = value
1540
1864
  case "start": updated.startMs = min(Int64(value), (layer.endMs > 0 ? layer.endMs : session.durationMs))
1541
1865
  case "end": updated.endMs = max(Int64(value), layer.startMs)
1542
1866
  default: updated.scale = value / 100
@@ -1548,61 +1872,37 @@ final class PhotoVideoEditorViewController: UIViewController {
1548
1872
  }
1549
1873
 
1550
1874
  private func makeVideoLayerToolBar(session: VideoEditSession, textColor: UIColor, primaryColor: UIColor, toolbarColor: UIColor) -> UIView {
1551
- let properties: [(String, String)] = [("rotation", "Rotate"), ("opacity", "Opacity")]
1552
- let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
1553
- let scroll = UIScrollView()
1554
- let chipStack = UIStackView(); chipStack.axis = .horizontal; chipStack.spacing = 4
1555
- properties.forEach { key, label in
1556
- let chip = button(label, color: textColor) { [weak self] in
1557
- self?.activeVideoLayerPropertyKey = key
1558
- if let self { self.syncVideoLayerPropertySlider(session: session) }
1559
- 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)
1560
1888
  }
1561
- videoLayerPropertyButtons[key] = chip
1562
- chipStack.addArrangedSubview(chip)
1563
1889
  }
1564
- chipStack.addArrangedSubview(button("Delete", color: textColor) { [weak self] in
1565
- guard let self, let id = self.selectedVideoLayerID else { return }
1566
- session.layerStack.commit { $0.filter { $0.id != id } }
1567
- self.selectVideoLayer(nil, session: session)
1568
- self.onVideoLayerStackChanged()
1569
- })
1570
- scroll.addSubview(chipStack)
1571
- chipStack.translatesAutoresizingMaskIntoConstraints = false
1572
- NSLayoutConstraint.activate([
1573
- chipStack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
1574
- chipStack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
1575
- chipStack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
1576
- chipStack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
1577
- chipStack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
1578
- ])
1579
- bar.addArrangedSubview(scroll)
1580
- bar.addArrangedSubview(button("Done", color: primaryColor) { [weak self] in self?.selectVideoLayer(nil, session: session) })
1581
- bar.backgroundColor = toolbarColor
1582
- bar.isLayoutMarginsRelativeArrangement = true
1583
- bar.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
1584
- return bar
1585
1890
  }
1586
1891
 
1587
- private func showVideoTextInputDialog(session: VideoEditSession) {
1588
- let alert = UIAlertController(title: "Add text", message: nil, preferredStyle: .alert)
1589
- alert.addTextField { $0.placeholder = "Text" }
1590
- alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
1591
- alert.addAction(UIAlertAction(title: "Add", style: .default) { [weak self, weak alert] _ in
1592
- guard let text = alert?.textFields?.first?.text, !text.trimmingCharacters(in: .whitespaces).isEmpty else { return }
1593
- var layer = PhotoLayer(type: .text)
1594
- layer.text = text
1595
- session.layerStack.commit { $0 + [layer] }
1596
- self?.onVideoLayerStackChanged()
1597
- self?.selectVideoLayer(layer.id, session: session)
1598
- })
1599
- 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)
1600
1899
  }
1601
1900
 
1602
- @objc private func positionSliderTouchDown(_ slider: UISlider) { seekingPosition = true }
1901
+ @objc private func positionSliderTouchDown(_ slider: UISlider) { seekingPosition = true; setVideoControlsVisible(true) }
1603
1902
 
1604
1903
  @objc private func positionSliderTouchUp(_ slider: UISlider) {
1605
1904
  seekingPosition = false
1905
+ scheduleAutoHideVideoControls()
1606
1906
  guard let session = videoSession, session.durationMs > 0 else { return }
1607
1907
  let targetMs = Double(slider.value / 1000) * Double(session.durationMs)
1608
1908
  session.player.seek(to: CMTime(value: Int64(targetMs), timescale: 1000))
@@ -1614,29 +1914,50 @@ final class PhotoVideoEditorViewController: UIViewController {
1614
1914
  session.player.seek(to: CMTime(value: Int64(targetMs), timescale: 1000))
1615
1915
  }
1616
1916
 
1617
- private func makeVideoToolBar(session: VideoEditSession, playerController: AVPlayerViewController, textColor: UIColor, toolbarColor: UIColor) -> UIView {
1618
- let tools: [(String, String)] = [
1619
- ("text", "Text"), ("stickers", "Stickers"),
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"),
1620
1923
  ]
1621
- let scroll = UIScrollView(); scroll.backgroundColor = toolbarColor
1622
- let stack = UIStackView(); stack.axis = .horizontal; stack.spacing = 8
1623
- tools.filter { features[$0.0] as? Bool != false }.forEach { key, label in
1624
- 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
1625
1932
  self?.onVideoToolTapped(key, session: session, playerView: playerController?.view)
1626
- }
1627
- videoToolButtons[key] = toolButton
1628
- stack.addArrangedSubview(toolButton)
1933
+ })
1629
1934
  }
1630
- scroll.addSubview(stack)
1631
- stack.translatesAutoresizingMaskIntoConstraints = false
1632
- NSLayoutConstraint.activate([
1633
- stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 12),
1634
- stack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -12),
1635
- stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
1636
- stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
1637
- stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
1638
- ])
1639
- 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
1640
1961
  }
1641
1962
 
1642
1963
  private func refreshVideoToolSelection(session: VideoEditSession) {
@@ -1654,6 +1975,7 @@ final class PhotoVideoEditorViewController: UIViewController {
1654
1975
 
1655
1976
  private func onVideoToolTapped(_ key: String, session: VideoEditSession, playerView: UIView?) {
1656
1977
  switch key {
1978
+ case "crop": setVideoCropMode(true,session:session)
1657
1979
  case "cover":
1658
1980
  let atMs = Int64(CMTimeGetSeconds(session.player.currentTime()) * 1000)
1659
1981
  session.update { $0.coverFrameMs = atMs }
@@ -1931,7 +2253,9 @@ final class PhotoVideoEditorViewController: UIViewController {
1931
2253
  let title = UILabel()
1932
2254
  title.text = mediaType == "photo" ? "Photo Editor" : "Video Editor"
1933
2255
  title.textColor = textColor
1934
- title.font = .systemFont(ofSize: 16, weight: .bold)
2256
+ title.font = .systemFont(ofSize: 14, weight: .semibold)
2257
+ title.adjustsFontSizeToFitWidth = true
2258
+ title.minimumScaleFactor = 0.75
1935
2259
  title.textAlignment = .center
1936
2260
 
1937
2261
  header.addArrangedSubview(close)
@@ -1946,15 +2270,12 @@ final class PhotoVideoEditorViewController: UIViewController {
1946
2270
  self?.photoSession?.layerStack.redo()
1947
2271
  self?.onLayerStackChanged()
1948
2272
  }
2273
+ undo.isEnabled = false; undo.alpha = 0.4
2274
+ redo.isEnabled = false; redo.alpha = 0.4
1949
2275
  undoButton = undo
1950
2276
  redoButton = redo
1951
2277
  header.addArrangedSubview(undo)
1952
2278
  header.addArrangedSubview(redo)
1953
- let compare = iconButton(systemName: "rectangle.split.2x1", tint: textColor, handler: nil)
1954
- compare.accessibilityLabel = "Hold to compare with original"
1955
- compare.addTarget(self, action: #selector(compareTouchDown), for: .touchDown)
1956
- compare.addTarget(self, action: #selector(compareTouchUp), for: [.touchUpInside, .touchUpOutside, .touchCancel])
1957
- header.addArrangedSubview(compare)
1958
2279
  } else if mediaType == "video" {
1959
2280
  let undo = iconButton(systemName: "arrow.uturn.backward", tint: textColor) { [weak self] in
1960
2281
  self?.videoSession?.layerStack.undo()
@@ -1964,6 +2285,8 @@ final class PhotoVideoEditorViewController: UIViewController {
1964
2285
  self?.videoSession?.layerStack.redo()
1965
2286
  self?.onVideoLayerStackChanged()
1966
2287
  }
2288
+ undo.isEnabled = false; undo.alpha = 0.4
2289
+ redo.isEnabled = false; redo.alpha = 0.4
1967
2290
  undoButton = undo
1968
2291
  redoButton = redo
1969
2292
  header.addArrangedSubview(undo)
@@ -1973,7 +2296,7 @@ final class PhotoVideoEditorViewController: UIViewController {
1973
2296
  let onPrimaryColor = color("onPrimaryColor") ?? DesignTokens.onPrimaryContainer
1974
2297
  let export = EditorComponents.primaryButton(
1975
2298
  text: "Export",
1976
- systemImage: "square.and.arrow.up",
2299
+ systemImage: nil,
1977
2300
  fillColor: primaryColor,
1978
2301
  textColor: onPrimaryColor
1979
2302
  ) { [weak self] in
@@ -2045,6 +2368,14 @@ final class PhotoVideoEditorViewController: UIViewController {
2045
2368
  }
2046
2369
  }
2047
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
+
2048
2379
  @objc private func compareTouchDown() {
2049
2380
  guard !comparingOriginal, let session = photoSession else { return }
2050
2381
  comparingOriginal = true
@@ -2087,7 +2418,11 @@ final class PhotoVideoEditorViewController: UIViewController {
2087
2418
  guard self.viewIfLoaded?.window != nil,
2088
2419
  let session = self.photoSession,
2089
2420
  let imageView = self.photoImageView else { return }
2090
- 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
+ }
2091
2426
  }
2092
2427
  }
2093
2428