react-native-photo-video-editor 0.2.0 → 0.2.2

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.
@@ -61,11 +61,18 @@ final class CropPanel: UIStackView {
61
61
  control.addAction(UIAction { _ in onRatio(name,name == "Original" ? originalRatio() : ratio) },for:.touchUpInside)
62
62
  control.applyPressScale(); rail.addArrangedSubview(control); presets[name] = control
63
63
  }
64
- let scroll = UIScrollView(); scroll.showsHorizontalScrollIndicator = false; scroll.addSubview(rail)
64
+ let scroll = UIScrollView()
65
+ scroll.showsHorizontalScrollIndicator = false
66
+ scroll.alwaysBounceHorizontal = true
67
+ scroll.delaysContentTouches = true
68
+ scroll.canCancelContentTouches = true
69
+ scroll.addSubview(rail)
65
70
  rail.translatesAutoresizingMaskIntoConstraints = false
66
71
  NSLayoutConstraint.activate([
67
- rail.leadingAnchor.constraint(equalTo:scroll.contentLayoutGuide.leadingAnchor), rail.trailingAnchor.constraint(equalTo:scroll.contentLayoutGuide.trailingAnchor),
68
- rail.topAnchor.constraint(equalTo:scroll.contentLayoutGuide.topAnchor),rail.bottomAnchor.constraint(equalTo:scroll.contentLayoutGuide.bottomAnchor),
72
+ rail.leadingAnchor.constraint(equalTo:scroll.contentLayoutGuide.leadingAnchor),
73
+ scroll.contentLayoutGuide.trailingAnchor.constraint(equalTo:rail.trailingAnchor),
74
+ rail.topAnchor.constraint(equalTo:scroll.contentLayoutGuide.topAnchor),
75
+ scroll.contentLayoutGuide.bottomAnchor.constraint(equalTo:rail.bottomAnchor),
69
76
  rail.heightAnchor.constraint(equalTo:scroll.frameLayoutGuide.heightAnchor), scroll.heightAnchor.constraint(equalToConstant:72)
70
77
  ])
71
78
  addArrangedSubview(scroll)
@@ -17,10 +17,11 @@ final class ClosureTapGestureRecognizer: UITapGestureRecognizer {
17
17
  }
18
18
 
19
19
  /// Tactile press feedback (scale 1.0 -> 0.97) matching DESIGN.md's "Tactile Instrument Feel".
