@apollohg/react-native-prose-editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +160 -0
  2. package/README.md +143 -0
  3. package/android/build.gradle +39 -0
  4. package/android/src/main/assets/editor-icons/MaterialIcons.json +2236 -0
  5. package/android/src/main/assets/editor-icons/MaterialIcons.ttf +0 -0
  6. package/android/src/main/java/com/apollohg/editor/EditorAddons.kt +131 -0
  7. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +1057 -0
  8. package/android/src/main/java/com/apollohg/editor/EditorHeightBehavior.kt +14 -0
  9. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +191 -0
  10. package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +325 -0
  11. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +647 -0
  12. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +257 -0
  13. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +714 -0
  14. package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +76 -0
  15. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +1044 -0
  16. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +211 -0
  17. package/expo-module.config.json +9 -0
  18. package/ios/EditorAddons.swift +228 -0
  19. package/ios/EditorCore.xcframework/Info.plist +44 -0
  20. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  21. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  22. package/ios/EditorLayoutManager.swift +254 -0
  23. package/ios/EditorTheme.swift +372 -0
  24. package/ios/Generated_editor_core.swift +1143 -0
  25. package/ios/NativeEditorExpoView.swift +1417 -0
  26. package/ios/NativeEditorModule.swift +263 -0
  27. package/ios/PositionBridge.swift +278 -0
  28. package/ios/ReactNativeProseEditor.podspec +49 -0
  29. package/ios/RenderBridge.swift +825 -0
  30. package/ios/RichTextEditorView.swift +1559 -0
  31. package/ios/editor_coreFFI/editor_coreFFI.h +1014 -0
  32. package/ios/editor_coreFFI/module.modulemap +7 -0
  33. package/ios/editor_coreFFI.h +904 -0
  34. package/ios/editor_coreFFI.modulemap +7 -0
  35. package/package.json +66 -0
  36. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  37. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  38. package/rust/android/x86_64/libeditor_core.so +0 -0
  39. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2014 -0
  40. package/src/EditorTheme.ts +130 -0
  41. package/src/EditorToolbar.tsx +620 -0
  42. package/src/NativeEditorBridge.ts +607 -0
  43. package/src/NativeRichTextEditor.tsx +951 -0
  44. package/src/addons.ts +158 -0
  45. package/src/index.ts +63 -0
  46. package/src/schemas.ts +153 -0
  47. package/src/useNativeEditor.ts +173 -0
