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

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 (68) hide show
  1. package/android/build.gradle +35 -0
  2. package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +2 -2
  3. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +138 -597
  4. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +13 -3
  5. package/android/src/main/java/com/apollohg/editor/InputSnapshotSupport.kt +6 -0
  6. package/android/src/main/java/com/apollohg/editor/NativeBlockEditorSurface.kt +1086 -0
  7. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +1 -1
  8. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +26 -50
  9. package/android/src/main/java/com/apollohg/editor/NativeImageLoader.kt +89 -0
  10. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +31 -66
  11. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +98 -33
  12. package/dist/EditorToolbar.d.ts +1 -1
  13. package/dist/EditorToolbar.js +19 -0
  14. package/dist/NativeEditorBridge.d.ts +62 -35
  15. package/dist/NativeEditorBridge.js +122 -79
  16. package/dist/NativeProseViewer.d.ts +13 -34
  17. package/dist/NativeProseViewer.js +40 -465
  18. package/dist/NativeRichTextEditor.d.ts +10 -1
  19. package/dist/NativeRichTextEditor.js +72 -34
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.js +2 -1
  22. package/dist/schemas.d.ts +11 -0
  23. package/dist/schemas.js +149 -0
  24. package/dist/useNativeEditor.d.ts +3 -3
  25. package/dist/useNativeEditor.js +3 -3
  26. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  27. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  28. package/ios/Generated_editor_core.swift +97 -2
  29. package/ios/NativeBlockEditorSurface.swift +1445 -0
  30. package/ios/NativeEditorExpoView.swift +3 -3
  31. package/ios/NativeEditorModule.swift +31 -46
  32. package/ios/NativeInputSupport.swift +396 -0
  33. package/ios/PositionBridge.swift +4 -4
  34. package/ios/RichTextEditorView.swift +352 -786
  35. package/ios/editor_coreFFI/editor_coreFFI.h +147 -70
  36. package/package.json +1 -1
  37. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  38. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  39. package/rust/android/x86_64/libeditor_core.so +0 -0
  40. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2087 -1399
  41. package/android/src/androidTest/AndroidManifest.xml +0 -8
  42. package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +0 -53
  43. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +0 -241
  44. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +0 -338
  45. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +0 -789
  46. package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +0 -350
  47. package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +0 -5
  48. package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +0 -315
  49. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +0 -2172
  50. package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +0 -482
  51. package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +0 -137
  52. package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +0 -321
  53. package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +0 -4202
  54. package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +0 -2581
  55. package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +0 -27
  56. package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +0 -197
  57. package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +0 -84
  58. package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +0 -544
  59. package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +0 -461
  60. package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +0 -2054
  61. package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +0 -1367
  62. package/ios/EditorLayoutManager.swift +0 -774
  63. package/ios/NativeProseViewerExpoView.swift +0 -276
  64. package/ios/RenderBridge.swift +0 -1708
  65. package/ios/Tests/NativePerformanceTests.swift +0 -687
  66. package/ios/Tests/PositionBridgeTests.swift +0 -706
  67. package/ios/Tests/RenderBridgeTests.swift +0 -2236
  68. package/ios/Tests/RichTextEditorViewTests.swift +0 -6548
@@ -1,4 +1,4 @@
1
- import ExpoModulesCore
1
+ public import ExpoModulesCore
2
2
  import UIKit
3
3
 
4
4
  private final class WeakNativeEditorExpoView {
@@ -2468,7 +2468,7 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
2468
2468
  pendingEditableRetryValue = nil
2469
2469
  pendingEditableRetryEditorId = nil
2470
2470
  pendingEditableRetryScheduled = false
2471
- richTextView.textView.isEditable = editable
2471
+ richTextView.setEditable(editable)
2472
2472
  updateAccessoryToolbarVisibility()
2473
2473
  }
2474
2474
 
@@ -3157,7 +3157,7 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
3157
3157
  ]
3158
3158
 
