@openeditor/react-native-prose-editor 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +160 -0
  2. package/README.md +180 -0
  3. package/android/build.gradle +69 -0
  4. package/android/consumer-rules.pro +18 -0
  5. package/android/src/androidTest/AndroidManifest.xml +8 -0
  6. package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +53 -0
  7. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +241 -0
  8. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +338 -0
  9. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +789 -0
  10. package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +350 -0
  11. package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +5 -0
  12. package/android/src/main/assets/editor-icons/MaterialIcons.json +2236 -0
  13. package/android/src/main/assets/editor-icons/MaterialIcons.ttf +0 -0
  14. package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +50 -0
  15. package/android/src/main/java/com/apollohg/editor/EditorAddons.kt +135 -0
  16. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +4527 -0
  17. package/android/src/main/java/com/apollohg/editor/EditorHeightBehavior.kt +14 -0
  18. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +999 -0
  19. package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +474 -0
  20. package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +199 -0
  21. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +3137 -0
  22. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +561 -0
  23. package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +315 -0
  24. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +1413 -0
  25. package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +133 -0
  26. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +315 -0
  27. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +2172 -0
  28. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +399 -0
  29. package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +482 -0
  30. package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +137 -0
  31. package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +321 -0
  32. package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +4202 -0
  33. package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +2581 -0
  34. package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +27 -0
  35. package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +197 -0
  36. package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +84 -0
  37. package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +544 -0
  38. package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +461 -0
  39. package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +2054 -0
  40. package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +1367 -0
  41. package/app.plugin.js +62 -0
  42. package/dist/EditorTheme.d.ts +113 -0
  43. package/dist/EditorTheme.js +29 -0
  44. package/dist/EditorToolbar.d.ts +191 -0
  45. package/dist/EditorToolbar.js +1095 -0
  46. package/dist/NativeEditorBridge.d.ts +325 -0
  47. package/dist/NativeEditorBridge.js +933 -0
  48. package/dist/NativeProseViewer.d.ts +53 -0
  49. package/dist/NativeProseViewer.js +480 -0
  50. package/dist/NativeRichTextEditor.d.ts +183 -0
  51. package/dist/NativeRichTextEditor.js +2574 -0
  52. package/dist/YjsCollaboration.d.ts +90 -0
  53. package/dist/YjsCollaboration.js +743 -0
  54. package/dist/addons.d.ts +100 -0
  55. package/dist/addons.js +82 -0
  56. package/dist/heightCache.d.ts +6 -0
  57. package/dist/heightCache.js +45 -0
  58. package/dist/index.d.ts +10 -0
  59. package/dist/index.js +30 -0
  60. package/dist/schemas.d.ts +37 -0
  61. package/dist/schemas.js +263 -0
  62. package/dist/useNativeEditor.d.ts +42 -0
  63. package/dist/useNativeEditor.js +124 -0
  64. package/expo-module.config.json +9 -0
  65. package/ios/EditorAddons.swift +327 -0
  66. package/ios/EditorCore.xcframework/Info.plist +44 -0
  67. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  68. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  69. package/ios/EditorLayoutManager.swift +774 -0
  70. package/ios/EditorTheme.swift +515 -0
  71. package/ios/Generated_editor_core.swift +1513 -0
  72. package/ios/NativeEditorExpoView.swift +3593 -0
  73. package/ios/NativeEditorModule.swift +624 -0
  74. package/ios/NativeProseViewerExpoView.swift +276 -0
  75. package/ios/PositionBridge.swift +558 -0
  76. package/ios/ReactNativeProseEditor.podspec +49 -0
  77. package/ios/RenderBridge.swift +1708 -0
  78. package/ios/RichTextEditorView.swift +6150 -0
  79. package/ios/Tests/NativePerformanceTests.swift +687 -0
  80. package/ios/Tests/PositionBridgeTests.swift +706 -0
  81. package/ios/Tests/RenderBridgeTests.swift +2236 -0
  82. package/ios/Tests/RichTextEditorViewTests.swift +6548 -0
  83. package/ios/editor_coreFFI/editor_coreFFI.h +1289 -0
  84. package/ios/editor_coreFFI/module.modulemap +7 -0
  85. package/ios/editor_coreFFI.h +904 -0
  86. package/ios/editor_coreFFI.modulemap +7 -0
  87. package/package.json +74 -0
  88. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  89. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  90. package/rust/android/x86_64/libeditor_core.so +0 -0
  91. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2563 -0
