react-native-photo-video-editor 0.2.1 → 0.3.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.
package/CHANGELOG.md CHANGED
@@ -2,9 +2,29 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.3.0
6
+
7
+ - Breaking API change: migrate `initialStickerId: 'brand'` to `initialStickerIds: ['brand']`.
8
+ - Replace `initialStickerId` with `initialStickerIds` and add a top-right control on the selected default sticker to cycle through default stickers on Android and iOS. Swaps preserve transforms and support undo/redo; the button is enabled only for multiple IDs.
9
+ - Place the swap control on the sticker’s top-right corner, above the scale control, so it follows dragging and rotation.
10
+ - Remove dummy sticker data from the example; default stickers are supplied by the caller.
11
+
12
+ ## 0.2.2
13
+
14
+ - Add `initialStickerId` to place a matching `stickerAssets` image at the center when opening the photo or video editor on Android and iOS.
15
+ - Validate initial sticker IDs before opening the editor and report unreadable initial images.
16
+ - Support base64 PNG data URIs for locally embedded sticker images.
17
+ - Keep initial stickers optional: the editor opens without a sticker unless one is supplied.
18
+
5
19
  ## 0.2.1
6
20
 
7
21
  - Fix an iOS build failure in `TextEditorSheet`: its private stored property `editing` collided with `UIViewController.isEditing`, which is exported to Objective-C as `editing`, so the compiler treated it as an invalid override ("cannot override with a stored property" / "overriding property must be as accessible as its enclosing type"). The property is now `isEditingExisting`; the `editing:` initialiser label is unchanged, so call sites are unaffected.
22
+ - Fix three more iOS build failures in `PhotoVideoEditorViewController`:
23
+ - `ZoomableImageView()` had no matching initialiser. The class overrides `init(frame:)`, so it does not inherit `UIView`'s no-argument initialiser; the call is now `ZoomableImageView(frame: .zero)`. This also cleared the cascading "cannot infer type of closure parameter 'bounds'" error on the `onBoundsChanged` assignment.
24
+ - Two layer-transform handlers used an immediately-applied closure inside a ternary (`{ var layer = $0; ... }()`). The `$0` inside bound to the inner closure's own parameter rather than the enclosing `map`'s element, making it a one-argument closure invoked with none. Both are rewritten as an explicit `map { current in ... }` with a `guard`.
25
+ - Fix an iOS build failure in `PhotoExporter`: `(exportOptions?["maxWidth"] as? NSNumber)?.doubleValue.map { ... }` applied `map` to the non-optional `Double` inside the optional chain rather than to the optional itself. `map` now runs on the optional `NSNumber`, keeping the `CGFloat?` type that `resize(_:maxWidth:maxHeight:)` expects.
26
+ - Fix an iOS build failure in `VideoEditSession`: inside `private extension Int64`, the bare `min`/`max` in `clamped(to:)` resolved to the static properties `Int64.min`/`Int64.max` instead of the global functions. They are now qualified as `Swift.min`/`Swift.max`.
27
+ - Fix an iOS build failure in `PhotoVideoEditor.mm`: the TurboModule class subclassed the Swift `PhotoVideoEditorSwift`, which Objective-C cannot do — Swift emits every class into the generated header with `objc_subclassing_restricted`. The TurboModule now holds a `PhotoVideoEditorSwift` instance and forwards `openEditor`, `cancelExport` and `isAvailable` to it.
8
28
 
9
29
  ## 0.2.0
10
30
 
