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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/android/build.gradle +35 -0
  2. package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +2 -2
  3. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +138 -597
  4. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +13 -3
  5. package/android/src/main/java/com/apollohg/editor/InputSnapshotSupport.kt +6 -0
  6. package/android/src/main/java/com/apollohg/editor/NativeBlockEditorSurface.kt +1086 -0
  7. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +1 -1
  8. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +26 -50
  9. package/android/src/main/java/com/apollohg/editor/NativeImageLoader.kt +89 -0
  10. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +31 -66
  11. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +98 -33
  12. package/dist/EditorToolbar.d.ts +1 -1
  13. package/dist/EditorToolbar.js +19 -0
  14. package/dist/NativeEditorBridge.d.ts +62 -35
  15. package/dist/NativeEditorBridge.js +122 -79
  16. package/dist/NativeProseViewer.d.ts +13 -34
  17. package/dist/NativeProseViewer.js +40 -465
  18. package/dist/NativeRichTextEditor.d.ts +10 -1
  19. package/dist/NativeRichTextEditor.js +72 -34
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.js +2 -1
  22. package/dist/schemas.d.ts +11 -0
  23. package/dist/schemas.js +149 -0
  24. package/dist/useNativeEditor.d.ts +3 -3
  25. package/dist/useNativeEditor.js +3 -3
  26. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  27. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  28. package/ios/Generated_editor_core.swift +97 -2
  29. package/ios/NativeBlockEditorSurface.swift +1445 -0
  30. package/ios/NativeEditorExpoView.swift +3 -3
  31. package/ios/NativeEditorModule.swift +31 -46
  32. package/ios/NativeInputSupport.swift +396 -0
  33. package/ios/PositionBridge.swift +4 -4
  34. package/ios/RichTextEditorView.swift +352 -786
  35. package/ios/editor_coreFFI/editor_coreFFI.h +147 -70
  36. package/package.json +1 -1
  37. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  38. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  39. package/rust/android/x86_64/libeditor_core.so +0 -0
  40. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2087 -1399
  41. package/android/src/androidTest/AndroidManifest.xml +0 -8
  42. package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +0 -53
  43. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +0 -241
  44. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +0 -338
  45. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +0 -789
  46. package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +0 -350
  47. package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +0 -5
  48. package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +0 -315
  49. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +0 -2172
  50. package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +0 -482
  51. package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +0 -137
  52. package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +0 -321
  53. package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +0 -4202
  54. package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +0 -2581
  55. package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +0 -27
  56. package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +0 -197
  57. package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +0 -84
  58. package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +0 -544
  59. package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +0 -461
  60. package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +0 -2054
  61. package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +0 -1367
  62. package/ios/EditorLayoutManager.swift +0 -774
  63. package/ios/NativeProseViewerExpoView.swift +0 -276
  64. package/ios/RenderBridge.swift +0 -1708
  65. package/ios/Tests/NativePerformanceTests.swift +0 -687
  66. package/ios/Tests/PositionBridgeTests.swift +0 -706
  67. package/ios/Tests/RenderBridgeTests.swift +0 -2236
  68. package/ios/Tests/RichTextEditorViewTests.swift +0 -6548
