@apollohg/react-native-prose-editor 0.5.15 → 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.
@@ -819,6 +819,26 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
819
819
  let entries: [TopLevelChildMetadata]
820
820
  }
821
821
 
822
+ private struct NativeTextMutation {
823
+ let from: UInt32
824
+ let to: UInt32
825
+ let replacementText: String
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
840
+ }
841
+
822
842
  private enum PositionCacheUpdate {
823
843
  case scan
824
844
  case invalidate
@@ -915,6 +935,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
915
935
  /// The plain text from the last Rust render, used by the reconciliation
916
936
  /// fallback to detect unauthorized text storage mutations.
917
937
  private var lastAuthorizedTextStorage = NSMutableString()
938
+ private var lastAuthorizedAttributedTextStorage = NSMutableAttributedString()
918
939
  private var lastAuthorizedText: String {
919
940
  lastAuthorizedTextStorage as String
920
941
  }
@@ -964,6 +985,17 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
964
985
  /// trailing UIKit text-storage callbacks that arrive on the next run loop.
965
986
  private var interceptedInputDepth = 0
966
987
  private var reconciliationWorkScheduled = false
988
+ private var nativeTextMutationCommitScheduled = false
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
967
999
 
968
1000
  /// Coalesces selection sync until UIKit has finished resolving the
969
1001
  /// current tap/drag gesture's final caret position.
@@ -977,9 +1009,29 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
977
1009
  private var markedTextReplacementScalarRange: (from: UInt32, to: UInt32)?
978
1010
  private var markedTextReplacementUtf16Range: NSRange?
979
1011
  private var markedTextCompositionText: String?
1012
+ private var markedTextCompositionIsExplicitlyEmpty = false
980
1013
 
981
1014
  private let editorLayoutManager: EditorLayoutManager
982
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
+
983
1035
  // MARK: - Placeholder
984
1036
 
985
1037
  private lazy var placeholderLabel: UILabel = {
@@ -1064,6 +1116,19 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1064
1116
  }
1065
1117
 
1066
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?) {
1067
1132
  switch autoCapitalize {
1068
1133
  case "none":
1069
1134
  autocapitalizationType = .none
@@ -1074,21 +1139,127 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1074
1139
  default:
1075
1140
  autocapitalizationType = .sentences
1076
1141
  }
1142
+ if isFirstResponder {
1143
+ reloadInputViews()
1144
+ }
1077
1145
  }
1078
1146
 
1079
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?) {
1080
1161
  let isEnabled = autoCorrect ?? false
1081
1162
  autocorrectionType = isEnabled ? .yes : .no
1082
1163
  spellCheckingType = isEnabled ? .default : .no
1164
+ if isFirstResponder {
1165
+ reloadInputViews()
1166
+ }
1083
1167
  }
1084
1168
 
1085
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?) {
1086
1183
  self.keyboardType = Self.resolvedKeyboardType(from: keyboardType)
1087
1184
  if isFirstResponder {
1088
1185
  reloadInputViews()
1089
1186
  }
1090
1187
  }