20
- /// Only observes touch-down/up via a zero-duration long-press recognizer with
21
- /// `cancelsTouchesInView = false`, so it never interferes with an existing tap recognizer
22
- /// (e.g. the tool rail's `handleToolNodeTap`) already attached to the same view.
23
- private final class EditorPressFeedback: NSObject {
20
+ /// Tactile press feedback (scale 1.0 -> 0.97) matching DESIGN.md's "Tactile Instrument Feel".
21
+ /// For UIControl instances (like UIButton), hooks natively into touch tracking without gesture recognizers.
22
+ /// For generic UIViews, uses a non-exclusive long-press recognizer that allows simultaneous recognition
23
+ /// with all tap and scroll gestures.
24
+ private final class EditorPressFeedback: NSObject, UIGestureRecognizerDelegate {
24
25
  private let pressedScale: CGFloat
25
26
  private weak var target: UIView?
26
27
 
@@ -28,19 +29,61 @@ private final class EditorPressFeedback: NSObject {
28
29
  self.target = target
29
30
  self.pressedScale = pressedScale
30
31
  super.init()
31
- let recognizer = UILongPressGestureRecognizer(target: self, action: #selector(handle(_:)))
32
- recognizer.minimumPressDuration = 0
33
- recognizer.cancelsTouchesInView = false
34
- target.addGestureRecognizer(recognizer)
32
+ if let control = target as? UIControl {
33
+ control.addAction(UIAction { [weak target] _ in
34
+ UIView.animate(
35
+ withDuration: 0.08,
36
+ delay: 0,
37
+ options: [.allowUserInteraction, .beginFromCurrentState],
38
+ animations: { target?.transform = CGAffineTransform(scaleX: pressedScale, y: pressedScale) }
39
+ )
40
+ }, for: [.touchDown, .touchDragEnter])
41
+ control.addAction(UIAction { [weak target] _ in
42
+ UIView.animate(
43
+ withDuration: 0.12,
44
+ delay: 0,
45
+ options: [.allowUserInteraction, .beginFromCurrentState],
46
+ animations: { target?.transform = .identity }
47
+ )
48
+ }, for: [.touchUpInside, .touchUpOutside, .touchCancel, .touchDragExit])
49
+ } else {
50
+ let recognizer = UILongPressGestureRecognizer(target: self, action: #selector(handle(_:)))
51
+ recognizer.minimumPressDuration = 0
52
+ recognizer.cancelsTouchesInView = false
53
+ recognizer.delegate = self
54
+ target.addGestureRecognizer(recognizer)
55
+ }
56
+ }
57
+
58
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
59
+ return true
60
+ }
61
+
62
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool {
63
+ return false
64
+ }
65
+
66
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
67
+ return false
35
68
  }
36
69
 
37
70
  @objc private func handle(_ recognizer: UILongPressGestureRecognizer) {
38
71
  guard let target else { return }
39
72
  switch recognizer.state {
40
73
  case .began:
41
- UIView.animate(withDuration: 0.08) { target.transform = CGAffineTransform(scaleX: self.pressedScale, y: self.pressedScale) }
74
+ UIView.animate(
75
+ withDuration: 0.08,
76
+ delay: 0,
77
+ options: [.allowUserInteraction, .beginFromCurrentState],
78
+ animations: { target.transform = CGAffineTransform(scaleX: self.pressedScale, y: self.pressedScale) }
79
+ )
42
80
  case .ended, .cancelled, .failed:
43
- UIView.animate(withDuration: 0.12) { target.transform = .identity }
81
+ UIView.animate(
82
+ withDuration: 0.12,
83
+ delay: 0,
84
+ options: [.allowUserInteraction, .beginFromCurrentState],
85
+ animations: { target.transform = .identity }
86
+ )
44
87
  default:
45
88
  break
46
89
  }
@@ -10,8 +10,8 @@ enum EditorPanelMetrics {
10
10
  static let headerHeight: CGFloat = 40
11
11
  static let sliderHeight: CGFloat = 48
12
12
  static let stripHeight: CGFloat = 76
13
- static let stripItemWidth: CGFloat = 68
14
- static let iconSize: CGFloat = 22
13
+ static let stripItemWidth: CGFloat = 58
14
+ static let iconSize: CGFloat = 20
15
15
  static let thumbnailWidth: CGFloat = 64
16
16
  static let thumbnailHeight: CGFloat = 72
17
17
  static var totalHeight: CGFloat { headerHeight + sliderHeight + stripHeight }
@@ -26,36 +26,52 @@ func editorPanelHeader(
26
26
  onSecondary: (() -> Void)? = nil,
27
27
  onDone: @escaping () -> Void
28
28
  ) -> UIView {
29
+ let container = UIStackView()
30
+ container.axis = .horizontal
31
+ container.alignment = .center
32
+ container.distribution = .equalSpacing
33
+ container.isLayoutMarginsRelativeArrangement = true
34
+ container.layoutMargins = UIEdgeInsets(
35
+ top: 0,
36
+ left: DesignTokens.spaceMd,
37
+ bottom: 0,
38
+ right: DesignTokens.spaceMd
39
+ )
40
+
29
41
  let titleLabel = UILabel()
30
42
  titleLabel.text = title
31
- titleLabel.font = .systemFont(ofSize: 14, weight: .bold)
43
+ titleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
32
44
  titleLabel.textColor = DesignTokens.textPrimary
45
+ container.addArrangedSubview(titleLabel)
33
46
 
34
- let row = UIStackView(arrangedSubviews: [titleLabel])
35
- row.axis = .horizontal
36
- row.alignment = .center
37
- row.spacing = DesignTokens.spaceSm
38
- row.isLayoutMarginsRelativeArrangement = true
39
- row.layoutMargins = UIEdgeInsets(top: 0, left: DesignTokens.spaceLg, bottom: 0, right: DesignTokens.spaceSm)
40
-
41
- let spacer = UIView()
42
- spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
43
- row.addArrangedSubview(spacer)
47
+ let actions = UIStackView()
48
+ actions.axis = .horizontal
49
+ actions.spacing = DesignTokens.spaceMd
50
+ actions.alignment = .center
44
51
 
45
52
  if let secondaryLabel, let onSecondary {
46
- row.addArrangedSubview(editorHeaderAction(label: secondaryLabel, color: DesignTokens.textSecondary, action: onSecondary))
53
+ let reset = UIButton(type: .system)
54
+ reset.setTitle(secondaryLabel, for: .normal)
55
+ reset.setTitleColor(DesignTokens.textSecondary, for: .normal)
56
+ reset.titleLabel?.font = .systemFont(ofSize: 13, weight: .regular)
57
+ reset.addAction(UIAction { _ in onSecondary() }, for: .touchUpInside)
58
+ reset.applyPressScale()
59
+ actions.addArrangedSubview(reset)
47
60
  }
48
- row.addArrangedSubview(editorHeaderAction(label: "Done", color: accentColor, action: onDone))
49
- return row
61
+
62
+ let done = editorPanelDoneButton(accentColor: accentColor, action: onDone)
63
+ actions.addArrangedSubview(done)
64
+
65
+ container.addArrangedSubview(actions)
66
+ return container
50
67
  }
51
68
 
52
- /// Small text action sized for touch (44pt) without looking like a button.
53
- private func editorHeaderAction(label: String, color: UIColor, action: @escaping () -> Void) -> UIButton {
69
+ private func editorPanelDoneButton(accentColor: UIColor, action: @escaping () -> Void) -> UIButton {
54
70
  let control = UIButton(type: .system)
55
71
  var config = UIButton.Configuration.plain()
56
- config.title = label
57
- config.baseForegroundColor = color
58
- config.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: DesignTokens.spaceMd, bottom: 0, trailing: DesignTokens.spaceMd)
72
+ config.title = "Done"
73
+ config.baseForegroundColor = accentColor
74
+ config.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)
59
75
  config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
60
76
  var value = incoming; value.font = .systemFont(ofSize: 13, weight: .bold); return value
61
77
  }
@@ -71,180 +87,237 @@ private func editorHeaderAction(label: String, color: UIColor, action: @escaping
71
87
  func editorToolStrip(_ content: UIStackView) -> UIScrollView {
72
88
  let scroll = UIScrollView()
73
89
  scroll.showsHorizontalScrollIndicator = false
90
+ scroll.alwaysBounceHorizontal = true
91
+ scroll.delaysContentTouches = true
92
+ scroll.canCancelContentTouches = true
74
93
  content.axis = .horizontal
75
- content.alignment = .center
94
+ content.alignment = .fill
76
95
  scroll.addSubview(content)
77
96
  content.translatesAutoresizingMaskIntoConstraints = false
78
97
  NSLayoutConstraint.activate([
79
98
  content.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: DesignTokens.spaceMd),
80
- content.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -DesignTokens.spaceMd),
99
+ scroll.contentLayoutGuide.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: DesignTokens.spaceMd),
81
100
  content.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
82
- content.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
101
+ scroll.contentLayoutGuide.bottomAnchor.constraint(equalTo: content.bottomAnchor),
83
102
  content.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
84
103
  ])
85
104
  return scroll
86
105
  }
87
106
 
88
- /// An icon + single-line label entry in a strip, with an optional "value changed" dot.
89
- final class EditorStripItem {
90
- let root: UIView
91
- private let iconHolder: UIView
92
- private let icon: UIImageView
93
- private let label: UILabel
94
- private let dot: UIView
95
-
96
- init(root: UIView, iconHolder: UIView, icon: UIImageView, label: UILabel, dot: UIView) {
97
- self.root = root
98
- self.iconHolder = iconHolder
99
- self.icon = icon
100
- self.label = label
101
- self.dot = dot
107
+ /// Interactive icon + label button in an adjustment strip, with native press feedback.
108
+ final class EditorStripItemControl: UIButton {
109
+ let iconHolder = UIView()
110
+ let icon = UIImageView()
111
+ let label = UILabel()
112
+ let dot = UIView()
113
+ private var action: (() -> Void)?
114
+
115
+ init(systemName: String, labelText: String, action: @escaping () -> Void) {
116
+ self.action = action
117
+ super.init(frame: .zero)
118
+ setupViews(systemName: systemName, labelText: labelText)
119
+ }
120
+
121
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
122
+
123
+ private func setupViews(systemName: String, labelText: String) {
124
+ isAccessibilityElement = true
125
+ accessibilityLabel = labelText
126
+ accessibilityTraits = .button
127
+
128
+ icon.image = UIImage(systemName: systemName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .regular))
129
+ icon.tintColor = DesignTokens.textSecondary
130
+ icon.contentMode = .scaleAspectFit
131
+ icon.isUserInteractionEnabled = false
132
+
133
+ iconHolder.layer.cornerRadius = 15
134
+ iconHolder.isUserInteractionEnabled = false
135
+ iconHolder.addSubview(icon)
136
+ icon.translatesAutoresizingMaskIntoConstraints = false
137
+ NSLayoutConstraint.activate([
138
+ iconHolder.widthAnchor.constraint(equalToConstant: 30),
139
+ iconHolder.heightAnchor.constraint(equalToConstant: 30),
140
+ icon.centerXAnchor.constraint(equalTo: iconHolder.centerXAnchor),
141
+ icon.centerYAnchor.constraint(equalTo: iconHolder.centerYAnchor),
142
+ icon.widthAnchor.constraint(equalToConstant: EditorPanelMetrics.iconSize),
143
+ icon.heightAnchor.constraint(equalToConstant: EditorPanelMetrics.iconSize),
144
+ ])
145
+
146
+ label.text = labelText
147
+ label.font = .systemFont(ofSize: 11, weight: .medium)
148
+ label.textColor = DesignTokens.textSecondary
149
+ label.textAlignment = .center
150
+ label.numberOfLines = 1
151
+ label.adjustsFontSizeToFitWidth = true
152
+ label.minimumScaleFactor = 0.85
153
+ label.isUserInteractionEnabled = false
154
+
155
+ dot.layer.cornerRadius = 2
156
+ dot.isHidden = true
157
+ dot.isUserInteractionEnabled = false
158
+ dot.widthAnchor.constraint(equalToConstant: 4).isActive = true
159
+ dot.heightAnchor.constraint(equalToConstant: 4).isActive = true
160
+
161
+ let column = UIStackView(arrangedSubviews: [iconHolder, label, dot])
162
+ column.axis = .vertical
163
+ column.alignment = .center
164
+ column.spacing = DesignTokens.spaceXs
165
+ column.isUserInteractionEnabled = false
166
+
167
+ addSubview(column)
168
+ column.translatesAutoresizingMaskIntoConstraints = false
169
+ NSLayoutConstraint.activate([
170
+ widthAnchor.constraint(equalToConstant: EditorPanelMetrics.stripItemWidth),
171
+ column.centerXAnchor.constraint(equalTo: centerXAnchor),
172
+ column.centerYAnchor.constraint(equalTo: centerYAnchor),
173
+ column.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor),
174
+ column.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
175
+ ])
176
+
177
+ addAction(UIAction { [weak self] _ in self?.action?() }, for: .touchUpInside)
178
+ applyPressScale()
179
+ }
180
+
181
+ override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
182
+ return bounds.insetBy(dx: -8, dy: -8).contains(point)
102
183
  }
103
184
 
104
- /// Selected state is a purple icon + label over a faint purple disc — never a filled purple tile,
105
- /// which at this size reads as a giant button rather than a selection.
106
185
  func setSelected(_ selected: Bool, accentColor: UIColor) {
107
186
  icon.tintColor = selected ? accentColor : DesignTokens.textSecondary
108
187
  label.textColor = selected ? accentColor : DesignTokens.textSecondary
109
188
  iconHolder.backgroundColor = selected ? accentColor.withAlphaComponent(0.18) : .clear
189
+ accessibilityTraits = selected ? [.button, .selected] : .button
110
190
  }
111
191
 
112
- /// Tiny dot marking an adjustment that is no longer at its default value.
113
192
  func setModified(_ modified: Bool, accentColor: UIColor) {
114
193
  dot.isHidden = !modified
115
194
  dot.backgroundColor = accentColor
116
195
  }
117
196
  }
