@apollohg/react-native-prose-editor 0.3.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 (37) 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 +515 -39
  4. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +58 -28
  5. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +25 -0
  6. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +232 -62
  7. package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +57 -27
  8. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +147 -78
  9. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +249 -71
  10. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +7 -6
  11. package/dist/EditorToolbar.d.ts +26 -6
  12. package/dist/EditorToolbar.js +299 -65
  13. package/dist/NativeEditorBridge.d.ts +40 -1
  14. package/dist/NativeEditorBridge.js +184 -90
  15. package/dist/NativeRichTextEditor.d.ts +5 -1
  16. package/dist/NativeRichTextEditor.js +201 -78
  17. package/dist/YjsCollaboration.d.ts +2 -0
  18. package/dist/YjsCollaboration.js +142 -20
  19. package/dist/index.d.ts +1 -1
  20. package/dist/schemas.js +12 -0
  21. package/dist/useNativeEditor.d.ts +2 -0
  22. package/dist/useNativeEditor.js +7 -0
  23. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  24. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  25. package/ios/EditorLayoutManager.swift +3 -3
  26. package/ios/Generated_editor_core.swift +87 -0
  27. package/ios/NativeEditorExpoView.swift +488 -178
  28. package/ios/NativeEditorModule.swift +25 -0
  29. package/ios/PositionBridge.swift +310 -75
  30. package/ios/RenderBridge.swift +362 -27
  31. package/ios/RichTextEditorView.swift +2001 -189
  32. package/ios/editor_coreFFI/editor_coreFFI.h +55 -0
  33. package/package.json +11 -2
  34. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  35. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  36. package/rust/android/x86_64/libeditor_core.so +0 -0
  37. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +128 -0
