@apollohg/react-native-prose-editor 0.4.0 → 0.4.1

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.
Files changed (30) hide show
  1. package/README.md +18 -0
  2. package/android/build.gradle +23 -0
  3. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +502 -39
  4. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +56 -28
  5. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +6 -0
  6. package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +57 -27
  7. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +147 -78
  8. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +249 -71
  9. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +7 -6
  10. package/dist/NativeEditorBridge.d.ts +36 -1
  11. package/dist/NativeEditorBridge.js +173 -94
  12. package/dist/NativeRichTextEditor.d.ts +2 -0
  13. package/dist/NativeRichTextEditor.js +160 -53
  14. package/dist/YjsCollaboration.d.ts +2 -0
  15. package/dist/YjsCollaboration.js +142 -20
  16. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  17. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  18. package/ios/EditorLayoutManager.swift +3 -3
  19. package/ios/Generated_editor_core.swift +41 -0
  20. package/ios/NativeEditorExpoView.swift +43 -11
  21. package/ios/NativeEditorModule.swift +6 -0
  22. package/ios/PositionBridge.swift +310 -75
  23. package/ios/RenderBridge.swift +362 -27
  24. package/ios/RichTextEditorView.swift +1983 -187
  25. package/ios/editor_coreFFI/editor_coreFFI.h +33 -0
  26. package/package.json +11 -2
  27. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  28. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  29. package/rust/android/x86_64/libeditor_core.so +0 -0
  30. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +63 -0
@@ -120,9 +120,16 @@ private final class RemoteSelectionBadgeLabel: UILabel {
120
120
  }
121
121
 
