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

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