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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +11 -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
@@ -7,194 +7,152 @@ import android.graphics.Paint
7
7
  import android.graphics.RectF
8
8
  import android.view.MotionEvent
9
9
  import android.view.View
10
+ import com.photovideoeditor.photo.render.CropGeometry
11
+ import com.photovideoeditor.photo.render.PhotoTransformState
10
12
  import kotlin.math.abs
13
+ import kotlin.math.hypot
11
14
 
12
- /**
13
- * Draws a draggable/resizable crop rectangle over [imageBounds] (the on-screen
14
- * rect of the preview image, in this view's own coordinate space) and reports
15
- * the crop edges back normalized to that rect via [onCropChanged].
16
- */
15
+ /** Crop handles own resize gestures; interior drags/pinches move the media behind a fixed box. */
17
16
  class CropOverlayView(context: Context) : View(context) {
18
17
  private var imageBounds = RectF()
18
+ private var fittedBounds = RectF()
19
19
  private var cropRect = RectF()
20
20
  private var aspectRatio: Float? = null
21
- var onCropChanged: ((left: Float, top: Float, right: Float, bottom: Float) -> Unit)? = null
21
+ var onCropChanged: ((Float, Float, Float, Float) -> Unit)? = null
22
+ var onMediaBoundsChanged: ((RectF) -> Unit)? = null
23
+ var onViewportChanged: ((Float, Float, Float) -> Unit)? = null
24
+ private val density = resources.displayMetrics.density
25
+ private val dimPaint = Paint().apply { color = Color.argb(165, 0, 0, 0) }
26
+ private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE; style = Paint.Style.STROKE; strokeWidth = density }
27
+ private val gridPaint = Paint().apply { color = Color.argb(85, 255, 255, 255); strokeWidth = density * .5f }
28
+ private val handlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE; strokeWidth = 3 * density; strokeCap = Paint.Cap.ROUND; style = Paint.Style.STROKE }
29
+ private var activeHandle: CropGeometry.Handle? = null
30
+ private var lastX = 0f; private var lastY = 0f; private var lastDistance = 0f
31
+ private var movingMedia = false
22
32
 
23
- private val dimPaint = Paint().apply { color = Color.argb(153, 0, 0, 0) }
24
- private val borderPaint = Paint().apply { color = Color.WHITE; style = Paint.Style.STROKE; strokeWidth = 3f }
25
- private val gridPaint = Paint().apply { color = Color.argb(120, 255, 255, 255); style = Paint.Style.STROKE; strokeWidth = 1f }
26
- private val handlePaint = Paint().apply { color = Color.WHITE; style = Paint.Style.FILL }
27
-
28
- private val handleTouchRadius = 56f
29
- private val minSizePx = 96f
30
- private var activeHandle = Handle.NONE
31
- private var lastX = 0f
32
- private var lastY = 0f
33
-
34
- private enum class Handle { NONE, MOVE, TOP, RIGHT, BOTTOM, LEFT, TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT }
33
+ fun restore(bounds: RectF, state: PhotoTransformState) {
34
+ if (bounds.width() <= 0 || bounds.height() <= 0) return
35
+ fittedBounds = RectF(bounds)
36
+ val zoom = state.zoom.coerceIn(1f, 8f)
37
+ val cx = bounds.centerX() + state.panX * bounds.width()
38
+ val cy = bounds.centerY() + state.panY * bounds.height()
39
+ imageBounds = RectF(cx - bounds.width() * zoom / 2, cy - bounds.height() * zoom / 2,
40
+ cx + bounds.width() * zoom / 2, cy + bounds.height() * zoom / 2)
41
+ aspectRatio = state.aspectRatio
42
+ setCrop(state.cropLeft, state.cropTop, state.cropRight, state.cropBottom)
43
+ onMediaBoundsChanged?.invoke(RectF(imageBounds))
44
+ invalidate()
45
+ }
35
46
 
36
- /** Sets the on-screen image rect this overlay crops against. Resets the crop to full-frame when [resetCrop] is true. */
37
47
  fun setImageBounds(bounds: RectF, resetCrop: Boolean) {
38
- imageBounds = RectF(bounds)
39
- if (resetCrop || cropRect.width() <= 0f || cropRect.height() <= 0f) {
40
- cropRect = RectF(imageBounds)
41
- reportCrop()
42
- }
48
+ val old = normalized()
49
+ imageBounds = RectF(bounds); fittedBounds = RectF(bounds)
50
+ if (resetCrop || cropRect.isEmpty) cropRect = RectF(bounds) else setCrop(old[0], old[1], old[2], old[3])
43
51
  invalidate()
44
52
  }
45
53
 
