@apollohg/react-native-prose-editor 0.5.16 → 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.
@@ -824,6 +824,22 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
824
824
  let to: UInt32
825
825
  let replacementText: String
826
826
  let resultingText: String
827
+ let authorizedText: String
828
+ let selectionAnchor: UInt32?
829
+ let selectionHead: UInt32?
830
+ let authorizedSelectionUtf16Range: NSRange?
831
+ let rawSelectionUtf16Range: NSRange?
832
+ let selectionRevision: UInt64
833
+ let capturedWhileFirstResponder: Bool
834
+ let capturedWhileEditable: Bool
835
+ let capturedAfterBlur: Bool
836
+ let inputGeneration: UInt64
837
+ }
838
+
839
+ private enum NativeTextMutationCommitResult {
840
+ case committed
841
+ case deferred
842
+ case rejected
827
843
  }
828
844
 
829
845
  private enum PositionCacheUpdate {
@@ -922,6 +938,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
922
938
  /// The plain text from the last Rust render, used by the reconciliation
923
939
  /// fallback to detect unauthorized text storage mutations.
924
940
  private var lastAuthorizedTextStorage = NSMutableString()
941
+ private var lastAuthorizedAttributedTextStorage = NSMutableAttributedString()
925
942
  private var lastAuthorizedText: String {
926
943
  lastAuthorizedTextStorage as String
927
944
  }
@@ -973,6 +990,18 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
973
990
  private var reconciliationWorkScheduled = false
974
991
  private var nativeTextMutationCommitScheduled = false
975
992
  private var pendingNativeTextMutation: NativeTextMutation?
993
+ private var nativeTextMutationGeneration: UInt64 = 0
994
+ private var nativeTextMutationAfterBlurDeadline: TimeInterval?
995
+ private var nativeTextMutationAfterBlurGeneration: UInt64?
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
1000
+ private var desiredInputTraitState = InputTraitState()
1001
+ private var appliedInputTraitState = InputTraitState()
1002
+ private var pendingInputTraitChange = PendingInputTraitChange()
1003
+ private var pendingInputTraitRetryScheduled = false
1004
+ private var pendingInputTraitRetryGeneration: UInt64 = 0
976
1005
 
977
1006
  /// Coalesces selection sync until UIKit has finished resolving the
978
1007
  /// current tap/drag gesture's final caret position.
@@ -986,9 +1015,29 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
986
1015
  private var markedTextReplacementScalarRange: (from: UInt32, to: UInt32)?
987
1016
  private var markedTextReplacementUtf16Range: NSRange?
988
1017
  private var markedTextCompositionText: String?
1018
+ private var markedTextCompositionIsExplicitlyEmpty = false
989
1019
 
990
1020
  private let editorLayoutManager: EditorLayoutManager
991
1021
 
1022
+ private struct InputTraitState {
1023
+ var autoCapitalize: String?
1024
+ var autoCorrect: Bool?
1025
+ var keyboardType: String?
1026
+ }
1027
+
1028
+ private struct PendingInputTraitChange {
1029
+ var hasAutoCapitalize = false
1030
+ var autoCapitalize: String?
1031
+ var hasAutoCorrect = false
1032
+ var autoCorrect: Bool?
1033
+ var hasKeyboardType = false
1034
+ var keyboardType: String?
1035
+
1036
+ var isEmpty: Bool {
1037
+ !hasAutoCapitalize && !hasAutoCorrect && !hasKeyboardType
1038
+ }
1039
+ }
1040
+
992
1041
  // MARK: - Placeholder
993
1042
 
994
1043
  private lazy var placeholderLabel: UILabel = {
@@ -1073,6 +1122,19 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1073
1122
  }
1074
1123
 
1075
1124
  func setAutoCapitalize(_ autoCapitalize: String?) {
1125
+ desiredInputTraitState.autoCapitalize = autoCapitalize
1126
+ guard prepareForInputTraitChange() else {
1127
+ pendingInputTraitChange.hasAutoCapitalize = true
1128
+ pendingInputTraitChange.autoCapitalize = autoCapitalize
1129
+ scheduleInputTraitChangeRetry()
1130
+ return
1131
+ }
1132
+ applyAutoCapitalize(autoCapitalize)
1133
+ appliedInputTraitState.autoCapitalize = autoCapitalize
1134
+ clearPendingAutoCapitalize()
1135
+ }
1136
+
1137
+ private func applyAutoCapitalize(_ autoCapitalize: String?) {
1076
1138
  switch autoCapitalize {
1077
1139
  case "none":
1078
1140
  autocapitalizationType = .none
@@ -1083,21 +1145,127 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1083
1145
  default:
1084
1146
  autocapitalizationType = .sentences
1085
1147
  }
1148
+ if isFirstResponder {
1149
+ reloadInputViews()
1150
+ }
1086
1151
  }
1087
1152
 
1088
1153
  func setAutoCorrect(_ autoCorrect: Bool?) {
1154
+ desiredInputTraitState.autoCorrect = autoCorrect
1155
+ guard prepareForInputTraitChange() else {
1156
+ pendingInputTraitChange.hasAutoCorrect = true
1157
+ pendingInputTraitChange.autoCorrect = autoCorrect
1158
+ scheduleInputTraitChangeRetry()
1159
+ return
1160
+ }
1161
+ applyAutoCorrect(autoCorrect)
1162
+ appliedInputTraitState.autoCorrect = autoCorrect
1163
+ clearPendingAutoCorrect()
1164
+ }
1165
+
1166
+ private func applyAutoCorrect(_ autoCorrect: Bool?) {
1089
1167
  let isEnabled = autoCorrect ?? false
1090
1168
  autocorrectionType = isEnabled ? .yes : .no
1091
1169
  spellCheckingType = isEnabled ? .default : .no
1170
+ if isFirstResponder {
1171
+ reloadInputViews()
1172
+ }
1092
1173
  }
1093
1174
 
1094
1175
  func setKeyboardType(_ keyboardType: String?) {
1176
+ desiredInputTraitState.keyboardType = keyboardType
1177
+ guard prepareForInputTraitChange() else {
1178
+ pendingInputTraitChange.hasKeyboardType = true
1179
+ pendingInputTraitChange.keyboardType = keyboardType
1180
+ scheduleInputTraitChangeRetry()
1181
+ return
1182
+ }
1183
+ applyKeyboardType(keyboardType)
1184
+ appliedInputTraitState.keyboardType = keyboardType
1185
+ clearPendingKeyboardType()
1186
+ }
1187
+
1188
+ private func applyKeyboardType(_ keyboardType: String?) {
1095
1189
  self.keyboardType = Self.resolvedKeyboardType(from: keyboardType)
1096
1190
  if isFirstResponder {
1097
1191
  reloadInputViews()
1098
1192
  }
1099
1193
  }
1100
1194
 
1195
+ private func prepareForInputTraitChange() -> Bool {
1196
+ guard isFirstResponder, editorId != 0 else { return true }
1197
+ return prepareForExternalEditorUpdate()
1198
+ }
1199
+
1200
+ private func scheduleInputTraitChangeRetry() {
1201
+ guard !pendingInputTraitRetryScheduled else { return }
1202
+ pendingInputTraitRetryScheduled = true
1203
+ pendingInputTraitRetryGeneration &+= 1
1204
+ let retryGeneration = pendingInputTraitRetryGeneration
1205
+ DispatchQueue.main.async { [weak self] in
1206
+ guard let self else { return }
1207
+ guard retryGeneration == self.pendingInputTraitRetryGeneration else { return }
1208
+ self.pendingInputTraitRetryScheduled = false
1209
+ let pending = self.pendingInputTraitChange
1210
+ self.pendingInputTraitChange = PendingInputTraitChange()
1211
+ if pending.hasAutoCapitalize,
1212
+ pending.autoCapitalize == self.desiredInputTraitState.autoCapitalize {
1213
+ self.setAutoCapitalize(pending.autoCapitalize)
1214
+ }
1215
+ if pending.hasAutoCorrect,
1216
+ pending.autoCorrect == self.desiredInputTraitState.autoCorrect {
1217
+ self.setAutoCorrect(pending.autoCorrect)
1218
+ }
1219
+ if pending.hasKeyboardType,
1220
+ pending.keyboardType == self.desiredInputTraitState.keyboardType {
1221
+ self.setKeyboardType(pending.keyboardType)
1222
+ }
1223
+ }
1224
+ }
1225
+
1226
+ private func clearPendingAutoCapitalize() {
1227
+ pendingInputTraitChange.hasAutoCapitalize = false
1228
+ pendingInputTraitChange.autoCapitalize = nil
1229
+ cancelPendingInputTraitRetryIfEmpty()
1230
+ }
1231
+
1232
+ private func clearPendingAutoCorrect() {
1233
+ pendingInputTraitChange.hasAutoCorrect = false
1234
+ pendingInputTraitChange.autoCorrect = nil
1235
+ cancelPendingInputTraitRetryIfEmpty()
1236
+ }
1237
+
1238
+ private func clearPendingKeyboardType() {
1239
+ pendingInputTraitChange.hasKeyboardType = false
1240
+ pendingInputTraitChange.keyboardType = nil
1241
+ cancelPendingInputTraitRetryIfEmpty()
1242
+ }
1243
+
1244
+ private func clearPendingInputTraitRetry() {
1245
+ pendingInputTraitChange = PendingInputTraitChange()
1246
+ guard pendingInputTraitRetryScheduled else { return }
1247
+ pendingInputTraitRetryScheduled = false
1248
+ pendingInputTraitRetryGeneration &+= 1
1249
+ }
1250
+
1251
+ private func cancelPendingInputTraitRetryIfEmpty() {
1252
+ guard pendingInputTraitRetryScheduled, pendingInputTraitChange.isEmpty else { return }
1253
+ pendingInputTraitRetryScheduled = false
1254
+ pendingInputTraitRetryGeneration &+= 1
1255
+ }
1256
+
1257
+ private func replayDesiredInputTraitsIfNeeded() {
1258
+ if desiredInputTraitState.autoCapitalize != appliedInputTraitState.autoCapitalize {
1259
+ setAutoCapitalize(desiredInputTraitState.autoCapitalize)
1260
+ }
1261
+ if desiredInputTraitState.autoCorrect != appliedInputTraitState.autoCorrect {
1262
+ setAutoCorrect(desiredInputTraitState.autoCorrect)
1263
+ }
1264
+ if desiredInputTraitState.keyboardType != appliedInputTraitState.keyboardType {
1265
+ setKeyboardType(desiredInputTraitState.keyboardType)
1266
+ }
1267
+ }
1268
+
1101
1269
  private static func resolvedKeyboardType(from keyboardType: String?) -> UIKeyboardType {
1102
1270
  switch keyboardType {
1103
1271
  case "ascii-capable":
@@ -1232,15 +1400,42 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1232
1400
  let didBecomeFirstResponder = super.becomeFirstResponder()
1233
1401
  if didBecomeFirstResponder {
1234
1402
  ensureInternalTextViewDelegate()
1403
+ clearNativeTextMutationAfterBlurWindow()
1235
1404
  DispatchQueue.main.async { [weak self] in
1236
1405
  self?.ensureInternalTextViewDelegate()
1237
1406
  }
1238
1407
  _ = normalizeSelectionForEmptyBlockAutocapitalizationIfNeeded()
1408
+ recordAuthorizedSelectionIfPossible()
1239
1409
  refreshTypingAttributesForSelection()
1240
1410
  }
1241
1411
  return didBecomeFirstResponder
1242
1412
  }
1243
1413
 
1414
+ override func resignFirstResponder() -> Bool {
1415
+ ensureInternalTextViewDelegate()
1416
+ _ = drainPendingNativeTextMutation(allowAfterBlur: false, allowWhileIntercepting: true)
1417
+
1418
+ let wasFirstResponder = isFirstResponder
1419
+ if wasFirstResponder {
1420
+ nativeTextMutationAfterBlurGeneration = nativeTextMutationGeneration
1421
+ nativeTextMutationAfterBlurDeadline = ProcessInfo.processInfo.systemUptime
1422
+ + nativeTextMutationAfterBlurGraceInterval
1423
+ }
1424
+
1425
+ let didResignFirstResponder = super.resignFirstResponder()
1426
+ if wasFirstResponder || didResignFirstResponder {
1427
+ _ = drainPendingNativeTextMutation(allowAfterBlur: true, allowWhileIntercepting: true)
1428
+ DispatchQueue.main.async { [weak self] in
1429
+ guard let self else { return }
1430
+ _ = self.drainPendingNativeTextMutation(
1431
+ allowAfterBlur: true,
1432
+ allowWhileIntercepting: true
1433
+ )
1434
+ }
1435
+ }
1436
+ return didResignFirstResponder
1437
+ }
1438
+
1244
1439
  private func isRenderedContentEmpty() -> Bool {
1245
1440
  let renderedText = textStorage.string
1246
1441
  guard !renderedText.isEmpty else { return true }
@@ -1271,6 +1466,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1271
1466
  let adjustedRange = NSRange(location: 0, length: 0)
1272
1467
  guard currentRange != adjustedRange else { return false }
1273
1468
  selectedRange = adjustedRange
1469
+ noteSelectionDidChange()
1274
1470
  return true
1275
1471
  }
1276
1472
 
@@ -1306,6 +1502,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1306
1502
 
1307
1503
  _ = becomeFirstResponder()
1308
1504
  selectedTextRange = textRange
1505
+ noteSelectionDidChange()
1309
1506
  refreshNativeSelectionChromeVisibility()
1310
1507
  onSelectionOrContentMayChange?()
1311
1508
  scheduleSelectionSync()
@@ -1320,6 +1517,30 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1320
1517
  return NSRange(location: location, length: length)
1321
1518
  }
1322
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
+
1323
1544
  private func scheduleDeferredImageSelection(for range: NSRange) {
1324
1545
  pendingDeferredImageSelectionRange = range
1325
1546
  pendingDeferredImageSelectionGeneration &+= 1
@@ -1410,6 +1631,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1410
1631
  lastRenderAppliedPatchForTesting
1411
1632
  }
1412
1633
 
1634
+ func authorizedTextForTesting() -> String {
1635
+ lastAuthorizedText
1636
+ }
1637
+
1413
1638
  func lastApplyUpdateTrace() -> ApplyUpdateTrace? {
1414
1639
  lastApplyUpdateTraceForTesting
1415
1640
  }
@@ -1612,6 +1837,12 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1612
1837
  /// - initialHTML: Optional HTML to set as initial content.
1613
1838
  func bindEditor(id: UInt64, initialHTML: String? = nil) {
1614
1839
  ensureInternalTextViewDelegate()
1840
+ if editorId == id, initialHTML == nil {
1841
+ return
1842
+ }
1843
+ if editorId != id {
1844
+ discardTransientNativeInputForEditorRebind()
1845
+ }
1615
1846
  editorId = id
1616
1847
 
1617
1848
  if let html = initialHTML, !html.isEmpty {
@@ -1623,10 +1854,12 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1623
1854
  let stateJSON = editorGetCurrentState(id: editorId)
1624
1855
  applyUpdateJSON(stateJSON, notifyDelegate: false)
1625
1856
  }
1857
+ replayDesiredInputTraitsIfNeeded()
1626
1858
  }
1627
1859
 
1628
1860
  /// Unbind from the current editor instance.
1629
1861
  func unbindEditor() {
1862
+ discardTransientNativeInputForEditorRebind()
1630
1863
  editorId = 0
1631
1864
  }
1632
1865
 
@@ -1649,6 +1882,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1649
1882
  super.insertText(text)
1650
1883
  return
1651
1884
  }