1091
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
+
1092
1263
  private static func resolvedKeyboardType(from keyboardType: String?) -> UIKeyboardType {
1093
1264
  switch keyboardType {
1094
1265
  case "ascii-capable":
@@ -1223,6 +1394,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1223
1394
  let didBecomeFirstResponder = super.becomeFirstResponder()
1224
1395
  if didBecomeFirstResponder {
1225
1396
  ensureInternalTextViewDelegate()
1397
+ clearNativeTextMutationAfterBlurWindow()
1226
1398
  DispatchQueue.main.async { [weak self] in
1227
1399
  self?.ensureInternalTextViewDelegate()
1228
1400
  }
@@ -1232,6 +1404,31 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1232
1404
  return didBecomeFirstResponder
1233
1405
  }
1234
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
+
1235
1432
  private func isRenderedContentEmpty() -> Bool {
1236
1433
  let renderedText = textStorage.string
1237
1434
  guard !renderedText.isEmpty else { return true }
@@ -1401,6 +1598,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1401
1598
  lastRenderAppliedPatchForTesting
1402
1599
  }
1403
1600
 
1601
+ func authorizedTextForTesting() -> String {
1602
+ lastAuthorizedText
1603
+ }
1604
+
1404
1605
  func lastApplyUpdateTrace() -> ApplyUpdateTrace? {
1405
1606
  lastApplyUpdateTraceForTesting
1406
1607
  }
@@ -1603,6 +1804,12 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1603
1804
  /// - initialHTML: Optional HTML to set as initial content.
1604
1805
  func bindEditor(id: UInt64, initialHTML: String? = nil) {
1605
1806
  ensureInternalTextViewDelegate()
1807
+ if editorId == id, initialHTML == nil {
1808
+ return
1809
+ }
1810
+ if editorId != id {
1811
+ discardTransientNativeInputForEditorRebind()
1812
+ }
1606
1813
  editorId = id
1607
1814
 
1608
1815
  if let html = initialHTML, !html.isEmpty {
@@ -1614,10 +1821,12 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1614
1821
  let stateJSON = editorGetCurrentState(id: editorId)
1615
1822
  applyUpdateJSON(stateJSON, notifyDelegate: false)
1616
1823
  }
1824
+ replayDesiredInputTraitsIfNeeded()
1617
1825
  }
1618
1826
 
1619
1827
  /// Unbind from the current editor instance.
1620
1828
  func unbindEditor() {
1829
+ discardTransientNativeInputForEditorRebind()
1621
1830
  editorId = 0
1622
1831
  }
1623
1832
 
@@ -1640,6 +1849,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1640
1849
  super.insertText(text)
1641
1850
  return
1642
1851
  }
1852
+ guard flushPendingNativeTextMutationCommitIfNeeded() else { return }
1643
1853
 
1644
1854
  if markedTextReplacementScalarRange != nil || markedTextRange != nil {
1645
1855
  let replacementRange = trackedMarkedTextReplacementRange()
@@ -1731,6 +1941,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1731
1941
  super.deleteBackward()
1732
1942
  return
1733
1943
  }
1944
+ guard flushPendingNativeTextMutationCommitIfNeeded() else { return }
1734
1945
 
1735
1946
  if markedTextReplacementScalarRange != nil || markedTextRange != nil {
1736
1947
  performTransientTextMutation {
@@ -1883,6 +2094,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1883
2094
  guard !isApplyingRustState else { return }
1884
2095
  guard editorId != 0 else { return }
1885
2096
  guard isEditable else { return }
2097
+ guard flushPendingNativeTextMutationCommitIfNeeded() else { return }
1886
2098
  guard isCaretInsideList() else { return }
1887
2099
  guard let selection = currentScalarSelection() else { return }
1888
2100
 
@@ -1934,6 +2146,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1934
2146
  super.replace(range, withText: text)
1935
2147
  return
1936
2148
  }
2149
+ guard flushPendingNativeTextMutationCommitIfNeeded() else { return }
1937
2150
 
1938
2151
  if markedTextReplacementScalarRange != nil || markedTextRange != nil {
1939
2152
  let replacementRange = trackedMarkedTextReplacementRange()
@@ -1969,21 +2182,36 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1969
2182
  override func setMarkedText(_ markedText: String?, selectedRange: NSRange) {
1970
2183
  ensureInternalTextViewDelegate()
1971
2184
  if markedText != nil {
2185
+ guard flushPendingNativeTextMutationCommitIfNeeded() else { return }
1972
2186
  captureMarkedTextReplacementRangeIfNeeded()
2187
+ } else if markedTextReplacementScalarRange == nil, markedTextRange == nil {
2188
+ guard flushPendingNativeTextMutationCommitIfNeeded() else { return }
1973
2189
  }
1974
2190
  isComposing = markedText != nil || markedTextReplacementScalarRange != nil
1975
2191
  Self.inputLog.debug(
1976
2192
  "[setMarkedText] marked=\(self.preview(markedText ?? ""), privacy: .public) nsRange=\(selectedRange.location),\(selectedRange.length) selection=\(self.selectionSummary(), privacy: .public)"
1977
2193
  )
1978
- performTransientTextMutation {
1979
- super.setMarkedText(markedText, selectedRange: selectedRange)
1980
- }
1981
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
+ }
1982
2202
  clearMarkedTextTracking()
1983
- restoreAuthorizedTextAfterCancelledCompositionIfNeeded()
1984
- } else {
1985
- 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)
1986
2213
  }
2214
+ refreshMarkedTextCompositionText(fallback: markedText)
1987
2215
  }