118
197
 
119
- func editorStripItem(systemName: String, labelText: String, action: @escaping () -> Void) -> EditorStripItem {
120
- let icon = UIImageView(image: UIImage(systemName: systemName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .regular)))
121
- icon.tintColor = DesignTokens.textSecondary
122
- icon.contentMode = .scaleAspectFit
198
+ /// An icon + single-line label entry in a strip, with an optional "value changed" dot.
199
+ final class EditorStripItem {
200
+ let control: EditorStripItemControl
201
+ var root: UIView { control }
123
202
 
124
- let iconHolder = UIView()
125
- iconHolder.layer.cornerRadius = 16
126
- iconHolder.addSubview(icon)
127
- icon.translatesAutoresizingMaskIntoConstraints = false
128
- NSLayoutConstraint.activate([
129
- iconHolder.widthAnchor.constraint(equalToConstant: 32),
130
- iconHolder.heightAnchor.constraint(equalToConstant: 32),
131
- icon.centerXAnchor.constraint(equalTo: iconHolder.centerXAnchor),
132
- icon.centerYAnchor.constraint(equalTo: iconHolder.centerYAnchor),
133
- icon.widthAnchor.constraint(equalToConstant: EditorPanelMetrics.iconSize),
134
- icon.heightAnchor.constraint(equalToConstant: EditorPanelMetrics.iconSize),
135
- ])
203
+ init(control: EditorStripItemControl) {
204
+ self.control = control
205
+ }
206
+
207
+ func setSelected(_ selected: Bool, accentColor: UIColor) {
208
+ control.setSelected(selected, accentColor: accentColor)
209
+ }
210
+
211
+ func setModified(_ modified: Bool, accentColor: UIColor) {
212
+ control.setModified(modified, accentColor: accentColor)
213
+ }
214
+ }
136
215
 