1885
+ guard flushPendingNativeTextMutationCommitIfNeeded() else { return }
1652
1886
 
1653
1887
  if markedTextReplacementScalarRange != nil || markedTextRange != nil {
1654
1888
  let replacementRange = trackedMarkedTextReplacementRange()
@@ -1740,6 +1974,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1740
1974
  super.deleteBackward()
1741
1975
  return
1742
1976
  }
1977
+ guard flushPendingNativeTextMutationCommitIfNeeded() else { return }
1743
1978
 
1744
1979
  if markedTextReplacementScalarRange != nil || markedTextRange != nil {
1745
1980
  performTransientTextMutation {
@@ -1892,6 +2127,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1892
2127
  guard !isApplyingRustState else { return }
1893
2128
  guard editorId != 0 else { return }
1894
2129
  guard isEditable else { return }
2130
+ guard flushPendingNativeTextMutationCommitIfNeeded() else { return }
1895
2131
  guard isCaretInsideList() else { return }
1896
2132
  guard let selection = currentScalarSelection() else { return }
1897
2133
 
@@ -1943,6 +2179,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1943
2179
  super.replace(range, withText: text)
1944
2180
  return
1945
2181
  }
2182
+ guard flushPendingNativeTextMutationCommitIfNeeded() else { return }
1946
2183
 
1947
2184
  if markedTextReplacementScalarRange != nil || markedTextRange != nil {
1948
2185
  let replacementRange = trackedMarkedTextReplacementRange()
@@ -1978,21 +2215,36 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1978
2215
  override func setMarkedText(_ markedText: String?, selectedRange: NSRange) {
1979
2216
  ensureInternalTextViewDelegate()
1980
2217
  if markedText != nil {
2218
+ guard flushPendingNativeTextMutationCommitIfNeeded() else { return }
1981
2219
  captureMarkedTextReplacementRangeIfNeeded()
2220
+ } else if markedTextReplacementScalarRange == nil, markedTextRange == nil {
2221
+ guard flushPendingNativeTextMutationCommitIfNeeded() else { return }
1982
2222
  }
1983
2223
  isComposing = markedText != nil || markedTextReplacementScalarRange != nil
1984
2224
  Self.inputLog.debug(
1985
2225
  "[setMarkedText] marked=\(self.preview(markedText ?? ""), privacy: .public) nsRange=\(selectedRange.location),\(selectedRange.length) selection=\(self.selectionSummary(), privacy: .public)"
1986
2226
  )
1987
- performTransientTextMutation {
1988
- super.setMarkedText(markedText, selectedRange: selectedRange)
1989
- }
1990
2227
  if markedText == nil {
2228
+ // Some keyboard paths finalize composition by clearing marked text
2229
+ // instead of calling unmarkText().
2230
+ let composedText = validatedTrackedMarkedTextForCommit()
2231
+ let replacementRange = trackedMarkedTextReplacementRange()
2232
+ performTransientTextMutation {
2233
+ super.setMarkedText(nil, selectedRange: selectedRange)
2234
+ }
1991
2235
  clearMarkedTextTracking()
1992
- restoreAuthorizedTextAfterCancelledCompositionIfNeeded()
1993
- } else {
1994
- refreshMarkedTextCompositionText(fallback: markedText)
2236
+ if shouldCommitMarkedText(composedText, replacementRange: replacementRange) {
2237
+ commitMarkedText(composedText ?? "", replacementRange: replacementRange)
2238
+ } else {
2239
+ restoreAuthorizedTextAfterCancelledCompositionIfNeeded()
2240
+ }
2241
+ return
2242
+ }
2243
+
2244
+ performTransientTextMutation {
2245
+ super.setMarkedText(markedText, selectedRange: selectedRange)
1995
2246
  }
2247
+ refreshMarkedTextCompositionText(fallback: markedText)
1996
2248
  }
1997
2249
 
1998
2250
  /// Called when composition is finalized (user selects a candidate or
@@ -2013,6 +2265,8 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2013
2265
  "[unmarkText] composed=\(self.preview(composed), privacy: .public) replacement=\(self.previewMarkedTextReplacementRange(replacementRange), privacy: .public) selection=\(self.selectionSummary(), privacy: .public)"
2014
2266
  )
2015
2267
  commitMarkedText(composed, replacementRange: replacementRange)
2268
+ } else if shouldCommitMarkedText(composedText, replacementRange: replacementRange) {
2269
+ commitMarkedText(composedText ?? "", replacementRange: replacementRange)
2016
2270
  } else {
2017
2271
  restoreAuthorizedTextAfterCancelledCompositionIfNeeded()
2018
2272
  }
