@apollohg/react-native-prose-editor 0.1.1 → 0.3.0

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 (53) hide show
  1. package/README.md +12 -7
  2. package/android/build.gradle +7 -2
  3. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +289 -2
  4. package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +51 -1
  5. package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +199 -0
  6. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +16 -3
  7. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +82 -1
  8. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +403 -45
  9. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +246 -0
  10. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +841 -155
  11. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +125 -8
  12. package/{src/EditorTheme.ts → dist/EditorTheme.d.ts} +12 -52
  13. package/dist/EditorTheme.js +29 -0
  14. package/dist/EditorToolbar.d.ts +129 -0
  15. package/dist/EditorToolbar.js +394 -0
  16. package/dist/NativeEditorBridge.d.ts +242 -0
  17. package/dist/NativeEditorBridge.js +647 -0
  18. package/dist/NativeRichTextEditor.d.ts +142 -0
  19. package/dist/NativeRichTextEditor.js +649 -0
  20. package/dist/YjsCollaboration.d.ts +83 -0
  21. package/dist/YjsCollaboration.js +585 -0
  22. package/dist/addons.d.ts +70 -0
  23. package/dist/addons.js +77 -0
  24. package/dist/index.d.ts +8 -0
  25. package/dist/index.js +26 -0
  26. package/dist/schemas.d.ts +35 -0
  27. package/{src/schemas.ts → dist/schemas.js} +62 -27
  28. package/dist/useNativeEditor.d.ts +40 -0
  29. package/dist/useNativeEditor.js +117 -0
  30. package/ios/EditorAddons.swift +26 -3
  31. package/ios/EditorCore.xcframework/Info.plist +5 -5
  32. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  33. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  34. package/ios/EditorLayoutManager.swift +236 -0
  35. package/ios/EditorTheme.swift +51 -1
  36. package/ios/Generated_editor_core.swift +270 -2
  37. package/ios/NativeEditorExpoView.swift +612 -45
  38. package/ios/NativeEditorModule.swift +81 -0
  39. package/ios/PositionBridge.swift +22 -0
  40. package/ios/RenderBridge.swift +427 -39
  41. package/ios/RichTextEditorView.swift +1342 -18
  42. package/ios/editor_coreFFI/editor_coreFFI.h +209 -0
  43. package/package.json +80 -64
  44. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  45. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  46. package/rust/android/x86_64/libeditor_core.so +0 -0
  47. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +404 -4
  48. package/src/EditorToolbar.tsx +0 -620
  49. package/src/NativeEditorBridge.ts +0 -607
  50. package/src/NativeRichTextEditor.tsx +0 -951
  51. package/src/addons.ts +0 -158
  52. package/src/index.ts +0 -63
  53. package/src/useNativeEditor.ts +0 -173
@@ -1,5 +1,16 @@
1
1
  import UIKit
2
2
 
3
+ extension Notification.Name {
4
+ static let editorImageAttachmentDidLoad = Notification.Name(
5
+ "com.apollohg.editor.imageAttachmentDidLoad"
6
+ )
7
+ }
8
+
9
+ private enum RenderImageCache {
10
+ static let cache = NSCache<NSString, UIImage>()
11
+ static let queue = DispatchQueue(label: "com.apollohg.editor.image-loader", qos: .userInitiated)
12
+ }
13
+
3
14
  // MARK: - Constants
4
15
 
5
16
  /// Custom NSAttributedString attribute keys for editor metadata.
@@ -34,6 +45,18 @@ enum RenderBridgeAttributes {
34
45
 
35
46
  /// Stores the reserved list marker gutter width.
36
47
  static let listMarkerWidth = NSAttributedString.Key("com.apollohg.editor.listMarkerWidth")
48
+
49
+ /// Stores the rendered blockquote border color.
50
+ static let blockquoteBorderColor = NSAttributedString.Key("com.apollohg.editor.blockquoteBorderColor")
51
+
52
+ /// Stores the rendered blockquote border width.
53
+ static let blockquoteBorderWidth = NSAttributedString.Key("com.apollohg.editor.blockquoteBorderWidth")
54
+
55
+ /// Stores the rendered blockquote gap between border and text.
56
+ static let blockquoteMarkerGap = NSAttributedString.Key("com.apollohg.editor.blockquoteMarkerGap")
57
+
58
+ /// Marks synthetic zero-width placeholders used only for UIKit layout.
59
+ static let syntheticPlaceholder = NSAttributedString.Key("com.apollohg.editor.syntheticPlaceholder")
37
60
  }
