@openeditor/react-native-prose-editor 0.0.7 → 0.0.9

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 (68) hide show
  1. package/android/build.gradle +35 -0
  2. package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +2 -2
  3. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +138 -597
  4. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +13 -3
  5. package/android/src/main/java/com/apollohg/editor/InputSnapshotSupport.kt +6 -0
  6. package/android/src/main/java/com/apollohg/editor/NativeBlockEditorSurface.kt +1086 -0
  7. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +1 -1
  8. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +26 -50
  9. package/android/src/main/java/com/apollohg/editor/NativeImageLoader.kt +89 -0
  10. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +31 -66
  11. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +98 -33
  12. package/dist/EditorToolbar.d.ts +1 -1
  13. package/dist/EditorToolbar.js +19 -0
  14. package/dist/NativeEditorBridge.d.ts +62 -35
  15. package/dist/NativeEditorBridge.js +122 -79
  16. package/dist/NativeProseViewer.d.ts +13 -34
  17. package/dist/NativeProseViewer.js +40 -465
  18. package/dist/NativeRichTextEditor.d.ts +10 -1
  19. package/dist/NativeRichTextEditor.js +72 -34
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.js +2 -1
  22. package/dist/schemas.d.ts +11 -0
  23. package/dist/schemas.js +149 -0
  24. package/dist/useNativeEditor.d.ts +3 -3
  25. package/dist/useNativeEditor.js +3 -3
  26. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  27. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  28. package/ios/Generated_editor_core.swift +97 -2
  29. package/ios/NativeBlockEditorSurface.swift +1445 -0
  30. package/ios/NativeEditorExpoView.swift +3 -3
  31. package/ios/NativeEditorModule.swift +31 -46
  32. package/ios/NativeInputSupport.swift +396 -0
  33. package/ios/PositionBridge.swift +4 -4
  34. package/ios/RichTextEditorView.swift +352 -786
  35. package/ios/editor_coreFFI/editor_coreFFI.h +147 -70
  36. package/package.json +1 -1
  37. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  38. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  39. package/rust/android/x86_64/libeditor_core.so +0 -0
  40. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2087 -1399
  41. package/android/src/androidTest/AndroidManifest.xml +0 -8
  42. package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +0 -53
  43. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +0 -241
  44. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +0 -338
  45. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +0 -789
  46. package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +0 -350
  47. package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +0 -5
  48. package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +0 -315
  49. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +0 -2172
  50. package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +0 -482
  51. package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +0 -137
  52. package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +0 -321
  53. package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +0 -4202
  54. package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +0 -2581
  55. package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +0 -27
  56. package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +0 -197
  57. package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +0 -84
  58. package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +0 -544
  59. package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +0 -461
  60. package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +0 -2054
  61. package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +0 -1367
  62. package/ios/EditorLayoutManager.swift +0 -774
  63. package/ios/NativeProseViewerExpoView.swift +0 -276
  64. package/ios/RenderBridge.swift +0 -1708
  65. package/ios/Tests/NativePerformanceTests.swift +0 -687
  66. package/ios/Tests/PositionBridgeTests.swift +0 -706
  67. package/ios/Tests/RenderBridgeTests.swift +0 -2236
  68. package/ios/Tests/RichTextEditorViewTests.swift +0 -6548
@@ -126,7 +126,7 @@ private final class RemoteSelectionOverlayView: UIView {
126
126
  let color: UIColor
127
127
  }
128
128
 
129
- weak var textView: EditorTextView?
129
+ weak var documentSurface: NativeBlockEditorSurface?
130
130
  private var editorId: UInt64 = 0
131
131
  private var selections: [RemoteSelectionDecoration] = []
132
132
  private var selectionViews: [UIView] = []
@@ -143,8 +143,8 @@ private final class RemoteSelectionOverlayView: UIView {
143
143
  return nil
144
144
  }
145
145
 