46
- /** Restores a normalized crop rectangle, used when reopening or cancelling crop mode. */
47
54
  fun setCrop(left: Float, top: Float, right: Float, bottom: Float) {
48
- if (imageBounds.width() <= 0 || imageBounds.height() <= 0) return
49
- cropRect = RectF(
50
- imageBounds.left + left * imageBounds.width(),
51
- imageBounds.top + top * imageBounds.height(),
52
- imageBounds.left + right * imageBounds.width(),
53
- imageBounds.top + bottom * imageBounds.height()
54
- )
55
- clampToImageBounds(); invalidate()
55
+ cropRect = RectF(imageBounds.left + left * imageBounds.width(), imageBounds.top + top * imageBounds.height(),
56
+ imageBounds.left + right * imageBounds.width(), imageBounds.top + bottom * imageBounds.height())
57
+ invalidate()
56
58
  }
57
59
 
58
60
  fun setAspectRatio(ratio: Float?) {
59
61
  aspectRatio = ratio
60
- if (ratio != null && imageBounds.width() > 0 && imageBounds.height() > 0) {
61
- val centerX = cropRect.centerX()
62
- val centerY = cropRect.centerY()
63
- var width = cropRect.width()
64
- var height = width / ratio
65
- if (height > imageBounds.height()) {
66
- height = cropRect.height()
67
- width = height * ratio
68
- }
69
- cropRect = RectF(centerX - width / 2, centerY - height / 2, centerX + width / 2, centerY + height / 2)
70
- clampToImageBounds()
71
- invalidate()
72
- }
73
- reportCrop()
62
+ // A new preset starts with the largest centered rectangle in the visible media.
63
+ val available = RectF(imageBounds)
64
+ available.intersect(0f, 0f, width.toFloat(), height.toFloat())
65
+ cropRect = CropGeometry.fit(available.geometry(), ratio).native()
66
+ reportCrop(); invalidate()
74
67
  }
75
68
 
76
69
  override fun onDraw(canvas: Canvas) {
77
- super.onDraw(canvas)
78
- if (imageBounds.width() <= 0 || imageBounds.height() <= 0) return
79
- canvas.drawRect(imageBounds.left, imageBounds.top, imageBounds.right, cropRect.top, dimPaint)
80
- canvas.drawRect(imageBounds.left, cropRect.bottom, imageBounds.right, imageBounds.bottom, dimPaint)
81
- canvas.drawRect(imageBounds.left, cropRect.top, cropRect.left, cropRect.bottom, dimPaint)
82
- canvas.drawRect(cropRect.right, cropRect.top, imageBounds.right, cropRect.bottom, dimPaint)
70
+ if (cropRect.isEmpty) return
71
+ canvas.drawRect(0f, 0f, width.toFloat(), cropRect.top, dimPaint)
72
+ canvas.drawRect(0f, cropRect.bottom, width.toFloat(), height.toFloat(), dimPaint)
73
+ canvas.drawRect(0f, cropRect.top, cropRect.left, cropRect.bottom, dimPaint)
74
+ canvas.drawRect(cropRect.right, cropRect.top, width.toFloat(), cropRect.bottom, dimPaint)
83
75
  canvas.drawRect(cropRect, borderPaint)
84
- val thirdWidth = cropRect.width() / 3
85
- val thirdHeight = cropRect.height() / 3
86
76
  for (i in 1..2) {
87
- canvas.drawLine(cropRect.left + thirdWidth * i, cropRect.top, cropRect.left + thirdWidth * i, cropRect.bottom, gridPaint)
88
- canvas.drawLine(cropRect.left, cropRect.top + thirdHeight * i, cropRect.right, cropRect.top + thirdHeight * i, gridPaint)
77
+ val x = cropRect.left + cropRect.width() * i / 3; val y = cropRect.top + cropRect.height() * i / 3
78
+ canvas.drawLine(x, cropRect.top, x, cropRect.bottom, gridPaint)
79
+ canvas.drawLine(cropRect.left, y, cropRect.right, y, gridPaint)
80
+ }
81
+ val corner = 14 * density
82
+ for ((x, y) in listOf(cropRect.left to cropRect.top, cropRect.right to cropRect.top, cropRect.left to cropRect.bottom, cropRect.right to cropRect.bottom)) {
83
+ canvas.drawLine(x, y, x + if (x == cropRect.left) corner else -corner, y, handlePaint)
84
+ canvas.drawLine(x, y, x, y + if (y == cropRect.top) corner else -corner, handlePaint)
89
85
  }
90
- listOf(
91
- cropRect.left to cropRect.top,
92
- cropRect.centerX() to cropRect.top,
93
- cropRect.right to cropRect.top,
94
- cropRect.right to cropRect.centerY(),
95
- cropRect.right to cropRect.bottom,
96
- cropRect.centerX() to cropRect.bottom,
97
- cropRect.left to cropRect.bottom,
98
- cropRect.left to cropRect.centerY()
99
- ).forEach { (x, y) -> canvas.drawCircle(x, y, 8f, handlePaint) }
86
+ val edge = 8 * density
87
+ for (y in listOf(cropRect.top, cropRect.bottom)) canvas.drawLine(cropRect.centerX() - edge, y, cropRect.centerX() + edge, y, handlePaint)
88
+ for (x in listOf(cropRect.left, cropRect.right)) canvas.drawLine(x, cropRect.centerY() - edge, x, cropRect.centerY() + edge, handlePaint)
100
89
  }
