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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/PhotoVideoEditor.podspec +1 -0
  3. package/README.md +10 -6
  4. package/THIRD_PARTY_NOTICES.md +20 -0
  5. package/android/build.gradle +1 -1
  6. package/android/src/main/AndroidManifest.xml +1 -1
  7. package/android/src/main/assets/Stickers/AUTUMN_1.png +0 -0
  8. package/android/src/main/assets/Stickers/AUTUMN_10.png +0 -0
  9. package/android/src/main/assets/Stickers/AUTUMN_11.png +0 -0
  10. package/android/src/main/assets/Stickers/AUTUMN_12.png +0 -0
  11. package/android/src/main/assets/Stickers/AUTUMN_13.png +0 -0
  12. package/android/src/main/assets/Stickers/AUTUMN_14.png +0 -0
  13. package/android/src/main/assets/Stickers/AUTUMN_15.png +0 -0
  14. package/android/src/main/assets/Stickers/AUTUMN_16.png +0 -0
  15. package/android/src/main/assets/Stickers/AUTUMN_17.png +0 -0
  16. package/android/src/main/assets/Stickers/AUTUMN_18.png +0 -0
  17. package/android/src/main/assets/Stickers/AUTUMN_2.png +0 -0
  18. package/android/src/main/assets/Stickers/AUTUMN_3.png +0 -0
  19. package/android/src/main/assets/Stickers/AUTUMN_4.png +0 -0
  20. package/android/src/main/assets/Stickers/AUTUMN_5.png +0 -0
  21. package/android/src/main/assets/Stickers/AUTUMN_6.png +0 -0
  22. package/android/src/main/assets/Stickers/AUTUMN_7.png +0 -0
  23. package/android/src/main/assets/Stickers/AUTUMN_8.png +0 -0
  24. package/android/src/main/assets/Stickers/AUTUMN_9.png +0 -0
  25. package/android/src/main/assets/Stickers/LOL_1.png +0 -0
  26. package/android/src/main/assets/Stickers/LOL_10.png +0 -0
  27. package/android/src/main/assets/Stickers/LOL_11.png +0 -0
  28. package/android/src/main/assets/Stickers/LOL_12.png +0 -0
  29. package/android/src/main/assets/Stickers/LOL_13.png +0 -0
  30. package/android/src/main/assets/Stickers/LOL_14.png +0 -0
  31. package/android/src/main/assets/Stickers/LOL_15.png +0 -0
  32. package/android/src/main/assets/Stickers/LOL_16.png +0 -0
  33. package/android/src/main/assets/Stickers/LOL_17.png +0 -0
  34. package/android/src/main/assets/Stickers/LOL_18.png +0 -0
  35. package/android/src/main/assets/Stickers/LOL_19.png +0 -0
  36. package/android/src/main/assets/Stickers/LOL_2.png +0 -0
  37. package/android/src/main/assets/Stickers/LOL_3.png +0 -0
  38. package/android/src/main/assets/Stickers/LOL_4.png +0 -0
  39. package/android/src/main/assets/Stickers/LOL_5.png +0 -0
  40. package/android/src/main/assets/Stickers/LOL_6.png +0 -0
  41. package/android/src/main/assets/Stickers/LOL_7.png +0 -0
  42. package/android/src/main/assets/Stickers/LOL_8.png +0 -0
  43. package/android/src/main/assets/Stickers/LOL_9.png +0 -0
  44. package/android/src/main/assets/Stickers/SPRING_1.png +0 -0
  45. package/android/src/main/assets/Stickers/SPRING_2.png +0 -0
  46. package/android/src/main/assets/Stickers/SPRING_3.png +0 -0
  47. package/android/src/main/assets/Stickers/SPRING_4.png +0 -0
  48. package/android/src/main/assets/Stickers/SPRING_5.png +0 -0
  49. package/android/src/main/assets/Stickers/SPRING_6.png +0 -0
  50. package/android/src/main/assets/Stickers/SUMMER_1.png +0 -0
  51. package/android/src/main/assets/Stickers/SUMMER_10.png +0 -0
  52. package/android/src/main/assets/Stickers/SUMMER_11.png +0 -0
  53. package/android/src/main/assets/Stickers/SUMMER_12.png +0 -0
  54. package/android/src/main/assets/Stickers/SUMMER_13.png +0 -0
  55. package/android/src/main/assets/Stickers/SUMMER_14.png +0 -0
  56. package/android/src/main/assets/Stickers/SUMMER_15.png +0 -0
  57. package/android/src/main/assets/Stickers/SUMMER_16.png +0 -0
  58. package/android/src/main/assets/Stickers/SUMMER_17.png +0 -0
  59. package/android/src/main/assets/Stickers/SUMMER_18.png +0 -0
  60. package/android/src/main/assets/Stickers/SUMMER_19.png +0 -0
  61. package/android/src/main/assets/Stickers/SUMMER_2.png +0 -0
  62. package/android/src/main/assets/Stickers/SUMMER_20.png +0 -0
  63. package/android/src/main/assets/Stickers/SUMMER_21.png +0 -0
  64. package/android/src/main/assets/Stickers/SUMMER_3.png +0 -0
  65. package/android/src/main/assets/Stickers/SUMMER_4.png +0 -0
  66. package/android/src/main/assets/Stickers/SUMMER_5.png +0 -0
  67. package/android/src/main/assets/Stickers/SUMMER_6.png +0 -0
  68. package/android/src/main/assets/Stickers/SUMMER_7.png +0 -0
  69. package/android/src/main/assets/Stickers/SUMMER_8.png +0 -0
  70. package/android/src/main/assets/Stickers/SUMMER_9.png +0 -0
  71. package/android/src/main/assets/Stickers/TEXT_1.png +0 -0
  72. package/android/src/main/assets/Stickers/TEXT_12.png +0 -0
  73. package/android/src/main/assets/Stickers/TEXT_13.png +0 -0
  74. package/android/src/main/assets/Stickers/TEXT_14.png +0 -0
  75. package/android/src/main/assets/Stickers/TEXT_15.png +0 -0
  76. package/android/src/main/assets/Stickers/TEXT_16.png +0 -0
  77. package/android/src/main/assets/Stickers/TEXT_2.png +0 -0
  78. package/android/src/main/assets/Stickers/TEXT_25.png +0 -0
  79. package/android/src/main/assets/Stickers/TEXT_26.png +0 -0
  80. package/android/src/main/assets/Stickers/TEXT_27.png +0 -0
  81. package/android/src/main/assets/Stickers/TEXT_28.png +0 -0
  82. package/android/src/main/assets/Stickers/TEXT_29.png +0 -0
  83. package/android/src/main/assets/Stickers/TEXT_3.png +0 -0
  84. package/android/src/main/assets/Stickers/TEXT_30.png +0 -0
  85. package/android/src/main/assets/Stickers/TEXT_31.png +0 -0
  86. package/android/src/main/assets/Stickers/TEXT_32.png +0 -0
  87. package/android/src/main/assets/Stickers/TEXT_7.png +0 -0
  88. package/android/src/main/assets/Stickers/TEXT_8.png +0 -0
  89. package/android/src/main/assets/Stickers/TEXT_9.png +0 -0
  90. package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +188 -108
  91. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayer.kt +5 -1
  92. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerRenderer.kt +21 -0
  93. package/android/src/main/java/com/photovideoeditor/photo/ui/CropOverlayView.kt +34 -2
  94. package/android/src/main/java/com/photovideoeditor/photo/ui/DrawOverlayView.kt +66 -0
  95. package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +21 -1
  96. package/android/src/main/java/com/photovideoeditor/stickers/OnlineStickerSheet.kt +135 -104
  97. package/android/src/main/res/drawable/ic_draw.xml +6 -0
  98. package/android/src/main/res/drawable/ic_pause.xml +7 -0
  99. package/android/src/main/res/drawable/ic_play.xml +7 -0
  100. package/docs/api.md +1 -1
  101. package/docs/photo-editor.md +5 -6
  102. package/ios/Editor/PhotoVideoEditorViewController.swift +166 -77
  103. package/ios/Photo/Rendering/PhotoLayer.swift +5 -1
  104. package/ios/Photo/Rendering/PhotoLayerRenderer.swift +14 -0
  105. package/ios/Photo/UI/CropOverlayView.swift +38 -1
  106. package/ios/Photo/UI/DrawOverlayView.swift +48 -0
  107. package/ios/Photo/UI/LayerOverlayView.swift +9 -1
  108. package/ios/Resources/Stickers/AUTUMN_1.png +0 -0
  109. package/ios/Resources/Stickers/AUTUMN_10.png +0 -0
  110. package/ios/Resources/Stickers/AUTUMN_11.png +0 -0
  111. package/ios/Resources/Stickers/AUTUMN_12.png +0 -0
  112. package/ios/Resources/Stickers/AUTUMN_13.png +0 -0
  113. package/ios/Resources/Stickers/AUTUMN_14.png +0 -0
  114. package/ios/Resources/Stickers/AUTUMN_15.png +0 -0
  115. package/ios/Resources/Stickers/AUTUMN_16.png +0 -0
  116. package/ios/Resources/Stickers/AUTUMN_17.png +0 -0
  117. package/ios/Resources/Stickers/AUTUMN_18.png +0 -0
  118. package/ios/Resources/Stickers/AUTUMN_2.png +0 -0
  119. package/ios/Resources/Stickers/AUTUMN_3.png +0 -0
  120. package/ios/Resources/Stickers/AUTUMN_4.png +0 -0
  121. package/ios/Resources/Stickers/AUTUMN_5.png +0 -0
  122. package/ios/Resources/Stickers/AUTUMN_6.png +0 -0
  123. package/ios/Resources/Stickers/AUTUMN_7.png +0 -0
  124. package/ios/Resources/Stickers/AUTUMN_8.png +0 -0
  125. package/ios/Resources/Stickers/AUTUMN_9.png +0 -0
  126. package/ios/Resources/Stickers/LOL_1.png +0 -0
  127. package/ios/Resources/Stickers/LOL_10.png +0 -0
  128. package/ios/Resources/Stickers/LOL_11.png +0 -0
  129. package/ios/Resources/Stickers/LOL_12.png +0 -0
  130. package/ios/Resources/Stickers/LOL_13.png +0 -0
  131. package/ios/Resources/Stickers/LOL_14.png +0 -0
  132. package/ios/Resources/Stickers/LOL_15.png +0 -0
  133. package/ios/Resources/Stickers/LOL_16.png +0 -0
  134. package/ios/Resources/Stickers/LOL_17.png +0 -0
  135. package/ios/Resources/Stickers/LOL_18.png +0 -0
  136. package/ios/Resources/Stickers/LOL_19.png +0 -0
  137. package/ios/Resources/Stickers/LOL_2.png +0 -0
  138. package/ios/Resources/Stickers/LOL_3.png +0 -0
  139. package/ios/Resources/Stickers/LOL_4.png +0 -0
  140. package/ios/Resources/Stickers/LOL_5.png +0 -0
  141. package/ios/Resources/Stickers/LOL_6.png +0 -0
  142. package/ios/Resources/Stickers/LOL_7.png +0 -0
  143. package/ios/Resources/Stickers/LOL_8.png +0 -0
  144. package/ios/Resources/Stickers/LOL_9.png +0 -0
  145. package/ios/Resources/Stickers/SPRING_1.png +0 -0
  146. package/ios/Resources/Stickers/SPRING_2.png +0 -0
  147. package/ios/Resources/Stickers/SPRING_3.png +0 -0
  148. package/ios/Resources/Stickers/SPRING_4.png +0 -0
  149. package/ios/Resources/Stickers/SPRING_5.png +0 -0
  150. package/ios/Resources/Stickers/SPRING_6.png +0 -0
  151. package/ios/Resources/Stickers/SUMMER_1.png +0 -0
  152. package/ios/Resources/Stickers/SUMMER_10.png +0 -0
  153. package/ios/Resources/Stickers/SUMMER_11.png +0 -0
  154. package/ios/Resources/Stickers/SUMMER_12.png +0 -0
  155. package/ios/Resources/Stickers/SUMMER_13.png +0 -0
  156. package/ios/Resources/Stickers/SUMMER_14.png +0 -0
  157. package/ios/Resources/Stickers/SUMMER_15.png +0 -0
  158. package/ios/Resources/Stickers/SUMMER_16.png +0 -0
  159. package/ios/Resources/Stickers/SUMMER_17.png +0 -0
  160. package/ios/Resources/Stickers/SUMMER_18.png +0 -0
  161. package/ios/Resources/Stickers/SUMMER_19.png +0 -0
  162. package/ios/Resources/Stickers/SUMMER_2.png +0 -0
  163. package/ios/Resources/Stickers/SUMMER_20.png +0 -0
  164. package/ios/Resources/Stickers/SUMMER_21.png +0 -0
  165. package/ios/Resources/Stickers/SUMMER_3.png +0 -0
  166. package/ios/Resources/Stickers/SUMMER_4.png +0 -0
  167. package/ios/Resources/Stickers/SUMMER_5.png +0 -0
  168. package/ios/Resources/Stickers/SUMMER_6.png +0 -0
  169. package/ios/Resources/Stickers/SUMMER_7.png +0 -0
  170. package/ios/Resources/Stickers/SUMMER_8.png +0 -0
  171. package/ios/Resources/Stickers/SUMMER_9.png +0 -0
  172. package/ios/Resources/Stickers/TEXT_1.png +0 -0
  173. package/ios/Resources/Stickers/TEXT_12.png +0 -0
  174. package/ios/Resources/Stickers/TEXT_13.png +0 -0
  175. package/ios/Resources/Stickers/TEXT_14.png +0 -0
  176. package/ios/Resources/Stickers/TEXT_15.png +0 -0
  177. package/ios/Resources/Stickers/TEXT_16.png +0 -0
  178. package/ios/Resources/Stickers/TEXT_2.png +0 -0
  179. package/ios/Resources/Stickers/TEXT_25.png +0 -0
  180. package/ios/Resources/Stickers/TEXT_26.png +0 -0
  181. package/ios/Resources/Stickers/TEXT_27.png +0 -0
  182. package/ios/Resources/Stickers/TEXT_28.png +0 -0
  183. package/ios/Resources/Stickers/TEXT_29.png +0 -0
  184. package/ios/Resources/Stickers/TEXT_3.png +0 -0
  185. package/ios/Resources/Stickers/TEXT_30.png +0 -0
  186. package/ios/Resources/Stickers/TEXT_31.png +0 -0
  187. package/ios/Resources/Stickers/TEXT_32.png +0 -0
  188. package/ios/Resources/Stickers/TEXT_7.png +0 -0
  189. package/ios/Resources/Stickers/TEXT_8.png +0 -0
  190. package/ios/Resources/Stickers/TEXT_9.png +0 -0
  191. package/ios/Resources/Stickers/a_pety_icon.png +0 -0
  192. package/ios/Stickers/OnlineStickerSheetViewController.swift +102 -159
  193. package/lib/module/index.js +2 -2
  194. package/lib/module/index.js.map +1 -1
  195. package/lib/typescript/src/types.d.ts +2 -2
  196. package/lib/typescript/src/types.d.ts.map +1 -1
  197. package/package.json +2 -1
  198. package/src/index.ts +1 -1
  199. package/src/types.ts +2 -2
  200. package/android/src/main/java/com/photovideoeditor/stickers/OpenMojiCatalog.kt +0 -172
  201. package/android/src/main/java/com/photovideoeditor/stickers/RemoteImageLoader.kt +0 -110
  202. package/ios/Stickers/OpenMojiCatalog.swift +0 -188
  203. package/ios/Stickers/RemoteImageLoader.swift +0 -127
