@apollohg/react-native-prose-editor 0.5.9 → 0.5.11

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.
@@ -38,9 +38,9 @@ import uniffi.editor_core.* // UniFFI-generated bindings
38
38
  *
39
39
  * ## Composition Handling
40
40
  *
41
- * For CJK input methods, composing text is handled normally by the base
42
- * [InputConnection]. When composition finalizes, we capture the result and
43
- * route it through Rust.
41
+ * For CJK input methods, swipe keyboards, and some autocorrect flows, composing
42
+ * text is rendered transiently by the base [InputConnection]. The final commit
43
+ * is routed through Rust against the original authorized selection.
44
44
  *
45
45
  * ## Thread Safety
46
46
  *
@@ -190,6 +190,8 @@ class EditorEditText @JvmOverloads constructor(
190
190
  private var lastAppliedRenderAppearanceRevision: Long = 0L
191
191
  internal var onDeleteRangeInRustForTesting: ((Int, Int) -> Unit)? = null
192
192
  internal var onDeleteBackwardAtSelectionScalarInRustForTesting: ((Int, Int) -> Unit)? = null
193
+ internal var onInsertTextInRustForTesting: ((String, Int) -> Unit)? = null
194
+ internal var onReplaceTextInRustForTesting: ((Int, Int, String) -> Unit)? = null
193
195
 
194
196
  fun lastRenderAppliedPatch(): Boolean = lastRenderAppliedPatchForTesting
195
197
  fun lastApplyUpdateTrace(): ApplyUpdateTrace? = lastApplyUpdateTraceForTesting
@@ -549,6 +551,18 @@ class EditorEditText @JvmOverloads constructor(
549
551
  }
550
552
  }
551
553
 
554
+ internal fun caretRect(): RectF? {
555
+ val textLayout = layout ?: return null
556
+ val selectionOffset = selectionEnd.takeIf { it >= 0 } ?: return null
557
+ val clampedOffset = selectionOffset.coerceIn(0, textLayout.text.length)
558
+ val line = textLayout.getLineForOffset(clampedOffset)
559
+ val caretLeft = textLayout.getPrimaryHorizontal(clampedOffset)
560
+ val left = totalPaddingLeft + caretLeft - scrollX
561
+ val top = totalPaddingTop + textLayout.getLineTop(line) - scrollY
562
+ val bottom = totalPaddingTop + textLayout.getLineBottom(line) - scrollY
563
+ return RectF(left, top.toFloat(), left + 1f, bottom.toFloat())
564
+ }
565
+
552
566
  // ── Input Handling: Text Commit ─────────────────────────────────────
553
567
 