@@ -0,0 +1,825 @@
1
+ import UIKit
2
+
3
+ // MARK: - Constants
4
+
5
+ /// Custom NSAttributedString attribute keys for editor metadata.
6
+ enum RenderBridgeAttributes {
7
+ /// Marks a character as a void element placeholder (hardBreak, horizontalRule).
8
+ /// The value is the node type string (e.g. "hardBreak", "horizontalRule").
9
+ static let voidNodeType = NSAttributedString.Key("com.apollohg.editor.voidNodeType")
10
+
11
+ /// Stores the Rust document position (UInt32) for void elements.
12
+ static let docPos = NSAttributedString.Key("com.apollohg.editor.docPos")
13
+
14
+ /// Marks a character as a block boundary (for block start/end tracking).
15
+ static let blockBoundary = NSAttributedString.Key("com.apollohg.editor.blockBoundary")
16
+
17
+ /// Stores the block node type (e.g. "paragraph", "listItem").
18
+ static let blockNodeType = NSAttributedString.Key("com.apollohg.editor.blockNodeType")
19
+
20
+ /// Stores the block depth (UInt8).
21
+ static let blockDepth = NSAttributedString.Key("com.apollohg.editor.blockDepth")
22
+
23
+ /// Stores list context info as a dictionary for list items.
24
+ static let listContext = NSAttributedString.Key("com.apollohg.editor.listContext")
25
+
26
+ /// Marks blocks that should render a visible list marker.
27
+ static let listMarkerContext = NSAttributedString.Key("com.apollohg.editor.listMarkerContext")
28
+
29
+ /// Stores the rendered list marker color for the paragraph marker.
30
+ static let listMarkerColor = NSAttributedString.Key("com.apollohg.editor.listMarkerColor")
31
+
32
+ /// Stores the rendered list marker scale for unordered bullets.
33
+ static let listMarkerScale = NSAttributedString.Key("com.apollohg.editor.listMarkerScale")
34
+
35
+ /// Stores the reserved list marker gutter width.
36
+ static let listMarkerWidth = NSAttributedString.Key("com.apollohg.editor.listMarkerWidth")
37
+ }
38
+
39
+ /// Layout constants for paragraph styles.
40
+ enum LayoutConstants {
41
+ /// Spacing between paragraphs (points).
42
+ static let paragraphSpacing: CGFloat = 8.0
43
+
44
+ /// Base indentation per depth level (points).
45
+ static let indentPerDepth: CGFloat = 24.0
46
+
47
+ /// Width reserved for the list bullet/number (points).
48
+ static let listMarkerWidth: CGFloat = 20.0
49
+
50
+ /// Gap between the list marker and the text that follows (points).
51
+ static let listMarkerTextGap: CGFloat = 8.0
52
+
53
+ /// Height of the horizontal rule separator line (points).
54
+ static let horizontalRuleHeight: CGFloat = 1.0
55
+
56
+ /// Vertical padding above and below the horizontal rule (points).
57
+ static let horizontalRuleVerticalPadding: CGFloat = 8.0
58
+
59
+ /// Bullet character for unordered list items.
60
+ static let unorderedListBullet = "\u{2022} "
61
+
62
+ /// Scale factor applied only to unordered list marker glyphs.
63
+ static let unorderedListMarkerFontScale: CGFloat = 2.0
64
+
65
+ /// Object replacement character used for void block elements.
66
+ static let objectReplacementCharacter = "\u{FFFC}"
67
+ }
68
+
69
+ // MARK: - RenderBridge
70
+
71
+ /// Converts RenderElement JSON (emitted by Rust editor-core via UniFFI) into
72
+ /// NSAttributedString for display in a UITextView.
73
+ ///
74
+ /// The JSON format matches the output of `serialize_render_elements` in lib.rs:
75
+ /// ```json
76
+ /// [
77
+ /// {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
78
+ /// {"type": "textRun", "text": "Hello ", "marks": []},
79
+ /// {"type": "textRun", "text": "world", "marks": ["bold"]},
80
+ /// {"type": "blockEnd"},
81
+ /// {"type": "voidInline", "nodeType": "hardBreak", "docPos": 12},
82
+ /// {"type": "voidBlock", "nodeType": "horizontalRule", "docPos": 15}
83
+ /// ]
84
+ /// ```
85
+ final class RenderBridge {
86
+
87
+ // MARK: - Public API
88
+
89
+ /// Convert a JSON array of RenderElements into an NSAttributedString.
90
+ ///
91
+ /// - Parameters:
92
+ /// - json: A JSON string representing an array of render elements.
93
+ /// - baseFont: The default font for unstyled text.
94
+ /// - textColor: The default text color.
95
+ /// - Returns: The rendered attributed string. Returns an empty attributed
96
+ /// string if the JSON is invalid.
97
+ static func renderElements(
98
+ fromJSON json: String,
99
+ baseFont: UIFont,
100
+ textColor: UIColor,
101
+ theme: EditorTheme? = nil
102
+ ) -> NSAttributedString {
103
+ guard let data = json.data(using: .utf8),
104
+ let parsed = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
105
+ else {
106
+ return NSAttributedString()
107
+ }
108
+
109
+ return renderElements(
110
+ fromArray: parsed,
111
+ baseFont: baseFont,
112
+ textColor: textColor,
113
+ theme: theme
114
+ )
115
+ }
116
+
117
+ /// Convert a parsed array of RenderElement dictionaries into an NSAttributedString.
118
+ ///
119
+ /// This is the main rendering entry point. It processes elements in order,
120
+ /// maintaining a block context stack for proper paragraph styling.
121
+ ///
122
+ /// - Parameters:
123
+ /// - elements: Parsed JSON array where each element is a dictionary.
124
+ /// - baseFont: The default font for unstyled text.
125
+ /// - textColor: The default text color.
126
+ /// - Returns: The rendered attributed string.
127
+ static func renderElements(
128
+ fromArray elements: [[String: Any]],
129
+ baseFont: UIFont,
130
+ textColor: UIColor,
131
+ theme: EditorTheme? = nil
132
+ ) -> NSAttributedString {
133
+ let result = NSMutableAttributedString()
134
+ var blockStack: [BlockContext] = []
135
+ var isFirstBlock = true
136
+ var pendingTrailingParagraphSpacing: CGFloat? = nil
137
+
138
+ for element in elements {
139
+ guard let type = element["type"] as? String else { continue }
140
+
141
+ switch type {
142
+ case "textRun":
143
+ let text = element["text"] as? String ?? ""
144
+ let marks = element["marks"] as? [String] ?? []
145
+ let blockFont = resolvedFont(
146
+ for: blockStack,
147
+ baseFont: baseFont,
148
+ theme: theme
149
+ )
150
+ let blockColor = resolvedTextColor(
151
+ for: blockStack,
152
+ textColor: textColor,
153
+ theme: theme
154
+ )
155
+ let baseAttrs = attributesForMarks(
156
+ marks,
157
+ baseFont: blockFont,
158
+ textColor: blockColor
159
+ )
160
+ let attrs = applyBlockStyle(
161
+ to: baseAttrs,
162
+ blockStack: blockStack,
163
+ theme: theme
164
+ )
165
+ result.append(NSAttributedString(string: text, attributes: attrs))
166
+
167
+ case "voidInline":
168
+ let nodeType = element["nodeType"] as? String ?? ""
169
+ let docPos = jsonUInt32(element["docPos"])
170
+ let attrStr = attributedStringForVoidInline(
171
+ nodeType: nodeType,
172
+ docPos: docPos,
173
+ baseFont: baseFont,
174
+ textColor: textColor,
175
+ blockStack: blockStack,
176
+ theme: theme
177
+ )
178
+ result.append(attrStr)
179
+
180
+ case "voidBlock":
181
+ let nodeType = element["nodeType"] as? String ?? ""
182
+ let docPos = jsonUInt32(element["docPos"])
183
+
184
+ // Add inter-block newline if not the first block.
185
+ if !isFirstBlock {
186
+ applyPendingTrailingParagraphSpacing(
187
+ in: result,
188
+ pendingParagraphSpacing: &pendingTrailingParagraphSpacing
189
+ )
190
+ result.append(interBlockNewline(baseFont: baseFont, textColor: textColor))
191
+ }
192
+ isFirstBlock = false
193
+
194
+ let attrStr = attributedStringForVoidBlock(
195
+ nodeType: nodeType,
196
+ docPos: docPos,
197
+ baseFont: baseFont,
198
+ textColor: textColor,
199
+ theme: theme
200
+ )
201
+ result.append(attrStr)
202
+
203
+ case "opaqueInlineAtom":
204
+ let nodeType = element["nodeType"] as? String ?? ""
205
+ let label = element["label"] as? String ?? "?"
206
+ let docPos = jsonUInt32(element["docPos"])
207
+ let attrStr = attributedStringForOpaqueInlineAtom(
208
+ nodeType: nodeType,
209
+ label: label,
210
+ docPos: docPos,
211
+ baseFont: baseFont,
212
+ textColor: textColor,
213
+ blockStack: blockStack,
214
+ theme: theme
215
+ )
216
+ result.append(attrStr)
217
+
218
+ case "opaqueBlockAtom":
219
+ let nodeType = element["nodeType"] as? String ?? ""
220
+ let label = element["label"] as? String ?? "?"
221
+ let docPos = jsonUInt32(element["docPos"])
222
+
223
+ if !isFirstBlock {
224
+ applyPendingTrailingParagraphSpacing(
225
+ in: result,
226
+ pendingParagraphSpacing: &pendingTrailingParagraphSpacing
227
+ )
228
+ result.append(interBlockNewline(baseFont: baseFont, textColor: textColor))
229
+ }
230
+ isFirstBlock = false
231
+
232
+ let attrStr = attributedStringForOpaqueBlockAtom(
233
+ nodeType: nodeType,
234
+ label: label,
235
+ docPos: docPos,
236
+ baseFont: baseFont,
237
+ textColor: textColor,
238
+ theme: theme
239
+ )
240
+ result.append(attrStr)
241
+
242
+ case "blockStart":
243
+ let nodeType = element["nodeType"] as? String ?? ""
244
+ let depth = jsonUInt8(element["depth"])
245
+ let listContext = element["listContext"] as? [String: Any]
246
+ let isListItemContainer = nodeType == "listItem" && listContext != nil
247
+ let nestedListItemContainer =
248
+ isListItemContainer && (theme?.list?.itemSpacing != nil)
249
+ && blockStack.contains(where: { $0.nodeType == "listItem" && $0.listContext != nil })
250
+
251
+ if !isListItemContainer {
252
+ // Add inter-block newline before non-first rendered blocks.
253
+ if !isFirstBlock {
254
+ applyPendingTrailingParagraphSpacing(
255
+ in: result,
256
+ pendingParagraphSpacing: &pendingTrailingParagraphSpacing
257
+ )
258
+ result.append(interBlockNewline(baseFont: baseFont, textColor: textColor))
259
+ }
260
+ isFirstBlock = false
261
+ } else if applyPendingTrailingParagraphSpacing(
262
+ in: result,
263
+ pendingParagraphSpacing: &pendingTrailingParagraphSpacing
264
+ ) {
265
+ // Applied list item spacing queued when the previous item ended.
266
+ } else if nestedListItemContainer {
267
+ overrideTrailingParagraphSpacing(
268
+ in: result,
269
+ paragraphSpacing: CGFloat(theme?.list?.itemSpacing ?? 0)
270
+ )
271
+ }
272
+
273
+ // Push block context for inline children to reference.
274
+ let ctx = BlockContext(
275
+ nodeType: nodeType,
276
+ depth: depth,
277
+ listContext: listContext,
278
+ markerPending: isListItemContainer
279
+ )
280
+ blockStack.append(ctx)
281
+
282
+ var markerListContext: [String: Any]? = nil
283
+ if !isListItemContainer {
284
+ if let directListContext = listContext {
285
+ markerListContext = directListContext
286
+ } else {
287
+ markerListContext = consumePendingListMarker(from: &blockStack)
288
+ }
289
+ }
290
+
291
+ if markerListContext != nil {
292
+ if var currentBlock = blockStack.popLast() {
293
+ currentBlock.listMarkerContext = markerListContext
294
+ if currentBlock.listContext == nil {
295
+ currentBlock.listContext = markerListContext
296
+ }
297
+ blockStack.append(currentBlock)
298
+ }
299
+ // On iOS we draw list markers outside the editable text stream so
300
+ // UIKit still sees paragraph-start for native capitalization.
301
+ }
302
+
303
+ case "blockEnd":
304
+ if let endedBlock = blockStack.popLast(), endedBlock.listContext != nil {
305
+ pendingTrailingParagraphSpacing = theme?.list?.itemSpacing
306
+ }
307
+
308
+ default:
309
+ break
310
+ }
311
+ }
312
+
313
+ return result
314
+ }
315
+
316
+ // MARK: - Mark Handling
317
+
318
+ /// Build NSAttributedString attributes for a set of mark names.
319
+ ///
320
+ /// Supported marks:
321
+ /// - `bold` -> adds `.traitBold` to the font descriptor
322
+ /// - `italic` -> adds `.traitItalic` to the font descriptor
323
+ /// - `underline` -> sets `.underlineStyle = .single`
324
+ /// - `strike` / `strikethrough` -> sets `.strikethroughStyle = .single`
325
+ /// - `code` -> uses a monospaced font variant
326
+ ///
327
+ /// Multiple marks are combined: "bold italic" produces a bold-italic font.
328
+ static func attributesForMarks(
329
+ _ marks: [String],
330
+ baseFont: UIFont,
331
+ textColor: UIColor
332
+ ) -> [NSAttributedString.Key: Any] {
333
+ var attrs = defaultAttributes(baseFont: baseFont, textColor: textColor)
334
+
335
+ if marks.isEmpty {
336
+ return attrs
337
+ }
338
+
339
+ var traits: UIFontDescriptor.SymbolicTraits = []
340
+ var useMonospace = false
341
+
342
+ for mark in marks {
343
+ switch mark {
344
+ case "bold", "strong":
345
+ traits.insert(.traitBold)
346
+ case "italic", "em":
347
+ traits.insert(.traitItalic)
348
+ case "underline":
349
+ attrs[.underlineStyle] = NSUnderlineStyle.single.rawValue
350
+ case "strike", "strikethrough":
351
+ attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
352
+ case "code":
353
+ useMonospace = true
354
+ default:
355
+ break
356
+ }
357
+ }
358
+
359
+ var resolvedFont = baseFont
360
+
361
+ if useMonospace {
362
+ resolvedFont = UIFont.monospacedSystemFont(
363
+ ofSize: baseFont.pointSize,
364
+ weight: traits.contains(.traitBold) ? .bold : .regular
365
+ )
366
+ // Monospaced doesn't support italic via descriptor traits, but we
367
+ // still apply bold via the weight parameter above. If italic is also
368
+ // requested alongside code, we skip it (monospaced italic is rare).
369
+ if traits.contains(.traitItalic) && !traits.contains(.traitBold) {
370
+ // For code+italic only, try applying italic trait.
371
+ if let descriptor = resolvedFont.fontDescriptor.withSymbolicTraits(.traitItalic) {
372
+ resolvedFont = UIFont(descriptor: descriptor, size: baseFont.pointSize)
373
+ }
374
+ }
375
+ } else if !traits.isEmpty {
376
+ if let descriptor = baseFont.fontDescriptor.withSymbolicTraits(traits) {
377
+ resolvedFont = UIFont(descriptor: descriptor, size: baseFont.pointSize)
378
+ }
379
+ }
380
+
381
+ attrs[.font] = resolvedFont
382
+ return attrs
383
+ }
384
+
385
+ // MARK: - Void Inline Elements
386
+
387
+ /// Create an attributed string for a void inline element (e.g. hardBreak).
388
+ ///
389
+ /// A hardBreak is rendered as a newline character with custom attributes
390
+ /// so the position bridge knows it represents a single doc position.
391
+ private static func attributedStringForVoidInline(
392
+ nodeType: String,
393
+ docPos: UInt32,
394
+ baseFont: UIFont,
395
+ textColor: UIColor,
396
+ blockStack: [BlockContext],
397
+ theme: EditorTheme?
398
+ ) -> NSAttributedString {
399
+ let blockFont = resolvedFont(for: blockStack, baseFont: baseFont, theme: theme)
400
+ let blockColor = resolvedTextColor(for: blockStack, textColor: textColor, theme: theme)
401
+ var attrs = defaultAttributes(baseFont: blockFont, textColor: blockColor)
402
+ attrs[RenderBridgeAttributes.voidNodeType] = nodeType
403
+ attrs[RenderBridgeAttributes.docPos] = docPos
404
+ let styledAttrs = applyBlockStyle(
405
+ to: attrs,
406
+ blockStack: blockStack,
407
+ theme: theme
408
+ )
409
+
410
+ switch nodeType {
411
+ case "hardBreak":
412
+ return NSAttributedString(string: "\n", attributes: styledAttrs)
413
+ default:
414
+ // Unknown void inline: render as object replacement character.
415
+ return NSAttributedString(
416
+ string: LayoutConstants.objectReplacementCharacter,
417
+ attributes: styledAttrs
418
+ )
419
+ }
420
+ }
421
+
422
+ // MARK: - Void Block Elements
423
+
424
+ /// Create an attributed string for a void block element (e.g. horizontalRule).
425
+ ///
426
+ /// Horizontal rules are rendered as U+FFFC (object replacement character)
427
+ /// with an NSTextAttachment that draws a separator line.
428
+ private static func attributedStringForVoidBlock(
429
+ nodeType: String,
430
+ docPos: UInt32,
431
+ baseFont: UIFont,
432
+ textColor: UIColor,
433
+ theme: EditorTheme?
434
+ ) -> NSAttributedString {
435
+ var attrs = defaultAttributes(baseFont: baseFont, textColor: textColor)
436
+ attrs[RenderBridgeAttributes.voidNodeType] = nodeType
437
+ attrs[RenderBridgeAttributes.docPos] = docPos
438
+
439
+ switch nodeType {
440
+ case "horizontalRule":
441
+ let attachment = HorizontalRuleAttachment()
442
+ attachment.lineColor = theme?.horizontalRule?.color ?? textColor.withAlphaComponent(0.3)
443
+ attachment.lineHeight = theme?.horizontalRule?.thickness ?? LayoutConstants.horizontalRuleHeight
444
+ attachment.verticalPadding = theme?.horizontalRule?.verticalMargin ?? LayoutConstants.horizontalRuleVerticalPadding
445
+ let attrStr = NSMutableAttributedString(
446
+ attachment: attachment
447
+ )
448
+ // Apply our custom attributes to the attachment character.
449
+ let range = NSRange(location: 0, length: attrStr.length)
450
+ attrStr.addAttributes(attrs, range: range)
451
+ return attrStr
452
+ default:
453
+ // Unknown void block: render as object replacement character.
454
+ return NSAttributedString(
455
+ string: LayoutConstants.objectReplacementCharacter,
456
+ attributes: attrs
457
+ )
458
+ }
459
+ }
460
+
461
+ // MARK: - Opaque Atoms
462
+
463
+ /// Create an attributed string for an opaque inline atom (unknown inline void).
464
+ private static func attributedStringForOpaqueInlineAtom(
465
+ nodeType: String,
466
+ label: String,
467
+ docPos: UInt32,
468
+ baseFont: UIFont,
469
+ textColor: UIColor,
470
+ blockStack: [BlockContext],
471
+ theme: EditorTheme?
472
+ ) -> NSAttributedString {
473
+ let blockFont = resolvedFont(for: blockStack, baseFont: baseFont, theme: theme)
474
+ let blockColor = resolvedTextColor(for: blockStack, textColor: textColor, theme: theme)
475
+ var attrs = defaultAttributes(baseFont: blockFont, textColor: blockColor)
476
+ attrs[RenderBridgeAttributes.voidNodeType] = nodeType
477
+ attrs[RenderBridgeAttributes.docPos] = docPos
478
+ if nodeType == "mention" {
479
+ attrs[.foregroundColor] = theme?.mentions?.textColor ?? blockColor
480
+ attrs[.backgroundColor] = theme?.mentions?.backgroundColor ?? UIColor.systemBlue.withAlphaComponent(0.12)
481
+ if let mentionFont = mentionFont(from: blockFont, theme: theme?.mentions) {
482
+ attrs[.font] = mentionFont
483
+ }
484
+ } else {
485
+ attrs[.backgroundColor] = UIColor.systemGray5
486
+ }
487
+ let styledAttrs = applyBlockStyle(
488
+ to: attrs,
489
+ blockStack: blockStack,
490
+ theme: theme
491
+ )
492
+
493
+ let visibleText = nodeType == "mention" ? label : "[\(label)]"
494
+ return NSAttributedString(string: visibleText, attributes: styledAttrs)
495
+ }
496
+
497
+ /// Create an attributed string for an opaque block atom (unknown block void).
498
+ private static func attributedStringForOpaqueBlockAtom(
499
+ nodeType: String,
500
+ label: String,
501
+ docPos: UInt32,
502
+ baseFont: UIFont,
503
+ textColor: UIColor,
504
+ theme: EditorTheme?
505
+ ) -> NSAttributedString {
506
+ var attrs = defaultAttributes(baseFont: baseFont, textColor: textColor)
507
+ attrs[RenderBridgeAttributes.voidNodeType] = nodeType
508
+ attrs[RenderBridgeAttributes.docPos] = docPos
509
+ attrs[.backgroundColor] = UIColor.systemGray5
510
+
511
+ return NSAttributedString(string: "[\(label)]", attributes: attrs)
512
+ }
513
+
514
+ private static func mentionFont(from baseFont: UIFont, theme: EditorMentionTheme?) -> UIFont? {
515
+ guard let fontWeight = theme?.fontWeight else { return nil }
516
+ let descriptorTraits = EditorTheme.shouldApplyBoldTrait(fontWeight)
517
+ ? UIFontDescriptor.SymbolicTraits.traitBold
518
+ : []
519
+ if descriptorTraits.isEmpty {
520
+ return UIFont.systemFont(
521
+ ofSize: baseFont.pointSize,
522
+ weight: EditorTheme.fontWeight(from: fontWeight)
523
+ )
524
+ }
525
+ guard let descriptor = baseFont.fontDescriptor.withSymbolicTraits(descriptorTraits) else {
526
+ return UIFont.systemFont(
527
+ ofSize: baseFont.pointSize,
528
+ weight: EditorTheme.fontWeight(from: fontWeight)
529
+ )
530
+ }
531
+ return UIFont(descriptor: descriptor, size: baseFont.pointSize)
532
+ }
533
+
534
+ // MARK: - Block Styling
535
+
536
+ /// Create a paragraph style for a block context.
537
+ ///
538
+ /// Applies indentation based on depth and list context. List items get
539
+ /// a hanging indent so the bullet/number sits in the margin.
540
+ static func paragraphStyleForBlock(
541
+ _ context: BlockContext,
542
+ theme: EditorTheme? = nil,
543
+ baseFont: UIFont = .systemFont(ofSize: 16)
544
+ ) -> NSMutableParagraphStyle {
545
+ let style = NSMutableParagraphStyle()
546
+ let blockStyle = theme?.effectiveTextStyle(for: context.nodeType)
547
+ let spacing = blockStyle?.spacingAfter
548
+ ?? (context.listContext != nil ? theme?.list?.itemSpacing : nil)
549
+ ?? LayoutConstants.paragraphSpacing
550
+ style.paragraphSpacing = spacing
551
+
552
+ let indentPerDepth = theme?.list?.indent ?? LayoutConstants.indentPerDepth
553
+ let markerWidth = listMarkerWidth(for: context, theme: theme, baseFont: baseFont)
554
+ let baseIndent = CGFloat(context.depth) * indentPerDepth
555
+
556
+ if context.listContext != nil {
557
+ // List item: reserve a fixed gutter and align all wrapped lines to
558
+ // the text start since the marker is drawn separately.
559
+ style.firstLineHeadIndent = baseIndent + markerWidth
560
+ style.headIndent = baseIndent + markerWidth
561
+ } else {
562
+ style.firstLineHeadIndent = baseIndent
563
+ style.headIndent = baseIndent
564
+ }
565
+
566
+ if let lineHeight = blockStyle?.lineHeight {
567
+ style.minimumLineHeight = lineHeight
568
+ style.maximumLineHeight = lineHeight
569
+ }
570
+
571
+ return style
572
+ }
573
+
574
+ // MARK: - List Markers
575
+
576
+ /// Generate the list marker string (bullet or number) from a list context.
577
+ static func listMarkerString(listContext: [String: Any]) -> String {
578
+ let ordered = (listContext["ordered"] as? NSNumber)?.boolValue ?? false
579
+
580
+ if ordered {
581
+ let index = (listContext["index"] as? NSNumber)?.intValue ?? 1
582
+ return "\(index). "
583
+ } else {
584
+ return LayoutConstants.unorderedListBullet
585
+ }
586
+ }
587
+
588
+ // MARK: - Private Helpers
589
+
590
+ /// Safely extract a UInt32 from a JSON dictionary value.
591
+ ///
592
+ /// `JSONSerialization` produces `NSNumber` for numeric values. Direct `as? UInt32`
593
+ /// cast may fail depending on the stored numeric type. This helper handles all
594
+ /// numeric types that `JSONSerialization` can produce.
595
+ static func jsonUInt32(_ value: Any?) -> UInt32 {
596
+ if let number = value as? NSNumber {
597
+ return number.uint32Value
598
+ }
599
+ return 0
600
+ }
601
+
602
+ /// Safely extract a UInt8 from a JSON dictionary value.
603
+ static func jsonUInt8(_ value: Any?) -> UInt8 {
604
+ if let number = value as? NSNumber {
605
+ return number.uint8Value
606
+ }
607
+ return 0
608
+ }
609
+
610
+ /// Default attributed string attributes (font + color, no special styling).
611
+ private static func defaultAttributes(
612
+ baseFont: UIFont,
613
+ textColor: UIColor
614
+ ) -> [NSAttributedString.Key: Any] {
615
+ [
616
+ .font: baseFont,
617
+ .foregroundColor: textColor,
618
+ ]
619
+ }
620
+
621
+ /// Apply the current block context's paragraph style to a mutable attributes dictionary.
622
+ ///
623
+ /// This is a no-op if no block context is active.
624
+ @discardableResult
625
+ private static func applyBlockStyle(
626
+ to attrs: [NSAttributedString.Key: Any],
627
+ blockStack: [BlockContext],
628
+ theme: EditorTheme?
629
+ ) -> [NSAttributedString.Key: Any] {
630
+ guard let currentBlock = effectiveBlockContext(blockStack) else { return attrs }
631
+ var mutableAttrs = attrs
632
+ let blockFont = mutableAttrs[.font] as? UIFont ?? .systemFont(ofSize: 16)
633
+ mutableAttrs[.paragraphStyle] = paragraphStyleForBlock(
634
+ currentBlock,
635
+ theme: theme,
636
+ baseFont: blockFont
637
+ )
638
+ mutableAttrs[RenderBridgeAttributes.blockNodeType] = currentBlock.nodeType
639
+ mutableAttrs[RenderBridgeAttributes.blockDepth] = currentBlock.depth
640
+ if let listContext = currentBlock.listContext {
641
+ mutableAttrs[RenderBridgeAttributes.listContext] = listContext
642
+ }
643
+ if let markerContext = currentBlock.listMarkerContext {
644
+ mutableAttrs[RenderBridgeAttributes.listMarkerContext] = markerContext
645
+ mutableAttrs[RenderBridgeAttributes.listMarkerColor] = theme?.list?.markerColor
646
+ mutableAttrs[RenderBridgeAttributes.listMarkerScale] = theme?.list?.markerScale
647
+ mutableAttrs[RenderBridgeAttributes.listMarkerWidth] = listMarkerWidth(
648
+ for: currentBlock,
649
+ theme: theme,
650
+ baseFont: blockFont
651
+ )
652
+ }
653
+ return mutableAttrs
654
+ }
655
+
656
+ /// Create a newline attributed string used between blocks.
657
+ ///
658
+ /// This newline separates consecutive blocks in the flat rendered text.
659
+ /// It carries minimal styling (base font, no special attributes).
660
+ private static func interBlockNewline(
661
+ baseFont: UIFont,
662
+ textColor: UIColor
663
+ ) -> NSAttributedString {
664
+ let attrs = defaultAttributes(baseFont: baseFont, textColor: textColor)
665
+ return NSAttributedString(string: "\n", attributes: attrs)
666
+ }
667
+
668
+ private static func effectiveBlockContext(_ blockStack: [BlockContext]) -> BlockContext? {
669
+ guard let currentBlock = blockStack.last else { return nil }
670
+ if currentBlock.listContext != nil {
671
+ return currentBlock
672
+ }
673
+ guard let inheritedListContext = nearestListContext(in: Array(blockStack.dropLast())) else {
674
+ return currentBlock
675
+ }
676
+ return BlockContext(
677
+ nodeType: currentBlock.nodeType,
678
+ depth: currentBlock.depth,
679
+ listContext: inheritedListContext,
680
+ markerPending: false
681
+ )
682
+ }
683
+
684
+ private static func nearestListContext(in contexts: [BlockContext]) -> [String: Any]? {
685
+ for context in contexts.reversed() where context.listContext != nil {
686
+ return context.listContext
687
+ }
688
+ return nil
689
+ }
690
+
691
+ private static func consumePendingListMarker(from blockStack: inout [BlockContext]) -> [String: Any]? {
692
+ guard blockStack.count >= 2 else { return nil }
693
+ for idx in stride(from: blockStack.count - 2, through: 0, by: -1) {
694
+ guard blockStack[idx].markerPending else { continue }
695
+ blockStack[idx].markerPending = false
696
+ return blockStack[idx].listContext
697
+ }
698
+ return nil
699
+ }
700
+
701
+ private static func overrideTrailingParagraphSpacing(
702
+ in result: NSMutableAttributedString,
703
+ paragraphSpacing: CGFloat
704
+ ) {
705
+ guard result.length > 0 else { return }
706
+
707
+ let nsString = result.string as NSString
708
+ let paragraphRange = nsString.paragraphRange(for: NSRange(location: result.length - 1, length: 0))
709
+ result.enumerateAttribute(.paragraphStyle, in: paragraphRange, options: []) { value, range, _ in
710
+ let sourceStyle = (value as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle
711
+ ?? NSMutableParagraphStyle()
712
+ sourceStyle.paragraphSpacing = paragraphSpacing
713
+ result.addAttribute(.paragraphStyle, value: sourceStyle, range: range)
714
+ }
715
+ }
716
+
717
+ @discardableResult
718
+ private static func applyPendingTrailingParagraphSpacing(
719
+ in result: NSMutableAttributedString,
720
+ pendingParagraphSpacing: inout CGFloat?
721
+ ) -> Bool {
722
+ guard let paragraphSpacing = pendingParagraphSpacing else { return false }
723
+ overrideTrailingParagraphSpacing(in: result, paragraphSpacing: paragraphSpacing)
724
+ pendingParagraphSpacing = nil
725
+ return true
726
+ }
727
+
728
+ private static func listMarkerWidth(
729
+ for context: BlockContext,
730
+ theme: EditorTheme?,
731
+ baseFont: UIFont
732
+ ) -> CGFloat {
733
+ guard let listContext = context.listContext else { return 0 }
734
+ _ = listContext
735
+ _ = theme
736
+ _ = baseFont
737
+ return LayoutConstants.listMarkerWidth
738
+ }
739
+
740
+ private static func resolvedTextStyle(
741
+ for blockStack: [BlockContext],
742
+ theme: EditorTheme?
743
+ ) -> EditorTextStyle? {
744
+ guard let currentBlock = effectiveBlockContext(blockStack) else {
745
+ return theme?.effectiveTextStyle(for: "paragraph")
746
+ }
747
+ return theme?.effectiveTextStyle(for: currentBlock.nodeType)
748
+ }
749
+
750
+ private static func resolvedFont(
751
+ for blockStack: [BlockContext],
752
+ baseFont: UIFont,
753
+ theme: EditorTheme?
754
+ ) -> UIFont {
755
+ resolvedTextStyle(for: blockStack, theme: theme)?.resolvedFont(fallback: baseFont)
756
+ ?? baseFont
757
+ }
758
+
759
+ private static func resolvedTextColor(
760
+ for blockStack: [BlockContext],
761
+ textColor: UIColor,
762
+ theme: EditorTheme?
763
+ ) -> UIColor {
764
+ resolvedTextStyle(for: blockStack, theme: theme)?.color ?? textColor
765
+ }
766
+ }
767
+
768
+ // MARK: - BlockContext
769
+
770
+ /// Transient context while rendering block elements. Pushed onto a stack
771
+ /// when a `blockStart` element is encountered and popped on `blockEnd`.
772
+ struct BlockContext {
773
+ let nodeType: String
774
+ let depth: UInt8
775
+ var listContext: [String: Any]?
776
+ var listMarkerContext: [String: Any]? = nil
777
+ var markerPending: Bool = false
778
+ }
779
+
780
+ // MARK: - HorizontalRuleAttachment
781
+
782
+ /// NSTextAttachment subclass that draws a horizontal separator line.
783
+ ///
784
+ /// The attachment renders as a thin line across the available width with
785
+ /// vertical padding. Used for `horizontalRule` void block elements.
786
+ final class HorizontalRuleAttachment: NSTextAttachment {
787
+
788
+ var lineColor: UIColor = .separator
789
+ var lineHeight: CGFloat = LayoutConstants.horizontalRuleHeight
790
+ var verticalPadding: CGFloat = LayoutConstants.horizontalRuleVerticalPadding
791
+
792
+ override func attachmentBounds(
793
+ for textContainer: NSTextContainer?,
794
+ proposedLineFragment lineFrag: CGRect,
795
+ glyphPosition position: CGPoint,
796
+ characterIndex charIndex: Int
797
+ ) -> CGRect {
798
+ let totalHeight = lineHeight + (verticalPadding * 2)
799
+ return CGRect(
800
+ x: 0,
801
+ y: 0,
802
+ width: lineFrag.width,
803
+ height: totalHeight
804
+ )
805
+ }
806
+
807
+ override func image(
808
+ forBounds imageBounds: CGRect,
809
+ textContainer: NSTextContainer?,
810
+ characterIndex charIndex: Int
811
+ ) -> UIImage? {
812
+ let renderer = UIGraphicsImageRenderer(bounds: imageBounds)
813
+ return renderer.image { context in
814
+ lineColor.setFill()
815
+ let lineY = imageBounds.midY - (lineHeight / 2)
816
+ let lineRect = CGRect(
817
+ x: imageBounds.origin.x,
818
+ y: lineY,
819
+ width: imageBounds.width,
820
+ height: lineHeight
821
+ )
822
+ context.fill(lineRect)
823
+ }
824
+ }
825
+ }