@@ -0,0 +1,66 @@
1
+ package com.photovideoeditor.photo.ui
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.Path
8
+ import android.graphics.RectF
9
+ import android.view.MotionEvent
10
+ import android.view.View
11
+
12
+ data class DrawStroke(val points: MutableList<Pair<Float, Float>>, val color: Int, val widthPx: Float)
13
+
14
+ /** Captures freehand strokes over the visible image while Draw mode is active. */
15
+ class DrawOverlayView(context: Context) : View(context) {
16
+ private var imageBounds = RectF()
17
+ var strokeColor: Int = Color.RED
18
+ var strokeWidthPx: Float = 12f
19
+ private val strokes = mutableListOf<DrawStroke>()
20
+ private var current: DrawStroke? = null
21
+ var onStrokesChanged: ((List<DrawStroke>) -> Unit)? = null
22
+ private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
23
+ style = Paint.Style.STROKE
24
+ strokeCap = Paint.Cap.ROUND
25
+ strokeJoin = Paint.Join.ROUND
26
+ }
27
+
28
+ fun setImageBounds(bounds: RectF) { imageBounds = RectF(bounds) }
29
+ fun clearStrokes() { strokes.clear(); current = null; invalidate(); onStrokesChanged?.invoke(strokes) }
30
+ fun undoLastStroke() { if (strokes.isNotEmpty()) strokes.removeAt(strokes.lastIndex); invalidate(); onStrokesChanged?.invoke(strokes) }
31
+ val hasStrokes: Boolean get() = strokes.isNotEmpty()
32
+
33
+ fun normalizedStrokes(): List<Pair<List<Pair<Float, Float>>, DrawStroke>> {
34
+ if (imageBounds.width() <= 0 || imageBounds.height() <= 0) return emptyList()
35
+ return strokes.map { stroke ->
36
+ stroke.points.map { (x, y) ->
37
+ (x - imageBounds.left) / imageBounds.width() to (y - imageBounds.top) / imageBounds.height()
38
+ } to stroke
39
+ }
40
+ }
41
+
42
+ override fun onDraw(canvas: Canvas) {
43
+ strokes.forEach { stroke ->
44
+ if (stroke.points.size < 2) return@forEach
45
+ paint.color = stroke.color; paint.strokeWidth = stroke.widthPx
46
+ val path = Path()
47
+ stroke.points.forEachIndexed { index, point -> if (index == 0) path.moveTo(point.first, point.second) else path.lineTo(point.first, point.second) }
48
+ canvas.drawPath(path, paint)
49
+ }
50
+ }
51
+
52
+ override fun onTouchEvent(event: MotionEvent): Boolean {
53
+ if (!imageBounds.contains(event.x, event.y) && event.actionMasked == MotionEvent.ACTION_DOWN) return false
54
+ when (event.actionMasked) {
55
+ MotionEvent.ACTION_DOWN -> {
56
+ current = DrawStroke(mutableListOf(event.x to event.y), strokeColor, strokeWidthPx).also(strokes::add)
57
+ parent?.requestDisallowInterceptTouchEvent(true); invalidate(); return true
58
+ }
59
+ MotionEvent.ACTION_MOVE -> { current?.points?.add(event.x to event.y); invalidate(); return true }
60
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
61
+ current = null; parent?.requestDisallowInterceptTouchEvent(false); onStrokesChanged?.invoke(strokes); return true
62
+ }
63
+ }
64
+ return false
65
+ }
66
+ }
@@ -28,6 +28,7 @@ class LayerOverlayView(context: Context) : View(context) {
28
28
  var selectedLayerId: String? = null
29
29
  set(value) { field = value; invalidate() }
30
30
  var onLayerTapped: ((String?) -> Unit)? = null
31
+ var onLayerDoubleTapped: ((String) -> Unit)? = null
31
32
  var onLayerTransformChanged: ((id: String, x: Float, y: Float, scale: Float, rotationDegrees: Float) -> Unit)? = null
32
33
  var onLayerTransformEnded: ((id: String) -> Unit)? = null
33
34
 
@@ -40,6 +41,9 @@ class LayerOverlayView(context: Context) : View(context) {
40
41
  private var startAngle = 0f
41
42
  private var startMidX = 0f
42
43
  private var startMidY = 0f
44
+ private var movedDuringGesture = false
45
+ private var lastTapTimeMs = 0L
46
+ private var lastTapLayerId: String? = null
43
47
  private val density = resources.displayMetrics.density
44
48
  private val handleRadius = 11f * density
45
49
  private val handleTouchRadius = 30f * density
@@ -79,6 +83,7 @@ class LayerOverlayView(context: Context) : View(context) {
79
83
  onLayerTapped?.invoke(hit?.id)
80
84
  if (hit == null || hit.locked) { resetGesture(); return false }
81
85
  gestureId = hit.id
86
+ movedDuringGesture = false
82
87
  startLayer = hit.copy()
83
88
  startPointX = event.x; startPointY = event.y
84
89
  if (handleHit != null) {
@@ -103,6 +108,7 @@ class LayerOverlayView(context: Context) : View(context) {
103
108
  }
104
109
  MotionEvent.ACTION_MOVE -> {
105
110
  val initial = startLayer ?: return false
111
+ if (hypot(event.x - startPointX, event.y - startPointY) > handleRadius) movedDuringGesture = true
106
112
  when (gesture) {
107
113
  Gesture.DRAG -> {
108
114
  val x = (initial.x + (event.x - startPointX) / imageBounds.width()).coerceIn(0f, 1f)
@@ -127,7 +133,16 @@ class LayerOverlayView(context: Context) : View(context) {
127
133
  }
128
134
  return true
129
135
  }
130
- MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { finishGesture(); return true }
136
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
137
+ val tappedId = gestureId
138
+ if (event.actionMasked == MotionEvent.ACTION_UP && !movedDuringGesture && tappedId != null) {
139
+ val now = android.os.SystemClock.uptimeMillis()
140
+ if (lastTapLayerId == tappedId && now - lastTapTimeMs <= 350L) {
141
+ onLayerDoubleTapped?.invoke(tappedId); lastTapLayerId = null; lastTapTimeMs = 0L
142
+ } else { lastTapLayerId = tappedId; lastTapTimeMs = now }
143
+ }
144
+ finishGesture(); return true
145
+ }
131
146
  MotionEvent.ACTION_POINTER_UP -> return true
132
147
  }
133
148
  return false
@@ -166,6 +181,11 @@ class LayerOverlayView(context: Context) : View(context) {
166
181
  val baseSize = shortSide * 0.6f
167
182
  if (aspect >= 1f) Pair(baseSize / 2f, (baseSize / aspect) / 2f) else Pair((baseSize * aspect) / 2f, baseSize / 2f)
168
183
  }
184
+ LayerType.DRAWING -> {
185
+ val maxX = layer.drawPoints.maxOfOrNull { kotlin.math.abs(it.first) } ?: 0.08f
186
+ val maxY = layer.drawPoints.maxOfOrNull { kotlin.math.abs(it.second) } ?: 0.08f
187
+ Pair(maxX * imageBounds.width() + handleRadius, maxY * imageBounds.height() + handleRadius)
188
+ }
169
189
  }
170
190
  return Pair((base.first * layer.scale).coerceAtLeast(handleRadius), (base.second * layer.scale).coerceAtLeast(handleRadius))
171
191
  }
@@ -3,12 +3,14 @@ package com.photovideoeditor.stickers
3
3
  import android.app.Dialog
4
4
  import android.content.Context
5
5
  import android.graphics.Color
6
+ import android.graphics.BitmapFactory
6
7
  import android.os.Bundle
7
8
  import android.os.Handler
8
9
  import android.os.Looper
9
10
  import android.text.Editable
10
11
  import android.text.TextWatcher
11
12
  import android.view.Gravity
13
+ import android.view.MotionEvent
12
14
  import android.view.View
13
15
  import android.view.ViewGroup
14
16
  import android.view.Window
@@ -18,7 +20,6 @@ import android.widget.FrameLayout
18
20
  import android.widget.HorizontalScrollView
19
21
  import android.widget.ImageView
20
22
  import android.widget.LinearLayout
21
- import android.widget.ProgressBar
22
23
  import android.widget.TextView
23
24
  import android.widget.Toast
24
25
  import androidx.recyclerview.widget.GridLayoutManager
@@ -26,41 +27,50 @@ import androidx.recyclerview.widget.RecyclerView
26
27
  import com.photovideoeditor.ui.DesignTokens
27
28
  import com.photovideoeditor.ui.components.dpToPx
28
29
  import com.photovideoeditor.ui.components.editorChip
29
- import com.photovideoeditor.ui.components.editorPrimaryButton
30
30
  import com.photovideoeditor.ui.components.roundedDrawable
31
+ import com.photovideoeditor.files.SourceResolver
32
+ import java.io.File
33
+ import java.io.FileOutputStream
34
+ import java.net.URL
35
+ import java.util.concurrent.Executors
36
+
37
+ data class RuntimeSticker(val id: String, val uri: String)
31
38
 
32
39
  /**
33
- * Bottom sheet listing OpenMoji's free sticker catalog (search + category filter + grid),
40
+ * Bottom sheet listing bundled sticker assets (search + category filter + grid),
34
41
  * built as a plain [Dialog] (no Material `BottomSheetDialog`, which isn't a dependency here) that
35
42
  * slides up from the bottom and covers ~85% of the screen height.
36
43
  */
37
44
  class OnlineStickerSheet(
38
45
  context: Context,
46
+ private val runtimeStickers: List<RuntimeSticker> = emptyList(),
39
47
  private val onStickerPicked: (localFilePath: String) -> Unit
40
48
  ) : Dialog(context, android.R.style.Theme_Black_NoTitleBar) {
41
49
 
42
50
  private val mainHandler = Handler(Looper.getMainLooper())
51
+ private val ioExecutor = Executors.newFixedThreadPool(3)
43
52
  private var searchDebounce: Runnable? = null
44
53
 
45
- private var allEntries: List<OpenMojiEntry> = emptyList()
54
+ private val localAssets: List<String> by lazy {
55
+ context.assets.list("Stickers")?.filter { it.endsWith(".png", ignoreCase = true) }?.sorted() ?: emptyList()
56
+ }
46
57
  private var selectedGroup: String? = null // null == "All"
47
58
  private var searchQuery: String = ""
48
- private var loadError: Exception? = null
49
- private var loading = true
50
59
 
51
60
  private lateinit var searchInput: EditText
52
61
  private lateinit var chipRow: LinearLayout
53
62
  private lateinit var recycler: RecyclerView
54
- private lateinit var progress: ProgressBar
55
- private lateinit var statusText: TextView
56
- private lateinit var retryButton: View
57
63
  private lateinit var adapter: StickerAdapter
58
64
 
59
65
  override fun onCreate(savedInstanceState: Bundle?) {
60
66
  super.onCreate(savedInstanceState)
61
67
  setContentView(buildRootView())
62
68
  configureWindow()
63
- loadCatalog()
69
+ }
70
+
71
+ override fun dismiss() {
72
+ ioExecutor.shutdownNow()
73
+ super.dismiss()
64
74
  }
65
75
 
66
76
  private fun configureWindow() {
@@ -76,7 +86,7 @@ class OnlineStickerSheet(
76
86
 
77
87
  private fun buildRootView(): View {
78
88
  val screenHeight = context.resources.displayMetrics.heightPixels
79
- val sheetHeight = (screenHeight * 0.85f).toInt()
89
+ val sheetHeight = (screenHeight * 0.78f).toInt()
80
90
 
81
91
  val dimBackground = FrameLayout(context).apply {
82
92
  setBackgroundColor(Color.argb(140, 0, 0, 0))
@@ -90,17 +100,47 @@ class OnlineStickerSheet(
90
100
  setPadding(dp(DesignTokens.spaceLg), dp(DesignTokens.spaceMd), dp(DesignTokens.spaceLg), dp(DesignTokens.spaceLg))
91
101
  }
92
102
 
93
- // Decorative drag handle.
94
- sheet.addView(
95
- View(context).apply { setBackgroundColor(DesignTokens.outlineVariant) },
96
- LinearLayout.LayoutParams(dp(36), dp(4)).apply {
97
- gravity = Gravity.CENTER_HORIZONTAL
98
- bottomMargin = dp(DesignTokens.spaceMd)
103
+ // Functional drag handle with a generous touch target. The sheet follows
104
+ // the finger, dismisses past the threshold, or springs back into place.
105
+ var dragStartY = 0f
106
+ var sheetStartTranslation = 0f
107
+ val dragHandle = FrameLayout(context).apply {
108
+ isClickable = true
109
+ contentDescription = "Drag down to close stickers"
110
+ addView(
111
+ View(context).apply { setBackgroundColor(DesignTokens.outlineVariant) },
112
+ FrameLayout.LayoutParams(dp(40), dp(4), Gravity.CENTER)
113
+ )
114
+ setOnTouchListener { _, event ->
115
+ when (event.actionMasked) {
116
+ MotionEvent.ACTION_DOWN -> {
117
+ dragStartY = event.rawY
118
+ sheetStartTranslation = sheet.translationY
119
+ true
120
+ }
121
+ MotionEvent.ACTION_MOVE -> {
122
+ sheet.translationY = (sheetStartTranslation + event.rawY - dragStartY).coerceAtLeast(0f)
123
+ true
124
+ }
125
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
126
+ val shouldDismiss = sheet.translationY >= minOf(sheet.height * 0.18f, dp(140).toFloat())
127
+ if (shouldDismiss) {
128
+ sheet.animate().translationY(sheet.height.toFloat()).setDuration(180).withEndAction { dismiss() }.start()
129
+ } else {
130
+ sheet.animate().translationY(0f).setDuration(180).start()
131
+ }
132
+ true
133
+ }
134
+ else -> false
135
+ }
99
136
  }
100
- )
137
+ }
138
+ sheet.addView(dragHandle, LinearLayout.LayoutParams(MATCH, dp(28)).apply {
139
+ bottomMargin = dp(DesignTokens.spaceSm)
140
+ })
101
141
 
102
142
  sheet.addView(TextView(context).apply {
103
- text = "Browse stickers"
143
+ text = "Stickers"
104
144
  setTextColor(DesignTokens.textPrimary)
105
145
  textSize = 16f
106
146
  setTypeface(typeface, android.graphics.Typeface.BOLD)
@@ -140,43 +180,19 @@ class OnlineStickerSheet(
140
180
  val gridContainer = FrameLayout(context)
141
181
 
142
182
  recycler = RecyclerView(context).apply {
143
- layoutManager = GridLayoutManager(context, 4)
183
+ layoutManager = GridLayoutManager(context, 5)
184
+ setHasFixedSize(true)
144
185
  }
145
186
  adapter = StickerAdapter()
146
187
  recycler.adapter = adapter
147
188
  gridContainer.addView(recycler, FrameLayout.LayoutParams(MATCH, MATCH))
148
189
 
149
- progress = ProgressBar(context)
150
- gridContainer.addView(progress, FrameLayout.LayoutParams(WRAP, WRAP, Gravity.CENTER))
151
-
152
- val statusContainer = LinearLayout(context).apply {
153
- orientation = LinearLayout.VERTICAL
154
- gravity = Gravity.CENTER
155
- }
156
- statusText = TextView(context).apply {
157
- setTextColor(DesignTokens.textSecondary)
158
- textSize = 13f
159
- gravity = Gravity.CENTER
160
- }
161
- statusContainer.addView(statusText, LinearLayout.LayoutParams(WRAP, WRAP))
162
- retryButton = editorPrimaryButton(context, "Retry") { loadCatalog() }
163
- statusContainer.addView(retryButton, LinearLayout.LayoutParams(WRAP, dp(DesignTokens.touchTargetMin)).apply {
164
- topMargin = dp(DesignTokens.spaceMd)
165
- })
166
- gridContainer.addView(statusContainer, FrameLayout.LayoutParams(WRAP, WRAP, Gravity.CENTER))
167
-
168
190
  sheet.addView(gridContainer, LinearLayout.LayoutParams(MATCH, 0, 1f).apply {
169
191
  topMargin = dp(DesignTokens.spaceMd)
170
192
  })
171
193
 
172
- sheet.addView(TextView(context).apply {
173
- text = "Stickers by OpenMoji (CC BY-SA 4.0)"
174
- setTextColor(DesignTokens.textSubtle)
175
- textSize = 10f
176
- gravity = Gravity.CENTER
177
- }, LinearLayout.LayoutParams(MATCH, WRAP).apply { topMargin = dp(DesignTokens.spaceSm) })
178
-
179
- updateStatusViews()
194
+ rebuildChips()
195
+ refreshList()
180
196
 
181
197
  val root = FrameLayout(context)
182
198
  root.addView(dimBackground, FrameLayout.LayoutParams(MATCH, MATCH))
@@ -194,27 +210,10 @@ class OnlineStickerSheet(
194
210
  )
195
211
  }
196
212
 
197
- private fun loadCatalog() {
198
- loading = true
199
- loadError = null
200
- updateStatusViews()
201
- OpenMojiCatalog.load(context) { entries, error ->
202
- loading = false
203
- if (entries != null) {
204
- allEntries = entries
205
- loadError = null
206
- rebuildChips()
207
- refreshList()
208
- } else {
209
- loadError = error
210
- }
211
- updateStatusViews()
212
- }
213
- }
214
-
215
213
  private fun rebuildChips() {
216
214
  chipRow.removeAllViews()
217
- val groups = listOf<String?>(null) + OpenMojiCatalog.groups()
215
+ val localGroups = localAssets.map { "local:" + it.substringBefore('_').lowercase() }.distinct()
216
+ val groups = listOf<String?>(null) + (if (runtimeStickers.isNotEmpty()) listOf("runtime") else emptyList()) + localGroups
218
217
  groups.forEach { group ->
219
218
  val label = group?.let { humanizeGroup(it) } ?: "All"
220
219
  val chip = editorChip(context, label, selected = group == selectedGroup) {
@@ -227,32 +226,20 @@ class OnlineStickerSheet(
227
226
  }
228
227
 
229
228
  private fun humanizeGroup(group: String): String =
230
- group.split("-").joinToString(" ") { it.replaceFirstChar(Char::uppercase) }
229
+ if (group == "runtime") "My stickers" else group.removePrefix("local:").split("-").joinToString(" ") { it.replaceFirstChar(Char::uppercase) }
231
230
 
232
231
  private fun refreshList() {
233
232
  val query = searchQuery.trim()
234
- val list = if (query.isNotEmpty()) {
235
- OpenMojiCatalog.search(query)
236
- } else {
237
- val group = selectedGroup
238
- if (group == null) allEntries else OpenMojiCatalog.byGroup(group)
239
- }
240
- adapter.submit(list)
241
- updateStatusViews()
242
- }
243
-
244
- private fun updateStatusViews() {
245
- progress.visibility = if (loading) View.VISIBLE else View.GONE
246
- val error = loadError
247
- val empty = !loading && error == null && adapter.itemCount == 0
248
- recycler.visibility = if (!loading && error == null && !empty) View.VISIBLE else View.GONE
249
- statusText.visibility = if (!loading && (error != null || empty)) View.VISIBLE else View.GONE
250
- retryButton.visibility = if (!loading && error != null) View.VISIBLE else View.GONE
251
- statusText.text = when {
252
- error != null -> "Couldn't load stickers — check your connection."
253
- empty -> "No stickers found."
254
- else -> ""
255
- }
233
+ val group = selectedGroup
234
+ val local = localAssets.filter { asset ->
235
+ val matchesQuery = query.isEmpty() || asset.contains(query, ignoreCase = true)
236
+ val matchesGroup = group == null || (group.startsWith("local:") && asset.startsWith(group.removePrefix("local:"), ignoreCase = true))
237
+ matchesQuery && matchesGroup
238
+ }.map { StickerItem(assetName = it) }
239
+ val runtime = runtimeStickers.filter {
240
+ (group == null || group == "runtime") && (query.isEmpty() || it.id.contains(query, ignoreCase = true))
241
+ }.map { StickerItem(runtime = it) }
242
+ adapter.submit(runtime + local)
256
243
  }
257
244
 
258
245
  private inner class StickerViewHolder(itemView: FrameLayout) : RecyclerView.ViewHolder(itemView) {
@@ -260,9 +247,9 @@ class OnlineStickerSheet(
260
247
  }
261
248
 
262
249
  private inner class StickerAdapter : RecyclerView.Adapter<StickerViewHolder>() {
263
- private var items: List<OpenMojiEntry> = emptyList()
250
+ private var items: List<StickerItem> = emptyList()
264
251
 
265
- fun submit(newItems: List<OpenMojiEntry>) {
252
+ fun submit(newItems: List<StickerItem>) {
266
253
  items = newItems
267
254
  notifyDataSetChanged()
268
255
  }
@@ -270,12 +257,13 @@ class OnlineStickerSheet(
270
257
  override fun getItemCount(): Int = items.size
271
258
 
272
259
  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StickerViewHolder {
273
- val cardSize = dp(72)
260
+ val availableWidth = context.resources.displayMetrics.widthPixels - dp(DesignTokens.spaceLg * 2) - dp(8 * 5)
261
+ val cardSize = (availableWidth / 5).coerceAtLeast(dp(52))
274
262
  val image = ImageView(context).apply {
275
263
  scaleType = ImageView.ScaleType.CENTER_INSIDE
276
264
  }
277
265
  val cell = FrameLayout(context).apply {
278
- background = roundedDrawable(DesignTokens.surfaceContainer, DesignTokens.radiusMd, context)
266
+ background = roundedDrawable(DesignTokens.surfaceContainerLow, DesignTokens.radiusMd, context)
279
267
  addView(image, FrameLayout.LayoutParams(cardSize - dp(16), cardSize - dp(16), Gravity.CENTER))
280
268
  }
281
269
  cell.layoutParams = RecyclerView.LayoutParams(cardSize, cardSize).apply {
@@ -285,24 +273,67 @@ class OnlineStickerSheet(
285
273
  }
286
274
 
287
275
  override fun onBindViewHolder(holder: StickerViewHolder, position: Int) {
288
- val entry = items[position]
289
- RemoteImageLoader.loadThumbnail(context, OpenMojiCatalog.thumbnailUrl(entry.hexcode), holder.imageView)
290
- holder.itemView.setOnClickListener { onStickerTapped(entry) }
276
+ val item = items[position]
277
+ holder.imageView.setImageDrawable(null)
278
+ item.assetName?.let { asset ->
279
+ holder.imageView.setImageBitmap(context.assets.open("Stickers/$asset").use { BitmapFactory.decodeStream(it) })
280
+ holder.itemView.setOnClickListener { onLocalStickerTapped(asset) }
281
+ }
282
+ item.runtime?.let { runtime ->
283
+ loadRuntimeThumbnail(runtime, holder.imageView)
284
+ holder.itemView.setOnClickListener { onRuntimeStickerTapped(runtime) }
285
+ }
291
286
  }
292
287
  }
293
288
 
294
- private fun onStickerTapped(entry: OpenMojiEntry) {
295
- Toast.makeText(context, "Downloading sticker…", Toast.LENGTH_SHORT).show()
296
- RemoteImageLoader.downloadFull(context, OpenMojiCatalog.fullUrl(entry.hexcode)) { path, error ->
297
- if (path != null) {
298
- onStickerPicked(path)
299
- dismiss()
300
- } else {
301
- Toast.makeText(context, "Couldn't download that sticker, try another.", Toast.LENGTH_SHORT).show()
289
+ private data class StickerItem(val assetName: String? = null, val runtime: RuntimeSticker? = null)
290
+
291
+ private fun loadRuntimeThumbnail(sticker: RuntimeSticker, imageView: ImageView) {
292
+ imageView.tag = sticker.uri
293
+ ioExecutor.execute {
294
+ val bitmap = try {
295
+ if (sticker.uri.startsWith("https://")) downloadRuntimeSticker(sticker.uri).inputStream().use(BitmapFactory::decodeStream)
296
+ else SourceResolver.resolvePath(context, sticker.uri, "pve_runtime_sticker")?.let(BitmapFactory::decodeFile)
297
+ } catch (_: Exception) { null }
298
+ mainHandler.post { if (imageView.tag == sticker.uri) imageView.setImageBitmap(bitmap) }
299
+ }
300
+ }
301
+
302
+ private fun onRuntimeStickerTapped(sticker: RuntimeSticker) {
303
+ ioExecutor.execute {
304
+ val path = try {
305
+ if (sticker.uri.startsWith("https://")) {
306
+ val output = File.createTempFile("pve_runtime_sticker_", ".img", context.cacheDir)
307
+ output.writeBytes(downloadRuntimeSticker(sticker.uri))
308
+ output.absolutePath
309
+ } else SourceResolver.resolvePath(context, sticker.uri, "pve_runtime_sticker")
310
+ } catch (_: Exception) { null }
311
+ mainHandler.post {
312
+ if (path != null) { onStickerPicked(path); dismiss() }
313
+ else Toast.makeText(context, "Couldn't open that sticker.", Toast.LENGTH_SHORT).show()
302
314
  }
303
315
  }
304
316
  }
305
317
 
318
+ private fun downloadRuntimeSticker(uri: String): ByteArray {
319
+ val connection = URL(uri).openConnection().apply {
320
+ connectTimeout = 10_000
321
+ readTimeout = 15_000
322
+ }
323
+ return connection.getInputStream().use { it.readBytes() }
324
+ }
325
+
326
+ private fun onLocalStickerTapped(assetName: String) {
327
+ try {
328
+ val output = File(context.cacheDir, "sticker-${assetName.lowercase()}")
329
+ context.assets.open("Stickers/$assetName").use { input -> FileOutputStream(output).use(input::copyTo) }
330
+ onStickerPicked(output.absolutePath)
331
+ dismiss()
332
+ } catch (_: Exception) {
333
+ Toast.makeText(context, "Couldn't open that sticker.", Toast.LENGTH_SHORT).show()
334
+ }
335
+ }
336
+
306
337
  companion object {
307
338
  private const val MATCH = ViewGroup.LayoutParams.MATCH_PARENT
308
339
  private const val WRAP = ViewGroup.LayoutParams.WRAP_CONTENT
@@ -0,0 +1,6 @@
1
+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
2
+ android:width="24dp" android:height="24dp" android:viewportWidth="960" android:viewportHeight="960">
3
+ <group android:translateY="960">
4
+ <path android:fillColor="#FFFFFF" android:pathData="M160-120v-170l527-526q12-12 27-18t30-6q16 0 30.5 6t25.5 18l56 56q12 11 18 25.5t6 30.5q0 15-6 30t-18 27L330-120H160Zm80-80h56l393-392-28-29-29-28-392 393v56Zm320 80q74 0 137-37t63-103q0-36-19-62t-51-45l-59 59q23 10 36 22t13 26q0 23-36.5 41.5T560-200q-17 0-28.5 11.5T520-160q0 17 11.5 28.5T560-120ZM183-426l60-60q-20-8-31.5-16.5T200-520q0-12 18-24t76-37q88-38 117-69t29-70q0-55-44-87.5T280-840q-45 0-80.5 16T145-785q-11 13-9 29t15 26q13 11 29 9t27-13q14-14 31-20t42-6q41 0 60.5 12t19.5 28q0 14-17.5 25.5T262-654q-80 35-111 63.5T120-520q0 32 17 54.5t46 39.5Z"/>
5
+ </group>
6
+ </vector>
@@ -0,0 +1,7 @@
1
+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
2
+ android:width="24dp"
3
+ android:height="24dp"
4
+ android:viewportWidth="24"
5
+ android:viewportHeight="24">
6
+ <path android:fillColor="#FFFFFFFF" android:pathData="M6,19h4V5H6v14zM14,5v14h4V5h-4z" />
7
+ </vector>
@@ -0,0 +1,7 @@
1
+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
2
+ android:width="24dp"
3
+ android:height="24dp"
4
+ android:viewportWidth="24"
5
+ android:viewportHeight="24">
6
+ <path android:fillColor="#FFFFFFFF" android:pathData="M8,5v14l11,-7z" />
7
+ </vector>
package/docs/api.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  `openEditor(options)` validates defaults, invokes native code, and returns `Promise<EditorResult>`. In milestone one it returns the source URI/type and `cancelled: false`; it does not present UI or export. `cancelExport(jobId?)` currently returns `false`. `isAvailable()` synchronously checks native linkage. `addExportProgressListener(listener)` returns `{ remove() }`; no fake events are emitted.
4
4
 
5
- Defaults enable all feature flags, set quality to `high`, preserve metadata, and disable gallery saving. For photos, `trim` normalizes to `false`. Photo/video format mismatches and unknown enum values are rejected.
5
+ Defaults enable all feature flags—including `text`, `stickers`, `overlays`, and `draw`—set quality to `high`, preserve metadata, and disable gallery saving. For photos, `trim` normalizes to `false`. Photo/video format mismatches and unknown enum values are rejected.
6
6
 
7
7
  Dimensions use pixels, duration milliseconds, file size bytes, bitrate bits/second, frame rate frames/second, and progress ranges from 0 to 1. Public types and option-level JSDoc live in `src/types.ts`.
8
8
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Milestone 2 — transform and export
4
4
 
5
- - **Crop** — draggable/resizable freeform crop with corner handles, plus aspect-ratio presets (Free, 1:1, 4:5, 3:4, 9:16, 16:9). Tap **Crop** to enter crop mode; **Done** in the crop sub-bar commits it.
5
+ - **Crop** — dedicated ZLImageEditor-inspired crop mode with a dimmed mask, rule-of-thirds grid, four corner and four edge handles, movable crop frame, and aspect presets (Free, 1:1, 4:5, 3:4, 9:16, 16:9). **Cancel** restores the exact pre-crop state; **Done** commits the crop.
6
6
  - **Rotate** — rotates 90° per tap. Resets the current crop (a rotated image has a different frame of reference for the crop rectangle).
7
7
  - **Flip** — cycles none → horizontal → vertical → none.
8
8
  - **Straighten** — a fine -45°...45° slider, shown while crop mode is active.
@@ -18,13 +18,13 @@ Tap **Filters** to enter adjustment mode: a horizontal chip row selects which co
18
18
  - **Reset** (filters sub-bar) — restores all adjustments to their zero/no-op defaults, independent of crop/transform/layers.
19
19
  - Same pipeline and formulas applied in preview and export on both platforms.
20
20
 
21
- ## Milestone 4 — layers: text, stickers, shapes, drawing
21
+ ## Milestone 4 — layers: text, stickers, images, drawing
22
22
 
23
23
  - **Text** — tap **Text**, type a string in the dialog, and it's added as a centered, draggable text layer.
24
- - **Stickers** — tap **Stickers** for a picker with 8 built-in emoji stickers, any consumer-provided `stickerAssets` (see `EditorOptions.stickerAssets`), and three basic shapes (Rect/Oval/Line) sharing the same picker.
24
+ - **Stickers** — tap **Stickers** for the searchable, categorized sticker collection bundled with the package. No third-party sticker API or network connection is used.
25
25
  - **Draw** — tap **Draw** for a freehand canvas: pick a color and thin/thick width, draw multiple strokes, undo the last stroke or clear all, then **Done** commits every stroke as its own layer (so strokes with different colors/widths keep their own settings).
26
26
  - **Selection** — tap any layer on the canvas to select it (shown with a dashed bounding box) and open its mini toolbar.
27
- - **Transform** — the mini toolbar's Scale/Rotate/Opacity chips each bind a shared slider; dragging the layer directly on the canvas moves it. (Two-finger pinch/rotate gestures were scoped out in favor of sliders, to keep the gesture code small enough to be confident in without a device to test on — see Known limitations in the root README.)
27
+ - **Transform** — drag layers to move them, pinch with two fingers to scale and rotate, or use the corner handle. Scale/Rotate/Opacity sliders remain available for precise changes.
28
28
  - **Duplicate / Lock / Hide / Front / Back / Delete** — per-layer actions in the mini toolbar.
29
29
  - **Undo/redo** — header buttons, backed by a snapshot history (50 entries). One entry is recorded per *committed* operation — add/delete/duplicate/reorder, or a drag/slider gesture's final value — never per intermediate touch-move frame.
30
30
  - Layers are burned into the image identically in preview and at export, positioned in the same full-frame coordinate space as the crop overlay (so cropping after adding layers can crop them out, matching what the crop preview would suggest).
@@ -32,8 +32,7 @@ Tap **Filters** to enter adjustment mode: a horizontal chip row selects which co
32
32
  ### Known gaps in Milestone 4
33
33
 
34
34
  - **Font registration is not wired to custom font files yet.** `EditorOptions.fonts` is accepted structurally (passed through to the native layer) but text layers currently only resolve `fontFamily` against system-installed font names (Android `Typeface.create`, iOS `UIFont(name:)`) — a consumer-provided font *file* is not loaded. This is an honest scope cut, not a silent gap: closing it means adding a font-file loader (`Typeface.createFromFile` / `CTFontManagerRegisterFontsForURL`) on each platform, planned as a small follow-up.
35
- - Hit-testing for layer selection uses an axis-aligned box around each layer's center and does not account for the layer's own rotation.
36
- - No sealed per-type layer model — one flat `PhotoLayer` struct/data class covers text/sticker/shape/drawing, a deliberate simplification.
35
+ - No sealed per-type layer model — one flat `PhotoLayer` struct/data class covers text/sticker/image/drawing, a deliberate simplification.
37
36
  - Text styling is limited to color and font size for now (no stroke/background/shadow/alignment controls yet, though the fields exist on `PhotoLayer` for a later pass).
38
37
 
39
38
  Export applies the combined transform + adjustments + layers to the full-resolution source (never mutated) and writes JPEG or PNG (WebP on Android only; iOS falls back to JPEG). Output width/height/fileSize/mimeType are returned on the `EditorResult`.