package/README.md CHANGED
@@ -55,3 +55,30 @@ try {
55
55
  See [API](docs/api.md), [architecture](docs/architecture.md), [photo editor](docs/photo-editor.md), [video editor](docs/video-editor.md), and [roadmap](docs/roadmap.md). Known limitations are non-functional tool placeholders, no transformed export, local-URI-only input, and no verified legacy-architecture support.
56
56
 
57
57
  For local work, run `yarn`, then `yarn typecheck`, `yarn lint`, `yarn test`, and `yarn prepare`. See [CONTRIBUTING.md](CONTRIBUTING.md). MIT licensed; see [LICENSE](LICENSE).
58
+
59
+ To place a sticker at the center automatically when opening either editor, pass
60
+ `initialStickerIds` containing unique IDs from `stickerAssets`:
61
+
62
+ ```ts
63
+ await openPhotoEditor({
64
+ source: { uri: photoUri, type: 'photo' },
65
+ stickerAssets: [
66
+ { id: 'brand', uri: 'https://example.com/brand-sticker.png' },
67
+ { id: 'alternate', uri: 'https://example.com/alternate.png' },
68
+ ],
69
+ initialStickerIds: ['brand', 'alternate'],
70
+ });
71
+ ```
72
+
73
+ The first sticker starts at the center of the media and can be moved, resized, or
74
+ removed. The “Switch default sticker” control at the selected default sticker’s top-right
75
+ corner, above its bottom-right scale control, cycles through the IDs in
76
+ order and wraps around, replacing the current default sticker while preserving
77
+ its transforms. Switching is undoable. After deletion, use Undo to restore the sticker and its
78
+ controls. The swap control is disabled for one ID and appears only on the selected
79
+ default sticker. Failed swaps leave the existing sticker in place. The same option works with `openVideoEditor` and `openEditor`, even if
80
+ `features.stickers` hides the sticker picker. Omit it to start without a sticker.
81
+ An unknown or duplicate ID rejects with `E_INVALID_OPTIONS`; an unreadable image
82
+ rejects with `E_SOURCE_UNREADABLE`. HTTPS and local sticker URIs are supported.
83
+
84
+ Migration: replace `initialStickerId: 'brand'` with `initialStickerIds: ['brand']`.
@@ -185,6 +185,81 @@ class PhotoVideoEditorActivity : Activity() {
185
185
  }
186
186
  applyStatusBarStyle()
187
187
  setContentView(createEditorView())
188
+ if (!isFinishing) insertInitialSticker()
189
+ }
190
+
191
+ private val initialStickerIds: List<String> by lazy {
192
+ val ids = request.optJSONArray("initialStickerIds")
193
+ if (ids == null) emptyList() else (0 until ids.length()).map { ids.optString(it) }.filter { it.isNotBlank() }.distinct()
194
+ }
195
+ private val initialStickerLayerId = java.util.UUID.randomUUID().toString()
196
+ private val initialStickerPaths = mutableMapOf<String, String>()
197
+ private var initialStickerLoading = false
198
+
199
+ private fun currentInitialSticker(): PhotoLayer? =
200
+ (photoSession?.layerStack?.layers ?: videoSession?.layerStack?.layers)?.firstOrNull { it.id == initialStickerLayerId }
201
+
202
+ private fun switchInitialSticker() {
203
+ if (initialStickerLoading || initialStickerIds.size < 2) return
204
+ val currentIndex = initialStickerIds.indexOf(currentInitialSticker()?.stickerId)
205
+ insertInitialSticker((currentIndex + 1) % initialStickerIds.size)
206
+ }
207
+
208
+ private fun insertInitialSticker(index: Int = 0) {
209
+ if (initialStickerLoading) return
210
+ val id = initialStickerIds.getOrNull(index) ?: return
211
+ val asset = runtimeStickerAssets().firstOrNull { it.id == id } ?: return
212
+ initialStickerLoading = true
213
+ window.setFlags(android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE)
214
+ Thread {
215
+ val path = try {
216
+ val localPath = initialStickerPaths[id] ?: if (asset.uri.startsWith("https://", ignoreCase = true)) {
217
+ val output = File.createTempFile("pve_initial_sticker_", ".img", cacheDir)
218
+ val connection = java.net.URL(asset.uri).openConnection().apply {
219
+ connectTimeout = 10_000
220
+ readTimeout = 15_000
221
+ }
222
+ connection.getInputStream().use { input -> output.outputStream().use { input.copyTo(it) } }
223
+ output.absolutePath
224
+ } else SourceResolver.resolvePath(this, asset.uri, "pve_initial_sticker")
225
+ localPath?.takeIf {
226
+ val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
227
+ BitmapFactory.decodeFile(it, options)
228
+ options.outWidth > 0 && options.outHeight > 0
229
+ }
230
+ } catch (_: Exception) { null }
231
+ runOnUiThread {
232
+ initialStickerLoading = false
233
+ window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE)
234
+ if (isFinishing || isDestroyed) return@runOnUiThread
235
+ if (path == null) {
236
+ if (initialStickerPaths.isEmpty()) finishWithError("The initial sticker could not be loaded.", "E_SOURCE_UNREADABLE")
237
+ else Toast.makeText(this, "Could not load this sticker. Try again.", Toast.LENGTH_SHORT).show()
238
+ return@runOnUiThread
239
+ }
240
+ initialStickerPaths[id] = path
241
+ window.decorView.post {
242
+ if (isFinishing || isDestroyed) return@post
243
+ val uri = Uri.fromFile(File(path)).toString()
244
+ val fresh = newSticker(stickerId = id, stickerUri = uri)
245
+ val layer = currentInitialSticker()?.copy(stickerId = id, stickerUri = uri, overlayAspectRatio = fresh.overlayAspectRatio)
246
+ ?: fresh.copy(id = initialStickerLayerId)
247
+ val replace: (List<PhotoLayer>) -> List<PhotoLayer> = { layers ->
248
+ if (layers.any { it.id == initialStickerLayerId }) layers.map { if (it.id == initialStickerLayerId) layer else it }
249
+ else layers + layer
250
+ }
251
+ if (mediaType == "photo") {
252
+ photoSession?.layerStack?.commit(replace)
253
+ onLayerStackChanged()
254
+ selectLayer(layer.id)
255
+ } else videoSession?.let { session ->
256
+ session.layerStack.commit(replace)
257
+ onVideoLayerStackChanged()
258
+ selectVideoLayer(layer.id, session)
259
+ }
260
+ }
261
+ }
262
+ }.start()
188
263
  }
189
264
 
190
265
  private fun applyStatusBarStyle() {
@@ -275,6 +350,9 @@ class PhotoVideoEditorActivity : Activity() {
275
350
  previewContainer.addView(overlay, FrameLayout.LayoutParams(MATCH, MATCH))
276
351
 
277
352
  val layers = LayerOverlayView(this)
353
+ layers.swappableLayerId = initialStickerLayerId
354
+ layers.stickerSwapEnabled = initialStickerIds.size > 1
355
+ layers.onStickerSwap = { switchInitialSticker() }
278
356
  layerOverlay = layers
279
357
  previewContainer.addView(layers, FrameLayout.LayoutParams(MATCH, MATCH))
280
358
 
@@ -1456,6 +1534,9 @@ class PhotoVideoEditorActivity : Activity() {
1456
1534
  if (videoCropMode) videoCropOverlay.restore(videoMediaBounds(session, playerView, false), session.state.crop)
1457
1535
  }
1458
1536
  val layerOverlay = LayerOverlayView(this)
1537
+ layerOverlay.swappableLayerId = initialStickerLayerId
1538
+ layerOverlay.stickerSwapEnabled = initialStickerIds.size > 1
1539
+ layerOverlay.onStickerSwap = { switchInitialSticker() }
1459
1540
  videoLayerOverlay = layerOverlay
1460
1541
  preview.addView(layerOverlay, FrameLayout.LayoutParams(MATCH, MATCH))
1461
1542
  layerOverlay.onLayerDelete = { id ->
@@ -15,6 +15,13 @@ object SourceResolver {
15
15
  val uri = Uri.parse(sourceUri)
16
16
  return when (uri.scheme) {
17
17
  "file", null -> uri.path ?: sourceUri
18
+ "data" -> {
19
+ if (!sourceUri.startsWith("data:image/png;base64,")) return null
20
+ val bytes = try {
21
+ android.util.Base64.decode(sourceUri.substringAfter(','), android.util.Base64.DEFAULT)
22
+ } catch (_: IllegalArgumentException) { return null }
23
+ File.createTempFile(tempPrefix, ".png", context.cacheDir).apply { writeBytes(bytes) }.absolutePath
24
+ }
18
25
  "content" -> {
19
26
  val tempFile = File.createTempFile(tempPrefix, ".tmp", context.cacheDir)
20
27
  val copied = context.contentResolver.openInputStream(uri)?.use { input ->
@@ -28,6 +28,11 @@ class LayerOverlayView(context: Context) : View(context) {
28
28
  set(value) { field = value; invalidate() }
29
29
  var selectedLayerId: String? = null
30
30
  set(value) { field = value; invalidate() }
31
+ var swappableLayerId: String? = null
32
+ set(value) { field = value; invalidate() }
33
+ var stickerSwapEnabled = false
34
+ set(value) { field = value; invalidate() }
35
+ var onStickerSwap: (() -> Unit)? = null
31
36
  var onLayerDelete: ((String) -> Unit)? = null
32
37
  var onLayerTapped: ((String?) -> Unit)? = null
33
38
  var onLayerDoubleTapped: ((String) -> Unit)? = null
@@ -71,6 +76,10 @@ class LayerOverlayView(context: Context) : View(context) {
71
76
  }
72
77
  drawControl(canvas, -half.first, -half.second, R.drawable.ic_close, Color.rgb(255, 100, 115))
73
78
  drawControl(canvas, half.first, half.second, R.drawable.ic_fullscreen, Color.rgb(167, 139, 250))
79
+ if (selected.type == LayerType.STICKER && selected.id == swappableLayerId) {
80
+ drawControl(canvas, half.first, -half.second, R.drawable.ic_swap_sticker,
81
+ if (stickerSwapEnabled && !selected.locked) Color.rgb(167, 139, 250) else Color.GRAY)
82
+ }
74
83
  canvas.restore()
75
84
  }
76
85
 
@@ -92,6 +101,17 @@ class LayerOverlayView(context: Context) : View(context) {
92
101
  if (selected != null && !selected.locked) {
93
102
  val handle = handlePoint(selected)
94
103
  val center = centerOf(selected)
104
+ if (selected.type == LayerType.STICKER && selected.id == swappableLayerId) {
105
+ val swap = swapPoint(selected)
106
+ val swapDistance = hypot(event.x - swap.first, event.y - swap.second)
107
+ val deleteDistance = hypot(event.x - (2 * center.first - handle.first), event.y - (2 * center.second - handle.second))
108
+ val scaleDistance = hypot(event.x - handle.first, event.y - handle.second)
109
+ if (swapDistance <= handleTouchRadius && swapDistance <= deleteDistance && swapDistance <= scaleDistance) {
110
+ resetGesture()
111
+ if (stickerSwapEnabled) onStickerSwap?.invoke()
112
+ return true
113
+ }
114
+ }
95
115
  if (hypot(event.x - (2 * center.first - handle.first), event.y - (2 * center.second - handle.second)) <= handleTouchRadius) {
96
116
  onLayerDelete?.invoke(selected.id); resetGesture(); return true
97
117
  }
@@ -225,6 +245,12 @@ class LayerOverlayView(context: Context) : View(context) {
225
245
  return Pair(center.first + (half.first * cos(radians) - half.second * sin(radians)).toFloat(), center.second + (half.first * sin(radians) + half.second * cos(radians)).toFloat())
226
246
  }
227
247
 
248
+ private fun swapPoint(layer: PhotoLayer): Pair<Float, Float> {
249
+ val center = centerOf(layer); val half = selectionHalfExtents(layer)
250
+ val radians = Math.toRadians(layer.rotationDegrees.toDouble())
251
+ return Pair(center.first + (half.first * cos(radians) + half.second * sin(radians)).toFloat(), center.second + (half.first * sin(radians) - half.second * cos(radians)).toFloat())
252
+ }
253
+
228
254
  private fun isHandleHit(layer: PhotoLayer, x: Float, y: Float): Boolean {
229
255
  val handle = handlePoint(layer)
230
256
  return hypot(x - handle.first, y - handle.second) <= handleTouchRadius
@@ -0,0 +1,3 @@
1
+ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
2
+ <path android:fillColor="#FFFFFFFF" android:pathData="M7,7h11l-3,-3 1.4,-1.4L22,8l-5.6,5.4L15,12l3,-3H7zM17,17H6l3,3 -1.4,1.4L2,16l5.6,-5.4L9,12l-3,3h11z" />
3
+ </vector>