3159
3159
  for offset in candidateOffsets where offset >= 0 && offset < textStorage.length {
3160
- if let nodeType = textStorage.attribute(RenderBridgeAttributes.voidNodeType, at: offset, effectiveRange: nil) as? String,
3160
+ if let nodeType = textStorage.attribute(InputCoordinatorAttributes.voidNodeType, at: offset, effectiveRange: nil) as? String,
3161
3161
  nodeType == "mention" {
3162
3162
  return true
3163
3163
  }
@@ -1,4 +1,4 @@
1
- import ExpoModulesCore
1
+ public import ExpoModulesCore
2
2
 
3
3
  private func nativeUInt64(_ value: Int) -> UInt64? {
4
4
  guard value >= 0 else { return nil }
@@ -224,6 +224,15 @@ public class NativeEditorModule: Module {
224
224
  }
225
225
  editorSetSelection(id: editorId, anchor: anchor, head: head)
226
226
  }
227
+ Function("editorSetCellSelection") { (id: Int, anchor: Int, head: Int) in
228
+ guard let editorId = nativeUInt64(id),
229
+ let anchor = nativeUInt32(anchor),
230
+ let head = nativeUInt32(head)
231
+ else {
232
+ return
233
+ }
234
+ editorSetCellSelection(id: editorId, anchor: anchor, head: head)
235
+ }
227
236
  Function("editorSetSelectionScalar") { (id: Int, scalarAnchor: Int, scalarHead: Int) in
228
237
  guard let editorId = nativeUInt64(id),
229
238
  let scalarAnchor = nativeUInt32(scalarAnchor),
@@ -443,28 +452,6 @@ public class NativeEditorModule: Module {
443
452
  guard let editorId = nativeUInt64(id) else { return false }
444
453
  return editorCanRedo(id: editorId)
445
454
  }
446
- Function("renderDocumentJson") { (configJson: String, json: String) -> String in
447
- let editorId = editorCreate(configJson: configJson)
448
- defer {
449
- editorDestroy(id: editorId)
450
- }
451
- return editorSetJson(id: editorId, json: json)
452
- }
453
- Function("measureContentHeight") { (renderJson: String, themeJson: String?, width: Double) -> Double in
454
- let height = RenderBridge.measureHeight(
455
- forRenderJSON: renderJson,
456
- themeJSON: themeJson,
457
- width: CGFloat(width)
458
- )
459
- return Double(height)
460
- }
461
- Function("renderDocumentHtml") { (configJson: String, html: String) -> String in
462
- let editorId = editorCreate(configJson: configJson)
463
- defer {
464
- editorDestroy(id: editorId)
465
- }
466
- return editorSetHtml(id: editorId, html: html)
467
- }
468
455
  Function("editorReplaceHtml") { (id: Int, html: String) -> String in
469
456
  guard let editorId = nativeUInt64(id) else { return nativeArgumentError("editor id") }
470
457
  return editorReplaceHtml(id: editorId, html: html)
@@ -509,6 +496,26 @@ public class NativeEditorModule: Module {
509
496
  guard let editorId = nativeUInt64(id) else { return nativeArgumentError("editor id") }
510
497
  return editorOutdentListItem(id: editorId)
511
498
  }
499
+ Function("editorInsertTableRow") { (id: Int, after: Bool) -> String in
500
+ guard let editorId = nativeUInt64(id) else { return nativeArgumentError("editor id") }
501
+ return editorInsertTableRow(id: editorId, after: after)
502
+ }
503
+ Function("editorDeleteTableRow") { (id: Int) -> String in
504
+ guard let editorId = nativeUInt64(id) else { return nativeArgumentError("editor id") }
505
+ return editorDeleteTableRow(id: editorId)
506
+ }
507
+ Function("editorInsertTableColumn") { (id: Int, after: Bool) -> String in
508
+ guard let editorId = nativeUInt64(id) else { return nativeArgumentError("editor id") }
509
+ return editorInsertTableColumn(id: editorId, after: after)
510
+ }
511
+ Function("editorDeleteTableColumn") { (id: Int) -> String in
512
+ guard let editorId = nativeUInt64(id) else { return nativeArgumentError("editor id") }
513
+ return editorDeleteTableColumn(id: editorId)
514
+ }
515
+ Function("editorMoveTableCell") { (id: Int, forward: Bool) -> String in
516
+ guard let editorId = nativeUInt64(id) else { return nativeArgumentError("editor id") }
517
+ return editorMoveTableCell(id: editorId, forward: forward)
518
+ }
512
519
  Function("editorInsertNode") { (id: Int, nodeType: String) -> String in
513
520
  guard let editorId = nativeUInt64(id) else { return nativeArgumentError("editor id") }
514
521
  return editorInsertNode(id: editorId, nodeType: nodeType)
@@ -531,7 +538,7 @@ public class NativeEditorModule: Module {
531
538
  view.setEditable(editable)
532
539
  }
533
540
  Prop("placeholder") { (view: NativeEditorExpoView, placeholder: String) in
534
- view.richTextView.textView.placeholder = placeholder
541
+ view.richTextView.placeholder = placeholder
535
542
  }
536
543
  Prop("autoFocus") { (view: NativeEditorExpoView, autoFocus: Bool) in
537
544
  view.setAutoFocus(autoFocus)
@@ -598,27 +605,5 @@ public class NativeEditorModule: Module {
598
605
  view.getCaretRectJson()
599
606
  }
600
607
  }
601
-
602
- View(NativeProseViewerExpoView.self) {
603
- ViewName("NativeProseViewer")
604
- Events("onContentHeightChange", "onPressLink", "onPressMention")
605
-
606
- Prop("renderJson") { (view: NativeProseViewerExpoView, renderJson: String?) in
607
- view.setRenderJson(renderJson)
608
- }
609
- Prop("themeJson") { (view: NativeProseViewerExpoView, themeJson: String?) in
610
- view.setThemeJson(themeJson)
611
- }
612
- Prop("collapsesWhenEmpty") {
613
- (view: NativeProseViewerExpoView, collapsesWhenEmpty: Bool?) in
614
- view.setCollapsesWhenEmpty(collapsesWhenEmpty)
615
- }
616
- Prop("enableLinkTaps") { (view: NativeProseViewerExpoView, enableLinkTaps: Bool?) in
617
- view.setEnableLinkTaps(enableLinkTaps)
618
- }
619
- Prop("interceptLinkTaps") { (view: NativeProseViewerExpoView, interceptLinkTaps: Bool?) in
620
- view.setInterceptLinkTaps(interceptLinkTaps)
621
- }
622
- }
623
608
  }
624
609
  }
@@ -0,0 +1,396 @@
1
+ import UIKit
2
+
3
+ extension Notification.Name {
4
+ static let editorImageAttachmentDidLoad = Notification.Name(
5
+ "com.openeditor.editor.imageAttachmentDidLoad"
6
+ )
7
+ }
8
+
9
+ enum NativeImageLoader {
10
+ static let cache = NSCache<NSString, UIImage>()
11
+ static let stateQueue = DispatchQueue(label: "com.openeditor.editor.image-loader-state")
12
+ static let session: URLSession = {
13
+ let configuration = URLSessionConfiguration.ephemeral
14
+ configuration.timeoutIntervalForRequest = 10
15
+ configuration.timeoutIntervalForResource = 15
16
+ configuration.requestCachePolicy = .returnCacheDataElseLoad
17
+ return URLSession(configuration: configuration)
18
+ }()
19
+ private static var inFlight: [String: [(UIImage?) -> Void]] = [:]
20
+
21
+ static func load(source: String, completion: @escaping (UIImage?) -> Void) {
22
+ if let cached = cache.object(forKey: source as NSString) {
23
+ completion(cached)
24
+ return
25
+ }
26
+ if let inlineData = decodeDataURL(source), let image = UIImage(data: inlineData) {
27
+ cache.setObject(image, forKey: source as NSString)
28
+ completion(image)
29
+ return
30
+ }
31
+ guard let url = URL(string: source) else {
32
+ completion(nil)
33
+ return
34
+ }
35
+ load(source: source, url: url, completion: completion)
36
+ }
37
+
38
+ private static func load(
39
+ source: String,
40
+ url: URL,
41
+ completion: @escaping (UIImage?) -> Void
42
+ ) {
43
+ if let cached = cache.object(forKey: source as NSString) {
44
+ completion(cached)
45
+ return
46
+ }
47
+
48
+ var shouldStartLoad = false
49
+ stateQueue.sync {
50
+ if inFlight[source] != nil {
51
+ inFlight[source]?.append(completion)
52
+ } else {
53
+ inFlight[source] = [completion]
54
+ shouldStartLoad = true
55
+ }
56
+ }
57
+ guard shouldStartLoad else { return }
58
+
59
+ let finish: (Data?) -> Void = { data in
60
+ let image = data.flatMap(UIImage.init(data:))
61
+ if let image {
62
+ cache.setObject(image, forKey: source as NSString)
63
+ }
64
+
65
+ let callbacks: [(UIImage?) -> Void] = stateQueue.sync {
66
+ let callbacks = inFlight.removeValue(forKey: source) ?? []
67
+ return callbacks
68
+ }
69
+ DispatchQueue.main.async {
70
+ callbacks.forEach { $0(image) }
71
+ }
72
+ }
73
+
74
+ if url.isFileURL {
75
+ DispatchQueue.global(qos: .userInitiated).async {
76
+ finish(try? Data(contentsOf: url))
77
+ }
78
+ return
79
+ }
80
+ guard url.scheme == "https" || url.scheme == "http" else {
81
+ finish(nil)
82
+ return
83
+ }
84
+ session.dataTask(with: url) { data, _, _ in
85
+ finish(data)
86
+ }.resume()
87
+ }
88
+
89
+ private static func decodeDataURL(_ source: String) -> Data? {
90
+ let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines)
91
+ guard trimmed.lowercased().hasPrefix("data:image/"),
92
+ let commaIndex = trimmed.firstIndex(of: ",")
93
+ else {
94
+ return nil
95
+ }
96
+ let metadata = String(trimmed[..<commaIndex]).lowercased()
97
+ let payload = String(trimmed[trimmed.index(after: commaIndex)...])
98
+ guard metadata.contains(";base64") else { return nil }
99
+ return Data(base64Encoded: payload, options: [.ignoreUnknownCharacters])
100
+ }
101
+ }
102
+
103
+ // MARK: - Constants
104
+
105
+ /// Custom NSAttributedString attribute keys for editor metadata.
106
+ enum InputCoordinatorAttributes {
107
+ /// Marks a character as a void element placeholder (hardBreak, horizontalRule).
108
+ /// The value is the node type string (e.g. "hardBreak", "horizontalRule").
109
+ static let voidNodeType = NSAttributedString.Key("com.openeditor.editor.voidNodeType")
110
+
111
+ /// Stores the Rust document position (UInt32) for void elements.
112
+ static let docPos = NSAttributedString.Key("com.openeditor.editor.docPos")
113
+
114
+ /// Marks a character as a block boundary (for block start/end tracking).
115
+ static let blockBoundary = NSAttributedString.Key("com.openeditor.editor.blockBoundary")
116
+
117
+ /// Stores the block node type (e.g. "paragraph", "listItem").
118
+ static let blockNodeType = NSAttributedString.Key("com.openeditor.editor.blockNodeType")
119
+
120
+ /// Stores the block depth (UInt8).
121
+ static let blockDepth = NSAttributedString.Key("com.openeditor.editor.blockDepth")
122
+
123
+ /// Stores list context info as a dictionary for list items.
124
+ static let listContext = NSAttributedString.Key("com.openeditor.editor.listContext")
125
+
126
+ /// Marks blocks that should render a visible list marker.
127
+ static let listMarkerContext = NSAttributedString.Key("com.openeditor.editor.listMarkerContext")
128
+
129
+ /// Stores the rendered list marker color for the paragraph marker.
130
+ static let listMarkerColor = NSAttributedString.Key("com.openeditor.editor.listMarkerColor")
131
+
132
+ /// Stores the rendered list marker scale for unordered bullets.
133
+ static let listMarkerScale = NSAttributedString.Key("com.openeditor.editor.listMarkerScale")
134
+
135
+ /// Stores the paragraph base font used to render the list marker.
136
+ static let listMarkerBaseFont = NSAttributedString.Key("com.openeditor.editor.listMarkerBaseFont")
137
+
138
+ /// Stores the reserved list marker gutter width.
139
+ static let listMarkerWidth = NSAttributedString.Key("com.openeditor.editor.listMarkerWidth")
140
+
141
+ /// Stores the rendered blockquote border color.
142
+ static let blockquoteBorderColor = NSAttributedString.Key("com.openeditor.editor.blockquoteBorderColor")
143
+
144
+ /// Stores the rendered blockquote border width.
145
+ static let blockquoteBorderWidth = NSAttributedString.Key("com.openeditor.editor.blockquoteBorderWidth")
146
+
147
+ /// Stores the rendered blockquote gap between border and text.
148
+ static let blockquoteMarkerGap = NSAttributedString.Key("com.openeditor.editor.blockquoteMarkerGap")
149
+
150
+ /// Marks code-block paragraphs for custom background drawing.
151
+ static let codeBlockBackgroundColor = NSAttributedString.Key("com.openeditor.editor.codeBlockBackgroundColor")
152
+ static let codeBlockBorderRadius = NSAttributedString.Key("com.openeditor.editor.codeBlockBorderRadius")
153
+ static let codeBlockPaddingHorizontal = NSAttributedString.Key("com.openeditor.editor.codeBlockPaddingHorizontal")
154
+ static let codeBlockPaddingVertical = NSAttributedString.Key("com.openeditor.editor.codeBlockPaddingVertical")
155
+
156
+ /// Marks synthetic zero-width placeholders used only for UIKit layout.
157
+ static let syntheticPlaceholder = NSAttributedString.Key("com.openeditor.editor.syntheticPlaceholder")
158
+
159
+ /// Stores the link href for visually styled link text without enabling UITextView's default link interaction.
160
+ static let linkHref = NSAttributedString.Key("com.openeditor.editor.linkHref")
161
+
162
+ /// Stores the owning top-level document child index for partial native patching.
163
+ static let topLevelChildIndex = NSAttributedString.Key("com.openeditor.editor.topLevelChildIndex")
164
+ }
165
+
166
+ /// Layout constants for paragraph styles.
167
+ enum LayoutConstants {
168
+ /// Spacing between paragraphs (points).
169
+ static let paragraphSpacing: CGFloat = 8.0
170
+
171
+ /// Base indentation per depth level (points).
172
+ static let indentPerDepth: CGFloat = 24.0
173
+
174
+ /// Width reserved for the list bullet/number (points).
175
+ static let listMarkerWidth: CGFloat = 36.0
176
+
177
+ /// Gap between the list marker and the text that follows (points).
178
+ static let listMarkerTextGap: CGFloat = 8.0
179
+
180
+ /// Height of the horizontal rule separator line (points).
181
+ static let horizontalRuleHeight: CGFloat = 1.0
182
+
183
+ /// Vertical padding above and below the horizontal rule (points).
184
+ static let horizontalRuleVerticalPadding: CGFloat = 8.0
185
+
186
+ /// Total leading inset reserved for each blockquote depth.
187
+ static let blockquoteIndent: CGFloat = 18.0
188
+
189
+ /// Width of the rendered blockquote border bar.
190
+ static let blockquoteBorderWidth: CGFloat = 3.0
191
+
192
+ /// Gap between the blockquote border bar and the text that follows.
193
+ static let blockquoteMarkerGap: CGFloat = 8.0
194
+
195
+ /// Bullet character for unordered list items.
196
+ static let unorderedListBullet = "\u{2022} "
197
+
198
+ /// Scale factor applied only to unordered list marker glyphs.
199
+ static let unorderedListMarkerFontScale: CGFloat = 2.0
200
+
201
+ /// Object replacement character used for void block elements.
202
+ static let objectReplacementCharacter = "\u{FFFC}"
203
+ }
204
+
205
+ enum NativeListMarker {
206
+ static func text(for context: [String: Any]) -> String {
207
+ if context["task"] as? Bool == true {
208
+ return (context["checked"] as? Bool == true) ? "☑︎ " : "☐ "
209
+ }
210
+ if context["ordered"] as? Bool == true {
211
+ let index = context["index"] as? Int ?? 1
212
+ return "\(index). "
213
+ }
214
+ return LayoutConstants.unorderedListBullet
215
+ }
216
+ }
217
+
218
+
219
+ // MARK: - BlockContext
220
+
221
+ /// Transient context while rendering block elements. Pushed onto a stack
222
+ /// when a `blockStart` element is encountered and popped on `blockEnd`.
223
+ struct BlockContext {
224
+ let nodeType: String
225
+ let depth: UInt8
226
+ var listContext: [String: Any]?
227
+ var topLevelChildIndex: Int? = nil
228
+ var listMarkerContext: [String: Any]? = nil
229
+ var markerPending: Bool = false
230
+ }
231
+
232
+ // MARK: - HorizontalRuleAttachment
233
+
234
+ /// NSTextAttachment subclass that draws a horizontal separator line.
235
+ ///
236
+ /// The attachment renders as a thin line across the available width with
237
+ /// vertical padding. Used for `horizontalRule` void block elements.
238
+ final class HorizontalRuleAttachment: NSTextAttachment {
239
+
240
+ var lineColor: UIColor = .separator
241
+ var lineHeight: CGFloat = LayoutConstants.horizontalRuleHeight
242
+ var verticalPadding: CGFloat = LayoutConstants.horizontalRuleVerticalPadding
243
+
244
+ override func attachmentBounds(
245
+ for textContainer: NSTextContainer?,
246
+ proposedLineFragment lineFrag: CGRect,
247
+ glyphPosition position: CGPoint,
248
+ characterIndex charIndex: Int
249
+ ) -> CGRect {
250
+ let totalHeight = lineHeight + (verticalPadding * 2)
251
+ return CGRect(
252
+ x: 0,
253
+ y: 0,
254
+ width: lineFrag.width,
255
+ height: totalHeight
256
+ )
257
+ }
258
+
259
+ override func image(
260
+ forBounds imageBounds: CGRect,
261
+ textContainer: NSTextContainer?,
262
+ characterIndex charIndex: Int
263
+ ) -> UIImage? {
264
+ let renderer = UIGraphicsImageRenderer(bounds: imageBounds)
265
+ return renderer.image { context in
266
+ lineColor.setFill()
267
+ let lineY = imageBounds.midY - (lineHeight / 2)
268
+ let lineRect = CGRect(
269
+ x: imageBounds.origin.x,
270
+ y: lineY,
271
+ width: imageBounds.width,
272
+ height: lineHeight
273
+ )
274
+ context.fill(lineRect)
275
+ }
276
+ }
277
+ }
278
+
279
+ final class BlockImageAttachment: NSTextAttachment {
280
+ private let source: String
281
+ private let placeholderTint: UIColor
282
+ private var preferredWidth: CGFloat?
283
+ private var preferredHeight: CGFloat?
284
+ private var loadedImage: UIImage?
285
+
286
+ init(
287
+ source: String,
288
+ placeholderTint: UIColor,
289
+ preferredWidth: CGFloat?,
290
+ preferredHeight: CGFloat?
291
+ ) {
292
+ self.source = source
293
+ self.placeholderTint = placeholderTint
294
+ self.preferredWidth = preferredWidth
295
+ self.preferredHeight = preferredHeight
296
+ super.init(data: nil, ofType: nil)
297
+ loadImageIfNeeded()
298
+ }
299
+
300
+ required init?(coder: NSCoder) {
301
+ return nil
302
+ }
303
+
304
+ func setPreferredSize(width: CGFloat, height: CGFloat) {
305
+ preferredWidth = width
306
+ preferredHeight = height
307
+ }
308
+
309
+ func previewImage() -> UIImage? {
310
+ loadedImage ?? image
311
+ }
312
+
313
+ override func attachmentBounds(
314
+ for textContainer: NSTextContainer?,
315
+ proposedLineFragment lineFrag: CGRect,
316
+ glyphPosition position: CGPoint,
317
+ characterIndex charIndex: Int
318
+ ) -> CGRect {
319
+ let lineFragmentWidth = lineFrag.width.isFinite ? lineFrag.width : 0
320
+ let containerWidth = textContainer.map {
321
+ max(0, $0.size.width - ($0.lineFragmentPadding * 2))
322
+ } ?? 0
323
+ let widthCandidates = [lineFragmentWidth, containerWidth].filter { $0.isFinite && $0 > 0 }
324
+ let maxWidth = max(160, widthCandidates.min() ?? 160)
325
+ let fallbackAspectRatio = loadedImage.flatMap { image -> CGFloat? in
326
+ let imageSize = image.size
327
+ guard imageSize.width > 0, imageSize.height > 0 else { return nil }
328
+ return imageSize.height / imageSize.width
329
+ } ?? 0.56
330
+
331
+ var resolvedWidth = preferredWidth
332
+ var resolvedHeight = preferredHeight
333
+
334
+ if resolvedWidth == nil, resolvedHeight == nil, let loadedImage {
335
+ let imageSize = loadedImage.size
336
+ if imageSize.width > 0, imageSize.height > 0 {
337
+ resolvedWidth = imageSize.width
338
+ resolvedHeight = imageSize.height
339
+ }
340
+ } else if resolvedWidth == nil, let resolvedHeight {
341
+ resolvedWidth = resolvedHeight / fallbackAspectRatio
342
+ } else if resolvedHeight == nil, let resolvedWidth {
343
+ resolvedHeight = resolvedWidth * fallbackAspectRatio
344
+ }
345
+
346
+ let width = max(1, resolvedWidth ?? maxWidth)
347
+ let height = max(1, resolvedHeight ?? min(180, maxWidth * fallbackAspectRatio))
348
+ let scale = min(1, maxWidth / width)
349
+ return CGRect(x: 0, y: 0, width: width * scale, height: height * scale)
350
+ }
351
+
352
+ override func image(
353
+ forBounds imageBounds: CGRect,
354
+ textContainer: NSTextContainer?,
355
+ characterIndex charIndex: Int
356
+ ) -> UIImage? {
357
+ if let loadedImage {
358
+ return loadedImage
359
+ }
360
+
361
+ let renderer = UIGraphicsImageRenderer(bounds: imageBounds)
362
+ return renderer.image { _ in
363
+ let path = UIBezierPath(roundedRect: imageBounds, cornerRadius: 12)
364
+ UIColor.secondarySystemFill.setFill()
365
+ path.fill()
366
+
367
+ let iconSize = min(imageBounds.width, imageBounds.height) * 0.28
368
+ let iconOrigin = CGPoint(
369
+ x: imageBounds.midX - (iconSize / 2),
370
+ y: imageBounds.midY - (iconSize / 2)
371
+ )
372
+ let iconRect = CGRect(origin: iconOrigin, size: CGSize(width: iconSize, height: iconSize))
373
+
374
+ if #available(iOS 13.0, *) {
375
+ let config = UIImage.SymbolConfiguration(pointSize: iconSize, weight: .medium)
376
+ let icon = UIImage(systemName: "photo", withConfiguration: config)?
377
+ .withTintColor(placeholderTint.withAlphaComponent(0.7), renderingMode: .alwaysOriginal)
378
+ icon?.draw(in: iconRect)
379
+ }
380
+ }
381
+ }
382
+
383
+ private func loadImageIfNeeded() {
384
+ NativeImageLoader.load(source: source) { [weak self] image in
385
+ guard let self,
386
+ let image
387
+ else {
388
+ return
389
+ }
390
+ self.loadedImage = image
391
+ self.image = image
392
+ NotificationCenter.default.post(name: .editorImageAttachmentDidLoad, object: self)
393
+ }
394
+ }
395
+
396
+ }
@@ -495,11 +495,11 @@ final class PositionBridge {
495
495
  ) { attrs, range, _ in
496
496
  guard range.length > 0 else { return }
497
497
 
498
- if attrs[RenderBridgeAttributes.syntheticPlaceholder] as? Bool == true {
498
+ if attrs[InputCoordinatorAttributes.syntheticPlaceholder] as? Bool == true {
499
499
  placeholders.append(range.location)
500
500
  }
501
501
 
502
- guard let listContext = attrs[RenderBridgeAttributes.listMarkerContext] as? [String: Any] else {
502
+ guard let listContext = attrs[InputCoordinatorAttributes.listMarkerContext] as? [String: Any] else {
503
503
  return
504
504
  }
505
505
 
@@ -515,7 +515,7 @@ final class PositionBridge {
515
515
  guard seenStarts.insert(paragraphStart).inserted else { return }
516
516
 
517
517
  let markerLength = UInt32(
518
- RenderBridge.listMarkerString(listContext: listContext).unicodeScalars.count
518
+ NativeListMarker.text(for: listContext).unicodeScalars.count
519
519
  )
520
520
  markers.append(
521
521
  VirtualListMarker(
@@ -549,7 +549,7 @@ final class PositionBridge {
549
549
  ) -> Bool {
550
550
  guard paragraphStart > 0, paragraphStart <= attributedString.length else { return false }
551
551
  let previousVoidType = attributedString.attribute(
552
- RenderBridgeAttributes.voidNodeType,
552
+ InputCoordinatorAttributes.voidNodeType,
553
553
  at: paragraphStart - 1,
554
554
  effectiveRange: nil
555
555
  ) as? String