1988
2216
 
1989
2217
  /// Called when composition is finalized (user selects a candidate or
@@ -2004,6 +2232,8 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2004
2232
  "[unmarkText] composed=\(self.preview(composed), privacy: .public) replacement=\(self.previewMarkedTextReplacementRange(replacementRange), privacy: .public) selection=\(self.selectionSummary(), privacy: .public)"
2005
2233
  )
2006
2234
  commitMarkedText(composed, replacementRange: replacementRange)
2235
+ } else if shouldCommitMarkedText(composedText, replacementRange: replacementRange) {
2236
+ commitMarkedText(composedText ?? "", replacementRange: replacementRange)
2007
2237
  } else {
2008
2238
  restoreAuthorizedTextAfterCancelledCompositionIfNeeded()
2009
2239
  }
@@ -2014,9 +2244,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2014
2244
 
2015
2245
  guard let selectedRange = selectedTextRange else {
2016
2246
  let scalarPos = PositionBridge.cursorScalarOffset(in: self)
2247
+ let utf16Pos = PositionBridge.scalarToUtf16Offset(scalarPos, in: lastAuthorizedText)
2017
2248
  markedTextReplacementScalarRange = (from: scalarPos, to: scalarPos)
2018
2249
  markedTextReplacementUtf16Range = NSRange(
2019
- location: Int(scalarPos),
2250
+ location: utf16Pos,
2020
2251
  length: 0
2021
2252
  )
2022
2253
  return
@@ -2046,6 +2277,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2046
2277
  markedTextReplacementScalarRange = nil
2047
2278
  markedTextReplacementUtf16Range = nil
2048
2279
  markedTextCompositionText = nil
2280
+ markedTextCompositionIsExplicitlyEmpty = false
2049
2281
  isComposing = false
2050
2282
  }
2051
2283
 
@@ -2064,14 +2296,30 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2064
2296
  }
2065
2297
 
2066
2298
  private func currentMarkedTextForCommit() -> String? {
2067
- markedTextRange.flatMap { text(in: $0) }
2299
+ if markedTextCompositionIsExplicitlyEmpty { return "" }
2300
+ return transientMarkedTextFromAuthorizedDiff()
2301
+ ?? markedTextRange.flatMap { text(in: $0) }
2068
2302
  ?? markedTextCompositionText
2069
- ?? 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) }
2070
2312
  }
2071
2313
 
2072
2314
  private func refreshMarkedTextCompositionText(fallback: String? = nil) {
2073
- markedTextCompositionText = markedTextRange.flatMap { text(in: $0) }
2074
- ?? 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) }
2075
2323
  ?? fallback
2076
2324
  }
2077
2325
 
@@ -2094,6 +2342,27 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2094
2342
  return nil
2095
2343
  }
2096
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
+
2097
2366
  return currentText.substring(
2098
2367
  with: NSRange(location: replacementRange.location, length: insertedLength)
2099
2368
  )