216
+ func editorStripItem(systemName: String, labelText: String, action: @escaping () -> Void) -> EditorStripItem {
217
+ let control = EditorStripItemControl(systemName: systemName, labelText: labelText, action: action)
218
+ return EditorStripItem(control: control)
219
+ }
220
+
221
+ /// Interactive filter thumbnail button with native touch actions and selection border.
222
+ final class EditorFilterThumbnailControl: UIButton {
223
+ let image = UIImageView()
224
+ let frameView = UIView()
137
225
  let label = UILabel()
138
- label.text = labelText
139
- label.font = .systemFont(ofSize: 11, weight: .medium)
140
- label.textColor = DesignTokens.textSecondary
141
- label.textAlignment = .center
142
- // Labels like "Brightness" and "Saturation" must never wrap to a second line.
143
- label.numberOfLines = 1
144
- label.adjustsFontSizeToFitWidth = true
145
- label.minimumScaleFactor = 0.85
226
+ private var action: (() -> Void)?
146
227
 
147
- let dot = UIView()
148
- dot.layer.cornerRadius = 2
149
- dot.isHidden = true
150
- dot.widthAnchor.constraint(equalToConstant: 4).isActive = true
151
- dot.heightAnchor.constraint(equalToConstant: 4).isActive = true
152
-
153
- let column = UIStackView(arrangedSubviews: [iconHolder, label, dot])
154
- column.axis = .vertical
155
- column.alignment = .center
156
- column.spacing = DesignTokens.spaceXs
157
-
158
- let root = UIView()
159
- root.addSubview(column)
160
- column.translatesAutoresizingMaskIntoConstraints = false
161
- NSLayoutConstraint.activate([
162
- root.widthAnchor.constraint(equalToConstant: EditorPanelMetrics.stripItemWidth),
163
- column.centerXAnchor.constraint(equalTo: root.centerXAnchor),
164
- column.centerYAnchor.constraint(equalTo: root.centerYAnchor),
165
- column.leadingAnchor.constraint(greaterThanOrEqualTo: root.leadingAnchor),
166
- column.trailingAnchor.constraint(lessThanOrEqualTo: root.trailingAnchor),
167
- ])
168
- root.isAccessibilityElement = true
169
- root.accessibilityLabel = labelText
170
- root.accessibilityTraits = .button
171
- root.addGestureRecognizer(ClosureTapGestureRecognizer(action: action))
172
- root.applyPressScale()
173
- return EditorStripItem(root: root, iconHolder: iconHolder, icon: icon, label: label, dot: dot)
228
+ init(labelText: String, action: @escaping () -> Void) {
229
+ self.action = action
230
+ super.init(frame: .zero)
231
+ setupViews(labelText: labelText)
232
+ }
233
+
234
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
235
+
236
+ private func setupViews(labelText: String) {
237
+ isAccessibilityElement = true
238
+ accessibilityLabel = labelText
239
+ accessibilityTraits = .button
240
+
241
+ image.contentMode = .scaleAspectFill
242
+ image.clipsToBounds = true
243
+ image.layer.cornerRadius = DesignTokens.radiusMd
244
+ image.backgroundColor = DesignTokens.surfaceContainerHigh
245
+ image.isUserInteractionEnabled = false
246
+
247
+ frameView.layer.cornerRadius = DesignTokens.radiusMd + 2
248
+ frameView.layer.borderWidth = 2
249
+ frameView.layer.borderColor = UIColor.clear.cgColor
250
+ frameView.isUserInteractionEnabled = false
251
+
252
+ frameView.addSubview(image)
253
+ image.translatesAutoresizingMaskIntoConstraints = false
254
+ NSLayoutConstraint.activate([
255
+ image.widthAnchor.constraint(equalToConstant: EditorPanelMetrics.thumbnailWidth),
256
+ image.heightAnchor.constraint(equalToConstant: EditorPanelMetrics.thumbnailHeight),
257
+ image.leadingAnchor.constraint(equalTo: frameView.leadingAnchor, constant: 2),
258
+ image.trailingAnchor.constraint(equalTo: frameView.trailingAnchor, constant: -2),
259
+ image.topAnchor.constraint(equalTo: frameView.topAnchor, constant: 2),
260
+ image.bottomAnchor.constraint(equalTo: frameView.bottomAnchor, constant: -2),
261
+ ])
262
+
263
+ label.text = labelText
264
+ label.font = .systemFont(ofSize: 11, weight: .medium)
265
+ label.textColor = DesignTokens.textSecondary
266
+ label.textAlignment = .center
267
+ label.numberOfLines = 1
268
+ label.adjustsFontSizeToFitWidth = true
269
+ label.minimumScaleFactor = 0.8
270
+ label.isUserInteractionEnabled = false
271
+
272
+ let column = UIStackView(arrangedSubviews: [frameView, label])
273
+ column.axis = .vertical
274
+ column.alignment = .center
275
+ column.spacing = DesignTokens.spaceXs
276
+ column.isUserInteractionEnabled = false
277
+
278
+ addSubview(column)
279
+ column.translatesAutoresizingMaskIntoConstraints = false
280
+ NSLayoutConstraint.activate([
281
+ widthAnchor.constraint(equalToConstant: EditorPanelMetrics.thumbnailWidth + 12),
282
+ column.centerXAnchor.constraint(equalTo: centerXAnchor),
283
+ column.centerYAnchor.constraint(equalTo: centerYAnchor),
284
+ label.widthAnchor.constraint(equalTo: widthAnchor),
285
+ ])
286
+
287
+ addAction(UIAction { [weak self] _ in self?.action?() }, for: .touchUpInside)
288
+ applyPressScale()
289
+ }
290
+
291
+ override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
292
+ return bounds.insetBy(dx: -8, dy: -8).contains(point)
293
+ }
294
+
295
+ func setSelected(_ selected: Bool, accentColor: UIColor) {
296
+ frameView.layer.borderColor = (selected ? accentColor : UIColor.clear).cgColor
297
+ frameView.layer.borderWidth = 2
298
+ label.textColor = selected ? accentColor : DesignTokens.textSecondary
299
+ accessibilityTraits = selected ? [.button, .selected] : .button
300
+ }
174
301
  }
