@apollohg/react-native-prose-editor 0.2.0 → 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 (32) hide show
  1. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +228 -2
  2. package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +199 -0
  3. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +4 -0
  4. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +3 -0
  5. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +6 -0
  6. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +347 -10
  7. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +76 -8
  8. package/dist/EditorToolbar.d.ts +9 -2
  9. package/dist/EditorToolbar.js +20 -10
  10. package/dist/NativeEditorBridge.d.ts +2 -0
  11. package/dist/NativeEditorBridge.js +3 -0
  12. package/dist/NativeRichTextEditor.d.ts +17 -1
  13. package/dist/NativeRichTextEditor.js +94 -37
  14. package/dist/index.d.ts +2 -2
  15. package/dist/index.js +5 -1
  16. package/dist/schemas.d.ts +12 -0
  17. package/dist/schemas.js +45 -1
  18. package/ios/EditorCore.xcframework/Info.plist +5 -5
  19. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  20. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  21. package/ios/EditorLayoutManager.swift +0 -16
  22. package/ios/Generated_editor_core.swift +20 -2
  23. package/ios/NativeEditorExpoView.swift +51 -16
  24. package/ios/NativeEditorModule.swift +3 -0
  25. package/ios/RenderBridge.swift +208 -0
  26. package/ios/RichTextEditorView.swift +896 -15
  27. package/ios/editor_coreFFI/editor_coreFFI.h +11 -0
  28. package/package.json +1 -1
  29. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  30. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  31. package/rust/android/x86_64/libeditor_core.so +0 -0
  32. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +25 -2
@@ -649,12 +649,14 @@ public func editorCoreVersion() -> String {
649
649
  /**
650
650
  * Create a new editor from a JSON config object.
651
651
  *
652
- * Config fields (all optional — empty object `{}` creates a default editor):
652
+ * Config fields (all optional):
653
653
  * - `"schema"`: custom schema definition (see `Schema::from_json`)
654
654
  * - `"maxLength"`: maximum document length in characters
655
655
  * - `"readOnly"`: if `true`, rejects non-API mutations
656
656
  * - `"inputFilter"`: regex pattern; only matching characters are inserted
657
+ * - `"allowBase64Images"`: if `true`, parses `<img src="data:image/...">` as image nodes
657
658
  *
659
+ * An empty object creates a default editor.
658
660
  * Falls back to the default Tiptap schema when `"schema"` is absent or invalid.
659
661
  */
660
662
  public func editorCreate(configJson: String) -> UInt64 {
@@ -945,6 +947,19 @@ public func editorReplaceTextScalar(id: UInt64, scalarFrom: UInt32, scalarTo: UI
945
947
  )
946
948
  })
947
949
  }
950
+ /**
951
+ * Resize an image node at a document position. Returns an update JSON string.
952
+ */
953
+ public func editorResizeImageAtDocPos(id: UInt64, docPos: UInt32, width: UInt32, height: UInt32) -> String {
954
+ return try! FfiConverterString.lift(try! rustCall() {
955
+ uniffi_editor_core_fn_func_editor_resize_image_at_doc_pos(
956
+ FfiConverterUInt64.lower(id),
957
+ FfiConverterUInt32.lower(docPos),
958
+ FfiConverterUInt32.lower(width),
959
+ FfiConverterUInt32.lower(height),$0
960
+ )
961
+ })
962
+ }
948
963
  /**
949
964
  * Convert a rendered-text scalar offset to a document position.
950
965
  */