@@ -8,7 +8,53 @@ extension Notification.Name {
8
8
 
9
9
  private enum RenderImageCache {
10
10
  static let cache = NSCache<NSString, UIImage>()
11
- static let queue = DispatchQueue(label: "com.apollohg.editor.image-loader", qos: .userInitiated)
11
+ static let stateQueue = DispatchQueue(label: "com.apollohg.editor.image-loader-state")
12
+ static let queue: OperationQueue = {
13
+ let queue = OperationQueue()
14
+ queue.name = "com.apollohg.editor.image-loader"
15
+ queue.qualityOfService = .userInitiated
16
+ queue.maxConcurrentOperationCount = 2
17
+ return queue
18
+ }()
19
+ private static var inFlight: [String: [(UIImage?) -> Void]] = [:]
20
+
21
+ static func load(
22
+ source: String,
23
+ url: URL,
24
+ completion: @escaping (UIImage?) -> Void
25
+ ) {
26
+ if let cached = cache.object(forKey: source as NSString) {
27
+ completion(cached)
28
+ return
29
+ }
30
+
31
+ var shouldStartLoad = false
32
+ stateQueue.sync {
33
+ if inFlight[source] != nil {
34
+ inFlight[source]?.append(completion)
35
+ } else {
36
+ inFlight[source] = [completion]
37
+ shouldStartLoad = true
38
+ }
39
+ }
40
+ guard shouldStartLoad else { return }
41
+
42
+ queue.addOperation {
43
+ let data = try? Data(contentsOf: url)
44
+ let image = data.flatMap(UIImage.init(data:))
45
+ if let image {
46
+ cache.setObject(image, forKey: source as NSString)
47
+ }
48
+
49
+ let callbacks: [(UIImage?) -> Void] = stateQueue.sync {
50
+ let callbacks = inFlight.removeValue(forKey: source) ?? []
51
+ return callbacks
52
+ }
53
+ DispatchQueue.main.async {
54
+ callbacks.forEach { $0(image) }
55
+ }
56
+ }
57
+ }
12
58
  }
13
59
 
14
60
  // MARK: - Constants
@@ -57,6 +103,9 @@ enum RenderBridgeAttributes {
57
103
 
58
104
  /// Marks synthetic zero-width placeholders used only for UIKit layout.
59
105
  static let syntheticPlaceholder = NSAttributedString.Key("com.apollohg.editor.syntheticPlaceholder")
106
+
107
+ /// Stores the owning top-level document child index for partial native patching.
108
+ static let topLevelChildIndex = NSAttributedString.Key("com.apollohg.editor.topLevelChildIndex")
60
109
  }
61
110
 
62
111
  /// Layout constants for paragraph styles.
@@ -169,6 +218,7 @@ final class RenderBridge {
169
218
 
170
219
  for element in elements {
171
220
  guard let type = element["type"] as? String else { continue }
221
+ let topLevelChildIndex = jsonInt(element["topLevelChildIndex"])
172
222
 
173
223
  switch type {
174
224
  case "textRun":
@@ -194,7 +244,14 @@ final class RenderBridge {
194
244
  blockStack: blockStack,
195
245
  theme: theme
196
246
  )
197
- result.append(NSAttributedString(string: text, attributes: attrs))
247
+ let attributedText = NSAttributedString(string: text, attributes: attrs)
248
+ result.append(
249
+ attributedStringApplyingLeadingTopLevelChildIndexIfNeeded(
250
+ attributedText,
251
+ topLevelChildIndex: topLevelChildIndex,
252
+ resultIsEmpty: result.length == 0
253
+ )
254
+ )
198
255
 
199
256
  case "voidInline":
200
257
  let nodeType = element["nodeType"] as? String ?? ""
@@ -210,9 +267,16 @@ final class RenderBridge {
210
267
  baseFont: baseFont,
211
268
  textColor: textColor,
212
269
  blockStack: blockStack,
270
+ topLevelChildIndex: topLevelChildIndex,
213
271
  theme: theme
214
272
  )
215
- result.append(attrStr)
273
+ result.append(
274
+ attributedStringApplyingLeadingTopLevelChildIndexIfNeeded(
275
+ attrStr,
276
+ topLevelChildIndex: topLevelChildIndex,
277
+ resultIsEmpty: result.length == 0
278
+ )
279
+ )
216
280
 
217
281
  case "voidBlock":
218
282
  let nodeType = element["nodeType"] as? String ?? ""
@@ -221,6 +285,12 @@ final class RenderBridge {
221
285
 
222
286
  // Add inter-block newline if not the first block.
223
287
  if !isFirstBlock {
288
+ collapseTrailingSpacingBeforeHorizontalRuleIfNeeded(
289
+ in: result,
290
+ pendingParagraphSpacing: &pendingTrailingParagraphSpacing,
291
+ nodeType: nodeType,
292
+ theme: theme
293
+ )
224
294
  applyPendingTrailingParagraphSpacing(
225
295
  in: result,
226
296
  pendingParagraphSpacing: &pendingTrailingParagraphSpacing
@@ -230,7 +300,8 @@ final class RenderBridge {
230
300
  baseFont: baseFont,
231
301
  textColor: textColor,
232
302
  blockStack: [],
233
- theme: theme
303
+ theme: theme,
304
+ topLevelChildIndex: topLevelChildIndex
234
305
  )
235
306
  )
236
307
  }
@@ -242,6 +313,7 @@ final class RenderBridge {
242
313
  elementAttrs: attrs,
243
314
  baseFont: baseFont,
244
315
  textColor: textColor,
316
+ topLevelChildIndex: topLevelChildIndex,
245
317
  theme: theme
246
318
  )
247
319
  result.append(attrStr)
@@ -257,9 +329,16 @@ final class RenderBridge {
257
329
  baseFont: baseFont,
258
330
  textColor: textColor,
259
331
  blockStack: blockStack,
332
+ topLevelChildIndex: topLevelChildIndex,
260
333
  theme: theme
261
334
  )
262
- result.append(attrStr)
335
+ result.append(
336
+ attributedStringApplyingLeadingTopLevelChildIndexIfNeeded(
337
+ attrStr,
338
+ topLevelChildIndex: topLevelChildIndex,
339
+ resultIsEmpty: result.length == 0
340
+ )
341
+ )
263
342
 
264
343
  case "opaqueBlockAtom":
265
344
  let nodeType = element["nodeType"] as? String ?? ""
@@ -276,7 +355,8 @@ final class RenderBridge {
276
355
  baseFont: baseFont,
277
356
  textColor: textColor,
278
357
  blockStack: [],
279
- theme: theme
358
+ theme: theme,
359
+ topLevelChildIndex: topLevelChildIndex
280
360
  )
281
361
  )
282
362
  }
@@ -288,6 +368,7 @@ final class RenderBridge {
288
368
  docPos: docPos,
289
369
  baseFont: baseFont,
290
370
  textColor: textColor,
371
+ topLevelChildIndex: topLevelChildIndex,
291
372
  theme: theme
292
373
  )
293
374
  result.append(attrStr)
@@ -302,6 +383,7 @@ final class RenderBridge {
302
383
  nodeType: nodeType,
303
384
  depth: depth,
304
385
  listContext: listContext,
386
+ topLevelChildIndex: topLevelChildIndex,
305
387
  markerPending: isListItemContainer
306
388
  )
307
389
  let nestedListItemContainer =
@@ -323,12 +405,20 @@ final class RenderBridge {
323
405
  } else {
324
406
  newlineBlockStack = blockStack + [ctx]
325
407
  }
408
+ let collapsedSeparatorSpacing = collapsedParagraphSpacingAfterHorizontalRule(
409
+ in: result,
410
+ separatorBlockStack: newlineBlockStack,
411
+ theme: theme,
412
+ baseFont: baseFont
413
+ )
326
414
  result.append(
327
415
  interBlockNewline(
328
416
  baseFont: baseFont,
329
417
  textColor: textColor,
330
418
  blockStack: newlineBlockStack,
331
- theme: theme
419
+ theme: theme,
420
+ paragraphSpacingOverride: collapsedSeparatorSpacing,
421
+ topLevelChildIndex: topLevelChildIndex
332
422
  )
333
423
  )
334
424
  }
@@ -392,6 +482,54 @@ final class RenderBridge {
392
482
  return result
393
483
  }
394
484
 
485
+ static func renderBlocks(
486
+ fromArray blocks: [[[String: Any]]],
487
+ startIndex: Int = 0,
488
+ includeLeadingInterBlockSeparator: Bool = false,
489
+ baseFont: UIFont,
490
+ textColor: UIColor,
491
+ theme: EditorTheme? = nil
492
+ ) -> NSAttributedString {
493
+ var flattened: [[String: Any]] = []
494
+ flattened.reserveCapacity(blocks.reduce(0) { $0 + $1.count })
495
+
496
+ for (offset, block) in blocks.enumerated() {
497
+ let topLevelChildIndex = startIndex + offset
498
+ for element in block {
499
+ var tagged = element
500
+ tagged["topLevelChildIndex"] = topLevelChildIndex
501
+ flattened.append(tagged)
502
+ }
503
+ }
504
+
505
+ let renderedBlocks = renderElements(
506
+ fromArray: flattened,
507
+ baseFont: baseFont,
508
+ textColor: textColor,
509
+ theme: theme
510
+ )
511
+ guard includeLeadingInterBlockSeparator, startIndex > 0, !blocks.isEmpty else {
512
+ return renderedBlocks
513
+ }
514
+
515
+ let separatorReadyBlocks = removingLeadingTopLevelChildIndex(
516
+ from: renderedBlocks,
517
+ topLevelChildIndex: startIndex
518
+ )
519
+
520
+ let result = NSMutableAttributedString(
521
+ attributedString: interBlockNewline(
522
+ baseFont: baseFont,
523
+ textColor: textColor,
524
+ blockStack: [],
525
+ theme: theme,
526
+ topLevelChildIndex: startIndex
527
+ )
528
+ )
529
+ result.append(separatorReadyBlocks)
530
+ return result
531
+ }
532
+
395
533
  // MARK: - Mark Handling
396
534
 
397
535
  /// Build NSAttributedString attributes for a set of render marks.
@@ -486,6 +624,7 @@ final class RenderBridge {
486
624
  baseFont: UIFont,
487
625
  textColor: UIColor,
488
626
  blockStack: [BlockContext],
627
+ topLevelChildIndex _: Int?,
489
628
  theme: EditorTheme?
490
629
  ) -> NSAttributedString {
491
630
  let blockFont = resolvedFont(for: blockStack, baseFont: baseFont, theme: theme)
@@ -530,18 +669,22 @@ final class RenderBridge {
530
669
  elementAttrs: [String: Any],
531
670
  baseFont: UIFont,
532
671
  textColor: UIColor,
672
+ topLevelChildIndex: Int?,
533
673
  theme: EditorTheme?
534
674
  ) -> NSAttributedString {
535
675
  var attrs = defaultAttributes(baseFont: baseFont, textColor: textColor)
536
676
  attrs[RenderBridgeAttributes.voidNodeType] = nodeType
537
677
  attrs[RenderBridgeAttributes.docPos] = docPos
678
+ if let topLevelChildIndex {
679
+ attrs[RenderBridgeAttributes.topLevelChildIndex] = NSNumber(value: topLevelChildIndex)
680
+ }
538
681
 
539
682
  switch nodeType {
540
683
  case "horizontalRule":
541
684
  let attachment = HorizontalRuleAttachment()
542
685
  attachment.lineColor = theme?.horizontalRule?.color ?? textColor.withAlphaComponent(0.3)
543
686
  attachment.lineHeight = theme?.horizontalRule?.thickness ?? LayoutConstants.horizontalRuleHeight
544
- attachment.verticalPadding = theme?.horizontalRule?.verticalMargin ?? LayoutConstants.horizontalRuleVerticalPadding
687
+ attachment.verticalPadding = resolvedHorizontalRuleVerticalMargin(theme: theme)
545
688
  let attrStr = NSMutableAttributedString(
546
689
  attachment: attachment
547
690
  )
@@ -587,6 +730,7 @@ final class RenderBridge {
587
730
  baseFont: UIFont,
588
731
  textColor: UIColor,
589
732
  blockStack: [BlockContext],
733
+ topLevelChildIndex _: Int?,
590
734
  theme: EditorTheme?
591
735
  ) -> NSAttributedString {
592
736
  let blockFont = resolvedFont(for: blockStack, baseFont: baseFont, theme: theme)
@@ -620,12 +764,16 @@ final class RenderBridge {
620
764
  docPos: UInt32,
621
765
  baseFont: UIFont,
622
766
  textColor: UIColor,
767
+ topLevelChildIndex: Int?,
623
768
  theme: EditorTheme?
624
769
  ) -> NSAttributedString {
625
770
  var attrs = defaultAttributes(baseFont: baseFont, textColor: textColor)
626
771
  attrs[RenderBridgeAttributes.voidNodeType] = nodeType
627
772
  attrs[RenderBridgeAttributes.docPos] = docPos
628
773
  attrs[.backgroundColor] = UIColor.systemGray5
774
+ if let topLevelChildIndex {
775
+ attrs[RenderBridgeAttributes.topLevelChildIndex] = NSNumber(value: topLevelChildIndex)
776
+ }
629
777
 
630
778
  return NSAttributedString(string: "[\(label)]", attributes: attrs)
631
779
  }
@@ -734,6 +882,18 @@ final class RenderBridge {
734
882
  return 0
735
883
  }
736
884
 
885
+ static func jsonInt(_ value: Any?) -> Int? {
886
+ if let number = value as? NSNumber {
887
+ return number.intValue
888
+ }
889
+ if let string = value as? String,
890
+ let resolved = Int(string.trimmingCharacters(in: .whitespacesAndNewlines))
891
+ {
892
+ return resolved
893
+ }
894
+ return nil
895
+ }
896
+
737
897
  /// Extract a positive `CGFloat` from a JSON value produced by `JSONSerialization`.
738
898
  static func jsonCGFloat(_ value: Any?) -> CGFloat? {
739
899
  if let number = value as? NSNumber {
@@ -810,16 +970,79 @@ final class RenderBridge {
810
970
  baseFont: UIFont,
811
971
  textColor: UIColor,
812
972
  blockStack: [BlockContext],
813
- theme: EditorTheme?
973
+ theme: EditorTheme?,
974
+ paragraphSpacingOverride: CGFloat? = nil,
975
+ topLevelChildIndex: Int? = nil
814
976
  ) -> NSAttributedString {
815
- let attrs = applyBlockStyle(
977
+ var attrs = applyBlockStyle(
816
978
  to: defaultAttributes(baseFont: baseFont, textColor: textColor),
817
979
  blockStack: blockStack,
818
980
  theme: theme
819
981
  )
982
+ if let topLevelChildIndex {
983
+ attrs[RenderBridgeAttributes.topLevelChildIndex] = NSNumber(value: topLevelChildIndex)
984
+ }
985
+ if let paragraphSpacingOverride,
986
+ let paragraphStyle = (attrs[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy()
987
+ as? NSMutableParagraphStyle
988
+ {
989
+ paragraphStyle.paragraphSpacing = paragraphSpacingOverride
990
+ attrs[.paragraphStyle] = paragraphStyle
991
+ }
820
992
  return NSAttributedString(string: "\n", attributes: attrs)
821
993
  }
822
994
 
995
+ private static func attributedStringApplyingLeadingTopLevelChildIndexIfNeeded(
996
+ _ attributedString: NSAttributedString,
997
+ topLevelChildIndex: Int?,
998
+ resultIsEmpty: Bool
999
+ ) -> NSAttributedString {
1000
+ guard resultIsEmpty,
1001
+ let topLevelChildIndex,
1002
+ attributedString.length > 0
1003
+ else {
1004
+ return attributedString
1005
+ }
1006
+
1007
+ let tagged = NSMutableAttributedString(attributedString: attributedString)
1008
+ tagged.addAttribute(
1009
+ RenderBridgeAttributes.topLevelChildIndex,
1010
+ value: NSNumber(value: topLevelChildIndex),
1011
+ range: NSRange(location: 0, length: 1)
1012
+ )
1013
+ return tagged
1014
+ }
1015
+
1016
+ private static func removingLeadingTopLevelChildIndex(
1017
+ from attributedString: NSAttributedString,
1018
+ topLevelChildIndex: Int
1019
+ ) -> NSAttributedString {
1020
+ guard attributedString.length > 0 else { return attributedString }
1021
+
1022
+ let firstValue = attributedString.attribute(
1023
+ RenderBridgeAttributes.topLevelChildIndex,
1024
+ at: 0,
1025
+ effectiveRange: nil
1026
+ ) as? NSNumber
1027
+ guard firstValue?.intValue == topLevelChildIndex else {
1028
+ return attributedString
1029
+ }
1030
+
1031
+ let adjusted = NSMutableAttributedString(attributedString: attributedString)
1032
+ var effectiveRange = NSRange(location: 0, length: 0)
1033
+ adjusted.attribute(
1034
+ RenderBridgeAttributes.topLevelChildIndex,
1035
+ at: 0,
1036
+ longestEffectiveRange: &effectiveRange,
1037
+ in: NSRange(location: 0, length: adjusted.length)
1038
+ )
1039
+ adjusted.removeAttribute(
1040
+ RenderBridgeAttributes.topLevelChildIndex,
1041
+ range: effectiveRange
1042
+ )
1043
+ return adjusted
1044
+ }
1045
+
823
1046
  private static func effectiveBlockContext(_ blockStack: [BlockContext]) -> BlockContext? {
824
1047
  guard let currentBlock = blockStack.last else { return nil }
825
1048
  if currentBlock.listContext != nil {
@@ -883,7 +1106,11 @@ final class RenderBridge {
883
1106
 
884
1107
  let nsString = result.string as NSString
885
1108
  let paragraphRange = nsString.paragraphRange(for: NSRange(location: result.length - 1, length: 0))
886
- result.enumerateAttribute(.paragraphStyle, in: paragraphRange, options: []) { value, range, _ in
1109
+ result.enumerateAttribute(
1110
+ .paragraphStyle,
1111
+ in: paragraphRange,
1112
+ options: [.longestEffectiveRangeNotRequired]
1113
+ ) { value, range, _ in
887
1114
  let sourceStyle = (value as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle
888
1115
  ?? NSMutableParagraphStyle()
889
1116
  sourceStyle.paragraphSpacing = paragraphSpacing
@@ -891,6 +1118,54 @@ final class RenderBridge {
891
1118
  }
892
1119
  }
893
1120
 
1121
+ private static func collapseTrailingSpacingBeforeHorizontalRuleIfNeeded(
1122
+ in result: NSMutableAttributedString,
1123
+ pendingParagraphSpacing: inout CGFloat?,
1124
+ nodeType: String,
1125
+ theme: EditorTheme?
1126
+ ) {
1127
+ guard nodeType == "horizontalRule" else { return }
1128
+ let horizontalRuleMargin = resolvedHorizontalRuleVerticalMargin(theme: theme)
1129
+
1130
+ if let pendingSpacing = pendingParagraphSpacing {
1131
+ pendingParagraphSpacing = collapsedSpacing(
1132
+ existingSpacing: pendingSpacing,
1133
+ adjacentHorizontalRuleMargin: horizontalRuleMargin
1134
+ )
1135
+ return
1136
+ }
1137
+
1138
+ guard let trailingParagraphSpacing = trailingParagraphSpacing(in: result) else { return }
1139
+ let adjustedSpacing = collapsedSpacing(
1140
+ existingSpacing: trailingParagraphSpacing,
1141
+ adjacentHorizontalRuleMargin: horizontalRuleMargin
1142
+ )
1143
+ guard abs(adjustedSpacing - trailingParagraphSpacing) > 0.01 else { return }
1144
+ overrideTrailingParagraphSpacing(in: result, paragraphSpacing: adjustedSpacing)
1145
+ }
1146
+
1147
+ private static func collapsedParagraphSpacingAfterHorizontalRule(
1148
+ in result: NSAttributedString,
1149
+ separatorBlockStack: [BlockContext],
1150
+ theme: EditorTheme?,
1151
+ baseFont: UIFont
1152
+ ) -> CGFloat? {
1153
+ guard let horizontalRuleMargin = trailingHorizontalRuleMargin(in: result),
1154
+ let separatorSpacing = separatorParagraphSpacing(
1155
+ for: separatorBlockStack,
1156
+ theme: theme,
1157
+ baseFont: baseFont
1158
+ )
1159
+ else {
1160
+ return nil
1161
+ }
1162
+
1163
+ return collapsedSpacing(
1164
+ existingSpacing: separatorSpacing,
1165
+ adjacentHorizontalRuleMargin: horizontalRuleMargin
1166
+ )
1167
+ }
1168
+
894
1169
  @discardableResult
895
1170
  private static func applyPendingTrailingParagraphSpacing(
896
1171
  in result: NSMutableAttributedString,
@@ -902,6 +1177,75 @@ final class RenderBridge {
902
1177
  return true
903
1178
  }
904
1179
 
1180
+ private static func trailingParagraphSpacing(in result: NSAttributedString) -> CGFloat? {
1181
+ guard result.length > 0 else { return nil }
1182
+
1183
+ let nsString = result.string as NSString
1184
+ let paragraphRange = nsString.paragraphRange(for: NSRange(location: result.length - 1, length: 0))
1185
+ var spacing: CGFloat? = nil
1186
+ result.enumerateAttribute(
1187
+ .paragraphStyle,
1188
+ in: paragraphRange,
1189
+ options: [.reverse, .longestEffectiveRangeNotRequired]
1190
+ ) { value, _, stop in
1191
+ if let paragraphStyle = value as? NSParagraphStyle {
1192
+ spacing = paragraphStyle.paragraphSpacing
1193
+ stop.pointee = true
1194
+ }
1195
+ }
1196
+ return spacing
1197
+ }
1198
+
1199
+ private static func separatorParagraphSpacing(
1200
+ for blockStack: [BlockContext],
1201
+ theme: EditorTheme?,
1202
+ baseFont: UIFont
1203
+ ) -> CGFloat? {
1204
+ guard let currentBlock = effectiveBlockContext(blockStack) else { return nil }
1205
+ return paragraphStyleForBlock(
1206
+ currentBlock,
1207
+ blockStack: blockStack,
1208
+ theme: theme,
1209
+ baseFont: baseFont
1210
+ ).paragraphSpacing
1211
+ }
1212
+
1213
+ private static func trailingHorizontalRuleMargin(in result: NSAttributedString) -> CGFloat? {
1214
+ guard result.length > 0 else { return nil }
1215
+ let nsString = result.string as NSString
1216
+
1217
+ for index in stride(from: result.length - 1, through: 0, by: -1) {
1218
+ let scalar = nsString.character(at: index)
1219
+ if scalar == 0x000A || scalar == 0x000D {
1220
+ continue
1221
+ }
1222
+ guard result.attribute(
1223
+ RenderBridgeAttributes.voidNodeType,
1224
+ at: index,
1225
+ effectiveRange: nil
1226
+ ) as? String == "horizontalRule" else {
1227
+ return nil
1228
+ }
1229
+ return (
1230
+ result.attribute(.attachment, at: index, effectiveRange: nil)
1231
+ as? HorizontalRuleAttachment
1232
+ )?.verticalPadding
1233
+ }
1234
+
1235
+ return nil
1236
+ }
1237
+
1238
+ private static func resolvedHorizontalRuleVerticalMargin(theme: EditorTheme?) -> CGFloat {
1239
+ theme?.horizontalRule?.verticalMargin ?? LayoutConstants.horizontalRuleVerticalPadding
1240
+ }
1241
+
1242
+ private static func collapsedSpacing(
1243
+ existingSpacing: CGFloat,
1244
+ adjacentHorizontalRuleMargin: CGFloat
1245
+ ) -> CGFloat {
1246
+ max(existingSpacing, adjacentHorizontalRuleMargin) - adjacentHorizontalRuleMargin
1247
+ }
1248
+
905
1249
  private static func appendTrailingHardBreakPlaceholderIfNeeded(
906
1250
  in result: NSMutableAttributedString,
907
1251
  endedBlock: BlockContext,
@@ -1004,6 +1348,7 @@ struct BlockContext {
1004
1348
  let nodeType: String
1005
1349
  let depth: UInt8
1006
1350
  var listContext: [String: Any]?
1351
+ var topLevelChildIndex: Int? = nil
1007
1352
  var listMarkerContext: [String: Any]? = nil
1008
1353
  var markerPending: Bool = false
1009
1354
  }
@@ -1176,25 +1521,15 @@ final class BlockImageAttachment: NSTextAttachment {
1176
1521
  }
1177
1522
 
1178
1523
  guard let url = URL(string: source) else { return }
1179
- RenderImageCache.queue.async { [weak self] in
1180
- guard let self else { return }
1181
- let data: Data?
1182
- if url.isFileURL {
1183
- data = try? Data(contentsOf: url)
1184
- } else {
1185
- data = try? Data(contentsOf: url)
1186
- }
1187
- guard let data,
1188
- let image = UIImage(data: data)
1524
+ RenderImageCache.load(source: source, url: url) { [weak self] image in
1525
+ guard let self,
1526
+ let image
1189
1527
  else {
1190
1528
  return
1191
1529
  }
1192
- RenderImageCache.cache.setObject(image, forKey: self.source as NSString)
1193
- DispatchQueue.main.async {
1194
- self.loadedImage = image
1195
- self.image = image
1196
- NotificationCenter.default.post(name: .editorImageAttachmentDidLoad, object: self)
1197
- }
1530
+ self.loadedImage = image
1531
+ self.image = image
1532
+ NotificationCenter.default.post(name: .editorImageAttachmentDidLoad, object: self)
1198
1533
  }
1199
1534
  }
1200
1535