101
90
 
102
91
  override fun onTouchEvent(event: MotionEvent): Boolean {
92
+ if (imageBounds.isEmpty) return false
103
93
  when (event.actionMasked) {
104
94
  MotionEvent.ACTION_DOWN -> {
105
- activeHandle = hitTest(event.x, event.y)
106
- lastX = event.x
107
- lastY = event.y
108
- return activeHandle != Handle.NONE
95
+ activeHandle = hitTest(event.x, event.y); movingMedia = activeHandle == null
96
+ lastX = event.x; lastY = event.y; lastDistance = 0f
97
+ parent?.requestDisallowInterceptTouchEvent(true)
98
+ }
99
+ MotionEvent.ACTION_POINTER_DOWN -> {
100
+ activeHandle = null; movingMedia = true
101
+ lastX = (event.getX(0) + event.getX(1)) / 2; lastY = (event.getY(0) + event.getY(1)) / 2
102
+ lastDistance = hypot(event.getX(0) - event.getX(1), event.getY(0) - event.getY(1))
109
103
  }
110
104
  MotionEvent.ACTION_MOVE -> {
111
- if (activeHandle == Handle.NONE) return false
112
- val dx = event.x - lastX
113
- val dy = event.y - lastY
114
- lastX = event.x
115
- lastY = event.y
116
- applyDrag(activeHandle, dx, dy)
117
- invalidate()
118
- reportCrop()
119
- return true
105
+ val multi = event.pointerCount > 1
106
+ val x = if (multi) (event.getX(0) + event.getX(1)) / 2 else event.x
107
+ val y = if (multi) (event.getY(0) + event.getY(1)) / 2 else event.y
108
+ val distance = if (multi) hypot(event.getX(0) - event.getX(1), event.getY(0) - event.getY(1)) else 0f
109
+ val handle = activeHandle
110
+ if (handle != null) {
111
+ val valid = RectF(imageBounds).apply { intersect(0f, 0f, width.toFloat(), height.toFloat()) }
112
+ cropRect = CropGeometry.resize(cropRect.geometry(), valid.geometry(), handle, x - lastX, y - lastY, aspectRatio, 48 * density).native()
113
+ } else if (movingMedia) {
114
+ moveMedia(x - lastX, y - lastY, if (lastDistance > 0 && distance > 0) distance / lastDistance else 1f, x, y)
115
+ }
116
+ lastX = x; lastY = y; lastDistance = distance
117
+ reportCrop(); invalidate()
120
118
  }
121
- MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
122
- val handled = activeHandle != Handle.NONE
123
- activeHandle = Handle.NONE
124
- return handled
119
+ MotionEvent.ACTION_POINTER_UP -> {
120
+ val index = if (event.actionIndex == 0) 1 else 0
121
+ lastX = event.getX(index); lastY = event.getY(index); lastDistance = 0f
125
122
  }
126
- }
127
- return super.onTouchEvent(event)
128
- }
129
-
130
- private fun hitTest(x: Float, y: Float): Handle {
131
- fun near(px: Float, py: Float) = abs(x - px) < handleTouchRadius && abs(y - py) < handleTouchRadius
132
- return when {
133
- near(cropRect.left, cropRect.top) -> Handle.TOP_LEFT
134
- near(cropRect.right, cropRect.top) -> Handle.TOP_RIGHT
135
- near(cropRect.left, cropRect.bottom) -> Handle.BOTTOM_LEFT
136
- near(cropRect.right, cropRect.bottom) -> Handle.BOTTOM_RIGHT
137
- near(cropRect.centerX(), cropRect.top) -> Handle.TOP
138
- near(cropRect.right, cropRect.centerY()) -> Handle.RIGHT
139
- near(cropRect.centerX(), cropRect.bottom) -> Handle.BOTTOM
140
- near(cropRect.left, cropRect.centerY()) -> Handle.LEFT
141
- cropRect.contains(x, y) -> Handle.MOVE
142
- else -> Handle.NONE
143
- }
144
- }
145
-
146
- private fun applyDrag(handle: Handle, dx: Float, dy: Float) {
147
- val proposed = RectF(cropRect)
148
- when (handle) {
149
- Handle.MOVE -> proposed.offset(dx, dy)
150
- Handle.TOP_LEFT -> { proposed.left += dx; proposed.top += dy }
151
- Handle.TOP_RIGHT -> { proposed.right += dx; proposed.top += dy }
152
- Handle.BOTTOM_LEFT -> { proposed.left += dx; proposed.bottom += dy }
153
- Handle.BOTTOM_RIGHT -> { proposed.right += dx; proposed.bottom += dy }
154
- Handle.TOP -> proposed.top += dy
155
- Handle.RIGHT -> proposed.right += dx
156
- Handle.BOTTOM -> proposed.bottom += dy
157
- Handle.LEFT -> proposed.left += dx
158
- Handle.NONE -> return
159
- }
160
- if (proposed.width() < minSizePx || proposed.height() < minSizePx) return
161
- aspectRatio?.let { ratio ->
162
- when (handle) {
163
- Handle.TOP_LEFT, Handle.BOTTOM_RIGHT -> proposed.bottom = proposed.top + proposed.width() / ratio
164
- Handle.TOP_RIGHT -> proposed.top = proposed.bottom - proposed.width() / ratio
165
- Handle.BOTTOM_LEFT -> proposed.bottom = proposed.top + proposed.width() / ratio
166
- Handle.LEFT, Handle.RIGHT -> {
167
- val centerY = cropRect.centerY(); val height = proposed.width() / ratio
168
- proposed.top = centerY - height / 2; proposed.bottom = centerY + height / 2
169
- }
170
- Handle.TOP, Handle.BOTTOM -> {
171
- val centerX = cropRect.centerX(); val width = proposed.height() * ratio
172
- proposed.left = centerX - width / 2; proposed.right = centerX + width / 2
173
- }
174
- else -> {}
123
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
124
+ activeHandle = null; movingMedia = false; lastDistance = 0f
125
+ parent?.requestDisallowInterceptTouchEvent(false)
175
126
  }
176
127
  }
177
- cropRect = proposed
178
- clampToImageBounds()
128
+ return true
179
129
  }
