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

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,19 @@ 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 capturedWhileFirstResponder: Bool
831
+ let capturedWhileEditable: Bool
832
+ let capturedAfterBlur: Bool
833
+ let inputGeneration: UInt64
834
+ }
835
+
836
+ private enum NativeTextMutationCommitResult {
837
+ case committed
838
+ case deferred
839
+ case rejected
827
840
  }
828
841
 
829
842
  private enum PositionCacheUpdate {
@@ -922,6 +935,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
922
935
  /// The plain text from the last Rust render, used by the reconciliation
923
936
  /// fallback to detect unauthorized text storage mutations.
924
937
  private var lastAuthorizedTextStorage = NSMutableString()
938
+ private var lastAuthorizedAttributedTextStorage = NSMutableAttributedString()
925
939
  private var lastAuthorizedText: String {
926
940
  lastAuthorizedTextStorage as String
927
941
  }
@@ -973,6 +987,15 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
973
987
  private var reconciliationWorkScheduled = false
974
988
  private var nativeTextMutationCommitScheduled = false
975
989
  private var pendingNativeTextMutation: NativeTextMutation?
990
+ private var nativeTextMutationGeneration: UInt64 = 0
991
+ private var nativeTextMutationAfterBlurDeadline: TimeInterval?
992
+ private var nativeTextMutationAfterBlurGeneration: UInt64?
993
+ private let nativeTextMutationAfterBlurGraceInterval: TimeInterval = 1.0
994
+ private var desiredInputTraitState = InputTraitState()
995
+ private var appliedInputTraitState = InputTraitState()
996
+ private var pendingInputTraitChange = PendingInputTraitChange()
997
+ private var pendingInputTraitRetryScheduled = false
998
+ private var pendingInputTraitRetryGeneration: UInt64 = 0
976
999
 
977
1000
  /// Coalesces selection sync until UIKit has finished resolving the
978
1001
  /// current tap/drag gesture's final caret position.
@@ -986,9 +1009,29 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
986
1009
  private var markedTextReplacementScalarRange: (from: UInt32, to: UInt32)?
987
1010
  private var markedTextReplacementUtf16Range: NSRange?
988
1011
  private var markedTextCompositionText: String?
1012
+ private var markedTextCompositionIsExplicitlyEmpty = false
989
1013
 
990
1014
  private let editorLayoutManager: EditorLayoutManager
991
1015
 
1016
+ private struct InputTraitState {
1017
+ var autoCapitalize: String?
1018
+ var autoCorrect: Bool?
1019
+ var keyboardType: String?
1020
+ }
1021
+
1022
+ private struct PendingInputTraitChange {
1023
+ var hasAutoCapitalize = false
1024
+ var autoCapitalize: String?
1025
+ var hasAutoCorrect = false
1026
+ var autoCorrect: Bool?
1027
+ var hasKeyboardType = false
1028
+ var keyboardType: String?
1029
+
1030
+ var isEmpty: Bool {
1031
+ !hasAutoCapitalize && !hasAutoCorrect && !hasKeyboardType
1032
+ }
1033
+ }
1034
+
992
1035
  // MARK: - Placeholder
993
1036
 
994
1037
  private lazy var placeholderLabel: UILabel = {
@@ -1073,6 +1116,19 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1073
1116
  }
1074
1117
 
1075
1118
  func setAutoCapitalize(_ autoCapitalize: String?) {
1119
+ desiredInputTraitState.autoCapitalize = autoCapitalize
1120
+ guard prepareForInputTraitChange() else {
1121
+ pendingInputTraitChange.hasAutoCapitalize = true
1122
+ pendingInputTraitChange.autoCapitalize = autoCapitalize
1123
+ scheduleInputTraitChangeRetry()
1124
+ return
1125
+ }
1126
+ applyAutoCapitalize(autoCapitalize)
1127
+ appliedInputTraitState.autoCapitalize = autoCapitalize
1128
+ clearPendingAutoCapitalize()
1129
+ }
1130
+
1131
+ private func applyAutoCapitalize(_ autoCapitalize: String?) {
1076
1132
  switch autoCapitalize {
1077
1133
  case "none":
1078
1134
  autocapitalizationType = .none
@@ -1083,21 +1139,127 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1083
1139
  default:
1084
1140
  autocapitalizationType = .sentences
1085
1141
  }
1142
+ if isFirstResponder {
1143
+ reloadInputViews()
1144
+ }
1086
1145
  }
1087
1146
 
1088
1147
  func setAutoCorrect(_ autoCorrect: Bool?) {
1148
+ desiredInputTraitState.autoCorrect = autoCorrect
1149
+ guard prepareForInputTraitChange() else {
1150
+ pendingInputTraitChange.hasAutoCorrect = true
1151
+ pendingInputTraitChange.autoCorrect = autoCorrect
1152
+ scheduleInputTraitChangeRetry()
1153
+ return
1154
+ }
1155
+ applyAutoCorrect(autoCorrect)
1156
+ appliedInputTraitState.autoCorrect = autoCorrect
1157
+ clearPendingAutoCorrect()
1158
+ }
1159
+
1160
+ private func applyAutoCorrect(_ autoCorrect: Bool?) {
1089
1161
  let isEnabled = autoCorrect ?? false
1090
1162
  autocorrectionType = isEnabled ? .yes : .no
1091
1163
  spellCheckingType = isEnabled ? .default : .no
1164
+ if isFirstResponder {
1165
+ reloadInputViews()
1166
+ }
1092
1167
  }
1093
1168
 
1094
1169
  func setKeyboardType(_ keyboardType: String?) {
1170
+ desiredInputTraitState.keyboardType = keyboardType
1171
+ guard prepareForInputTraitChange() else {
1172
+ pendingInputTraitChange.hasKeyboardType = true
1173
+ pendingInputTraitChange.keyboardType = keyboardType
1174
+ scheduleInputTraitChangeRetry()
1175
+ return
1176
+ }
1177
+ applyKeyboardType(keyboardType)
1178
+ appliedInputTraitState.keyboardType = keyboardType
1179
+ clearPendingKeyboardType()
1180
+ }
1181
+
1182
+ private func applyKeyboardType(_ keyboardType: String?) {
1095
1183
  self.keyboardType = Self.resolvedKeyboardType(from: keyboardType)
1096
1184
  if isFirstResponder {
1097
1185
  reloadInputViews()
1098
1186
  }
1099
1187
  }
1100
1188
 
1189
+ private func prepareForInputTraitChange() -> Bool {
1190
+ guard isFirstResponder, editorId != 0 else { return true }
1191
+ return prepareForExternalEditorUpdate()
1192
+ }
1193
+
1194
+ private func scheduleInputTraitChangeRetry() {
1195
+ guard !pendingInputTraitRetryScheduled else { return }
1196
+ pendingInputTraitRetryScheduled = true
1197
+ pendingInputTraitRetryGeneration &+= 1
1198
+ let retryGeneration = pendingInputTraitRetryGeneration
1199
+ DispatchQueue.main.async { [weak self] in
1200
+ guard let self else { return }
1201
+ guard retryGeneration == self.pendingInputTraitRetryGeneration else { return }
1202
+ self.pendingInputTraitRetryScheduled = false
1203
+ let pending = self.pendingInputTraitChange
1204
+ self.pendingInputTraitChange = PendingInputTraitChange()
1205
+ if pending.hasAutoCapitalize,
1206
+ pending.autoCapitalize == self.desiredInputTraitState.autoCapitalize {
1207
+ self.setAutoCapitalize(pending.autoCapitalize)
1208
+ }
1209
+ if pending.hasAutoCorrect,
1210
+ pending.autoCorrect == self.desiredInputTraitState.autoCorrect {
1211
+ self.setAutoCorrect(pending.autoCorrect)
1212
+ }
1213
+ if pending.hasKeyboardType,
1214
+ pending.keyboardType == self.desiredInputTraitState.keyboardType {
1215
+ self.setKeyboardType(pending.keyboardType)
1216
+ }
1217
+ }
1218
+ }
1219
+
1220
+ private func clearPendingAutoCapitalize() {
1221
+ pendingInputTraitChange.hasAutoCapitalize = false
1222
+ pendingInputTraitChange.autoCapitalize = nil
1223
+ cancelPendingInputTraitRetryIfEmpty()
1224
+ }
1225
+
1226
+ private func clearPendingAutoCorrect() {
1227
+ pendingInputTraitChange.hasAutoCorrect = false
1228
+ pendingInputTraitChange.autoCorrect = nil
1229
+ cancelPendingInputTraitRetryIfEmpty()
1230
+ }
1231
+
1232
+ private func clearPendingKeyboardType() {
1233
+ pendingInputTraitChange.hasKeyboardType = false
1234
+ pendingInputTraitChange.keyboardType = nil
1235
+ cancelPendingInputTraitRetryIfEmpty()
1236
+ }
1237
+
1238
+ private func clearPendingInputTraitRetry() {
1239
+ pendingInputTraitChange = PendingInputTraitChange()
1240
+ guard pendingInputTraitRetryScheduled else { return }
1241
+ pendingInputTraitRetryScheduled = false
1242
+ pendingInputTraitRetryGeneration &+= 1
1243
+ }
1244
+
1245
+ private func cancelPendingInputTraitRetryIfEmpty() {
1246
+ guard pendingInputTraitRetryScheduled, pendingInputTraitChange.isEmpty else { return }
1247
+ pendingInputTraitRetryScheduled = false
1248
+ pendingInputTraitRetryGeneration &+= 1
1249
+ }
1250
+
1251
+ private func replayDesiredInputTraitsIfNeeded() {
1252
+ if desiredInputTraitState.autoCapitalize != appliedInputTraitState.autoCapitalize {
1253
+ setAutoCapitalize(desiredInputTraitState.autoCapitalize)
1254
+ }
1255
+ if desiredInputTraitState.autoCorrect != appliedInputTraitState.autoCorrect {
1256
+ setAutoCorrect(desiredInputTraitState.autoCorrect)
1257
+ }
1258
+ if desiredInputTraitState.keyboardType != appliedInputTraitState.keyboardType {
1259
+ setKeyboardType(desiredInputTraitState.keyboardType)
1260
+ }
1261
+ }
1262
+
1101
1263
  private static func resolvedKeyboardType(from keyboardType: String?) -> UIKeyboardType {
1102
1264
  switch keyboardType {
1103
1265
  case "ascii-capable":
@@ -1232,6 +1394,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1232
1394
  let didBecomeFirstResponder = super.becomeFirstResponder()
1233
1395
  if didBecomeFirstResponder {
1234
1396
  ensureInternalTextViewDelegate()
1397
+ clearNativeTextMutationAfterBlurWindow()
1235
1398
  DispatchQueue.main.async { [weak self] in
1236
1399
  self?.ensureInternalTextViewDelegate()
1237
1400
  }
@@ -1241,6 +1404,31 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1241
1404
  return didBecomeFirstResponder
1242
1405
  }
1243
1406
 
1407
+ override func resignFirstResponder() -> Bool {
1408
+ ensureInternalTextViewDelegate()
1409
+ _ = drainPendingNativeTextMutation(allowAfterBlur: false, allowWhileIntercepting: true)
1410
+
1411
+ let wasFirstResponder = isFirstResponder
1412
+ if wasFirstResponder {
1413
+ nativeTextMutationAfterBlurGeneration = nativeTextMutationGeneration
1414
+ nativeTextMutationAfterBlurDeadline = ProcessInfo.processInfo.systemUptime
1415
+ + nativeTextMutationAfterBlurGraceInterval
1416
+ }
1417
+
1418
+ let didResignFirstResponder = super.resignFirstResponder()
1419
+ if wasFirstResponder || didResignFirstResponder {
1420
+ _ = drainPendingNativeTextMutation(allowAfterBlur: true, allowWhileIntercepting: true)
1421
+ DispatchQueue.main.async { [weak self] in
1422
+ guard let self else { return }
1423
+ _ = self.drainPendingNativeTextMutation(
1424
+ allowAfterBlur: true,
1425
+ allowWhileIntercepting: true
1426
+ )
1427
+ }
1428
+ }
1429
+ return didResignFirstResponder
1430
+ }
1431
+
1244
1432
  private func isRenderedContentEmpty() -> Bool {
1245
1433
  let renderedText = textStorage.string
1246
1434
  guard !renderedText.isEmpty else { return true }
@@ -1410,6 +1598,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1410
1598
  lastRenderAppliedPatchForTesting
1411
1599
  }
1412
1600
 
1601
+ func authorizedTextForTesting() -> String {
1602
+ lastAuthorizedText
1603
+ }
1604
+
1413
1605
  func lastApplyUpdateTrace() -> ApplyUpdateTrace? {
1414
1606
  lastApplyUpdateTraceForTesting
1415
1607
  }
@@ -1612,6 +1804,12 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1612
1804
  /// - initialHTML: Optional HTML to set as initial content.
1613
1805
  func bindEditor(id: UInt64, initialHTML: String? = nil) {
1614
1806
  ensureInternalTextViewDelegate()
1807
+ if editorId == id, initialHTML == nil {
1808
+ return
1809
+ }
1810
+ if editorId != id {
1811
+ discardTransientNativeInputForEditorRebind()
1812
+ }
1615
1813
  editorId = id
1616
1814
 
1617
1815
  if let html = initialHTML, !html.isEmpty {
@@ -1623,10 +1821,12 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1623
1821
  let stateJSON = editorGetCurrentState(id: editorId)
1624
1822
  applyUpdateJSON(stateJSON, notifyDelegate: false)
1625
1823
  }
1824
+ replayDesiredInputTraitsIfNeeded()
1626
1825
  }
1627
1826
 
1628
1827
  /// Unbind from the current editor instance.
1629
1828
  func unbindEditor() {
1829
+ discardTransientNativeInputForEditorRebind()
1630
1830
  editorId = 0
1631
1831
  }
1632
1832
 
@@ -1649,6 +1849,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1649
1849
  super.insertText(text)
1650
1850
  return
1651
1851
  }
1852
+ guard flushPendingNativeTextMutationCommitIfNeeded() else { return }
1652
1853
 
1653
1854
  if markedTextReplacementScalarRange != nil || markedTextRange != nil {
1654
1855
  let replacementRange = trackedMarkedTextReplacementRange()
@@ -1740,6 +1941,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1740
1941
  super.deleteBackward()
1741
1942
  return
1742
1943
  }
1944
+ guard flushPendingNativeTextMutationCommitIfNeeded() else { return }
1743
1945
 
1744
1946
  if markedTextReplacementScalarRange != nil || markedTextRange != nil {
1745
1947
  performTransientTextMutation {
@@ -1892,6 +2094,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1892
2094
  guard !isApplyingRustState else { return }
1893
2095
  guard editorId != 0 else { return }
1894
2096
  guard isEditable else { return }
2097
+ guard flushPendingNativeTextMutationCommitIfNeeded() else { return }
1895
2098
  guard isCaretInsideList() else { return }
1896
2099
  guard let selection = currentScalarSelection() else { return }
1897
2100
 
@@ -1943,6 +2146,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1943
2146
  super.replace(range, withText: text)
1944
2147
  return
1945
2148
  }
2149
+ guard flushPendingNativeTextMutationCommitIfNeeded() else { return }
1946
2150
 
1947
2151
  if markedTextReplacementScalarRange != nil || markedTextRange != nil {
1948
2152
  let replacementRange = trackedMarkedTextReplacementRange()
@@ -1978,21 +2182,36 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1978
2182
  override func setMarkedText(_ markedText: String?, selectedRange: NSRange) {
1979
2183
  ensureInternalTextViewDelegate()
1980
2184
  if markedText != nil {
2185
+ guard flushPendingNativeTextMutationCommitIfNeeded() else { return }
1981
2186
  captureMarkedTextReplacementRangeIfNeeded()
2187
+ } else if markedTextReplacementScalarRange == nil, markedTextRange == nil {
2188
+ guard flushPendingNativeTextMutationCommitIfNeeded() else { return }
1982
2189
  }
1983
2190
  isComposing = markedText != nil || markedTextReplacementScalarRange != nil
1984
2191
  Self.inputLog.debug(
1985
2192
  "[setMarkedText] marked=\(self.preview(markedText ?? ""), privacy: .public) nsRange=\(selectedRange.location),\(selectedRange.length) selection=\(self.selectionSummary(), privacy: .public)"
1986
2193
  )
1987
- performTransientTextMutation {
1988
- super.setMarkedText(markedText, selectedRange: selectedRange)
1989
- }
1990
2194
  if markedText == nil {
2195
+ // Some keyboard paths finalize composition by clearing marked text
2196
+ // instead of calling unmarkText().
2197
+ let composedText = validatedTrackedMarkedTextForCommit()
2198
+ let replacementRange = trackedMarkedTextReplacementRange()
2199
+ performTransientTextMutation {
2200
+ super.setMarkedText(nil, selectedRange: selectedRange)
2201
+ }
1991
2202
  clearMarkedTextTracking()
1992
- restoreAuthorizedTextAfterCancelledCompositionIfNeeded()
1993
- } else {
1994
- refreshMarkedTextCompositionText(fallback: markedText)
2203
+ if shouldCommitMarkedText(composedText, replacementRange: replacementRange) {
2204
+ commitMarkedText(composedText ?? "", replacementRange: replacementRange)
2205
+ } else {
2206
+ restoreAuthorizedTextAfterCancelledCompositionIfNeeded()
2207
+ }
2208
+ return
2209
+ }
2210
+
2211
+ performTransientTextMutation {
2212
+ super.setMarkedText(markedText, selectedRange: selectedRange)
1995
2213
  }
2214
+ refreshMarkedTextCompositionText(fallback: markedText)
1996
2215
  }
1997
2216
 
1998
2217
  /// Called when composition is finalized (user selects a candidate or
@@ -2013,6 +2232,8 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2013
2232
  "[unmarkText] composed=\(self.preview(composed), privacy: .public) replacement=\(self.previewMarkedTextReplacementRange(replacementRange), privacy: .public) selection=\(self.selectionSummary(), privacy: .public)"
2014
2233
  )
2015
2234
  commitMarkedText(composed, replacementRange: replacementRange)
2235
+ } else if shouldCommitMarkedText(composedText, replacementRange: replacementRange) {
2236
+ commitMarkedText(composedText ?? "", replacementRange: replacementRange)
2016
2237
  } else {
2017
2238
  restoreAuthorizedTextAfterCancelledCompositionIfNeeded()
2018
2239
  }
@@ -2023,9 +2244,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2023
2244
 
2024
2245
  guard let selectedRange = selectedTextRange else {
2025
2246
  let scalarPos = PositionBridge.cursorScalarOffset(in: self)
2247
+ let utf16Pos = PositionBridge.scalarToUtf16Offset(scalarPos, in: lastAuthorizedText)
2026
2248
  markedTextReplacementScalarRange = (from: scalarPos, to: scalarPos)
2027
2249
  markedTextReplacementUtf16Range = NSRange(
2028
- location: Int(scalarPos),
2250
+ location: utf16Pos,
2029
2251
  length: 0
2030
2252
  )
2031
2253
  return
@@ -2055,6 +2277,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2055
2277
  markedTextReplacementScalarRange = nil
2056
2278
  markedTextReplacementUtf16Range = nil
2057
2279
  markedTextCompositionText = nil
2280
+ markedTextCompositionIsExplicitlyEmpty = false
2058
2281
  isComposing = false
2059
2282
  }
2060
2283
 
@@ -2073,14 +2296,30 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2073
2296
  }
2074
2297
 
2075
2298
  private func currentMarkedTextForCommit() -> String? {
2076
- markedTextRange.flatMap { text(in: $0) }
2299
+ if markedTextCompositionIsExplicitlyEmpty { return "" }
2300
+ return transientMarkedTextFromAuthorizedDiff()
2301
+ ?? markedTextRange.flatMap { text(in: $0) }
2077
2302
  ?? markedTextCompositionText
2078
- ?? transientMarkedTextFromAuthorizedDiff()
2303
+ }
2304
+
2305
+ private func validatedTrackedMarkedTextForCommit() -> String? {
2306
+ guard markedTextReplacementScalarRange != nil || markedTextReplacementUtf16Range != nil else {
2307
+ return nil
2308
+ }
2309
+ if markedTextCompositionIsExplicitlyEmpty { return "" }
2310
+ return transientMarkedTextFromAuthorizedDiff()
2311
+ ?? markedTextRange.flatMap { text(in: $0) }
2079
2312
  }
2080
2313
 
2081
2314
  private func refreshMarkedTextCompositionText(fallback: String? = nil) {
2082
- markedTextCompositionText = markedTextRange.flatMap { text(in: $0) }
2083
- ?? transientMarkedTextFromAuthorizedDiff()
2315
+ if fallback?.isEmpty == true {
2316
+ markedTextCompositionText = ""
2317
+ markedTextCompositionIsExplicitlyEmpty = true
2318
+ return
2319
+ }
2320
+ markedTextCompositionIsExplicitlyEmpty = false
2321
+ markedTextCompositionText = transientMarkedTextFromAuthorizedDiff()
2322
+ ?? markedTextRange.flatMap { text(in: $0) }
2084
2323
  ?? fallback
2085
2324
  }
2086
2325
 
@@ -2103,6 +2342,27 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2103
2342
  return nil
2104
2343
  }