175
302
 
176
303
  /// Filter thumbnail: a preview of the current photo, outlined in purple when selected.
177
- /// A thin outline rather than a filled container, so the photo stays readable.
178
304
  final class EditorFilterThumbnail {
179
- let root: UIView
180
- let image: UIImageView
181
- private let frame: UIView
182
- private let label: UILabel
183
-
184
- init(root: UIView, image: UIImageView, frame: UIView, label: UILabel) {
185
- self.root = root
186
- self.image = image
187
- self.frame = frame
188
- self.label = label
305
+ let control: EditorFilterThumbnailControl
306
+ var root: UIView { control }
307
+ var image: UIImageView { control.image }
308
+
309
+ init(control: EditorFilterThumbnailControl) {
310
+ self.control = control
189
311
  }
190
312
 
191
313
  func setSelected(_ selected: Bool, accentColor: UIColor) {
192
- frame.layer.borderColor = (selected ? accentColor : UIColor.clear).cgColor
193
- frame.layer.borderWidth = 2
194
- label.textColor = selected ? accentColor : DesignTokens.textSecondary
314
+ control.setSelected(selected, accentColor: accentColor)
195
315
  }
196
316
  }
197
317
 
198
318
  func editorFilterThumbnail(labelText: String, action: @escaping () -> Void) -> EditorFilterThumbnail {
199
- let image = UIImageView()
200
- image.contentMode = .scaleAspectFill
201
- image.clipsToBounds = true
202
- image.layer.cornerRadius = DesignTokens.radiusMd
203
- image.backgroundColor = DesignTokens.surfaceContainerHigh
204
-
205
- // The outline lives on a frame *around* the image so the stroke never crops the preview.
206
- let frame = UIView()
207
- frame.layer.cornerRadius = DesignTokens.radiusMd + 2
208
- frame.addSubview(image)
209
- image.translatesAutoresizingMaskIntoConstraints = false
210
- NSLayoutConstraint.activate([
211
- image.widthAnchor.constraint(equalToConstant: EditorPanelMetrics.thumbnailWidth),
212
- image.heightAnchor.constraint(equalToConstant: EditorPanelMetrics.thumbnailHeight),
213
- image.leadingAnchor.constraint(equalTo: frame.leadingAnchor, constant: 2),
214
- image.trailingAnchor.constraint(equalTo: frame.trailingAnchor, constant: -2),
215
- image.topAnchor.constraint(equalTo: frame.topAnchor, constant: 2),
216
- image.bottomAnchor.constraint(equalTo: frame.bottomAnchor, constant: -2),
217
- ])
218
-
219
- let label = UILabel()
220
- label.text = labelText
221
- label.font = .systemFont(ofSize: 11, weight: .medium)
222
- label.textColor = DesignTokens.textSecondary
223
- label.textAlignment = .center
224
- label.numberOfLines = 1
225
- label.adjustsFontSizeToFitWidth = true
226
- label.minimumScaleFactor = 0.8
227
-
228
- let column = UIStackView(arrangedSubviews: [frame, label])
229
- column.axis = .vertical
230
- column.alignment = .center
231
- column.spacing = DesignTokens.spaceXs
232
-
233
- let root = UIView()
234
- root.addSubview(column)
235
- column.translatesAutoresizingMaskIntoConstraints = false
236
- NSLayoutConstraint.activate([
237
- root.widthAnchor.constraint(equalToConstant: EditorPanelMetrics.thumbnailWidth + 12),
238
- column.centerXAnchor.constraint(equalTo: root.centerXAnchor),
239
- column.centerYAnchor.constraint(equalTo: root.centerYAnchor),
240
- label.widthAnchor.constraint(equalTo: root.widthAnchor),
241
- ])
242
- root.isAccessibilityElement = true
243
- root.accessibilityLabel = labelText
244
- root.accessibilityTraits = .button
245
- root.addGestureRecognizer(ClosureTapGestureRecognizer(action: action))
246
- root.applyPressScale()
247
- return EditorFilterThumbnail(root: root, image: image, frame: frame, label: label)
319
+ let control = EditorFilterThumbnailControl(labelText: labelText, action: action)
320
+ return EditorFilterThumbnail(control: control)
248
321
  }
249
322
 
250
323
  /// The editor's one slider: a `Label ......... value` line above a thin track, so a panel never
@@ -7,6 +7,8 @@ final class EditorToolSlider: UISlider {
7
7
  override func trackRect(forBounds bounds: CGRect) -> CGRect {
8
8
  var rect = super.trackRect(forBounds: bounds)
9
9
  rect.origin.y = bounds.height * 0.65
10
+ rect.origin.x = bounds.origin.x + 16
11
+ rect.size.width = max(0, bounds.width - 32)
10
12
  return rect
11
13
  }
12
14
  override func draw(_ rect: CGRect) {
@@ -15,8 +17,8 @@ final class EditorToolSlider: UISlider {
15
17
  let suffix = propertyKey == "rotation" ? "°" : (propertyKey == "fontSize" ? "" : "%")
16
18
  let valueText = "\(Int(value.rounded()))\(suffix)"
17
19
  let attributes: [NSAttributedString.Key: Any] = [.font: UIFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium), .foregroundColor: DesignTokens.textSecondary]
18
- (title as NSString).draw(at: CGPoint(x: 4, y: 4), withAttributes: attributes)
20
+ (title as NSString).draw(at: CGPoint(x: 16, y: 8), withAttributes: attributes)
19
21
  let size = (valueText as NSString).size(withAttributes: attributes)
20
- (valueText as NSString).draw(at: CGPoint(x: bounds.width - size.width - 4, y: 4), withAttributes: attributes)
22
+ (valueText as NSString).draw(at: CGPoint(x: bounds.width - size.width - 16, y: 8), withAttributes: attributes)
21
23
  }
22
24
  }
@@ -5,13 +5,13 @@ final class TextEditorSheet: UIViewController, UITextViewDelegate {
5
5
  private let input = UITextView()
6
6
  private let placeholder = UILabel()
7
7
  private let initialText: String
8
- private let editing: Bool
8
+ private let isEditingExisting: Bool
9
9
  private let accent: UIColor
10
10
  private let onSave: (String) -> Void
11
11
  private var inputHeight: NSLayoutConstraint?
12
12
 
13
13
  init(text: String, editing: Bool, accent: UIColor, onSave: @escaping (String) -> Void) {
14
- initialText = text; self.editing = editing; self.accent = accent; self.onSave = onSave
14
+ initialText = text; isEditingExisting = editing; self.accent = accent; self.onSave = onSave
15
15
  super.init(nibName: nil, bundle: nil)
16
16
  modalPresentationStyle = .overFullScreen
17
17
  modalTransitionStyle = .crossDissolve
@@ -26,7 +26,7 @@ final class TextEditorSheet: UIViewController, UITextViewDelegate {
26
26
  panel.layer.cornerRadius = 16
27
27
  panel.isLayoutMarginsRelativeArrangement = true
28
28
  panel.layoutMargins = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
29
- let title = UILabel(); title.text = editing ? "Edit Text" : "Add Text"
29
+ let title = UILabel(); title.text = isEditingExisting ? "Edit Text" : "Add Text"
30
30
  title.textColor = .white; title.font = .systemFont(ofSize: 20, weight: .semibold)
31
31
  panel.addArrangedSubview(title)
32
32
  input.text = initialText; input.textColor = .white; input.tintColor = accent
@@ -50,7 +50,7 @@ final class TextEditorSheet: UIViewController, UITextViewDelegate {
50
50
  let cancel = UIButton(type: .system); cancel.setTitle("Cancel", for: .normal); cancel.tintColor = .white
51
51
  cancel.addAction(UIAction { [weak self] _ in self?.close() }, for: .touchUpInside)
52
52
  let save = UIButton(type: .system)
53
- save.setTitle(editing ? "Done" : "Add", for: .normal); save.tintColor = .white
53
+ save.setTitle(isEditingExisting ? "Done" : "Add", for: .normal); save.tintColor = .white
54
54
  save.backgroundColor = accent; save.layer.cornerRadius = 12
55
55
  save.addAction(UIAction { [weak self] _ in
56
56
  guard let self, !self.input.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }