@apollohg/react-native-prose-editor 0.5.16 → 0.5.18

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.
@@ -5,7 +5,10 @@ import android.content.Context
5
5
  import android.content.ContextWrapper
6
6
  import android.graphics.Rect
7
7
  import android.graphics.RectF
8
+ import android.os.Handler
9
+ import android.os.Looper
8
10
  import android.os.SystemClock
11
+ import android.util.Log
9
12
  import android.view.Gravity
10
13
  import android.view.MotionEvent
11
14
  import android.view.View
@@ -20,8 +23,334 @@ import expo.modules.kotlin.viewevent.EventDispatcher
20
23
  import expo.modules.kotlin.views.ExpoView
21
24
  import org.json.JSONArray
22
25
  import org.json.JSONObject
26
+ import java.lang.ref.WeakReference
27
+ import java.util.concurrent.CountDownLatch
28
+ import java.util.concurrent.TimeUnit
29
+ import java.util.WeakHashMap
30
+ import java.util.concurrent.atomic.AtomicInteger
31
+ import java.util.concurrent.atomic.AtomicReference
23
32
  import uniffi.editor_core.*
24
33
 
34
+ private const val DESTROY_INVALIDATION_AWAIT_TIMEOUT_MS = 250L
35
+
36
+ private class WeakNativeEditorExpoView private constructor(
37
+ val view: WeakReference<NativeEditorExpoView?>
38
+ ) {
39
+ constructor(view: NativeEditorExpoView) : this(WeakReference(view))
40
+
41
+ companion object {
42
+ fun cleared(): WeakNativeEditorExpoView =
43
+ WeakNativeEditorExpoView(WeakReference<NativeEditorExpoView?>(null))
44
+ }
45
+ }
46
+
47
+ internal object NativeEditorViewRegistry {
48
+ private data class CommandPreparationSnapshot(
49
+ val view: NativeEditorExpoView?,
50
+ val isDetached: Boolean,
51
+ val isDestroyed: Boolean
52
+ )
53
+
54
+ private val viewsByEditorId = mutableMapOf<Long, WeakNativeEditorExpoView>()
55
+ private val detachedEditorOwnersByEditorId = mutableMapOf<Long, WeakNativeEditorExpoView>()
56
+ private val destroyedEditorIds = mutableSetOf<Long>()
57
+ private val mainHandler = Handler(Looper.getMainLooper())
58
+
59
+ @Synchronized
60
+ fun markEditorCreated(editorId: Long) {
61
+ if (editorId == 0L) return
62
+ destroyedEditorIds.remove(editorId)
63
+ }
64
+
65
+ @Synchronized
66
+ fun register(editorId: Long, view: NativeEditorExpoView): Boolean {
67
+ if (editorId == 0L) return false
68
+ if (destroyedEditorIds.contains(editorId)) return false
69
+ viewsByEditorId[editorId] = WeakNativeEditorExpoView(view)
70
+ detachedEditorOwnersByEditorId.remove(editorId)
71
+ return true
72
+ }
73
+
74
+ @Synchronized
75
+ fun unregister(
76
+ editorId: Long,
77
+ view: NativeEditorExpoView,
78
+ blockCommandsUntilRegistered: Boolean = false
79
+ ) {
80
+ if (editorId == 0L) return
81
+ val registeredView = viewsByEditorId[editorId]?.view?.get()
82
+ if (registeredView === view) {
83
+ viewsByEditorId.remove(editorId)
84
+ }
85
+ if (blockCommandsUntilRegistered) {
86
+ detachedEditorOwnersByEditorId[editorId] = WeakNativeEditorExpoView(view)
87
+ } else {
88
+ val detachedOwner = detachedEditorOwnersByEditorId[editorId]?.view?.get()
89
+ if (registeredView === view || detachedOwner === view) {
90
+ detachedEditorOwnersByEditorId.remove(editorId)
91
+ }
92
+ }
93
+ }
94
+
95
+ @Synchronized
96
+ fun isDestroyed(editorId: Long): Boolean = destroyedEditorIds.contains(editorId)
97
+
98
+ @Synchronized
99
+ internal fun forceDetachedOwnerClearedForTesting(editorId: Long) {
100
+ detachedEditorOwnersByEditorId[editorId] = WeakNativeEditorExpoView.cleared()
101
+ }
102
+
103
+ fun invalidateDestroyedEditor(editorId: Long) {
104
+ if (editorId == 0L) return
105
+ val affectedViews = synchronized(this) {
106
+ destroyedEditorIds.add(editorId)
107
+ val views = listOfNotNull(
108
+ viewsByEditorId.remove(editorId)?.view?.get(),
109
+ detachedEditorOwnersByEditorId.remove(editorId)?.view?.get()
110
+ ).distinct()
111
+ views
112
+ }
113
+ if (affectedViews.isEmpty()) return
114
+ val invalidate = Runnable {
115
+ affectedViews.forEach { view ->
116
+ view.handleEditorDestroyed(editorId)
117
+ }
118
+ }
119
+ if (Looper.myLooper() == Looper.getMainLooper()) {
120
+ invalidate.run()
121
+ } else {
122
+ val latch = CountDownLatch(1)
123
+ val posted = mainHandler.post {
124
+ try {
125
+ invalidate.run()
126
+ } finally {
127
+ latch.countDown()
128
+ }
129
+ }
130
+ if (!posted) return
131
+ try {
132
+ latch.await(DESTROY_INVALIDATION_AWAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS)
133
+ } catch (_: InterruptedException) {
134
+ Thread.currentThread().interrupt()
135
+ } catch (_: Throwable) {
136
+ return
137
+ }
138
+ }
139
+ }
140
+
141
+ fun prepareForCommandJSON(editorId: Long): String {
142
+ val prepare = {
143
+ val snapshot = synchronized(this) {
144
+ val isDestroyed = destroyedEditorIds.contains(editorId)
145
+ if (isDestroyed) {
146
+ return@synchronized CommandPreparationSnapshot(
147
+ view = null,
148
+ isDetached = false,
149
+ isDestroyed = true
150
+ )
151
+ }
152
+ val candidate = viewsByEditorId[editorId]?.view?.get()
153
+ if (candidate == null) {
154
+ viewsByEditorId.remove(editorId)
155
+ }
156
+ val detachedOwner = detachedEditorOwnersByEditorId[editorId]?.view?.get()
157
+ val isDetached = if (detachedOwner == null) {
158
+ detachedEditorOwnersByEditorId.remove(editorId)
159
+ false
160
+ } else {
161
+ true
162
+ }
163
+ CommandPreparationSnapshot(
164
+ view = candidate,
165
+ isDetached = isDetached,
166
+ isDestroyed = false
167
+ )
168
+ }
169
+ snapshot.view?.prepareForEditorCommandJSON()
170
+ ?: commandPreparationJSON(
171
+ ready = !snapshot.isDetached && !snapshot.isDestroyed,
172
+ blockedReason = if (snapshot.isDestroyed) {
173
+ "destroyed"
174
+ } else if (snapshot.isDetached) {
175
+ "detached"
176
+ } else {
177
+ null
178
+ }
179
+ )
180
+ }
181
+
182
+ if (Looper.myLooper() == Looper.getMainLooper()) {
183
+ return prepare()
184
+ }
185
+
186
+ val result = AtomicReference(commandPreparationJSON(ready = false, blockedReason = "unknown"))
187
+ val state = AtomicInteger(PREFLIGHT_STATE_QUEUED)
188
+ val latch = CountDownLatch(1)
189
+ if (!mainHandler.post {
190
+ try {
191
+ if (state.compareAndSet(PREFLIGHT_STATE_QUEUED, PREFLIGHT_STATE_RUNNING)) {
192
+ result.set(prepare())
193
+ state.set(PREFLIGHT_STATE_DONE)
194
+ }
195
+ } finally {
196
+ latch.countDown()
197
+ }
198
+ }) {
199
+ return commandPreparationJSON(ready = false, blockedReason = "unknown")
200
+ }
201
+ try {
202
+ if (!latch.await(DESTROY_INVALIDATION_AWAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
203
+ if (state.compareAndSet(PREFLIGHT_STATE_QUEUED, PREFLIGHT_STATE_CANCELLED)) {
204
+ return commandPreparationJSON(ready = false, blockedReason = "unknown")
205
+ }
206
+ if (state.get() == PREFLIGHT_STATE_RUNNING) {
207
+ latch.await()
208
+ return result.get()
209
+ }
210
+ return commandPreparationJSON(ready = false, blockedReason = "unknown")
211
+ }
212
+ } catch (_: InterruptedException) {
213
+ var interrupted = true
214
+ if (state.compareAndSet(PREFLIGHT_STATE_QUEUED, PREFLIGHT_STATE_CANCELLED)) {
215
+ Thread.currentThread().interrupt()
216
+ return commandPreparationJSON(ready = false, blockedReason = "unknown")
217
+ }
218
+ while (state.get() == PREFLIGHT_STATE_RUNNING) {
219
+ try {
220
+ latch.await()
221
+ break
222
+ } catch (_: InterruptedException) {
223
+ interrupted = true
224
+ }
225
+ }
226
+ if (interrupted) {
227
+ Thread.currentThread().interrupt()
228
+ }
229
+ return if (state.get() == PREFLIGHT_STATE_DONE) {
230
+ result.get()
231
+ } else {
232
+ commandPreparationJSON(ready = false, blockedReason = "unknown")
233
+ }
234
+ }
235
+ return result.get()
236
+ }
237
+
238
+ fun commandPreparationJSON(
239
+ ready: Boolean,
240
+ updateJSON: String? = null,
241
+ blockedReason: String? = null
242
+ ): String {
243
+ return JSONObject().apply {
244
+ put("ready", ready)
245
+ if (updateJSON != null) {
246
+ put("updateJSON", updateJSON)
247
+ }
248
+ if (!ready && blockedReason != null) {
249
+ put("blockedReason", blockedReason)
250
+ }
251
+ }.toString()
252
+ }
253
+
254
+ private const val PREFLIGHT_STATE_QUEUED = 0
255
+ private const val PREFLIGHT_STATE_RUNNING = 1
256
+ private const val PREFLIGHT_STATE_CANCELLED = 2
257
+ private const val PREFLIGHT_STATE_DONE = 3
258
+ }
259
+
260
+ private object NativeEditorOutsideTapDispatcher {
261
+ private val dispatchers = WeakHashMap<Window, OutsideTapWindowCallback>()
262
+
263
+ fun register(window: Window, view: NativeEditorExpoView) {
264
+ val currentCallback = window.callback ?: return
265
+ val previousDispatcher = dispatchers[window]
266
+ val dispatcher = if (currentCallback is OutsideTapWindowCallback) {
267
+ previousDispatcher
268
+ ?.takeIf { it !== currentCallback }
269
+ ?.transferViewsTo(currentCallback)
270
+ currentCallback
271
+ } else {
272
+ OutsideTapWindowCallback(window, currentCallback).also { nextDispatcher ->
273
+ previousDispatcher?.transferViewsTo(nextDispatcher)
274
+ window.callback = nextDispatcher
275
+ }
276
+ }
277
+ dispatchers[window] = dispatcher
278
+ dispatcher.add(view)
279
+ }
280
+
281
+ fun unregister(window: Window, view: NativeEditorExpoView) {
282
+ val dispatcher = dispatchers[window] ?: return
283
+ if (!dispatcher.remove(view)) return
284
+ dispatchers.remove(window)
285
+ if (window.callback === dispatcher) {
286
+ window.callback = dispatcher.baseCallback
287
+ }
288
+ }
289
+
290
+ private class OutsideTapWindowCallback(
291
+ private val window: Window,
292
+ val baseCallback: Window.Callback
293
+ ) : Window.Callback by baseCallback {
294
+ private val views = mutableListOf<WeakReference<NativeEditorExpoView>>()
295
+ private var disabled = false
296
+
297
+ fun add(view: NativeEditorExpoView) {
298
+ prune()
299
+ if (views.any { it.get() === view }) return
300
+ views.add(WeakReference(view))
301
+ }
302
+
303
+ fun liveViews(): List<NativeEditorExpoView> {
304
+ prune()
305
+ return views.mapNotNull { it.get() }
306
+ }
307
+
308
+ fun transferViewsTo(target: OutsideTapWindowCallback) {
309
+ liveViews().forEach { target.add(it) }
310
+ views.clear()
311
+ disabled = true
312
+ }
313
+
314
+ fun remove(view: NativeEditorExpoView): Boolean {
315
+ views.removeAll { it.get()?.let { candidate -> candidate === view } != false }
316
+ return views.isEmpty()
317
+ }
318
+
319
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean {
320
+ if (disabled) {
321
+ return baseCallback.dispatchTouchEvent(event)
322
+ }
323
+ val activeViews = liveViews()
324
+ if (event.action != MotionEvent.ACTION_DOWN || activeViews.isEmpty()) {
325
+ return baseCallback.dispatchTouchEvent(event)
326
+ }
327
+
328
+ val decisions = activeViews.map { view ->
329
+ view to view.shouldScheduleOutsideTapBlurForWindowEvent(event)
330
+ }
331
+ val result = baseCallback.dispatchTouchEvent(event)
332
+ decisions.forEach { (view, shouldBlur) ->
333
+ if (shouldBlur) {
334
+ view.scheduleOutsideTapBlurFromWindowDispatcher()
335
+ } else {
336
+ view.cancelOutsideTapBlurFromWindowDispatcher()
337
+ }
338
+ }
339
+ return result
340
+ }
341
+
342
+ private fun prune() {
343
+ views.removeAll { it.get() == null }
344
+ if (views.isEmpty() && window.callback === this) {
345
+ window.callback = baseCallback
346
+ if (dispatchers[window] === this) {
347
+ dispatchers.remove(window)
348
+ }
349
+ }
350
+ }
351
+ }
352
+ }
353
+
25
354
  /**
26
355
  * Expo Modules wrapper view that hosts a [RichTextEditorView] and bridges
27
356
  * editor events to React Native via [EventDispatcher].
@@ -43,13 +372,38 @@ class NativeEditorExpoView(
43
372
  }
44
373
  }
45
374
 
375
+ private sealed class PendingNativeAction {
376
+ data class ToolbarItemPress(val item: NativeToolbarItem) : PendingNativeAction()
377
+ data class MentionSuggestionSelect(val suggestion: NativeMentionSuggestion) : PendingNativeAction()
378
+ }
379
+
380
+ private data class PendingNativeActionScope(
381
+ val editorId: Long,
382
+ val documentVersion: Int?,
383
+ val allowedDocumentVersion: Int?,
384
+ val hadFocus: Boolean,
385
+ val hadVisibleToolbar: Boolean,
386
+ val selectionAnchor: Int?,
387
+ val selectionHead: Int?,
388
+ val mentionAnchor: Int? = null,
389
+ val mentionHead: Int? = null,
390
+ val mentionQuery: String? = null
391
+ )
392
+
393
+ private data class PendingEditorUpdateEvent(
394
+ val editorId: Long,
395
+ val updateJSON: String
396
+ )
397
+
46
398
  val richTextView: RichTextEditorView = RichTextEditorView(context)
47
399
  private val keyboardToolbarView = EditorKeyboardToolbarView(context)
400
+ private val mainHandler = Handler(Looper.getMainLooper())
48
401
 
49
402
  private val onEditorUpdate by EventDispatcher<Map<String, Any>>()
50
403
  private val onSelectionChange by EventDispatcher<Map<String, Any>>()
51
404
  private val onFocusChange by EventDispatcher<Map<String, Any>>()
52
405
  private val onContentHeightChange by EventDispatcher<Map<String, Any>>()
406
+ private val onEditorReady by EventDispatcher<Map<String, Any>>()
53
407
  @Suppress("unused")
54
408
  private val onToolbarAction by EventDispatcher<Map<String, Any>>()
55
409
  @Suppress("unused")
@@ -57,34 +411,88 @@ class NativeEditorExpoView(
57
411
 
58
412
  /** Guard flag: when true, editor updates originated from JS and should not echo back. */
59
413
  var isApplyingJSUpdate = false
414
+ internal var blockEditorUpdatePreflightForTesting = false
415
+ internal var blockThemePreflightForTesting = false
416
+ internal var onToolbarActionForTesting: ((Map<String, Any>) -> Unit)? = null
417
+ internal var onAddonEventForTesting: ((Map<String, Any>) -> Unit)? = null
418
+ internal var onFocusChangeForTesting: ((Map<String, Any>) -> Unit)? = null
419
+ internal var onEditorReadyForTesting: ((Map<String, Any>) -> Unit)? = null
420
+ internal var onRefreshToolbarStateFromEditorSelectionForTesting: (() -> String?)? = null
421
+ internal var onBeforePrepareForEditorCommandForTesting: (() -> Unit)? = null
422
+ private var isAttachedToNativeWindow = false
60
423
  private var didApplyAutoFocus = false
61
424
  private var heightBehavior = EditorHeightBehavior.FIXED
62
425
  private var lastEmittedContentHeight = 0
63
- private var outsideTapWindowCallback: Window.Callback? = null
64
- private var previousWindowCallback: Window.Callback? = null
426
+ private var outsideTapWindow: Window? = null
65
427
  private var toolbarFramesInWindow: List<RectF> = emptyList()
66
428
  private var lastToolbarTouchUptimeMs: Long? = null
67
429
  private var pendingOutsideTapBlur: Runnable? = null
68
430
  private var pendingKeyboardDismiss: Runnable? = null
431
+ private var pendingToolbarRefocus: Runnable? = null
432
+ private var pendingToolbarRefocusEditorId: Long? = null
433
+ private var pendingToolbarRefocusGeneration = 0
434
+ private var autoFocusRequested = false
69
435
  private var addons = NativeEditorAddons(null)
70
436
  private var mentionQueryState: MentionQueryState? = null
71
437
  private var lastMentionEventJson: String? = null
438
+ private var lastMentionEventEditorId: Long? = null
72
439
  private var lastThemeJson: String? = null
440
+ private var pendingThemeJson: String? = null
441
+ private var hasPendingTheme = false
442
+ private var pendingThemeRetryScheduled = false
443
+ private var pendingThemeRetryEditorId: Long? = null
444
+ private var pendingThemeRetryGeneration = 0
445
+ private var pendingThemeRetryAttempts = 0
73
446
  private var lastAddonsJson: String? = null
74
447
  private var lastRemoteSelectionsJson: String? = null
75
448
  private var lastToolbarItemsJson: String? = null
76
449
  private var lastToolbarFrameJson: String? = null
450
+ private var lastDocumentVersion: Int? = null
77
451
  private var toolbarState = NativeToolbarState.empty
78
452
  private var showsToolbar = true
79
453
  private var toolbarPlacement = ToolbarPlacement.KEYBOARD
80
454
  private var currentImeBottom = 0
81
455
  private var pendingEditorUpdateJson: String? = null
456
+ private var pendingEditorUpdateEditorId: Long? = null
82
457
  private var pendingEditorUpdateRevision = 0
83
458
  private var appliedEditorUpdateRevision = 0
459
+ private var pendingEditorUpdateRetryScheduled = false
460
+ private var pendingEditorUpdateRetryEditorId: Long? = null
461
+ private var pendingEditorUpdateRetryGeneration = 0
462
+ private var pendingEditorUpdateRetryAttempts = 0
463
+ private var pendingEditorUpdateForcedRecoveryAttempted = false
464
+ private var pendingViewCommandUpdateJson: String? = null
465
+ private var pendingViewCommandUpdateEditorId: Long? = null
466
+ private var pendingViewCommandUpdateRetryScheduled = false
467
+ private var pendingViewCommandUpdateRetryGeneration = 0
468
+ private var pendingViewCommandUpdateRetryAttempts = 0
469
+ private var pendingPreflightWakeScheduled = false
470
+ private var pendingPreflightWakeGeneration = 0
471
+ private var pendingBlurRetry: Runnable? = null
472
+ private var pendingBlurRetryEditorId: Long? = null
473
+ private var pendingBlurRetryGeneration = 0
474
+ private var pendingBlurRetryAttempts = 0
475
+ private var pendingDetachPreflightRetryScheduled = false
476
+ private var pendingDetachPreflightRetryEditorId: Long? = null
477
+ private var pendingDetachPreflightRetryGeneration = 0
478
+ private var pendingDetachPreflightRetryAttempts = 0
479
+ private var pendingNativeAction: PendingNativeAction? = null
480
+ private var pendingNativeActionScope: PendingNativeActionScope? = null
481
+ private var pendingNativeActionRetryScheduled = false
482
+ private var pendingNativeActionRetryEditorId: Long? = null
483
+ private var pendingNativeActionRetryGeneration = 0
484
+ private var pendingNativeActionRetryAttempts = 0
485
+ private var lastReadyEditorId: Long? = null
486
+ private val pendingEditorUpdateEvents = java.util.ArrayDeque<PendingEditorUpdateEvent>()
487
+ private var pendingEditorUpdateDispatchGeneration = 0
488
+ private var pendingEditorUpdateDispatchScheduled = false
84
489
 
85
490
  init {
86
491
  addView(richTextView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
87
492
  richTextView.editorEditText.editorListener = this
493
+ richTextView.onBeforeDetachedFromWindow = {
494
+ prepareForDetachFromWindow()
495
+ }
88
496
  keyboardToolbarView.onPressItem = { item ->
89
497
  handleToolbarItemPress(item)
90
498
  }
@@ -102,36 +510,130 @@ class NativeEditorExpoView(
102
510
  // Observe EditText focus changes.
103
511
  richTextView.editorEditText.setOnFocusChangeListener { _, hasFocus ->
104
512
  if (hasFocus) {
513
+ cancelPendingToolbarRefocus()
105
514
  installOutsideTapBlurHandlerIfNeeded()
106
515
  refreshMentionQuery()
107
516
  } else {
108
517
  if (shouldPreserveFocusAfterToolbarTouch()) {
109
- richTextView.editorEditText.post {
110
- focus()
111
- }
518
+ scheduleToolbarRefocus()
112
519
  return@setOnFocusChangeListener
113
520
  }
114
521
  uninstallOutsideTapBlurHandler()
115
522
  clearMentionQueryState()
523
+ clearPendingNativeActionRetry()
116
524
  }
117
525
  updateKeyboardToolbarVisibility()
118
- val event = mapOf<String, Any>("isFocused" to hasFocus)
119
- onFocusChange(event)
526
+ val event = mapOf<String, Any>(
527
+ "isFocused" to hasFocus,
528
+ "editorId" to richTextView.editorId
529
+ )
530
+ onFocusChangeForTesting?.invoke(event) ?: onFocusChange(event)
120
531
  }
121
532
  }
122
533
 
123
534
  fun setEditorId(id: Long) {
124
- richTextView.editorId = id
535
+ if (id != 0L && NativeEditorViewRegistry.isDestroyed(id)) {
536
+ setEditorId(0L)
537
+ return
538
+ }
539
+ val previousEditorId = richTextView.editorId
540
+ if (previousEditorId == id && richTextView.editorEditText.editorId == id) {
541
+ if (id != 0L && isAttachedToNativeWindow) {
542
+ if (!NativeEditorViewRegistry.register(id, this)) {
543
+ handleEditorDestroyed(id)
544
+ return
545
+ }
546
+ applyPendingEditorUpdateIfNeeded()
547
+ applyPendingThemeIfNeeded()
548
+ refreshReadyStateIfSettled()
549
+ applyAutoFocusIfNeeded()
550
+ } else if (id != 0L) {
551
+ NativeEditorViewRegistry.unregister(
552
+ id,
553
+ this,
554
+ blockCommandsUntilRegistered = true
555
+ )
556
+ }
557
+ return
558
+ }
559
+ if (previousEditorId != id) {
560
+ NativeEditorViewRegistry.unregister(previousEditorId, this)
561
+ lastDocumentVersion = null
562
+ cancelPendingToolbarRefocus()
563
+ cancelPendingEditorUpdateRetry()
564
+ if (pendingEditorUpdateEditorId != null && pendingEditorUpdateEditorId != id) {
565
+ clearPendingEditorUpdateState()
566
+ }
567
+ appliedEditorUpdateRevision = 0
568
+ clearPendingViewCommandUpdateRetry()
569
+ cancelPendingThemeRetry()
570
+ if (hasPendingTheme) {
571
+ pendingThemeRetryEditorId = id
572
+ }
573
+ cancelPendingBlurRetry()
574
+ clearPendingNativeActionRetry()
575
+ clearMentionQueryState(resetLastEvent = true)
576
+ lastReadyEditorId = null
577
+ }
578
+ if (!isAttachedToNativeWindow) {
579
+ richTextView.setEditorIdWhileDetached(id)
580
+ if (id != 0L) {
581
+ NativeEditorViewRegistry.unregister(
582
+ id,
583
+ this,
584
+ blockCommandsUntilRegistered = true
585
+ )
586
+ } else {
587
+ toolbarState = NativeToolbarState.empty
588
+ keyboardToolbarView.applyState(toolbarState)
589
+ }
590
+ return
591
+ }
592
+
593
+ if (hasPendingEditorUpdateForEditor(id)) {
594
+ richTextView.setEditorIdWhileDetached(id)
595
+ richTextView.rebindEditorIfNeeded(notifyListener = false)
596
+ } else {
597
+ richTextView.editorId = id
598
+ }
599
+ if (id != 0L) {
600
+ if (!NativeEditorViewRegistry.register(id, this)) {
601
+ handleEditorDestroyed(id)
602
+ return
603
+ }
604
+ } else {
605
+ toolbarState = NativeToolbarState.empty
606
+ keyboardToolbarView.applyState(toolbarState)
607
+ }
608
+ applyPendingEditorUpdateIfNeeded()
609
+ applyPendingThemeIfNeeded()
610
+ refreshReadyStateIfSettled()
611
+ applyAutoFocusIfNeeded()
125
612
  }
126
613
 
127
614
  fun setThemeJson(themeJson: String?) {
615
+ if (lastThemeJson == themeJson && !hasPendingTheme) return
616
+ pendingThemeJson = themeJson
617
+ hasPendingTheme = true
618
+ pendingThemeRetryEditorId = richTextView.editorId
619
+ pendingThemeRetryAttempts = 0
620
+ applyPendingThemeIfNeeded()
621
+ }
622
+
623
+ private fun applyThemeJson(themeJson: String?) {
128
624
  if (lastThemeJson == themeJson) return
129
625
  lastThemeJson = themeJson
130
626
  val theme = EditorTheme.fromJson(themeJson)
131
627
  richTextView.applyTheme(theme)
132
628
  keyboardToolbarView.applyTheme(theme?.toolbar)
133
629
  keyboardToolbarView.applyMentionTheme(theme?.mentions ?: addons.mentions?.theme)
630
+ keyboardToolbarView.requestLayout()
134
631
  updateKeyboardToolbarLayout()
632
+ updateEditorViewportInset(forceMeasureToolbar = true)
633
+ post {
634
+ updateKeyboardToolbarLayout()
635
+ updateEditorViewportInset(forceMeasureToolbar = true)
636
+ }
135
637
  }
136
638
 
137
639
  fun setHeightBehavior(rawHeightBehavior: String) {
@@ -159,6 +661,7 @@ class NativeEditorExpoView(
159
661
 
160
662
  fun setAddonsJson(addonsJson: String?) {
161
663
  if (lastAddonsJson == addonsJson) return
664
+ clearPendingNativeActionRetry()
162
665
  lastAddonsJson = addonsJson
163
666
  addons = NativeEditorAddons.fromJson(addonsJson)
164
667
  keyboardToolbarView.applyMentionTheme(richTextView.editorEditText.theme?.mentions ?: addons.mentions?.theme)
@@ -174,9 +677,12 @@ class NativeEditorExpoView(
174
677
  }
175
678
 
176
679
  fun setAutoFocus(autoFocus: Boolean) {
177
- if (!autoFocus || didApplyAutoFocus) {
178
- return
179
- }
680
+ autoFocusRequested = autoFocus
681
+ applyAutoFocusIfNeeded()
682
+ }
683
+
684
+ private fun applyAutoFocusIfNeeded() {
685
+ if (!autoFocusRequested || didApplyAutoFocus || !canFocusCurrentEditor()) return
180
686
  didApplyAutoFocus = true
181
687
  focus()
182
688
  }
@@ -193,13 +699,34 @@ class NativeEditorExpoView(
193
699
  richTextView.editorEditText.setKeyboardType(keyboardType)
194
700
  }
195
701
 
702
+ fun setEditable(editable: Boolean) {
703
+ if (richTextView.editorEditText.isEditable == editable) return
704
+ if (!editable) {
705
+ cancelPendingToolbarRefocus()
706
+ clearPendingNativeActionRetry()
707
+ }
708
+ richTextView.editorEditText.isEditable = editable
709
+ updateKeyboardToolbarVisibility()
710
+ }
711
+
196
712
  fun setShowToolbar(showToolbar: Boolean) {
713
+ if (showsToolbar == showToolbar) return
714
+ if (!showToolbar) {
715
+ cancelPendingToolbarRefocus()
716
+ clearPendingNativeActionRetry()
717
+ }
197
718
  showsToolbar = showToolbar
198
719
  updateKeyboardToolbarVisibility()
199
720
  }
200
721
 
201
722
  fun setToolbarPlacement(rawToolbarPlacement: String?) {
202
- toolbarPlacement = ToolbarPlacement.fromRaw(rawToolbarPlacement)
723
+ val nextPlacement = ToolbarPlacement.fromRaw(rawToolbarPlacement)
724
+ if (toolbarPlacement == nextPlacement) return
725
+ if (nextPlacement != ToolbarPlacement.KEYBOARD) {
726
+ cancelPendingToolbarRefocus()
727
+ clearPendingNativeActionRetry()
728
+ }
729
+ toolbarPlacement = nextPlacement
203
730
  updateKeyboardToolbarVisibility()
204
731
  }
205
732
 
@@ -209,6 +736,7 @@ class NativeEditorExpoView(
209
736
 
210
737
  fun setToolbarItemsJson(toolbarItemsJson: String?) {
211
738
  if (lastToolbarItemsJson == toolbarItemsJson) return
739
+ clearPendingNativeActionRetry()
212
740
  lastToolbarItemsJson = toolbarItemsJson
213
741
  keyboardToolbarView.setItems(NativeToolbarItem.fromJson(toolbarItemsJson))
214
742
  }
@@ -267,23 +795,495 @@ class NativeEditorExpoView(
267
795
  pendingEditorUpdateJson = editorUpdateJson
268
796
  }
269
797
 
798
+ fun setPendingEditorUpdateEditorId(editorUpdateEditorId: Long?) {
799
+ pendingEditorUpdateEditorId = editorUpdateEditorId
800
+ }
801
+
270
802
  fun setPendingEditorUpdateRevision(editorUpdateRevision: Int) {
803
+ if (pendingEditorUpdateRevision != editorUpdateRevision) {
804
+ pendingEditorUpdateRetryAttempts = 0
805
+ pendingEditorUpdateForcedRecoveryAttempted = false
806
+ }
271
807
  pendingEditorUpdateRevision = editorUpdateRevision
272
808
  }
273
809
 
810
+ private fun hasPendingEditorUpdateForEditor(editorId: Long): Boolean =
811
+ pendingEditorUpdateJson != null &&
812
+ pendingEditorUpdateRevision != 0 &&
813
+ pendingEditorUpdateRevision != appliedEditorUpdateRevision &&
814
+ pendingEditorUpdateEditorId == editorId
815
+
816
+ private fun hasPendingEditorUpdateForCurrentEditor(): Boolean =
817
+ hasPendingEditorUpdateForEditor(richTextView.editorId)
818
+
819
+ private fun pendingEditorUpdateCommandPreparationJSON(): String =
820
+ NativeEditorViewRegistry.commandPreparationJSON(
821
+ ready = false,
822
+ blockedReason = "pendingUpdate"
823
+ )
824
+
825
+ private fun shouldBlockEditorCommandForPendingUpdate(): Boolean =
826
+ hasPendingEditorUpdateForCurrentEditor()
827
+
828
+ private fun refreshReadyStateIfSettled() {
829
+ if (handleDestroyedCurrentEditorIfNeeded()) return
830
+ if (hasPendingEditorUpdateForCurrentEditor()) return
831
+ if (!isAttachedToNativeWindow) return
832
+ if (richTextView.editorEditText.editorId != richTextView.editorId) return
833
+ refreshToolbarStateFromEditorSelection()
834
+ refreshMentionQuery()
835
+ emitEditorReadyIfNeeded()
836
+ }
837
+
274
838
  fun applyPendingEditorUpdateIfNeeded() {
275
- val updateJson = pendingEditorUpdateJson ?: return
839
+ if (handleDestroyedCurrentEditorIfNeeded()) return
276
840
  if (pendingEditorUpdateRevision == 0) return
277
- if (pendingEditorUpdateRevision == appliedEditorUpdateRevision) return
278
- appliedEditorUpdateRevision = pendingEditorUpdateRevision
279
- applyEditorUpdate(updateJson)
841
+ val revision = pendingEditorUpdateRevision
842
+ val editorId = richTextView.editorId
843
+ val expectedEditorId = pendingEditorUpdateEditorId
844
+ if (expectedEditorId == null) return
845
+ if (expectedEditorId != editorId) return
846
+ if (pendingEditorUpdateJson == null) {
847
+ clearPendingEditorUpdateState(resetAppliedRevision = false)
848
+ refreshReadyStateIfSettled()
849
+ return
850
+ }
851
+ val updateJson = pendingEditorUpdateJson ?: return
852
+ if (pendingEditorUpdateRevision == appliedEditorUpdateRevision) {
853
+ clearPendingEditorUpdateState(resetAppliedRevision = false)
854
+ emitEditorReady(editorUpdateRevision = revision)
855
+ refreshReadyStateIfSettled()
856
+ return
857
+ }
858
+ if (editorId != 0L && !isAttachedToNativeWindow) return
859
+ val apply = Runnable {
860
+ if (editorId != richTextView.editorId) return@Runnable
861
+ if (expectedEditorId != richTextView.editorId) return@Runnable
862
+ if (editorId != 0L && !isAttachedToNativeWindow) return@Runnable
863
+ if (revision != pendingEditorUpdateRevision) return@Runnable
864
+ if (revision == appliedEditorUpdateRevision) {
865
+ clearPendingEditorUpdateState(resetAppliedRevision = false)
866
+ emitEditorReady(editorUpdateRevision = revision)
867
+ refreshReadyStateIfSettled()
868
+ return@Runnable
869
+ }
870
+ if (applyEditorUpdate(updateJson, scheduleViewCommandRetry = false)) {
871
+ appliedEditorUpdateRevision = revision
872
+ pendingEditorUpdateJson = null
873
+ pendingEditorUpdateEditorId = null
874
+ pendingEditorUpdateRevision = 0
875
+ pendingEditorUpdateRetryAttempts = 0
876
+ pendingEditorUpdateForcedRecoveryAttempted = false
877
+ cancelPendingEditorUpdateRetry()
878
+ emitEditorReady(editorUpdateRevision = revision)
879
+ refreshReadyStateIfSettled()
880
+ } else {
881
+ schedulePendingEditorUpdateRetry()
882
+ }
883
+ }
884
+ if (Looper.myLooper() == Looper.getMainLooper()) {
885
+ apply.run()
886
+ } else if (!post(apply)) {
887
+ richTextView.post(apply)
888
+ }
889
+ }
890
+
891
+ private fun clearPendingEditorUpdateState(resetAppliedRevision: Boolean = true) {
892
+ pendingEditorUpdateJson = null
893
+ pendingEditorUpdateEditorId = null
894
+ pendingEditorUpdateRevision = 0
895
+ if (resetAppliedRevision) {
896
+ appliedEditorUpdateRevision = 0
897
+ }
898
+ cancelPendingEditorUpdateRetry()
899
+ }
900
+
901
+ private fun cancelPendingEditorUpdateRetry() {
902
+ pendingEditorUpdateRetryScheduled = false
903
+ pendingEditorUpdateRetryEditorId = null
904
+ pendingEditorUpdateRetryAttempts = 0
905
+ pendingEditorUpdateForcedRecoveryAttempted = false
906
+ pendingEditorUpdateRetryGeneration += 1
907
+ }
908
+
909
+ private fun schedulePendingEditorUpdateRetry() {
910
+ if (pendingEditorUpdateRetryScheduled) return
911
+ val pastFastRetryBudget =
912
+ pendingEditorUpdateRetryAttempts >= MAX_PENDING_UPDATE_RETRY_ATTEMPTS
913
+ if (
914
+ pastFastRetryBudget &&
915
+ !pendingEditorUpdateForcedRecoveryAttempted &&
916
+ richTextView.editorId != 0L &&
917
+ richTextView.editorEditText.editorId == richTextView.editorId
918
+ ) {
919
+ pendingEditorUpdateForcedRecoveryAttempted = true
920
+ richTextView.editorEditText.discardTransientNativeInputForExternalRecovery()
921
+ }
922
+ if (!pastFastRetryBudget) {
923
+ pendingEditorUpdateRetryAttempts += 1
924
+ }
925
+ pendingEditorUpdateRetryEditorId = richTextView.editorId
926
+ pendingEditorUpdateRetryScheduled = true
927
+ pendingEditorUpdateRetryGeneration += 1
928
+ val retryGeneration = pendingEditorUpdateRetryGeneration
929
+ val delayMs = if (pastFastRetryBudget) {
930
+ PENDING_UPDATE_RECOVERY_RETRY_DELAY_MS
931
+ } else {
932
+ NATIVE_ACTION_RETRY_DELAY_MS * pendingEditorUpdateRetryAttempts
933
+ }
934
+ val retry = Runnable {
935
+ if (retryGeneration != pendingEditorUpdateRetryGeneration) return@Runnable
936
+ if (pendingEditorUpdateRetryEditorId != richTextView.editorId) {
937
+ clearPendingEditorUpdateState()
938
+ return@Runnable
939
+ }
940
+ pendingEditorUpdateRetryScheduled = false
941
+ pendingEditorUpdateRetryEditorId = null
942
+ applyPendingEditorUpdateIfNeeded()
943
+ }
944
+ mainHandler.postDelayed(retry, delayMs)
945
+ }
946
+
947
+ private fun clearPendingThemeRetry() {
948
+ pendingThemeJson = null
949
+ hasPendingTheme = false
950
+ cancelPendingThemeRetry()
951
+ }
952
+
953
+ private fun cancelPendingThemeRetry() {
954
+ pendingThemeRetryScheduled = false
955
+ pendingThemeRetryEditorId = null
956
+ pendingThemeRetryAttempts = 0
957
+ pendingThemeRetryGeneration += 1
958
+ }
959
+
960
+ private fun applyPendingThemeIfNeeded() {
961
+ if (handleDestroyedCurrentEditorIfNeeded()) return
962
+ if (!hasPendingTheme) return
963
+ val themeJson = pendingThemeJson
964
+ val editorId = richTextView.editorId
965
+ if (pendingThemeRetryEditorId != editorId) {
966
+ pendingThemeRetryEditorId = editorId
967
+ }
968
+ if (
969
+ blockThemePreflightForTesting ||
970
+ !richTextView.editorEditText.prepareForExternalEditorUpdate()
971
+ ) {
972
+ schedulePendingThemeRetry()
973
+ return
974
+ }
975
+ pendingThemeJson = null
976
+ hasPendingTheme = false
977
+ cancelPendingThemeRetry()
978
+ applyThemeJson(themeJson)
979
+ }
980
+
981
+ private fun schedulePendingThemeRetry() {
982
+ if (pendingThemeRetryScheduled) return
983
+ if (pendingThemeRetryAttempts >= MAX_PENDING_UPDATE_RETRY_ATTEMPTS) return
984
+ pendingThemeRetryAttempts += 1
985
+ pendingThemeRetryEditorId = richTextView.editorId
986
+ pendingThemeRetryScheduled = true
987
+ pendingThemeRetryGeneration += 1
988
+ val retryGeneration = pendingThemeRetryGeneration
989
+ val delayMs = NATIVE_ACTION_RETRY_DELAY_MS * pendingThemeRetryAttempts
990
+ val retry = Runnable {
991
+ if (retryGeneration != pendingThemeRetryGeneration) return@Runnable
992
+ if (pendingThemeRetryEditorId != richTextView.editorId) {
993
+ clearPendingThemeRetry()
994
+ return@Runnable
995
+ }
996
+ pendingThemeRetryScheduled = false
997
+ applyPendingThemeIfNeeded()
998
+ }
999
+ mainHandler.postDelayed(retry, delayMs)
1000
+ }
1001
+
1002
+ private fun clearPendingViewCommandUpdateRetry() {
1003
+ pendingViewCommandUpdateJson = null
1004
+ pendingViewCommandUpdateEditorId = null
1005
+ pendingViewCommandUpdateRetryScheduled = false
1006
+ pendingViewCommandUpdateRetryAttempts = 0
1007
+ pendingViewCommandUpdateRetryGeneration += 1
1008
+ }
1009
+
1010
+ private fun scheduleViewCommandUpdateRetry(updateJson: String) {
1011
+ if (pendingViewCommandUpdateJson != updateJson) {
1012
+ pendingViewCommandUpdateRetryAttempts = 0
1013
+ }
1014
+ pendingViewCommandUpdateJson = updateJson
1015
+ pendingViewCommandUpdateEditorId = richTextView.editorId
1016
+ if (pendingViewCommandUpdateRetryScheduled) return
1017
+ if (pendingViewCommandUpdateRetryAttempts >= MAX_PENDING_UPDATE_RETRY_ATTEMPTS) return
1018
+ pendingViewCommandUpdateRetryAttempts += 1
1019
+ pendingViewCommandUpdateRetryScheduled = true
1020
+ pendingViewCommandUpdateRetryGeneration += 1
1021
+ val retryGeneration = pendingViewCommandUpdateRetryGeneration
1022
+ val delayMs = NATIVE_ACTION_RETRY_DELAY_MS * pendingViewCommandUpdateRetryAttempts
1023
+ val retry = Runnable {
1024
+ if (retryGeneration != pendingViewCommandUpdateRetryGeneration) return@Runnable
1025
+ val retryJson = pendingViewCommandUpdateJson ?: run {
1026
+ pendingViewCommandUpdateRetryScheduled = false
1027
+ return@Runnable
1028
+ }
1029
+ if (pendingViewCommandUpdateEditorId != richTextView.editorId || richTextView.editorId == 0L) {
1030
+ clearPendingViewCommandUpdateRetry()
1031
+ return@Runnable
1032
+ }
1033
+ if (handleDestroyedCurrentEditorIfNeeded()) {
1034
+ clearPendingViewCommandUpdateRetry()
1035
+ return@Runnable
1036
+ }
1037
+ pendingViewCommandUpdateRetryScheduled = false
1038
+ if (applyEditorUpdate(retryJson, scheduleViewCommandRetry = true)) {
1039
+ clearPendingViewCommandUpdateRetry()
1040
+ }
1041
+ }
1042
+ mainHandler.postDelayed(retry, delayMs)
1043
+ }
1044
+
1045
+ private fun schedulePendingPreflightWake() {
1046
+ if (pendingPreflightWakeScheduled) return
1047
+ pendingPreflightWakeScheduled = true
1048
+ pendingPreflightWakeGeneration += 1
1049
+ val wakeGeneration = pendingPreflightWakeGeneration
1050
+ mainHandler.post {
1051
+ if (wakeGeneration != pendingPreflightWakeGeneration) return@post
1052
+ pendingPreflightWakeScheduled = false
1053
+ wakePendingPreflightWork()
1054
+ }
1055
+ }
1056
+
1057
+ private fun cancelPendingPreflightWake() {
1058
+ pendingPreflightWakeScheduled = false
1059
+ pendingPreflightWakeGeneration += 1
1060
+ }
1061
+
1062
+ private fun wakePendingPreflightWork() {
1063
+ if (Looper.myLooper() != Looper.getMainLooper()) {
1064
+ schedulePendingPreflightWake()
1065
+ return
1066
+ }
1067
+ if (handleDestroyedCurrentEditorIfNeeded()) return
1068
+ if (pendingEditorUpdateJson != null) {
1069
+ pendingEditorUpdateRetryAttempts = 0
1070
+ pendingEditorUpdateForcedRecoveryAttempted = false
1071
+ applyPendingEditorUpdateIfNeeded()
1072
+ }
1073
+ if (hasPendingTheme) {
1074
+ pendingThemeRetryAttempts = 0
1075
+ applyPendingThemeIfNeeded()
1076
+ }
1077
+ pendingViewCommandUpdateJson?.let { updateJson ->
1078
+ pendingViewCommandUpdateRetryAttempts = 0
1079
+ pendingViewCommandUpdateRetryScheduled = false
1080
+ pendingViewCommandUpdateRetryGeneration += 1
1081
+ if (applyEditorUpdate(updateJson, scheduleViewCommandRetry = true)) {
1082
+ clearPendingViewCommandUpdateRetry()
1083
+ }
1084
+ }
1085
+ retryPendingNativeActionFromWake()
1086
+ }
1087
+
1088
+ private fun clearPendingNativeActionRetry() {
1089
+ pendingNativeAction = null
1090
+ pendingNativeActionScope = null
1091
+ pendingNativeActionRetryEditorId = null
1092
+ pendingNativeActionRetryScheduled = false
1093
+ pendingNativeActionRetryAttempts = 0
1094
+ pendingNativeActionRetryGeneration += 1
1095
+ }
1096
+
1097
+ private fun currentNativeActionScope(action: PendingNativeAction): PendingNativeActionScope {
1098
+ val selection = richTextView.editorEditText.currentScalarSelection()
1099
+ val mentionScope = when (action) {
1100
+ is PendingNativeAction.MentionSuggestionSelect ->
1101
+ mentionQueryState ?: addons.mentions?.let { currentMentionQueryState(it.trigger) }
1102
+ is PendingNativeAction.ToolbarItemPress -> null
1103
+ }
1104
+ return PendingNativeActionScope(
1105
+ editorId = richTextView.editorId,
1106
+ documentVersion = lastDocumentVersion,
1107
+ allowedDocumentVersion = documentVersionFromUpdateJSON(pendingEditorUpdateJson),
1108
+ hadFocus = isEditorEffectivelyFocusedForNativeAction(),
1109
+ hadVisibleToolbar = isNativeActionToolbarVisible(action),
1110
+ selectionAnchor = selection?.first,
1111
+ selectionHead = selection?.second,
1112
+ mentionAnchor = mentionScope?.anchor,
1113
+ mentionHead = mentionScope?.head,
1114
+ mentionQuery = mentionScope?.query
1115
+ )
1116
+ }
1117
+
1118
+ private fun isPendingNativeActionScopeCurrent(
1119
+ action: PendingNativeAction,
1120
+ scope: PendingNativeActionScope
1121
+ ): Boolean {
1122
+ if (scope.editorId != richTextView.editorId) return false
1123
+ if (scope.hadFocus != isEditorEffectivelyFocusedForNativeAction()) return false
1124
+ if (scope.hadVisibleToolbar != isNativeActionToolbarVisible(action)) return false
1125
+ if (
1126
+ scope.documentVersion != lastDocumentVersion &&
1127
+ (scope.allowedDocumentVersion == null || scope.allowedDocumentVersion != lastDocumentVersion)
1128
+ ) {
1129
+ return false
1130
+ }
1131
+ val selection = richTextView.editorEditText.currentScalarSelection()
1132
+ if (scope.selectionAnchor != selection?.first || scope.selectionHead != selection?.second) {
1133
+ return false
1134
+ }
1135
+ if (action is PendingNativeAction.MentionSuggestionSelect) {
1136
+ val mentions = addons.mentions ?: return false
1137
+ val currentQuery = currentMentionQueryState(mentions.trigger) ?: return false
1138
+ if (
1139
+ scope.mentionAnchor != currentQuery.anchor ||
1140
+ scope.mentionHead != currentQuery.head ||
1141
+ scope.mentionQuery != currentQuery.query
1142
+ ) {
1143
+ return false
1144
+ }
1145
+ }
1146
+ return true
1147
+ }
1148
+
1149
+ private fun isNativeActionToolbarVisible(action: PendingNativeAction): Boolean {
1150
+ if (!showsToolbar || toolbarPlacement != ToolbarPlacement.KEYBOARD) return false
1151
+ if (keyboardToolbarView.parent == null || keyboardToolbarView.visibility != View.VISIBLE) return false
1152
+ if (action is PendingNativeAction.MentionSuggestionSelect) {
1153
+ return keyboardToolbarView.isShowingMentionSuggestions
1154
+ }
1155
+ return true
1156
+ }
1157
+
1158
+ private fun isEditorEffectivelyFocusedForNativeAction(): Boolean =
1159
+ richTextView.editorEditText.hasFocus() ||
1160
+ (pendingToolbarRefocus != null && pendingToolbarRefocusEditorId == richTextView.editorId)
1161
+
1162
+ private fun clearPendingNativeActionRetryIfScopeChanged() {
1163
+ val action = pendingNativeAction ?: return
1164
+ val scope = pendingNativeActionScope ?: return
1165
+ if (!isPendingNativeActionScopeCurrent(action, scope)) {
1166
+ clearPendingNativeActionRetry()
1167
+ }
1168
+ }
1169
+
1170
+ private fun schedulePendingNativeActionRetry(action: PendingNativeAction) {
1171
+ val isSameAction = pendingNativeAction == action
1172
+ if (isSameAction) {
1173
+ pendingNativeActionRetryAttempts += 1
1174
+ } else {
1175
+ pendingNativeActionRetryAttempts = 1
1176
+ pendingNativeActionScope = currentNativeActionScope(action)
1177
+ }
1178
+ if (pendingNativeActionRetryAttempts > MAX_NATIVE_ACTION_RETRY_ATTEMPTS) {
1179
+ pendingNativeAction = action
1180
+ pendingNativeActionRetryEditorId = richTextView.editorId
1181
+ pendingNativeActionRetryScheduled = false
1182
+ return
1183
+ }
1184
+ pendingNativeAction = action
1185
+ pendingNativeActionRetryEditorId = richTextView.editorId
1186
+ if (pendingNativeActionRetryScheduled) return
1187
+ pendingNativeActionRetryScheduled = true
1188
+ pendingNativeActionRetryGeneration += 1
1189
+ val retryGeneration = pendingNativeActionRetryGeneration
1190
+ val retry = Runnable {
1191
+ if (retryGeneration != pendingNativeActionRetryGeneration) return@Runnable
1192
+ val retryAction = pendingNativeAction ?: run {
1193
+ pendingNativeActionRetryScheduled = false
1194
+ return@Runnable
1195
+ }
1196
+ val retryScope = pendingNativeActionScope ?: run {
1197
+ clearPendingNativeActionRetry()
1198
+ return@Runnable
1199
+ }
1200
+ if (pendingNativeActionRetryEditorId != richTextView.editorId || richTextView.editorId == 0L) {
1201
+ clearPendingNativeActionRetry()
1202
+ return@Runnable
1203
+ }
1204
+ if (!isPendingNativeActionScopeCurrent(retryAction, retryScope)) {
1205
+ clearPendingNativeActionRetry()
1206
+ return@Runnable
1207
+ }
1208
+ pendingNativeActionRetryScheduled = false
1209
+ val allowNextRetry = pendingNativeActionRetryAttempts < MAX_NATIVE_ACTION_RETRY_ATTEMPTS
1210
+ when (retryAction) {
1211
+ is PendingNativeAction.ToolbarItemPress ->
1212
+ handleToolbarItemPress(retryAction.item, allowPreflightRetry = allowNextRetry)
1213
+ is PendingNativeAction.MentionSuggestionSelect ->
1214
+ insertMentionSuggestion(retryAction.suggestion, allowPreflightRetry = allowNextRetry)
1215
+ }
1216
+ }
1217
+ mainHandler.postDelayed(retry, NATIVE_ACTION_RETRY_DELAY_MS)
1218
+ }
1219
+
1220
+ private fun retryPendingNativeActionFromWake() {
1221
+ val action = pendingNativeAction ?: return
1222
+ val scope = pendingNativeActionScope ?: run {
1223
+ clearPendingNativeActionRetry()
1224
+ return
1225
+ }
1226
+ if (!isPendingNativeActionScopeCurrent(action, scope)) {
1227
+ clearPendingNativeActionRetry()
1228
+ return
1229
+ }
1230
+ pendingNativeActionRetryAttempts = 0
1231
+ pendingNativeActionRetryScheduled = false
1232
+ when (action) {
1233
+ is PendingNativeAction.ToolbarItemPress ->
1234
+ handleToolbarItemPress(action.item, allowPreflightRetry = true)
1235
+ is PendingNativeAction.MentionSuggestionSelect ->
1236
+ insertMentionSuggestion(action.suggestion, allowPreflightRetry = true)
1237
+ }
1238
+ }
1239
+
1240
+ private fun documentVersionFromUpdateJSON(updateJSON: String?): Int? =
1241
+ try {
1242
+ if (updateJSON == null) null
1243
+ else {
1244
+ val version = JSONObject(updateJSON).optInt("documentVersion", Int.MIN_VALUE)
1245
+ version.takeIf { it != Int.MIN_VALUE }
1246
+ }
1247
+ } catch (_: Throwable) {
1248
+ null
1249
+ }
1250
+
1251
+ private fun noteDocumentVersionFromUpdateJSON(updateJSON: String?) {
1252
+ documentVersionFromUpdateJSON(updateJSON)?.let { version ->
1253
+ lastDocumentVersion = version
1254
+ }
1255
+ }
1256
+
1257
+ private fun addPreflightUpdateToEvent(
1258
+ event: MutableMap<String, Any>,
1259
+ updateJSON: String?
1260
+ ) {
1261
+ if (updateJSON == null) return
1262
+ event["updateJson"] = updateJSON
1263
+ documentVersionFromUpdateJSON(updateJSON)?.let { version ->
1264
+ event["documentVersion"] = version
1265
+ }
1266
+ }
1267
+
1268
+ private fun emitAddonEvent(payload: Map<String, Any>) {
1269
+ onAddonEventForTesting?.invoke(payload) ?: onAddonEvent(payload)
1270
+ }
1271
+
1272
+ private fun canFocusCurrentEditor(): Boolean {
1273
+ val editorId = richTextView.editorId
1274
+ return editorId != 0L &&
1275
+ isAttachedToNativeWindow &&
1276
+ !NativeEditorViewRegistry.isDestroyed(editorId)
280
1277
  }
281
1278
 
282
1279
  fun focus() {
1280
+ if (!canFocusCurrentEditor()) return
283
1281
  cancelPendingOutsideTapBlur()
284
1282
  cancelPendingKeyboardDismiss()
1283
+ cancelPendingBlurRetry()
285
1284
  richTextView.editorEditText.requestFocus()
286
1285
  richTextView.editorEditText.post {
1286
+ if (!canFocusCurrentEditor()) return@post
287
1287
  val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
288
1288
  imm?.showSoftInput(richTextView.editorEditText, InputMethodManager.SHOW_IMPLICIT)
289
1289
  }
@@ -293,24 +1293,96 @@ class NativeEditorExpoView(
293
1293
  cancelPendingOutsideTapBlur()
294
1294
  cancelPendingKeyboardDismiss()
295
1295
  clearRecentToolbarTouch()
1296
+ performBlur(deferKeyboardDismiss = false, allowRetry = true)
1297
+ }
1298
+
1299
+ private fun performBlur(deferKeyboardDismiss: Boolean, allowRetry: Boolean) {
1300
+ if (handleDestroyedCurrentEditorIfNeeded()) return
1301
+ if (!richTextView.editorEditText.prepareForExternalEditorUpdate()) {
1302
+ if (allowRetry && pendingBlurRetryAttempts < MAX_PENDING_UPDATE_RETRY_ATTEMPTS) {
1303
+ schedulePendingBlurRetry(deferKeyboardDismiss)
1304
+ return
1305
+ }
1306
+ if (handleDestroyedCurrentEditorIfNeeded()) return
1307
+ richTextView.editorEditText.restoreAuthorizedTextIfNeeded()
1308
+ }
1309
+ completeBlur(deferKeyboardDismiss)
1310
+ }
1311
+
1312
+ private fun completeBlur(deferKeyboardDismiss: Boolean) {
1313
+ cancelPendingBlurRetry()
296
1314
  richTextView.editorEditText.clearFocus()
1315
+ if (deferKeyboardDismiss) {
1316
+ val dismiss = Runnable {
1317
+ pendingKeyboardDismiss = null
1318
+ if (!richTextView.editorEditText.hasFocus()) {
1319
+ val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
1320
+ imm?.hideSoftInputFromWindow(richTextView.editorEditText.windowToken, 0)
1321
+ }
1322
+ }
1323
+ pendingKeyboardDismiss = dismiss
1324
+ richTextView.editorEditText.post(dismiss)
1325
+ return
1326
+ }
297
1327
  val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
298
1328
  imm?.hideSoftInputFromWindow(richTextView.editorEditText.windowToken, 0)
299
1329
  }
300
1330
 
1331
+ private fun schedulePendingBlurRetry(deferKeyboardDismiss: Boolean) {
1332
+ pendingBlurRetry?.let {
1333
+ mainHandler.removeCallbacks(it)
1334
+ pendingBlurRetry = null
1335
+ }
1336
+ pendingBlurRetryAttempts += 1
1337
+ pendingBlurRetryEditorId = richTextView.editorId
1338
+ pendingBlurRetryGeneration += 1
1339
+ val retryGeneration = pendingBlurRetryGeneration
1340
+ val delayMs = NATIVE_ACTION_RETRY_DELAY_MS * pendingBlurRetryAttempts
1341
+ val retry = Runnable {
1342
+ pendingBlurRetry = null
1343
+ if (retryGeneration != pendingBlurRetryGeneration) return@Runnable
1344
+ if (pendingBlurRetryEditorId != richTextView.editorId) {
1345
+ pendingBlurRetryEditorId = null
1346
+ return@Runnable
1347
+ }
1348
+ pendingBlurRetryEditorId = null
1349
+ if (handleDestroyedCurrentEditorIfNeeded()) return@Runnable
1350
+ performBlur(deferKeyboardDismiss, allowRetry = true)
1351
+ }
1352
+ pendingBlurRetry = retry
1353
+ mainHandler.postDelayed(retry, delayMs)
1354
+ }
1355
+
301
1356
  private fun blurWithDeferredKeyboardDismiss() {
302
1357
  cancelPendingKeyboardDismiss()
303
1358
  clearRecentToolbarTouch()
304
- richTextView.editorEditText.clearFocus()
305
- val dismiss = Runnable {
306
- pendingKeyboardDismiss = null
307
- if (!richTextView.editorEditText.hasFocus()) {
308
- val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
309
- imm?.hideSoftInputFromWindow(richTextView.editorEditText.windowToken, 0)
310
- }
1359
+ performBlur(deferKeyboardDismiss = true, allowRetry = true)
1360
+ }
1361
+
1362
+ private fun scheduleToolbarRefocus() {
1363
+ cancelPendingToolbarRefocus()
1364
+ val editorId = richTextView.editorId
1365
+ pendingToolbarRefocusEditorId = editorId
1366
+ pendingToolbarRefocusGeneration += 1
1367
+ val refocusGeneration = pendingToolbarRefocusGeneration
1368
+ val refocus = Runnable {
1369
+ pendingToolbarRefocus = null
1370
+ if (refocusGeneration != pendingToolbarRefocusGeneration) return@Runnable
1371
+ if (pendingToolbarRefocusEditorId != richTextView.editorId) return@Runnable
1372
+ pendingToolbarRefocusEditorId = null
1373
+ focus()
1374
+ }
1375
+ pendingToolbarRefocus = refocus
1376
+ richTextView.editorEditText.post(refocus)
1377
+ }
1378
+
1379
+ private fun cancelPendingToolbarRefocus() {
1380
+ pendingToolbarRefocus?.let {
1381
+ richTextView.editorEditText.removeCallbacks(it)
1382
+ pendingToolbarRefocus = null
311
1383
  }
312
- pendingKeyboardDismiss = dismiss
313
- richTextView.editorEditText.post(dismiss)
1384
+ pendingToolbarRefocusEditorId = null
1385
+ pendingToolbarRefocusGeneration += 1
314
1386
  }
315
1387
 
316
1388
  private fun scheduleOutsideTapBlur() {
@@ -339,6 +1411,16 @@ class NativeEditorExpoView(
339
1411
  }
340
1412
  }
341
1413
 
1414
+ private fun cancelPendingBlurRetry() {
1415
+ pendingBlurRetry?.let {
1416
+ mainHandler.removeCallbacks(it)
1417
+ pendingBlurRetry = null
1418
+ }
1419
+ pendingBlurRetryEditorId = null
1420
+ pendingBlurRetryAttempts = 0
1421
+ pendingBlurRetryGeneration += 1
1422
+ }
1423
+
342
1424
  fun getCaretRectJson(): String? {
343
1425
  if (width <= 0 || height <= 0) return null
344
1426
  val rect = richTextView.caretRect() ?: return null
@@ -353,12 +1435,182 @@ class NativeEditorExpoView(
353
1435
  .toString()
354
1436
  }
355
1437
 
1438
+ override fun onAttachedToWindow() {
1439
+ super.onAttachedToWindow()
1440
+ handleAttachedToWindow()
1441
+ }
1442
+
1443
+ internal fun handleEditorDestroyed(editorId: Long) {
1444
+ if (richTextView.editorId != editorId && richTextView.editorEditText.editorId != editorId) {
1445
+ return
1446
+ }
1447
+ cancelPendingEditorUpdateRetry()
1448
+ clearPendingViewCommandUpdateRetry()
1449
+ cancelPendingThemeRetry()
1450
+ cancelPendingBlurRetry()
1451
+ cancelPendingDetachPreflightRetry()
1452
+ cancelPendingOutsideTapBlur()
1453
+ cancelPendingKeyboardDismiss()
1454
+ cancelPendingToolbarRefocus()
1455
+ cancelPendingPreflightWake()
1456
+ clearPendingNativeActionRetry()
1457
+ clearRecentToolbarTouch()
1458
+ uninstallOutsideTapBlurHandler()
1459
+ detachKeyboardToolbarIfNeeded()
1460
+ richTextView.setViewportBottomInsetPx(0)
1461
+ val editText = richTextView.editorEditText
1462
+ if (editText.hasFocus()) {
1463
+ editText.clearFocus()
1464
+ }
1465
+ val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
1466
+ imm?.hideSoftInputFromWindow(editText.windowToken, 0)
1467
+ clearMentionQueryState(resetLastEvent = true)
1468
+ pendingEditorUpdateJson = null
1469
+ pendingEditorUpdateEditorId = null
1470
+ pendingEditorUpdateRevision = 0
1471
+ appliedEditorUpdateRevision = 0
1472
+ lastDocumentVersion = null
1473
+ lastReadyEditorId = null
1474
+ toolbarState = NativeToolbarState.empty
1475
+ keyboardToolbarView.applyState(toolbarState)
1476
+ keyboardToolbarView.visibility = View.GONE
1477
+ richTextView.editorId = 0L
1478
+ }
1479
+
1480
+ private fun handleDestroyedCurrentEditorIfNeeded(): Boolean {
1481
+ val editorId = richTextView.editorId.takeIf { it != 0L }
1482
+ ?: richTextView.editorEditText.editorId.takeIf { it != 0L }
1483
+ ?: return false
1484
+ if (!NativeEditorViewRegistry.isDestroyed(editorId)) return false
1485
+ handleEditorDestroyed(editorId)
1486
+ return true
1487
+ }
1488
+
1489
+ private fun handleAttachedToWindow() {
1490
+ isAttachedToNativeWindow = true
1491
+ cancelPendingDetachPreflightRetry()
1492
+ richTextView.clearDeferredEditorUnbind()
1493
+ val editorId = richTextView.editorId
1494
+ if (editorId == 0L) return
1495
+ if (NativeEditorViewRegistry.isDestroyed(editorId)) {
1496
+ handleEditorDestroyed(editorId)
1497
+ return
1498
+ }
1499
+ if (!NativeEditorViewRegistry.register(editorId, this)) {
1500
+ handleEditorDestroyed(editorId)
1501
+ return
1502
+ }
1503
+ richTextView.rebindEditorIfNeeded(
1504
+ notifyListener = !hasPendingEditorUpdateForEditor(editorId)
1505
+ )
1506
+ if (hasPendingTheme) {
1507
+ pendingThemeRetryEditorId = editorId
1508
+ }
1509
+ applyPendingEditorUpdateIfNeeded()
1510
+ applyPendingThemeIfNeeded()
1511
+ refreshReadyStateIfSettled()
1512
+ applyAutoFocusIfNeeded()
1513
+ }
1514
+
1515
+ private fun emitEditorReady(editorUpdateRevision: Int? = null): Boolean {
1516
+ val editorId = richTextView.editorId
1517
+ if (editorId == 0L) return false
1518
+ if (!isAttachedToNativeWindow) return false
1519
+ if (richTextView.editorEditText.editorId != editorId) return false
1520
+ if (hasPendingEditorUpdateForCurrentEditor()) return false
1521
+ lastReadyEditorId = editorId
1522
+ val payload = mutableMapOf<String, Any>("editorId" to editorId)
1523
+ editorUpdateRevision?.let { payload["editorUpdateRevision"] = it }
1524
+ onEditorReadyForTesting?.invoke(payload) ?: onEditorReady(payload)
1525
+ return true
1526
+ }
1527
+
1528
+ private fun emitEditorReadyIfNeeded() {
1529
+ val editorId = richTextView.editorId
1530
+ if (lastReadyEditorId == editorId) return
1531
+ emitEditorReady()
1532
+ }
1533
+
356
1534
  override fun onDetachedFromWindow() {
1535
+ prepareForDetachFromWindow()
357
1536
  super.onDetachedFromWindow()
1537
+ handleDetachedFromWindow()
1538
+ }
1539
+
1540
+ private fun prepareForDetachFromWindow() {
1541
+ if (handleDestroyedCurrentEditorIfNeeded()) return
1542
+ val editorId = richTextView.editorId
1543
+ if (editorId == 0L || richTextView.editorEditText.editorId == 0L) return
1544
+ if (richTextView.editorEditText.prepareForExternalEditorUpdate()) {
1545
+ cancelPendingDetachPreflightRetry()
1546
+ richTextView.clearDeferredEditorUnbind()
1547
+ return
1548
+ }
1549
+ richTextView.deferEditorUnbindOnNextDetach()
1550
+ schedulePendingDetachPreflightRetry(editorId)
1551
+ }
1552
+
1553
+ private fun schedulePendingDetachPreflightRetry(editorId: Long) {
1554
+ if (pendingDetachPreflightRetryScheduled) return
1555
+ if (pendingDetachPreflightRetryAttempts >= MAX_PENDING_UPDATE_RETRY_ATTEMPTS) {
1556
+ if (handleDestroyedCurrentEditorIfNeeded()) return
1557
+ richTextView.editorEditText.restoreAuthorizedTextIfNeeded()
1558
+ cancelPendingDetachPreflightRetry()
1559
+ richTextView.unbindEditorForDetachedViewIfNeeded()
1560
+ return
1561
+ }
1562
+ pendingDetachPreflightRetryAttempts += 1
1563
+ pendingDetachPreflightRetryEditorId = editorId
1564
+ pendingDetachPreflightRetryScheduled = true
1565
+ pendingDetachPreflightRetryGeneration += 1
1566
+ val retryGeneration = pendingDetachPreflightRetryGeneration
1567
+ val delayMs = NATIVE_ACTION_RETRY_DELAY_MS * pendingDetachPreflightRetryAttempts
1568
+ mainHandler.postDelayed({
1569
+ if (retryGeneration != pendingDetachPreflightRetryGeneration) return@postDelayed
1570
+ pendingDetachPreflightRetryScheduled = false
1571
+ if (isAttachedToNativeWindow || pendingDetachPreflightRetryEditorId != richTextView.editorId) {
1572
+ cancelPendingDetachPreflightRetry()
1573
+ return@postDelayed
1574
+ }
1575
+ if (handleDestroyedCurrentEditorIfNeeded()) return@postDelayed
1576
+ if (richTextView.editorEditText.prepareForExternalEditorUpdate()) {
1577
+ cancelPendingDetachPreflightRetry()
1578
+ richTextView.unbindEditorForDetachedViewIfNeeded()
1579
+ return@postDelayed
1580
+ }
1581
+ schedulePendingDetachPreflightRetry(editorId)
1582
+ }, delayMs)
1583
+ }
1584
+
1585
+ private fun cancelPendingDetachPreflightRetry() {
1586
+ pendingDetachPreflightRetryScheduled = false
1587
+ pendingDetachPreflightRetryEditorId = null
1588
+ pendingDetachPreflightRetryAttempts = 0
1589
+ pendingDetachPreflightRetryGeneration += 1
1590
+ }
1591
+
1592
+ private fun handleDetachedFromWindow() {
1593
+ isAttachedToNativeWindow = false
1594
+ NativeEditorViewRegistry.unregister(
1595
+ richTextView.editorId,
1596
+ this,
1597
+ blockCommandsUntilRegistered = true
1598
+ )
358
1599
  cancelPendingOutsideTapBlur()
359
1600
  cancelPendingKeyboardDismiss()
1601
+ cancelPendingToolbarRefocus()
1602
+ cancelPendingBlurRetry()
1603
+ cancelPendingEditorUpdateRetry()
1604
+ clearPendingViewCommandUpdateRetry()
1605
+ cancelPendingThemeRetry()
1606
+ clearPendingNativeActionRetry()
1607
+ cancelPendingPreflightWake()
1608
+ lastReadyEditorId = null
360
1609
  uninstallOutsideTapBlurHandler()
1610
+ currentImeBottom = 0
1611
+ keyboardToolbarView.visibility = View.GONE
361
1612
  detachKeyboardToolbarIfNeeded()
1613
+ richTextView.setViewportBottomInsetPx(0)
362
1614
  }
363
1615
 
364
1616
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
@@ -436,30 +1688,132 @@ class NativeEditorExpoView(
436
1688
  if (contentHeight <= 0) return
437
1689
  if (!force && contentHeight == lastEmittedContentHeight) return
438
1690
  lastEmittedContentHeight = contentHeight
439
- onContentHeightChange(mapOf("contentHeight" to contentHeight))
1691
+ onContentHeightChange(
1692
+ mapOf(
1693
+ "contentHeight" to contentHeight,
1694
+ "editorId" to richTextView.editorId
1695
+ )
1696
+ )
440
1697
  }
441
1698
 
442
1699
  /** Applies an editor update from JS without echoing it back through events. */
443
- fun applyEditorUpdate(updateJson: String) {
444
- val apply = Runnable {
445
- isApplyingJSUpdate = true
446
- richTextView.editorEditText.applyUpdateJSON(updateJson)
447
- isApplyingJSUpdate = false
448
- }
449
- if (android.os.Looper.myLooper() == android.os.Looper.getMainLooper()) {
450
- apply.run()
451
- } else {
1700
+ fun applyEditorUpdate(updateJson: String): Boolean =
1701
+ applyEditorUpdate(updateJson, scheduleViewCommandRetry = true)
1702
+
1703
+ private fun isEditorReadyForNativeUpdate(): Boolean {
1704
+ val editorId = richTextView.editorId
1705
+ return editorId == 0L || (isAttachedToNativeWindow && richTextView.editorEditText.editorId == editorId)
1706
+ }
1707
+
1708
+ private fun applyEditorUpdate(
1709
+ updateJson: String,
1710
+ scheduleViewCommandRetry: Boolean,
1711
+ expectedEditorId: Long? = null
1712
+ ): Boolean {
1713
+ if (Looper.myLooper() != Looper.getMainLooper()) {
1714
+ val postedEditorId = expectedEditorId ?: richTextView.editorId
1715
+ val apply = Runnable {
1716
+ if (postedEditorId != richTextView.editorId) return@Runnable
1717
+ applyEditorUpdate(updateJson, scheduleViewCommandRetry, postedEditorId)
1718
+ }
452
1719
  if (!post(apply)) {
453
1720
  richTextView.post(apply)
454
1721
  }
1722
+ return false
1723
+ }
1724
+ if (expectedEditorId != null && expectedEditorId != richTextView.editorId) {
1725
+ return false
1726
+ }
1727
+ if (handleDestroyedCurrentEditorIfNeeded()) {
1728
+ return false
1729
+ }
1730
+ if (!isEditorReadyForNativeUpdate()) {
1731
+ if (scheduleViewCommandRetry) {
1732
+ scheduleViewCommandUpdateRetry(updateJson)
1733
+ }
1734
+ return false
1735
+ }
1736
+ if (
1737
+ blockEditorUpdatePreflightForTesting ||
1738
+ !richTextView.editorEditText.prepareForExternalEditorUpdate()
1739
+ ) {
1740
+ if (scheduleViewCommandRetry) {
1741
+ scheduleViewCommandUpdateRetry(updateJson)
1742
+ }
1743
+ return false
1744
+ }
1745
+ isApplyingJSUpdate = true
1746
+ return try {
1747
+ richTextView.editorEditText.applyUpdateJSON(updateJson)
1748
+ clearPendingEditorUpdateDispatchQueue("jsUpdate")
1749
+ true
1750
+ } catch (error: Throwable) {
1751
+ Log.w(LOG_TAG, "Failed to apply JS editor update", error)
1752
+ if (scheduleViewCommandRetry) {
1753
+ scheduleViewCommandUpdateRetry(updateJson)
1754
+ }
1755
+ false
1756
+ } finally {
1757
+ isApplyingJSUpdate = false
1758
+ }
1759
+ }
1760
+
1761
+ fun prepareForEditorCommandJSON(): String {
1762
+ if (Looper.myLooper() != Looper.getMainLooper()) {
1763
+ return NativeEditorViewRegistry.commandPreparationJSON(
1764
+ ready = false,
1765
+ blockedReason = "unknown"
1766
+ )
1767
+ }
1768
+ if (handleDestroyedCurrentEditorIfNeeded()) {
1769
+ return NativeEditorViewRegistry.commandPreparationJSON(
1770
+ ready = false,
1771
+ blockedReason = "destroyed"
1772
+ )
1773
+ }
1774
+ if (richTextView.editorId != 0L && !isAttachedToNativeWindow) {
1775
+ return NativeEditorViewRegistry.commandPreparationJSON(
1776
+ ready = false,
1777
+ blockedReason = "detached"
1778
+ )
1779
+ }
1780
+ if (richTextView.editorId != 0L && richTextView.editorEditText.editorId != richTextView.editorId) {
1781
+ return NativeEditorViewRegistry.commandPreparationJSON(
1782
+ ready = false,
1783
+ blockedReason = "detached"
1784
+ )
1785
+ }
1786
+ if (shouldBlockEditorCommandForPendingUpdate()) {
1787
+ return pendingEditorUpdateCommandPreparationJSON()
1788
+ }
1789
+ isApplyingJSUpdate = true
1790
+ return try {
1791
+ onBeforePrepareForEditorCommandForTesting?.invoke()
1792
+ val preparation = richTextView.editorEditText.prepareForExternalEditorCommand()
1793
+ NativeEditorViewRegistry.commandPreparationJSON(
1794
+ ready = preparation.ready,
1795
+ updateJSON = preparation.updateJSON,
1796
+ blockedReason = if (preparation.ready) null else "composition"
1797
+ )
1798
+ } finally {
1799
+ isApplyingJSUpdate = false
455
1800
  }
456
1801
  }
457
1802
 
458
1803
  override fun onSelectionChanged(anchor: Int, head: Int) {
459
1804
  val stateJson = refreshToolbarStateFromEditorSelection()
460
1805
  refreshMentionQuery()
1806
+ clearPendingNativeActionRetryIfScopeChanged()
1807
+ schedulePendingPreflightWake()
461
1808
  richTextView.refreshRemoteSelections()
462
- val event = mutableMapOf<String, Any>("anchor" to anchor, "head" to head)
1809
+ val event = mutableMapOf<String, Any>(
1810
+ "anchor" to anchor,
1811
+ "head" to head,
1812
+ "editorId" to richTextView.editorId
1813
+ )
1814
+ lastDocumentVersion?.let {
1815
+ event["documentVersion"] = it
1816
+ }
463
1817
  if (stateJson != null) {
464
1818
  event["stateJson"] = stateJson
465
1819
  }
@@ -467,57 +1821,142 @@ class NativeEditorExpoView(
467
1821
  }
468
1822
 
469
1823
  override fun onEditorUpdate(updateJSON: String) {
1824
+ if (isApplyingJSUpdate) {
1825
+ dispatchEditorUpdate(
1826
+ PendingEditorUpdateEvent(
1827
+ editorId = richTextView.editorId,
1828
+ updateJSON = updateJSON
1829
+ ),
1830
+ emitToJS = false
1831
+ )
1832
+ return
1833
+ }
1834
+ pendingEditorUpdateEvents.addLast(
1835
+ PendingEditorUpdateEvent(
1836
+ editorId = richTextView.editorId,
1837
+ updateJSON = updateJSON
1838
+ )
1839
+ )
1840
+ richTextView.editorEditText.recordImeTraceForTesting(
1841
+ "nativeViewEditorUpdateQueued",
1842
+ "queue=${pendingEditorUpdateEvents.size} jsonLength=${updateJSON.length}"
1843
+ )
1844
+ schedulePendingEditorUpdateDispatch()
1845
+ }
1846
+
1847
+ internal fun pendingEditorUpdateEventCountForTesting(): Int =
1848
+ pendingEditorUpdateEvents.size
1849
+
1850
+ private fun schedulePendingEditorUpdateDispatch() {
1851
+ pendingEditorUpdateDispatchScheduled = true
1852
+ val generation = ++pendingEditorUpdateDispatchGeneration
1853
+ mainHandler.postDelayed({
1854
+ if (generation != pendingEditorUpdateDispatchGeneration) return@postDelayed
1855
+ pendingEditorUpdateDispatchScheduled = false
1856
+ drainPendingEditorUpdateEvents()
1857
+ }, EDITOR_UPDATE_EVENT_DEBOUNCE_MS)
1858
+ }
1859
+
1860
+ private fun drainPendingEditorUpdateEvents() {
1861
+ if (pendingEditorUpdateEvents.isEmpty()) return
1862
+ val startedAt = System.nanoTime()
1863
+ var drainedCount = 0
1864
+ while (pendingEditorUpdateEvents.isNotEmpty()) {
1865
+ val event = pendingEditorUpdateEvents.removeFirst()
1866
+ if (event.editorId != richTextView.editorId) {
1867
+ richTextView.editorEditText.recordImeTraceForTesting(
1868
+ "nativeViewEditorUpdateSkipped",
1869
+ "reason=staleEditor queuedEditor=${event.editorId} currentEditor=${richTextView.editorId}"
1870
+ )
1871
+ continue
1872
+ }
1873
+ dispatchEditorUpdate(event, emitToJS = true)
1874
+ drainedCount += 1
1875
+ }
1876
+ richTextView.editorEditText.recordImeTraceForTesting(
1877
+ "nativeViewEditorUpdateDrained",
1878
+ "count=$drainedCount totalUs=${nanosToMicros(System.nanoTime() - startedAt)}"
1879
+ )
1880
+ }
1881
+
1882
+ private fun clearPendingEditorUpdateDispatchQueue(reason: String) {
1883
+ if (pendingEditorUpdateEvents.isEmpty() && !pendingEditorUpdateDispatchScheduled) return
1884
+ val clearedCount = pendingEditorUpdateEvents.size
1885
+ pendingEditorUpdateEvents.clear()
1886
+ pendingEditorUpdateDispatchScheduled = false
1887
+ pendingEditorUpdateDispatchGeneration += 1
1888
+ richTextView.editorEditText.recordImeTraceForTesting(
1889
+ "nativeViewEditorUpdateQueueCleared",
1890
+ "reason=$reason count=$clearedCount"
1891
+ )
1892
+ }
1893
+
1894
+ private fun dispatchEditorUpdate(event: PendingEditorUpdateEvent, emitToJS: Boolean) {
1895
+ val updateJSON = event.updateJSON
1896
+ val startedAt = System.nanoTime()
1897
+ noteDocumentVersionFromUpdateJSON(updateJSON)
1898
+ val noteNanos = System.nanoTime() - startedAt
1899
+ val toolbarStartedAt = System.nanoTime()
470
1900
  NativeToolbarState.fromUpdateJson(updateJSON)?.let { state ->
471
1901
  toolbarState = state
472
1902
  keyboardToolbarView.applyState(state)
473
1903
  }
1904
+ val toolbarNanos = System.nanoTime() - toolbarStartedAt
1905
+ val mentionStartedAt = System.nanoTime()
474
1906
  refreshMentionQuery()
1907
+ val mentionNanos = System.nanoTime() - mentionStartedAt
1908
+ val retryStartedAt = System.nanoTime()
1909
+ clearPendingNativeActionRetryIfScopeChanged()
1910
+ schedulePendingPreflightWake()
475
1911
  richTextView.refreshRemoteSelections()
1912
+ val retryNanos = System.nanoTime() - retryStartedAt
476
1913
  if (heightBehavior == EditorHeightBehavior.AUTO_GROW) {
477
1914
  post {
478
1915
  requestLayout()
479
1916
  emitContentHeightIfNeeded(force = false)
480
1917
  }
481
1918
  }
482
- if (isApplyingJSUpdate) return
483
- val event = mapOf<String, Any>("updateJson" to updateJSON)
484
- onEditorUpdate(event)
1919
+ val emitStartedAt = System.nanoTime()
1920
+ if (emitToJS) {
1921
+ val payload = mapOf<String, Any>(
1922
+ "updateJson" to updateJSON,
1923
+ "editorId" to event.editorId
1924
+ )
1925
+ onEditorUpdate(payload)
1926
+ }
1927
+ val totalNanos = System.nanoTime() - startedAt
1928
+ richTextView.editorEditText.recordImeTraceForTesting(
1929
+ "nativeViewEditorUpdateDispatch",
1930
+ "emitToJS=$emitToJS jsonLength=${updateJSON.length} noteUs=${nanosToMicros(noteNanos)} toolbarUs=${nanosToMicros(toolbarNanos)} mentionUs=${nanosToMicros(mentionNanos)} retryUs=${nanosToMicros(retryNanos)} emitUs=${nanosToMicros(System.nanoTime() - emitStartedAt)} totalUs=${nanosToMicros(totalNanos)}"
1931
+ )
485
1932
  }
486
1933
 
487
1934
  private fun installOutsideTapBlurHandlerIfNeeded() {
488
1935
  val window = resolveActivity(context)?.window ?: return
489
- val currentCallback = window.callback ?: return
490
- if (currentCallback === outsideTapWindowCallback) return
491
-
492
- val wrappedCallback = object : Window.Callback by currentCallback {
493
- override fun dispatchTouchEvent(event: MotionEvent): Boolean {
494
- val shouldBlur =
495
- event.action == MotionEvent.ACTION_DOWN &&
496
- richTextView.editorEditText.hasFocus() &&
497
- isTouchOutsideEditor(event)
498
- val result = currentCallback.dispatchTouchEvent(event)
499
- if (shouldBlur) {
500
- scheduleOutsideTapBlur()
501
- } else if (event.action == MotionEvent.ACTION_DOWN) {
502
- cancelPendingOutsideTapBlur()
503
- }
504
- return result
505
- }
506
- }
507
-
508
- previousWindowCallback = currentCallback
509
- outsideTapWindowCallback = wrappedCallback
510
- window.callback = wrappedCallback
1936
+ if (outsideTapWindow === window) return
1937
+ uninstallOutsideTapBlurHandler()
1938
+ NativeEditorOutsideTapDispatcher.register(window, this)
1939
+ outsideTapWindow = window
511
1940
  }
512
1941
 
513
1942
  private fun uninstallOutsideTapBlurHandler() {
514
- val window = resolveActivity(context)?.window ?: return
515
- val callback = outsideTapWindowCallback ?: return
516
- if (window.callback === callback) {
517
- window.callback = previousWindowCallback ?: callback
518
- }
519
- outsideTapWindowCallback = null
520
- previousWindowCallback = null
1943
+ val window = outsideTapWindow ?: return
1944
+ NativeEditorOutsideTapDispatcher.unregister(window, this)
1945
+ outsideTapWindow = null
1946
+ }
1947
+
1948
+ internal fun shouldScheduleOutsideTapBlurForWindowEvent(event: MotionEvent): Boolean =
1949
+ isAttachedToNativeWindow &&
1950
+ event.action == MotionEvent.ACTION_DOWN &&
1951
+ richTextView.editorEditText.hasFocus() &&
1952
+ isTouchOutsideEditor(event)
1953
+
1954
+ internal fun scheduleOutsideTapBlurFromWindowDispatcher() {
1955
+ scheduleOutsideTapBlur()
1956
+ }
1957
+
1958
+ internal fun cancelOutsideTapBlurFromWindowDispatcher() {
1959
+ cancelPendingOutsideTapBlur()
521
1960
  }
522
1961
 
523
1962
  private fun isTouchOutsideEditor(event: MotionEvent): Boolean {
@@ -555,6 +1994,125 @@ class NativeEditorExpoView(
555
1994
  internal fun shouldPreserveFocusAfterToolbarTouchForTesting(): Boolean =
556
1995
  shouldPreserveFocusAfterToolbarTouch()
557
1996
 
1997
+ internal fun setAttachedToNativeWindowForTesting(isAttached: Boolean) {
1998
+ isAttachedToNativeWindow = isAttached
1999
+ }
2000
+
2001
+ internal fun handleAttachedToWindowForTesting() {
2002
+ handleAttachedToWindow()
2003
+ }
2004
+
2005
+ internal fun handleDetachedFromWindowForTesting() {
2006
+ prepareForDetachFromWindow()
2007
+ handleDetachedFromWindow()
2008
+ }
2009
+
2010
+ internal fun performBlurForTesting(deferKeyboardDismiss: Boolean = false) {
2011
+ performBlur(deferKeyboardDismiss = deferKeyboardDismiss, allowRetry = true)
2012
+ }
2013
+
2014
+ internal fun pendingBlurRetryAttemptsForTesting(): Int = pendingBlurRetryAttempts
2015
+
2016
+ internal fun pendingDetachPreflightRetryAttemptsForTesting(): Int =
2017
+ pendingDetachPreflightRetryAttempts
2018
+
2019
+ internal fun hasPendingOutsideTapBlurForTesting(): Boolean = pendingOutsideTapBlur != null
2020
+
2021
+ internal fun hasPendingKeyboardDismissForTesting(): Boolean = pendingKeyboardDismiss != null
2022
+
2023
+ internal fun hasPendingPreflightWakeForTesting(): Boolean = pendingPreflightWakeScheduled
2024
+
2025
+ internal fun hasPendingToolbarRefocusForTesting(): Boolean = pendingToolbarRefocus != null
2026
+
2027
+ internal fun isKeyboardToolbarAttachedForTesting(): Boolean = keyboardToolbarView.parent != null
2028
+
2029
+ internal fun currentImeBottomForTesting(): Int = currentImeBottom
2030
+
2031
+ internal fun setCurrentImeBottomForTesting(bottom: Int) {
2032
+ currentImeBottom = bottom
2033
+ }
2034
+
2035
+ internal fun updateAttachedKeyboardToolbarForInsetsForTesting() {
2036
+ updateAttachedKeyboardToolbarForInsets()
2037
+ }
2038
+
2039
+ internal fun scheduleToolbarRefocusForTesting() {
2040
+ scheduleToolbarRefocus()
2041
+ }
2042
+
2043
+ internal fun applyAutoFocusForTesting() {
2044
+ applyAutoFocusIfNeeded()
2045
+ }
2046
+
2047
+ internal fun installOutsideTapBlurHandlerForTesting() {
2048
+ installOutsideTapBlurHandlerIfNeeded()
2049
+ }
2050
+
2051
+ internal fun uninstallOutsideTapBlurHandlerForTesting() {
2052
+ uninstallOutsideTapBlurHandler()
2053
+ }
2054
+
2055
+ internal fun schedulePendingPreflightWakeForTesting() {
2056
+ schedulePendingPreflightWake()
2057
+ }
2058
+
2059
+ internal fun hasPendingNativeActionForTesting(): Boolean = pendingNativeAction != null
2060
+
2061
+ internal fun pendingNativeActionRetryAttemptsForTesting(): Int = pendingNativeActionRetryAttempts
2062
+
2063
+ internal fun lastDocumentVersionForTesting(): Int? = lastDocumentVersion
2064
+
2065
+ internal fun setLastDocumentVersionForTesting(documentVersion: Int?) {
2066
+ lastDocumentVersion = documentVersion
2067
+ }
2068
+
2069
+ internal fun refreshToolbarStateFromEditorSelectionForTesting(): String? =
2070
+ refreshToolbarStateFromEditorSelection()
2071
+
2072
+ internal fun handleToolbarItemPressForTesting(item: NativeToolbarItem) {
2073
+ handleToolbarItemPress(item)
2074
+ }
2075
+
2076
+ internal fun insertMentionSuggestionForTesting(suggestion: NativeMentionSuggestion) {
2077
+ insertMentionSuggestion(suggestion)
2078
+ }
2079
+
2080
+ internal fun wakePendingPreflightWorkForTesting() {
2081
+ wakePendingPreflightWork()
2082
+ }
2083
+
2084
+ internal fun emitEditorReadyForTesting(editorUpdateRevision: Int? = null): Boolean =
2085
+ emitEditorReady(editorUpdateRevision)
2086
+
2087
+ internal fun pendingEditorUpdateJsonForTesting(): String? = pendingEditorUpdateJson
2088
+
2089
+ internal fun pendingEditorUpdateRevisionForTesting(): Int = pendingEditorUpdateRevision
2090
+
2091
+ internal fun setAppliedEditorUpdateRevisionForTesting(editorUpdateRevision: Int) {
2092
+ appliedEditorUpdateRevision = editorUpdateRevision
2093
+ }
2094
+
2095
+ internal fun pendingEditorUpdateEditorIdForTesting(): Long? = pendingEditorUpdateEditorId
2096
+
2097
+ internal fun pendingViewCommandUpdateJsonForTesting(): String? = pendingViewCommandUpdateJson
2098
+
2099
+ internal fun pendingViewCommandUpdateRetryAttemptsForTesting(): Int =
2100
+ pendingViewCommandUpdateRetryAttempts
2101
+
2102
+ internal fun scheduleViewCommandUpdateRetryForTesting(updateJson: String) {
2103
+ scheduleViewCommandUpdateRetry(updateJson)
2104
+ }
2105
+
2106
+ internal fun pendingThemeJsonForTesting(): String? = pendingThemeJson.takeIf { hasPendingTheme }
2107
+
2108
+ internal fun lastThemeJsonForTesting(): String? = lastThemeJson
2109
+
2110
+ internal fun pendingThemeRetryAttemptsForTesting(): Int = pendingThemeRetryAttempts
2111
+
2112
+ internal fun applyPendingThemeForTesting() {
2113
+ applyPendingThemeIfNeeded()
2114
+ }
2115
+
558
2116
  private fun isTouchInsideStandaloneToolbar(event: MotionEvent): Boolean {
559
2117
  val visibleWindowFrame = Rect()
560
2118
  getWindowVisibleDisplayFrame(visibleWindowFrame)
@@ -619,6 +2177,14 @@ class NativeEditorExpoView(
619
2177
  private const val TOOLBAR_HIT_SLOP_DP = 8f
620
2178
  private const val TOOLBAR_FOCUS_PRESERVE_MS = 750L
621
2179
  private const val OUTSIDE_TAP_BLUR_DELAY_MS = 100L
2180
+ private const val NATIVE_ACTION_RETRY_DELAY_MS = 16L
2181
+ private const val EDITOR_UPDATE_EVENT_DEBOUNCE_MS = 64L
2182
+ private const val PENDING_UPDATE_RECOVERY_RETRY_DELAY_MS = 250L
2183
+ private const val MAX_NATIVE_ACTION_RETRY_ATTEMPTS = 3
2184
+ private const val MAX_PENDING_UPDATE_RETRY_ATTEMPTS = 5
2185
+ private const val LOG_TAG = "NativeEditor"
2186
+
2187
+ private fun nanosToMicros(nanos: Long): Long = nanos / 1_000L
622
2188
  }
623
2189
 
624
2190
  private fun resolveActivity(context: Context): Activity? {
@@ -658,8 +2224,12 @@ class NativeEditorExpoView(
658
2224
  )
659
2225
  }
660
2226
 
661
- private fun clearMentionQueryState() {
2227
+ private fun clearMentionQueryState(resetLastEvent: Boolean = false) {
662
2228
  mentionQueryState = null
2229
+ if (resetLastEvent) {
2230
+ lastMentionEventJson = null
2231
+ lastMentionEventEditorId = null
2232
+ }
663
2233
  syncKeyboardToolbarMentionSuggestions(emptyList())
664
2234
  }
665
2235
 
@@ -722,10 +2292,15 @@ class NativeEditorExpoView(
722
2292
  .put("trigger", trigger)
723
2293
  .put("range", JSONObject().put("anchor", anchor).put("head", head))
724
2294
  .put("isActive", isActive)
2295
+ .apply {
2296
+ lastDocumentVersion?.let { put("documentVersion", it) }
2297
+ }
725
2298
  .toString()
726
- if (eventJson == lastMentionEventJson) return
2299
+ val editorId = richTextView.editorId
2300
+ if (eventJson == lastMentionEventJson && editorId == lastMentionEventEditorId) return
727
2301
  lastMentionEventJson = eventJson
728
- onAddonEvent(mapOf("eventJson" to eventJson))
2302
+ lastMentionEventEditorId = editorId
2303
+ emitAddonEvent(mapOf("eventJson" to eventJson, "editorId" to editorId))
729
2304
  }
730
2305
 
731
2306
  private fun resolvedMentionAttrs(
@@ -748,15 +2323,19 @@ class NativeEditorExpoView(
748
2323
  .put("trigger", trigger)
749
2324
  .put("suggestionKey", suggestion.key)
750
2325
  .put("attrs", attrs)
2326
+ .apply {
2327
+ lastDocumentVersion?.let { put("documentVersion", it) }
2328
+ }
751
2329
  .toString()
752
- onAddonEvent(mapOf("eventJson" to eventJson))
2330
+ emitAddonEvent(mapOf("eventJson" to eventJson, "editorId" to richTextView.editorId))
753
2331
  }
754
2332
 
755
2333
  private fun emitMentionSelectRequest(
756
2334
  trigger: String,
757
2335
  suggestion: NativeMentionSuggestion,
758
2336
  attrs: JSONObject,
759
- range: MentionQueryState
2337
+ range: MentionQueryState,
2338
+ preflightUpdateJSON: String?
760
2339
  ) {
761
2340
  val eventJson = JSONObject()
762
2341
  .put("type", "mentionsSelectRequest")
@@ -764,16 +2343,60 @@ class NativeEditorExpoView(
764
2343
  .put("suggestionKey", suggestion.key)
765
2344
  .put("attrs", attrs)
766
2345
  .put("range", JSONObject().put("anchor", range.anchor).put("head", range.head))
2346
+ .apply {
2347
+ if (preflightUpdateJSON != null) {
2348
+ put("updateJson", preflightUpdateJSON)
2349
+ }
2350
+ (documentVersionFromUpdateJSON(preflightUpdateJSON) ?: lastDocumentVersion)
2351
+ ?.let { put("documentVersion", it) }
2352
+ }
767
2353
  .toString()
768
- onAddonEvent(mapOf("eventJson" to eventJson))
2354
+ emitAddonEvent(mapOf("eventJson" to eventJson, "editorId" to richTextView.editorId))
769
2355
  }
770
2356
 
771
- private fun insertMentionSuggestion(suggestion: NativeMentionSuggestion) {
2357
+ private fun insertMentionSuggestion(
2358
+ suggestion: NativeMentionSuggestion,
2359
+ allowPreflightRetry: Boolean = true
2360
+ ) {
2361
+ if (handleDestroyedCurrentEditorIfNeeded()) return
2362
+ if (!richTextView.editorEditText.isEditable) {
2363
+ clearPendingNativeActionRetry()
2364
+ return
2365
+ }
772
2366
  val mentions = addons.mentions ?: return
773
- val queryState = mentionQueryState ?: return
2367
+ if (shouldBlockEditorCommandForPendingUpdate()) {
2368
+ if (allowPreflightRetry) {
2369
+ schedulePendingNativeActionRetry(
2370
+ PendingNativeAction.MentionSuggestionSelect(suggestion)
2371
+ )
2372
+ }
2373
+ return
2374
+ }
2375
+ val preparation = richTextView.editorEditText.prepareForExternalEditorCommand()
2376
+ if (!preparation.ready) {
2377
+ if (allowPreflightRetry) {
2378
+ schedulePendingNativeActionRetry(
2379
+ PendingNativeAction.MentionSuggestionSelect(suggestion)
2380
+ )
2381
+ }
2382
+ return
2383
+ }
2384
+ val preflightUpdateJSON = preparation.updateJSON
2385
+ noteDocumentVersionFromUpdateJSON(preflightUpdateJSON)
2386
+ clearPendingNativeActionRetry()
2387
+ val queryState = currentMentionQueryState(mentions.trigger) ?: run {
2388
+ clearMentionQueryState()
2389
+ return
2390
+ }
2391
+ val freshSuggestions = filteredMentionSuggestions(queryState, mentions)
2392
+ if (freshSuggestions.none { it.key == suggestion.key }) {
2393
+ refreshMentionQuery()
2394
+ return
2395
+ }
2396
+ mentionQueryState = queryState
774
2397
  val attrs = resolvedMentionAttrs(mentions.trigger, suggestion)
775
2398
  if (mentions.resolveSelectionAttrs || mentions.resolveTheme) {
776
- emitMentionSelectRequest(mentions.trigger, suggestion, attrs, queryState)
2399
+ emitMentionSelectRequest(mentions.trigger, suggestion, attrs, queryState, preflightUpdateJSON)
777
2400
  lastMentionEventJson = null
778
2401
  clearMentionQueryState()
779
2402
  return
@@ -803,7 +2426,14 @@ class NativeEditorExpoView(
803
2426
 
804
2427
  private fun refreshToolbarStateFromEditorSelection(): String? {
805
2428
  if (richTextView.editorId == 0L) return null
2429
+ if (handleDestroyedCurrentEditorIfNeeded()) return null
2430
+ onRefreshToolbarStateFromEditorSelectionForTesting?.let { callback ->
2431
+ val stateJson = callback()
2432
+ noteDocumentVersionFromUpdateJSON(stateJson)
2433
+ return stateJson
2434
+ }
806
2435
  val stateJson = editorGetSelectionState(richTextView.editorId.toULong())
2436
+ noteDocumentVersionFromUpdateJSON(stateJson)
807
2437
  val state = NativeToolbarState.fromUpdateJson(stateJson) ?: return null
808
2438
  toolbarState = state
809
2439
  keyboardToolbarView.applyState(state)
@@ -847,6 +2477,9 @@ class NativeEditorExpoView(
847
2477
  }
848
2478
 
849
2479
  private fun updateAttachedKeyboardToolbarForInsets() {
2480
+ if (currentImeBottom <= 0) {
2481
+ clearPendingNativeActionRetry()
2482
+ }
850
2483
  keyboardToolbarView.visibility = if (currentImeBottom > 0) View.VISIBLE else View.INVISIBLE
851
2484
  updateEditorViewportInset()
852
2485
  }
@@ -854,6 +2487,7 @@ class NativeEditorExpoView(
854
2487
  private fun updateKeyboardToolbarVisibility() {
855
2488
  val shouldAttach =
856
2489
  showsToolbar &&
2490
+ canFocusCurrentEditor() &&
857
2491
  toolbarPlacement == ToolbarPlacement.KEYBOARD &&
858
2492
  richTextView.editorEditText.isEditable &&
859
2493
  richTextView.editorEditText.hasFocus()
@@ -870,7 +2504,7 @@ class NativeEditorExpoView(
870
2504
  updateEditorViewportInset()
871
2505
  }
872
2506
 
873
- private fun updateEditorViewportInset() {
2507
+ private fun updateEditorViewportInset(forceMeasureToolbar: Boolean = false) {
874
2508
  val shouldReserveToolbarSpace =
875
2509
  heightBehavior == EditorHeightBehavior.FIXED &&
876
2510
  showsToolbar &&
@@ -889,7 +2523,7 @@ class NativeEditorExpoView(
889
2523
  val toolbarTheme = richTextView.editorEditText.theme?.toolbar
890
2524
  val density = resources.displayMetrics.density
891
2525
  val horizontalInsetPx = ((toolbarTheme?.resolvedHorizontalInset() ?: 0f) * density).toInt()
892
- if (keyboardToolbarView.measuredHeight == 0) {
2526
+ if (forceMeasureToolbar || keyboardToolbarView.measuredHeight == 0) {
893
2527
  val availableWidth = (hostWidth - horizontalInsetPx * 2).coerceAtLeast(0)
894
2528
  val widthSpec = MeasureSpec.makeMeasureSpec(availableWidth, MeasureSpec.AT_MOST)
895
2529
  val heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
@@ -904,7 +2538,46 @@ class NativeEditorExpoView(
904
2538
  richTextView.editorEditText.performToolbarToggleList(listType, isActive)
905
2539
  }
906
2540
 
907
- private fun handleToolbarItemPress(item: NativeToolbarItem) {
2541
+ private fun handleToolbarItemPress(
2542
+ item: NativeToolbarItem,
2543
+ allowPreflightRetry: Boolean = true
2544
+ ) {
2545
+ if (handleDestroyedCurrentEditorIfNeeded()) return
2546
+ if (!richTextView.editorEditText.isEditable) {
2547
+ clearPendingNativeActionRetry()
2548
+ return
2549
+ }
2550
+ var preflightUpdateJSON: String? = null
2551
+ val needsEditorPreflight = when (item.type) {
2552
+ ToolbarItemKind.mark,
2553
+ ToolbarItemKind.heading,
2554
+ ToolbarItemKind.blockquote,
2555
+ ToolbarItemKind.list,
2556
+ ToolbarItemKind.command,
2557
+ ToolbarItemKind.node,
2558
+ ToolbarItemKind.action -> true
2559
+ ToolbarItemKind.group,
2560
+ ToolbarItemKind.separator -> false
2561
+ }
2562
+ if (needsEditorPreflight) {
2563
+ if (shouldBlockEditorCommandForPendingUpdate()) {
2564
+ if (allowPreflightRetry) {
2565
+ schedulePendingNativeActionRetry(PendingNativeAction.ToolbarItemPress(item))
2566
+ }
2567
+ return
2568
+ }
2569
+ val preparation = richTextView.editorEditText.prepareForExternalEditorCommand()
2570
+ if (!preparation.ready) {
2571
+ if (allowPreflightRetry) {
2572
+ schedulePendingNativeActionRetry(PendingNativeAction.ToolbarItemPress(item))
2573
+ }
2574
+ return
2575
+ }
2576
+ preflightUpdateJSON = preparation.updateJSON
2577
+ noteDocumentVersionFromUpdateJSON(preflightUpdateJSON)
2578
+ clearPendingNativeActionRetry()
2579
+ }
2580
+ if (handleDestroyedCurrentEditorIfNeeded()) return
908
2581
  when (item.type) {
909
2582
  ToolbarItemKind.mark -> item.mark?.let { richTextView.editorEditText.performToolbarToggleMark(it) }
910
2583
  ToolbarItemKind.heading -> item.headingLevel?.let { richTextView.editorEditText.performToolbarToggleHeading(it) }
@@ -918,7 +2591,20 @@ class NativeEditorExpoView(
918
2591
  null -> Unit
919
2592
  }
920
2593
  ToolbarItemKind.node -> item.nodeType?.let { richTextView.editorEditText.performToolbarInsertNode(it) }
921
- ToolbarItemKind.action -> item.key?.let { onToolbarAction(mapOf("key" to it)) }
2594
+ ToolbarItemKind.action -> item.key?.let {
2595
+ if (handleDestroyedCurrentEditorIfNeeded()) return
2596
+ val payload = mutableMapOf<String, Any>(
2597
+ "key" to it,
2598
+ "editorId" to richTextView.editorId
2599
+ )
2600
+ addPreflightUpdateToEvent(payload, preflightUpdateJSON)
2601
+ if (!payload.containsKey("documentVersion")) {
2602
+ lastDocumentVersion?.let { version ->
2603
+ payload["documentVersion"] = version
2604
+ }
2605
+ }
2606
+ onToolbarActionForTesting?.invoke(payload) ?: onToolbarAction(payload)
2607
+ }
922
2608
  ToolbarItemKind.group -> Unit
923
2609
  ToolbarItemKind.separator -> Unit
924
2610
  }