@apollohg/react-native-prose-editor 0.5.8 → 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
  *
@@ -362,6 +362,10 @@ private fun parseColor(raw: String?): Int? {
362
362
  val value = raw?.trim()?.lowercase() ?: return null
363
363
  if (value.isEmpty()) return null
364
364
 
365
+ when (value) {
366
+ "clear", "transparent" -> return Color.TRANSPARENT
367
+ }
368
+
365
369
  parseCssHexColor(value)?.let { return it }
366
370
 
367
371
  try {
@@ -54,6 +54,7 @@ class RichTextEditorView @JvmOverloads constructor(
54
54
  private var baseBackgroundColor: Int = Color.WHITE
55
55
  private var viewportBottomInsetPx: Int = 0
56
56
  internal var appliedCornerRadiusPx: Float = 0f
57
+ internal var appliedBackgroundColorForTesting: Int = Color.WHITE
57
58
 
58
59
  /** Binds or unbinds the Rust editor instance. */
59
60
  var editorId: Long = 0
@@ -251,13 +252,15 @@ class RichTextEditorView @JvmOverloads constructor(
251
252
 
252
253
  private fun updateScrollContainerAppearance() {
253
254
  val cornerRadiusPx = (theme?.borderRadius ?: 0f) * resources.displayMetrics.density
255
+ val backgroundColor = theme?.backgroundColor ?: baseBackgroundColor
254
256
  editorViewport.background = GradientDrawable().apply {
255
257
  cornerRadius = cornerRadiusPx
256
- setColor(theme?.backgroundColor ?: baseBackgroundColor)
258
+ setColor(backgroundColor)
257
259
  }
258
260
  editorViewport.clipToOutline = cornerRadiusPx > 0f
259
261
  editorScrollView.setBackgroundColor(Color.TRANSPARENT)
260
262
  appliedCornerRadiusPx = cornerRadiusPx
263
+ appliedBackgroundColorForTesting = backgroundColor
261
264
  }
262
265
 
263
266
  private fun updateScrollContainerInsets() {
@@ -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>
@@ -1576,6 +1576,46 @@ final class EditorAccessoryToolbarView: UIInputView {
1576
1576
  }
1577
1577
  }
1578
1578
 
1579
+ /// Keeps iOS keyboard integrations on the inputAccessoryView path when the
1580
+ /// visible toolbar is rendered outside the native keyboard accessory.
1581
+ final class EditorAccessoryPlaceholderView: UIView {
1582
+ override init(frame: CGRect) {
1583
+ super.init(
1584
+ frame: CGRect(
1585
+ x: frame.origin.x,
1586
+ y: frame.origin.y,
1587
+ width: frame.width,
1588
+ height: 0
1589
+ )
1590
+ )
1591
+ commonInit()
1592
+ }
1593
+
1594
+ required init?(coder: NSCoder) {
1595
+ return nil
1596
+ }
1597
+
1598
+ override var intrinsicContentSize: CGSize {
1599
+ CGSize(width: UIView.noIntrinsicMetric, height: 0)
1600
+ }
1601
+
1602
+ override func sizeThatFits(_ size: CGSize) -> CGSize {
1603
+ CGSize(width: size.width, height: 0)
1604
+ }
1605
+
1606
+ override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
1607
+ false
1608
+ }
1609
+
1610
+ private func commonInit() {
1611
+ frame.size.height = 0
1612
+ backgroundColor = .clear
1613
+ isOpaque = false
1614
+ isUserInteractionEnabled = false
1615
+ autoresizingMask = [.flexibleWidth]
1616
+ }
1617
+ }
1618
+
1579
1619
  class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognizerDelegate {
1580
1620
 
1581
1621
  // MARK: - Subviews
@@ -1585,6 +1625,7 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1585
1625
  frame: .zero,
1586
1626
  inputViewStyle: .keyboard
1587
1627
  )
1628
+ private let accessoryPlaceholder = EditorAccessoryPlaceholderView(frame: .zero)
1588
1629
  private var toolbarFrameInWindow: CGRect?
1589
1630
  private var didApplyAutoFocus = false
1590
1631
  private var toolbarState = NativeToolbarState.empty
@@ -2269,14 +2310,33 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
2269
2310
  func triggerMentionSuggestionTapForTesting(at index: Int) {
2270
2311
  accessoryToolbar.triggerMentionSuggestionTapForTesting(at: index)
2271
2312
  }
2313
+
2314
+ func inputAccessoryViewForTesting() -> UIView? {
2315
+ richTextView.textView.inputAccessoryView
2316
+ }
2317
+
2318
+ func isUsingAccessoryToolbarForTesting() -> Bool {
2319
+ richTextView.textView.inputAccessoryView === accessoryToolbar
2320
+ }
2321
+
2322
+ func isUsingAccessoryPlaceholderForTesting() -> Bool {
2323
+ richTextView.textView.inputAccessoryView === accessoryPlaceholder
2324
+ }
2325
+
2272
2326
  private func updateAccessoryToolbarVisibility() {
2273
2327
  refreshSystemAssistantToolbarIfNeeded()
2274
- let nextAccessoryView: UIView? = showsToolbar &&
2328
+ let nextAccessoryView: UIView?
2329
+ if showsToolbar &&
2275
2330
  toolbarPlacement == "keyboard" &&
2276
2331
  richTextView.textView.isEditable &&
2277
2332
  !shouldUseSystemAssistantToolbar
2278
- ? accessoryToolbar
2279
- : nil
2333
+ {
2334
+ nextAccessoryView = accessoryToolbar
2335
+ } else if richTextView.textView.isEditable && !shouldUseSystemAssistantToolbar {
2336
+ nextAccessoryView = accessoryPlaceholder
2337
+ } else {
2338
+ nextAccessoryView = nil
2339
+ }
2280
2340
  if richTextView.textView.inputAccessoryView !== nextAccessoryView {
2281
2341
  richTextView.textView.inputAccessoryView = nextAccessoryView
2282
2342
  if richTextView.textView.isFirstResponder {
@@ -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
 
@@ -1050,7 +1054,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1050
1054
  // Register as the text storage delegate so we can detect unauthorized
1051
1055
  // mutations (reconciliation fallback).
1052
1056
  textStorage.delegate = self
1053
- delegate = self
1057
+ ensureInternalTextViewDelegate()
1054
1058
  addGestureRecognizer(imageSelectionTapRecognizer)
1055
1059
  installImageSelectionTapDependencies()
1056
1060
 
@@ -1161,6 +1165,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1161
1165
  override func becomeFirstResponder() -> Bool {
1162
1166
  let didBecomeFirstResponder = super.becomeFirstResponder()
1163
1167
  if didBecomeFirstResponder {
1168
+ ensureInternalTextViewDelegate()
1169
+ DispatchQueue.main.async { [weak self] in
1170
+ self?.ensureInternalTextViewDelegate()
1171
+ }
1164
1172
  _ = normalizeSelectionForEmptyBlockAutocapitalizationIfNeeded()
1165
1173
  refreshTypingAttributesForSelection()
1166
1174
  }
@@ -1340,6 +1348,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1340
1348
  lastApplyUpdateTraceForTesting
1341
1349
  }
1342
1350
 
1351
+ func isUsingInternalTextViewDelegateForTesting() -> Bool {
1352
+ (delegate as AnyObject?) === (self as AnyObject)
1353
+ }
1354
+
1343
1355
  func blockquoteStripeRectsForTesting() -> [CGRect] {
1344
1356
  editorLayoutManager.blockquoteStripeRectsForTesting(in: textStorage)
1345
1357
  }
@@ -1533,6 +1545,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1533
1545
  /// - id: The editor ID from `editor_create()`.
1534
1546
  /// - initialHTML: Optional HTML to set as initial content.
1535
1547
  func bindEditor(id: UInt64, initialHTML: String? = nil) {
1548
+ ensureInternalTextViewDelegate()
1536
1549
  editorId = id
1537
1550
 
1538
1551
  if let html = initialHTML, !html.isEmpty {
@@ -1561,6 +1574,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1561
1574
  /// Instead of calling `super.insertText()` (which would modify the
1562
1575
  /// underlying text storage directly), we route through Rust.
1563
1576
  override func insertText(_ text: String) {
1577
+ ensureInternalTextViewDelegate()
1564
1578
  guard !isApplyingRustState else {
1565
1579
  super.insertText(text)
1566
1580
  return
@@ -1570,6 +1584,13 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1570
1584
  return
1571
1585
  }
1572
1586
 
1587
+ if markedTextReplacementScalarRange != nil || markedTextRange != nil {
1588
+ let replacementRange = trackedMarkedTextReplacementRange()
1589
+ finishTransientMarkedTextMutation()
1590
+ commitMarkedText(text, replacementRange: replacementRange)
1591
+ return
1592
+ }
1593
+
1573
1594
  // Handle Enter/Return as a block split operation.
1574
1595
  if text == "\n" {
1575
1596
  performInterceptedInput {
@@ -1644,6 +1665,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1644
1665
  /// If there's a range selection, delete the range. If it's a cursor,
1645
1666
  /// delete the character (grapheme cluster) before the cursor.
1646
1667
  override func deleteBackward() {
1668
+ ensureInternalTextViewDelegate()
1647
1669
  guard !isApplyingRustState else {
1648
1670
  super.deleteBackward()
1649
1671
  return
@@ -1653,6 +1675,15 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1653
1675
  return
1654
1676
  }
1655
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
+
1656
1687
  guard let selectedRange = selectedTextRange else { return }
1657
1688
  Self.inputLog.debug(
1658
1689
  "[deleteBackward] selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
@@ -1837,6 +1868,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1837
1868
  ///
1838
1869
  /// We route the replacement through Rust to keep the document model in sync.
1839
1870
  override func replace(_ range: UITextRange, withText text: String) {
1871
+ ensureInternalTextViewDelegate()
1840
1872
  guard !isApplyingRustState else {
1841
1873
  super.replace(range, withText: text)
1842
1874
  return
@@ -1846,6 +1878,13 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1846
1878
  return
1847
1879
  }
1848
1880
 
1881
+ if markedTextReplacementScalarRange != nil || markedTextRange != nil {
1882
+ let replacementRange = trackedMarkedTextReplacementRange()
1883
+ finishTransientMarkedTextMutation()
1884
+ commitMarkedText(text, replacementRange: replacementRange)
1885
+ return
1886
+ }
1887
+
1849
1888
  let scalarRange = PositionBridge.textRangeToScalarRange(range, in: self)
1850
1889
  Self.inputLog.debug(
1851
1890
  "[replace] text=\(self.preview(text), privacy: .public) scalarRange=\(scalarRange.from)-\(scalarRange.to) selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
@@ -1871,45 +1910,179 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1871
1910
  /// UITextView display the composing text normally (with its underline
1872
1911
  /// decoration). The text is NOT sent to Rust during composition.
1873
1912
  override func setMarkedText(_ markedText: String?, selectedRange: NSRange) {
1874
- isComposing = true
1913
+ ensureInternalTextViewDelegate()
1914
+ if markedText != nil {
1915
+ captureMarkedTextReplacementRangeIfNeeded()
1916
+ }
1917
+ isComposing = markedText != nil || markedTextReplacementScalarRange != nil
1875
1918
  Self.inputLog.debug(
1876
1919
  "[setMarkedText] marked=\(self.preview(markedText ?? ""), privacy: .public) nsRange=\(selectedRange.location),\(selectedRange.length) selection=\(self.selectionSummary(), privacy: .public)"
1877
1920
  )
1878
- 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
+ }
1879
1930
  }
1880
1931
 
1881
1932
  /// Called when composition is finalized (user selects a candidate or
1882
1933
  /// presses space/enter to commit).
1883
1934
  ///
1884
- /// At this point, the composed text is final. We capture it and send
1885
- /// it to Rust as a single insertion. `unmarkText` in UITextView will
1886
- /// replace the marked text with the final text in the text storage,
1887
- /// 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.
1888
1938
  override func unmarkText() {
1889
- // Capture the finalized composed text before UIKit clears it.
1890
- composedText = markedTextRange.flatMap { text(in: $0) }
1939
+ ensureInternalTextViewDelegate()
1940
+ let composedText = currentMarkedTextForCommit()
1941
+ let replacementRange = trackedMarkedTextReplacementRange()
1891
1942
 
1892
- // Prevent selection sync while UIKit commits the marked text, since
1893
- // the Rust document doesn't have the composed text yet.
1894
- isApplyingRustState = true
1895
- super.unmarkText()
1896
- isApplyingRustState = false
1897
- isComposing = false
1943
+ finishTransientMarkedTextMutation()
1898
1944
 
1899
- // Now route the composed text through Rust. The cursor is at the end
1900
- // of the composed text, so the insert position is cursor - length.
1901
- if let composed = composedText, !composed.isEmpty, editorId != 0 {
1902
- let cursorPos = PositionBridge.cursorScalarOffset(in: self)
1903
- let composedScalars = UInt32(composed.unicodeScalars.count)
1904
- let insertPos = cursorPos >= composedScalars ? cursorPos - composedScalars : 0
1945
+ if let composed = composedText, !composed.isEmpty {
1905
1946
  Self.inputLog.debug(
1906
- "[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)"
1907
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
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 {
1908
2051
  performInterceptedInput {
1909
- insertTextInRust(composed, at: insertPos)
2052
+ insertTextInRust(text, at: PositionBridge.cursorScalarOffset(in: self))
2053
+ }
2054
+ return
2055
+ }
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
+ )
1910
2066
  }
1911
2067
  }
1912
- composedText = nil
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)"
1913
2086
  }
1914
2087
 
1915
2088
  // MARK: - Paste Handling
@@ -1919,6 +2092,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1919
2092
  /// Attempts to extract HTML from the pasteboard first (for rich text paste),
1920
2093
  /// falling back to plain text.
1921
2094
  override func paste(_ sender: Any?) {
2095
+ ensureInternalTextViewDelegate()
1922
2096
  guard editorId != 0 else {
1923
2097
  super.paste(sender)
1924
2098
  return
@@ -1977,7 +2151,8 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1977
2151
  /// internally during tap handling and word-boundary resolution.
1978
2152
  func textViewDidChangeSelection(_ textView: UITextView) {
1979
2153
  guard textView === self else { return }
1980
- guard !isApplyingRustState else { return }
2154
+ ensureInternalTextViewDelegate()
2155
+ guard !isApplyingRustState, !isComposing else { return }
1981
2156
  if normalizeSelectionForEmptyBlockAutocapitalizationIfNeeded() {
1982
2157
  return
1983
2158
  }
@@ -2029,6 +2204,14 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2029
2204
  interceptedInputDepth > 0
2030
2205
  }
2031
2206
 
2207
+ private func ensureInternalTextViewDelegate() {
2208
+ // Some keyboard integrations replace UITextView's private delegate ivar
2209
+ // directly. The editor must own delegate callbacks so external observers
2210
+ // cannot inspect transient TextKit state during Rust-driven edits.
2211
+ guard (delegate as AnyObject?) !== (self as AnyObject) else { return }
2212
+ delegate = self
2213
+ }
2214
+
2032
2215
  private func performInterceptedInput(_ action: () -> Void) {
2033
2216
  interceptedInputDepth += 1
2034
2217
  Self.inputLog.debug(
@@ -2150,7 +2333,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2150
2333
  }
2151
2334
 
2152
2335
  private func syncSelectionToRustAndNotifyDelegate() {
2153
- guard !isApplyingRustState, editorId != 0 else { return }
2336
+ guard !isApplyingRustState, !isComposing, editorId != 0 else { return }
2154
2337
  guard let range = selectedTextRange else { return }
2155
2338
 
2156
2339
  let anchor = PositionBridge.textViewToScalar(range.start, in: self)
@@ -2744,6 +2927,19 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2744
2927
  applyUpdateJSON(updateJSON)
2745
2928
  }
2746
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
+
2747
2943
  private func insertNodeInRust(_ nodeType: String) {
2748
2944
  guard let selection = currentScalarSelection() else { return }
2749
2945
  Self.inputLog.debug(
@@ -3236,12 +3432,11 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3236
3432
  var stringMutationNanos: UInt64 = 0
3237
3433
  var attributeMutationNanos: UInt64 = 0
3238
3434
  let previousTextStorageDelegate = textStorage.delegate
3239
- let previousTextViewDelegate = delegate
3240
3435
  textStorage.delegate = nil
3241
3436
  delegate = nil
3242
3437
  defer {
3243
3438
  textStorage.delegate = previousTextStorageDelegate
3244
- delegate = previousTextViewDelegate
3439
+ ensureInternalTextViewDelegate()
3245
3440
  }
3246
3441
  if let replaceRange {
3247
3442
  if shouldUseSmallPatchTextMutation {
@@ -3810,6 +4005,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3810
4005
  ///
3811
4006
  /// - Parameter updateJSON: The JSON string from editor_insert_text, etc.
3812
4007
  func applyUpdateJSON(_ updateJSON: String, notifyDelegate: Bool = true) {
4008
+ ensureInternalTextViewDelegate()
3813
4009
  let totalStartedAt = DispatchTime.now().uptimeNanoseconds
3814
4010
  let parseStartedAt = totalStartedAt
3815
4011
  guard let data = updateJSON.data(using: .utf8),
@@ -3996,6 +4192,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3996
4192
  /// Used for initial content loading (set_html / set_json return render
3997
4193
  /// elements directly, not wrapped in an EditorUpdate).
3998
4194
  func applyRenderJSON(_ renderJSON: String) {
4195
+ ensureInternalTextViewDelegate()
3999
4196
  Self.updateLog.debug(
4000
4197
  "[applyRenderJSON.begin] before=\(self.textSnapshotSummary(), privacy: .public)"
4001
4198
  )
@@ -4031,7 +4228,11 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
4031
4228
 
4032
4229
  let totalStartedAt = DispatchTime.now().uptimeNanoseconds
4033
4230
  isApplyingRustState = true
4034
- defer { isApplyingRustState = false }
4231
+ delegate = nil
4232
+ defer {
4233
+ ensureInternalTextViewDelegate()
4234
+ isApplyingRustState = false
4235
+ }
4035
4236
 
4036
4237
  switch type {
4037
4238
  case "text":
@@ -4176,7 +4377,7 @@ extension EditorTextView: NSTextStorageDelegate {
4176
4377
  guard editedMask.contains(.editedCharacters) else { return }
4177
4378
 
4178
4379
  // Skip if this change came from our own Rust apply path.
4179
- guard !isApplyingRustState, !isInterceptingInput else { return }
4380
+ guard !isApplyingRustState, !isInterceptingInput, !isComposing else { return }
4180
4381
 
4181
4382
  // Skip if no editor is bound yet (nothing to reconcile against).
4182
4383
  guard editorId != 0 else { return }
@@ -4218,7 +4419,7 @@ extension EditorTextView: NSTextStorageDelegate {
4218
4419
  guard let self else { return }
4219
4420
  self.reconciliationWorkScheduled = false
4220
4421
 
4221
- guard !self.isApplyingRustState, !self.isInterceptingInput else { return }
4422
+ guard !self.isApplyingRustState, !self.isInterceptingInput, !self.isComposing else { return }
4222
4423
  guard self.editorId != 0 else { return }
4223
4424
  guard self.textStorage.string != self.lastAuthorizedText else { return }
4224
4425
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apollohg/react-native-prose-editor",
3
- "version": "0.5.8",
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",