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

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 (26) hide show
  1. package/README.md +5 -9
  2. package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +23 -0
  3. package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +2 -2
  4. package/android/src/main/java/com/apollohg/editor/NativeBlockEditorSurface.kt +476 -53
  5. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +110 -169
  6. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +1 -1
  7. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +124 -337
  8. package/dist/EditorTheme.d.ts +8 -0
  9. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  10. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  11. package/ios/Generated_editor_core.swift +15 -0
  12. package/ios/NativeBlockEditorSurface.swift +371 -60
  13. package/ios/NativeEditorExpoView.swift +114 -117
  14. package/ios/NativeInputSupport.swift +0 -302
  15. package/ios/PositionBridge.swift +22 -548
  16. package/ios/RichTextEditorView.swift +217 -4671
  17. package/ios/editor_coreFFI/editor_coreFFI.h +11 -0
  18. package/package.json +1 -1
  19. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  20. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  21. package/rust/android/x86_64/libeditor_core.so +0 -0
  22. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +31 -0
  23. package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +0 -50
  24. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +0 -4068
  25. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +0 -1009
  26. package/android/src/main/java/com/apollohg/editor/InputSnapshotSupport.kt +0 -6
@@ -1,6 +1,6 @@
1
1
  import UIKit
2
2
 
3
- final class NativeBlockEditorSurface: UIScrollView {
3
+ final class NativeBlockEditorSurface: UIScrollView, UITextViewDelegate, UIGestureRecognizerDelegate {
4
4
  private final class TextLeafView: UITextView {
5
5
  struct PositionSegment {
6
6
  let range: NSRange
@@ -8,49 +8,34 @@ final class NativeBlockEditorSurface: UIScrollView {
8
8
  let docPos: UInt32
9
9
  let docSize: UInt32
10
10
  let isAtom: Bool
11
+ let nodeType: String?
11
12
  }
12
13
 
13
14
  var positionSegments: [PositionSegment] = []
14
- var onActivate: ((UInt32) -> Void)?
15
+ var compositionBaselineText: String?
15
16
  var geometryContainerWidth: CGFloat?
16
17
  private let geometryStorage = NSTextStorage()
17
18
  private let geometryLayoutManager = NSLayoutManager()
18
19
  private let geometryTextContainer = NSTextContainer()
19
20
  private var geometryInitialized = false
20
21
 
21
- // Leaves expose TextKit geometry but never own keyboard focus. The
22
- // shared EditorTextView input coordinator is the sole first responder.
23
- override var canBecomeFirstResponder: Bool { false }
24
-
25
22
  override var intrinsicContentSize: CGSize {
26
23
  let size = sizeThatFits(CGSize(width: bounds.width > 0 ? bounds.width : 320, height: .greatestFiniteMagnitude))
27
24
  return CGSize(width: UIView.noIntrinsicMetric, height: max(36, ceil(size.height)))
28
25
  }
29
26
 
30
- @objc func activate(_ gesture: UITapGestureRecognizer) {
31
- let point = gesture.location(in: self)
32
- var fraction: CGFloat = 0
33
- let glyphIndex = layoutManager.glyphIndex(
34
- for: point,
35
- in: textContainer,
36
- fractionOfDistanceThroughGlyph: &fraction
37
- )
38
- let utf16Offset = min(
39
- layoutManager.characterIndexForGlyph(at: glyphIndex) + (fraction >= 0.5 ? 1 : 0),
40
- textStorage.length
41
- )
27
+ func docPosition(forNativeOffset utf16Offset: Int) -> UInt32? {
42
28
  guard let segment = positionSegments.first(where: {
43
29
  utf16Offset >= $0.range.location && utf16Offset <= NSMaxRange($0.range)
44
- }) ?? positionSegments.last else { return }
30
+ }) ?? positionSegments.last else { return nil }
45
31
  if segment.isAtom {
46
32
  let after = utf16Offset > segment.range.location + segment.range.length / 2
47
- onActivate?(segment.docPos + (after ? segment.docSize : 0))
48
- return
33
+ return segment.docPos + (after ? segment.docSize : 0)
49
34
  }
50
35
  let localUtf16 = max(0, min(utf16Offset - segment.range.location, segment.text.utf16.count))
51
36
  let index = String.Index(utf16Offset: localUtf16, in: segment.text)
52
37
  let scalarOffset = segment.text[..<index].unicodeScalars.count
53
- onActivate?(segment.docPos + UInt32(scalarOffset))
38
+ return segment.docPos + UInt32(scalarOffset)
54
39
  }
55
40
 
56
41
  func nativeOffset(forDocPosition docPosition: UInt32) -> Int? {
@@ -231,7 +216,7 @@ final class NativeBlockEditorSurface: UIScrollView {
231
216
  private var textLeafViews: [TextLeafView] = []
232
217
  private var selectedNodePos: UInt32?
233
218
  private var selectedTextPos: UInt32?
234
- private let caretView = UIView()
219
+ private var isApplyingEditorUpdate = false
235
220
 
236
221
  var placeholder: String = "" {
237
222
  didSet {
@@ -248,18 +233,94 @@ final class NativeBlockEditorSurface: UIScrollView {
248
233
  }
249
234
  }
250
235
  var onAppliedUpdate: ((String) -> Void)?
251
- var onRequestTextInput: ((UInt32) -> Void)?
236
+ var onFocusChange: ((Bool) -> Void)?
237
+ private var configuredInputAccessoryView: UIView?
238
+ private var configuredAutocapitalizationType: UITextAutocapitalizationType = .sentences
239
+ private var configuredAutocorrectionType: UITextAutocorrectionType = .default
240
+ private var configuredKeyboardType: UIKeyboardType = .default
241
+ private var configuredKeyboardAppearance: UIKeyboardAppearance = .default
252
242
  var isEditable = true {
253
243
  didSet {
244
+ textLeafViews.forEach {
245
+ $0.isEditable = isEditable
246
+ $0.isSelectable = isEditable
247
+ }
254
248
  refreshImageSelection()
255
- refreshCaret()
256
249
  }
257
250
  }
258
- var isInputFocused = false {
259
- didSet { refreshCaret() }
260
- }
261
251
  var onContentHeightMayChange: ((CGFloat) -> Void)?
262
252
 
253
+ var activeTextView: UITextView? {
254
+ textLeafViews.first(where: { $0.isFirstResponder })
255
+ ?? textLeafViews.first(where: { leaf in
256
+ guard let selectedTextPos else { return false }
257
+ return leaf.nativeOffset(forDocPosition: selectedTextPos) != nil
258
+ })
259
+ ?? textLeafViews.first
260
+ }
261
+
262
+ func activeDocPosition(forUTF16Offset offset: Int) -> UInt32? {
263
+ guard let leaf = activeTextView as? TextLeafView else { return nil }
264
+ return leaf.docPosition(forNativeOffset: offset)
265
+ }
266
+
267
+ func activeInlineAtomType(atUTF16Offset offset: Int) -> String? {
268
+ guard let leaf = activeTextView as? TextLeafView else { return nil }
269
+ return leaf.positionSegments.first(where: {
270
+ $0.isAtom && offset >= $0.range.location && offset <= NSMaxRange($0.range)
271
+ })?.nodeType
272
+ }
273
+
274
+ var activeDocSelection: (anchor: UInt32, head: UInt32)? {
275
+ guard let leaf = activeTextView as? TextLeafView,
276
+ let range = leaf.selectedTextRange
277
+ else { return nil }
278
+ let start = leaf.offset(from: leaf.beginningOfDocument, to: range.start)
279
+ let end = leaf.offset(from: leaf.beginningOfDocument, to: range.end)
280
+ guard let anchor = leaf.docPosition(forNativeOffset: start),
281
+ let head = leaf.docPosition(forNativeOffset: end)
282
+ else { return nil }
283
+ return (anchor, head)
284
+ }
285
+
286
+ @discardableResult
287
+ func focus() -> Bool {
288
+ activeTextView?.becomeFirstResponder() ?? false
289
+ }
290
+
291
+ func blur() {
292
+ activeTextView?.resignFirstResponder()
293
+ }
294
+
295
+ func setInputAccessoryView(_ view: UIView?) {
296
+ configuredInputAccessoryView = view
297
+ textLeafViews.forEach { $0.inputAccessoryView = view }
298
+ activeTextView?.reloadInputViews()
299
+ }
300
+
301
+ func configureInputTraits(
302
+ autocapitalizationType: UITextAutocapitalizationType? = nil,
303
+ autocorrectionType: UITextAutocorrectionType? = nil,
304
+ keyboardType: UIKeyboardType? = nil,
305
+ keyboardAppearance: UIKeyboardAppearance? = nil
306
+ ) {
307
+ if let autocapitalizationType { configuredAutocapitalizationType = autocapitalizationType }
308
+ if let autocorrectionType { configuredAutocorrectionType = autocorrectionType }
309
+ if let keyboardType { configuredKeyboardType = keyboardType }
310
+ if let keyboardAppearance { configuredKeyboardAppearance = keyboardAppearance }
311
+ textLeafViews.forEach(applyInputConfiguration)
312
+ activeTextView?.reloadInputViews()
313
+ }
314
+
315
+ private func applyInputConfiguration(to leaf: TextLeafView) {
316
+ leaf.inputAccessoryView = configuredInputAccessoryView
317
+ leaf.autocapitalizationType = configuredAutocapitalizationType
318
+ leaf.autocorrectionType = configuredAutocorrectionType
319
+ leaf.spellCheckingType = configuredAutocorrectionType == .no ? .no : .default
320
+ leaf.keyboardType = configuredKeyboardType
321
+ leaf.keyboardAppearance = configuredKeyboardAppearance
322
+ }
323
+
263
324
  override init(frame: CGRect) {
264
325
  super.init(frame: frame)
265
326
  setup()
@@ -283,11 +344,6 @@ final class NativeBlockEditorSurface: UIScrollView {
283
344
  placeholderLabel.isHidden = true
284
345
  placeholderLabel.layer.zPosition = 1
285
346
  addSubview(placeholderLabel)
286
- caretView.backgroundColor = .systemBlue
287
- caretView.isUserInteractionEnabled = false
288
- caretView.layer.cornerRadius = 1
289
- caretView.isHidden = true
290
- addSubview(caretView)
291
347
  }
292
348
 
293
349
  override func layoutSubviews() {
@@ -321,7 +377,6 @@ final class NativeBlockEditorSurface: UIScrollView {
321
377
  ).height
322
378
  )
323
379
  contentSize = CGSize(width: bounds.width, height: stack.frame.maxY + bottomInset)
324
- refreshCaret()
325
380
  onContentHeightMayChange?(contentSize.height)
326
381
  }
327
382
 
@@ -351,19 +406,30 @@ final class NativeBlockEditorSurface: UIScrollView {
351
406
  guard let data = updateJSON.data(using: .utf8),
352
407
  let update = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
353
408
  else { return }
409
+ let shouldPreserveTextFocus = textLeafViews.contains(where: \.isFirstResponder)
410
+ isApplyingEditorUpdate = true
411
+ defer { isApplyingEditorUpdate = false }
354
412
 
413
+ var textSelection: (UInt32, UInt32)?
355
414
  if let selection = update["selection"] as? [String: Any] {
356
415
  let type = selection["type"] as? String
357
416
  selectedNodePos = type == "node" ? (selection["pos"] as? NSNumber)?.uint32Value : nil
358
417
  let anchor = (selection["anchor"] as? NSNumber)?.uint32Value
359
418
  let head = (selection["head"] as? NSNumber)?.uint32Value
360
419
  selectedTextPos = type == "text" && anchor == head ? anchor : nil
420
+ if type == "text", let anchor, let head {
421
+ textSelection = (anchor, head)
422
+ }
361
423
  refreshImageSelection()
362
- refreshCaret()
363
424
  }
364
425
  guard let renderTree = update["renderTree"] as? [String: Any],
365
426
  let root = renderTree["root"] as? [String: Any]
366
- else { return }
427
+ else {
428
+ if let textSelection {
429
+ applyNativeSelection(anchor: textSelection.0, head: textSelection.1, preservingFocus: shouldPreserveTextFocus)
430
+ }
431
+ return
432
+ }
367
433
 
368
434
  currentEditorId = editorId
369
435
  isApplyingRemoteUpdate = true
@@ -375,10 +441,12 @@ final class NativeBlockEditorSurface: UIScrollView {
375
441
  }
376
442
  refreshTableCellRegistry()
377
443
  refreshImageSelection()
378
- refreshCaret()
379
444
  currentRoot = root
380
445
  refreshPlaceholder()
381
446
  isApplyingRemoteUpdate = false
447
+ if let textSelection {
448
+ applyNativeSelection(anchor: textSelection.0, head: textSelection.1, preservingFocus: shouldPreserveTextFocus)
449
+ }
382
450
  setNeedsLayout()
383
451
  layoutIfNeeded()
384
452
  }
@@ -486,6 +554,10 @@ final class NativeBlockEditorSurface: UIScrollView {
486
554
  rebuildAll(from: root)
487
555
  return
488
556
  }
557
+ if let leaf = oldView as? TextLeafView {
558
+ configureTextLeaf(leaf, for: node)
559
+ return
560
+ }
489
561
  guard let parent = oldView.superview else {
490
562
  rebuildAll(from: root)
491
563
  return
@@ -521,7 +593,7 @@ final class NativeBlockEditorSurface: UIScrollView {
521
593
  let blockPos = (node["docPos"] as? NSNumber)?.uint32Value ?? 0
522
594
  let blockSize = (node["docSize"] as? NSNumber)?.uint32Value ?? 0
523
595
  leaf.positionSegments = [
524
- .init(range: NSRange(location: 0, length: 0), text: "", docPos: blockPos, docSize: blockSize, isAtom: false),
596
+ .init(range: NSRange(location: 0, length: 0), text: "", docPos: blockPos, docSize: blockSize, isAtom: false, nodeType: nil),
525
597
  ]
526
598
  } else {
527
599
  leaf.positionSegments = segments
@@ -713,8 +785,6 @@ final class NativeBlockEditorSurface: UIScrollView {
713
785
  row.alignment = .top
714
786
  row.spacing = 8
715
787
 
716
- row.addArrangedSubview(markerView(for: node, parentListType: parentListType, index: index))
717
-
718
788
  let content = UIStackView()
719
789
  content.axis = .vertical
720
790
  content.spacing = 4
@@ -724,11 +794,20 @@ final class NativeBlockEditorSurface: UIScrollView {
724
794
  if content.arrangedSubviews.isEmpty {
725
795
  content.addArrangedSubview(textBlockView(for: node, depth: depth + 1))
726
796
  }
797
+ if parentListType == "toggleList" || parentListType == "toggle_list" {
798
+ row.addArrangedSubview(toggleMarkerView(for: node, content: content))
799
+ setToggleContent(content, open: boolAttr(node, "open") ?? true)
800
+ } else {
801
+ row.addArrangedSubview(markerView(for: node, parentListType: parentListType, index: index))
802
+ }
727
803
  row.addArrangedSubview(content)
728
804
  return row
729
805
  }
730
806
 
731
807
  private func blockContainerView(for node: [String: Any], depth: Int, path: [Int]) -> UIView {
808
+ if (node["nodeType"] as? String) == "callout" {
809
+ return calloutView(for: node, depth: depth, path: path)
810
+ }
732
811
  let container = UIStackView()
733
812
  container.axis = .vertical
734
813
  container.spacing = 4
@@ -762,6 +841,96 @@ final class NativeBlockEditorSurface: UIScrollView {
762
841
  return container
763
842
  }
764
843
 
844
+ private func calloutView(for node: [String: Any], depth: Int, path: [Int]) -> UIView {
845
+ let tone = stringAttr(node, "tone") ?? "info"
846
+ let accent: UIColor = switch tone {
847
+ case "success": .systemGreen
848
+ case "warning": .systemOrange
849
+ case "danger": .systemRed
850
+ default: .systemBlue
851
+ }
852
+ let container = UIStackView()
853
+ container.axis = .vertical
854
+ container.spacing = 8
855
+ container.layoutMargins = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
856
+ container.isLayoutMarginsRelativeArrangement = true
857
+ container.backgroundColor = accent.withAlphaComponent(0.1)
858
+ container.layer.borderColor = accent.withAlphaComponent(0.55).cgColor
859
+ container.layer.borderWidth = 1
860
+ container.layer.cornerRadius = 10
861
+
862
+ let toneButton = UIButton(type: .system)
863
+ toneButton.setTitle(tone.capitalized, for: .normal)
864
+ toneButton.setTitleColor(accent, for: .normal)
865
+ toneButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .semibold)
866
+ toneButton.contentHorizontalAlignment = .leading
867
+ toneButton.isEnabled = isEditable
868
+ toneButton.addAction(UIAction { [weak self] _ in
869
+ self?.cycleCalloutTone(node)
870
+ }, for: .touchUpInside)
871
+ container.addArrangedSubview(toneButton)
872
+
873
+ for (index, child) in (node["children"] as? [[String: Any]] ?? []).enumerated() {
874
+ container.addArrangedSubview(view(for: child, depth: depth + 1, path: path + [index]))
875
+ }
876
+ return container
877
+ }
878
+
879
+ private func toggleMarkerView(for node: [String: Any], content: UIStackView) -> UIView {
880
+ let open = boolAttr(node, "open") ?? true
881
+ let button = UIButton(type: .system)
882
+ button.setTitle(open ? "⌄" : "›", for: .normal)
883
+ button.titleLabel?.font = .systemFont(ofSize: 18, weight: .semibold)
884
+ button.accessibilityLabel = open ? "Collapse toggle item" : "Expand toggle item"
885
+ button.addAction(UIAction { [weak self, weak button, weak content] _ in
886
+ guard let self, let button, let content else { return }
887
+ let nextOpen = button.title(for: .normal) != "⌄"
888
+ button.setTitle(nextOpen ? "⌄" : "›", for: .normal)
889
+ button.accessibilityLabel = nextOpen ? "Collapse toggle item" : "Expand toggle item"
890
+ self.setToggleContent(content, open: nextOpen)
891
+ if self.isEditable {
892
+ self.updateNodeAttrs(node, patch: ["open": nextOpen])
893
+ }
894
+ }, for: .touchUpInside)
895
+ let wrapper = UIView()
896
+ wrapper.widthAnchor.constraint(equalToConstant: 24).isActive = true
897
+ wrapper.heightAnchor.constraint(equalToConstant: 24).isActive = true
898
+ button.translatesAutoresizingMaskIntoConstraints = false
899
+ wrapper.addSubview(button)
900
+ NSLayoutConstraint.activate([
901
+ button.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor),
902
+ button.centerYAnchor.constraint(equalTo: wrapper.centerYAnchor),
903
+ ])
904
+ return wrapper
905
+ }
906
+
907
+ private func setToggleContent(_ content: UIStackView, open: Bool) {
908
+ for (index, child) in content.arrangedSubviews.enumerated() where index > 0 {
909
+ child.isHidden = !open
910
+ }
911
+ }
912
+
913
+ private func cycleCalloutTone(_ node: [String: Any]) {
914
+ guard isEditable else { return }
915
+ let tones = ["info", "success", "warning", "danger"]
916
+ let current = stringAttr(node, "tone") ?? "info"
917
+ let next = tones[((tones.firstIndex(of: current) ?? 0) + 1) % tones.count]
918
+ updateNodeAttrs(node, patch: ["tone": next])
919
+ }
920
+
921
+ private func updateNodeAttrs(_ node: [String: Any], patch: [String: Any]) {
922
+ guard currentEditorId != 0 else { return }
923
+ var attrs = node["attrs"] as? [String: Any] ?? [:]
924
+ attrs.merge(patch) { _, next in next }
925
+ guard let data = try? JSONSerialization.data(withJSONObject: attrs),
926
+ let json = String(data: data, encoding: .utf8)
927
+ else { return }
928
+ let pos = (node["docPos"] as? NSNumber)?.uint32Value ?? 0
929
+ let updateJSON = editorUpdateNodeAttrs(id: currentEditorId, pos: pos, attrsJson: json)
930
+ applyUpdateJSON(updateJSON, editorId: currentEditorId)
931
+ onAppliedUpdate?(updateJSON)
932
+ }
933
+
765
934
  func blockquoteStripeRectsForTesting() -> [CGRect] {
766
935
  layoutIfNeeded()
767
936
  return descendants(of: self)
@@ -849,6 +1018,7 @@ final class NativeBlockEditorSurface: UIScrollView {
849
1018
  cell.backgroundColor = cell.baseFillColor
850
1019
  cell.isUserInteractionEnabled = true
851
1020
  let tap = UITapGestureRecognizer(target: self, action: #selector(handleCellTap(_:)))
1021
+ tap.delegate = self
852
1022
  cell.addGestureRecognizer(tap)
853
1023
  tableCells.append(cell)
854
1024
  for (index, child) in (node["children"] as? [[String: Any]] ?? []).enumerated() {
@@ -860,8 +1030,25 @@ final class NativeBlockEditorSurface: UIScrollView {
860
1030
  return cell
861
1031
  }
862
1032
 
1033
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
1034
+ guard gestureRecognizer.view is CellView else { return true }
1035
+ var touchedView: UIView? = touch.view
1036
+ while let view = touchedView, view !== gestureRecognizer.view {
1037
+ if view is UITextView || view is UIControl { return false }
1038
+ touchedView = view.superview
1039
+ }
1040
+ return true
1041
+ }
1042
+
863
1043
  private func textBlockView(for node: [String: Any], depth: Int) -> UIView {
864
1044
  let leaf = TextLeafView()
1045
+ configureTextLeaf(leaf, for: node)
1046
+ return leaf
1047
+ }
1048
+
1049
+ private func configureTextLeaf(_ leaf: TextLeafView, for node: [String: Any]) {
1050
+ let wasFirstResponder = leaf.isFirstResponder
1051
+ let previousSelection = leaf.selectedRange
865
1052
  leaf.font = font(for: node)
866
1053
  let nodeType = node["nodeType"] as? String ?? "paragraph"
867
1054
  let textStyle = currentTheme?.effectiveTextStyle(for: nodeType)
@@ -896,9 +1083,10 @@ final class NativeBlockEditorSurface: UIScrollView {
896
1083
  .init(
897
1084
  range: NSRange(location: 0, length: 0),
898
1085
  text: "",
899
- docPos: blockPos,
900
- docSize: blockSize,
901
- isAtom: false
1086
+ docPos: blockPos + (blockSize >= 2 ? 1 : 0),
1087
+ docSize: 0,
1088
+ isAtom: false,
1089
+ nodeType: nil
902
1090
  ),
903
1091
  ]
904
1092
  }
@@ -917,16 +1105,17 @@ final class NativeBlockEditorSurface: UIScrollView {
917
1105
  leaf.textContainerInset = .zero
918
1106
  }
919
1107
  leaf.isScrollEnabled = false
920
- leaf.isEditable = false
921
- leaf.isSelectable = false
922
- leaf.textContainer.widthTracksTextView = false
1108
+ leaf.isEditable = isEditable
1109
+ leaf.isSelectable = isEditable
1110
+ leaf.textContainer.widthTracksTextView = true
923
1111
  leaf.textContainer.lineFragmentPadding = 0
924
- leaf.onActivate = { [weak self] docPos in
925
- guard let self, self.isEditable else { return }
926
- self.onRequestTextInput?(docPos)
1112
+ leaf.delegate = self
1113
+ applyInputConfiguration(to: leaf)
1114
+ if wasFirstResponder {
1115
+ let location = min(previousSelection.location, leaf.textStorage.length)
1116
+ let length = min(previousSelection.length, leaf.textStorage.length - location)
1117
+ leaf.selectedRange = NSRange(location: location, length: length)
927
1118
  }
928
- leaf.addGestureRecognizer(UITapGestureRecognizer(target: leaf, action: #selector(TextLeafView.activate(_:))))
929
- return leaf
930
1119
  }
931
1120
 
932
1121
  private func attributedInlineContent(
@@ -953,7 +1142,8 @@ final class NativeBlockEditorSurface: UIScrollView {
953
1142
  text: value,
954
1143
  docPos: docPos,
955
1144
  docSize: docSize,
956
- isAtom: false
1145
+ isAtom: false,
1146
+ nodeType: nil
957
1147
  ))
958
1148
  return
959
1149
  }
@@ -977,7 +1167,8 @@ final class NativeBlockEditorSurface: UIScrollView {
977
1167
  text: value,
978
1168
  docPos: docPos,
979
1169
  docSize: max(docSize, 1),
980
- isAtom: true
1170
+ isAtom: true,
1171
+ nodeType: nodeType
981
1172
  ))
982
1173
  return
983
1174
  }
@@ -1187,16 +1378,132 @@ final class NativeBlockEditorSurface: UIScrollView {
1187
1378
  }
1188
1379
  }
1189
1380
 
1190
- private func refreshCaret() {
1191
- guard isEditable, isInputFocused, selectedNodePos == nil,
1192
- let rect = selectedCaretRect()
1381
+ private func applyNativeSelection(anchor: UInt32, head: UInt32, preservingFocus: Bool) {
1382
+ let candidates = textLeafViews.filter {
1383
+ $0.nativeOffset(forDocPosition: anchor) != nil &&
1384
+ $0.nativeOffset(forDocPosition: head) != nil
1385
+ }
1386
+ guard let leaf = candidates.first(where: \.isFirstResponder)
1387
+ ?? (anchor == head ? candidates.last : candidates.first),
1388
+ let anchorOffset = leaf.nativeOffset(forDocPosition: anchor),
1389
+ let headOffset = leaf.nativeOffset(forDocPosition: head),
1390
+ let start = leaf.position(
1391
+ from: leaf.beginningOfDocument,
1392
+ offset: min(anchorOffset, headOffset)
1393
+ ),
1394
+ let end = leaf.position(
1395
+ from: leaf.beginningOfDocument,
1396
+ offset: max(anchorOffset, headOffset)
1397
+ ),
1398
+ let range = leaf.textRange(from: start, to: end)
1399
+ else { return }
1400
+ leaf.selectedTextRange = range
1401
+ if preservingFocus, !leaf.isFirstResponder {
1402
+ leaf.becomeFirstResponder()
1403
+ }
1404
+ }
1405
+
1406
+ func textViewDidChangeSelection(_ textView: UITextView) {
1407
+ guard !isApplyingEditorUpdate, isEditable, currentEditorId != 0,
1408
+ let leaf = textView as? TextLeafView,
1409
+ let range = leaf.selectedTextRange
1410
+ else { return }
1411
+ let anchorOffset = leaf.offset(from: leaf.beginningOfDocument, to: range.start)
1412
+ let headOffset = leaf.offset(from: leaf.beginningOfDocument, to: range.end)
1413
+ guard let anchor = leaf.docPosition(forNativeOffset: anchorOffset),
1414
+ let head = leaf.docPosition(forNativeOffset: headOffset)
1415
+ else { return }
1416
+ editorSetSelection(id: currentEditorId, anchor: anchor, head: head)
1417
+ onAppliedUpdate?(editorGetSelectionState(id: currentEditorId))
1418
+ }
1419
+
1420
+ func textViewDidChange(_ textView: UITextView) {
1421
+ guard !isApplyingEditorUpdate, isEditable, currentEditorId != 0,
1422
+ let leaf = textView as? TextLeafView,
1423
+ textView.markedTextRange == nil,
1424
+ let baseline = leaf.compositionBaselineText
1425
+ else { return }
1426
+ leaf.compositionBaselineText = nil
1427
+ commitNativeText(in: leaf, baseline: baseline, current: leaf.text ?? "")
1428
+ }
1429
+
1430
+ func textViewDidBeginEditing(_ textView: UITextView) {
1431
+ onFocusChange?(true)
1432
+ }
1433
+
1434
+ func textViewDidEndEditing(_ textView: UITextView) {
1435
+ if !textLeafViews.contains(where: { $0.isFirstResponder }) {
1436
+ onFocusChange?(false)
1437
+ }
1438
+ }
1439
+
1440
+ func textView(
1441
+ _ textView: UITextView,
1442
+ shouldChangeTextIn range: NSRange,
1443
+ replacementText text: String
1444
+ ) -> Bool {
1445
+ guard !isApplyingEditorUpdate, isEditable, currentEditorId != 0,
1446
+ let leaf = textView as? TextLeafView
1447
+ else { return false }
1448
+
1449
+ if text.isEmpty, range.length == 0, range.location == 0,
1450
+ let docPosition = leaf.docPosition(forNativeOffset: 0) {
1451
+ let scalar = editorDocToScalar(id: currentEditorId, docPos: docPosition)
1452
+ let update = editorDeleteBackwardAtSelectionScalar(
1453
+ id: currentEditorId,
1454
+ scalarAnchor: scalar,
1455
+ scalarHead: scalar
1456
+ )
1457
+ applyUpdateJSON(update, editorId: currentEditorId)
1458
+ onAppliedUpdate?(update)
1459
+ return false
1460
+ }
1461
+
1462
+ if text == "\n" {
1463
+ guard let anchor = leaf.docPosition(forNativeOffset: range.location),
1464
+ let head = leaf.docPosition(forNativeOffset: NSMaxRange(range))
1465
+ else { return false }
1466
+ editorSetSelection(id: currentEditorId, anchor: anchor, head: head)
1467
+ if anchor != head {
1468
+ _ = editorReplaceSelectionText(id: currentEditorId, text: "")
1469
+ }
1470
+ let update = editorSplitBlock(id: currentEditorId, pos: anchor)
1471
+ applyUpdateJSON(update, editorId: currentEditorId)
1472
+ onAppliedUpdate?(update)
1473
+ return false
1474
+ }
1475
+
1476
+ if leaf.compositionBaselineText == nil {
1477
+ leaf.compositionBaselineText = leaf.text ?? ""
1478
+ }
1479
+ return true
1480
+ }
1481
+
1482
+ private func commitNativeText(in leaf: TextLeafView, baseline: String, current: String) {
1483
+ guard baseline != current else { return }
1484
+ let old = baseline as NSString
1485
+ let new = current as NSString
1486
+ var prefix = 0
1487
+ while prefix < old.length, prefix < new.length,
1488
+ old.character(at: prefix) == new.character(at: prefix) { prefix += 1 }
1489
+ var oldSuffix = old.length
1490
+ var newSuffix = new.length
1491
+ while oldSuffix > prefix, newSuffix > prefix,
1492
+ old.character(at: oldSuffix - 1) == new.character(at: newSuffix - 1) {
1493
+ oldSuffix -= 1
1494
+ newSuffix -= 1
1495
+ }
1496
+ guard let anchor = leaf.docPosition(forNativeOffset: prefix),
1497
+ let head = leaf.docPosition(forNativeOffset: oldSuffix)
1193
1498
  else {
1194
- caretView.isHidden = true
1499
+ applyUpdateJSON(editorGetCurrentState(id: currentEditorId), editorId: currentEditorId)
1195
1500
  return
1196
1501
  }
1197
- caretView.frame = CGRect(x: rect.minX, y: rect.minY, width: 2, height: rect.height)
1198
- caretView.isHidden = false
1199
- bringSubviewToFront(caretView)
1502
+ let replacement = new.substring(with: NSRange(location: prefix, length: newSuffix - prefix))
1503
+ editorSetSelection(id: currentEditorId, anchor: anchor, head: head)
1504
+ let update = editorReplaceSelectionText(id: currentEditorId, text: replacement)
1505
+ applyUpdateJSON(update, editorId: currentEditorId)
1506
+ onAppliedUpdate?(update)
1200
1507
  }
1201
1508
 
1202
1509
  func firstImageGeometryForTesting() -> (docPos: UInt32, rect: CGRect)? {
@@ -1339,6 +1646,10 @@ final class NativeBlockEditorSurface: UIScrollView {
1339
1646
  return attrs[key] as? Bool
1340
1647
  }
1341
1648
 
1649
+ private func stringAttr(_ node: [String: Any], _ key: String) -> String? {
1650
+ (node["attrs"] as? [String: Any])?[key] as? String
1651
+ }
1652
+
1342
1653
  private func isCodeBlock(_ node: [String: Any]) -> Bool {
1343
1654
  let nodeType = node["nodeType"] as? String
1344
1655
  return nodeType == "codeBlock" || nodeType == "code_block"