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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +18 -0
  2. package/android/build.gradle +23 -0
  3. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +502 -39
  4. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +56 -28
  5. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +6 -0
  6. package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +57 -27
  7. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +147 -78
  8. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +249 -71
  9. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +7 -6
  10. package/dist/NativeEditorBridge.d.ts +36 -1
  11. package/dist/NativeEditorBridge.js +173 -94
  12. package/dist/NativeRichTextEditor.d.ts +2 -0
  13. package/dist/NativeRichTextEditor.js +160 -53
  14. package/dist/YjsCollaboration.d.ts +2 -0
  15. package/dist/YjsCollaboration.js +142 -20
  16. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  17. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  18. package/ios/EditorLayoutManager.swift +3 -3
  19. package/ios/Generated_editor_core.swift +41 -0
  20. package/ios/NativeEditorExpoView.swift +43 -11
  21. package/ios/NativeEditorModule.swift +6 -0
  22. package/ios/PositionBridge.swift +310 -75
  23. package/ios/RenderBridge.swift +362 -27
  24. package/ios/RichTextEditorView.swift +1983 -187
  25. package/ios/editor_coreFFI/editor_coreFFI.h +33 -0
  26. package/package.json +11 -2
  27. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  28. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  29. package/rust/android/x86_64/libeditor_core.so +0 -0
  30. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +63 -0
@@ -1,4 +1,5 @@
1
1
  import UIKit
2
+ import ObjectiveC
2
3
 
3
4
  // MARK: - PositionBridge
4
5
 
@@ -16,11 +17,34 @@ import UIKit
16
17
  /// This bridge converts between those scalar offsets and UITextView UTF-16 offsets.
