@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.
- package/LICENSE +160 -0
- package/README.md +143 -0
- package/android/build.gradle +39 -0
- package/android/src/main/assets/editor-icons/MaterialIcons.json +2236 -0
- package/android/src/main/assets/editor-icons/MaterialIcons.ttf +0 -0
- package/android/src/main/java/com/apollohg/editor/EditorAddons.kt +131 -0
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +1057 -0
- package/android/src/main/java/com/apollohg/editor/EditorHeightBehavior.kt +14 -0
- package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +191 -0
- package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +325 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +647 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +257 -0
- package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +714 -0
- package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +76 -0
- package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +1044 -0
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +211 -0
- package/expo-module.config.json +9 -0
- package/ios/EditorAddons.swift +228 -0
- package/ios/EditorCore.xcframework/Info.plist +44 -0
- package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
- package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
- package/ios/EditorLayoutManager.swift +254 -0
- package/ios/EditorTheme.swift +372 -0
- package/ios/Generated_editor_core.swift +1143 -0
- package/ios/NativeEditorExpoView.swift +1417 -0
- package/ios/NativeEditorModule.swift +263 -0
- package/ios/PositionBridge.swift +278 -0
- package/ios/ReactNativeProseEditor.podspec +49 -0
- package/ios/RenderBridge.swift +825 -0
- package/ios/RichTextEditorView.swift +1559 -0
- package/ios/editor_coreFFI/editor_coreFFI.h +1014 -0
- package/ios/editor_coreFFI/module.modulemap +7 -0
- package/ios/editor_coreFFI.h +904 -0
- package/ios/editor_coreFFI.modulemap +7 -0
- package/package.json +66 -0
- package/rust/android/arm64-v8a/libeditor_core.so +0 -0
- package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
- package/rust/android/x86_64/libeditor_core.so +0 -0
- package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2014 -0
- package/src/EditorTheme.ts +130 -0
- package/src/EditorToolbar.tsx +620 -0
- package/src/NativeEditorBridge.ts +607 -0
- package/src/NativeRichTextEditor.tsx +951 -0
- package/src/addons.ts +158 -0
- package/src/index.ts +63 -0
- package/src/schemas.ts +153 -0
- 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
|
+
}
|