@@ -1235,7 +1250,7 @@ private let initializationResult: InitializationResult = {
1235
1250
  if (uniffi_editor_core_checksum_func_editor_core_version() != 41638) {
1236
1251
  return InitializationResult.apiChecksumMismatch
1237
1252
  }
1238
- if (uniffi_editor_core_checksum_func_editor_create() != 23908) {
1253
+ if (uniffi_editor_core_checksum_func_editor_create() != 19812) {
1239
1254
  return InitializationResult.apiChecksumMismatch
1240
1255
  }
1241
1256
  if (uniffi_editor_core_checksum_func_editor_delete_and_split_scalar() != 13764) {
@@ -1313,6 +1328,9 @@ private let initializationResult: InitializationResult = {
1313
1328
  if (uniffi_editor_core_checksum_func_editor_replace_text_scalar() != 45475) {
1314
1329
  return InitializationResult.apiChecksumMismatch
1315
1330
  }
1331
+ if (uniffi_editor_core_checksum_func_editor_resize_image_at_doc_pos() != 36353) {
1332
+ return InitializationResult.apiChecksumMismatch
1333
+ }
1316
1334
  if (uniffi_editor_core_checksum_func_editor_scalar_to_doc() != 40126) {
1317
1335
  return InitializationResult.apiChecksumMismatch
1318
1336
  }
@@ -1,6 +1,5 @@
1
1
  import ExpoModulesCore
2
2
  import UIKit
3
- import os
4
3
 
5
4
  private struct NativeToolbarState {
6
5
  let marks: [String: Bool]
@@ -92,6 +91,7 @@ private enum ToolbarDefaultIconId: String {
92
91
  case underline
93
92
  case strike
94
93
  case link
94
+ case image
95
95
  case blockquote
96
96
  case bulletList
97
97
  case orderedList
@@ -125,6 +125,7 @@ private struct NativeToolbarIcon {
125
125
  .underline: "underline",
126
126
  .strike: "strikethrough",
127
127
  .link: "link",
128
+ .image: "photo",
128
129
  .blockquote: "text.quote",
129
130
  .bulletList: "list.bullet",
130
131
  .orderedList: "list.number",
@@ -142,6 +143,7 @@ private struct NativeToolbarIcon {
142
143
  .underline: "U",
143
144
  .strike: "S",
144
145
  .link: "🔗",
146
+ .image: "🖼",
145
147
  .blockquote: "❝",
146
148
  .bulletList: "•≡",
147
149
  .orderedList: "1.",
@@ -484,9 +486,11 @@ final class EditorAccessoryToolbarView: UIInputView {
484
486
  resolvedAppearance == .native
485
487
  }
486
488
  var usesUIGlassEffectForTesting: Bool {
489
+ #if compiler(>=6.2)
487
490
  if #available(iOS 26.0, *) {
488
491
  return blurView.effect is UIGlassEffect
489
492
  }
493
+ #endif
490
494
  return false
491
495
  }
492
496
  var chromeBorderWidthForTesting: CGFloat {
@@ -508,11 +512,13 @@ final class EditorAccessoryToolbarView: UIInputView {
508
512
  activeNativeToolbarScrollViewForTesting.contentOffset.x = offsetX
509
513
  }
510
514
  var selectedButtonCountForTesting: Int {
515
+ #if compiler(>=6.2)
511
516
  if #available(iOS 26.0, *) {
512
517
  if usesNativeBarToolbar {
513
518
  return barButtonBindings.filter { $0.button.style == .prominent }.count
514
519
  }
515
520
  }
521
+ #endif
516
522
  return buttonBindings.filter(\.button.isSelected).count
517
523
  }
518
524
  func mentionButtonAtForTesting(_ index: Int) -> MentionSuggestionChipButton? {
@@ -602,6 +608,7 @@ final class EditorAccessoryToolbarView: UIInputView {
602
608
  if #available(iOS 13.0, *) {
603
609
  chromeView.layer.cornerCurve = .continuous
604
610
  }
611
+ #if compiler(>=6.2)
605
612
  if #available(iOS 26.0, *) {
606
613
  let cornerConfig: UICornerConfiguration = usesNativeAppearance
607
614
  ? .capsule(maximumRadius: 24)
@@ -610,6 +617,7 @@ final class EditorAccessoryToolbarView: UIInputView {
610
617
  blurView.cornerConfiguration = cornerConfig
611
618
  glassTintView.cornerConfiguration = cornerConfig
612
619
  }
620
+ #endif
613
621
  chromeView.clipsToBounds = (usesNativeAppearance && !hasFloatingGlassButtons && !usesBarToolbar) || resolvedBorderRadius > 0
614
622
  chromeView.layer.shadowOpacity = usesNativeAppearance && !hasFloatingGlassButtons && !usesBarToolbar ? 0.08 : 0
615
623
  chromeView.layer.shadowRadius = usesNativeAppearance && !hasFloatingGlassButtons && !usesBarToolbar ? 10 : 0
@@ -682,6 +690,7 @@ final class EditorAccessoryToolbarView: UIInputView {
682
690
  updateButtonAppearance(binding.button, item: binding.item, enabled: buttonState.enabled, active: buttonState.active)
683
691
  }
684
692
 
693
+ #if compiler(>=6.2)
685
694
  if #available(iOS 26.0, *), usesNativeBarToolbar {
686
695
  for binding in barButtonBindings {
687
696
  let state = buttonState(for: binding.item, state: currentState)
@@ -690,6 +699,17 @@ final class EditorAccessoryToolbarView: UIInputView {
690
699
  binding.button.style = state.active ? .prominent : .plain
691
700
  }
692
701
  }
702
+ #endif
703
+ }
704
+
705
+ var firstButtonAlphaForTesting: CGFloat {
706
+ buttonBindings.first?.button.alpha ?? 0
707
+ }
708
+ var firstButtonTintColorForTesting: UIColor? {
709
+ buttonBindings.first?.button.tintColor
710
+ }
711
+ var firstButtonTintAdjustmentModeForTesting: UIView.TintAdjustmentMode {
712
+ buttonBindings.first?.button.tintAdjustmentMode ?? .automatic
693
713
  }
694
714
 
695
715
  func applyBoldStateForTesting(active: Bool, enabled: Bool) {
@@ -869,11 +889,15 @@ final class EditorAccessoryToolbarView: UIInputView {
869
889
  stackView.addArrangedSubview(button)
870
890
  }
871
891
 
892
+ #if compiler(>=6.2)
872
893
  if #available(iOS 26.0, *) {
873
894
  nativeToolbarView.setItems(makeNativeToolbarItems(from: compactItems), animated: false)
874
895
  } else {
875
896
  nativeToolbarView.setItems([], animated: false)
876
897
  }
898
+ #else
899
+ nativeToolbarView.setItems([], animated: false)
900
+ #endif
877
901
 
878
902
  updateNativeToolbarMetricsIfNeeded()
879
903
  apply(theme: theme)
@@ -881,6 +905,7 @@ final class EditorAccessoryToolbarView: UIInputView {
881
905
  }
882
906
 
883
907
  private func updateNativeToolbarMetricsIfNeeded() {
908
+ #if compiler(>=6.2)
884
909
  guard #available(iOS 26.0, *), usesNativeBarToolbar else {
885
910
  nativeToolbarWidthConstraint?.constant = Self.baseHeight
886
911
  nativeToolbarDidInitializeScrollPosition = false
@@ -932,8 +957,13 @@ final class EditorAccessoryToolbarView: UIInputView {
932
957
  animated: false
933
958
  )
934
959
  }
960
+ #else
961
+ nativeToolbarWidthConstraint?.constant = Self.baseHeight
962
+ nativeToolbarDidInitializeScrollPosition = false
963
+ #endif
935
964
  }
936
965
 
966
+ #if compiler(>=6.2)
937
967
  @available(iOS 26.0, *)
938
968
  private func makeNativeToolbarItems(from compactItems: [NativeToolbarItem]) -> [UIBarButtonItem] {
939
969
  var toolbarItems: [UIBarButtonItem] = []
@@ -977,9 +1007,12 @@ final class EditorAccessoryToolbarView: UIInputView {
977
1007
  barButtonItem.isEnabled = enabled
978
1008
  barButtonItem.isSelected = active
979
1009
  barButtonItem.style = active ? .prominent : .plain
1010
+
1011
+ barButtonItem.sharesBackground = true
980
1012
  barButtonItem.hidesSharedBackground = active
981
1013
  return barButtonItem
982
1014
  }
1015
+ #endif
983
1016
 
984
1017
  private func makeButton(item: NativeToolbarItem) -> UIButton {
985
1018
  let button = UIButton(type: .system)
@@ -1106,6 +1139,7 @@ final class EditorAccessoryToolbarView: UIInputView {
1106
1139
  enabled: Bool,
1107
1140
  active: Bool
1108
1141
  ) {
1142
+ #if compiler(>=6.2)
1109
1143
  if #available(iOS 26.0, *), usesFloatingGlassButtons {
1110
1144
  var configuration = active
1111
1145
  ? UIButton.Configuration.prominentGlass()
@@ -1135,11 +1169,12 @@ final class EditorAccessoryToolbarView: UIInputView {
1135
1169
  button.alpha = enabled ? 1 : 0.45
1136
1170
  return
1137
1171
  }
1172
+ #endif
1138
1173
 
1139
1174
  if resolvedAppearance == .native {
1140
- button.tintColor = nil
1141
- button.alpha = enabled ? 1 : 0.45
1142
- button.tintAdjustmentMode = enabled ? .automatic : .dimmed
1175
+ button.tintColor = enabled ? nil : .systemGray
1176
+ button.tintAdjustmentMode = enabled ? .automatic : .normal
1177
+ button.alpha = 1
1143
1178
  button.backgroundColor = active
1144
1179
  ? UIColor.white.withAlphaComponent(0.18)
1145
1180
  : .clear
@@ -1200,12 +1235,14 @@ final class EditorAccessoryToolbarView: UIInputView {
1200
1235
  }
1201
1236
 
1202
1237
  private func resolvedBlurEffect() -> UIVisualEffect {
1238
+ #if compiler(>=6.2)
1203
1239
  if #available(iOS 26.0, *) {
1204
1240
  let effect = UIGlassEffect(style: .regular)
1205
1241
  effect.isInteractive = true
1206
1242
  effect.tintColor = resolvedGlassEffectTintColor
1207
1243
  return effect
1208
1244
  }
1245
+ #endif
1209
1246
  if #available(iOS 13.0, *) {
1210
1247
  return UIBlurEffect(style: .systemUltraThinMaterial)
1211
1248
  }
@@ -1268,11 +1305,6 @@ final class EditorAccessoryToolbarView: UIInputView {
1268
1305
 
1269
1306
  class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognizerDelegate {
1270
1307
 
1271
- private static let updateLog = Logger(
1272
- subsystem: "com.apollohg.prose-editor",
1273
- category: "view-command"
1274
- )
1275
-
1276
1308
  // MARK: - Subviews
1277
1309
 
1278
1310
  let richTextView: RichTextEditorView
@@ -1461,6 +1493,10 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1461
1493
  }
1462
1494
  }
1463
1495
 
1496
+ func setAllowImageResizing(_ allowImageResizing: Bool) {
1497
+ richTextView.allowImageResizing = allowImageResizing
1498
+ }
1499
+
1464
1500
  private func emitContentHeightIfNeeded(force: Bool = false) {
1465
1501
  guard heightBehavior == .autoGrow else { return }
1466
1502
  let contentHeight = ceil(richTextView.intrinsicContentSize.height)
@@ -1513,13 +1549,9 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1513
1549
  /// Apply an editor update from JS. Sets the echo-suppression flag so the
1514
1550
  /// resulting delegate callback is NOT re-dispatched back to JS.
1515
1551
  func applyEditorUpdate(_ updateJson: String) {
1516
- Self.updateLog.debug("[applyEditorUpdate.begin] bytes=\(updateJson.utf8.count)")
1517
1552
  isApplyingJSUpdate = true
1518
1553
  richTextView.textView.applyUpdateJSON(updateJson)
1519
1554
  isApplyingJSUpdate = false
1520
- Self.updateLog.debug(
1521
- "[applyEditorUpdate.end] textState=\(self.richTextView.textView.textStorage.string.count)"
1522
- )
1523
1555
  }
1524
1556
 
1525
1557
  // MARK: - Focus Commands
@@ -1536,12 +1568,14 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1536
1568
 
1537
1569
  @objc private func textViewDidBeginEditing(_ notification: Notification) {
1538
1570
  installOutsideTapRecognizerIfNeeded()
1571
+ richTextView.textView.refreshSelectionVisualState()
1539
1572
  refreshMentionQuery()
1540
1573
  onFocusChange(["isFocused": true])
1541
1574
  }
1542
1575
 
1543
1576
  @objc private func textViewDidEndEditing(_ notification: Notification) {
1544
1577
  uninstallOutsideTapRecognizer()
1578
+ richTextView.textView.refreshSelectionVisualState()
1545
1579
  clearMentionQueryStateAndHidePopover()
1546
1580
  onFocusChange(["isFocused": false])
1547
1581
  }
@@ -1577,10 +1611,12 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1577
1611
  func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
1578
1612
  guard gestureRecognizer === outsideTapGestureRecognizer else { return true }
1579
1613
  guard let tapWindow = gestureWindow ?? window else { return true }
1580
- return shouldHandleOutsideTap(
1581
- locationInWindow: touch.location(in: tapWindow),
1614
+ let locationInWindow = touch.location(in: tapWindow)
1615
+ let result = shouldHandleOutsideTap(
1616
+ locationInWindow: locationInWindow,
1582
1617
  touchedView: touch.view
1583
1618
  )
1619
+ return result
1584
1620
  }
1585
1621
 
1586
1622
  private func shouldHandleOutsideTap(
@@ -1624,7 +1660,6 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1624
1660
  refreshMentionQuery()
1625
1661
  richTextView.refreshRemoteSelections()
1626
1662
  guard !isApplyingJSUpdate else { return }
1627
- Self.updateLog.debug("[didReceiveUpdate] bytes=\(updateJSON.utf8.count)")
1628
1663
  onEditorUpdate(["updateJson": updateJSON])
1629
1664
  }
1630
1665
 
@@ -302,6 +302,9 @@ public class NativeEditorModule: Module {
302
302
  Prop("heightBehavior") { (view: NativeEditorExpoView, heightBehavior: String) in
303
303
  view.setHeightBehavior(heightBehavior)
304
304
  }
305
+ Prop("allowImageResizing") { (view: NativeEditorExpoView, allowImageResizing: Bool) in
306
+ view.setAllowImageResizing(allowImageResizing)
307
+ }
305
308
  Prop("themeJson") { (view: NativeEditorExpoView, themeJson: String?) in
306
309
  view.setThemeJson(themeJson)
307
310
  }
@@ -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.
@@ -188,12 +199,14 @@ final class RenderBridge {
188
199
  case "voidInline":
189
200
  let nodeType = element["nodeType"] as? String ?? ""
190
201
  let docPos = jsonUInt32(element["docPos"])
202
+ let attrs = element["attrs"] as? [String: Any] ?? [:]
191
203
  if nodeType == "hardBreak" {
192
204
  overrideTrailingParagraphSpacing(in: result, paragraphSpacing: 0)
193
205
  }
194
206
  let attrStr = attributedStringForVoidInline(
195
207
  nodeType: nodeType,
196
208
  docPos: docPos,
209
+ attrs: attrs,
197
210
  baseFont: baseFont,
198
211
  textColor: textColor,
199
212
  blockStack: blockStack,
@@ -204,6 +217,7 @@ final class RenderBridge {
204
217
  case "voidBlock":
205
218
  let nodeType = element["nodeType"] as? String ?? ""
206
219
  let docPos = jsonUInt32(element["docPos"])
220
+ let attrs = element["attrs"] as? [String: Any] ?? [:]
207
221
 
208
222
  // Add inter-block newline if not the first block.
209
223
  if !isFirstBlock {
@@ -225,6 +239,7 @@ final class RenderBridge {
225
239
  let attrStr = attributedStringForVoidBlock(
226
240
  nodeType: nodeType,
227
241
  docPos: docPos,
242
+ elementAttrs: attrs,
228
243
  baseFont: baseFont,
229
244
  textColor: textColor,
230
245
  theme: theme
@@ -467,6 +482,7 @@ final class RenderBridge {
467
482
  private static func attributedStringForVoidInline(
468
483
  nodeType: String,
469
484
  docPos: UInt32,
485
+ attrs _: [String: Any],
470
486
  baseFont: UIFont,
471
487
  textColor: UIColor,
472
488
  blockStack: [BlockContext],
@@ -511,6 +527,7 @@ final class RenderBridge {
511
527
  private static func attributedStringForVoidBlock(
512
528
  nodeType: String,
513
529
  docPos: UInt32,
530
+ elementAttrs: [String: Any],
514
531
  baseFont: UIFont,
515
532
  textColor: UIColor,
516
533
  theme: EditorTheme?
@@ -532,6 +549,25 @@ final class RenderBridge {
532
549
  let range = NSRange(location: 0, length: attrStr.length)
533
550
  attrStr.addAttributes(attrs, range: range)
534
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
535
571
  default:
536
572
  // Unknown void block: render as object replacement character.
537
573
  return NSAttributedString(
@@ -698,6 +734,21 @@ final class RenderBridge {
698
734
  return 0
699
735
  }
700
736
 
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
+
701
752
  private static func defaultAttributes(
702
753
  baseFont: UIFont,
703
754
  textColor: UIColor
@@ -1003,3 +1054,160 @@ final class HorizontalRuleAttachment: NSTextAttachment {
1003
1054
  }
1004
1055
  }
1005
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
+ }