146
- func bind(textView: EditorTextView) {
147
- self.textView = textView
146
+ func bind(documentSurface: NativeBlockEditorSurface) {
147
+ self.documentSurface = documentSurface
148
148
  }
149
149
 
150
150
  func update(selections: [RemoteSelectionDecoration], editorId: UInt64) {
@@ -155,7 +155,7 @@ private final class RemoteSelectionOverlayView: UIView {
155
155
 
156
156
  func refresh() {
157
157
  guard editorId != 0,
158
- let textView
158
+ let documentSurface
159
159
  else {
160
160
  syncSelectionViews(with: [])
161
161
  syncCaretViews(with: [])
@@ -166,7 +166,7 @@ private final class RemoteSelectionOverlayView: UIView {
166
166
  var caretRects: [ColoredRect] = []
167
167
 
168
168
  for selection in selections {
169
- let geometry = geometry(for: selection, in: textView)
169
+ let geometry = geometry(for: selection, in: documentSurface)
170
170
  for rect in geometry.selectionRects {
171
171
  selectionRects.append(
172
172
  ColoredRect(
@@ -209,76 +209,18 @@ private final class RemoteSelectionOverlayView: UIView {
209
209
 
210
210
  private func geometry(
211
211
  for selection: RemoteSelectionDecoration,
212
- in textView: EditorTextView
212
+ in documentSurface: NativeBlockEditorSurface
213
213
  ) -> (selectionRects: [CGRect], caretRect: CGRect?) {
214
- let startScalar = editorDocToScalar(
215
- id: editorId,
216
- docPos: min(selection.anchor, selection.head)
217
- )
218
- let endScalar = editorDocToScalar(
219
- id: editorId,
220
- docPos: max(selection.anchor, selection.head)
221
- )
222
-
223
- let startPosition = PositionBridge.scalarToTextView(startScalar, in: textView)
224
- let endPosition = PositionBridge.scalarToTextView(endScalar, in: textView)
225
- let caretRect = resolvedCaretRect(
226
- for: endPosition,
227
- in: textView
228
- )
229
-
230
- if startScalar == endScalar {
231
- return ([], caretRect)
232
- }
233
-
234
- guard let range = textView.textRange(from: startPosition, to: endPosition) else {
235
- return ([], caretRect)
236
- }
237
-
238
- let selectionRects = textView.selectionRects(for: range)
239
- .map(\.rect)
240
- .filter { !$0.isEmpty && $0.width > 0 && $0.height > 0 }
241
- .map { textView.convert($0, to: self) }
242
-
214
+ let selectionRects = documentSurface
215
+ .selectionRects(anchor: selection.anchor, head: selection.head)
216
+ .map { documentSurface.convert($0, to: self) }
217
+ let caretRect = selection.isFocused
218
+ ? documentSurface.caretRect(forDocPosition: selection.head)
219
+ .map { documentSurface.convert($0, to: self) }
220
+ : nil
243
221
  return (selectionRects, caretRect)
244
222
  }
245
223
 
246
- private func resolvedCaretRect(
247
- for position: UITextPosition,
248
- in textView: EditorTextView
249
- ) -> CGRect? {
250
- let directRect = textView.convert(textView.caretRect(for: position), to: self)
251
- if directRect.height > 0, directRect.width >= 0 {
252
- return directRect
253
- }
254
-
255
- if let previousPosition = textView.position(from: position, offset: -1),
256
- let previousRange = textView.textRange(from: previousPosition, to: position),
257
- let previousRect = textView.selectionRects(for: previousRange)
258
- .map(\.rect)
259
- .last(where: { !$0.isEmpty && $0.height > 0 })
260
- {
261
- let rect = textView.convert(previousRect, to: self)
262
- return CGRect(x: rect.maxX, y: rect.minY, width: 2, height: rect.height)
263
- }
264
-
265
- if let nextPosition = textView.position(from: position, offset: 1),
266
- let nextRange = textView.textRange(from: position, to: nextPosition),
267
- let nextRect = textView.selectionRects(for: nextRange)
268
- .map(\.rect)
269
- .first(where: { !$0.isEmpty && $0.height > 0 })
270
- {
271
- let rect = textView.convert(nextRect, to: self)
272
- return CGRect(x: rect.minX, y: rect.minY, width: 2, height: rect.height)
273
- }
274
-
275
- if directRect.isEmpty {
276
- return nil
277
- }
278
-
279
- return directRect
280
- }
281
-
282
224
  private func syncSelectionViews(with rects: [ColoredRect]) {
283
225
  syncViews(rects, existingViews: &selectionViews) { view, rect in
284
226
  view.frame = rect.frame
@@ -347,15 +289,15 @@ private final class ImageTapOverlayView: UIView {
347
289
 
348
290
  override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
349
291
  guard let editorView else { return false }
350
- let pointInTextView = convert(point, to: editorView.textView)
351
- return editorView.textView.hasImageAttachment(at: pointInTextView)
292
+ return editorView.blockSurfaceHasImage(at: convert(point, to: editorView))
352
293
  }
353
294
 
354
295
  @objc
355
296
  private func handleTap(_ recognizer: UITapGestureRecognizer) {
356
297
  guard recognizer.state == .ended, let editorView else { return }
357
- let pointInTextView = convert(recognizer.location(in: self), to: editorView.textView)
358
- _ = editorView.textView.selectImageAttachment(at: pointInTextView)
298
+ _ = editorView.selectBlockSurfaceImage(
299
+ at: convert(recognizer.location(in: self), to: editorView)
300
+ )
359
301
  }
360
302
 
361
303
  func interceptsPointForTesting(_ point: CGPoint) -> Bool {
@@ -365,8 +307,7 @@ private final class ImageTapOverlayView: UIView {
365
307
  @discardableResult
366
308
  func handleTapForTesting(_ point: CGPoint) -> Bool {
367
309
  guard let editorView else { return false }
368
- let pointInTextView = convert(point, to: editorView.textView)
369
- return editorView.textView.selectImageAttachment(at: pointInTextView)
310
+ return editorView.selectBlockSurfaceImage(at: convert(point, to: editorView))
370
311
  }
371
312
  }
372
313
 
@@ -784,11 +725,11 @@ final class EditorTextViewInternalDelegate: NSObject, UITextViewDelegate {
784
725
  /// Instead of letting UITextView's internal text storage handle insertions
785
726
  /// and deletions, this class captures the user's intent (typing, deleting,
786
727
  /// pasting, autocorrect) and sends it to the Rust editor. The Rust editor
787
- /// returns render elements, which are converted to NSAttributedString via
788
- /// RenderBridge and applied back to the text view.
728
+ /// returns a canonical plain-text input snapshot, which is authorized and
729
+ /// applied back to this hidden input adapter.
789
730
  ///
790
731
  /// This is the "input interception" pattern: the UITextView is effectively
791
- /// a rendering surface, not a text editing engine.
732
+ /// an IME/selection surface, not the visible block renderer.
792
733
  ///
793
734
  /// ## Composition (IME) Handling
794
735
  ///
@@ -848,25 +789,6 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
848
789
  let postApplySelectionOrContentCallbackNanos: UInt64
849
790
  }
850
791
 
851
- private struct PatchApplyTrace {
852
- let applied: Bool
853
- let eligibilityNanos: UInt64
854
- let trimNanos: UInt64
855
- let metadataNanos: UInt64
856
- let buildRenderNanos: UInt64
857
- let applyRenderNanos: UInt64
858
- let applyRenderReplaceUtf16Length: Int
859
- let applyRenderReplacementUtf16Length: Int
860
- let applyRenderTextMutationNanos: UInt64
861
- let applyRenderBeginEditingNanos: UInt64
862
- let applyRenderEndEditingNanos: UInt64
863
- let applyRenderStringMutationNanos: UInt64
864
- let applyRenderAttributeMutationNanos: UInt64
865
- let applyRenderAuthorizedTextNanos: UInt64
866
- let applyRenderCacheInvalidationNanos: UInt64
867
- let usedSmallPatchTextMutation: Bool
868
- }
869
-
870
792
  private struct ApplyRenderTrace {
871
793
  let totalNanos: UInt64
872
794
  let replaceUtf16Length: Int
@@ -901,17 +823,6 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
901
823
  let selectionOrContentCallbackNanos: UInt64
902
824
  }
903
825
 
904
- private struct TopLevelChildMetadata {
905
- var startOffset: Int
906
- var containsAttachment: Bool
907
- var containsPositionAdjustments: Bool
908
- }
909
-
910
- private struct TopLevelChildMetadataSlice {
911
- let startIndex: Int
912
- let entries: [TopLevelChildMetadata]
913
- }
914
-
915
826
  private struct NativeTextMutation {
916
827
  let from: UInt32
917
828
  let to: UInt32
@@ -935,13 +846,6 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
935
846
  case rejected
936
847
  }
937
848
 
938
- private enum PositionCacheUpdate {
939
- case scan
940
- case invalidate
941
- case plainText
942
- case attributed
943
- }
944
-
945
849
  // MARK: - Properties
946
850
 
947
851
  /// The Rust editor instance ID (from editor_create / editor_create_with_max_length).
@@ -1027,6 +931,7 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
1027
931
 
1028
932
  /// Delegate for editor events.
1029
933
  weak var editorDelegate: EditorTextViewDelegate?
934
+ var onUpdateJSONApplied: ((String) -> Void)?
1030
935
 
1031
936
  /// The plain text from the last Rust render, used by the reconciliation
1032
937
  /// fallback to detect unauthorized text storage mutations.
@@ -1038,8 +943,7 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
1038
943
  private(set) var lastRenderAppliedPatchForTesting: Bool = false
1039
944
  var captureApplyUpdateTraceForTesting = false
1040
945
  private(set) var lastApplyUpdateTraceForTesting: ApplyUpdateTrace?
1041
- private var currentRenderBlocks: [[[String: Any]]]? = nil
1042
- private var currentTopLevelChildMetadata: [TopLevelChildMetadata]? = nil
946
+ var pasteboardDataForTesting: [String: Data]?
1043
947
  private var renderAppearanceRevision: UInt64 = 1
1044
948
  private var lastAppliedRenderAppearanceRevision: UInt64 = 0
1045
949
 
@@ -1110,7 +1014,7 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
1110
1014
  private var markedTextCompositionText: String?
1111
1015
  private var markedTextCompositionIsExplicitlyEmpty = false
1112
1016
 
1113
- private let editorLayoutManager: EditorLayoutManager
1017
+ private let editorLayoutManager: NSLayoutManager
1114
1018
 
1115
1019
  private struct InputTraitState {
1116
1020
  var autoCapitalize: String?
@@ -1156,7 +1060,7 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
1156
1060
  // MARK: - Initialization
1157
1061
 
1158
1062
  override init(frame: CGRect, textContainer: NSTextContainer?) {
1159
- let layoutManager = EditorLayoutManager()
1063
+ let layoutManager = NSLayoutManager()
1160
1064
  let container = textContainer ?? NSTextContainer(size: .zero)
1161
1065
  let textStorage = NSTextStorage()
1162
1066
  layoutManager.addTextContainer(container)
@@ -1755,7 +1659,7 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
1755
1659
  guard value is NSTextAttachment, range.length > 0 else { return }
1756
1660
 
1757
1661
  let attrs = textStorage.attributes(at: range.location, effectiveRange: nil)
1758
- guard (attrs[RenderBridgeAttributes.voidNodeType] as? String) == "image" else { return }
1662
+ guard (attrs[InputCoordinatorAttributes.voidNodeType] as? String) == "image" else { return }
1759
1663
 
1760
1664
  let glyphRange = layoutManager.glyphRange(
1761
1665
  forCharacterRange: range,
@@ -1801,15 +1705,15 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
1801
1705
  }
1802
1706
 
1803
1707
  func blockquoteStripeRectsForTesting() -> [CGRect] {
1804
- editorLayoutManager.blockquoteStripeRectsForTesting(in: textStorage)
1708
+ []
1805
1709
  }
1806
1710
 
1807
1711
  func resetBlockquoteStripeDrawPassesForTesting() {
1808
- editorLayoutManager.resetBlockquoteStripeDrawPassesForTesting()
1712
+ // Blockquote geometry belongs to the structural surface.
1809
1713
  }
1810
1714
 
1811
1715
  func blockquoteStripeDrawPassesForTesting() -> [[CGRect]] {
1812
- editorLayoutManager.blockquoteStripeDrawPassesForTesting
1716
+ []
1813
1717
  }
1814
1718
 
1815
1719
  @discardableResult
@@ -1818,16 +1722,7 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
1818
1722
  }
1819
1723
 
1820
1724
  private func taskListMarkerParagraphStart(at location: CGPoint) -> Int? {
1821
- guard let layoutManager = layoutManager as? EditorLayoutManager else { return nil }
1822
- let origin = CGPoint(
1823
- x: textContainerInset.left - contentOffset.x,
1824
- y: textContainerInset.top - contentOffset.y
1825
- )
1826
- return layoutManager.taskListMarkerParagraphStart(
1827
- at: location,
1828
- in: textStorage,
1829
- textContainerOrigin: origin
1830
- )
1725
+ nil
1831
1726
  }
1832
1727
 
1833
1728
  func imageSelectionTapWouldHandleForTesting(at location: CGPoint) -> Bool {
@@ -2234,7 +2129,7 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
2234
2129
  }
2235
2130
  let attrs = textStorage.attributes(at: cursorUtf16Offset, effectiveRange: nil)
2236
2131
  guard attrs[.attachment] is NSTextAttachment,
2237
- attrs[RenderBridgeAttributes.voidNodeType] as? String != nil,
2132
+ attrs[InputCoordinatorAttributes.voidNodeType] as? String != nil,
2238
2133
  cursorScalar < UInt32.max
2239
2134
  else {
2240
2135
  return nil
@@ -2280,7 +2175,7 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
2280
2175
  }
2281
2176
  let attrs = textStorage.attributes(at: utf16Offset, effectiveRange: nil)
2282
2177
  guard attrs[.attachment] is NSTextAttachment,
2283
- attrs[RenderBridgeAttributes.voidNodeType] as? String != nil
2178
+ attrs[InputCoordinatorAttributes.voidNodeType] as? String != nil
2284
2179
  else {
2285
2180
  return nil
2286
2181
  }
@@ -2643,9 +2538,15 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
2643
2538
  )
2644
2539
 
2645
2540
  let pasteboard = UIPasteboard.general
2541
+ let pasteboardData: (String) -> Data? = { [pasteboardDataForTesting] type in
2542
+ if let pasteboardDataForTesting {
2543
+ return pasteboardDataForTesting[type]
2544
+ }
2545
+ return pasteboard.data(forPasteboardType: type)
2546
+ }
2646
2547
 
2647
2548
  // Try HTML first for rich paste.
2648
- if let htmlData = pasteboard.data(forPasteboardType: "public.html"),
2549
+ if let htmlData = pasteboardData("public.html"),
2649
2550
  let html = String(data: htmlData, encoding: .utf8) {
2650
2551
  performInterceptedInput {
2651
2552
  pasteHTML(html)
@@ -2654,7 +2555,7 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
2654
2555
  }
2655
2556
 
2656
2557
  // Try attributed string (e.g. from Notes, Pages).
2657
- if let rtfData = pasteboard.data(forPasteboardType: "public.rtf") {
2558
+ if let rtfData = pasteboardData("public.rtf") {
2658
2559
  if let attrStr = try? NSAttributedString(
2659
2560
  data: rtfData,
2660
2561
  options: [.documentType: NSAttributedString.DocumentType.rtf],
@@ -2735,7 +2636,7 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
2735
2636
  }
2736
2637
 
2737
2638
  let attrs = textStorage.attributes(at: characterRange.location, effectiveRange: nil)
2738
- guard (attrs[RenderBridgeAttributes.voidNodeType] as? String) == "image",
2639
+ guard (attrs[InputCoordinatorAttributes.voidNodeType] as? String) == "image",
2739
2640
  let start = position(from: beginningOfDocument, offset: characterRange.location),
2740
2641
  let end = position(from: start, offset: characterRange.length)
2741
2642
  else {
@@ -2920,6 +2821,7 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
2920
2821
  editorSetSelectionScalar(id: editorId, scalarAnchor: anchor, scalarHead: head)
2921
2822
  recordAuthorizedSelectionIfPossible()
2922
2823
  refreshTypingAttributesForSelection()
2824
+ onSelectionOrContentMayChange?()
2923
2825
  editorDelegate?.editorTextView(self, selectionDidChange: docAnchor, head: docHead)
2924
2826
  }
2925
2827
 
@@ -3321,13 +3223,13 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
3321
3223
 
3322
3224
  let attrs = textStorage.attributes(at: utf16Offset, effectiveRange: nil)
3323
3225
  return attrs[.attachment] is NSTextAttachment
3324
- && (attrs[RenderBridgeAttributes.voidNodeType] as? String) == "horizontalRule"
3226
+ && (attrs[InputCoordinatorAttributes.voidNodeType] as? String) == "horizontalRule"
3325
3227
  }
3326
3228
 
3327
3229
  private func hardBreakBaselineY(after utf16Offset: Int) -> CGFloat? {
3328
3230
  guard utf16Offset > 0, utf16Offset <= textStorage.length else { return nil }
3329
3231
  let previousVoidType = textStorage.attribute(
3330
- RenderBridgeAttributes.voidNodeType,
3232
+ InputCoordinatorAttributes.voidNodeType,
3331
3233
  at: utf16Offset - 1,
3332
3234
  effectiveRange: nil
3333
3235
  ) as? String
@@ -4102,14 +4004,14 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
4102
4004
  }
4103
4005
 
4104
4006
  let attrs = textStorage.attributes(at: selectedRange.location, effectiveRange: nil)
4105
- guard (attrs[RenderBridgeAttributes.voidNodeType] as? String) == "image",
4007
+ guard (attrs[InputCoordinatorAttributes.voidNodeType] as? String) == "image",
4106
4008
  attrs[.attachment] is NSTextAttachment
4107
4009
  else {
4108
4010
  return nil
4109
4011
  }
4110
4012
 
4111
- let docPos = (attrs[RenderBridgeAttributes.docPos] as? NSNumber)?.uint32Value
4112
- ?? (attrs[RenderBridgeAttributes.docPos] as? UInt32)
4013
+ let docPos = (attrs[InputCoordinatorAttributes.docPos] as? NSNumber)?.uint32Value
4014
+ ?? (attrs[InputCoordinatorAttributes.docPos] as? UInt32)
4113
4015
  guard let docPos else { return nil }
4114
4016
  return (docPos, selectedRange.location)
4115
4017
  }
@@ -4140,9 +4042,9 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
4140
4042
  ) { value, range, stop in
4141
4043
  guard let attachment = value as? BlockImageAttachment, range.length > 0 else { return }
4142
4044
  let attrs = textStorage.attributes(at: range.location, effectiveRange: nil)
4143
- guard (attrs[RenderBridgeAttributes.voidNodeType] as? String) == "image" else { return }
4144
- let attributeDocPos = (attrs[RenderBridgeAttributes.docPos] as? NSNumber)?.uint32Value
4145
- ?? (attrs[RenderBridgeAttributes.docPos] as? UInt32)
4045
+ guard (attrs[InputCoordinatorAttributes.voidNodeType] as? String) == "image" else { return }
4046
+ let attributeDocPos = (attrs[InputCoordinatorAttributes.docPos] as? NSNumber)?.uint32Value
4047
+ ?? (attrs[InputCoordinatorAttributes.docPos] as? UInt32)
4146
4048
  guard attributeDocPos == docPos else { return }
4147
4049
  resolved = (range, attachment)
4148
4050
  stop.pointee = true
@@ -4264,270 +4166,10 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
4264
4166
 
4265
4167
  // MARK: - Applying Rust State
4266
4168
 
4267
- private struct ParsedRenderPatch {
4268
- let startIndex: Int
4269
- let deleteCount: Int
4270
- let renderBlocks: [[[String: Any]]]
4271
- }
4272
-
4273
- private enum DerivedRenderPatch {
4274
- case unchanged
4275
- case patch(ParsedRenderPatch)
4276
- }
4277
-
4278
- private func parseRenderBlocks(_ value: Any?) -> [[[String: Any]]]? {
4279
- value as? [[[String: Any]]]
4280
- }
4281
-
4282
- private func parseRenderPatch(_ value: Any?) -> ParsedRenderPatch? {
4283
- guard let raw = value as? [String: Any],
4284
- let startIndex = RenderBridge.jsonInt(raw["startIndex"]),
4285
- let deleteCount = RenderBridge.jsonInt(raw["deleteCount"]),
4286
- let renderBlocks = parseRenderBlocks(raw["renderBlocks"])
4287
- else {
4288
- return nil
4289
- }
4290
-
4291
- return ParsedRenderPatch(
4292
- startIndex: startIndex,
4293
- deleteCount: deleteCount,
4294
- renderBlocks: renderBlocks
4295
- )
4296
- }
4297
-
4298
- private func mergeRenderBlocks(
4299
- applying patch: ParsedRenderPatch,
4300
- to current: [[[String: Any]]]
4301
- ) -> [[[String: Any]]]? {
4302
- guard patch.startIndex >= 0,
4303
- patch.deleteCount >= 0,
4304
- patch.startIndex <= current.count,
4305
- patch.startIndex + patch.deleteCount <= current.count
4306
- else {
4307
- return nil
4308
- }
4309
-
4310
- var merged = current
4311
- merged.replaceSubrange(
4312
- patch.startIndex..<(patch.startIndex + patch.deleteCount),
4313
- with: patch.renderBlocks
4314
- )
4315
- return merged
4316
- }
4317
-
4318
- private func renderBlockEquals(
4319
- _ lhs: [[String: Any]],
4320
- _ rhs: [[String: Any]]
4321
- ) -> Bool {
4322
- (lhs as NSArray).isEqual(rhs)
4323
- }
4324
-
4325
- private func deriveRenderPatch(
4326
- from current: [[[String: Any]]],
4327
- to updated: [[[String: Any]]]
4328
- ) -> DerivedRenderPatch {
4329
- let sharedCount = min(current.count, updated.count)
4330
-
4331
- var prefix = 0
4332
- while prefix < sharedCount, renderBlockEquals(current[prefix], updated[prefix]) {
4333
- prefix += 1
4334
- }
4335
-
4336
- if prefix == current.count, prefix == updated.count {
4337
- return .unchanged
4338
- }
4339
-
4340
- var suffix = 0
4341
- while suffix < (sharedCount - prefix),
4342
- renderBlockEquals(
4343
- current[current.count - suffix - 1],
4344
- updated[updated.count - suffix - 1]
4345
- )
4346
- {
4347
- suffix += 1
4348
- }
4349
-
4350
- let startIndex = prefix
4351
- let deleteCount = current.count - prefix - suffix
4352
- let endIndex = updated.count - suffix
4353
- let replacementBlocks = Array(updated[startIndex..<endIndex])
4354
-
4355
- return .patch(
4356
- ParsedRenderPatch(
4357
- startIndex: startIndex,
4358
- deleteCount: deleteCount,
4359
- renderBlocks: replacementBlocks
4360
- )
4361
- )
4362
- }
4363
-
4364
- private func topLevelChildIndex(from value: Any?) -> Int? {
4365
- if let number = value as? NSNumber {
4366
- return number.intValue
4367
- }
4368
- return value as? Int
4369
- }
4370
-
4371
- private func topLevelChildMetadataSlice(
4372
- from attributedString: NSAttributedString
4373
- ) -> TopLevelChildMetadataSlice? {
4374
- guard attributedString.length > 0 else {
4375
- return TopLevelChildMetadataSlice(startIndex: 0, entries: [])
4376
- }
4377
-
4378
- var entriesByIndex: [Int: TopLevelChildMetadata] = [:]
4379
- var orderedIndexes: [Int] = []
4380
-
4381
- attributedString.enumerateAttributes(
4382
- in: NSRange(location: 0, length: attributedString.length),
4383
- options: []
4384
- ) { attrs, range, _ in
4385
- guard let index = topLevelChildIndex(from: attrs[RenderBridgeAttributes.topLevelChildIndex]) else {
4386
- return
4387
- }
4388
- if entriesByIndex[index] == nil {
4389
- entriesByIndex[index] = TopLevelChildMetadata(
4390
- startOffset: range.location,
4391
- containsAttachment: false,
4392
- containsPositionAdjustments: false
4393
- )
4394
- orderedIndexes.append(index)
4395
- }
4396
- if attrs[.attachment] != nil {
4397
- entriesByIndex[index]?.containsAttachment = true
4398
- }
4399
- if attrs[RenderBridgeAttributes.syntheticPlaceholder] as? Bool == true
4400
- || attrs[RenderBridgeAttributes.listMarkerContext] != nil
4401
- {
4402
- entriesByIndex[index]?.containsPositionAdjustments = true
4403
- }
4404
- }
4405
-
4406
- guard !orderedIndexes.isEmpty else { return nil }
4407
- orderedIndexes.sort()
4408
- guard let startIndex = orderedIndexes.first else { return nil }
4409
-
4410
- var entries: [TopLevelChildMetadata] = []
4411
- entries.reserveCapacity(orderedIndexes.count)
4412
- for (offset, index) in orderedIndexes.enumerated() {
4413
- guard index == startIndex + offset,
4414
- let entry = entriesByIndex[index]
4415
- else {
4416
- return nil
4417
- }
4418
- entries.append(entry)
4419
- }
4420
-
4421
- return TopLevelChildMetadataSlice(startIndex: startIndex, entries: entries)
4422
- }
4423
-
4424
- private func refreshTopLevelChildMetadata(
4425
- from attributedString: NSAttributedString
4426
- ) {
4427
- guard let slice = topLevelChildMetadataSlice(from: attributedString),
4428
- slice.startIndex == 0
4429
- else {
4430
- currentTopLevelChildMetadata = nil
4431
- return
4432
- }
4433
- currentTopLevelChildMetadata = slice.entries
4434
- }
4435
-
4436
- private func applyTopLevelChildMetadataPatch(
4437
- _ patch: ParsedRenderPatch,
4438
- replaceRange: NSRange,
4439
- renderedPatchMetadata: TopLevelChildMetadataSlice?,
4440
- renderedPatchLength: Int
4441
- ) {
4442
- guard var currentMetadata = currentTopLevelChildMetadata else {
4443
- currentTopLevelChildMetadata = nil
4444
- return
4445
- }
4446
-
4447
- let newEntries: [TopLevelChildMetadata]
4448
- if let renderedPatchMetadata,
4449
- renderedPatchMetadata.entries.isEmpty
4450
- {
4451
- newEntries = []
4452
- } else if let renderedPatchMetadata,
4453
- renderedPatchMetadata.startIndex == patch.startIndex
4454
- {
4455
- newEntries = renderedPatchMetadata.entries.map { entry in
4456
- TopLevelChildMetadata(
4457
- startOffset: replaceRange.location + entry.startOffset,
4458
- containsAttachment: entry.containsAttachment,
4459
- containsPositionAdjustments: entry.containsPositionAdjustments
4460
- )
4461
- }
4462
- } else {
4463
- currentTopLevelChildMetadata = nil
4464
- return
4465
- }
4466
-
4467
- guard patch.startIndex >= 0,
4468
- patch.deleteCount >= 0,
4469
- patch.startIndex <= currentMetadata.count,
4470
- patch.startIndex + patch.deleteCount <= currentMetadata.count
4471
- else {
4472
- currentTopLevelChildMetadata = nil
4473
- return
4474
- }
4475
-
4476
- currentMetadata.replaceSubrange(
4477
- patch.startIndex..<(patch.startIndex + patch.deleteCount),
4478
- with: newEntries
4479
- )
4480
-
4481
- let delta = renderedPatchLength - replaceRange.length
4482
- if delta != 0 {
4483
- let shiftStart = patch.startIndex + newEntries.count
4484
- for index in shiftStart..<currentMetadata.count {
4485
- currentMetadata[index].startOffset += delta
4486
- }
4487
- }
4488
-
4489
- currentTopLevelChildMetadata = currentMetadata
4490
- }
4491
-
4492
- private func hasTopLevelChildMetadata() -> Bool {
4493
- currentTopLevelChildMetadata != nil
4494
- }
4495
-
4496
- private func firstCharacterOffset(forTopLevelChildIndex index: Int) -> Int? {
4497
- guard let currentTopLevelChildMetadata,
4498
- index >= 0,
4499
- index < currentTopLevelChildMetadata.count
4500
- else {
4501
- return nil
4502
- }
4503
- return currentTopLevelChildMetadata[index].startOffset
4504
- }
4505
-
4506
- private func replacementRangeForRenderPatch(
4507
- startIndex: Int,
4508
- deleteCount: Int
4509
- ) -> NSRange? {
4510
- let startLocation: Int
4511
- if let resolvedStart = firstCharacterOffset(forTopLevelChildIndex: startIndex) {
4512
- startLocation = resolvedStart
4513
- } else if deleteCount == 0 {
4514
- startLocation = textStorage.length
4515
- } else {
4516
- return nil
4517
- }
4518
-
4519
- let endIndexExclusive = startIndex + deleteCount
4520
- let endLocation = firstCharacterOffset(forTopLevelChildIndex: endIndexExclusive)
4521
- ?? textStorage.length
4522
- guard startLocation <= endLocation else { return nil }
4523
- return NSRange(location: startLocation, length: endLocation - startLocation)
4524
- }
4525
-
4526
4169
  private func applyAttributedRender(
4527
4170
  _ attrStr: NSAttributedString,
4528
4171
  replaceRange: NSRange? = nil,
4529
4172
  usedPatch: Bool,
4530
- positionCacheUpdate: PositionCacheUpdate = .scan,
4531
4173
  authorizedReplaceRange: NSRange? = nil,
4532
4174
  authorizedReplacementText: String? = nil,
4533
4175
  authorizedReplacementAttributedText: NSAttributedString? = nil
@@ -4604,68 +4246,7 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
4604
4246
  let authorizedTextNanos = DispatchTime.now().uptimeNanoseconds - authorizedTextStartedAt
4605
4247
  let cacheInvalidationStartedAt = DispatchTime.now().uptimeNanoseconds
4606
4248
  lastRenderAppliedPatchForTesting = usedPatch
4607
- switch positionCacheUpdate {
4608
- case .plainText:
4609
- guard let replaceRange else {
4610
- PositionBridge.invalidateCache(for: self)
4611
- break
4612
- }
4613
- let patchedPositionCache = PositionBridge.applyPlainTextPatchIfPossible(
4614
- for: self,
4615
- replaceRange: replaceRange,
4616
- replacementText: attrStr.string
4617
- )
4618
- if !patchedPositionCache {
4619
- PositionBridge.invalidateCache(for: self)
4620
- }
4621
- case .attributed:
4622
- guard let replaceRange else {
4623
- PositionBridge.invalidateCache(for: self)
4624
- break
4625
- }
4626
- let patchedPositionCache = PositionBridge.applyAttributedPatchIfPossible(
4627
- for: self,
4628
- replaceRange: replaceRange,
4629
- replacement: attrStr
4630
- )
4631
- if !patchedPositionCache {
4632
- PositionBridge.invalidateCache(for: self)
4633
- }
4634
- case .invalidate:
4635
- PositionBridge.invalidateCache(for: self)
4636
- case .scan:
4637
- let canPatchPositionCache = if let replaceRange {
4638
- replaceRange.location >= 0
4639
- && !textStorageRangeContainsAttachment(replaceRange)
4640
- && !attributedStringContainsAttachment(attrStr)
4641
- } else {
4642
- false
4643
- }
4644
- if let replaceRange, canPatchPositionCache {
4645
- let patchedPositionCache: Bool
4646
- if !textStorageRangeContainsPositionAdjustments(replaceRange),
4647
- !attributedStringContainsPositionAdjustments(attrStr)
4648
- {
4649
- patchedPositionCache = PositionBridge.applyPlainTextPatchIfPossible(
4650
- for: self,
4651
- replaceRange: replaceRange,
4652
- replacementText: attrStr.string
4653
- )
4654
- } else {
4655
- patchedPositionCache = PositionBridge.applyAttributedPatchIfPossible(
4656
- for: self,
4657
- replaceRange: replaceRange,
4658
- replacement: attrStr
4659
- )
4660
- }
4661
-
4662
- if !patchedPositionCache {
4663
- PositionBridge.invalidateCache(for: self)
4664
- }
4665
- } else {
4666
- PositionBridge.invalidateCache(for: self)
4667
- }
4668
- }
4249
+ PositionBridge.invalidateCache(for: self)
4669
4250
  let cacheInvalidationNanos = DispatchTime.now().uptimeNanoseconds - cacheInvalidationStartedAt
4670
4251
  isApplyingRustState = false
4671
4252
  return ApplyRenderTrace(
@@ -4697,8 +4278,8 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
4697
4278
  _ lhs: [NSAttributedString.Key: Any],
4698
4279
  _ rhs: [NSAttributedString.Key: Any]
4699
4280
  ) -> Bool {
4700
- if let lhsValue = lhs[RenderBridgeAttributes.topLevelChildIndex] as? NSNumber,
4701
- let rhsValue = rhs[RenderBridgeAttributes.topLevelChildIndex] as? NSNumber,
4281
+ if let lhsValue = lhs[InputCoordinatorAttributes.topLevelChildIndex] as? NSNumber,
4282
+ let rhsValue = rhs[InputCoordinatorAttributes.topLevelChildIndex] as? NSNumber,
4702
4283
  lhsValue == rhsValue
4703
4284
  {
4704
4285
  return NSDictionary(dictionary: lhs).isEqual(to: rhs)
@@ -4706,8 +4287,8 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
4706
4287
 
4707
4288
  var lhsComparable = lhs
4708
4289
  var rhsComparable = rhs
4709
- lhsComparable.removeValue(forKey: RenderBridgeAttributes.topLevelChildIndex)
4710
- rhsComparable.removeValue(forKey: RenderBridgeAttributes.topLevelChildIndex)
4290
+ lhsComparable.removeValue(forKey: InputCoordinatorAttributes.topLevelChildIndex)
4291
+ rhsComparable.removeValue(forKey: InputCoordinatorAttributes.topLevelChildIndex)
4711
4292
  return NSDictionary(dictionary: lhsComparable).isEqual(to: rhsComparable)
4712
4293
  }
4713
4294
 
@@ -4767,8 +4348,8 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
4767
4348
  in: NSRange(location: 0, length: attributedString.length),
4768
4349
  options: [.longestEffectiveRangeNotRequired]
4769
4350
  ) { attrs, _, stop in
4770
- if attrs[RenderBridgeAttributes.syntheticPlaceholder] as? Bool == true
4771
- || attrs[RenderBridgeAttributes.listMarkerContext] != nil
4351
+ if attrs[InputCoordinatorAttributes.syntheticPlaceholder] as? Bool == true
4352
+ || attrs[InputCoordinatorAttributes.listMarkerContext] != nil
4772
4353
  {
4773
4354
  hasAdjustments = true
4774
4355
  stop.pointee = true
@@ -4781,7 +4362,7 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
4781
4362
  guard attributedString.length > 0 else { return false }
4782
4363
  var hasListMarkerContext = false
4783
4364
  attributedString.enumerateAttribute(
4784
- RenderBridgeAttributes.listMarkerContext,
4365
+ InputCoordinatorAttributes.listMarkerContext,
4785
4366
  in: NSRange(location: 0, length: attributedString.length),
4786
4367
  options: [.longestEffectiveRangeNotRequired]
4787
4368
  ) { value, _, stop in
@@ -4806,8 +4387,8 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
4806
4387
  in: range,
4807
4388
  options: [.longestEffectiveRangeNotRequired]
4808
4389
  ) { attrs, _, stop in
4809
- if attrs[RenderBridgeAttributes.syntheticPlaceholder] as? Bool == true
4810
- || attrs[RenderBridgeAttributes.listMarkerContext] != nil
4390
+ if attrs[InputCoordinatorAttributes.syntheticPlaceholder] as? Bool == true
4391
+ || attrs[InputCoordinatorAttributes.listMarkerContext] != nil
4811
4392
  {
4812
4393
  hasAdjustments = true
4813
4394
  stop.pointee = true
@@ -4826,7 +4407,7 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
4826
4407
 
4827
4408
  var hasListMarkerContext = false
4828
4409
  textStorage.enumerateAttribute(
4829
- RenderBridgeAttributes.listMarkerContext,
4410
+ InputCoordinatorAttributes.listMarkerContext,
4830
4411
  in: range,
4831
4412
  options: [.longestEffectiveRangeNotRequired]
4832
4413
  ) { value, _, stop in
@@ -4860,36 +4441,6 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
4860
4441
  return hasAttachment
4861
4442
  }
4862
4443
 
4863
- private func topLevelChildrenContainAttachment(
4864
- startIndex: Int,
4865
- deleteCount: Int
4866
- ) -> Bool {
4867
- guard deleteCount > 0,
4868
- let currentTopLevelChildMetadata,
4869
- startIndex >= 0,
4870
- startIndex + deleteCount <= currentTopLevelChildMetadata.count
4871
- else {
4872
- return false
4873
- }
4874
- return currentTopLevelChildMetadata[startIndex..<(startIndex + deleteCount)]
4875
- .contains(where: \.containsAttachment)
4876
- }
4877
-
4878
- private func topLevelChildrenContainPositionAdjustments(
4879
- startIndex: Int,
4880
- deleteCount: Int
4881
- ) -> Bool {
4882
- guard deleteCount > 0,
4883
- let currentTopLevelChildMetadata,
4884
- startIndex >= 0,
4885
- startIndex + deleteCount <= currentTopLevelChildMetadata.count
4886
- else {
4887
- return false
4888
- }
4889
- return currentTopLevelChildMetadata[startIndex..<(startIndex + deleteCount)]
4890
- .contains(where: \.containsPositionAdjustments)
4891
- }
4892
-
4893
4444
  private func trimmedAttributedPatch(
4894
4445
  replacing fullReplaceRange: NSRange,
4895
4446
  with replacement: NSAttributedString
@@ -4995,156 +4546,10 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
4995
4546
  )
4996
4547
  }
4997
4548
 
4998
- private func applyRenderPatchIfPossible(_ patch: ParsedRenderPatch) -> PatchApplyTrace {
4999
- let eligibilityStartedAt = DispatchTime.now().uptimeNanoseconds
5000
- guard hasTopLevelChildMetadata(),
5001
- let fullReplaceRange = replacementRangeForRenderPatch(
5002
- startIndex: patch.startIndex,
5003
- deleteCount: patch.deleteCount
5004
- )
5005
- else {
5006
- return PatchApplyTrace(
5007
- applied: false,
5008
- eligibilityNanos: DispatchTime.now().uptimeNanoseconds - eligibilityStartedAt,
5009
- trimNanos: 0,
5010
- metadataNanos: 0,
5011
- buildRenderNanos: 0,
5012
- applyRenderNanos: 0,
5013
- applyRenderReplaceUtf16Length: 0,
5014
- applyRenderReplacementUtf16Length: 0,
5015
- applyRenderTextMutationNanos: 0,
5016
- applyRenderBeginEditingNanos: 0,
5017
- applyRenderEndEditingNanos: 0,
5018
- applyRenderStringMutationNanos: 0,
5019
- applyRenderAttributeMutationNanos: 0,
5020
- applyRenderAuthorizedTextNanos: 0,
5021
- applyRenderCacheInvalidationNanos: 0,
5022
- usedSmallPatchTextMutation: false
5023
- )
5024
- }
5025
-
5026
- let buildStartedAt = DispatchTime.now().uptimeNanoseconds
5027
- let attrStr = RenderBridge.renderBlocks(
5028
- fromArray: patch.renderBlocks,
5029
- startIndex: patch.startIndex,
5030
- includeLeadingInterBlockSeparator: patch.startIndex > 0,
5031
- baseFont: baseFont,
5032
- textColor: baseTextColor,
5033
- theme: theme
5034
- )
5035
- let buildRenderNanos = DispatchTime.now().uptimeNanoseconds - buildStartedAt
5036
- let renderedPatchMetadata = topLevelChildMetadataSlice(from: attrStr)
5037
- let renderedPatchContainsAttachment =
5038
- renderedPatchMetadata?.entries.contains(where: \.containsAttachment)
5039
- ?? attributedStringContainsAttachment(attrStr)
5040
- let renderedPatchContainsListMarkerContext =
5041
- attributedStringContainsListMarkerContext(attrStr)
5042
- let renderedPatchContainsPositionAdjustments =
5043
- renderedPatchMetadata?.entries.contains(where: \.containsPositionAdjustments)
5044
- ?? attributedStringContainsPositionAdjustments(attrStr)
5045
- guard !topLevelChildrenContainAttachment(
5046
- startIndex: patch.startIndex,
5047
- deleteCount: patch.deleteCount
5048
- ),
5049
- !renderedPatchContainsAttachment
5050
- else {
5051
- return PatchApplyTrace(
5052
- applied: false,
5053
- eligibilityNanos: DispatchTime.now().uptimeNanoseconds - eligibilityStartedAt,
5054
- trimNanos: 0,
5055
- metadataNanos: 0,
5056
- buildRenderNanos: buildRenderNanos,
5057
- applyRenderNanos: 0,
5058
- applyRenderReplaceUtf16Length: 0,
5059
- applyRenderReplacementUtf16Length: 0,
5060
- applyRenderTextMutationNanos: 0,
5061
- applyRenderBeginEditingNanos: 0,
5062
- applyRenderEndEditingNanos: 0,
5063
- applyRenderStringMutationNanos: 0,
5064
- applyRenderAttributeMutationNanos: 0,
5065
- applyRenderAuthorizedTextNanos: 0,
5066
- applyRenderCacheInvalidationNanos: 0,
5067
- usedSmallPatchTextMutation: false
5068
- )
5069
- }
5070
- guard !textStorageRangeContainsListMarkerContext(fullReplaceRange),
5071
- !renderedPatchContainsListMarkerContext
5072
- else {
5073
- return PatchApplyTrace(
5074
- applied: false,
5075
- eligibilityNanos: DispatchTime.now().uptimeNanoseconds - eligibilityStartedAt,
5076
- trimNanos: 0,
5077
- metadataNanos: 0,
5078
- buildRenderNanos: buildRenderNanos,
5079
- applyRenderNanos: 0,
5080
- applyRenderReplaceUtf16Length: 0,
5081
- applyRenderReplacementUtf16Length: 0,
5082
- applyRenderTextMutationNanos: 0,
5083
- applyRenderBeginEditingNanos: 0,
5084
- applyRenderEndEditingNanos: 0,
5085
- applyRenderStringMutationNanos: 0,
5086
- applyRenderAttributeMutationNanos: 0,
5087
- applyRenderAuthorizedTextNanos: 0,
5088
- applyRenderCacheInvalidationNanos: 0,
5089
- usedSmallPatchTextMutation: false
5090
- )
5091
- }
5092
- let eligibilityNanos =
5093
- DispatchTime.now().uptimeNanoseconds - eligibilityStartedAt - buildRenderNanos
5094
- let positionCacheUpdate: PositionCacheUpdate =
5095
- if topLevelChildrenContainPositionAdjustments(
5096
- startIndex: patch.startIndex,
5097
- deleteCount: patch.deleteCount
5098
- ) || renderedPatchContainsPositionAdjustments
5099
- {
5100
- .attributed
5101
- } else {
5102
- .plainText
5103
- }
5104
- let trimStartedAt = DispatchTime.now().uptimeNanoseconds
5105
- let patchToApply = trimmedAttributedPatch(replacing: fullReplaceRange, with: attrStr)
5106
- let trimNanos = DispatchTime.now().uptimeNanoseconds - trimStartedAt
5107
- let applyTrace = applyAttributedRender(
5108
- patchToApply.replacement,
5109
- replaceRange: patchToApply.replaceRange,
5110
- usedPatch: true,
5111
- positionCacheUpdate: positionCacheUpdate,
5112
- authorizedReplaceRange: fullReplaceRange,
5113
- authorizedReplacementText: attrStr.string,
5114
- authorizedReplacementAttributedText: attrStr
5115
- )
5116
- let metadataStartedAt = DispatchTime.now().uptimeNanoseconds
5117
- applyTopLevelChildMetadataPatch(
5118
- patch,
5119
- replaceRange: fullReplaceRange,
5120
- renderedPatchMetadata: renderedPatchMetadata,
5121
- renderedPatchLength: attrStr.length
5122
- )
5123
- let metadataNanos = DispatchTime.now().uptimeNanoseconds - metadataStartedAt
5124
- return PatchApplyTrace(
5125
- applied: true,
5126
- eligibilityNanos: eligibilityNanos,
5127
- trimNanos: trimNanos,
5128
- metadataNanos: metadataNanos,
5129
- buildRenderNanos: buildRenderNanos,
5130
- applyRenderNanos: applyTrace.totalNanos,
5131
- applyRenderReplaceUtf16Length: applyTrace.replaceUtf16Length,
5132
- applyRenderReplacementUtf16Length: applyTrace.replacementUtf16Length,
5133
- applyRenderTextMutationNanos: applyTrace.textMutationNanos,
5134
- applyRenderBeginEditingNanos: applyTrace.beginEditingNanos,
5135
- applyRenderEndEditingNanos: applyTrace.endEditingNanos,
5136
- applyRenderStringMutationNanos: applyTrace.stringMutationNanos,
5137
- applyRenderAttributeMutationNanos: applyTrace.attributeMutationNanos,
5138
- applyRenderAuthorizedTextNanos: applyTrace.authorizedTextNanos,
5139
- applyRenderCacheInvalidationNanos: applyTrace.cacheInvalidationNanos,
5140
- usedSmallPatchTextMutation: applyTrace.usedSmallPatchTextMutation
5141
- )
5142
- }
5143
-
5144
- /// Apply a full render update from Rust to the text view.
4549
+ /// Apply a structural editor update from Rust to the hidden input text view.
5145
4550
  ///
5146
- /// Parses the update JSON, converts render elements to NSAttributedString
5147
- /// via RenderBridge, and replaces the text view's content.
4551
+ /// The visible editor surface renders the block tree. This text view keeps a
4552
+ /// plain text snapshot for input, selection, and IME routing only.
5148
4553
  ///
5149
4554
  /// - Parameter updateJSON: The JSON string from editor_insert_text, etc.
5150
4555
  func applyUpdateJSON(_ updateJSON: String, notifyDelegate: Bool = true) {
@@ -5157,94 +4562,97 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
5157
4562
  let parseNanos = DispatchTime.now().uptimeNanoseconds - parseStartedAt
5158
4563
  resetPendingNativeTextMutationState()
5159
4564
 
5160
- let renderElements = update["renderElements"] as? [[String: Any]]
4565
+ let inputSnapshot = update["inputText"] as? String
5161
4566
  let selectionFromUpdate = (update["selection"] as? [String: Any])
5162
4567
  .map(self.selectionSummary(from:)) ?? "none"
5163
4568
  Self.updateLog.debug(
5164
- "[applyUpdateJSON.begin] renderCount=\(renderElements?.count ?? 0) updateSelection=\(selectionFromUpdate, privacy: .public) before=\(self.textSnapshotSummary(), privacy: .public)"
4569
+ "[applyUpdateJSON.begin] structuralInput=\(inputSnapshot != nil, privacy: .public) updateSelection=\(selectionFromUpdate, privacy: .public) before=\(self.textSnapshotSummary(), privacy: .public)"
5165
4570
  )
5166
- let resolveRenderBlocksStartedAt = DispatchTime.now().uptimeNanoseconds
5167
- let renderBlocks = parseRenderBlocks(update["renderBlocks"])
5168
- let explicitRenderPatch = parseRenderPatch(update["renderPatch"])
5169
- let resolvedRenderBlocks = renderBlocks
5170
- ?? explicitRenderPatch.flatMap { patch in
5171
- currentRenderBlocks.flatMap { mergeRenderBlocks(applying: patch, to: $0) }
5172
- }
5173
- let resolveRenderBlocksNanos =
5174
- DispatchTime.now().uptimeNanoseconds - resolveRenderBlocksStartedAt
5175
-
5176
- let derivedRenderPatch: DerivedRenderPatch? =
5177
- if explicitRenderPatch == nil,
5178
- let currentRenderBlocks,
5179
- let resolvedRenderBlocks
5180
- {
5181
- deriveRenderPatch(from: currentRenderBlocks, to: resolvedRenderBlocks)
5182
- } else {
5183
- nil
4571
+ if inputSnapshot == nil, let selection = update["selection"] as? [String: Any] {
4572
+ let selectionTrace = applySelectionFromJSON(selection)
4573
+ let postApplyTrace = performPostApplyMaintenance()
4574
+ if captureApplyUpdateTraceForTesting {
4575
+ lastApplyUpdateTraceForTesting = ApplyUpdateTrace(
4576
+ attemptedPatch: false,
4577
+ usedPatch: false,
4578
+ usedSmallPatchTextMutation: false,
4579
+ applyRenderReplaceUtf16Length: 0,
4580
+ applyRenderReplacementUtf16Length: 0,
4581
+ parseNanos: parseNanos,
4582
+ resolveRenderBlocksNanos: 0,
4583
+ patchEligibilityNanos: 0,
4584
+ patchTrimNanos: 0,
4585
+ patchMetadataNanos: 0,
4586
+ buildRenderNanos: 0,
4587
+ applyRenderNanos: 0,
4588
+ selectionNanos: selectionTrace.totalNanos,
4589
+ postApplyNanos: postApplyTrace.totalNanos,
4590
+ totalNanos: DispatchTime.now().uptimeNanoseconds - totalStartedAt,
4591
+ applyRenderTextMutationNanos: 0,
4592
+ applyRenderBeginEditingNanos: 0,
4593
+ applyRenderEndEditingNanos: 0,
4594
+ applyRenderStringMutationNanos: 0,
4595
+ applyRenderAttributeMutationNanos: 0,
4596
+ applyRenderAuthorizedTextNanos: 0,
4597
+ applyRenderCacheInvalidationNanos: 0,
4598
+ selectionResolveNanos: selectionTrace.resolveNanos,
4599
+ selectionAssignmentNanos: selectionTrace.assignmentNanos,
4600
+ selectionChromeNanos: selectionTrace.chromeNanos,
4601
+ postApplyTypingAttributesNanos: postApplyTrace.typingAttributesNanos,
4602
+ postApplyHeightNotifyNanos: postApplyTrace.heightNotifyNanos,
4603
+ postApplyHeightNotifyMeasureNanos: postApplyTrace.heightNotifyMeasureNanos,
4604
+ postApplyHeightNotifyCallbackNanos: postApplyTrace.heightNotifyCallbackNanos,
4605
+ postApplyHeightNotifyEnsureLayoutNanos: postApplyTrace.heightNotifyEnsureLayoutNanos,
4606
+ postApplyHeightNotifyUsedRectNanos: postApplyTrace.heightNotifyUsedRectNanos,
4607
+ postApplyHeightNotifyContentSizeNanos: postApplyTrace.heightNotifyContentSizeNanos,
4608
+ postApplyHeightNotifySizeThatFitsNanos: postApplyTrace.heightNotifySizeThatFitsNanos,
4609
+ postApplySelectionOrContentCallbackNanos:
4610
+ postApplyTrace.selectionOrContentCallbackNanos
4611
+ )
5184
4612
  }
5185
- let renderPatch = explicitRenderPatch ?? {
5186
- if case let .patch(patch)? = derivedRenderPatch {
5187
- return patch
4613
+ if notifyDelegate {
4614
+ editorDelegate?.editorTextView(self, didReceiveUpdate: updateJSON)
5188
4615
  }
5189
- return nil
5190
- }()
5191
- let shouldSkipRender = if case .unchanged? = derivedRenderPatch {
5192
- textStorage.string == lastAuthorizedText
5193
- && lastAppliedRenderAppearanceRevision == renderAppearanceRevision
5194
- } else {
5195
- false
5196
- }
5197
-
5198
- let patchTrace = renderPatch.map(applyRenderPatchIfPossible)
5199
- let appliedPatch = patchTrace?.applied == true
5200
- var usedSmallPatchTextMutation = patchTrace?.usedSmallPatchTextMutation ?? false
5201
- var applyRenderReplaceUtf16Length = patchTrace?.applyRenderReplaceUtf16Length ?? 0
5202
- var applyRenderReplacementUtf16Length =
5203
- patchTrace?.applyRenderReplacementUtf16Length ?? 0
5204
- var buildRenderNanos = patchTrace?.buildRenderNanos ?? 0
5205
- var applyRenderNanos = patchTrace?.applyRenderNanos ?? 0
5206
- var applyRenderTextMutationNanos = patchTrace?.applyRenderTextMutationNanos ?? 0
5207
- var applyRenderBeginEditingNanos = patchTrace?.applyRenderBeginEditingNanos ?? 0
5208
- var applyRenderEndEditingNanos = patchTrace?.applyRenderEndEditingNanos ?? 0
5209
- var applyRenderStringMutationNanos = patchTrace?.applyRenderStringMutationNanos ?? 0
5210
- var applyRenderAttributeMutationNanos =
5211
- patchTrace?.applyRenderAttributeMutationNanos ?? 0
5212
- var applyRenderAuthorizedTextNanos = patchTrace?.applyRenderAuthorizedTextNanos ?? 0
5213
- var applyRenderCacheInvalidationNanos = patchTrace?.applyRenderCacheInvalidationNanos ?? 0
4616
+ onUpdateJSONApplied?(updateJSON)
4617
+ return
4618
+ }
4619
+ let resolveRenderBlocksNanos: UInt64 = 0
4620
+ let shouldSkipRender =
4621
+ inputSnapshot != nil
4622
+ && textStorage.string == inputSnapshot
4623
+ && textStorage.string == lastAuthorizedText
4624
+ && lastAppliedRenderAppearanceRevision == renderAppearanceRevision
4625
+
4626
+ let appliedPatch = false
4627
+ var usedSmallPatchTextMutation = false
4628
+ var applyRenderReplaceUtf16Length = 0
4629
+ var applyRenderReplacementUtf16Length = 0
4630
+ var buildRenderNanos: UInt64 = 0
4631
+ var applyRenderNanos: UInt64 = 0
4632
+ var applyRenderTextMutationNanos: UInt64 = 0
4633
+ var applyRenderBeginEditingNanos: UInt64 = 0
4634
+ var applyRenderEndEditingNanos: UInt64 = 0
4635
+ var applyRenderStringMutationNanos: UInt64 = 0
4636
+ var applyRenderAttributeMutationNanos: UInt64 = 0
4637
+ var applyRenderAuthorizedTextNanos: UInt64 = 0
4638
+ var applyRenderCacheInvalidationNanos: UInt64 = 0
5214
4639
  if shouldSkipRender {
5215
4640
  lastRenderAppliedPatchForTesting = false
5216
- if let resolvedRenderBlocks {
5217
- currentRenderBlocks = resolvedRenderBlocks
5218
- }
5219
- } else if !appliedPatch {
4641
+ } else {
4642
+ guard let inputSnapshot else { return }
5220
4643
  let buildStartedAt = DispatchTime.now().uptimeNanoseconds
5221
- let attrStr: NSAttributedString
5222
- if let resolvedRenderBlocks {
5223
- attrStr = RenderBridge.renderBlocks(
5224
- fromArray: resolvedRenderBlocks,
5225
- baseFont: baseFont,
5226
- textColor: baseTextColor,
5227
- theme: theme
5228
- )
5229
- currentRenderBlocks = resolvedRenderBlocks
5230
- } else if let renderElements {
5231
- attrStr = RenderBridge.renderElements(
5232
- fromArray: renderElements,
5233
- baseFont: baseFont,
5234
- textColor: baseTextColor,
5235
- theme: theme
5236
- )
5237
- currentRenderBlocks = nil
5238
- } else {
5239
- return
5240
- }
4644
+ let attrStr = NSAttributedString(
4645
+ string: inputSnapshot,
4646
+ attributes: [
4647
+ .font: baseFont,
4648
+ .foregroundColor: baseTextColor
4649
+ ]
4650
+ )
5241
4651
  buildRenderNanos = DispatchTime.now().uptimeNanoseconds - buildStartedAt
5242
4652
  let applyTrace = applyAttributedRender(
5243
4653
  attrStr,
5244
- usedPatch: false,
5245
- positionCacheUpdate: .invalidate
4654
+ usedPatch: false
5246
4655
  )
5247
- refreshTopLevelChildMetadata(from: attrStr)
5248
4656
  applyRenderReplaceUtf16Length = applyTrace.replaceUtf16Length
5249
4657
  applyRenderReplacementUtf16Length = applyTrace.replacementUtf16Length
5250
4658
  applyRenderNanos = applyTrace.totalNanos
@@ -5257,14 +4665,11 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
5257
4665
  applyRenderCacheInvalidationNanos = applyTrace.cacheInvalidationNanos
5258
4666
  usedSmallPatchTextMutation = applyTrace.usedSmallPatchTextMutation
5259
4667
  lastAppliedRenderAppearanceRevision = renderAppearanceRevision
5260
- } else if let resolvedRenderBlocks {
5261
- currentRenderBlocks = resolvedRenderBlocks
5262
- lastAppliedRenderAppearanceRevision = renderAppearanceRevision
5263
4668
  }
5264
4669
 
5265
4670
  refreshPlaceholderVisibility()
5266
4671
  Self.updateLog.debug(
5267
- "[applyUpdateJSON.rendered] mode=\(appliedPatch ? "patch" : "full", privacy: .public) after=\(self.textSnapshotSummary(), privacy: .public)"
4672
+ "[applyUpdateJSON.rendered] mode=structuralInput after=\(self.textSnapshotSummary(), privacy: .public)"
5268
4673
  )
5269
4674
 
5270
4675
  // Apply the selection from the update.
@@ -5284,16 +4689,16 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
5284
4689
 
5285
4690
  if captureApplyUpdateTraceForTesting {
5286
4691
  lastApplyUpdateTraceForTesting = ApplyUpdateTrace(
5287
- attemptedPatch: renderPatch != nil,
4692
+ attemptedPatch: false,
5288
4693
  usedPatch: appliedPatch,
5289
4694
  usedSmallPatchTextMutation: usedSmallPatchTextMutation,
5290
4695
  applyRenderReplaceUtf16Length: applyRenderReplaceUtf16Length,
5291
4696
  applyRenderReplacementUtf16Length: applyRenderReplacementUtf16Length,
5292
4697
  parseNanos: parseNanos,
5293
4698
  resolveRenderBlocksNanos: resolveRenderBlocksNanos,
5294
- patchEligibilityNanos: patchTrace?.eligibilityNanos ?? 0,
5295
- patchTrimNanos: patchTrace?.trimNanos ?? 0,
5296
- patchMetadataNanos: patchTrace?.metadataNanos ?? 0,
4699
+ patchEligibilityNanos: 0,
4700
+ patchTrimNanos: 0,
4701
+ patchMetadataNanos: 0,
5297
4702
  buildRenderNanos: buildRenderNanos,
5298
4703
  applyRenderNanos: applyRenderNanos,
5299
4704
  selectionNanos: selectionTrace.totalNanos,
@@ -5330,34 +4735,60 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate {
5330
4735
  if notifyDelegate {
5331
4736
  editorDelegate?.editorTextView(self, didReceiveUpdate: updateJSON)
5332
4737
  }
4738
+ onUpdateJSONApplied?(updateJSON)
5333
4739
  }
5334
4740
 
5335
- /// Apply a render JSON string (just render elements, no update wrapper).
5336
- ///
5337
- /// Used for initial content loading (set_html / set_json return render
5338
- /// elements directly, not wrapped in an EditorUpdate).
5339
- func applyRenderJSON(_ renderJSON: String) {
5340
- ensureInternalTextViewDelegate()
5341
- resetPendingNativeTextMutationState()
5342
- Self.updateLog.debug(
5343
- "[applyRenderJSON.begin] before=\(self.textSnapshotSummary(), privacy: .public)"
5344
- )
5345
- let attrStr = RenderBridge.renderElements(
5346
- fromJSON: renderJSON,
5347
- baseFont: baseFont,
5348
- textColor: baseTextColor,
5349
- theme: theme
5350
- )
5351
- _ = applyAttributedRender(attrStr, usedPatch: false)
5352
- currentRenderBlocks = nil
5353
- lastAppliedRenderAppearanceRevision = renderAppearanceRevision
4741
+ private static func plainTextSnapshot(fromRenderTree tree: [String: Any]?) -> String? {
4742
+ guard let root = tree?["root"] as? [String: Any] else { return nil }
4743
+ var text = ""
4744
+ appendInputSnapshotNode(root, to: &text, isRoot: true)
4745
+ return text
4746
+ }
5354
4747
 
5355
- refreshPlaceholderVisibility()
5356
- _ = performPostApplyMaintenance()
5357
- recordAuthorizedSelectionIfPossible()
5358
- Self.updateLog.debug(
5359
- "[applyRenderJSON.end] after=\(self.textSnapshotSummary(), privacy: .public)"
5360
- )
4748
+ private static func appendInputSnapshotNode(
4749
+ _ node: [String: Any],
4750
+ to text: inout String,
4751
+ isRoot: Bool
4752
+ ) {
4753
+ let kind = node["kind"] as? String ?? "block"
4754
+ let nodeType = node["nodeType"] as? String ?? "unknown"
4755
+ if kind == "text" {
4756
+ text += node["text"] as? String ?? ""
4757
+ return
4758
+ }
4759
+ if kind == "inlineAtom" {
4760
+ if nodeType == "hardBreak" || nodeType == "hard_break" {
4761
+ text += "\n"
4762
+ } else {
4763
+ text += node["label"] as? String ?? "\u{FFFC}"
4764
+ }
4765
+ return
4766
+ }
4767
+ if kind == "blockAtom" {
4768
+ if !text.isEmpty && !text.hasSuffix("\n") {
4769
+ text += "\n"
4770
+ }
4771
+ text += node["label"] as? String ?? "\u{FFFC}"
4772
+ text += "\n"
4773
+ return
4774
+ }
4775
+
4776
+ let children = node["children"] as? [[String: Any]] ?? []
4777
+ let startsBlock = !isRoot && isInputSnapshotBlockBoundary(kind: kind)
4778
+ if startsBlock && !text.isEmpty && !text.hasSuffix("\n") {
4779
+ text += "\n"
4780
+ }
4781
+ for child in children {
4782
+ appendInputSnapshotNode(child, to: &text, isRoot: false)
4783
+ }
4784
+ if startsBlock && !text.isEmpty && !text.hasSuffix("\n") {
4785
+ text += "\n"
4786
+ }
4787
+ }
4788
+
4789
+ private static func isInputSnapshotBlockBoundary(kind: String) -> Bool {
4790
+ kind == "textBlock" || kind == "listItem" || kind == "block" ||
4791
+ kind == "tableCell" || kind == "tableRow" || kind == "table"
5361
4792
  }
5362
4793
 
5363
4794
  /// Apply a selection from a parsed JSON selection object.
@@ -5542,7 +4973,6 @@ extension EditorTextView: NSTextStorageDelegate {
5542
4973
  // Compare current text storage content against last authorized snapshot.
5543
4974
  let currentText = textStorage.string
5544
4975
  guard currentText != lastAuthorizedText else { return }
5545
- currentTopLevelChildMetadata = nil
5546
4976
 
5547
4977
  let allowAfterBlur = canAdoptNativeTextMutationAfterBlur()
5548
4978
  if let mutation = nativeTextMutationFromAuthorizedDiff(currentText: currentText),
@@ -5633,6 +5063,14 @@ final class RichTextEditorView: UIView {
5633
5063
 
5634
5064
  /// The editor text view that handles input interception.
5635
5065
  let textView: EditorTextView
5066
+ private let blockSurface = NativeBlockEditorSurface()
5067
+
5068
+ var placeholder: String = "" {
5069
+ didSet {
5070
+ textView.placeholder = placeholder
5071
+ blockSurface.placeholder = placeholder
5072
+ }
5073
+ }
5636
5074
  private let remoteSelectionOverlayView = RemoteSelectionOverlayView()
5637
5075
  private let taskListMarkerTapOverlayView = TaskListMarkerTapOverlayView()
5638
5076
  private let imageTapOverlayView = ImageTapOverlayView()
@@ -5642,6 +5080,7 @@ final class RichTextEditorView: UIView {
5642
5080
  private var cachedAutoGrowMeasuredHeight: CGFloat = 0
5643
5081
  private var remoteSelections: [RemoteSelectionDecoration] = []
5644
5082
  private var overlayRefreshScheduled = false
5083
+ private var isSyncingBlockSurfaceUpdateToInputAdapter = false
5645
5084
  var captureHostedLayoutTraceForTesting = false
5646
5085
  private var hostedLayoutTraceNanos = (
5647
5086
  intrinsicContentSize: UInt64(0),
@@ -5674,6 +5113,8 @@ final class RichTextEditorView: UIView {
5674
5113
  didSet {
5675
5114
  guard oldValue != heightBehavior else { return }
5676
5115
  textView.heightBehavior = heightBehavior
5116
+ blockSurface.isScrollEnabled = heightBehavior == .fixed
5117
+ blockSurface.alwaysBounceVertical = heightBehavior == .fixed
5677
5118
  textView.updateAutoGrowHostHeight(heightBehavior == .autoGrow ? bounds.height : 0)
5678
5119
  if heightBehavior != .autoGrow {
5679
5120
  cachedAutoGrowMeasuredHeight = 0
@@ -5702,8 +5143,10 @@ final class RichTextEditorView: UIView {
5702
5143
  guard oldValue != editorId else { return }
5703
5144
  textView.discardTransientNativeInputForEditorRebind()
5704
5145
  if editorId != 0 {
5146
+ blockSurface.bind(editorId: editorId)
5705
5147
  textView.bindEditor(id: editorId)
5706
5148
  } else {
5149
+ blockSurface.bind(editorId: 0)
5707
5150
  textView.unbindEditor()
5708
5151
  }
5709
5152
  remoteSelectionOverlayView.update(
@@ -5732,7 +5175,7 @@ final class RichTextEditorView: UIView {
5732
5175
  private func setupView() {
5733
5176
  // Add the text view as a subview. These views always track the host bounds,
5734
5177
  // so manual layout is cheaper than driving them through Auto Layout.
5735
- remoteSelectionOverlayView.bind(textView: textView)
5178
+ remoteSelectionOverlayView.bind(documentSurface: blockSurface)
5736
5179
  taskListMarkerTapOverlayView.bind(editorView: self)
5737
5180
  imageTapOverlayView.bind(editorView: self)
5738
5181
  imageResizeOverlayView.bind(editorView: self)
@@ -5753,9 +5196,46 @@ final class RichTextEditorView: UIView {
5753
5196
  self?.refreshOverlaysIfNeeded()
5754
5197
  }
5755
5198
  textView.onSelectionOrContentMayChange = { [weak self] in
5756
- self?.scheduleRefreshOverlaysIfNeeded()
5199
+ guard let self else { return }
5200
+ if self.editorId != 0, !self.isSyncingBlockSurfaceUpdateToInputAdapter {
5201
+ self.blockSurface.applyUpdateJSON(
5202
+ editorGetSelectionState(id: self.editorId),
5203
+ editorId: self.editorId
5204
+ )
5205
+ }
5206
+ self.scheduleRefreshOverlaysIfNeeded()
5757
5207
  }
5208
+ textView.onUpdateJSONApplied = { [weak self] updateJSON in
5209
+ guard let self else { return }
5210
+ if self.isSyncingBlockSurfaceUpdateToInputAdapter { return }
5211
+ self.blockSurface.applyUpdateJSON(updateJSON, editorId: self.editorId)
5212
+ }
5213
+ blockSurface.onAppliedUpdate = { [weak self] updateJSON in
5214
+ guard let self else { return }
5215
+ self.isSyncingBlockSurfaceUpdateToInputAdapter = true
5216
+ defer { self.isSyncingBlockSurfaceUpdateToInputAdapter = false }
5217
+ self.textView.applyUpdateJSON(updateJSON, notifyDelegate: true)
5218
+ }
5219
+ blockSurface.onRequestTextInput = { [weak self] docPos in
5220
+ guard let self, self.editorId != 0, self.textView.isEditable else { return }
5221
+ editorSetSelection(id: self.editorId, anchor: docPos, head: docPos)
5222
+ self.isSyncingBlockSurfaceUpdateToInputAdapter = true
5223
+ self.textView.applyUpdateJSON(
5224
+ editorGetSelectionState(id: self.editorId),
5225
+ notifyDelegate: true
5226
+ )
5227
+ self.isSyncingBlockSurfaceUpdateToInputAdapter = false
5228
+ self.textView.becomeFirstResponder()
5229
+ }
5230
+ blockSurface.onContentHeightMayChange = { [weak self] measuredHeight in
5231
+ guard let self, self.heightBehavior == .autoGrow else { return }
5232
+ self.cachedAutoGrowMeasuredHeight = measuredHeight
5233
+ self.invalidateIntrinsicContentSize()
5234
+ self.onHeightMayChange?(measuredHeight)
5235
+ }
5236
+ textView.alpha = 0.01
5758
5237
  addSubview(textView)
5238
+ addSubview(blockSurface)
5759
5239
  addSubview(remoteSelectionOverlayView)
5760
5240
  addSubview(taskListMarkerTapOverlayView)
5761
5241
  addSubview(imageTapOverlayView)
@@ -5821,11 +5301,15 @@ final class RichTextEditorView: UIView {
5821
5301
  textView.font = font
5822
5302
  textView.textColor = textColor
5823
5303
  textView.backgroundColor = backgroundColor
5304
+ blockSurface.baseFont = font
5305
+ blockSurface.baseTextColor = textColor
5306
+ blockSurface.baseBackgroundColor = backgroundColor
5824
5307
  }
5825
5308
 
5826
5309
  @discardableResult
5827
5310
  func applyTheme(_ theme: EditorTheme?) -> Bool {
5828
5311
  guard textView.applyTheme(theme) else { return false }
5312
+ blockSurface.applyTheme(theme)
5829
5313
  let cornerRadius = theme?.borderRadius ?? 0
5830
5314
  layer.cornerRadius = cornerRadius
5831
5315
  clipsToBounds = cornerRadius > 0
@@ -5835,22 +5319,31 @@ final class RichTextEditorView: UIView {
5835
5319
 
5836
5320
  func setRemoteSelections(_ selections: [RemoteSelectionDecoration]) {
5837
5321
  remoteSelections = selections
5322
+ layoutManagedSubviews()
5323
+ blockSurface.layoutIfNeeded()
5838
5324
  remoteSelectionOverlayView.update(
5839
5325
  selections: selections,
5840
5326
  editorId: editorId
5841
5327
  )
5842
5328
  }
5843
5329
 
5330
+ func setEditable(_ editable: Bool) {
5331
+ textView.isEditable = editable
5332
+ blockSurface.isEditable = editable
5333
+ if !editable {
5334
+ textView.resignFirstResponder()
5335
+ }
5336
+ }
5337
+
5844
5338
  func refreshRemoteSelections() {
5845
5339
  guard remoteSelectionOverlayView.hasSelectionsOrVisibleDecorations else { return }
5846
5340
  remoteSelectionOverlayView.refresh()
5847
5341
  }
5848
5342
 
5849
5343
  func currentCaretRect() -> CGRect? {
5850
- guard let selectedTextRange = textView.selectedTextRange else { return nil }
5851
- let rect = textView.caretRect(for: selectedTextRange.end)
5852
- guard rect.height > 0 else { return nil }
5853
- return textView.convert(rect, to: self)
5344
+ blockSurface.isInputFocused = textView.isFirstResponder
5345
+ guard let rect = blockSurface.selectedCaretRect() else { return nil }
5346
+ return blockSurface.convert(rect, to: self)
5854
5347
  }
5855
5348
 
5856
5349
  func remoteSelectionOverlaySubviewsForTesting() -> [UIView] {
@@ -5899,8 +5392,50 @@ final class RichTextEditorView: UIView {
5899
5392
  imageResizeOverlayView.visibleRectForTesting
5900
5393
  }
5901
5394
 
5395
+ func blockSurfaceImageRectForTesting() -> CGRect? {
5396
+ guard let geometry = blockSurface.selectedImageGeometry()
5397
+ ?? blockSurface.firstImageGeometryForTesting()
5398
+ else { return nil }
5399
+ return blockSurface.convert(geometry.rect, to: self)
5400
+ }
5401
+
5402
+ func blockSurfaceTopLevelViewForTesting(at index: Int) -> UIView? {
5403
+ blockSurface.topLevelViewForTesting(at: index)
5404
+ }
5405
+
5406
+ func blockSurfaceTopLevelViewCountForTesting() -> Int {
5407
+ blockSurface.topLevelViewCountForTesting
5408
+ }
5409
+
5410
+ func isBlockSurfacePlaceholderVisibleForTesting() -> Bool {
5411
+ blockSurface.isPlaceholderVisibleForTesting()
5412
+ }
5413
+
5414
+ func blockSurfaceBackgroundColorForTesting() -> UIColor? {
5415
+ blockSurface.backgroundColor
5416
+ }
5417
+
5418
+ func blockSurfacePlaceholderColorForTesting() -> UIColor? {
5419
+ blockSurface.placeholderColorForTesting()
5420
+ }
5421
+
5422
+ func blockSurfaceFirstTextAttributesForTesting() -> [NSAttributedString.Key: Any]? {
5423
+ blockSurface.firstTextAttributesForTesting()
5424
+ }
5425
+
5426
+ func blockSurfaceCaretRectForTesting(docPosition: UInt32) -> CGRect? {
5427
+ guard let rect = blockSurface.caretRect(forDocPosition: docPosition) else { return nil }
5428
+ return blockSurface.convert(rect, to: self)
5429
+ }
5430
+
5431
+ func blockSurfaceTopLevelRectForTesting(at index: Int) -> CGRect? {
5432
+ guard let child = blockSurface.topLevelViewForTesting(at: index) else { return nil }
5433
+ return child.convert(child.bounds, to: self)
5434
+ }
5435
+
5902
5436
  func imageTapOverlayInterceptsPointForTesting(_ point: CGPoint) -> Bool {
5903
- imageTapOverlayView.interceptsPointForTesting(convert(point, to: imageTapOverlayView))
5437
+ !imageTapOverlayView.isHidden
5438
+ && imageTapOverlayView.interceptsPointForTesting(convert(point, to: imageTapOverlayView))
5904
5439
  }
5905
5440
 
5906
5441
  func taskListMarkerTapOverlayInterceptsPointForTesting(_ point: CGPoint) -> Bool {
@@ -5934,7 +5469,7 @@ final class RichTextEditorView: UIView {
5934
5469
  }
5935
5470
 
5936
5471
  func maximumImageWidthForTesting() -> CGFloat {
5937
- textView.maximumRenderableImageWidth()
5472
+ blockSurface.maximumImageWidth()
5938
5473
  }
5939
5474
 
5940
5475
  func resizeSelectedImageForTesting(width: CGFloat, height: CGFloat) {
@@ -5955,7 +5490,7 @@ final class RichTextEditorView: UIView {
5955
5490
  func setContent(html: String) {
5956
5491
  guard editorId != 0 else { return }
5957
5492
  _ = editorSetHtml(id: editorId, html: html)
5958
- textView.applyUpdateJSON(editorGetCurrentState(id: editorId), notifyDelegate: false)
5493
+ applyFullStateToInputAndSurface(editorGetCurrentState(id: editorId))
5959
5494
  }
5960
5495
 
5961
5496
  /// Set initial content from ProseMirror JSON.
@@ -5964,7 +5499,14 @@ final class RichTextEditorView: UIView {
5964
5499
  func setContent(json: String) {
5965
5500
  guard editorId != 0 else { return }
5966
5501
  _ = editorSetJson(id: editorId, json: json)
5967
- textView.applyUpdateJSON(editorGetCurrentState(id: editorId), notifyDelegate: false)
5502
+ applyFullStateToInputAndSurface(editorGetCurrentState(id: editorId))
5503
+ }
5504
+
5505
+ private func applyFullStateToInputAndSurface(_ stateJSON: String) {
5506
+ isSyncingBlockSurfaceUpdateToInputAdapter = true
5507
+ textView.applyUpdateJSON(stateJSON, notifyDelegate: false)
5508
+ isSyncingBlockSurfaceUpdateToInputAdapter = false
5509
+ blockSurface.applyUpdateJSON(stateJSON, editorId: editorId)
5968
5510
  }
5969
5511
 
5970
5512
  private func measuredEditorHeight() -> CGFloat {
@@ -6002,6 +5544,9 @@ final class RichTextEditorView: UIView {
6002
5544
  if textView.frame != managedFrame {
6003
5545
  textView.frame = managedFrame
6004
5546
  }
5547
+ if blockSurface.frame != managedFrame {
5548
+ blockSurface.frame = managedFrame
5549
+ }
6005
5550
  if remoteSelectionOverlayView.frame != managedFrame {
6006
5551
  remoteSelectionOverlayView.frame = managedFrame
6007
5552
  }
@@ -6017,19 +5562,21 @@ final class RichTextEditorView: UIView {
6017
5562
  }
6018
5563
 
6019
5564
  fileprivate func selectedImageGeometry() -> (docPos: UInt32, rect: CGRect)? {
6020
- guard let geometry = textView.selectedImageGeometry() else { return nil }
5565
+ guard allowImageResizing, textView.isFirstResponder,
5566
+ let geometry = blockSurface.selectedImageGeometry()
5567
+ else { return nil }
6021
5568
  return (
6022
5569
  docPos: geometry.docPos,
6023
- rect: textView.convert(geometry.rect, to: imageResizeOverlayView)
5570
+ rect: blockSurface.convert(geometry.rect, to: imageResizeOverlayView)
6024
5571
  )
6025
5572
  }
6026
5573
 
6027
5574
  fileprivate func setImageResizePreviewActive(_ active: Bool) {
6028
- textView.setImageResizePreviewActive(active)
5575
+ blockSurface.setImagePreviewActive(active)
6029
5576
  }
6030
5577
 
6031
5578
  fileprivate func imagePreviewForResize(docPos: UInt32) -> UIImage? {
6032
- textView.imagePreviewForDocPos(docPos)
5579
+ blockSurface.imagePreview(docPos: docPos)
6033
5580
  }
6034
5581
 
6035
5582
  fileprivate func imageResizePreviewBackgroundColor() -> UIColor {
@@ -6037,12 +5584,12 @@ final class RichTextEditorView: UIView {
6037
5584
  }
6038
5585
 
6039
5586
  fileprivate func maximumImageWidthForResizeGesture() -> CGFloat {
6040
- textView.maximumRenderableImageWidth()
5587
+ blockSurface.maximumImageWidth()
6041
5588
  }
6042
5589
 
6043
5590
  fileprivate func clampedImageSize(_ size: CGSize, maximumWidth: CGFloat? = nil) -> CGSize {
6044
5591
  let aspectRatio = max(size.width / max(size.height, 1), 0.1)
6045
- let maxWidth = max(48, maximumWidth ?? textView.maximumRenderableImageWidth())
5592
+ let maxWidth = max(48, maximumWidth ?? blockSurface.maximumImageWidth())
6046
5593
  let clampedWidth = min(maxWidth, max(48, size.width))
6047
5594
  let clampedHeight = max(48, clampedWidth / aspectRatio)
6048
5595
  return CGSize(width: clampedWidth, height: clampedHeight)
@@ -6052,7 +5599,7 @@ final class RichTextEditorView: UIView {
6052
5599
  let clampedSize = clampedImageSize(size)
6053
5600
  let width = max(48, Int(clampedSize.width.rounded()))
6054
5601
  let height = max(48, Int(clampedSize.height.rounded()))
6055
- textView.resizeImageAtDocPos(docPos, width: UInt32(width), height: UInt32(height))
5602
+ blockSurface.resizeImage(docPos: docPos, width: UInt32(width), height: UInt32(height))
6056
5603
  }
6057
5604
 
6058
5605
  private func refreshOverlays() {
@@ -6063,6 +5610,10 @@ final class RichTextEditorView: UIView {
6063
5610
  keyPath: .refreshOverlays
6064
5611
  )
6065
5612
  }
5613
+ blockSurface.isInputFocused = textView.isFirstResponder
5614
+ // Overlay geometry must observe the structural surface's final width;
5615
+ // otherwise a first-pass caret can be cached before text wraps.
5616
+ blockSurface.layoutIfNeeded()
6066
5617
  remoteSelectionOverlayView.refresh()
6067
5618
  imageResizeOverlayView.refresh()
6068
5619
  }
@@ -6105,12 +5656,27 @@ final class RichTextEditorView: UIView {
6105
5656
  if imageResizeOverlayView.isOverlayVisible {
6106
5657
  return true
6107
5658
  }
6108
- if textView.selectedImageGeometry() != nil {
5659
+ if blockSurface.selectedImageGeometry() != nil {
6109
5660
  return true
6110
5661
  }
6111
5662
  return false
6112
5663
  }
6113
5664
 
5665
+ fileprivate func blockSurfaceHasImage(at point: CGPoint) -> Bool {
5666
+ blockSurface.hasImage(at: convert(point, to: blockSurface))
5667
+ }
5668
+
5669
+ @discardableResult
5670
+ fileprivate func selectBlockSurfaceImage(at point: CGPoint) -> Bool {
5671
+ guard allowImageResizing else { return false }
5672
+ _ = textView.becomeFirstResponder()
5673
+ let didSelect = blockSurface.selectImage(at: convert(point, to: blockSurface))
5674
+ if didSelect {
5675
+ refreshOverlays()
5676
+ }
5677
+ return didSelect
5678
+ }
5679
+
6114
5680
  private enum HostedLayoutTraceKey {
6115
5681
  case intrinsicContentSize
6116
5682
  case measuredEditorHeight