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

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.
@@ -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
@@ -849,6 +921,7 @@ final class NativeBlockEditorSurface: UIScrollView {
849
921
  cell.backgroundColor = cell.baseFillColor
850
922
  cell.isUserInteractionEnabled = true
851
923
  let tap = UITapGestureRecognizer(target: self, action: #selector(handleCellTap(_:)))
924
+ tap.delegate = self
852
925
  cell.addGestureRecognizer(tap)
853
926
  tableCells.append(cell)
854
927
  for (index, child) in (node["children"] as? [[String: Any]] ?? []).enumerated() {
@@ -860,8 +933,25 @@ final class NativeBlockEditorSurface: UIScrollView {
860
933
  return cell
861
934
  }
862
935
 
936
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
937
+ guard gestureRecognizer.view is CellView else { return true }
938
+ var touchedView: UIView? = touch.view
939
+ while let view = touchedView, view !== gestureRecognizer.view {
940
+ if view is UITextView || view is UIControl { return false }
941
+ touchedView = view.superview
942
+ }
943
+ return true
944
+ }
945
+
863
946
  private func textBlockView(for node: [String: Any], depth: Int) -> UIView {
864
947
  let leaf = TextLeafView()
948
+ configureTextLeaf(leaf, for: node)
949
+ return leaf
950
+ }
951
+
952
+ private func configureTextLeaf(_ leaf: TextLeafView, for node: [String: Any]) {
953
+ let wasFirstResponder = leaf.isFirstResponder
954
+ let previousSelection = leaf.selectedRange
865
955
  leaf.font = font(for: node)
866
956
  let nodeType = node["nodeType"] as? String ?? "paragraph"
867
957
  let textStyle = currentTheme?.effectiveTextStyle(for: nodeType)
@@ -896,9 +986,10 @@ final class NativeBlockEditorSurface: UIScrollView {
896
986
  .init(
897
987
  range: NSRange(location: 0, length: 0),
898
988
  text: "",
899
- docPos: blockPos,
900
- docSize: blockSize,
901
- isAtom: false
989
+ docPos: blockPos + (blockSize >= 2 ? 1 : 0),
990
+ docSize: 0,
991
+ isAtom: false,
992
+ nodeType: nil
902
993
  ),
903
994
  ]
904
995
  }
@@ -917,16 +1008,17 @@ final class NativeBlockEditorSurface: UIScrollView {
917
1008
  leaf.textContainerInset = .zero
918
1009
  }
919
1010
  leaf.isScrollEnabled = false
920
- leaf.isEditable = false
921
- leaf.isSelectable = false
922
- leaf.textContainer.widthTracksTextView = false
1011
+ leaf.isEditable = isEditable
1012
+ leaf.isSelectable = isEditable
1013
+ leaf.textContainer.widthTracksTextView = true
923
1014
  leaf.textContainer.lineFragmentPadding = 0
924
- leaf.onActivate = { [weak self] docPos in
925
- guard let self, self.isEditable else { return }
926
- self.onRequestTextInput?(docPos)
1015
+ leaf.delegate = self
1016
+ applyInputConfiguration(to: leaf)
1017
+ if wasFirstResponder {
1018
+ let location = min(previousSelection.location, leaf.textStorage.length)
1019
+ let length = min(previousSelection.length, leaf.textStorage.length - location)
1020
+ leaf.selectedRange = NSRange(location: location, length: length)
927
1021
  }
928
- leaf.addGestureRecognizer(UITapGestureRecognizer(target: leaf, action: #selector(TextLeafView.activate(_:))))
929
- return leaf
930
1022
  }
931
1023
 
932
1024
  private func attributedInlineContent(
@@ -953,7 +1045,8 @@ final class NativeBlockEditorSurface: UIScrollView {
953
1045
  text: value,
954
1046
  docPos: docPos,
955
1047
  docSize: docSize,
956
- isAtom: false
1048
+ isAtom: false,
1049
+ nodeType: nil
957
1050
  ))
958
1051
  return
959
1052
  }
@@ -977,7 +1070,8 @@ final class NativeBlockEditorSurface: UIScrollView {
977
1070
  text: value,
978
1071
  docPos: docPos,
979
1072
  docSize: max(docSize, 1),
980
- isAtom: true
1073
+ isAtom: true,
1074
+ nodeType: nodeType
981
1075
  ))
982
1076
  return
983
1077
  }
@@ -1187,16 +1281,132 @@ final class NativeBlockEditorSurface: UIScrollView {
1187
1281
  }
1188
1282
  }
