@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.
- package/android/build.gradle +35 -0
- package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +2 -2
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +138 -597
- package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +13 -3
- package/android/src/main/java/com/apollohg/editor/InputSnapshotSupport.kt +6 -0
- package/android/src/main/java/com/apollohg/editor/NativeBlockEditorSurface.kt +1086 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +1 -1
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +26 -50
- package/android/src/main/java/com/apollohg/editor/NativeImageLoader.kt +89 -0
- package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +31 -66
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +98 -33
- package/dist/EditorToolbar.d.ts +1 -1
- package/dist/EditorToolbar.js +19 -0
- package/dist/NativeEditorBridge.d.ts +62 -35
- package/dist/NativeEditorBridge.js +122 -79
- package/dist/NativeProseViewer.d.ts +13 -34
- package/dist/NativeProseViewer.js +40 -465
- package/dist/NativeRichTextEditor.d.ts +10 -1
- package/dist/NativeRichTextEditor.js +72 -34
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -1
- package/dist/schemas.d.ts +11 -0
- package/dist/schemas.js +149 -0
- package/dist/useNativeEditor.d.ts +3 -3
- package/dist/useNativeEditor.js +3 -3
- package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
- package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
- package/ios/Generated_editor_core.swift +97 -2
- package/ios/NativeBlockEditorSurface.swift +1445 -0
- package/ios/NativeEditorExpoView.swift +3 -3
- package/ios/NativeEditorModule.swift +31 -46
- package/ios/NativeInputSupport.swift +396 -0
- package/ios/PositionBridge.swift +4 -4
- package/ios/RichTextEditorView.swift +352 -786
- package/ios/editor_coreFFI/editor_coreFFI.h +147 -70
- package/package.json +1 -1
- package/rust/android/arm64-v8a/libeditor_core.so +0 -0
- package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
- package/rust/android/x86_64/libeditor_core.so +0 -0
- package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2087 -1399
- package/android/src/androidTest/AndroidManifest.xml +0 -8
- package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +0 -53
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +0 -241
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +0 -338
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +0 -789
- package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +0 -350
- package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +0 -5
- package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +0 -315
- package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +0 -2172
- package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +0 -482
- package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +0 -137
- package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +0 -321
- package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +0 -4202
- package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +0 -2581
- package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +0 -27
- package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +0 -197
- package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +0 -84
- package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +0 -544
- package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +0 -461
- package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +0 -2054
- package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +0 -1367
- package/ios/EditorLayoutManager.swift +0 -774
- package/ios/NativeProseViewerExpoView.swift +0 -276
- package/ios/RenderBridge.swift +0 -1708
- package/ios/Tests/NativePerformanceTests.swift +0 -687
- package/ios/Tests/PositionBridgeTests.swift +0 -706
- package/ios/Tests/RenderBridgeTests.swift +0 -2236
- 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
|
+
}
|