180
130
 
181
- private fun clampToImageBounds() {
182
- if (cropRect.left < imageBounds.left) cropRect.offset(imageBounds.left - cropRect.left, 0f)
183
- if (cropRect.top < imageBounds.top) cropRect.offset(0f, imageBounds.top - cropRect.top)
184
- if (cropRect.right > imageBounds.right) cropRect.offset(imageBounds.right - cropRect.right, 0f)
185
- if (cropRect.bottom > imageBounds.bottom) cropRect.offset(0f, imageBounds.bottom - cropRect.bottom)
186
- cropRect.left = cropRect.left.coerceAtLeast(imageBounds.left)
187
- cropRect.top = cropRect.top.coerceAtLeast(imageBounds.top)
188
- cropRect.right = cropRect.right.coerceAtMost(imageBounds.right)
189
- cropRect.bottom = cropRect.bottom.coerceAtMost(imageBounds.bottom)
131
+ private fun moveMedia(dx: Float, dy: Float, factor: Float, focusX: Float, focusY: Float) {
132
+ val minimumZoom = maxOf(1f, cropRect.width() / fittedBounds.width(), cropRect.height() / fittedBounds.height())
133
+ val zoom = imageBounds.width() / fittedBounds.width()
134
+ val scale = (zoom * factor).coerceIn(minimumZoom, maxOf(8f, minimumZoom)) / zoom
135
+ val w = imageBounds.width() * scale; val h = imageBounds.height() * scale
136
+ val left = (focusX + (imageBounds.left - focusX) * scale + dx).coerceIn(cropRect.right - w, cropRect.left)
137
+ val top = (focusY + (imageBounds.top - focusY) * scale + dy).coerceIn(cropRect.bottom - h, cropRect.top)
138
+ imageBounds = RectF(left, top, left + w, top + h)
139
+ onMediaBoundsChanged?.invoke(RectF(imageBounds))
140
+ onViewportChanged?.invoke(w / fittedBounds.width(), (imageBounds.centerX() - fittedBounds.centerX()) / fittedBounds.width(), (imageBounds.centerY() - fittedBounds.centerY()) / fittedBounds.height())
190
141
  }
191
142
 
192
- private fun reportCrop() {
193
- if (imageBounds.width() <= 0 || imageBounds.height() <= 0) return
194
- val left = (cropRect.left - imageBounds.left) / imageBounds.width()
195
- val top = (cropRect.top - imageBounds.top) / imageBounds.height()
196
- val right = (cropRect.right - imageBounds.left) / imageBounds.width()
197
- val bottom = (cropRect.bottom - imageBounds.top) / imageBounds.height()
198
- onCropChanged?.invoke(left, top, right, bottom)
143
+ private fun hitTest(x: Float, y: Float): CropGeometry.Handle? {
144
+ val points = listOf(
145
+ Triple(cropRect.left, cropRect.top, CropGeometry.Handle.TOP_LEFT), Triple(cropRect.right, cropRect.top, CropGeometry.Handle.TOP_RIGHT),
146
+ Triple(cropRect.left, cropRect.bottom, CropGeometry.Handle.BOTTOM_LEFT), Triple(cropRect.right, cropRect.bottom, CropGeometry.Handle.BOTTOM_RIGHT),
147
+ Triple(cropRect.centerX(), cropRect.top, CropGeometry.Handle.TOP), Triple(cropRect.right, cropRect.centerY(), CropGeometry.Handle.RIGHT),
148
+ Triple(cropRect.centerX(), cropRect.bottom, CropGeometry.Handle.BOTTOM), Triple(cropRect.left, cropRect.centerY(), CropGeometry.Handle.LEFT))
149
+ val nearest = points.minByOrNull { hypot(x - it.first, y - it.second) } ?: return null
150
+ return nearest.third.takeIf { hypot(x - nearest.first, y - nearest.second) <= 24 * density }
199
151
  }
152
+ private fun normalized(): FloatArray = if (imageBounds.isEmpty) floatArrayOf(0f, 0f, 1f, 1f) else floatArrayOf(
153
+ (cropRect.left - imageBounds.left) / imageBounds.width(), (cropRect.top - imageBounds.top) / imageBounds.height(),
154
+ (cropRect.right - imageBounds.left) / imageBounds.width(), (cropRect.bottom - imageBounds.top) / imageBounds.height())
155
+ private fun reportCrop() { val r = normalized(); onCropChanged?.invoke(r[0].coerceIn(0f, 1f), r[1].coerceIn(0f, 1f), r[2].coerceIn(0f, 1f), r[3].coerceIn(0f, 1f)) }
156
+ private fun RectF.geometry() = CropGeometry.Rect(left, top, right, bottom)
157
+ private fun CropGeometry.Rect.native() = RectF(left, top, right, bottom)
200
158
  }
@@ -3,12 +3,13 @@ package com.photovideoeditor.photo.ui
3
3
  import android.content.Context
4
4
  import android.graphics.Canvas
5
5
  import android.graphics.Color
6
- import android.graphics.DashPathEffect
7
6
  import android.graphics.Paint
8
7
  import android.graphics.RectF
9
8
  import android.view.MotionEvent
10
9
  import android.view.View
11
10
  import android.text.TextPaint
11
+ import com.photovideoeditor.R
12
+ import com.photovideoeditor.photo.render.OverlayGeometry
12
13
  import com.photovideoeditor.photo.render.PhotoLayer
13
14
  import com.photovideoeditor.photo.render.LayerType
14
15
  import kotlin.math.atan2
@@ -27,6 +28,7 @@ class LayerOverlayView(context: Context) : View(context) {
27
28
  set(value) { field = value; invalidate() }
28
29
  var selectedLayerId: String? = null
29
30
  set(value) { field = value; invalidate() }
31
+ var onLayerDelete: ((String) -> Unit)? = null
30
32
  var onLayerTapped: ((String?) -> Unit)? = null
31
33
  var onLayerDoubleTapped: ((String) -> Unit)? = null
32
34
  var onLayerTransformChanged: ((id: String, x: Float, y: Float, scale: Float, rotationDegrees: Float) -> Unit)? = null
@@ -48,13 +50,10 @@ class LayerOverlayView(context: Context) : View(context) {
48
50
  private val handleRadius = 11f * density
49
51
  private val handleTouchRadius = 30f * density
50
52
  private val selectionPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
51
- color = Color.WHITE; style = Paint.Style.STROKE; strokeWidth = 2f * density
52
- pathEffect = DashPathEffect(floatArrayOf(8f * density, 5f * density), 0f)
53
+ color = Color.WHITE; style = Paint.Style.STROKE; strokeWidth = 1f * density
53
54
  }
54
55
  private val handlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE }
55
- private val handleGlyphPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
56
- color = Color.rgb(87, 55, 245); style = Paint.Style.STROKE; strokeWidth = 2f * density
57
- }
56
+
58
57
 