554
568
  /**
@@ -583,16 +597,48 @@ class EditorEditText @JvmOverloads constructor(
583
597
  // Range selection: atomic replace via Rust.
584
598
  val scalarStart = PositionBridge.utf16ToScalar(start, currentText)
585
599
  val scalarEnd = PositionBridge.utf16ToScalar(end, currentText)
586
- val updateJSON = editorReplaceTextScalar(
587
- editorId.toULong(), scalarStart.toUInt(), scalarEnd.toUInt(), text
588
- )
589
- applyUpdateJSON(updateJSON)
600
+ replaceTextRangeInRust(scalarStart, scalarEnd, text)
590
601
  } else {
591
602
  val scalarPos = PositionBridge.utf16ToScalar(start, currentText)
592
603
  insertTextInRust(text, scalarPos)
593
604
  }
594
605
  }
595
606
 
607
+ internal fun runWithTransientInputMutationGuard(block: () -> Boolean): Boolean {
608
+ val wasApplyingRustState = isApplyingRustState
609
+ isApplyingRustState = true
610
+ return try {
611
+ block()
612
+ } finally {
613
+ isApplyingRustState = wasApplyingRustState
614
+ }
615
+ }
616
+
617
+ fun handleCompositionCommit(text: String, replacementStartUtf16: Int, replacementEndUtf16: Int) {
618
+ if (!isEditable) return
619
+ if (isApplyingRustState) return
620
+ if (editorId == 0L) return
621
+
622
+ if (text == "\n") {
623
+ handleReturnKey()
624
+ return
625
+ }
626
+
627
+ val authorizedText = lastAuthorizedText
628
+ val startUtf16 = minOf(replacementStartUtf16, replacementEndUtf16)
629
+ .coerceIn(0, authorizedText.length)
630
+ val endUtf16 = maxOf(replacementStartUtf16, replacementEndUtf16)
631
+ .coerceIn(0, authorizedText.length)
632
+ val scalarStart = PositionBridge.utf16ToScalar(startUtf16, authorizedText)
633
+ val scalarEnd = PositionBridge.utf16ToScalar(endUtf16, authorizedText)
634
+
635
+ if (scalarStart != scalarEnd) {
636
+ replaceTextRangeInRust(scalarStart, scalarEnd, text)
637
+ } else {
638
+ insertTextInRust(text, scalarStart)
639
+ }
640
+ }
641
+
596
642
  // ── Input Handling: Deletion ────────────────────────────────────────
597
643
 
598
644
  /**
@@ -699,35 +745,6 @@ class EditorEditText @JvmOverloads constructor(
699
745
  }
700
746
  }
701
747
 
702
- // ── Input Handling: Composition ─────────────────────────────────────
703
-
704
- /**
705
- * Handle finalization of IME composition (CJK input, swipe keyboard).
706
- *
707
- * Called by [EditorInputConnection.finishComposingText] after the base
708
- * InputConnection has finalized the composing text.
709
- */
710
- /**
711
- * Handle finalization of IME composition.
712
- *
713
- * @param composedText The finalized composed text captured from the InputConnection.
714
- */
715
- fun handleCompositionFinished(composedText: String?) {
716
- if (!isEditable) return
717
- if (isApplyingRustState) return
718
- if (editorId == 0L) return
719
- if (composedText.isNullOrEmpty()) return
720
-
721
- // The cursor is at the end of the composed text. Calculate the insert
722
- // position as cursor - composed_length (in scalar offsets).
723
- val currentText = text?.toString() ?: ""
724
- val cursorUtf16 = selectionStart
725
- val cursorScalar = PositionBridge.utf16ToScalar(cursorUtf16, currentText)
726
- val composedScalarLen = composedText.codePointCount(0, composedText.length)
727
- val insertPos = if (cursorScalar >= composedScalarLen) cursorScalar - composedScalarLen else 0
728
- insertTextInRust(composedText, insertPos)
729
- }
730
-
731
748
  // ── Input Handling: Return Key ──────────────────────────────────────
732
749
 
733
750
  /**
@@ -1078,10 +1095,28 @@ class EditorEditText @JvmOverloads constructor(
1078
1095
  * Insert text at a scalar position via the Rust editor.
1079
1096
  */
1080
1097
  private fun insertTextInRust(text: String, atScalarPos: Int) {
1098
+ onInsertTextInRustForTesting?.let { callback ->
1099
+ callback(text, atScalarPos)
1100
+ return
1101
+ }
1081
1102
  val updateJSON = editorInsertTextScalar(editorId.toULong(), atScalarPos.toUInt(), text)
1082
1103
  applyUpdateJSON(updateJSON)
1083
1104
  }
1084
1105
 
1106
+ private fun replaceTextRangeInRust(scalarFrom: Int, scalarTo: Int, text: String) {
1107
+ onReplaceTextInRustForTesting?.let { callback ->
1108
+ callback(scalarFrom, scalarTo, text)
1109
+ return
1110
+ }
1111
+ val updateJSON = editorReplaceTextScalar(
1112
+ editorId.toULong(),
1113
+ scalarFrom.toUInt(),
1114
+ scalarTo.toUInt(),
1115
+ text
1116
+ )
1117
+ applyUpdateJSON(updateJSON)
1118
+ }
1119
+
1085
1120
  /**
1086
1121
  * Delete a scalar range via the Rust editor.
1087
1122
  *
@@ -1,6 +1,7 @@
1
1
  package com.apollohg.editor
2
2
 
3
3
  import android.view.KeyEvent
4
+ import android.view.inputmethod.BaseInputConnection
4
5
  import android.view.inputmethod.InputConnection
5
6
  import android.view.inputmethod.InputConnectionWrapper
6
7
 
@@ -16,11 +17,11 @@ import android.view.inputmethod.InputConnectionWrapper
16
17
  *
17
18
  * ## Composition (IME) Handling
18
19
  *
19
- * For CJK input methods (and swipe keyboards), [setComposingText] and
20
- * [finishComposingText] are used. During composition, we let the base [InputConnection]
21
- * handle composing text normally so the user sees their in-progress input with the
22
- * composing underline. When composition finalizes ([finishComposingText]), we capture
23
- * the result and route it through Rust.
20
+ * For CJK input methods, swipe keyboards, and some autocorrect flows, [setComposingText],
21
+ * [commitText], and [finishComposingText] are used together. During composition, we let
22
+ * the base [InputConnection] render transient composing text, but keep the original
23
+ * Rust-authorized replacement range so the final committed text lands at the correct
24
+ * document position.
24
25
  *
25
26
  * ## Key Events
26
27
  *
@@ -70,6 +71,8 @@ class EditorInputConnection(
70
71
 
71
72
  /** Tracks the current composing text for CJK/swipe input. */