38
61
 
39
62
  /// Layout constants for paragraph styles.
@@ -56,6 +79,15 @@ enum LayoutConstants {
56
79
  /// Vertical padding above and below the horizontal rule (points).
57
80
  static let horizontalRuleVerticalPadding: CGFloat = 8.0
58
81
 
82
+ /// Total leading inset reserved for each blockquote depth.
83
+ static let blockquoteIndent: CGFloat = 18.0
84
+
85
+ /// Width of the rendered blockquote border bar.
86
+ static let blockquoteBorderWidth: CGFloat = 3.0
87
+
88
+ /// Gap between the blockquote border bar and the text that follows.
89
+ static let blockquoteMarkerGap: CGFloat = 8.0
90
+
59
91
  /// Bullet character for unordered list items.
60
92
  static let unorderedListBullet = "\u{2022} "
61
93
 
@@ -141,7 +173,7 @@ final class RenderBridge {
141
173
  switch type {
142
174
  case "textRun":
143
175
  let text = element["text"] as? String ?? ""
144
- let marks = element["marks"] as? [String] ?? []
176
+ let marks = element["marks"] as? [Any] ?? []
145
177
  let blockFont = resolvedFont(
146
178
  for: blockStack,
147
179
  baseFont: baseFont,
@@ -167,9 +199,14 @@ final class RenderBridge {
167
199
  case "voidInline":
168
200
  let nodeType = element["nodeType"] as? String ?? ""
169
201
  let docPos = jsonUInt32(element["docPos"])
202
+ let attrs = element["attrs"] as? [String: Any] ?? [:]
203
+ if nodeType == "hardBreak" {
204
+ overrideTrailingParagraphSpacing(in: result, paragraphSpacing: 0)
205
+ }
170
206
  let attrStr = attributedStringForVoidInline(
171
207
  nodeType: nodeType,
172
208
  docPos: docPos,
209
+ attrs: attrs,
173
210
  baseFont: baseFont,
174
211
  textColor: textColor,
175
212
  blockStack: blockStack,
@@ -180,6 +217,7 @@ final class RenderBridge {
180
217
  case "voidBlock":
181
218
  let nodeType = element["nodeType"] as? String ?? ""
182
219
  let docPos = jsonUInt32(element["docPos"])
220
+ let attrs = element["attrs"] as? [String: Any] ?? [:]
183
221
 
184
222
  // Add inter-block newline if not the first block.
185
223
  if !isFirstBlock {
@@ -187,13 +225,21 @@ final class RenderBridge {
187
225
  in: result,
188
226
  pendingParagraphSpacing: &pendingTrailingParagraphSpacing
189
227
  )
190
- result.append(interBlockNewline(baseFont: baseFont, textColor: textColor))
228
+ result.append(
229
+ interBlockNewline(
230
+ baseFont: baseFont,
231
+ textColor: textColor,
232
+ blockStack: [],
233
+ theme: theme
234
+ )
235
+ )
191
236
  }
192
237
  isFirstBlock = false
193
238
 
194
239
  let attrStr = attributedStringForVoidBlock(
195
240
  nodeType: nodeType,
196
241
  docPos: docPos,
242
+ elementAttrs: attrs,
197
243
  baseFont: baseFont,
198
244
  textColor: textColor,
199
245
  theme: theme
@@ -225,7 +271,14 @@ final class RenderBridge {
225
271
  in: result,
226
272
  pendingParagraphSpacing: &pendingTrailingParagraphSpacing
227
273
  )
228
- result.append(interBlockNewline(baseFont: baseFont, textColor: textColor))
274
+ result.append(
275
+ interBlockNewline(
276
+ baseFont: baseFont,
277
+ textColor: textColor,
278
+ blockStack: [],
279
+ theme: theme
280
+ )
281
+ )
229
282
  }
230
283
  isFirstBlock = false
231
284
 
@@ -244,18 +297,40 @@ final class RenderBridge {
244
297
  let depth = jsonUInt8(element["depth"])
245
298
  let listContext = element["listContext"] as? [String: Any]
246
299
  let isListItemContainer = nodeType == "listItem" && listContext != nil
300
+ let isTransparentContainer = nodeType == "blockquote"
301
+ let ctx = BlockContext(
302
+ nodeType: nodeType,
303
+ depth: depth,
304
+ listContext: listContext,
305
+ markerPending: isListItemContainer
306
+ )
247
307
  let nestedListItemContainer =
248
308
  isListItemContainer && (theme?.list?.itemSpacing != nil)
249
309
  && blockStack.contains(where: { $0.nodeType == "listItem" && $0.listContext != nil })
250
310
 
251
- if !isListItemContainer {
311
+ if !isListItemContainer && !isTransparentContainer {
252
312
  // Add inter-block newline before non-first rendered blocks.
253
313
  if !isFirstBlock {
254
314
  applyPendingTrailingParagraphSpacing(
255
315
  in: result,
256
316
  pendingParagraphSpacing: &pendingTrailingParagraphSpacing
257
317
  )
258
- result.append(interBlockNewline(baseFont: baseFont, textColor: textColor))
318
+ let newlineBlockStack: [BlockContext]
319
+ if blockquoteDepth(in: blockStack + [ctx]) > 0,
320
+ !trailingRenderedContentHasBlockquote(in: result)
321
+ {
322
+ newlineBlockStack = []
323
+ } else {
324
+ newlineBlockStack = blockStack + [ctx]
325
+ }
326
+ result.append(
327
+ interBlockNewline(
328
+ baseFont: baseFont,
329
+ textColor: textColor,
330
+ blockStack: newlineBlockStack,
331
+ theme: theme
332
+ )
333
+ )
259
334
  }
260
335
  isFirstBlock = false
261
336
  } else if applyPendingTrailingParagraphSpacing(
@@ -271,12 +346,6 @@ final class RenderBridge {
271
346
  }
272
347
 
273
348
  // Push block context for inline children to reference.
274
- let ctx = BlockContext(
275
- nodeType: nodeType,
276
- depth: depth,
277
- listContext: listContext,
278
- markerPending: isListItemContainer
279
- )
280
349
  blockStack.append(ctx)
281
350
 
282
351
  var markerListContext: [String: Any]? = nil
@@ -291,7 +360,7 @@ final class RenderBridge {
291
360
  if markerListContext != nil {
292
361
  if var currentBlock = blockStack.popLast() {
293
362
  currentBlock.listMarkerContext = markerListContext
294
- if currentBlock.listContext == nil {
363
+ if currentBlock.listContext != nil {
295
364
  currentBlock.listContext = markerListContext
296
365
  }
297
366
  blockStack.append(currentBlock)
@@ -301,8 +370,18 @@ final class RenderBridge {
301
370
  }
302
371
 
303
372
  case "blockEnd":
304
- if let endedBlock = blockStack.popLast(), endedBlock.listContext != nil {
305
- pendingTrailingParagraphSpacing = theme?.list?.itemSpacing
373
+ if let endedBlock = blockStack.popLast() {
374
+ appendTrailingHardBreakPlaceholderIfNeeded(
375
+ in: result,
376
+ endedBlock: endedBlock,
377
+ remainingBlockStack: blockStack,
378
+ baseFont: baseFont,
379
+ textColor: textColor,
380
+ theme: theme
381
+ )
382
+ if endedBlock.listContext != nil {
383
+ pendingTrailingParagraphSpacing = theme?.list?.itemSpacing
384
+ }
306
385
  }
307
386
 
308
387
  default:
@@ -315,7 +394,7 @@ final class RenderBridge {
315
394
 
316
395
  // MARK: - Mark Handling
317
396
 
318
- /// Build NSAttributedString attributes for a set of mark names.
397
+ /// Build NSAttributedString attributes for a set of render marks.
319
398
  ///
320
399
  /// Supported marks:
321
400
  /// - `bold` -> adds `.traitBold` to the font descriptor
@@ -326,7 +405,7 @@ final class RenderBridge {
326
405
  ///
327
406
  /// Multiple marks are combined: "bold italic" produces a bold-italic font.
328
407
  static func attributesForMarks(
329
- _ marks: [String],
408
+ _ marks: [Any],
330
409
  baseFont: UIFont,
331
410
  textColor: UIColor
332
411
  ) -> [NSAttributedString.Key: Any] {
@@ -338,9 +417,18 @@ final class RenderBridge {
338
417
 
339
418
  var traits: UIFontDescriptor.SymbolicTraits = []
340
419
  var useMonospace = false
341
-
342
420
  for mark in marks {
343
- switch mark {
421
+ let markType: String
422
+ if let markName = mark as? String {
423
+ markType = markName
424
+ } else if let markObject = mark as? [String: Any],
425
+ let resolvedType = markObject["type"] as? String {
426
+ markType = resolvedType
427
+ } else {
428
+ continue
429
+ }
430
+
431
+ switch markType {
344
432
  case "bold", "strong":
345
433
  traits.insert(.traitBold)
346
434
  case "italic", "em":
@@ -351,6 +439,9 @@ final class RenderBridge {
351
439
  attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
352
440
  case "code":
353
441
  useMonospace = true
442
+ case "link":
443
+ attrs[.underlineStyle] = NSUnderlineStyle.single.rawValue
444
+ attrs[.foregroundColor] = UIColor.systemBlue
354
445
  default:
355
446
  break
356
447
  }
@@ -391,6 +482,7 @@ final class RenderBridge {
391
482
  private static func attributedStringForVoidInline(
392
483
  nodeType: String,
393
484
  docPos: UInt32,
485
+ attrs _: [String: Any],
394
486
  baseFont: UIFont,
395
487
  textColor: UIColor,
396
488
  blockStack: [BlockContext],
@@ -409,7 +501,14 @@ final class RenderBridge {
409
501
 
410
502
  switch nodeType {
411
503
  case "hardBreak":
412
- return NSAttributedString(string: "\n", attributes: styledAttrs)
504
+ var hardBreakAttrs = styledAttrs
505
+ if let paragraphStyle = (hardBreakAttrs[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy()
506
+ as? NSMutableParagraphStyle
507
+ {
508
+ paragraphStyle.paragraphSpacing = 0
509
+ hardBreakAttrs[.paragraphStyle] = paragraphStyle
510
+ }
511
+ return NSAttributedString(string: "\n", attributes: hardBreakAttrs)
413
512
  default:
414
513
  // Unknown void inline: render as object replacement character.
415
514
  return NSAttributedString(
@@ -428,6 +527,7 @@ final class RenderBridge {
428
527
  private static func attributedStringForVoidBlock(
429
528
  nodeType: String,
430
529
  docPos: UInt32,
530
+ elementAttrs: [String: Any],
431
531
  baseFont: UIFont,
432
532
  textColor: UIColor,
433
533
  theme: EditorTheme?
@@ -449,6 +549,25 @@ final class RenderBridge {
449
549
  let range = NSRange(location: 0, length: attrStr.length)
450
550
  attrStr.addAttributes(attrs, range: range)
451
551
  return attrStr
552
+ case "image":
553
+ guard let source = (elementAttrs["src"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
554
+ !source.isEmpty
555
+ else {
556
+ return NSAttributedString(
557
+ string: LayoutConstants.objectReplacementCharacter,
558
+ attributes: attrs
559
+ )
560
+ }
561
+ let attachment = BlockImageAttachment(
562
+ source: source,
563
+ placeholderTint: textColor,
564
+ preferredWidth: jsonCGFloat(elementAttrs["width"]),
565
+ preferredHeight: jsonCGFloat(elementAttrs["height"])
566
+ )
567
+ let attrStr = NSMutableAttributedString(attachment: attachment)
568
+ let range = NSRange(location: 0, length: attrStr.length)
569
+ attrStr.addAttributes(attrs, range: range)
570
+ return attrStr
452
571
  default:
453
572
  // Unknown void block: render as object replacement character.
454
573
  return NSAttributedString(
@@ -539,11 +658,15 @@ final class RenderBridge {
539
658
  /// a hanging indent so the bullet/number sits in the margin.
540
659
  static func paragraphStyleForBlock(
541
660
  _ context: BlockContext,
661
+ blockStack: [BlockContext],
542
662
  theme: EditorTheme? = nil,
543
663
  baseFont: UIFont = .systemFont(ofSize: 16)
544
664
  ) -> NSMutableParagraphStyle {
545
665
  let style = NSMutableParagraphStyle()
546
- let blockStyle = theme?.effectiveTextStyle(for: context.nodeType)
666
+ let blockStyle = theme?.effectiveTextStyle(
667
+ for: context.nodeType,
668
+ inBlockquote: blockquoteDepth(in: blockStack) > 0
669
+ )
547
670
  let spacing = blockStyle?.spacingAfter
548
671
  ?? (context.listContext != nil ? theme?.list?.itemSpacing : nil)
549
672
  ?? LayoutConstants.paragraphSpacing
@@ -551,7 +674,15 @@ final class RenderBridge {
551
674
 
552
675
  let indentPerDepth = theme?.list?.indent ?? LayoutConstants.indentPerDepth
553
676
  let markerWidth = listMarkerWidth(for: context, theme: theme, baseFont: baseFont)
554
- let baseIndent = CGFloat(context.depth) * indentPerDepth
677
+ let quoteDepth = CGFloat(blockquoteDepth(in: blockStack))
678
+ let quoteIndent = max(
679
+ theme?.blockquote?.indent ?? LayoutConstants.blockquoteIndent,
680
+ (theme?.blockquote?.markerGap ?? LayoutConstants.blockquoteMarkerGap)
681
+ + (theme?.blockquote?.borderWidth ?? LayoutConstants.blockquoteBorderWidth)
682
+ )
683
+ let baseIndent = (CGFloat(context.depth) * indentPerDepth)
684
+ - (quoteDepth * indentPerDepth)
685
+ + (quoteDepth * quoteIndent)
555
686
 
556
687
  if context.listContext != nil {
557
688
  // List item: reserve a fixed gutter and align all wrapped lines to
@@ -587,11 +718,7 @@ final class RenderBridge {
587
718
 
588
719
  // MARK: - Private Helpers
589
720
 
590
- /// Safely extract a UInt32 from a JSON dictionary value.
591
- ///
592
- /// `JSONSerialization` produces `NSNumber` for numeric values. Direct `as? UInt32`
593
- /// cast may fail depending on the stored numeric type. This helper handles all
594
- /// numeric types that `JSONSerialization` can produce.
721
+ /// Extract a `UInt32` from a JSON value produced by `JSONSerialization`.
595
722
  static func jsonUInt32(_ value: Any?) -> UInt32 {
596
723
  if let number = value as? NSNumber {
597
724
  return number.uint32Value
@@ -599,7 +726,7 @@ final class RenderBridge {
599
726
  return 0
600
727
  }
601
728
 
602
- /// Safely extract a UInt8 from a JSON dictionary value.
729
+ /// Extract a `UInt8` from a JSON value produced by `JSONSerialization`.
603
730
  static func jsonUInt8(_ value: Any?) -> UInt8 {
604
731
  if let number = value as? NSNumber {
605
732
  return number.uint8Value
@@ -607,7 +734,21 @@ final class RenderBridge {
607
734
  return 0
608
735
  }
609
736
 
610
- /// Default attributed string attributes (font + color, no special styling).
737
+ /// Extract a positive `CGFloat` from a JSON value produced by `JSONSerialization`.
738
+ static func jsonCGFloat(_ value: Any?) -> CGFloat? {
739
+ if let number = value as? NSNumber {
740
+ let resolved = CGFloat(truncating: number)
741
+ return resolved > 0 ? resolved : nil
742
+ }
743
+ if let string = value as? String,
744
+ let resolved = Double(string.trimmingCharacters(in: .whitespacesAndNewlines)),
745
+ resolved > 0
746
+ {
747
+ return CGFloat(resolved)
748
+ }
749
+ return nil
750
+ }
751
+
611
752
  private static func defaultAttributes(
612
753
  baseFont: UIFont,
613
754
  textColor: UIColor
@@ -618,9 +759,6 @@ final class RenderBridge {
618
759
  ]
619
760
  }
620
761
 
621
- /// Apply the current block context's paragraph style to a mutable attributes dictionary.
622
- ///
623
- /// This is a no-op if no block context is active.
624
762
  @discardableResult
625
763
  private static func applyBlockStyle(
626
764
  to attrs: [NSAttributedString.Key: Any],
@@ -632,6 +770,7 @@ final class RenderBridge {
632
770
  let blockFont = mutableAttrs[.font] as? UIFont ?? .systemFont(ofSize: 16)
633
771
  mutableAttrs[.paragraphStyle] = paragraphStyleForBlock(
634
772
  currentBlock,
773
+ blockStack: blockStack,
635
774
  theme: theme,
636
775
  baseFont: blockFont
637
776
  )
@@ -650,6 +789,16 @@ final class RenderBridge {
650
789
  baseFont: blockFont
651
790
  )
652
791
  }
792
+ if blockquoteDepth(in: blockStack) > 0 {
793
+ let foreground = mutableAttrs[.foregroundColor] as? UIColor ?? .separator
794
+ mutableAttrs[RenderBridgeAttributes.blockquoteBorderColor] =
795
+ theme?.blockquote?.borderColor
796
+ ?? foreground.withAlphaComponent(0.3)
797
+ mutableAttrs[RenderBridgeAttributes.blockquoteBorderWidth] =
798
+ theme?.blockquote?.borderWidth ?? LayoutConstants.blockquoteBorderWidth
799
+ mutableAttrs[RenderBridgeAttributes.blockquoteMarkerGap] =
800
+ theme?.blockquote?.markerGap ?? LayoutConstants.blockquoteMarkerGap
801
+ }
653
802
  return mutableAttrs
654
803
  }
655
804
 
@@ -659,9 +808,15 @@ final class RenderBridge {
659
808
  /// It carries minimal styling (base font, no special attributes).
660
809
  private static func interBlockNewline(
661
810
  baseFont: UIFont,
662
- textColor: UIColor
811
+ textColor: UIColor,
812
+ blockStack: [BlockContext],
813
+ theme: EditorTheme?
663
814
  ) -> NSAttributedString {
664
- let attrs = defaultAttributes(baseFont: baseFont, textColor: textColor)
815
+ let attrs = applyBlockStyle(
816
+ to: defaultAttributes(baseFont: baseFont, textColor: textColor),
817
+ blockStack: blockStack,
818
+ theme: theme
819
+ )
665
820
  return NSAttributedString(string: "\n", attributes: attrs)
666
821
  }
667
822
 
@@ -670,24 +825,46 @@ final class RenderBridge {
670
825
  if currentBlock.listContext != nil {
671
826
  return currentBlock
672
827
  }
673
- guard let inheritedListContext = nearestListContext(in: Array(blockStack.dropLast())) else {
828
+ guard let inheritedListBlock = nearestListBlock(in: Array(blockStack.dropLast())) else {
674
829
  return currentBlock
675
830
  }
676
831
  return BlockContext(
677
832
  nodeType: currentBlock.nodeType,
678
833
  depth: currentBlock.depth,
679
- listContext: inheritedListContext,
834
+ listContext: inheritedListBlock.listContext,
835
+ listMarkerContext: currentBlock.listMarkerContext,
680
836
  markerPending: false
681
837
  )
682
838
  }
683
839
 
684
- private static func nearestListContext(in contexts: [BlockContext]) -> [String: Any]? {
840
+ private static func nearestListBlock(in contexts: [BlockContext]) -> BlockContext? {
685
841
  for context in contexts.reversed() where context.listContext != nil {
686
- return context.listContext
842
+ return context
687
843
  }
688
844
  return nil
689
845
  }
690
846
 
847
+ private static func trailingRenderedContentHasBlockquote(
848
+ in result: NSAttributedString
849
+ ) -> Bool {
850
+ guard result.length > 0 else { return false }
851
+ let nsString = result.string as NSString
852
+
853
+ for index in stride(from: result.length - 1, through: 0, by: -1) {
854
+ let scalar = nsString.character(at: index)
855
+ if scalar == 0x000A || scalar == 0x000D {
856
+ continue
857
+ }
858
+ return result.attribute(
859
+ RenderBridgeAttributes.blockquoteBorderColor,
860
+ at: index,
861
+ effectiveRange: nil
862
+ ) != nil
863
+ }
864
+
865
+ return false
866
+ }
867
+
691
868
  private static func consumePendingListMarker(from blockStack: inout [BlockContext]) -> [String: Any]? {
692
869
  guard blockStack.count >= 2 else { return nil }
693
870
  for idx in stride(from: blockStack.count - 2, through: 0, by: -1) {
@@ -725,6 +902,51 @@ final class RenderBridge {
725
902
  return true
726
903
  }
727
904
 
905
+ private static func appendTrailingHardBreakPlaceholderIfNeeded(
906
+ in result: NSMutableAttributedString,
907
+ endedBlock: BlockContext,
908
+ remainingBlockStack: [BlockContext],
909
+ baseFont: UIFont,
910
+ textColor: UIColor,
911
+ theme: EditorTheme?
912
+ ) {
913
+ guard result.length > 0 else { return }
914
+ guard endedBlock.nodeType != "listItem" else { return }
915
+ guard result.attribute(
916
+ RenderBridgeAttributes.voidNodeType,
917
+ at: result.length - 1,
918
+ effectiveRange: nil
919
+ ) as? String == "hardBreak" else {
920
+ return
921
+ }
922
+
923
+ let placeholderBlockStack = remainingBlockStack + [endedBlock]
924
+ let blockFont = resolvedFont(
925
+ for: placeholderBlockStack,
926
+ baseFont: baseFont,
927
+ theme: theme
928
+ )
929
+ let blockColor = resolvedTextColor(
930
+ for: placeholderBlockStack,
931
+ textColor: textColor,
932
+ theme: theme
933
+ )
934
+ var attrs = defaultAttributes(baseFont: blockFont, textColor: blockColor)
935
+ attrs[RenderBridgeAttributes.syntheticPlaceholder] = true
936
+ var styledAttrs = applyBlockStyle(
937
+ to: attrs,
938
+ blockStack: placeholderBlockStack,
939
+ theme: theme
940
+ )
941
+ if let paragraphStyle = (styledAttrs[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy()
942
+ as? NSMutableParagraphStyle
943
+ {
944
+ paragraphStyle.paragraphSpacing = 0
945
+ styledAttrs[.paragraphStyle] = paragraphStyle
946
+ }
947
+ result.append(NSAttributedString(string: "\u{200B}", attributes: styledAttrs))
948
+ }
949
+
728
950
  private static func listMarkerWidth(
729
951
  for context: BlockContext,
730
952
  theme: EditorTheme?,
@@ -741,10 +963,19 @@ final class RenderBridge {
741
963
  for blockStack: [BlockContext],
742
964
  theme: EditorTheme?
743
965
  ) -> EditorTextStyle? {
966
+ let inBlockquote = blockquoteDepth(in: blockStack) > 0
744
967
  guard let currentBlock = effectiveBlockContext(blockStack) else {
745
- return theme?.effectiveTextStyle(for: "paragraph")
968
+ return theme?.effectiveTextStyle(for: "paragraph", inBlockquote: inBlockquote)
969
+ }
970
+ return theme?.effectiveTextStyle(for: currentBlock.nodeType, inBlockquote: inBlockquote)
971
+ }
972
+
973
+ private static func blockquoteDepth(in blockStack: [BlockContext]) -> Int {
974
+ blockStack.reduce(into: 0) { count, context in
975
+ if context.nodeType == "blockquote" {
976
+ count += 1
977
+ }
746
978
  }
747
- return theme?.effectiveTextStyle(for: currentBlock.nodeType)
748
979
  }
749
980
 
750
981
  private static func resolvedFont(
@@ -823,3 +1054,160 @@ final class HorizontalRuleAttachment: NSTextAttachment {
823
1054
  }
824
1055
  }
825
1056
  }
1057
+
1058
+ final class BlockImageAttachment: NSTextAttachment {
1059
+ private let source: String
1060
+ private let placeholderTint: UIColor
1061
+ private var preferredWidth: CGFloat?
1062
+ private var preferredHeight: CGFloat?
1063
+ private var loadedImage: UIImage?
1064
+
1065
+ init(
1066
+ source: String,
1067
+ placeholderTint: UIColor,
1068
+ preferredWidth: CGFloat?,
1069
+ preferredHeight: CGFloat?
1070
+ ) {
1071
+ self.source = source
1072
+ self.placeholderTint = placeholderTint
1073
+ self.preferredWidth = preferredWidth
1074
+ self.preferredHeight = preferredHeight
1075
+ super.init(data: nil, ofType: nil)
1076
+ loadImageIfNeeded()
1077
+ }
1078
+
1079
+ required init?(coder: NSCoder) {
1080
+ return nil
1081
+ }
1082
+
1083
+ func setPreferredSize(width: CGFloat, height: CGFloat) {
1084
+ preferredWidth = width
1085
+ preferredHeight = height
1086
+ }
1087
+
1088
+ func previewImage() -> UIImage? {
1089
+ loadedImage ?? image
1090
+ }
1091
+
1092
+ override func attachmentBounds(
1093
+ for textContainer: NSTextContainer?,
1094
+ proposedLineFragment lineFrag: CGRect,
1095
+ glyphPosition position: CGPoint,
1096
+ characterIndex charIndex: Int
1097
+ ) -> CGRect {
1098
+ let lineFragmentWidth = lineFrag.width.isFinite ? lineFrag.width : 0
1099
+ let containerWidth = textContainer.map {
1100
+ max(0, $0.size.width - ($0.lineFragmentPadding * 2))
1101
+ } ?? 0
1102
+ let widthCandidates = [lineFragmentWidth, containerWidth].filter { $0.isFinite && $0 > 0 }
1103
+ let maxWidth = max(160, widthCandidates.min() ?? 160)
1104
+ let fallbackAspectRatio = loadedImage.flatMap { image -> CGFloat? in
1105
+ let imageSize = image.size
1106
+ guard imageSize.width > 0, imageSize.height > 0 else { return nil }
1107
+ return imageSize.height / imageSize.width
1108
+ } ?? 0.56
1109
+
1110
+ var resolvedWidth = preferredWidth
1111
+ var resolvedHeight = preferredHeight
1112
+
1113
+ if resolvedWidth == nil, resolvedHeight == nil, let loadedImage {
1114
+ let imageSize = loadedImage.size
1115
+ if imageSize.width > 0, imageSize.height > 0 {
1116
+ resolvedWidth = imageSize.width
1117
+ resolvedHeight = imageSize.height
1118
+ }
1119
+ } else if resolvedWidth == nil, let resolvedHeight {
1120
+ resolvedWidth = resolvedHeight / fallbackAspectRatio
1121
+ } else if resolvedHeight == nil, let resolvedWidth {
1122
+ resolvedHeight = resolvedWidth * fallbackAspectRatio
1123
+ }
1124
+
1125
+ let width = max(1, resolvedWidth ?? maxWidth)
1126
+ let height = max(1, resolvedHeight ?? min(180, maxWidth * fallbackAspectRatio))
1127
+ let scale = min(1, maxWidth / width)
1128
+ return CGRect(x: 0, y: 0, width: width * scale, height: height * scale)
1129
+ }
1130
+
1131
+ override func image(
1132
+ forBounds imageBounds: CGRect,
1133
+ textContainer: NSTextContainer?,
1134
+ characterIndex charIndex: Int
1135
+ ) -> UIImage? {
1136
+ if let loadedImage {
1137
+ return loadedImage
1138
+ }
1139
+
1140
+ let renderer = UIGraphicsImageRenderer(bounds: imageBounds)
1141
+ return renderer.image { _ in
1142
+ let path = UIBezierPath(roundedRect: imageBounds, cornerRadius: 12)
1143
+ UIColor.secondarySystemFill.setFill()
1144
+ path.fill()
1145
+
1146
+ let iconSize = min(imageBounds.width, imageBounds.height) * 0.28
1147
+ let iconOrigin = CGPoint(
1148
+ x: imageBounds.midX - (iconSize / 2),
1149
+ y: imageBounds.midY - (iconSize / 2)
1150
+ )
1151
+ let iconRect = CGRect(origin: iconOrigin, size: CGSize(width: iconSize, height: iconSize))
1152
+
1153
+ if #available(iOS 13.0, *) {
1154
+ let config = UIImage.SymbolConfiguration(pointSize: iconSize, weight: .medium)
1155
+ let icon = UIImage(systemName: "photo", withConfiguration: config)?
1156
+ .withTintColor(placeholderTint.withAlphaComponent(0.7), renderingMode: .alwaysOriginal)
1157
+ icon?.draw(in: iconRect)
1158
+ }
1159
+ }
1160
+ }
1161
+
1162
+ private func loadImageIfNeeded() {
1163
+ if let cached = RenderImageCache.cache.object(forKey: source as NSString) {
1164
+ loadedImage = cached
1165
+ image = cached
1166
+ return
1167
+ }
1168
+
1169
+ if let inlineData = Self.decodeDataURL(source),
1170
+ let image = UIImage(data: inlineData)
1171
+ {
1172
+ RenderImageCache.cache.setObject(image, forKey: source as NSString)
1173
+ loadedImage = image
1174
+ self.image = image
1175
+ return
1176
+ }
1177
+
1178
+ 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)
1189
+ else {
1190
+ return
1191
+ }
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
+ }
1198
+ }
1199
+ }
1200
+
1201
+ private static func decodeDataURL(_ source: String) -> Data? {
1202
+ let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines)
1203
+ guard trimmed.lowercased().hasPrefix("data:image/"),
1204
+ let commaIndex = trimmed.firstIndex(of: ",")
1205
+ else {
1206
+ return nil
1207
+ }
1208
+ let metadata = String(trimmed[..<commaIndex]).lowercased()
1209
+ let payload = String(trimmed[trimmed.index(after: commaIndex)...])
1210
+ guard metadata.contains(";base64") else { return nil }
1211
+ return Data(base64Encoded: payload, options: [.ignoreUnknownCharacters])
1212
+ }
1213
+ }