@@ -2023,9 +2277,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2023
2277
 
2024
2278
  guard let selectedRange = selectedTextRange else {
2025
2279
  let scalarPos = PositionBridge.cursorScalarOffset(in: self)
2280
+ let utf16Pos = PositionBridge.scalarToUtf16Offset(scalarPos, in: lastAuthorizedText)
2026
2281
  markedTextReplacementScalarRange = (from: scalarPos, to: scalarPos)
2027
2282
  markedTextReplacementUtf16Range = NSRange(
2028
- location: Int(scalarPos),
2283
+ location: utf16Pos,
2029
2284
  length: 0
2030
2285
  )
2031
2286
  return
@@ -2055,6 +2310,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2055
2310
  markedTextReplacementScalarRange = nil
2056
2311
  markedTextReplacementUtf16Range = nil
2057
2312
  markedTextCompositionText = nil
2313
+ markedTextCompositionIsExplicitlyEmpty = false
2058
2314
  isComposing = false
2059
2315
  }
2060
2316
 
@@ -2073,14 +2329,30 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2073
2329
  }
2074
2330
 
2075
2331
  private func currentMarkedTextForCommit() -> String? {
2076
- markedTextRange.flatMap { text(in: $0) }
2332
+ if markedTextCompositionIsExplicitlyEmpty { return "" }
2333
+ return transientMarkedTextFromAuthorizedDiff()
2334
+ ?? markedTextRange.flatMap { text(in: $0) }
2077
2335
  ?? markedTextCompositionText
2078
- ?? transientMarkedTextFromAuthorizedDiff()
2336
+ }
2337
+
2338
+ private func validatedTrackedMarkedTextForCommit() -> String? {
2339
+ guard markedTextReplacementScalarRange != nil || markedTextReplacementUtf16Range != nil else {
2340
+ return nil
2341
+ }
2342
+ if markedTextCompositionIsExplicitlyEmpty { return "" }
2343
+ return transientMarkedTextFromAuthorizedDiff()
2344
+ ?? markedTextRange.flatMap { text(in: $0) }
2079
2345
  }
2080
2346
 
2081
2347
  private func refreshMarkedTextCompositionText(fallback: String? = nil) {
2082
- markedTextCompositionText = markedTextRange.flatMap { text(in: $0) }
2083
- ?? transientMarkedTextFromAuthorizedDiff()
2348
+ if fallback?.isEmpty == true {
2349
+ markedTextCompositionText = ""
2350
+ markedTextCompositionIsExplicitlyEmpty = true
2351
+ return
2352
+ }
2353
+ markedTextCompositionIsExplicitlyEmpty = false
2354
+ markedTextCompositionText = transientMarkedTextFromAuthorizedDiff()
2355
+ ?? markedTextRange.flatMap { text(in: $0) }
2084
2356
  ?? fallback
2085
2357
  }
2086
2358
 
@@ -2103,6 +2375,27 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2103
2375
  return nil
2104
2376
  }
2105
2377
 
2378
+ if replacementRange.location > 0 {
2379
+ let prefixRange = NSRange(location: 0, length: replacementRange.location)
2380
+ guard currentText.substring(with: prefixRange) == authorizedText.substring(with: prefixRange) else {
2381
+ return nil
2382
+ }
2383
+ }
2384
+
2385
+ let insertedEnd = replacementRange.location + insertedLength
2386
+ let authorizedSuffixLength = authorizedText.length - replacementEnd
2387
+ let currentSuffixLength = currentText.length - insertedEnd
2388
+ guard authorizedSuffixLength == currentSuffixLength else { return nil }
2389
+ if authorizedSuffixLength > 0 {
2390
+ let authorizedSuffixRange = NSRange(location: replacementEnd, length: authorizedSuffixLength)
2391
+ let currentSuffixRange = NSRange(location: insertedEnd, length: currentSuffixLength)
2392
+ guard currentText.substring(with: currentSuffixRange)
2393
+ == authorizedText.substring(with: authorizedSuffixRange)
2394
+ else {
2395
+ return nil
2396
+ }
2397
+ }
2398
+
2106
2399
  return currentText.substring(
2107
2400
  with: NSRange(location: replacementRange.location, length: insertedLength)
2108
2401
  )