@@ -2105,13 +2374,13 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2105
2374
  ) {
2106
2375
  guard editorId != 0 else { return }
2107
2376
  guard let replacementRange else {
2108
- performInterceptedInput {
2377
+ performInterceptedInput(flushPendingNativeTextMutation: false) {
2109
2378
  insertTextInRust(text, at: PositionBridge.cursorScalarOffset(in: self))
2110
2379
  }
2111
2380
  return
2112
2381
  }
2113
2382
 
2114
- performInterceptedInput {
2383
+ performInterceptedInput(flushPendingNativeTextMutation: false) {
2115
2384
  if replacementRange.from == replacementRange.to {
2116
2385
  insertTextInRust(text, at: replacementRange.from)
2117
2386
  } else {
@@ -2124,6 +2393,16 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2124
2393
  }
2125
2394
  }
2126
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
+
2127
2406
  private func restoreAuthorizedTextAfterCancelledCompositionIfNeeded() {
2128
2407
  guard editorId != 0 else { return }
2129
2408
  guard textStorage.string != lastAuthorizedText else { return }
@@ -2154,6 +2433,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2154
2433
  super.paste(sender)
2155
2434
  return
2156
2435
  }
2436
+ guard prepareForExternalEditorUpdate() else { return }
2157
2437
 
2158
2438
  Self.inputLog.debug(
2159
2439
  "[paste] selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
@@ -2183,7 +2463,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2183
2463
  documentAttributes: [.documentType: NSAttributedString.DocumentType.html]
2184
2464
  ), let html = String(data: htmlData, encoding: .utf8) {
2185
2465
  performInterceptedInput {
2186
- pasteHTML(html)
2466
+ if !pasteHTML(html, detectContentChange: true),
2467
+ !attrStr.string.isEmpty {
2468
+ pastePlainText(attrStr.string)
2469
+ }
2187
2470
  }
2188
2471
  return
2189
2472
  }
@@ -2209,7 +2492,13 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2209
2492
  func textViewDidChangeSelection(_ textView: UITextView) {
2210
2493
  guard textView === self else { return }
2211
2494
  ensureInternalTextViewDelegate()
2212
- guard !isApplyingRustState, !isComposing else { return }
2495
+ guard !isApplyingRustState,
2496
+ !isComposing,
2497
+ !nativeTextMutationCommitScheduled,
2498
+ pendingNativeTextMutation == nil
2499
+ else {
2500
+ return
2501
+ }
2213
2502
  if normalizeSelectionForEmptyBlockAutocapitalizationIfNeeded() {
2214
2503
  return
2215
2504
  }
@@ -2269,7 +2558,13 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2269
2558
  delegate = self
2270
2559
  }
2271
2560
 
2272
- 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
+ }
2273
2568
  interceptedInputDepth += 1
2274
2569
  Self.inputLog.debug(
2275
2570
  "[intercept.begin] depth=\(self.interceptedInputDepth) selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
@@ -2281,6 +2576,12 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2281
2576
  Self.inputLog.debug(
2282
2577
  "[intercept.end] depth=\(self.interceptedInputDepth) selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
2283
2578
  )
2579
+ if self.interceptedInputDepth == 0 {
2580
+ _ = self.drainPendingNativeTextMutation(
2581
+ allowAfterBlur: false,
2582
+ allowWhileIntercepting: false
2583
+ )
2584
+ }
2284
2585
  }
2285
2586
  }
2286
2587
 
@@ -2390,7 +2691,14 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2390
2691
  }
2391
2692
 
2392
2693
  private func syncSelectionToRustAndNotifyDelegate() {
2393
- guard !isApplyingRustState, !isComposing, editorId != 0 else { return }
2694
+ guard !isApplyingRustState,
2695
+ !isComposing,
2696
+ !nativeTextMutationCommitScheduled,
2697
+ pendingNativeTextMutation == nil,
2698
+ editorId != 0
2699
+ else {
2700
+ return
2701
+ }
2394
2702
  guard let range = selectedTextRange else { return }
2395
2703
 
2396
2704
  let anchor = PositionBridge.textViewToScalar(range.start, in: self)
@@ -2406,9 +2714,11 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2406
2714
  editorDelegate?.editorTextView(self, selectionDidChange: docAnchor, head: docHead)
2407
2715
  }
2408
2716
 
