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

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
@@ -583,16 +585,48 @@ class EditorEditText @JvmOverloads constructor(
583
585
  // Range selection: atomic replace via Rust.
584
586
  val scalarStart = PositionBridge.utf16ToScalar(start, currentText)
585
587
  val scalarEnd = PositionBridge.utf16ToScalar(end, currentText)
586
- val updateJSON = editorReplaceTextScalar(
587
- editorId.toULong(), scalarStart.toUInt(), scalarEnd.toUInt(), text
588
- )
589
- applyUpdateJSON(updateJSON)
588
+ replaceTextRangeInRust(scalarStart, scalarEnd, text)
590
589
  } else {
591
590
  val scalarPos = PositionBridge.utf16ToScalar(start, currentText)
592
591
  insertTextInRust(text, scalarPos)
593
592
  }
594
593
  }
595
594
 
595
+ internal fun runWithTransientInputMutationGuard(block: () -> Boolean): Boolean {
596
+ val wasApplyingRustState = isApplyingRustState
597
+ isApplyingRustState = true
598
+ return try {
599
+ block()
600
+ } finally {
601
+ isApplyingRustState = wasApplyingRustState
602
+ }
603
+ }
604
+
605
+ fun handleCompositionCommit(text: String, replacementStartUtf16: Int, replacementEndUtf16: Int) {
606
+ if (!isEditable) return
607
+ if (isApplyingRustState) return
608
+ if (editorId == 0L) return
609
+
610
+ if (text == "\n") {
611
+ handleReturnKey()
612
+ return
613
+ }
614
+
615
+ val authorizedText = lastAuthorizedText
616
+ val startUtf16 = minOf(replacementStartUtf16, replacementEndUtf16)
617
+ .coerceIn(0, authorizedText.length)
618
+ val endUtf16 = maxOf(replacementStartUtf16, replacementEndUtf16)
619
+ .coerceIn(0, authorizedText.length)
620
+ val scalarStart = PositionBridge.utf16ToScalar(startUtf16, authorizedText)
621
+ val scalarEnd = PositionBridge.utf16ToScalar(endUtf16, authorizedText)
622
+
623
+ if (scalarStart != scalarEnd) {
624
+ replaceTextRangeInRust(scalarStart, scalarEnd, text)
625
+ } else {
626
+ insertTextInRust(text, scalarStart)
627
+ }
628
+ }
629
+
596
630
  // ── Input Handling: Deletion ────────────────────────────────────────
597
631
 
598
632
  /**
@@ -699,35 +733,6 @@ class EditorEditText @JvmOverloads constructor(
699
733
  }
700
734
  }
701
735
 
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
736
  // ── Input Handling: Return Key ──────────────────────────────────────
732
737
 
733
738
  /**
@@ -1078,10 +1083,28 @@ class EditorEditText @JvmOverloads constructor(
1078
1083
  * Insert text at a scalar position via the Rust editor.
1079
1084
  */
1080
1085
  private fun insertTextInRust(text: String, atScalarPos: Int) {
1086
+ onInsertTextInRustForTesting?.let { callback ->
1087
+ callback(text, atScalarPos)
1088
+ return
1089
+ }
1081
1090
  val updateJSON = editorInsertTextScalar(editorId.toULong(), atScalarPos.toUInt(), text)
1082
1091
  applyUpdateJSON(updateJSON)
1083
1092
  }
1084
1093
 