17
18
  final class PositionBridge {
18
19
 
20
+ private struct StringConversionTable {
21
+ let utf16ToScalar: [UInt32]
22
+ let scalarToUtf16: [Int]
23
+ }
24
+
25
+ private final class TextViewConversionTable: NSObject {
26
+ let adjustedUtf16ToScalar: [UInt32]
27
+
28
+ init(adjustedUtf16ToScalar: [UInt32]) {
29
+ self.adjustedUtf16ToScalar = adjustedUtf16ToScalar
30
+ }
31
+ }
32
+
19
33
  struct VirtualListMarker {
20
34
  let paragraphStartUtf16: Int
21
35
  let scalarLength: UInt32
22
36
  }
23
37
 
38
+ private struct PositionAdjustments {
39
+ let placeholders: [Int]
40
+ let listMarkers: [VirtualListMarker]
41
+ }
42
+
43
+ private static var textViewConversionTableKey: UInt8 = 0
44
+ private static let stringTableLock = NSLock()
45
+ private static var lastStringTableText = ""
46
+ private static var lastStringTable: StringConversionTable?
47
+
24
48
  // MARK: - UTF-16 <-> Scalar Conversion
25
49
 
26
50
  /// Convert a UITextView cursor position (UTF-16 offset) to a Rust scalar offset.
@@ -58,36 +82,27 @@ final class PositionBridge {
58
82
 
59
83
  static func utf16OffsetToScalar(_ utf16Offset: Int, in textView: UITextView) -> UInt32 {
60
84
  let text = textView.text ?? ""
61
- let nsString = text as NSString
62
- let clampedOffset = min(max(utf16Offset, 0), nsString.length)
63
- var scalarOffset = utf16OffsetToScalar(clampedOffset, in: text)
64
-
65
- for placeholderOffset in syntheticPlaceholderOffsets(in: textView) where clampedOffset > placeholderOffset {
66
- if scalarOffset > 0 {
67
- scalarOffset -= 1
68
- }
69
- }
70
-
71
- for marker in virtualListMarkers(in: textView) where clampedOffset >= marker.paragraphStartUtf16 {
72
- scalarOffset += marker.scalarLength
73
- }
74
-
75
- return scalarOffset
85
+ let clampedOffset = min(max(utf16Offset, 0), (text as NSString).length)
86
+ let conversionTable = textViewConversionTable(for: textView)
87
+ return conversionTable.adjustedUtf16ToScalar[clampedOffset]
76
88
  }
77
89
 
78
90
  static func scalarToUtf16Offset(_ scalar: UInt32, in textView: UITextView) -> Int {
79
- let text = textView.text ?? ""
80
- let maxUtf16 = (text as NSString).length
81
-
82
- if scalar == 0 || maxUtf16 == 0 {
91
+ let conversionTable = textViewConversionTable(for: textView)
92
+ let utf16ToScalar = conversionTable.adjustedUtf16ToScalar
93
+ guard scalar > 0, !utf16ToScalar.isEmpty else {
83
94
  return 0
84
95
  }
85
96
 
97
+ if let last = utf16ToScalar.last, scalar > last {
98
+ return utf16ToScalar.count - 1
99
+ }
100
+
86
101
  var low = 0
87
- var high = maxUtf16
102
+ var high = utf16ToScalar.count - 1
88
103
  while low < high {
89
104
  let mid = (low + high) / 2
90
- if utf16OffsetToScalar(mid, in: textView) < scalar {
105
+ if utf16ToScalar[mid] < scalar {
91
106
  low = mid + 1
92
107
  } else {
93
108
  high = mid
@@ -107,21 +122,9 @@ final class PositionBridge {
107
122
  /// - text: The string to walk.
108
123
  /// - Returns: The number of Unicode scalars from the start to the given UTF-16 offset.
109
124
  static func utf16OffsetToScalar(_ utf16Offset: Int, in text: String) -> UInt32 {
110
- guard utf16Offset > 0 else { return 0 }
111
-
112
- let utf16View = text.utf16
113
- let endIndex = min(utf16Offset, utf16View.count)
114
- var scalarCount: UInt32 = 0
115
- var utf16Pos = 0
116
-
117
- for scalar in text.unicodeScalars {
118
- if utf16Pos >= endIndex { break }
119
- let scalarUtf16Len = scalar.utf16.count
120
- utf16Pos += scalarUtf16Len
121
- scalarCount += 1
122
- }
123
-
124
- return scalarCount
125
+ let conversionTable = stringConversionTable(for: text)
126
+ let clampedOffset = min(max(utf16Offset, 0), conversionTable.utf16ToScalar.count - 1)
127
+ return conversionTable.utf16ToScalar[clampedOffset]
125
128
  }
126
129
 
127
130
  /// Convert a Unicode scalar offset to a UTF-16 offset within a string.
@@ -134,18 +137,10 @@ final class PositionBridge {
134
137
  /// - text: The string to walk.
135
138
  /// - Returns: The number of UTF-16 code units from the start to the given scalar offset.
136
139
  static func scalarToUtf16Offset(_ scalar: UInt32, in text: String) -> Int {
140
+ let conversionTable = stringConversionTable(for: text)
137
141
  guard scalar > 0 else { return 0 }
138
-
139
- var utf16Len = 0
140
- var scalarsSeen: UInt32 = 0
141
-
142
- for s in text.unicodeScalars {
143
- if scalarsSeen >= scalar { break }
144
- utf16Len += s.utf16.count
145
- scalarsSeen += 1
146
- }
147
-
148
- return utf16Len
142
+ let scalarIndex = min(Int(scalar), conversionTable.scalarToUtf16.count - 1)
143
+ return conversionTable.scalarToUtf16[scalarIndex]
149
144
  }
150
145
 
151
146
  // MARK: - Grapheme Boundary Snapping
@@ -238,31 +233,259 @@ final class PositionBridge {
238
233
  atUtf16Offset utf16Offset: Int,
239
234
  in textView: UITextView
240
235
  ) -> VirtualListMarker? {
241
- virtualListMarkers(in: textView).first { $0.paragraphStartUtf16 == utf16Offset }
236
+ virtualListMarkers(in: textView.textStorage).first { $0.paragraphStartUtf16 == utf16Offset }
237
+ }
238
+
239
+ static func invalidateCache(for textView: UITextView) {
240
+ objc_setAssociatedObject(
241
+ textView,
242
+ &textViewConversionTableKey,
243
+ nil,
244
+ .OBJC_ASSOCIATION_RETAIN_NONATOMIC
245
+ )
242
246
  }
243
247
 
244
- private static func virtualListMarkers(in textView: UITextView) -> [VirtualListMarker] {
245
- let textStorage = textView.textStorage
246
- guard textStorage.length > 0 else { return [] }
248
+ @discardableResult
249
+ static func applyAttributedPatchIfPossible(
250
+ for textView: UITextView,
251
+ replaceRange: NSRange,
252
+ replacement: NSAttributedString
253
+ ) -> Bool {
254
+ guard let cached = objc_getAssociatedObject(textView, &textViewConversionTableKey) as? TextViewConversionTable else {
255
+ return false
256
+ }
257
+
258
+ let oldAdjusted = cached.adjustedUtf16ToScalar
259
+ let oldUtf16Count = max(0, oldAdjusted.count - 1)
260
+ guard replaceRange.location >= 0,
261
+ replaceRange.length >= 0,
262
+ replaceRange.location + replaceRange.length <= oldUtf16Count
263
+ else {
264
+ return false
265
+ }
247
266
 
248
- let nsString = textStorage.string as NSString
267
+ let startOffset = replaceRange.location
268
+ let endOffset = replaceRange.location + replaceRange.length
269
+ let replacementAdjusted = adjustedConversionTable(for: replacement)
270
+ let patched = patchedAdjustedConversionTable(
271
+ oldAdjusted: oldAdjusted,
272
+ startOffset: startOffset,
273
+ endOffset: endOffset,
274
+ replacementAdjusted: replacementAdjusted
275
+ )
276
+
277
+ objc_setAssociatedObject(
278
+ textView,
279
+ &textViewConversionTableKey,
280
+ TextViewConversionTable(adjustedUtf16ToScalar: patched),
281
+ .OBJC_ASSOCIATION_RETAIN_NONATOMIC
282
+ )
283
+ return true
284
+ }
285
+
286
+ @discardableResult
287
+ static func applyPlainTextPatchIfPossible(
288
+ for textView: UITextView,
289
+ replaceRange: NSRange,
290
+ replacementText: String
291
+ ) -> Bool {
292
+ guard let cached = objc_getAssociatedObject(textView, &textViewConversionTableKey) as? TextViewConversionTable else {
293
+ return false
294
+ }
295
+
296
+ let oldAdjusted = cached.adjustedUtf16ToScalar
297
+ let oldUtf16Count = max(0, oldAdjusted.count - 1)
298
+ guard replaceRange.location >= 0,
299
+ replaceRange.length >= 0,
300
+ replaceRange.location + replaceRange.length <= oldUtf16Count
301
+ else {
302
+ return false
303
+ }
304
+
305
+ let startOffset = replaceRange.location
306
+ let endOffset = replaceRange.location + replaceRange.length
307
+ let replacementBase = stringConversionTable(for: replacementText).utf16ToScalar
308
+ let patched = patchedAdjustedConversionTable(
309
+ oldAdjusted: oldAdjusted,
310
+ startOffset: startOffset,
311
+ endOffset: endOffset,
312
+ replacementAdjusted: replacementBase
313
+ )
314
+
315
+ objc_setAssociatedObject(
316
+ textView,
317
+ &textViewConversionTableKey,
318
+ TextViewConversionTable(adjustedUtf16ToScalar: patched),
319
+ .OBJC_ASSOCIATION_RETAIN_NONATOMIC
320
+ )
321
+ return true
322
+ }
323
+
324
+ private static func patchedAdjustedConversionTable(
325
+ oldAdjusted: [UInt32],
326
+ startOffset: Int,
327
+ endOffset: Int,
328
+ replacementAdjusted: [UInt32]
329
+ ) -> [UInt32] {
330
+ let startScalar = Int32(oldAdjusted[startOffset])
331
+ let deletedScalarCount = Int32(oldAdjusted[endOffset]) - startScalar
332
+ let replacementScalarCount = Int32(replacementAdjusted.last ?? 0)
333
+ let scalarDelta = replacementScalarCount - deletedScalarCount
334
+ let replacement = replacementAdjusted.map { value in
335
+ UInt32(max(0, Int32(value) + startScalar))
336
+ }
337
+ let prefix = Array(oldAdjusted[..<startOffset])
338
+ let suffix = oldAdjusted[(endOffset + 1)...].map { value in
339
+ UInt32(max(0, Int32(value) + scalarDelta))
340
+ }
341
+ return prefix + replacement + suffix
342
+ }
343
+
344
+ private static func stringConversionTable(for text: String) -> StringConversionTable {
345
+ stringTableLock.lock()
346
+ if lastStringTableText == text, let lastStringTable {
347
+ stringTableLock.unlock()
348
+ return lastStringTable
349
+ }
350
+ stringTableLock.unlock()
351
+
352
+ let utf16Count = text.utf16.count
353
+ let scalarCount = text.unicodeScalars.count
354
+ var utf16ToScalar = Array(repeating: UInt32(0), count: utf16Count + 1)
355
+ var scalarToUtf16 = Array(repeating: 0, count: scalarCount + 1)
356
+ var utf16Pos = 0
357
+ var scalarPos = 0
358
+
359
+ for scalar in text.unicodeScalars {
360
+ let nextUtf16Pos = utf16Pos + scalar.utf16.count
361
+ scalarPos += 1
362
+ if nextUtf16Pos > utf16Pos {
363
+ for offset in (utf16Pos + 1)...nextUtf16Pos {
364
+ utf16ToScalar[offset] = UInt32(scalarPos)
365
+ }
366
+ }
367
+ scalarToUtf16[scalarPos] = nextUtf16Pos
368
+ utf16Pos = nextUtf16Pos
369
+ }
370
+
371
+ let conversionTable = StringConversionTable(
372
+ utf16ToScalar: utf16ToScalar,
373
+ scalarToUtf16: scalarToUtf16
374
+ )
375
+
376
+ stringTableLock.lock()
377
+ lastStringTableText = text
378
+ lastStringTable = conversionTable
379
+ stringTableLock.unlock()
380
+
381
+ return conversionTable
382
+ }
383
+
384
+ private static func adjustedConversionTable(for attributedString: NSAttributedString) -> [UInt32] {
385
+ let baseTable = stringConversionTable(for: attributedString.string)
386
+ let adjustments = positionAdjustments(in: attributedString)
387
+ return adjustedUtf16ToScalar(
388
+ baseUtf16ToScalar: baseTable.utf16ToScalar,
389
+ placeholders: adjustments.placeholders,
390
+ listMarkers: adjustments.listMarkers
391
+ )
392
+ }
393
+
394
+ private static func textViewConversionTable(for textView: UITextView) -> TextViewConversionTable {
395
+ if let cached = objc_getAssociatedObject(textView, &textViewConversionTableKey) as? TextViewConversionTable {
396
+ return cached
397
+ }
398
+
399
+ let text = textView.text ?? ""
400
+ let baseTable = stringConversionTable(for: text)
401
+ let adjustments = positionAdjustments(in: textView.textStorage)
402
+ let adjustedUtf16ToScalar = adjustedUtf16ToScalar(
403
+ baseUtf16ToScalar: baseTable.utf16ToScalar,
404
+ placeholders: adjustments.placeholders,
405
+ listMarkers: adjustments.listMarkers
406
+ )
407
+ let conversionTable = TextViewConversionTable(adjustedUtf16ToScalar: adjustedUtf16ToScalar)
408
+ objc_setAssociatedObject(
409
+ textView,
410
+ &textViewConversionTableKey,
411
+ conversionTable,
412
+ .OBJC_ASSOCIATION_RETAIN_NONATOMIC
413
+ )
414
+ return conversionTable
415
+ }
416
+
417
+ private static func adjustedUtf16ToScalar(
418
+ baseUtf16ToScalar: [UInt32],
419
+ placeholders: [Int],
420
+ listMarkers: [VirtualListMarker] = [],
421
+ ) -> [UInt32] {
422
+ let utf16Count = max(0, baseUtf16ToScalar.count - 1)
423
+ var deltas = Array(repeating: Int32(0), count: utf16Count + 2)
424
+
425
+ for placeholderOffset in placeholders {
426
+ let startOffset = min(max(placeholderOffset + 1, 0), utf16Count + 1)
427
+ if startOffset <= utf16Count {
428
+ deltas[startOffset] -= 1
429
+ }
430
+ }
431
+
432
+ for marker in listMarkers {
433
+ let startOffset = min(max(marker.paragraphStartUtf16, 0), utf16Count)
434
+ deltas[startOffset] += Int32(marker.scalarLength)
435
+ }
436
+
437
+ var adjustedUtf16ToScalar = Array(repeating: UInt32(0), count: utf16Count + 1)
438
+ var runningDelta: Int32 = 0
439
+ for offset in 0...utf16Count {
440
+ runningDelta += deltas[offset]
441
+ let adjustedValue = Int32(baseUtf16ToScalar[offset]) + runningDelta
442
+ adjustedUtf16ToScalar[offset] = UInt32(max(0, adjustedValue))
443
+ }
444
+ return adjustedUtf16ToScalar
445
+ }
446
+
447
+ private static func adjustedUtf16ToScalar(
448
+ baseUtf16ToScalar: [UInt32],
449
+ listMarkers: [VirtualListMarker]
450
+ ) -> [UInt32] {
451
+ adjustedUtf16ToScalar(baseUtf16ToScalar: baseUtf16ToScalar, placeholders: [], listMarkers: listMarkers)
452
+ }
453
+
454
+ private static func virtualListMarkers(in attributedString: NSAttributedString) -> [VirtualListMarker] {
455
+ positionAdjustments(in: attributedString).listMarkers
456
+ }
457
+
458
+ private static func positionAdjustments(in attributedString: NSAttributedString) -> PositionAdjustments {
459
+ guard attributedString.length > 0 else {
460
+ return PositionAdjustments(placeholders: [], listMarkers: [])
461
+ }
462
+
463
+ let nsString = attributedString.string as NSString
464
+ var placeholders: [Int] = []
249
465
  var markers: [VirtualListMarker] = []
250
466
  var seenStarts = Set<Int>()
251
- let fullRange = NSRange(location: 0, length: textStorage.length)
467
+ let fullRange = NSRange(location: 0, length: attributedString.length)
252
468
 
253
- textStorage.enumerateAttribute(
254
- RenderBridgeAttributes.listMarkerContext,
469
+ attributedString.enumerateAttributes(
255
470
  in: fullRange,
256
- options: []
257
- ) { value, range, _ in
258
- guard range.length > 0, let listContext = value as? [String: Any] else { return }
471
+ options: [.longestEffectiveRangeNotRequired]
472
+ ) { attrs, range, _ in
473
+ guard range.length > 0 else { return }
474
+
475
+ if attrs[RenderBridgeAttributes.syntheticPlaceholder] as? Bool == true {
476
+ placeholders.append(range.location)
477
+ }
478
+
479
+ guard let listContext = attrs[RenderBridgeAttributes.listMarkerContext] as? [String: Any] else {
480
+ return
481
+ }
259
482
 
260
483
  let paragraphStart = nsString.paragraphRange(
261
484
  for: NSRange(location: range.location, length: 0)
262
485
  ).location
263
- guard !EditorLayoutManager.isParagraphStartCreatedByHardBreak(
486
+ guard !isParagraphStartCreatedByHardBreak(
264
487
  paragraphStart,
265
- in: textStorage
488
+ in: attributedString
266
489
  ) else {
267
490
  return
268
491
  }
@@ -279,22 +502,34 @@ final class PositionBridge {
279
502
  )
280
503
  }
281
504
 
282
- return markers.sorted { $0.paragraphStartUtf16 < $1.paragraphStartUtf16 }
505
+ return PositionAdjustments(
506
+ placeholders: placeholders,
507
+ listMarkers: markers.sorted { $0.paragraphStartUtf16 < $1.paragraphStartUtf16 }
508
+ )
283
509
  }
284
510
 
285
- private static func syntheticPlaceholderOffsets(in textView: UITextView) -> [Int] {
286
- let textStorage = textView.textStorage
287
- guard textStorage.length > 0 else { return [] }
288
-
289
- var offsets: [Int] = []
290
- textStorage.enumerateAttribute(
291
- RenderBridgeAttributes.syntheticPlaceholder,
292
- in: NSRange(location: 0, length: textStorage.length),
293
- options: []
294
- ) { value, range, _ in
295
- guard range.length > 0, (value as? Bool) == true else { return }
296
- offsets.append(range.location)
297
- }
298
- return offsets
511
+ private static func virtualListMarkers(in textStorage: NSTextStorage) -> [VirtualListMarker] {
512
+ virtualListMarkers(in: textStorage as NSAttributedString)
513
+ }
514
+
515
+ private static func syntheticPlaceholderOffsets(in attributedString: NSAttributedString) -> [Int] {
516
+ positionAdjustments(in: attributedString).placeholders
517
+ }
518
+
519
+ private static func syntheticPlaceholderOffsets(in textStorage: NSTextStorage) -> [Int] {
520
+ syntheticPlaceholderOffsets(in: textStorage as NSAttributedString)
521
+ }
522
+
523
+ private static func isParagraphStartCreatedByHardBreak(
524
+ _ paragraphStart: Int,
525
+ in attributedString: NSAttributedString
526
+ ) -> Bool {
527
+ guard paragraphStart > 0, paragraphStart <= attributedString.length else { return false }
528
+ let previousVoidType = attributedString.attribute(
529
+ RenderBridgeAttributes.voidNodeType,
530
+ at: paragraphStart - 1,
531
+ effectiveRange: nil
532
+ ) as? String
533
+ return previousVoidType == "hardBreak"
299
534
  }
300
535
  }