122
122
  private final class RemoteSelectionOverlayView: UIView {
123
+ private struct ColoredRect {
124
+ let frame: CGRect
125
+ let color: UIColor
126
+ }
127
+
123
128
  weak var textView: EditorTextView?
124
129
  private var editorId: UInt64 = 0
125
130
  private var selections: [RemoteSelectionDecoration] = []
131
+ private var selectionViews: [UIView] = []
132
+ private var caretViews: [UIView] = []
126
133
 
127
134
  override init(frame: CGRect) {
128
135
  super.init(frame: frame)
@@ -146,20 +153,26 @@ private final class RemoteSelectionOverlayView: UIView {
146
153
  }
147
154
 
148
155
  func refresh() {
149
- subviews.forEach { $0.removeFromSuperview() }
150
156
  guard editorId != 0,
151
157
  let textView
152
158
  else {
159
+ syncSelectionViews(with: [])
160
+ syncCaretViews(with: [])
153
161
  return
154
162
  }
155
163
 
164
+ var selectionRects: [ColoredRect] = []
165
+ var caretRects: [ColoredRect] = []
166
+
156
167
  for selection in selections {
157
168
  let geometry = geometry(for: selection, in: textView)
158
169
  for rect in geometry.selectionRects {
159
- let selectionView = UIView(frame: rect.integral)
160
- selectionView.backgroundColor = selection.color.withAlphaComponent(0.18)
161
- selectionView.layer.cornerRadius = 3
162
- addSubview(selectionView)
170
+ selectionRects.append(
171
+ ColoredRect(
172
+ frame: rect.integral,
173
+ color: selection.color.withAlphaComponent(0.18)
174
+ )
175
+ )
163
176
  }
164
177
 
165
178
  guard selection.isFocused,
@@ -168,16 +181,29 @@ private final class RemoteSelectionOverlayView: UIView {
168
181
  continue
169
182
  }
170
183
 
171
- let caretView = UIView(frame: CGRect(
172
- x: round(caretRect.minX),
173
- y: round(caretRect.minY),
174
- width: max(2, round(caretRect.width)),
175
- height: round(caretRect.height)
176
- ))
177
- caretView.backgroundColor = selection.color
178
- caretView.layer.cornerRadius = caretView.bounds.width / 2
179
- addSubview(caretView)
184
+ caretRects.append(
185
+ ColoredRect(
186
+ frame: CGRect(
187
+ x: round(caretRect.minX),
188
+ y: round(caretRect.minY),
189
+ width: max(2, round(caretRect.width)),
190
+ height: round(caretRect.height)
191
+ ),
192
+ color: selection.color
193
+ )
194
+ )
180
195
  }
196
+
197
+ syncSelectionViews(with: selectionRects)
198
+ syncCaretViews(with: caretRects)
199
+ }
200
+
201
+ var hasVisibleDecorations: Bool {
202
+ selectionViews.contains { !$0.isHidden } || caretViews.contains { !$0.isHidden }
203
+ }
204
+
205
+ var hasSelectionsOrVisibleDecorations: Bool {
206
+ !selections.isEmpty || hasVisibleDecorations
181
207
  }
182
208
 
183
209
  private func geometry(
@@ -251,6 +277,49 @@ private final class RemoteSelectionOverlayView: UIView {
251
277
 
252
278
  return directRect
253
279
  }
280
+
281
+ private func syncSelectionViews(with rects: [ColoredRect]) {
282
+ syncViews(rects, existingViews: &selectionViews) { view, rect in
283
+ view.frame = rect.frame
284
+ view.backgroundColor = rect.color
285
+ view.layer.cornerRadius = 3
286
+ }
287
+ }
288
+
289
+ private func syncCaretViews(with rects: [ColoredRect]) {
290
+ syncViews(rects, existingViews: &caretViews) { view, rect in
291
+ view.frame = rect.frame
292
+ view.backgroundColor = rect.color
293
+ view.layer.cornerRadius = view.bounds.width / 2
294
+ bringSubviewToFront(view)
295
+ }
296
+ }
297
+
298
+ private func syncViews(
299
+ _ rects: [ColoredRect],
300
+ existingViews: inout [UIView],
301
+ configure: (UIView, ColoredRect) -> Void
302
+ ) {
303
+ while existingViews.count < rects.count {
304
+ let view = UIView(frame: .zero)
305
+ view.isUserInteractionEnabled = false
306
+ addSubview(view)
307
+ existingViews.append(view)
308
+ }
309
+
310
+ for (index, rect) in rects.enumerated() {
311
+ let view = existingViews[index]
312
+ view.isHidden = false
313
+ configure(view, rect)
314
+ }
315
+
316
+ if existingViews.count > rects.count {
317
+ for view in existingViews[rects.count...] {
318
+ view.isHidden = true
319
+ view.frame = .zero
320
+ }
321
+ }
322
+ }
254
323
  }
255
324
 
256
325
  private final class ImageTapOverlayView: UIView {
@@ -427,6 +496,10 @@ private final class ImageResizeOverlayView: UIView {
427
496
  isHidden ? nil : currentRect
428
497
  }
429
498
 
499
+ var isOverlayVisible: Bool {
500
+ !isHidden
501
+ }
502
+
430
503
  var previewHasImageForTesting: Bool {
431
504
  !previewImageView.isHidden && previewImageView.image != nil
432
505
  }
@@ -640,7 +713,117 @@ private final class ImageResizeOverlayView: UIView {
640
713
  /// fast enough for main-thread use. If profiling shows otherwise, we can
641
714
  /// dispatch to a serial queue and batch updates.
642
715
  final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerDelegate {
643
- private static let emptyBlockPlaceholderScalar = UnicodeScalar(0x200B)
716
+ private static let emptyBlockPlaceholderScalar = UnicodeScalar(0x200B)!
717
+
718
+ override var undoManager: UndoManager? { nil }
719
+
720
+ struct ApplyUpdateTrace {
721
+ let attemptedPatch: Bool
722
+ let usedPatch: Bool
723
+ let usedSmallPatchTextMutation: Bool
724
+ let applyRenderReplaceUtf16Length: Int
725
+ let applyRenderReplacementUtf16Length: Int
726
+ let parseNanos: UInt64
727
+ let resolveRenderBlocksNanos: UInt64
728
+ let patchEligibilityNanos: UInt64
729
+ let patchTrimNanos: UInt64
730
+ let patchMetadataNanos: UInt64
731
+ let buildRenderNanos: UInt64
732
+ let applyRenderNanos: UInt64
733
+ let selectionNanos: UInt64
734
+ let postApplyNanos: UInt64
735
+ let totalNanos: UInt64
736
+ let applyRenderTextMutationNanos: UInt64
737
+ let applyRenderBeginEditingNanos: UInt64
738
+ let applyRenderEndEditingNanos: UInt64
739
+ let applyRenderStringMutationNanos: UInt64
740
+ let applyRenderAttributeMutationNanos: UInt64
741
+ let applyRenderAuthorizedTextNanos: UInt64
742
+ let applyRenderCacheInvalidationNanos: UInt64
743
+ let selectionResolveNanos: UInt64
744
+ let selectionAssignmentNanos: UInt64
745
+ let selectionChromeNanos: UInt64
746
+ let postApplyTypingAttributesNanos: UInt64
747
+ let postApplyHeightNotifyNanos: UInt64
748
+ let postApplyHeightNotifyMeasureNanos: UInt64
749
+ let postApplyHeightNotifyCallbackNanos: UInt64
750
+ let postApplyHeightNotifyEnsureLayoutNanos: UInt64
751
+ let postApplyHeightNotifyUsedRectNanos: UInt64
752
+ let postApplyHeightNotifyContentSizeNanos: UInt64
753
+ let postApplyHeightNotifySizeThatFitsNanos: UInt64
754
+ let postApplySelectionOrContentCallbackNanos: UInt64
755
+ }
756
+
757
+ private struct PatchApplyTrace {
758
+ let applied: Bool
759
+ let eligibilityNanos: UInt64
760
+ let trimNanos: UInt64
761
+ let metadataNanos: UInt64
762
+ let buildRenderNanos: UInt64
763
+ let applyRenderNanos: UInt64
764
+ let applyRenderReplaceUtf16Length: Int
765
+ let applyRenderReplacementUtf16Length: Int
766
+ let applyRenderTextMutationNanos: UInt64
767
+ let applyRenderBeginEditingNanos: UInt64
768
+ let applyRenderEndEditingNanos: UInt64
769
+ let applyRenderStringMutationNanos: UInt64
770
+ let applyRenderAttributeMutationNanos: UInt64
771
+ let applyRenderAuthorizedTextNanos: UInt64
772
+ let applyRenderCacheInvalidationNanos: UInt64
773
+ let usedSmallPatchTextMutation: Bool
774
+ }
775
+
776
+ private struct ApplyRenderTrace {
777
+ let totalNanos: UInt64
778
+ let replaceUtf16Length: Int
779
+ let replacementUtf16Length: Int
780
+ let textMutationNanos: UInt64
781
+ let beginEditingNanos: UInt64
782
+ let endEditingNanos: UInt64
783
+ let stringMutationNanos: UInt64
784
+ let attributeMutationNanos: UInt64
785
+ let authorizedTextNanos: UInt64
786
+ let cacheInvalidationNanos: UInt64
787
+ let usedSmallPatchTextMutation: Bool
788
+ }
789
+
790
+ private struct SelectionApplyTrace {
791
+ let totalNanos: UInt64
792
+ let resolveNanos: UInt64
793
+ let assignmentNanos: UInt64
794
+ let chromeNanos: UInt64
795
+ }
796
+
797
+ private struct PostApplyTrace {
798
+ let totalNanos: UInt64
799
+ let typingAttributesNanos: UInt64
800
+ let heightNotifyNanos: UInt64
801
+ let heightNotifyMeasureNanos: UInt64
802
+ let heightNotifyCallbackNanos: UInt64
803
+ let heightNotifyEnsureLayoutNanos: UInt64
804
+ let heightNotifyUsedRectNanos: UInt64
805
+ let heightNotifyContentSizeNanos: UInt64
806
+ let heightNotifySizeThatFitsNanos: UInt64
807
+ let selectionOrContentCallbackNanos: UInt64
808
+ }
809
+
810
+ private struct TopLevelChildMetadata {
811
+ var startOffset: Int
812
+ var containsAttachment: Bool
813
+ var containsPositionAdjustments: Bool
814
+ }
815
+
816
+ private struct TopLevelChildMetadataSlice {
817
+ let startIndex: Int
818
+ let entries: [TopLevelChildMetadata]
819
+ }
820
+
821
+ private enum PositionCacheUpdate {
822
+ case scan
823
+ case invalidate
824
+ case plainText
825
+ case attributed
826
+ }
644
827
 
645
828
  // MARK: - Properties
646
829
 
@@ -657,10 +840,20 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
657
840
  var allowImageResizing = true
658
841
 
659
842
  /// The base font used for unstyled text. Configurable from React props.
660
- var baseFont: UIFont = .systemFont(ofSize: 16)
843
+ var baseFont: UIFont = .systemFont(ofSize: 16) {
844
+ didSet {
845
+ placeholderLabel.font = resolvedDefaultFont()
846
+ renderAppearanceRevision &+= 1
847
+ invalidateAutoGrowHeightMeasurement()
848
+ }
849
+ }
661
850
 
662
851
  /// The base text color. Configurable from React props.
663
- var baseTextColor: UIColor = .label
852
+ var baseTextColor: UIColor = .label {
853
+ didSet {
854
+ renderAppearanceRevision &+= 1
855
+ }
856
+ }
664
857
 
665
858
  /// The base background color before theme overrides.
666
859
  var baseBackgroundColor: UIColor = .systemBackground
@@ -669,6 +862,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
669
862
  /// Optional render theme supplied by React.
670
863
  var theme: EditorTheme? {
671
864
  didSet {
865
+ renderAppearanceRevision &+= 1
672
866
  placeholderLabel.font = resolvedDefaultFont()
673
867
  backgroundColor = theme?.backgroundColor ?? baseBackgroundColor
674
868
  if let contentInsets = theme?.contentInsets {
@@ -681,6 +875,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
681
875
  } else {
682
876
  textContainerInset = baseTextContainerInset
683
877
  }
878
+ invalidateAutoGrowHeightMeasurement()
684
879
  setNeedsLayout()
685
880
  }
686
881
  }
@@ -689,22 +884,42 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
689
884
  didSet {
690
885
  guard oldValue != heightBehavior else { return }
691
886
  isScrollEnabled = heightBehavior == .fixed
887
+ invalidateAutoGrowHeightMeasurement()
692
888
  invalidateIntrinsicContentSize()
693
889
  notifyHeightChangeIfNeeded(force: true)
694
890
  }
695
891
  }
696
892
 
697
- var onHeightMayChange: (() -> Void)?
893
+ var onHeightMayChange: ((CGFloat) -> Void)?
698
894
  var onViewportMayChange: (() -> Void)?
699
895
  var onSelectionOrContentMayChange: (() -> Void)?
700
896
  private var lastAutoGrowMeasuredHeight: CGFloat = 0
897
+ private var lastAutoGrowMeasuredWidth: CGFloat = 0
898
+ private var autoGrowHostHeight: CGFloat = 0
899
+ private var autoGrowHeightCheckIsDirty = true
900
+ private var lastHeightNotifyMeasureNanosForTesting: UInt64 = 0
901
+ private var lastHeightNotifyCallbackNanosForTesting: UInt64 = 0
902
+ private var lastHeightNotifyEnsureLayoutNanosForTesting: UInt64 = 0
903
+ private var lastHeightNotifyUsedRectNanosForTesting: UInt64 = 0
904
+ private var lastHeightNotifyContentSizeNanosForTesting: UInt64 = 0
905
+ private var lastHeightNotifySizeThatFitsNanosForTesting: UInt64 = 0
701
906
 
702
907
  /// Delegate for editor events.
703
908
  weak var editorDelegate: EditorTextViewDelegate?
704
909
 
705
910
  /// The plain text from the last Rust render, used by the reconciliation
706
911
  /// fallback to detect unauthorized text storage mutations.
707
- private(set) var lastAuthorizedText: String = ""
912
+ private var lastAuthorizedTextStorage = NSMutableString()
913
+ private var lastAuthorizedText: String {
914
+ lastAuthorizedTextStorage as String
915
+ }
916
+ private(set) var lastRenderAppliedPatchForTesting: Bool = false
917
+ var captureApplyUpdateTraceForTesting = false
918
+ private(set) var lastApplyUpdateTraceForTesting: ApplyUpdateTrace?
919
+ private var currentRenderBlocks: [[[String: Any]]]? = nil
920
+ private var currentTopLevelChildMetadata: [TopLevelChildMetadata]? = nil
921
+ private var renderAppearanceRevision: UInt64 = 1
922
+ private var lastAppliedRenderAppearanceRevision: UInt64 = 0
708
923
 
709
924
  /// Number of times the reconciliation fallback has fired. Exposed for
710
925
  /// monitoring / kill-condition telemetry.
@@ -795,7 +1010,9 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
795
1010
 
796
1011
  private func commonInit() {
797
1012
  textContainer.widthTracksTextView = true
798
- editorLayoutManager.allowsNonContiguousLayout = false
1013
+ // Large documents edit more smoothly when TextKit can invalidate and
1014
+ // relayout only the touched region instead of forcing contiguous layout.
1015
+ editorLayoutManager.allowsNonContiguousLayout = true
799
1016
  NotificationCenter.default.addObserver(
800
1017
  self,
801
1018
  selector: #selector(handleImageAttachmentDidLoad(_:)),
@@ -855,6 +1072,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
855
1072
  textStorage.beginEditing()
856
1073
  textStorage.edited(.editedAttributes, range: NSRange(location: 0, length: textStorage.length), changeInLength: 0)
857
1074
  textStorage.endEditing()
1075
+ invalidateAutoGrowHeightMeasurement()
858
1076
  setNeedsLayout()
859
1077
  invalidateIntrinsicContentSize()
860
1078
  onSelectionOrContentMayChange?()
@@ -880,28 +1098,43 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
880
1098
 
881
1099
  override func layoutSubviews() {
882
1100
  super.layoutSubviews()
883
- installImageSelectionTapDependencies()
884
1101
  let placeholderX = textContainerInset.left + textContainer.lineFragmentPadding
885
1102
  let placeholderY = textContainerInset.top
886
1103
  let placeholderWidth = max(
887
1104
  0,
888
1105
  bounds.width - textContainerInset.left - textContainerInset.right - 2 * textContainer.lineFragmentPadding
889
1106
  )
890
- let maxPlaceholderHeight = max(
891
- 0,
892
- bounds.height - textContainerInset.top - textContainerInset.bottom
893
- )
894
- let fittedHeight = placeholderLabel.sizeThatFits(
895
- CGSize(width: placeholderWidth, height: CGFloat.greatestFiniteMagnitude)
896
- ).height
897
- placeholderLabel.frame = CGRect(
898
- x: placeholderX,
899
- y: placeholderY,
900
- width: placeholderWidth,
901
- height: min(maxPlaceholderHeight, ceil(fittedHeight))
902
- )
1107
+ if placeholderLabel.isHidden {
1108
+ placeholderLabel.frame = CGRect(
1109
+ x: placeholderX,
1110
+ y: placeholderY,
1111
+ width: placeholderWidth,
1112
+ height: 0
1113
+ )
1114
+ } else {
1115
+ let maxPlaceholderHeight = max(
1116
+ 0,
1117
+ bounds.height - textContainerInset.top - textContainerInset.bottom
1118
+ )
1119
+ let fittedHeight = placeholderLabel.sizeThatFits(
1120
+ CGSize(width: placeholderWidth, height: CGFloat.greatestFiniteMagnitude)
1121
+ ).height
1122
+ placeholderLabel.frame = CGRect(
1123
+ x: placeholderX,
1124
+ y: placeholderY,
1125
+ width: placeholderWidth,
1126
+ height: min(maxPlaceholderHeight, ceil(fittedHeight))
1127
+ )
1128
+ }
903
1129
  if heightBehavior == .autoGrow, !isPreviewingImageResize {
904
- notifyHeightChangeIfNeeded()
1130
+ let currentWidth = ceil(bounds.width)
1131
+ if abs(currentWidth - lastAutoGrowMeasuredWidth) > 0.5 {
1132
+ autoGrowHeightCheckIsDirty = true
1133
+ lastAutoGrowMeasuredWidth = currentWidth
1134
+ }
1135
+ if autoGrowHeightCheckIsDirty {
1136
+ notifyHeightChangeIfNeeded()
1137
+ }
905
1138
  }
906
1139
  if !isPreviewingImageResize {
907
1140
  onViewportMayChange?()
@@ -1031,7 +1264,11 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1031
1264
  let fullRange = NSRange(location: 0, length: textStorage.length)
1032
1265
  var resolvedRange: NSRange?
1033
1266
 
1034
- textStorage.enumerateAttribute(.attachment, in: fullRange) { value, range, stop in
1267
+ textStorage.enumerateAttribute(
1268
+ .attachment,
1269
+ in: fullRange,
1270
+ options: [.longestEffectiveRangeNotRequired]
1271
+ ) { value, range, stop in
1035
1272
  guard value is NSTextAttachment, range.length > 0 else { return }
1036
1273
 
1037
1274
  let attrs = textStorage.attributes(at: range.location, effectiveRange: nil)
@@ -1064,6 +1301,14 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1064
1301
  placeholderLabel.frame
1065
1302
  }
1066
1303
 
1304
+ func lastRenderAppliedPatch() -> Bool {
1305
+ lastRenderAppliedPatchForTesting
1306
+ }
1307
+
1308
+ func lastApplyUpdateTrace() -> ApplyUpdateTrace? {
1309
+ lastApplyUpdateTraceForTesting
1310
+ }
1311
+
1067
1312
  func blockquoteStripeRectsForTesting() -> [CGRect] {
1068
1313
  editorLayoutManager.blockquoteStripeRectsForTesting(in: textStorage)
1069
1314
  }
@@ -1158,8 +1403,18 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1158
1403
  )
1159
1404
  }
1160
1405
 
1406
+ func measuredAutoGrowHeightForTesting(width: CGFloat) -> CGFloat {
1407
+ measuredAutoGrowHeight(forWidth: width)
1408
+ }
1409
+
1161
1410
  private func resolvedCaretReferenceRect(for position: UITextPosition) -> CGRect {
1162
1411
  let directRect = super.caretRect(for: position)
1412
+ if let horizontalRuleRect = resolvedHorizontalRuleAdjacentCaretRect(
1413
+ for: position,
1414
+ directRect: directRect
1415
+ ) {
1416
+ return horizontalRuleRect
1417
+ }
1163
1418
  guard directRect.height <= 0 || directRect.isEmpty else {
1164
1419
  return directRect
1165
1420
  }
@@ -1197,6 +1452,48 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1197
1452
  return directRect
1198
1453
  }
1199
1454
 
1455
+ private func resolvedHorizontalRuleAdjacentCaretRect(
1456
+ for position: UITextPosition,
1457
+ directRect: CGRect
1458
+ ) -> CGRect? {
1459
+ guard textStorage.length > 0 else { return nil }
1460
+
1461
+ let utf16Offset = offset(from: beginningOfDocument, to: position)
1462
+ let caretWidth = max(directRect.width, 2)
1463
+
1464
+ if isHorizontalRuleAttachment(at: utf16Offset),
1465
+ let previousCharacterIndex = nearestVisibleCharacterIndex(
1466
+ from: utf16Offset - 1,
1467
+ direction: -1
1468
+ ),
1469
+ let previousRect = visibleSelectionRect(forCharacterAt: previousCharacterIndex)
1470
+ {
1471
+ return CGRect(
1472
+ x: previousRect.maxX,
1473
+ y: previousRect.minY,
1474
+ width: caretWidth,
1475
+ height: max(directRect.height, previousRect.height)
1476
+ )
1477
+ }
1478
+
1479
+ if isHorizontalRuleAttachment(at: utf16Offset - 1),
1480
+ let nextCharacterIndex = nearestVisibleCharacterIndex(
1481
+ from: utf16Offset,
1482
+ direction: 1
1483
+ ),
1484
+ let nextRect = visibleSelectionRect(forCharacterAt: nextCharacterIndex)
1485
+ {
1486
+ return CGRect(
1487
+ x: nextRect.minX,
1488
+ y: nextRect.minY,
1489
+ width: caretWidth,
1490
+ height: max(directRect.height, nextRect.height)
1491
+ )
1492
+ }
1493
+
1494
+ return nil
1495
+ }
1496
+
1200
1497
  // MARK: - Editor Binding
1201
1498
 
1202
1499
  /// Bind this text view to a Rust editor instance and apply initial content.
@@ -1208,12 +1505,13 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1208
1505
  editorId = id
1209
1506
 
1210
1507
  if let html = initialHTML, !html.isEmpty {
1211
- let renderJSON = editorSetHtml(id: editorId, html: html)
1212
- applyRenderJSON(renderJSON)
1508
+ _ = editorSetHtml(id: editorId, html: html)
1509
+ let stateJSON = editorGetCurrentState(id: editorId)
1510
+ applyUpdateJSON(stateJSON, notifyDelegate: false)
1213
1511
  } else {
1214
1512
  // Pull current state from Rust (content may already be loaded via bridge).
1215
1513
  let stateJSON = editorGetCurrentState(id: editorId)
1216
- applyUpdateJSON(stateJSON)
1514
+ applyUpdateJSON(stateJSON, notifyDelegate: false)
1217
1515
  }
1218
1516
  }
1219
1517
 
@@ -1338,7 +1636,12 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1338
1636
  } else {
1339
1637
  // Cursor: delete one grapheme cluster backward.
1340
1638
  let cursorPos = PositionBridge.textViewToScalar(selectedRange.start, in: self)
1341
- guard cursorPos > 0 else { return }
1639
+ if cursorPos == 0 {
1640
+ performInterceptedInput {
1641
+ deleteBackwardAtSelectionScalarInRust(anchor: cursorPos, head: cursorPos)
1642
+ }
1643
+ return
1644
+ }
1342
1645
 
1343
1646
  let cursorUtf16Offset = offset(from: beginningOfDocument, to: selectedRange.start)
1344
1647
  if let marker = PositionBridge.virtualListMarker(
@@ -1380,7 +1683,11 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1380
1683
  let prevScalar = PositionBridge.textViewToScalar(prevPos, in: self)
1381
1684
 
1382
1685
  performInterceptedInput {
1383
- deleteScalarRangeInRust(from: prevScalar, to: cursorPos)
1686
+ if prevScalar < cursorPos {
1687
+ deleteScalarRangeInRust(from: prevScalar, to: cursorPos)
1688
+ } else {
1689
+ deleteBackwardAtSelectionScalarInRust(anchor: cursorPos, head: cursorPos)
1690
+ }
1384
1691
  }
1385
1692
  }
1386
1693
  }
@@ -1639,6 +1946,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1639
1946
  /// internally during tap handling and word-boundary resolution.
1640
1947
  func textViewDidChangeSelection(_ textView: UITextView) {
1641
1948
  guard textView === self else { return }
1949
+ guard !isApplyingRustState else { return }
1642
1950
  refreshNativeSelectionChromeVisibility()
1643
1951
  onSelectionOrContentMayChange?()
1644
1952
  scheduleSelectionSync()
@@ -1776,13 +2084,20 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1776
2084
  }
1777
2085
 
1778
2086
  private func refreshNativeSelectionChromeVisibility() {
1779
- let hidden = selectedImageGeometry() != nil
2087
+ let hidden = selectedImageSelectionState() != nil
1780
2088
  if !hidden, tintColor.cgColor.alpha > 0 {
1781
2089
  visibleSelectionTintColor = tintColor
1782
2090
  }
1783
2091
  setNativeSelectionChromeHidden(hidden)
1784
2092
  }
1785
2093
 
2094
+ private func showNativeSelectionChromeIfNeeded() {
2095
+ if tintColor.cgColor.alpha > 0 {
2096
+ visibleSelectionTintColor = tintColor
2097
+ }
2098
+ setNativeSelectionChromeHidden(false)
2099
+ }
2100
+
1786
2101
  func refreshSelectionVisualState() {
1787
2102
  refreshNativeSelectionChromeVisibility()
1788
2103
  onSelectionOrContentMayChange?()
@@ -1877,12 +2192,143 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1877
2192
  guard heightBehavior == .autoGrow else { return }
1878
2193
  let width = bounds.width > 0 ? bounds.width : UIScreen.main.bounds.width
1879
2194
  guard width > 0 else { return }
1880
- let measuredHeight = ceil(
1881
- sizeThatFits(CGSize(width: width, height: CGFloat.greatestFiniteMagnitude)).height
1882
- )
2195
+ if !force {
2196
+ let measuredWidth = ceil(width)
2197
+ if !autoGrowHeightCheckIsDirty && abs(measuredWidth - lastAutoGrowMeasuredWidth) <= 0.5 {
2198
+ return
2199
+ }
2200
+ }
2201
+ lastHeightNotifyEnsureLayoutNanosForTesting = 0
2202
+ lastHeightNotifyUsedRectNanosForTesting = 0
2203
+ lastHeightNotifyContentSizeNanosForTesting = 0
2204
+ lastHeightNotifySizeThatFitsNanosForTesting = 0
2205
+ let measurementStartedAt = DispatchTime.now().uptimeNanoseconds
2206
+ let measuredHeight = measuredAutoGrowHeight(forWidth: width)
2207
+ lastHeightNotifyMeasureNanosForTesting =
2208
+ DispatchTime.now().uptimeNanoseconds - measurementStartedAt
2209
+ autoGrowHeightCheckIsDirty = false
2210
+ lastAutoGrowMeasuredWidth = ceil(width)
1883
2211
  guard force || abs(measuredHeight - lastAutoGrowMeasuredHeight) > 0.5 else { return }
1884
2212
  lastAutoGrowMeasuredHeight = measuredHeight
1885
- onHeightMayChange?()
2213
+ let callbackStartedAt = DispatchTime.now().uptimeNanoseconds
2214
+ onHeightMayChange?(measuredHeight)
2215
+ lastHeightNotifyCallbackNanosForTesting =
2216
+ DispatchTime.now().uptimeNanoseconds - callbackStartedAt
2217
+ }
2218
+
2219
+ private func measuredAutoGrowHeight(forWidth width: CGFloat) -> CGFloat {
2220
+ guard width > 0 else { return 0 }
2221
+
2222
+ if abs(bounds.width - width) <= 0.5 {
2223
+ let currentHeight = ceil(bounds.height)
2224
+ let ensureLayoutStartedAt = DispatchTime.now().uptimeNanoseconds
2225
+ editorLayoutManager.ensureLayout(for: textContainer)
2226
+ lastHeightNotifyEnsureLayoutNanosForTesting =
2227
+ DispatchTime.now().uptimeNanoseconds - ensureLayoutStartedAt
2228
+
2229
+ let usedRectStartedAt = DispatchTime.now().uptimeNanoseconds
2230
+ var usedRect = editorLayoutManager.usedRect(for: textContainer)
2231
+ let extraLineFragmentRect = editorLayoutManager.extraLineFragmentRect
2232
+ if !extraLineFragmentRect.isEmpty {
2233
+ usedRect = usedRect.union(extraLineFragmentRect)
2234
+ }
2235
+ lastHeightNotifyUsedRectNanosForTesting =
2236
+ DispatchTime.now().uptimeNanoseconds - usedRectStartedAt
2237
+ let layoutHeight = ceil(usedRect.height + textContainerInset.top + textContainerInset.bottom)
2238
+
2239
+ let contentSizeStartedAt = DispatchTime.now().uptimeNanoseconds
2240
+ let contentHeight = ceil(contentSize.height)
2241
+ lastHeightNotifyContentSizeNanosForTesting =
2242
+ DispatchTime.now().uptimeNanoseconds - contentSizeStartedAt
2243
+ if currentHeight > 0 {
2244
+ if layoutHeight > currentHeight + 0.5 {
2245
+ return layoutHeight
2246
+ }
2247
+ let hostIsTrackingMeasuredHeight =
2248
+ autoGrowHostHeight > 0
2249
+ && abs(currentHeight - ceil(autoGrowHostHeight)) <= 1.0
2250
+ guard hostIsTrackingMeasuredHeight else {
2251
+ return layoutHeight
2252
+ }
2253
+ let measuredFromLayout = max(layoutHeight, contentHeight)
2254
+ if measuredFromLayout > currentHeight + 0.5 {
2255
+ return measuredFromLayout
2256
+ }
2257
+ let sizeThatFitsStartedAt = DispatchTime.now().uptimeNanoseconds
2258
+ let fittedHeight = ceil(
2259
+ sizeThatFits(
2260
+ CGSize(width: width, height: CGFloat.greatestFiniteMagnitude)
2261
+ ).height
2262
+ )
2263
+ lastHeightNotifySizeThatFitsNanosForTesting =
2264
+ DispatchTime.now().uptimeNanoseconds - sizeThatFitsStartedAt
2265
+ if fittedHeight > currentHeight + 0.5 {
2266
+ return max(measuredFromLayout, fittedHeight)
2267
+ }
2268
+ return layoutHeight
2269
+ }
2270
+ return max(layoutHeight, contentHeight)
2271
+ }
2272
+
2273
+ let sizeThatFitsStartedAt = DispatchTime.now().uptimeNanoseconds
2274
+ let fittedHeight = ceil(
2275
+ sizeThatFits(CGSize(width: width, height: CGFloat.greatestFiniteMagnitude)).height
2276
+ )
2277
+ lastHeightNotifySizeThatFitsNanosForTesting =
2278
+ DispatchTime.now().uptimeNanoseconds - sizeThatFitsStartedAt
2279
+ return fittedHeight
2280
+ }
2281
+
2282
+ func updateAutoGrowHostHeight(_ height: CGFloat) {
2283
+ autoGrowHostHeight = max(0, ceil(height))
2284
+ }
2285
+
2286
+ private func invalidateAutoGrowHeightMeasurement() {
2287
+ autoGrowHeightCheckIsDirty = true
2288
+ lastAutoGrowMeasuredWidth = 0
2289
+ }
2290
+
2291
+ private func performPostApplyMaintenance(forceHeightNotify: Bool = false) -> PostApplyTrace {
2292
+ let totalStartedAt = DispatchTime.now().uptimeNanoseconds
2293
+
2294
+ let typingAttributesStartedAt = totalStartedAt
2295
+ refreshTypingAttributesForSelection()
2296
+ let typingAttributesNanos = DispatchTime.now().uptimeNanoseconds - typingAttributesStartedAt
2297
+
2298
+ let heightNotifyStartedAt = DispatchTime.now().uptimeNanoseconds
2299
+ lastHeightNotifyMeasureNanosForTesting = 0
2300
+ lastHeightNotifyCallbackNanosForTesting = 0
2301
+ lastHeightNotifyEnsureLayoutNanosForTesting = 0
2302
+ lastHeightNotifyUsedRectNanosForTesting = 0
2303
+ lastHeightNotifyContentSizeNanosForTesting = 0
2304
+ lastHeightNotifySizeThatFitsNanosForTesting = 0
2305
+ if heightBehavior == .autoGrow {
2306
+ invalidateAutoGrowHeightMeasurement()
2307
+ if forceHeightNotify || window == nil {
2308
+ notifyHeightChangeIfNeeded(force: forceHeightNotify)
2309
+ } else {
2310
+ setNeedsLayout()
2311
+ }
2312
+ }
2313
+ let heightNotifyNanos = DispatchTime.now().uptimeNanoseconds - heightNotifyStartedAt
2314
+
2315
+ let selectionOrContentStartedAt = DispatchTime.now().uptimeNanoseconds
2316
+ onSelectionOrContentMayChange?()
2317
+ let selectionOrContentCallbackNanos =
2318
+ DispatchTime.now().uptimeNanoseconds - selectionOrContentStartedAt
2319
+
2320
+ return PostApplyTrace(
2321
+ totalNanos: DispatchTime.now().uptimeNanoseconds - totalStartedAt,
2322
+ typingAttributesNanos: typingAttributesNanos,
2323
+ heightNotifyNanos: heightNotifyNanos,
2324
+ heightNotifyMeasureNanos: lastHeightNotifyMeasureNanosForTesting,
2325
+ heightNotifyCallbackNanos: lastHeightNotifyCallbackNanosForTesting,
2326
+ heightNotifyEnsureLayoutNanos: lastHeightNotifyEnsureLayoutNanosForTesting,
2327
+ heightNotifyUsedRectNanos: lastHeightNotifyUsedRectNanosForTesting,
2328
+ heightNotifyContentSizeNanos: lastHeightNotifyContentSizeNanosForTesting,
2329
+ heightNotifySizeThatFitsNanos: lastHeightNotifySizeThatFitsNanosForTesting,
2330
+ selectionOrContentCallbackNanos: selectionOrContentCallbackNanos
2331
+ )
1886
2332
  }
1887
2333
 
1888
2334
  static func adjustedCaretRect(
@@ -1947,6 +2393,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1947
2393
  let rawOffset = offset(from: beginningOfDocument, to: position)
1948
2394
  let clampedOffset = min(max(rawOffset, 0), textStorage.length)
1949
2395
 
2396
+ if let horizontalRuleBaselineY = horizontalRuleAdjacentBaselineY(at: clampedOffset) {
2397
+ return horizontalRuleBaselineY
2398
+ }
2399
+
1950
2400
  if let hardBreakBaselineY = hardBreakBaselineY(after: clampedOffset) {
1951
2401
  return hardBreakBaselineY
1952
2402
  }
@@ -1992,6 +2442,91 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1992
2442
  return bestMatch?.baselineY
1993
2443
  }
1994
2444
 
2445
+ private func horizontalRuleAdjacentBaselineY(at utf16Offset: Int) -> CGFloat? {
2446
+ guard textStorage.length > 0 else { return nil }
2447
+
2448
+ if isHorizontalRuleAttachment(at: utf16Offset),
2449
+ let previousCharacterIndex = nearestVisibleCharacterIndex(
2450
+ from: utf16Offset - 1,
2451
+ direction: -1
2452
+ )
2453
+ {
2454
+ return baselineY(forCharacterAt: previousCharacterIndex)
2455
+ }
2456
+
2457
+ if isHorizontalRuleAttachment(at: utf16Offset - 1),
2458
+ let nextCharacterIndex = nearestVisibleCharacterIndex(
2459
+ from: utf16Offset,
2460
+ direction: 1
2461
+ )
2462
+ {
2463
+ return baselineY(forCharacterAt: nextCharacterIndex)
2464
+ }
2465
+
2466
+ return nil
2467
+ }
2468
+
2469
+ private func baselineY(forCharacterAt characterIndex: Int) -> CGFloat? {
2470
+ guard characterIndex >= 0, characterIndex < textStorage.length else { return nil }
2471
+
2472
+ let glyphIndex = layoutManager.glyphIndexForCharacter(at: characterIndex)
2473
+ guard glyphIndex < layoutManager.numberOfGlyphs else { return nil }
2474
+
2475
+ let lineFragmentRect = layoutManager.lineFragmentRect(
2476
+ forGlyphAt: glyphIndex,
2477
+ effectiveRange: nil
2478
+ )
2479
+ let glyphLocation = layoutManager.location(forGlyphAt: glyphIndex)
2480
+ return textContainerInset.top + lineFragmentRect.minY + glyphLocation.y
2481
+ }
2482
+
2483
+ private func visibleSelectionRect(forCharacterAt characterIndex: Int) -> CGRect? {
2484
+ guard characterIndex >= 0, characterIndex < textStorage.length else { return nil }
2485
+ guard let start = position(from: beginningOfDocument, offset: characterIndex),
2486
+ let end = position(from: start, offset: 1),
2487
+ let range = textRange(from: start, to: end)
2488
+ else {
2489
+ return nil
2490
+ }
2491
+
2492
+ return selectionRects(for: range)
2493
+ .map(\.rect)
2494
+ .first(where: { !$0.isEmpty && $0.width > 0 && $0.height > 0 })
2495
+ }
2496
+
2497
+ private func nearestVisibleCharacterIndex(from startIndex: Int, direction: Int) -> Int? {
2498
+ guard direction == -1 || direction == 1 else { return nil }
2499
+ guard textStorage.length > 0 else { return nil }
2500
+
2501
+ let text = textStorage.string as NSString
2502
+ var index = startIndex
2503
+
2504
+ while index >= 0, index < text.length {
2505
+ let attrs = textStorage.attributes(at: index, effectiveRange: nil)
2506
+ let character = text.substring(with: NSRange(location: index, length: 1))
2507
+
2508
+ if attrs[.attachment] == nil,
2509
+ character != "\n",
2510
+ character != "\r",
2511
+ visibleSelectionRect(forCharacterAt: index) != nil
2512
+ {
2513
+ return index
2514
+ }
2515
+
2516
+ index += direction
2517
+ }
2518
+
2519
+ return nil
2520
+ }
2521
+
2522
+ private func isHorizontalRuleAttachment(at utf16Offset: Int) -> Bool {
2523
+ guard utf16Offset >= 0, utf16Offset < textStorage.length else { return false }
2524
+
2525
+ let attrs = textStorage.attributes(at: utf16Offset, effectiveRange: nil)
2526
+ return attrs[.attachment] is NSTextAttachment
2527
+ && (attrs[RenderBridgeAttributes.voidNodeType] as? String) == "horizontalRule"
2528
+ }
2529
+
1995
2530
  private func hardBreakBaselineY(after utf16Offset: Int) -> CGFloat? {
1996
2531
  guard utf16Offset > 0, utf16Offset <= textStorage.length else { return nil }
1997
2532
  let previousVoidType = textStorage.attribute(
@@ -2197,6 +2732,18 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2197
2732
  applyUpdateJSON(updateJSON)
2198
2733
  }
2199
2734
 
2735
+ private func deleteBackwardAtSelectionScalarInRust(anchor: UInt32, head: UInt32) {
2736
+ Self.inputLog.debug(
2737
+ "[rust.deleteBackwardAtSelectionScalar] scalar=\(anchor)-\(head) selection=\(self.selectionSummary(), privacy: .public)"
2738
+ )
2739
+ let updateJSON = editorDeleteBackwardAtSelectionScalar(
2740
+ id: editorId,
2741
+ scalarAnchor: anchor,
2742
+ scalarHead: head
2743
+ )
2744
+ applyUpdateJSON(updateJSON)
2745
+ }
2746
+
2200
2747
  /// Delete a document-position range via the Rust editor.
2201
2748
  private func deleteRangeInRust(from: UInt32, to: UInt32) {
2202
2749
  guard from < to else { return }
@@ -2213,35 +2760,35 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2213
2760
  return (anchor: scalarRange.from, head: scalarRange.to)
2214
2761
  }
2215
2762
 
2216
- func selectedImageGeometry() -> (docPos: UInt32, rect: CGRect)? {
2763
+ private func selectedImageSelectionState() -> (docPos: UInt32, utf16Offset: Int)? {
2217
2764
  guard allowImageResizing else { return nil }
2218
2765
  guard isFirstResponder else { return nil }
2219
- guard let selectedRange = selectedTextRange else { return nil }
2220
-
2221
- let startOffset = offset(from: beginningOfDocument, to: selectedRange.start)
2222
- let endOffset = offset(from: beginningOfDocument, to: selectedRange.end)
2223
- guard endOffset == startOffset + 1, startOffset >= 0, startOffset < textStorage.length else {
2766
+ guard let selectedRange = selectedUtf16Range(),
2767
+ selectedRange.length == 1,
2768
+ selectedRange.location >= 0,
2769
+ selectedRange.location < textStorage.length
2770
+ else {
2224
2771
  return nil
2225
2772
  }
2226
2773
 
2227
- let attrs = textStorage.attributes(at: startOffset, effectiveRange: nil)
2774
+ let attrs = textStorage.attributes(at: selectedRange.location, effectiveRange: nil)
2228
2775
  guard (attrs[RenderBridgeAttributes.voidNodeType] as? String) == "image",
2229
2776
  attrs[.attachment] is NSTextAttachment
2230
2777
  else {
2231
2778
  return nil
2232
2779
  }
2233
2780
 
2234
- let docPos: UInt32
2235
- if let number = attrs[RenderBridgeAttributes.docPos] as? NSNumber {
2236
- docPos = number.uint32Value
2237
- } else if let value = attrs[RenderBridgeAttributes.docPos] as? UInt32 {
2238
- docPos = value
2239
- } else {
2240
- return nil
2241
- }
2781
+ let docPos = (attrs[RenderBridgeAttributes.docPos] as? NSNumber)?.uint32Value
2782
+ ?? (attrs[RenderBridgeAttributes.docPos] as? UInt32)
2783
+ guard let docPos else { return nil }
2784
+ return (docPos, selectedRange.location)
2785
+ }
2786
+
2787
+ func selectedImageGeometry() -> (docPos: UInt32, rect: CGRect)? {
2788
+ guard let selectionState = selectedImageSelectionState() else { return nil }
2242
2789
 
2243
2790
  let glyphRange = layoutManager.glyphRange(
2244
- forCharacterRange: NSRange(location: startOffset, length: 1),
2791
+ forCharacterRange: NSRange(location: selectionState.utf16Offset, length: 1),
2245
2792
  actualCharacterRange: nil
2246
2793
  )
2247
2794
  guard glyphRange.length > 0 else { return nil }
@@ -2250,13 +2797,17 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2250
2797
  rect.origin.x += textContainerInset.left
2251
2798
  rect.origin.y += textContainerInset.top
2252
2799
  guard rect.width > 0, rect.height > 0 else { return nil }
2253
- return (docPos, rect)
2800
+ return (selectionState.docPos, rect)
2254
2801
  }
2255
2802
 
2256
2803
  private func blockImageAttachment(docPos: UInt32) -> (range: NSRange, attachment: BlockImageAttachment)? {
2257
2804
  let fullRange = NSRange(location: 0, length: textStorage.length)
2258
2805
  var resolved: (range: NSRange, attachment: BlockImageAttachment)?
2259
- textStorage.enumerateAttribute(.attachment, in: fullRange) { value, range, stop in
2806
+ textStorage.enumerateAttribute(
2807
+ .attachment,
2808
+ in: fullRange,
2809
+ options: [.longestEffectiveRangeNotRequired]
2810
+ ) { value, range, stop in
2260
2811
  guard let attachment = value as? BlockImageAttachment, range.length > 0 else { return }
2261
2812
  let attrs = textStorage.attributes(at: range.location, effectiveRange: nil)
2262
2813
  guard (attrs[RenderBridgeAttributes.voidNodeType] as? String) == "image" else { return }
@@ -2371,93 +2922,1059 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2371
2922
 
2372
2923
  // MARK: - Applying Rust State
2373
2924
 
2374
- /// Apply a full render update from Rust to the text view.
2375
- ///
2376
- /// Parses the update JSON, converts render elements to NSAttributedString
2377
- /// via RenderBridge, and replaces the text view's content.
2378
- ///
2379
- /// - Parameter updateJSON: The JSON string from editor_insert_text, etc.
2380
- func applyUpdateJSON(_ updateJSON: String, notifyDelegate: Bool = true) {
2381
- guard let data = updateJSON.data(using: .utf8),
2382
- let update = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
2383
- else { return }
2925
+ private struct ParsedRenderPatch {
2926
+ let startIndex: Int
2927
+ let deleteCount: Int
2928
+ let renderBlocks: [[[String: Any]]]
2929
+ }
2384
2930
 
2385
- // Extract render elements.
2386
- guard let renderElements = update["renderElements"] as? [[String: Any]] else { return }
2387
- let selectionFromUpdate = (update["selection"] as? [String: Any])
2388
- .map(self.selectionSummary(from:)) ?? "none"
2389
- Self.updateLog.debug(
2390
- "[applyUpdateJSON.begin] renderCount=\(renderElements.count) updateSelection=\(selectionFromUpdate, privacy: .public) before=\(self.textSnapshotSummary(), privacy: .public)"
2391
- )
2931
+ private enum DerivedRenderPatch {
2932
+ case unchanged
2933
+ case patch(ParsedRenderPatch)
2934
+ }
2392
2935
 
2393
- let attrStr = RenderBridge.renderElements(
2394
- fromArray: renderElements,
2395
- baseFont: baseFont,
2396
- textColor: baseTextColor,
2397
- theme: theme
2936
+ private func parseRenderBlocks(_ value: Any?) -> [[[String: Any]]]? {
2937
+ value as? [[[String: Any]]]
2938
+ }
2939
+
2940
+ private func parseRenderPatch(_ value: Any?) -> ParsedRenderPatch? {
2941
+ guard let raw = value as? [String: Any],
2942
+ let startIndex = RenderBridge.jsonInt(raw["startIndex"]),
2943
+ let deleteCount = RenderBridge.jsonInt(raw["deleteCount"]),
2944
+ let renderBlocks = parseRenderBlocks(raw["renderBlocks"])
2945
+ else {
2946
+ return nil
2947
+ }
2948
+
2949
+ return ParsedRenderPatch(
2950
+ startIndex: startIndex,
2951
+ deleteCount: deleteCount,
2952
+ renderBlocks: renderBlocks
2398
2953
  )
2954
+ }
2399
2955
 
2400
- // Apply the attributed string without triggering input interception.
2401
- isApplyingRustState = true
2402
- textStorage.beginEditing()
2403
- textStorage.setAttributedString(attrStr)
2404
- textStorage.endEditing()
2405
- lastAuthorizedText = textStorage.string
2406
- isApplyingRustState = false
2956
+ private func mergeRenderBlocks(
2957
+ applying patch: ParsedRenderPatch,
2958
+ to current: [[[String: Any]]]
2959
+ ) -> [[[String: Any]]]? {
2960
+ guard patch.startIndex >= 0,
2961
+ patch.deleteCount >= 0,
2962
+ patch.startIndex <= current.count,
2963
+ patch.startIndex + patch.deleteCount <= current.count
2964
+ else {
2965
+ return nil
2966
+ }
2407
2967
 
2408
- refreshPlaceholderVisibility()
2409
- Self.updateLog.debug(
2410
- "[applyUpdateJSON.rendered] after=\(self.textSnapshotSummary(), privacy: .public)"
2968
+ var merged = current
2969
+ merged.replaceSubrange(
2970
+ patch.startIndex..<(patch.startIndex + patch.deleteCount),
2971
+ with: patch.renderBlocks
2411
2972
  )
2973
+ return merged
2974
+ }
2412
2975
 
2413
- // Apply the selection from the update.
2414
- if let selection = update["selection"] as? [String: Any] {
2415
- applySelectionFromJSON(selection)
2976
+ private func renderBlockEquals(
2977
+ _ lhs: [[String: Any]],
2978
+ _ rhs: [[String: Any]]
2979
+ ) -> Bool {
2980
+ (lhs as NSArray).isEqual(rhs)
2981
+ }
2982
+
2983
+ private func deriveRenderPatch(
2984
+ from current: [[[String: Any]]],
2985
+ to updated: [[[String: Any]]]
2986
+ ) -> DerivedRenderPatch {
2987
+ let sharedCount = min(current.count, updated.count)
2988
+
2989
+ var prefix = 0
2990
+ while prefix < sharedCount, renderBlockEquals(current[prefix], updated[prefix]) {
2991
+ prefix += 1
2416
2992
  }
2417
- refreshTypingAttributesForSelection()
2418
- if heightBehavior == .autoGrow {
2419
- notifyHeightChangeIfNeeded(force: true)
2993
+
2994
+ if prefix == current.count, prefix == updated.count {
2995
+ return .unchanged
2420
2996
  }
2421
- onSelectionOrContentMayChange?()
2422
2997
 
2423
- Self.updateLog.debug(
2424
- "[applyUpdateJSON.end] finalSelection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
2998
+ var suffix = 0
2999
+ while suffix < (sharedCount - prefix),
3000
+ renderBlockEquals(
3001
+ current[current.count - suffix - 1],
3002
+ updated[updated.count - suffix - 1]
3003
+ )
3004
+ {
3005
+ suffix += 1
3006
+ }
3007
+
3008
+ let startIndex = prefix
3009
+ let deleteCount = current.count - prefix - suffix
3010
+ let endIndex = updated.count - suffix
3011
+ let replacementBlocks = Array(updated[startIndex..<endIndex])
3012
+
3013
+ return .patch(
3014
+ ParsedRenderPatch(
3015
+ startIndex: startIndex,
3016
+ deleteCount: deleteCount,
3017
+ renderBlocks: replacementBlocks
3018
+ )
2425
3019
  )
3020
+ }
2426
3021
 
2427
- // Notify the delegate.
2428
- if notifyDelegate {
2429
- editorDelegate?.editorTextView(self, didReceiveUpdate: updateJSON)
3022
+ private func topLevelChildIndex(from value: Any?) -> Int? {
3023
+ if let number = value as? NSNumber {
3024
+ return number.intValue
2430
3025
  }
3026
+ return value as? Int
2431
3027
  }
2432
3028
 
2433
- /// Apply a render JSON string (just render elements, no update wrapper).
2434
- ///
2435
- /// Used for initial content loading (set_html / set_json return render
2436
- /// elements directly, not wrapped in an EditorUpdate).
2437
- func applyRenderJSON(_ renderJSON: String) {
2438
- Self.updateLog.debug(
2439
- "[applyRenderJSON.begin] before=\(self.textSnapshotSummary(), privacy: .public)"
2440
- )
2441
- let attrStr = RenderBridge.renderElements(
2442
- fromJSON: renderJSON,
2443
- baseFont: baseFont,
2444
- textColor: baseTextColor,
2445
- theme: theme
3029
+ private func topLevelChildMetadataSlice(
3030
+ from attributedString: NSAttributedString
3031
+ ) -> TopLevelChildMetadataSlice? {
3032
+ guard attributedString.length > 0 else {
3033
+ return TopLevelChildMetadataSlice(startIndex: 0, entries: [])
3034
+ }
3035
+
3036
+ var entriesByIndex: [Int: TopLevelChildMetadata] = [:]
3037
+ var orderedIndexes: [Int] = []
3038
+
3039
+ attributedString.enumerateAttributes(
3040
+ in: NSRange(location: 0, length: attributedString.length),
3041
+ options: []
3042
+ ) { attrs, range, _ in
3043
+ guard let index = topLevelChildIndex(from: attrs[RenderBridgeAttributes.topLevelChildIndex]) else {
3044
+ return
3045
+ }
3046
+ if entriesByIndex[index] == nil {
3047
+ entriesByIndex[index] = TopLevelChildMetadata(
3048
+ startOffset: range.location,
3049
+ containsAttachment: false,
3050
+ containsPositionAdjustments: false
3051
+ )
3052
+ orderedIndexes.append(index)
3053
+ }
3054
+ if attrs[.attachment] != nil {
3055
+ entriesByIndex[index]?.containsAttachment = true
3056
+ }
3057
+ if attrs[RenderBridgeAttributes.syntheticPlaceholder] as? Bool == true
3058
+ || attrs[RenderBridgeAttributes.listMarkerContext] != nil
3059
+ {
3060
+ entriesByIndex[index]?.containsPositionAdjustments = true
3061
+ }
3062
+ }
3063
+
3064
+ guard !orderedIndexes.isEmpty else { return nil }
3065
+ orderedIndexes.sort()
3066
+ guard let startIndex = orderedIndexes.first else { return nil }
3067
+
3068
+ var entries: [TopLevelChildMetadata] = []
3069
+ entries.reserveCapacity(orderedIndexes.count)
3070
+ for (offset, index) in orderedIndexes.enumerated() {
3071
+ guard index == startIndex + offset,
3072
+ let entry = entriesByIndex[index]
3073
+ else {
3074
+ return nil
3075
+ }
3076
+ entries.append(entry)
3077
+ }
3078
+
3079
+ return TopLevelChildMetadataSlice(startIndex: startIndex, entries: entries)
3080
+ }
3081
+
3082
+ private func refreshTopLevelChildMetadata(
3083
+ from attributedString: NSAttributedString
3084
+ ) {
3085
+ guard let slice = topLevelChildMetadataSlice(from: attributedString),
3086
+ slice.startIndex == 0
3087
+ else {
3088
+ currentTopLevelChildMetadata = nil
3089
+ return
3090
+ }
3091
+ currentTopLevelChildMetadata = slice.entries
3092
+ }
3093
+
3094
+ private func applyTopLevelChildMetadataPatch(
3095
+ _ patch: ParsedRenderPatch,
3096
+ replaceRange: NSRange,
3097
+ renderedPatchMetadata: TopLevelChildMetadataSlice?,
3098
+ renderedPatchLength: Int
3099
+ ) {
3100
+ guard var currentMetadata = currentTopLevelChildMetadata else {
3101
+ currentTopLevelChildMetadata = nil
3102
+ return
3103
+ }
3104
+
3105
+ let newEntries: [TopLevelChildMetadata]
3106
+ if let renderedPatchMetadata,
3107
+ renderedPatchMetadata.entries.isEmpty
3108
+ {
3109
+ newEntries = []
3110
+ } else if let renderedPatchMetadata,
3111
+ renderedPatchMetadata.startIndex == patch.startIndex
3112
+ {
3113
+ newEntries = renderedPatchMetadata.entries.map { entry in
3114
+ TopLevelChildMetadata(
3115
+ startOffset: replaceRange.location + entry.startOffset,
3116
+ containsAttachment: entry.containsAttachment,
3117
+ containsPositionAdjustments: entry.containsPositionAdjustments
3118
+ )
3119
+ }
3120
+ } else {
3121
+ currentTopLevelChildMetadata = nil
3122
+ return
3123
+ }
3124
+
3125
+ guard patch.startIndex >= 0,
3126
+ patch.deleteCount >= 0,
3127
+ patch.startIndex <= currentMetadata.count,
3128
+ patch.startIndex + patch.deleteCount <= currentMetadata.count
3129
+ else {
3130
+ currentTopLevelChildMetadata = nil
3131
+ return
3132
+ }
3133
+
3134
+ currentMetadata.replaceSubrange(
3135
+ patch.startIndex..<(patch.startIndex + patch.deleteCount),
3136
+ with: newEntries
2446
3137
  )
2447
3138
 
3139
+ let delta = renderedPatchLength - replaceRange.length
3140
+ if delta != 0 {
3141
+ let shiftStart = patch.startIndex + newEntries.count
3142
+ for index in shiftStart..<currentMetadata.count {
3143
+ currentMetadata[index].startOffset += delta
3144
+ }
3145
+ }
3146
+
3147
+ currentTopLevelChildMetadata = currentMetadata
3148
+ }
3149
+
3150
+ private func hasTopLevelChildMetadata() -> Bool {
3151
+ currentTopLevelChildMetadata != nil
3152
+ }
3153
+
3154
+ private func firstCharacterOffset(forTopLevelChildIndex index: Int) -> Int? {
3155
+ guard let currentTopLevelChildMetadata,
3156
+ index >= 0,
3157
+ index < currentTopLevelChildMetadata.count
3158
+ else {
3159
+ return nil
3160
+ }
3161
+ return currentTopLevelChildMetadata[index].startOffset
3162
+ }
3163
+
3164
+ private func replacementRangeForRenderPatch(
3165
+ startIndex: Int,
3166
+ deleteCount: Int
3167
+ ) -> NSRange? {
3168
+ let startLocation: Int
3169
+ if let resolvedStart = firstCharacterOffset(forTopLevelChildIndex: startIndex) {
3170
+ startLocation = resolvedStart
3171
+ } else if deleteCount == 0 {
3172
+ startLocation = textStorage.length
3173
+ } else {
3174
+ return nil
3175
+ }
3176
+
3177
+ let endIndexExclusive = startIndex + deleteCount
3178
+ let endLocation = firstCharacterOffset(forTopLevelChildIndex: endIndexExclusive)
3179
+ ?? textStorage.length
3180
+ guard startLocation <= endLocation else { return nil }
3181
+ return NSRange(location: startLocation, length: endLocation - startLocation)
3182
+ }
3183
+
3184
+ private func applyAttributedRender(
3185
+ _ attrStr: NSAttributedString,
3186
+ replaceRange: NSRange? = nil,
3187
+ usedPatch: Bool,
3188
+ positionCacheUpdate: PositionCacheUpdate = .scan
3189
+ ) -> ApplyRenderTrace {
3190
+ let totalStartedAt = DispatchTime.now().uptimeNanoseconds
3191
+ let replaceUtf16Length = replaceRange?.length ?? textStorage.length
3192
+ let replacementUtf16Length = attrStr.length
3193
+ let shouldUseSmallPatchTextMutation =
3194
+ replaceRange != nil && shouldUseSmallPatchTextMutation(for: attrStr, replaceRange: replaceRange)
2448
3195
  isApplyingRustState = true
3196
+ let textMutationStartedAt = DispatchTime.now().uptimeNanoseconds
3197
+ let beginEditingStartedAt = DispatchTime.now().uptimeNanoseconds
2449
3198
  textStorage.beginEditing()
2450
- textStorage.setAttributedString(attrStr)
3199
+ let beginEditingNanos = DispatchTime.now().uptimeNanoseconds - beginEditingStartedAt
3200
+ var stringMutationNanos: UInt64 = 0
3201
+ var attributeMutationNanos: UInt64 = 0
3202
+ let previousTextStorageDelegate = textStorage.delegate
3203
+ let previousTextViewDelegate = delegate
3204
+ textStorage.delegate = nil
3205
+ delegate = nil
3206
+ defer {
3207
+ textStorage.delegate = previousTextStorageDelegate
3208
+ delegate = previousTextViewDelegate
3209
+ }
3210
+ if let replaceRange {
3211
+ if shouldUseSmallPatchTextMutation {
3212
+ let stringMutationStartedAt = DispatchTime.now().uptimeNanoseconds
3213
+ textStorage.replaceCharacters(in: replaceRange, with: attrStr.string)
3214
+ stringMutationNanos =
3215
+ DispatchTime.now().uptimeNanoseconds - stringMutationStartedAt
3216
+ let destinationRange = NSRange(location: replaceRange.location, length: attrStr.length)
3217
+ let attributeMutationStartedAt = DispatchTime.now().uptimeNanoseconds
3218
+ applyAttributes(from: attrStr, to: destinationRange)
3219
+ attributeMutationNanos =
3220
+ DispatchTime.now().uptimeNanoseconds - attributeMutationStartedAt
3221
+ } else {
3222
+ let stringMutationStartedAt = DispatchTime.now().uptimeNanoseconds
3223
+ textStorage.replaceCharacters(in: replaceRange, with: attrStr)
3224
+ stringMutationNanos =
3225
+ DispatchTime.now().uptimeNanoseconds - stringMutationStartedAt
3226
+ }
3227
+ } else {
3228
+ let stringMutationStartedAt = DispatchTime.now().uptimeNanoseconds
3229
+ textStorage.setAttributedString(attrStr)
3230
+ stringMutationNanos =
3231
+ DispatchTime.now().uptimeNanoseconds - stringMutationStartedAt
3232
+ }
3233
+ let endEditingStartedAt = DispatchTime.now().uptimeNanoseconds
2451
3234
  textStorage.endEditing()
2452
- lastAuthorizedText = textStorage.string
3235
+ let endEditingNanos = DispatchTime.now().uptimeNanoseconds - endEditingStartedAt
3236
+ let textMutationNanos = DispatchTime.now().uptimeNanoseconds - textMutationStartedAt
3237
+ let authorizedTextStartedAt = DispatchTime.now().uptimeNanoseconds
3238
+ if let replaceRange,
3239
+ replaceRange.location >= 0,
3240
+ replaceRange.location + replaceRange.length <= lastAuthorizedTextStorage.length
3241
+ {
3242
+ lastAuthorizedTextStorage.replaceCharacters(in: replaceRange, with: attrStr.string)
3243
+ } else {
3244
+ lastAuthorizedTextStorage.setString(attrStr.string)
3245
+ }
3246
+ let authorizedTextNanos = DispatchTime.now().uptimeNanoseconds - authorizedTextStartedAt
3247
+ let cacheInvalidationStartedAt = DispatchTime.now().uptimeNanoseconds
3248
+ lastRenderAppliedPatchForTesting = usedPatch
3249
+ switch positionCacheUpdate {
3250
+ case .plainText:
3251
+ guard let replaceRange else {
3252
+ PositionBridge.invalidateCache(for: self)
3253
+ break
3254
+ }
3255
+ let patchedPositionCache = PositionBridge.applyPlainTextPatchIfPossible(
3256
+ for: self,
3257
+ replaceRange: replaceRange,
3258
+ replacementText: attrStr.string
3259
+ )
3260
+ if !patchedPositionCache {
3261
+ PositionBridge.invalidateCache(for: self)
3262
+ }
3263
+ case .attributed:
3264
+ guard let replaceRange else {
3265
+ PositionBridge.invalidateCache(for: self)
3266
+ break
3267
+ }
3268
+ let patchedPositionCache = PositionBridge.applyAttributedPatchIfPossible(
3269
+ for: self,
3270
+ replaceRange: replaceRange,
3271
+ replacement: attrStr
3272
+ )
3273
+ if !patchedPositionCache {
3274
+ PositionBridge.invalidateCache(for: self)
3275
+ }
3276
+ case .invalidate:
3277
+ PositionBridge.invalidateCache(for: self)
3278
+ case .scan:
3279
+ let canPatchPositionCache = if let replaceRange {
3280
+ replaceRange.location >= 0
3281
+ && !textStorageRangeContainsAttachment(replaceRange)
3282
+ && !attributedStringContainsAttachment(attrStr)
3283
+ } else {
3284
+ false
3285
+ }
3286
+ if let replaceRange, canPatchPositionCache {
3287
+ let patchedPositionCache: Bool
3288
+ if !textStorageRangeContainsPositionAdjustments(replaceRange),
3289
+ !attributedStringContainsPositionAdjustments(attrStr)
3290
+ {
3291
+ patchedPositionCache = PositionBridge.applyPlainTextPatchIfPossible(
3292
+ for: self,
3293
+ replaceRange: replaceRange,
3294
+ replacementText: attrStr.string
3295
+ )
3296
+ } else {
3297
+ patchedPositionCache = PositionBridge.applyAttributedPatchIfPossible(
3298
+ for: self,
3299
+ replaceRange: replaceRange,
3300
+ replacement: attrStr
3301
+ )
3302
+ }
3303
+
3304
+ if !patchedPositionCache {
3305
+ PositionBridge.invalidateCache(for: self)
3306
+ }
3307
+ } else {
3308
+ PositionBridge.invalidateCache(for: self)
3309
+ }
3310
+ }
3311
+ let cacheInvalidationNanos = DispatchTime.now().uptimeNanoseconds - cacheInvalidationStartedAt
2453
3312
  isApplyingRustState = false
3313
+ return ApplyRenderTrace(
3314
+ totalNanos: DispatchTime.now().uptimeNanoseconds - totalStartedAt,
3315
+ replaceUtf16Length: replaceUtf16Length,
3316
+ replacementUtf16Length: replacementUtf16Length,
3317
+ textMutationNanos: textMutationNanos,
3318
+ beginEditingNanos: beginEditingNanos,
3319
+ endEditingNanos: endEditingNanos,
3320
+ stringMutationNanos: stringMutationNanos,
3321
+ attributeMutationNanos: attributeMutationNanos,
3322
+ authorizedTextNanos: authorizedTextNanos,
3323
+ cacheInvalidationNanos: cacheInvalidationNanos,
3324
+ usedSmallPatchTextMutation: shouldUseSmallPatchTextMutation
3325
+ )
3326
+ }
3327
+
3328
+ private func shouldUseSmallPatchTextMutation(
3329
+ for attributedString: NSAttributedString,
3330
+ replaceRange: NSRange?
3331
+ ) -> Bool {
3332
+ attributedString.length > 0
3333
+ && attributedString.length <= 512
3334
+ && (replaceRange?.length ?? 0) <= 512
3335
+ && !attributedStringContainsAttachment(attributedString)
3336
+ }
3337
+
3338
+ private func attributesEqualForPatchTrimming(
3339
+ _ lhs: [NSAttributedString.Key: Any],
3340
+ _ rhs: [NSAttributedString.Key: Any]
3341
+ ) -> Bool {
3342
+ if let lhsValue = lhs[RenderBridgeAttributes.topLevelChildIndex] as? NSNumber,
3343
+ let rhsValue = rhs[RenderBridgeAttributes.topLevelChildIndex] as? NSNumber,
3344
+ lhsValue == rhsValue
3345
+ {
3346
+ return NSDictionary(dictionary: lhs).isEqual(to: rhs)
3347
+ }
3348
+
3349
+ var lhsComparable = lhs
3350
+ var rhsComparable = rhs
3351
+ lhsComparable.removeValue(forKey: RenderBridgeAttributes.topLevelChildIndex)
3352
+ rhsComparable.removeValue(forKey: RenderBridgeAttributes.topLevelChildIndex)
3353
+ return NSDictionary(dictionary: lhsComparable).isEqual(to: rhsComparable)
3354
+ }
3355
+
3356
+ private func applyAttributes(from attributedString: NSAttributedString, to destinationRange: NSRange) {
3357
+ guard attributedString.length == destinationRange.length else { return }
3358
+ if let uniformAttributes = uniformAttributes(in: attributedString) {
3359
+ textStorage.setAttributes(uniformAttributes, range: destinationRange)
3360
+ return
3361
+ }
3362
+ let sourceRange = NSRange(location: 0, length: attributedString.length)
3363
+ attributedString.enumerateAttributes(
3364
+ in: sourceRange,
3365
+ options: [.longestEffectiveRangeNotRequired]
3366
+ ) { attrs, range, _ in
3367
+ let targetRange = NSRange(location: destinationRange.location + range.location, length: range.length)
3368
+ textStorage.setAttributes(attrs, range: targetRange)
3369
+ }
3370
+ }
3371
+
3372
+ private func uniformAttributes(in attributedString: NSAttributedString) -> [NSAttributedString.Key: Any]? {
3373
+ guard attributedString.length > 0 else { return [:] }
3374
+ let firstAttributes = attributedString.attributes(at: 0, effectiveRange: nil)
3375
+ var isUniform = true
3376
+ attributedString.enumerateAttributes(
3377
+ in: NSRange(location: 0, length: attributedString.length),
3378
+ options: [.longestEffectiveRangeNotRequired]
3379
+ ) { attrs, _, stop in
3380
+ guard (attrs as NSDictionary).isEqual(firstAttributes) else {
3381
+ isUniform = false
3382
+ stop.pointee = true
3383
+ return
3384
+ }
3385
+ }
3386
+ return isUniform ? firstAttributes : nil
3387
+ }
3388
+
3389
+ private func attributedStringContainsAttachment(_ attributedString: NSAttributedString) -> Bool {
3390
+ guard attributedString.length > 0 else { return false }
3391
+ var hasAttachment = false
3392
+ attributedString.enumerateAttribute(
3393
+ .attachment,
3394
+ in: NSRange(location: 0, length: attributedString.length),
3395
+ options: [.longestEffectiveRangeNotRequired]
3396
+ ) { value, _, stop in
3397
+ if value != nil {
3398
+ hasAttachment = true
3399
+ stop.pointee = true
3400
+ }
3401
+ }
3402
+ return hasAttachment
3403
+ }
3404
+
3405
+ private func attributedStringContainsPositionAdjustments(_ attributedString: NSAttributedString) -> Bool {
3406
+ guard attributedString.length > 0 else { return false }
3407
+ var hasAdjustments = false
3408
+ attributedString.enumerateAttributes(
3409
+ in: NSRange(location: 0, length: attributedString.length),
3410
+ options: [.longestEffectiveRangeNotRequired]
3411
+ ) { attrs, _, stop in
3412
+ if attrs[RenderBridgeAttributes.syntheticPlaceholder] as? Bool == true
3413
+ || attrs[RenderBridgeAttributes.listMarkerContext] != nil
3414
+ {
3415
+ hasAdjustments = true
3416
+ stop.pointee = true
3417
+ }
3418
+ }
3419
+ return hasAdjustments
3420
+ }
3421
+
3422
+ private func attributedStringContainsListMarkerContext(_ attributedString: NSAttributedString) -> Bool {
3423
+ guard attributedString.length > 0 else { return false }
3424
+ var hasListMarkerContext = false
3425
+ attributedString.enumerateAttribute(
3426
+ RenderBridgeAttributes.listMarkerContext,
3427
+ in: NSRange(location: 0, length: attributedString.length),
3428
+ options: [.longestEffectiveRangeNotRequired]
3429
+ ) { value, _, stop in
3430
+ if value != nil {
3431
+ hasListMarkerContext = true
3432
+ stop.pointee = true
3433
+ }
3434
+ }
3435
+ return hasListMarkerContext
3436
+ }
3437
+
3438
+ private func textStorageRangeContainsPositionAdjustments(_ range: NSRange) -> Bool {
3439
+ guard range.length > 0,
3440
+ range.location >= 0,
3441
+ range.location + range.length <= textStorage.length
3442
+ else {
3443
+ return false
3444
+ }
3445
+
3446
+ var hasAdjustments = false
3447
+ textStorage.enumerateAttributes(
3448
+ in: range,
3449
+ options: [.longestEffectiveRangeNotRequired]
3450
+ ) { attrs, _, stop in
3451
+ if attrs[RenderBridgeAttributes.syntheticPlaceholder] as? Bool == true
3452
+ || attrs[RenderBridgeAttributes.listMarkerContext] != nil
3453
+ {
3454
+ hasAdjustments = true
3455
+ stop.pointee = true
3456
+ }
3457
+ }
3458
+ return hasAdjustments
3459
+ }
3460
+
3461
+ private func textStorageRangeContainsListMarkerContext(_ range: NSRange) -> Bool {
3462
+ guard range.length > 0,
3463
+ range.location >= 0,
3464
+ range.location + range.length <= textStorage.length
3465
+ else {
3466
+ return false
3467
+ }
3468
+
3469
+ var hasListMarkerContext = false
3470
+ textStorage.enumerateAttribute(
3471
+ RenderBridgeAttributes.listMarkerContext,
3472
+ in: range,
3473
+ options: [.longestEffectiveRangeNotRequired]
3474
+ ) { value, _, stop in
3475
+ if value != nil {
3476
+ hasListMarkerContext = true
3477
+ stop.pointee = true
3478
+ }
3479
+ }
3480
+ return hasListMarkerContext
3481
+ }
3482
+
3483
+ private func textStorageRangeContainsAttachment(_ range: NSRange) -> Bool {
3484
+ guard range.length > 0,
3485
+ range.location >= 0,
3486
+ range.location + range.length <= textStorage.length
3487
+ else {
3488
+ return false
3489
+ }
3490
+
3491
+ var hasAttachment = false
3492
+ textStorage.enumerateAttribute(
3493
+ .attachment,
3494
+ in: range,
3495
+ options: [.longestEffectiveRangeNotRequired]
3496
+ ) { value, _, stop in
3497
+ if value != nil {
3498
+ hasAttachment = true
3499
+ stop.pointee = true
3500
+ }
3501
+ }
3502
+ return hasAttachment
3503
+ }
3504
+
3505
+ private func topLevelChildrenContainAttachment(
3506
+ startIndex: Int,
3507
+ deleteCount: Int
3508
+ ) -> Bool {
3509
+ guard deleteCount > 0,
3510
+ let currentTopLevelChildMetadata,
3511
+ startIndex >= 0,
3512
+ startIndex + deleteCount <= currentTopLevelChildMetadata.count
3513
+ else {
3514
+ return false
3515
+ }
3516
+ return currentTopLevelChildMetadata[startIndex..<(startIndex + deleteCount)]
3517
+ .contains(where: \.containsAttachment)
3518
+ }
3519
+
3520
+ private func topLevelChildrenContainPositionAdjustments(
3521
+ startIndex: Int,
3522
+ deleteCount: Int
3523
+ ) -> Bool {
3524
+ guard deleteCount > 0,
3525
+ let currentTopLevelChildMetadata,
3526
+ startIndex >= 0,
3527
+ startIndex + deleteCount <= currentTopLevelChildMetadata.count
3528
+ else {
3529
+ return false
3530
+ }
3531
+ return currentTopLevelChildMetadata[startIndex..<(startIndex + deleteCount)]
3532
+ .contains(where: \.containsPositionAdjustments)
3533
+ }
3534
+
3535
+ private func trimmedAttributedPatch(
3536
+ replacing fullReplaceRange: NSRange,
3537
+ with replacement: NSAttributedString
3538
+ ) -> (replaceRange: NSRange, replacement: NSAttributedString) {
3539
+ guard fullReplaceRange.length > 0 else {
3540
+ return (fullReplaceRange, replacement)
3541
+ }
3542
+
3543
+ let existing = textStorage.attributedSubstring(from: fullReplaceRange)
3544
+ let existingString = existing.string as NSString
3545
+ let replacementString = replacement.string as NSString
3546
+ let sharedLength = min(existing.length, replacement.length)
3547
+
3548
+ var prefix = 0
3549
+ while prefix < sharedLength {
3550
+ var existingRange = NSRange()
3551
+ let existingAttrs = existing.attributes(
3552
+ at: prefix,
3553
+ longestEffectiveRange: &existingRange,
3554
+ in: NSRange(location: prefix, length: sharedLength - prefix)
3555
+ )
3556
+ var replacementRange = NSRange()
3557
+ let replacementAttrs = replacement.attributes(
3558
+ at: prefix,
3559
+ longestEffectiveRange: &replacementRange,
3560
+ in: NSRange(location: prefix, length: sharedLength - prefix)
3561
+ )
3562
+ guard attributesEqualForPatchTrimming(existingAttrs, replacementAttrs) else { break }
3563
+ let runEnd = min(NSMaxRange(existingRange), NSMaxRange(replacementRange), sharedLength)
3564
+ while prefix < runEnd,
3565
+ existingString.character(at: prefix) == replacementString.character(at: prefix)
3566
+ {
3567
+ prefix += 1
3568
+ }
3569
+ if prefix < runEnd {
3570
+ break
3571
+ }
3572
+ }
3573
+
3574
+ var suffix = 0
3575
+ while suffix < (sharedLength - prefix) {
3576
+ let existingIndex = existing.length - suffix - 1
3577
+ let replacementIndex = replacement.length - suffix - 1
3578
+ var existingRange = NSRange()
3579
+ let existingAttrs = existing.attributes(
3580
+ at: existingIndex,
3581
+ longestEffectiveRange: &existingRange,
3582
+ in: NSRange(location: prefix, length: existingIndex - prefix + 1)
3583
+ )
3584
+ var replacementRange = NSRange()
3585
+ let replacementAttrs = replacement.attributes(
3586
+ at: replacementIndex,
3587
+ longestEffectiveRange: &replacementRange,
3588
+ in: NSRange(location: prefix, length: replacementIndex - prefix + 1)
3589
+ )
3590
+ guard attributesEqualForPatchTrimming(existingAttrs, replacementAttrs) else { break }
3591
+ let maxComparableLength = min(
3592
+ existingIndex - max(existingRange.location, prefix) + 1,
3593
+ replacementIndex - max(replacementRange.location, prefix) + 1,
3594
+ sharedLength - prefix - suffix
3595
+ )
3596
+ var matchedLength = 0
3597
+ while matchedLength < maxComparableLength,
3598
+ existingString.character(at: existingIndex - matchedLength)
3599
+ == replacementString.character(at: replacementIndex - matchedLength)
3600
+ {
3601
+ matchedLength += 1
3602
+ }
3603
+ suffix += matchedLength
3604
+ if matchedLength < maxComparableLength {
3605
+ break
3606
+ }
3607
+ }
3608
+
3609
+ guard prefix > 0 || suffix > 0 else {
3610
+ return (fullReplaceRange, replacement)
3611
+ }
3612
+
3613
+ let trimmedReplaceRange = NSRange(
3614
+ location: fullReplaceRange.location + prefix,
3615
+ length: fullReplaceRange.length - prefix - suffix
3616
+ )
3617
+ let trimmedReplacementRange = NSRange(
3618
+ location: prefix,
3619
+ length: replacement.length - prefix - suffix
3620
+ )
3621
+ return (
3622
+ trimmedReplaceRange,
3623
+ replacement.attributedSubstring(from: trimmedReplacementRange)
3624
+ )
3625
+ }
3626
+
3627
+ private func applyRenderPatchIfPossible(_ patch: ParsedRenderPatch) -> PatchApplyTrace {
3628
+ let eligibilityStartedAt = DispatchTime.now().uptimeNanoseconds
3629
+ guard hasTopLevelChildMetadata(),
3630
+ let fullReplaceRange = replacementRangeForRenderPatch(
3631
+ startIndex: patch.startIndex,
3632
+ deleteCount: patch.deleteCount
3633
+ )
3634
+ else {
3635
+ return PatchApplyTrace(
3636
+ applied: false,
3637
+ eligibilityNanos: DispatchTime.now().uptimeNanoseconds - eligibilityStartedAt,
3638
+ trimNanos: 0,
3639
+ metadataNanos: 0,
3640
+ buildRenderNanos: 0,
3641
+ applyRenderNanos: 0,
3642
+ applyRenderReplaceUtf16Length: 0,
3643
+ applyRenderReplacementUtf16Length: 0,
3644
+ applyRenderTextMutationNanos: 0,
3645
+ applyRenderBeginEditingNanos: 0,
3646
+ applyRenderEndEditingNanos: 0,
3647
+ applyRenderStringMutationNanos: 0,
3648
+ applyRenderAttributeMutationNanos: 0,
3649
+ applyRenderAuthorizedTextNanos: 0,
3650
+ applyRenderCacheInvalidationNanos: 0,
3651
+ usedSmallPatchTextMutation: false
3652
+ )
3653
+ }
3654
+
3655
+ let buildStartedAt = DispatchTime.now().uptimeNanoseconds
3656
+ let attrStr = RenderBridge.renderBlocks(
3657
+ fromArray: patch.renderBlocks,
3658
+ startIndex: patch.startIndex,
3659
+ includeLeadingInterBlockSeparator: patch.startIndex > 0,
3660
+ baseFont: baseFont,
3661
+ textColor: baseTextColor,
3662
+ theme: theme
3663
+ )
3664
+ let buildRenderNanos = DispatchTime.now().uptimeNanoseconds - buildStartedAt
3665
+ let renderedPatchMetadata = topLevelChildMetadataSlice(from: attrStr)
3666
+ let renderedPatchContainsAttachment =
3667
+ renderedPatchMetadata?.entries.contains(where: \.containsAttachment)
3668
+ ?? attributedStringContainsAttachment(attrStr)
3669
+ let renderedPatchContainsListMarkerContext =
3670
+ attributedStringContainsListMarkerContext(attrStr)
3671
+ let renderedPatchContainsPositionAdjustments =
3672
+ renderedPatchMetadata?.entries.contains(where: \.containsPositionAdjustments)
3673
+ ?? attributedStringContainsPositionAdjustments(attrStr)
3674
+ guard !topLevelChildrenContainAttachment(
3675
+ startIndex: patch.startIndex,
3676
+ deleteCount: patch.deleteCount
3677
+ ),
3678
+ !renderedPatchContainsAttachment
3679
+ else {
3680
+ return PatchApplyTrace(
3681
+ applied: false,
3682
+ eligibilityNanos: DispatchTime.now().uptimeNanoseconds - eligibilityStartedAt,
3683
+ trimNanos: 0,
3684
+ metadataNanos: 0,
3685
+ buildRenderNanos: buildRenderNanos,
3686
+ applyRenderNanos: 0,
3687
+ applyRenderReplaceUtf16Length: 0,
3688
+ applyRenderReplacementUtf16Length: 0,
3689
+ applyRenderTextMutationNanos: 0,
3690
+ applyRenderBeginEditingNanos: 0,
3691
+ applyRenderEndEditingNanos: 0,
3692
+ applyRenderStringMutationNanos: 0,
3693
+ applyRenderAttributeMutationNanos: 0,
3694
+ applyRenderAuthorizedTextNanos: 0,
3695
+ applyRenderCacheInvalidationNanos: 0,
3696
+ usedSmallPatchTextMutation: false
3697
+ )
3698
+ }
3699
+ guard !textStorageRangeContainsListMarkerContext(fullReplaceRange),
3700
+ !renderedPatchContainsListMarkerContext
3701
+ else {
3702
+ return PatchApplyTrace(
3703
+ applied: false,
3704
+ eligibilityNanos: DispatchTime.now().uptimeNanoseconds - eligibilityStartedAt,
3705
+ trimNanos: 0,
3706
+ metadataNanos: 0,
3707
+ buildRenderNanos: buildRenderNanos,
3708
+ applyRenderNanos: 0,
3709
+ applyRenderReplaceUtf16Length: 0,
3710
+ applyRenderReplacementUtf16Length: 0,
3711
+ applyRenderTextMutationNanos: 0,
3712
+ applyRenderBeginEditingNanos: 0,
3713
+ applyRenderEndEditingNanos: 0,
3714
+ applyRenderStringMutationNanos: 0,
3715
+ applyRenderAttributeMutationNanos: 0,
3716
+ applyRenderAuthorizedTextNanos: 0,
3717
+ applyRenderCacheInvalidationNanos: 0,
3718
+ usedSmallPatchTextMutation: false
3719
+ )
3720
+ }
3721
+ let eligibilityNanos =
3722
+ DispatchTime.now().uptimeNanoseconds - eligibilityStartedAt - buildRenderNanos
3723
+ let positionCacheUpdate: PositionCacheUpdate =
3724
+ if topLevelChildrenContainPositionAdjustments(
3725
+ startIndex: patch.startIndex,
3726
+ deleteCount: patch.deleteCount
3727
+ ) || renderedPatchContainsPositionAdjustments
3728
+ {
3729
+ .attributed
3730
+ } else {
3731
+ .plainText
3732
+ }
3733
+ let trimStartedAt = DispatchTime.now().uptimeNanoseconds
3734
+ let patchToApply = trimmedAttributedPatch(replacing: fullReplaceRange, with: attrStr)
3735
+ let trimNanos = DispatchTime.now().uptimeNanoseconds - trimStartedAt
3736
+ let applyTrace = applyAttributedRender(
3737
+ patchToApply.replacement,
3738
+ replaceRange: patchToApply.replaceRange,
3739
+ usedPatch: true,
3740
+ positionCacheUpdate: positionCacheUpdate
3741
+ )
3742
+ let metadataStartedAt = DispatchTime.now().uptimeNanoseconds
3743
+ applyTopLevelChildMetadataPatch(
3744
+ patch,
3745
+ replaceRange: fullReplaceRange,
3746
+ renderedPatchMetadata: renderedPatchMetadata,
3747
+ renderedPatchLength: attrStr.length
3748
+ )
3749
+ let metadataNanos = DispatchTime.now().uptimeNanoseconds - metadataStartedAt
3750
+ return PatchApplyTrace(
3751
+ applied: true,
3752
+ eligibilityNanos: eligibilityNanos,
3753
+ trimNanos: trimNanos,
3754
+ metadataNanos: metadataNanos,
3755
+ buildRenderNanos: buildRenderNanos,
3756
+ applyRenderNanos: applyTrace.totalNanos,
3757
+ applyRenderReplaceUtf16Length: applyTrace.replaceUtf16Length,
3758
+ applyRenderReplacementUtf16Length: applyTrace.replacementUtf16Length,
3759
+ applyRenderTextMutationNanos: applyTrace.textMutationNanos,
3760
+ applyRenderBeginEditingNanos: applyTrace.beginEditingNanos,
3761
+ applyRenderEndEditingNanos: applyTrace.endEditingNanos,
3762
+ applyRenderStringMutationNanos: applyTrace.stringMutationNanos,
3763
+ applyRenderAttributeMutationNanos: applyTrace.attributeMutationNanos,
3764
+ applyRenderAuthorizedTextNanos: applyTrace.authorizedTextNanos,
3765
+ applyRenderCacheInvalidationNanos: applyTrace.cacheInvalidationNanos,
3766
+ usedSmallPatchTextMutation: applyTrace.usedSmallPatchTextMutation
3767
+ )
3768
+ }
3769
+
3770
+ /// Apply a full render update from Rust to the text view.
3771
+ ///
3772
+ /// Parses the update JSON, converts render elements to NSAttributedString
3773
+ /// via RenderBridge, and replaces the text view's content.
3774
+ ///
3775
+ /// - Parameter updateJSON: The JSON string from editor_insert_text, etc.
3776
+ func applyUpdateJSON(_ updateJSON: String, notifyDelegate: Bool = true) {
3777
+ let totalStartedAt = DispatchTime.now().uptimeNanoseconds
3778
+ let parseStartedAt = totalStartedAt
3779
+ guard let data = updateJSON.data(using: .utf8),
3780
+ let update = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
3781
+ else { return }
3782
+ let parseNanos = DispatchTime.now().uptimeNanoseconds - parseStartedAt
3783
+
3784
+ let renderElements = update["renderElements"] as? [[String: Any]]
3785
+ let selectionFromUpdate = (update["selection"] as? [String: Any])
3786
+ .map(self.selectionSummary(from:)) ?? "none"
3787
+ Self.updateLog.debug(
3788
+ "[applyUpdateJSON.begin] renderCount=\(renderElements?.count ?? 0) updateSelection=\(selectionFromUpdate, privacy: .public) before=\(self.textSnapshotSummary(), privacy: .public)"
3789
+ )
3790
+ let resolveRenderBlocksStartedAt = DispatchTime.now().uptimeNanoseconds
3791
+ let renderBlocks = parseRenderBlocks(update["renderBlocks"])
3792
+ let explicitRenderPatch = parseRenderPatch(update["renderPatch"])
3793
+ let resolvedRenderBlocks = renderBlocks
3794
+ ?? explicitRenderPatch.flatMap { patch in
3795
+ currentRenderBlocks.flatMap { mergeRenderBlocks(applying: patch, to: $0) }
3796
+ }
3797
+ let resolveRenderBlocksNanos =
3798
+ DispatchTime.now().uptimeNanoseconds - resolveRenderBlocksStartedAt
3799
+
3800
+ let derivedRenderPatch: DerivedRenderPatch? =
3801
+ if explicitRenderPatch == nil,
3802
+ let currentRenderBlocks,
3803
+ let resolvedRenderBlocks
3804
+ {
3805
+ deriveRenderPatch(from: currentRenderBlocks, to: resolvedRenderBlocks)
3806
+ } else {
3807
+ nil
3808
+ }
3809
+ let renderPatch = explicitRenderPatch ?? {
3810
+ if case let .patch(patch)? = derivedRenderPatch {
3811
+ return patch
3812
+ }
3813
+ return nil
3814
+ }()
3815
+ let shouldSkipRender = if case .unchanged? = derivedRenderPatch {
3816
+ textStorage.string == lastAuthorizedText
3817
+ && lastAppliedRenderAppearanceRevision == renderAppearanceRevision
3818
+ } else {
3819
+ false
3820
+ }
3821
+
3822
+ let patchTrace = renderPatch.map(applyRenderPatchIfPossible)
3823
+ let appliedPatch = patchTrace?.applied == true
3824
+ var usedSmallPatchTextMutation = patchTrace?.usedSmallPatchTextMutation ?? false
3825
+ var applyRenderReplaceUtf16Length = patchTrace?.applyRenderReplaceUtf16Length ?? 0
3826
+ var applyRenderReplacementUtf16Length =
3827
+ patchTrace?.applyRenderReplacementUtf16Length ?? 0
3828
+ var buildRenderNanos = patchTrace?.buildRenderNanos ?? 0
3829
+ var applyRenderNanos = patchTrace?.applyRenderNanos ?? 0
3830
+ var applyRenderTextMutationNanos = patchTrace?.applyRenderTextMutationNanos ?? 0
3831
+ var applyRenderBeginEditingNanos = patchTrace?.applyRenderBeginEditingNanos ?? 0
3832
+ var applyRenderEndEditingNanos = patchTrace?.applyRenderEndEditingNanos ?? 0
3833
+ var applyRenderStringMutationNanos = patchTrace?.applyRenderStringMutationNanos ?? 0
3834
+ var applyRenderAttributeMutationNanos =
3835
+ patchTrace?.applyRenderAttributeMutationNanos ?? 0
3836
+ var applyRenderAuthorizedTextNanos = patchTrace?.applyRenderAuthorizedTextNanos ?? 0
3837
+ var applyRenderCacheInvalidationNanos = patchTrace?.applyRenderCacheInvalidationNanos ?? 0
3838
+ if shouldSkipRender {
3839
+ lastRenderAppliedPatchForTesting = false
3840
+ if let resolvedRenderBlocks {
3841
+ currentRenderBlocks = resolvedRenderBlocks
3842
+ }
3843
+ } else if !appliedPatch {
3844
+ let buildStartedAt = DispatchTime.now().uptimeNanoseconds
3845
+ let attrStr: NSAttributedString
3846
+ if let resolvedRenderBlocks {
3847
+ attrStr = RenderBridge.renderBlocks(
3848
+ fromArray: resolvedRenderBlocks,
3849
+ baseFont: baseFont,
3850
+ textColor: baseTextColor,
3851
+ theme: theme
3852
+ )
3853
+ currentRenderBlocks = resolvedRenderBlocks
3854
+ } else if let renderElements {
3855
+ attrStr = RenderBridge.renderElements(
3856
+ fromArray: renderElements,
3857
+ baseFont: baseFont,
3858
+ textColor: baseTextColor,
3859
+ theme: theme
3860
+ )
3861
+ currentRenderBlocks = nil
3862
+ } else {
3863
+ return
3864
+ }
3865
+ buildRenderNanos = DispatchTime.now().uptimeNanoseconds - buildStartedAt
3866
+ let applyTrace = applyAttributedRender(
3867
+ attrStr,
3868
+ usedPatch: false,
3869
+ positionCacheUpdate: .invalidate
3870
+ )
3871
+ refreshTopLevelChildMetadata(from: attrStr)
3872
+ applyRenderReplaceUtf16Length = applyTrace.replaceUtf16Length
3873
+ applyRenderReplacementUtf16Length = applyTrace.replacementUtf16Length
3874
+ applyRenderNanos = applyTrace.totalNanos
3875
+ applyRenderTextMutationNanos = applyTrace.textMutationNanos
3876
+ applyRenderBeginEditingNanos = applyTrace.beginEditingNanos
3877
+ applyRenderEndEditingNanos = applyTrace.endEditingNanos
3878
+ applyRenderStringMutationNanos = applyTrace.stringMutationNanos
3879
+ applyRenderAttributeMutationNanos = applyTrace.attributeMutationNanos
3880
+ applyRenderAuthorizedTextNanos = applyTrace.authorizedTextNanos
3881
+ applyRenderCacheInvalidationNanos = applyTrace.cacheInvalidationNanos
3882
+ usedSmallPatchTextMutation = applyTrace.usedSmallPatchTextMutation
3883
+ lastAppliedRenderAppearanceRevision = renderAppearanceRevision
3884
+ } else if let resolvedRenderBlocks {
3885
+ currentRenderBlocks = resolvedRenderBlocks
3886
+ lastAppliedRenderAppearanceRevision = renderAppearanceRevision
3887
+ }
2454
3888
 
2455
3889
  refreshPlaceholderVisibility()
2456
- refreshTypingAttributesForSelection()
2457
- if heightBehavior == .autoGrow {
2458
- notifyHeightChangeIfNeeded(force: true)
3890
+ Self.updateLog.debug(
3891
+ "[applyUpdateJSON.rendered] mode=\(appliedPatch ? "patch" : "full", privacy: .public) after=\(self.textSnapshotSummary(), privacy: .public)"
3892
+ )
3893
+
3894
+ // Apply the selection from the update.
3895
+ let selectionTrace: SelectionApplyTrace
3896
+ if let selection = update["selection"] as? [String: Any] {
3897
+ selectionTrace = applySelectionFromJSON(selection)
3898
+ } else {
3899
+ selectionTrace = SelectionApplyTrace(
3900
+ totalNanos: 0,
3901
+ resolveNanos: 0,
3902
+ assignmentNanos: 0,
3903
+ chromeNanos: 0
3904
+ )
2459
3905
  }
2460
- onSelectionOrContentMayChange?()
3906
+ let postApplyTrace = performPostApplyMaintenance()
3907
+ let postApplyNanos = postApplyTrace.totalNanos
3908
+
3909
+ if captureApplyUpdateTraceForTesting {
3910
+ lastApplyUpdateTraceForTesting = ApplyUpdateTrace(
3911
+ attemptedPatch: renderPatch != nil,
3912
+ usedPatch: appliedPatch,
3913
+ usedSmallPatchTextMutation: usedSmallPatchTextMutation,
3914
+ applyRenderReplaceUtf16Length: applyRenderReplaceUtf16Length,
3915
+ applyRenderReplacementUtf16Length: applyRenderReplacementUtf16Length,
3916
+ parseNanos: parseNanos,
3917
+ resolveRenderBlocksNanos: resolveRenderBlocksNanos,
3918
+ patchEligibilityNanos: patchTrace?.eligibilityNanos ?? 0,
3919
+ patchTrimNanos: patchTrace?.trimNanos ?? 0,
3920
+ patchMetadataNanos: patchTrace?.metadataNanos ?? 0,
3921
+ buildRenderNanos: buildRenderNanos,
3922
+ applyRenderNanos: applyRenderNanos,
3923
+ selectionNanos: selectionTrace.totalNanos,
3924
+ postApplyNanos: postApplyNanos,
3925
+ totalNanos: DispatchTime.now().uptimeNanoseconds - totalStartedAt,
3926
+ applyRenderTextMutationNanos: applyRenderTextMutationNanos,
3927
+ applyRenderBeginEditingNanos: applyRenderBeginEditingNanos,
3928
+ applyRenderEndEditingNanos: applyRenderEndEditingNanos,
3929
+ applyRenderStringMutationNanos: applyRenderStringMutationNanos,
3930
+ applyRenderAttributeMutationNanos: applyRenderAttributeMutationNanos,
3931
+ applyRenderAuthorizedTextNanos: applyRenderAuthorizedTextNanos,
3932
+ applyRenderCacheInvalidationNanos: applyRenderCacheInvalidationNanos,
3933
+ selectionResolveNanos: selectionTrace.resolveNanos,
3934
+ selectionAssignmentNanos: selectionTrace.assignmentNanos,
3935
+ selectionChromeNanos: selectionTrace.chromeNanos,
3936
+ postApplyTypingAttributesNanos: postApplyTrace.typingAttributesNanos,
3937
+ postApplyHeightNotifyNanos: postApplyTrace.heightNotifyNanos,
3938
+ postApplyHeightNotifyMeasureNanos: postApplyTrace.heightNotifyMeasureNanos,
3939
+ postApplyHeightNotifyCallbackNanos: postApplyTrace.heightNotifyCallbackNanos,
3940
+ postApplyHeightNotifyEnsureLayoutNanos: postApplyTrace.heightNotifyEnsureLayoutNanos,
3941
+ postApplyHeightNotifyUsedRectNanos: postApplyTrace.heightNotifyUsedRectNanos,
3942
+ postApplyHeightNotifyContentSizeNanos: postApplyTrace.heightNotifyContentSizeNanos,
3943
+ postApplyHeightNotifySizeThatFitsNanos: postApplyTrace.heightNotifySizeThatFitsNanos,
3944
+ postApplySelectionOrContentCallbackNanos:
3945
+ postApplyTrace.selectionOrContentCallbackNanos
3946
+ )
3947
+ }
3948
+ Self.updateLog.debug(
3949
+ "[applyUpdateJSON.end] finalSelection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
3950
+ )
3951
+
3952
+ // Notify the delegate.
3953
+ if notifyDelegate {
3954
+ editorDelegate?.editorTextView(self, didReceiveUpdate: updateJSON)
3955
+ }
3956
+ }
3957
+
3958
+ /// Apply a render JSON string (just render elements, no update wrapper).
3959
+ ///
3960
+ /// Used for initial content loading (set_html / set_json return render
3961
+ /// elements directly, not wrapped in an EditorUpdate).
3962
+ func applyRenderJSON(_ renderJSON: String) {
3963
+ Self.updateLog.debug(
3964
+ "[applyRenderJSON.begin] before=\(self.textSnapshotSummary(), privacy: .public)"
3965
+ )
3966
+ let attrStr = RenderBridge.renderElements(
3967
+ fromJSON: renderJSON,
3968
+ baseFont: baseFont,
3969
+ textColor: baseTextColor,
3970
+ theme: theme
3971
+ )
3972
+ _ = applyAttributedRender(attrStr, usedPatch: false)
3973
+ currentRenderBlocks = nil
3974
+ lastAppliedRenderAppearanceRevision = renderAppearanceRevision
3975
+
3976
+ refreshPlaceholderVisibility()
3977
+ _ = performPostApplyMaintenance()
2461
3978
  Self.updateLog.debug(
2462
3979
  "[applyRenderJSON.end] after=\(self.textSnapshotSummary(), privacy: .public)"
2463
3980
  )
@@ -2471,54 +3988,136 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2471
3988
  /// {"type": "node", "pos": 10}
2472
3989
  /// {"type": "all"}
2473
3990
  /// ```
2474
- private func applySelectionFromJSON(_ selection: [String: Any]) {
2475
- guard let type = selection["type"] as? String else { return }
3991
+ private func applySelectionFromJSON(_ selection: [String: Any]) -> SelectionApplyTrace {
3992
+ guard let type = selection["type"] as? String else {
3993
+ return SelectionApplyTrace(totalNanos: 0, resolveNanos: 0, assignmentNanos: 0, chromeNanos: 0)
3994
+ }
2476
3995
 
3996
+ let totalStartedAt = DispatchTime.now().uptimeNanoseconds
2477
3997
  isApplyingRustState = true
2478
3998
  defer { isApplyingRustState = false }
2479
3999
 
2480
4000
  switch type {
2481
4001
  case "text":
4002
+ let resolveStartedAt = DispatchTime.now().uptimeNanoseconds
2482
4003
  guard let anchorNum = selection["anchor"] as? NSNumber,
2483
4004
  let headNum = selection["head"] as? NSNumber
2484
- else { return }
4005
+ else {
4006
+ return SelectionApplyTrace(totalNanos: 0, resolveNanos: 0, assignmentNanos: 0, chromeNanos: 0)
4007
+ }
2485
4008
  // anchor/head from Rust are document positions; convert to scalar offsets first.
2486
- let anchorScalar = editorDocToScalar(id: editorId, docPos: anchorNum.uint32Value)
2487
- let headScalar = editorDocToScalar(id: editorId, docPos: headNum.uint32Value)
2488
-
2489
- let startPos = PositionBridge.scalarToTextView(min(anchorScalar, headScalar), in: self)
2490
- let endPos = PositionBridge.scalarToTextView(max(anchorScalar, headScalar), in: self)
2491
- selectedTextRange = textRange(from: startPos, to: endPos)
2492
- refreshNativeSelectionChromeVisibility()
4009
+ let anchorScalar = (selection["anchorScalar"] as? NSNumber)?.uint32Value
4010
+ ?? editorDocToScalar(id: editorId, docPos: anchorNum.uint32Value)
4011
+ let headScalar = (selection["headScalar"] as? NSNumber)?.uint32Value
4012
+ ?? editorDocToScalar(id: editorId, docPos: headNum.uint32Value)
4013
+ let startUtf16 = PositionBridge.scalarToUtf16Offset(
4014
+ min(anchorScalar, headScalar),
4015
+ in: self
4016
+ )
4017
+ let endUtf16 = PositionBridge.scalarToUtf16Offset(
4018
+ max(anchorScalar, headScalar),
4019
+ in: self
4020
+ )
4021
+ let resolveNanos = DispatchTime.now().uptimeNanoseconds - resolveStartedAt
4022
+
4023
+ let assignmentStartedAt = DispatchTime.now().uptimeNanoseconds
4024
+ if anchorScalar == headScalar {
4025
+ let endPos = position(from: beginningOfDocument, offset: endUtf16) ?? endOfDocument
4026
+ if let adjustedPosition = autocapitalizationFriendlyEmptyBlockPosition(for: endPos) {
4027
+ let adjustedOffset = offset(from: beginningOfDocument, to: adjustedPosition)
4028
+ let adjustedRange = NSRange(location: adjustedOffset, length: 0)
4029
+ if selectedRange != adjustedRange {
4030
+ selectedRange = adjustedRange
4031
+ }
4032
+ } else {
4033
+ let targetRange = NSRange(location: endUtf16, length: 0)
4034
+ if selectedRange != targetRange {
4035
+ selectedRange = targetRange
4036
+ }
4037
+ }
4038
+ } else {
4039
+ let targetRange = NSRange(location: startUtf16, length: endUtf16 - startUtf16)
4040
+ if selectedRange != targetRange {
4041
+ selectedRange = targetRange
4042
+ }
4043
+ }
4044
+ let assignmentNanos = DispatchTime.now().uptimeNanoseconds - assignmentStartedAt
4045
+ let chromeStartedAt = DispatchTime.now().uptimeNanoseconds
4046
+ showNativeSelectionChromeIfNeeded()
4047
+ let chromeNanos = DispatchTime.now().uptimeNanoseconds - chromeStartedAt
2493
4048
  Self.selectionLog.debug(
2494
4049
  "[applySelectionFromJSON.text] doc=\(anchorNum.uint32Value)-\(headNum.uint32Value) scalar=\(anchorScalar)-\(headScalar) final=\(self.selectionSummary(), privacy: .public)"
2495
4050
  )
4051
+ return SelectionApplyTrace(
4052
+ totalNanos: DispatchTime.now().uptimeNanoseconds - totalStartedAt,
4053
+ resolveNanos: resolveNanos,
4054
+ assignmentNanos: assignmentNanos,
4055
+ chromeNanos: chromeNanos
4056
+ )
2496
4057
 
2497
4058
  case "node":
2498
4059
  // Node selection: select the object replacement character at that position.
2499
- guard let posNum = selection["pos"] as? NSNumber else { return }
4060
+ let resolveStartedAt = DispatchTime.now().uptimeNanoseconds
4061
+ guard let posNum = selection["pos"] as? NSNumber else {
4062
+ return SelectionApplyTrace(totalNanos: 0, resolveNanos: 0, assignmentNanos: 0, chromeNanos: 0)
4063
+ }
2500
4064
  // pos from Rust is a document position; convert to scalar offset.
2501
- let posScalar = editorDocToScalar(id: editorId, docPos: posNum.uint32Value)
2502
- let startPos = PositionBridge.scalarToTextView(posScalar, in: self)
2503
- // Select one character (the void node placeholder).
2504
- if let endPos = position(from: startPos, offset: 1) {
2505
- selectedTextRange = textRange(from: startPos, to: endPos)
4065
+ let posScalar = (selection["posScalar"] as? NSNumber)?.uint32Value
4066
+ ?? editorDocToScalar(id: editorId, docPos: posNum.uint32Value)
4067
+ let startUtf16 = PositionBridge.scalarToUtf16Offset(posScalar, in: self)
4068
+ let targetRange = NSRange(location: startUtf16, length: 1)
4069
+ let resolveNanos = DispatchTime.now().uptimeNanoseconds - resolveStartedAt
4070
+ let assignmentStartedAt = DispatchTime.now().uptimeNanoseconds
4071
+ if selectedRange != targetRange {
4072
+ selectedRange = targetRange
2506
4073
  }
4074
+ let assignmentNanos = DispatchTime.now().uptimeNanoseconds - assignmentStartedAt
4075
+ let chromeStartedAt = DispatchTime.now().uptimeNanoseconds
2507
4076
  refreshNativeSelectionChromeVisibility()
4077
+ let chromeNanos = DispatchTime.now().uptimeNanoseconds - chromeStartedAt
2508
4078
  Self.selectionLog.debug(
2509
4079
  "[applySelectionFromJSON.node] doc=\(posNum.uint32Value) scalar=\(posScalar) final=\(self.selectionSummary(), privacy: .public)"
2510
4080
  )
4081
+ return SelectionApplyTrace(
4082
+ totalNanos: DispatchTime.now().uptimeNanoseconds - totalStartedAt,
4083
+ resolveNanos: resolveNanos,
4084
+ assignmentNanos: assignmentNanos,
4085
+ chromeNanos: chromeNanos
4086
+ )
2511
4087
 
2512
4088
  case "all":
4089
+ let assignmentStartedAt = DispatchTime.now().uptimeNanoseconds
2513
4090
  selectedTextRange = textRange(from: beginningOfDocument, to: endOfDocument)
2514
- refreshNativeSelectionChromeVisibility()
4091
+ let assignmentNanos = DispatchTime.now().uptimeNanoseconds - assignmentStartedAt
4092
+ let chromeStartedAt = DispatchTime.now().uptimeNanoseconds
4093
+ showNativeSelectionChromeIfNeeded()
4094
+ let chromeNanos = DispatchTime.now().uptimeNanoseconds - chromeStartedAt
2515
4095
  Self.selectionLog.debug(
2516
4096
  "[applySelectionFromJSON.all] final=\(self.selectionSummary(), privacy: .public)"
2517
4097
  )
4098
+ return SelectionApplyTrace(
4099
+ totalNanos: DispatchTime.now().uptimeNanoseconds - totalStartedAt,
4100
+ resolveNanos: 0,
4101
+ assignmentNanos: assignmentNanos,
4102
+ chromeNanos: chromeNanos
4103
+ )
2518
4104
 
2519
4105
  default:
2520
- break
4106
+ return SelectionApplyTrace(totalNanos: 0, resolveNanos: 0, assignmentNanos: 0, chromeNanos: 0)
4107
+ }
4108
+ }
4109
+
4110
+ private func autocapitalizationFriendlyEmptyBlockPosition(
4111
+ for position: UITextPosition
4112
+ ) -> UITextPosition? {
4113
+ guard textStorage.length == 1 else { return nil }
4114
+ guard textStorage.string.unicodeScalars.elementsEqual([Self.emptyBlockPlaceholderScalar]) else {
4115
+ return nil
2521
4116
  }
4117
+
4118
+ let utf16Offset = offset(from: beginningOfDocument, to: position)
4119
+ guard utf16Offset == textStorage.length else { return nil }
4120
+ return beginningOfDocument
2522
4121
  }
2523
4122
 
2524
4123
  }
@@ -2549,6 +4148,7 @@ extension EditorTextView: NSTextStorageDelegate {
2549
4148
  // Compare current text storage content against last authorized snapshot.
2550
4149
  let currentText = textStorage.string
2551
4150
  guard currentText != lastAuthorizedText else { return }
4151
+ currentTopLevelChildMetadata = nil
2552
4152
  let authorizedPreview = preview(lastAuthorizedText)
2553
4153
  let storagePreview = preview(currentText)
2554
4154
 
@@ -2606,6 +4206,22 @@ extension EditorTextView: NSTextStorageDelegate {
2606
4206
  /// and serves as the integration point for the future Fabric component.
2607
4207
  final class RichTextEditorView: UIView {
2608
4208
 
4209
+ struct HostedLayoutTrace {
4210
+ let intrinsicContentSizeNanos: UInt64
4211
+ let intrinsicContentSizeCount: Int
4212
+ let measuredEditorHeightNanos: UInt64
4213
+ let measuredEditorHeightCount: Int
4214
+ let layoutSubviewsNanos: UInt64
4215
+ let layoutSubviewsCount: Int
4216
+ let refreshOverlaysNanos: UInt64
4217
+ let refreshOverlaysCount: Int
4218
+ let overlayScheduleRequestCount: Int
4219
+ let overlayScheduleExecuteCount: Int
4220
+ let overlayScheduleSkipCount: Int
4221
+ let onHeightMayChangeNanos: UInt64
4222
+ let onHeightMayChangeCount: Int
4223
+ }
4224
+
2609
4225
  // MARK: - Properties
2610
4226
 
2611
4227
  /// The editor text view that handles input interception.
@@ -2613,9 +4229,29 @@ final class RichTextEditorView: UIView {
2613
4229
  private let remoteSelectionOverlayView = RemoteSelectionOverlayView()
2614
4230
  private let imageTapOverlayView = ImageTapOverlayView()
2615
4231
  private let imageResizeOverlayView = ImageResizeOverlayView()
2616
- var onHeightMayChange: (() -> Void)?
4232
+ var onHeightMayChange: ((CGFloat) -> Void)?
2617
4233
  private var lastAutoGrowWidth: CGFloat = 0
4234
+ private var cachedAutoGrowMeasuredHeight: CGFloat = 0
2618
4235
  private var remoteSelections: [RemoteSelectionDecoration] = []
4236
+ private var overlayRefreshScheduled = false
4237
+ var captureHostedLayoutTraceForTesting = false
4238
+ private var hostedLayoutTraceNanos = (
4239
+ intrinsicContentSize: UInt64(0),
4240
+ measuredEditorHeight: UInt64(0),
4241
+ layoutSubviews: UInt64(0),
4242
+ refreshOverlays: UInt64(0),
4243
+ onHeightMayChange: UInt64(0)
4244
+ )
4245
+ private var hostedLayoutTraceCounts = (
4246
+ intrinsicContentSize: 0,
4247
+ measuredEditorHeight: 0,
4248
+ layoutSubviews: 0,
4249
+ refreshOverlays: 0,
4250
+ overlayScheduleRequest: 0,
4251
+ overlayScheduleExecute: 0,
4252
+ overlayScheduleSkip: 0,
4253
+ onHeightMayChange: 0
4254
+ )
2619
4255
  var allowImageResizing = true {
2620
4256
  didSet {
2621
4257
  guard oldValue != allowImageResizing else { return }
@@ -2630,9 +4266,23 @@ final class RichTextEditorView: UIView {
2630
4266
  didSet {
2631
4267
  guard oldValue != heightBehavior else { return }
2632
4268
  textView.heightBehavior = heightBehavior
4269
+ textView.updateAutoGrowHostHeight(heightBehavior == .autoGrow ? bounds.height : 0)
4270
+ if heightBehavior != .autoGrow {
4271
+ cachedAutoGrowMeasuredHeight = 0
4272
+ }
2633
4273
  invalidateIntrinsicContentSize()
2634
4274
  setNeedsLayout()
2635
- onHeightMayChange?()
4275
+ if heightBehavior == .autoGrow {
4276
+ let measuredHeight = measuredEditorHeight()
4277
+ if measuredHeight > 0 {
4278
+ cachedAutoGrowMeasuredHeight = measuredHeight
4279
+ onHeightMayChange?(measuredHeight)
4280
+ } else {
4281
+ onHeightMayChange?(0)
4282
+ }
4283
+ } else {
4284
+ onHeightMayChange?(0)
4285
+ }
2636
4286
  remoteSelectionOverlayView.refresh()
2637
4287
  imageResizeOverlayView.refresh()
2638
4288
  }
@@ -2670,54 +4320,45 @@ final class RichTextEditorView: UIView {
2670
4320
  }
2671
4321
 
2672
4322
  private func setupView() {
2673
- // Add the text view as a subview.
2674
- textView.translatesAutoresizingMaskIntoConstraints = false
2675
- remoteSelectionOverlayView.translatesAutoresizingMaskIntoConstraints = false
2676
- imageTapOverlayView.translatesAutoresizingMaskIntoConstraints = false
2677
- imageResizeOverlayView.translatesAutoresizingMaskIntoConstraints = false
4323
+ // Add the text view as a subview. These views always track the host bounds,
4324
+ // so manual layout is cheaper than driving them through Auto Layout.
2678
4325
  remoteSelectionOverlayView.bind(textView: textView)
2679
4326
  imageTapOverlayView.bind(editorView: self)
2680
4327
  imageResizeOverlayView.bind(editorView: self)
2681
4328
  textView.allowImageResizing = allowImageResizing
2682
4329
  imageTapOverlayView.isHidden = editorId == 0 || !allowImageResizing
2683
- textView.onHeightMayChange = { [weak self] in
4330
+ textView.onHeightMayChange = { [weak self] measuredHeight in
2684
4331
  guard let self, self.heightBehavior == .autoGrow else { return }
4332
+ let startedAt = DispatchTime.now().uptimeNanoseconds
4333
+ self.cachedAutoGrowMeasuredHeight = measuredHeight
2685
4334
  self.invalidateIntrinsicContentSize()
2686
- self.superview?.setNeedsLayout()
2687
- self.onHeightMayChange?()
4335
+ self.onHeightMayChange?(measuredHeight)
4336
+ self.recordHostedLayoutTrace(
4337
+ durationNanos: DispatchTime.now().uptimeNanoseconds - startedAt,
4338
+ keyPath: .onHeightMayChange
4339
+ )
2688
4340
  }
2689
4341
  textView.onViewportMayChange = { [weak self] in
2690
- self?.refreshOverlays()
4342
+ self?.refreshOverlaysIfNeeded()
2691
4343
  }
2692
4344
  textView.onSelectionOrContentMayChange = { [weak self] in
2693
- self?.refreshOverlays()
4345
+ self?.scheduleRefreshOverlaysIfNeeded()
2694
4346
  }
2695
4347
  addSubview(textView)
2696
4348
  addSubview(remoteSelectionOverlayView)
2697
4349
  addSubview(imageTapOverlayView)
2698
4350
  addSubview(imageResizeOverlayView)
2699
-
2700
- NSLayoutConstraint.activate([
2701
- textView.topAnchor.constraint(equalTo: topAnchor),
2702
- textView.leadingAnchor.constraint(equalTo: leadingAnchor),
2703
- textView.trailingAnchor.constraint(equalTo: trailingAnchor),
2704
- textView.bottomAnchor.constraint(equalTo: bottomAnchor),
2705
- remoteSelectionOverlayView.topAnchor.constraint(equalTo: topAnchor),
2706
- remoteSelectionOverlayView.leadingAnchor.constraint(equalTo: leadingAnchor),
2707
- remoteSelectionOverlayView.trailingAnchor.constraint(equalTo: trailingAnchor),
2708
- remoteSelectionOverlayView.bottomAnchor.constraint(equalTo: bottomAnchor),
2709
- imageTapOverlayView.topAnchor.constraint(equalTo: topAnchor),
2710
- imageTapOverlayView.leadingAnchor.constraint(equalTo: leadingAnchor),
2711
- imageTapOverlayView.trailingAnchor.constraint(equalTo: trailingAnchor),
2712
- imageTapOverlayView.bottomAnchor.constraint(equalTo: bottomAnchor),
2713
- imageResizeOverlayView.topAnchor.constraint(equalTo: topAnchor),
2714
- imageResizeOverlayView.leadingAnchor.constraint(equalTo: leadingAnchor),
2715
- imageResizeOverlayView.trailingAnchor.constraint(equalTo: trailingAnchor),
2716
- imageResizeOverlayView.bottomAnchor.constraint(equalTo: bottomAnchor),
2717
- ])
4351
+ layoutManagedSubviews()
2718
4352
  }
2719
4353
 
2720
4354
  override var intrinsicContentSize: CGSize {
4355
+ let startedAt = DispatchTime.now().uptimeNanoseconds
4356
+ defer {
4357
+ recordHostedLayoutTrace(
4358
+ durationNanos: DispatchTime.now().uptimeNanoseconds - startedAt,
4359
+ keyPath: .intrinsicContentSize
4360
+ )
4361
+ }
2721
4362
  guard heightBehavior == .autoGrow else {
2722
4363
  return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
2723
4364
  }
@@ -2730,12 +4371,22 @@ final class RichTextEditorView: UIView {
2730
4371
  }
2731
4372
 
2732
4373
  override func layoutSubviews() {
4374
+ let startedAt = DispatchTime.now().uptimeNanoseconds
4375
+ defer {
4376
+ recordHostedLayoutTrace(
4377
+ durationNanos: DispatchTime.now().uptimeNanoseconds - startedAt,
4378
+ keyPath: .layoutSubviews
4379
+ )
4380
+ }
2733
4381
  super.layoutSubviews()
2734
- refreshOverlays()
4382
+ layoutManagedSubviews()
4383
+ refreshOverlaysIfNeeded()
2735
4384
  guard heightBehavior == .autoGrow else { return }
4385
+ textView.updateAutoGrowHostHeight(bounds.height)
2736
4386
  let currentWidth = bounds.width.rounded(.towardZero)
2737
4387
  guard currentWidth != lastAutoGrowWidth else { return }
2738
4388
  lastAutoGrowWidth = currentWidth
4389
+ cachedAutoGrowMeasuredHeight = 0
2739
4390
  invalidateIntrinsicContentSize()
2740
4391
  }
2741
4392
 
@@ -2777,11 +4428,50 @@ final class RichTextEditorView: UIView {
2777
4428
  }
2778
4429
 
2779
4430
  func refreshRemoteSelections() {
4431
+ guard remoteSelectionOverlayView.hasSelectionsOrVisibleDecorations else { return }
2780
4432
  remoteSelectionOverlayView.refresh()
2781
4433
  }
2782
4434
 
2783
4435
  func remoteSelectionOverlaySubviewsForTesting() -> [UIView] {
2784
- remoteSelectionOverlayView.subviews
4436
+ remoteSelectionOverlayView.subviews.filter { !$0.isHidden }
4437
+ }
4438
+
4439
+ func resetHostedLayoutTraceForTesting() {
4440
+ hostedLayoutTraceNanos = (
4441
+ intrinsicContentSize: 0,
4442
+ measuredEditorHeight: 0,
4443
+ layoutSubviews: 0,
4444
+ refreshOverlays: 0,
4445
+ onHeightMayChange: 0
4446
+ )
4447
+ hostedLayoutTraceCounts = (
4448
+ intrinsicContentSize: 0,
4449
+ measuredEditorHeight: 0,
4450
+ layoutSubviews: 0,
4451
+ refreshOverlays: 0,
4452
+ overlayScheduleRequest: 0,
4453
+ overlayScheduleExecute: 0,
4454
+ overlayScheduleSkip: 0,
4455
+ onHeightMayChange: 0
4456
+ )
4457
+ }
4458
+
4459
+ func lastHostedLayoutTraceForTesting() -> HostedLayoutTrace {
4460
+ HostedLayoutTrace(
4461
+ intrinsicContentSizeNanos: hostedLayoutTraceNanos.intrinsicContentSize,
4462
+ intrinsicContentSizeCount: hostedLayoutTraceCounts.intrinsicContentSize,
4463
+ measuredEditorHeightNanos: hostedLayoutTraceNanos.measuredEditorHeight,
4464
+ measuredEditorHeightCount: hostedLayoutTraceCounts.measuredEditorHeight,
4465
+ layoutSubviewsNanos: hostedLayoutTraceNanos.layoutSubviews,
4466
+ layoutSubviewsCount: hostedLayoutTraceCounts.layoutSubviews,
4467
+ refreshOverlaysNanos: hostedLayoutTraceNanos.refreshOverlays,
4468
+ refreshOverlaysCount: hostedLayoutTraceCounts.refreshOverlays,
4469
+ overlayScheduleRequestCount: hostedLayoutTraceCounts.overlayScheduleRequest,
4470
+ overlayScheduleExecuteCount: hostedLayoutTraceCounts.overlayScheduleExecute,
4471
+ overlayScheduleSkipCount: hostedLayoutTraceCounts.overlayScheduleSkip,
4472
+ onHeightMayChangeNanos: hostedLayoutTraceNanos.onHeightMayChange,
4473
+ onHeightMayChangeCount: hostedLayoutTraceCounts.onHeightMayChange
4474
+ )
2785
4475
  }
2786
4476
 
2787
4477
  func imageResizeOverlayRectForTesting() -> CGRect? {
@@ -2830,8 +4520,8 @@ final class RichTextEditorView: UIView {
2830
4520
  /// - Parameter html: The HTML string to load.
2831
4521
  func setContent(html: String) {
2832
4522
  guard editorId != 0 else { return }
2833
- let renderJSON = editorSetHtml(id: editorId, html: html)
2834
- textView.applyRenderJSON(renderJSON)
4523
+ _ = editorSetHtml(id: editorId, html: html)
4524
+ textView.applyUpdateJSON(editorGetCurrentState(id: editorId), notifyDelegate: false)
2835
4525
  }
2836
4526
 
2837
4527
  /// Set initial content from ProseMirror JSON.
@@ -2839,18 +4529,28 @@ final class RichTextEditorView: UIView {
2839
4529
  /// - Parameter json: The JSON string to load.
2840
4530
  func setContent(json: String) {
2841
4531
  guard editorId != 0 else { return }
2842
- let renderJSON = editorSetJson(id: editorId, json: json)
2843
- textView.applyRenderJSON(renderJSON)
4532
+ _ = editorSetJson(id: editorId, json: json)
4533
+ textView.applyUpdateJSON(editorGetCurrentState(id: editorId), notifyDelegate: false)
2844
4534
  }
2845
4535
 
2846
4536
  private func measuredEditorHeight() -> CGFloat {
4537
+ let startedAt = DispatchTime.now().uptimeNanoseconds
4538
+ defer {
4539
+ recordHostedLayoutTrace(
4540
+ durationNanos: DispatchTime.now().uptimeNanoseconds - startedAt,
4541
+ keyPath: .measuredEditorHeight
4542
+ )
4543
+ }
4544
+ if cachedAutoGrowMeasuredHeight > 0 {
4545
+ return cachedAutoGrowMeasuredHeight
4546
+ }
2847
4547
  let width = resolvedMeasurementWidth()
2848
4548
  guard width > 0 else { return 0 }
2849
- return ceil(
2850
- textView.sizeThatFits(
2851
- CGSize(width: width, height: CGFloat.greatestFiniteMagnitude)
2852
- ).height
2853
- )
4549
+ let measuredHeight = textView.measuredAutoGrowHeightForTesting(width: width)
4550
+ if measuredHeight > 0 {
4551
+ cachedAutoGrowMeasuredHeight = measuredHeight
4552
+ }
4553
+ return measuredHeight
2854
4554
  }
2855
4555
 
2856
4556
  private func resolvedMeasurementWidth() -> CGFloat {
@@ -2863,6 +4563,22 @@ final class RichTextEditorView: UIView {
2863
4563
  return UIScreen.main.bounds.width
2864
4564
  }
2865
4565
 
4566
+ private func layoutManagedSubviews() {
4567
+ let managedFrame = bounds
4568
+ if textView.frame != managedFrame {
4569
+ textView.frame = managedFrame
4570
+ }
4571
+ if remoteSelectionOverlayView.frame != managedFrame {
4572
+ remoteSelectionOverlayView.frame = managedFrame
4573
+ }
4574
+ if imageTapOverlayView.frame != managedFrame {
4575
+ imageTapOverlayView.frame = managedFrame
4576
+ }
4577
+ if imageResizeOverlayView.frame != managedFrame {
4578
+ imageResizeOverlayView.frame = managedFrame
4579
+ }
4580
+ }
4581
+
2866
4582
  fileprivate func selectedImageGeometry() -> (docPos: UInt32, rect: CGRect)? {
2867
4583
  guard let geometry = textView.selectedImageGeometry() else { return nil }
2868
4584
  return (
@@ -2903,10 +4619,90 @@ final class RichTextEditorView: UIView {
2903
4619
  }
2904
4620
 
2905
4621
  private func refreshOverlays() {
4622
+ let startedAt = DispatchTime.now().uptimeNanoseconds
4623
+ defer {
4624
+ recordHostedLayoutTrace(
4625
+ durationNanos: DispatchTime.now().uptimeNanoseconds - startedAt,
4626
+ keyPath: .refreshOverlays
4627
+ )
4628
+ }
2906
4629
  remoteSelectionOverlayView.refresh()
2907
4630
  imageResizeOverlayView.refresh()
2908
4631
  }
2909
4632
 
4633
+ private func refreshOverlaysIfNeeded() {
4634
+ guard shouldRefreshOverlays() else { return }
4635
+ refreshOverlays()
4636
+ }
4637
+
4638
+ private func scheduleRefreshOverlaysIfNeeded() {
4639
+ if !shouldRefreshOverlays() {
4640
+ if captureHostedLayoutTraceForTesting {
4641
+ hostedLayoutTraceCounts.overlayScheduleSkip += 1
4642
+ }
4643
+ return
4644
+ }
4645
+ scheduleRefreshOverlays()
4646
+ }
4647
+
4648
+ private func scheduleRefreshOverlays() {
4649
+ if captureHostedLayoutTraceForTesting {
4650
+ hostedLayoutTraceCounts.overlayScheduleRequest += 1
4651
+ }
4652
+ guard !overlayRefreshScheduled else { return }
4653
+ overlayRefreshScheduled = true
4654
+ DispatchQueue.main.async { [weak self] in
4655
+ guard let self else { return }
4656
+ self.overlayRefreshScheduled = false
4657
+ if self.captureHostedLayoutTraceForTesting {
4658
+ self.hostedLayoutTraceCounts.overlayScheduleExecute += 1
4659
+ }
4660
+ self.refreshOverlays()
4661
+ }
4662
+ }
4663
+
4664
+ private func shouldRefreshOverlays() -> Bool {
4665
+ if !remoteSelections.isEmpty || remoteSelectionOverlayView.hasVisibleDecorations {
4666
+ return true
4667
+ }
4668
+ if imageResizeOverlayView.isOverlayVisible {
4669
+ return true
4670
+ }
4671
+ if textView.selectedImageGeometry() != nil {
4672
+ return true
4673
+ }
4674
+ return false
4675
+ }
4676
+
4677
+ private enum HostedLayoutTraceKey {
4678
+ case intrinsicContentSize
4679
+ case measuredEditorHeight
4680
+ case layoutSubviews
4681
+ case refreshOverlays
4682
+ case onHeightMayChange
4683
+ }
4684
+
4685
+ private func recordHostedLayoutTrace(durationNanos: UInt64, keyPath: HostedLayoutTraceKey) {
4686
+ guard captureHostedLayoutTraceForTesting else { return }
4687
+ switch keyPath {
4688
+ case .intrinsicContentSize:
4689
+ hostedLayoutTraceNanos.intrinsicContentSize += durationNanos
4690
+ hostedLayoutTraceCounts.intrinsicContentSize += 1
4691
+ case .measuredEditorHeight:
4692
+ hostedLayoutTraceNanos.measuredEditorHeight += durationNanos
4693
+ hostedLayoutTraceCounts.measuredEditorHeight += 1
4694
+ case .layoutSubviews:
4695
+ hostedLayoutTraceNanos.layoutSubviews += durationNanos
4696
+ hostedLayoutTraceCounts.layoutSubviews += 1
4697
+ case .refreshOverlays:
4698
+ hostedLayoutTraceNanos.refreshOverlays += durationNanos
4699
+ hostedLayoutTraceCounts.refreshOverlays += 1
4700
+ case .onHeightMayChange:
4701
+ hostedLayoutTraceNanos.onHeightMayChange += durationNanos
4702
+ hostedLayoutTraceCounts.onHeightMayChange += 1
4703
+ }
4704
+ }
4705
+
2910
4706
  // MARK: - Cleanup
2911
4707
 
2912
4708
  deinit {