59
58
  fun setImageBounds(bounds: RectF) { imageBounds = RectF(bounds); invalidate() }
60
59
 
@@ -67,17 +66,36 @@ class LayerOverlayView(context: Context) : View(context) {
67
66
  canvas.translate(center.first, center.second)
68
67
  canvas.rotate(selected.rotationDegrees)
69
68
  canvas.drawRect(-half.first, -half.second, half.first, half.second, selectionPaint)
70
- canvas.drawCircle(half.first, half.second, handleRadius, handlePaint)
71
- val glyph = handleRadius * 0.52f
72
- canvas.drawLine(half.first - glyph, half.second + glyph, half.first + glyph, half.second - glyph, handleGlyphPaint)
69
+ for ((x, y) in listOf(Pair(-half.first, -half.second), Pair(half.first, -half.second), Pair(-half.first, half.second), Pair(half.first, half.second))) {
70
+ canvas.drawCircle(x, y, 4f * density, handlePaint)
71
+ }
72
+ drawControl(canvas, -half.first, -half.second, R.drawable.ic_close, Color.rgb(255, 100, 115))
73
+ drawControl(canvas, half.first, half.second, R.drawable.ic_fullscreen, Color.rgb(167, 139, 250))
73
74
  canvas.restore()
74
75
  }
