@apollohg/react-native-prose-editor 0.5.15 → 0.5.17

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,33 @@ 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
+
46
393
  val richTextView: RichTextEditorView = RichTextEditorView(context)
47
394
  private val keyboardToolbarView = EditorKeyboardToolbarView(context)
395
+ private val mainHandler = Handler(Looper.getMainLooper())
48
396
 
49
397
  private val onEditorUpdate by EventDispatcher<Map<String, Any>>()
50
398
  private val onSelectionChange by EventDispatcher<Map<String, Any>>()
51
399
  private val onFocusChange by EventDispatcher<Map<String, Any>>()
52
400
  private val onContentHeightChange by EventDispatcher<Map<String, Any>>()
401
+ private val onEditorReady by EventDispatcher<Map<String, Any>>()
53
402
  @Suppress("unused")
54
403
  private val onToolbarAction by EventDispatcher<Map<String, Any>>()
55
404
  @Suppress("unused")
@@ -57,34 +406,85 @@ class NativeEditorExpoView(
57
406
 
58
407
  /** Guard flag: when true, editor updates originated from JS and should not echo back. */
59
408
  var isApplyingJSUpdate = false
409
+ internal var blockEditorUpdatePreflightForTesting = false
410
+ internal var blockThemePreflightForTesting = false
411
+ internal var onToolbarActionForTesting: ((Map<String, Any>) -> Unit)? = null
412
+ internal var onAddonEventForTesting: ((Map<String, Any>) -> Unit)? = null
413
+ internal var onFocusChangeForTesting: ((Map<String, Any>) -> Unit)? = null
414
+ internal var onEditorReadyForTesting: ((Map<String, Any>) -> Unit)? = null
415
+ internal var onRefreshToolbarStateFromEditorSelectionForTesting: (() -> String?)? = null
416
+ internal var onBeforePrepareForEditorCommandForTesting: (() -> Unit)? = null
417
+ private var isAttachedToNativeWindow = false
60
418
  private var didApplyAutoFocus = false
61
419
  private var heightBehavior = EditorHeightBehavior.FIXED
62
420
  private var lastEmittedContentHeight = 0
63
- private var outsideTapWindowCallback: Window.Callback? = null
64
- private var previousWindowCallback: Window.Callback? = null
421
+ private var outsideTapWindow: Window? = null
65
422
  private var toolbarFramesInWindow: List<RectF> = emptyList()
66
423
  private var lastToolbarTouchUptimeMs: Long? = null
67
424
  private var pendingOutsideTapBlur: Runnable? = null
68
425
  private var pendingKeyboardDismiss: Runnable? = null
426
+ private var pendingToolbarRefocus: Runnable? = null
427
+ private var pendingToolbarRefocusEditorId: Long? = null
428
+ private var pendingToolbarRefocusGeneration = 0
429
+ private var autoFocusRequested = false
69
430
  private var addons = NativeEditorAddons(null)
70
431
  private var mentionQueryState: MentionQueryState? = null
71
432
  private var lastMentionEventJson: String? = null
433
+ private var lastMentionEventEditorId: Long? = null
72
434
  private var lastThemeJson: String? = null
435
+ private var pendingThemeJson: String? = null
436
+ private var hasPendingTheme = false
437
+ private var pendingThemeRetryScheduled = false
438
+ private var pendingThemeRetryEditorId: Long? = null
439
+ private var pendingThemeRetryGeneration = 0
440
+ private var pendingThemeRetryAttempts = 0
73
441
  private var lastAddonsJson: String? = null
74
442
  private var lastRemoteSelectionsJson: String? = null
75
443
  private var lastToolbarItemsJson: String? = null
76
444
  private var lastToolbarFrameJson: String? = null
445
+ private var lastDocumentVersion: Int? = null
77
446
  private var toolbarState = NativeToolbarState.empty
78
447
  private var showsToolbar = true
79
448
  private var toolbarPlacement = ToolbarPlacement.KEYBOARD
80
449
  private var currentImeBottom = 0
81
450
  private var pendingEditorUpdateJson: String? = null
451
+ private var pendingEditorUpdateEditorId: Long? = null
82
452
  private var pendingEditorUpdateRevision = 0
83
453
  private var appliedEditorUpdateRevision = 0
454
+ private var pendingEditorUpdateRetryScheduled = false
455
+ private var pendingEditorUpdateRetryEditorId: Long? = null
456
+ private var pendingEditorUpdateRetryGeneration = 0
457
+ private var pendingEditorUpdateRetryAttempts = 0
458
+ private var pendingEditorUpdateForcedRecoveryAttempted = false
459
+ private var pendingViewCommandUpdateJson: String? = null
460
+ private var pendingViewCommandUpdateEditorId: Long? = null
461
+ private var pendingViewCommandUpdateRetryScheduled = false
462
+ private var pendingViewCommandUpdateRetryGeneration = 0
463
+ private var pendingViewCommandUpdateRetryAttempts = 0
464
+ private var pendingPreflightWakeScheduled = false
465
+ private var pendingPreflightWakeGeneration = 0
466
+ private var pendingBlurRetry: Runnable? = null
467
+ private var pendingBlurRetryEditorId: Long? = null
468
+ private var pendingBlurRetryGeneration = 0
469
+ private var pendingBlurRetryAttempts = 0
470
+ private var pendingDetachPreflightRetryScheduled = false
471
+ private var pendingDetachPreflightRetryEditorId: Long? = null
472
+ private var pendingDetachPreflightRetryGeneration = 0
473
+ private var pendingDetachPreflightRetryAttempts = 0
474
+ private var pendingNativeAction: PendingNativeAction? = null
475
+ private var pendingNativeActionScope: PendingNativeActionScope? = null
476
+ private var pendingNativeActionRetryScheduled = false
477
+ private var pendingNativeActionRetryEditorId: Long? = null
478
+ private var pendingNativeActionRetryGeneration = 0
479
+ private var pendingNativeActionRetryAttempts = 0
480
+ private var lastReadyEditorId: Long? = null
84
481
 
85
482
  init {
86
483
  addView(richTextView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
87
484
  richTextView.editorEditText.editorListener = this
485
+ richTextView.onBeforeDetachedFromWindow = {
486
+ prepareForDetachFromWindow()
487
+ }
88
488
  keyboardToolbarView.onPressItem = { item ->
89
489
  handleToolbarItemPress(item)
90
490
  }
@@ -102,36 +502,130 @@ class NativeEditorExpoView(
102
502
  // Observe EditText focus changes.
103
503
  richTextView.editorEditText.setOnFocusChangeListener { _, hasFocus ->
104
504
  if (hasFocus) {
505
+ cancelPendingToolbarRefocus()
105
506
  installOutsideTapBlurHandlerIfNeeded()
106
507
  refreshMentionQuery()
107
508
  } else {
108
509
  if (shouldPreserveFocusAfterToolbarTouch()) {
109
- richTextView.editorEditText.post {
110
- focus()
111
- }
510
+ scheduleToolbarRefocus()
112
511
  return@setOnFocusChangeListener
113
512
  }
114
513
  uninstallOutsideTapBlurHandler()
115
514
  clearMentionQueryState()
515
+ clearPendingNativeActionRetry()
116
516
  }
117
517
  updateKeyboardToolbarVisibility()
118
- val event = mapOf<String, Any>("isFocused" to hasFocus)
119
- onFocusChange(event)
518
+ val event = mapOf<String, Any>(
519
+ "isFocused" to hasFocus,
520
+ "editorId" to richTextView.editorId
521
+ )
522
+ onFocusChangeForTesting?.invoke(event) ?: onFocusChange(event)
120
523
  }
121
524
  }
122
525
 
123
526
  fun setEditorId(id: Long) {
124
- richTextView.editorId = id
527
+ if (id != 0L && NativeEditorViewRegistry.isDestroyed(id)) {
528
+ setEditorId(0L)
529
+ return
530
+ }
531
+ val previousEditorId = richTextView.editorId
532
+ if (previousEditorId == id && richTextView.editorEditText.editorId == id) {
533
+ if (id != 0L && isAttachedToNativeWindow) {
534
+ if (!NativeEditorViewRegistry.register(id, this)) {
535
+ handleEditorDestroyed(id)
536
+ return
537
+ }
538
+ applyPendingEditorUpdateIfNeeded()
539
+ applyPendingThemeIfNeeded()
540
+ refreshReadyStateIfSettled()
541
+ applyAutoFocusIfNeeded()
542
+ } else if (id != 0L) {
543
+ NativeEditorViewRegistry.unregister(
544
+ id,
545
+ this,
546
+ blockCommandsUntilRegistered = true
547
+ )
548
+ }
549
+ return
550
+ }
551
+ if (previousEditorId != id) {
552
+ NativeEditorViewRegistry.unregister(previousEditorId, this)
553
+ lastDocumentVersion = null
554
+ cancelPendingToolbarRefocus()
555
+ cancelPendingEditorUpdateRetry()
556
+ if (pendingEditorUpdateEditorId != null && pendingEditorUpdateEditorId != id) {
557
+ clearPendingEditorUpdateState()
558
+ }
559
+ appliedEditorUpdateRevision = 0
560
+ clearPendingViewCommandUpdateRetry()
561
+ cancelPendingThemeRetry()
562
+ if (hasPendingTheme) {
563
+ pendingThemeRetryEditorId = id
564
+ }
565
+ cancelPendingBlurRetry()
566
+ clearPendingNativeActionRetry()
567
+ clearMentionQueryState(resetLastEvent = true)
568
+ lastReadyEditorId = null
569
+ }
570
+ if (!isAttachedToNativeWindow) {
571
+ richTextView.setEditorIdWhileDetached(id)
572
+ if (id != 0L) {
573
+ NativeEditorViewRegistry.unregister(
574
+ id,
575
+ this,
576
+ blockCommandsUntilRegistered = true
577
+ )
578
+ } else {
579
+ toolbarState = NativeToolbarState.empty
580
+ keyboardToolbarView.applyState(toolbarState)
581
+ }
582
+ return
583
+ }
584
+
585
+ if (hasPendingEditorUpdateForEditor(id)) {
586
+ richTextView.setEditorIdWhileDetached(id)
587
+ richTextView.rebindEditorIfNeeded(notifyListener = false)
588
+ } else {
589
+ richTextView.editorId = id
590
+ }
591
+ if (id != 0L) {
592
+ if (!NativeEditorViewRegistry.register(id, this)) {
593
+ handleEditorDestroyed(id)
594
+ return
595
+ }
596
+ } else {
597
+ toolbarState = NativeToolbarState.empty
598
+ keyboardToolbarView.applyState(toolbarState)
599
+ }
600
+ applyPendingEditorUpdateIfNeeded()
601
+ applyPendingThemeIfNeeded()
602
+ refreshReadyStateIfSettled()
603
+ applyAutoFocusIfNeeded()
125
604
  }
126
605
 
127
606
  fun setThemeJson(themeJson: String?) {
607
+ if (lastThemeJson == themeJson && !hasPendingTheme) return
608
+ pendingThemeJson = themeJson
609
+ hasPendingTheme = true
610
+ pendingThemeRetryEditorId = richTextView.editorId
611
+ pendingThemeRetryAttempts = 0
612
+ applyPendingThemeIfNeeded()
613
+ }
614
+
615
+ private fun applyThemeJson(themeJson: String?) {
128
616
  if (lastThemeJson == themeJson) return
129
617
  lastThemeJson = themeJson
130
618
  val theme = EditorTheme.fromJson(themeJson)
131
619
  richTextView.applyTheme(theme)
132
620
  keyboardToolbarView.applyTheme(theme?.toolbar)
133
621
  keyboardToolbarView.applyMentionTheme(theme?.mentions ?: addons.mentions?.theme)
622
+ keyboardToolbarView.requestLayout()
134
623
  updateKeyboardToolbarLayout()
624
+ updateEditorViewportInset(forceMeasureToolbar = true)
625
+ post {
626
+ updateKeyboardToolbarLayout()
627
+ updateEditorViewportInset(forceMeasureToolbar = true)
628
+ }
135
629
  }
136
630
 
137
631
  fun setHeightBehavior(rawHeightBehavior: String) {
@@ -159,6 +653,7 @@ class NativeEditorExpoView(
159
653
 
160
654
  fun setAddonsJson(addonsJson: String?) {
161
655
  if (lastAddonsJson == addonsJson) return
656
+ clearPendingNativeActionRetry()
162
657
  lastAddonsJson = addonsJson
163
658
  addons = NativeEditorAddons.fromJson(addonsJson)
164
659
  keyboardToolbarView.applyMentionTheme(richTextView.editorEditText.theme?.mentions ?: addons.mentions?.theme)
@@ -174,9 +669,12 @@ class NativeEditorExpoView(
174
669
  }
175
670
 
176
671
  fun setAutoFocus(autoFocus: Boolean) {
177
- if (!autoFocus || didApplyAutoFocus) {
178
- return
179
- }
672
+ autoFocusRequested = autoFocus
673
+ applyAutoFocusIfNeeded()
674
+ }
675
+
676
+ private fun applyAutoFocusIfNeeded() {
677
+ if (!autoFocusRequested || didApplyAutoFocus || !canFocusCurrentEditor()) return
180
678
  didApplyAutoFocus = true
181
679
  focus()
182
680
  }
@@ -193,13 +691,34 @@ class NativeEditorExpoView(
193
691
  richTextView.editorEditText.setKeyboardType(keyboardType)
194
692
  }
195
693
 
694
+ fun setEditable(editable: Boolean) {
695
+ if (richTextView.editorEditText.isEditable == editable) return
696
+ if (!editable) {
697
+ cancelPendingToolbarRefocus()
698
+ clearPendingNativeActionRetry()
699
+ }
700
+ richTextView.editorEditText.isEditable = editable
701
+ updateKeyboardToolbarVisibility()
702
+ }
703
+
196
704
  fun setShowToolbar(showToolbar: Boolean) {
705
+ if (showsToolbar == showToolbar) return
706
+ if (!showToolbar) {
707
+ cancelPendingToolbarRefocus()
708
+ clearPendingNativeActionRetry()
709
+ }
197
710
  showsToolbar = showToolbar
198
711
  updateKeyboardToolbarVisibility()
199
712
  }
200
713
 
201
714
  fun setToolbarPlacement(rawToolbarPlacement: String?) {
202
- toolbarPlacement = ToolbarPlacement.fromRaw(rawToolbarPlacement)
715
+ val nextPlacement = ToolbarPlacement.fromRaw(rawToolbarPlacement)
716
+ if (toolbarPlacement == nextPlacement) return
717
+ if (nextPlacement != ToolbarPlacement.KEYBOARD) {
718
+ cancelPendingToolbarRefocus()
719
+ clearPendingNativeActionRetry()
720
+ }
721
+ toolbarPlacement = nextPlacement
203
722
  updateKeyboardToolbarVisibility()
204
723
  }
205
724
 
@@ -209,6 +728,7 @@ class NativeEditorExpoView(
209
728
 
210
729
  fun setToolbarItemsJson(toolbarItemsJson: String?) {
211
730
  if (lastToolbarItemsJson == toolbarItemsJson) return
731
+ clearPendingNativeActionRetry()
212
732
  lastToolbarItemsJson = toolbarItemsJson
213
733
  keyboardToolbarView.setItems(NativeToolbarItem.fromJson(toolbarItemsJson))
214
734
  }
@@ -267,23 +787,495 @@ class NativeEditorExpoView(
267
787
  pendingEditorUpdateJson = editorUpdateJson
268
788
  }
269
789
 
790
+ fun setPendingEditorUpdateEditorId(editorUpdateEditorId: Long?) {
791
+ pendingEditorUpdateEditorId = editorUpdateEditorId
792
+ }
793
+
270
794
  fun setPendingEditorUpdateRevision(editorUpdateRevision: Int) {
795
+ if (pendingEditorUpdateRevision != editorUpdateRevision) {
796
+ pendingEditorUpdateRetryAttempts = 0
797
+ pendingEditorUpdateForcedRecoveryAttempted = false
798
+ }
271
799
  pendingEditorUpdateRevision = editorUpdateRevision
272
800
  }
273
801
 
802
+ private fun hasPendingEditorUpdateForEditor(editorId: Long): Boolean =
803
+ pendingEditorUpdateJson != null &&
804
+ pendingEditorUpdateRevision != 0 &&
805
+ pendingEditorUpdateRevision != appliedEditorUpdateRevision &&
806
+ pendingEditorUpdateEditorId == editorId
807
+
808
+ private fun hasPendingEditorUpdateForCurrentEditor(): Boolean =
809
+ hasPendingEditorUpdateForEditor(richTextView.editorId)
810
+
811
+ private fun pendingEditorUpdateCommandPreparationJSON(): String =
812
+ NativeEditorViewRegistry.commandPreparationJSON(
813
+ ready = false,
814
+ blockedReason = "pendingUpdate"
815
+ )
816
+
817
+ private fun shouldBlockEditorCommandForPendingUpdate(): Boolean =
818
+ hasPendingEditorUpdateForCurrentEditor()
819
+
820
+ private fun refreshReadyStateIfSettled() {
821
+ if (handleDestroyedCurrentEditorIfNeeded()) return
822
+ if (hasPendingEditorUpdateForCurrentEditor()) return
823
+ if (!isAttachedToNativeWindow) return
824
+ if (richTextView.editorEditText.editorId != richTextView.editorId) return
825
+ refreshToolbarStateFromEditorSelection()
826
+ refreshMentionQuery()
827
+ emitEditorReadyIfNeeded()
828
+ }
829
+
274
830
  fun applyPendingEditorUpdateIfNeeded() {
275
- val updateJson = pendingEditorUpdateJson ?: return
831
+ if (handleDestroyedCurrentEditorIfNeeded()) return
276
832
  if (pendingEditorUpdateRevision == 0) return
277
- if (pendingEditorUpdateRevision == appliedEditorUpdateRevision) return
278
- appliedEditorUpdateRevision = pendingEditorUpdateRevision
279
- applyEditorUpdate(updateJson)
833
+ val revision = pendingEditorUpdateRevision
834
+ val editorId = richTextView.editorId
835
+ val expectedEditorId = pendingEditorUpdateEditorId
836
+ if (expectedEditorId == null) return
837
+ if (expectedEditorId != editorId) return
838
+ if (pendingEditorUpdateJson == null) {
839
+ clearPendingEditorUpdateState(resetAppliedRevision = false)
840
+ refreshReadyStateIfSettled()
841
+ return
842
+ }
843
+ val updateJson = pendingEditorUpdateJson ?: return
844
+ if (pendingEditorUpdateRevision == appliedEditorUpdateRevision) {
845
+ clearPendingEditorUpdateState(resetAppliedRevision = false)
846
+ emitEditorReady(editorUpdateRevision = revision)
847
+ refreshReadyStateIfSettled()
848
+ return
849
+ }
850
+ if (editorId != 0L && !isAttachedToNativeWindow) return
851
+ val apply = Runnable {
852
+ if (editorId != richTextView.editorId) return@Runnable
853
+ if (expectedEditorId != richTextView.editorId) return@Runnable
854
+ if (editorId != 0L && !isAttachedToNativeWindow) return@Runnable
855
+ if (revision != pendingEditorUpdateRevision) return@Runnable
856
+ if (revision == appliedEditorUpdateRevision) {
857
+ clearPendingEditorUpdateState(resetAppliedRevision = false)
858
+ emitEditorReady(editorUpdateRevision = revision)
859
+ refreshReadyStateIfSettled()
860
+ return@Runnable
861
+ }
862
+ if (applyEditorUpdate(updateJson, scheduleViewCommandRetry = false)) {
863
+ appliedEditorUpdateRevision = revision
864
+ pendingEditorUpdateJson = null
865
+ pendingEditorUpdateEditorId = null
866
+ pendingEditorUpdateRevision = 0
867
+ pendingEditorUpdateRetryAttempts = 0
868
+ pendingEditorUpdateForcedRecoveryAttempted = false
869
+ cancelPendingEditorUpdateRetry()
870
+ emitEditorReady(editorUpdateRevision = revision)
871
+ refreshReadyStateIfSettled()
872
+ } else {
873
+ schedulePendingEditorUpdateRetry()
874
+ }
875
+ }
876
+ if (Looper.myLooper() == Looper.getMainLooper()) {
877
+ apply.run()
878
+ } else if (!post(apply)) {
879
+ richTextView.post(apply)
880
+ }
881
+ }
882
+
883
+ private fun clearPendingEditorUpdateState(resetAppliedRevision: Boolean = true) {
884
+ pendingEditorUpdateJson = null
885
+ pendingEditorUpdateEditorId = null
886
+ pendingEditorUpdateRevision = 0
887
+ if (resetAppliedRevision) {
888
+ appliedEditorUpdateRevision = 0
889
+ }
890
+ cancelPendingEditorUpdateRetry()
891
+ }
892
+
893
+ private fun cancelPendingEditorUpdateRetry() {
894
+ pendingEditorUpdateRetryScheduled = false
895
+ pendingEditorUpdateRetryEditorId = null
896
+ pendingEditorUpdateRetryAttempts = 0
897
+ pendingEditorUpdateForcedRecoveryAttempted = false
898
+ pendingEditorUpdateRetryGeneration += 1
899
+ }
900
+
901
+ private fun schedulePendingEditorUpdateRetry() {
902
+ if (pendingEditorUpdateRetryScheduled) return
903
+ val pastFastRetryBudget =
904
+ pendingEditorUpdateRetryAttempts >= MAX_PENDING_UPDATE_RETRY_ATTEMPTS
905
+ if (
906
+ pastFastRetryBudget &&
907
+ !pendingEditorUpdateForcedRecoveryAttempted &&
908
+ richTextView.editorId != 0L &&
909
+ richTextView.editorEditText.editorId == richTextView.editorId
910
+ ) {
911
+ pendingEditorUpdateForcedRecoveryAttempted = true
912
+ richTextView.editorEditText.discardTransientNativeInputForExternalRecovery()
913
+ }
914
+ if (!pastFastRetryBudget) {
915
+ pendingEditorUpdateRetryAttempts += 1
916
+ }
917
+ pendingEditorUpdateRetryEditorId = richTextView.editorId
918
+ pendingEditorUpdateRetryScheduled = true
919
+ pendingEditorUpdateRetryGeneration += 1
920
+ val retryGeneration = pendingEditorUpdateRetryGeneration
921
+ val delayMs = if (pastFastRetryBudget) {
922
+ PENDING_UPDATE_RECOVERY_RETRY_DELAY_MS
923
+ } else {
924
+ NATIVE_ACTION_RETRY_DELAY_MS * pendingEditorUpdateRetryAttempts
925
+ }
926
+ val retry = Runnable {
927
+ if (retryGeneration != pendingEditorUpdateRetryGeneration) return@Runnable
928
+ if (pendingEditorUpdateRetryEditorId != richTextView.editorId) {
929
+ clearPendingEditorUpdateState()
930
+ return@Runnable
931
+ }
932
+ pendingEditorUpdateRetryScheduled = false
933
+ pendingEditorUpdateRetryEditorId = null
934
+ applyPendingEditorUpdateIfNeeded()
935
+ }
936
+ mainHandler.postDelayed(retry, delayMs)
937
+ }
938
+
939
+ private fun clearPendingThemeRetry() {
940
+ pendingThemeJson = null
941
+ hasPendingTheme = false
942
+ cancelPendingThemeRetry()
943
+ }
944
+
945
+ private fun cancelPendingThemeRetry() {
946
+ pendingThemeRetryScheduled = false
947
+ pendingThemeRetryEditorId = null
948
+ pendingThemeRetryAttempts = 0
949
+ pendingThemeRetryGeneration += 1
950
+ }
951
+
952
+ private fun applyPendingThemeIfNeeded() {
953
+ if (handleDestroyedCurrentEditorIfNeeded()) return
954
+ if (!hasPendingTheme) return
955
+ val themeJson = pendingThemeJson
956
+ val editorId = richTextView.editorId
957
+ if (pendingThemeRetryEditorId != editorId) {
958
+ pendingThemeRetryEditorId = editorId
959
+ }
960
+ if (
961
+ blockThemePreflightForTesting ||
962
+ !richTextView.editorEditText.prepareForExternalEditorUpdate()
963
+ ) {
964
+ schedulePendingThemeRetry()
965
+ return
966
+ }
967
+ pendingThemeJson = null
968
+ hasPendingTheme = false
969
+ cancelPendingThemeRetry()
970
+ applyThemeJson(themeJson)
971
+ }
972
+
973
+ private fun schedulePendingThemeRetry() {
974
+ if (pendingThemeRetryScheduled) return
975
+ if (pendingThemeRetryAttempts >= MAX_PENDING_UPDATE_RETRY_ATTEMPTS) return
976
+ pendingThemeRetryAttempts += 1
977
+ pendingThemeRetryEditorId = richTextView.editorId
978
+ pendingThemeRetryScheduled = true
979
+ pendingThemeRetryGeneration += 1
980
+ val retryGeneration = pendingThemeRetryGeneration
981
+ val delayMs = NATIVE_ACTION_RETRY_DELAY_MS * pendingThemeRetryAttempts
982
+ val retry = Runnable {
983
+ if (retryGeneration != pendingThemeRetryGeneration) return@Runnable
984
+ if (pendingThemeRetryEditorId != richTextView.editorId) {
985
+ clearPendingThemeRetry()
986
+ return@Runnable
987
+ }
988
+ pendingThemeRetryScheduled = false
989
+ applyPendingThemeIfNeeded()
990
+ }
991
+ mainHandler.postDelayed(retry, delayMs)
992
+ }
993
+
994
+ private fun clearPendingViewCommandUpdateRetry() {
995
+ pendingViewCommandUpdateJson = null
996
+ pendingViewCommandUpdateEditorId = null
997
+ pendingViewCommandUpdateRetryScheduled = false
998
+ pendingViewCommandUpdateRetryAttempts = 0
999
+ pendingViewCommandUpdateRetryGeneration += 1
1000
+ }
1001
+
1002
+ private fun scheduleViewCommandUpdateRetry(updateJson: String) {
1003
+ if (pendingViewCommandUpdateJson != updateJson) {
1004
+ pendingViewCommandUpdateRetryAttempts = 0
1005
+ }
1006
+ pendingViewCommandUpdateJson = updateJson
1007
+ pendingViewCommandUpdateEditorId = richTextView.editorId
1008
+ if (pendingViewCommandUpdateRetryScheduled) return
1009
+ if (pendingViewCommandUpdateRetryAttempts >= MAX_PENDING_UPDATE_RETRY_ATTEMPTS) return
1010
+ pendingViewCommandUpdateRetryAttempts += 1
1011
+ pendingViewCommandUpdateRetryScheduled = true
1012
+ pendingViewCommandUpdateRetryGeneration += 1
1013
+ val retryGeneration = pendingViewCommandUpdateRetryGeneration
1014
+ val delayMs = NATIVE_ACTION_RETRY_DELAY_MS * pendingViewCommandUpdateRetryAttempts
1015
+ val retry = Runnable {
1016
+ if (retryGeneration != pendingViewCommandUpdateRetryGeneration) return@Runnable
1017
+ val retryJson = pendingViewCommandUpdateJson ?: run {
1018
+ pendingViewCommandUpdateRetryScheduled = false
1019
+ return@Runnable
1020
+ }
1021
+ if (pendingViewCommandUpdateEditorId != richTextView.editorId || richTextView.editorId == 0L) {
1022
+ clearPendingViewCommandUpdateRetry()
1023
+ return@Runnable
1024
+ }
1025
+ if (handleDestroyedCurrentEditorIfNeeded()) {
1026
+ clearPendingViewCommandUpdateRetry()
1027
+ return@Runnable
1028
+ }
1029
+ pendingViewCommandUpdateRetryScheduled = false
1030
+ if (applyEditorUpdate(retryJson, scheduleViewCommandRetry = true)) {
1031
+ clearPendingViewCommandUpdateRetry()
1032
+ }
1033
+ }
1034
+ mainHandler.postDelayed(retry, delayMs)
1035
+ }
1036
+
1037
+ private fun schedulePendingPreflightWake() {
1038
+ if (pendingPreflightWakeScheduled) return
1039
+ pendingPreflightWakeScheduled = true
1040
+ pendingPreflightWakeGeneration += 1
1041
+ val wakeGeneration = pendingPreflightWakeGeneration
1042
+ mainHandler.post {
1043
+ if (wakeGeneration != pendingPreflightWakeGeneration) return@post
1044
+ pendingPreflightWakeScheduled = false
1045
+ wakePendingPreflightWork()
1046
+ }
1047
+ }
1048
+
1049
+ private fun cancelPendingPreflightWake() {
1050
+ pendingPreflightWakeScheduled = false
1051
+ pendingPreflightWakeGeneration += 1
1052
+ }
1053
+
1054
+ private fun wakePendingPreflightWork() {
1055
+ if (Looper.myLooper() != Looper.getMainLooper()) {
1056
+ schedulePendingPreflightWake()
1057
+ return
1058
+ }
1059
+ if (handleDestroyedCurrentEditorIfNeeded()) return
1060
+ if (pendingEditorUpdateJson != null) {
1061
+ pendingEditorUpdateRetryAttempts = 0
1062
+ pendingEditorUpdateForcedRecoveryAttempted = false
1063
+ applyPendingEditorUpdateIfNeeded()
1064
+ }
1065
+ if (hasPendingTheme) {
1066
+ pendingThemeRetryAttempts = 0
1067
+ applyPendingThemeIfNeeded()
1068
+ }
1069
+ pendingViewCommandUpdateJson?.let { updateJson ->
1070
+ pendingViewCommandUpdateRetryAttempts = 0
1071
+ pendingViewCommandUpdateRetryScheduled = false
1072
+ pendingViewCommandUpdateRetryGeneration += 1
1073
+ if (applyEditorUpdate(updateJson, scheduleViewCommandRetry = true)) {
1074
+ clearPendingViewCommandUpdateRetry()
1075
+ }
1076
+ }
1077
+ retryPendingNativeActionFromWake()
1078
+ }
1079
+
1080
+ private fun clearPendingNativeActionRetry() {
1081
+ pendingNativeAction = null
1082
+ pendingNativeActionScope = null
1083
+ pendingNativeActionRetryEditorId = null
1084
+ pendingNativeActionRetryScheduled = false
1085
+ pendingNativeActionRetryAttempts = 0
1086
+ pendingNativeActionRetryGeneration += 1
1087
+ }
1088
+
1089
+ private fun currentNativeActionScope(action: PendingNativeAction): PendingNativeActionScope {
1090
+ val selection = richTextView.editorEditText.currentScalarSelection()
1091
+ val mentionScope = when (action) {
1092
+ is PendingNativeAction.MentionSuggestionSelect ->
1093
+ mentionQueryState ?: addons.mentions?.let { currentMentionQueryState(it.trigger) }
1094
+ is PendingNativeAction.ToolbarItemPress -> null
1095
+ }
1096
+ return PendingNativeActionScope(
1097
+ editorId = richTextView.editorId,
1098
+ documentVersion = lastDocumentVersion,
1099
+ allowedDocumentVersion = documentVersionFromUpdateJSON(pendingEditorUpdateJson),
1100
+ hadFocus = isEditorEffectivelyFocusedForNativeAction(),
1101
+ hadVisibleToolbar = isNativeActionToolbarVisible(action),
1102
+ selectionAnchor = selection?.first,
1103
+ selectionHead = selection?.second,
1104
+ mentionAnchor = mentionScope?.anchor,
1105
+ mentionHead = mentionScope?.head,
1106
+ mentionQuery = mentionScope?.query
1107
+ )
1108
+ }
1109
+
1110
+ private fun isPendingNativeActionScopeCurrent(
1111
+ action: PendingNativeAction,
1112
+ scope: PendingNativeActionScope
1113
+ ): Boolean {
1114
+ if (scope.editorId != richTextView.editorId) return false
1115
+ if (scope.hadFocus != isEditorEffectivelyFocusedForNativeAction()) return false
1116
+ if (scope.hadVisibleToolbar != isNativeActionToolbarVisible(action)) return false
1117
+ if (
1118
+ scope.documentVersion != lastDocumentVersion &&
1119
+ (scope.allowedDocumentVersion == null || scope.allowedDocumentVersion != lastDocumentVersion)
1120
+ ) {
1121
+ return false
1122
+ }
1123
+ val selection = richTextView.editorEditText.currentScalarSelection()
1124
+ if (scope.selectionAnchor != selection?.first || scope.selectionHead != selection?.second) {
1125
+ return false
1126
+ }
1127
+ if (action is PendingNativeAction.MentionSuggestionSelect) {
1128
+ val mentions = addons.mentions ?: return false
1129
+ val currentQuery = currentMentionQueryState(mentions.trigger) ?: return false
1130
+ if (
1131
+ scope.mentionAnchor != currentQuery.anchor ||
1132
+ scope.mentionHead != currentQuery.head ||
1133
+ scope.mentionQuery != currentQuery.query
1134
+ ) {
1135
+ return false
1136
+ }
1137
+ }
1138
+ return true
1139
+ }
1140
+
1141
+ private fun isNativeActionToolbarVisible(action: PendingNativeAction): Boolean {
1142
+ if (!showsToolbar || toolbarPlacement != ToolbarPlacement.KEYBOARD) return false
1143
+ if (keyboardToolbarView.parent == null || keyboardToolbarView.visibility != View.VISIBLE) return false
1144
+ if (action is PendingNativeAction.MentionSuggestionSelect) {
1145
+ return keyboardToolbarView.isShowingMentionSuggestions
1146
+ }
1147
+ return true
1148
+ }
1149
+
1150
+ private fun isEditorEffectivelyFocusedForNativeAction(): Boolean =
1151
+ richTextView.editorEditText.hasFocus() ||
1152
+ (pendingToolbarRefocus != null && pendingToolbarRefocusEditorId == richTextView.editorId)
1153
+
1154
+ private fun clearPendingNativeActionRetryIfScopeChanged() {
1155
+ val action = pendingNativeAction ?: return
1156
+ val scope = pendingNativeActionScope ?: return
1157
+ if (!isPendingNativeActionScopeCurrent(action, scope)) {
1158
+ clearPendingNativeActionRetry()
1159
+ }
1160
+ }
1161
+
1162
+ private fun schedulePendingNativeActionRetry(action: PendingNativeAction) {
1163
+ val isSameAction = pendingNativeAction == action
1164
+ if (isSameAction) {
1165
+ pendingNativeActionRetryAttempts += 1
1166
+ } else {
1167
+ pendingNativeActionRetryAttempts = 1
1168
+ pendingNativeActionScope = currentNativeActionScope(action)
1169
+ }
1170
+ if (pendingNativeActionRetryAttempts > MAX_NATIVE_ACTION_RETRY_ATTEMPTS) {
1171
+ pendingNativeAction = action
1172
+ pendingNativeActionRetryEditorId = richTextView.editorId
1173
+ pendingNativeActionRetryScheduled = false
1174
+ return
1175
+ }
1176
+ pendingNativeAction = action
1177
+ pendingNativeActionRetryEditorId = richTextView.editorId
1178
+ if (pendingNativeActionRetryScheduled) return
1179
+ pendingNativeActionRetryScheduled = true
1180
+ pendingNativeActionRetryGeneration += 1
1181
+ val retryGeneration = pendingNativeActionRetryGeneration
1182
+ val retry = Runnable {
1183
+ if (retryGeneration != pendingNativeActionRetryGeneration) return@Runnable
1184
+ val retryAction = pendingNativeAction ?: run {
1185
+ pendingNativeActionRetryScheduled = false
1186
+ return@Runnable
1187
+ }
1188
+ val retryScope = pendingNativeActionScope ?: run {
1189
+ clearPendingNativeActionRetry()
1190
+ return@Runnable
1191
+ }
1192
+ if (pendingNativeActionRetryEditorId != richTextView.editorId || richTextView.editorId == 0L) {
1193
+ clearPendingNativeActionRetry()
1194
+ return@Runnable
1195
+ }
1196
+ if (!isPendingNativeActionScopeCurrent(retryAction, retryScope)) {
1197
+ clearPendingNativeActionRetry()
1198
+ return@Runnable
1199
+ }
1200
+ pendingNativeActionRetryScheduled = false
1201
+ val allowNextRetry = pendingNativeActionRetryAttempts < MAX_NATIVE_ACTION_RETRY_ATTEMPTS
1202
+ when (retryAction) {
1203
+ is PendingNativeAction.ToolbarItemPress ->
1204
+ handleToolbarItemPress(retryAction.item, allowPreflightRetry = allowNextRetry)
1205
+ is PendingNativeAction.MentionSuggestionSelect ->
1206
+ insertMentionSuggestion(retryAction.suggestion, allowPreflightRetry = allowNextRetry)
1207
+ }
1208
+ }
1209
+ mainHandler.postDelayed(retry, NATIVE_ACTION_RETRY_DELAY_MS)
1210
+ }
1211
+
1212
+ private fun retryPendingNativeActionFromWake() {
1213
+ val action = pendingNativeAction ?: return
1214
+ val scope = pendingNativeActionScope ?: run {
1215
+ clearPendingNativeActionRetry()
1216
+ return
1217
+ }
1218
+ if (!isPendingNativeActionScopeCurrent(action, scope)) {
1219
+ clearPendingNativeActionRetry()
1220
+ return
1221
+ }
1222
+ pendingNativeActionRetryAttempts = 0
1223
+ pendingNativeActionRetryScheduled = false
1224
+ when (action) {
1225
+ is PendingNativeAction.ToolbarItemPress ->
1226
+ handleToolbarItemPress(action.item, allowPreflightRetry = true)
1227
+ is PendingNativeAction.MentionSuggestionSelect ->
1228
+ insertMentionSuggestion(action.suggestion, allowPreflightRetry = true)
1229
+ }
1230
+ }
1231
+
1232
+ private fun documentVersionFromUpdateJSON(updateJSON: String?): Int? =
1233
+ try {
1234
+ if (updateJSON == null) null
1235
+ else {
1236
+ val version = JSONObject(updateJSON).optInt("documentVersion", Int.MIN_VALUE)
1237
+ version.takeIf { it != Int.MIN_VALUE }
1238
+ }
1239
+ } catch (_: Throwable) {
1240
+ null
1241
+ }
1242
+
1243
+ private fun noteDocumentVersionFromUpdateJSON(updateJSON: String?) {
1244
+ documentVersionFromUpdateJSON(updateJSON)?.let { version ->
1245
+ lastDocumentVersion = version
1246
+ }
1247
+ }
1248
+
1249
+ private fun addPreflightUpdateToEvent(
1250
+ event: MutableMap<String, Any>,
1251
+ updateJSON: String?
1252
+ ) {
1253
+ if (updateJSON == null) return
1254
+ event["updateJson"] = updateJSON
1255
+ documentVersionFromUpdateJSON(updateJSON)?.let { version ->
1256
+ event["documentVersion"] = version
1257
+ }
1258
+ }
1259
+
1260
+ private fun emitAddonEvent(payload: Map<String, Any>) {
1261
+ onAddonEventForTesting?.invoke(payload) ?: onAddonEvent(payload)
1262
+ }
1263
+
1264
+ private fun canFocusCurrentEditor(): Boolean {
1265
+ val editorId = richTextView.editorId
1266
+ return editorId != 0L &&
1267
+ isAttachedToNativeWindow &&
1268
+ !NativeEditorViewRegistry.isDestroyed(editorId)
280
1269
  }
281
1270
 
282
1271
  fun focus() {
1272
+ if (!canFocusCurrentEditor()) return
283
1273
  cancelPendingOutsideTapBlur()
284
1274
  cancelPendingKeyboardDismiss()
1275
+ cancelPendingBlurRetry()
285
1276
  richTextView.editorEditText.requestFocus()
286
1277
  richTextView.editorEditText.post {
1278
+ if (!canFocusCurrentEditor()) return@post
287
1279
  val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
288
1280
  imm?.showSoftInput(richTextView.editorEditText, InputMethodManager.SHOW_IMPLICIT)
289
1281
  }
@@ -293,24 +1285,96 @@ class NativeEditorExpoView(
293
1285
  cancelPendingOutsideTapBlur()
294
1286
  cancelPendingKeyboardDismiss()
295
1287
  clearRecentToolbarTouch()
1288
+ performBlur(deferKeyboardDismiss = false, allowRetry = true)
1289
+ }
1290
+
1291
+ private fun performBlur(deferKeyboardDismiss: Boolean, allowRetry: Boolean) {
1292
+ if (handleDestroyedCurrentEditorIfNeeded()) return
1293
+ if (!richTextView.editorEditText.prepareForExternalEditorUpdate()) {
1294
+ if (allowRetry && pendingBlurRetryAttempts < MAX_PENDING_UPDATE_RETRY_ATTEMPTS) {
1295
+ schedulePendingBlurRetry(deferKeyboardDismiss)
1296
+ return
1297
+ }
1298
+ if (handleDestroyedCurrentEditorIfNeeded()) return
1299
+ richTextView.editorEditText.restoreAuthorizedTextIfNeeded()
1300
+ }
1301
+ completeBlur(deferKeyboardDismiss)
1302
+ }
1303
+
1304
+ private fun completeBlur(deferKeyboardDismiss: Boolean) {
1305
+ cancelPendingBlurRetry()
296
1306
  richTextView.editorEditText.clearFocus()
1307
+ if (deferKeyboardDismiss) {
1308
+ val dismiss = Runnable {
1309
+ pendingKeyboardDismiss = null
1310
+ if (!richTextView.editorEditText.hasFocus()) {
1311
+ val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
1312
+ imm?.hideSoftInputFromWindow(richTextView.editorEditText.windowToken, 0)
1313
+ }
1314
+ }
1315
+ pendingKeyboardDismiss = dismiss
1316
+ richTextView.editorEditText.post(dismiss)
1317
+ return
1318
+ }
297
1319
  val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
298
1320
  imm?.hideSoftInputFromWindow(richTextView.editorEditText.windowToken, 0)
299
1321
  }
300
1322
 
1323
+ private fun schedulePendingBlurRetry(deferKeyboardDismiss: Boolean) {
1324
+ pendingBlurRetry?.let {
1325
+ mainHandler.removeCallbacks(it)
1326
+ pendingBlurRetry = null
1327
+ }
1328
+ pendingBlurRetryAttempts += 1
1329
+ pendingBlurRetryEditorId = richTextView.editorId
1330
+ pendingBlurRetryGeneration += 1
1331
+ val retryGeneration = pendingBlurRetryGeneration
1332
+ val delayMs = NATIVE_ACTION_RETRY_DELAY_MS * pendingBlurRetryAttempts
1333
+ val retry = Runnable {
1334
+ pendingBlurRetry = null
1335
+ if (retryGeneration != pendingBlurRetryGeneration) return@Runnable
1336
+ if (pendingBlurRetryEditorId != richTextView.editorId) {
1337
+ pendingBlurRetryEditorId = null
1338
+ return@Runnable
1339
+ }
1340
+ pendingBlurRetryEditorId = null
1341
+ if (handleDestroyedCurrentEditorIfNeeded()) return@Runnable
1342
+ performBlur(deferKeyboardDismiss, allowRetry = true)
1343
+ }
1344
+ pendingBlurRetry = retry
1345
+ mainHandler.postDelayed(retry, delayMs)
1346
+ }
1347
+
301
1348
  private fun blurWithDeferredKeyboardDismiss() {
302
1349
  cancelPendingKeyboardDismiss()
303
1350
  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
- }
1351
+ performBlur(deferKeyboardDismiss = true, allowRetry = true)
1352
+ }
1353
+
1354
+ private fun scheduleToolbarRefocus() {
1355
+ cancelPendingToolbarRefocus()
1356
+ val editorId = richTextView.editorId
1357
+ pendingToolbarRefocusEditorId = editorId
1358
+ pendingToolbarRefocusGeneration += 1
1359
+ val refocusGeneration = pendingToolbarRefocusGeneration
1360
+ val refocus = Runnable {
1361
+ pendingToolbarRefocus = null
1362
+ if (refocusGeneration != pendingToolbarRefocusGeneration) return@Runnable
1363
+ if (pendingToolbarRefocusEditorId != richTextView.editorId) return@Runnable
1364
+ pendingToolbarRefocusEditorId = null
1365
+ focus()
1366
+ }
1367
+ pendingToolbarRefocus = refocus
1368
+ richTextView.editorEditText.post(refocus)
1369
+ }
1370
+
1371
+ private fun cancelPendingToolbarRefocus() {
1372
+ pendingToolbarRefocus?.let {
1373
+ richTextView.editorEditText.removeCallbacks(it)
1374
+ pendingToolbarRefocus = null
311
1375
  }
312
- pendingKeyboardDismiss = dismiss
313
- richTextView.editorEditText.post(dismiss)
1376
+ pendingToolbarRefocusEditorId = null
1377
+ pendingToolbarRefocusGeneration += 1
314
1378
  }
315
1379
 
316
1380
  private fun scheduleOutsideTapBlur() {
@@ -339,6 +1403,16 @@ class NativeEditorExpoView(
339
1403
  }
340
1404
  }
341
1405
 
1406
+ private fun cancelPendingBlurRetry() {
1407
+ pendingBlurRetry?.let {
1408
+ mainHandler.removeCallbacks(it)
1409
+ pendingBlurRetry = null
1410
+ }
1411
+ pendingBlurRetryEditorId = null
1412
+ pendingBlurRetryAttempts = 0
1413
+ pendingBlurRetryGeneration += 1
1414
+ }
1415
+
342
1416
  fun getCaretRectJson(): String? {
343
1417
  if (width <= 0 || height <= 0) return null
344
1418
  val rect = richTextView.caretRect() ?: return null
@@ -353,12 +1427,182 @@ class NativeEditorExpoView(
353
1427
  .toString()
354
1428
  }
355
1429
 
1430
+ override fun onAttachedToWindow() {
1431
+ super.onAttachedToWindow()
1432
+ handleAttachedToWindow()
1433
+ }
1434
+
1435
+ internal fun handleEditorDestroyed(editorId: Long) {
1436
+ if (richTextView.editorId != editorId && richTextView.editorEditText.editorId != editorId) {
1437
+ return
1438
+ }
1439
+ cancelPendingEditorUpdateRetry()
1440
+ clearPendingViewCommandUpdateRetry()
1441
+ cancelPendingThemeRetry()
1442
+ cancelPendingBlurRetry()
1443
+ cancelPendingDetachPreflightRetry()
1444
+ cancelPendingOutsideTapBlur()
1445
+ cancelPendingKeyboardDismiss()
1446
+ cancelPendingToolbarRefocus()
1447
+ cancelPendingPreflightWake()
1448
+ clearPendingNativeActionRetry()
1449
+ clearRecentToolbarTouch()
1450
+ uninstallOutsideTapBlurHandler()
1451
+ detachKeyboardToolbarIfNeeded()
1452
+ richTextView.setViewportBottomInsetPx(0)
1453
+ val editText = richTextView.editorEditText
1454
+ if (editText.hasFocus()) {
1455
+ editText.clearFocus()
1456
+ }
1457
+ val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
1458
+ imm?.hideSoftInputFromWindow(editText.windowToken, 0)
1459
+ clearMentionQueryState(resetLastEvent = true)
1460
+ pendingEditorUpdateJson = null
1461
+ pendingEditorUpdateEditorId = null
1462
+ pendingEditorUpdateRevision = 0
1463
+ appliedEditorUpdateRevision = 0
1464
+ lastDocumentVersion = null
1465
+ lastReadyEditorId = null
1466
+ toolbarState = NativeToolbarState.empty
1467
+ keyboardToolbarView.applyState(toolbarState)
1468
+ keyboardToolbarView.visibility = View.GONE
1469
+ richTextView.editorId = 0L
1470
+ }
1471
+
1472
+ private fun handleDestroyedCurrentEditorIfNeeded(): Boolean {
1473
+ val editorId = richTextView.editorId.takeIf { it != 0L }
1474
+ ?: richTextView.editorEditText.editorId.takeIf { it != 0L }
1475
+ ?: return false
1476
+ if (!NativeEditorViewRegistry.isDestroyed(editorId)) return false
1477
+ handleEditorDestroyed(editorId)
1478
+ return true
1479
+ }
1480
+
1481
+ private fun handleAttachedToWindow() {
1482
+ isAttachedToNativeWindow = true
1483
+ cancelPendingDetachPreflightRetry()
1484
+ richTextView.clearDeferredEditorUnbind()
1485
+ val editorId = richTextView.editorId
1486
+ if (editorId == 0L) return
1487
+ if (NativeEditorViewRegistry.isDestroyed(editorId)) {
1488
+ handleEditorDestroyed(editorId)
1489
+ return
1490
+ }
1491
+ if (!NativeEditorViewRegistry.register(editorId, this)) {
1492
+ handleEditorDestroyed(editorId)
1493
+ return
1494
+ }
1495
+ richTextView.rebindEditorIfNeeded(
1496
+ notifyListener = !hasPendingEditorUpdateForEditor(editorId)
1497
+ )
1498
+ if (hasPendingTheme) {
1499
+ pendingThemeRetryEditorId = editorId
1500
+ }
1501
+ applyPendingEditorUpdateIfNeeded()
1502
+ applyPendingThemeIfNeeded()
1503
+ refreshReadyStateIfSettled()
1504
+ applyAutoFocusIfNeeded()
1505
+ }
1506
+
1507
+ private fun emitEditorReady(editorUpdateRevision: Int? = null): Boolean {
1508
+ val editorId = richTextView.editorId
1509
+ if (editorId == 0L) return false
1510
+ if (!isAttachedToNativeWindow) return false
1511
+ if (richTextView.editorEditText.editorId != editorId) return false
1512
+ if (hasPendingEditorUpdateForCurrentEditor()) return false
1513
+ lastReadyEditorId = editorId
1514
+ val payload = mutableMapOf<String, Any>("editorId" to editorId)
1515
+ editorUpdateRevision?.let { payload["editorUpdateRevision"] = it }
1516
+ onEditorReadyForTesting?.invoke(payload) ?: onEditorReady(payload)
1517
+ return true
1518
+ }
1519
+
1520
+ private fun emitEditorReadyIfNeeded() {
1521
+ val editorId = richTextView.editorId
1522
+ if (lastReadyEditorId == editorId) return
1523
+ emitEditorReady()
1524
+ }
1525
+
356
1526
  override fun onDetachedFromWindow() {
1527
+ prepareForDetachFromWindow()
357
1528
  super.onDetachedFromWindow()
1529
+ handleDetachedFromWindow()
1530
+ }
1531
+
1532
+ private fun prepareForDetachFromWindow() {
1533
+ if (handleDestroyedCurrentEditorIfNeeded()) return
1534
+ val editorId = richTextView.editorId
1535
+ if (editorId == 0L || richTextView.editorEditText.editorId == 0L) return
1536
+ if (richTextView.editorEditText.prepareForExternalEditorUpdate()) {
1537
+ cancelPendingDetachPreflightRetry()
1538
+ richTextView.clearDeferredEditorUnbind()
1539
+ return
1540
+ }
1541
+ richTextView.deferEditorUnbindOnNextDetach()
1542
+ schedulePendingDetachPreflightRetry(editorId)
1543
+ }
1544
+
1545
+ private fun schedulePendingDetachPreflightRetry(editorId: Long) {
1546
+ if (pendingDetachPreflightRetryScheduled) return
1547
+ if (pendingDetachPreflightRetryAttempts >= MAX_PENDING_UPDATE_RETRY_ATTEMPTS) {
1548
+ if (handleDestroyedCurrentEditorIfNeeded()) return
1549
+ richTextView.editorEditText.restoreAuthorizedTextIfNeeded()
1550
+ cancelPendingDetachPreflightRetry()
1551
+ richTextView.unbindEditorForDetachedViewIfNeeded()
1552
+ return
1553
+ }
1554
+ pendingDetachPreflightRetryAttempts += 1
1555
+ pendingDetachPreflightRetryEditorId = editorId
1556
+ pendingDetachPreflightRetryScheduled = true
1557
+ pendingDetachPreflightRetryGeneration += 1
1558
+ val retryGeneration = pendingDetachPreflightRetryGeneration
1559
+ val delayMs = NATIVE_ACTION_RETRY_DELAY_MS * pendingDetachPreflightRetryAttempts
1560
+ mainHandler.postDelayed({
1561
+ if (retryGeneration != pendingDetachPreflightRetryGeneration) return@postDelayed
1562
+ pendingDetachPreflightRetryScheduled = false
1563
+ if (isAttachedToNativeWindow || pendingDetachPreflightRetryEditorId != richTextView.editorId) {
1564
+ cancelPendingDetachPreflightRetry()
1565
+ return@postDelayed
1566
+ }
1567
+ if (handleDestroyedCurrentEditorIfNeeded()) return@postDelayed
1568
+ if (richTextView.editorEditText.prepareForExternalEditorUpdate()) {
1569
+ cancelPendingDetachPreflightRetry()
1570
+ richTextView.unbindEditorForDetachedViewIfNeeded()
1571
+ return@postDelayed
1572
+ }
1573
+ schedulePendingDetachPreflightRetry(editorId)
1574
+ }, delayMs)
1575
+ }
1576
+
1577
+ private fun cancelPendingDetachPreflightRetry() {
1578
+ pendingDetachPreflightRetryScheduled = false
1579
+ pendingDetachPreflightRetryEditorId = null
1580
+ pendingDetachPreflightRetryAttempts = 0
1581
+ pendingDetachPreflightRetryGeneration += 1
1582
+ }
1583
+
1584
+ private fun handleDetachedFromWindow() {
1585
+ isAttachedToNativeWindow = false
1586
+ NativeEditorViewRegistry.unregister(
1587
+ richTextView.editorId,
1588
+ this,
1589
+ blockCommandsUntilRegistered = true
1590
+ )
358
1591
  cancelPendingOutsideTapBlur()
359
1592
  cancelPendingKeyboardDismiss()
1593
+ cancelPendingToolbarRefocus()
1594
+ cancelPendingBlurRetry()
1595
+ cancelPendingEditorUpdateRetry()
1596
+ clearPendingViewCommandUpdateRetry()
1597
+ cancelPendingThemeRetry()
1598
+ clearPendingNativeActionRetry()
1599
+ cancelPendingPreflightWake()
1600
+ lastReadyEditorId = null
360
1601
  uninstallOutsideTapBlurHandler()
1602
+ currentImeBottom = 0
1603
+ keyboardToolbarView.visibility = View.GONE
361
1604
  detachKeyboardToolbarIfNeeded()
1605
+ richTextView.setViewportBottomInsetPx(0)
362
1606
  }
363
1607
 
364
1608
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
@@ -436,30 +1680,131 @@ class NativeEditorExpoView(
436
1680
  if (contentHeight <= 0) return
437
1681
  if (!force && contentHeight == lastEmittedContentHeight) return
438
1682
  lastEmittedContentHeight = contentHeight
439
- onContentHeightChange(mapOf("contentHeight" to contentHeight))
1683
+ onContentHeightChange(
1684
+ mapOf(
1685
+ "contentHeight" to contentHeight,
1686
+ "editorId" to richTextView.editorId
1687
+ )
1688
+ )
440
1689
  }
441
1690
 
442
1691
  /** 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 {
1692
+ fun applyEditorUpdate(updateJson: String): Boolean =
1693
+ applyEditorUpdate(updateJson, scheduleViewCommandRetry = true)
1694
+
1695
+ private fun isEditorReadyForNativeUpdate(): Boolean {
1696
+ val editorId = richTextView.editorId
1697
+ return editorId == 0L || (isAttachedToNativeWindow && richTextView.editorEditText.editorId == editorId)
1698
+ }
1699
+
1700
+ private fun applyEditorUpdate(
1701
+ updateJson: String,
1702
+ scheduleViewCommandRetry: Boolean,
1703
+ expectedEditorId: Long? = null
1704
+ ): Boolean {
1705
+ if (Looper.myLooper() != Looper.getMainLooper()) {
1706
+ val postedEditorId = expectedEditorId ?: richTextView.editorId
1707
+ val apply = Runnable {
1708
+ if (postedEditorId != richTextView.editorId) return@Runnable
1709
+ applyEditorUpdate(updateJson, scheduleViewCommandRetry, postedEditorId)
1710
+ }
452
1711
  if (!post(apply)) {
453
1712
  richTextView.post(apply)
454
1713
  }
1714
+ return false
1715
+ }
1716
+ if (expectedEditorId != null && expectedEditorId != richTextView.editorId) {
1717
+ return false
1718
+ }
1719
+ if (handleDestroyedCurrentEditorIfNeeded()) {
1720
+ return false
1721
+ }
1722
+ if (!isEditorReadyForNativeUpdate()) {
1723
+ if (scheduleViewCommandRetry) {
1724
+ scheduleViewCommandUpdateRetry(updateJson)
1725
+ }
1726
+ return false
1727
+ }
1728
+ if (
1729
+ blockEditorUpdatePreflightForTesting ||
1730
+ !richTextView.editorEditText.prepareForExternalEditorUpdate()
1731
+ ) {
1732
+ if (scheduleViewCommandRetry) {
1733
+ scheduleViewCommandUpdateRetry(updateJson)
1734
+ }
1735
+ return false
1736
+ }
1737
+ isApplyingJSUpdate = true
1738
+ return try {
1739
+ richTextView.editorEditText.applyUpdateJSON(updateJson)
1740
+ true
1741
+ } catch (error: Throwable) {
1742
+ Log.w(LOG_TAG, "Failed to apply JS editor update", error)
1743
+ if (scheduleViewCommandRetry) {
1744
+ scheduleViewCommandUpdateRetry(updateJson)
1745
+ }
1746
+ false
1747
+ } finally {
1748
+ isApplyingJSUpdate = false
1749
+ }
1750
+ }
1751
+
1752
+ fun prepareForEditorCommandJSON(): String {
1753
+ if (Looper.myLooper() != Looper.getMainLooper()) {
1754
+ return NativeEditorViewRegistry.commandPreparationJSON(
1755
+ ready = false,
1756
+ blockedReason = "unknown"
1757
+ )
1758
+ }
1759
+ if (handleDestroyedCurrentEditorIfNeeded()) {
1760
+ return NativeEditorViewRegistry.commandPreparationJSON(
1761
+ ready = false,
1762
+ blockedReason = "destroyed"
1763
+ )
1764
+ }
1765
+ if (richTextView.editorId != 0L && !isAttachedToNativeWindow) {
1766
+ return NativeEditorViewRegistry.commandPreparationJSON(
1767
+ ready = false,
1768
+ blockedReason = "detached"
1769
+ )
1770
+ }
1771
+ if (richTextView.editorId != 0L && richTextView.editorEditText.editorId != richTextView.editorId) {
1772
+ return NativeEditorViewRegistry.commandPreparationJSON(
1773
+ ready = false,
1774
+ blockedReason = "detached"
1775
+ )
1776
+ }
1777
+ if (shouldBlockEditorCommandForPendingUpdate()) {
1778
+ return pendingEditorUpdateCommandPreparationJSON()
1779
+ }
1780
+ isApplyingJSUpdate = true
1781
+ return try {
1782
+ onBeforePrepareForEditorCommandForTesting?.invoke()
1783
+ val preparation = richTextView.editorEditText.prepareForExternalEditorCommand()
1784
+ NativeEditorViewRegistry.commandPreparationJSON(
1785
+ ready = preparation.ready,
1786
+ updateJSON = preparation.updateJSON,
1787
+ blockedReason = if (preparation.ready) null else "composition"
1788
+ )
1789
+ } finally {
1790
+ isApplyingJSUpdate = false
455
1791
  }
456
1792
  }
457
1793
 
458
1794
  override fun onSelectionChanged(anchor: Int, head: Int) {
459
1795
  val stateJson = refreshToolbarStateFromEditorSelection()
460
1796
  refreshMentionQuery()
1797
+ clearPendingNativeActionRetryIfScopeChanged()
1798
+ schedulePendingPreflightWake()
461
1799
  richTextView.refreshRemoteSelections()
462
- val event = mutableMapOf<String, Any>("anchor" to anchor, "head" to head)
1800
+ val event = mutableMapOf<String, Any>(
1801
+ "anchor" to anchor,
1802
+ "head" to head,
1803
+ "editorId" to richTextView.editorId
1804
+ )
1805
+ lastDocumentVersion?.let {
1806
+ event["documentVersion"] = it
1807
+ }
463
1808
  if (stateJson != null) {
464
1809
  event["stateJson"] = stateJson
465
1810
  }
@@ -467,11 +1812,14 @@ class NativeEditorExpoView(
467
1812
  }
468
1813
 
469
1814
  override fun onEditorUpdate(updateJSON: String) {
1815
+ noteDocumentVersionFromUpdateJSON(updateJSON)
470
1816
  NativeToolbarState.fromUpdateJson(updateJSON)?.let { state ->
471
1817
  toolbarState = state
472
1818
  keyboardToolbarView.applyState(state)
473
1819
  }
474
1820
  refreshMentionQuery()
1821
+ clearPendingNativeActionRetryIfScopeChanged()
1822
+ schedulePendingPreflightWake()
475
1823
  richTextView.refreshRemoteSelections()
476
1824
  if (heightBehavior == EditorHeightBehavior.AUTO_GROW) {
477
1825
  post {
@@ -480,44 +1828,39 @@ class NativeEditorExpoView(
480
1828
  }
481
1829
  }
482
1830
  if (isApplyingJSUpdate) return
483
- val event = mapOf<String, Any>("updateJson" to updateJSON)
1831
+ val event = mapOf<String, Any>(
1832
+ "updateJson" to updateJSON,
1833
+ "editorId" to richTextView.editorId
1834
+ )
484
1835
  onEditorUpdate(event)
485
1836
  }
486
1837
 
487
1838
  private fun installOutsideTapBlurHandlerIfNeeded() {
488
1839
  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
1840
+ if (outsideTapWindow === window) return
1841
+ uninstallOutsideTapBlurHandler()
1842
+ NativeEditorOutsideTapDispatcher.register(window, this)
1843
+ outsideTapWindow = window
511
1844
  }
512
1845
 
513
1846
  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
1847
+ val window = outsideTapWindow ?: return
1848
+ NativeEditorOutsideTapDispatcher.unregister(window, this)
1849
+ outsideTapWindow = null
1850
+ }
1851
+
1852
+ internal fun shouldScheduleOutsideTapBlurForWindowEvent(event: MotionEvent): Boolean =
1853
+ isAttachedToNativeWindow &&
1854
+ event.action == MotionEvent.ACTION_DOWN &&
1855
+ richTextView.editorEditText.hasFocus() &&
1856
+ isTouchOutsideEditor(event)
1857
+
1858
+ internal fun scheduleOutsideTapBlurFromWindowDispatcher() {
1859
+ scheduleOutsideTapBlur()
1860
+ }
1861
+
1862
+ internal fun cancelOutsideTapBlurFromWindowDispatcher() {
1863
+ cancelPendingOutsideTapBlur()
521
1864
  }
522
1865
 
523
1866
  private fun isTouchOutsideEditor(event: MotionEvent): Boolean {
@@ -555,6 +1898,125 @@ class NativeEditorExpoView(
555
1898
  internal fun shouldPreserveFocusAfterToolbarTouchForTesting(): Boolean =
556
1899
  shouldPreserveFocusAfterToolbarTouch()
557
1900
 
1901
+ internal fun setAttachedToNativeWindowForTesting(isAttached: Boolean) {
1902
+ isAttachedToNativeWindow = isAttached
1903
+ }
1904
+
1905
+ internal fun handleAttachedToWindowForTesting() {
1906
+ handleAttachedToWindow()
1907
+ }
1908
+
1909
+ internal fun handleDetachedFromWindowForTesting() {
1910
+ prepareForDetachFromWindow()
1911
+ handleDetachedFromWindow()
1912
+ }
1913
+
1914
+ internal fun performBlurForTesting(deferKeyboardDismiss: Boolean = false) {
1915
+ performBlur(deferKeyboardDismiss = deferKeyboardDismiss, allowRetry = true)
1916
+ }
1917
+
1918
+ internal fun pendingBlurRetryAttemptsForTesting(): Int = pendingBlurRetryAttempts
1919
+
1920
+ internal fun pendingDetachPreflightRetryAttemptsForTesting(): Int =
1921
+ pendingDetachPreflightRetryAttempts
1922
+
1923
+ internal fun hasPendingOutsideTapBlurForTesting(): Boolean = pendingOutsideTapBlur != null
1924
+
1925
+ internal fun hasPendingKeyboardDismissForTesting(): Boolean = pendingKeyboardDismiss != null
1926
+
1927
+ internal fun hasPendingPreflightWakeForTesting(): Boolean = pendingPreflightWakeScheduled
1928
+
1929
+ internal fun hasPendingToolbarRefocusForTesting(): Boolean = pendingToolbarRefocus != null
1930
+
1931
+ internal fun isKeyboardToolbarAttachedForTesting(): Boolean = keyboardToolbarView.parent != null
1932
+
1933
+ internal fun currentImeBottomForTesting(): Int = currentImeBottom
1934
+
1935
+ internal fun setCurrentImeBottomForTesting(bottom: Int) {
1936
+ currentImeBottom = bottom
1937
+ }
1938
+
1939
+ internal fun updateAttachedKeyboardToolbarForInsetsForTesting() {
1940
+ updateAttachedKeyboardToolbarForInsets()
1941
+ }
1942
+
1943
+ internal fun scheduleToolbarRefocusForTesting() {
1944
+ scheduleToolbarRefocus()
1945
+ }
1946
+
1947
+ internal fun applyAutoFocusForTesting() {
1948
+ applyAutoFocusIfNeeded()
1949
+ }
1950
+
1951
+ internal fun installOutsideTapBlurHandlerForTesting() {
1952
+ installOutsideTapBlurHandlerIfNeeded()
1953
+ }
1954
+
1955
+ internal fun uninstallOutsideTapBlurHandlerForTesting() {
1956
+ uninstallOutsideTapBlurHandler()
1957
+ }
1958
+
1959
+ internal fun schedulePendingPreflightWakeForTesting() {
1960
+ schedulePendingPreflightWake()
1961
+ }
1962
+
1963
+ internal fun hasPendingNativeActionForTesting(): Boolean = pendingNativeAction != null
1964
+
1965
+ internal fun pendingNativeActionRetryAttemptsForTesting(): Int = pendingNativeActionRetryAttempts
1966
+
1967
+ internal fun lastDocumentVersionForTesting(): Int? = lastDocumentVersion
1968
+
1969
+ internal fun setLastDocumentVersionForTesting(documentVersion: Int?) {
1970
+ lastDocumentVersion = documentVersion
1971
+ }
1972
+
1973
+ internal fun refreshToolbarStateFromEditorSelectionForTesting(): String? =
1974
+ refreshToolbarStateFromEditorSelection()
1975
+
1976
+ internal fun handleToolbarItemPressForTesting(item: NativeToolbarItem) {
1977
+ handleToolbarItemPress(item)
1978
+ }
1979
+
1980
+ internal fun insertMentionSuggestionForTesting(suggestion: NativeMentionSuggestion) {
1981
+ insertMentionSuggestion(suggestion)
1982
+ }
1983
+
1984
+ internal fun wakePendingPreflightWorkForTesting() {
1985
+ wakePendingPreflightWork()
1986
+ }
1987
+
1988
+ internal fun emitEditorReadyForTesting(editorUpdateRevision: Int? = null): Boolean =
1989
+ emitEditorReady(editorUpdateRevision)
1990
+
1991
+ internal fun pendingEditorUpdateJsonForTesting(): String? = pendingEditorUpdateJson
1992
+
1993
+ internal fun pendingEditorUpdateRevisionForTesting(): Int = pendingEditorUpdateRevision
1994
+
1995
+ internal fun setAppliedEditorUpdateRevisionForTesting(editorUpdateRevision: Int) {
1996
+ appliedEditorUpdateRevision = editorUpdateRevision
1997
+ }
1998
+
1999
+ internal fun pendingEditorUpdateEditorIdForTesting(): Long? = pendingEditorUpdateEditorId
2000
+
2001
+ internal fun pendingViewCommandUpdateJsonForTesting(): String? = pendingViewCommandUpdateJson
2002
+
2003
+ internal fun pendingViewCommandUpdateRetryAttemptsForTesting(): Int =
2004
+ pendingViewCommandUpdateRetryAttempts
2005
+
2006
+ internal fun scheduleViewCommandUpdateRetryForTesting(updateJson: String) {
2007
+ scheduleViewCommandUpdateRetry(updateJson)
2008
+ }
2009
+
2010
+ internal fun pendingThemeJsonForTesting(): String? = pendingThemeJson.takeIf { hasPendingTheme }
2011
+
2012
+ internal fun lastThemeJsonForTesting(): String? = lastThemeJson
2013
+
2014
+ internal fun pendingThemeRetryAttemptsForTesting(): Int = pendingThemeRetryAttempts
2015
+
2016
+ internal fun applyPendingThemeForTesting() {
2017
+ applyPendingThemeIfNeeded()
2018
+ }
2019
+
558
2020
  private fun isTouchInsideStandaloneToolbar(event: MotionEvent): Boolean {
559
2021
  val visibleWindowFrame = Rect()
560
2022
  getWindowVisibleDisplayFrame(visibleWindowFrame)
@@ -619,6 +2081,11 @@ class NativeEditorExpoView(
619
2081
  private const val TOOLBAR_HIT_SLOP_DP = 8f
620
2082
  private const val TOOLBAR_FOCUS_PRESERVE_MS = 750L
621
2083
  private const val OUTSIDE_TAP_BLUR_DELAY_MS = 100L
2084
+ private const val NATIVE_ACTION_RETRY_DELAY_MS = 16L
2085
+ private const val PENDING_UPDATE_RECOVERY_RETRY_DELAY_MS = 250L
2086
+ private const val MAX_NATIVE_ACTION_RETRY_ATTEMPTS = 3
2087
+ private const val MAX_PENDING_UPDATE_RETRY_ATTEMPTS = 5
2088
+ private const val LOG_TAG = "NativeEditor"
622
2089
  }
623
2090
 
624
2091
  private fun resolveActivity(context: Context): Activity? {
@@ -658,8 +2125,12 @@ class NativeEditorExpoView(
658
2125
  )
659
2126
  }
660
2127
 
661
- private fun clearMentionQueryState() {
2128
+ private fun clearMentionQueryState(resetLastEvent: Boolean = false) {
662
2129
  mentionQueryState = null
2130
+ if (resetLastEvent) {
2131
+ lastMentionEventJson = null
2132
+ lastMentionEventEditorId = null
2133
+ }
663
2134
  syncKeyboardToolbarMentionSuggestions(emptyList())
664
2135
  }
665
2136
 
@@ -722,10 +2193,15 @@ class NativeEditorExpoView(
722
2193
  .put("trigger", trigger)
723
2194
  .put("range", JSONObject().put("anchor", anchor).put("head", head))
724
2195
  .put("isActive", isActive)
2196
+ .apply {
2197
+ lastDocumentVersion?.let { put("documentVersion", it) }
2198
+ }
725
2199
  .toString()
726
- if (eventJson == lastMentionEventJson) return
2200
+ val editorId = richTextView.editorId
2201
+ if (eventJson == lastMentionEventJson && editorId == lastMentionEventEditorId) return
727
2202
  lastMentionEventJson = eventJson
728
- onAddonEvent(mapOf("eventJson" to eventJson))
2203
+ lastMentionEventEditorId = editorId
2204
+ emitAddonEvent(mapOf("eventJson" to eventJson, "editorId" to editorId))
729
2205
  }
730
2206
 
731
2207
  private fun resolvedMentionAttrs(
@@ -748,15 +2224,19 @@ class NativeEditorExpoView(
748
2224
  .put("trigger", trigger)
749
2225
  .put("suggestionKey", suggestion.key)
750
2226
  .put("attrs", attrs)
2227
+ .apply {
2228
+ lastDocumentVersion?.let { put("documentVersion", it) }
2229
+ }
751
2230
  .toString()
752
- onAddonEvent(mapOf("eventJson" to eventJson))
2231
+ emitAddonEvent(mapOf("eventJson" to eventJson, "editorId" to richTextView.editorId))
753
2232
  }
754
2233
 
755
2234
  private fun emitMentionSelectRequest(
756
2235
  trigger: String,
757
2236
  suggestion: NativeMentionSuggestion,
758
2237
  attrs: JSONObject,
759
- range: MentionQueryState
2238
+ range: MentionQueryState,
2239
+ preflightUpdateJSON: String?
760
2240
  ) {
761
2241
  val eventJson = JSONObject()
762
2242
  .put("type", "mentionsSelectRequest")
@@ -764,16 +2244,60 @@ class NativeEditorExpoView(
764
2244
  .put("suggestionKey", suggestion.key)
765
2245
  .put("attrs", attrs)
766
2246
  .put("range", JSONObject().put("anchor", range.anchor).put("head", range.head))
2247
+ .apply {
2248
+ if (preflightUpdateJSON != null) {
2249
+ put("updateJson", preflightUpdateJSON)
2250
+ }
2251
+ (documentVersionFromUpdateJSON(preflightUpdateJSON) ?: lastDocumentVersion)
2252
+ ?.let { put("documentVersion", it) }
2253
+ }
767
2254
  .toString()
768
- onAddonEvent(mapOf("eventJson" to eventJson))
2255
+ emitAddonEvent(mapOf("eventJson" to eventJson, "editorId" to richTextView.editorId))
769
2256
  }
770
2257
 
771
- private fun insertMentionSuggestion(suggestion: NativeMentionSuggestion) {
2258
+ private fun insertMentionSuggestion(
2259
+ suggestion: NativeMentionSuggestion,
2260
+ allowPreflightRetry: Boolean = true
2261
+ ) {
2262
+ if (handleDestroyedCurrentEditorIfNeeded()) return
2263
+ if (!richTextView.editorEditText.isEditable) {
2264
+ clearPendingNativeActionRetry()
2265
+ return
2266
+ }
772
2267
  val mentions = addons.mentions ?: return
773
- val queryState = mentionQueryState ?: return
2268
+ if (shouldBlockEditorCommandForPendingUpdate()) {
2269
+ if (allowPreflightRetry) {
2270
+ schedulePendingNativeActionRetry(
2271
+ PendingNativeAction.MentionSuggestionSelect(suggestion)
2272
+ )
2273
+ }
2274
+ return
2275
+ }
2276
+ val preparation = richTextView.editorEditText.prepareForExternalEditorCommand()
2277
+ if (!preparation.ready) {
2278
+ if (allowPreflightRetry) {
2279
+ schedulePendingNativeActionRetry(
2280
+ PendingNativeAction.MentionSuggestionSelect(suggestion)
2281
+ )
2282
+ }
2283
+ return
2284
+ }
2285
+ val preflightUpdateJSON = preparation.updateJSON
2286
+ noteDocumentVersionFromUpdateJSON(preflightUpdateJSON)
2287
+ clearPendingNativeActionRetry()
2288
+ val queryState = currentMentionQueryState(mentions.trigger) ?: run {
2289
+ clearMentionQueryState()
2290
+ return
2291
+ }
2292
+ val freshSuggestions = filteredMentionSuggestions(queryState, mentions)
2293
+ if (freshSuggestions.none { it.key == suggestion.key }) {
2294
+ refreshMentionQuery()
2295
+ return
2296
+ }
2297
+ mentionQueryState = queryState
774
2298
  val attrs = resolvedMentionAttrs(mentions.trigger, suggestion)
775
2299
  if (mentions.resolveSelectionAttrs || mentions.resolveTheme) {
776
- emitMentionSelectRequest(mentions.trigger, suggestion, attrs, queryState)
2300
+ emitMentionSelectRequest(mentions.trigger, suggestion, attrs, queryState, preflightUpdateJSON)
777
2301
  lastMentionEventJson = null
778
2302
  clearMentionQueryState()
779
2303
  return
@@ -803,7 +2327,14 @@ class NativeEditorExpoView(
803
2327
 
804
2328
  private fun refreshToolbarStateFromEditorSelection(): String? {
805
2329
  if (richTextView.editorId == 0L) return null
2330
+ if (handleDestroyedCurrentEditorIfNeeded()) return null
2331
+ onRefreshToolbarStateFromEditorSelectionForTesting?.let { callback ->
2332
+ val stateJson = callback()
2333
+ noteDocumentVersionFromUpdateJSON(stateJson)
2334
+ return stateJson
2335
+ }
806
2336
  val stateJson = editorGetSelectionState(richTextView.editorId.toULong())
2337
+ noteDocumentVersionFromUpdateJSON(stateJson)
807
2338
  val state = NativeToolbarState.fromUpdateJson(stateJson) ?: return null
808
2339
  toolbarState = state
809
2340
  keyboardToolbarView.applyState(state)
@@ -847,6 +2378,9 @@ class NativeEditorExpoView(
847
2378
  }
848
2379
 
849
2380
  private fun updateAttachedKeyboardToolbarForInsets() {
2381
+ if (currentImeBottom <= 0) {
2382
+ clearPendingNativeActionRetry()
2383
+ }
850
2384
  keyboardToolbarView.visibility = if (currentImeBottom > 0) View.VISIBLE else View.INVISIBLE
851
2385
  updateEditorViewportInset()
852
2386
  }
@@ -854,6 +2388,7 @@ class NativeEditorExpoView(
854
2388
  private fun updateKeyboardToolbarVisibility() {
855
2389
  val shouldAttach =
856
2390
  showsToolbar &&
2391
+ canFocusCurrentEditor() &&
857
2392
  toolbarPlacement == ToolbarPlacement.KEYBOARD &&
858
2393
  richTextView.editorEditText.isEditable &&
859
2394
  richTextView.editorEditText.hasFocus()
@@ -870,7 +2405,7 @@ class NativeEditorExpoView(
870
2405
  updateEditorViewportInset()
871
2406
  }
872
2407
 
873
- private fun updateEditorViewportInset() {
2408
+ private fun updateEditorViewportInset(forceMeasureToolbar: Boolean = false) {
874
2409
  val shouldReserveToolbarSpace =
875
2410
  heightBehavior == EditorHeightBehavior.FIXED &&
876
2411
  showsToolbar &&
@@ -889,7 +2424,7 @@ class NativeEditorExpoView(
889
2424
  val toolbarTheme = richTextView.editorEditText.theme?.toolbar
890
2425
  val density = resources.displayMetrics.density
891
2426
  val horizontalInsetPx = ((toolbarTheme?.resolvedHorizontalInset() ?: 0f) * density).toInt()
892
- if (keyboardToolbarView.measuredHeight == 0) {
2427
+ if (forceMeasureToolbar || keyboardToolbarView.measuredHeight == 0) {
893
2428
  val availableWidth = (hostWidth - horizontalInsetPx * 2).coerceAtLeast(0)
894
2429
  val widthSpec = MeasureSpec.makeMeasureSpec(availableWidth, MeasureSpec.AT_MOST)
895
2430
  val heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
@@ -904,7 +2439,46 @@ class NativeEditorExpoView(
904
2439
  richTextView.editorEditText.performToolbarToggleList(listType, isActive)
905
2440
  }
906
2441
 
907
- private fun handleToolbarItemPress(item: NativeToolbarItem) {
2442
+ private fun handleToolbarItemPress(
2443
+ item: NativeToolbarItem,
2444
+ allowPreflightRetry: Boolean = true
2445
+ ) {
2446
+ if (handleDestroyedCurrentEditorIfNeeded()) return
2447
+ if (!richTextView.editorEditText.isEditable) {
2448
+ clearPendingNativeActionRetry()
2449
+ return
2450
+ }
2451
+ var preflightUpdateJSON: String? = null
2452
+ val needsEditorPreflight = when (item.type) {
2453
+ ToolbarItemKind.mark,
2454
+ ToolbarItemKind.heading,
2455
+ ToolbarItemKind.blockquote,
2456
+ ToolbarItemKind.list,
2457
+ ToolbarItemKind.command,
2458
+ ToolbarItemKind.node,
2459
+ ToolbarItemKind.action -> true
2460
+ ToolbarItemKind.group,
2461
+ ToolbarItemKind.separator -> false
2462
+ }
2463
+ if (needsEditorPreflight) {
2464
+ if (shouldBlockEditorCommandForPendingUpdate()) {
2465
+ if (allowPreflightRetry) {
2466
+ schedulePendingNativeActionRetry(PendingNativeAction.ToolbarItemPress(item))
2467
+ }
2468
+ return
2469
+ }
2470
+ val preparation = richTextView.editorEditText.prepareForExternalEditorCommand()
2471
+ if (!preparation.ready) {
2472
+ if (allowPreflightRetry) {
2473
+ schedulePendingNativeActionRetry(PendingNativeAction.ToolbarItemPress(item))
2474
+ }
2475
+ return
2476
+ }
2477
+ preflightUpdateJSON = preparation.updateJSON
2478
+ noteDocumentVersionFromUpdateJSON(preflightUpdateJSON)
2479
+ clearPendingNativeActionRetry()
2480
+ }
2481
+ if (handleDestroyedCurrentEditorIfNeeded()) return
908
2482
  when (item.type) {
909
2483
  ToolbarItemKind.mark -> item.mark?.let { richTextView.editorEditText.performToolbarToggleMark(it) }
910
2484
  ToolbarItemKind.heading -> item.headingLevel?.let { richTextView.editorEditText.performToolbarToggleHeading(it) }
@@ -918,7 +2492,20 @@ class NativeEditorExpoView(
918
2492
  null -> Unit
919
2493
  }
920
2494
  ToolbarItemKind.node -> item.nodeType?.let { richTextView.editorEditText.performToolbarInsertNode(it) }
921
- ToolbarItemKind.action -> item.key?.let { onToolbarAction(mapOf("key" to it)) }
2495
+ ToolbarItemKind.action -> item.key?.let {
2496
+ if (handleDestroyedCurrentEditorIfNeeded()) return
2497
+ val payload = mutableMapOf<String, Any>(
2498
+ "key" to it,
2499
+ "editorId" to richTextView.editorId
2500
+ )
2501
+ addPreflightUpdateToEvent(payload, preflightUpdateJSON)
2502
+ if (!payload.containsKey("documentVersion")) {
2503
+ lastDocumentVersion?.let { version ->
2504
+ payload["documentVersion"] = version
2505
+ }
2506
+ }
2507
+ onToolbarActionForTesting?.invoke(payload) ?: onToolbarAction(payload)
2508
+ }
922
2509
  ToolbarItemKind.group -> Unit
923
2510
  ToolbarItemKind.separator -> Unit
924
2511
  }