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

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 +1393 -0
  30. package/ios/NativeEditorExpoView.swift +3 -3
  31. package/ios/NativeEditorModule.swift +30 -45
  32. package/ios/NativeInputSupport.swift +396 -0
  33. package/ios/PositionBridge.swift +4 -4
  34. package/ios/RichTextEditorView.swift +333 -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,7 @@ 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()
5636
5067
  private let remoteSelectionOverlayView = RemoteSelectionOverlayView()
5637
5068
  private let taskListMarkerTapOverlayView = TaskListMarkerTapOverlayView()
5638
5069
  private let imageTapOverlayView = ImageTapOverlayView()
@@ -5642,6 +5073,7 @@ final class RichTextEditorView: UIView {
5642
5073
  private var cachedAutoGrowMeasuredHeight: CGFloat = 0
5643
5074
  private var remoteSelections: [RemoteSelectionDecoration] = []
5644
5075
  private var overlayRefreshScheduled = false
5076
+ private var isSyncingBlockSurfaceUpdateToInputAdapter = false
5645
5077
  var captureHostedLayoutTraceForTesting = false
5646
5078
  private var hostedLayoutTraceNanos = (
5647
5079
  intrinsicContentSize: UInt64(0),
@@ -5674,6 +5106,8 @@ final class RichTextEditorView: UIView {
5674
5106
  didSet {
5675
5107
  guard oldValue != heightBehavior else { return }
5676
5108
  textView.heightBehavior = heightBehavior
5109
+ blockSurface.isScrollEnabled = heightBehavior == .fixed
5110
+ blockSurface.alwaysBounceVertical = heightBehavior == .fixed
5677
5111
  textView.updateAutoGrowHostHeight(heightBehavior == .autoGrow ? bounds.height : 0)
5678
5112
  if heightBehavior != .autoGrow {
5679
5113
  cachedAutoGrowMeasuredHeight = 0
@@ -5702,8 +5136,10 @@ final class RichTextEditorView: UIView {
5702
5136
  guard oldValue != editorId else { return }
5703
5137
  textView.discardTransientNativeInputForEditorRebind()
5704
5138
  if editorId != 0 {
5139
+ blockSurface.bind(editorId: editorId)
5705
5140
  textView.bindEditor(id: editorId)
5706
5141
  } else {
5142
+ blockSurface.bind(editorId: 0)
5707
5143
  textView.unbindEditor()
5708
5144
  }
5709
5145
  remoteSelectionOverlayView.update(
@@ -5732,7 +5168,7 @@ final class RichTextEditorView: UIView {
5732
5168
  private func setupView() {
5733
5169
  // Add the text view as a subview. These views always track the host bounds,
5734
5170
  // so manual layout is cheaper than driving them through Auto Layout.
5735
- remoteSelectionOverlayView.bind(textView: textView)
5171
+ remoteSelectionOverlayView.bind(documentSurface: blockSurface)
5736
5172
  taskListMarkerTapOverlayView.bind(editorView: self)
5737
5173
  imageTapOverlayView.bind(editorView: self)
5738
5174
  imageResizeOverlayView.bind(editorView: self)
@@ -5753,9 +5189,46 @@ final class RichTextEditorView: UIView {
5753
5189
  self?.refreshOverlaysIfNeeded()
5754
5190
  }
5755
5191
  textView.onSelectionOrContentMayChange = { [weak self] in
5756
- self?.scheduleRefreshOverlaysIfNeeded()
5192
+ guard let self else { return }
5193
+ if self.editorId != 0, !self.isSyncingBlockSurfaceUpdateToInputAdapter {
5194
+ self.blockSurface.applyUpdateJSON(
5195
+ editorGetSelectionState(id: self.editorId),
5196
+ editorId: self.editorId
5197
+ )
5198
+ }
5199
+ self.scheduleRefreshOverlaysIfNeeded()
5200
+ }
5201
+ textView.onUpdateJSONApplied = { [weak self] updateJSON in
5202
+ guard let self else { return }
5203
+ if self.isSyncingBlockSurfaceUpdateToInputAdapter { return }
5204
+ self.blockSurface.applyUpdateJSON(updateJSON, editorId: self.editorId)
5757
5205
  }
5206
+ blockSurface.onAppliedUpdate = { [weak self] updateJSON in
5207
+ guard let self else { return }
5208
+ self.isSyncingBlockSurfaceUpdateToInputAdapter = true
5209
+ defer { self.isSyncingBlockSurfaceUpdateToInputAdapter = false }
5210
+ self.textView.applyUpdateJSON(updateJSON, notifyDelegate: true)
5211
+ }
5212
+ blockSurface.onRequestTextInput = { [weak self] docPos in
5213
+ guard let self, self.editorId != 0, self.textView.isEditable else { return }
5214
+ editorSetSelection(id: self.editorId, anchor: docPos, head: docPos)
5215
+ self.isSyncingBlockSurfaceUpdateToInputAdapter = true
5216
+ self.textView.applyUpdateJSON(
5217
+ editorGetSelectionState(id: self.editorId),
5218
+ notifyDelegate: true
5219
+ )
5220
+ self.isSyncingBlockSurfaceUpdateToInputAdapter = false
5221
+ self.textView.becomeFirstResponder()
5222
+ }
5223
+ blockSurface.onContentHeightMayChange = { [weak self] measuredHeight in
5224
+ guard let self, self.heightBehavior == .autoGrow else { return }
5225
+ self.cachedAutoGrowMeasuredHeight = measuredHeight
5226
+ self.invalidateIntrinsicContentSize()
5227
+ self.onHeightMayChange?(measuredHeight)
5228
+ }
5229
+ textView.alpha = 0.01
5758
5230
  addSubview(textView)
5231
+ addSubview(blockSurface)
5759
5232
  addSubview(remoteSelectionOverlayView)
5760
5233
  addSubview(taskListMarkerTapOverlayView)
5761
5234
  addSubview(imageTapOverlayView)
@@ -5821,11 +5294,15 @@ final class RichTextEditorView: UIView {
5821
5294
  textView.font = font
5822
5295
  textView.textColor = textColor
5823
5296
  textView.backgroundColor = backgroundColor
5297
+ blockSurface.baseFont = font
5298
+ blockSurface.baseTextColor = textColor
5299
+ blockSurface.baseBackgroundColor = backgroundColor
5824
5300
  }
5825
5301
 
5826
5302
  @discardableResult
5827
5303
  func applyTheme(_ theme: EditorTheme?) -> Bool {
5828
5304
  guard textView.applyTheme(theme) else { return false }
5305
+ blockSurface.applyTheme(theme)
5829
5306
  let cornerRadius = theme?.borderRadius ?? 0
5830
5307
  layer.cornerRadius = cornerRadius
5831
5308
  clipsToBounds = cornerRadius > 0
@@ -5835,22 +5312,31 @@ final class RichTextEditorView: UIView {
5835
5312
 
5836
5313
  func setRemoteSelections(_ selections: [RemoteSelectionDecoration]) {
5837
5314
  remoteSelections = selections
5315
+ layoutManagedSubviews()
5316
+ blockSurface.layoutIfNeeded()
5838
5317
  remoteSelectionOverlayView.update(
5839
5318
  selections: selections,
5840
5319
  editorId: editorId
5841
5320
  )
5842
5321
  }
5843
5322
 
5323
+ func setEditable(_ editable: Bool) {
5324
+ textView.isEditable = editable
5325
+ blockSurface.isEditable = editable
5326
+ if !editable {
5327
+ textView.resignFirstResponder()
5328
+ }
5329
+ }
5330
+
5844
5331
  func refreshRemoteSelections() {
5845
5332
  guard remoteSelectionOverlayView.hasSelectionsOrVisibleDecorations else { return }
5846
5333
  remoteSelectionOverlayView.refresh()
5847
5334
  }
5848
5335
 
5849
5336
  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)
5337
+ blockSurface.isInputFocused = textView.isFirstResponder
5338
+ guard let rect = blockSurface.selectedCaretRect() else { return nil }
5339
+ return blockSurface.convert(rect, to: self)
5854
5340
  }
5855
5341
 
5856
5342
  func remoteSelectionOverlaySubviewsForTesting() -> [UIView] {
@@ -5899,8 +5385,38 @@ final class RichTextEditorView: UIView {
5899
5385
  imageResizeOverlayView.visibleRectForTesting
5900
5386
  }
5901
5387
 
5388
+ func blockSurfaceImageRectForTesting() -> CGRect? {
5389
+ guard let geometry = blockSurface.selectedImageGeometry()
5390
+ ?? blockSurface.firstImageGeometryForTesting()
5391
+ else { return nil }
5392
+ return blockSurface.convert(geometry.rect, to: self)
5393
+ }
5394
+
5395
+ func blockSurfaceTopLevelViewForTesting(at index: Int) -> UIView? {
5396
+ blockSurface.topLevelViewForTesting(at: index)
5397
+ }
5398
+
5399
+ func blockSurfaceTopLevelViewCountForTesting() -> Int {
5400
+ blockSurface.topLevelViewCountForTesting
5401
+ }
5402
+
5403
+ func blockSurfaceFirstTextAttributesForTesting() -> [NSAttributedString.Key: Any]? {
5404
+ blockSurface.firstTextAttributesForTesting()
5405
+ }
5406
+
5407
+ func blockSurfaceCaretRectForTesting(docPosition: UInt32) -> CGRect? {
5408
+ guard let rect = blockSurface.caretRect(forDocPosition: docPosition) else { return nil }
5409
+ return blockSurface.convert(rect, to: self)
5410
+ }
5411
+
5412
+ func blockSurfaceTopLevelRectForTesting(at index: Int) -> CGRect? {
5413
+ guard let child = blockSurface.topLevelViewForTesting(at: index) else { return nil }
5414
+ return child.convert(child.bounds, to: self)
5415
+ }
5416
+
5902
5417
  func imageTapOverlayInterceptsPointForTesting(_ point: CGPoint) -> Bool {
5903
- imageTapOverlayView.interceptsPointForTesting(convert(point, to: imageTapOverlayView))
5418
+ !imageTapOverlayView.isHidden
5419
+ && imageTapOverlayView.interceptsPointForTesting(convert(point, to: imageTapOverlayView))
5904
5420
  }
5905
5421
 
5906
5422
  func taskListMarkerTapOverlayInterceptsPointForTesting(_ point: CGPoint) -> Bool {
@@ -5934,7 +5450,7 @@ final class RichTextEditorView: UIView {
5934
5450
  }
5935
5451
 
5936
5452
  func maximumImageWidthForTesting() -> CGFloat {
5937
- textView.maximumRenderableImageWidth()
5453
+ blockSurface.maximumImageWidth()
5938
5454
  }
5939
5455
 
5940
5456
  func resizeSelectedImageForTesting(width: CGFloat, height: CGFloat) {
@@ -5955,7 +5471,7 @@ final class RichTextEditorView: UIView {
5955
5471
  func setContent(html: String) {
5956
5472
  guard editorId != 0 else { return }
5957
5473
  _ = editorSetHtml(id: editorId, html: html)
5958
- textView.applyUpdateJSON(editorGetCurrentState(id: editorId), notifyDelegate: false)
5474
+ applyFullStateToInputAndSurface(editorGetCurrentState(id: editorId))
5959
5475
  }
5960
5476
 
5961
5477
  /// Set initial content from ProseMirror JSON.
@@ -5964,7 +5480,14 @@ final class RichTextEditorView: UIView {
5964
5480
  func setContent(json: String) {
5965
5481
  guard editorId != 0 else { return }
5966
5482
  _ = editorSetJson(id: editorId, json: json)
5967
- textView.applyUpdateJSON(editorGetCurrentState(id: editorId), notifyDelegate: false)
5483
+ applyFullStateToInputAndSurface(editorGetCurrentState(id: editorId))
5484
+ }
5485
+
5486
+ private func applyFullStateToInputAndSurface(_ stateJSON: String) {
5487
+ isSyncingBlockSurfaceUpdateToInputAdapter = true
5488
+ textView.applyUpdateJSON(stateJSON, notifyDelegate: false)
5489
+ isSyncingBlockSurfaceUpdateToInputAdapter = false
5490
+ blockSurface.applyUpdateJSON(stateJSON, editorId: editorId)
5968
5491
  }
5969
5492
 
5970
5493
  private func measuredEditorHeight() -> CGFloat {
@@ -6002,6 +5525,9 @@ final class RichTextEditorView: UIView {
6002
5525
  if textView.frame != managedFrame {
6003
5526
  textView.frame = managedFrame
6004
5527
  }
5528
+ if blockSurface.frame != managedFrame {
5529
+ blockSurface.frame = managedFrame
5530
+ }
6005
5531
  if remoteSelectionOverlayView.frame != managedFrame {
6006
5532
  remoteSelectionOverlayView.frame = managedFrame
6007
5533
  }
@@ -6017,19 +5543,21 @@ final class RichTextEditorView: UIView {
6017
5543
  }
6018
5544
 
6019
5545
  fileprivate func selectedImageGeometry() -> (docPos: UInt32, rect: CGRect)? {
6020
- guard let geometry = textView.selectedImageGeometry() else { return nil }
5546
+ guard allowImageResizing, textView.isFirstResponder,
5547
+ let geometry = blockSurface.selectedImageGeometry()
5548
+ else { return nil }
6021
5549
  return (
6022
5550
  docPos: geometry.docPos,
6023
- rect: textView.convert(geometry.rect, to: imageResizeOverlayView)
5551
+ rect: blockSurface.convert(geometry.rect, to: imageResizeOverlayView)
6024
5552
  )
6025
5553
  }
6026
5554
 
6027
5555
  fileprivate func setImageResizePreviewActive(_ active: Bool) {
6028
- textView.setImageResizePreviewActive(active)
5556
+ blockSurface.setImagePreviewActive(active)
6029
5557
  }
6030
5558
 
6031
5559
  fileprivate func imagePreviewForResize(docPos: UInt32) -> UIImage? {
6032
- textView.imagePreviewForDocPos(docPos)
5560
+ blockSurface.imagePreview(docPos: docPos)
6033
5561
  }
6034
5562
 
6035
5563
  fileprivate func imageResizePreviewBackgroundColor() -> UIColor {
@@ -6037,12 +5565,12 @@ final class RichTextEditorView: UIView {
6037
5565
  }
6038
5566
 
6039
5567
  fileprivate func maximumImageWidthForResizeGesture() -> CGFloat {
6040
- textView.maximumRenderableImageWidth()
5568
+ blockSurface.maximumImageWidth()
6041
5569
  }
6042
5570
 
6043
5571
  fileprivate func clampedImageSize(_ size: CGSize, maximumWidth: CGFloat? = nil) -> CGSize {
6044
5572
  let aspectRatio = max(size.width / max(size.height, 1), 0.1)
6045
- let maxWidth = max(48, maximumWidth ?? textView.maximumRenderableImageWidth())
5573
+ let maxWidth = max(48, maximumWidth ?? blockSurface.maximumImageWidth())
6046
5574
  let clampedWidth = min(maxWidth, max(48, size.width))
6047
5575
  let clampedHeight = max(48, clampedWidth / aspectRatio)
6048
5576
  return CGSize(width: clampedWidth, height: clampedHeight)
@@ -6052,7 +5580,7 @@ final class RichTextEditorView: UIView {
6052
5580
  let clampedSize = clampedImageSize(size)
6053
5581
  let width = max(48, Int(clampedSize.width.rounded()))
6054
5582
  let height = max(48, Int(clampedSize.height.rounded()))
6055
- textView.resizeImageAtDocPos(docPos, width: UInt32(width), height: UInt32(height))
5583
+ blockSurface.resizeImage(docPos: docPos, width: UInt32(width), height: UInt32(height))
6056
5584
  }
6057
5585
 
6058
5586
  private func refreshOverlays() {
@@ -6063,6 +5591,10 @@ final class RichTextEditorView: UIView {
6063
5591
  keyPath: .refreshOverlays
6064
5592
  )
6065
5593
  }
5594
+ blockSurface.isInputFocused = textView.isFirstResponder
5595
+ // Overlay geometry must observe the structural surface's final width;
5596
+ // otherwise a first-pass caret can be cached before text wraps.
5597
+ blockSurface.layoutIfNeeded()
6066
5598
  remoteSelectionOverlayView.refresh()
6067
5599
  imageResizeOverlayView.refresh()
6068
5600
  }
@@ -6105,12 +5637,27 @@ final class RichTextEditorView: UIView {
6105
5637
  if imageResizeOverlayView.isOverlayVisible {
6106
5638
  return true
6107
5639
  }
6108
- if textView.selectedImageGeometry() != nil {
5640
+ if blockSurface.selectedImageGeometry() != nil {
6109
5641
  return true
6110
5642
  }
6111
5643
  return false
6112
5644
  }
6113
5645
 
5646
+ fileprivate func blockSurfaceHasImage(at point: CGPoint) -> Bool {
5647
+ blockSurface.hasImage(at: convert(point, to: blockSurface))
5648
+ }
5649
+
5650
+ @discardableResult
5651
+ fileprivate func selectBlockSurfaceImage(at point: CGPoint) -> Bool {
5652
+ guard allowImageResizing else { return false }
5653
+ _ = textView.becomeFirstResponder()
5654
+ let didSelect = blockSurface.selectImage(at: convert(point, to: blockSurface))
5655
+ if didSelect {
5656
+ refreshOverlays()
5657
+ }
5658
+ return didSelect
5659
+ }
5660
+
6114
5661
  private enum HostedLayoutTraceKey {
6115
5662
  case intrinsicContentSize
6116
5663
  case measuredEditorHeight