@openeditor/react-native-prose-editor 0.0.6 → 0.0.8

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 +1393 -0
  30. package/ios/NativeEditorExpoView.swift +3 -3
  31. package/ios/NativeEditorModule.swift +30 -45
  32. package/ios/NativeInputSupport.swift +396 -0
  33. package/ios/PositionBridge.swift +4 -4
  34. package/ios/RichTextEditorView.swift +333 -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,1708 +0,0 @@
1
- import UIKit
2
-
3
- extension Notification.Name {
4
- static let editorImageAttachmentDidLoad = Notification.Name(
5
- "com.openeditor.editor.imageAttachmentDidLoad"
6
- )
7
- }
8
-
9
- private enum RenderImageCache {
10
- static let cache = NSCache<NSString, UIImage>()
11
- static let stateQueue = DispatchQueue(label: "com.openeditor.editor.image-loader-state")
12
- static let queue: OperationQueue = {
13
- let queue = OperationQueue()
14
- queue.name = "com.openeditor.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
- }
58
- }
59
-
60
- // MARK: - Constants
61
-
62
- /// Custom NSAttributedString attribute keys for editor metadata.
63
- enum RenderBridgeAttributes {
64
- /// Marks a character as a void element placeholder (hardBreak, horizontalRule).
65
- /// The value is the node type string (e.g. "hardBreak", "horizontalRule").
66
- static let voidNodeType = NSAttributedString.Key("com.openeditor.editor.voidNodeType")
67
-
68
- /// Stores the Rust document position (UInt32) for void elements.
69
- static let docPos = NSAttributedString.Key("com.openeditor.editor.docPos")
70
-
71
- /// Marks a character as a block boundary (for block start/end tracking).
72
- static let blockBoundary = NSAttributedString.Key("com.openeditor.editor.blockBoundary")
73
-
74
- /// Stores the block node type (e.g. "paragraph", "listItem").
75
- static let blockNodeType = NSAttributedString.Key("com.openeditor.editor.blockNodeType")
76
-
77
- /// Stores the block depth (UInt8).
78
- static let blockDepth = NSAttributedString.Key("com.openeditor.editor.blockDepth")
79
-
80
- /// Stores list context info as a dictionary for list items.
81
- static let listContext = NSAttributedString.Key("com.openeditor.editor.listContext")
82
-
83
- /// Marks blocks that should render a visible list marker.
84
- static let listMarkerContext = NSAttributedString.Key("com.openeditor.editor.listMarkerContext")
85
-
86
- /// Stores the rendered list marker color for the paragraph marker.
87
- static let listMarkerColor = NSAttributedString.Key("com.openeditor.editor.listMarkerColor")
88
-
89
- /// Stores the rendered list marker scale for unordered bullets.
90
- static let listMarkerScale = NSAttributedString.Key("com.openeditor.editor.listMarkerScale")
91
-
92
- /// Stores the paragraph base font used to render the list marker.
93
- static let listMarkerBaseFont = NSAttributedString.Key("com.openeditor.editor.listMarkerBaseFont")
94
-
95
- /// Stores the reserved list marker gutter width.
96
- static let listMarkerWidth = NSAttributedString.Key("com.openeditor.editor.listMarkerWidth")
97
-
98
- /// Stores the rendered blockquote border color.
99
- static let blockquoteBorderColor = NSAttributedString.Key("com.openeditor.editor.blockquoteBorderColor")
100
-
101
- /// Stores the rendered blockquote border width.
102
- static let blockquoteBorderWidth = NSAttributedString.Key("com.openeditor.editor.blockquoteBorderWidth")
103
-
104
- /// Stores the rendered blockquote gap between border and text.
105
- static let blockquoteMarkerGap = NSAttributedString.Key("com.openeditor.editor.blockquoteMarkerGap")
106
-
107
- /// Marks code-block paragraphs for custom background drawing.
108
- static let codeBlockBackgroundColor = NSAttributedString.Key("com.openeditor.editor.codeBlockBackgroundColor")
109
- static let codeBlockBorderRadius = NSAttributedString.Key("com.openeditor.editor.codeBlockBorderRadius")
110
- static let codeBlockPaddingHorizontal = NSAttributedString.Key("com.openeditor.editor.codeBlockPaddingHorizontal")
111
- static let codeBlockPaddingVertical = NSAttributedString.Key("com.openeditor.editor.codeBlockPaddingVertical")
112
-
113
- /// Marks synthetic zero-width placeholders used only for UIKit layout.
114
- static let syntheticPlaceholder = NSAttributedString.Key("com.openeditor.editor.syntheticPlaceholder")
115
-
116
- /// Stores the link href for visually styled link text without enabling UITextView's default link interaction.
117
- static let linkHref = NSAttributedString.Key("com.openeditor.editor.linkHref")
118
-
119
- /// Stores the owning top-level document child index for partial native patching.
120
- static let topLevelChildIndex = NSAttributedString.Key("com.openeditor.editor.topLevelChildIndex")
121
- }
122
-
123
- /// Layout constants for paragraph styles.
124
- enum LayoutConstants {
125
- /// Spacing between paragraphs (points).
126
- static let paragraphSpacing: CGFloat = 8.0
127
-
128
- /// Base indentation per depth level (points).
129
- static let indentPerDepth: CGFloat = 24.0
130
-
131
- /// Width reserved for the list bullet/number (points).
132
- static let listMarkerWidth: CGFloat = 36.0
133
-
134
- /// Gap between the list marker and the text that follows (points).
135
- static let listMarkerTextGap: CGFloat = 8.0
136
-
137
- /// Height of the horizontal rule separator line (points).
138
- static let horizontalRuleHeight: CGFloat = 1.0
139
-
140
- /// Vertical padding above and below the horizontal rule (points).
141
- static let horizontalRuleVerticalPadding: CGFloat = 8.0
142
-
143
- /// Total leading inset reserved for each blockquote depth.
144
- static let blockquoteIndent: CGFloat = 18.0
145
-
146
- /// Width of the rendered blockquote border bar.
147
- static let blockquoteBorderWidth: CGFloat = 3.0
148
-
149
- /// Gap between the blockquote border bar and the text that follows.
150
- static let blockquoteMarkerGap: CGFloat = 8.0
151
-
152
- /// Bullet character for unordered list items.
153
- static let unorderedListBullet = "\u{2022} "
154
-
155
- /// Scale factor applied only to unordered list marker glyphs.
156
- static let unorderedListMarkerFontScale: CGFloat = 2.0
157
-
158
- /// Object replacement character used for void block elements.
159
- static let objectReplacementCharacter = "\u{FFFC}"
160
- }
161
-
162
- // MARK: - RenderBridge
163
-
164
- /// Converts RenderElement JSON (emitted by Rust editor-core via UniFFI) into
165
- /// NSAttributedString for display in a UITextView.
166
- ///
167
- /// The JSON format matches the output of `serialize_render_elements` in lib.rs:
168
- /// ```json
169
- /// [
170
- /// {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
171
- /// {"type": "textRun", "text": "Hello ", "marks": []},
172
- /// {"type": "textRun", "text": "world", "marks": ["bold"]},
173
- /// {"type": "blockEnd"},
174
- /// {"type": "voidInline", "nodeType": "hardBreak", "docPos": 12},
175
- /// {"type": "voidBlock", "nodeType": "horizontalRule", "docPos": 15}
176
- /// ]
177
- /// ```
178
- final class RenderBridge {
179
-
180
- // MARK: - Public API
181
-
182
- /// Convert a JSON array of RenderElements into an NSAttributedString.
183
- ///
184
- /// - Parameters:
185
- /// - json: A JSON string representing an array of render elements.
186
- /// - baseFont: The default font for unstyled text.
187
- /// - textColor: The default text color.
188
- /// - Returns: The rendered attributed string. Returns an empty attributed
189
- /// string if the JSON is invalid.
190
- static func renderElements(
191
- fromJSON json: String,
192
- baseFont: UIFont,
193
- textColor: UIColor,
194
- theme: EditorTheme? = nil
195
- ) -> NSAttributedString {
196
- guard let data = json.data(using: .utf8),
197
- let parsed = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
198
- else {
199
- return NSAttributedString()
200
- }
201
-
202
- return renderElements(
203
- fromArray: parsed,
204
- baseFont: baseFont,
205
- textColor: textColor,
206
- theme: theme
207
- )
208
- }
209
-
210
- /// Convert a parsed array of RenderElement dictionaries into an NSAttributedString.
211
- ///
212
- /// This is the main rendering entry point. It processes elements in order,
213
- /// maintaining a block context stack for proper paragraph styling.
214
- ///
215
- /// - Parameters:
216
- /// - elements: Parsed JSON array where each element is a dictionary.
217
- /// - baseFont: The default font for unstyled text.
218
- /// - textColor: The default text color.
219
- /// - Returns: The rendered attributed string.
220
- static func renderElements(
221
- fromArray elements: [[String: Any]],
222
- baseFont: UIFont,
223
- textColor: UIColor,
224
- theme: EditorTheme? = nil
225
- ) -> NSAttributedString {
226
- let result = NSMutableAttributedString()
227
- var blockStack: [BlockContext] = []
228
- var isFirstBlock = true
229
- var pendingTrailingParagraphSpacing: CGFloat? = nil
230
-
231
- for element in elements {
232
- guard let type = element["type"] as? String else { continue }
233
- let topLevelChildIndex = jsonInt(element["topLevelChildIndex"])
234
-
235
- switch type {
236
- case "textRun":
237
- let text = element["text"] as? String ?? ""
238
- let marks = element["marks"] as? [Any] ?? []
239
- let isCodeBlock = blockStack.last?.nodeType == "codeBlock"
240
- let blockFont = resolvedFont(
241
- for: blockStack,
242
- baseFont: baseFont,
243
- theme: theme
244
- )
245
- let blockColor = resolvedTextColor(
246
- for: blockStack,
247
- textColor: textColor,
248
- theme: theme
249
- )
250
- var baseAttrs = attributesForMarks(
251
- marks,
252
- baseFont: blockFont,
253
- textColor: blockColor,
254
- theme: theme
255
- )
256
- if isCodeBlock {
257
- baseAttrs[.font] = UIFont.monospacedSystemFont(
258
- ofSize: blockFont.pointSize,
259
- weight: .regular
260
- )
261
- }
262
- let attrs = applyBlockStyle(
263
- to: baseAttrs,
264
- blockStack: blockStack,
265
- theme: theme,
266
- blockBaseFont: blockFont
267
- )
268
- let attributedText = NSAttributedString(string: text, attributes: attrs)
269
- result.append(
270
- attributedStringApplyingLeadingTopLevelChildIndexIfNeeded(
271
- attributedText,
272
- topLevelChildIndex: topLevelChildIndex,
273
- resultIsEmpty: result.length == 0
274
- )
275
- )
276
-
277
- case "voidInline":
278
- let nodeType = element["nodeType"] as? String ?? ""
279
- let docPos = jsonUInt32(element["docPos"])
280
- let attrs = element["attrs"] as? [String: Any] ?? [:]
281
- if nodeType == "hardBreak" {
282
- overrideTrailingParagraphSpacing(in: result, paragraphSpacing: 0)
283
- }
284
- let attrStr = attributedStringForVoidInline(
285
- nodeType: nodeType,
286
- docPos: docPos,
287
- attrs: attrs,
288
- baseFont: baseFont,
289
- textColor: textColor,
290
- blockStack: blockStack,
291
- topLevelChildIndex: topLevelChildIndex,
292
- theme: theme
293
- )
294
- result.append(
295
- attributedStringApplyingLeadingTopLevelChildIndexIfNeeded(
296
- attrStr,
297
- topLevelChildIndex: topLevelChildIndex,
298
- resultIsEmpty: result.length == 0
299
- )
300
- )
301
-
302
- case "voidBlock":
303
- let nodeType = element["nodeType"] as? String ?? ""
304
- let docPos = jsonUInt32(element["docPos"])
305
- let attrs = element["attrs"] as? [String: Any] ?? [:]
306
-
307
- // Add inter-block newline if not the first block.
308
- if !isFirstBlock {
309
- collapseTrailingSpacingBeforeHorizontalRuleIfNeeded(
310
- in: result,
311
- pendingParagraphSpacing: &pendingTrailingParagraphSpacing,
312
- nodeType: nodeType,
313
- theme: theme
314
- )
315
- applyPendingTrailingParagraphSpacing(
316
- in: result,
317
- pendingParagraphSpacing: &pendingTrailingParagraphSpacing
318
- )
319
- result.append(
320
- interBlockNewline(
321
- baseFont: baseFont,
322
- textColor: textColor,
323
- blockStack: [],
324
- theme: theme,
325
- topLevelChildIndex: topLevelChildIndex
326
- )
327
- )
328
- }
329
- isFirstBlock = false
330
-
331
- let attrStr = attributedStringForVoidBlock(
332
- nodeType: nodeType,
333
- docPos: docPos,
334
- elementAttrs: attrs,
335
- baseFont: baseFont,
336
- textColor: textColor,
337
- topLevelChildIndex: topLevelChildIndex,
338
- theme: theme
339
- )
340
- result.append(attrStr)
341
-
342
- case "opaqueInlineAtom":
343
- let nodeType = element["nodeType"] as? String ?? ""
344
- let label = element["label"] as? String ?? "?"
345
- let docPos = jsonUInt32(element["docPos"])
346
- let mentionTheme = (element["mentionTheme"] as? [String: Any]).map(
347
- EditorMentionTheme.init(dictionary:)
348
- )
349
- let attrStr = attributedStringForOpaqueInlineAtom(
350
- nodeType: nodeType,
351
- label: label,
352
- docPos: docPos,
353
- baseFont: baseFont,
354
- textColor: textColor,
355
- blockStack: blockStack,
356
- topLevelChildIndex: topLevelChildIndex,
357
- theme: theme,
358
- mentionTheme: mentionTheme
359
- )
360
- result.append(
361
- attributedStringApplyingLeadingTopLevelChildIndexIfNeeded(
362
- attrStr,
363
- topLevelChildIndex: topLevelChildIndex,
364
- resultIsEmpty: result.length == 0
365
- )
366
- )
367
-
368
- case "opaqueBlockAtom":
369
- let nodeType = element["nodeType"] as? String ?? ""
370
- let label = element["label"] as? String ?? "?"
371
- let docPos = jsonUInt32(element["docPos"])
372
-
373
- if !isFirstBlock {
374
- applyPendingTrailingParagraphSpacing(
375
- in: result,
376
- pendingParagraphSpacing: &pendingTrailingParagraphSpacing
377
- )
378
- result.append(
379
- interBlockNewline(
380
- baseFont: baseFont,
381
- textColor: textColor,
382
- blockStack: [],
383
- theme: theme,
384
- topLevelChildIndex: topLevelChildIndex
385
- )
386
- )
387
- }
388
- isFirstBlock = false
389
-
390
- let attrStr = attributedStringForOpaqueBlockAtom(
391
- nodeType: nodeType,
392
- label: label,
393
- docPos: docPos,
394
- baseFont: baseFont,
395
- textColor: textColor,
396
- topLevelChildIndex: topLevelChildIndex,
397
- theme: theme
398
- )
399
- result.append(attrStr)
400
-
401
- case "blockStart":
402
- let nodeType = element["nodeType"] as? String ?? ""
403
- let depth = jsonUInt8(element["depth"])
404
- let listContext = element["listContext"] as? [String: Any]
405
- let isListItemContainer = isListItemNodeType(nodeType) && listContext != nil
406
- let isTransparentContainer = isTransparentContainer(nodeType)
407
- let ctx = BlockContext(
408
- nodeType: nodeType,
409
- depth: depth,
410
- listContext: listContext,
411
- topLevelChildIndex: topLevelChildIndex,
412
- markerPending: isListItemContainer
413
- )
414
- let nestedListItemContainer =
415
- isListItemContainer && (theme?.list?.itemSpacing != nil)
416
- && blockStack.contains(where: {
417
- isListItemNodeType($0.nodeType) && $0.listContext != nil
418
- })
419
-
420
- if !isListItemContainer && !isTransparentContainer {
421
- // Add inter-block newline before non-first rendered blocks.
422
- if !isFirstBlock {
423
- applyPendingTrailingParagraphSpacing(
424
- in: result,
425
- pendingParagraphSpacing: &pendingTrailingParagraphSpacing
426
- )
427
- let newlineBlockStack: [BlockContext]
428
- if ctx.nodeType == "codeBlock" {
429
- // The separator before a code block should stay neutral so
430
- // the preceding paragraph is never grouped into the code block.
431
- newlineBlockStack = []
432
- } else if blockquoteDepth(in: blockStack + [ctx]) > 0,
433
- !trailingRenderedContentHasBlockquote(in: result)
434
- {
435
- newlineBlockStack = []
436
- } else {
437
- newlineBlockStack = blockStack + [ctx]
438
- }
439
- let collapsedSeparatorSpacing = collapsedParagraphSpacingAfterHorizontalRule(
440
- in: result,
441
- separatorBlockStack: newlineBlockStack,
442
- theme: theme,
443
- baseFont: baseFont
444
- )
445
- result.append(
446
- interBlockNewline(
447
- baseFont: baseFont,
448
- textColor: textColor,
449
- blockStack: newlineBlockStack,
450
- theme: theme,
451
- paragraphSpacingOverride: collapsedSeparatorSpacing,
452
- topLevelChildIndex: topLevelChildIndex
453
- )
454
- )
455
- }
456
- isFirstBlock = false
457
- } else if applyPendingTrailingParagraphSpacing(
458
- in: result,
459
- pendingParagraphSpacing: &pendingTrailingParagraphSpacing
460
- ) {
461
- // Applied list item spacing queued when the previous item ended.
462
- } else if nestedListItemContainer {
463
- overrideTrailingParagraphSpacing(
464
- in: result,
465
- paragraphSpacing: CGFloat(theme?.list?.itemSpacing ?? 0)
466
- )
467
- }
468
-
469
- // Push block context for inline children to reference.
470
- blockStack.append(ctx)
471
-
472
- var markerListContext: [String: Any]? = nil
473
- if !isListItemContainer {
474
- if let directListContext = listContext {
475
- markerListContext = directListContext
476
- } else {
477
- markerListContext = consumePendingListMarker(from: &blockStack)
478
- }
479
- }
480
-
481
- if markerListContext != nil {
482
- if var currentBlock = blockStack.popLast() {
483
- currentBlock.listMarkerContext = markerListContext
484
- if currentBlock.listContext != nil {
485
- currentBlock.listContext = markerListContext
486
- }
487
- blockStack.append(currentBlock)
488
- }
489
- // On iOS we draw list markers outside the editable text stream so
490
- // UIKit still sees paragraph-start for native capitalization.
491
- }
492
-
493
- case "blockEnd":
494
- if let endedBlock = blockStack.popLast() {
495
- appendTrailingHardBreakPlaceholderIfNeeded(
496
- in: result,
497
- endedBlock: endedBlock,
498
- remainingBlockStack: blockStack,
499
- baseFont: baseFont,
500
- textColor: textColor,
501
- theme: theme
502
- )
503
- if endedBlock.listContext != nil {
504
- pendingTrailingParagraphSpacing = theme?.list?.itemSpacing
505
- }
506
- }
507
-
508
- default:
509
- break
510
- }
511
- }
512
-
513
- return result
514
- }
515
-
516
- static func renderBlocks(
517
- fromArray blocks: [[[String: Any]]],
518
- startIndex: Int = 0,
519
- includeLeadingInterBlockSeparator: Bool = false,
520
- baseFont: UIFont,
521
- textColor: UIColor,
522
- theme: EditorTheme? = nil
523
- ) -> NSAttributedString {
524
- var flattened: [[String: Any]] = []
525
- flattened.reserveCapacity(blocks.reduce(0) { $0 + $1.count })
526
-
527
- for (offset, block) in blocks.enumerated() {
528
- let topLevelChildIndex = startIndex + offset
529
- for element in block {
530
- var tagged = element
531
- tagged["topLevelChildIndex"] = topLevelChildIndex
532
- flattened.append(tagged)
533
- }
534
- }
535
-
536
- let renderedBlocks = renderElements(
537
- fromArray: flattened,
538
- baseFont: baseFont,
539
- textColor: textColor,
540
- theme: theme
541
- )
542
- guard includeLeadingInterBlockSeparator, startIndex > 0, !blocks.isEmpty else {
543
- return renderedBlocks
544
- }
545
-
546
- let separatorReadyBlocks = removingLeadingTopLevelChildIndex(
547
- from: renderedBlocks,
548
- topLevelChildIndex: startIndex
549
- )
550
-
551
- let result = NSMutableAttributedString(
552
- attributedString: interBlockNewline(
553
- baseFont: baseFont,
554
- textColor: textColor,
555
- blockStack: [],
556
- theme: theme,
557
- topLevelChildIndex: startIndex
558
- )
559
- )
560
- result.append(separatorReadyBlocks)
561
- return result
562
- }
563
-
564
- // MARK: - Height Pre-Measurement
565
-
566
- static func measureHeight(
567
- forRenderJSON renderJSON: String,
568
- themeJSON: String?,
569
- width: CGFloat
570
- ) -> CGFloat {
571
- guard width > 0 else { return 0 }
572
-
573
- let theme = EditorTheme.from(json: themeJSON)
574
- let baseFontSize = theme?.text?.fontSize ?? theme?.paragraph?.fontSize ?? 16
575
- let baseFont = UIFont.systemFont(ofSize: baseFontSize)
576
- let textColor = theme?.text?.color ?? UIColor.label
577
-
578
- let attributedString = renderElements(
579
- fromJSON: renderJSON,
580
- baseFont: baseFont,
581
- textColor: textColor,
582
- theme: theme
583
- )
584
-
585
- guard attributedString.length > 0 else { return 0 }
586
-
587
- let contentInsets = theme?.contentInsets
588
- let topInset = contentInsets?.top ?? 0
589
- let bottomInset = contentInsets?.bottom ?? 0
590
- let leftInset = contentInsets?.left ?? 0
591
- let rightInset = contentInsets?.right ?? 0
592
-
593
- // When contentInsets are set, lineFragmentPadding is 0 (matches
594
- // RichTextEditorView.theme didSet). Otherwise use the UITextView
595
- // default of 5.
596
- let lineFragmentPadding: CGFloat = contentInsets != nil ? 0 : 5
597
-
598
- let textStorage = NSTextStorage(attributedString: attributedString)
599
- let layoutManager = NSLayoutManager()
600
- let containerWidth = width - leftInset - rightInset - lineFragmentPadding * 2
601
- let textContainer = NSTextContainer(
602
- size: CGSize(width: max(containerWidth, 0), height: .greatestFiniteMagnitude)
603
- )
604
- textContainer.lineFragmentPadding = 0
605
-
606
- layoutManager.addTextContainer(textContainer)
607
- textStorage.addLayoutManager(layoutManager)
608
-
609
- layoutManager.ensureLayout(for: textContainer)
610
-
611
- var usedRect = layoutManager.usedRect(for: textContainer)
612
- let extraLineFragmentRect = layoutManager.extraLineFragmentRect
613
- if !extraLineFragmentRect.isEmpty {
614
- usedRect = usedRect.union(extraLineFragmentRect)
615
- }
616
-
617
- let height = ceil(usedRect.height + topInset + bottomInset)
618
- return height
619
- }
620
-
621
- // MARK: - Mark Handling
622
-
623
- /// Build NSAttributedString attributes for a set of render marks.
624
- ///
625
- /// Supported marks:
626
- /// - `bold` -> adds `.traitBold` to the font descriptor
627
- /// - `italic` -> adds `.traitItalic` to the font descriptor
628
- /// - `underline` -> sets `.underlineStyle = .single`
629
- /// - `strike` / `strikethrough` -> sets `.strikethroughStyle = .single`
630
- /// - `code` -> uses a monospaced font variant
631
- ///
632
- /// Multiple marks are combined: "bold italic" produces a bold-italic font.
633
- static func attributesForMarks(
634
- _ marks: [Any],
635
- baseFont: UIFont,
636
- textColor: UIColor,
637
- theme: EditorTheme? = nil
638
- ) -> [NSAttributedString.Key: Any] {
639
- var attrs = defaultAttributes(baseFont: baseFont, textColor: textColor)
640
-
641
- if marks.isEmpty {
642
- return attrs
643
- }
644
-
645
- var traits: UIFontDescriptor.SymbolicTraits = []
646
- var useMonospace = false
647
- var linkTheme: EditorLinkTheme?
648
- var shouldUnderline = false
649
- for mark in marks {
650
- let markObject = mark as? [String: Any]
651
- let markType: String
652
- if let markName = mark as? String {
653
- markType = markName
654
- } else if let resolvedType = markObject?["type"] as? String {
655
- markType = resolvedType
656
- } else {
657
- continue
658
- }
659
-
660
- switch markType {
661
- case "bold", "strong":
662
- traits.insert(.traitBold)
663
- case "italic", "em":
664
- traits.insert(.traitItalic)
665
- case "underline":
666
- shouldUnderline = true
667
- case "strike", "strikethrough":
668
- attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
669
- case "code":
670
- useMonospace = true
671
- case "link":
672
- linkTheme = theme?.links
673
- if theme?.links?.underline ?? true {
674
- shouldUnderline = true
675
- }
676
- attrs[.foregroundColor] = theme?.links?.color ?? UIColor.systemBlue
677
- if let backgroundColor = theme?.links?.backgroundColor {
678
- attrs[.backgroundColor] = backgroundColor
679
- }
680
- if let href = markObject?["href"] as? String, !href.isEmpty {
681
- attrs[RenderBridgeAttributes.linkHref] = href
682
- }
683
- default:
684
- break
685
- }
686
- }
687
-
688
- var resolvedFont = linkTheme?.resolvedFont(fallback: baseFont) ?? baseFont
689
-
690
- if useMonospace {
691
- resolvedFont = UIFont.monospacedSystemFont(
692
- ofSize: resolvedFont.pointSize,
693
- weight: traits.contains(.traitBold) ? .bold : .regular
694
- )
695
- // Monospaced doesn't support italic via descriptor traits, but we
696
- // still apply bold via the weight parameter above. If italic is also
697
- // requested alongside code, we skip it (monospaced italic is rare).
698
- if traits.contains(.traitItalic) && !traits.contains(.traitBold) {
699
- // For code+italic only, try applying italic trait.
700
- if let descriptor = resolvedFont.fontDescriptor.withSymbolicTraits(.traitItalic) {
701
- resolvedFont = UIFont(descriptor: descriptor, size: resolvedFont.pointSize)
702
- }
703
- }
704
- } else if !traits.isEmpty {
705
- let mergedTraits = resolvedFont.fontDescriptor.symbolicTraits.union(traits)
706
- if let descriptor = resolvedFont.fontDescriptor.withSymbolicTraits(mergedTraits) {
707
- resolvedFont = UIFont(descriptor: descriptor, size: resolvedFont.pointSize)
708
- }
709
- }
710
-
711
- if shouldUnderline {
712
- attrs[.underlineStyle] = NSUnderlineStyle.single.rawValue
713
- }
714
- attrs[.font] = resolvedFont
715
- return attrs
716
- }
717
-
718
- // MARK: - Void Inline Elements
719
-
720
- /// Create an attributed string for a void inline element (e.g. hardBreak).
721
- ///
722
- /// A hardBreak is rendered as a newline character with custom attributes
723
- /// so the position bridge knows it represents a single doc position.
724
- private static func attributedStringForVoidInline(
725
- nodeType: String,
726
- docPos: UInt32,
727
- attrs _: [String: Any],
728
- baseFont: UIFont,
729
- textColor: UIColor,
730
- blockStack: [BlockContext],
731
- topLevelChildIndex _: Int?,
732
- theme: EditorTheme?
733
- ) -> NSAttributedString {
734
- let blockFont = resolvedFont(for: blockStack, baseFont: baseFont, theme: theme)
735
- let blockColor = resolvedTextColor(for: blockStack, textColor: textColor, theme: theme)
736
- var attrs = defaultAttributes(baseFont: blockFont, textColor: blockColor)
737
- attrs[RenderBridgeAttributes.voidNodeType] = nodeType
738
- attrs[RenderBridgeAttributes.docPos] = docPos
739
- let styledAttrs = applyBlockStyle(
740
- to: attrs,
741
- blockStack: blockStack,
742
- theme: theme,
743
- blockBaseFont: blockFont
744
- )
745
-
746
- switch nodeType {
747
- case "hardBreak":
748
- var hardBreakAttrs = styledAttrs
749
- if let paragraphStyle = (hardBreakAttrs[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy()
750
- as? NSMutableParagraphStyle
751
- {
752
- paragraphStyle.paragraphSpacing = 0
753
- hardBreakAttrs[.paragraphStyle] = paragraphStyle
754
- }
755
- return NSAttributedString(string: "\n", attributes: hardBreakAttrs)
756
- default:
757
- // Unknown void inline: render as object replacement character.
758
- return NSAttributedString(
759
- string: LayoutConstants.objectReplacementCharacter,
760
- attributes: styledAttrs
761
- )
762
- }
763
- }
764
-
765
- // MARK: - Void Block Elements
766
-
767
- /// Create an attributed string for a void block element (e.g. horizontalRule).
768
- ///
769
- /// Horizontal rules are rendered as U+FFFC (object replacement character)
770
- /// with an NSTextAttachment that draws a separator line.
771
- private static func attributedStringForVoidBlock(
772
- nodeType: String,
773
- docPos: UInt32,
774
- elementAttrs: [String: Any],
775
- baseFont: UIFont,
776
- textColor: UIColor,
777
- topLevelChildIndex: Int?,
778
- theme: EditorTheme?
779
- ) -> NSAttributedString {
780
- var attrs = defaultAttributes(baseFont: baseFont, textColor: textColor)
781
- attrs[RenderBridgeAttributes.voidNodeType] = nodeType
782
- attrs[RenderBridgeAttributes.docPos] = docPos
783
- if let topLevelChildIndex {
784
- attrs[RenderBridgeAttributes.topLevelChildIndex] = NSNumber(value: topLevelChildIndex)
785
- }
786
-
787
- switch nodeType {
788
- case "horizontalRule":
789
- let attachment = HorizontalRuleAttachment()
790
- attachment.lineColor = theme?.horizontalRule?.color ?? textColor.withAlphaComponent(0.3)
791
- attachment.lineHeight = theme?.horizontalRule?.thickness ?? LayoutConstants.horizontalRuleHeight
792
- attachment.verticalPadding = resolvedHorizontalRuleVerticalMargin(theme: theme)
793
- let attrStr = NSMutableAttributedString(
794
- attachment: attachment
795
- )
796
- // Apply our custom attributes to the attachment character.
797
- let range = NSRange(location: 0, length: attrStr.length)
798
- attrStr.addAttributes(attrs, range: range)
799
- return attrStr
800
- case "image":
801
- guard let source = (elementAttrs["src"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
802
- !source.isEmpty
803
- else {
804
- return NSAttributedString(
805
- string: LayoutConstants.objectReplacementCharacter,
806
- attributes: attrs
807
- )
808
- }
809
- let attachment = BlockImageAttachment(
810
- source: source,
811
- placeholderTint: textColor,
812
- preferredWidth: jsonCGFloat(elementAttrs["width"]),
813
- preferredHeight: jsonCGFloat(elementAttrs["height"])
814
- )
815
- let attrStr = NSMutableAttributedString(attachment: attachment)
816
- let range = NSRange(location: 0, length: attrStr.length)
817
- attrStr.addAttributes(attrs, range: range)
818
- return attrStr
819
- default:
820
- // Unknown void block: render as object replacement character.
821
- return NSAttributedString(
822
- string: LayoutConstants.objectReplacementCharacter,
823
- attributes: attrs
824
- )
825
- }
826
- }
827
-
828
- // MARK: - Opaque Atoms
829
-
830
- /// Create an attributed string for an opaque inline atom (unknown inline void).
831
- private static func attributedStringForOpaqueInlineAtom(
832
- nodeType: String,
833
- label: String,
834
- docPos: UInt32,
835
- baseFont: UIFont,
836
- textColor: UIColor,
837
- blockStack: [BlockContext],
838
- topLevelChildIndex _: Int?,
839
- theme: EditorTheme?,
840
- mentionTheme: EditorMentionTheme?
841
- ) -> NSAttributedString {
842
- let blockFont = resolvedFont(for: blockStack, baseFont: baseFont, theme: theme)
843
- let blockColor = resolvedTextColor(for: blockStack, textColor: textColor, theme: theme)
844
- var attrs = defaultAttributes(baseFont: blockFont, textColor: blockColor)
845
- attrs[RenderBridgeAttributes.voidNodeType] = nodeType
846
- attrs[RenderBridgeAttributes.docPos] = docPos
847
- if nodeType == "mention" {
848
- let resolvedMentionTheme = theme?.mentions?.merged(with: mentionTheme) ?? mentionTheme
849
- attrs[.foregroundColor] = resolvedMentionTheme?.textColor ?? blockColor
850
- attrs[.backgroundColor] =
851
- resolvedMentionTheme?.backgroundColor ?? UIColor.systemBlue.withAlphaComponent(0.12)
852
- if let mentionFont = mentionFont(from: blockFont, theme: resolvedMentionTheme) {
853
- attrs[.font] = mentionFont
854
- }
855
- } else {
856
- attrs[.backgroundColor] = UIColor.systemGray5
857
- }
858
- let styledAttrs = applyBlockStyle(
859
- to: attrs,
860
- blockStack: blockStack,
861
- theme: theme,
862
- blockBaseFont: blockFont
863
- )
864
-
865
- let visibleText = nodeType == "mention" ? label : "[\(label)]"
866
- return NSAttributedString(string: visibleText, attributes: styledAttrs)
867
- }
868
-
869
- /// Create an attributed string for an opaque block atom (unknown block void).
870
- private static func attributedStringForOpaqueBlockAtom(
871
- nodeType: String,
872
- label: String,
873
- docPos: UInt32,
874
- baseFont: UIFont,
875
- textColor: UIColor,
876
- topLevelChildIndex: Int?,
877
- theme: EditorTheme?
878
- ) -> NSAttributedString {
879
- var attrs = defaultAttributes(baseFont: baseFont, textColor: textColor)
880
- attrs[RenderBridgeAttributes.voidNodeType] = nodeType
881
- attrs[RenderBridgeAttributes.docPos] = docPos
882
- attrs[.backgroundColor] = UIColor.systemGray5
883
- if let topLevelChildIndex {
884
- attrs[RenderBridgeAttributes.topLevelChildIndex] = NSNumber(value: topLevelChildIndex)
885
- }
886
-
887
- return NSAttributedString(string: "[\(label)]", attributes: attrs)
888
- }
889
-
890
- private static func mentionFont(from baseFont: UIFont, theme: EditorMentionTheme?) -> UIFont? {
891
- guard let fontWeight = theme?.fontWeight else { return nil }
892
- let descriptorTraits = EditorTheme.shouldApplyBoldTrait(fontWeight)
893
- ? UIFontDescriptor.SymbolicTraits.traitBold
894
- : []
895
- if descriptorTraits.isEmpty {
896
- return UIFont.systemFont(
897
- ofSize: baseFont.pointSize,
898
- weight: EditorTheme.fontWeight(from: fontWeight)
899
- )
900
- }
901
- guard let descriptor = baseFont.fontDescriptor.withSymbolicTraits(descriptorTraits) else {
902
- return UIFont.systemFont(
903
- ofSize: baseFont.pointSize,
904
- weight: EditorTheme.fontWeight(from: fontWeight)
905
- )
906
- }
907
- return UIFont(descriptor: descriptor, size: baseFont.pointSize)
908
- }
909
-
910
- // MARK: - Block Styling
911
-
912
- /// Create a paragraph style for a block context.
913
- ///
914
- /// Applies indentation based on depth and list context. List items get
915
- /// a hanging indent so the bullet/number sits in the margin.
916
- static func paragraphStyleForBlock(
917
- _ context: BlockContext,
918
- blockStack: [BlockContext],
919
- theme: EditorTheme? = nil,
920
- baseFont: UIFont = .systemFont(ofSize: 16)
921
- ) -> NSMutableParagraphStyle {
922
- let style = NSMutableParagraphStyle()
923
- let blockStyle = theme?.effectiveTextStyle(
924
- for: context.nodeType,
925
- inBlockquote: blockquoteDepth(in: blockStack) > 0
926
- )
927
- let spacing = blockStyle?.spacingAfter
928
- ?? (context.listContext != nil ? theme?.list?.itemSpacing : nil)
929
- ?? LayoutConstants.paragraphSpacing
930
- style.paragraphSpacing = spacing
931
-
932
- let indentPerDepth = theme?.list?.indent ?? LayoutConstants.indentPerDepth
933
- let markerWidth = listMarkerWidth(for: context, theme: theme, baseFont: baseFont)
934
- let quoteDepth = CGFloat(blockquoteDepth(in: blockStack))
935
- let quoteIndent = max(
936
- theme?.blockquote?.indent ?? LayoutConstants.blockquoteIndent,
937
- (theme?.blockquote?.markerGap ?? LayoutConstants.blockquoteMarkerGap)
938
- + (theme?.blockquote?.borderWidth ?? LayoutConstants.blockquoteBorderWidth)
939
- )
940
- let listBaseIndentMultiplier = max(theme?.list?.baseIndentMultiplier ?? 1, 0)
941
- let listBaseIndentAdjustment = context.listContext != nil
942
- ? ((listBaseIndentMultiplier - 1) * indentPerDepth)
943
- : 0
944
- let columnsDepth = CGFloat(columnContainerDepth(in: blockStack))
945
- let baseIndent = (CGFloat(context.depth) * indentPerDepth)
946
- - (quoteDepth * indentPerDepth)
947
- - (columnsDepth * indentPerDepth)
948
- + listBaseIndentAdjustment
949
- + (quoteDepth * quoteIndent)
950
-
951
- if context.listContext != nil {
952
- // List item: reserve a fixed gutter and align all wrapped lines to
953
- // the text start since the marker is drawn separately.
954
- style.firstLineHeadIndent = baseIndent + markerWidth
955
- style.headIndent = baseIndent + markerWidth
956
- } else {
957
- style.firstLineHeadIndent = baseIndent
958
- style.headIndent = baseIndent
959
- }
960
-
961
- if context.nodeType == "codeBlock" {
962
- let horizontalPadding = theme?.codeBlock?.paddingHorizontal ?? 12
963
- style.firstLineHeadIndent += horizontalPadding
964
- style.headIndent += horizontalPadding
965
- style.tailIndent = -horizontalPadding
966
- }
967
-
968
- if let lineHeight = blockStyle?.lineHeight {
969
- style.minimumLineHeight = lineHeight
970
- style.maximumLineHeight = lineHeight
971
- }
972
-
973
- return style
974
- }
975
-
976
- // MARK: - List Markers
977
-
978
- /// Generate the list marker string (bullet or number) from a list context.
979
- static func listMarkerString(listContext: [String: Any]) -> String {
980
- if (listContext["kind"] as? String) == "task" {
981
- let checked = (listContext["checked"] as? NSNumber)?.boolValue ?? false
982
- return checked ? "\u{2611} " : "\u{2610} "
983
- }
984
- let ordered = (listContext["ordered"] as? NSNumber)?.boolValue ?? false
985
-
986
- if ordered {
987
- let index = (listContext["index"] as? NSNumber)?.intValue ?? 1
988
- return "\(index). "
989
- } else {
990
- return LayoutConstants.unorderedListBullet
991
- }
992
- }
993
-
994
- // MARK: - Private Helpers
995
-
996
- /// Extract a `UInt32` from a JSON value produced by `JSONSerialization`.
997
- static func jsonUInt32(_ value: Any?) -> UInt32 {
998
- if let number = value as? NSNumber {
999
- return number.uint32Value
1000
- }
1001
- return 0
1002
- }
1003
-
1004
- /// Extract a `UInt8` from a JSON value produced by `JSONSerialization`.
1005
- static func jsonUInt8(_ value: Any?) -> UInt8 {
1006
- if let number = value as? NSNumber {
1007
- return number.uint8Value
1008
- }
1009
- return 0
1010
- }
1011
-
1012
- static func jsonInt(_ value: Any?) -> Int? {
1013
- if let number = value as? NSNumber {
1014
- return number.intValue
1015
- }
1016
- if let string = value as? String,
1017
- let resolved = Int(string.trimmingCharacters(in: .whitespacesAndNewlines))
1018
- {
1019
- return resolved
1020
- }
1021
- return nil
1022
- }
1023
-
1024
- /// Extract a positive `CGFloat` from a JSON value produced by `JSONSerialization`.
1025
- static func jsonCGFloat(_ value: Any?) -> CGFloat? {
1026
- if let number = value as? NSNumber {
1027
- let resolved = CGFloat(truncating: number)
1028
- return resolved > 0 ? resolved : nil
1029
- }
1030
- if let string = value as? String,
1031
- let resolved = Double(string.trimmingCharacters(in: .whitespacesAndNewlines)),
1032
- resolved > 0
1033
- {
1034
- return CGFloat(resolved)
1035
- }
1036
- return nil
1037
- }
1038
-
1039
- private static func defaultAttributes(
1040
- baseFont: UIFont,
1041
- textColor: UIColor
1042
- ) -> [NSAttributedString.Key: Any] {
1043
- [
1044
- .font: baseFont,
1045
- .foregroundColor: textColor,
1046
- ]
1047
- }
1048
-
1049
- @discardableResult
1050
- private static func applyBlockStyle(
1051
- to attrs: [NSAttributedString.Key: Any],
1052
- blockStack: [BlockContext],
1053
- theme: EditorTheme?,
1054
- blockBaseFont: UIFont? = nil
1055
- ) -> [NSAttributedString.Key: Any] {
1056
- guard let currentBlock = effectiveBlockContext(blockStack) else { return attrs }
1057
- var mutableAttrs = attrs
1058
- let renderedFont = mutableAttrs[.font] as? UIFont ?? .systemFont(ofSize: 16)
1059
- let paragraphBaseFont = blockBaseFont ?? renderedFont
1060
- mutableAttrs[.paragraphStyle] = paragraphStyleForBlock(
1061
- currentBlock,
1062
- blockStack: blockStack,
1063
- theme: theme,
1064
- baseFont: paragraphBaseFont
1065
- )
1066
- mutableAttrs[RenderBridgeAttributes.blockNodeType] = currentBlock.nodeType
1067
- mutableAttrs[RenderBridgeAttributes.blockDepth] = currentBlock.depth
1068
- if let listContext = currentBlock.listContext {
1069
- mutableAttrs[RenderBridgeAttributes.listContext] = listContext
1070
- }
1071
- if let markerContext = currentBlock.listMarkerContext {
1072
- mutableAttrs[RenderBridgeAttributes.listMarkerContext] = markerContext
1073
- mutableAttrs[RenderBridgeAttributes.listMarkerColor] = theme?.list?.markerColor
1074
- mutableAttrs[RenderBridgeAttributes.listMarkerScale] = theme?.list?.markerScale
1075
- mutableAttrs[RenderBridgeAttributes.listMarkerBaseFont] = paragraphBaseFont
1076
- mutableAttrs[RenderBridgeAttributes.listMarkerWidth] = listMarkerWidth(
1077
- for: currentBlock,
1078
- theme: theme,
1079
- baseFont: paragraphBaseFont
1080
- )
1081
- }
1082
- if currentBlock.nodeType == "codeBlock" {
1083
- mutableAttrs[RenderBridgeAttributes.codeBlockBackgroundColor] =
1084
- theme?.codeBlock?.backgroundColor ?? UIColor.secondarySystemBackground
1085
- mutableAttrs[RenderBridgeAttributes.codeBlockBorderRadius] =
1086
- theme?.codeBlock?.borderRadius ?? 8
1087
- mutableAttrs[RenderBridgeAttributes.codeBlockPaddingHorizontal] =
1088
- theme?.codeBlock?.paddingHorizontal ?? 12
1089
- mutableAttrs[RenderBridgeAttributes.codeBlockPaddingVertical] =
1090
- theme?.codeBlock?.paddingVertical ?? 8
1091
- }
1092
- if blockquoteDepth(in: blockStack) > 0 {
1093
- let foreground = mutableAttrs[.foregroundColor] as? UIColor ?? .separator
1094
- mutableAttrs[RenderBridgeAttributes.blockquoteBorderColor] =
1095
- theme?.blockquote?.borderColor
1096
- ?? foreground.withAlphaComponent(0.3)
1097
- mutableAttrs[RenderBridgeAttributes.blockquoteBorderWidth] =
1098
- theme?.blockquote?.borderWidth ?? LayoutConstants.blockquoteBorderWidth
1099
- mutableAttrs[RenderBridgeAttributes.blockquoteMarkerGap] =
1100
- theme?.blockquote?.markerGap ?? LayoutConstants.blockquoteMarkerGap
1101
- }
1102
- return mutableAttrs
1103
- }
1104
-
1105
- /// Create a newline attributed string used between blocks.
1106
- ///
1107
- /// This newline separates consecutive blocks in the flat rendered text.
1108
- /// It carries minimal styling (base font, no special attributes).
1109
- private static func interBlockNewline(
1110
- baseFont: UIFont,
1111
- textColor: UIColor,
1112
- blockStack: [BlockContext],
1113
- theme: EditorTheme?,
1114
- paragraphSpacingOverride: CGFloat? = nil,
1115
- topLevelChildIndex: Int? = nil
1116
- ) -> NSAttributedString {
1117
- var attrs = applyBlockStyle(
1118
- to: defaultAttributes(baseFont: baseFont, textColor: textColor),
1119
- blockStack: blockStack,
1120
- theme: theme,
1121
- blockBaseFont: baseFont
1122
- )
1123
- if let topLevelChildIndex {
1124
- attrs[RenderBridgeAttributes.topLevelChildIndex] = NSNumber(value: topLevelChildIndex)
1125
- }
1126
- if let paragraphSpacingOverride,
1127
- let paragraphStyle = (attrs[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy()
1128
- as? NSMutableParagraphStyle
1129
- {
1130
- paragraphStyle.paragraphSpacing = paragraphSpacingOverride
1131
- attrs[.paragraphStyle] = paragraphStyle
1132
- }
1133
- return NSAttributedString(string: "\n", attributes: attrs)
1134
- }
1135
-
1136
- private static func attributedStringApplyingLeadingTopLevelChildIndexIfNeeded(
1137
- _ attributedString: NSAttributedString,
1138
- topLevelChildIndex: Int?,
1139
- resultIsEmpty: Bool
1140
- ) -> NSAttributedString {
1141
- guard resultIsEmpty,
1142
- let topLevelChildIndex,
1143
- attributedString.length > 0
1144
- else {
1145
- return attributedString
1146
- }
1147
-
1148
- let tagged = NSMutableAttributedString(attributedString: attributedString)
1149
- let firstComposedCharacterRange = (tagged.string as NSString)
1150
- .rangeOfComposedCharacterSequence(at: 0)
1151
- tagged.addAttribute(
1152
- RenderBridgeAttributes.topLevelChildIndex,
1153
- value: NSNumber(value: topLevelChildIndex),
1154
- range: firstComposedCharacterRange
1155
- )
1156
- return tagged
1157
- }
1158
-
1159
- private static func removingLeadingTopLevelChildIndex(
1160
- from attributedString: NSAttributedString,
1161
- topLevelChildIndex: Int
1162
- ) -> NSAttributedString {
1163
- guard attributedString.length > 0 else { return attributedString }
1164
-
1165
- let firstValue = attributedString.attribute(
1166
- RenderBridgeAttributes.topLevelChildIndex,
1167
- at: 0,
1168
- effectiveRange: nil
1169
- ) as? NSNumber
1170
- guard firstValue?.intValue == topLevelChildIndex else {
1171
- return attributedString
1172
- }
1173
-
1174
- let adjusted = NSMutableAttributedString(attributedString: attributedString)
1175
- var effectiveRange = NSRange(location: 0, length: 0)
1176
- adjusted.attribute(
1177
- RenderBridgeAttributes.topLevelChildIndex,
1178
- at: 0,
1179
- longestEffectiveRange: &effectiveRange,
1180
- in: NSRange(location: 0, length: adjusted.length)
1181
- )
1182
- adjusted.removeAttribute(
1183
- RenderBridgeAttributes.topLevelChildIndex,
1184
- range: effectiveRange
1185
- )
1186
- return adjusted
1187
- }
1188
-
1189
- private static func effectiveBlockContext(_ blockStack: [BlockContext]) -> BlockContext? {
1190
- guard let currentBlock = blockStack.last else { return nil }
1191
- if currentBlock.listContext != nil {
1192
- return currentBlock
1193
- }
1194
- guard let inheritedListBlock = nearestListBlock(in: Array(blockStack.dropLast())) else {
1195
- return currentBlock
1196
- }
1197
- return BlockContext(
1198
- nodeType: currentBlock.nodeType,
1199
- depth: currentBlock.depth,
1200
- listContext: inheritedListBlock.listContext,
1201
- listMarkerContext: currentBlock.listMarkerContext,
1202
- markerPending: false
1203
- )
1204
- }
1205
-
1206
- private static func nearestListBlock(in contexts: [BlockContext]) -> BlockContext? {
1207
- for context in contexts.reversed() where context.listContext != nil {
1208
- return context
1209
- }
1210
- return nil
1211
- }
1212
-
1213
- private static func trailingRenderedContentHasBlockquote(
1214
- in result: NSAttributedString
1215
- ) -> Bool {
1216
- guard result.length > 0 else { return false }
1217
- let nsString = result.string as NSString
1218
-
1219
- for index in stride(from: result.length - 1, through: 0, by: -1) {
1220
- let scalar = nsString.character(at: index)
1221
- if scalar == 0x000A || scalar == 0x000D {
1222
- continue
1223
- }
1224
- return result.attribute(
1225
- RenderBridgeAttributes.blockquoteBorderColor,
1226
- at: index,
1227
- effectiveRange: nil
1228
- ) != nil
1229
- }
1230
-
1231
- return false
1232
- }
1233
-
1234
- private static func consumePendingListMarker(from blockStack: inout [BlockContext]) -> [String: Any]? {
1235
- guard blockStack.count >= 2 else { return nil }
1236
- for idx in stride(from: blockStack.count - 2, through: 0, by: -1) {
1237
- guard blockStack[idx].markerPending else { continue }
1238
- blockStack[idx].markerPending = false
1239
- return blockStack[idx].listContext
1240
- }
1241
- return nil
1242
- }
1243
-
1244
- private static func isListItemNodeType(_ nodeType: String) -> Bool {
1245
- nodeType == "listItem" || nodeType == "taskItem"
1246
- }
1247
-
1248
- private static func overrideTrailingParagraphSpacing(
1249
- in result: NSMutableAttributedString,
1250
- paragraphSpacing: CGFloat
1251
- ) {
1252
- guard result.length > 0 else { return }
1253
-
1254
- let nsString = result.string as NSString
1255
- let paragraphRange = nsString.paragraphRange(for: NSRange(location: result.length - 1, length: 0))
1256
- result.enumerateAttribute(
1257
- .paragraphStyle,
1258
- in: paragraphRange,
1259
- options: [.longestEffectiveRangeNotRequired]
1260
- ) { value, range, _ in
1261
- let sourceStyle = (value as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle
1262
- ?? NSMutableParagraphStyle()
1263
- sourceStyle.paragraphSpacing = paragraphSpacing
1264
- result.addAttribute(.paragraphStyle, value: sourceStyle, range: range)
1265
- }
1266
- }
1267
-
1268
- private static func collapseTrailingSpacingBeforeHorizontalRuleIfNeeded(
1269
- in result: NSMutableAttributedString,
1270
- pendingParagraphSpacing: inout CGFloat?,
1271
- nodeType: String,
1272
- theme: EditorTheme?
1273
- ) {
1274
- guard nodeType == "horizontalRule" else { return }
1275
- let horizontalRuleMargin = resolvedHorizontalRuleVerticalMargin(theme: theme)
1276
-
1277
- if let pendingSpacing = pendingParagraphSpacing {
1278
- pendingParagraphSpacing = collapsedSpacing(
1279
- existingSpacing: pendingSpacing,
1280
- adjacentHorizontalRuleMargin: horizontalRuleMargin
1281
- )
1282
- return
1283
- }
1284
-
1285
- guard let trailingParagraphSpacing = trailingParagraphSpacing(in: result) else { return }
1286
- let adjustedSpacing = collapsedSpacing(
1287
- existingSpacing: trailingParagraphSpacing,
1288
- adjacentHorizontalRuleMargin: horizontalRuleMargin
1289
- )
1290
- guard abs(adjustedSpacing - trailingParagraphSpacing) > 0.01 else { return }
1291
- overrideTrailingParagraphSpacing(in: result, paragraphSpacing: adjustedSpacing)
1292
- }
1293
-
1294
- private static func collapsedParagraphSpacingAfterHorizontalRule(
1295
- in result: NSAttributedString,
1296
- separatorBlockStack: [BlockContext],
1297
- theme: EditorTheme?,
1298
- baseFont: UIFont
1299
- ) -> CGFloat? {
1300
- guard let horizontalRuleMargin = trailingHorizontalRuleMargin(in: result),
1301
- let separatorSpacing = separatorParagraphSpacing(
1302
- for: separatorBlockStack,
1303
- theme: theme,
1304
- baseFont: baseFont
1305
- )
1306
- else {
1307
- return nil
1308
- }
1309
-
1310
- return collapsedSpacing(
1311
- existingSpacing: separatorSpacing,
1312
- adjacentHorizontalRuleMargin: horizontalRuleMargin
1313
- )
1314
- }
1315
-
1316
- @discardableResult
1317
- private static func applyPendingTrailingParagraphSpacing(
1318
- in result: NSMutableAttributedString,
1319
- pendingParagraphSpacing: inout CGFloat?
1320
- ) -> Bool {
1321
- guard let paragraphSpacing = pendingParagraphSpacing else { return false }
1322
- overrideTrailingParagraphSpacing(in: result, paragraphSpacing: paragraphSpacing)
1323
- pendingParagraphSpacing = nil
1324
- return true
1325
- }
1326
-
1327
- private static func trailingParagraphSpacing(in result: NSAttributedString) -> CGFloat? {
1328
- guard result.length > 0 else { return nil }
1329
-
1330
- let nsString = result.string as NSString
1331
- let paragraphRange = nsString.paragraphRange(for: NSRange(location: result.length - 1, length: 0))
1332
- var spacing: CGFloat? = nil
1333
- result.enumerateAttribute(
1334
- .paragraphStyle,
1335
- in: paragraphRange,
1336
- options: [.reverse, .longestEffectiveRangeNotRequired]
1337
- ) { value, _, stop in
1338
- if let paragraphStyle = value as? NSParagraphStyle {
1339
- spacing = paragraphStyle.paragraphSpacing
1340
- stop.pointee = true
1341
- }
1342
- }
1343
- return spacing
1344
- }
1345
-
1346
- private static func separatorParagraphSpacing(
1347
- for blockStack: [BlockContext],
1348
- theme: EditorTheme?,
1349
- baseFont: UIFont
1350
- ) -> CGFloat? {
1351
- guard let currentBlock = effectiveBlockContext(blockStack) else { return nil }
1352
- return paragraphStyleForBlock(
1353
- currentBlock,
1354
- blockStack: blockStack,
1355
- theme: theme,
1356
- baseFont: baseFont
1357
- ).paragraphSpacing
1358
- }
1359
-
1360
- private static func trailingHorizontalRuleMargin(in result: NSAttributedString) -> CGFloat? {
1361
- guard result.length > 0 else { return nil }
1362
- let nsString = result.string as NSString
1363
-
1364
- for index in stride(from: result.length - 1, through: 0, by: -1) {
1365
- let scalar = nsString.character(at: index)
1366
- if scalar == 0x000A || scalar == 0x000D {
1367
- continue
1368
- }
1369
- guard result.attribute(
1370
- RenderBridgeAttributes.voidNodeType,
1371
- at: index,
1372
- effectiveRange: nil
1373
- ) as? String == "horizontalRule" else {
1374
- return nil
1375
- }
1376
- return (
1377
- result.attribute(.attachment, at: index, effectiveRange: nil)
1378
- as? HorizontalRuleAttachment
1379
- )?.verticalPadding
1380
- }
1381
-
1382
- return nil
1383
- }
1384
-
1385
- private static func resolvedHorizontalRuleVerticalMargin(theme: EditorTheme?) -> CGFloat {
1386
- theme?.horizontalRule?.verticalMargin ?? LayoutConstants.horizontalRuleVerticalPadding
1387
- }
1388
-
1389
- private static func collapsedSpacing(
1390
- existingSpacing: CGFloat,
1391
- adjacentHorizontalRuleMargin: CGFloat
1392
- ) -> CGFloat {
1393
- max(existingSpacing, adjacentHorizontalRuleMargin) - adjacentHorizontalRuleMargin
1394
- }
1395
-
1396
- private static func appendTrailingHardBreakPlaceholderIfNeeded(
1397
- in result: NSMutableAttributedString,
1398
- endedBlock: BlockContext,
1399
- remainingBlockStack: [BlockContext],
1400
- baseFont: UIFont,
1401
- textColor: UIColor,
1402
- theme: EditorTheme?
1403
- ) {
1404
- guard result.length > 0 else { return }
1405
- guard endedBlock.nodeType != "listItem" else { return }
1406
- guard result.attribute(
1407
- RenderBridgeAttributes.voidNodeType,
1408
- at: result.length - 1,
1409
- effectiveRange: nil
1410
- ) as? String == "hardBreak" else {
1411
- return
1412
- }
1413
-
1414
- let placeholderBlockStack = remainingBlockStack + [endedBlock]
1415
- let blockFont = resolvedFont(
1416
- for: placeholderBlockStack,
1417
- baseFont: baseFont,
1418
- theme: theme
1419
- )
1420
- let blockColor = resolvedTextColor(
1421
- for: placeholderBlockStack,
1422
- textColor: textColor,
1423
- theme: theme
1424
- )
1425
- var attrs = defaultAttributes(baseFont: blockFont, textColor: blockColor)
1426
- attrs[RenderBridgeAttributes.syntheticPlaceholder] = true
1427
- var styledAttrs = applyBlockStyle(
1428
- to: attrs,
1429
- blockStack: placeholderBlockStack,
1430
- theme: theme,
1431
- blockBaseFont: blockFont
1432
- )
1433
- if let paragraphStyle = (styledAttrs[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy()
1434
- as? NSMutableParagraphStyle
1435
- {
1436
- paragraphStyle.paragraphSpacing = 0
1437
- styledAttrs[.paragraphStyle] = paragraphStyle
1438
- }
1439
- result.append(NSAttributedString(string: "\u{200B}", attributes: styledAttrs))
1440
- }
1441
-
1442
- private static func listMarkerWidth(
1443
- for context: BlockContext,
1444
- theme: EditorTheme?,
1445
- baseFont: UIFont
1446
- ) -> CGFloat {
1447
- guard let listContext = context.listContext else { return 0 }
1448
- _ = listContext
1449
- _ = theme
1450
- _ = baseFont
1451
- return LayoutConstants.listMarkerWidth
1452
- }
1453
-
1454
- private static func resolvedTextStyle(
1455
- for blockStack: [BlockContext],
1456
- theme: EditorTheme?
1457
- ) -> EditorTextStyle? {
1458
- let inBlockquote = blockquoteDepth(in: blockStack) > 0
1459
- guard let currentBlock = effectiveBlockContext(blockStack) else {
1460
- return theme?.effectiveTextStyle(for: "paragraph", inBlockquote: inBlockquote)
1461
- }
1462
- return theme?.effectiveTextStyle(for: currentBlock.nodeType, inBlockquote: inBlockquote)
1463
- }
1464
-
1465
- private static func blockquoteDepth(in blockStack: [BlockContext]) -> Int {
1466
- blockStack.reduce(into: 0) { count, context in
1467
- if context.nodeType == "blockquote" {
1468
- count += 1
1469
- }
1470
- }
1471
- }
1472
-
1473
- private static func columnContainerDepth(in blockStack: [BlockContext]) -> Int {
1474
- blockStack.reduce(into: 0) { count, context in
1475
- if context.nodeType == "columns" || context.nodeType == "column" {
1476
- count += 1
1477
- }
1478
- }
1479
- }
1480
-
1481
- private static func isTransparentContainer(_ nodeType: String) -> Bool {
1482
- nodeType == "blockquote" || nodeType == "columns" || nodeType == "column"
1483
- }
1484
-
1485
- private static func resolvedFont(
1486
- for blockStack: [BlockContext],
1487
- baseFont: UIFont,
1488
- theme: EditorTheme?
1489
- ) -> UIFont {
1490
- resolvedTextStyle(for: blockStack, theme: theme)?.resolvedFont(fallback: baseFont)
1491
- ?? baseFont
1492
- }
1493
-
1494
- private static func resolvedTextColor(
1495
- for blockStack: [BlockContext],
1496
- textColor: UIColor,
1497
- theme: EditorTheme?
1498
- ) -> UIColor {
1499
- resolvedTextStyle(for: blockStack, theme: theme)?.color ?? textColor
1500
- }
1501
- }
1502
-
1503
- // MARK: - BlockContext
1504
-
1505
- /// Transient context while rendering block elements. Pushed onto a stack
1506
- /// when a `blockStart` element is encountered and popped on `blockEnd`.
1507
- struct BlockContext {
1508
- let nodeType: String
1509
- let depth: UInt8
1510
- var listContext: [String: Any]?
1511
- var topLevelChildIndex: Int? = nil
1512
- var listMarkerContext: [String: Any]? = nil
1513
- var markerPending: Bool = false
1514
- }
1515
-
1516
- // MARK: - HorizontalRuleAttachment
1517
-
1518
- /// NSTextAttachment subclass that draws a horizontal separator line.
1519
- ///
1520
- /// The attachment renders as a thin line across the available width with
1521
- /// vertical padding. Used for `horizontalRule` void block elements.
1522
- final class HorizontalRuleAttachment: NSTextAttachment {
1523
-
1524
- var lineColor: UIColor = .separator
1525
- var lineHeight: CGFloat = LayoutConstants.horizontalRuleHeight
1526
- var verticalPadding: CGFloat = LayoutConstants.horizontalRuleVerticalPadding
1527
-
1528
- override func attachmentBounds(
1529
- for textContainer: NSTextContainer?,
1530
- proposedLineFragment lineFrag: CGRect,
1531
- glyphPosition position: CGPoint,
1532
- characterIndex charIndex: Int
1533
- ) -> CGRect {
1534
- let totalHeight = lineHeight + (verticalPadding * 2)
1535
- return CGRect(
1536
- x: 0,
1537
- y: 0,
1538
- width: lineFrag.width,
1539
- height: totalHeight
1540
- )
1541
- }
1542
-
1543
- override func image(
1544
- forBounds imageBounds: CGRect,
1545
- textContainer: NSTextContainer?,
1546
- characterIndex charIndex: Int
1547
- ) -> UIImage? {
1548
- let renderer = UIGraphicsImageRenderer(bounds: imageBounds)
1549
- return renderer.image { context in
1550
- lineColor.setFill()
1551
- let lineY = imageBounds.midY - (lineHeight / 2)
1552
- let lineRect = CGRect(
1553
- x: imageBounds.origin.x,
1554
- y: lineY,
1555
- width: imageBounds.width,
1556
- height: lineHeight
1557
- )
1558
- context.fill(lineRect)
1559
- }
1560
- }
1561
- }
1562
-
1563
- final class BlockImageAttachment: NSTextAttachment {
1564
- private let source: String
1565
- private let placeholderTint: UIColor
1566
- private var preferredWidth: CGFloat?
1567
- private var preferredHeight: CGFloat?
1568
- private var loadedImage: UIImage?
1569
-
1570
- init(
1571
- source: String,
1572
- placeholderTint: UIColor,
1573
- preferredWidth: CGFloat?,
1574
- preferredHeight: CGFloat?
1575
- ) {
1576
- self.source = source
1577
- self.placeholderTint = placeholderTint
1578
- self.preferredWidth = preferredWidth
1579
- self.preferredHeight = preferredHeight
1580
- super.init(data: nil, ofType: nil)
1581
- loadImageIfNeeded()
1582
- }
1583
-
1584
- required init?(coder: NSCoder) {
1585
- return nil
1586
- }
1587
-
1588
- func setPreferredSize(width: CGFloat, height: CGFloat) {
1589
- preferredWidth = width
1590
- preferredHeight = height
1591
- }
1592
-
1593
- func previewImage() -> UIImage? {
1594
- loadedImage ?? image
1595
- }
1596
-
1597
- override func attachmentBounds(
1598
- for textContainer: NSTextContainer?,
1599
- proposedLineFragment lineFrag: CGRect,
1600
- glyphPosition position: CGPoint,
1601
- characterIndex charIndex: Int
1602
- ) -> CGRect {
1603
- let lineFragmentWidth = lineFrag.width.isFinite ? lineFrag.width : 0
1604
- let containerWidth = textContainer.map {
1605
- max(0, $0.size.width - ($0.lineFragmentPadding * 2))
1606
- } ?? 0
1607
- let widthCandidates = [lineFragmentWidth, containerWidth].filter { $0.isFinite && $0 > 0 }
1608
- let maxWidth = max(160, widthCandidates.min() ?? 160)
1609
- let fallbackAspectRatio = loadedImage.flatMap { image -> CGFloat? in
1610
- let imageSize = image.size
1611
- guard imageSize.width > 0, imageSize.height > 0 else { return nil }
1612
- return imageSize.height / imageSize.width
1613
- } ?? 0.56
1614
-
1615
- var resolvedWidth = preferredWidth
1616
- var resolvedHeight = preferredHeight
1617
-
1618
- if resolvedWidth == nil, resolvedHeight == nil, let loadedImage {
1619
- let imageSize = loadedImage.size
1620
- if imageSize.width > 0, imageSize.height > 0 {
1621
- resolvedWidth = imageSize.width
1622
- resolvedHeight = imageSize.height
1623
- }
1624
- } else if resolvedWidth == nil, let resolvedHeight {
1625
- resolvedWidth = resolvedHeight / fallbackAspectRatio
1626
- } else if resolvedHeight == nil, let resolvedWidth {
1627
- resolvedHeight = resolvedWidth * fallbackAspectRatio
1628
- }
1629
-
1630
- let width = max(1, resolvedWidth ?? maxWidth)
1631
- let height = max(1, resolvedHeight ?? min(180, maxWidth * fallbackAspectRatio))
1632
- let scale = min(1, maxWidth / width)
1633
- return CGRect(x: 0, y: 0, width: width * scale, height: height * scale)
1634
- }
1635
-
1636
- override func image(
1637
- forBounds imageBounds: CGRect,
1638
- textContainer: NSTextContainer?,
1639
- characterIndex charIndex: Int
1640
- ) -> UIImage? {
1641
- if let loadedImage {
1642
- return loadedImage
1643
- }
1644
-
1645
- let renderer = UIGraphicsImageRenderer(bounds: imageBounds)
1646
- return renderer.image { _ in
1647
- let path = UIBezierPath(roundedRect: imageBounds, cornerRadius: 12)
1648
- UIColor.secondarySystemFill.setFill()
1649
- path.fill()
1650
-
1651
- let iconSize = min(imageBounds.width, imageBounds.height) * 0.28
1652
- let iconOrigin = CGPoint(
1653
- x: imageBounds.midX - (iconSize / 2),
1654
- y: imageBounds.midY - (iconSize / 2)
1655
- )
1656
- let iconRect = CGRect(origin: iconOrigin, size: CGSize(width: iconSize, height: iconSize))
1657
-
1658
- if #available(iOS 13.0, *) {
1659
- let config = UIImage.SymbolConfiguration(pointSize: iconSize, weight: .medium)
1660
- let icon = UIImage(systemName: "photo", withConfiguration: config)?
1661
- .withTintColor(placeholderTint.withAlphaComponent(0.7), renderingMode: .alwaysOriginal)
1662
- icon?.draw(in: iconRect)
1663
- }
1664
- }
1665
- }
1666
-
1667
- private func loadImageIfNeeded() {
1668
- if let cached = RenderImageCache.cache.object(forKey: source as NSString) {
1669
- loadedImage = cached
1670
- image = cached
1671
- return
1672
- }
1673
-
1674
- if let inlineData = Self.decodeDataURL(source),
1675
- let image = UIImage(data: inlineData)
1676
- {
1677
- RenderImageCache.cache.setObject(image, forKey: source as NSString)
1678
- loadedImage = image
1679
- self.image = image
1680
- return
1681
- }
1682
-
1683
- guard let url = URL(string: source) else { return }
1684
- RenderImageCache.load(source: source, url: url) { [weak self] image in
1685
- guard let self,
1686
- let image
1687
- else {
1688
- return
1689
- }
1690
- self.loadedImage = image
1691
- self.image = image
1692
- NotificationCenter.default.post(name: .editorImageAttachmentDidLoad, object: self)
1693
- }
1694
- }
1695
-
1696
- private static func decodeDataURL(_ source: String) -> Data? {
1697
- let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines)
1698
- guard trimmed.lowercased().hasPrefix("data:image/"),
1699
- let commaIndex = trimmed.firstIndex(of: ",")
1700
- else {
1701
- return nil
1702
- }
1703
- let metadata = String(trimmed[..<commaIndex]).lowercased()
1704
- let payload = String(trimmed[trimmed.index(after: commaIndex)...])
1705
- guard metadata.contains(";base64") else { return nil }
1706
- return Data(base64Encoded: payload, options: [.ignoreUnknownCharacters])
1707
- }
1708
- }