72
73
  private var composingText: String? = null
74
+ private var composingReplacementStartUtf16: Int? = null
75
+ private var composingReplacementEndUtf16: Int? = null
73
76
 
74
77
  /**
75
78
  * Called when the IME commits finalized text (single character, word,
@@ -82,7 +85,26 @@ class EditorInputConnection(
82
85
  if (editorView.isApplyingRustState) {
83
86
  return super.commitText(text, newCursorPosition)
84
87
  }
85
- text?.toString()?.let { editorView.handleTextCommit(it) }
88
+ if (editorView.editorId == 0L) {
89
+ return super.commitText(text, newCursorPosition)
90
+ }
91
+
92
+ val committedText = text?.toString()
93
+ val replacementRange = trackedCompositionReplacementRange()
94
+ if (replacementRange != null && committedText != null) {
95
+ clearCompositionTracking()
96
+ editorView.runWithTransientInputMutationGuard {
97
+ super.finishComposingText()
98
+ }
99
+ editorView.handleCompositionCommit(
100
+ committedText,
101
+ replacementRange.first,
102
+ replacementRange.second
103
+ )
104
+ } else {
105
+ clearCompositionTracking()
106
+ committedText?.let { editorView.handleTextCommit(it) }
107
+ }
86
108
  return true
87
109
  }
88
110
 
@@ -99,6 +121,13 @@ class EditorInputConnection(
99
121
  if (editorView.isApplyingRustState) {
100
122
  return super.deleteSurroundingText(beforeLength, afterLength)
101
123
  }
124
+ if (trackedCompositionReplacementRange() != null) {
125
+ val result = editorView.runWithTransientInputMutationGuard {
126
+ super.deleteSurroundingText(beforeLength, afterLength)
127
+ }
128
+ refreshComposingTextFromEditable()
129
+ return result
130
+ }
102
131
  editorView.handleDelete(beforeLength, afterLength)
103
132
  return true
104
133
  }
@@ -108,6 +137,13 @@ class EditorInputConnection(
108
137
  if (editorView.isApplyingRustState) {
109
138
  return super.deleteSurroundingTextInCodePoints(beforeLength, afterLength)
110
139
  }
140
+ if (trackedCompositionReplacementRange() != null) {
141
+ val result = editorView.runWithTransientInputMutationGuard {
142
+ super.deleteSurroundingTextInCodePoints(beforeLength, afterLength)
143
+ }
144
+ refreshComposingTextFromEditable()
145
+ return result
146
+ }
111
147
 
112
148
  val currentText = editorView.text?.toString().orEmpty()
113
149
  val cursor = editorView.selectionStart.coerceAtLeast(0)
@@ -132,12 +168,27 @@ class EditorInputConnection(
132
168
  *
133
169
  * We let the base InputConnection handle this normally so the user sees
134
170
  * the composing text with its underline decoration. The text is NOT sent
135
- * to Rust during composition — only when [finishComposingText] is called.
171
+ * to Rust during composition — only when the IME commits or finishes it.
136
172
  */