@@ -0,0 +1,3137 @@
1
+ package com.openeditor.editor
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import android.content.ContextWrapper
6
+ import android.graphics.Rect
7
+ import android.graphics.RectF
8
+ import android.os.Handler
9
+ import android.os.Looper
10
+ import android.os.SystemClock
11
+ import android.util.Log
12
+ import android.view.Gravity
13
+ import android.view.MotionEvent
14
+ import android.view.View
15
+ import android.view.ViewConfiguration
16
+ import android.view.ViewGroup
17
+ import android.view.ViewTreeObserver
18
+ import android.view.Window
19
+ import android.view.inputmethod.InputMethodManager
20
+ import android.widget.FrameLayout
21
+ import androidx.core.view.ViewCompat
22
+ import androidx.core.view.WindowInsetsCompat
23
+ import expo.modules.kotlin.AppContext
24
+ import expo.modules.kotlin.viewevent.EventDispatcher
25
+ import expo.modules.kotlin.views.ExpoView
26
+ import org.json.JSONArray
27
+ import org.json.JSONObject
28
+ import java.lang.ref.WeakReference
29
+ import java.util.concurrent.CountDownLatch
30
+ import java.util.concurrent.TimeUnit
31
+ import java.util.WeakHashMap
32
+ import java.util.concurrent.atomic.AtomicInteger
33
+ import java.util.concurrent.atomic.AtomicReference
34
+ import uniffi.editor_core.*
35
+
36
+ private const val DESTROY_INVALIDATION_AWAIT_TIMEOUT_MS = 250L
37
+ private const val OUTSIDE_TAP_GESTURE_CONFIRM_DELAY_MS = 150L
38
+
39
+ internal enum class NativeEditorOutsideTapDecision {
40
+ IGNORE,
41
+ PRESERVE_FOCUS,
42
+ OUTSIDE_EDITOR
43
+ }
44
+
45
+ private class WeakNativeEditorExpoView private constructor(
46
+ val view: WeakReference<NativeEditorExpoView?>
47
+ ) {
48
+ constructor(view: NativeEditorExpoView) : this(WeakReference(view))
49
+
50
+ companion object {
51
+ fun cleared(): WeakNativeEditorExpoView =
52
+ WeakNativeEditorExpoView(WeakReference<NativeEditorExpoView?>(null))
53
+ }
54
+ }
55
+
56
+ internal object NativeEditorViewRegistry {
57
+ private data class CommandPreparationSnapshot(
58
+ val view: NativeEditorExpoView?,
59
+ val isDetached: Boolean,
60
+ val isDestroyed: Boolean
61
+ )
62
+
63
+ private val viewsByEditorId = mutableMapOf<Long, WeakNativeEditorExpoView>()
64
+ private val detachedEditorOwnersByEditorId = mutableMapOf<Long, WeakNativeEditorExpoView>()
65
+ private val destroyedEditorIds = mutableSetOf<Long>()
66
+ private val mainHandler = Handler(Looper.getMainLooper())
67
+
68
+ @Synchronized
69
+ fun markEditorCreated(editorId: Long) {
70
+ if (editorId == 0L) return
71
+ destroyedEditorIds.remove(editorId)
72
+ }
73
+
74
+ @Synchronized
75
+ fun register(editorId: Long, view: NativeEditorExpoView): Boolean {
76
+ if (editorId == 0L) return false
77
+ if (destroyedEditorIds.contains(editorId)) return false
78
+ viewsByEditorId[editorId] = WeakNativeEditorExpoView(view)
79
+ detachedEditorOwnersByEditorId.remove(editorId)
80
+ return true
81
+ }
82
+
83
+ @Synchronized
84
+ fun unregister(
85
+ editorId: Long,
86
+ view: NativeEditorExpoView,
87
+ blockCommandsUntilRegistered: Boolean = false
88
+ ) {
89
+ if (editorId == 0L) return
90
+ val registeredView = viewsByEditorId[editorId]?.view?.get()
91
+ if (registeredView === view) {
92
+ viewsByEditorId.remove(editorId)
93
+ }
94
+ if (blockCommandsUntilRegistered) {
95
+ detachedEditorOwnersByEditorId[editorId] = WeakNativeEditorExpoView(view)
96
+ } else {
97
+ val detachedOwner = detachedEditorOwnersByEditorId[editorId]?.view?.get()
98
+ if (registeredView === view || detachedOwner === view) {
99
+ detachedEditorOwnersByEditorId.remove(editorId)
100
+ }
101
+ }
102
+ }
103
+
104
+ @Synchronized
105
+ fun isDestroyed(editorId: Long): Boolean = destroyedEditorIds.contains(editorId)
106
+
107
+ @Synchronized
108
+ internal fun forceDetachedOwnerClearedForTesting(editorId: Long) {
109
+ detachedEditorOwnersByEditorId[editorId] = WeakNativeEditorExpoView.cleared()
110
+ }
111
+
112
+ fun invalidateDestroyedEditor(editorId: Long) {
113
+ if (editorId == 0L) return
114
+ val affectedViews = synchronized(this) {
115
+ destroyedEditorIds.add(editorId)
116
+ val views = listOfNotNull(
117
+ viewsByEditorId.remove(editorId)?.view?.get(),
118
+ detachedEditorOwnersByEditorId.remove(editorId)?.view?.get()
119
+ ).distinct()
120
+ views
121
+ }
122
+ if (affectedViews.isEmpty()) return
123
+ val invalidate = Runnable {
124
+ affectedViews.forEach { view ->
125
+ view.handleEditorDestroyed(editorId)
126
+ }
127
+ }
128
+ if (Looper.myLooper() == Looper.getMainLooper()) {
129
+ invalidate.run()
130
+ } else {
131
+ val latch = CountDownLatch(1)
132
+ val posted = mainHandler.post {
133
+ try {
134
+ invalidate.run()
135
+ } finally {
136
+ latch.countDown()
137
+ }
138
+ }
139
+ if (!posted) return
140
+ try {
141
+ latch.await(DESTROY_INVALIDATION_AWAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS)
142
+ } catch (_: InterruptedException) {
143
+ Thread.currentThread().interrupt()
144
+ } catch (_: Throwable) {
145
+ return
146
+ }
147
+ }
148
+ }
149
+
150
+ fun prepareForCommandJSON(editorId: Long): String {
151
+ val prepare = {
152
+ val snapshot = synchronized(this) {
153
+ val isDestroyed = destroyedEditorIds.contains(editorId)
154
+ if (isDestroyed) {
155
+ return@synchronized CommandPreparationSnapshot(
156
+ view = null,
157
+ isDetached = false,
158
+ isDestroyed = true
159
+ )
160
+ }
161
+ val candidate = viewsByEditorId[editorId]?.view?.get()
162
+ if (candidate == null) {
163
+ viewsByEditorId.remove(editorId)
164
+ }
165
+ val detachedOwner = detachedEditorOwnersByEditorId[editorId]?.view?.get()
166
+ val isDetached = if (detachedOwner == null) {
167
+ detachedEditorOwnersByEditorId.remove(editorId)
168
+ false
169
+ } else {
170
+ true
171
+ }
172
+ CommandPreparationSnapshot(
173
+ view = candidate,
174
+ isDetached = isDetached,
175
+ isDestroyed = false
176
+ )
177
+ }
178
+ snapshot.view?.prepareForEditorCommandJSON()
179
+ ?: commandPreparationJSON(
180
+ ready = !snapshot.isDetached && !snapshot.isDestroyed,
181
+ blockedReason = if (snapshot.isDestroyed) {
182
+ "destroyed"
183
+ } else if (snapshot.isDetached) {
184
+ "detached"
185
+ } else {
186
+ null
187
+ }
188
+ )
189
+ }
190
+
191
+ if (Looper.myLooper() == Looper.getMainLooper()) {
192
+ return prepare()
193
+ }
194
+
195
+ val result = AtomicReference(commandPreparationJSON(ready = false, blockedReason = "unknown"))
196
+ val state = AtomicInteger(PREFLIGHT_STATE_QUEUED)
197
+ val latch = CountDownLatch(1)
198
+ if (!mainHandler.post {
199
+ try {
200
+ if (state.compareAndSet(PREFLIGHT_STATE_QUEUED, PREFLIGHT_STATE_RUNNING)) {
201
+ result.set(prepare())
202
+ state.set(PREFLIGHT_STATE_DONE)
203
+ }
204
+ } finally {
205
+ latch.countDown()
206
+ }
207
+ }) {
208
+ return commandPreparationJSON(ready = false, blockedReason = "unknown")
209
+ }
210
+ try {
211
+ if (!latch.await(DESTROY_INVALIDATION_AWAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
212
+ if (state.compareAndSet(PREFLIGHT_STATE_QUEUED, PREFLIGHT_STATE_CANCELLED)) {
213
+ return commandPreparationJSON(ready = false, blockedReason = "unknown")
214
+ }
215
+ if (state.get() == PREFLIGHT_STATE_RUNNING) {
216
+ latch.await()
217
+ return result.get()
218
+ }
219
+ return commandPreparationJSON(ready = false, blockedReason = "unknown")
220
+ }
221
+ } catch (_: InterruptedException) {
222
+ var interrupted = true
223
+ if (state.compareAndSet(PREFLIGHT_STATE_QUEUED, PREFLIGHT_STATE_CANCELLED)) {
224
+ Thread.currentThread().interrupt()
225
+ return commandPreparationJSON(ready = false, blockedReason = "unknown")
226
+ }
227
+ while (state.get() == PREFLIGHT_STATE_RUNNING) {
228
+ try {
229
+ latch.await()
230
+ break
231
+ } catch (_: InterruptedException) {
232
+ interrupted = true
233
+ }
234
+ }
235
+ if (interrupted) {
236
+ Thread.currentThread().interrupt()
237
+ }
238
+ return if (state.get() == PREFLIGHT_STATE_DONE) {
239
+ result.get()
240
+ } else {
241
+ commandPreparationJSON(ready = false, blockedReason = "unknown")
242
+ }
243
+ }
244
+ return result.get()
245
+ }
246
+
247
+ fun commandPreparationJSON(
248
+ ready: Boolean,
249
+ updateJSON: String? = null,
250
+ blockedReason: String? = null
251
+ ): String {
252
+ return JSONObject().apply {
253
+ put("ready", ready)
254
+ if (updateJSON != null) {
255
+ put("updateJSON", updateJSON)
256
+ }
257
+ if (!ready && blockedReason != null) {
258
+ put("blockedReason", blockedReason)
259
+ }
260
+ }.toString()
261
+ }
262
+
263
+ private const val PREFLIGHT_STATE_QUEUED = 0
264
+ private const val PREFLIGHT_STATE_RUNNING = 1
265
+ private const val PREFLIGHT_STATE_CANCELLED = 2
266
+ private const val PREFLIGHT_STATE_DONE = 3
267
+ }
268
+
269
+ private object NativeEditorOutsideTapDispatcher {
270
+ private val dispatchers = WeakHashMap<Window, OutsideTapTouchDispatcher>()
271
+
272
+ fun register(window: Window, view: NativeEditorExpoView): Boolean {
273
+ val host = contentRootFor(window)
274
+ if (host == null) {
275
+ view.traceOutsideTap("register skipped missing content root")
276
+ return false
277
+ }
278
+ val previousDispatcher = dispatchers[window]
279
+ val dispatcher = if (previousDispatcher?.host === host) {
280
+ previousDispatcher
281
+ } else {
282
+ OutsideTapTouchDispatcher(host).also { nextDispatcher ->
283
+ previousDispatcher?.transferViewsTo(nextDispatcher)
284
+ previousDispatcher?.detach()
285
+ dispatchers[window] = nextDispatcher
286
+ }
287
+ }
288
+ dispatchers[window] = dispatcher
289
+ dispatcher.add(view)
290
+ view.traceOutsideTap(
291
+ "register overlayAttached=${dispatcher.isAttached()} " +
292
+ "host=${host.javaClass.name} " +
293
+ "activeViews=${dispatcher.liveViews().size}"
294
+ )
295
+ return dispatcher.isAttached()
296
+ }
297
+
298
+ fun unregister(window: Window, view: NativeEditorExpoView) {
299
+ val dispatcher = dispatchers[window] ?: return
300
+ if (!dispatcher.remove(view)) return
301
+ dispatcher.detach()
302
+ dispatchers.remove(window)
303
+ }
304
+
305
+ private fun contentRootFor(window: Window): ViewGroup? {
306
+ val decorView = window.decorView
307
+ return decorView.findViewById<View>(android.R.id.content) as? ViewGroup
308
+ ?: decorView as? ViewGroup
309
+ }
310
+
311
+ private class OutsideTapTouchDispatcher(
312
+ val host: ViewGroup
313
+ ) : View.OnTouchListener {
314
+ private data class OutsideTapCandidate(
315
+ val view: WeakReference<NativeEditorExpoView>,
316
+ val downRawX: Float,
317
+ val downRawY: Float,
318
+ val editorRectOnDown: Rect?,
319
+ val confirm: Runnable
320
+ )
321
+
322
+ private val views = mutableListOf<WeakReference<NativeEditorExpoView>>()
323
+ private val pendingOutsideTapCandidates = mutableListOf<OutsideTapCandidate>()
324
+ private val touchSlopPx = ViewConfiguration.get(host.context).scaledTouchSlop
325
+ private val scrollChangedListener = ViewTreeObserver.OnScrollChangedListener {
326
+ cancelPendingOutsideTapCandidates("scroll")
327
+ }
328
+ private var scrollListenerTreeObserver: ViewTreeObserver? = null
329
+ private var attachmentGeneration = 0
330
+ private val observerView = View(host.context).apply {
331
+ isClickable = false
332
+ isFocusable = false
333
+ isFocusableInTouchMode = false
334
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
335
+ }
336
+
337
+ init {
338
+ observerView.setOnTouchListener(this)
339
+ attach()
340
+ }
341
+
342
+ fun add(view: NativeEditorExpoView) {
343
+ prune()
344
+ attach()
345
+ if (views.any { it.get() === view }) return
346
+ views.add(WeakReference(view))
347
+ }
348
+
349
+ fun liveViews(): List<NativeEditorExpoView> {
350
+ prune()
351
+ return views.mapNotNull { it.get() }
352
+ }
353
+
354
+ fun transferViewsTo(target: OutsideTapTouchDispatcher) {
355
+ liveViews().forEach { target.add(it) }
356
+ views.clear()
357
+ cancelPendingOutsideTapCandidates("transfer")
358
+ }
359
+
360
+ fun remove(view: NativeEditorExpoView): Boolean {
361
+ cancelPendingOutsideTapCandidatesFor(view, "remove view")
362
+ views.removeAll { it.get()?.let { candidate -> candidate === view } != false }
363
+ return views.isEmpty()
364
+ }
365
+
366
+ override fun onTouch(view: View, event: MotionEvent): Boolean {
367
+ val activeViews = liveViews()
368
+ if (activeViews.isEmpty()) {
369
+ return false
370
+ }
371
+
372
+ when (event.actionMasked) {
373
+ MotionEvent.ACTION_DOWN -> handleActionDown(activeViews, event)
374
+ MotionEvent.ACTION_MOVE -> {
375
+ if (hasMovedBeyondTapSlop(event)) {
376
+ cancelPendingOutsideTapCandidates("move")
377
+ }
378
+ }
379
+ MotionEvent.ACTION_UP -> {
380
+ if (hasMovedBeyondTapSlop(event)) {
381
+ cancelPendingOutsideTapCandidates("up moved")
382
+ } else {
383
+ confirmPendingOutsideTapCandidates("up")
384
+ }
385
+ }
386
+ MotionEvent.ACTION_CANCEL -> cancelPendingOutsideTapCandidates("cancel")
387
+ }
388
+ return false
389
+ }
390
+
391
+ private fun handleActionDown(activeViews: List<NativeEditorExpoView>, event: MotionEvent) {
392
+ cancelPendingOutsideTapCandidates("new down")
393
+ val decisions = activeViews.map { view ->
394
+ view to view.prepareOutsideTapDecisionForWindowEvent(event)
395
+ }
396
+ decisions.forEach { (view, decision) ->
397
+ view.traceOutsideTap(
398
+ "dispatch overlay action=${event.action} raw=${event.rawX.toInt()},${event.rawY.toInt()} decision=$decision"
399
+ )
400
+ if (decision == NativeEditorOutsideTapDecision.OUTSIDE_EDITOR) {
401
+ scheduleOutsideTapCandidate(view, event)
402
+ } else {
403
+ view.handleOutsideTapDecisionFromWindowDispatcher(decision)
404
+ }
405
+ }
406
+ }
407
+
408
+ private fun scheduleOutsideTapCandidate(view: NativeEditorExpoView, event: MotionEvent) {
409
+ val editorRect = Rect()
410
+ val editorRectOnDown = if (
411
+ view.richTextView.editorEditText.getGlobalVisibleRect(editorRect) &&
412
+ !editorRect.isEmpty
413
+ ) {
414
+ editorRect
415
+ } else {
416
+ null
417
+ }
418
+ val viewRef = WeakReference(view)
419
+ lateinit var candidate: OutsideTapCandidate
420
+ val confirm = Runnable {
421
+ confirmOutsideTapCandidate(candidate, "delay")
422
+ }
423
+ candidate = OutsideTapCandidate(
424
+ view = viewRef,
425
+ downRawX = event.rawX,
426
+ downRawY = event.rawY,
427
+ editorRectOnDown = editorRectOnDown,
428
+ confirm = confirm
429
+ )
430
+ pendingOutsideTapCandidates.add(candidate)
431
+ ensureScrollListener()
432
+ view.traceOutsideTap("candidate outside tap")
433
+ observerView.postDelayed(confirm, OUTSIDE_TAP_GESTURE_CONFIRM_DELAY_MS)
434
+ }
435
+
436
+ private fun confirmPendingOutsideTapCandidates(reason: String) {
437
+ val candidates = pendingOutsideTapCandidates.toList()
438
+ candidates.forEach { candidate ->
439
+ confirmOutsideTapCandidate(candidate, reason)
440
+ }
441
+ }
442
+
443
+ private fun confirmOutsideTapCandidate(candidate: OutsideTapCandidate, reason: String) {
444
+ if (!pendingOutsideTapCandidates.remove(candidate)) return
445
+ removeScrollListenerIfIdle()
446
+ observerView.removeCallbacks(candidate.confirm)
447
+ val view = candidate.view.get() ?: return
448
+ if (editorMovedBeyondTapSlop(view, candidate)) {
449
+ view.traceOutsideTap("cancel outside tap candidate reason=$reason moved")
450
+ return
451
+ }
452
+ view.traceOutsideTap("confirm outside tap candidate reason=$reason")
453
+ view.handleOutsideTapDecisionFromWindowDispatcher(NativeEditorOutsideTapDecision.OUTSIDE_EDITOR)
454
+ }
455
+
456
+ private fun hasMovedBeyondTapSlop(event: MotionEvent): Boolean =
457
+ pendingOutsideTapCandidates.any { candidate ->
458
+ val dx = event.rawX - candidate.downRawX
459
+ val dy = event.rawY - candidate.downRawY
460
+ dx * dx + dy * dy > touchSlopPx * touchSlopPx
461
+ }
462
+
463
+ private fun editorMovedBeyondTapSlop(
464
+ view: NativeEditorExpoView,
465
+ candidate: OutsideTapCandidate
466
+ ): Boolean {
467
+ val editorRectOnDown = candidate.editorRectOnDown ?: return false
468
+ val currentRect = Rect()
469
+ if (!view.richTextView.editorEditText.getGlobalVisibleRect(currentRect)) {
470
+ return true
471
+ }
472
+ val dx = currentRect.left - editorRectOnDown.left
473
+ val dy = currentRect.top - editorRectOnDown.top
474
+ return dx * dx + dy * dy > touchSlopPx * touchSlopPx
475
+ }
476
+
477
+ private fun cancelPendingOutsideTapCandidatesFor(view: NativeEditorExpoView, reason: String) {
478
+ val candidates = pendingOutsideTapCandidates.toList()
479
+ candidates.forEach { candidate ->
480
+ if (candidate.view.get() === view) {
481
+ pendingOutsideTapCandidates.remove(candidate)
482
+ observerView.removeCallbacks(candidate.confirm)
483
+ view.traceOutsideTap("cancel outside tap candidate reason=$reason")
484
+ }
485
+ }
486
+ removeScrollListenerIfIdle()
487
+ }
488
+
489
+ private fun cancelPendingOutsideTapCandidates(reason: String) {
490
+ val candidates = pendingOutsideTapCandidates.toList()
491
+ pendingOutsideTapCandidates.clear()
492
+ removeScrollListener()
493
+ candidates.forEach { candidate ->
494
+ observerView.removeCallbacks(candidate.confirm)
495
+ candidate.view.get()?.traceOutsideTap("cancel outside tap candidate reason=$reason")
496
+ }
497
+ }
498
+
499
+ private fun ensureScrollListener() {
500
+ val activeObserver = scrollListenerTreeObserver
501
+ if (activeObserver?.isAlive == true && activeObserver === host.viewTreeObserver) {
502
+ return
503
+ }
504
+ removeScrollListener()
505
+ val nextObserver = host.viewTreeObserver
506
+ if (nextObserver.isAlive) {
507
+ nextObserver.addOnScrollChangedListener(scrollChangedListener)
508
+ scrollListenerTreeObserver = nextObserver
509
+ }
510
+ }
511
+
512
+ private fun removeScrollListenerIfIdle() {
513
+ if (pendingOutsideTapCandidates.isEmpty()) {
514
+ removeScrollListener()
515
+ }
516
+ }
517
+
518
+ private fun removeScrollListener() {
519
+ val observer = scrollListenerTreeObserver
520
+ if (observer?.isAlive == true) {
521
+ observer.removeOnScrollChangedListener(scrollChangedListener)
522
+ }
523
+ scrollListenerTreeObserver = null
524
+ }
525
+
526
+ fun isAttached(): Boolean = observerView.parent === host
527
+
528
+ fun detach() {
529
+ attachmentGeneration += 1
530
+ val generation = attachmentGeneration
531
+ cancelPendingOutsideTapCandidates("detach")
532
+ val parent = observerView.parent as? ViewGroup ?: return
533
+ observerView.visibility = View.GONE
534
+ parent.post {
535
+ if (generation != attachmentGeneration) return@post
536
+ if (observerView.parent === parent) {
537
+ parent.removeView(observerView)
538
+ }
539
+ }
540
+ }
541
+
542
+ private fun attach() {
543
+ attachmentGeneration += 1
544
+ observerView.visibility = View.VISIBLE
545
+ val parent = observerView.parent as? ViewGroup
546
+ if (parent !== host) {
547
+ if (parent != null) {
548
+ parent.removeView(observerView)
549
+ }
550
+ host.addView(
551
+ observerView,
552
+ ViewGroup.LayoutParams(
553
+ ViewGroup.LayoutParams.MATCH_PARENT,
554
+ ViewGroup.LayoutParams.MATCH_PARENT
555
+ )
556
+ )
557
+ return
558
+ }
559
+ if (host.indexOfChild(observerView) != host.childCount - 1) {
560
+ val generation = attachmentGeneration
561
+ host.post {
562
+ if (generation != attachmentGeneration) return@post
563
+ if (observerView.parent === host && host.indexOfChild(observerView) != host.childCount - 1) {
564
+ observerView.bringToFront()
565
+ }
566
+ }
567
+ }
568
+ }
569
+
570
+ private fun prune() {
571
+ views.removeAll { it.get() == null }
572
+ }
573
+ }
574
+ }
575
+
576
+ /**
577
+ * Expo Modules wrapper view that hosts a [RichTextEditorView] and bridges
578
+ * editor events to React Native via [EventDispatcher].
579
+ *
580
+ * Registered as the native view component in [NativeEditorModule].
581
+ */
582
+ class NativeEditorExpoView(
583
+ context: Context,
584
+ appContext: AppContext
585
+ ) : ExpoView(context, appContext), EditorEditText.EditorListener {
586
+
587
+ private enum class ToolbarPlacement {
588
+ KEYBOARD,
589
+ INLINE;
590
+
591
+ companion object {
592
+ fun fromRaw(raw: String?): ToolbarPlacement =
593
+ if (raw == "inline") INLINE else KEYBOARD
594
+ }
595
+ }
596
+
597
+ private sealed class PendingNativeAction {
598
+ data class ToolbarItemPress(val item: NativeToolbarItem) : PendingNativeAction()
599
+ data class MentionSuggestionSelect(val suggestion: NativeMentionSuggestion) : PendingNativeAction()
600
+ }
601
+
602
+ private data class PendingNativeActionScope(
603
+ val editorId: Long,
604
+ val documentVersion: Int?,
605
+ val allowedDocumentVersion: Int?,
606
+ val hadFocus: Boolean,
607
+ val hadVisibleToolbar: Boolean,
608
+ val selectionAnchor: Int?,
609
+ val selectionHead: Int?,
610
+ val mentionAnchor: Int? = null,
611
+ val mentionHead: Int? = null,
612
+ val mentionQuery: String? = null
613
+ )
614
+
615
+ private data class PendingEditorUpdateEvent(
616
+ val editorId: Long,
617
+ val updateJSON: String
618
+ )
619
+
620
+ val richTextView: RichTextEditorView = RichTextEditorView(context)
621
+ private val keyboardToolbarView = EditorKeyboardToolbarView(context)
622
+ private val mainHandler = Handler(Looper.getMainLooper())
623
+
624
+ private val onEditorUpdate by EventDispatcher<Map<String, Any>>()
625
+ private val onSelectionChange by EventDispatcher<Map<String, Any>>()
626
+ private val onFocusChange by EventDispatcher<Map<String, Any>>()
627
+ private val onContentHeightChange by EventDispatcher<Map<String, Any>>()
628
+ private val onEditorReady by EventDispatcher<Map<String, Any>>()
629
+ @Suppress("unused")
630
+ private val onToolbarAction by EventDispatcher<Map<String, Any>>()
631
+ @Suppress("unused")
632
+ private val onAddonEvent by EventDispatcher<Map<String, Any>>()
633
+
634
+ /** Guard flag: when true, editor updates originated from JS and should not echo back. */
635
+ var isApplyingJSUpdate = false
636
+ internal var blockEditorUpdatePreflightForTesting = false
637
+ internal var blockThemePreflightForTesting = false
638
+ internal var onToolbarActionForTesting: ((Map<String, Any>) -> Unit)? = null
639
+ internal var onAddonEventForTesting: ((Map<String, Any>) -> Unit)? = null
640
+ internal var onSelectionChangeForTesting: ((Map<String, Any>) -> Unit)? = null
641
+ internal var onFocusChangeForTesting: ((Map<String, Any>) -> Unit)? = null
642
+ internal var onContentHeightChangeForTesting: ((Map<String, Any>) -> Unit)? = null
643
+ internal var onEditorUpdateForTesting: ((Map<String, Any>) -> Unit)? = null
644
+ internal var onEditorReadyForTesting: ((Map<String, Any>) -> Unit)? = null
645
+ internal var onOutsideTapTraceForTesting: ((String) -> Unit)? = null
646
+ internal var onRefreshToolbarStateFromEditorSelectionForTesting: (() -> String?)? = null
647
+ internal var onBeforePrepareForEditorCommandForTesting: (() -> Unit)? = null
648
+ private var isAttachedToNativeWindow = false
649
+ private var didApplyAutoFocus = false
650
+ private var heightBehavior = EditorHeightBehavior.FIXED
651
+ private var lastEmittedContentHeight = 0
652
+ private var lastEmittedContentHeightEditorId: Long? = null
653
+ private var outsideTapWindow: Window? = null
654
+ private var pendingOutsideTapHandlerInstallRetry: Runnable? = null
655
+ private var toolbarFramesInWindow: List<RectF> = emptyList()
656
+ private var lastToolbarTouchUptimeMs: Long? = null
657
+ private var editorFocusedForOutsideTapOverrideForTesting: Boolean? = null
658
+ private var pendingOutsideTapBlur: Runnable? = null
659
+ private var pendingKeyboardDismiss: Runnable? = null
660
+ private var pendingToolbarRefocus: Runnable? = null
661
+ private var pendingToolbarRefocusEditorId: Long? = null
662
+ private var pendingToolbarRefocusGeneration = 0
663
+ private var pendingKeyboardToolbarDetachGeneration = 0
664
+ private var autoFocusRequested = false
665
+ private var addons = NativeEditorAddons(null)
666
+ private var mentionQueryState: MentionQueryState? = null
667
+ private var lastMentionEventJson: String? = null
668
+ private var lastMentionEventEditorId: Long? = null
669
+ private var lastThemeJson: String? = null
670
+ private var pendingThemeJson: String? = null
671
+ private var hasPendingTheme = false
672
+ private var pendingThemeRetryScheduled = false
673
+ private var pendingThemeRetryEditorId: Long? = null
674
+ private var pendingThemeRetryGeneration = 0
675
+ private var pendingThemeRetryAttempts = 0
676
+ private var lastAddonsJson: String? = null
677
+ private var lastRemoteSelectionsJson: String? = null
678
+ private var lastToolbarItemsJson: String? = null
679
+ private var lastToolbarFrameJson: String? = null
680
+ private var lastDocumentVersion: Int? = null
681
+ private var toolbarState = NativeToolbarState.empty
682
+ private var showsToolbar = true
683
+ private var toolbarPlacement = ToolbarPlacement.KEYBOARD
684
+ private var currentImeBottom = 0
685
+ private var pendingEditorUpdateJson: String? = null
686
+ private var pendingEditorUpdateEditorId: Long? = null
687
+ private var pendingEditorUpdateRevision = 0
688
+ private var appliedEditorUpdateRevision = 0
689
+ private var pendingEditorResetUpdateJson: String? = null
690
+ private var pendingEditorResetUpdateEditorId: Long? = null
691
+ private var pendingEditorResetUpdateRevision = 0
692
+ private var appliedEditorResetUpdateRevision = 0
693
+ private var lastEditorResetUpdateJsonProp: String? = null
694
+ private var lastEditorResetUpdateEditorIdProp: Long? = null
695
+ private var pendingEditorUpdateRetryScheduled = false
696
+ private var pendingEditorUpdateRetryEditorId: Long? = null
697
+ private var pendingEditorUpdateRetryGeneration = 0
698
+ private var pendingEditorUpdateRetryAttempts = 0
699
+ private var pendingEditorUpdateForcedRecoveryAttempted = false
700
+ private var pendingViewCommandUpdateJson: String? = null
701
+ private var pendingViewCommandUpdateEditorId: Long? = null
702
+ private var pendingViewCommandUpdateRetryScheduled = false
703
+ private var pendingViewCommandUpdateRetryGeneration = 0
704
+ private var pendingViewCommandUpdateRetryAttempts = 0
705
+ private var pendingPreflightWakeScheduled = false
706
+ private var pendingPreflightWakeGeneration = 0
707
+ private var pendingBlurRetry: Runnable? = null
708
+ private var pendingBlurRetryEditorId: Long? = null
709
+ private var pendingBlurRetryGeneration = 0
710
+ private var pendingBlurRetryAttempts = 0
711
+ private var pendingDetachPreflightRetryScheduled = false
712
+ private var pendingDetachPreflightRetryEditorId: Long? = null
713
+ private var pendingDetachPreflightRetryGeneration = 0
714
+ private var pendingDetachPreflightRetryAttempts = 0
715
+ private var pendingNativeAction: PendingNativeAction? = null
716
+ private var pendingNativeActionScope: PendingNativeActionScope? = null
717
+ private var pendingNativeActionRetryScheduled = false
718
+ private var pendingNativeActionRetryEditorId: Long? = null
719
+ private var pendingNativeActionRetryGeneration = 0
720
+ private var pendingNativeActionRetryAttempts = 0
721
+ private var lastReadyEditorId: Long? = null
722
+ private val pendingEditorUpdateEvents = java.util.ArrayDeque<PendingEditorUpdateEvent>()
723
+ private var pendingEditorUpdateDispatchGeneration = 0
724
+ private var pendingEditorUpdateDispatchScheduled = false
725
+
726
+ init {
727
+ addView(richTextView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
728
+ richTextView.editorEditText.editorListener = this
729
+ richTextView.onBeforeDetachedFromWindow = {
730
+ prepareForDetachFromWindow()
731
+ }
732
+ keyboardToolbarView.onPressItem = { item ->
733
+ handleToolbarItemPress(item)
734
+ }
735
+ keyboardToolbarView.onSelectMentionSuggestion = { suggestion ->
736
+ insertMentionSuggestion(suggestion)
737
+ }
738
+ keyboardToolbarView.applyState(toolbarState)
739
+ ViewCompat.setOnApplyWindowInsetsListener(keyboardToolbarView) { _, insets ->
740
+ currentImeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
741
+ updateKeyboardToolbarLayout()
742
+ updateAttachedKeyboardToolbarForInsets()
743
+ insets
744
+ }
745
+
746
+ // Observe EditText focus changes.
747
+ richTextView.editorEditText.setOnFocusChangeListener { _, hasFocus ->
748
+ if (hasFocus) {
749
+ cancelPendingToolbarRefocus()
750
+ installOutsideTapBlurHandlerIfNeeded()
751
+ scheduleOutsideTapBlurHandlerInstallRetry()
752
+ refreshMentionQuery()
753
+ } else {
754
+ if (consumeToolbarFocusPreservationForBlur()) {
755
+ scheduleToolbarRefocus()
756
+ return@setOnFocusChangeListener
757
+ }
758
+ uninstallOutsideTapBlurHandler()
759
+ clearMentionQueryState()
760
+ clearPendingNativeActionRetry()
761
+ }
762
+ updateKeyboardToolbarVisibility()
763
+ val event = mapOf<String, Any>(
764
+ "isFocused" to hasFocus,
765
+ "editorId" to richTextView.editorId
766
+ )
767
+ onFocusChangeForTesting?.invoke(event) ?: onFocusChange(event)
768
+ }
769
+ }
770
+
771
+ fun setEditorId(id: Long) {
772
+ if (id != 0L && NativeEditorViewRegistry.isDestroyed(id)) {
773
+ setEditorId(0L)
774
+ return
775
+ }
776
+ val previousEditorId = richTextView.editorId
777
+ if (previousEditorId != id) {
778
+ invalidateAutoGrowContentHeightEmission()
779
+ }
780
+ if (previousEditorId == id && richTextView.editorEditText.editorId == id) {
781
+ if (id != 0L && isAttachedToNativeWindow) {
782
+ if (!NativeEditorViewRegistry.register(id, this)) {
783
+ handleEditorDestroyed(id)
784
+ return
785
+ }
786
+ applyPendingEditorResetUpdateIfNeeded()
787
+ applyPendingEditorUpdateIfNeeded()
788
+ applyPendingThemeIfNeeded()
789
+ refreshReadyStateIfSettled()
790
+ applyAutoFocusIfNeeded()
791
+ } else if (id != 0L) {
792
+ NativeEditorViewRegistry.unregister(
793
+ id,
794
+ this,
795
+ blockCommandsUntilRegistered = true
796
+ )
797
+ }
798
+ return
799
+ }
800
+ if (previousEditorId != id) {
801
+ NativeEditorViewRegistry.unregister(previousEditorId, this)
802
+ lastDocumentVersion = null
803
+ cancelPendingToolbarRefocus()
804
+ cancelPendingEditorUpdateRetry()
805
+ if (pendingEditorUpdateEditorId != null && pendingEditorUpdateEditorId != id) {
806
+ clearPendingEditorUpdateState()
807
+ }
808
+ if (pendingEditorResetUpdateEditorId != null && pendingEditorResetUpdateEditorId != id) {
809
+ clearPendingEditorResetUpdateState()
810
+ }
811
+ appliedEditorUpdateRevision = 0
812
+ appliedEditorResetUpdateRevision = 0
813
+ clearPendingViewCommandUpdateRetry()
814
+ cancelPendingThemeRetry()
815
+ if (hasPendingTheme) {
816
+ pendingThemeRetryEditorId = id
817
+ }
818
+ cancelPendingBlurRetry()
819
+ clearPendingNativeActionRetry()
820
+ clearMentionQueryState(resetLastEvent = true)
821
+ lastReadyEditorId = null
822
+ }
823
+ if (!isAttachedToNativeWindow) {
824
+ richTextView.setEditorIdWhileDetached(id)
825
+ if (id != 0L) {
826
+ NativeEditorViewRegistry.unregister(
827
+ id,
828
+ this,
829
+ blockCommandsUntilRegistered = true
830
+ )
831
+ } else {
832
+ toolbarState = NativeToolbarState.empty
833
+ keyboardToolbarView.applyState(toolbarState)
834
+ }
835
+ return
836
+ }
837
+
838
+ if (hasPendingEditorResetUpdateForEditor(id) || hasPendingEditorUpdateForEditor(id)) {
839
+ richTextView.setEditorIdWhileDetached(id)
840
+ richTextView.rebindEditorIfNeeded(notifyListener = false)
841
+ } else {
842
+ richTextView.editorId = id
843
+ }
844
+ if (id != 0L) {
845
+ if (!NativeEditorViewRegistry.register(id, this)) {
846
+ handleEditorDestroyed(id)
847
+ return
848
+ }
849
+ } else {
850
+ toolbarState = NativeToolbarState.empty
851
+ keyboardToolbarView.applyState(toolbarState)
852
+ }
853
+ applyPendingEditorResetUpdateIfNeeded()
854
+ applyPendingEditorUpdateIfNeeded()
855
+ applyPendingThemeIfNeeded()
856
+ refreshReadyStateIfSettled()
857
+ applyAutoFocusIfNeeded()
858
+ }
859
+
860
+ fun setThemeJson(themeJson: String?) {
861
+ if (lastThemeJson == themeJson && !hasPendingTheme) return
862
+ pendingThemeJson = themeJson
863
+ hasPendingTheme = true
864
+ pendingThemeRetryEditorId = richTextView.editorId
865
+ pendingThemeRetryAttempts = 0
866
+ applyPendingThemeIfNeeded()
867
+ }
868
+
869
+ private fun applyThemeJson(themeJson: String?) {
870
+ if (lastThemeJson == themeJson) return
871
+ lastThemeJson = themeJson
872
+ val theme = EditorTheme.fromJson(themeJson)
873
+ richTextView.applyTheme(theme)
874
+ keyboardToolbarView.applyTheme(theme?.toolbar)
875
+ keyboardToolbarView.applyMentionTheme(theme?.mentions ?: addons.mentions?.theme)
876
+ keyboardToolbarView.requestLayout()
877
+ updateKeyboardToolbarLayout()
878
+ updateEditorViewportInset(forceMeasureToolbar = true)
879
+ post {
880
+ updateKeyboardToolbarLayout()
881
+ updateEditorViewportInset(forceMeasureToolbar = true)
882
+ }
883
+ }
884
+
885
+ fun setHeightBehavior(rawHeightBehavior: String) {
886
+ val nextBehavior = EditorHeightBehavior.fromRaw(rawHeightBehavior)
887
+ if (heightBehavior == nextBehavior) return
888
+ heightBehavior = nextBehavior
889
+ if (nextBehavior != EditorHeightBehavior.AUTO_GROW) {
890
+ lastEmittedContentHeight = 0
891
+ lastEmittedContentHeightEditorId = null
892
+ }
893
+ richTextView.setHeightBehavior(nextBehavior)
894
+ val params = richTextView.layoutParams as LayoutParams
895
+ params.width = LayoutParams.MATCH_PARENT
896
+ params.height = if (nextBehavior == EditorHeightBehavior.AUTO_GROW) {
897
+ LayoutParams.WRAP_CONTENT
898
+ } else {
899
+ LayoutParams.MATCH_PARENT
900
+ }
901
+ richTextView.layoutParams = params
902
+ requestLayout()
903
+ if (nextBehavior == EditorHeightBehavior.AUTO_GROW) {
904
+ post { emitContentHeightIfNeeded(force = true) }
905
+ }
906
+ updateEditorViewportInset()
907
+ }
908
+
909
+ private fun invalidateAutoGrowContentHeightEmission() {
910
+ if (heightBehavior != EditorHeightBehavior.AUTO_GROW) return
911
+ lastEmittedContentHeight = 0
912
+ lastEmittedContentHeightEditorId = null
913
+ requestLayout()
914
+ }
915
+
916
+ fun setAddonsJson(addonsJson: String?) {
917
+ if (lastAddonsJson == addonsJson) return
918
+ clearPendingNativeActionRetry()
919
+ lastAddonsJson = addonsJson
920
+ addons = NativeEditorAddons.fromJson(addonsJson)
921
+ keyboardToolbarView.applyMentionTheme(richTextView.editorEditText.theme?.mentions ?: addons.mentions?.theme)
922
+ refreshMentionQuery()
923
+ }
924
+
925
+ fun setRemoteSelectionsJson(remoteSelectionsJson: String?) {
926
+ if (lastRemoteSelectionsJson == remoteSelectionsJson) return
927
+ lastRemoteSelectionsJson = remoteSelectionsJson
928
+ richTextView.setRemoteSelections(
929
+ RemoteSelectionDecoration.fromJson(context, remoteSelectionsJson)
930
+ )
931
+ }
932
+
933
+ fun setAutoFocus(autoFocus: Boolean) {
934
+ autoFocusRequested = autoFocus
935
+ applyAutoFocusIfNeeded()
936
+ }
937
+
938
+ private fun applyAutoFocusIfNeeded() {
939
+ if (!autoFocusRequested || didApplyAutoFocus || !canFocusCurrentEditor()) return
940
+ didApplyAutoFocus = true
941
+ focus()
942
+ }
943
+
944
+ fun setAutoCapitalize(autoCapitalize: String?) {
945
+ richTextView.editorEditText.setAutoCapitalize(autoCapitalize)
946
+ }
947
+
948
+ fun setAutoCorrect(autoCorrect: Boolean?) {
949
+ richTextView.editorEditText.setAutoCorrect(autoCorrect)
950
+ }
951
+
952
+ fun setKeyboardType(keyboardType: String?) {
953
+ richTextView.editorEditText.setKeyboardType(keyboardType)
954
+ }
955
+
956
+ fun setEditable(editable: Boolean) {
957
+ if (richTextView.editorEditText.isEditable == editable) return
958
+ if (!editable) {
959
+ cancelPendingToolbarRefocus()
960
+ clearPendingNativeActionRetry()
961
+ }
962
+ richTextView.editorEditText.isEditable = editable
963
+ updateKeyboardToolbarVisibility()
964
+ }
965
+
966
+ fun setShowToolbar(showToolbar: Boolean) {
967
+ if (showsToolbar == showToolbar) return
968
+ if (!showToolbar) {
969
+ cancelPendingToolbarRefocus()
970
+ clearPendingNativeActionRetry()
971
+ }
972
+ showsToolbar = showToolbar
973
+ updateKeyboardToolbarVisibility()
974
+ }
975
+
976
+ fun setToolbarPlacement(rawToolbarPlacement: String?) {
977
+ val nextPlacement = ToolbarPlacement.fromRaw(rawToolbarPlacement)
978
+ if (toolbarPlacement == nextPlacement) return
979
+ if (nextPlacement != ToolbarPlacement.KEYBOARD) {
980
+ cancelPendingToolbarRefocus()
981
+ clearPendingNativeActionRetry()
982
+ }
983
+ toolbarPlacement = nextPlacement
984
+ updateKeyboardToolbarVisibility()
985
+ }
986
+
987
+ fun setAllowImageResizing(allowImageResizing: Boolean) {
988
+ richTextView.setImageResizingEnabled(allowImageResizing)
989
+ }
990
+
991
+ fun setToolbarItemsJson(toolbarItemsJson: String?) {
992
+ if (lastToolbarItemsJson == toolbarItemsJson) return
993
+ clearPendingNativeActionRetry()
994
+ lastToolbarItemsJson = toolbarItemsJson
995
+ keyboardToolbarView.setItems(NativeToolbarItem.fromJson(toolbarItemsJson))
996
+ }
997
+
998
+ fun setToolbarFrameJson(toolbarFrameJson: String?) {
999
+ if (lastToolbarFrameJson == toolbarFrameJson) return
1000
+ lastToolbarFrameJson = toolbarFrameJson
1001
+ if (toolbarFrameJson.isNullOrBlank()) {
1002
+ toolbarFramesInWindow = emptyList()
1003
+ return
1004
+ }
1005
+
1006
+ toolbarFramesInWindow = try {
1007
+ val json = JSONObject(toolbarFrameJson)
1008
+ val frames = json.optJSONArray("frames")
1009
+ if (frames != null) {
1010
+ buildList {
1011
+ for (index in 0 until frames.length()) {
1012
+ frames.optJSONObject(index)?.toToolbarFrame()?.let { add(it) }
1013
+ }
1014
+ }
1015
+ } else {
1016
+ listOfNotNull(json.toToolbarFrame())
1017
+ }
1018
+ } catch (_: Throwable) {
1019
+ emptyList()
1020
+ }
1021
+ }
1022
+
1023
+ private fun JSONObject.toToolbarFrame(): RectF? {
1024
+ val x = optDouble("x", Double.NaN)
1025
+ val y = optDouble("y", Double.NaN)
1026
+ val width = optDouble("width", Double.NaN)
1027
+ val height = optDouble("height", Double.NaN)
1028
+ if (
1029
+ x.isNaN() || x.isInfinite() ||
1030
+ y.isNaN() || y.isInfinite() ||
1031
+ width.isNaN() || width.isInfinite() ||
1032
+ height.isNaN() || height.isInfinite()
1033
+ ) {
1034
+ return null
1035
+ }
1036
+ if (width <= 0.0 || height <= 0.0) {
1037
+ return null
1038
+ }
1039
+
1040
+ return RectF(
1041
+ x.toFloat(),
1042
+ y.toFloat(),
1043
+ (x + width).toFloat(),
1044
+ (y + height).toFloat()
1045
+ )
1046
+ }
1047
+
1048
+ fun setPendingEditorUpdateJson(editorUpdateJson: String?) {
1049
+ pendingEditorUpdateJson = editorUpdateJson
1050
+ }
1051
+
1052
+ fun setPendingEditorUpdateEditorId(editorUpdateEditorId: Long?) {
1053
+ pendingEditorUpdateEditorId = editorUpdateEditorId
1054
+ }
1055
+
1056
+ fun setPendingEditorUpdateRevision(editorUpdateRevision: Int) {
1057
+ if (pendingEditorUpdateRevision != editorUpdateRevision) {
1058
+ pendingEditorUpdateRetryAttempts = 0
1059
+ pendingEditorUpdateForcedRecoveryAttempted = false
1060
+ }
1061
+ pendingEditorUpdateRevision = editorUpdateRevision
1062
+ }
1063
+
1064
+ fun setPendingEditorResetUpdateJson(editorResetUpdateJson: String?) {
1065
+ lastEditorResetUpdateJsonProp = editorResetUpdateJson
1066
+ pendingEditorResetUpdateJson = editorResetUpdateJson
1067
+ }
1068
+
1069
+ fun setPendingEditorResetUpdateEditorId(editorResetUpdateEditorId: Long?) {
1070
+ lastEditorResetUpdateEditorIdProp = editorResetUpdateEditorId
1071
+ pendingEditorResetUpdateEditorId = editorResetUpdateEditorId
1072
+ }
1073
+
1074
+ fun setPendingEditorResetUpdateRevision(editorResetUpdateRevision: Int) {
1075
+ if (pendingEditorResetUpdateRevision != editorResetUpdateRevision) {
1076
+ pendingEditorUpdateRetryAttempts = 0
1077
+ pendingEditorUpdateForcedRecoveryAttempted = false
1078
+ }
1079
+ if (editorResetUpdateRevision != 0 && pendingEditorResetUpdateJson == null) {
1080
+ pendingEditorResetUpdateJson = lastEditorResetUpdateJsonProp
1081
+ }
1082
+ if (editorResetUpdateRevision != 0 && pendingEditorResetUpdateEditorId == null) {
1083
+ pendingEditorResetUpdateEditorId = lastEditorResetUpdateEditorIdProp
1084
+ }
1085
+ pendingEditorResetUpdateRevision = editorResetUpdateRevision
1086
+ }
1087
+
1088
+ private fun hasPendingEditorUpdateForEditor(editorId: Long): Boolean =
1089
+ pendingEditorUpdateJson != null &&
1090
+ pendingEditorUpdateRevision != 0 &&
1091
+ pendingEditorUpdateRevision != appliedEditorUpdateRevision &&
1092
+ pendingEditorUpdateEditorId == editorId
1093
+
1094
+ private fun hasPendingEditorResetUpdateForEditor(editorId: Long): Boolean =
1095
+ pendingEditorResetUpdateJson != null &&
1096
+ pendingEditorResetUpdateRevision != 0 &&
1097
+ pendingEditorResetUpdateRevision != appliedEditorResetUpdateRevision &&
1098
+ pendingEditorResetUpdateEditorId == editorId
1099
+
1100
+ private fun hasPendingEditorUpdateForCurrentEditor(): Boolean =
1101
+ hasPendingEditorUpdateForEditor(richTextView.editorId)
1102
+
1103
+ private fun hasPendingEditorResetUpdateForCurrentEditor(): Boolean =
1104
+ hasPendingEditorResetUpdateForEditor(richTextView.editorId)
1105
+
1106
+ private fun pendingEditorUpdateCommandPreparationJSON(): String =
1107
+ NativeEditorViewRegistry.commandPreparationJSON(
1108
+ ready = false,
1109
+ blockedReason = "pendingUpdate"
1110
+ )
1111
+
1112
+ private fun shouldBlockEditorCommandForPendingUpdate(): Boolean =
1113
+ hasPendingEditorResetUpdateForCurrentEditor() || hasPendingEditorUpdateForCurrentEditor()
1114
+
1115
+ private fun refreshReadyStateIfSettled() {
1116
+ if (handleDestroyedCurrentEditorIfNeeded()) return
1117
+ if (hasPendingEditorResetUpdateForCurrentEditor()) return
1118
+ if (hasPendingEditorUpdateForCurrentEditor()) return
1119
+ if (!isAttachedToNativeWindow) return
1120
+ if (richTextView.editorEditText.editorId != richTextView.editorId) return
1121
+ refreshToolbarStateFromEditorSelection()
1122
+ refreshMentionQuery()
1123
+ emitEditorReadyIfNeeded()
1124
+ }
1125
+
1126
+ fun applyPendingEditorResetUpdateIfNeeded() {
1127
+ if (handleDestroyedCurrentEditorIfNeeded()) return
1128
+ if (pendingEditorResetUpdateRevision == 0) return
1129
+ val revision = pendingEditorResetUpdateRevision
1130
+ val editorId = richTextView.editorId
1131
+ val expectedEditorId = pendingEditorResetUpdateEditorId
1132
+ if (expectedEditorId == null) return
1133
+ if (expectedEditorId != editorId) return
1134
+ if (pendingEditorResetUpdateJson == null) {
1135
+ clearPendingEditorResetUpdateState(resetAppliedRevision = false)
1136
+ refreshReadyStateIfSettled()
1137
+ return
1138
+ }
1139
+ val updateJson = pendingEditorResetUpdateJson ?: return
1140
+ if (revision == appliedEditorResetUpdateRevision) {
1141
+ clearPendingEditorResetUpdateState(resetAppliedRevision = false)
1142
+ emitEditorReady(editorUpdateRevision = revision)
1143
+ refreshReadyStateIfSettled()
1144
+ return
1145
+ }
1146
+ if (editorId != 0L && !isAttachedToNativeWindow) return
1147
+ val apply = Runnable {
1148
+ if (editorId != richTextView.editorId) return@Runnable
1149
+ if (expectedEditorId != richTextView.editorId) return@Runnable
1150
+ if (editorId != 0L && !isAttachedToNativeWindow) return@Runnable
1151
+ if (revision != pendingEditorResetUpdateRevision) return@Runnable
1152
+ if (revision == appliedEditorResetUpdateRevision) {
1153
+ clearPendingEditorResetUpdateState(resetAppliedRevision = false)
1154
+ emitEditorReady(editorUpdateRevision = revision)
1155
+ refreshReadyStateIfSettled()
1156
+ return@Runnable
1157
+ }
1158
+ if (applyEditorResetUpdate(updateJson)) {
1159
+ appliedEditorResetUpdateRevision = revision
1160
+ clearPendingEditorResetUpdateState(resetAppliedRevision = false)
1161
+ emitEditorReady(editorUpdateRevision = revision)
1162
+ refreshReadyStateIfSettled()
1163
+ } else {
1164
+ schedulePendingEditorUpdateRetry()
1165
+ }
1166
+ }
1167
+ if (Looper.myLooper() == Looper.getMainLooper()) {
1168
+ apply.run()
1169
+ } else if (!post(apply)) {
1170
+ richTextView.post(apply)
1171
+ }
1172
+ }
1173
+
1174
+ fun applyPendingEditorUpdateIfNeeded() {
1175
+ if (handleDestroyedCurrentEditorIfNeeded()) return
1176
+ if (pendingEditorUpdateRevision == 0) return
1177
+ val revision = pendingEditorUpdateRevision
1178
+ val editorId = richTextView.editorId
1179
+ val expectedEditorId = pendingEditorUpdateEditorId
1180
+ if (expectedEditorId == null) return
1181
+ if (expectedEditorId != editorId) return
1182
+ if (pendingEditorUpdateJson == null) {
1183
+ clearPendingEditorUpdateState(resetAppliedRevision = false)
1184
+ refreshReadyStateIfSettled()
1185
+ return
1186
+ }
1187
+ val updateJson = pendingEditorUpdateJson ?: return
1188
+ if (pendingEditorUpdateRevision == appliedEditorUpdateRevision) {
1189
+ clearPendingEditorUpdateState(resetAppliedRevision = false)
1190
+ emitEditorReady(editorUpdateRevision = revision)
1191
+ refreshReadyStateIfSettled()
1192
+ return
1193
+ }
1194
+ if (editorId != 0L && !isAttachedToNativeWindow) return
1195
+ val apply = Runnable {
1196
+ if (editorId != richTextView.editorId) return@Runnable
1197
+ if (expectedEditorId != richTextView.editorId) return@Runnable
1198
+ if (editorId != 0L && !isAttachedToNativeWindow) return@Runnable
1199
+ if (revision != pendingEditorUpdateRevision) return@Runnable
1200
+ if (revision == appliedEditorUpdateRevision) {
1201
+ clearPendingEditorUpdateState(resetAppliedRevision = false)
1202
+ emitEditorReady(editorUpdateRevision = revision)
1203
+ refreshReadyStateIfSettled()
1204
+ return@Runnable
1205
+ }
1206
+ if (applyEditorUpdate(updateJson, scheduleViewCommandRetry = false)) {
1207
+ appliedEditorUpdateRevision = revision
1208
+ pendingEditorUpdateJson = null
1209
+ pendingEditorUpdateEditorId = null
1210
+ pendingEditorUpdateRevision = 0
1211
+ pendingEditorUpdateRetryAttempts = 0
1212
+ pendingEditorUpdateForcedRecoveryAttempted = false
1213
+ cancelPendingEditorUpdateRetry()
1214
+ emitEditorReady(editorUpdateRevision = revision)
1215
+ refreshReadyStateIfSettled()
1216
+ } else {
1217
+ schedulePendingEditorUpdateRetry()
1218
+ }
1219
+ }
1220
+ if (Looper.myLooper() == Looper.getMainLooper()) {
1221
+ apply.run()
1222
+ } else if (!post(apply)) {
1223
+ richTextView.post(apply)
1224
+ }
1225
+ }
1226
+
1227
+ private fun clearPendingEditorUpdateState(resetAppliedRevision: Boolean = true) {
1228
+ pendingEditorUpdateJson = null
1229
+ pendingEditorUpdateEditorId = null
1230
+ pendingEditorUpdateRevision = 0
1231
+ if (resetAppliedRevision) {
1232
+ appliedEditorUpdateRevision = 0
1233
+ }
1234
+ cancelPendingEditorUpdateRetry()
1235
+ }
1236
+
1237
+ private fun clearPendingEditorResetUpdateState(resetAppliedRevision: Boolean = true) {
1238
+ pendingEditorResetUpdateJson = null
1239
+ pendingEditorResetUpdateEditorId = null
1240
+ pendingEditorResetUpdateRevision = 0
1241
+ if (resetAppliedRevision) {
1242
+ appliedEditorResetUpdateRevision = 0
1243
+ }
1244
+ }
1245
+
1246
+ private fun cancelPendingEditorUpdateRetry() {
1247
+ pendingEditorUpdateRetryScheduled = false
1248
+ pendingEditorUpdateRetryEditorId = null
1249
+ pendingEditorUpdateRetryAttempts = 0
1250
+ pendingEditorUpdateForcedRecoveryAttempted = false
1251
+ pendingEditorUpdateRetryGeneration += 1
1252
+ }
1253
+
1254
+ private fun schedulePendingEditorUpdateRetry() {
1255
+ if (pendingEditorUpdateRetryScheduled) return
1256
+ val pastFastRetryBudget =
1257
+ pendingEditorUpdateRetryAttempts >= MAX_PENDING_UPDATE_RETRY_ATTEMPTS
1258
+ if (
1259
+ pastFastRetryBudget &&
1260
+ !pendingEditorUpdateForcedRecoveryAttempted &&
1261
+ richTextView.editorId != 0L &&
1262
+ richTextView.editorEditText.editorId == richTextView.editorId
1263
+ ) {
1264
+ pendingEditorUpdateForcedRecoveryAttempted = true
1265
+ richTextView.editorEditText.discardTransientNativeInputForExternalRecovery()
1266
+ }
1267
+ if (!pastFastRetryBudget) {
1268
+ pendingEditorUpdateRetryAttempts += 1
1269
+ }
1270
+ pendingEditorUpdateRetryEditorId = richTextView.editorId
1271
+ pendingEditorUpdateRetryScheduled = true
1272
+ pendingEditorUpdateRetryGeneration += 1
1273
+ val retryGeneration = pendingEditorUpdateRetryGeneration
1274
+ val delayMs = if (pastFastRetryBudget) {
1275
+ PENDING_UPDATE_RECOVERY_RETRY_DELAY_MS
1276
+ } else {
1277
+ NATIVE_ACTION_RETRY_DELAY_MS * pendingEditorUpdateRetryAttempts
1278
+ }
1279
+ val retry = Runnable {
1280
+ if (retryGeneration != pendingEditorUpdateRetryGeneration) return@Runnable
1281
+ if (pendingEditorUpdateRetryEditorId != richTextView.editorId) {
1282
+ clearPendingEditorUpdateState()
1283
+ return@Runnable
1284
+ }
1285
+ pendingEditorUpdateRetryScheduled = false
1286
+ pendingEditorUpdateRetryEditorId = null
1287
+ applyPendingEditorResetUpdateIfNeeded()
1288
+ applyPendingEditorUpdateIfNeeded()
1289
+ }
1290
+ mainHandler.postDelayed(retry, delayMs)
1291
+ }
1292
+
1293
+ private fun clearPendingThemeRetry() {
1294
+ pendingThemeJson = null
1295
+ hasPendingTheme = false
1296
+ cancelPendingThemeRetry()
1297
+ }
1298
+
1299
+ private fun cancelPendingThemeRetry() {
1300
+ pendingThemeRetryScheduled = false
1301
+ pendingThemeRetryEditorId = null
1302
+ pendingThemeRetryAttempts = 0
1303
+ pendingThemeRetryGeneration += 1
1304
+ }
1305
+
1306
+ private fun applyPendingThemeIfNeeded() {
1307
+ if (handleDestroyedCurrentEditorIfNeeded()) return
1308
+ if (!hasPendingTheme) return
1309
+ val themeJson = pendingThemeJson
1310
+ val editorId = richTextView.editorId
1311
+ if (pendingThemeRetryEditorId != editorId) {
1312
+ pendingThemeRetryEditorId = editorId
1313
+ }
1314
+ if (
1315
+ blockThemePreflightForTesting ||
1316
+ !richTextView.editorEditText.prepareForExternalEditorUpdate()
1317
+ ) {
1318
+ schedulePendingThemeRetry()
1319
+ return
1320
+ }
1321
+ pendingThemeJson = null
1322
+ hasPendingTheme = false
1323
+ cancelPendingThemeRetry()
1324
+ applyThemeJson(themeJson)
1325
+ }
1326
+
1327
+ private fun schedulePendingThemeRetry() {
1328
+ if (pendingThemeRetryScheduled) return
1329
+ if (pendingThemeRetryAttempts >= MAX_PENDING_UPDATE_RETRY_ATTEMPTS) return
1330
+ pendingThemeRetryAttempts += 1
1331
+ pendingThemeRetryEditorId = richTextView.editorId
1332
+ pendingThemeRetryScheduled = true
1333
+ pendingThemeRetryGeneration += 1
1334
+ val retryGeneration = pendingThemeRetryGeneration
1335
+ val delayMs = NATIVE_ACTION_RETRY_DELAY_MS * pendingThemeRetryAttempts
1336
+ val retry = Runnable {
1337
+ if (retryGeneration != pendingThemeRetryGeneration) return@Runnable
1338
+ if (pendingThemeRetryEditorId != richTextView.editorId) {
1339
+ clearPendingThemeRetry()
1340
+ return@Runnable
1341
+ }
1342
+ pendingThemeRetryScheduled = false
1343
+ applyPendingThemeIfNeeded()
1344
+ }
1345
+ mainHandler.postDelayed(retry, delayMs)
1346
+ }
1347
+
1348
+ private fun clearPendingViewCommandUpdateRetry() {
1349
+ pendingViewCommandUpdateJson = null
1350
+ pendingViewCommandUpdateEditorId = null
1351
+ pendingViewCommandUpdateRetryScheduled = false
1352
+ pendingViewCommandUpdateRetryAttempts = 0
1353
+ pendingViewCommandUpdateRetryGeneration += 1
1354
+ }
1355
+
1356
+ private fun scheduleViewCommandUpdateRetry(updateJson: String) {
1357
+ if (pendingViewCommandUpdateJson != updateJson) {
1358
+ pendingViewCommandUpdateRetryAttempts = 0
1359
+ }
1360
+ pendingViewCommandUpdateJson = updateJson
1361
+ pendingViewCommandUpdateEditorId = richTextView.editorId
1362
+ if (pendingViewCommandUpdateRetryScheduled) return
1363
+ if (pendingViewCommandUpdateRetryAttempts >= MAX_PENDING_UPDATE_RETRY_ATTEMPTS) return
1364
+ pendingViewCommandUpdateRetryAttempts += 1
1365
+ pendingViewCommandUpdateRetryScheduled = true
1366
+ pendingViewCommandUpdateRetryGeneration += 1
1367
+ val retryGeneration = pendingViewCommandUpdateRetryGeneration
1368
+ val delayMs = NATIVE_ACTION_RETRY_DELAY_MS * pendingViewCommandUpdateRetryAttempts
1369
+ val retry = Runnable {
1370
+ if (retryGeneration != pendingViewCommandUpdateRetryGeneration) return@Runnable
1371
+ val retryJson = pendingViewCommandUpdateJson ?: run {
1372
+ pendingViewCommandUpdateRetryScheduled = false
1373
+ return@Runnable
1374
+ }
1375
+ if (pendingViewCommandUpdateEditorId != richTextView.editorId || richTextView.editorId == 0L) {
1376
+ clearPendingViewCommandUpdateRetry()
1377
+ return@Runnable
1378
+ }
1379
+ if (handleDestroyedCurrentEditorIfNeeded()) {
1380
+ clearPendingViewCommandUpdateRetry()
1381
+ return@Runnable
1382
+ }
1383
+ pendingViewCommandUpdateRetryScheduled = false
1384
+ if (applyEditorUpdate(retryJson, scheduleViewCommandRetry = true)) {
1385
+ clearPendingViewCommandUpdateRetry()
1386
+ }
1387
+ }
1388
+ mainHandler.postDelayed(retry, delayMs)
1389
+ }
1390
+
1391
+ private fun schedulePendingPreflightWake() {
1392
+ if (pendingPreflightWakeScheduled) return
1393
+ pendingPreflightWakeScheduled = true
1394
+ pendingPreflightWakeGeneration += 1
1395
+ val wakeGeneration = pendingPreflightWakeGeneration
1396
+ mainHandler.post {
1397
+ if (wakeGeneration != pendingPreflightWakeGeneration) return@post
1398
+ pendingPreflightWakeScheduled = false
1399
+ wakePendingPreflightWork()
1400
+ }
1401
+ }
1402
+
1403
+ private fun cancelPendingPreflightWake() {
1404
+ pendingPreflightWakeScheduled = false
1405
+ pendingPreflightWakeGeneration += 1
1406
+ }
1407
+
1408
+ private fun wakePendingPreflightWork() {
1409
+ if (Looper.myLooper() != Looper.getMainLooper()) {
1410
+ schedulePendingPreflightWake()
1411
+ return
1412
+ }
1413
+ if (handleDestroyedCurrentEditorIfNeeded()) return
1414
+ if (pendingEditorResetUpdateJson != null) {
1415
+ applyPendingEditorResetUpdateIfNeeded()
1416
+ }
1417
+ if (pendingEditorUpdateJson != null) {
1418
+ pendingEditorUpdateRetryAttempts = 0
1419
+ pendingEditorUpdateForcedRecoveryAttempted = false
1420
+ applyPendingEditorUpdateIfNeeded()
1421
+ }
1422
+ if (hasPendingTheme) {
1423
+ pendingThemeRetryAttempts = 0
1424
+ applyPendingThemeIfNeeded()
1425
+ }
1426
+ pendingViewCommandUpdateJson?.let { updateJson ->
1427
+ pendingViewCommandUpdateRetryAttempts = 0
1428
+ pendingViewCommandUpdateRetryScheduled = false
1429
+ pendingViewCommandUpdateRetryGeneration += 1
1430
+ if (applyEditorUpdate(updateJson, scheduleViewCommandRetry = true)) {
1431
+ clearPendingViewCommandUpdateRetry()
1432
+ }
1433
+ }
1434
+ retryPendingNativeActionFromWake()
1435
+ }
1436
+
1437
+ private fun clearPendingNativeActionRetry() {
1438
+ pendingNativeAction = null
1439
+ pendingNativeActionScope = null
1440
+ pendingNativeActionRetryEditorId = null
1441
+ pendingNativeActionRetryScheduled = false
1442
+ pendingNativeActionRetryAttempts = 0
1443
+ pendingNativeActionRetryGeneration += 1
1444
+ }
1445
+
1446
+ private fun currentNativeActionScope(action: PendingNativeAction): PendingNativeActionScope {
1447
+ val selection = richTextView.editorEditText.currentScalarSelection()
1448
+ val mentionScope = when (action) {
1449
+ is PendingNativeAction.MentionSuggestionSelect ->
1450
+ mentionQueryState ?: addons.mentions?.let { currentMentionQueryState(it.trigger) }
1451
+ is PendingNativeAction.ToolbarItemPress -> null
1452
+ }
1453
+ return PendingNativeActionScope(
1454
+ editorId = richTextView.editorId,
1455
+ documentVersion = lastDocumentVersion,
1456
+ allowedDocumentVersion = documentVersionFromUpdateJSON(pendingEditorUpdateJson),
1457
+ hadFocus = isEditorEffectivelyFocusedForNativeAction(),
1458
+ hadVisibleToolbar = isNativeActionToolbarVisible(action),
1459
+ selectionAnchor = selection?.first,
1460
+ selectionHead = selection?.second,
1461
+ mentionAnchor = mentionScope?.anchor,
1462
+ mentionHead = mentionScope?.head,
1463
+ mentionQuery = mentionScope?.query
1464
+ )
1465
+ }
1466
+
1467
+ private fun isPendingNativeActionScopeCurrent(
1468
+ action: PendingNativeAction,
1469
+ scope: PendingNativeActionScope
1470
+ ): Boolean {
1471
+ if (scope.editorId != richTextView.editorId) return false
1472
+ if (scope.hadFocus != isEditorEffectivelyFocusedForNativeAction()) return false
1473
+ if (scope.hadVisibleToolbar != isNativeActionToolbarVisible(action)) return false
1474
+ if (
1475
+ scope.documentVersion != lastDocumentVersion &&
1476
+ (scope.allowedDocumentVersion == null || scope.allowedDocumentVersion != lastDocumentVersion)
1477
+ ) {
1478
+ return false
1479
+ }
1480
+ val selection = richTextView.editorEditText.currentScalarSelection()
1481
+ if (scope.selectionAnchor != selection?.first || scope.selectionHead != selection?.second) {
1482
+ return false
1483
+ }
1484
+ if (action is PendingNativeAction.MentionSuggestionSelect) {
1485
+ val mentions = addons.mentions ?: return false
1486
+ val currentQuery = currentMentionQueryState(mentions.trigger) ?: return false
1487
+ if (
1488
+ scope.mentionAnchor != currentQuery.anchor ||
1489
+ scope.mentionHead != currentQuery.head ||
1490
+ scope.mentionQuery != currentQuery.query
1491
+ ) {
1492
+ return false
1493
+ }
1494
+ }
1495
+ return true
1496
+ }
1497
+
1498
+ private fun isNativeActionToolbarVisible(action: PendingNativeAction): Boolean {
1499
+ if (!showsToolbar || toolbarPlacement != ToolbarPlacement.KEYBOARD) return false
1500
+ if (keyboardToolbarView.parent == null || keyboardToolbarView.visibility != View.VISIBLE) return false
1501
+ if (action is PendingNativeAction.MentionSuggestionSelect) {
1502
+ return keyboardToolbarView.isShowingMentionSuggestions
1503
+ }
1504
+ return true
1505
+ }
1506
+
1507
+ private fun isEditorEffectivelyFocusedForNativeAction(): Boolean =
1508
+ richTextView.editorEditText.hasFocus() ||
1509
+ (pendingToolbarRefocus != null && pendingToolbarRefocusEditorId == richTextView.editorId)
1510
+
1511
+ private fun clearPendingNativeActionRetryIfScopeChanged() {
1512
+ val action = pendingNativeAction ?: return
1513
+ val scope = pendingNativeActionScope ?: return
1514
+ if (!isPendingNativeActionScopeCurrent(action, scope)) {
1515
+ clearPendingNativeActionRetry()
1516
+ }
1517
+ }
1518
+
1519
+ private fun schedulePendingNativeActionRetry(action: PendingNativeAction) {
1520
+ val isSameAction = pendingNativeAction == action
1521
+ if (isSameAction) {
1522
+ pendingNativeActionRetryAttempts += 1
1523
+ } else {
1524
+ pendingNativeActionRetryAttempts = 1
1525
+ pendingNativeActionScope = currentNativeActionScope(action)
1526
+ }
1527
+ if (pendingNativeActionRetryAttempts > MAX_NATIVE_ACTION_RETRY_ATTEMPTS) {
1528
+ pendingNativeAction = action
1529
+ pendingNativeActionRetryEditorId = richTextView.editorId
1530
+ pendingNativeActionRetryScheduled = false
1531
+ return
1532
+ }
1533
+ pendingNativeAction = action
1534
+ pendingNativeActionRetryEditorId = richTextView.editorId
1535
+ if (pendingNativeActionRetryScheduled) return
1536
+ pendingNativeActionRetryScheduled = true
1537
+ pendingNativeActionRetryGeneration += 1
1538
+ val retryGeneration = pendingNativeActionRetryGeneration
1539
+ val retry = Runnable {
1540
+ if (retryGeneration != pendingNativeActionRetryGeneration) return@Runnable
1541
+ val retryAction = pendingNativeAction ?: run {
1542
+ pendingNativeActionRetryScheduled = false
1543
+ return@Runnable
1544
+ }
1545
+ val retryScope = pendingNativeActionScope ?: run {
1546
+ clearPendingNativeActionRetry()
1547
+ return@Runnable
1548
+ }
1549
+ if (pendingNativeActionRetryEditorId != richTextView.editorId || richTextView.editorId == 0L) {
1550
+ clearPendingNativeActionRetry()
1551
+ return@Runnable
1552
+ }
1553
+ if (!isPendingNativeActionScopeCurrent(retryAction, retryScope)) {
1554
+ clearPendingNativeActionRetry()
1555
+ return@Runnable
1556
+ }
1557
+ pendingNativeActionRetryScheduled = false
1558
+ val allowNextRetry = pendingNativeActionRetryAttempts < MAX_NATIVE_ACTION_RETRY_ATTEMPTS
1559
+ when (retryAction) {
1560
+ is PendingNativeAction.ToolbarItemPress ->
1561
+ handleToolbarItemPress(retryAction.item, allowPreflightRetry = allowNextRetry)
1562
+ is PendingNativeAction.MentionSuggestionSelect ->
1563
+ insertMentionSuggestion(retryAction.suggestion, allowPreflightRetry = allowNextRetry)
1564
+ }
1565
+ }
1566
+ mainHandler.postDelayed(retry, NATIVE_ACTION_RETRY_DELAY_MS)
1567
+ }
1568
+
1569
+ private fun retryPendingNativeActionFromWake() {
1570
+ val action = pendingNativeAction ?: return
1571
+ val scope = pendingNativeActionScope ?: run {
1572
+ clearPendingNativeActionRetry()
1573
+ return
1574
+ }
1575
+ if (!isPendingNativeActionScopeCurrent(action, scope)) {
1576
+ clearPendingNativeActionRetry()
1577
+ return
1578
+ }
1579
+ pendingNativeActionRetryAttempts = 0
1580
+ pendingNativeActionRetryScheduled = false
1581
+ when (action) {
1582
+ is PendingNativeAction.ToolbarItemPress ->
1583
+ handleToolbarItemPress(action.item, allowPreflightRetry = true)
1584
+ is PendingNativeAction.MentionSuggestionSelect ->
1585
+ insertMentionSuggestion(action.suggestion, allowPreflightRetry = true)
1586
+ }
1587
+ }
1588
+
1589
+ private fun documentVersionFromUpdateJSON(updateJSON: String?): Int? =
1590
+ try {
1591
+ if (updateJSON == null) null
1592
+ else {
1593
+ val version = JSONObject(updateJSON).optInt("documentVersion", Int.MIN_VALUE)
1594
+ version.takeIf { it != Int.MIN_VALUE }
1595
+ }
1596
+ } catch (_: Throwable) {
1597
+ null
1598
+ }
1599
+
1600
+ private fun noteDocumentVersionFromUpdateJSON(updateJSON: String?) {
1601
+ documentVersionFromUpdateJSON(updateJSON)?.let { version ->
1602
+ lastDocumentVersion = version
1603
+ }
1604
+ }
1605
+
1606
+ private fun addPreflightUpdateToEvent(
1607
+ event: MutableMap<String, Any>,
1608
+ updateJSON: String?
1609
+ ) {
1610
+ if (updateJSON == null) return
1611
+ event["updateJson"] = updateJSON
1612
+ documentVersionFromUpdateJSON(updateJSON)?.let { version ->
1613
+ event["documentVersion"] = version
1614
+ }
1615
+ }
1616
+
1617
+ private fun emitAddonEvent(payload: Map<String, Any>) {
1618
+ onAddonEventForTesting?.invoke(payload) ?: onAddonEvent(payload)
1619
+ }
1620
+
1621
+ private fun canFocusCurrentEditor(): Boolean {
1622
+ val editorId = richTextView.editorId
1623
+ return editorId != 0L &&
1624
+ isAttachedToNativeWindow &&
1625
+ !NativeEditorViewRegistry.isDestroyed(editorId)
1626
+ }
1627
+
1628
+ fun focus() {
1629
+ focusInternal(cancelPendingOutsideTapBlur = true)
1630
+ }
1631
+
1632
+ private fun focusInternal(cancelPendingOutsideTapBlur: Boolean) {
1633
+ if (!canFocusCurrentEditor()) return
1634
+ if (cancelPendingOutsideTapBlur) {
1635
+ cancelPendingOutsideTapBlur()
1636
+ }
1637
+ cancelPendingKeyboardDismiss()
1638
+ cancelPendingBlurRetry()
1639
+ richTextView.editorEditText.requestFocus()
1640
+ richTextView.editorEditText.post {
1641
+ if (!canFocusCurrentEditor()) return@post
1642
+ val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
1643
+ imm?.showSoftInput(richTextView.editorEditText, InputMethodManager.SHOW_IMPLICIT)
1644
+ }
1645
+ }
1646
+
1647
+ fun blur() {
1648
+ cancelPendingOutsideTapBlur()
1649
+ cancelPendingKeyboardDismiss()
1650
+ cancelPendingToolbarRefocus()
1651
+ clearRecentToolbarTouch()
1652
+ performBlur(deferKeyboardDismiss = false, allowRetry = true)
1653
+ }
1654
+
1655
+ private fun performBlur(deferKeyboardDismiss: Boolean, allowRetry: Boolean) {
1656
+ if (handleDestroyedCurrentEditorIfNeeded()) return
1657
+ if (!richTextView.editorEditText.prepareForExternalEditorUpdate()) {
1658
+ if (allowRetry && pendingBlurRetryAttempts < MAX_PENDING_UPDATE_RETRY_ATTEMPTS) {
1659
+ schedulePendingBlurRetry(deferKeyboardDismiss)
1660
+ return
1661
+ }
1662
+ if (handleDestroyedCurrentEditorIfNeeded()) return
1663
+ richTextView.editorEditText.restoreAuthorizedTextIfNeeded()
1664
+ }
1665
+ completeBlur(deferKeyboardDismiss)
1666
+ }
1667
+
1668
+ private fun completeBlur(deferKeyboardDismiss: Boolean) {
1669
+ cancelPendingBlurRetry()
1670
+ traceOutsideTap(
1671
+ "complete blur deferKeyboardDismiss=$deferKeyboardDismiss focusedBefore=${richTextView.editorEditText.hasFocus()}"
1672
+ )
1673
+ richTextView.editorEditText.clearFocus()
1674
+ traceOutsideTap("complete blur focusedAfter=${richTextView.editorEditText.hasFocus()}")
1675
+ if (deferKeyboardDismiss) {
1676
+ val dismiss = Runnable {
1677
+ pendingKeyboardDismiss = null
1678
+ if (!richTextView.editorEditText.hasFocus()) {
1679
+ val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
1680
+ imm?.hideSoftInputFromWindow(richTextView.editorEditText.windowToken, 0)
1681
+ }
1682
+ }
1683
+ pendingKeyboardDismiss = dismiss
1684
+ richTextView.editorEditText.post(dismiss)
1685
+ return
1686
+ }
1687
+ val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
1688
+ imm?.hideSoftInputFromWindow(richTextView.editorEditText.windowToken, 0)
1689
+ }
1690
+
1691
+ private fun schedulePendingBlurRetry(deferKeyboardDismiss: Boolean) {
1692
+ pendingBlurRetry?.let {
1693
+ mainHandler.removeCallbacks(it)
1694
+ pendingBlurRetry = null
1695
+ }
1696
+ pendingBlurRetryAttempts += 1
1697
+ pendingBlurRetryEditorId = richTextView.editorId
1698
+ pendingBlurRetryGeneration += 1
1699
+ val retryGeneration = pendingBlurRetryGeneration
1700
+ val delayMs = NATIVE_ACTION_RETRY_DELAY_MS * pendingBlurRetryAttempts
1701
+ val retry = Runnable {
1702
+ pendingBlurRetry = null
1703
+ if (retryGeneration != pendingBlurRetryGeneration) return@Runnable
1704
+ if (pendingBlurRetryEditorId != richTextView.editorId) {
1705
+ pendingBlurRetryEditorId = null
1706
+ return@Runnable
1707
+ }
1708
+ pendingBlurRetryEditorId = null
1709
+ if (handleDestroyedCurrentEditorIfNeeded()) return@Runnable
1710
+ performBlur(deferKeyboardDismiss, allowRetry = true)
1711
+ }
1712
+ pendingBlurRetry = retry
1713
+ mainHandler.postDelayed(retry, delayMs)
1714
+ }
1715
+
1716
+ private fun blurWithDeferredKeyboardDismiss() {
1717
+ cancelPendingKeyboardDismiss()
1718
+ cancelPendingToolbarRefocus()
1719
+ clearRecentToolbarTouch()
1720
+ performBlur(deferKeyboardDismiss = true, allowRetry = true)
1721
+ }
1722
+
1723
+ private fun scheduleToolbarRefocus() {
1724
+ cancelPendingToolbarRefocus()
1725
+ val editorId = richTextView.editorId
1726
+ pendingToolbarRefocusEditorId = editorId
1727
+ pendingToolbarRefocusGeneration += 1
1728
+ val refocusGeneration = pendingToolbarRefocusGeneration
1729
+ val refocus = Runnable {
1730
+ pendingToolbarRefocus = null
1731
+ if (refocusGeneration != pendingToolbarRefocusGeneration) return@Runnable
1732
+ if (pendingToolbarRefocusEditorId != richTextView.editorId) return@Runnable
1733
+ pendingToolbarRefocusEditorId = null
1734
+ focusInternal(cancelPendingOutsideTapBlur = false)
1735
+ }
1736
+ pendingToolbarRefocus = refocus
1737
+ richTextView.editorEditText.post(refocus)
1738
+ }
1739
+
1740
+ private fun cancelPendingToolbarRefocus() {
1741
+ pendingToolbarRefocus?.let {
1742
+ richTextView.editorEditText.removeCallbacks(it)
1743
+ pendingToolbarRefocus = null
1744
+ }
1745
+ pendingToolbarRefocusEditorId = null
1746
+ pendingToolbarRefocusGeneration += 1
1747
+ }
1748
+
1749
+ private fun scheduleOutsideTapBlur() {
1750
+ cancelPendingOutsideTapBlur()
1751
+ traceOutsideTap("schedule outside blur focused=${richTextView.editorEditText.hasFocus()}")
1752
+ val blur = Runnable {
1753
+ pendingOutsideTapBlur = null
1754
+ traceOutsideTap("run outside blur focused=${richTextView.editorEditText.hasFocus()}")
1755
+ if (richTextView.editorEditText.hasFocus()) {
1756
+ blurWithDeferredKeyboardDismiss()
1757
+ }
1758
+ }
1759
+ pendingOutsideTapBlur = blur
1760
+ richTextView.editorEditText.postDelayed(blur, OUTSIDE_TAP_BLUR_DELAY_MS)
1761
+ }
1762
+
1763
+ private fun cancelPendingOutsideTapBlur() {
1764
+ pendingOutsideTapBlur?.let {
1765
+ traceOutsideTap("cancel outside blur")
1766
+ richTextView.editorEditText.removeCallbacks(it)
1767
+ pendingOutsideTapBlur = null
1768
+ }
1769
+ }
1770
+
1771
+ private fun cancelPendingKeyboardDismiss() {
1772
+ pendingKeyboardDismiss?.let {
1773
+ richTextView.editorEditText.removeCallbacks(it)
1774
+ pendingKeyboardDismiss = null
1775
+ }
1776
+ }
1777
+
1778
+ private fun cancelPendingBlurRetry() {
1779
+ pendingBlurRetry?.let {
1780
+ mainHandler.removeCallbacks(it)
1781
+ pendingBlurRetry = null
1782
+ }
1783
+ pendingBlurRetryEditorId = null
1784
+ pendingBlurRetryAttempts = 0
1785
+ pendingBlurRetryGeneration += 1
1786
+ }
1787
+
1788
+ fun getCaretRectJson(): String? {
1789
+ if (width <= 0 || height <= 0) return null
1790
+ val rect = richTextView.caretRect() ?: return null
1791
+ val density = resources.displayMetrics.density
1792
+ return JSONObject()
1793
+ .put("x", rect.left / density)
1794
+ .put("y", rect.top / density)
1795
+ .put("width", rect.width() / density)
1796
+ .put("height", rect.height() / density)
1797
+ .put("editorWidth", width / density)
1798
+ .put("editorHeight", height / density)
1799
+ .toString()
1800
+ }
1801
+
1802
+ override fun onAttachedToWindow() {
1803
+ super.onAttachedToWindow()
1804
+ handleAttachedToWindow()
1805
+ }
1806
+
1807
+ internal fun handleEditorDestroyed(editorId: Long) {
1808
+ if (richTextView.editorId != editorId && richTextView.editorEditText.editorId != editorId) {
1809
+ return
1810
+ }
1811
+ cancelPendingEditorUpdateRetry()
1812
+ clearPendingViewCommandUpdateRetry()
1813
+ cancelPendingThemeRetry()
1814
+ cancelPendingBlurRetry()
1815
+ cancelPendingDetachPreflightRetry()
1816
+ cancelPendingOutsideTapBlur()
1817
+ cancelPendingKeyboardDismiss()
1818
+ cancelPendingToolbarRefocus()
1819
+ cancelPendingPreflightWake()
1820
+ clearPendingNativeActionRetry()
1821
+ clearRecentToolbarTouch()
1822
+ uninstallOutsideTapBlurHandler()
1823
+ detachKeyboardToolbarIfNeeded()
1824
+ richTextView.setViewportBottomInsetPx(0)
1825
+ val editText = richTextView.editorEditText
1826
+ if (editText.hasFocus()) {
1827
+ editText.clearFocus()
1828
+ }
1829
+ val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
1830
+ imm?.hideSoftInputFromWindow(editText.windowToken, 0)
1831
+ clearMentionQueryState(resetLastEvent = true)
1832
+ pendingEditorUpdateJson = null
1833
+ pendingEditorUpdateEditorId = null
1834
+ pendingEditorUpdateRevision = 0
1835
+ appliedEditorUpdateRevision = 0
1836
+ pendingEditorResetUpdateJson = null
1837
+ pendingEditorResetUpdateEditorId = null
1838
+ pendingEditorResetUpdateRevision = 0
1839
+ appliedEditorResetUpdateRevision = 0
1840
+ lastEditorResetUpdateJsonProp = null
1841
+ lastEditorResetUpdateEditorIdProp = null
1842
+ lastDocumentVersion = null
1843
+ lastReadyEditorId = null
1844
+ toolbarState = NativeToolbarState.empty
1845
+ keyboardToolbarView.applyState(toolbarState)
1846
+ keyboardToolbarView.visibility = View.GONE
1847
+ richTextView.editorId = 0L
1848
+ }
1849
+
1850
+ private fun handleDestroyedCurrentEditorIfNeeded(): Boolean {
1851
+ val editorId = richTextView.editorId.takeIf { it != 0L }
1852
+ ?: richTextView.editorEditText.editorId.takeIf { it != 0L }
1853
+ ?: return false
1854
+ if (!NativeEditorViewRegistry.isDestroyed(editorId)) return false
1855
+ handleEditorDestroyed(editorId)
1856
+ return true
1857
+ }
1858
+
1859
+ private fun handleAttachedToWindow() {
1860
+ isAttachedToNativeWindow = true
1861
+ cancelPendingDetachPreflightRetry()
1862
+ richTextView.clearDeferredEditorUnbind()
1863
+ val editorId = richTextView.editorId
1864
+ if (editorId == 0L) return
1865
+ if (NativeEditorViewRegistry.isDestroyed(editorId)) {
1866
+ handleEditorDestroyed(editorId)
1867
+ return
1868
+ }
1869
+ if (!NativeEditorViewRegistry.register(editorId, this)) {
1870
+ handleEditorDestroyed(editorId)
1871
+ return
1872
+ }
1873
+ richTextView.rebindEditorIfNeeded(
1874
+ notifyListener = !hasPendingEditorResetUpdateForEditor(editorId) &&
1875
+ !hasPendingEditorUpdateForEditor(editorId)
1876
+ )
1877
+ if (hasPendingTheme) {
1878
+ pendingThemeRetryEditorId = editorId
1879
+ }
1880
+ applyPendingEditorResetUpdateIfNeeded()
1881
+ applyPendingEditorUpdateIfNeeded()
1882
+ applyPendingThemeIfNeeded()
1883
+ refreshReadyStateIfSettled()
1884
+ applyAutoFocusIfNeeded()
1885
+ }
1886
+
1887
+ private fun emitEditorReady(editorUpdateRevision: Int? = null): Boolean {
1888
+ val editorId = richTextView.editorId
1889
+ if (editorId == 0L) return false
1890
+ if (!isAttachedToNativeWindow) return false
1891
+ if (richTextView.editorEditText.editorId != editorId) return false
1892
+ if (hasPendingEditorResetUpdateForCurrentEditor()) return false
1893
+ if (hasPendingEditorUpdateForCurrentEditor()) return false
1894
+ lastReadyEditorId = editorId
1895
+ val payload = mutableMapOf<String, Any>("editorId" to editorId)
1896
+ editorUpdateRevision?.let { payload["editorUpdateRevision"] = it }
1897
+ onEditorReadyForTesting?.invoke(payload) ?: onEditorReady(payload)
1898
+ return true
1899
+ }
1900
+
1901
+ private fun emitEditorReadyIfNeeded() {
1902
+ val editorId = richTextView.editorId
1903
+ if (lastReadyEditorId == editorId) return
1904
+ emitEditorReady()
1905
+ }
1906
+
1907
+ override fun onDetachedFromWindow() {
1908
+ prepareForDetachFromWindow()
1909
+ super.onDetachedFromWindow()
1910
+ handleDetachedFromWindow()
1911
+ }
1912
+
1913
+ private fun prepareForDetachFromWindow() {
1914
+ if (handleDestroyedCurrentEditorIfNeeded()) return
1915
+ val editorId = richTextView.editorId
1916
+ if (editorId == 0L || richTextView.editorEditText.editorId == 0L) return
1917
+ if (richTextView.editorEditText.prepareForExternalEditorUpdate()) {
1918
+ cancelPendingDetachPreflightRetry()
1919
+ richTextView.clearDeferredEditorUnbind()
1920
+ return
1921
+ }
1922
+ richTextView.deferEditorUnbindOnNextDetach()
1923
+ schedulePendingDetachPreflightRetry(editorId)
1924
+ }
1925
+
1926
+ private fun schedulePendingDetachPreflightRetry(editorId: Long) {
1927
+ if (pendingDetachPreflightRetryScheduled) return
1928
+ if (pendingDetachPreflightRetryAttempts >= MAX_PENDING_UPDATE_RETRY_ATTEMPTS) {
1929
+ if (handleDestroyedCurrentEditorIfNeeded()) return
1930
+ richTextView.editorEditText.restoreAuthorizedTextIfNeeded()
1931
+ cancelPendingDetachPreflightRetry()
1932
+ richTextView.unbindEditorForDetachedViewIfNeeded()
1933
+ return
1934
+ }
1935
+ pendingDetachPreflightRetryAttempts += 1
1936
+ pendingDetachPreflightRetryEditorId = editorId
1937
+ pendingDetachPreflightRetryScheduled = true
1938
+ pendingDetachPreflightRetryGeneration += 1
1939
+ val retryGeneration = pendingDetachPreflightRetryGeneration
1940
+ val delayMs = NATIVE_ACTION_RETRY_DELAY_MS * pendingDetachPreflightRetryAttempts
1941
+ mainHandler.postDelayed({
1942
+ if (retryGeneration != pendingDetachPreflightRetryGeneration) return@postDelayed
1943
+ pendingDetachPreflightRetryScheduled = false
1944
+ if (isAttachedToNativeWindow || pendingDetachPreflightRetryEditorId != richTextView.editorId) {
1945
+ cancelPendingDetachPreflightRetry()
1946
+ return@postDelayed
1947
+ }
1948
+ if (handleDestroyedCurrentEditorIfNeeded()) return@postDelayed
1949
+ if (richTextView.editorEditText.prepareForExternalEditorUpdate()) {
1950
+ cancelPendingDetachPreflightRetry()
1951
+ richTextView.unbindEditorForDetachedViewIfNeeded()
1952
+ return@postDelayed
1953
+ }
1954
+ schedulePendingDetachPreflightRetry(editorId)
1955
+ }, delayMs)
1956
+ }
1957
+
1958
+ private fun cancelPendingDetachPreflightRetry() {
1959
+ pendingDetachPreflightRetryScheduled = false
1960
+ pendingDetachPreflightRetryEditorId = null
1961
+ pendingDetachPreflightRetryAttempts = 0
1962
+ pendingDetachPreflightRetryGeneration += 1
1963
+ }
1964
+
1965
+ private fun handleDetachedFromWindow() {
1966
+ isAttachedToNativeWindow = false
1967
+ NativeEditorViewRegistry.unregister(
1968
+ richTextView.editorId,
1969
+ this,
1970
+ blockCommandsUntilRegistered = true
1971
+ )
1972
+ cancelPendingOutsideTapBlur()
1973
+ cancelPendingKeyboardDismiss()
1974
+ cancelPendingToolbarRefocus()
1975
+ cancelPendingBlurRetry()
1976
+ cancelPendingEditorUpdateRetry()
1977
+ clearPendingViewCommandUpdateRetry()
1978
+ cancelPendingThemeRetry()
1979
+ clearPendingNativeActionRetry()
1980
+ cancelPendingPreflightWake()
1981
+ lastReadyEditorId = null
1982
+ uninstallOutsideTapBlurHandler()
1983
+ currentImeBottom = 0
1984
+ keyboardToolbarView.visibility = View.GONE
1985
+ detachKeyboardToolbarIfNeeded()
1986
+ richTextView.setViewportBottomInsetPx(0)
1987
+ }
1988
+
1989
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
1990
+ if (heightBehavior != EditorHeightBehavior.AUTO_GROW) {
1991
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
1992
+ return
1993
+ }
1994
+
1995
+ val childWidthSpec = getChildMeasureSpec(
1996
+ widthMeasureSpec,
1997
+ paddingLeft + paddingRight,
1998
+ richTextView.layoutParams.width
1999
+ )
2000
+ val childHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
2001
+ richTextView.measure(childWidthSpec, childHeightSpec)
2002
+
2003
+ val measuredWidth = resolveSize(
2004
+ richTextView.measuredWidth + paddingLeft + paddingRight,
2005
+ widthMeasureSpec
2006
+ )
2007
+ val desiredHeight = richTextView.measuredHeight + paddingTop + paddingBottom
2008
+ val measuredHeight = when (MeasureSpec.getMode(heightMeasureSpec)) {
2009
+ MeasureSpec.AT_MOST -> desiredHeight.coerceAtMost(MeasureSpec.getSize(heightMeasureSpec))
2010
+ else -> desiredHeight
2011
+ }
2012
+ setMeasuredDimension(measuredWidth, measuredHeight)
2013
+ emitContentHeightIfNeeded(force = false)
2014
+ }
2015
+
2016
+ private fun emitContentHeightIfNeeded(force: Boolean) {
2017
+ if (heightBehavior != EditorHeightBehavior.AUTO_GROW) return
2018
+ val editText = richTextView.editorEditText
2019
+ val resolvedEditHeight = editText.resolveAutoGrowHeight()
2020
+ val resolvedContainerHeight =
2021
+ resolvedEditHeight +
2022
+ richTextView.paddingTop +
2023
+ richTextView.paddingBottom +
2024
+ paddingTop +
2025
+ paddingBottom
2026
+ val contentHeight = (
2027
+ when {
2028
+ editText.isLaidOut && (editText.layout?.height ?: 0) > 0 -> {
2029
+ maxOf(
2030
+ (editText.layout?.height ?: 0) +
2031
+ editText.compoundPaddingTop +
2032
+ editText.compoundPaddingBottom +
2033
+ richTextView.paddingTop +
2034
+ richTextView.paddingBottom +
2035
+ paddingTop +
2036
+ paddingBottom,
2037
+ resolvedContainerHeight
2038
+ )
2039
+ }
2040
+ richTextView.measuredHeight > 0 -> {
2041
+ maxOf(
2042
+ richTextView.measuredHeight + paddingTop + paddingBottom,
2043
+ resolvedContainerHeight
2044
+ )
2045
+ }
2046
+ editText.measuredHeight > 0 -> {
2047
+ maxOf(
2048
+ editText.measuredHeight +
2049
+ richTextView.paddingTop +
2050
+ richTextView.paddingBottom +
2051
+ paddingTop +
2052
+ paddingBottom,
2053
+ resolvedContainerHeight
2054
+ )
2055
+ }
2056
+ else -> {
2057
+ resolvedContainerHeight
2058
+ }
2059
+ }
2060
+ ).coerceAtLeast(0)
2061
+ if (contentHeight <= 0) return
2062
+ val editorId = richTextView.editorId
2063
+ if (
2064
+ !force &&
2065
+ contentHeight == lastEmittedContentHeight &&
2066
+ editorId == lastEmittedContentHeightEditorId
2067
+ ) {
2068
+ return
2069
+ }
2070
+ lastEmittedContentHeight = contentHeight
2071
+ lastEmittedContentHeightEditorId = editorId
2072
+ val event = mapOf(
2073
+ "contentHeight" to contentHeight,
2074
+ "editorId" to editorId
2075
+ )
2076
+ onContentHeightChangeForTesting?.invoke(event) ?: onContentHeightChange(event)
2077
+ }
2078
+
2079
+ /** Applies an editor update from JS without echoing it back through events. */
2080
+ fun applyEditorUpdate(updateJson: String): Boolean =
2081
+ applyEditorUpdate(updateJson, scheduleViewCommandRetry = true)
2082
+
2083
+ /** Applies a reset-style update from JS, discarding pending native composition. */
2084
+ fun applyEditorResetUpdate(updateJson: String): Boolean {
2085
+ if (Looper.myLooper() != Looper.getMainLooper()) {
2086
+ val postedEditorId = richTextView.editorId
2087
+ val apply = Runnable {
2088
+ if (postedEditorId != richTextView.editorId) return@Runnable
2089
+ applyEditorResetUpdate(updateJson)
2090
+ }
2091
+ if (!post(apply)) {
2092
+ richTextView.post(apply)
2093
+ }
2094
+ return false
2095
+ }
2096
+ if (handleDestroyedCurrentEditorIfNeeded()) {
2097
+ return false
2098
+ }
2099
+ if (!isEditorReadyForNativeUpdate()) {
2100
+ return false
2101
+ }
2102
+ clearPendingEditorUpdateState(resetAppliedRevision = false)
2103
+ clearPendingViewCommandUpdateRetry()
2104
+ isApplyingJSUpdate = true
2105
+ val applied = try {
2106
+ richTextView.editorEditText.applyUpdateJSON(
2107
+ updateJson,
2108
+ refreshInputConnectionForExternalUpdate = true
2109
+ )
2110
+ clearPendingEditorUpdateDispatchQueue("jsResetUpdate")
2111
+ true
2112
+ } catch (error: Throwable) {
2113
+ Log.w(LOG_TAG, "Failed to apply JS editor reset update", error)
2114
+ false
2115
+ } finally {
2116
+ isApplyingJSUpdate = false
2117
+ }
2118
+ if (applied) {
2119
+ refreshReadyStateIfSettled()
2120
+ }
2121
+ return applied
2122
+ }
2123
+
2124
+ private fun isEditorReadyForNativeUpdate(): Boolean {
2125
+ val editorId = richTextView.editorId
2126
+ return editorId == 0L || (isAttachedToNativeWindow && richTextView.editorEditText.editorId == editorId)
2127
+ }
2128
+
2129
+ private fun applyEditorUpdate(
2130
+ updateJson: String,
2131
+ scheduleViewCommandRetry: Boolean,
2132
+ expectedEditorId: Long? = null
2133
+ ): Boolean {
2134
+ if (Looper.myLooper() != Looper.getMainLooper()) {
2135
+ val postedEditorId = expectedEditorId ?: richTextView.editorId
2136
+ val apply = Runnable {
2137
+ if (postedEditorId != richTextView.editorId) return@Runnable
2138
+ applyEditorUpdate(updateJson, scheduleViewCommandRetry, postedEditorId)
2139
+ }
2140
+ if (!post(apply)) {
2141
+ richTextView.post(apply)
2142
+ }
2143
+ return false
2144
+ }
2145
+ if (expectedEditorId != null && expectedEditorId != richTextView.editorId) {
2146
+ return false
2147
+ }
2148
+ if (handleDestroyedCurrentEditorIfNeeded()) {
2149
+ return false
2150
+ }
2151
+ if (!isEditorReadyForNativeUpdate()) {
2152
+ if (scheduleViewCommandRetry) {
2153
+ scheduleViewCommandUpdateRetry(updateJson)
2154
+ }
2155
+ return false
2156
+ }
2157
+ if (
2158
+ blockEditorUpdatePreflightForTesting ||
2159
+ !richTextView.editorEditText.prepareForExternalEditorUpdate()
2160
+ ) {
2161
+ if (scheduleViewCommandRetry) {
2162
+ scheduleViewCommandUpdateRetry(updateJson)
2163
+ }
2164
+ return false
2165
+ }
2166
+ isApplyingJSUpdate = true
2167
+ return try {
2168
+ richTextView.editorEditText.applyUpdateJSON(
2169
+ updateJson,
2170
+ refreshInputConnectionForExternalUpdate = true
2171
+ )
2172
+ clearPendingEditorUpdateDispatchQueue("jsUpdate")
2173
+ true
2174
+ } catch (error: Throwable) {
2175
+ Log.w(LOG_TAG, "Failed to apply JS editor update", error)
2176
+ if (scheduleViewCommandRetry) {
2177
+ scheduleViewCommandUpdateRetry(updateJson)
2178
+ }
2179
+ false
2180
+ } finally {
2181
+ isApplyingJSUpdate = false
2182
+ }
2183
+ }
2184
+
2185
+ fun prepareForEditorCommandJSON(): String {
2186
+ if (Looper.myLooper() != Looper.getMainLooper()) {
2187
+ return NativeEditorViewRegistry.commandPreparationJSON(
2188
+ ready = false,
2189
+ blockedReason = "unknown"
2190
+ )
2191
+ }
2192
+ if (handleDestroyedCurrentEditorIfNeeded()) {
2193
+ return NativeEditorViewRegistry.commandPreparationJSON(
2194
+ ready = false,
2195
+ blockedReason = "destroyed"
2196
+ )
2197
+ }
2198
+ if (richTextView.editorId != 0L && !isAttachedToNativeWindow) {
2199
+ return NativeEditorViewRegistry.commandPreparationJSON(
2200
+ ready = false,
2201
+ blockedReason = "detached"
2202
+ )
2203
+ }
2204
+ if (richTextView.editorId != 0L && richTextView.editorEditText.editorId != richTextView.editorId) {
2205
+ return NativeEditorViewRegistry.commandPreparationJSON(
2206
+ ready = false,
2207
+ blockedReason = "detached"
2208
+ )
2209
+ }
2210
+ if (shouldBlockEditorCommandForPendingUpdate()) {
2211
+ return pendingEditorUpdateCommandPreparationJSON()
2212
+ }
2213
+ isApplyingJSUpdate = true
2214
+ return try {
2215
+ onBeforePrepareForEditorCommandForTesting?.invoke()
2216
+ val preparation = richTextView.editorEditText.prepareForExternalEditorCommand()
2217
+ NativeEditorViewRegistry.commandPreparationJSON(
2218
+ ready = preparation.ready,
2219
+ updateJSON = preparation.updateJSON,
2220
+ blockedReason = if (preparation.ready) null else "composition"
2221
+ )
2222
+ } finally {
2223
+ isApplyingJSUpdate = false
2224
+ }
2225
+ }
2226
+
2227
+ override fun onSelectionChanged(anchor: Int, head: Int) {
2228
+ val stateJson = refreshToolbarStateFromEditorSelection()
2229
+ refreshMentionQuery()
2230
+ clearPendingNativeActionRetryIfScopeChanged()
2231
+ schedulePendingPreflightWake()
2232
+ richTextView.refreshRemoteSelections()
2233
+ val event = mutableMapOf<String, Any>(
2234
+ "anchor" to anchor,
2235
+ "head" to head,
2236
+ "editorId" to richTextView.editorId
2237
+ )
2238
+ lastDocumentVersion?.let {
2239
+ event["documentVersion"] = it
2240
+ }
2241
+ if (stateJson != null) {
2242
+ event["stateJson"] = stateJson
2243
+ }
2244
+ onSelectionChangeForTesting?.invoke(event) ?: onSelectionChange(event)
2245
+ }
2246
+
2247
+ override fun onEditorUpdate(updateJSON: String) {
2248
+ if (isApplyingJSUpdate) {
2249
+ dispatchEditorUpdate(
2250
+ PendingEditorUpdateEvent(
2251
+ editorId = richTextView.editorId,
2252
+ updateJSON = updateJSON
2253
+ ),
2254
+ emitToJS = false
2255
+ )
2256
+ return
2257
+ }
2258
+ pendingEditorUpdateEvents.addLast(
2259
+ PendingEditorUpdateEvent(
2260
+ editorId = richTextView.editorId,
2261
+ updateJSON = updateJSON
2262
+ )
2263
+ )
2264
+ richTextView.editorEditText.recordImeTraceForTesting(
2265
+ "nativeViewEditorUpdateQueued",
2266
+ "queue=${pendingEditorUpdateEvents.size} jsonLength=${updateJSON.length}"
2267
+ )
2268
+ schedulePendingEditorUpdateDispatch()
2269
+ }
2270
+
2271
+ internal fun pendingEditorUpdateEventCountForTesting(): Int =
2272
+ pendingEditorUpdateEvents.size
2273
+
2274
+ private fun schedulePendingEditorUpdateDispatch() {
2275
+ pendingEditorUpdateDispatchScheduled = true
2276
+ val generation = ++pendingEditorUpdateDispatchGeneration
2277
+ mainHandler.postDelayed({
2278
+ if (generation != pendingEditorUpdateDispatchGeneration) return@postDelayed
2279
+ pendingEditorUpdateDispatchScheduled = false
2280
+ drainPendingEditorUpdateEvents()
2281
+ }, EDITOR_UPDATE_EVENT_DEBOUNCE_MS)
2282
+ }
2283
+
2284
+ private fun drainPendingEditorUpdateEvents() {
2285
+ if (pendingEditorUpdateEvents.isEmpty()) return
2286
+ val startedAt = System.nanoTime()
2287
+ var drainedCount = 0
2288
+ while (pendingEditorUpdateEvents.isNotEmpty()) {
2289
+ val event = pendingEditorUpdateEvents.removeFirst()
2290
+ if (event.editorId != richTextView.editorId) {
2291
+ richTextView.editorEditText.recordImeTraceForTesting(
2292
+ "nativeViewEditorUpdateSkipped",
2293
+ "reason=staleEditor queuedEditor=${event.editorId} currentEditor=${richTextView.editorId}"
2294
+ )
2295
+ continue
2296
+ }
2297
+ dispatchEditorUpdate(event, emitToJS = true)
2298
+ drainedCount += 1
2299
+ }
2300
+ richTextView.editorEditText.recordImeTraceForTesting(
2301
+ "nativeViewEditorUpdateDrained",
2302
+ "count=$drainedCount totalUs=${nanosToMicros(System.nanoTime() - startedAt)}"
2303
+ )
2304
+ }
2305
+
2306
+ private fun clearPendingEditorUpdateDispatchQueue(reason: String) {
2307
+ if (pendingEditorUpdateEvents.isEmpty() && !pendingEditorUpdateDispatchScheduled) return
2308
+ val clearedCount = pendingEditorUpdateEvents.size
2309
+ pendingEditorUpdateEvents.clear()
2310
+ pendingEditorUpdateDispatchScheduled = false
2311
+ pendingEditorUpdateDispatchGeneration += 1
2312
+ richTextView.editorEditText.recordImeTraceForTesting(
2313
+ "nativeViewEditorUpdateQueueCleared",
2314
+ "reason=$reason count=$clearedCount"
2315
+ )
2316
+ }
2317
+
2318
+ private fun dispatchEditorUpdate(event: PendingEditorUpdateEvent, emitToJS: Boolean) {
2319
+ val updateJSON = event.updateJSON
2320
+ val startedAt = System.nanoTime()
2321
+ noteDocumentVersionFromUpdateJSON(updateJSON)
2322
+ val noteNanos = System.nanoTime() - startedAt
2323
+ val toolbarStartedAt = System.nanoTime()
2324
+ NativeToolbarState.fromUpdateJson(updateJSON)?.let { state ->
2325
+ toolbarState = state
2326
+ keyboardToolbarView.applyState(state)
2327
+ }
2328
+ val toolbarNanos = System.nanoTime() - toolbarStartedAt
2329
+ val mentionStartedAt = System.nanoTime()
2330
+ refreshMentionQuery()
2331
+ val mentionNanos = System.nanoTime() - mentionStartedAt
2332
+ val retryStartedAt = System.nanoTime()
2333
+ clearPendingNativeActionRetryIfScopeChanged()
2334
+ schedulePendingPreflightWake()
2335
+ richTextView.refreshRemoteSelections()
2336
+ val retryNanos = System.nanoTime() - retryStartedAt
2337
+ if (heightBehavior == EditorHeightBehavior.AUTO_GROW) {
2338
+ post {
2339
+ requestLayout()
2340
+ emitContentHeightIfNeeded(force = false)
2341
+ }
2342
+ }
2343
+ val emitStartedAt = System.nanoTime()
2344
+ if (emitToJS) {
2345
+ val payload = mapOf<String, Any>(
2346
+ "updateJson" to updateJSON,
2347
+ "editorId" to event.editorId
2348
+ )
2349
+ onEditorUpdateForTesting?.invoke(payload) ?: onEditorUpdate(payload)
2350
+ }
2351
+ val totalNanos = System.nanoTime() - startedAt
2352
+ richTextView.editorEditText.recordImeTraceForTesting(
2353
+ "nativeViewEditorUpdateDispatch",
2354
+ "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)}"
2355
+ )
2356
+ }
2357
+
2358
+ private fun installOutsideTapBlurHandlerIfNeeded() {
2359
+ val window = resolveActivity(context)?.window ?: return
2360
+ if (outsideTapWindow !== window) {
2361
+ uninstallOutsideTapBlurHandler()
2362
+ }
2363
+ if (NativeEditorOutsideTapDispatcher.register(window, this)) {
2364
+ outsideTapWindow = window
2365
+ } else if (outsideTapWindow === window) {
2366
+ outsideTapWindow = null
2367
+ }
2368
+ }
2369
+
2370
+ private fun scheduleOutsideTapBlurHandlerInstallRetry() {
2371
+ cancelPendingOutsideTapBlurHandlerInstallRetry()
2372
+ val retry = Runnable {
2373
+ pendingOutsideTapHandlerInstallRetry = null
2374
+ if (richTextView.editorEditText.hasFocus()) {
2375
+ installOutsideTapBlurHandlerIfNeeded()
2376
+ }
2377
+ }
2378
+ pendingOutsideTapHandlerInstallRetry = retry
2379
+ richTextView.editorEditText.postDelayed(retry, OUTSIDE_TAP_HANDLER_INSTALL_RETRY_DELAY_MS)
2380
+ }
2381
+
2382
+ private fun cancelPendingOutsideTapBlurHandlerInstallRetry() {
2383
+ pendingOutsideTapHandlerInstallRetry?.let {
2384
+ richTextView.editorEditText.removeCallbacks(it)
2385
+ pendingOutsideTapHandlerInstallRetry = null
2386
+ }
2387
+ }
2388
+
2389
+ private fun uninstallOutsideTapBlurHandler() {
2390
+ cancelPendingOutsideTapBlurHandlerInstallRetry()
2391
+ val window = outsideTapWindow ?: return
2392
+ NativeEditorOutsideTapDispatcher.unregister(window, this)
2393
+ outsideTapWindow = null
2394
+ }
2395
+
2396
+ internal fun prepareOutsideTapDecisionForWindowEvent(event: MotionEvent): NativeEditorOutsideTapDecision {
2397
+ if (!isAttachedToNativeWindow) {
2398
+ traceOutsideTap("decision ignored detached")
2399
+ return NativeEditorOutsideTapDecision.IGNORE
2400
+ }
2401
+ if (event.action != MotionEvent.ACTION_DOWN) {
2402
+ traceOutsideTap("decision ignored action=${event.action}")
2403
+ return NativeEditorOutsideTapDecision.IGNORE
2404
+ }
2405
+ if (!isEditorFocusedForOutsideTapDecision()) {
2406
+ traceOutsideTap("decision ignored not focused")
2407
+ return NativeEditorOutsideTapDecision.IGNORE
2408
+ }
2409
+
2410
+ val decision = if (isTouchOutsideEditor(event)) {
2411
+ NativeEditorOutsideTapDecision.OUTSIDE_EDITOR
2412
+ } else {
2413
+ NativeEditorOutsideTapDecision.PRESERVE_FOCUS
2414
+ }
2415
+ traceOutsideTap("decision raw=${event.rawX.toInt()},${event.rawY.toInt()} value=$decision")
2416
+ return decision
2417
+ }
2418
+
2419
+ internal fun handleOutsideTapDecisionFromWindowDispatcher(decision: NativeEditorOutsideTapDecision) {
2420
+ traceOutsideTap("handle decision=$decision")
2421
+ when (decision) {
2422
+ NativeEditorOutsideTapDecision.IGNORE -> {
2423
+ if (!richTextView.editorEditText.hasFocus()) {
2424
+ cancelPendingOutsideTapBlur()
2425
+ }
2426
+ }
2427
+ NativeEditorOutsideTapDecision.PRESERVE_FOCUS -> cancelPendingOutsideTapBlur()
2428
+ NativeEditorOutsideTapDecision.OUTSIDE_EDITOR -> {
2429
+ clearRecentToolbarTouch()
2430
+ cancelPendingToolbarRefocus()
2431
+ scheduleOutsideTapBlur()
2432
+ }
2433
+ }
2434
+ }
2435
+
2436
+ internal fun scheduleOutsideTapBlurFromWindowDispatcher() {
2437
+ scheduleOutsideTapBlur()
2438
+ }
2439
+
2440
+ internal fun cancelOutsideTapBlurFromWindowDispatcher() {
2441
+ cancelPendingOutsideTapBlur()
2442
+ }
2443
+
2444
+ private fun isEditorFocusedForOutsideTapDecision(): Boolean =
2445
+ editorFocusedForOutsideTapOverrideForTesting ?: richTextView.editorEditText.hasFocus()
2446
+
2447
+ private fun isTouchOutsideEditor(event: MotionEvent): Boolean {
2448
+ if (isTouchInsideKeyboardToolbar(event)) {
2449
+ markRecentToolbarTouch()
2450
+ return false
2451
+ }
2452
+ if (isTouchInsideStandaloneToolbar(event)) {
2453
+ markRecentToolbarTouch()
2454
+ return false
2455
+ }
2456
+ val rect = Rect()
2457
+ richTextView.editorEditText.getGlobalVisibleRect(rect)
2458
+ val isOutside = !rect.contains(event.rawX.toInt(), event.rawY.toInt())
2459
+ if (isOutside) {
2460
+ clearRecentToolbarTouch()
2461
+ }
2462
+ return isOutside
2463
+ }
2464
+
2465
+ private fun markRecentToolbarTouch() {
2466
+ lastToolbarTouchUptimeMs = SystemClock.uptimeMillis()
2467
+ }
2468
+
2469
+ private fun clearRecentToolbarTouch() {
2470
+ lastToolbarTouchUptimeMs = null
2471
+ }
2472
+
2473
+ private fun shouldPreserveFocusAfterToolbarTouch(): Boolean {
2474
+ val lastToolbarTouch = lastToolbarTouchUptimeMs ?: return false
2475
+ val elapsedMs = SystemClock.uptimeMillis() - lastToolbarTouch
2476
+ return elapsedMs in 0L..TOOLBAR_FOCUS_PRESERVE_MS
2477
+ }
2478
+
2479
+ private fun consumeToolbarFocusPreservationForBlur(): Boolean {
2480
+ if (!shouldPreserveFocusAfterToolbarTouch()) {
2481
+ return false
2482
+ }
2483
+ clearRecentToolbarTouch()
2484
+ return true
2485
+ }
2486
+
2487
+ internal fun markRecentToolbarTouchForTesting() {
2488
+ markRecentToolbarTouch()
2489
+ }
2490
+
2491
+ internal fun shouldPreserveFocusAfterToolbarTouchForTesting(): Boolean =
2492
+ shouldPreserveFocusAfterToolbarTouch()
2493
+
2494
+ internal fun setEditorFocusedForOutsideTapDecisionForTesting(isFocused: Boolean?) {
2495
+ editorFocusedForOutsideTapOverrideForTesting = isFocused
2496
+ }
2497
+
2498
+ internal fun setAttachedToNativeWindowForTesting(isAttached: Boolean) {
2499
+ isAttachedToNativeWindow = isAttached
2500
+ }
2501
+
2502
+ internal fun handleAttachedToWindowForTesting() {
2503
+ handleAttachedToWindow()
2504
+ }
2505
+
2506
+ internal fun traceOutsideTap(message: String) {
2507
+ onOutsideTapTraceForTesting?.invoke(message)
2508
+ }
2509
+
2510
+ internal fun handleDetachedFromWindowForTesting() {
2511
+ prepareForDetachFromWindow()
2512
+ handleDetachedFromWindow()
2513
+ }
2514
+
2515
+ internal fun performBlurForTesting(deferKeyboardDismiss: Boolean = false) {
2516
+ performBlur(deferKeyboardDismiss = deferKeyboardDismiss, allowRetry = true)
2517
+ }
2518
+
2519
+ internal fun pendingBlurRetryAttemptsForTesting(): Int = pendingBlurRetryAttempts
2520
+
2521
+ internal fun pendingDetachPreflightRetryAttemptsForTesting(): Int =
2522
+ pendingDetachPreflightRetryAttempts
2523
+
2524
+ internal fun hasPendingOutsideTapBlurForTesting(): Boolean = pendingOutsideTapBlur != null
2525
+
2526
+ internal fun isOutsideTapBlurHandlerInstalledForTesting(): Boolean = outsideTapWindow != null
2527
+
2528
+ internal fun hasPendingKeyboardDismissForTesting(): Boolean = pendingKeyboardDismiss != null
2529
+
2530
+ internal fun hasPendingPreflightWakeForTesting(): Boolean = pendingPreflightWakeScheduled
2531
+
2532
+ internal fun hasPendingToolbarRefocusForTesting(): Boolean = pendingToolbarRefocus != null
2533
+
2534
+ internal fun isKeyboardToolbarAttachedForTesting(): Boolean = keyboardToolbarView.parent != null
2535
+
2536
+ internal fun currentImeBottomForTesting(): Int = currentImeBottom
2537
+
2538
+ internal fun setCurrentImeBottomForTesting(bottom: Int) {
2539
+ currentImeBottom = bottom
2540
+ }
2541
+
2542
+ internal fun updateAttachedKeyboardToolbarForInsetsForTesting() {
2543
+ updateAttachedKeyboardToolbarForInsets()
2544
+ }
2545
+
2546
+ internal fun scheduleToolbarRefocusForTesting() {
2547
+ scheduleToolbarRefocus()
2548
+ }
2549
+
2550
+ internal fun focusFromToolbarPreserveForTesting() {
2551
+ focusInternal(cancelPendingOutsideTapBlur = false)
2552
+ }
2553
+
2554
+ internal fun applyAutoFocusForTesting() {
2555
+ applyAutoFocusIfNeeded()
2556
+ }
2557
+
2558
+ internal fun installOutsideTapBlurHandlerForTesting() {
2559
+ installOutsideTapBlurHandlerIfNeeded()
2560
+ }
2561
+
2562
+ internal fun uninstallOutsideTapBlurHandlerForTesting() {
2563
+ uninstallOutsideTapBlurHandler()
2564
+ }
2565
+
2566
+ internal fun schedulePendingPreflightWakeForTesting() {
2567
+ schedulePendingPreflightWake()
2568
+ }
2569
+
2570
+ internal fun hasPendingNativeActionForTesting(): Boolean = pendingNativeAction != null
2571
+
2572
+ internal fun pendingNativeActionRetryAttemptsForTesting(): Int = pendingNativeActionRetryAttempts
2573
+
2574
+ internal fun lastDocumentVersionForTesting(): Int? = lastDocumentVersion
2575
+
2576
+ internal fun setLastDocumentVersionForTesting(documentVersion: Int?) {
2577
+ lastDocumentVersion = documentVersion
2578
+ }
2579
+
2580
+ internal fun refreshToolbarStateFromEditorSelectionForTesting(): String? =
2581
+ refreshToolbarStateFromEditorSelection()
2582
+
2583
+ internal fun handleToolbarItemPressForTesting(item: NativeToolbarItem) {
2584
+ handleToolbarItemPress(item)
2585
+ }
2586
+
2587
+ internal fun insertMentionSuggestionForTesting(suggestion: NativeMentionSuggestion) {
2588
+ insertMentionSuggestion(suggestion)
2589
+ }
2590
+
2591
+ internal fun wakePendingPreflightWorkForTesting() {
2592
+ wakePendingPreflightWork()
2593
+ }
2594
+
2595
+ internal fun emitEditorReadyForTesting(editorUpdateRevision: Int? = null): Boolean =
2596
+ emitEditorReady(editorUpdateRevision)
2597
+
2598
+ internal fun pendingEditorUpdateJsonForTesting(): String? = pendingEditorUpdateJson
2599
+
2600
+ internal fun pendingEditorUpdateRevisionForTesting(): Int = pendingEditorUpdateRevision
2601
+
2602
+ internal fun pendingEditorResetUpdateJsonForTesting(): String? = pendingEditorResetUpdateJson
2603
+
2604
+ internal fun pendingEditorResetUpdateRevisionForTesting(): Int =
2605
+ pendingEditorResetUpdateRevision
2606
+
2607
+ internal fun setAppliedEditorUpdateRevisionForTesting(editorUpdateRevision: Int) {
2608
+ appliedEditorUpdateRevision = editorUpdateRevision
2609
+ }
2610
+
2611
+ internal fun pendingEditorUpdateEditorIdForTesting(): Long? = pendingEditorUpdateEditorId
2612
+
2613
+ internal fun pendingEditorResetUpdateEditorIdForTesting(): Long? =
2614
+ pendingEditorResetUpdateEditorId
2615
+
2616
+ internal fun pendingViewCommandUpdateJsonForTesting(): String? = pendingViewCommandUpdateJson
2617
+
2618
+ internal fun pendingViewCommandUpdateRetryAttemptsForTesting(): Int =
2619
+ pendingViewCommandUpdateRetryAttempts
2620
+
2621
+ internal fun scheduleViewCommandUpdateRetryForTesting(updateJson: String) {
2622
+ scheduleViewCommandUpdateRetry(updateJson)
2623
+ }
2624
+
2625
+ internal fun pendingThemeJsonForTesting(): String? = pendingThemeJson.takeIf { hasPendingTheme }
2626
+
2627
+ internal fun lastThemeJsonForTesting(): String? = lastThemeJson
2628
+
2629
+ internal fun pendingThemeRetryAttemptsForTesting(): Int = pendingThemeRetryAttempts
2630
+
2631
+ internal fun applyPendingThemeForTesting() {
2632
+ applyPendingThemeIfNeeded()
2633
+ }
2634
+
2635
+ private fun isTouchInsideStandaloneToolbar(event: MotionEvent): Boolean {
2636
+ val visibleWindowFrame = Rect()
2637
+ getWindowVisibleDisplayFrame(visibleWindowFrame)
2638
+ return isPointInsideStandaloneToolbar(event.rawX, event.rawY, visibleWindowFrame)
2639
+ }
2640
+
2641
+ internal fun isPointInsideStandaloneToolbarForTesting(
2642
+ rawX: Float,
2643
+ rawY: Float,
2644
+ visibleWindowFrame: Rect
2645
+ ): Boolean = isPointInsideStandaloneToolbar(rawX, rawY, visibleWindowFrame)
2646
+
2647
+ private fun isPointInsideStandaloneToolbar(
2648
+ rawX: Float,
2649
+ rawY: Float,
2650
+ visibleWindowFrame: Rect
2651
+ ): Boolean {
2652
+ if (toolbarFramesInWindow.isEmpty()) {
2653
+ return false
2654
+ }
2655
+ // toolbarFrame is in DP from React Native's measureInWindow, while
2656
+ // rawX/rawY are screen pixels. Normalize the event into the visible
2657
+ // window before comparing so shifted fallback rectangles cannot
2658
+ // preserve focus for unrelated outside taps.
2659
+ val density = resources.displayMetrics.density
2660
+ val hitSlopPx = TOOLBAR_HIT_SLOP_DP * density
2661
+ val eventX = rawX - visibleWindowFrame.left
2662
+ val eventY = rawY - visibleWindowFrame.top
2663
+ for (toolbarFrame in toolbarFramesInWindow) {
2664
+ val windowFrameInPx = RectF(
2665
+ toolbarFrame.left * density,
2666
+ toolbarFrame.top * density,
2667
+ toolbarFrame.right * density,
2668
+ toolbarFrame.bottom * density
2669
+ ).apply {
2670
+ inset(-hitSlopPx, -hitSlopPx)
2671
+ }
2672
+ if (windowFrameInPx.contains(eventX, eventY)) {
2673
+ return true
2674
+ }
2675
+ }
2676
+ return false
2677
+ }
2678
+
2679
+ private fun isTouchInsideKeyboardToolbar(event: MotionEvent): Boolean {
2680
+ if (keyboardToolbarView.parent == null || keyboardToolbarView.visibility != View.VISIBLE) {
2681
+ return false
2682
+ }
2683
+ val rect = Rect()
2684
+ keyboardToolbarView.getGlobalVisibleRect(rect)
2685
+ return rect.contains(event.rawX.toInt(), event.rawY.toInt())
2686
+ }
2687
+
2688
+ private companion object {
2689
+ private const val TOOLBAR_HIT_SLOP_DP = 8f
2690
+ private const val TOOLBAR_FOCUS_PRESERVE_MS = 750L
2691
+ private const val OUTSIDE_TAP_BLUR_DELAY_MS = 100L
2692
+ private const val OUTSIDE_TAP_HANDLER_INSTALL_RETRY_DELAY_MS = 64L
2693
+ private const val NATIVE_ACTION_RETRY_DELAY_MS = 16L
2694
+ private const val EDITOR_UPDATE_EVENT_DEBOUNCE_MS = 64L
2695
+ private const val PENDING_UPDATE_RECOVERY_RETRY_DELAY_MS = 250L
2696
+ private const val MAX_NATIVE_ACTION_RETRY_ATTEMPTS = 3
2697
+ private const val MAX_PENDING_UPDATE_RETRY_ATTEMPTS = 5
2698
+ private const val LOG_TAG = "NativeEditor"
2699
+
2700
+ private fun nanosToMicros(nanos: Long): Long = nanos / 1_000L
2701
+ }
2702
+
2703
+ private fun resolveActivity(context: Context): Activity? {
2704
+ appContext.currentActivity?.let { return it }
2705
+ var current: Context? = context
2706
+ while (current is ContextWrapper) {
2707
+ if (current is Activity) return current
2708
+ current = current.baseContext
2709
+ }
2710
+ return null
2711
+ }
2712
+
2713
+ private fun refreshMentionQuery() {
2714
+ val mentions = addons.mentions
2715
+ if (mentions == null || !richTextView.editorEditText.hasFocus()) {
2716
+ clearMentionQueryState()
2717
+ emitMentionQueryChange("", "@", 0, 0, false)
2718
+ return
2719
+ }
2720
+
2721
+ val queryState = currentMentionQueryState(mentions.trigger)
2722
+ if (queryState == null) {
2723
+ clearMentionQueryState()
2724
+ emitMentionQueryChange("", mentions.trigger, 0, 0, false)
2725
+ return
2726
+ }
2727
+
2728
+ mentionQueryState = queryState
2729
+ val suggestions = filteredMentionSuggestions(queryState, mentions)
2730
+ keyboardToolbarView.applyMentionTheme(richTextView.editorEditText.theme?.mentions ?: mentions.theme)
2731
+ syncKeyboardToolbarMentionSuggestions(suggestions)
2732
+ emitMentionQueryChange(
2733
+ queryState.query,
2734
+ queryState.trigger,
2735
+ queryState.anchor,
2736
+ queryState.head,
2737
+ true
2738
+ )
2739
+ }
2740
+
2741
+ private fun clearMentionQueryState(resetLastEvent: Boolean = false) {
2742
+ mentionQueryState = null
2743
+ if (resetLastEvent) {
2744
+ lastMentionEventJson = null
2745
+ lastMentionEventEditorId = null
2746
+ }
2747
+ syncKeyboardToolbarMentionSuggestions(emptyList())
2748
+ }
2749
+
2750
+ private fun currentMentionQueryState(trigger: String): MentionQueryState? {
2751
+ val editor = richTextView.editorEditText
2752
+ if (editor.selectionStart != editor.selectionEnd) return null
2753
+ val text = editor.text?.toString() ?: return null
2754
+ val cursorUtf16 = editor.selectionStart
2755
+ val cursorScalar = PositionBridge.utf16ToScalar(cursorUtf16, text)
2756
+ return resolveMentionQueryState(
2757
+ text = text,
2758
+ cursorScalar = cursorScalar,
2759
+ trigger = trigger,
2760
+ isCaretInsideMention = isCaretInsideMention(cursorUtf16)
2761
+ )
2762
+ }
2763
+
2764
+ private fun isCaretInsideMention(cursorUtf16: Int): Boolean {
2765
+ val editable = richTextView.editorEditText.text ?: return false
2766
+ val checkOffsets = listOf(cursorUtf16, (cursorUtf16 - 1).coerceAtLeast(0))
2767
+ return checkOffsets.any { offset ->
2768
+ editable.getSpans(offset, offset, android.text.Annotation::class.java).any { span ->
2769
+ span.key == "nativeVoidNodeType" && span.value == "mention"
2770
+ }
2771
+ }
2772
+ }
2773
+
2774
+ private fun filteredMentionSuggestions(
2775
+ queryState: MentionQueryState,
2776
+ config: NativeMentionsAddonConfig
2777
+ ): List<NativeMentionSuggestion> {
2778
+ val normalizedQuery = queryState.query.trim().lowercase()
2779
+ if (normalizedQuery.isEmpty()) return config.suggestions
2780
+ return config.suggestions.filter { suggestion ->
2781
+ suggestion.title.lowercase().contains(normalizedQuery) ||
2782
+ suggestion.label.lowercase().contains(normalizedQuery) ||
2783
+ (suggestion.subtitle?.lowercase()?.contains(normalizedQuery) == true)
2784
+ }
2785
+ }
2786
+
2787
+ private fun syncKeyboardToolbarMentionSuggestions(suggestions: List<NativeMentionSuggestion>) {
2788
+ keyboardToolbarView.setMentionSuggestions(suggestions)
2789
+ keyboardToolbarView.requestLayout()
2790
+ post {
2791
+ updateKeyboardToolbarLayout()
2792
+ updateEditorViewportInset()
2793
+ }
2794
+ }
2795
+
2796
+ private fun emitMentionQueryChange(
2797
+ query: String,
2798
+ trigger: String,
2799
+ anchor: Int,
2800
+ head: Int,
2801
+ isActive: Boolean
2802
+ ) {
2803
+ val eventJson = JSONObject()
2804
+ .put("type", "mentionsQueryChange")
2805
+ .put("query", query)
2806
+ .put("trigger", trigger)
2807
+ .put("range", JSONObject().put("anchor", anchor).put("head", head))
2808
+ .put("isActive", isActive)
2809
+ .apply {
2810
+ lastDocumentVersion?.let { put("documentVersion", it) }
2811
+ }
2812
+ .toString()
2813
+ val editorId = richTextView.editorId
2814
+ if (eventJson == lastMentionEventJson && editorId == lastMentionEventEditorId) return
2815
+ lastMentionEventJson = eventJson
2816
+ lastMentionEventEditorId = editorId
2817
+ emitAddonEvent(mapOf("eventJson" to eventJson, "editorId" to editorId))
2818
+ }
2819
+
2820
+ private fun resolvedMentionAttrs(
2821
+ trigger: String,
2822
+ suggestion: NativeMentionSuggestion
2823
+ ): JSONObject {
2824
+ val attrs = JSONObject(suggestion.attrs.toString())
2825
+ if (!attrs.has("label")) {
2826
+ attrs.put("label", suggestion.label)
2827
+ }
2828
+ if (!attrs.has("mentionSuggestionChar")) {
2829
+ attrs.put("mentionSuggestionChar", trigger)
2830
+ }
2831
+ return attrs
2832
+ }
2833
+
2834
+ private fun emitMentionSelect(trigger: String, suggestion: NativeMentionSuggestion, attrs: JSONObject) {
2835
+ val eventJson = JSONObject()
2836
+ .put("type", "mentionsSelect")
2837
+ .put("trigger", trigger)
2838
+ .put("suggestionKey", suggestion.key)
2839
+ .put("attrs", attrs)
2840
+ .apply {
2841
+ lastDocumentVersion?.let { put("documentVersion", it) }
2842
+ }
2843
+ .toString()
2844
+ emitAddonEvent(mapOf("eventJson" to eventJson, "editorId" to richTextView.editorId))
2845
+ }
2846
+
2847
+ private fun emitMentionSelectRequest(
2848
+ trigger: String,
2849
+ suggestion: NativeMentionSuggestion,
2850
+ attrs: JSONObject,
2851
+ range: MentionQueryState,
2852
+ preflightUpdateJSON: String?
2853
+ ) {
2854
+ val eventJson = JSONObject()
2855
+ .put("type", "mentionsSelectRequest")
2856
+ .put("trigger", trigger)
2857
+ .put("suggestionKey", suggestion.key)
2858
+ .put("attrs", attrs)
2859
+ .put("range", JSONObject().put("anchor", range.anchor).put("head", range.head))
2860
+ .apply {
2861
+ if (preflightUpdateJSON != null) {
2862
+ put("updateJson", preflightUpdateJSON)
2863
+ }
2864
+ (documentVersionFromUpdateJSON(preflightUpdateJSON) ?: lastDocumentVersion)
2865
+ ?.let { put("documentVersion", it) }
2866
+ }
2867
+ .toString()
2868
+ emitAddonEvent(mapOf("eventJson" to eventJson, "editorId" to richTextView.editorId))
2869
+ }
2870
+
2871
+ private fun insertMentionSuggestion(
2872
+ suggestion: NativeMentionSuggestion,
2873
+ allowPreflightRetry: Boolean = true
2874
+ ) {
2875
+ if (handleDestroyedCurrentEditorIfNeeded()) return
2876
+ if (!richTextView.editorEditText.isEditable) {
2877
+ clearPendingNativeActionRetry()
2878
+ return
2879
+ }
2880
+ val mentions = addons.mentions ?: return
2881
+ if (shouldBlockEditorCommandForPendingUpdate()) {
2882
+ if (allowPreflightRetry) {
2883
+ schedulePendingNativeActionRetry(
2884
+ PendingNativeAction.MentionSuggestionSelect(suggestion)
2885
+ )
2886
+ }
2887
+ return
2888
+ }
2889
+ val preparation = richTextView.editorEditText.prepareForExternalEditorCommand()
2890
+ if (!preparation.ready) {
2891
+ if (allowPreflightRetry) {
2892
+ schedulePendingNativeActionRetry(
2893
+ PendingNativeAction.MentionSuggestionSelect(suggestion)
2894
+ )
2895
+ }
2896
+ return
2897
+ }
2898
+ val preflightUpdateJSON = preparation.updateJSON
2899
+ noteDocumentVersionFromUpdateJSON(preflightUpdateJSON)
2900
+ clearPendingNativeActionRetry()
2901
+ val queryState = currentMentionQueryState(mentions.trigger) ?: run {
2902
+ clearMentionQueryState()
2903
+ return
2904
+ }
2905
+ val freshSuggestions = filteredMentionSuggestions(queryState, mentions)
2906
+ if (freshSuggestions.none { it.key == suggestion.key }) {
2907
+ refreshMentionQuery()
2908
+ return
2909
+ }
2910
+ mentionQueryState = queryState
2911
+ val attrs = resolvedMentionAttrs(mentions.trigger, suggestion)
2912
+ if (mentions.resolveSelectionAttrs || mentions.resolveTheme) {
2913
+ emitMentionSelectRequest(mentions.trigger, suggestion, attrs, queryState, preflightUpdateJSON)
2914
+ lastMentionEventJson = null
2915
+ clearMentionQueryState()
2916
+ return
2917
+ }
2918
+ val docJson = JSONObject()
2919
+ .put("type", "doc")
2920
+ .put(
2921
+ "content",
2922
+ JSONArray().put(
2923
+ JSONObject()
2924
+ .put("type", "mention")
2925
+ .put("attrs", attrs)
2926
+ )
2927
+ )
2928
+
2929
+ val updateJson = editorInsertContentJsonAtSelectionScalar(
2930
+ richTextView.editorId.toULong(),
2931
+ queryState.anchor.toUInt(),
2932
+ queryState.head.toUInt(),
2933
+ docJson.toString()
2934
+ )
2935
+ richTextView.editorEditText.applyUpdateJSON(updateJson)
2936
+ emitMentionSelect(mentions.trigger, suggestion, attrs)
2937
+ lastMentionEventJson = null
2938
+ clearMentionQueryState()
2939
+ }
2940
+
2941
+ private fun refreshToolbarStateFromEditorSelection(): String? {
2942
+ if (richTextView.editorId == 0L) return null
2943
+ if (handleDestroyedCurrentEditorIfNeeded()) return null
2944
+ onRefreshToolbarStateFromEditorSelectionForTesting?.let { callback ->
2945
+ val stateJson = callback()
2946
+ noteDocumentVersionFromUpdateJSON(stateJson)
2947
+ return stateJson
2948
+ }
2949
+ val stateJson = editorGetSelectionState(richTextView.editorId.toULong())
2950
+ noteDocumentVersionFromUpdateJSON(stateJson)
2951
+ val state = NativeToolbarState.fromUpdateJson(stateJson) ?: return null
2952
+ toolbarState = state
2953
+ keyboardToolbarView.applyState(state)
2954
+ return stateJson
2955
+ }
2956
+
2957
+ private fun ensureKeyboardToolbarAttached() {
2958
+ val host = resolveActivity(context)?.findViewById<ViewGroup>(android.R.id.content) ?: return
2959
+ pendingKeyboardToolbarDetachGeneration += 1
2960
+ if (keyboardToolbarView.parent === host) {
2961
+ updateKeyboardToolbarLayout()
2962
+ return
2963
+ }
2964
+ (keyboardToolbarView.parent as? ViewGroup)?.removeView(keyboardToolbarView)
2965
+ host.addView(
2966
+ keyboardToolbarView,
2967
+ FrameLayout.LayoutParams(
2968
+ FrameLayout.LayoutParams.MATCH_PARENT,
2969
+ FrameLayout.LayoutParams.WRAP_CONTENT,
2970
+ Gravity.BOTTOM or Gravity.START
2971
+ )
2972
+ )
2973
+ updateKeyboardToolbarLayout()
2974
+ ViewCompat.requestApplyInsets(keyboardToolbarView)
2975
+ }
2976
+
2977
+ private fun detachKeyboardToolbarIfNeeded() {
2978
+ pendingKeyboardToolbarDetachGeneration += 1
2979
+ val generation = pendingKeyboardToolbarDetachGeneration
2980
+ val parent = keyboardToolbarView.parent as? ViewGroup ?: return
2981
+ keyboardToolbarView.visibility = View.GONE
2982
+ parent.post {
2983
+ if (generation != pendingKeyboardToolbarDetachGeneration) return@post
2984
+ if (keyboardToolbarView.parent === parent) {
2985
+ parent.removeView(keyboardToolbarView)
2986
+ }
2987
+ }
2988
+ }
2989
+
2990
+ private fun updateKeyboardToolbarLayout() {
2991
+ val params = keyboardToolbarView.layoutParams as? FrameLayout.LayoutParams ?: return
2992
+ val toolbarTheme = richTextView.editorEditText.theme?.toolbar
2993
+ val density = resources.displayMetrics.density
2994
+ params.gravity = Gravity.BOTTOM or Gravity.START
2995
+ val horizontalInsetPx = ((toolbarTheme?.resolvedHorizontalInset() ?: 0f) * density).toInt()
2996
+ val keyboardOffsetPx = ((toolbarTheme?.resolvedKeyboardOffset() ?: 0f) * density).toInt()
2997
+ params.leftMargin = horizontalInsetPx
2998
+ params.rightMargin = horizontalInsetPx
2999
+ params.bottomMargin = currentImeBottom + keyboardOffsetPx
3000
+ keyboardToolbarView.layoutParams = params
3001
+ }
3002
+
3003
+ private fun updateAttachedKeyboardToolbarForInsets() {
3004
+ if (currentImeBottom <= 0) {
3005
+ clearPendingNativeActionRetry()
3006
+ }
3007
+ keyboardToolbarView.visibility = if (currentImeBottom > 0) View.VISIBLE else View.INVISIBLE
3008
+ updateEditorViewportInset()
3009
+ }
3010
+
3011
+ private fun updateKeyboardToolbarVisibility() {
3012
+ val shouldAttach =
3013
+ showsToolbar &&
3014
+ canFocusCurrentEditor() &&
3015
+ toolbarPlacement == ToolbarPlacement.KEYBOARD &&
3016
+ richTextView.editorEditText.isEditable &&
3017
+ richTextView.editorEditText.hasFocus()
3018
+
3019
+ if (!shouldAttach) {
3020
+ keyboardToolbarView.visibility = View.GONE
3021
+ detachKeyboardToolbarIfNeeded()
3022
+ updateEditorViewportInset()
3023
+ return
3024
+ }
3025
+
3026
+ ensureKeyboardToolbarAttached()
3027
+ keyboardToolbarView.visibility = if (currentImeBottom > 0) View.VISIBLE else View.INVISIBLE
3028
+ updateEditorViewportInset()
3029
+ }
3030
+
3031
+ private fun updateEditorViewportInset(forceMeasureToolbar: Boolean = false) {
3032
+ val shouldReserveToolbarSpace =
3033
+ heightBehavior == EditorHeightBehavior.FIXED &&
3034
+ showsToolbar &&
3035
+ toolbarPlacement == ToolbarPlacement.KEYBOARD &&
3036
+ richTextView.editorEditText.isEditable &&
3037
+ richTextView.editorEditText.hasFocus() &&
3038
+ currentImeBottom > 0
3039
+
3040
+ if (!shouldReserveToolbarSpace) {
3041
+ richTextView.setViewportBottomInsetPx(0)
3042
+ return
3043
+ }
3044
+
3045
+ val hostWidth = (resolveActivity(context)?.findViewById<ViewGroup>(android.R.id.content)?.width ?: width)
3046
+ .coerceAtLeast(0)
3047
+ val toolbarTheme = richTextView.editorEditText.theme?.toolbar
3048
+ val density = resources.displayMetrics.density
3049
+ val horizontalInsetPx = ((toolbarTheme?.resolvedHorizontalInset() ?: 0f) * density).toInt()
3050
+ if (forceMeasureToolbar || keyboardToolbarView.measuredHeight == 0) {
3051
+ val availableWidth = (hostWidth - horizontalInsetPx * 2).coerceAtLeast(0)
3052
+ val widthSpec = MeasureSpec.makeMeasureSpec(availableWidth, MeasureSpec.AT_MOST)
3053
+ val heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
3054
+ keyboardToolbarView.measure(widthSpec, heightSpec)
3055
+ }
3056
+ val toolbarHeight = keyboardToolbarView.measuredHeight.coerceAtLeast(keyboardToolbarView.height)
3057
+ richTextView.setViewportBottomInsetPx(toolbarHeight.coerceAtLeast(0))
3058
+ }
3059
+
3060
+ private fun handleListToggle(listType: String) {
3061
+ val isActive = toolbarState.nodes[listType] == true
3062
+ richTextView.editorEditText.performToolbarToggleList(listType, isActive)
3063
+ }
3064
+
3065
+ private fun handleToolbarItemPress(
3066
+ item: NativeToolbarItem,
3067
+ allowPreflightRetry: Boolean = true
3068
+ ) {
3069
+ if (handleDestroyedCurrentEditorIfNeeded()) return
3070
+ if (!richTextView.editorEditText.isEditable) {
3071
+ clearPendingNativeActionRetry()
3072
+ return
3073
+ }
3074
+ var preflightUpdateJSON: String? = null
3075
+ val needsEditorPreflight = when (item.type) {
3076
+ ToolbarItemKind.mark,
3077
+ ToolbarItemKind.heading,
3078
+ ToolbarItemKind.blockquote,
3079
+ ToolbarItemKind.list,
3080
+ ToolbarItemKind.command,
3081
+ ToolbarItemKind.node,
3082
+ ToolbarItemKind.action -> true
3083
+ ToolbarItemKind.group,
3084
+ ToolbarItemKind.separator -> false
3085
+ }
3086
+ if (needsEditorPreflight) {
3087
+ if (shouldBlockEditorCommandForPendingUpdate()) {
3088
+ if (allowPreflightRetry) {
3089
+ schedulePendingNativeActionRetry(PendingNativeAction.ToolbarItemPress(item))
3090
+ }
3091
+ return
3092
+ }
3093
+ val preparation = richTextView.editorEditText.prepareForExternalEditorCommand()
3094
+ if (!preparation.ready) {
3095
+ if (allowPreflightRetry) {
3096
+ schedulePendingNativeActionRetry(PendingNativeAction.ToolbarItemPress(item))
3097
+ }
3098
+ return
3099
+ }
3100
+ preflightUpdateJSON = preparation.updateJSON
3101
+ noteDocumentVersionFromUpdateJSON(preflightUpdateJSON)
3102
+ clearPendingNativeActionRetry()
3103
+ }
3104
+ if (handleDestroyedCurrentEditorIfNeeded()) return
3105
+ when (item.type) {
3106
+ ToolbarItemKind.mark -> item.mark?.let { richTextView.editorEditText.performToolbarToggleMark(it) }
3107
+ ToolbarItemKind.heading -> item.headingLevel?.let { richTextView.editorEditText.performToolbarToggleHeading(it) }
3108
+ ToolbarItemKind.blockquote -> richTextView.editorEditText.performToolbarToggleBlockquote()
3109
+ ToolbarItemKind.list -> item.listType?.name?.let { handleListToggle(it) }
3110
+ ToolbarItemKind.command -> when (item.command) {
3111
+ ToolbarCommand.indentList -> richTextView.editorEditText.performToolbarIndentListItem()
3112
+ ToolbarCommand.outdentList -> richTextView.editorEditText.performToolbarOutdentListItem()
3113
+ ToolbarCommand.undo -> richTextView.editorEditText.performToolbarUndo()
3114
+ ToolbarCommand.redo -> richTextView.editorEditText.performToolbarRedo()
3115
+ null -> Unit
3116
+ }
3117
+ ToolbarItemKind.node -> item.nodeType?.let { richTextView.editorEditText.performToolbarInsertNode(it) }
3118
+ ToolbarItemKind.action -> item.key?.let {
3119
+ if (handleDestroyedCurrentEditorIfNeeded()) return
3120
+ val payload = mutableMapOf<String, Any>(
3121
+ "key" to it,
3122
+ "editorId" to richTextView.editorId
3123
+ )
3124
+ addPreflightUpdateToEvent(payload, preflightUpdateJSON)
3125
+ if (!payload.containsKey("documentVersion")) {
3126
+ lastDocumentVersion?.let { version ->
3127
+ payload["documentVersion"] = version
3128
+ }
3129
+ }
3130
+ onToolbarActionForTesting?.invoke(payload) ?: onToolbarAction(payload)
3131
+ }
3132
+ ToolbarItemKind.group -> Unit
3133
+ ToolbarItemKind.separator -> Unit
3134
+ }
3135
+ }
3136
+
3137
+ }