2409
- func applyTheme(_ theme: EditorTheme?) {
2410
- self.theme = theme
2717
+ @discardableResult
2718
+ func applyTheme(_ theme: EditorTheme?) -> Bool {
2411
2719
  if editorId != 0 {
2720
+ guard prepareForExternalEditorUpdate() else { return false }
2721
+ self.theme = theme
2412
2722
  let previousOffset = contentOffset
2413
2723
  let stateJSON = editorGetCurrentState(id: editorId)
2414
2724
  applyUpdateJSON(stateJSON, notifyDelegate: false)
@@ -2416,11 +2726,13 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2416
2726
  preserveScrollOffset(previousOffset)
2417
2727
  }
2418
2728
  } else {
2729
+ self.theme = theme
2419
2730
  refreshTypingAttributesForSelection()
2420
2731
  }
2421
2732
  if heightBehavior == .autoGrow {
2422
2733
  notifyHeightChangeIfNeeded(force: true)
2423
2734
  }
2735
+ return true
2424
2736
  }
2425
2737
 
2426
2738
  private func preserveScrollOffset(_ previousOffset: CGPoint) {
@@ -2856,8 +3168,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2856
3168
  }
2857
3169
 
2858
3170
  func performToolbarToggleMark(_ markName: String) {
2859
- guard editorId != 0 else { return }
2860
- guard isEditable else { return }
3171
+ guard prepareForToolbarCommand() else { return }
2861
3172
  guard let selection = currentScalarSelection() else { return }
2862
3173
  performInterceptedInput {
2863
3174
  let updateJSON = editorToggleMarkAtSelectionScalar(
@@ -2871,8 +3182,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2871
3182
  }
2872
3183
 
2873
3184
  func performToolbarToggleList(_ listType: String, isActive: Bool) {
2874
- guard editorId != 0 else { return }
2875
- guard isEditable else { return }
3185
+ guard prepareForToolbarCommand() else { return }
2876
3186
  guard let selection = currentScalarSelection() else { return }
2877
3187
  performInterceptedInput {
2878
3188
  let updateJSON = isActive
@@ -2892,8 +3202,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2892
3202
  }
2893
3203
 
2894
3204
  func performToolbarToggleBlockquote() {
2895
- guard editorId != 0 else { return }
2896
- guard isEditable else { return }
3205
+ guard prepareForToolbarCommand() else { return }
2897
3206
  guard let selection = currentScalarSelection() else { return }
2898
3207
  performInterceptedInput {
2899
3208
  let updateJSON = editorToggleBlockquoteAtSelectionScalar(
@@ -2906,8 +3215,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2906
3215
  }
2907
3216
 
2908
3217
  func performToolbarToggleHeading(_ level: Int) {
2909
- guard editorId != 0 else { return }
2910
- guard isEditable else { return }
3218
+ guard prepareForToolbarCommand() else { return }
2911
3219
  guard let selection = currentScalarSelection() else { return }
2912
3220
  guard let level = UInt8(exactly: level), (1...6).contains(level) else { return }
2913
3221
  performInterceptedInput {
@@ -2922,8 +3230,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2922
3230
  }
2923
3231
 
2924
3232
  func performToolbarIndentListItem() {
2925
- guard editorId != 0 else { return }
2926
- guard isEditable else { return }
3233
+ guard prepareForToolbarCommand() else { return }
2927
3234
  guard let selection = currentScalarSelection() else { return }
2928
3235
  performInterceptedInput {
2929
3236
  let updateJSON = editorIndentListItemAtSelectionScalar(
@@ -2936,8 +3243,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2936
3243
  }
2937
3244
 
2938
3245
  func performToolbarOutdentListItem() {
2939
- guard editorId != 0 else { return }
2940
- guard isEditable else { return }
3246
+ guard prepareForToolbarCommand() else { return }
2941
3247
  guard let selection = currentScalarSelection() else { return }
2942
3248
  performInterceptedInput {
2943
3249
  let updateJSON = editorOutdentListItemAtSelectionScalar(
@@ -2950,16 +3256,14 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2950
3256
  }
2951
3257
 
2952
3258
  func performToolbarInsertNode(_ nodeType: String) {
2953
- guard editorId != 0 else { return }
2954
- guard isEditable else { return }
3259
+ guard prepareForToolbarCommand() else { return }
2955
3260
  performInterceptedInput {
2956
3261
  insertNodeInRust(nodeType)
2957
3262
  }
2958
3263
  }
2959
3264
 
2960
3265
  func performToolbarUndo() {
2961
- guard editorId != 0 else { return }
2962
- guard isEditable else { return }
3266
+ guard prepareForToolbarCommand() else { return }
2963
3267
  performInterceptedInput {
2964
3268
  let updateJSON = editorUndo(id: editorId)
2965
3269
  applyUpdateJSON(updateJSON)
@@ -2967,14 +3271,19 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2967
3271
  }
2968
3272
 
2969
3273
  func performToolbarRedo() {
2970
- guard editorId != 0 else { return }
2971
- guard isEditable else { return }
3274
+ guard prepareForToolbarCommand() else { return }
2972
3275
  performInterceptedInput {
2973
3276
  let updateJSON = editorRedo(id: editorId)
2974
3277
  applyUpdateJSON(updateJSON)
2975
3278
  }
2976
3279
  }
2977
3280
 
3281
+ private func prepareForToolbarCommand() -> Bool {
3282
+ guard editorId != 0 else { return false }
3283
+ guard isEditable else { return false }
3284
+ return prepareForExternalEditorUpdate()
3285
+ }
3286
+
2978
3287
  /// Insert text at a scalar position via the Rust editor.
2979
3288
  private func insertTextInRust(_ text: String, at scalarPos: UInt32) {
2980
3289
  Self.inputLog.debug(
@@ -2997,6 +3306,366 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2997
3306
  applyUpdateJSON(updateJSON)
2998
3307
  }
2999
3308
 
3309
+ private func nativeTextMutationFromAuthorizedDiff(
3310
+ currentText: String
3311
+ ) -> NativeTextMutation? {
3312
+ let authorizedText = lastAuthorizedText
3313
+ guard currentText != authorizedText else { return nil }
3314
+
3315
+ let authorized = authorizedText as NSString
3316
+ let current = currentText as NSString
3317
+ let sharedLength = min(authorized.length, current.length)
3318
+ var prefix = 0
3319
+ while prefix < sharedLength,
3320
+ authorized.character(at: prefix) == current.character(at: prefix) {
3321
+ prefix += 1
3322
+ }
3323
+ prefix = sharedUtf16ScalarBoundary(atOrBefore: prefix, in: authorizedText, and: currentText)
3324
+
3325
+ var authorizedEnd = authorized.length
3326
+ var currentEnd = current.length
3327
+ while authorizedEnd > prefix,
3328
+ currentEnd > prefix,
3329
+ authorized.character(at: authorizedEnd - 1) == current.character(at: currentEnd - 1) {
3330
+ authorizedEnd -= 1
3331
+ currentEnd -= 1
3332
+ }
3333
+ authorizedEnd = utf16ScalarBoundary(atOrAfter: authorizedEnd, in: authorizedText)
3334
+ currentEnd = utf16ScalarBoundary(atOrAfter: currentEnd, in: currentText)
3335
+
3336
+ let replacementLength = currentEnd - prefix
3337
+ guard replacementLength >= 0 else { return nil }
3338
+ let replacementText = current.substring(
3339
+ with: NSRange(location: prefix, length: replacementLength)
3340
+ )
3341
+
3342
+ let selectedScalarRange = selectedTextRange.map {
3343
+ PositionBridge.textRangeToScalarRange($0, in: self)
3344
+ }
3345
+ let capturedAfterBlur = canAdoptNativeTextMutationAfterBlur()
3346
+
3347
+ return NativeTextMutation(
3348
+ from: PositionBridge.utf16OffsetToScalar(prefix, in: lastAuthorizedAttributedTextStorage),
3349
+ to: PositionBridge.utf16OffsetToScalar(authorizedEnd, in: lastAuthorizedAttributedTextStorage),
3350
+ replacementText: replacementText,
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
3380
+ )
3381
+ }
3382
+
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
+ }
3565
+ }
3566
+
3567
+ private func scheduleNativeTextMutationCommit(_ mutation: NativeTextMutation) {
3568
+ pendingNativeTextMutation = mutation
3569
+ guard !nativeTextMutationCommitScheduled else { return }
3570
+
3571
+ nativeTextMutationCommitScheduled = true
3572
+ DispatchQueue.main.async { [weak self] in
3573
+ guard let self else { 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()
3601
+ }
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
+ )
3612
+ }
3613
+ return .rejected
3614
+ }
3615
+
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
+ )
3623
+ }
3624
+ return .rejected
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)
3667
+ }
3668
+
3000
3669
  private func insertNodeInRust(_ nodeType: String) {
3001
3670
  guard let selection = currentScalarSelection() else { return }
3002
3671
  Self.inputLog.debug(
@@ -3178,12 +3847,24 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3178
3847
  }
3179
3848
 
3180
3849
  /// Paste HTML content through Rust.
3181
- 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()
3182
3854
  Self.inputLog.debug(
3183
3855
  "[rust.pasteHTML] html=\(self.preview(html), privacy: .public) selection=\(self.selectionSummary(), privacy: .public)"
3184
3856
  )
3185
3857
  let updateJSON = editorInsertContentHtml(id: editorId, html: html)
3186
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)
3187
3868
  }
3188
3869
 
3189
3870
  /// Paste plain text through Rust.
@@ -3474,7 +4155,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3474
4155
  _ attrStr: NSAttributedString,
3475
4156
  replaceRange: NSRange? = nil,
3476
4157
  usedPatch: Bool,
3477
- positionCacheUpdate: PositionCacheUpdate = .scan
4158
+ positionCacheUpdate: PositionCacheUpdate = .scan,
4159
+ authorizedReplaceRange: NSRange? = nil,
4160
+ authorizedReplacementText: String? = nil,
4161
+ authorizedReplacementAttributedText: NSAttributedString? = nil
3478
4162
  ) -> ApplyRenderTrace {
3479
4163
  let totalStartedAt = DispatchTime.now().uptimeNanoseconds
3480
4164
  let replaceUtf16Length = replaceRange?.length ?? textStorage.length
@@ -3523,13 +4207,27 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3523
4207
  let endEditingNanos = DispatchTime.now().uptimeNanoseconds - endEditingStartedAt
3524
4208
  let textMutationNanos = DispatchTime.now().uptimeNanoseconds - textMutationStartedAt
3525
4209
  let authorizedTextStartedAt = DispatchTime.now().uptimeNanoseconds
3526
- if let replaceRange,
3527
- replaceRange.location >= 0,
3528
- 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
3529
4216
  {
3530
- 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
+ )
3531
4225
  } else {
3532
- 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)
3533
4231
  }
3534
4232
  let authorizedTextNanos = DispatchTime.now().uptimeNanoseconds - authorizedTextStartedAt
3535
4233
  let cacheInvalidationStartedAt = DispatchTime.now().uptimeNanoseconds
@@ -3829,8 +4527,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3829
4527
  }
3830
4528
 
3831
4529
  let existing = textStorage.attributedSubstring(from: fullReplaceRange)
3832
- let existingString = existing.string as NSString
3833
- 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
3834
4534
  let sharedLength = min(existing.length, replacement.length)
3835
4535
 
3836
4536
  var prefix = 0
@@ -3893,6 +4593,17 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3893
4593
  break
3894
4594
  }