1094
+ private fun replaceTextRangeInRust(scalarFrom: Int, scalarTo: Int, text: String) {
1095
+ onReplaceTextInRustForTesting?.let { callback ->
1096
+ callback(scalarFrom, scalarTo, text)
1097
+ return
1098
+ }
1099
+ val updateJSON = editorReplaceTextScalar(
1100
+ editorId.toULong(),
1101
+ scalarFrom.toUInt(),
1102
+ scalarTo.toUInt(),
1103
+ text
1104
+ )
1105
+ applyUpdateJSON(updateJSON)
1106
+ }
1107
+
1085
1108
  /**
1086
1109
  * Delete a scalar range via the Rust editor.
1087
1110
  *
@@ -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
  *
@@ -8,32 +8,32 @@
8
8
  <key>BinaryPath</key>
9
9
  <string>libeditor_core.a</string>
10
10
  <key>LibraryIdentifier</key>
11
- <string>ios-arm64</string>
11
+ <string>ios-arm64_x86_64-simulator</string>
12
12
  <key>LibraryPath</key>
13
13
  <string>libeditor_core.a</string>
14
14
  <key>SupportedArchitectures</key>
15
15
  <array>
16
16
  <string>arm64</string>
17
+ <string>x86_64</string>
17
18
  </array>
18
19
  <key>SupportedPlatform</key>
19
20
  <string>ios</string>
21
+ <key>SupportedPlatformVariant</key>
22
+ <string>simulator</string>
20
23
  </dict>
21
24
  <dict>
22
25
  <key>BinaryPath</key>
23
26
  <string>libeditor_core.a</string>
24
27
  <key>LibraryIdentifier</key>
25
- <string>ios-arm64_x86_64-simulator</string>
28
+ <string>ios-arm64</string>
26
29
  <key>LibraryPath</key>
27
30
  <string>libeditor_core.a</string>
28
31
  <key>SupportedArchitectures</key>
29
32
  <array>
30
33
  <string>arm64</string>
31
- <string>x86_64</string>
32
34
  </array>
33
35
  <key>SupportedPlatform</key>
34
36
  <string>ios</string>
35
- <key>SupportedPlatformVariant</key>
36
- <string>simulator</string>
37
37
  </dict>
38
38
  </array>
39
39
  <key>CFBundlePackageType</key>
@@ -704,7 +704,8 @@ private final class ImageResizeOverlayView: UIView {
704
704
  /// For CJK input methods, `setMarkedText` / `unmarkText` are used. During
705
705
  /// composition (marked text), we let UITextView handle it normally so the
706
706
  /// user sees their composing text. When composition finalizes (`unmarkText`),
707
- /// we capture the result and route it through Rust.
707
+ /// we commit the final text through Rust at the original Rust-authorized
708
+ /// replacement range.
708
709
  ///
709
710
  /// ## Thread Safety
710
711
  ///
@@ -970,9 +971,12 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
970
971
  private var pendingDeferredImageSelectionRange: NSRange?
971
972
  private var pendingDeferredImageSelectionGeneration: UInt64 = 0
972
973
 
973
- /// Stores the text that was composed during a marked text session,
974
- /// captured when `unmarkText` is called.
975
- private var composedText: String?
974
+ /// Stores the Rust-authorized scalar range replaced by the active marked
975
+ /// text session. UIKit mutates visible TextKit state during composition,
976
+ /// so final commits must not infer their range from the transient cursor.
977
+ private var markedTextReplacementScalarRange: (from: UInt32, to: UInt32)?
978
+ private var markedTextReplacementUtf16Range: NSRange?
979
+ private var markedTextCompositionText: String?
976
980
 
977
981
  private let editorLayoutManager: EditorLayoutManager
978
982
 
@@ -1580,6 +1584,13 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1580
1584
  return
1581
1585
  }
1582
1586
 
1587
+ if markedTextReplacementScalarRange != nil || markedTextRange != nil {
1588
+ let replacementRange = trackedMarkedTextReplacementRange()
1589
+ finishTransientMarkedTextMutation()
1590
+ commitMarkedText(text, replacementRange: replacementRange)
1591
+ return
1592
+ }
1593
+
1583
1594
  // Handle Enter/Return as a block split operation.
1584
1595
  if text == "\n" {
1585
1596
  performInterceptedInput {
@@ -1664,6 +1675,15 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1664
1675
  return
1665
1676
  }
1666
1677
 
1678
+ if markedTextReplacementScalarRange != nil || markedTextRange != nil {
1679
+ performTransientTextMutation {
1680
+ super.deleteBackward()
1681
+ }
1682
+ refreshMarkedTextCompositionText()
1683
+ isComposing = markedTextRange != nil || markedTextReplacementScalarRange != nil
1684
+ return
1685
+ }
1686
+
1667
1687
  guard let selectedRange = selectedTextRange else { return }
1668
1688
  Self.inputLog.debug(
1669
1689
  "[deleteBackward] selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
@@ -1858,6 +1878,13 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1858
1878
  return
1859
1879
  }
1860
1880
 
1881
+ if markedTextReplacementScalarRange != nil || markedTextRange != nil {
1882
+ let replacementRange = trackedMarkedTextReplacementRange()
1883
+ finishTransientMarkedTextMutation()
1884
+ commitMarkedText(text, replacementRange: replacementRange)
1885
+ return
1886
+ }
1887
+
1861
1888
  let scalarRange = PositionBridge.textRangeToScalarRange(range, in: self)
1862
1889
  Self.inputLog.debug(
1863
1890
  "[replace] text=\(self.preview(text), privacy: .public) scalarRange=\(scalarRange.from)-\(scalarRange.to) selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
@@ -1884,46 +1911,178 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1884
1911
  /// decoration). The text is NOT sent to Rust during composition.
1885
1912
  override func setMarkedText(_ markedText: String?, selectedRange: NSRange) {
1886
1913
  ensureInternalTextViewDelegate()
1887
- isComposing = true
1914
+ if markedText != nil {
1915
+ captureMarkedTextReplacementRangeIfNeeded()
1916
+ }
1917
+ isComposing = markedText != nil || markedTextReplacementScalarRange != nil
1888
1918
  Self.inputLog.debug(
1889
1919
  "[setMarkedText] marked=\(self.preview(markedText ?? ""), privacy: .public) nsRange=\(selectedRange.location),\(selectedRange.length) selection=\(self.selectionSummary(), privacy: .public)"
1890
1920
  )
1891
- super.setMarkedText(markedText, selectedRange: selectedRange)
1921
+ performTransientTextMutation {
1922
+ super.setMarkedText(markedText, selectedRange: selectedRange)
1923
+ }
1924
+ if markedText == nil {
1925
+ clearMarkedTextTracking()
1926
+ restoreAuthorizedTextAfterCancelledCompositionIfNeeded()
1927
+ } else {
1928
+ refreshMarkedTextCompositionText(fallback: markedText)
1929
+ }
1892
1930
  }
1893
1931
 
1894
1932
  /// Called when composition is finalized (user selects a candidate or
1895
1933
  /// presses space/enter to commit).
1896
1934
  ///
1897
- /// At this point, the composed text is final. We capture it and send
1898
- /// it to Rust as a single insertion. `unmarkText` in UITextView will
1899
- /// replace the marked text with the final text in the text storage,
1900
- /// but we intercept at a higher level.
1935
+ /// At this point, the composed text is final. We capture it and commit it
1936
+ /// to Rust at the original replacement range captured before UIKit mutated
1937
+ /// the transient text storage.
1901
1938
  override func unmarkText() {
1902
1939
  ensureInternalTextViewDelegate()
1903
- // Capture the finalized composed text before UIKit clears it.
1904
- composedText = markedTextRange.flatMap { text(in: $0) }
1940
+ let composedText = currentMarkedTextForCommit()
1941
+ let replacementRange = trackedMarkedTextReplacementRange()
1905
1942
 
1906
- // Prevent selection sync while UIKit commits the marked text, since
1907
- // the Rust document doesn't have the composed text yet.
1908
- isApplyingRustState = true
1909
- super.unmarkText()
1910
- isApplyingRustState = false
1911
- isComposing = false
1943
+ finishTransientMarkedTextMutation()
1912
1944
 
1913
- // Now route the composed text through Rust. The cursor is at the end
1914
- // of the composed text, so the insert position is cursor - length.
1915
- if let composed = composedText, !composed.isEmpty, editorId != 0 {
1916
- let cursorPos = PositionBridge.cursorScalarOffset(in: self)
1917
- let composedScalars = UInt32(composed.unicodeScalars.count)
1918
- let insertPos = cursorPos >= composedScalars ? cursorPos - composedScalars : 0
1945
+ if let composed = composedText, !composed.isEmpty {
1919
1946
  Self.inputLog.debug(
1920
- "[unmarkText] composed=\(self.preview(composed), privacy: .public) cursorPos=\(cursorPos) insertPos=\(insertPos) selection=\(self.selectionSummary(), privacy: .public)"
1947
+ "[unmarkText] composed=\(self.preview(composed), privacy: .public) replacement=\(self.previewMarkedTextReplacementRange(replacementRange), privacy: .public) selection=\(self.selectionSummary(), privacy: .public)"
1948
+ )
1949
+ commitMarkedText(composed, replacementRange: replacementRange)
1950
+ } else {
1951
+ restoreAuthorizedTextAfterCancelledCompositionIfNeeded()
1952
+ }
1953
+ }
1954
+
1955
+ private func captureMarkedTextReplacementRangeIfNeeded() {
1956
+ guard markedTextReplacementScalarRange == nil else { return }
1957
+
1958
+ guard let selectedRange = selectedTextRange else {
1959
+ let scalarPos = PositionBridge.cursorScalarOffset(in: self)
1960
+ markedTextReplacementScalarRange = (from: scalarPos, to: scalarPos)
1961
+ markedTextReplacementUtf16Range = NSRange(
1962
+ location: Int(scalarPos),
1963
+ length: 0
1921
1964
  )
1965
+ return
1966
+ }
1967
+
1968
+ let scalarRange = PositionBridge.textRangeToScalarRange(selectedRange, in: self)
1969
+ let startUtf16 = offset(from: beginningOfDocument, to: selectedRange.start)
1970
+ let endUtf16 = offset(from: beginningOfDocument, to: selectedRange.end)
1971
+
1972
+ markedTextReplacementScalarRange = (from: scalarRange.from, to: scalarRange.to)
1973
+ markedTextReplacementUtf16Range = NSRange(
1974
+ location: min(startUtf16, endUtf16),
1975
+ length: abs(endUtf16 - startUtf16)
1976
+ )
1977
+ }
1978
+
1979
+ private func trackedMarkedTextReplacementRange() -> (from: UInt32, to: UInt32)? {
1980
+ if let markedTextReplacementScalarRange {
1981
+ return markedTextReplacementScalarRange
1982
+ }
1983
+ guard let selectedRange = selectedTextRange else { return nil }
1984
+ let scalarRange = PositionBridge.textRangeToScalarRange(selectedRange, in: self)
1985
+ return (from: scalarRange.from, to: scalarRange.to)
1986
+ }
1987
+
1988
+ private func clearMarkedTextTracking() {
1989
+ markedTextReplacementScalarRange = nil
1990
+ markedTextReplacementUtf16Range = nil
1991
+ markedTextCompositionText = nil
1992
+ isComposing = false
1993
+ }
1994
+
1995
+ private func finishTransientMarkedTextMutation() {
1996
+ performTransientTextMutation {
1997
+ super.unmarkText()
1998
+ }
1999
+ clearMarkedTextTracking()
2000
+ }
2001
+
2002
+ private func performTransientTextMutation(_ action: () -> Void) {
2003
+ let wasApplyingRustState = isApplyingRustState
2004
+ isApplyingRustState = true
2005
+ action()
2006
+ isApplyingRustState = wasApplyingRustState
2007
+ }
2008
+
2009
+ private func currentMarkedTextForCommit() -> String? {
2010
+ markedTextRange.flatMap { text(in: $0) }
2011
+ ?? markedTextCompositionText
2012
+ ?? transientMarkedTextFromAuthorizedDiff()
2013
+ }
2014
+
2015
+ private func refreshMarkedTextCompositionText(fallback: String? = nil) {
2016
+ markedTextCompositionText = markedTextRange.flatMap { text(in: $0) }
2017
+ ?? transientMarkedTextFromAuthorizedDiff()
2018
+ ?? fallback
2019
+ }
2020
+
2021
+ private func transientMarkedTextFromAuthorizedDiff() -> String? {
2022
+ guard let replacementRange = markedTextReplacementUtf16Range else { return nil }
2023
+
2024
+ let currentText = textStorage.string as NSString
2025
+ let authorizedText = lastAuthorizedText as NSString
2026
+ let replacementEnd = replacementRange.location + replacementRange.length
2027
+ guard replacementRange.location >= 0,
2028
+ replacementEnd <= authorizedText.length
2029
+ else {
2030
+ return nil
2031
+ }
2032
+
2033
+ let insertedLength = currentText.length - (authorizedText.length - replacementRange.length)
2034
+ guard insertedLength >= 0,
2035
+ replacementRange.location + insertedLength <= currentText.length
2036
+ else {
2037
+ return nil
2038
+ }
2039
+
2040
+ return currentText.substring(
2041
+ with: NSRange(location: replacementRange.location, length: insertedLength)
2042
+ )
2043
+ }
2044
+
2045
+ private func commitMarkedText(
2046
+ _ text: String,
2047
+ replacementRange: (from: UInt32, to: UInt32)?
2048
+ ) {
2049
+ guard editorId != 0 else { return }
2050
+ guard let replacementRange else {
1922
2051
  performInterceptedInput {
1923
- insertTextInRust(composed, at: insertPos)
2052
+ insertTextInRust(text, at: PositionBridge.cursorScalarOffset(in: self))
1924
2053
  }
2054
+ return
1925
2055
  }
1926
- composedText = nil
2056
+
2057
+ performInterceptedInput {
2058
+ if replacementRange.from == replacementRange.to {
2059
+ insertTextInRust(text, at: replacementRange.from)
2060
+ } else {
2061
+ replaceTextRangeInRust(
2062
+ from: replacementRange.from,
2063
+ to: replacementRange.to,
2064
+ with: text
2065
+ )
2066
+ }
2067
+ }
2068
+ }
2069
+
2070
+ private func restoreAuthorizedTextAfterCancelledCompositionIfNeeded() {
2071
+ guard editorId != 0 else { return }
2072
+ guard textStorage.string != lastAuthorizedText else { return }
2073
+
2074
+ let stateJSON = editorGetCurrentState(id: editorId)
2075
+ applyUpdateJSON(stateJSON)
2076
+ }
2077
+
2078
+ private func previewMarkedTextReplacementRange(
2079
+ _ range: (from: UInt32, to: UInt32)?
2080
+ ) -> String {
2081
+ guard let range else { return "none" }
2082
+ let utf16 = markedTextReplacementUtf16Range
2083
+ .map { "\($0.location)..<\($0.location + $0.length)" }
2084
+ ?? "none"
2085
+ return "scalar=\(range.from)..<\(range.to) utf16=\(utf16)"
1927
2086
  }
1928
2087
 
1929
2088
  // MARK: - Paste Handling
@@ -1993,7 +2152,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1993
2152
  func textViewDidChangeSelection(_ textView: UITextView) {
1994
2153
  guard textView === self else { return }
1995
2154
  ensureInternalTextViewDelegate()
1996
- guard !isApplyingRustState else { return }
2155
+ guard !isApplyingRustState, !isComposing else { return }
1997
2156
  if normalizeSelectionForEmptyBlockAutocapitalizationIfNeeded() {
1998
2157
  return
1999
2158
  }
@@ -2174,7 +2333,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2174
2333
  }
2175
2334
 
2176
2335
  private func syncSelectionToRustAndNotifyDelegate() {
2177
- guard !isApplyingRustState, editorId != 0 else { return }
2336
+ guard !isApplyingRustState, !isComposing, editorId != 0 else { return }
2178
2337
  guard let range = selectedTextRange else { return }
2179
2338
 
2180
2339
  let anchor = PositionBridge.textViewToScalar(range.start, in: self)
@@ -2768,6 +2927,19 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2768
2927
  applyUpdateJSON(updateJSON)
2769
2928
  }
2770
2929
 
2930
+ private func replaceTextRangeInRust(from: UInt32, to: UInt32, with text: String) {
2931
+ Self.inputLog.debug(
2932
+ "[rust.replaceTextScalar] text=\(self.preview(text), privacy: .public) scalar=\(from)-\(to) selection=\(self.selectionSummary(), privacy: .public)"
2933
+ )
2934
+ let updateJSON = editorReplaceTextScalar(
2935
+ id: editorId,
2936
+ scalarFrom: from,
2937
+ scalarTo: to,
2938
+ text: text
2939
+ )
2940
+ applyUpdateJSON(updateJSON)
2941
+ }
2942
+
2771
2943
  private func insertNodeInRust(_ nodeType: String) {
2772
2944
  guard let selection = currentScalarSelection() else { return }
2773
2945
  Self.inputLog.debug(
@@ -4205,7 +4377,7 @@ extension EditorTextView: NSTextStorageDelegate {
4205
4377
  guard editedMask.contains(.editedCharacters) else { return }
4206
4378
 
4207
4379
  // Skip if this change came from our own Rust apply path.
4208
- guard !isApplyingRustState, !isInterceptingInput else { return }
4380
+ guard !isApplyingRustState, !isInterceptingInput, !isComposing else { return }
4209
4381
 
4210
4382
  // Skip if no editor is bound yet (nothing to reconcile against).
4211
4383
  guard editorId != 0 else { return }
@@ -4247,7 +4419,7 @@ extension EditorTextView: NSTextStorageDelegate {
4247
4419
  guard let self else { return }
4248
4420
  self.reconciliationWorkScheduled = false
4249
4421
 
4250
- guard !self.isApplyingRustState, !self.isInterceptingInput else { return }
4422
+ guard !self.isApplyingRustState, !self.isInterceptingInput, !self.isComposing else { return }
4251
4423
  guard self.editorId != 0 else { return }
4252
4424
  guard self.textStorage.string != self.lastAuthorizedText else { return }
4253
4425
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apollohg/react-native-prose-editor",
3
- "version": "0.5.9",
3
+ "version": "0.5.10",
4
4
  "description": "Native rich text editor with Rust core for React Native",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/apollohg/react-native-prose-editor",