@@ -2114,13 +2407,13 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2114
2407
  ) {
2115
2408
  guard editorId != 0 else { return }
2116
2409
  guard let replacementRange else {
2117
- performInterceptedInput {
2410
+ performInterceptedInput(flushPendingNativeTextMutation: false) {
2118
2411
  insertTextInRust(text, at: PositionBridge.cursorScalarOffset(in: self))
2119
2412
  }
2120
2413
  return
2121
2414
  }
2122
2415
 
2123
- performInterceptedInput {
2416
+ performInterceptedInput(flushPendingNativeTextMutation: false) {
2124
2417
  if replacementRange.from == replacementRange.to {
2125
2418
  insertTextInRust(text, at: replacementRange.from)
2126
2419
  } else {
@@ -2133,6 +2426,16 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2133
2426
  }
2134
2427
  }
2135
2428
 
2429
+ private func shouldCommitMarkedText(
2430
+ _ text: String?,
2431
+ replacementRange: (from: UInt32, to: UInt32)?
2432
+ ) -> Bool {
2433
+ guard let text else { return false }
2434
+ if !text.isEmpty { return true }
2435
+ guard let replacementRange else { return false }
2436
+ return replacementRange.from != replacementRange.to
2437
+ }
2438
+
2136
2439
  private func restoreAuthorizedTextAfterCancelledCompositionIfNeeded() {
2137
2440
  guard editorId != 0 else { return }
2138
2441
  guard textStorage.string != lastAuthorizedText else { return }
@@ -2163,6 +2466,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2163
2466
  super.paste(sender)
2164
2467
  return
2165
2468
  }
2469
+ guard prepareForExternalEditorUpdate() else { return }
2166
2470
 
2167
2471
  Self.inputLog.debug(
2168
2472
  "[paste] selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
@@ -2192,7 +2496,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2192
2496
  documentAttributes: [.documentType: NSAttributedString.DocumentType.html]
2193
2497
  ), let html = String(data: htmlData, encoding: .utf8) {
2194
2498
  performInterceptedInput {
2195
- pasteHTML(html)
2499
+ if !pasteHTML(html, detectContentChange: true),
2500
+ !attrStr.string.isEmpty {
2501
+ pastePlainText(attrStr.string)
2502
+ }
2196
2503
  }
2197
2504
  return
2198
2505
  }
@@ -2218,10 +2525,18 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2218
2525
  func textViewDidChangeSelection(_ textView: UITextView) {
2219
2526
  guard textView === self else { return }
2220
2527
  ensureInternalTextViewDelegate()
2221
- guard !isApplyingRustState, !isComposing, !nativeTextMutationCommitScheduled else { return }
2528
+ noteSelectionDidChange()
2529
+ guard !isApplyingRustState,
2530
+ !isComposing,
2531
+ !nativeTextMutationCommitScheduled,
2532
+ pendingNativeTextMutation == nil
2533
+ else {
2534
+ return
2535
+ }
2222
2536
  if normalizeSelectionForEmptyBlockAutocapitalizationIfNeeded() {
2223
2537
  return
2224
2538
  }
2539
+ recordAuthorizedSelectionIfPossible()
2225
2540
  refreshNativeSelectionChromeVisibility()
2226
2541
  onSelectionOrContentMayChange?()
2227
2542
  scheduleSelectionSync()
@@ -2258,6 +2573,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2258
2573
  }
2259
2574
 
2260
2575
  selectedTextRange = textRange(from: start, to: end)
2576
+ noteSelectionDidChange()
2261
2577
  refreshNativeSelectionChromeVisibility()
2262
2578
  onSelectionOrContentMayChange?()
2263
2579
  scheduleSelectionSync()
@@ -2278,7 +2594,13 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2278
2594
  delegate = self
2279
2595
  }
2280
2596
 
2281
- private func performInterceptedInput(_ action: () -> Void) {
2597
+ private func performInterceptedInput(
2598
+ flushPendingNativeTextMutation: Bool = true,
2599
+ _ action: () -> Void
2600
+ ) {
2601
+ if flushPendingNativeTextMutation, interceptedInputDepth == 0 {
2602
+ guard flushPendingNativeTextMutationCommitIfNeeded() else { return }
2603
+ }
2282
2604
  interceptedInputDepth += 1
2283
2605
  Self.inputLog.debug(
2284
2606
  "[intercept.begin] depth=\(self.interceptedInputDepth) selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
@@ -2290,6 +2612,12 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2290
2612
  Self.inputLog.debug(
2291
2613
  "[intercept.end] depth=\(self.interceptedInputDepth) selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
2292
2614
  )
2615
+ if self.interceptedInputDepth == 0 {
2616
+ _ = self.drainPendingNativeTextMutation(
2617
+ allowAfterBlur: false,
2618
+ allowWhileIntercepting: false
2619
+ )
2620
+ }
2293
2621
  }
2294
2622
  }
2295
2623
 
@@ -2402,6 +2730,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2402
2730
  guard !isApplyingRustState,
2403
2731
  !isComposing,
2404
2732
  !nativeTextMutationCommitScheduled,
2733
+ pendingNativeTextMutation == nil,
2405
2734
  editorId != 0
2406
2735
  else {
2407
2736
  return
@@ -2417,13 +2746,16 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2417
2746
  )
2418
2747
 
2419
2748
  editorSetSelectionScalar(id: editorId, scalarAnchor: anchor, scalarHead: head)
2749
+ recordAuthorizedSelectionIfPossible()
2420
2750
  refreshTypingAttributesForSelection()
2421
2751
  editorDelegate?.editorTextView(self, selectionDidChange: docAnchor, head: docHead)
2422
2752
  }
2423
2753
 
2424
- func applyTheme(_ theme: EditorTheme?) {
2425
- self.theme = theme
2754
+ @discardableResult
2755
+ func applyTheme(_ theme: EditorTheme?) -> Bool {
2426
2756
  if editorId != 0 {
2757
+ guard prepareForExternalEditorUpdate() else { return false }
2758
+ self.theme = theme
2427
2759
  let previousOffset = contentOffset
2428
2760
  let stateJSON = editorGetCurrentState(id: editorId)
2429
2761
  applyUpdateJSON(stateJSON, notifyDelegate: false)
@@ -2431,11 +2763,13 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2431
2763
  preserveScrollOffset(previousOffset)
2432
2764
  }
2433
2765
  } else {
2766
+ self.theme = theme
2434
2767
  refreshTypingAttributesForSelection()
2435
2768
  }
2436
2769
  if heightBehavior == .autoGrow {
2437
2770
  notifyHeightChangeIfNeeded(force: true)
2438
2771
  }
2772
+ return true
2439
2773
  }
2440
2774
 
2441
2775
  private func preserveScrollOffset(_ previousOffset: CGPoint) {
@@ -2871,8 +3205,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2871
3205
  }
2872
3206
 
2873
3207
  func performToolbarToggleMark(_ markName: String) {
2874
- guard editorId != 0 else { return }
2875
- guard isEditable else { return }
3208
+ guard prepareForToolbarCommand() else { return }
2876
3209
  guard let selection = currentScalarSelection() else { return }
2877
3210
  performInterceptedInput {
2878
3211
  let updateJSON = editorToggleMarkAtSelectionScalar(
@@ -2886,8 +3219,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2886
3219
  }
2887
3220
 
2888
3221
  func performToolbarToggleList(_ listType: String, isActive: Bool) {
2889
- guard editorId != 0 else { return }
2890
- guard isEditable else { return }
3222
+ guard prepareForToolbarCommand() else { return }
2891
3223
  guard let selection = currentScalarSelection() else { return }
2892
3224
  performInterceptedInput {
2893
3225
  let updateJSON = isActive
@@ -2907,8 +3239,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2907
3239
  }
2908
3240
 
2909
3241
  func performToolbarToggleBlockquote() {
2910
- guard editorId != 0 else { return }
2911
- guard isEditable else { return }
3242
+ guard prepareForToolbarCommand() else { return }
2912
3243
  guard let selection = currentScalarSelection() else { return }
2913
3244
  performInterceptedInput {
2914
3245
  let updateJSON = editorToggleBlockquoteAtSelectionScalar(
@@ -2921,8 +3252,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2921
3252
  }
2922
3253
 
2923
3254
  func performToolbarToggleHeading(_ level: Int) {
2924
- guard editorId != 0 else { return }
2925
- guard isEditable else { return }
3255
+ guard prepareForToolbarCommand() else { return }
2926
3256
  guard let selection = currentScalarSelection() else { return }
2927
3257
  guard let level = UInt8(exactly: level), (1...6).contains(level) else { return }
2928
3258
  performInterceptedInput {
@@ -2937,8 +3267,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2937
3267
  }
2938
3268
 
2939
3269
  func performToolbarIndentListItem() {
2940
- guard editorId != 0 else { return }
2941
- guard isEditable else { return }
3270
+ guard prepareForToolbarCommand() else { return }
2942
3271
  guard let selection = currentScalarSelection() else { return }
2943
3272
  performInterceptedInput {
2944
3273
  let updateJSON = editorIndentListItemAtSelectionScalar(
@@ -2951,8 +3280,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2951
3280
  }
2952
3281
 
2953
3282
  func performToolbarOutdentListItem() {
2954
- guard editorId != 0 else { return }
2955
- guard isEditable else { return }
3283
+ guard prepareForToolbarCommand() else { return }
2956
3284
  guard let selection = currentScalarSelection() else { return }
2957
3285
  performInterceptedInput {
2958
3286
  let updateJSON = editorOutdentListItemAtSelectionScalar(
@@ -2965,16 +3293,14 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2965
3293
  }
2966
3294
 
2967
3295
  func performToolbarInsertNode(_ nodeType: String) {
2968
- guard editorId != 0 else { return }
2969
- guard isEditable else { return }
3296
+ guard prepareForToolbarCommand() else { return }
2970
3297
  performInterceptedInput {
2971
3298
  insertNodeInRust(nodeType)
2972
3299
  }
2973
3300
  }
2974
3301
 
2975
3302
  func performToolbarUndo() {
2976
- guard editorId != 0 else { return }
2977
- guard isEditable else { return }
3303
+ guard prepareForToolbarCommand() else { return }
2978
3304
  performInterceptedInput {
2979
3305
  let updateJSON = editorUndo(id: editorId)
2980
3306
  applyUpdateJSON(updateJSON)
@@ -2982,14 +3308,19 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2982
3308
  }
2983
3309
 
2984
3310
  func performToolbarRedo() {
2985
- guard editorId != 0 else { return }
2986
- guard isEditable else { return }
3311
+ guard prepareForToolbarCommand() else { return }
2987
3312
  performInterceptedInput {
2988
3313
  let updateJSON = editorRedo(id: editorId)
2989
3314
  applyUpdateJSON(updateJSON)
2990
3315
  }
2991
3316
  }
2992
3317
 
3318
+ private func prepareForToolbarCommand() -> Bool {
3319
+ guard editorId != 0 else { return false }
3320
+ guard isEditable else { return false }
3321
+ return prepareForExternalEditorUpdate()
3322
+ }
3323
+
2993
3324
  /// Insert text at a scalar position via the Rust editor.
2994
3325
  private func insertTextInRust(_ text: String, at scalarPos: UInt32) {
2995
3326
  Self.inputLog.debug(
@@ -3026,6 +3357,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3026
3357
  authorized.character(at: prefix) == current.character(at: prefix) {
3027
3358
  prefix += 1
3028
3359
  }
3360
+ prefix = sharedUtf16ScalarBoundary(atOrBefore: prefix, in: authorizedText, and: currentText)
3029
3361
 
3030
3362
  var authorizedEnd = authorized.length
3031
3363
  var currentEnd = current.length
@@ -3035,6 +3367,8 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3035
3367
  authorizedEnd -= 1
3036
3368
  currentEnd -= 1
3037
3369
  }
3370
+ authorizedEnd = utf16ScalarBoundary(atOrAfter: authorizedEnd, in: authorizedText)
3371
+ currentEnd = utf16ScalarBoundary(atOrAfter: currentEnd, in: currentText)
3038
3372
 
3039
3373
  let replacementLength = currentEnd - prefix
3040
3374
  guard replacementLength >= 0 else { return nil }
@@ -3042,16 +3376,378 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3042
3376
  with: NSRange(location: prefix, length: replacementLength)
3043
3377
  )
3044
3378
 
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:))
3390
+ let capturedAfterBlur = canAdoptNativeTextMutationAfterBlur()
3391
+
3045
3392
  return NativeTextMutation(
3046
- from: PositionBridge.utf16OffsetToScalar(prefix, in: authorizedText),
3047
- to: PositionBridge.utf16OffsetToScalar(authorizedEnd, in: authorizedText),
3393
+ from: PositionBridge.utf16OffsetToScalar(prefix, in: lastAuthorizedAttributedTextStorage),
3394
+ to: PositionBridge.utf16OffsetToScalar(authorizedEnd, in: lastAuthorizedAttributedTextStorage),
3048
3395
  replacementText: replacementText,
3049
- resultingText: currentText
3396
+ resultingText: currentText,
3397
+ authorizedText: authorizedText,
3398
+ selectionAnchor: selectedScalarRange?.from,
3399
+ selectionHead: selectedScalarRange?.to,
3400
+ authorizedSelectionUtf16Range: authorizedSelectionUtf16Range,
3401
+ rawSelectionUtf16Range: rawSelectionUtf16Range,
3402
+ selectionRevision: selectionRevision,
3403
+ capturedWhileFirstResponder: isFirstResponder || capturedAfterBlur,
3404
+ capturedWhileEditable: isEditable,
3405
+ capturedAfterBlur: capturedAfterBlur,
3406
+ inputGeneration: nativeTextMutationGeneration
3050
3407
  )
3051
3408
  }
3052
3409
 
3053
- private func shouldAdoptNativeTextStorageMutation() -> Bool {
3054
- isFirstResponder && isEditable
3410
+ private func nativeTextMutationWithCurrentSelection(
3411
+ _ mutation: NativeTextMutation
3412
+ ) -> NativeTextMutation {
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
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
3444
+ return NativeTextMutation(
3445
+ from: mutation.from,
3446
+ to: mutation.to,
3447
+ replacementText: mutation.replacementText,
3448
+ resultingText: mutation.resultingText,
3449
+ authorizedText: mutation.authorizedText,
3450
+ selectionAnchor: selectedScalarRange?.from ?? mutation.selectionAnchor,
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,
3459
+ capturedWhileFirstResponder: mutation.capturedWhileFirstResponder,
3460
+ capturedWhileEditable: mutation.capturedWhileEditable,
3461
+ capturedAfterBlur: mutation.capturedAfterBlur,
3462
+ inputGeneration: mutation.inputGeneration
3463
+ )
3464
+ }
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
+
3564
+ private func isUtf16ScalarBoundary(_ offset: Int, in text: String) -> Bool {
3565
+ guard offset >= 0, offset <= text.utf16.count else { return false }
3566
+ let utf16Index = text.utf16.index(text.utf16.startIndex, offsetBy: offset)
3567
+ return String.Index(utf16Index, within: text) != nil
3568
+ }
3569
+
3570
+ private func utf16ScalarBoundary(atOrBefore offset: Int, in text: String) -> Int {
3571
+ var candidate = min(max(offset, 0), text.utf16.count)
3572
+ while candidate > 0, !isUtf16ScalarBoundary(candidate, in: text) {
3573
+ candidate -= 1
3574
+ }
3575
+ return candidate
3576
+ }
3577
+
3578
+ private func utf16ScalarBoundary(atOrAfter offset: Int, in text: String) -> Int {
3579
+ var candidate = min(max(offset, 0), text.utf16.count)
3580
+ while candidate < text.utf16.count, !isUtf16ScalarBoundary(candidate, in: text) {
3581
+ candidate += 1
3582
+ }
3583
+ return candidate
3584
+ }
3585
+
3586
+ private func sharedUtf16ScalarBoundary(atOrBefore offset: Int, in lhs: String, and rhs: String) -> Int {
3587
+ var candidate = min(max(offset, 0), lhs.utf16.count, rhs.utf16.count)
3588
+ while candidate > 0,
3589
+ (!isUtf16ScalarBoundary(candidate, in: lhs) || !isUtf16ScalarBoundary(candidate, in: rhs)) {
3590
+ candidate -= 1
3591
+ }
3592
+ return candidate
3593
+ }
3594
+
3595
+ private func shouldAdoptNativeTextStorageMutation(
3596
+ _ mutation: NativeTextMutation,
3597
+ allowAfterBlur: Bool = false
3598
+ ) -> Bool {
3599
+ if isFirstResponder && isEditable {
3600
+ return true
3601
+ }
3602
+ return allowAfterBlur
3603
+ && mutation.capturedAfterBlur
3604
+ && mutation.inputGeneration == nativeTextMutationGeneration
3605
+ && canAdoptNativeTextMutationAfterBlur()
3606
+ && mutation.capturedWhileFirstResponder
3607
+ && mutation.capturedWhileEditable
3608
+ }
3609
+
3610
+ private func canAdoptNativeTextMutationAfterBlur() -> Bool {
3611
+ guard let deadline = nativeTextMutationAfterBlurDeadline else {
3612
+ return false
3613
+ }
3614
+ guard nativeTextMutationAfterBlurGeneration == nativeTextMutationGeneration else {
3615
+ clearNativeTextMutationAfterBlurWindow()
3616
+ return false
3617
+ }
3618
+ guard ProcessInfo.processInfo.systemUptime <= deadline else {
3619
+ clearNativeTextMutationAfterBlurWindow()
3620
+ return false
3621
+ }
3622
+ return true
3623
+ }
3624
+
3625
+ private func clearNativeTextMutationAfterBlurWindow() {
3626
+ nativeTextMutationAfterBlurDeadline = nil
3627
+ nativeTextMutationAfterBlurGeneration = nil
3628
+ }
3629
+
3630
+ private func advanceNativeTextMutationGeneration() {
3631
+ nativeTextMutationGeneration &+= 1
3632
+ clearNativeTextMutationAfterBlurWindow()
3633
+ }
3634
+
3635
+ private func resetPendingNativeTextMutationState() {
3636
+ pendingNativeTextMutation = nil
3637
+ nativeTextMutationCommitScheduled = false
3638
+ advanceNativeTextMutationGeneration()
3639
+ }
3640
+
3641
+ func expireNativeTextMutationAfterBlurDeadlineForTesting() {
3642
+ nativeTextMutationAfterBlurDeadline = ProcessInfo.processInfo.systemUptime - 0.001
3643
+ }
3644
+
3645
+ func discardTransientNativeInputForEditorRebind() {
3646
+ resetPendingNativeTextMutationState()
3647
+ lastAuthorizedSelectedUtf16Range = nil
3648
+ clearPendingInputTraitRetry()
3649
+ markedTextReplacementScalarRange = nil
3650
+ markedTextReplacementUtf16Range = nil
3651
+ markedTextCompositionText = nil
3652
+ markedTextCompositionIsExplicitlyEmpty = false
3653
+ isComposing = false
3654
+ }
3655
+
3656
+ @discardableResult
3657
+ private func flushPendingNativeTextMutationCommitIfNeeded() -> Bool {
3658
+ drainPendingNativeTextMutation(
3659
+ allowAfterBlur: false,
3660
+ allowWhileIntercepting: true
3661
+ )
3662
+ }
3663
+
3664
+ @discardableResult
3665
+ func prepareForExternalEditorUpdate() -> Bool {
3666
+ guard prepareActiveCompositionForExternalMutation() else { return false }
3667
+ return drainPendingNativeTextMutation(
3668
+ allowAfterBlur: true,
3669
+ allowWhileIntercepting: true
3670
+ )
3671
+ }
3672
+
3673
+ @discardableResult
3674
+ func prepareForExternalEditorCommand() -> (ready: Bool, updateJSON: String?, blockedReason: String?) {
3675
+ let previousEditorId = editorId
3676
+ let previousAuthorizedText = lastAuthorizedText
3677
+ let previousStateJSON = previousEditorId != 0 ? editorGetCurrentState(id: previousEditorId) : nil
3678
+ guard prepareForExternalEditorUpdate() else {
3679
+ return (false, nil, "composition")
3680
+ }
3681
+ guard editorId != 0 else {
3682
+ return (true, nil, nil)
3683
+ }
3684
+ let currentStateJSON = editorGetCurrentState(id: editorId)
3685
+ guard lastAuthorizedText != previousAuthorizedText
3686
+ || previousEditorId != editorId
3687
+ || previousStateJSON != currentStateJSON
3688
+ else {
3689
+ return (true, nil, nil)
3690
+ }
3691
+ return (true, currentStateJSON, nil)
3692
+ }
3693
+
3694
+ private func prepareActiveCompositionForExternalMutation() -> Bool {
3695
+ guard isComposing else { return true }
3696
+
3697
+ let composedText = validatedTrackedMarkedTextForCommit()
3698
+ let replacementRange = trackedMarkedTextReplacementRange()
3699
+ finishTransientMarkedTextMutation()
3700
+
3701
+ guard shouldCommitMarkedText(composedText, replacementRange: replacementRange) else {
3702
+ restoreAuthorizedTextAfterCancelledCompositionIfNeeded()
3703
+ return false
3704
+ }
3705
+
3706
+ commitMarkedText(composedText ?? "", replacementRange: replacementRange)
3707
+ return true
3708
+ }
3709
+
3710
+ @discardableResult
3711
+ private func drainPendingNativeTextMutation(
3712
+ allowAfterBlur: Bool,
3713
+ allowWhileIntercepting: Bool
3714
+ ) -> Bool {
3715
+ guard nativeTextMutationCommitScheduled
3716
+ || pendingNativeTextMutation != nil
3717
+ || (!isComposing && markedTextRange == nil && textStorage.string != lastAuthorizedText)
3718
+ else {
3719
+ return true
3720
+ }
3721
+
3722
+ nativeTextMutationCommitScheduled = false
3723
+ let currentText = textStorage.string
3724
+ let mutation: NativeTextMutation?
3725
+ if let pendingNativeTextMutation,
3726
+ pendingNativeTextMutation.resultingText == currentText,
3727
+ pendingNativeTextMutation.authorizedText == lastAuthorizedText
3728
+ {
3729
+ mutation = nativeTextMutationWithCurrentSelection(pendingNativeTextMutation)
3730
+ } else {
3731
+ mutation = nativeTextMutationFromAuthorizedDiff(currentText: currentText)
3732
+ }
3733
+
3734
+ guard let mutation else {
3735
+ pendingNativeTextMutation = nil
3736
+ return true
3737
+ }
3738
+
3739
+ switch commitNativeTextMutationIfPossible(
3740
+ mutation,
3741
+ allowAfterBlur: allowAfterBlur,
3742
+ allowWhileIntercepting: allowWhileIntercepting
3743
+ ) {
3744
+ case .committed, .rejected:
3745
+ pendingNativeTextMutation = nil
3746
+ return true
3747
+ case .deferred:
3748
+ pendingNativeTextMutation = mutation
3749
+ return false
3750
+ }
3055
3751
  }
3056
3752
 
3057
3753
  private func scheduleNativeTextMutationCommit(_ mutation: NativeTextMutation) {
@@ -3061,43 +3757,101 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3061
3757
  nativeTextMutationCommitScheduled = true
3062
3758
  DispatchQueue.main.async { [weak self] in
3063
3759
  guard let self else { return }
3064
- self.nativeTextMutationCommitScheduled = false
3065
- guard let mutation = self.pendingNativeTextMutation else { return }
3066
- self.pendingNativeTextMutation = nil
3067
-
3068
- guard self.editorId != 0,
3069
- !self.isApplyingRustState,
3070
- !self.isInterceptingInput,
3071
- !self.isComposing,
3072
- self.shouldAdoptNativeTextStorageMutation()
3073
- else {
3074
- if self.textStorage.string != self.lastAuthorizedText {
3075
- self.scheduleReconciliationFromRust()
3076
- }
3077
- return
3760
+ _ = self.drainPendingNativeTextMutation(
3761
+ allowAfterBlur: true,
3762
+ allowWhileIntercepting: true
3763
+ )
3764
+ }
3765
+ }
3766
+
3767
+ @discardableResult
3768
+ private func commitNativeTextMutationIfPossible(
3769
+ _ mutation: NativeTextMutation,
3770
+ allowAfterBlur: Bool,
3771
+ allowWhileIntercepting: Bool
3772
+ ) -> NativeTextMutationCommitResult {
3773
+ guard editorId != 0 else {
3774
+ return .rejected
3775
+ }
3776
+
3777
+ guard !isApplyingRustState,
3778
+ (!isInterceptingInput || allowWhileIntercepting),
3779
+ !isComposing
3780
+ else {
3781
+ return .deferred
3782
+ }
3783
+
3784
+ guard shouldAdoptNativeTextStorageMutation(mutation, allowAfterBlur: allowAfterBlur) else {
3785
+ if textStorage.string != lastAuthorizedText {
3786
+ scheduleReconciliationFromRust()
3078
3787
  }
3079
- guard self.textStorage.string == mutation.resultingText else {
3080
- if self.textStorage.string != self.lastAuthorizedText {
3081
- self.scheduleReconciliationFromRust()
3082
- }
3083
- return
3788
+ return .rejected
3789
+ }
3790
+
3791
+ guard textStorage.string == mutation.resultingText else {
3792
+ if let refreshedMutation = nativeTextMutationFromAuthorizedDiff(currentText: textStorage.string) {
3793
+ return commitNativeTextMutationIfPossible(
3794
+ refreshedMutation,
3795
+ allowAfterBlur: allowAfterBlur,
3796
+ allowWhileIntercepting: allowWhileIntercepting
3797
+ )
3084
3798
  }
3799
+ return .rejected
3800
+ }
3085
3801
 
3086
- self.performInterceptedInput {
3087
- if mutation.from == mutation.to {
3088
- guard !mutation.replacementText.isEmpty else { return }
3089
- self.insertTextInRust(mutation.replacementText, at: mutation.from)
3090
- } else if mutation.replacementText.isEmpty {
3091
- self.deleteScalarRangeInRust(from: mutation.from, to: mutation.to)
3092
- } else {
3093
- self.replaceTextRangeInRust(
3094
- from: mutation.from,
3095
- to: mutation.to,
3096
- with: mutation.replacementText
3097
- )
3098
- }
3802
+ guard mutation.authorizedText == lastAuthorizedText else {
3803
+ if let refreshedMutation = nativeTextMutationFromAuthorizedDiff(currentText: textStorage.string) {
3804
+ return commitNativeTextMutationIfPossible(
3805
+ refreshedMutation,
3806
+ allowAfterBlur: allowAfterBlur,
3807
+ allowWhileIntercepting: allowWhileIntercepting
3808
+ )
3809
+ }
3810
+ return .rejected
3811
+ }
3812
+
3813
+ performInterceptedInput(flushPendingNativeTextMutation: false) {
3814
+ if mutation.from == mutation.to {
3815
+ guard !mutation.replacementText.isEmpty else { return }
3816
+ insertTextInRust(mutation.replacementText, at: mutation.from)
3817
+ } else if mutation.replacementText.isEmpty {
3818
+ deleteScalarRangeInRust(from: mutation.from, to: mutation.to)
3819
+ } else {
3820
+ replaceTextRangeInRust(
3821
+ from: mutation.from,
3822
+ to: mutation.to,
3823
+ with: mutation.replacementText
3824
+ )
3099
3825
  }
3826
+ restoreNativeTextMutationSelectionIfNeeded(mutation)
3100
3827
  }
3828
+ if mutation.capturedAfterBlur {
3829
+ clearNativeTextMutationAfterBlurWindow()
3830
+ }
3831
+ return .committed
3832
+ }
3833
+
3834
+ private func restoreNativeTextMutationSelectionIfNeeded(_ mutation: NativeTextMutation) {
3835
+ guard let anchor = mutation.selectionAnchor,
3836
+ let head = mutation.selectionHead,
3837
+ editorId != 0
3838
+ else {
3839
+ return
3840
+ }
3841
+
3842
+ let startUtf16 = PositionBridge.scalarToUtf16Offset(min(anchor, head), in: self)
3843
+ let endUtf16 = PositionBridge.scalarToUtf16Offset(max(anchor, head), in: self)
3844
+ let targetRange = NSRange(location: startUtf16, length: max(0, endUtf16 - startUtf16))
3845
+ if selectedRange != targetRange {
3846
+ selectedRange = targetRange
3847
+ noteSelectionDidChange()
3848
+ }
3849
+ editorSetSelectionScalar(id: editorId, scalarAnchor: anchor, scalarHead: head)
3850
+ recordAuthorizedSelectionIfPossible()
3851
+ refreshTypingAttributesForSelection()
3852
+ let docAnchor = editorScalarToDoc(id: editorId, scalar: anchor)
3853
+ let docHead = editorScalarToDoc(id: editorId, scalar: head)
3854
+ editorDelegate?.editorTextView(self, selectionDidChange: docAnchor, head: docHead)
3101
3855
  }
3102
3856
 
3103
3857
  private func insertNodeInRust(_ nodeType: String) {
@@ -3281,12 +4035,24 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3281
4035
  }
3282
4036
 
3283
4037
  /// Paste HTML content through Rust.
3284
- private func pasteHTML(_ html: String) {
4038
+ @discardableResult
4039
+ private func pasteHTML(_ html: String, detectContentChange: Bool = false) -> Bool {
4040
+ let previousHTML = detectContentChange ? editorGetHtml(id: editorId) : nil
4041
+ syncCurrentUIKitSelectionToRust()
3285
4042
  Self.inputLog.debug(
3286
4043
  "[rust.pasteHTML] html=\(self.preview(html), privacy: .public) selection=\(self.selectionSummary(), privacy: .public)"
3287
4044
  )
3288
4045
  let updateJSON = editorInsertContentHtml(id: editorId, html: html)
3289
4046
  applyUpdateJSON(updateJSON)
4047
+ guard let previousHTML else { return true }
4048
+ return editorGetHtml(id: editorId) != previousHTML
4049
+ }
4050
+
4051
+ private func syncCurrentUIKitSelectionToRust() {
4052
+ guard editorId != 0, let range = selectedTextRange else { return }
4053
+ let anchor = PositionBridge.textViewToScalar(range.start, in: self)
4054
+ let head = PositionBridge.textViewToScalar(range.end, in: self)
4055
+ editorSetSelectionScalar(id: editorId, scalarAnchor: anchor, scalarHead: head)
3290
4056
  }
3291
4057
 
3292
4058
  /// Paste plain text through Rust.
@@ -3577,7 +4343,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3577
4343
  _ attrStr: NSAttributedString,
3578
4344
  replaceRange: NSRange? = nil,
3579
4345
  usedPatch: Bool,
3580
- positionCacheUpdate: PositionCacheUpdate = .scan
4346
+ positionCacheUpdate: PositionCacheUpdate = .scan,
4347
+ authorizedReplaceRange: NSRange? = nil,
4348
+ authorizedReplacementText: String? = nil,
4349
+ authorizedReplacementAttributedText: NSAttributedString? = nil
3581
4350
  ) -> ApplyRenderTrace {
3582
4351
  let totalStartedAt = DispatchTime.now().uptimeNanoseconds
3583
4352
  let replaceUtf16Length = replaceRange?.length ?? textStorage.length
@@ -3626,13 +4395,27 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3626
4395
  let endEditingNanos = DispatchTime.now().uptimeNanoseconds - endEditingStartedAt
3627
4396
  let textMutationNanos = DispatchTime.now().uptimeNanoseconds - textMutationStartedAt
3628
4397
  let authorizedTextStartedAt = DispatchTime.now().uptimeNanoseconds
3629
- if let replaceRange,
3630
- replaceRange.location >= 0,
3631
- replaceRange.location + replaceRange.length <= lastAuthorizedTextStorage.length
4398
+ let snapshotReplaceRange = authorizedReplaceRange ?? replaceRange
4399
+ let snapshotReplacementText = authorizedReplacementText ?? attrStr.string
4400
+ let snapshotReplacementAttributedText = authorizedReplacementAttributedText ?? attrStr
4401
+ if let snapshotReplaceRange,
4402
+ snapshotReplaceRange.location >= 0,
4403
+ snapshotReplaceRange.location + snapshotReplaceRange.length <= lastAuthorizedTextStorage.length
3632
4404
  {
3633
- lastAuthorizedTextStorage.replaceCharacters(in: replaceRange, with: attrStr.string)
4405
+ lastAuthorizedTextStorage.replaceCharacters(
4406
+ in: snapshotReplaceRange,
4407
+ with: snapshotReplacementText
4408
+ )
4409
+ lastAuthorizedAttributedTextStorage.replaceCharacters(
4410
+ in: snapshotReplaceRange,
4411
+ with: snapshotReplacementAttributedText
4412
+ )
3634
4413
  } else {
3635
- lastAuthorizedTextStorage.setString(attrStr.string)
4414
+ lastAuthorizedTextStorage.setString(replaceRange == nil ? snapshotReplacementText : textStorage.string)
4415
+ let fallbackAttributedSnapshot = replaceRange == nil
4416
+ ? snapshotReplacementAttributedText
4417
+ : NSAttributedString(attributedString: textStorage)
4418
+ lastAuthorizedAttributedTextStorage.setAttributedString(fallbackAttributedSnapshot)
3636
4419
  }
3637
4420
  let authorizedTextNanos = DispatchTime.now().uptimeNanoseconds - authorizedTextStartedAt
3638
4421
  let cacheInvalidationStartedAt = DispatchTime.now().uptimeNanoseconds
@@ -3932,8 +4715,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3932
4715
  }
3933
4716
 
3934
4717
  let existing = textStorage.attributedSubstring(from: fullReplaceRange)
3935
- let existingString = existing.string as NSString
3936
- let replacementString = replacement.string as NSString
4718
+ let existingRawString = existing.string
4719
+ let replacementRawString = replacement.string
4720
+ let existingString = existingRawString as NSString
4721
+ let replacementString = replacementRawString as NSString
3937
4722
  let sharedLength = min(existing.length, replacement.length)
3938
4723
 
3939
4724
  var prefix = 0
@@ -3996,6 +4781,17 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3996
4781
  break
3997
4782
  }
3998
4783
  }
4784
+ prefix = sharedUtf16ScalarBoundary(atOrBefore: prefix, in: existingRawString, and: replacementRawString)
4785
+ while suffix > 0 {
4786
+ let existingSuffixStart = existing.length - suffix
4787
+ let replacementSuffixStart = replacement.length - suffix
4788
+ if suffix <= sharedLength - prefix,
4789
+ isUtf16ScalarBoundary(existingSuffixStart, in: existingRawString),
4790
+ isUtf16ScalarBoundary(replacementSuffixStart, in: replacementRawString) {
4791
+ break
4792
+ }
4793
+ suffix -= 1
4794
+ }
3999
4795
 
4000
4796
  guard prefix > 0 || suffix > 0 else {
4001
4797
  return (fullReplaceRange, replacement)
@@ -4128,7 +4924,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
4128
4924
  patchToApply.replacement,
4129
4925
  replaceRange: patchToApply.replaceRange,
4130
4926
  usedPatch: true,
4131
- positionCacheUpdate: positionCacheUpdate
4927
+ positionCacheUpdate: positionCacheUpdate,
4928
+ authorizedReplaceRange: fullReplaceRange,
4929
+ authorizedReplacementText: attrStr.string,
4930
+ authorizedReplacementAttributedText: attrStr
4132
4931
  )
4133
4932
  let metadataStartedAt = DispatchTime.now().uptimeNanoseconds
4134
4933
  applyTopLevelChildMetadataPatch(
@@ -4172,6 +4971,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
4172
4971
  let update = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
4173
4972
  else { return }
4174
4973
  let parseNanos = DispatchTime.now().uptimeNanoseconds - parseStartedAt
4974
+ resetPendingNativeTextMutationState()
4175
4975
 
4176
4976
  let renderElements = update["renderElements"] as? [[String: Any]]
4177
4977
  let selectionFromUpdate = (update["selection"] as? [String: Any])
@@ -4337,6 +5137,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
4337
5137
  postApplyTrace.selectionOrContentCallbackNanos
4338
5138
  )
4339
5139
  }
5140
+ recordAuthorizedSelectionIfPossible()
4340
5141
  Self.updateLog.debug(
4341
5142
  "[applyUpdateJSON.end] finalSelection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
4342
5143
  )
@@ -4353,6 +5154,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
4353
5154
  /// elements directly, not wrapped in an EditorUpdate).
4354
5155
  func applyRenderJSON(_ renderJSON: String) {
4355
5156
  ensureInternalTextViewDelegate()
5157
+ resetPendingNativeTextMutationState()
4356
5158
  Self.updateLog.debug(
4357
5159
  "[applyRenderJSON.begin] before=\(self.textSnapshotSummary(), privacy: .public)"
4358
5160
  )
@@ -4368,6 +5170,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
4368
5170
 
4369
5171
  refreshPlaceholderVisibility()
4370
5172
  _ = performPostApplyMaintenance()
5173
+ recordAuthorizedSelectionIfPossible()
4371
5174
  Self.updateLog.debug(
4372
5175
  "[applyRenderJSON.end] after=\(self.textSnapshotSummary(), privacy: .public)"
4373
5176
  )
@@ -4425,17 +5228,20 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
4425
5228
  let adjustedRange = NSRange(location: adjustedOffset, length: 0)
4426
5229
  if selectedRange != adjustedRange {
4427
5230
  selectedRange = adjustedRange
5231
+ noteSelectionDidChange()
4428
5232
  }
4429
5233
  } else {
4430
5234
  let targetRange = NSRange(location: endUtf16, length: 0)
4431
5235
  if selectedRange != targetRange {
4432
5236
  selectedRange = targetRange
5237
+ noteSelectionDidChange()
4433
5238
  }
4434
5239
  }
4435
5240
  } else {
4436
5241
  let targetRange = NSRange(location: startUtf16, length: endUtf16 - startUtf16)
4437
5242
  if selectedRange != targetRange {
4438
5243
  selectedRange = targetRange
5244
+ noteSelectionDidChange()
4439
5245
  }
4440
5246
  }
4441
5247
  let assignmentNanos = DispatchTime.now().uptimeNanoseconds - assignmentStartedAt
@@ -4467,6 +5273,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
4467
5273
  let assignmentStartedAt = DispatchTime.now().uptimeNanoseconds
4468
5274
  if selectedRange != targetRange {
4469
5275
  selectedRange = targetRange
5276
+ noteSelectionDidChange()
4470
5277
  }
4471
5278
  let assignmentNanos = DispatchTime.now().uptimeNanoseconds - assignmentStartedAt
4472
5279
  let chromeStartedAt = DispatchTime.now().uptimeNanoseconds
@@ -4485,6 +5292,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
4485
5292
  case "all":
4486
5293
  let assignmentStartedAt = DispatchTime.now().uptimeNanoseconds
4487
5294
  selectedTextRange = textRange(from: beginningOfDocument, to: endOfDocument)
5295
+ noteSelectionDidChange()
4488
5296
  let assignmentNanos = DispatchTime.now().uptimeNanoseconds - assignmentStartedAt
4489
5297
  let chromeStartedAt = DispatchTime.now().uptimeNanoseconds
4490
5298
  showNativeSelectionChromeIfNeeded()
@@ -4536,19 +5344,30 @@ extension EditorTextView: NSTextStorageDelegate {
4536
5344
  // Only care about actual character edits, not attribute-only changes.
4537
5345
  guard editedMask.contains(.editedCharacters) else { return }
4538
5346
 
4539
- // Skip if this change came from our own Rust apply path.
4540
- guard !isApplyingRustState, !isInterceptingInput, !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 }
4541
5352
 
4542
5353
  // Skip if no editor is bound yet (nothing to reconcile against).
4543
5354
  guard editorId != 0 else { return }
4544
5355
 
5356
+ PositionBridge.invalidateCache(for: self)
5357
+
4545
5358
  // Compare current text storage content against last authorized snapshot.
4546
5359
  let currentText = textStorage.string
4547
5360
  guard currentText != lastAuthorizedText else { return }
4548
5361
  currentTopLevelChildMetadata = nil
4549
5362
 
4550
- if shouldAdoptNativeTextStorageMutation(),
4551
- let mutation = nativeTextMutationFromAuthorizedDiff(currentText: currentText) {
5363
+ let allowAfterBlur = canAdoptNativeTextMutationAfterBlur()
5364
+ if let mutation = nativeTextMutationFromAuthorizedDiff(currentText: currentText),
5365
+ isInterceptingInput
5366
+ || shouldAdoptNativeTextStorageMutation(
5367
+ mutation,
5368
+ allowAfterBlur: allowAfterBlur
5369
+ )
5370
+ {
4552
5371
  scheduleNativeTextMutationCommit(mutation)
4553
5372
  return
4554
5373
  }
@@ -4695,6 +5514,8 @@ final class RichTextEditorView: UIView {
4695
5514
  /// The Rust editor instance ID. Setting this binds/unbinds the editor.
4696
5515
  var editorId: UInt64 = 0 {
4697
5516
  didSet {
5517
+ guard oldValue != editorId else { return }
5518
+ textView.discardTransientNativeInputForEditorRebind()
4698
5519
  if editorId != 0 {
4699
5520
  textView.bindEditor(id: editorId)
4700
5521
  } else {
@@ -4815,12 +5636,14 @@ final class RichTextEditorView: UIView {
4815
5636
  textView.backgroundColor = backgroundColor
4816
5637
  }
4817
5638
 
4818
- func applyTheme(_ theme: EditorTheme?) {
4819
- textView.applyTheme(theme)
5639
+ @discardableResult
5640
+ func applyTheme(_ theme: EditorTheme?) -> Bool {
5641
+ guard textView.applyTheme(theme) else { return false }
4820
5642
  let cornerRadius = theme?.borderRadius ?? 0
4821
5643
  layer.cornerRadius = cornerRadius
4822
5644
  clipsToBounds = cornerRadius > 0
4823
5645
  refreshOverlays()
5646
+ return true
4824
5647
  }
4825
5648
 
4826
5649
  func setRemoteSelections(_ selections: [RemoteSelectionDecoration]) {