@@ -0,0 +1,1445 @@
1
+ import UIKit
2
+
3
+ final class NativeBlockEditorSurface: UIScrollView {
4
+ private final class TextLeafView: UITextView {
5
+ struct PositionSegment {
6
+ let range: NSRange
7
+ let text: String
8
+ let docPos: UInt32
9
+ let docSize: UInt32
10
+ let isAtom: Bool
11
+ }
12
+
13
+ var positionSegments: [PositionSegment] = []
14
+ var onActivate: ((UInt32) -> Void)?
15
+ var geometryContainerWidth: CGFloat?
16
+ private let geometryStorage = NSTextStorage()
17
+ private let geometryLayoutManager = NSLayoutManager()
18
+ private let geometryTextContainer = NSTextContainer()
19
+ private var geometryInitialized = false
20
+
21
+ // Leaves expose TextKit geometry but never own keyboard focus. The
22
+ // shared EditorTextView input coordinator is the sole first responder.
23
+ override var canBecomeFirstResponder: Bool { false }
24
+
25
+ override var intrinsicContentSize: CGSize {
26
+ let size = sizeThatFits(CGSize(width: bounds.width > 0 ? bounds.width : 320, height: .greatestFiniteMagnitude))
27
+ return CGSize(width: UIView.noIntrinsicMetric, height: max(36, ceil(size.height)))
28
+ }
29
+
30
+ @objc func activate(_ gesture: UITapGestureRecognizer) {
31
+ let point = gesture.location(in: self)
32
+ var fraction: CGFloat = 0
33
+ let glyphIndex = layoutManager.glyphIndex(
34
+ for: point,
35
+ in: textContainer,
36
+ fractionOfDistanceThroughGlyph: &fraction
37
+ )
38
+ let utf16Offset = min(
39
+ layoutManager.characterIndexForGlyph(at: glyphIndex) + (fraction >= 0.5 ? 1 : 0),
40
+ textStorage.length
41
+ )
42
+ guard let segment = positionSegments.first(where: {
43
+ utf16Offset >= $0.range.location && utf16Offset <= NSMaxRange($0.range)
44
+ }) ?? positionSegments.last else { return }
45
+ if segment.isAtom {
46
+ let after = utf16Offset > segment.range.location + segment.range.length / 2
47
+ onActivate?(segment.docPos + (after ? segment.docSize : 0))
48
+ return
49
+ }
50
+ let localUtf16 = max(0, min(utf16Offset - segment.range.location, segment.text.utf16.count))
51
+ let index = String.Index(utf16Offset: localUtf16, in: segment.text)
52
+ let scalarOffset = segment.text[..<index].unicodeScalars.count
53
+ onActivate?(segment.docPos + UInt32(scalarOffset))
54
+ }
55
+
56
+ func nativeOffset(forDocPosition docPosition: UInt32) -> Int? {
57
+ guard let segment = positionSegments.first(where: {
58
+ docPosition >= $0.docPos && docPosition <= $0.docPos + $0.docSize
59
+ }) else { return nil }
60
+ let localUtf16: Int
61
+ if segment.isAtom {
62
+ localUtf16 = docPosition > segment.docPos ? segment.range.length : 0
63
+ } else {
64
+ let scalarOffset = Int(docPosition - segment.docPos)
65
+ localUtf16 = String(
66
+ segment.text.unicodeScalars.prefix(
67
+ min(scalarOffset, segment.text.unicodeScalars.count)
68
+ )
69
+ ).utf16.count
70
+ }
71
+ return segment.range.location + localUtf16
72
+ }
73
+
74
+ func caretRect(forDocPosition docPosition: UInt32) -> CGRect? {
75
+ guard let offset = nativeOffset(forDocPosition: docPosition) else { return nil }
76
+ prepareGeometryLayout()
77
+ let lineHeight = font?.lineHeight ?? 20
78
+ let inset = textContainerInset
79
+ guard geometryStorage.length > 0, geometryLayoutManager.numberOfGlyphs > 0 else {
80
+ return CGRect(x: inset.left, y: inset.top, width: 2, height: lineHeight)
81
+ }
82
+ let clamped = min(max(offset, 0), geometryStorage.length)
83
+ if clamped == geometryStorage.length {
84
+ let lastGlyph = geometryLayoutManager.numberOfGlyphs - 1
85
+ let lineRect = geometryLayoutManager.lineFragmentUsedRect(
86
+ forGlyphAt: lastGlyph,
87
+ effectiveRange: nil
88
+ )
89
+ let endsInHardBreak = (geometryStorage.string as NSString)
90
+ .character(at: geometryStorage.length - 1) == 0x000A
91
+ if endsInHardBreak {
92
+ return CGRect(
93
+ x: inset.left,
94
+ y: inset.top + lineRect.maxY,
95
+ width: 2,
96
+ height: lineRect.height > 0 ? lineRect.height : lineHeight
97
+ )
98
+ }
99
+ let glyphRect = geometryLayoutManager.boundingRect(
100
+ forGlyphRange: NSRange(location: lastGlyph, length: 1),
101
+ in: geometryTextContainer
102
+ )
103
+ return CGRect(
104
+ x: inset.left + glyphRect.maxX,
105
+ y: inset.top + lineRect.minY,
106
+ width: 2,
107
+ height: lineRect.height > 0 ? lineRect.height : lineHeight
108
+ )
109
+ }
110
+ let glyph = geometryLayoutManager.glyphIndexForCharacter(at: clamped)
111
+ let lineRect = geometryLayoutManager.lineFragmentUsedRect(forGlyphAt: glyph, effectiveRange: nil)
112
+ let glyphLocation = geometryLayoutManager.location(forGlyphAt: glyph)
113
+ return CGRect(
114
+ x: inset.left + lineRect.minX + glyphLocation.x,
115
+ y: inset.top + lineRect.minY,
116
+ width: 2,
117
+ height: lineRect.height > 0 ? lineRect.height : lineHeight
118
+ )
119
+ }
120
+
121
+ private func prepareGeometryLayout() {
122
+ if !geometryInitialized {
123
+ geometryStorage.addLayoutManager(geometryLayoutManager)
124
+ geometryLayoutManager.addTextContainer(geometryTextContainer)
125
+ geometryTextContainer.lineFragmentPadding = 0
126
+ geometryInitialized = true
127
+ }
128
+ if geometryStorage.string != attributedText.string
129
+ || geometryStorage.length != attributedText.length
130
+ {
131
+ geometryStorage.setAttributedString(attributedText)
132
+ }
133
+ let width = max(geometryContainerWidth ?? bounds.width, 1)
134
+ if geometryTextContainer.size.width != width {
135
+ geometryTextContainer.size = CGSize(width: width, height: .greatestFiniteMagnitude)
136
+ geometryLayoutManager.invalidateLayout(
137
+ forCharacterRange: NSRange(location: 0, length: geometryStorage.length),
138
+ actualCharacterRange: nil
139
+ )
140
+ }
141
+ geometryLayoutManager.ensureLayout(for: geometryTextContainer)
142
+ }
143
+
144
+ func selectionRects(from: UInt32, to: UInt32) -> [CGRect] {
145
+ guard let startOffset = nativeOffset(forDocPosition: from),
146
+ let endOffset = nativeOffset(forDocPosition: to),
147
+ startOffset < endOffset
148
+ else { return [] }
149
+ prepareGeometryLayout()
150
+ let characterRange = NSRange(
151
+ location: startOffset,
152
+ length: endOffset - startOffset
153
+ )
154
+ let glyphRange = geometryLayoutManager.glyphRange(
155
+ forCharacterRange: characterRange,
156
+ actualCharacterRange: nil
157
+ )
158
+ let inset = textContainerInset
159
+ var rects: [CGRect] = []
160
+ geometryLayoutManager.enumerateLineFragments(
161
+ forGlyphRange: glyphRange
162
+ ) { _, usedRect, _, lineGlyphRange, _ in
163
+ let selectedGlyphRange = NSIntersectionRange(glyphRange, lineGlyphRange)
164
+ guard selectedGlyphRange.length > 0 else { return }
165
+ let selectedRect = self.geometryLayoutManager.boundingRect(
166
+ forGlyphRange: selectedGlyphRange,
167
+ in: self.geometryTextContainer
168
+ )
169
+ let rect = CGRect(
170
+ x: inset.left + selectedRect.minX,
171
+ y: inset.top + usedRect.minY,
172
+ width: selectedRect.width,
173
+ height: usedRect.height
174
+ )
175
+ if !rect.isEmpty, rect.width > 0, rect.height > 0 {
176
+ rects.append(rect)
177
+ }
178
+ }
179
+ return rects
180
+ }
181
+ }
182
+
183
+ private final class CellView: UIStackView {
184
+ var docPos: UInt32 = 0
185
+ var tableId: Int = 0
186
+ var rowIndex: Int = 0
187
+ var columnIndex: Int = 0
188
+ var baseFillColor: UIColor = .systemBackground
189
+ }
190
+
191
+ private final class BlockquoteStripeView: UIView {}
192
+
193
+ private final class BlockImageView: UIImageView {
194
+ var docPos: UInt32 = 0
195
+ var preferredWidth: CGFloat?
196
+ var preferredHeight: CGFloat?
197
+ weak var preferredWidthConstraint: NSLayoutConstraint?
198
+ weak var preferredHeightConstraint: NSLayoutConstraint?
199
+
200
+ override var intrinsicContentSize: CGSize {
201
+ let ratio = image.flatMap { image -> CGFloat? in
202
+ guard image.size.width > 0, image.size.height > 0 else { return nil }
203
+ return image.size.height / image.size.width
204
+ } ?? 0.56
205
+ let width = max(1, preferredWidth ?? image?.size.width ?? 320)
206
+ return CGSize(width: width, height: max(1, preferredHeight ?? width * ratio))
207
+ }
208
+
209
+ func refreshPreferredSizeConstraints() {
210
+ let size = intrinsicContentSize
211
+ preferredWidthConstraint?.constant = size.width
212
+ preferredHeightConstraint?.constant = size.height
213
+ }
214
+ }
215
+
216
+ private let stack = UIStackView()
217
+ private let placeholderLabel = UILabel()
218
+ private var currentEditorId: UInt64 = 0
219
+ private var isApplyingRemoteUpdate = false
220
+ private var currentTheme: EditorTheme?
221
+ private var nextTableId = 1
222
+ private var tableCells: [CellView] = []
223
+ private weak var selectionAnchorCell: CellView?
224
+ private weak var selectionHeadCell: CellView?
225
+ private var currentRoot: [String: Any]?
226
+ /// Native views are bound to canonical render-tree paths. Inline nodes share
227
+ /// their owning text-block view; every structural node owns a distinct view.
228
+ /// This is the identity layer used by recursive patches.
229
+ private var nodeViews: [[Int]: UIView] = [:]
230
+ private var imageViews: [BlockImageView] = []
231
+ private var textLeafViews: [TextLeafView] = []
232
+ private var selectedNodePos: UInt32?
233
+ private var selectedTextPos: UInt32?
234
+ private let caretView = UIView()
235
+
236
+ var placeholder: String = "" {
237
+ didSet {
238
+ placeholderLabel.text = placeholder
239
+ refreshPlaceholder()
240
+ }
241
+ }
242
+
243
+ var baseFont: UIFont = .systemFont(ofSize: 16)
244
+ var baseTextColor: UIColor = .label
245
+ var baseBackgroundColor: UIColor = .systemBackground {
246
+ didSet {
247
+ backgroundColor = baseBackgroundColor
248
+ }
249
+ }
250
+ var onAppliedUpdate: ((String) -> Void)?
251
+ var onRequestTextInput: ((UInt32) -> Void)?
252
+ var isEditable = true {
253
+ didSet {
254
+ refreshImageSelection()
255
+ refreshCaret()
256
+ }
257
+ }
258
+ var isInputFocused = false {
259
+ didSet { refreshCaret() }
260
+ }
261
+ var onContentHeightMayChange: ((CGFloat) -> Void)?
262
+
263
+ override init(frame: CGRect) {
264
+ super.init(frame: frame)
265
+ setup()
266
+ }
267
+
268
+ required init?(coder: NSCoder) {
269
+ super.init(coder: coder)
270
+ setup()
271
+ }
272
+
273
+ private func setup() {
274
+ backgroundColor = baseBackgroundColor
275
+ keyboardDismissMode = .interactive
276
+ alwaysBounceVertical = true
277
+ stack.axis = .vertical
278
+ stack.spacing = 8
279
+ stack.alignment = .fill
280
+ addSubview(stack)
281
+ placeholderLabel.numberOfLines = 0
282
+ placeholderLabel.isUserInteractionEnabled = false
283
+ placeholderLabel.isHidden = true
284
+ placeholderLabel.layer.zPosition = 1
285
+ addSubview(placeholderLabel)
286
+ caretView.backgroundColor = .systemBlue
287
+ caretView.isUserInteractionEnabled = false
288
+ caretView.layer.cornerRadius = 1
289
+ caretView.isHidden = true
290
+ addSubview(caretView)
291
+ }
292
+
293
+ override func layoutSubviews() {
294
+ super.layoutSubviews()
295
+ let topInset = currentTheme?.contentInsets?.top ?? 0
296
+ let bottomInset = currentTheme?.contentInsets?.bottom ?? 0
297
+ let leftInset = currentTheme?.contentInsets?.left ?? 0
298
+ let rightInset = currentTheme?.contentInsets?.right ?? 0
299
+ let contentWidth = max(bounds.width - leftInset - rightInset, 0)
300
+ // Give text leaves their real width before asking Auto Layout for height.
301
+ // Measuring the zero-width stack made UITextView fall back to 320pt, so
302
+ // wrapped caret and selection geometry was computed on the wrong line.
303
+ stack.frame = CGRect(x: leftInset, y: topInset, width: contentWidth, height: max(stack.bounds.height, 1))
304
+ stack.setNeedsLayout()
305
+ stack.layoutIfNeeded()
306
+ let contentHeight = stack.systemLayoutSizeFitting(
307
+ CGSize(width: contentWidth, height: UIView.layoutFittingCompressedSize.height),
308
+ withHorizontalFittingPriority: .required,
309
+ verticalFittingPriority: .fittingSizeLevel
310
+ ).height
311
+ stack.frame.size.height = contentHeight
312
+ stack.layoutIfNeeded()
313
+ placeholderLabel.font = font(for: ["nodeType": "paragraph"])
314
+ placeholderLabel.textColor = currentTheme?.placeholderColor ?? .placeholderText
315
+ placeholderLabel.frame = CGRect(
316
+ x: leftInset,
317
+ y: topInset,
318
+ width: contentWidth,
319
+ height: placeholderLabel.sizeThatFits(
320
+ CGSize(width: contentWidth, height: .greatestFiniteMagnitude)
321
+ ).height
322
+ )
323
+ contentSize = CGSize(width: bounds.width, height: stack.frame.maxY + bottomInset)
324
+ refreshCaret()
325
+ onContentHeightMayChange?(contentSize.height)
326
+ }
327
+
328
+ func applyTheme(_ theme: EditorTheme?) {
329
+ currentTheme = theme
330
+ backgroundColor = theme?.backgroundColor ?? baseBackgroundColor
331
+ contentInset = .zero
332
+ verticalScrollIndicatorInsets = .zero
333
+ if let root = currentRoot {
334
+ rebuildAll(from: root)
335
+ currentRoot = root
336
+ refreshTableCellRegistry()
337
+ refreshImageSelection()
338
+ }
339
+ refreshPlaceholder()
340
+ setNeedsLayout()
341
+ }
342
+
343
+ func bind(editorId: UInt64) {
344
+ currentEditorId = editorId
345
+ if editorId == 0 {
346
+ clear()
347
+ }
348
+ }
349
+
350
+ func applyUpdateJSON(_ updateJSON: String, editorId: UInt64) {
351
+ guard let data = updateJSON.data(using: .utf8),
352
+ let update = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
353
+ else { return }
354
+
355
+ if let selection = update["selection"] as? [String: Any] {
356
+ let type = selection["type"] as? String
357
+ selectedNodePos = type == "node" ? (selection["pos"] as? NSNumber)?.uint32Value : nil
358
+ let anchor = (selection["anchor"] as? NSNumber)?.uint32Value
359
+ let head = (selection["head"] as? NSNumber)?.uint32Value
360
+ selectedTextPos = type == "text" && anchor == head ? anchor : nil
361
+ refreshImageSelection()
362
+ refreshCaret()
363
+ }
364
+ guard let renderTree = update["renderTree"] as? [String: Any],
365
+ let root = renderTree["root"] as? [String: Any]
366
+ else { return }
367
+
368
+ currentEditorId = editorId
369
+ isApplyingRemoteUpdate = true
370
+ let patch = update["renderPatch"] as? [String: Any]
371
+ if currentRoot != nil, let operations = patch?["operations"] as? [[String: Any]] {
372
+ applyStructuralPatch(operations, nextRoot: root)
373
+ } else {
374
+ rebuildAll(from: root)
375
+ }
376
+ refreshTableCellRegistry()
377
+ refreshImageSelection()
378
+ refreshCaret()
379
+ currentRoot = root
380
+ refreshPlaceholder()
381
+ isApplyingRemoteUpdate = false
382
+ setNeedsLayout()
383
+ layoutIfNeeded()
384
+ }
385
+
386
+ private func rebuildAll(from root: [String: Any]) {
387
+ clear()
388
+ nodeViews.removeAll()
389
+ tableCells.removeAll()
390
+ imageViews.removeAll()
391
+ textLeafViews.removeAll()
392
+ selectionAnchorCell = nil
393
+ selectionHeadCell = nil
394
+ let children = root["children"] as? [[String: Any]] ?? []
395
+ for (index, child) in children.enumerated() {
396
+ stack.addArrangedSubview(view(for: child, depth: 0, path: [index]))
397
+ }
398
+ if children.isEmpty {
399
+ let leaf = textBlockView(for: root, depth: 0)
400
+ nodeViews[[]] = leaf
401
+ stack.addArrangedSubview(leaf)
402
+ }
403
+ }
404
+
405
+ private func refreshPlaceholder() {
406
+ placeholderLabel.isHidden = placeholder.isEmpty || !isRenderedDocumentEmpty()
407
+ setNeedsLayout()
408
+ }
409
+
410
+ private func isRenderedDocumentEmpty() -> Bool {
411
+ guard let root = currentRoot else { return false }
412
+ let children = root["children"] as? [[String: Any]] ?? []
413
+ guard children.count <= 1 else { return false }
414
+ guard let child = children.first else {
415
+ return textLeafViews.count == 1 && textLeafViews[0].attributedText.length == 0
416
+ }
417
+ guard (child["nodeType"] as? String ?? "paragraph") == "paragraph" else { return false }
418
+ return textLeafViews.count == 1 && textLeafViews[0].attributedText.length == 0
419
+ }
420
+
421
+ /// Applies canonical recursive patches at the smallest native view boundary.
422
+ /// Metadata-only changes mutate bindings in place; node replacements stop at
423
+ /// the nearest rendered owner (normally the edited text block); and root
424
+ /// splices preserve both unchanged prefixes and suffixes.
425
+ private func applyStructuralPatch(
426
+ _ operations: [[String: Any]],
427
+ nextRoot: [String: Any]
428
+ ) {
429
+ for operation in operations {
430
+ let type = operation["type"] as? String
431
+ let path = (operation["path"] as? [NSNumber])?.map(\.intValue) ?? []
432
+ switch type {
433
+ case "updateMetadata":
434
+ updateMetadata(at: path, from: nextRoot)
435
+ case "replaceNode":
436
+ guard !path.isEmpty else {
437
+ rebuildAll(from: nextRoot)
438
+ continue
439
+ }
440
+ replaceRenderedOwner(containing: path, from: nextRoot)
441
+ case "spliceChildren":
442
+ if path.isEmpty {
443
+ applyRootSplice(operation, nextRoot: nextRoot)
444
+ } else {
445
+ replaceRenderedOwner(containing: path, from: nextRoot)
446
+ }
447
+ default:
448
+ rebuildAll(from: nextRoot)
449
+ return
450
+ }
451
+ }
452
+ }
453
+
454
+ private func applyRootSplice(_ operation: [String: Any], nextRoot: [String: Any]) {
455
+ let start = max(0, (operation["startIndex"] as? NSNumber)?.intValue ?? 0)
456
+ let deleteCount = max(0, (operation["deleteCount"] as? NSNumber)?.intValue ?? 0)
457
+ let insertedCount = (operation["children"] as? [[String: Any]])?.count ?? 0
458
+ let nextChildren = nextRoot["children"] as? [[String: Any]] ?? []
459
+ guard start <= stack.arrangedSubviews.count,
460
+ start + deleteCount <= stack.arrangedSubviews.count,
461
+ start + insertedCount <= nextChildren.count
462
+ else {
463
+ rebuildAll(from: nextRoot)
464
+ return
465
+ }
466
+
467
+ removeBindings(underTopLevelRange: start..<(start + deleteCount))
468
+ for _ in 0..<deleteCount {
469
+ let old = stack.arrangedSubviews[start]
470
+ stack.removeArrangedSubview(old)
471
+ old.removeFromSuperview()
472
+ }
473
+ shiftTopLevelBindings(startingAt: start + deleteCount, by: insertedCount - deleteCount)
474
+ for offset in 0..<insertedCount {
475
+ let index = start + offset
476
+ stack.insertArrangedSubview(view(for: nextChildren[index], depth: 0, path: [index]), at: index)
477
+ }
478
+ }
479
+
480
+ private func replaceRenderedOwner(containing requestedPath: [Int], from root: [String: Any]) {
481
+ guard let ownerPath = nearestRenderedPath(containing: requestedPath),
482
+ !ownerPath.isEmpty,
483
+ let oldView = nodeViews[ownerPath],
484
+ let node = node(at: ownerPath, in: root)
485
+ else {
486
+ rebuildAll(from: root)
487
+ return
488
+ }
489
+ guard let parent = oldView.superview else {
490
+ rebuildAll(from: root)
491
+ return
492
+ }
493
+ removeBindings(under: ownerPath)
494
+ let replacement = view(for: node, depth: max(0, ownerPath.count - 1), path: ownerPath)
495
+ if let parentStack = parent as? UIStackView,
496
+ let index = parentStack.arrangedSubviews.firstIndex(where: { $0 === oldView }) {
497
+ parentStack.removeArrangedSubview(oldView)
498
+ oldView.removeFromSuperview()
499
+ parentStack.insertArrangedSubview(replacement, at: index)
500
+ } else if let index = parent.subviews.firstIndex(where: { $0 === oldView }) {
501
+ oldView.removeFromSuperview()
502
+ parent.insertSubview(replacement, at: index)
503
+ replacement.frame = oldView.frame
504
+ } else {
505
+ rebuildAll(from: root)
506
+ }
507
+ }
508
+
509
+ private func updateMetadata(at requestedPath: [Int], from root: [String: Any]) {
510
+ guard let ownerPath = nearestRenderedPath(containing: requestedPath),
511
+ let owner = nodeViews[ownerPath],
512
+ let node = node(at: ownerPath, in: root)
513
+ else { return }
514
+ if let leaf = owner as? TextLeafView {
515
+ let segments = attributedInlineContent(
516
+ for: node,
517
+ baseFont: leaf.font ?? baseFont,
518
+ textColor: leaf.textColor ?? baseTextColor
519
+ ).segments
520
+ if segments.isEmpty {
521
+ let blockPos = (node["docPos"] as? NSNumber)?.uint32Value ?? 0
522
+ let blockSize = (node["docSize"] as? NSNumber)?.uint32Value ?? 0
523
+ leaf.positionSegments = [
524
+ .init(range: NSRange(location: 0, length: 0), text: "", docPos: blockPos, docSize: blockSize, isAtom: false),
525
+ ]
526
+ } else {
527
+ leaf.positionSegments = segments
528
+ }
529
+ }
530
+ if let cell = owner as? CellView {
531
+ cell.docPos = (node["docPos"] as? NSNumber)?.uint32Value ?? cell.docPos
532
+ }
533
+ if let image = owner as? BlockImageView {
534
+ image.docPos = (node["docPos"] as? NSNumber)?.uint32Value ?? image.docPos
535
+ }
536
+ }
537
+
538
+ private func nearestRenderedPath(containing requestedPath: [Int]) -> [Int]? {
539
+ var candidate = requestedPath
540
+ while true {
541
+ if nodeViews[candidate] != nil { return candidate }
542
+ guard !candidate.isEmpty else { return nil }
543
+ candidate.removeLast()
544
+ }
545
+ }
546
+
547
+ private func node(at path: [Int], in root: [String: Any]) -> [String: Any]? {
548
+ var current = root
549
+ for index in path {
550
+ guard let children = current["children"] as? [[String: Any]],
551
+ index >= 0, index < children.count
552
+ else { return nil }
553
+ current = children[index]
554
+ }
555
+ return current
556
+ }
557
+
558
+ private func removeBindings(under path: [Int]) {
559
+ nodeViews = nodeViews.filter { key, _ in
560
+ key.count < path.count || Array(key.prefix(path.count)) != path
561
+ }
562
+ }
563
+
564
+ private func removeBindings(underTopLevelRange range: Range<Int>) {
565
+ nodeViews = nodeViews.filter { key, _ in
566
+ guard let first = key.first else { return true }
567
+ return !range.contains(first)
568
+ }
569
+ }
570
+
571
+ private func shiftTopLevelBindings(startingAt start: Int, by delta: Int) {
572
+ guard delta != 0 else { return }
573
+ var shifted: [[Int]: UIView] = [:]
574
+ for (path, view) in nodeViews {
575
+ guard let first = path.first, first >= start else {
576
+ shifted[path] = view
577
+ continue
578
+ }
579
+ var nextPath = path
580
+ nextPath[0] += delta
581
+ shifted[nextPath] = view
582
+ }
583
+ nodeViews = shifted
584
+ }
585
+
586
+ private func clear() {
587
+ for view in stack.arrangedSubviews {
588
+ stack.removeArrangedSubview(view)
589
+ view.removeFromSuperview()
590
+ }
591
+ currentRoot = nil
592
+ refreshPlaceholder()
593
+ nodeViews.removeAll()
594
+ tableCells.removeAll()
595
+ imageViews.removeAll()
596
+ textLeafViews.removeAll()
597
+ selectionAnchorCell = nil
598
+ selectionHeadCell = nil
599
+ selectedNodePos = nil
600
+ selectedTextPos = nil
601
+ }
602
+
603
+ private func refreshTableCellRegistry() {
604
+ tableCells.removeAll()
605
+ imageViews.removeAll()
606
+ textLeafViews.removeAll()
607
+ func collect(_ view: UIView) {
608
+ if let cell = view as? CellView {
609
+ tableCells.append(cell)
610
+ }
611
+ if let image = view as? BlockImageView {
612
+ imageViews.append(image)
613
+ }
614
+ if let leaf = view as? TextLeafView {
615
+ textLeafViews.append(leaf)
616
+ }
617
+ for child in view.subviews {
618
+ collect(child)
619
+ }
620
+ }
621
+ for view in stack.arrangedSubviews {
622
+ collect(view)
623
+ }
624
+ }
625
+
626
+ private func view(for node: [String: Any], depth: Int, path: [Int]) -> UIView {
627
+ let rendered: UIView = switch node["kind"] as? String {
628
+ case "list":
629
+ listView(for: node, depth: depth, path: path)
630
+ case "listItem":
631
+ listItemView(for: node, index: 0, parentListType: nil, depth: depth, path: path)
632
+ case "columns":
633
+ columnsView(for: node, depth: depth, path: path)
634
+ case "column":
635
+ columnView(for: node, depth: depth, path: path)
636
+ case "table":
637
+ tableView(for: node, path: path)
638
+ case "tableRow":
639
+ rowView(for: node, rowIndex: 0, tableId: nextTableId, path: path)
640
+ case "tableCell":
641
+ cellView(for: node, tableId: nextTableId, rowIndex: 0, columnIndex: 0, path: path)
642
+ case "blockAtom":
643
+ atomView(for: node)
644
+ default:
645
+ if (node["kind"] as? String) == "block" {
646
+ blockContainerView(for: node, depth: depth, path: path)
647
+ } else {
648
+ textBlockView(for: node, depth: depth)
649
+ }
650
+ }
651
+ nodeViews[path] = rendered
652
+ return rendered
653
+ }
654
+
655
+ private func columnsView(for node: [String: Any], depth: Int, path: [Int]) -> UIView {
656
+ let row = UIStackView()
657
+ row.axis = .horizontal
658
+ row.alignment = .top
659
+ row.distribution = .fillEqually
660
+ row.spacing = 12
661
+ row.layoutMargins = UIEdgeInsets(top: 4, left: CGFloat(depth) * 8, bottom: 4, right: 0)
662
+ row.isLayoutMarginsRelativeArrangement = true
663
+ for (index, column) in (node["children"] as? [[String: Any]] ?? []).enumerated() {
664
+ row.addArrangedSubview(view(for: column, depth: depth + 1, path: path + [index]))
665
+ }
666
+ return row
667
+ }
668
+
669
+ private func columnView(for node: [String: Any], depth: Int, path: [Int]) -> UIView {
670
+ let column = UIStackView()
671
+ column.axis = .vertical
672
+ column.spacing = 4
673
+ for (index, child) in (node["children"] as? [[String: Any]] ?? []).enumerated() {
674
+ column.addArrangedSubview(view(for: child, depth: depth + 1, path: path + [index]))
675
+ }
676
+ if column.arrangedSubviews.isEmpty {
677
+ column.addArrangedSubview(textBlockView(for: node, depth: depth))
678
+ }
679
+ return column
680
+ }
681
+
682
+ private func listView(for node: [String: Any], depth: Int, path: [Int]) -> UIView {
683
+ let list = UIStackView()
684
+ list.axis = .vertical
685
+ list.spacing = 2
686
+ list.layoutMargins = UIEdgeInsets(top: 2, left: CGFloat(depth) * 12, bottom: 2, right: 0)
687
+ list.isLayoutMarginsRelativeArrangement = true
688
+ let children = node["children"] as? [[String: Any]] ?? []
689
+ let start = intAttr(node, "start") ?? 1
690
+ for (offset, child) in children.enumerated() {
691
+ list.addArrangedSubview(
692
+ listItemView(
693
+ for: child,
694
+ index: start + offset,
695
+ parentListType: node["nodeType"] as? String,
696
+ depth: depth,
697
+ path: path + [offset]
698
+ )
699
+ )
700
+ }
701
+ return list
702
+ }
703
+
704
+ private func listItemView(
705
+ for node: [String: Any],
706
+ index: Int,
707
+ parentListType: String?,
708
+ depth: Int,
709
+ path: [Int]
710
+ ) -> UIView {
711
+ let row = UIStackView()
712
+ row.axis = .horizontal
713
+ row.alignment = .top
714
+ row.spacing = 8
715
+
716
+ row.addArrangedSubview(markerView(for: node, parentListType: parentListType, index: index))
717
+
718
+ let content = UIStackView()
719
+ content.axis = .vertical
720
+ content.spacing = 4
721
+ for (childIndex, child) in (node["children"] as? [[String: Any]] ?? []).enumerated() {
722
+ content.addArrangedSubview(view(for: child, depth: depth + 1, path: path + [childIndex]))
723
+ }
724
+ if content.arrangedSubviews.isEmpty {
725
+ content.addArrangedSubview(textBlockView(for: node, depth: depth + 1))
726
+ }
727
+ row.addArrangedSubview(content)
728
+ return row
729
+ }
730
+
731
+ private func blockContainerView(for node: [String: Any], depth: Int, path: [Int]) -> UIView {
732
+ let container = UIStackView()
733
+ container.axis = .vertical
734
+ container.spacing = 4
735
+ container.layoutMargins = UIEdgeInsets(top: 6, left: 12 + CGFloat(depth) * 8, bottom: 6, right: 0)
736
+ container.isLayoutMarginsRelativeArrangement = true
737
+ if (node["nodeType"] as? String) == "blockquote" {
738
+ container.layer.borderColor = UIColor.separator.cgColor
739
+ container.layer.borderWidth = 0
740
+ let border = BlockquoteStripeView()
741
+ border.backgroundColor = currentTheme?.blockquote?.borderColor ?? UIColor.separator
742
+ let row = UIStackView()
743
+ row.axis = .horizontal
744
+ row.spacing = currentTheme?.blockquote?.markerGap ?? 8
745
+ border.widthAnchor.constraint(equalToConstant: currentTheme?.blockquote?.borderWidth ?? 3).isActive = true
746
+ row.addArrangedSubview(border)
747
+ let quoteContent = UIStackView()
748
+ quoteContent.axis = .vertical
749
+ quoteContent.spacing = 4
750
+ for (index, child) in (node["children"] as? [[String: Any]] ?? []).enumerated() {
751
+ quoteContent.addArrangedSubview(view(for: child, depth: depth + 1, path: path + [index]))
752
+ }
753
+ row.addArrangedSubview(quoteContent)
754
+ return row
755
+ }
756
+ for (index, child) in (node["children"] as? [[String: Any]] ?? []).enumerated() {
757
+ container.addArrangedSubview(view(for: child, depth: depth + 1, path: path + [index]))
758
+ }
759
+ if container.arrangedSubviews.isEmpty {
760
+ container.addArrangedSubview(textBlockView(for: node, depth: depth))
761
+ }
762
+ return container
763
+ }
764
+
765
+ func blockquoteStripeRectsForTesting() -> [CGRect] {
766
+ layoutIfNeeded()
767
+ return descendants(of: self)
768
+ .compactMap { $0 as? BlockquoteStripeView }
769
+ .map { stripe in stripe.convert(stripe.bounds, to: self) }
770
+ }
771
+
772
+ func topLevelViewForTesting(at index: Int) -> UIView? {
773
+ guard index >= 0, index < stack.arrangedSubviews.count else { return nil }
774
+ return stack.arrangedSubviews[index]
775
+ }
776
+
777
+ var topLevelViewCountForTesting: Int {
778
+ stack.arrangedSubviews.count
779
+ }
780
+
781
+ func isPlaceholderVisibleForTesting() -> Bool {
782
+ !placeholderLabel.isHidden
783
+ }
784
+
785
+ func placeholderColorForTesting() -> UIColor? {
786
+ placeholderLabel.textColor
787
+ }
788
+
789
+ func firstTextAttributesForTesting() -> [NSAttributedString.Key: Any]? {
790
+ func find(in view: UIView) -> TextLeafView? {
791
+ if let leaf = view as? TextLeafView, leaf.attributedText.length > 0 {
792
+ return leaf
793
+ }
794
+ return view.subviews.lazy.compactMap(find).first
795
+ }
796
+ guard let leaf = find(in: stack) else { return nil }
797
+ return leaf.attributedText.attributes(at: 0, effectiveRange: nil)
798
+ }
799
+
800
+ private func descendants(of view: UIView) -> [UIView] {
801
+ view.subviews.flatMap { [$0] + descendants(of: $0) }
802
+ }
803
+
804
+ private func tableView(for node: [String: Any], path: [Int]) -> UIView {
805
+ let tableId = nextTableId
806
+ nextTableId += 1
807
+ let table = UIStackView()
808
+ table.axis = .vertical
809
+ table.spacing = 0
810
+ table.layer.borderColor = UIColor.separator.cgColor
811
+ table.layer.borderWidth = 1
812
+ table.layer.cornerRadius = 6
813
+ table.clipsToBounds = true
814
+ for (index, row) in (node["children"] as? [[String: Any]] ?? []).enumerated() {
815
+ table.addArrangedSubview(rowView(for: row, rowIndex: index, tableId: tableId, path: path + [index]))
816
+ }
817
+ let pan = UIPanGestureRecognizer(target: self, action: #selector(handleTablePan(_:)))
818
+ pan.cancelsTouchesInView = false
819
+ table.addGestureRecognizer(pan)
820
+ return table
821
+ }
822
+
823
+ private func rowView(for node: [String: Any], rowIndex: Int, tableId: Int, path: [Int]) -> UIView {
824
+ let row = UIStackView()
825
+ row.axis = .horizontal
826
+ row.spacing = 0
827
+ row.distribution = .fillEqually
828
+ for (index, cell) in (node["children"] as? [[String: Any]] ?? []).enumerated() {
829
+ row.addArrangedSubview(cellView(for: cell, tableId: tableId, rowIndex: rowIndex, columnIndex: index, path: path + [index]))
830
+ }
831
+ return row
832
+ }
833
+
834
+ private func cellView(for node: [String: Any], tableId: Int, rowIndex: Int, columnIndex: Int, path: [Int]) -> UIView {
835
+ let cell = CellView()
836
+ cell.docPos = (node["docPos"] as? NSNumber)?.uint32Value ?? 0
837
+ cell.tableId = tableId
838
+ cell.rowIndex = rowIndex
839
+ cell.columnIndex = columnIndex
840
+ cell.axis = .vertical
841
+ cell.spacing = 4
842
+ cell.layoutMargins = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
843
+ cell.isLayoutMarginsRelativeArrangement = true
844
+ cell.layer.borderColor = UIColor.separator.cgColor
845
+ cell.layer.borderWidth = 0.5
846
+ cell.baseFillColor = (node["nodeType"] as? String) == "tableHeader"
847
+ ? UIColor.secondarySystemBackground
848
+ : (currentTheme?.backgroundColor ?? baseBackgroundColor)
849
+ cell.backgroundColor = cell.baseFillColor
850
+ cell.isUserInteractionEnabled = true
851
+ let tap = UITapGestureRecognizer(target: self, action: #selector(handleCellTap(_:)))
852
+ cell.addGestureRecognizer(tap)
853
+ tableCells.append(cell)
854
+ for (index, child) in (node["children"] as? [[String: Any]] ?? []).enumerated() {
855
+ cell.addArrangedSubview(view(for: child, depth: 0, path: path + [index]))
856
+ }
857
+ if cell.arrangedSubviews.isEmpty {
858
+ cell.addArrangedSubview(textBlockView(for: node, depth: 0))
859
+ }
860
+ return cell
861
+ }
862
+
863
+ private func textBlockView(for node: [String: Any], depth: Int) -> UIView {
864
+ let leaf = TextLeafView()
865
+ leaf.font = font(for: node)
866
+ let nodeType = node["nodeType"] as? String ?? "paragraph"
867
+ let textStyle = currentTheme?.effectiveTextStyle(for: nodeType)
868
+ let textColor = textStyle?.color ?? baseTextColor
869
+ leaf.textColor = textColor
870
+ let rendered = attributedInlineContent(
871
+ for: node,
872
+ baseFont: leaf.font ?? baseFont,
873
+ textColor: textColor
874
+ )
875
+ let attributedText = NSMutableAttributedString(attributedString: rendered.text)
876
+ if attributedText.length > 0,
877
+ textStyle?.lineHeight != nil || textStyle?.spacingAfter != nil {
878
+ let paragraphStyle = NSMutableParagraphStyle()
879
+ if let lineHeight = textStyle?.lineHeight {
880
+ paragraphStyle.minimumLineHeight = lineHeight
881
+ paragraphStyle.maximumLineHeight = lineHeight
882
+ }
883
+ paragraphStyle.paragraphSpacing = textStyle?.spacingAfter ?? 0
884
+ attributedText.addAttribute(
885
+ .paragraphStyle,
886
+ value: paragraphStyle,
887
+ range: NSRange(location: 0, length: attributedText.length)
888
+ )
889
+ }
890
+ leaf.attributedText = attributedText
891
+ leaf.positionSegments = rendered.segments
892
+ if leaf.positionSegments.isEmpty {
893
+ let blockPos = (node["docPos"] as? NSNumber)?.uint32Value ?? 0
894
+ let blockSize = (node["docSize"] as? NSNumber)?.uint32Value ?? 0
895
+ leaf.positionSegments = [
896
+ .init(
897
+ range: NSRange(location: 0, length: 0),
898
+ text: "",
899
+ docPos: blockPos,
900
+ docSize: blockSize,
901
+ isAtom: false
902
+ ),
903
+ ]
904
+ }
905
+ if isCodeBlock(node) {
906
+ leaf.backgroundColor = currentTheme?.codeBlock?.backgroundColor ?? UIColor.secondarySystemBackground
907
+ leaf.textContainerInset = UIEdgeInsets(
908
+ top: currentTheme?.codeBlock?.paddingVertical ?? 8,
909
+ left: currentTheme?.codeBlock?.paddingHorizontal ?? 12,
910
+ bottom: currentTheme?.codeBlock?.paddingVertical ?? 8,
911
+ right: currentTheme?.codeBlock?.paddingHorizontal ?? 12
912
+ )
913
+ leaf.layer.cornerRadius = currentTheme?.codeBlock?.borderRadius ?? 8
914
+ leaf.clipsToBounds = true
915
+ } else {
916
+ leaf.backgroundColor = .clear
917
+ leaf.textContainerInset = .zero
918
+ }
919
+ leaf.isScrollEnabled = false
920
+ leaf.isEditable = false
921
+ leaf.isSelectable = false
922
+ leaf.textContainer.widthTracksTextView = false
923
+ leaf.textContainer.lineFragmentPadding = 0
924
+ leaf.onActivate = { [weak self] docPos in
925
+ guard let self, self.isEditable else { return }
926
+ self.onRequestTextInput?(docPos)
927
+ }
928
+ leaf.addGestureRecognizer(UITapGestureRecognizer(target: leaf, action: #selector(TextLeafView.activate(_:))))
929
+ return leaf
930
+ }
931
+
932
+ private func attributedInlineContent(
933
+ for node: [String: Any],
934
+ baseFont: UIFont,
935
+ textColor: UIColor
936
+ ) -> (text: NSAttributedString, segments: [TextLeafView.PositionSegment]) {
937
+ let output = NSMutableAttributedString()
938
+ var segments: [TextLeafView.PositionSegment] = []
939
+
940
+ func append(_ current: [String: Any]) {
941
+ let kind = current["kind"] as? String
942
+ let docPos = (current["docPos"] as? NSNumber)?.uint32Value ?? 0
943
+ let docSize = (current["docSize"] as? NSNumber)?.uint32Value ?? 0
944
+ if kind == "text" {
945
+ let value = current["text"] as? String ?? ""
946
+ let range = NSRange(location: output.length, length: value.utf16.count)
947
+ output.append(NSAttributedString(
948
+ string: value,
949
+ attributes: inlineAttributes(for: current, baseFont: baseFont, textColor: textColor)
950
+ ))
951
+ segments.append(.init(
952
+ range: range,
953
+ text: value,
954
+ docPos: docPos,
955
+ docSize: docSize,
956
+ isAtom: false
957
+ ))
958
+ return
959
+ }
960
+ if kind == "inlineAtom" {
961
+ let nodeType = current["nodeType"] as? String
962
+ let value = nodeType == "hardBreak" || nodeType == "hard_break"
963
+ ? "\n"
964
+ : (current["label"] as? String ?? "\u{FFFC}")
965
+ let range = NSRange(location: output.length, length: value.utf16.count)
966
+ var attributes: [NSAttributedString.Key: Any] = [
967
+ .font: baseFont,
968
+ .foregroundColor: textColor,
969
+ ]
970
+ if nodeType == "mention" {
971
+ attributes[.foregroundColor] = tintColor
972
+ attributes[.backgroundColor] = tintColor.withAlphaComponent(0.12)
973
+ }
974
+ output.append(NSAttributedString(string: value, attributes: attributes))
975
+ segments.append(.init(
976
+ range: range,
977
+ text: value,
978
+ docPos: docPos,
979
+ docSize: max(docSize, 1),
980
+ isAtom: true
981
+ ))
982
+ return
983
+ }
984
+ for child in current["children"] as? [[String: Any]] ?? [] {
985
+ append(child)
986
+ }
987
+ }
988
+
989
+ append(node)
990
+ return (output, segments)
991
+ }
992
+
993
+ private func inlineAttributes(
994
+ for node: [String: Any],
995
+ baseFont: UIFont,
996
+ textColor: UIColor
997
+ ) -> [NSAttributedString.Key: Any] {
998
+ var attributes: [NSAttributedString.Key: Any] = [
999
+ .font: baseFont,
1000
+ .foregroundColor: textColor,
1001
+ ]
1002
+ let marks = node["marks"] as? [[String: Any]] ?? []
1003
+ let markTypes = Set(marks.compactMap { $0["type"] as? String })
1004
+ var traits: UIFontDescriptor.SymbolicTraits = []
1005
+ if markTypes.contains("bold") { traits.insert(.traitBold) }
1006
+ if markTypes.contains("italic") { traits.insert(.traitItalic) }
1007
+ if !traits.isEmpty, let descriptor = baseFont.fontDescriptor.withSymbolicTraits(traits) {
1008
+ attributes[.font] = UIFont(descriptor: descriptor, size: baseFont.pointSize)
1009
+ }
1010
+ if markTypes.contains("underline") || markTypes.contains("link") {
1011
+ attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
1012
+ }
1013
+ if markTypes.contains("strike") {
1014
+ attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
1015
+ }
1016
+ if markTypes.contains("link") {
1017
+ attributes[.foregroundColor] = currentTheme?.links?.color ?? tintColor
1018
+ }
1019
+ if markTypes.contains("code") {
1020
+ attributes[.font] = UIFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular)
1021
+ }
1022
+ return attributes
1023
+ }
1024
+
1025
+ private func atomView(for node: [String: Any]) -> UIView {
1026
+ if isHorizontalRule(node) {
1027
+ let margin = currentTheme?.horizontalRule?.verticalMargin ?? 16
1028
+ let wrapper = UIView()
1029
+ let line = UIView()
1030
+ line.backgroundColor = currentTheme?.horizontalRule?.color ?? UIColor.separator
1031
+ wrapper.addSubview(line)
1032
+ let thickness = currentTheme?.horizontalRule?.thickness ?? 1
1033
+ wrapper.heightAnchor.constraint(equalToConstant: margin * 2 + thickness).isActive = true
1034
+ line.translatesAutoresizingMaskIntoConstraints = false
1035
+ NSLayoutConstraint.activate([
1036
+ line.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
1037
+ line.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
1038
+ line.centerYAnchor.constraint(equalTo: wrapper.centerYAnchor),
1039
+ line.heightAnchor.constraint(equalToConstant: thickness)
1040
+ ])
1041
+ return wrapper
1042
+ }
1043
+ if (node["nodeType"] as? String) == "image" {
1044
+ return imageView(for: node)
1045
+ }
1046
+ let label = UILabel()
1047
+ label.text = node["label"] as? String ?? node["nodeType"] as? String ?? "Block"
1048
+ label.font = .systemFont(ofSize: 14, weight: .medium)
1049
+ label.textColor = .secondaryLabel
1050
+ label.backgroundColor = .secondarySystemBackground
1051
+ label.layer.cornerRadius = 6
1052
+ label.clipsToBounds = true
1053
+ label.numberOfLines = 0
1054
+ return label
1055
+ }
1056
+
1057
+ private func imageView(for node: [String: Any]) -> UIView {
1058
+ let attrs = node["attrs"] as? [String: Any] ?? [:]
1059
+ let source = attrs["src"] as? String ?? ""
1060
+ let imageView = BlockImageView()
1061
+ imageView.docPos = (node["docPos"] as? NSNumber)?.uint32Value ?? 0
1062
+ imageView.preferredWidth = (attrs["width"] as? NSNumber).map { CGFloat($0.doubleValue) }
1063
+ imageView.preferredHeight = (attrs["height"] as? NSNumber).map { CGFloat($0.doubleValue) }
1064
+ imageView.contentMode = .scaleAspectFit
1065
+ imageView.clipsToBounds = true
1066
+ imageView.layer.cornerRadius = 8
1067
+ imageView.backgroundColor = .secondarySystemFill
1068
+ imageView.isUserInteractionEnabled = true
1069
+ imageView.addGestureRecognizer(
1070
+ UITapGestureRecognizer(target: self, action: #selector(handleImageTap(_:)))
1071
+ )
1072
+ NativeImageLoader.load(source: source) { [weak self, weak imageView] image in
1073
+ guard let self, let imageView else { return }
1074
+ imageView.image = image
1075
+ imageView.invalidateIntrinsicContentSize()
1076
+ imageView.refreshPreferredSizeConstraints()
1077
+ self.setNeedsLayout()
1078
+ }
1079
+ let wrapper = UIView()
1080
+ wrapper.addSubview(imageView)
1081
+ imageView.translatesAutoresizingMaskIntoConstraints = false
1082
+ let preferredWidth = imageView.widthAnchor.constraint(
1083
+ equalToConstant: imageView.intrinsicContentSize.width
1084
+ )
1085
+ preferredWidth.priority = .defaultHigh
1086
+ let preferredHeight = imageView.heightAnchor.constraint(
1087
+ equalToConstant: imageView.intrinsicContentSize.height
1088
+ )
1089
+ imageView.preferredWidthConstraint = preferredWidth
1090
+ imageView.preferredHeightConstraint = preferredHeight
1091
+ NSLayoutConstraint.activate([
1092
+ imageView.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
1093
+ imageView.topAnchor.constraint(equalTo: wrapper.topAnchor),
1094
+ imageView.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor),
1095
+ imageView.widthAnchor.constraint(lessThanOrEqualTo: wrapper.widthAnchor),
1096
+ preferredWidth,
1097
+ preferredHeight
1098
+ ])
1099
+ return wrapper
1100
+ }
1101
+
1102
+ @objc private func handleImageTap(_ gesture: UITapGestureRecognizer) {
1103
+ guard let imageView = gesture.view as? BlockImageView else { return }
1104
+ selectImage(imageView)
1105
+ }
1106
+
1107
+ private func selectImage(_ imageView: BlockImageView) {
1108
+ guard isEditable, currentEditorId != 0 else { return }
1109
+ selectedNodePos = imageView.docPos
1110
+ editorSetNodeSelection(id: currentEditorId, pos: imageView.docPos)
1111
+ refreshImageSelection()
1112
+ onAppliedUpdate?(editorGetSelectionState(id: currentEditorId))
1113
+ }
1114
+
1115
+ private func refreshImageSelection() {
1116
+ for imageView in imageViews {
1117
+ let selected = isEditable && imageView.docPos == selectedNodePos
1118
+ imageView.layer.borderWidth = selected ? 2 : 0
1119
+ imageView.layer.borderColor = selected ? UIColor.systemBlue.cgColor : nil
1120
+ }
1121
+ }
1122
+
1123
+ func hasImage(at point: CGPoint) -> Bool {
1124
+ imageView(at: point) != nil
1125
+ }
1126
+
1127
+ @discardableResult
1128
+ func selectImage(at point: CGPoint) -> Bool {
1129
+ guard isEditable, currentEditorId != 0, let imageView = imageView(at: point) else {
1130
+ return false
1131
+ }
1132
+ selectImage(imageView)
1133
+ return true
1134
+ }
1135
+
1136
+ func selectedImageGeometry() -> (docPos: UInt32, rect: CGRect)? {
1137
+ guard let selectedNodePos,
1138
+ let imageView = imageViews.first(where: { $0.docPos == selectedNodePos })
1139
+ else { return nil }
1140
+ return (selectedNodePos, imageView.convert(imageView.bounds, to: self))
1141
+ }
1142
+
1143
+ func selectedCaretRect() -> CGRect? {
1144
+ guard let selectedTextPos else { return nil }
1145
+ return caretRect(forDocPosition: selectedTextPos)
1146
+ }
1147
+
1148
+ func caretRect(forDocPosition docPosition: UInt32) -> CGRect? {
1149
+ for leaf in textLeafViews {
1150
+ leaf.geometryContainerWidth = effectiveGeometryWidth(for: leaf)
1151
+ guard let rect = leaf.caretRect(forDocPosition: docPosition) else { continue }
1152
+ let converted = leaf.convert(rect, to: self)
1153
+ if converted.height > 0 {
1154
+ return converted
1155
+ }
1156
+ }
1157
+ return nil
1158
+ }
1159
+
1160
+ private func effectiveGeometryWidth(for leaf: TextLeafView) -> CGFloat {
1161
+ var widths = [leaf.bounds.width]
1162
+ var ancestor = leaf.superview
1163
+ while let view = ancestor, view !== self {
1164
+ if view.bounds.width > 0 { widths.append(view.bounds.width) }
1165
+ ancestor = view.superview
1166
+ }
1167
+ if bounds.width > 0 { widths.append(bounds.width) }
1168
+ return widths.filter { $0 > 0 }.min() ?? max(bounds.width, 1)
1169
+ }
1170
+
1171
+ func selectionRects(anchor: UInt32, head: UInt32) -> [CGRect] {
1172
+ let from = min(anchor, head)
1173
+ let to = max(anchor, head)
1174
+ guard from < to else { return [] }
1175
+ return textLeafViews.flatMap { leaf -> [CGRect] in
1176
+ guard let first = leaf.positionSegments.first?.docPos,
1177
+ let lastSegment = leaf.positionSegments.last
1178
+ else { return [] }
1179
+ let last = lastSegment.docPos + lastSegment.docSize
1180
+ let localFrom = max(from, first)
1181
+ let localTo = min(to, last)
1182
+ guard localFrom < localTo else { return [] }
1183
+ leaf.geometryContainerWidth = effectiveGeometryWidth(for: leaf)
1184
+ return leaf.selectionRects(from: localFrom, to: localTo).map {
1185
+ leaf.convert($0, to: self)
1186
+ }
1187
+ }
1188
+ }
1189
+
1190
+ private func refreshCaret() {
1191
+ guard isEditable, isInputFocused, selectedNodePos == nil,
1192
+ let rect = selectedCaretRect()
1193
+ else {
1194
+ caretView.isHidden = true
1195
+ return
1196
+ }
1197
+ caretView.frame = CGRect(x: rect.minX, y: rect.minY, width: 2, height: rect.height)
1198
+ caretView.isHidden = false
1199
+ bringSubviewToFront(caretView)
1200
+ }
1201
+
1202
+ func firstImageGeometryForTesting() -> (docPos: UInt32, rect: CGRect)? {
1203
+ guard let imageView = imageViews.first else { return nil }
1204
+ return (imageView.docPos, imageView.convert(imageView.bounds, to: self))
1205
+ }
1206
+
1207
+ func imagePreview(docPos: UInt32) -> UIImage? {
1208
+ imageViews.first(where: { $0.docPos == docPos })?.image
1209
+ }
1210
+
1211
+ func setImagePreviewActive(_ active: Bool) {
1212
+ guard let selectedNodePos,
1213
+ let imageView = imageViews.first(where: { $0.docPos == selectedNodePos })
1214
+ else { return }
1215
+ imageView.alpha = active ? 0 : 1
1216
+ }
1217
+
1218
+ func maximumImageWidth() -> CGFloat {
1219
+ max(48, bounds.width - contentInset.left - contentInset.right)
1220
+ }
1221
+
1222
+ func resizeImage(docPos: UInt32, width: UInt32, height: UInt32) {
1223
+ guard currentEditorId != 0 else { return }
1224
+ let updateJSON = editorResizeImageAtDocPos(
1225
+ id: currentEditorId,
1226
+ docPos: docPos,
1227
+ width: width,
1228
+ height: height
1229
+ )
1230
+ applyUpdateJSON(updateJSON, editorId: currentEditorId)
1231
+ onAppliedUpdate?(updateJSON)
1232
+ }
1233
+
1234
+ private func imageView(at point: CGPoint) -> BlockImageView? {
1235
+ imageViews.last { imageView in
1236
+ imageView.convert(imageView.bounds, to: self).contains(point)
1237
+ }
1238
+ }
1239
+
1240
+ private func font(for node: [String: Any]) -> UIFont {
1241
+ let nodeType = node["nodeType"] as? String ?? "paragraph"
1242
+ let fallback: UIFont = switch nodeType {
1243
+ case "h1":
1244
+ .systemFont(ofSize: 28, weight: .bold)
1245
+ case "h2":
1246
+ .systemFont(ofSize: 24, weight: .bold)
1247
+ case "h3":
1248
+ .systemFont(ofSize: 21, weight: .semibold)
1249
+ case "h4":
1250
+ .systemFont(ofSize: 19, weight: .semibold)
1251
+ case "h5":
1252
+ .systemFont(ofSize: 17, weight: .semibold)
1253
+ case "h6":
1254
+ .systemFont(ofSize: 16, weight: .semibold)
1255
+ case "codeBlock", "code_block":
1256
+ .monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular)
1257
+ default:
1258
+ baseFont
1259
+ }
1260
+ return currentTheme?.effectiveTextStyle(for: nodeType).resolvedFont(fallback: fallback)
1261
+ ?? fallback
1262
+ }
1263
+
1264
+ private func listMarker(for nodeType: String?, index: Int) -> String {
1265
+ switch nodeType {
1266
+ case "orderedList", "ordered_list":
1267
+ return "\(index)."
1268
+ case "taskList", "task_list":
1269
+ return "☐"
1270
+ default:
1271
+ return "•"
1272
+ }
1273
+ }
1274
+
1275
+ private func intAttr(_ node: [String: Any], _ key: String) -> Int? {
1276
+ guard let attrs = node["attrs"] as? [String: Any] else { return nil }
1277
+ if let number = attrs[key] as? NSNumber {
1278
+ return number.intValue
1279
+ }
1280
+ return attrs[key] as? Int
1281
+ }
1282
+
1283
+ private func markerView(for node: [String: Any], parentListType: String?, index: Int) -> UIView {
1284
+ guard parentListType == "taskList" || parentListType == "task_list" else {
1285
+ let marker = UILabel()
1286
+ marker.text = listMarker(for: parentListType, index: index)
1287
+ marker.font = baseFont
1288
+ marker.textColor = currentTheme?.list?.markerColor ?? baseTextColor
1289
+ marker.textAlignment = .right
1290
+ marker.setContentHuggingPriority(.required, for: .horizontal)
1291
+ marker.widthAnchor.constraint(equalToConstant: 24).isActive = true
1292
+ marker.heightAnchor.constraint(equalToConstant: 24).isActive = true
1293
+ return marker
1294
+ }
1295
+
1296
+ let checked = boolAttr(node, "checked") ?? false
1297
+ let button = UIButton(type: .custom)
1298
+ button.setTitle(checked ? "✓" : "", for: .normal)
1299
+ button.titleLabel?.font = .systemFont(ofSize: 12, weight: .bold)
1300
+ button.setTitleColor(.white, for: .normal)
1301
+ button.backgroundColor = checked ? (currentTheme?.list?.markerColor ?? tintColor) : .clear
1302
+ button.layer.borderColor = (currentTheme?.list?.markerColor ?? UIColor.secondaryLabel).cgColor
1303
+ button.layer.borderWidth = 1.5
1304
+ button.layer.cornerRadius = 4
1305
+ button.widthAnchor.constraint(equalToConstant: 18).isActive = true
1306
+ button.heightAnchor.constraint(equalToConstant: 18).isActive = true
1307
+ button.addAction(UIAction { [weak self] _ in
1308
+ self?.toggleTaskItem(node)
1309
+ }, for: .touchUpInside)
1310
+
1311
+ let wrapper = UIView()
1312
+ wrapper.widthAnchor.constraint(equalToConstant: 24).isActive = true
1313
+ wrapper.heightAnchor.constraint(equalToConstant: 24).isActive = true
1314
+ button.translatesAutoresizingMaskIntoConstraints = false
1315
+ wrapper.addSubview(button)
1316
+ NSLayoutConstraint.activate([
1317
+ button.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor),
1318
+ button.centerYAnchor.constraint(equalTo: wrapper.centerYAnchor),
1319
+ ])
1320
+ return wrapper
1321
+ }
1322
+
1323
+ private func toggleTaskItem(_ node: [String: Any]) {
1324
+ guard isEditable, currentEditorId != 0 else { return }
1325
+ let textInfo = textRange(in: node)
1326
+ let docPos = textInfo.docFrom ?? UInt32((node["docPos"] as? NSNumber)?.uint32Value ?? 0) + 1
1327
+ let scalar = editorDocToScalar(id: currentEditorId, docPos: docPos)
1328
+ let updateJSON = editorToggleTaskItemCheckedAtSelectionScalar(
1329
+ id: currentEditorId,
1330
+ scalarAnchor: scalar,
1331
+ scalarHead: scalar
1332
+ )
1333
+ applyUpdateJSON(updateJSON, editorId: currentEditorId)
1334
+ onAppliedUpdate?(updateJSON)
1335
+ }
1336
+
1337
+ private func boolAttr(_ node: [String: Any], _ key: String) -> Bool? {
1338
+ guard let attrs = node["attrs"] as? [String: Any] else { return nil }
1339
+ return attrs[key] as? Bool
1340
+ }
1341
+
1342
+ private func isCodeBlock(_ node: [String: Any]) -> Bool {
1343
+ let nodeType = node["nodeType"] as? String
1344
+ return nodeType == "codeBlock" || nodeType == "code_block"
1345
+ }
1346
+
1347
+ private func isHorizontalRule(_ node: [String: Any]) -> Bool {
1348
+ let nodeType = node["nodeType"] as? String
1349
+ return nodeType == "horizontalRule" || nodeType == "horizontal_rule"
1350
+ }
1351
+
1352
+ private func textRange(in node: [String: Any]) -> (text: String, docFrom: UInt32?, docTo: UInt32?) {
1353
+ var text = ""
1354
+ var docFrom: UInt32?
1355
+ var docTo: UInt32?
1356
+ appendTextRange(in: node, text: &text, docFrom: &docFrom, docTo: &docTo)
1357
+ return (text, docFrom, docTo)
1358
+ }
1359
+
1360
+ private func appendTextRange(
1361
+ in node: [String: Any],
1362
+ text: inout String,
1363
+ docFrom: inout UInt32?,
1364
+ docTo: inout UInt32?
1365
+ ) {
1366
+ if (node["kind"] as? String) == "text" {
1367
+ let value = node["text"] as? String ?? ""
1368
+ let from = (node["docPos"] as? NSNumber)?.uint32Value
1369
+ if docFrom == nil {
1370
+ docFrom = from
1371
+ }
1372
+ text += value
1373
+ if let from {
1374
+ docTo = from + UInt32(value.unicodeScalars.count)
1375
+ }
1376
+ return
1377
+ }
1378
+ for child in node["children"] as? [[String: Any]] ?? [] {
1379
+ appendTextRange(in: child, text: &text, docFrom: &docFrom, docTo: &docTo)
1380
+ }
1381
+ }
1382
+
1383
+ @objc private func handleCellTap(_ gesture: UITapGestureRecognizer) {
1384
+ guard isEditable else { return }
1385
+ guard let cell = gesture.view as? CellView else { return }
1386
+ selectCell(anchor: cell, head: cell)
1387
+ }
1388
+
1389
+ @objc private func handleTablePan(_ gesture: UIPanGestureRecognizer) {
1390
+ guard isEditable else { return }
1391
+ let point = gesture.location(in: self)
1392
+ guard let cell = cell(at: point) else { return }
1393
+ switch gesture.state {
1394
+ case .began:
1395
+ selectionAnchorCell = cell
1396
+ selectCell(anchor: cell, head: cell)
1397
+ case .changed:
1398
+ if let anchor = selectionAnchorCell {
1399
+ selectCell(anchor: anchor, head: cell)
1400
+ }
1401
+ case .ended, .cancelled, .failed:
1402
+ if let anchor = selectionAnchorCell {
1403
+ selectCell(anchor: anchor, head: cell)
1404
+ }
1405
+ default:
1406
+ break
1407
+ }
1408
+ }
1409
+
1410
+ private func cell(at point: CGPoint) -> CellView? {
1411
+ tableCells.first { cell in
1412
+ cell.convert(cell.bounds, to: self).contains(point)
1413
+ }
1414
+ }
1415
+
1416
+ private func selectCell(anchor: CellView, head: CellView) {
1417
+ guard currentEditorId != 0, anchor.docPos > 0, head.docPos > 0 else { return }
1418
+ selectionAnchorCell = anchor
1419
+ selectionHeadCell = head
1420
+ editorSetCellSelection(id: currentEditorId, anchor: anchor.docPos, head: head.docPos)
1421
+ paintCellSelection(anchor: anchor, head: head)
1422
+ onAppliedUpdate?(editorGetSelectionState(id: currentEditorId))
1423
+ }
1424
+
1425
+ private func paintCellSelection(anchor: CellView, head: CellView) {
1426
+ let sameTable = anchor.tableId == head.tableId
1427
+ let minRow = min(anchor.rowIndex, head.rowIndex)
1428
+ let maxRow = max(anchor.rowIndex, head.rowIndex)
1429
+ let minColumn = min(anchor.columnIndex, head.columnIndex)
1430
+ let maxColumn = max(anchor.columnIndex, head.columnIndex)
1431
+ for cell in tableCells {
1432
+ let selected: Bool
1433
+ if sameTable {
1434
+ selected = cell.tableId == anchor.tableId &&
1435
+ (minRow...maxRow).contains(cell.rowIndex) &&
1436
+ (minColumn...maxColumn).contains(cell.columnIndex)
1437
+ } else {
1438
+ selected = cell === anchor || cell === head
1439
+ }
1440
+ cell.backgroundColor = selected ? UIColor.systemBlue.withAlphaComponent(0.12) : cell.baseFillColor
1441
+ cell.layer.borderColor = selected ? UIColor.systemBlue.cgColor : UIColor.separator.cgColor
1442
+ cell.layer.borderWidth = selected ? 1.5 : 0.5
1443
+ }
1444
+ }
1445
+ }