@openeditor/react-native-prose-editor 0.0.2
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 +180 -0
- package/android/build.gradle +69 -0
- package/android/consumer-rules.pro +18 -0
- package/android/src/androidTest/AndroidManifest.xml +8 -0
- package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +53 -0
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +241 -0
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +338 -0
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +789 -0
- package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +350 -0
- package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +5 -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/CaretGeometry.kt +50 -0
- package/android/src/main/java/com/apollohg/editor/EditorAddons.kt +135 -0
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +4527 -0
- package/android/src/main/java/com/apollohg/editor/EditorHeightBehavior.kt +14 -0
- package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +999 -0
- package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +474 -0
- package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +199 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +3137 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +561 -0
- package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +315 -0
- package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +1413 -0
- package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +133 -0
- package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +315 -0
- package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +2172 -0
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +399 -0
- package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +482 -0
- package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +137 -0
- package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +321 -0
- package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +4202 -0
- package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +2581 -0
- package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +27 -0
- package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +197 -0
- package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +84 -0
- package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +544 -0
- package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +461 -0
- package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +2054 -0
- package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +1367 -0
- package/app.plugin.js +62 -0
- package/dist/EditorTheme.d.ts +113 -0
- package/dist/EditorTheme.js +29 -0
- package/dist/EditorToolbar.d.ts +191 -0
- package/dist/EditorToolbar.js +1095 -0
- package/dist/NativeEditorBridge.d.ts +325 -0
- package/dist/NativeEditorBridge.js +933 -0
- package/dist/NativeProseViewer.d.ts +53 -0
- package/dist/NativeProseViewer.js +480 -0
- package/dist/NativeRichTextEditor.d.ts +183 -0
- package/dist/NativeRichTextEditor.js +2574 -0
- package/dist/YjsCollaboration.d.ts +90 -0
- package/dist/YjsCollaboration.js +743 -0
- package/dist/addons.d.ts +100 -0
- package/dist/addons.js +82 -0
- package/dist/heightCache.d.ts +6 -0
- package/dist/heightCache.js +45 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +30 -0
- package/dist/schemas.d.ts +37 -0
- package/dist/schemas.js +263 -0
- package/dist/useNativeEditor.d.ts +42 -0
- package/dist/useNativeEditor.js +124 -0
- package/expo-module.config.json +9 -0
- package/ios/EditorAddons.swift +327 -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 +774 -0
- package/ios/EditorTheme.swift +515 -0
- package/ios/Generated_editor_core.swift +1513 -0
- package/ios/NativeEditorExpoView.swift +3593 -0
- package/ios/NativeEditorModule.swift +624 -0
- package/ios/NativeProseViewerExpoView.swift +276 -0
- package/ios/PositionBridge.swift +558 -0
- package/ios/ReactNativeProseEditor.podspec +49 -0
- package/ios/RenderBridge.swift +1708 -0
- package/ios/RichTextEditorView.swift +6150 -0
- package/ios/Tests/NativePerformanceTests.swift +687 -0
- package/ios/Tests/PositionBridgeTests.swift +706 -0
- package/ios/Tests/RenderBridgeTests.swift +2236 -0
- package/ios/Tests/RichTextEditorViewTests.swift +6548 -0
- package/ios/editor_coreFFI/editor_coreFFI.h +1289 -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 +74 -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 +2563 -0
|
@@ -0,0 +1,1708 @@
|
|
|
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
|
+
}
|