75
76
 
77
+ private fun drawControl(canvas: Canvas, x: Float, y: Float, icon: Int, tint: Int) {
78
+ canvas.drawCircle(x, y, handleRadius, handlePaint)
79
+ context.getDrawable(icon)?.mutate()?.let { drawable ->
80
+ drawable.setTint(tint)
81
+ val size = (handleRadius * 0.7f).toInt()
82
+ drawable.setBounds(x.toInt() - size, y.toInt() - size, x.toInt() + size, y.toInt() + size)
83
+ drawable.draw(canvas)
84
+ }
85
+ }
86
+
76
87
  override fun onTouchEvent(event: MotionEvent): Boolean {
77
88
  if (imageBounds.width() <= 0 || imageBounds.height() <= 0) return false
78
89
  when (event.actionMasked) {
79
90
  MotionEvent.ACTION_DOWN -> {
80
91
  val selected = selectedLayer()
92
+ if (selected != null && !selected.locked) {
93
+ val handle = handlePoint(selected)
94
+ val center = centerOf(selected)
95
+ if (hypot(event.x - (2 * center.first - handle.first), event.y - (2 * center.second - handle.second)) <= handleTouchRadius) {
96
+ onLayerDelete?.invoke(selected.id); resetGesture(); return true
97
+ }
98
+ }
81
99
  val handleHit = selected?.takeIf { !it.locked && isHandleHit(it, event.x, event.y) }
82
100
  val hit = handleHit ?: hitTest(event.x, event.y)
83
101
  onLayerTapped?.invoke(hit?.id)
@@ -102,6 +120,7 @@ class LayerOverlayView(context: Context) : View(context) {
102
120
  val metrics = multiMetrics(event)
103
121
  startDistance = metrics.distance.coerceAtLeast(1f); startAngle = metrics.angle
104
122
  startMidX = metrics.midX; startMidY = metrics.midY
123
+ movedDuringGesture = true
105
124
  gesture = Gesture.MULTI
106
125
  return true
107
126
  }
@@ -143,7 +162,17 @@ class LayerOverlayView(context: Context) : View(context) {
143
162
  }
144
163
  finishGesture(); return true
145
164
  }
146
- MotionEvent.ACTION_POINTER_UP -> return true
165
+ MotionEvent.ACTION_POINTER_UP -> {
166
+ // Rebase to the remaining finger so lifting a pinch finger does not jump/freeze the layer.
167
+ val remaining = if (event.actionIndex == 0) 1 else 0
168
+ if (remaining < event.pointerCount) {
169
+ startLayer = currentGestureLayer()?.copy()
170
+ startPointX = event.getX(remaining); startPointY = event.getY(remaining)
171
+ gesture = Gesture.DRAG
172
+ }
173
+ movedDuringGesture = true
174
+ return true
175
+ }
147
176
  }
148
177
  return false
149
178
  }
@@ -173,8 +202,8 @@ class LayerOverlayView(context: Context) : View(context) {
173
202
  Pair((lines.maxOfOrNull { paint.measureText(it) } ?: paint.textSize) / 2f + 8f * density, paint.fontSpacing * lines.size / 2f + 6f * density)
174
203
  }
175
204
  LayerType.STICKER -> {
176
- val size = shortSide * 0.18f
177
- Pair(size / 2f, size / 2f)
205
+ val (width, height) = OverlayGeometry.stickerSize(shortSide, layer.overlayAspectRatio ?: 1f)
206
+ Pair(width / 2f + 4f * density, height / 2f + 4f * density)
178
207
  }
179
208
  LayerType.OVERLAY -> {
180
209
  val aspect = layer.overlayAspectRatio ?: 1f
@@ -187,7 +216,7 @@ class LayerOverlayView(context: Context) : View(context) {
187
216
  Pair(maxX * imageBounds.width() + handleRadius, maxY * imageBounds.height() + handleRadius)
188
217
  }
189
218
  }
190
- return Pair((base.first * layer.scale).coerceAtLeast(handleRadius), (base.second * layer.scale).coerceAtLeast(handleRadius))
219
+ return Pair((base.first * layer.scale).coerceAtLeast(24f * density), (base.second * layer.scale).coerceAtLeast(24f * density))
191
220
  }
192
221
 