3895
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
+ }
3896
4607
 
3897
4608
  guard prefix > 0 || suffix > 0 else {
3898
4609
  return (fullReplaceRange, replacement)
@@ -4025,7 +4736,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
4025
4736
  patchToApply.replacement,
4026
4737
  replaceRange: patchToApply.replaceRange,
4027
4738
  usedPatch: true,
4028
- positionCacheUpdate: positionCacheUpdate
4739
+ positionCacheUpdate: positionCacheUpdate,
4740
+ authorizedReplaceRange: fullReplaceRange,
4741
+ authorizedReplacementText: attrStr.string,
4742
+ authorizedReplacementAttributedText: attrStr
4029
4743
  )
4030
4744
  let metadataStartedAt = DispatchTime.now().uptimeNanoseconds
4031
4745
  applyTopLevelChildMetadataPatch(
@@ -4069,6 +4783,9 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
4069
4783
  let update = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
4070
4784
  else { return }
4071
4785
  let parseNanos = DispatchTime.now().uptimeNanoseconds - parseStartedAt
4786
+ pendingNativeTextMutation = nil
4787
+ nativeTextMutationCommitScheduled = false
4788
+ advanceNativeTextMutationGeneration()
4072
4789
 
4073
4790
  let renderElements = update["renderElements"] as? [[String: Any]]
4074
4791
  let selectionFromUpdate = (update["selection"] as? [String: Any])
@@ -4433,16 +5150,31 @@ extension EditorTextView: NSTextStorageDelegate {
4433
5150
  // Only care about actual character edits, not attribute-only changes.
4434
5151
  guard editedMask.contains(.editedCharacters) else { return }
4435
5152
 
4436
- // Skip if this change came from our own Rust apply path.
4437
- 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 }
4438
5155
 
4439
5156
  // Skip if no editor is bound yet (nothing to reconcile against).
4440
5157
  guard editorId != 0 else { return }
4441
5158
 
5159
+ PositionBridge.invalidateCache(for: self)
5160
+
4442
5161
  // Compare current text storage content against last authorized snapshot.
4443
5162
  let currentText = textStorage.string
4444
5163
  guard currentText != lastAuthorizedText else { return }
4445
5164
  currentTopLevelChildMetadata = nil
5165
+
5166
+ let allowAfterBlur = canAdoptNativeTextMutationAfterBlur()
5167
+ if let mutation = nativeTextMutationFromAuthorizedDiff(currentText: currentText),
5168
+ isInterceptingInput
5169
+ || shouldAdoptNativeTextStorageMutation(
5170
+ mutation,
5171
+ allowAfterBlur: allowAfterBlur
5172
+ )
5173
+ {
5174
+ scheduleNativeTextMutationCommit(mutation)
5175
+ return
5176
+ }
5177
+
4446
5178
  let authorizedPreview = preview(lastAuthorizedText)
4447
5179
  let storagePreview = preview(currentText)
4448
5180
 
@@ -4585,6 +5317,8 @@ final class RichTextEditorView: UIView {
4585
5317
  /// The Rust editor instance ID. Setting this binds/unbinds the editor.
4586
5318
  var editorId: UInt64 = 0 {
4587
5319
  didSet {
5320
+ guard oldValue != editorId else { return }
5321
+ textView.discardTransientNativeInputForEditorRebind()
4588
5322
  if editorId != 0 {
4589
5323
  textView.bindEditor(id: editorId)
4590
5324
  } else {
@@ -4705,12 +5439,14 @@ final class RichTextEditorView: UIView {
4705
5439
  textView.backgroundColor = backgroundColor
4706
5440
  }
4707
5441
 
4708
- func applyTheme(_ theme: EditorTheme?) {
4709
- textView.applyTheme(theme)
5442
+ @discardableResult
5443
+ func applyTheme(_ theme: EditorTheme?) -> Bool {
5444
+ guard textView.applyTheme(theme) else { return false }
4710
5445
  let cornerRadius = theme?.borderRadius ?? 0
4711
5446
  layer.cornerRadius = cornerRadius
4712
5447
  clipsToBounds = cornerRadius > 0
4713
5448
  refreshOverlays()
5449
+ return true
4714
5450
  }
4715
5451
 
4716
5452
  func setRemoteSelections(_ selections: [RemoteSelectionDecoration]) {