2105
2344
 
2345
+ if replacementRange.location > 0 {
2346
+ let prefixRange = NSRange(location: 0, length: replacementRange.location)
2347
+ guard currentText.substring(with: prefixRange) == authorizedText.substring(with: prefixRange) else {
2348
+ return nil
2349
+ }
2350
+ }
2351
+
2352
+ let insertedEnd = replacementRange.location + insertedLength
2353
+ let authorizedSuffixLength = authorizedText.length - replacementEnd
2354
+ let currentSuffixLength = currentText.length - insertedEnd
2355
+ guard authorizedSuffixLength == currentSuffixLength else { return nil }
2356
+ if authorizedSuffixLength > 0 {
2357
+ let authorizedSuffixRange = NSRange(location: replacementEnd, length: authorizedSuffixLength)
2358
+ let currentSuffixRange = NSRange(location: insertedEnd, length: currentSuffixLength)
2359
+ guard currentText.substring(with: currentSuffixRange)
2360
+ == authorizedText.substring(with: authorizedSuffixRange)
2361
+ else {
2362
+ return nil
2363
+ }
2364
+ }
2365
+
2106
2366
  return currentText.substring(
2107
2367
  with: NSRange(location: replacementRange.location, length: insertedLength)
2108
2368
  )
@@ -2114,13 +2374,13 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2114
2374
  ) {
2115
2375
  guard editorId != 0 else { return }
2116
2376
  guard let replacementRange else {
2117
- performInterceptedInput {
2377
+ performInterceptedInput(flushPendingNativeTextMutation: false) {
2118
2378
  insertTextInRust(text, at: PositionBridge.cursorScalarOffset(in: self))
2119
2379
  }
2120
2380
  return
2121
2381
  }
2122
2382
 
2123
- performInterceptedInput {
2383
+ performInterceptedInput(flushPendingNativeTextMutation: false) {
2124
2384
  if replacementRange.from == replacementRange.to {
2125
2385
  insertTextInRust(text, at: replacementRange.from)
2126
2386
  } else {
@@ -2133,6 +2393,16 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2133
2393
  }
2134
2394
  }
2135
2395
 
2396
+ private func shouldCommitMarkedText(
2397
+ _ text: String?,
2398
+ replacementRange: (from: UInt32, to: UInt32)?
2399
+ ) -> Bool {
2400
+ guard let text else { return false }
2401
+ if !text.isEmpty { return true }
2402
+ guard let replacementRange else { return false }
2403
+ return replacementRange.from != replacementRange.to
2404
+ }
2405
+
2136
2406
  private func restoreAuthorizedTextAfterCancelledCompositionIfNeeded() {
2137
2407
  guard editorId != 0 else { return }
2138
2408
  guard textStorage.string != lastAuthorizedText else { return }
@@ -2163,6 +2433,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2163
2433
  super.paste(sender)
2164
2434
  return
2165
2435
  }
2436
+ guard prepareForExternalEditorUpdate() else { return }
2166
2437
 
2167
2438
  Self.inputLog.debug(
2168
2439
  "[paste] selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
@@ -2192,7 +2463,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2192
2463
  documentAttributes: [.documentType: NSAttributedString.DocumentType.html]
2193
2464
  ), let html = String(data: htmlData, encoding: .utf8) {
2194
2465
  performInterceptedInput {
2195
- pasteHTML(html)
2466
+ if !pasteHTML(html, detectContentChange: true),
2467
+ !attrStr.string.isEmpty {
2468
+ pastePlainText(attrStr.string)
2469
+ }
2196
2470
  }
2197
2471
  return
2198
2472
  }
@@ -2218,7 +2492,13 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2218
2492
  func textViewDidChangeSelection(_ textView: UITextView) {
2219
2493
  guard textView === self else { return }
2220
2494
  ensureInternalTextViewDelegate()
2221
- guard !isApplyingRustState, !isComposing, !nativeTextMutationCommitScheduled else { return }
2495
+ guard !isApplyingRustState,
2496
+ !isComposing,
2497
+ !nativeTextMutationCommitScheduled,
2498
+ pendingNativeTextMutation == nil
2499
+ else {
2500
+ return
2501
+ }
2222
2502
  if normalizeSelectionForEmptyBlockAutocapitalizationIfNeeded() {
2223
2503
  return
2224
2504
  }
@@ -2278,7 +2558,13 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2278
2558
  delegate = self
2279
2559
  }
2280
2560
 
2281
- private func performInterceptedInput(_ action: () -> Void) {
2561
+ private func performInterceptedInput(
2562
+ flushPendingNativeTextMutation: Bool = true,
2563
+ _ action: () -> Void
2564
+ ) {
2565
+ if flushPendingNativeTextMutation, interceptedInputDepth == 0 {
2566
+ guard flushPendingNativeTextMutationCommitIfNeeded() else { return }
2567
+ }
2282
2568
  interceptedInputDepth += 1
2283
2569
  Self.inputLog.debug(
2284
2570
  "[intercept.begin] depth=\(self.interceptedInputDepth) selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
@@ -2290,6 +2576,12 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2290
2576
  Self.inputLog.debug(
2291
2577
  "[intercept.end] depth=\(self.interceptedInputDepth) selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
2292
2578
  )
2579
+ if self.interceptedInputDepth == 0 {
2580
+ _ = self.drainPendingNativeTextMutation(
2581
+ allowAfterBlur: false,
2582
+ allowWhileIntercepting: false
2583
+ )
2584
+ }
2293
2585
  }
2294
2586
  }
2295
2587
 
@@ -2402,6 +2694,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2402
2694
  guard !isApplyingRustState,
2403
2695
  !isComposing,
2404
2696
  !nativeTextMutationCommitScheduled,
2697
+ pendingNativeTextMutation == nil,
2405
2698
  editorId != 0
2406
2699
  else {
2407
2700
  return
@@ -2421,9 +2714,11 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2421
2714
  editorDelegate?.editorTextView(self, selectionDidChange: docAnchor, head: docHead)
2422
2715
  }
2423
2716
 
2424
- func applyTheme(_ theme: EditorTheme?) {
2425
- self.theme = theme
2717
+ @discardableResult
2718
+ func applyTheme(_ theme: EditorTheme?) -> Bool {
2426
2719
  if editorId != 0 {
2720
+ guard prepareForExternalEditorUpdate() else { return false }
2721
+ self.theme = theme
2427
2722
  let previousOffset = contentOffset
2428
2723
  let stateJSON = editorGetCurrentState(id: editorId)
2429
2724
  applyUpdateJSON(stateJSON, notifyDelegate: false)
@@ -2431,11 +2726,13 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2431
2726
  preserveScrollOffset(previousOffset)
2432
2727
  }
2433
2728
  } else {
2729
+ self.theme = theme
2434
2730
  refreshTypingAttributesForSelection()
2435
2731
  }
2436
2732
  if heightBehavior == .autoGrow {
2437
2733
  notifyHeightChangeIfNeeded(force: true)
2438
2734
  }
2735
+ return true
2439
2736
  }
2440
2737
 
2441
2738
  private func preserveScrollOffset(_ previousOffset: CGPoint) {
@@ -2871,8 +3168,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2871
3168
  }
2872
3169
 
2873
3170
  func performToolbarToggleMark(_ markName: String) {
2874
- guard editorId != 0 else { return }
2875
- guard isEditable else { return }
3171
+ guard prepareForToolbarCommand() else { return }
2876
3172
  guard let selection = currentScalarSelection() else { return }
2877
3173
  performInterceptedInput {
2878
3174
  let updateJSON = editorToggleMarkAtSelectionScalar(
@@ -2886,8 +3182,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2886
3182
  }
2887
3183
 
2888
3184
  func performToolbarToggleList(_ listType: String, isActive: Bool) {
2889
- guard editorId != 0 else { return }
2890
- guard isEditable else { return }
3185
+ guard prepareForToolbarCommand() else { return }
2891
3186
  guard let selection = currentScalarSelection() else { return }
2892
3187
  performInterceptedInput {
2893
3188
  let updateJSON = isActive
@@ -2907,8 +3202,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2907
3202
  }
2908
3203
 
2909
3204
  func performToolbarToggleBlockquote() {
2910
- guard editorId != 0 else { return }
2911
- guard isEditable else { return }
3205
+ guard prepareForToolbarCommand() else { return }
2912
3206
  guard let selection = currentScalarSelection() else { return }
2913
3207
  performInterceptedInput {
2914
3208
  let updateJSON = editorToggleBlockquoteAtSelectionScalar(
@@ -2921,8 +3215,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2921
3215
  }
2922
3216
 
2923
3217
  func performToolbarToggleHeading(_ level: Int) {
2924
- guard editorId != 0 else { return }
2925
- guard isEditable else { return }
3218
+ guard prepareForToolbarCommand() else { return }
2926
3219
  guard let selection = currentScalarSelection() else { return }
2927
3220
  guard let level = UInt8(exactly: level), (1...6).contains(level) else { return }
2928
3221
  performInterceptedInput {
@@ -2937,8 +3230,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2937
3230
  }
2938
3231
 
2939
3232
  func performToolbarIndentListItem() {
2940
- guard editorId != 0 else { return }
2941
- guard isEditable else { return }
3233
+ guard prepareForToolbarCommand() else { return }
2942
3234
  guard let selection = currentScalarSelection() else { return }
2943
3235
  performInterceptedInput {
2944
3236
  let updateJSON = editorIndentListItemAtSelectionScalar(
@@ -2951,8 +3243,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2951
3243
  }
2952
3244
 
2953
3245
  func performToolbarOutdentListItem() {
2954
- guard editorId != 0 else { return }
2955
- guard isEditable else { return }
3246
+ guard prepareForToolbarCommand() else { return }
2956
3247
  guard let selection = currentScalarSelection() else { return }
2957
3248
  performInterceptedInput {
2958
3249
  let updateJSON = editorOutdentListItemAtSelectionScalar(
@@ -2965,16 +3256,14 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2965
3256
  }
2966
3257
 
2967
3258
  func performToolbarInsertNode(_ nodeType: String) {
2968
- guard editorId != 0 else { return }
2969
- guard isEditable else { return }
3259
+ guard prepareForToolbarCommand() else { return }
2970
3260
  performInterceptedInput {
2971
3261
  insertNodeInRust(nodeType)
2972
3262
  }
2973
3263
  }
2974
3264
 
2975
3265
  func performToolbarUndo() {
2976
- guard editorId != 0 else { return }
2977
- guard isEditable else { return }
3266
+ guard prepareForToolbarCommand() else { return }
2978
3267
  performInterceptedInput {
2979
3268
  let updateJSON = editorUndo(id: editorId)
2980
3269
  applyUpdateJSON(updateJSON)
@@ -2982,14 +3271,19 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2982
3271
  }
2983
3272
 
2984
3273
  func performToolbarRedo() {
2985
- guard editorId != 0 else { return }
2986
- guard isEditable else { return }
3274
+ guard prepareForToolbarCommand() else { return }
2987
3275
  performInterceptedInput {
2988
3276
  let updateJSON = editorRedo(id: editorId)
2989
3277
  applyUpdateJSON(updateJSON)
2990
3278
  }
2991
3279
  }
2992
3280
 
3281
+ private func prepareForToolbarCommand() -> Bool {
3282
+ guard editorId != 0 else { return false }
3283
+ guard isEditable else { return false }
3284
+ return prepareForExternalEditorUpdate()
3285
+ }
3286
+
2993
3287
  /// Insert text at a scalar position via the Rust editor.
2994
3288
  private func insertTextInRust(_ text: String, at scalarPos: UInt32) {
2995
3289
  Self.inputLog.debug(
@@ -3026,6 +3320,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3026
3320
  authorized.character(at: prefix) == current.character(at: prefix) {
3027
3321
  prefix += 1
3028
3322
  }
3323
+ prefix = sharedUtf16ScalarBoundary(atOrBefore: prefix, in: authorizedText, and: currentText)
3029
3324
 
3030
3325
  var authorizedEnd = authorized.length
3031
3326
  var currentEnd = current.length
@@ -3035,6 +3330,8 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3035
3330
  authorizedEnd -= 1
3036
3331
  currentEnd -= 1
3037
3332
  }
3333
+ authorizedEnd = utf16ScalarBoundary(atOrAfter: authorizedEnd, in: authorizedText)
3334
+ currentEnd = utf16ScalarBoundary(atOrAfter: currentEnd, in: currentText)
3038
3335
 
3039
3336
  let replacementLength = currentEnd - prefix
3040
3337
  guard replacementLength >= 0 else { return nil }
@@ -3042,16 +3339,229 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3042
3339
  with: NSRange(location: prefix, length: replacementLength)
3043
3340
  )
3044
3341
 
3342
+ let selectedScalarRange = selectedTextRange.map {
3343
+ PositionBridge.textRangeToScalarRange($0, in: self)
3344
+ }
3345
+ let capturedAfterBlur = canAdoptNativeTextMutationAfterBlur()
3346
+
3045
3347
  return NativeTextMutation(
3046
- from: PositionBridge.utf16OffsetToScalar(prefix, in: authorizedText),
3047
- to: PositionBridge.utf16OffsetToScalar(authorizedEnd, in: authorizedText),
3348
+ from: PositionBridge.utf16OffsetToScalar(prefix, in: lastAuthorizedAttributedTextStorage),
3349
+ to: PositionBridge.utf16OffsetToScalar(authorizedEnd, in: lastAuthorizedAttributedTextStorage),
3048
3350
  replacementText: replacementText,
3049
- resultingText: currentText
3351
+ resultingText: currentText,
3352
+ authorizedText: authorizedText,
3353
+ selectionAnchor: selectedScalarRange?.from,
3354
+ selectionHead: selectedScalarRange?.to,
3355
+ capturedWhileFirstResponder: isFirstResponder || capturedAfterBlur,
3356
+ capturedWhileEditable: isEditable,
3357
+ capturedAfterBlur: capturedAfterBlur,
3358
+ inputGeneration: nativeTextMutationGeneration
3359
+ )
3360
+ }
3361
+
3362
+ private func nativeTextMutationWithCurrentSelection(
3363
+ _ mutation: NativeTextMutation
3364
+ ) -> NativeTextMutation {
3365
+ let selectedScalarRange = selectedTextRange.map {
3366
+ PositionBridge.textRangeToScalarRange($0, in: self)
3367
+ }
3368
+ return NativeTextMutation(
3369
+ from: mutation.from,
3370
+ to: mutation.to,
3371
+ replacementText: mutation.replacementText,
3372
+ resultingText: mutation.resultingText,
3373
+ authorizedText: mutation.authorizedText,
3374
+ selectionAnchor: selectedScalarRange?.from ?? mutation.selectionAnchor,
3375
+ selectionHead: selectedScalarRange?.to ?? mutation.selectionHead,
3376
+ capturedWhileFirstResponder: mutation.capturedWhileFirstResponder,
3377
+ capturedWhileEditable: mutation.capturedWhileEditable,
3378
+ capturedAfterBlur: mutation.capturedAfterBlur,
3379
+ inputGeneration: mutation.inputGeneration
3050
3380
  )
3051
3381
  }
3052
3382
 
3053
- private func shouldAdoptNativeTextStorageMutation() -> Bool {
3054
- isFirstResponder && isEditable
3383
+ private func isUtf16ScalarBoundary(_ offset: Int, in text: String) -> Bool {
3384
+ guard offset >= 0, offset <= text.utf16.count else { return false }
3385
+ let utf16Index = text.utf16.index(text.utf16.startIndex, offsetBy: offset)
3386
+ return String.Index(utf16Index, within: text) != nil
3387
+ }
3388
+
3389
+ private func utf16ScalarBoundary(atOrBefore offset: Int, in text: String) -> Int {
3390
+ var candidate = min(max(offset, 0), text.utf16.count)
3391
+ while candidate > 0, !isUtf16ScalarBoundary(candidate, in: text) {
3392
+ candidate -= 1
3393
+ }
3394
+ return candidate
3395
+ }
3396
+
3397
+ private func utf16ScalarBoundary(atOrAfter offset: Int, in text: String) -> Int {
3398
+ var candidate = min(max(offset, 0), text.utf16.count)
3399
+ while candidate < text.utf16.count, !isUtf16ScalarBoundary(candidate, in: text) {
3400
+ candidate += 1
3401
+ }
3402
+ return candidate
3403
+ }
3404
+
3405
+ private func sharedUtf16ScalarBoundary(atOrBefore offset: Int, in lhs: String, and rhs: String) -> Int {
3406
+ var candidate = min(max(offset, 0), lhs.utf16.count, rhs.utf16.count)
3407
+ while candidate > 0,
3408
+ (!isUtf16ScalarBoundary(candidate, in: lhs) || !isUtf16ScalarBoundary(candidate, in: rhs)) {
3409
+ candidate -= 1
3410
+ }
3411
+ return candidate
3412
+ }
3413
+
3414
+ private func shouldAdoptNativeTextStorageMutation(
3415
+ _ mutation: NativeTextMutation,
3416
+ allowAfterBlur: Bool = false
3417
+ ) -> Bool {
3418
+ if isFirstResponder && isEditable {
3419
+ return true
3420
+ }
3421
+ return allowAfterBlur
3422
+ && mutation.capturedAfterBlur
3423
+ && mutation.inputGeneration == nativeTextMutationGeneration
3424
+ && canAdoptNativeTextMutationAfterBlur()
3425
+ && mutation.capturedWhileFirstResponder
3426
+ && mutation.capturedWhileEditable
3427
+ }
3428
+
3429
+ private func canAdoptNativeTextMutationAfterBlur() -> Bool {
3430
+ guard let deadline = nativeTextMutationAfterBlurDeadline else {
3431
+ return false
3432
+ }
3433
+ guard nativeTextMutationAfterBlurGeneration == nativeTextMutationGeneration else {
3434
+ clearNativeTextMutationAfterBlurWindow()
3435
+ return false
3436
+ }
3437
+ guard ProcessInfo.processInfo.systemUptime <= deadline else {
3438
+ clearNativeTextMutationAfterBlurWindow()
3439
+ return false
3440
+ }
3441
+ return true
3442
+ }
3443
+
3444
+ private func clearNativeTextMutationAfterBlurWindow() {
3445
+ nativeTextMutationAfterBlurDeadline = nil
3446
+ nativeTextMutationAfterBlurGeneration = nil
3447
+ }
3448
+
3449
+ private func advanceNativeTextMutationGeneration() {
3450
+ nativeTextMutationGeneration &+= 1
3451
+ clearNativeTextMutationAfterBlurWindow()
3452
+ }
3453
+
3454
+ func expireNativeTextMutationAfterBlurDeadlineForTesting() {
3455
+ nativeTextMutationAfterBlurDeadline = ProcessInfo.processInfo.systemUptime - 0.001
3456
+ }
3457
+
3458
+ func discardTransientNativeInputForEditorRebind() {
3459
+ pendingNativeTextMutation = nil
3460
+ nativeTextMutationCommitScheduled = false
3461
+ clearPendingInputTraitRetry()
3462
+ markedTextReplacementScalarRange = nil
3463
+ markedTextReplacementUtf16Range = nil
3464
+ markedTextCompositionText = nil
3465
+ markedTextCompositionIsExplicitlyEmpty = false
3466
+ isComposing = false
3467
+ advanceNativeTextMutationGeneration()
3468
+ }
3469
+
3470
+ @discardableResult
3471
+ private func flushPendingNativeTextMutationCommitIfNeeded() -> Bool {
3472
+ drainPendingNativeTextMutation(
3473
+ allowAfterBlur: false,
3474
+ allowWhileIntercepting: true
3475
+ )
3476
+ }
3477
+
3478
+ @discardableResult
3479
+ func prepareForExternalEditorUpdate() -> Bool {
3480
+ guard prepareActiveCompositionForExternalMutation() else { return false }
3481
+ return drainPendingNativeTextMutation(
3482
+ allowAfterBlur: true,
3483
+ allowWhileIntercepting: true
3484
+ )
3485
+ }
3486
+
3487
+ @discardableResult
3488
+ func prepareForExternalEditorCommand() -> (ready: Bool, updateJSON: String?, blockedReason: String?) {
3489
+ let previousEditorId = editorId
3490
+ let previousAuthorizedText = lastAuthorizedText
3491
+ let previousStateJSON = previousEditorId != 0 ? editorGetCurrentState(id: previousEditorId) : nil
3492
+ guard prepareForExternalEditorUpdate() else {
3493
+ return (false, nil, "composition")
3494
+ }
3495
+ guard editorId != 0 else {
3496
+ return (true, nil, nil)
3497
+ }
3498
+ let currentStateJSON = editorGetCurrentState(id: editorId)
3499
+ guard lastAuthorizedText != previousAuthorizedText
3500
+ || previousEditorId != editorId
3501
+ || previousStateJSON != currentStateJSON
3502
+ else {
3503
+ return (true, nil, nil)
3504
+ }
3505
+ return (true, currentStateJSON, nil)
3506
+ }
3507
+
3508
+ private func prepareActiveCompositionForExternalMutation() -> Bool {
3509
+ guard isComposing else { return true }
3510
+
3511
+ let composedText = validatedTrackedMarkedTextForCommit()
3512
+ let replacementRange = trackedMarkedTextReplacementRange()
3513
+ finishTransientMarkedTextMutation()
3514
+
3515
+ guard shouldCommitMarkedText(composedText, replacementRange: replacementRange) else {
3516
+ restoreAuthorizedTextAfterCancelledCompositionIfNeeded()
3517
+ return false
3518
+ }
3519
+
3520
+ commitMarkedText(composedText ?? "", replacementRange: replacementRange)
3521
+ return true
3522
+ }
3523
+
3524
+ @discardableResult
3525
+ private func drainPendingNativeTextMutation(
3526
+ allowAfterBlur: Bool,
3527
+ allowWhileIntercepting: Bool
3528
+ ) -> Bool {
3529
+ guard nativeTextMutationCommitScheduled
3530
+ || pendingNativeTextMutation != nil
3531
+ || (!isComposing && textStorage.string != lastAuthorizedText)
3532
+ else {
3533
+ return true
3534
+ }
3535
+
3536
+ nativeTextMutationCommitScheduled = false
3537
+ let currentText = textStorage.string
3538
+ let mutation: NativeTextMutation?
3539
+ if let pendingNativeTextMutation,
3540
+ pendingNativeTextMutation.resultingText == currentText,
3541
+ pendingNativeTextMutation.authorizedText == lastAuthorizedText
3542
+ {
3543
+ mutation = nativeTextMutationWithCurrentSelection(pendingNativeTextMutation)
3544
+ } else {
3545
+ mutation = nativeTextMutationFromAuthorizedDiff(currentText: currentText)
3546
+ }
3547
+
3548
+ guard let mutation else {
3549
+ pendingNativeTextMutation = nil
3550
+ return true
3551
+ }
3552
+
3553
+ switch commitNativeTextMutationIfPossible(
3554
+ mutation,
3555
+ allowAfterBlur: allowAfterBlur,
3556
+ allowWhileIntercepting: allowWhileIntercepting
3557
+ ) {
3558
+ case .committed, .rejected:
3559
+ pendingNativeTextMutation = nil
3560
+ return true
3561
+ case .deferred:
3562
+ pendingNativeTextMutation = mutation
3563
+ return false
3564
+ }
3055
3565
  }
3056
3566
 
3057
3567
  private func scheduleNativeTextMutationCommit(_ mutation: NativeTextMutation) {
@@ -3061,43 +3571,99 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3061
3571
  nativeTextMutationCommitScheduled = true
3062
3572
  DispatchQueue.main.async { [weak self] in
3063
3573
  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
3574
+ _ = self.drainPendingNativeTextMutation(
3575
+ allowAfterBlur: true,
3576
+ allowWhileIntercepting: true
3577
+ )
3578
+ }
3579
+ }
3580
+
3581
+ @discardableResult
3582
+ private func commitNativeTextMutationIfPossible(
3583
+ _ mutation: NativeTextMutation,
3584
+ allowAfterBlur: Bool,
3585
+ allowWhileIntercepting: Bool
3586
+ ) -> NativeTextMutationCommitResult {
3587
+ guard editorId != 0 else {
3588
+ return .rejected
3589
+ }
3590
+
3591
+ guard !isApplyingRustState,
3592
+ (!isInterceptingInput || allowWhileIntercepting),
3593
+ !isComposing
3594
+ else {
3595
+ return .deferred
3596
+ }
3597
+
3598
+ guard shouldAdoptNativeTextStorageMutation(mutation, allowAfterBlur: allowAfterBlur) else {
3599
+ if textStorage.string != lastAuthorizedText {
3600
+ scheduleReconciliationFromRust()
3078
3601
  }
3079
- guard self.textStorage.string == mutation.resultingText else {
3080
- if self.textStorage.string != self.lastAuthorizedText {
3081
- self.scheduleReconciliationFromRust()
3082
- }
3083
- return
3602
+ return .rejected
3603
+ }
3604
+
3605
+ guard textStorage.string == mutation.resultingText else {
3606
+ if let refreshedMutation = nativeTextMutationFromAuthorizedDiff(currentText: textStorage.string) {
3607
+ return commitNativeTextMutationIfPossible(
3608
+ refreshedMutation,
3609
+ allowAfterBlur: allowAfterBlur,
3610
+ allowWhileIntercepting: allowWhileIntercepting
3611
+ )
3084
3612
  }
3613
+ return .rejected
3614
+ }
3085
3615
 
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
- }
3616
+ guard mutation.authorizedText == lastAuthorizedText else {
3617
+ if let refreshedMutation = nativeTextMutationFromAuthorizedDiff(currentText: textStorage.string) {
3618
+ return commitNativeTextMutationIfPossible(
3619
+ refreshedMutation,
3620
+ allowAfterBlur: allowAfterBlur,
3621
+ allowWhileIntercepting: allowWhileIntercepting
3622
+ )
3099
3623
  }
3624
+ return .rejected
3100
3625
  }
3626
+
3627
+ performInterceptedInput(flushPendingNativeTextMutation: false) {
3628
+ if mutation.from == mutation.to {
3629
+ guard !mutation.replacementText.isEmpty else { return }
3630
+ insertTextInRust(mutation.replacementText, at: mutation.from)
3631
+ } else if mutation.replacementText.isEmpty {
3632
+ deleteScalarRangeInRust(from: mutation.from, to: mutation.to)
3633
+ } else {
3634
+ replaceTextRangeInRust(
3635
+ from: mutation.from,
3636
+ to: mutation.to,
3637
+ with: mutation.replacementText
3638
+ )
3639
+ }
3640
+ restoreNativeTextMutationSelectionIfNeeded(mutation)
3641
+ }
3642
+ if mutation.capturedAfterBlur {
3643
+ clearNativeTextMutationAfterBlurWindow()
3644
+ }
3645
+ return .committed
3646
+ }
3647
+
3648
+ private func restoreNativeTextMutationSelectionIfNeeded(_ mutation: NativeTextMutation) {
3649
+ guard let anchor = mutation.selectionAnchor,
3650
+ let head = mutation.selectionHead,
3651
+ editorId != 0
3652
+ else {
3653
+ return
3654
+ }
3655
+
3656
+ let startUtf16 = PositionBridge.scalarToUtf16Offset(min(anchor, head), in: self)
3657
+ let endUtf16 = PositionBridge.scalarToUtf16Offset(max(anchor, head), in: self)
3658
+ let targetRange = NSRange(location: startUtf16, length: max(0, endUtf16 - startUtf16))
3659
+ if selectedRange != targetRange {
3660
+ selectedRange = targetRange
3661
+ }
3662
+ editorSetSelectionScalar(id: editorId, scalarAnchor: anchor, scalarHead: head)
3663
+ refreshTypingAttributesForSelection()
3664
+ let docAnchor = editorScalarToDoc(id: editorId, scalar: anchor)
3665
+ let docHead = editorScalarToDoc(id: editorId, scalar: head)
3666
+ editorDelegate?.editorTextView(self, selectionDidChange: docAnchor, head: docHead)
3101
3667
  }
3102
3668
 
3103
3669
  private func insertNodeInRust(_ nodeType: String) {
@@ -3281,12 +3847,24 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3281
3847
  }
3282
3848
 
3283
3849
  /// Paste HTML content through Rust.
3284
- private func pasteHTML(_ html: String) {
3850
+ @discardableResult
3851
+ private func pasteHTML(_ html: String, detectContentChange: Bool = false) -> Bool {
3852
+ let previousHTML = detectContentChange ? editorGetHtml(id: editorId) : nil
3853
+ syncCurrentUIKitSelectionToRust()
3285
3854
  Self.inputLog.debug(
3286
3855
  "[rust.pasteHTML] html=\(self.preview(html), privacy: .public) selection=\(self.selectionSummary(), privacy: .public)"
3287
3856
  )
3288
3857
  let updateJSON = editorInsertContentHtml(id: editorId, html: html)
3289
3858
  applyUpdateJSON(updateJSON)
3859
+ guard let previousHTML else { return true }
3860
+ return editorGetHtml(id: editorId) != previousHTML
3861
+ }
3862
+
3863
+ private func syncCurrentUIKitSelectionToRust() {
3864
+ guard editorId != 0, let range = selectedTextRange else { return }
3865
+ let anchor = PositionBridge.textViewToScalar(range.start, in: self)
3866
+ let head = PositionBridge.textViewToScalar(range.end, in: self)
3867
+ editorSetSelectionScalar(id: editorId, scalarAnchor: anchor, scalarHead: head)
3290
3868
  }
3291
3869
 
3292
3870
  /// Paste plain text through Rust.
@@ -3577,7 +4155,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3577
4155
  _ attrStr: NSAttributedString,
3578
4156
  replaceRange: NSRange? = nil,
3579
4157
  usedPatch: Bool,
3580
- positionCacheUpdate: PositionCacheUpdate = .scan
4158
+ positionCacheUpdate: PositionCacheUpdate = .scan,
4159
+ authorizedReplaceRange: NSRange? = nil,
4160
+ authorizedReplacementText: String? = nil,
4161
+ authorizedReplacementAttributedText: NSAttributedString? = nil
3581
4162
  ) -> ApplyRenderTrace {
3582
4163
  let totalStartedAt = DispatchTime.now().uptimeNanoseconds
3583
4164
  let replaceUtf16Length = replaceRange?.length ?? textStorage.length
@@ -3626,13 +4207,27 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3626
4207
  let endEditingNanos = DispatchTime.now().uptimeNanoseconds - endEditingStartedAt
3627
4208
  let textMutationNanos = DispatchTime.now().uptimeNanoseconds - textMutationStartedAt
3628
4209
  let authorizedTextStartedAt = DispatchTime.now().uptimeNanoseconds
3629
- if let replaceRange,
3630
- replaceRange.location >= 0,
3631
- replaceRange.location + replaceRange.length <= lastAuthorizedTextStorage.length
4210
+ let snapshotReplaceRange = authorizedReplaceRange ?? replaceRange
4211
+ let snapshotReplacementText = authorizedReplacementText ?? attrStr.string
4212
+ let snapshotReplacementAttributedText = authorizedReplacementAttributedText ?? attrStr
4213
+ if let snapshotReplaceRange,
4214
+ snapshotReplaceRange.location >= 0,
4215
+ snapshotReplaceRange.location + snapshotReplaceRange.length <= lastAuthorizedTextStorage.length
3632
4216
  {
3633
- lastAuthorizedTextStorage.replaceCharacters(in: replaceRange, with: attrStr.string)
4217
+ lastAuthorizedTextStorage.replaceCharacters(
4218
+ in: snapshotReplaceRange,
4219
+ with: snapshotReplacementText
4220
+ )
4221
+ lastAuthorizedAttributedTextStorage.replaceCharacters(
4222
+ in: snapshotReplaceRange,
4223
+ with: snapshotReplacementAttributedText
4224
+ )
3634
4225
  } else {
3635
- lastAuthorizedTextStorage.setString(attrStr.string)
4226
+ lastAuthorizedTextStorage.setString(replaceRange == nil ? snapshotReplacementText : textStorage.string)
4227
+ let fallbackAttributedSnapshot = replaceRange == nil
4228
+ ? snapshotReplacementAttributedText
4229
+ : NSAttributedString(attributedString: textStorage)
4230
+ lastAuthorizedAttributedTextStorage.setAttributedString(fallbackAttributedSnapshot)
3636
4231
  }
3637
4232
  let authorizedTextNanos = DispatchTime.now().uptimeNanoseconds - authorizedTextStartedAt
3638
4233
  let cacheInvalidationStartedAt = DispatchTime.now().uptimeNanoseconds
@@ -3932,8 +4527,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3932
4527
  }
3933
4528
 
3934
4529
  let existing = textStorage.attributedSubstring(from: fullReplaceRange)
3935
- let existingString = existing.string as NSString
3936
- let replacementString = replacement.string as NSString
4530
+ let existingRawString = existing.string
4531
+ let replacementRawString = replacement.string
4532
+ let existingString = existingRawString as NSString
4533
+ let replacementString = replacementRawString as NSString
3937
4534
  let sharedLength = min(existing.length, replacement.length)
3938
4535
 
3939
4536
  var prefix = 0
@@ -3996,6 +4593,17 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3996
4593
  break
3997
4594
  }
3998
4595
  }
4596
+ prefix = sharedUtf16ScalarBoundary(atOrBefore: prefix, in: existingRawString, and: replacementRawString)
4597
+ while suffix > 0 {
4598
+ let existingSuffixStart = existing.length - suffix
4599
+ let replacementSuffixStart = replacement.length - suffix
4600
+ if suffix <= sharedLength - prefix,
4601
+ isUtf16ScalarBoundary(existingSuffixStart, in: existingRawString),
4602
+ isUtf16ScalarBoundary(replacementSuffixStart, in: replacementRawString) {
4603
+ break
4604
+ }
4605
+ suffix -= 1
4606
+ }
3999
4607
 
4000
4608
  guard prefix > 0 || suffix > 0 else {
4001
4609
  return (fullReplaceRange, replacement)
@@ -4128,7 +4736,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
4128
4736
  patchToApply.replacement,
4129
4737
  replaceRange: patchToApply.replaceRange,
4130
4738
  usedPatch: true,
4131
- positionCacheUpdate: positionCacheUpdate
4739
+ positionCacheUpdate: positionCacheUpdate,
4740
+ authorizedReplaceRange: fullReplaceRange,
4741
+ authorizedReplacementText: attrStr.string,
4742
+ authorizedReplacementAttributedText: attrStr
4132
4743
  )
4133
4744
  let metadataStartedAt = DispatchTime.now().uptimeNanoseconds
4134
4745
  applyTopLevelChildMetadataPatch(
@@ -4172,6 +4783,9 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
4172
4783
  let update = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
4173
4784
  else { return }
4174
4785
  let parseNanos = DispatchTime.now().uptimeNanoseconds - parseStartedAt
4786
+ pendingNativeTextMutation = nil
4787
+ nativeTextMutationCommitScheduled = false
4788
+ advanceNativeTextMutationGeneration()
4175
4789
 
4176
4790
  let renderElements = update["renderElements"] as? [[String: Any]]
4177
4791
  let selectionFromUpdate = (update["selection"] as? [String: Any])
@@ -4536,19 +5150,27 @@ extension EditorTextView: NSTextStorageDelegate {
4536
5150
  // Only care about actual character edits, not attribute-only changes.
4537
5151
  guard editedMask.contains(.editedCharacters) else { return }
4538
5152
 
4539
- // Skip if this change came from our own Rust apply path.
4540
- guard !isApplyingRustState, !isInterceptingInput, !isComposing else { return }
5153
+ // Skip if this change came from our own Rust apply path or transient IME composition.
5154
+ guard !isApplyingRustState, !isComposing else { return }
4541
5155
 
4542
5156
  // Skip if no editor is bound yet (nothing to reconcile against).
4543
5157
  guard editorId != 0 else { return }
4544
5158
 
5159
+ PositionBridge.invalidateCache(for: self)
5160
+
4545
5161
  // Compare current text storage content against last authorized snapshot.
4546
5162
  let currentText = textStorage.string
4547
5163
  guard currentText != lastAuthorizedText else { return }
4548
5164
  currentTopLevelChildMetadata = nil
4549
5165
 
4550
- if shouldAdoptNativeTextStorageMutation(),
4551
- let mutation = nativeTextMutationFromAuthorizedDiff(currentText: currentText) {
5166
+ let allowAfterBlur = canAdoptNativeTextMutationAfterBlur()
5167
+ if let mutation = nativeTextMutationFromAuthorizedDiff(currentText: currentText),
5168
+ isInterceptingInput
5169
+ || shouldAdoptNativeTextStorageMutation(
5170
+ mutation,
5171
+ allowAfterBlur: allowAfterBlur
5172
+ )
5173
+ {
4552
5174
  scheduleNativeTextMutationCommit(mutation)
4553
5175
  return
4554
5176
  }
@@ -4695,6 +5317,8 @@ final class RichTextEditorView: UIView {
4695
5317
  /// The Rust editor instance ID. Setting this binds/unbinds the editor.
4696
5318
  var editorId: UInt64 = 0 {
4697
5319
  didSet {
5320
+ guard oldValue != editorId else { return }
5321
+ textView.discardTransientNativeInputForEditorRebind()
4698
5322
  if editorId != 0 {
4699
5323
  textView.bindEditor(id: editorId)
4700
5324
  } else {
@@ -4815,12 +5439,14 @@ final class RichTextEditorView: UIView {
4815
5439
  textView.backgroundColor = backgroundColor
4816
5440
  }
4817
5441
 
4818
- func applyTheme(_ theme: EditorTheme?) {
4819
- textView.applyTheme(theme)
5442
+ @discardableResult
5443
+ func applyTheme(_ theme: EditorTheme?) -> Bool {
5444
+ guard textView.applyTheme(theme) else { return false }
4820
5445
  let cornerRadius = theme?.borderRadius ?? 0
4821
5446
  layer.cornerRadius = cornerRadius
4822
5447
  clipsToBounds = cornerRadius > 0
4823
5448
  refreshOverlays()
5449
+ return true
4824
5450
  }
4825
5451
 
4826
5452
  func setRemoteSelections(_ selections: [RemoteSelectionDecoration]) {