137
173
  override fun setComposingText(text: CharSequence?, newCursorPosition: Int): Boolean {
138
174
  if (!editorView.isEditable) return super.setComposingText(text, newCursorPosition)
175
+ if (editorView.editorId == 0L) return super.setComposingText(text, newCursorPosition)
176
+ captureCompositionReplacementRangeIfNeeded()
139
177
  composingText = text?.toString()
140
- return super.setComposingText(text, newCursorPosition)
178
+ return editorView.runWithTransientInputMutationGuard {
179
+ super.setComposingText(text, newCursorPosition)
180
+ }
181
+ }
182
+
183
+ override fun setComposingRegion(start: Int, end: Int): Boolean {
184
+ if (!editorView.isEditable) return super.setComposingRegion(start, end)
185
+ if (editorView.editorId == 0L) return super.setComposingRegion(start, end)
186
+ val authorizedLength = editorView.text?.length ?: 0
187
+ composingReplacementStartUtf16 = minOf(start, end).coerceIn(0, authorizedLength)
188
+ composingReplacementEndUtf16 = maxOf(start, end).coerceIn(0, authorizedLength)
189
+ return editorView.runWithTransientInputMutationGuard {
190
+ super.setComposingRegion(start, end)
191
+ }
141
192
  }
142
193
 
143
194
  /**
@@ -149,22 +200,60 @@ class EditorInputConnection(
149
200
  */