1189
1283
 
1190
- private func refreshCaret() {
1191
- guard isEditable, isInputFocused, selectedNodePos == nil,
1192
- let rect = selectedCaretRect()
1284
+ private func applyNativeSelection(anchor: UInt32, head: UInt32, preservingFocus: Bool) {
1285
+ let candidates = textLeafViews.filter {
1286
+ $0.nativeOffset(forDocPosition: anchor) != nil &&
1287
+ $0.nativeOffset(forDocPosition: head) != nil
1288
+ }
1289
+ guard let leaf = candidates.first(where: \.isFirstResponder)
1290
+ ?? (anchor == head ? candidates.last : candidates.first),
1291
+ let anchorOffset = leaf.nativeOffset(forDocPosition: anchor),
1292
+ let headOffset = leaf.nativeOffset(forDocPosition: head),
1293
+ let start = leaf.position(
1294
+ from: leaf.beginningOfDocument,
1295
+ offset: min(anchorOffset, headOffset)
1296
+ ),
1297
+ let end = leaf.position(
1298
+ from: leaf.beginningOfDocument,
1299
+ offset: max(anchorOffset, headOffset)
1300
+ ),
1301
+ let range = leaf.textRange(from: start, to: end)
1302
+ else { return }
1303
+ leaf.selectedTextRange = range
1304
+ if preservingFocus, !leaf.isFirstResponder {
1305
+ leaf.becomeFirstResponder()
1306
+ }
1307
+ }
1308
+
1309
+ func textViewDidChangeSelection(_ textView: UITextView) {
1310
+ guard !isApplyingEditorUpdate, isEditable, currentEditorId != 0,
1311
+ let leaf = textView as? TextLeafView,
1312
+ let range = leaf.selectedTextRange
1313
+ else { return }
1314
+ let anchorOffset = leaf.offset(from: leaf.beginningOfDocument, to: range.start)
1315
+ let headOffset = leaf.offset(from: leaf.beginningOfDocument, to: range.end)
1316
+ guard let anchor = leaf.docPosition(forNativeOffset: anchorOffset),
1317
+ let head = leaf.docPosition(forNativeOffset: headOffset)
1318
+ else { return }
1319
+ editorSetSelection(id: currentEditorId, anchor: anchor, head: head)
1320
+ onAppliedUpdate?(editorGetSelectionState(id: currentEditorId))
1321
+ }
1322
+
1323
+ func textViewDidChange(_ textView: UITextView) {
1324
+ guard !isApplyingEditorUpdate, isEditable, currentEditorId != 0,
1325
+ let leaf = textView as? TextLeafView,
1326
+ textView.markedTextRange == nil,
1327
+ let baseline = leaf.compositionBaselineText
1328
+ else { return }
1329
+ leaf.compositionBaselineText = nil
1330
+ commitNativeText(in: leaf, baseline: baseline, current: leaf.text ?? "")
1331
+ }
1332
+
1333
+ func textViewDidBeginEditing(_ textView: UITextView) {
1334
+ onFocusChange?(true)
1335
+ }
1336
+
1337
+ func textViewDidEndEditing(_ textView: UITextView) {
1338
+ if !textLeafViews.contains(where: { $0.isFirstResponder }) {
1339
+ onFocusChange?(false)
1340
+ }
1341
+ }
1342
+
1343
+ func textView(
1344
+ _ textView: UITextView,
1345
+ shouldChangeTextIn range: NSRange,
1346
+ replacementText text: String
1347
+ ) -> Bool {
1348
+ guard !isApplyingEditorUpdate, isEditable, currentEditorId != 0,
1349
+ let leaf = textView as? TextLeafView
1350
+ else { return false }
1351
+
1352
+ if text.isEmpty, range.length == 0, range.location == 0,
1353
+ let docPosition = leaf.docPosition(forNativeOffset: 0) {
1354
+ let scalar = editorDocToScalar(id: currentEditorId, docPos: docPosition)
1355
+ let update = editorDeleteBackwardAtSelectionScalar(
1356
+ id: currentEditorId,
1357
+ scalarAnchor: scalar,
1358
+ scalarHead: scalar
1359
+ )
1360
+ applyUpdateJSON(update, editorId: currentEditorId)
1361
+ onAppliedUpdate?(update)
1362
+ return false
1363
+ }
1364
+
1365
+ if text == "\n" {
1366
+ guard let anchor = leaf.docPosition(forNativeOffset: range.location),
1367
+ let head = leaf.docPosition(forNativeOffset: NSMaxRange(range))
1368
+ else { return false }
1369
+ editorSetSelection(id: currentEditorId, anchor: anchor, head: head)
1370
+ if anchor != head {
1371
+ _ = editorReplaceSelectionText(id: currentEditorId, text: "")
1372
+ }
1373
+ let update = editorSplitBlock(id: currentEditorId, pos: anchor)
1374
+ applyUpdateJSON(update, editorId: currentEditorId)
1375
+ onAppliedUpdate?(update)
1376
+ return false
1377
+ }
1378
+
1379
+ if leaf.compositionBaselineText == nil {
1380
+ leaf.compositionBaselineText = leaf.text ?? ""
1381
+ }
1382
+ return true
1383
+ }
1384
+
1385
+ private func commitNativeText(in leaf: TextLeafView, baseline: String, current: String) {
1386
+ guard baseline != current else { return }
1387
+ let old = baseline as NSString
1388
+ let new = current as NSString
1389
+ var prefix = 0
1390
+ while prefix < old.length, prefix < new.length,
1391
+ old.character(at: prefix) == new.character(at: prefix) { prefix += 1 }
1392
+ var oldSuffix = old.length
1393
+ var newSuffix = new.length
1394
+ while oldSuffix > prefix, newSuffix > prefix,
1395
+ old.character(at: oldSuffix - 1) == new.character(at: newSuffix - 1) {
1396
+ oldSuffix -= 1
1397
+ newSuffix -= 1
1398
+ }
1399
+ guard let anchor = leaf.docPosition(forNativeOffset: prefix),
1400
+ let head = leaf.docPosition(forNativeOffset: oldSuffix)
1193
1401
  else {
1194
- caretView.isHidden = true
1402
+ applyUpdateJSON(editorGetCurrentState(id: currentEditorId), editorId: currentEditorId)
1195
1403
  return
1196
1404
  }
1197
- caretView.frame = CGRect(x: rect.minX, y: rect.minY, width: 2, height: rect.height)
1198
- caretView.isHidden = false
1199
- bringSubviewToFront(caretView)
1405
+ let replacement = new.substring(with: NSRange(location: prefix, length: newSuffix - prefix))
1406
+ editorSetSelection(id: currentEditorId, anchor: anchor, head: head)
1407
+ let update = editorReplaceSelectionText(id: currentEditorId, text: replacement)
1408
+ applyUpdateJSON(update, editorId: currentEditorId)
1409
+ onAppliedUpdate?(update)
1200
1410
  }
1201
1411
 
1202
1412
  func firstImageGeometryForTesting() -> (docPos: UInt32, rect: CGRect)? {