193
222
  private fun handlePoint(layer: PhotoLayer): Pair<Float, Float> {
@@ -59,6 +59,16 @@ class ZoomableImageView(context: Context) : ImageView(context) {
59
59
  onBoundsChanged?.invoke(currentImageBounds())
60
60
  }
61
61
 
62
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
63
+ super.onSizeChanged(w, h, oldw, oldh)
64
+ if (w != oldw || h != oldh) resetToFit()
65
+ }
66
+
67
+ fun setRenderedBounds(bounds: RectF) {
68
+ val image = drawable ?: return
69
+ imageMatrix = Matrix().apply { setRectToRect(RectF(0f, 0f, image.intrinsicWidth.toFloat(), image.intrinsicHeight.toFloat()), bounds, Matrix.ScaleToFit.FILL) }
70
+ }
71
+
62
72
  fun currentImageBounds(): RectF {
63
73
  val bitmapDrawable = drawable ?: return RectF(0f, 0f, width.toFloat(), height.toFloat())
64
74
  val rect = RectF(0f, 0f, bitmapDrawable.intrinsicWidth.toFloat(), bitmapDrawable.intrinsicHeight.toFloat())
@@ -0,0 +1,122 @@
1
+ package com.photovideoeditor.ui.components
2
+
3
+ import android.content.Context
4
+ import android.graphics.Canvas
5
+ import android.graphics.Color
6
+ import android.graphics.Paint
7
+ import android.graphics.drawable.GradientDrawable
8
+ import android.view.Gravity
9
+ import android.widget.*
10
+ import com.photovideoeditor.R
11
+ import com.photovideoeditor.photo.render.PhotoTransformState
12
+ import com.photovideoeditor.ui.DesignTokens
13
+ import kotlin.math.roundToInt
14
+
15
+ /** Shared photo/video crop surface. Only UI state lives here; the controller owns the draft. */
16
+ class CropPanel(
17
+ context: Context,
18
+ private val accent: Int,
19
+ surface: Int,
20
+ textColor: Int,
21
+ originalRatio: () -> Float,
22
+ onReset: () -> Unit,
23
+ onDone: () -> Unit,
24
+ onCancel: () -> Unit,
25
+ onStraighten: (Float) -> Unit,
26
+ onRotate: () -> Unit,
27
+ onRatio: (String, Float?) -> Unit
28
+ ) : LinearLayout(context) {
29
+ private fun dp(value: Int) = dpToPx(value, context)
30
+ private val valueLabel = TextView(context)
31
+ private val slider: SeekBar
32
+ private val presets = mutableMapOf<String, LinearLayout>()
33
+ private var syncing = false
34
+
35
+ init {
36
+ orientation = VERTICAL
37
+ setPadding(dp(12), dp(8), dp(12), dp(8))
38
+ background = GradientDrawable().apply {
39
+ setColor(surface); cornerRadii = floatArrayOf(dp(20).toFloat(), dp(20).toFloat(), dp(20).toFloat(), dp(20).toFloat(), 0f, 0f, 0f, 0f)
40
+ }
41
+ fun icon(resource: Int, label: String, action: () -> Unit) = ImageButton(context).apply {
42
+ setImageResource(resource); setColorFilter(textColor); contentDescription = label
43
+ background = roundedDrawable(Color.TRANSPARENT, 12, context)
44
+ setOnClickListener { action() }; applyPressScale()
45
+ }
46
+ val header = LinearLayout(context).apply { gravity = Gravity.CENTER_VERTICAL }
47
+ val reset = LinearLayout(context).apply {
48
+ gravity = Gravity.CENTER_VERTICAL; isClickable = true; contentDescription = "Reset crop"
49
+ addView(ImageView(context).apply { setImageResource(R.drawable.ic_undo); setColorFilter(textColor) }, LayoutParams(dp(20), dp(20)))
50
+ addView(TextView(context).apply { text = "Reset"; textSize = 13f; setTextColor(textColor); setPadding(dp(6), 0, 0, 0) })
51
+ setOnClickListener { onReset() }; applyPressScale()
52
+ }
53
+ header.addView(reset, LayoutParams(dp(88), dp(48)))
54
+ header.addView(TextView(context).apply { text = "Crop"; textSize = 17f; gravity = Gravity.CENTER; setTextColor(textColor); setTypeface(typeface, 1) }, LayoutParams(0, dp(48), 1f))
55
+ header.addView(icon(R.drawable.ic_close, "Cancel crop", onCancel), LayoutParams(dp(44), dp(48)))
56
+ header.addView(icon(R.drawable.ic_check, "Apply crop", onDone).apply { setColorFilter(accent) }, LayoutParams(dp(44), dp(48)))
57
+ addView(header)
58
+ val labels = LinearLayout(context).apply { gravity = Gravity.CENTER_VERTICAL }
59
+ labels.addView(TextView(context).apply { text = "Straighten"; textSize = 13f; setTextColor(textColor) }, LayoutParams(0, dp(44), 1f))
60
+ valueLabel.setTextColor(accent); valueLabel.textSize = 13f; valueLabel.gravity = Gravity.CENTER
61
+ labels.addView(valueLabel, LayoutParams(dp(56), dp(44)))
62
+ labels.addView(icon(R.drawable.ic_undo, "Reset straighten") { onStraighten(0f) }, LayoutParams(dp(44), dp(44)))
63
+ labels.addView(icon(R.drawable.ic_rotate_right, "Rotate 90 degrees", onRotate), LayoutParams(dp(44), dp(44)))
64
+ addView(labels)
65
+ slider = object : SeekBar(context) {
66
+ private val tick = Paint(Paint.ANTI_ALIAS_FLAG).apply { strokeWidth = resources.displayMetrics.density; color = DesignTokens.outline }
67
+ override fun onDraw(canvas: Canvas) {
68
+ val start = paddingLeft.toFloat(); val end = width - paddingRight.toFloat()
69
+ for (i in 0..90) {
70
+ val x = start + (end - start) * i / 90
71
+ tick.alpha = if (i % 5 == 0) 160 else 65
72
+ canvas.drawLine(x, 6f, x, if (i % 5 == 0) dp(12).toFloat() else dp(7).toFloat(), tick)
73
+ }
74
+ super.onDraw(canvas)
75
+ }
76
+ }.apply {
77
+ max = 900; progress = 450; contentDescription = "Straighten, minus 45 to plus 45 degrees"
78
+ progressTintList = android.content.res.ColorStateList.valueOf(accent); thumbTintList = progressTintList
79
+ setPadding(dp(12), dp(12), dp(12), 0)
80
+ setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
81
+ override fun onProgressChanged(bar: SeekBar?, progress: Int, fromUser: Boolean) { if (fromUser && !syncing) onStraighten((progress - 450) / 10f) }
82
+ override fun onStartTrackingTouch(bar: SeekBar?) {}
83
+ override fun onStopTrackingTouch(bar: SeekBar?) {}
84
+ })
85
+ }
86
+ addView(slider, LayoutParams(LayoutParams.MATCH_PARENT, dp(48)))
87
+ val rail = LinearLayout(context).apply { gravity = Gravity.CENTER_VERTICAL }
88
+ val ratios = listOf("Free" to null, "Original" to null, "1:1" to 1f, "9:16" to 9f / 16, "16:9" to 16f / 9, "10:16" to 10f / 16, "16:10" to 16f / 10, "4:5" to 4f / 5, "5:4" to 5f / 4, "3:4" to 3f / 4, "4:3" to 4f / 3)
89
+ ratios.forEach { (name, ratio) ->
90
+ val tile = LinearLayout(context).apply {
91
+ orientation = VERTICAL; gravity = Gravity.CENTER; isClickable = true; isFocusable = true; contentDescription = "$name crop ratio"
92
+ addView(object : android.view.View(context) {
93
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; strokeWidth = dp(1).toFloat(); color = textColor }
94
+ override fun onDraw(canvas: Canvas) {
95
+ paint.color = if (this@apply.isSelected) accent else textColor
96
+ val aspect = if (name == "Original") originalRatio() else ratio ?: 1.25f
97
+ val w = minOf(dp(24).toFloat(), dp(24) * aspect); val h = w / aspect
98
+ canvas.drawRoundRect((width-w)/2, (height-h)/2, (width+w)/2, (height+h)/2, dp(2).toFloat(), dp(2).toFloat(), paint)
99
+ }
100
+ }, LayoutParams(dp(40), dp(38)))
101
+ addView(TextView(context).apply { text = name; textSize = 11f; gravity = Gravity.CENTER; setTextColor(textColor) })
102
+ setOnClickListener { onRatio(name, if (name == "Original") originalRatio() else ratio) }; applyPressScale()
103
+ }
104
+ presets[name] = tile
105
+ rail.addView(tile, LayoutParams(dp(64), dp(68)))
106
+ }
107
+ addView(HorizontalScrollView(context).apply { isHorizontalScrollBarEnabled = false; addView(rail) }, LayoutParams(LayoutParams.MATCH_PARENT, dp(72)))
108
+ }
109
+
110
+ fun sync(state: PhotoTransformState) {
111
+ syncing = true
112
+ slider.progress = (state.straightenDegrees * 10 + 450).roundToInt()
113
+ valueLabel.text = String.format(java.util.Locale.US, "%.1f°", state.straightenDegrees)
114
+ presets.forEach { (name, tile) ->
115
+ tile.isSelected = name == state.aspectPreset
116
+ tile.background = roundedDrawable(if (tile.isSelected) (accent and 0x00FFFFFF) or 0x22000000 else Color.TRANSPARENT, 12, context)
117
+ (tile.getChildAt(1) as TextView).setTextColor(if (tile.isSelected) accent else DesignTokens.textSecondary)
118
+ tile.getChildAt(0).invalidate()
119
+ }
120
+ syncing = false
121
+ }
122
+ }