150
201
  override fun finishComposingText(): Boolean {
151
202
  if (!editorView.isEditable) return super.finishComposingText()
203
+ if (editorView.editorId == 0L) return super.finishComposingText()
152
204
  val composed = composingText
153
- composingText = null
205
+ val replacementRange = trackedCompositionReplacementRange()
206
+ clearCompositionTracking()
154
207
 
155
208
  // Prevent selection sync while the base connection commits the composed
156
209
  // text, since the Rust document doesn't have it yet.
157
- editorView.isApplyingRustState = true
158
- val result = super.finishComposingText()
159
- editorView.isApplyingRustState = false
210
+ val result = editorView.runWithTransientInputMutationGuard {
211
+ super.finishComposingText()
212
+ }
160
213
 
161
214
  // Now route the composed text through Rust.
162
- if (!editorView.isApplyingRustState) {
163
- editorView.handleCompositionFinished(composed)
215
+ if (replacementRange != null && !composed.isNullOrEmpty()) {
216
+ editorView.handleCompositionCommit(
217
+ composed,
218
+ replacementRange.first,
219
+ replacementRange.second
220
+ )
164
221
  }
165
222
  return result
166
223
  }
167
224
 
225
+ private fun captureCompositionReplacementRangeIfNeeded() {
226
+ if (trackedCompositionReplacementRange() != null) return
227
+ val start = editorView.selectionStart.coerceAtLeast(0)
228
+ val end = editorView.selectionEnd.coerceAtLeast(0)
229
+ val authorizedLength = editorView.text?.length ?: 0
230
+ composingReplacementStartUtf16 = minOf(start, end).coerceIn(0, authorizedLength)
231
+ composingReplacementEndUtf16 = maxOf(start, end).coerceIn(0, authorizedLength)
232
+ }
233
+
234
+ private fun trackedCompositionReplacementRange(): Pair<Int, Int>? {
235
+ val start = composingReplacementStartUtf16 ?: return null
236
+ val end = composingReplacementEndUtf16 ?: return null
237
+ return start to end
238
+ }
239
+
240
+ private fun clearCompositionTracking() {
241
+ composingText = null
242
+ composingReplacementStartUtf16 = null
243
+ composingReplacementEndUtf16 = null
244
+ }
245
+
246
+ private fun refreshComposingTextFromEditable() {
247
+ val editable = editorView.text ?: return
248
+ val start = BaseInputConnection.getComposingSpanStart(editable)
249
+ val end = BaseInputConnection.getComposingSpanEnd(editable)
250
+ if (start < 0 || end < 0 || start > end || end > editable.length) {
251
+ composingText = null
252
+ return
253
+ }
254
+ composingText = editable.subSequence(start, end).toString()
255
+ }
256
+
168
257
  /**
169
258
  * Called for hardware keyboard key events.
170
259
  *
@@ -5,6 +5,7 @@ import android.content.Context
5
5
  import android.content.ContextWrapper
6
6
  import android.graphics.Rect
7
7
  import android.graphics.RectF
8
+ import android.os.SystemClock
8
9
  import android.view.Gravity
9
10
  import android.view.MotionEvent
10
11
  import android.view.View
@@ -61,7 +62,8 @@ class NativeEditorExpoView(
61
62
  private var lastEmittedContentHeight = 0
62
63
  private var outsideTapWindowCallback: Window.Callback? = null
63
64
  private var previousWindowCallback: Window.Callback? = null
64
- private var toolbarFrameInWindow: RectF? = null
65
+ private var toolbarFramesInWindow: List<RectF> = emptyList()
66
+ private var lastToolbarTouchUptimeMs: Long? = null
65
67
  private var addons = NativeEditorAddons(null)
66
68
  private var mentionQueryState: MentionQueryState? = null
67
69
  private var lastMentionEventJson: String? = null
@@ -91,7 +93,7 @@ class NativeEditorExpoView(
91
93
  ViewCompat.setOnApplyWindowInsetsListener(keyboardToolbarView) { _, insets ->
92
94
  currentImeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
93
95
  updateKeyboardToolbarLayout()
94
- updateKeyboardToolbarVisibility()
96
+ updateAttachedKeyboardToolbarForInsets()
95
97
  insets
96
98
  }
97
99
 
@@ -101,6 +103,12 @@ class NativeEditorExpoView(
101
103
  installOutsideTapBlurHandlerIfNeeded()
102
104
  refreshMentionQuery()
103
105
  } else {
106
+ if (shouldPreserveFocusAfterToolbarTouch()) {
107
+ richTextView.editorEditText.post {
108
+ focus()
109
+ }
110
+ return@setOnFocusChangeListener
111
+ }
104
112
  uninstallOutsideTapBlurHandler()
105
113
  clearMentionQueryState()
106
114
  }
@@ -195,23 +203,52 @@ class NativeEditorExpoView(
195
203
  if (lastToolbarFrameJson == toolbarFrameJson) return
196
204
  lastToolbarFrameJson = toolbarFrameJson
197
205
  if (toolbarFrameJson.isNullOrBlank()) {
198
- toolbarFrameInWindow = null
206
+ toolbarFramesInWindow = emptyList()
199
207
  return
200
208
  }
201
209
 
202
- toolbarFrameInWindow = try {
210
+ toolbarFramesInWindow = try {
203
211
  val json = JSONObject(toolbarFrameJson)
204
- RectF(
205
- json.optDouble("x").toFloat(),
206
- json.optDouble("y").toFloat(),
207
- (json.optDouble("x") + json.optDouble("width")).toFloat(),
208
- (json.optDouble("y") + json.optDouble("height")).toFloat()
209
- )
212
+ val frames = json.optJSONArray("frames")
213
+ if (frames != null) {
214
+ buildList {
215
+ for (index in 0 until frames.length()) {
216
+ frames.optJSONObject(index)?.toToolbarFrame()?.let { add(it) }
217
+ }
218
+ }
219
+ } else {
220
+ listOfNotNull(json.toToolbarFrame())
221
+ }
210
222
  } catch (_: Throwable) {
211
- null
223
+ emptyList()
212
224
  }
213
225
  }
214
226
 
227
+ private fun JSONObject.toToolbarFrame(): RectF? {
228
+ val x = optDouble("x", Double.NaN)
229
+ val y = optDouble("y", Double.NaN)
230
+ val width = optDouble("width", Double.NaN)
231
+ val height = optDouble("height", Double.NaN)
232
+ if (
233
+ x.isNaN() || x.isInfinite() ||
234
+ y.isNaN() || y.isInfinite() ||
235
+ width.isNaN() || width.isInfinite() ||
236
+ height.isNaN() || height.isInfinite()
237
+ ) {
238
+ return null
239
+ }
240
+ if (width <= 0.0 || height <= 0.0) {
241
+ return null
242
+ }
243
+
244
+ return RectF(
245
+ x.toFloat(),
246
+ y.toFloat(),
247
+ (x + width).toFloat(),
248
+ (y + height).toFloat()
249
+ )
250
+ }
251
+
215
252
  fun setPendingEditorUpdateJson(editorUpdateJson: String?) {
216
253
  pendingEditorUpdateJson = editorUpdateJson
217
254
  }
@@ -230,14 +267,33 @@ class NativeEditorExpoView(
230
267
 
231
268
  fun focus() {
232
269
  richTextView.editorEditText.requestFocus()
270
+ richTextView.editorEditText.post {
271
+ val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
272
+ imm?.showSoftInput(richTextView.editorEditText, InputMethodManager.SHOW_IMPLICIT)
273
+ }
233
274
  }
234
275
 
235
276
  fun blur() {
277
+ clearRecentToolbarTouch()
236
278
  richTextView.editorEditText.clearFocus()
237
279
  val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
238
280
  imm?.hideSoftInputFromWindow(richTextView.editorEditText.windowToken, 0)
239
281
  }
240
282
 
283
+ fun getCaretRectJson(): String? {
284
+ if (width <= 0 || height <= 0) return null
285
+ val rect = richTextView.caretRect() ?: return null
286
+ val density = resources.displayMetrics.density
287
+ return JSONObject()
288
+ .put("x", rect.left / density)
289
+ .put("y", rect.top / density)
290
+ .put("width", rect.width() / density)
291
+ .put("height", rect.height() / density)
292
+ .put("editorWidth", width / density)
293
+ .put("editorHeight", height / density)
294
+ .toString()
295
+ }
296
+
241
297
  override fun onDetachedFromWindow() {
242
298
  super.onDetachedFromWindow()
243
299
  uninstallOutsideTapBlurHandler()
@@ -404,24 +460,85 @@ class NativeEditorExpoView(
404
460
  if (isTouchInsideKeyboardToolbar(event)) {
405
461
  return false
406
462
  }
407
- val toolbarFrame = toolbarFrameInWindow
408
- if (toolbarFrame != null) {
409
- // toolbarFrame is in DP (from React Native's measureInWindow),
410
- // but rawX/rawY are in pixels — convert before comparing.
411
- val density = resources.displayMetrics.density
412
- val frameInPx = RectF(
463
+ if (isTouchInsideStandaloneToolbar(event)) {
464
+ markRecentToolbarTouch()
465
+ return false
466
+ }
467
+ val rect = Rect()
468
+ richTextView.editorEditText.getGlobalVisibleRect(rect)
469
+ return !rect.contains(event.rawX.toInt(), event.rawY.toInt())
470
+ }
471
+
472
+ private fun markRecentToolbarTouch() {
473
+ lastToolbarTouchUptimeMs = SystemClock.uptimeMillis()
474
+ }
475
+
476
+ private fun clearRecentToolbarTouch() {
477
+ lastToolbarTouchUptimeMs = null
478
+ }
479
+
480
+ private fun shouldPreserveFocusAfterToolbarTouch(): Boolean {
481
+ val lastToolbarTouch = lastToolbarTouchUptimeMs ?: return false
482
+ val elapsedMs = SystemClock.uptimeMillis() - lastToolbarTouch
483
+ return elapsedMs in 0L..TOOLBAR_FOCUS_PRESERVE_MS
484
+ }
485
+
486
+ internal fun markRecentToolbarTouchForTesting() {
487
+ markRecentToolbarTouch()
488
+ }
489
+
490
+ internal fun shouldPreserveFocusAfterToolbarTouchForTesting(): Boolean =
491
+ shouldPreserveFocusAfterToolbarTouch()
492
+
493
+ private fun isTouchInsideStandaloneToolbar(event: MotionEvent): Boolean {
494
+ val visibleWindowFrame = Rect()
495
+ getWindowVisibleDisplayFrame(visibleWindowFrame)
496
+ return isPointInsideStandaloneToolbar(event.rawX, event.rawY, visibleWindowFrame)
497
+ }
498
+
499
+ internal fun isPointInsideStandaloneToolbarForTesting(
500
+ rawX: Float,
501
+ rawY: Float,
502
+ visibleWindowFrame: Rect
503
+ ): Boolean = isPointInsideStandaloneToolbar(rawX, rawY, visibleWindowFrame)
504
+
505
+ private fun isPointInsideStandaloneToolbar(
506
+ rawX: Float,
507
+ rawY: Float,
508
+ visibleWindowFrame: Rect
509
+ ): Boolean {
510
+ if (toolbarFramesInWindow.isEmpty()) {
511
+ return false
512
+ }
513
+ // toolbarFrame is in DP from React Native's measureInWindow. On Android
514
+ // that is window-relative after visible-window insets are subtracted,
515
+ // while rawX/rawY are screen pixels. Fabric/newer implementations may
516
+ // differ here, so accept both window-relative and raw-screen comparisons.
517
+ val density = resources.displayMetrics.density
518
+ val hitSlopPx = TOOLBAR_HIT_SLOP_DP * density
519
+ val eventX = rawX - visibleWindowFrame.left
520
+ val eventY = rawY - visibleWindowFrame.top
521
+ for (toolbarFrame in toolbarFramesInWindow) {
522
+ val windowFrameInPx = RectF(
413
523
  toolbarFrame.left * density,
414
524
  toolbarFrame.top * density,
415
525
  toolbarFrame.right * density,
416
526
  toolbarFrame.bottom * density
417
- )
418
- if (frameInPx.contains(event.rawX, event.rawY)) {
419
- return false
527
+ ).apply {
528
+ inset(-hitSlopPx, -hitSlopPx)
529
+ }
530
+ val screenFrameInPx = RectF(windowFrameInPx).apply {
531
+ offset(visibleWindowFrame.left.toFloat(), visibleWindowFrame.top.toFloat())
532
+ }
533
+ if (
534
+ windowFrameInPx.contains(rawX, rawY) ||
535
+ windowFrameInPx.contains(eventX, eventY) ||
536
+ screenFrameInPx.contains(rawX, rawY)
537
+ ) {
538
+ return true
420
539
  }
421
540
  }
422
- val rect = Rect()
423
- richTextView.editorEditText.getGlobalVisibleRect(rect)
424
- return !rect.contains(event.rawX.toInt(), event.rawY.toInt())
541
+ return false
425
542
  }
426
543
 
427
544
  private fun isTouchInsideKeyboardToolbar(event: MotionEvent): Boolean {
@@ -433,6 +550,11 @@ class NativeEditorExpoView(
433
550
  return rect.contains(event.rawX.toInt(), event.rawY.toInt())
434
551
  }
435
552
 
553
+ private companion object {
554
+ private const val TOOLBAR_HIT_SLOP_DP = 8f
555
+ private const val TOOLBAR_FOCUS_PRESERVE_MS = 750L
556
+ }
557
+
436
558
  private fun resolveActivity(context: Context): Activity? {
437
559
  var current: Context? = context
438
560
  while (current is ContextWrapper) {
@@ -658,6 +780,11 @@ class NativeEditorExpoView(
658
780
  keyboardToolbarView.layoutParams = params
659
781
  }
660
782
 
783
+ private fun updateAttachedKeyboardToolbarForInsets() {
784
+ keyboardToolbarView.visibility = if (currentImeBottom > 0) View.VISIBLE else View.INVISIBLE
785
+ updateEditorViewportInset()
786
+ }
787
+
661
788
  private fun updateKeyboardToolbarVisibility() {
662
789
  val shouldAttach =
663
790
  showsToolbar &&
@@ -380,6 +380,10 @@ class NativeEditorModule : Module() {
380
380
  view.blur()
381
381
  }
382
382
 
383
+ AsyncFunction("getCaretRect") { view: NativeEditorExpoView ->
384
+ view.getCaretRectJson()
385
+ }
386
+
383
387
  AsyncFunction("applyEditorUpdate") { view: NativeEditorExpoView, updateJson: String ->
384
388
  view.applyEditorUpdate(updateJson)
385
389
  }
@@ -11,6 +11,7 @@ import android.view.View
11
11
  import android.view.ViewOutlineProvider
12
12
  import android.widget.HorizontalScrollView
13
13
  import android.widget.LinearLayout
14
+ import androidx.appcompat.R as AppCompatR
14
15
  import androidx.appcompat.widget.AppCompatButton
15
16
  import androidx.appcompat.widget.PopupMenu
16
17
  import androidx.appcompat.widget.AppCompatTextView
@@ -834,7 +835,7 @@ internal class EditorKeyboardToolbarView(context: Context) : HorizontalScrollVie
834
835
  0.38f
835
836
  )
836
837
  active -> theme?.buttonActiveColor ?: resolveColorAttr(
837
- MaterialR.attr.colorPrimary,
838
+ AppCompatR.attr.colorPrimary,
838
839
  android.R.attr.textColorPrimary
839
840
  )
840
841
  else -> theme?.buttonColor ?: resolveColorAttr(
@@ -301,6 +301,16 @@ class RichTextEditorView @JvmOverloads constructor(
301
301
  )
302
302
  }
303
303
 
304
+ internal fun caretRect(): RectF? {
305
+ val rect = editorEditText.caretRect() ?: return null
306
+ return RectF(
307
+ editorViewport.left + editorScrollView.left + editorEditText.left + rect.left,
308
+ editorViewport.top + editorScrollView.top + editorEditText.top + rect.top - editorScrollView.scrollY,
309
+ editorViewport.left + editorScrollView.left + editorEditText.left + rect.right,
310
+ editorViewport.top + editorScrollView.top + editorEditText.top + rect.bottom - editorScrollView.scrollY
311
+ )
312
+ }
313
+
304
314
  internal fun maximumImageWidthPx(): Float {
305
315
  val availableWidth =
306
316
  maxOf(editorEditText.width, editorEditText.measuredWidth) -
@@ -91,6 +91,18 @@ export type EditorToolbarItem = EditorToolbarLeafItem | EditorToolbarGroupItem |
91
91
  type: 'separator';
92
92
  key?: string;
93
93
  };
94
+ export interface EditorToolbarFrame {
95
+ x: number;
96
+ y: number;
97
+ width: number;
98
+ height: number;
99
+ }
100
+ export declare function isEditorToolbarFocusPreservationActive(): boolean;
101
+ export declare function useEditorToolbarFrames(): readonly EditorToolbarFrame[];
102
+ export declare function _setEditorToolbarFrameForTests(id: number, frame: EditorToolbarFrame | null): void;
103
+ export declare function _resetEditorToolbarFrameRegistryForTests(): void;
104
+ export declare function _beginEditorToolbarInteractionForTests(): void;
105
+ export declare function _endEditorToolbarInteractionForTests(): void;
94
106
  export declare const DEFAULT_EDITOR_TOOLBAR_ITEMS: readonly EditorToolbarItem[];
95
107
  export interface EditorToolbarProps {
96
108
  /** Currently active marks and nodes from the Rust engine. */
@@ -145,5 +157,10 @@ export interface EditorToolbarProps {
145
157
  theme?: EditorToolbarTheme;
146
158
  /** Whether to render the built-in top separator line. */
147
159
  showTopBorder?: boolean;
160
+ /**
161
+ * Keep NativeRichTextEditor focused when this toolbar is rendered outside
162
+ * the editor wrapper. Defaults to true.
163
+ */
164
+ preserveEditorFocus?: boolean;
148
165
  }
149
- export declare function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic, onToggleUnderline, onToggleStrike, onToggleBulletList, onToggleHeading, onToggleBlockquote, onToggleOrderedList, onIndentList, onOutdentList, onInsertHorizontalRule, onInsertLineBreak, onUndo, onRedo, onToggleMark, onToggleListType, onInsertNodeType, onRunCommand, onToolbarAction, onRequestLink, onRequestImage, toolbarItems, theme, showTopBorder, }: EditorToolbarProps): import("react/jsx-runtime").JSX.Element;
166
+ export declare function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic, onToggleUnderline, onToggleStrike, onToggleBulletList, onToggleHeading, onToggleBlockquote, onToggleOrderedList, onIndentList, onOutdentList, onInsertHorizontalRule, onInsertLineBreak, onUndo, onRedo, onToggleMark, onToggleListType, onInsertNodeType, onRunCommand, onToolbarAction, onRequestLink, onRequestImage, toolbarItems, theme, showTopBorder, preserveEditorFocus, }: EditorToolbarProps): import("react/jsx-runtime").JSX.Element;