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

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.
@@ -390,6 +390,11 @@ class NativeEditorExpoView(
390
390
  val mentionQuery: String? = null
391
391
  )
392
392
 
393
+ private data class PendingEditorUpdateEvent(
394
+ val editorId: Long,
395
+ val updateJSON: String
396
+ )
397
+
393
398
  val richTextView: RichTextEditorView = RichTextEditorView(context)
394
399
  private val keyboardToolbarView = EditorKeyboardToolbarView(context)
395
400
  private val mainHandler = Handler(Looper.getMainLooper())
@@ -478,6 +483,9 @@ class NativeEditorExpoView(
478
483
  private var pendingNativeActionRetryGeneration = 0
479
484
  private var pendingNativeActionRetryAttempts = 0
480
485
  private var lastReadyEditorId: Long? = null
486
+ private val pendingEditorUpdateEvents = java.util.ArrayDeque<PendingEditorUpdateEvent>()
487
+ private var pendingEditorUpdateDispatchGeneration = 0
488
+ private var pendingEditorUpdateDispatchScheduled = false
481
489
 
482
490
  init {
483
491
  addView(richTextView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
@@ -1737,6 +1745,7 @@ class NativeEditorExpoView(
1737
1745
  isApplyingJSUpdate = true
1738
1746
  return try {
1739
1747
  richTextView.editorEditText.applyUpdateJSON(updateJson)
1748
+ clearPendingEditorUpdateDispatchQueue("jsUpdate")
1740
1749
  true
1741
1750
  } catch (error: Throwable) {
1742
1751
  Log.w(LOG_TAG, "Failed to apply JS editor update", error)
@@ -1812,27 +1821,114 @@ class NativeEditorExpoView(
1812
1821
  }
1813
1822
 
1814
1823
  override fun onEditorUpdate(updateJSON: String) {
1824
+ if (isApplyingJSUpdate) {
1825
+ dispatchEditorUpdate(
1826
+ PendingEditorUpdateEvent(
1827
+ editorId = richTextView.editorId,
1828
+ updateJSON = updateJSON
1829
+ ),
1830
+ emitToJS = false
1831
+ )
1832
+ return
1833
+ }
1834
+ pendingEditorUpdateEvents.addLast(
1835
+ PendingEditorUpdateEvent(
1836
+ editorId = richTextView.editorId,
1837
+ updateJSON = updateJSON
1838
+ )
1839
+ )
1840
+ richTextView.editorEditText.recordImeTraceForTesting(
1841
+ "nativeViewEditorUpdateQueued",
1842
+ "queue=${pendingEditorUpdateEvents.size} jsonLength=${updateJSON.length}"
1843
+ )
1844
+ schedulePendingEditorUpdateDispatch()
1845
+ }
1846
+
1847
+ internal fun pendingEditorUpdateEventCountForTesting(): Int =
1848
+ pendingEditorUpdateEvents.size
1849
+
1850
+ private fun schedulePendingEditorUpdateDispatch() {
1851
+ pendingEditorUpdateDispatchScheduled = true
1852
+ val generation = ++pendingEditorUpdateDispatchGeneration
1853
+ mainHandler.postDelayed({
1854
+ if (generation != pendingEditorUpdateDispatchGeneration) return@postDelayed
1855
+ pendingEditorUpdateDispatchScheduled = false
1856
+ drainPendingEditorUpdateEvents()
1857
+ }, EDITOR_UPDATE_EVENT_DEBOUNCE_MS)
1858
+ }
1859
+
1860
+ private fun drainPendingEditorUpdateEvents() {
1861
+ if (pendingEditorUpdateEvents.isEmpty()) return
1862
+ val startedAt = System.nanoTime()
1863
+ var drainedCount = 0
1864
+ while (pendingEditorUpdateEvents.isNotEmpty()) {
1865
+ val event = pendingEditorUpdateEvents.removeFirst()
1866
+ if (event.editorId != richTextView.editorId) {
1867
+ richTextView.editorEditText.recordImeTraceForTesting(
1868
+ "nativeViewEditorUpdateSkipped",
1869
+ "reason=staleEditor queuedEditor=${event.editorId} currentEditor=${richTextView.editorId}"
1870
+ )
1871
+ continue
1872
+ }
1873
+ dispatchEditorUpdate(event, emitToJS = true)
1874
+ drainedCount += 1
1875
+ }
1876
+ richTextView.editorEditText.recordImeTraceForTesting(
1877
+ "nativeViewEditorUpdateDrained",
1878
+ "count=$drainedCount totalUs=${nanosToMicros(System.nanoTime() - startedAt)}"
1879
+ )
1880
+ }
1881
+
1882
+ private fun clearPendingEditorUpdateDispatchQueue(reason: String) {
1883
+ if (pendingEditorUpdateEvents.isEmpty() && !pendingEditorUpdateDispatchScheduled) return
1884
+ val clearedCount = pendingEditorUpdateEvents.size
1885
+ pendingEditorUpdateEvents.clear()
1886
+ pendingEditorUpdateDispatchScheduled = false
1887
+ pendingEditorUpdateDispatchGeneration += 1
1888
+ richTextView.editorEditText.recordImeTraceForTesting(
1889
+ "nativeViewEditorUpdateQueueCleared",
1890
+ "reason=$reason count=$clearedCount"
1891
+ )
1892
+ }
1893
+
1894
+ private fun dispatchEditorUpdate(event: PendingEditorUpdateEvent, emitToJS: Boolean) {
1895
+ val updateJSON = event.updateJSON
1896
+ val startedAt = System.nanoTime()
1815
1897
  noteDocumentVersionFromUpdateJSON(updateJSON)
1898
+ val noteNanos = System.nanoTime() - startedAt
1899
+ val toolbarStartedAt = System.nanoTime()
1816
1900
  NativeToolbarState.fromUpdateJson(updateJSON)?.let { state ->
1817
1901
  toolbarState = state
1818
1902
  keyboardToolbarView.applyState(state)
1819
1903
  }
1904
+ val toolbarNanos = System.nanoTime() - toolbarStartedAt
1905
+ val mentionStartedAt = System.nanoTime()
1820
1906
  refreshMentionQuery()
1907
+ val mentionNanos = System.nanoTime() - mentionStartedAt
1908
+ val retryStartedAt = System.nanoTime()
1821
1909
  clearPendingNativeActionRetryIfScopeChanged()
1822
1910
  schedulePendingPreflightWake()
1823
1911
  richTextView.refreshRemoteSelections()
1912
+ val retryNanos = System.nanoTime() - retryStartedAt
1824
1913
  if (heightBehavior == EditorHeightBehavior.AUTO_GROW) {
1825
1914
  post {
1826
1915
  requestLayout()
1827
1916
  emitContentHeightIfNeeded(force = false)
1828
1917
  }
1829
1918
  }
1830
- if (isApplyingJSUpdate) return
1831
- val event = mapOf<String, Any>(
1832
- "updateJson" to updateJSON,
1833
- "editorId" to richTextView.editorId
1919
+ val emitStartedAt = System.nanoTime()
1920
+ if (emitToJS) {
1921
+ val payload = mapOf<String, Any>(
1922
+ "updateJson" to updateJSON,
1923
+ "editorId" to event.editorId
1924
+ )
1925
+ onEditorUpdate(payload)
1926
+ }
1927
+ val totalNanos = System.nanoTime() - startedAt
1928
+ richTextView.editorEditText.recordImeTraceForTesting(
1929
+ "nativeViewEditorUpdateDispatch",
1930
+ "emitToJS=$emitToJS jsonLength=${updateJSON.length} noteUs=${nanosToMicros(noteNanos)} toolbarUs=${nanosToMicros(toolbarNanos)} mentionUs=${nanosToMicros(mentionNanos)} retryUs=${nanosToMicros(retryNanos)} emitUs=${nanosToMicros(System.nanoTime() - emitStartedAt)} totalUs=${nanosToMicros(totalNanos)}"
1834
1931
  )
1835
- onEditorUpdate(event)
1836
1932
  }
1837
1933
 
1838
1934
  private fun installOutsideTapBlurHandlerIfNeeded() {
@@ -2082,10 +2178,13 @@ class NativeEditorExpoView(
2082
2178
  private const val TOOLBAR_FOCUS_PRESERVE_MS = 750L
2083
2179
  private const val OUTSIDE_TAP_BLUR_DELAY_MS = 100L
2084
2180
  private const val NATIVE_ACTION_RETRY_DELAY_MS = 16L
2181
+ private const val EDITOR_UPDATE_EVENT_DEBOUNCE_MS = 64L
2085
2182
  private const val PENDING_UPDATE_RECOVERY_RETRY_DELAY_MS = 250L
2086
2183
  private const val MAX_NATIVE_ACTION_RETRY_ATTEMPTS = 3
2087
2184
  private const val MAX_PENDING_UPDATE_RETRY_ATTEMPTS = 5
2088
2185
  private const val LOG_TAG = "NativeEditor"
2186
+
2187
+ private fun nanosToMicros(nanos: Long): Long = nanos / 1_000L
2089
2188
  }
2090
2189
 
2091
2190
  private fun resolveActivity(context: Context): Activity? {
@@ -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>
@@ -827,6 +827,9 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
827
827
  let authorizedText: String
828
828
  let selectionAnchor: UInt32?
829
829
  let selectionHead: UInt32?
830
+ let authorizedSelectionUtf16Range: NSRange?
831
+ let rawSelectionUtf16Range: NSRange?
832
+ let selectionRevision: UInt64
830
833
  let capturedWhileFirstResponder: Bool
831
834
  let capturedWhileEditable: Bool
832
835
  let capturedAfterBlur: Bool
@@ -991,6 +994,9 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
991
994
  private var nativeTextMutationAfterBlurDeadline: TimeInterval?
992
995
  private var nativeTextMutationAfterBlurGeneration: UInt64?
993
996
  private let nativeTextMutationAfterBlurGraceInterval: TimeInterval = 1.0
997
+ /// Last selection known to match `lastAuthorizedText`, stored in that text's UTF-16 coordinates.
998
+ private var lastAuthorizedSelectedUtf16Range: NSRange?
999
+ private var selectionRevision: UInt64 = 0
994
1000
  private var desiredInputTraitState = InputTraitState()
995
1001
  private var appliedInputTraitState = InputTraitState()
996
1002
  private var pendingInputTraitChange = PendingInputTraitChange()
@@ -1399,6 +1405,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1399
1405
  self?.ensureInternalTextViewDelegate()
1400
1406
  }
1401
1407
  _ = normalizeSelectionForEmptyBlockAutocapitalizationIfNeeded()
1408
+ recordAuthorizedSelectionIfPossible()
1402
1409
  refreshTypingAttributesForSelection()
1403
1410
  }
1404
1411
  return didBecomeFirstResponder
@@ -1459,6 +1466,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1459
1466
  let adjustedRange = NSRange(location: 0, length: 0)
1460
1467
  guard currentRange != adjustedRange else { return false }
1461
1468
  selectedRange = adjustedRange
1469
+ noteSelectionDidChange()
1462
1470
  return true
1463
1471
  }
1464
1472
 
@@ -1494,6 +1502,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1494
1502
 
1495
1503
  _ = becomeFirstResponder()
1496
1504
  selectedTextRange = textRange
1505
+ noteSelectionDidChange()
1497
1506
  refreshNativeSelectionChromeVisibility()
1498
1507
  onSelectionOrContentMayChange?()
1499
1508
  scheduleSelectionSync()
@@ -1508,6 +1517,30 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1508
1517
  return NSRange(location: location, length: length)
1509
1518
  }
1510
1519
 
1520
+ private func noteSelectionDidChange() {
1521
+ selectionRevision &+= 1
1522
+ }
1523
+
1524
+ private func recordAuthorizedSelectionIfPossible() {
1525
+ guard editorId != 0 else {
1526
+ lastAuthorizedSelectedUtf16Range = nil
1527
+ return
1528
+ }
1529
+ let currentText = textStorage.string
1530
+ guard currentText.utf16.count == lastAuthorizedTextStorage.length,
1531
+ currentText == lastAuthorizedText
1532
+ else {
1533
+ return
1534
+ }
1535
+ lastAuthorizedSelectedUtf16Range = selectedUtf16Range()
1536
+ }
1537
+
1538
+ private func scalarRange(forUtf16Range range: NSRange) -> (from: UInt32, to: UInt32) {
1539
+ let start = PositionBridge.utf16OffsetToScalar(range.location, in: self)
1540
+ let end = PositionBridge.utf16OffsetToScalar(NSMaxRange(range), in: self)
1541
+ return (from: min(start, end), to: max(start, end))
1542
+ }
1543
+
1511
1544
  private func scheduleDeferredImageSelection(for range: NSRange) {
1512
1545
  pendingDeferredImageSelectionRange = range
1513
1546
  pendingDeferredImageSelectionGeneration &+= 1
@@ -2492,6 +2525,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2492
2525
  func textViewDidChangeSelection(_ textView: UITextView) {
2493
2526
  guard textView === self else { return }
2494
2527
  ensureInternalTextViewDelegate()
2528
+ noteSelectionDidChange()
2495
2529
  guard !isApplyingRustState,
2496
2530
  !isComposing,
2497
2531
  !nativeTextMutationCommitScheduled,
@@ -2502,6 +2536,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2502
2536
  if normalizeSelectionForEmptyBlockAutocapitalizationIfNeeded() {
2503
2537
  return
2504
2538
  }
2539
+ recordAuthorizedSelectionIfPossible()
2505
2540
  refreshNativeSelectionChromeVisibility()
2506
2541
  onSelectionOrContentMayChange?()
2507
2542
  scheduleSelectionSync()
@@ -2538,6 +2573,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2538
2573
  }
2539
2574
 
2540
2575
  selectedTextRange = textRange(from: start, to: end)
2576
+ noteSelectionDidChange()
2541
2577
  refreshNativeSelectionChromeVisibility()
2542
2578
  onSelectionOrContentMayChange?()
2543
2579
  scheduleSelectionSync()
@@ -2710,6 +2746,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2710
2746
  )
2711
2747
 
2712
2748
  editorSetSelectionScalar(id: editorId, scalarAnchor: anchor, scalarHead: head)
2749
+ recordAuthorizedSelectionIfPossible()
2713
2750
  refreshTypingAttributesForSelection()
2714
2751
  editorDelegate?.editorTextView(self, selectionDidChange: docAnchor, head: docHead)
2715
2752
  }
@@ -3339,9 +3376,17 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3339
3376
  with: NSRange(location: prefix, length: replacementLength)
3340
3377
  )
3341
3378
 
3342
- let selectedScalarRange = selectedTextRange.map {
3343
- PositionBridge.textRangeToScalarRange($0, in: self)
3344
- }
3379
+ let rawSelectionUtf16Range = selectedUtf16Range()
3380
+ let authorizedSelectionUtf16Range = lastAuthorizedSelectedUtf16Range
3381
+ let targetSelectionUtf16Range = targetSelectionUtf16RangeForNativeTextMutation(
3382
+ rawSelectionUtf16Range: rawSelectionUtf16Range,
3383
+ authorizedSelectionUtf16Range: authorizedSelectionUtf16Range,
3384
+ replacementStartUtf16: prefix,
3385
+ authorizedEndUtf16: authorizedEnd,
3386
+ currentEndUtf16: currentEnd,
3387
+ currentTextUtf16Length: current.length
3388
+ )
3389
+ let selectedScalarRange = targetSelectionUtf16Range.map(scalarRange(forUtf16Range:))
3345
3390
  let capturedAfterBlur = canAdoptNativeTextMutationAfterBlur()
3346
3391
 
3347
3392
  return NativeTextMutation(
@@ -3352,6 +3397,9 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3352
3397
  authorizedText: authorizedText,
3353
3398
  selectionAnchor: selectedScalarRange?.from,
3354
3399
  selectionHead: selectedScalarRange?.to,
3400
+ authorizedSelectionUtf16Range: authorizedSelectionUtf16Range,
3401
+ rawSelectionUtf16Range: rawSelectionUtf16Range,
3402
+ selectionRevision: selectionRevision,
3355
3403
  capturedWhileFirstResponder: isFirstResponder || capturedAfterBlur,
3356
3404
  capturedWhileEditable: isEditable,
3357
3405
  capturedAfterBlur: capturedAfterBlur,
@@ -3362,9 +3410,37 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3362
3410
  private func nativeTextMutationWithCurrentSelection(
3363
3411
  _ mutation: NativeTextMutation
3364
3412
  ) -> NativeTextMutation {
3365
- let selectedScalarRange = selectedTextRange.map {
3366
- PositionBridge.textRangeToScalarRange($0, in: self)
3413
+ let currentSelectionUtf16Range = selectedUtf16Range()
3414
+ let didSelectionChangeAfterCapture = selectionRevision != mutation.selectionRevision
3415
+ let didCurrentRangeMoveAfterCapture: Bool
3416
+ if let currentSelectionUtf16Range,
3417
+ let rawSelectionUtf16Range = mutation.rawSelectionUtf16Range {
3418
+ didCurrentRangeMoveAfterCapture = !NSEqualRanges(
3419
+ currentSelectionUtf16Range,
3420
+ rawSelectionUtf16Range
3421
+ )
3422
+ } else {
3423
+ didCurrentRangeMoveAfterCapture = false
3424
+ }
3425
+ let currentSelectionDiffersFromAuthorized: Bool
3426
+ if let currentSelectionUtf16Range,
3427
+ let authorizedSelectionUtf16Range = mutation.authorizedSelectionUtf16Range {
3428
+ currentSelectionDiffersFromAuthorized = !NSEqualRanges(
3429
+ currentSelectionUtf16Range,
3430
+ authorizedSelectionUtf16Range
3431
+ )
3432
+ } else {
3433
+ currentSelectionDiffersFromAuthorized = currentSelectionUtf16Range != nil
3367
3434
  }
3435
+ let shouldUseCurrentSelection = currentSelectionUtf16Range != nil
3436
+ && (
3437
+ (didSelectionChangeAfterCapture && currentSelectionDiffersFromAuthorized)
3438
+ || didCurrentRangeMoveAfterCapture
3439
+ || mutation.rawSelectionUtf16Range == nil
3440
+ )
3441
+ let selectedScalarRange = shouldUseCurrentSelection
3442
+ ? currentSelectionUtf16Range.map(scalarRange(forUtf16Range:))
3443
+ : nil
3368
3444
  return NativeTextMutation(
3369
3445
  from: mutation.from,
3370
3446
  to: mutation.to,
@@ -3373,6 +3449,13 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3373
3449
  authorizedText: mutation.authorizedText,
3374
3450
  selectionAnchor: selectedScalarRange?.from ?? mutation.selectionAnchor,
3375
3451
  selectionHead: selectedScalarRange?.to ?? mutation.selectionHead,
3452
+ authorizedSelectionUtf16Range: mutation.authorizedSelectionUtf16Range,
3453
+ rawSelectionUtf16Range: shouldUseCurrentSelection
3454
+ ? currentSelectionUtf16Range
3455
+ : mutation.rawSelectionUtf16Range,
3456
+ selectionRevision: shouldUseCurrentSelection
3457
+ ? selectionRevision
3458
+ : mutation.selectionRevision,
3376
3459
  capturedWhileFirstResponder: mutation.capturedWhileFirstResponder,
3377
3460
  capturedWhileEditable: mutation.capturedWhileEditable,
3378
3461
  capturedAfterBlur: mutation.capturedAfterBlur,
@@ -3380,6 +3463,104 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3380
3463
  )
3381
3464
  }
3382
3465
 
3466
+ private func targetSelectionUtf16RangeForNativeTextMutation(
3467
+ rawSelectionUtf16Range: NSRange?,
3468
+ authorizedSelectionUtf16Range: NSRange?,
3469
+ replacementStartUtf16: Int,
3470
+ authorizedEndUtf16: Int,
3471
+ currentEndUtf16: Int,
3472
+ currentTextUtf16Length: Int
3473
+ ) -> NSRange? {
3474
+ guard let authorizedSelection = authorizedSelectionUtf16Range else {
3475
+ return clampedUtf16Range(rawSelectionUtf16Range, length: currentTextUtf16Length)
3476
+ }
3477
+ guard authorizedSelection.location != NSNotFound else {
3478
+ return clampedUtf16Range(rawSelectionUtf16Range, length: currentTextUtf16Length)
3479
+ }
3480
+
3481
+ if let rawSelection = rawSelectionUtf16Range,
3482
+ rawSelection.location != NSNotFound,
3483
+ !NSEqualRanges(rawSelection, authorizedSelection) {
3484
+ return clampedUtf16Range(rawSelection, length: currentTextUtf16Length)
3485
+ }
3486
+
3487
+ if authorizedSelection.length == 0 {
3488
+ let mappedOffset = mapCollapsedAuthorizedSelectionOffsetThroughNativeTextMutation(
3489
+ authorizedSelection.location,
3490
+ replacementStartUtf16: replacementStartUtf16,
3491
+ authorizedEndUtf16: authorizedEndUtf16,
3492
+ currentEndUtf16: currentEndUtf16
3493
+ )
3494
+ let clampedOffset = min(max(mappedOffset, 0), currentTextUtf16Length)
3495
+ return NSRange(location: clampedOffset, length: 0)
3496
+ }
3497
+
3498
+ let mappedStart = mapAuthorizedSelectionOffsetThroughNativeTextMutation(
3499
+ authorizedSelection.location,
3500
+ replacementStartUtf16: replacementStartUtf16,
3501
+ authorizedEndUtf16: authorizedEndUtf16,
3502
+ currentEndUtf16: currentEndUtf16,
3503
+ isRangeStart: true
3504
+ )
3505
+ let mappedEnd = mapAuthorizedSelectionOffsetThroughNativeTextMutation(
3506
+ NSMaxRange(authorizedSelection),
3507
+ replacementStartUtf16: replacementStartUtf16,
3508
+ authorizedEndUtf16: authorizedEndUtf16,
3509
+ currentEndUtf16: currentEndUtf16,
3510
+ isRangeStart: false
3511
+ )
3512
+ let start = min(mappedStart, mappedEnd)
3513
+ let end = max(mappedStart, mappedEnd)
3514
+ let clampedStart = min(max(start, 0), currentTextUtf16Length)
3515
+ let clampedEnd = min(max(end, 0), currentTextUtf16Length)
3516
+ return NSRange(location: clampedStart, length: max(0, clampedEnd - clampedStart))
3517
+ }
3518
+
3519
+ private func clampedUtf16Range(_ range: NSRange?, length: Int) -> NSRange? {
3520
+ guard let range, range.location != NSNotFound else { return nil }
3521
+ let start = min(max(range.location, 0), length)
3522
+ let end = min(max(NSMaxRange(range), 0), length)
3523
+ return NSRange(location: min(start, end), length: abs(end - start))
3524
+ }
3525
+
3526
+ private func mapCollapsedAuthorizedSelectionOffsetThroughNativeTextMutation(
3527
+ _ offset: Int,
3528
+ replacementStartUtf16: Int,
3529
+ authorizedEndUtf16: Int,
3530
+ currentEndUtf16: Int
3531
+ ) -> Int {
3532
+ // UIKit can leave a stale caret at the insertion point during autocomplete.
3533
+ // A collapsed authorized caret should stay collapsed after the inserted text.
3534
+ if replacementStartUtf16 == authorizedEndUtf16,
3535
+ offset == replacementStartUtf16,
3536
+ currentEndUtf16 > replacementStartUtf16 {
3537
+ return currentEndUtf16
3538
+ }
3539
+ if offset <= replacementStartUtf16 {
3540
+ return offset
3541
+ }
3542
+ if offset < authorizedEndUtf16 {
3543
+ return currentEndUtf16
3544
+ }
3545
+ return offset + currentEndUtf16 - authorizedEndUtf16
3546
+ }
3547
+
3548
+ private func mapAuthorizedSelectionOffsetThroughNativeTextMutation(
3549
+ _ offset: Int,
3550
+ replacementStartUtf16: Int,
3551
+ authorizedEndUtf16: Int,
3552
+ currentEndUtf16: Int,
3553
+ isRangeStart: Bool
3554
+ ) -> Int {
3555
+ if offset <= replacementStartUtf16 {
3556
+ return offset
3557
+ }
3558
+ if offset >= authorizedEndUtf16 {
3559
+ return offset + currentEndUtf16 - authorizedEndUtf16
3560
+ }
3561
+ return isRangeStart ? replacementStartUtf16 : currentEndUtf16
3562
+ }
3563
+
3383
3564
  private func isUtf16ScalarBoundary(_ offset: Int, in text: String) -> Bool {
3384
3565
  guard offset >= 0, offset <= text.utf16.count else { return false }
3385
3566
  let utf16Index = text.utf16.index(text.utf16.startIndex, offsetBy: offset)
@@ -3451,20 +3632,25 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3451
3632
  clearNativeTextMutationAfterBlurWindow()
3452
3633
  }
3453
3634
 
3635
+ private func resetPendingNativeTextMutationState() {
3636
+ pendingNativeTextMutation = nil
3637
+ nativeTextMutationCommitScheduled = false
3638
+ advanceNativeTextMutationGeneration()
3639
+ }
3640
+
3454
3641
  func expireNativeTextMutationAfterBlurDeadlineForTesting() {
3455
3642
  nativeTextMutationAfterBlurDeadline = ProcessInfo.processInfo.systemUptime - 0.001
3456
3643
  }
3457
3644
 
3458
3645
  func discardTransientNativeInputForEditorRebind() {
3459
- pendingNativeTextMutation = nil
3460
- nativeTextMutationCommitScheduled = false
3646
+ resetPendingNativeTextMutationState()
3647
+ lastAuthorizedSelectedUtf16Range = nil
3461
3648
  clearPendingInputTraitRetry()
3462
3649
  markedTextReplacementScalarRange = nil
3463
3650
  markedTextReplacementUtf16Range = nil
3464
3651
  markedTextCompositionText = nil
3465
3652
  markedTextCompositionIsExplicitlyEmpty = false
3466
3653
  isComposing = false
3467
- advanceNativeTextMutationGeneration()
3468
3654
  }
3469
3655
 
3470
3656
  @discardableResult
@@ -3528,7 +3714,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3528
3714
  ) -> Bool {
3529
3715
  guard nativeTextMutationCommitScheduled
3530
3716
  || pendingNativeTextMutation != nil
3531
- || (!isComposing && textStorage.string != lastAuthorizedText)
3717
+ || (!isComposing && markedTextRange == nil && textStorage.string != lastAuthorizedText)
3532
3718
  else {
3533
3719
  return true
3534
3720
  }
@@ -3658,8 +3844,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3658
3844
  let targetRange = NSRange(location: startUtf16, length: max(0, endUtf16 - startUtf16))
3659
3845
  if selectedRange != targetRange {
3660
3846
  selectedRange = targetRange
3847
+ noteSelectionDidChange()
3661
3848
  }
3662
3849
  editorSetSelectionScalar(id: editorId, scalarAnchor: anchor, scalarHead: head)
3850
+ recordAuthorizedSelectionIfPossible()
3663
3851
  refreshTypingAttributesForSelection()
3664
3852
  let docAnchor = editorScalarToDoc(id: editorId, scalar: anchor)
3665
3853
  let docHead = editorScalarToDoc(id: editorId, scalar: head)
@@ -4783,9 +4971,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
4783
4971
  let update = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
4784
4972
  else { return }
4785
4973
  let parseNanos = DispatchTime.now().uptimeNanoseconds - parseStartedAt
4786
- pendingNativeTextMutation = nil
4787
- nativeTextMutationCommitScheduled = false
4788
- advanceNativeTextMutationGeneration()
4974
+ resetPendingNativeTextMutationState()
4789
4975
 
4790
4976
  let renderElements = update["renderElements"] as? [[String: Any]]
4791
4977
  let selectionFromUpdate = (update["selection"] as? [String: Any])
@@ -4951,6 +5137,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
4951
5137
  postApplyTrace.selectionOrContentCallbackNanos
4952
5138
  )
4953
5139
  }
5140
+ recordAuthorizedSelectionIfPossible()
4954
5141
  Self.updateLog.debug(
4955
5142
  "[applyUpdateJSON.end] finalSelection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
4956
5143
  )
@@ -4967,6 +5154,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
4967
5154
  /// elements directly, not wrapped in an EditorUpdate).
4968
5155
  func applyRenderJSON(_ renderJSON: String) {
4969
5156
  ensureInternalTextViewDelegate()
5157
+ resetPendingNativeTextMutationState()
4970
5158
  Self.updateLog.debug(
4971
5159
  "[applyRenderJSON.begin] before=\(self.textSnapshotSummary(), privacy: .public)"
4972
5160
  )
@@ -4982,6 +5170,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
4982
5170
 
4983
5171
  refreshPlaceholderVisibility()
4984
5172
  _ = performPostApplyMaintenance()
5173
+ recordAuthorizedSelectionIfPossible()
4985
5174
  Self.updateLog.debug(
4986
5175
  "[applyRenderJSON.end] after=\(self.textSnapshotSummary(), privacy: .public)"
4987
5176
  )
@@ -5039,17 +5228,20 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
5039
5228
  let adjustedRange = NSRange(location: adjustedOffset, length: 0)
5040
5229
  if selectedRange != adjustedRange {
5041
5230
  selectedRange = adjustedRange
5231
+ noteSelectionDidChange()
5042
5232
  }
5043
5233
  } else {
5044
5234
  let targetRange = NSRange(location: endUtf16, length: 0)
5045
5235
  if selectedRange != targetRange {
5046
5236
  selectedRange = targetRange
5237
+ noteSelectionDidChange()
5047
5238
  }
5048
5239
  }
5049
5240
  } else {
5050
5241
  let targetRange = NSRange(location: startUtf16, length: endUtf16 - startUtf16)
5051
5242
  if selectedRange != targetRange {
5052
5243
  selectedRange = targetRange
5244
+ noteSelectionDidChange()
5053
5245
  }
5054
5246
  }
5055
5247
  let assignmentNanos = DispatchTime.now().uptimeNanoseconds - assignmentStartedAt
@@ -5081,6 +5273,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
5081
5273
  let assignmentStartedAt = DispatchTime.now().uptimeNanoseconds
5082
5274
  if selectedRange != targetRange {
5083
5275
  selectedRange = targetRange
5276
+ noteSelectionDidChange()
5084
5277
  }
5085
5278
  let assignmentNanos = DispatchTime.now().uptimeNanoseconds - assignmentStartedAt
5086
5279
  let chromeStartedAt = DispatchTime.now().uptimeNanoseconds
@@ -5099,6 +5292,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
5099
5292
  case "all":
5100
5293
  let assignmentStartedAt = DispatchTime.now().uptimeNanoseconds
5101
5294
  selectedTextRange = textRange(from: beginningOfDocument, to: endOfDocument)
5295
+ noteSelectionDidChange()
5102
5296
  let assignmentNanos = DispatchTime.now().uptimeNanoseconds - assignmentStartedAt
5103
5297
  let chromeStartedAt = DispatchTime.now().uptimeNanoseconds
5104
5298
  showNativeSelectionChromeIfNeeded()
@@ -5150,8 +5344,11 @@ extension EditorTextView: NSTextStorageDelegate {
5150
5344
  // Only care about actual character edits, not attribute-only changes.
5151
5345
  guard editedMask.contains(.editedCharacters) else { return }
5152
5346
 
5153
- // Skip if this change came from our own Rust apply path or transient IME composition.
5154
- guard !isApplyingRustState, !isComposing else { return }
5347
+ // Skip if this change came from our own Rust apply path, transient IME
5348
+ // composition, or an inline prediction. iOS inline predictions (iOS 17+)
5349
+ // mutate textStorage directly and set markedTextRange without calling
5350
+ // setMarkedText, so isComposing remains false — check markedTextRange too.
5351
+ guard !isApplyingRustState, !isComposing, markedTextRange == nil else { return }
5155
5352
 
5156
5353
  // Skip if no editor is bound yet (nothing to reconcile against).
5157
5354
  guard editorId != 0 else { return }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apollohg/react-native-prose-editor",
3
- "version": "0.5.17",
3
+ "version": "0.5.18",
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",