@apollohg/react-native-prose-editor 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -7
- package/android/build.gradle +7 -2
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +289 -2
- package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +51 -1
- package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +199 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +16 -3
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +82 -1
- package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +403 -45
- package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +246 -0
- package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +841 -155
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +125 -8
- package/{src/EditorTheme.ts → dist/EditorTheme.d.ts} +12 -52
- package/dist/EditorTheme.js +29 -0
- package/dist/EditorToolbar.d.ts +129 -0
- package/dist/EditorToolbar.js +394 -0
- package/dist/NativeEditorBridge.d.ts +242 -0
- package/dist/NativeEditorBridge.js +647 -0
- package/dist/NativeRichTextEditor.d.ts +142 -0
- package/dist/NativeRichTextEditor.js +649 -0
- package/dist/YjsCollaboration.d.ts +83 -0
- package/dist/YjsCollaboration.js +585 -0
- package/dist/addons.d.ts +70 -0
- package/dist/addons.js +77 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +26 -0
- package/dist/schemas.d.ts +35 -0
- package/{src/schemas.ts → dist/schemas.js} +62 -27
- package/dist/useNativeEditor.d.ts +40 -0
- package/dist/useNativeEditor.js +117 -0
- package/ios/EditorAddons.swift +26 -3
- package/ios/EditorCore.xcframework/Info.plist +5 -5
- package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
- package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
- package/ios/EditorLayoutManager.swift +236 -0
- package/ios/EditorTheme.swift +51 -1
- package/ios/Generated_editor_core.swift +270 -2
- package/ios/NativeEditorExpoView.swift +612 -45
- package/ios/NativeEditorModule.swift +81 -0
- package/ios/PositionBridge.swift +22 -0
- package/ios/RenderBridge.swift +427 -39
- package/ios/RichTextEditorView.swift +1342 -18
- package/ios/editor_coreFFI/editor_coreFFI.h +209 -0
- package/package.json +80 -64
- 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 +404 -4
- package/src/EditorToolbar.tsx +0 -620
- package/src/NativeEditorBridge.ts +0 -607
- package/src/NativeRichTextEditor.tsx +0 -951
- package/src/addons.ts +0 -158
- package/src/index.ts +0 -63
- package/src/useNativeEditor.ts +0 -173
|
@@ -25,6 +25,593 @@ enum EditorHeightBehavior: String {
|
|
|
25
25
|
case autoGrow
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
struct RemoteSelectionDecoration {
|
|
29
|
+
let clientId: Int
|
|
30
|
+
let anchor: UInt32
|
|
31
|
+
let head: UInt32
|
|
32
|
+
let color: UIColor
|
|
33
|
+
let name: String?
|
|
34
|
+
let isFocused: Bool
|
|
35
|
+
|
|
36
|
+
static func from(json: String?) -> [RemoteSelectionDecoration] {
|
|
37
|
+
guard let json,
|
|
38
|
+
let data = json.data(using: .utf8),
|
|
39
|
+
let raw = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
|
|
40
|
+
else {
|
|
41
|
+
return []
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return raw.compactMap { item in
|
|
45
|
+
guard let clientId = item["clientId"] as? NSNumber,
|
|
46
|
+
let anchor = item["anchor"] as? NSNumber,
|
|
47
|
+
let head = item["head"] as? NSNumber,
|
|
48
|
+
let colorRaw = item["color"] as? String,
|
|
49
|
+
let color = colorFromString(colorRaw)
|
|
50
|
+
else {
|
|
51
|
+
return nil
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return RemoteSelectionDecoration(
|
|
55
|
+
clientId: clientId.intValue,
|
|
56
|
+
anchor: anchor.uint32Value,
|
|
57
|
+
head: head.uint32Value,
|
|
58
|
+
color: color,
|
|
59
|
+
name: item["name"] as? String,
|
|
60
|
+
isFocused: (item["isFocused"] as? Bool) ?? false
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private static func colorFromString(_ raw: String) -> UIColor? {
|
|
66
|
+
let value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
67
|
+
guard value.hasPrefix("#") else { return nil }
|
|
68
|
+
let hex = String(value.dropFirst())
|
|
69
|
+
|
|
70
|
+
switch hex.count {
|
|
71
|
+
case 3:
|
|
72
|
+
let chars = Array(hex)
|
|
73
|
+
return UIColor(
|
|
74
|
+
red: component(String(repeating: String(chars[0]), count: 2)),
|
|
75
|
+
green: component(String(repeating: String(chars[1]), count: 2)),
|
|
76
|
+
blue: component(String(repeating: String(chars[2]), count: 2)),
|
|
77
|
+
alpha: 1
|
|
78
|
+
)
|
|
79
|
+
case 4:
|
|
80
|
+
let chars = Array(hex)
|
|
81
|
+
return UIColor(
|
|
82
|
+
red: component(String(repeating: String(chars[0]), count: 2)),
|
|
83
|
+
green: component(String(repeating: String(chars[1]), count: 2)),
|
|
84
|
+
blue: component(String(repeating: String(chars[2]), count: 2)),
|
|
85
|
+
alpha: component(String(repeating: String(chars[3]), count: 2))
|
|
86
|
+
)
|
|
87
|
+
case 6:
|
|
88
|
+
return UIColor(
|
|
89
|
+
red: component(String(hex.prefix(2))),
|
|
90
|
+
green: component(String(hex.dropFirst(2).prefix(2))),
|
|
91
|
+
blue: component(String(hex.dropFirst(4).prefix(2))),
|
|
92
|
+
alpha: 1
|
|
93
|
+
)
|
|
94
|
+
case 8:
|
|
95
|
+
return UIColor(
|
|
96
|
+
red: component(String(hex.prefix(2))),
|
|
97
|
+
green: component(String(hex.dropFirst(2).prefix(2))),
|
|
98
|
+
blue: component(String(hex.dropFirst(4).prefix(2))),
|
|
99
|
+
alpha: component(String(hex.dropFirst(6).prefix(2)))
|
|
100
|
+
)
|
|
101
|
+
default:
|
|
102
|
+
return nil
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private static func component(_ hex: String) -> CGFloat {
|
|
107
|
+
CGFloat(Int(hex, radix: 16) ?? 0) / 255
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private final class RemoteSelectionBadgeLabel: UILabel {
|
|
112
|
+
override func drawText(in rect: CGRect) {
|
|
113
|
+
super.drawText(in: rect.inset(by: UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
override var intrinsicContentSize: CGSize {
|
|
117
|
+
let size = super.intrinsicContentSize
|
|
118
|
+
return CGSize(width: size.width + 16, height: max(size.height + 8, 22))
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private final class RemoteSelectionOverlayView: UIView {
|
|
123
|
+
weak var textView: EditorTextView?
|
|
124
|
+
private var editorId: UInt64 = 0
|
|
125
|
+
private var selections: [RemoteSelectionDecoration] = []
|
|
126
|
+
|
|
127
|
+
override init(frame: CGRect) {
|
|
128
|
+
super.init(frame: frame)
|
|
129
|
+
backgroundColor = .clear
|
|
130
|
+
isUserInteractionEnabled = false
|
|
131
|
+
clipsToBounds = true
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
required init?(coder: NSCoder) {
|
|
135
|
+
return nil
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func bind(textView: EditorTextView) {
|
|
139
|
+
self.textView = textView
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
func update(selections: [RemoteSelectionDecoration], editorId: UInt64) {
|
|
143
|
+
self.selections = selections
|
|
144
|
+
self.editorId = editorId
|
|
145
|
+
refresh()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
func refresh() {
|
|
149
|
+
subviews.forEach { $0.removeFromSuperview() }
|
|
150
|
+
guard editorId != 0,
|
|
151
|
+
let textView
|
|
152
|
+
else {
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for selection in selections {
|
|
157
|
+
let geometry = geometry(for: selection, in: textView)
|
|
158
|
+
for rect in geometry.selectionRects {
|
|
159
|
+
let selectionView = UIView(frame: rect.integral)
|
|
160
|
+
selectionView.backgroundColor = selection.color.withAlphaComponent(0.18)
|
|
161
|
+
selectionView.layer.cornerRadius = 3
|
|
162
|
+
addSubview(selectionView)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
guard selection.isFocused,
|
|
166
|
+
let caretRect = geometry.caretRect
|
|
167
|
+
else {
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let caretView = UIView(frame: CGRect(
|
|
172
|
+
x: round(caretRect.minX),
|
|
173
|
+
y: round(caretRect.minY),
|
|
174
|
+
width: max(2, round(caretRect.width)),
|
|
175
|
+
height: round(caretRect.height)
|
|
176
|
+
))
|
|
177
|
+
caretView.backgroundColor = selection.color
|
|
178
|
+
caretView.layer.cornerRadius = caretView.bounds.width / 2
|
|
179
|
+
addSubview(caretView)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private func geometry(
|
|
184
|
+
for selection: RemoteSelectionDecoration,
|
|
185
|
+
in textView: EditorTextView
|
|
186
|
+
) -> (selectionRects: [CGRect], caretRect: CGRect?) {
|
|
187
|
+
let startScalar = editorDocToScalar(
|
|
188
|
+
id: editorId,
|
|
189
|
+
docPos: min(selection.anchor, selection.head)
|
|
190
|
+
)
|
|
191
|
+
let endScalar = editorDocToScalar(
|
|
192
|
+
id: editorId,
|
|
193
|
+
docPos: max(selection.anchor, selection.head)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
let startPosition = PositionBridge.scalarToTextView(startScalar, in: textView)
|
|
197
|
+
let endPosition = PositionBridge.scalarToTextView(endScalar, in: textView)
|
|
198
|
+
let caretRect = resolvedCaretRect(
|
|
199
|
+
for: endPosition,
|
|
200
|
+
in: textView
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
if startScalar == endScalar {
|
|
204
|
+
return ([], caretRect)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
guard let range = textView.textRange(from: startPosition, to: endPosition) else {
|
|
208
|
+
return ([], caretRect)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let selectionRects = textView.selectionRects(for: range)
|
|
212
|
+
.map(\.rect)
|
|
213
|
+
.filter { !$0.isEmpty && $0.width > 0 && $0.height > 0 }
|
|
214
|
+
.map { textView.convert($0, to: self) }
|
|
215
|
+
|
|
216
|
+
return (selectionRects, caretRect)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private func resolvedCaretRect(
|
|
220
|
+
for position: UITextPosition,
|
|
221
|
+
in textView: EditorTextView
|
|
222
|
+
) -> CGRect? {
|
|
223
|
+
let directRect = textView.convert(textView.caretRect(for: position), to: self)
|
|
224
|
+
if directRect.height > 0, directRect.width >= 0 {
|
|
225
|
+
return directRect
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if let previousPosition = textView.position(from: position, offset: -1),
|
|
229
|
+
let previousRange = textView.textRange(from: previousPosition, to: position),
|
|
230
|
+
let previousRect = textView.selectionRects(for: previousRange)
|
|
231
|
+
.map(\.rect)
|
|
232
|
+
.last(where: { !$0.isEmpty && $0.height > 0 })
|
|
233
|
+
{
|
|
234
|
+
let rect = textView.convert(previousRect, to: self)
|
|
235
|
+
return CGRect(x: rect.maxX, y: rect.minY, width: 2, height: rect.height)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if let nextPosition = textView.position(from: position, offset: 1),
|
|
239
|
+
let nextRange = textView.textRange(from: position, to: nextPosition),
|
|
240
|
+
let nextRect = textView.selectionRects(for: nextRange)
|
|
241
|
+
.map(\.rect)
|
|
242
|
+
.first(where: { !$0.isEmpty && $0.height > 0 })
|
|
243
|
+
{
|
|
244
|
+
let rect = textView.convert(nextRect, to: self)
|
|
245
|
+
return CGRect(x: rect.minX, y: rect.minY, width: 2, height: rect.height)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if directRect.isEmpty {
|
|
249
|
+
return nil
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return directRect
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private final class ImageTapOverlayView: UIView {
|
|
257
|
+
private weak var editorView: RichTextEditorView?
|
|
258
|
+
private lazy var tapRecognizer: UITapGestureRecognizer = {
|
|
259
|
+
let recognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
|
|
260
|
+
recognizer.cancelsTouchesInView = true
|
|
261
|
+
return recognizer
|
|
262
|
+
}()
|
|
263
|
+
|
|
264
|
+
override init(frame: CGRect) {
|
|
265
|
+
super.init(frame: frame)
|
|
266
|
+
backgroundColor = .clear
|
|
267
|
+
addGestureRecognizer(tapRecognizer)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
required init?(coder: NSCoder) {
|
|
271
|
+
return nil
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
func bind(editorView: RichTextEditorView) {
|
|
275
|
+
self.editorView = editorView
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
|
|
279
|
+
guard let editorView else { return false }
|
|
280
|
+
let pointInTextView = convert(point, to: editorView.textView)
|
|
281
|
+
return editorView.textView.hasImageAttachment(at: pointInTextView)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
@objc
|
|
285
|
+
private func handleTap(_ recognizer: UITapGestureRecognizer) {
|
|
286
|
+
guard recognizer.state == .ended, let editorView else { return }
|
|
287
|
+
let pointInTextView = convert(recognizer.location(in: self), to: editorView.textView)
|
|
288
|
+
_ = editorView.textView.selectImageAttachment(at: pointInTextView)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
func interceptsPointForTesting(_ point: CGPoint) -> Bool {
|
|
292
|
+
self.point(inside: point, with: nil)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
@discardableResult
|
|
296
|
+
func handleTapForTesting(_ point: CGPoint) -> Bool {
|
|
297
|
+
guard let editorView else { return false }
|
|
298
|
+
let pointInTextView = convert(point, to: editorView.textView)
|
|
299
|
+
return editorView.textView.selectImageAttachment(at: pointInTextView)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private final class ImageResizeHandleView: UIView {
|
|
304
|
+
let corner: ImageResizeOverlayView.Corner
|
|
305
|
+
|
|
306
|
+
init(corner: ImageResizeOverlayView.Corner) {
|
|
307
|
+
self.corner = corner
|
|
308
|
+
super.init(frame: .zero)
|
|
309
|
+
isUserInteractionEnabled = true
|
|
310
|
+
backgroundColor = .systemBackground
|
|
311
|
+
layer.borderColor = UIColor.systemBlue.cgColor
|
|
312
|
+
layer.borderWidth = 2
|
|
313
|
+
layer.cornerRadius = 10
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
required init?(coder: NSCoder) {
|
|
317
|
+
return nil
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private final class ImageResizeOverlayView: UIView {
|
|
322
|
+
enum Corner: CaseIterable {
|
|
323
|
+
case topLeft
|
|
324
|
+
case topRight
|
|
325
|
+
case bottomLeft
|
|
326
|
+
case bottomRight
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private struct DragState {
|
|
330
|
+
let corner: Corner
|
|
331
|
+
let originalRect: CGRect
|
|
332
|
+
let docPos: UInt32
|
|
333
|
+
let maximumWidth: CGFloat
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private weak var editorView: RichTextEditorView?
|
|
337
|
+
private let selectionLayer = CAShapeLayer()
|
|
338
|
+
private let previewBackdropView = UIView()
|
|
339
|
+
private let previewImageView = UIImageView()
|
|
340
|
+
private var handleViews: [Corner: ImageResizeHandleView] = [:]
|
|
341
|
+
private var currentRect: CGRect?
|
|
342
|
+
private var currentDocPos: UInt32?
|
|
343
|
+
private var dragState: DragState?
|
|
344
|
+
private let handleSize: CGFloat = 20
|
|
345
|
+
private let minimumImageSize: CGFloat = 48
|
|
346
|
+
|
|
347
|
+
override init(frame: CGRect) {
|
|
348
|
+
super.init(frame: frame)
|
|
349
|
+
backgroundColor = .clear
|
|
350
|
+
clipsToBounds = true
|
|
351
|
+
|
|
352
|
+
previewBackdropView.isUserInteractionEnabled = false
|
|
353
|
+
previewBackdropView.isHidden = true
|
|
354
|
+
previewBackdropView.layer.zPosition = 1
|
|
355
|
+
addSubview(previewBackdropView)
|
|
356
|
+
|
|
357
|
+
previewImageView.isUserInteractionEnabled = false
|
|
358
|
+
previewImageView.isHidden = true
|
|
359
|
+
previewImageView.contentMode = .scaleToFill
|
|
360
|
+
previewImageView.layer.zPosition = 2
|
|
361
|
+
addSubview(previewImageView)
|
|
362
|
+
|
|
363
|
+
selectionLayer.strokeColor = UIColor.systemBlue.cgColor
|
|
364
|
+
selectionLayer.fillColor = UIColor.clear.cgColor
|
|
365
|
+
selectionLayer.lineWidth = 2
|
|
366
|
+
selectionLayer.zPosition = 10
|
|
367
|
+
layer.addSublayer(selectionLayer)
|
|
368
|
+
|
|
369
|
+
for corner in Corner.allCases {
|
|
370
|
+
let handleView = ImageResizeHandleView(corner: corner)
|
|
371
|
+
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
|
|
372
|
+
handleView.addGestureRecognizer(panGesture)
|
|
373
|
+
handleView.layer.zPosition = 20
|
|
374
|
+
addSubview(handleView)
|
|
375
|
+
handleViews[corner] = handleView
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
isHidden = true
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
required init?(coder: NSCoder) {
|
|
382
|
+
return nil
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
func bind(editorView: RichTextEditorView) {
|
|
386
|
+
self.editorView = editorView
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
func refresh() {
|
|
390
|
+
if dragState != nil {
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
guard let editorView,
|
|
395
|
+
let geometry = editorView.selectedImageGeometry()
|
|
396
|
+
else {
|
|
397
|
+
hideOverlay()
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
hidePreviewLayers()
|
|
402
|
+
applyGeometry(rect: geometry.rect, docPos: geometry.docPos)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
func simulateResizeForTesting(width: CGFloat, height: CGFloat) {
|
|
406
|
+
guard let docPos = currentDocPos else { return }
|
|
407
|
+
editorView?.resizeImage(docPos: docPos, size: CGSize(width: width, height: height))
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
func simulatePreviewResizeForTesting(width: CGFloat, height: CGFloat) {
|
|
411
|
+
guard beginPreviewResize(from: .bottomRight) else { return }
|
|
412
|
+
let nextRect = CGRect(
|
|
413
|
+
origin: dragState?.originalRect.origin ?? .zero,
|
|
414
|
+
size: editorView?.clampedImageSize(
|
|
415
|
+
CGSize(width: width, height: height),
|
|
416
|
+
maximumWidth: dragState?.maximumWidth
|
|
417
|
+
) ?? CGSize(width: width, height: height)
|
|
418
|
+
)
|
|
419
|
+
updatePreviewRect(nextRect)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
func commitPreviewResizeForTesting() {
|
|
423
|
+
finishPreviewResize(commit: true)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
var visibleRectForTesting: CGRect? {
|
|
427
|
+
isHidden ? nil : currentRect
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
var previewHasImageForTesting: Bool {
|
|
431
|
+
!previewImageView.isHidden && previewImageView.image != nil
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
func interceptsPointForTesting(_ location: CGPoint) -> Bool {
|
|
435
|
+
self.point(inside: location, with: nil)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
|
|
439
|
+
guard !isHidden else { return false }
|
|
440
|
+
for handleView in handleViews.values where !handleView.isHidden {
|
|
441
|
+
if handleView.frame.insetBy(dx: -12, dy: -12).contains(point) {
|
|
442
|
+
return true
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return false
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private func hideOverlay() {
|
|
449
|
+
hidePreviewLayers()
|
|
450
|
+
dragState = nil
|
|
451
|
+
currentRect = nil
|
|
452
|
+
currentDocPos = nil
|
|
453
|
+
selectionLayer.path = nil
|
|
454
|
+
isHidden = true
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private func applyGeometry(rect: CGRect, docPos: UInt32) {
|
|
458
|
+
let integralRect = rect.integral
|
|
459
|
+
currentRect = integralRect
|
|
460
|
+
currentDocPos = docPos
|
|
461
|
+
selectionLayer.path = UIBezierPath(roundedRect: integralRect, cornerRadius: 8).cgPath
|
|
462
|
+
isHidden = false
|
|
463
|
+
layoutHandleViews(for: integralRect)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private func hidePreviewLayers() {
|
|
467
|
+
previewBackdropView.isHidden = true
|
|
468
|
+
previewImageView.isHidden = true
|
|
469
|
+
previewImageView.image = nil
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private func showPreview(docPos: UInt32, originalRect: CGRect) {
|
|
473
|
+
previewBackdropView.backgroundColor = editorView?.imageResizePreviewBackgroundColor() ?? .systemBackground
|
|
474
|
+
previewBackdropView.frame = originalRect
|
|
475
|
+
previewBackdropView.isHidden = false
|
|
476
|
+
|
|
477
|
+
previewImageView.image = editorView?.imagePreviewForResize(docPos: docPos)
|
|
478
|
+
previewImageView.frame = originalRect
|
|
479
|
+
previewImageView.isHidden = previewImageView.image == nil
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
@discardableResult
|
|
483
|
+
private func beginPreviewResize(from corner: Corner) -> Bool {
|
|
484
|
+
guard let currentRect, let currentDocPos else { return false }
|
|
485
|
+
editorView?.setImageResizePreviewActive(true)
|
|
486
|
+
let maximumWidth = editorView?.maximumImageWidthForResizeGesture() ?? currentRect.width
|
|
487
|
+
dragState = DragState(
|
|
488
|
+
corner: corner,
|
|
489
|
+
originalRect: currentRect,
|
|
490
|
+
docPos: currentDocPos,
|
|
491
|
+
maximumWidth: maximumWidth
|
|
492
|
+
)
|
|
493
|
+
showPreview(docPos: currentDocPos, originalRect: currentRect)
|
|
494
|
+
return true
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private func updatePreviewRect(_ rect: CGRect) {
|
|
498
|
+
guard let currentDocPos else { return }
|
|
499
|
+
applyGeometry(rect: rect, docPos: currentDocPos)
|
|
500
|
+
previewImageView.frame = currentRect ?? rect.integral
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private func finishPreviewResize(commit: Bool) {
|
|
504
|
+
guard let dragState else { return }
|
|
505
|
+
let finalSize = currentRect?.size ?? dragState.originalRect.size
|
|
506
|
+
self.dragState = nil
|
|
507
|
+
editorView?.setImageResizePreviewActive(false)
|
|
508
|
+
if commit {
|
|
509
|
+
editorView?.resizeImage(docPos: dragState.docPos, size: finalSize)
|
|
510
|
+
} else {
|
|
511
|
+
hidePreviewLayers()
|
|
512
|
+
}
|
|
513
|
+
DispatchQueue.main.async { [weak self] in
|
|
514
|
+
self?.refresh()
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private func layoutHandleViews(for rect: CGRect) {
|
|
519
|
+
for (corner, handleView) in handleViews {
|
|
520
|
+
let center = handleCenter(for: corner, in: rect)
|
|
521
|
+
handleView.frame = CGRect(
|
|
522
|
+
x: center.x - (handleSize / 2),
|
|
523
|
+
y: center.y - (handleSize / 2),
|
|
524
|
+
width: handleSize,
|
|
525
|
+
height: handleSize
|
|
526
|
+
)
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private func handleCenter(for corner: Corner, in rect: CGRect) -> CGPoint {
|
|
531
|
+
switch corner {
|
|
532
|
+
case .topLeft:
|
|
533
|
+
return CGPoint(x: rect.minX, y: rect.minY)
|
|
534
|
+
case .topRight:
|
|
535
|
+
return CGPoint(x: rect.maxX, y: rect.minY)
|
|
536
|
+
case .bottomLeft:
|
|
537
|
+
return CGPoint(x: rect.minX, y: rect.maxY)
|
|
538
|
+
case .bottomRight:
|
|
539
|
+
return CGPoint(x: rect.maxX, y: rect.maxY)
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private func anchorPoint(for corner: Corner, in rect: CGRect) -> CGPoint {
|
|
544
|
+
switch corner {
|
|
545
|
+
case .topLeft:
|
|
546
|
+
return CGPoint(x: rect.maxX, y: rect.maxY)
|
|
547
|
+
case .topRight:
|
|
548
|
+
return CGPoint(x: rect.minX, y: rect.maxY)
|
|
549
|
+
case .bottomLeft:
|
|
550
|
+
return CGPoint(x: rect.maxX, y: rect.minY)
|
|
551
|
+
case .bottomRight:
|
|
552
|
+
return CGPoint(x: rect.minX, y: rect.minY)
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private func resizedRect(
|
|
557
|
+
from originalRect: CGRect,
|
|
558
|
+
corner: Corner,
|
|
559
|
+
translation: CGPoint,
|
|
560
|
+
maximumWidth: CGFloat?
|
|
561
|
+
) -> CGRect {
|
|
562
|
+
let aspectRatio = max(originalRect.width / max(originalRect.height, 1), 0.1)
|
|
563
|
+
let signedDx = (corner == .topRight || corner == .bottomRight) ? translation.x : -translation.x
|
|
564
|
+
let signedDy = (corner == .bottomLeft || corner == .bottomRight) ? translation.y : -translation.y
|
|
565
|
+
let widthScale = (originalRect.width + signedDx) / max(originalRect.width, 1)
|
|
566
|
+
let heightScale = (originalRect.height + signedDy) / max(originalRect.height, 1)
|
|
567
|
+
let scale = max(minimumImageSize / max(originalRect.width, 1), widthScale, heightScale)
|
|
568
|
+
let unclampedSize = CGSize(
|
|
569
|
+
width: max(minimumImageSize, originalRect.width * scale),
|
|
570
|
+
height: max(minimumImageSize / aspectRatio, (max(minimumImageSize, originalRect.width * scale) / aspectRatio))
|
|
571
|
+
)
|
|
572
|
+
let clampedSize = editorView?.clampedImageSize(unclampedSize, maximumWidth: maximumWidth) ?? unclampedSize
|
|
573
|
+
let width = clampedSize.width
|
|
574
|
+
let height = clampedSize.height
|
|
575
|
+
let anchor = anchorPoint(for: corner, in: originalRect)
|
|
576
|
+
|
|
577
|
+
switch corner {
|
|
578
|
+
case .topLeft:
|
|
579
|
+
return CGRect(x: anchor.x - width, y: anchor.y - height, width: width, height: height)
|
|
580
|
+
case .topRight:
|
|
581
|
+
return CGRect(x: anchor.x, y: anchor.y - height, width: width, height: height)
|
|
582
|
+
case .bottomLeft:
|
|
583
|
+
return CGRect(x: anchor.x - width, y: anchor.y, width: width, height: height)
|
|
584
|
+
case .bottomRight:
|
|
585
|
+
return CGRect(x: anchor.x, y: anchor.y, width: width, height: height)
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
@objc
|
|
590
|
+
private func handlePan(_ gesture: UIPanGestureRecognizer) {
|
|
591
|
+
guard let handleView = gesture.view as? ImageResizeHandleView else { return }
|
|
592
|
+
|
|
593
|
+
switch gesture.state {
|
|
594
|
+
case .began:
|
|
595
|
+
_ = beginPreviewResize(from: handleView.corner)
|
|
596
|
+
case .changed:
|
|
597
|
+
guard let dragState else { return }
|
|
598
|
+
let nextRect = resizedRect(
|
|
599
|
+
from: dragState.originalRect,
|
|
600
|
+
corner: dragState.corner,
|
|
601
|
+
translation: gesture.translation(in: self),
|
|
602
|
+
maximumWidth: dragState.maximumWidth
|
|
603
|
+
)
|
|
604
|
+
updatePreviewRect(nextRect)
|
|
605
|
+
case .ended:
|
|
606
|
+
finishPreviewResize(commit: true)
|
|
607
|
+
case .cancelled, .failed:
|
|
608
|
+
finishPreviewResize(commit: false)
|
|
609
|
+
default:
|
|
610
|
+
finishPreviewResize(commit: false)
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
28
615
|
// MARK: - EditorTextView
|
|
29
616
|
|
|
30
617
|
/// UITextView subclass that intercepts all text input and routes it through
|
|
@@ -52,7 +639,8 @@ enum EditorHeightBehavior: String {
|
|
|
52
639
|
/// (`editor_insert_text`, `editor_delete_range`, etc.) are synchronous and
|
|
53
640
|
/// fast enough for main-thread use. If profiling shows otherwise, we can
|
|
54
641
|
/// dispatch to a serial queue and batch updates.
|
|
55
|
-
final class EditorTextView: UITextView, UITextViewDelegate {
|
|
642
|
+
final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerDelegate {
|
|
643
|
+
private static let emptyBlockPlaceholderScalar = UnicodeScalar(0x200B)
|
|
56
644
|
|
|
57
645
|
// MARK: - Properties
|
|
58
646
|
|
|
@@ -63,6 +651,10 @@ final class EditorTextView: UITextView, UITextViewDelegate {
|
|
|
63
651
|
/// Guard flag to prevent re-entrant input interception while we're
|
|
64
652
|
/// applying state from Rust (calling replaceCharacters on the text storage).
|
|
65
653
|
var isApplyingRustState = false
|
|
654
|
+
private var visibleSelectionTintColor: UIColor = .systemBlue
|
|
655
|
+
private var hidesNativeSelectionChrome = false
|
|
656
|
+
private var isPreviewingImageResize = false
|
|
657
|
+
var allowImageResizing = true
|
|
66
658
|
|
|
67
659
|
/// The base font used for unstyled text. Configurable from React props.
|
|
68
660
|
var baseFont: UIFont = .systemFont(ofSize: 16)
|
|
@@ -89,6 +681,7 @@ final class EditorTextView: UITextView, UITextViewDelegate {
|
|
|
89
681
|
} else {
|
|
90
682
|
textContainerInset = baseTextContainerInset
|
|
91
683
|
}
|
|
684
|
+
setNeedsLayout()
|
|
92
685
|
}
|
|
93
686
|
}
|
|
94
687
|
|
|
@@ -102,6 +695,8 @@ final class EditorTextView: UITextView, UITextViewDelegate {
|
|
|
102
695
|
}
|
|
103
696
|
|
|
104
697
|
var onHeightMayChange: (() -> Void)?
|
|
698
|
+
var onViewportMayChange: (() -> Void)?
|
|
699
|
+
var onSelectionOrContentMayChange: (() -> Void)?
|
|
105
700
|
private var lastAutoGrowMeasuredHeight: CGFloat = 0
|
|
106
701
|
|
|
107
702
|
/// Delegate for editor events.
|
|
@@ -135,6 +730,14 @@ final class EditorTextView: UITextView, UITextViewDelegate {
|
|
|
135
730
|
|
|
136
731
|
/// Tracks whether we're in a composition session (CJK / IME input).
|
|
137
732
|
private var isComposing = false
|
|
733
|
+
private lazy var imageSelectionTapRecognizer: UITapGestureRecognizer = {
|
|
734
|
+
let recognizer = UITapGestureRecognizer(target: self, action: #selector(handleImageSelectionTap(_:)))
|
|
735
|
+
recognizer.cancelsTouchesInView = true
|
|
736
|
+
recognizer.delaysTouchesBegan = false
|
|
737
|
+
recognizer.delaysTouchesEnded = false
|
|
738
|
+
recognizer.delegate = self
|
|
739
|
+
return recognizer
|
|
740
|
+
}()
|
|
138
741
|
|
|
139
742
|
/// Guards against reconciliation firing while we're already intercepting
|
|
140
743
|
/// and replaying a user input operation through Rust, including the
|
|
@@ -145,6 +748,8 @@ final class EditorTextView: UITextView, UITextViewDelegate {
|
|
|
145
748
|
/// Coalesces selection sync until UIKit has finished resolving the
|
|
146
749
|
/// current tap/drag gesture's final caret position.
|
|
147
750
|
private var pendingSelectionSyncGeneration: UInt64 = 0
|
|
751
|
+
private var pendingDeferredImageSelectionRange: NSRange?
|
|
752
|
+
private var pendingDeferredImageSelectionGeneration: UInt64 = 0
|
|
148
753
|
|
|
149
754
|
/// Stores the text that was composed during a marked text session,
|
|
150
755
|
/// captured when `unmarkText` is called.
|
|
@@ -164,7 +769,11 @@ final class EditorTextView: UITextView, UITextViewDelegate {
|
|
|
164
769
|
}()
|
|
165
770
|
|
|
166
771
|
var placeholder: String = "" {
|
|
167
|
-
didSet {
|
|
772
|
+
didSet {
|
|
773
|
+
placeholderLabel.text = placeholder
|
|
774
|
+
refreshPlaceholderVisibility()
|
|
775
|
+
setNeedsLayout()
|
|
776
|
+
}
|
|
168
777
|
}
|
|
169
778
|
|
|
170
779
|
// MARK: - Initialization
|
|
@@ -187,6 +796,12 @@ final class EditorTextView: UITextView, UITextViewDelegate {
|
|
|
187
796
|
private func commonInit() {
|
|
188
797
|
textContainer.widthTracksTextView = true
|
|
189
798
|
editorLayoutManager.allowsNonContiguousLayout = false
|
|
799
|
+
NotificationCenter.default.addObserver(
|
|
800
|
+
self,
|
|
801
|
+
selector: #selector(handleImageAttachmentDidLoad(_:)),
|
|
802
|
+
name: .editorImageAttachmentDidLoad,
|
|
803
|
+
object: nil
|
|
804
|
+
)
|
|
190
805
|
|
|
191
806
|
// Configure the text view as a Rust-controlled editor surface.
|
|
192
807
|
// UIKit smart-edit features mutate text storage outside our transaction
|
|
@@ -208,32 +823,318 @@ final class EditorTextView: UITextView, UITextViewDelegate {
|
|
|
208
823
|
textColor = baseTextColor
|
|
209
824
|
backgroundColor = baseBackgroundColor
|
|
210
825
|
baseTextContainerInset = textContainerInset
|
|
826
|
+
visibleSelectionTintColor = tintColor
|
|
211
827
|
|
|
212
828
|
// Register as the text storage delegate so we can detect unauthorized
|
|
213
829
|
// mutations (reconciliation fallback).
|
|
214
830
|
textStorage.delegate = self
|
|
215
831
|
delegate = self
|
|
832
|
+
addGestureRecognizer(imageSelectionTapRecognizer)
|
|
833
|
+
installImageSelectionTapDependencies()
|
|
216
834
|
|
|
217
835
|
addSubview(placeholderLabel)
|
|
836
|
+
refreshPlaceholderVisibility()
|
|
837
|
+
refreshNativeSelectionChromeVisibility()
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
override func didMoveToWindow() {
|
|
841
|
+
super.didMoveToWindow()
|
|
842
|
+
installImageSelectionTapDependencies()
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
override func didAddSubview(_ subview: UIView) {
|
|
846
|
+
super.didAddSubview(subview)
|
|
847
|
+
installImageSelectionTapDependencies()
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
@objc
|
|
851
|
+
private func handleImageAttachmentDidLoad(_ notification: Notification) {
|
|
852
|
+
guard notification.object is NSTextAttachment else { return }
|
|
853
|
+
guard textStorage.length > 0 else { return }
|
|
854
|
+
|
|
855
|
+
textStorage.beginEditing()
|
|
856
|
+
textStorage.edited(.editedAttributes, range: NSRange(location: 0, length: textStorage.length), changeInLength: 0)
|
|
857
|
+
textStorage.endEditing()
|
|
858
|
+
setNeedsLayout()
|
|
859
|
+
invalidateIntrinsicContentSize()
|
|
860
|
+
onSelectionOrContentMayChange?()
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
override func tintColorDidChange() {
|
|
864
|
+
super.tintColorDidChange()
|
|
865
|
+
if !hidesNativeSelectionChrome, tintColor.cgColor.alpha > 0 {
|
|
866
|
+
visibleSelectionTintColor = tintColor
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
@objc
|
|
871
|
+
private func handleImageSelectionTap(_ gesture: UITapGestureRecognizer) {
|
|
872
|
+
guard gesture.state == .ended, gesture.numberOfTouches == 1 else { return }
|
|
873
|
+
let location = gesture.location(in: self)
|
|
874
|
+
guard let range = imageAttachmentRange(at: location) else { return }
|
|
875
|
+
scheduleDeferredImageSelection(for: range)
|
|
876
|
+
_ = selectImageAttachment(range: range)
|
|
218
877
|
}
|
|
219
878
|
|
|
220
879
|
// MARK: - Layout
|
|
221
880
|
|
|
222
881
|
override func layoutSubviews() {
|
|
223
882
|
super.layoutSubviews()
|
|
883
|
+
installImageSelectionTapDependencies()
|
|
884
|
+
let placeholderX = textContainerInset.left + textContainer.lineFragmentPadding
|
|
885
|
+
let placeholderY = textContainerInset.top
|
|
886
|
+
let placeholderWidth = max(
|
|
887
|
+
0,
|
|
888
|
+
bounds.width - textContainerInset.left - textContainerInset.right - 2 * textContainer.lineFragmentPadding
|
|
889
|
+
)
|
|
890
|
+
let maxPlaceholderHeight = max(
|
|
891
|
+
0,
|
|
892
|
+
bounds.height - textContainerInset.top - textContainerInset.bottom
|
|
893
|
+
)
|
|
894
|
+
let fittedHeight = placeholderLabel.sizeThatFits(
|
|
895
|
+
CGSize(width: placeholderWidth, height: CGFloat.greatestFiniteMagnitude)
|
|
896
|
+
).height
|
|
224
897
|
placeholderLabel.frame = CGRect(
|
|
225
|
-
x:
|
|
226
|
-
y:
|
|
227
|
-
width:
|
|
228
|
-
height:
|
|
898
|
+
x: placeholderX,
|
|
899
|
+
y: placeholderY,
|
|
900
|
+
width: placeholderWidth,
|
|
901
|
+
height: min(maxPlaceholderHeight, ceil(fittedHeight))
|
|
229
902
|
)
|
|
230
|
-
if heightBehavior == .autoGrow {
|
|
903
|
+
if heightBehavior == .autoGrow, !isPreviewingImageResize {
|
|
231
904
|
notifyHeightChangeIfNeeded()
|
|
232
905
|
}
|
|
906
|
+
if !isPreviewingImageResize {
|
|
907
|
+
onViewportMayChange?()
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
deinit {
|
|
912
|
+
NotificationCenter.default.removeObserver(self)
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
override var contentOffset: CGPoint {
|
|
916
|
+
didSet {
|
|
917
|
+
if !isPreviewingImageResize {
|
|
918
|
+
onViewportMayChange?()
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
private func isRenderedContentEmpty() -> Bool {
|
|
924
|
+
let renderedText = textStorage.string
|
|
925
|
+
guard !renderedText.isEmpty else { return true }
|
|
926
|
+
|
|
927
|
+
for scalar in renderedText.unicodeScalars {
|
|
928
|
+
switch scalar {
|
|
929
|
+
case Self.emptyBlockPlaceholderScalar, "\n", "\r":
|
|
930
|
+
continue
|
|
931
|
+
default:
|
|
932
|
+
return false
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
return true
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
private func refreshPlaceholderVisibility() {
|
|
940
|
+
placeholderLabel.isHidden = placeholder.isEmpty || !isRenderedContentEmpty()
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
@discardableResult
|
|
944
|
+
private func selectImageAttachmentIfNeeded(at location: CGPoint) -> Bool {
|
|
945
|
+
guard let range = imageAttachmentRange(at: location) else { return false }
|
|
946
|
+
scheduleDeferredImageSelection(for: range)
|
|
947
|
+
return selectImageAttachment(range: range)
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
@discardableResult
|
|
951
|
+
func selectImageAttachment(at location: CGPoint) -> Bool {
|
|
952
|
+
selectImageAttachmentIfNeeded(at: location)
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
func hasImageAttachment(at location: CGPoint) -> Bool {
|
|
956
|
+
imageAttachmentRange(at: location) != nil
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
@discardableResult
|
|
960
|
+
private func selectImageAttachment(range: NSRange) -> Bool {
|
|
961
|
+
guard isSelectable,
|
|
962
|
+
let start = position(from: beginningOfDocument, offset: range.location),
|
|
963
|
+
let end = position(from: start, offset: range.length),
|
|
964
|
+
let textRange = textRange(from: start, to: end)
|
|
965
|
+
else {
|
|
966
|
+
return false
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
_ = becomeFirstResponder()
|
|
970
|
+
selectedTextRange = textRange
|
|
971
|
+
refreshNativeSelectionChromeVisibility()
|
|
972
|
+
onSelectionOrContentMayChange?()
|
|
973
|
+
scheduleSelectionSync()
|
|
974
|
+
return true
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
private func selectedUtf16Range() -> NSRange? {
|
|
978
|
+
guard let range = selectedTextRange else { return nil }
|
|
979
|
+
let location = offset(from: beginningOfDocument, to: range.start)
|
|
980
|
+
let length = offset(from: range.start, to: range.end)
|
|
981
|
+
guard location >= 0, length >= 0 else { return nil }
|
|
982
|
+
return NSRange(location: location, length: length)
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
private func scheduleDeferredImageSelection(for range: NSRange) {
|
|
986
|
+
pendingDeferredImageSelectionRange = range
|
|
987
|
+
pendingDeferredImageSelectionGeneration &+= 1
|
|
988
|
+
let generation = pendingDeferredImageSelectionGeneration
|
|
989
|
+
DispatchQueue.main.async { [weak self] in
|
|
990
|
+
self?.applyDeferredImageSelectionIfNeeded(generation: generation)
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
private func applyDeferredImageSelectionIfNeeded(generation: UInt64) {
|
|
995
|
+
guard pendingDeferredImageSelectionGeneration == generation,
|
|
996
|
+
let pendingRange = pendingDeferredImageSelectionRange
|
|
997
|
+
else {
|
|
998
|
+
return
|
|
999
|
+
}
|
|
1000
|
+
pendingDeferredImageSelectionRange = nil
|
|
1001
|
+
guard selectedUtf16Range() != pendingRange else { return }
|
|
1002
|
+
_ = selectImageAttachment(range: pendingRange)
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
private func installImageSelectionTapDependencies() {
|
|
1006
|
+
for view in gestureDependencyViews(startingAt: self) {
|
|
1007
|
+
guard let recognizers = view.gestureRecognizers else { continue }
|
|
1008
|
+
for recognizer in recognizers {
|
|
1009
|
+
guard recognizer !== imageSelectionTapRecognizer,
|
|
1010
|
+
let tapRecognizer = recognizer as? UITapGestureRecognizer
|
|
1011
|
+
else {
|
|
1012
|
+
continue
|
|
1013
|
+
}
|
|
1014
|
+
tapRecognizer.require(toFail: imageSelectionTapRecognizer)
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
private func gestureDependencyViews(startingAt rootView: UIView) -> [UIView] {
|
|
1020
|
+
var views: [UIView] = [rootView]
|
|
1021
|
+
for subview in rootView.subviews {
|
|
1022
|
+
views.append(contentsOf: gestureDependencyViews(startingAt: subview))
|
|
1023
|
+
}
|
|
1024
|
+
return views
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
private func imageAttachmentRange(at location: CGPoint) -> NSRange? {
|
|
1028
|
+
guard allowImageResizing else { return nil }
|
|
1029
|
+
guard textStorage.length > 0 else { return nil }
|
|
1030
|
+
|
|
1031
|
+
let fullRange = NSRange(location: 0, length: textStorage.length)
|
|
1032
|
+
var resolvedRange: NSRange?
|
|
1033
|
+
|
|
1034
|
+
textStorage.enumerateAttribute(.attachment, in: fullRange) { value, range, stop in
|
|
1035
|
+
guard value is NSTextAttachment, range.length > 0 else { return }
|
|
1036
|
+
|
|
1037
|
+
let attrs = textStorage.attributes(at: range.location, effectiveRange: nil)
|
|
1038
|
+
guard (attrs[RenderBridgeAttributes.voidNodeType] as? String) == "image" else { return }
|
|
1039
|
+
|
|
1040
|
+
let glyphRange = layoutManager.glyphRange(
|
|
1041
|
+
forCharacterRange: range,
|
|
1042
|
+
actualCharacterRange: nil
|
|
1043
|
+
)
|
|
1044
|
+
guard glyphRange.length > 0 else { return }
|
|
1045
|
+
|
|
1046
|
+
var rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
|
|
1047
|
+
rect.origin.x += textContainerInset.left - contentOffset.x
|
|
1048
|
+
rect.origin.y += textContainerInset.top - contentOffset.y
|
|
1049
|
+
|
|
1050
|
+
if rect.insetBy(dx: -8, dy: -8).contains(location) {
|
|
1051
|
+
resolvedRange = range
|
|
1052
|
+
stop.pointee = true
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
return resolvedRange
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
func isPlaceholderVisibleForTesting() -> Bool {
|
|
1060
|
+
!placeholderLabel.isHidden
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
func placeholderFrameForTesting() -> CGRect {
|
|
1064
|
+
placeholderLabel.frame
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
func blockquoteStripeRectsForTesting() -> [CGRect] {
|
|
1068
|
+
editorLayoutManager.blockquoteStripeRectsForTesting(in: textStorage)
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
func resetBlockquoteStripeDrawPassesForTesting() {
|
|
1072
|
+
editorLayoutManager.resetBlockquoteStripeDrawPassesForTesting()
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
func blockquoteStripeDrawPassesForTesting() -> [[CGRect]] {
|
|
1076
|
+
editorLayoutManager.blockquoteStripeDrawPassesForTesting
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
@discardableResult
|
|
1080
|
+
func selectImageAttachmentForTesting(at location: CGPoint) -> Bool {
|
|
1081
|
+
selectImageAttachmentIfNeeded(at: location)
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
func imageSelectionTapWouldHandleForTesting(at location: CGPoint) -> Bool {
|
|
1085
|
+
imageAttachmentRange(at: location) != nil
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
func imageSelectionTapCancelsTouchesForTesting() -> Bool {
|
|
1089
|
+
imageSelectionTapRecognizer.cancelsTouchesInView
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
func imageSelectionTapYieldsToDefaultTapForTesting() -> Bool {
|
|
1093
|
+
gestureRecognizer(
|
|
1094
|
+
imageSelectionTapRecognizer,
|
|
1095
|
+
shouldBeRequiredToFailBy: UITapGestureRecognizer()
|
|
1096
|
+
) || gestureRecognizer(
|
|
1097
|
+
imageSelectionTapRecognizer,
|
|
1098
|
+
shouldRequireFailureOf: UITapGestureRecognizer()
|
|
1099
|
+
)
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
|
1103
|
+
guard gestureRecognizer === imageSelectionTapRecognizer,
|
|
1104
|
+
touch.tapCount == 1
|
|
1105
|
+
else {
|
|
1106
|
+
return true
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
return imageAttachmentRange(at: touch.location(in: self)) != nil
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
func gestureRecognizer(
|
|
1113
|
+
_ gestureRecognizer: UIGestureRecognizer,
|
|
1114
|
+
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
|
|
1115
|
+
) -> Bool {
|
|
1116
|
+
false
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
func gestureRecognizer(
|
|
1120
|
+
_ gestureRecognizer: UIGestureRecognizer,
|
|
1121
|
+
shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer
|
|
1122
|
+
) -> Bool {
|
|
1123
|
+
false
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
func gestureRecognizer(
|
|
1127
|
+
_ gestureRecognizer: UIGestureRecognizer,
|
|
1128
|
+
shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer
|
|
1129
|
+
) -> Bool {
|
|
1130
|
+
false
|
|
233
1131
|
}
|
|
234
1132
|
|
|
235
1133
|
override func caretRect(for position: UITextPosition) -> CGRect {
|
|
236
|
-
|
|
1134
|
+
if hidesNativeSelectionChrome {
|
|
1135
|
+
return .zero
|
|
1136
|
+
}
|
|
1137
|
+
let rect = resolvedCaretReferenceRect(for: position)
|
|
237
1138
|
guard rect.height > 0 else { return rect }
|
|
238
1139
|
|
|
239
1140
|
let caretFont = resolvedCaretFont(for: position)
|
|
@@ -257,6 +1158,45 @@ final class EditorTextView: UITextView, UITextViewDelegate {
|
|
|
257
1158
|
)
|
|
258
1159
|
}
|
|
259
1160
|
|
|
1161
|
+
private func resolvedCaretReferenceRect(for position: UITextPosition) -> CGRect {
|
|
1162
|
+
let directRect = super.caretRect(for: position)
|
|
1163
|
+
guard directRect.height <= 0 || directRect.isEmpty else {
|
|
1164
|
+
return directRect
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
let caretWidth = max(directRect.width, 2)
|
|
1168
|
+
|
|
1169
|
+
if let nextPosition = self.position(from: position, offset: 1),
|
|
1170
|
+
let nextRange = textRange(from: position, to: nextPosition),
|
|
1171
|
+
let nextRect = selectionRects(for: nextRange)
|
|
1172
|
+
.map(\.rect)
|
|
1173
|
+
.first(where: { !$0.isEmpty && $0.width > 0 && $0.height > 0 })
|
|
1174
|
+
{
|
|
1175
|
+
return CGRect(
|
|
1176
|
+
x: nextRect.minX,
|
|
1177
|
+
y: nextRect.minY,
|
|
1178
|
+
width: caretWidth,
|
|
1179
|
+
height: max(directRect.height, nextRect.height)
|
|
1180
|
+
)
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
if let previousPosition = self.position(from: position, offset: -1),
|
|
1184
|
+
let previousRange = textRange(from: previousPosition, to: position),
|
|
1185
|
+
let previousRect = selectionRects(for: previousRange)
|
|
1186
|
+
.map(\.rect)
|
|
1187
|
+
.last(where: { !$0.isEmpty && $0.width > 0 && $0.height > 0 })
|
|
1188
|
+
{
|
|
1189
|
+
return CGRect(
|
|
1190
|
+
x: previousRect.maxX,
|
|
1191
|
+
y: previousRect.minY,
|
|
1192
|
+
width: caretWidth,
|
|
1193
|
+
height: max(directRect.height, previousRect.height)
|
|
1194
|
+
)
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
return directRect
|
|
1198
|
+
}
|
|
1199
|
+
|
|
260
1200
|
// MARK: - Editor Binding
|
|
261
1201
|
|
|
262
1202
|
/// Bind this text view to a Rust editor instance and apply initial content.
|
|
@@ -411,7 +1351,7 @@ final class EditorTextView: UITextView, UITextViewDelegate {
|
|
|
411
1351
|
return
|
|
412
1352
|
}
|
|
413
1353
|
|
|
414
|
-
if let deleteRange =
|
|
1354
|
+
if let deleteRange = trailingVoidBlockDeleteRangeForBackwardDelete(
|
|
415
1355
|
cursorUtf16Offset: cursorUtf16Offset
|
|
416
1356
|
) {
|
|
417
1357
|
performInterceptedInput {
|
|
@@ -462,7 +1402,7 @@ final class EditorTextView: UITextView, UITextViewDelegate {
|
|
|
462
1402
|
return (from: cursorScalar, to: cursorScalar + 1)
|
|
463
1403
|
}
|
|
464
1404
|
|
|
465
|
-
private func
|
|
1405
|
+
private func trailingVoidBlockDeleteRangeForBackwardDelete(
|
|
466
1406
|
cursorUtf16Offset: Int
|
|
467
1407
|
) -> (from: UInt32, to: UInt32)? {
|
|
468
1408
|
let text = textStorage.string as NSString
|
|
@@ -483,19 +1423,34 @@ final class EditorTextView: UITextView, UITextViewDelegate {
|
|
|
483
1423
|
guard text.character(at: paragraphRange.location - 1) == 0x000A else { return nil }
|
|
484
1424
|
|
|
485
1425
|
let attachmentIndex = paragraphRange.location - 2
|
|
486
|
-
|
|
1426
|
+
guard
|
|
1427
|
+
let deleteRange = scalarDeleteRangeForVoidAttachment(at: attachmentIndex)
|
|
1428
|
+
else {
|
|
1429
|
+
return nil
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
return deleteRange
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
private func scalarDeleteRangeForVoidAttachment(
|
|
1436
|
+
at utf16Offset: Int
|
|
1437
|
+
) -> (from: UInt32, to: UInt32)? {
|
|
1438
|
+
guard utf16Offset >= 0, utf16Offset < textStorage.length else {
|
|
1439
|
+
return nil
|
|
1440
|
+
}
|
|
1441
|
+
let attrs = textStorage.attributes(at: utf16Offset, effectiveRange: nil)
|
|
487
1442
|
guard attrs[.attachment] is NSTextAttachment,
|
|
488
|
-
attrs[RenderBridgeAttributes.voidNodeType] as? String
|
|
1443
|
+
attrs[RenderBridgeAttributes.voidNodeType] as? String != nil
|
|
489
1444
|
else {
|
|
490
1445
|
return nil
|
|
491
1446
|
}
|
|
492
1447
|
|
|
493
|
-
let
|
|
494
|
-
|
|
1448
|
+
let attachmentEndScalar = PositionBridge.utf16OffsetToScalar(
|
|
1449
|
+
utf16Offset + 1,
|
|
495
1450
|
in: self
|
|
496
1451
|
)
|
|
497
|
-
guard
|
|
498
|
-
return (from:
|
|
1452
|
+
guard attachmentEndScalar > 0 else { return nil }
|
|
1453
|
+
return (from: attachmentEndScalar - 1, to: attachmentEndScalar)
|
|
499
1454
|
}
|
|
500
1455
|
|
|
501
1456
|
private func handleListDepthKeyCommand(outdent: Bool) {
|
|
@@ -684,9 +1639,48 @@ final class EditorTextView: UITextView, UITextViewDelegate {
|
|
|
684
1639
|
/// internally during tap handling and word-boundary resolution.
|
|
685
1640
|
func textViewDidChangeSelection(_ textView: UITextView) {
|
|
686
1641
|
guard textView === self else { return }
|
|
1642
|
+
refreshNativeSelectionChromeVisibility()
|
|
1643
|
+
onSelectionOrContentMayChange?()
|
|
687
1644
|
scheduleSelectionSync()
|
|
688
1645
|
}
|
|
689
1646
|
|
|
1647
|
+
func textView(
|
|
1648
|
+
_ textView: UITextView,
|
|
1649
|
+
shouldInteractWith URL: URL,
|
|
1650
|
+
in characterRange: NSRange,
|
|
1651
|
+
interaction: UITextItemInteraction
|
|
1652
|
+
) -> Bool {
|
|
1653
|
+
return false
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
func textView(
|
|
1657
|
+
_ textView: UITextView,
|
|
1658
|
+
shouldInteractWith textAttachment: NSTextAttachment,
|
|
1659
|
+
in characterRange: NSRange,
|
|
1660
|
+
interaction: UITextItemInteraction
|
|
1661
|
+
) -> Bool {
|
|
1662
|
+
guard textView === self,
|
|
1663
|
+
characterRange.location >= 0,
|
|
1664
|
+
characterRange.location < textStorage.length
|
|
1665
|
+
else {
|
|
1666
|
+
return false
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
let attrs = textStorage.attributes(at: characterRange.location, effectiveRange: nil)
|
|
1670
|
+
guard (attrs[RenderBridgeAttributes.voidNodeType] as? String) == "image",
|
|
1671
|
+
let start = position(from: beginningOfDocument, offset: characterRange.location),
|
|
1672
|
+
let end = position(from: start, offset: characterRange.length)
|
|
1673
|
+
else {
|
|
1674
|
+
return false
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
selectedTextRange = textRange(from: start, to: end)
|
|
1678
|
+
refreshNativeSelectionChromeVisibility()
|
|
1679
|
+
onSelectionOrContentMayChange?()
|
|
1680
|
+
scheduleSelectionSync()
|
|
1681
|
+
return false
|
|
1682
|
+
}
|
|
1683
|
+
|
|
690
1684
|
// MARK: - Private: Rust Integration
|
|
691
1685
|
|
|
692
1686
|
private var isInterceptingInput: Bool {
|
|
@@ -775,6 +1769,25 @@ final class EditorTextView: UITextView, UITextViewDelegate {
|
|
|
775
1769
|
typingAttributes = attrs
|
|
776
1770
|
}
|
|
777
1771
|
|
|
1772
|
+
private func setNativeSelectionChromeHidden(_ hidden: Bool) {
|
|
1773
|
+
guard hidesNativeSelectionChrome != hidden else { return }
|
|
1774
|
+
hidesNativeSelectionChrome = hidden
|
|
1775
|
+
super.tintColor = hidden ? .clear : visibleSelectionTintColor
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
private func refreshNativeSelectionChromeVisibility() {
|
|
1779
|
+
let hidden = selectedImageGeometry() != nil
|
|
1780
|
+
if !hidden, tintColor.cgColor.alpha > 0 {
|
|
1781
|
+
visibleSelectionTintColor = tintColor
|
|
1782
|
+
}
|
|
1783
|
+
setNativeSelectionChromeHidden(hidden)
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
func refreshSelectionVisualState() {
|
|
1787
|
+
refreshNativeSelectionChromeVisibility()
|
|
1788
|
+
onSelectionOrContentMayChange?()
|
|
1789
|
+
}
|
|
1790
|
+
|
|
778
1791
|
private func scheduleSelectionSync() {
|
|
779
1792
|
pendingSelectionSyncGeneration &+= 1
|
|
780
1793
|
let generation = pendingSelectionSyncGeneration
|
|
@@ -933,6 +1946,11 @@ final class EditorTextView: UITextView, UITextViewDelegate {
|
|
|
933
1946
|
|
|
934
1947
|
let rawOffset = offset(from: beginningOfDocument, to: position)
|
|
935
1948
|
let clampedOffset = min(max(rawOffset, 0), textStorage.length)
|
|
1949
|
+
|
|
1950
|
+
if let hardBreakBaselineY = hardBreakBaselineY(after: clampedOffset) {
|
|
1951
|
+
return hardBreakBaselineY
|
|
1952
|
+
}
|
|
1953
|
+
|
|
936
1954
|
var candidateCharacters = Set<Int>()
|
|
937
1955
|
|
|
938
1956
|
if clampedOffset < textStorage.length {
|
|
@@ -974,6 +1992,41 @@ final class EditorTextView: UITextView, UITextViewDelegate {
|
|
|
974
1992
|
return bestMatch?.baselineY
|
|
975
1993
|
}
|
|
976
1994
|
|
|
1995
|
+
private func hardBreakBaselineY(after utf16Offset: Int) -> CGFloat? {
|
|
1996
|
+
guard utf16Offset > 0, utf16Offset <= textStorage.length else { return nil }
|
|
1997
|
+
let previousVoidType = textStorage.attribute(
|
|
1998
|
+
RenderBridgeAttributes.voidNodeType,
|
|
1999
|
+
at: utf16Offset - 1,
|
|
2000
|
+
effectiveRange: nil
|
|
2001
|
+
) as? String
|
|
2002
|
+
guard previousVoidType == "hardBreak" else { return nil }
|
|
2003
|
+
|
|
2004
|
+
let previousGlyphIndex = layoutManager.glyphIndexForCharacter(at: utf16Offset - 1)
|
|
2005
|
+
guard previousGlyphIndex < layoutManager.numberOfGlyphs else { return nil }
|
|
2006
|
+
|
|
2007
|
+
let lineFragmentRect = layoutManager.lineFragmentRect(
|
|
2008
|
+
forGlyphAt: previousGlyphIndex,
|
|
2009
|
+
effectiveRange: nil
|
|
2010
|
+
)
|
|
2011
|
+
let glyphLocation = layoutManager.location(forGlyphAt: previousGlyphIndex)
|
|
2012
|
+
let previousBaselineY = textContainerInset.top + lineFragmentRect.minY + glyphLocation.y
|
|
2013
|
+
|
|
2014
|
+
let paragraphStyle = textStorage.attribute(
|
|
2015
|
+
.paragraphStyle,
|
|
2016
|
+
at: utf16Offset - 1,
|
|
2017
|
+
effectiveRange: nil
|
|
2018
|
+
) as? NSParagraphStyle
|
|
2019
|
+
let configuredLineHeight = max(
|
|
2020
|
+
paragraphStyle?.minimumLineHeight ?? 0,
|
|
2021
|
+
paragraphStyle?.maximumLineHeight ?? 0
|
|
2022
|
+
)
|
|
2023
|
+
let lineAdvance = configuredLineHeight > 0
|
|
2024
|
+
? configuredLineHeight
|
|
2025
|
+
: lineFragmentRect.height
|
|
2026
|
+
|
|
2027
|
+
return previousBaselineY + lineAdvance
|
|
2028
|
+
}
|
|
2029
|
+
|
|
977
2030
|
private func resolvedCaretFont(for position: UITextPosition) -> UIFont {
|
|
978
2031
|
guard textStorage.length > 0 else { return resolvedDefaultFont() }
|
|
979
2032
|
|
|
@@ -1027,6 +2080,20 @@ final class EditorTextView: UITextView, UITextViewDelegate {
|
|
|
1027
2080
|
}
|
|
1028
2081
|
}
|
|
1029
2082
|
|
|
2083
|
+
func performToolbarToggleBlockquote() {
|
|
2084
|
+
guard editorId != 0 else { return }
|
|
2085
|
+
guard isEditable else { return }
|
|
2086
|
+
guard let selection = currentScalarSelection() else { return }
|
|
2087
|
+
performInterceptedInput {
|
|
2088
|
+
let updateJSON = editorToggleBlockquoteAtSelectionScalar(
|
|
2089
|
+
id: editorId,
|
|
2090
|
+
scalarAnchor: selection.anchor,
|
|
2091
|
+
scalarHead: selection.head
|
|
2092
|
+
)
|
|
2093
|
+
applyUpdateJSON(updateJSON)
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
|
|
1030
2097
|
func performToolbarIndentListItem() {
|
|
1031
2098
|
guard editorId != 0 else { return }
|
|
1032
2099
|
guard isEditable else { return }
|
|
@@ -1130,6 +2197,104 @@ final class EditorTextView: UITextView, UITextViewDelegate {
|
|
|
1130
2197
|
return (anchor: scalarRange.from, head: scalarRange.to)
|
|
1131
2198
|
}
|
|
1132
2199
|
|
|
2200
|
+
func selectedImageGeometry() -> (docPos: UInt32, rect: CGRect)? {
|
|
2201
|
+
guard allowImageResizing else { return nil }
|
|
2202
|
+
guard isFirstResponder else { return nil }
|
|
2203
|
+
guard let selectedRange = selectedTextRange else { return nil }
|
|
2204
|
+
|
|
2205
|
+
let startOffset = offset(from: beginningOfDocument, to: selectedRange.start)
|
|
2206
|
+
let endOffset = offset(from: beginningOfDocument, to: selectedRange.end)
|
|
2207
|
+
guard endOffset == startOffset + 1, startOffset >= 0, startOffset < textStorage.length else {
|
|
2208
|
+
return nil
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
let attrs = textStorage.attributes(at: startOffset, effectiveRange: nil)
|
|
2212
|
+
guard (attrs[RenderBridgeAttributes.voidNodeType] as? String) == "image",
|
|
2213
|
+
attrs[.attachment] is NSTextAttachment
|
|
2214
|
+
else {
|
|
2215
|
+
return nil
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
let docPos: UInt32
|
|
2219
|
+
if let number = attrs[RenderBridgeAttributes.docPos] as? NSNumber {
|
|
2220
|
+
docPos = number.uint32Value
|
|
2221
|
+
} else if let value = attrs[RenderBridgeAttributes.docPos] as? UInt32 {
|
|
2222
|
+
docPos = value
|
|
2223
|
+
} else {
|
|
2224
|
+
return nil
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
let glyphRange = layoutManager.glyphRange(
|
|
2228
|
+
forCharacterRange: NSRange(location: startOffset, length: 1),
|
|
2229
|
+
actualCharacterRange: nil
|
|
2230
|
+
)
|
|
2231
|
+
guard glyphRange.length > 0 else { return nil }
|
|
2232
|
+
|
|
2233
|
+
var rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
|
|
2234
|
+
rect.origin.x += textContainerInset.left
|
|
2235
|
+
rect.origin.y += textContainerInset.top
|
|
2236
|
+
guard rect.width > 0, rect.height > 0 else { return nil }
|
|
2237
|
+
return (docPos, rect)
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
private func blockImageAttachment(docPos: UInt32) -> (range: NSRange, attachment: BlockImageAttachment)? {
|
|
2241
|
+
let fullRange = NSRange(location: 0, length: textStorage.length)
|
|
2242
|
+
var resolved: (range: NSRange, attachment: BlockImageAttachment)?
|
|
2243
|
+
textStorage.enumerateAttribute(.attachment, in: fullRange) { value, range, stop in
|
|
2244
|
+
guard let attachment = value as? BlockImageAttachment, range.length > 0 else { return }
|
|
2245
|
+
let attrs = textStorage.attributes(at: range.location, effectiveRange: nil)
|
|
2246
|
+
guard (attrs[RenderBridgeAttributes.voidNodeType] as? String) == "image" else { return }
|
|
2247
|
+
let attributeDocPos = (attrs[RenderBridgeAttributes.docPos] as? NSNumber)?.uint32Value
|
|
2248
|
+
?? (attrs[RenderBridgeAttributes.docPos] as? UInt32)
|
|
2249
|
+
guard attributeDocPos == docPos else { return }
|
|
2250
|
+
resolved = (range, attachment)
|
|
2251
|
+
stop.pointee = true
|
|
2252
|
+
}
|
|
2253
|
+
return resolved
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
func imagePreviewForDocPos(_ docPos: UInt32) -> UIImage? {
|
|
2257
|
+
blockImageAttachment(docPos: docPos)?.attachment.previewImage()
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
func maximumRenderableImageWidth() -> CGFloat {
|
|
2261
|
+
let containerWidth: CGFloat
|
|
2262
|
+
if bounds.width > 0 {
|
|
2263
|
+
containerWidth = bounds.width - textContainerInset.left - textContainerInset.right
|
|
2264
|
+
} else {
|
|
2265
|
+
containerWidth = textContainer.size.width
|
|
2266
|
+
}
|
|
2267
|
+
let linePadding = textContainer.lineFragmentPadding * 2
|
|
2268
|
+
return max(48, containerWidth - linePadding)
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
func resizeImageAtDocPos(_ docPos: UInt32, width: UInt32, height: UInt32) {
|
|
2272
|
+
guard editorId != 0 else { return }
|
|
2273
|
+
performInterceptedInput {
|
|
2274
|
+
let updateJSON = editorResizeImageAtDocPos(
|
|
2275
|
+
id: editorId,
|
|
2276
|
+
docPos: docPos,
|
|
2277
|
+
width: width,
|
|
2278
|
+
height: height
|
|
2279
|
+
)
|
|
2280
|
+
applyUpdateJSON(updateJSON)
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
func previewResizeImageAtDocPos(_ docPos: UInt32, width: CGFloat, height: CGFloat) {
|
|
2285
|
+
guard let attachmentState = blockImageAttachment(docPos: docPos) else { return }
|
|
2286
|
+
attachmentState.attachment.setPreferredSize(width: width, height: height)
|
|
2287
|
+
layoutManager.invalidateLayout(forCharacterRange: attachmentState.range, actualCharacterRange: nil)
|
|
2288
|
+
layoutManager.invalidateDisplay(forCharacterRange: attachmentState.range)
|
|
2289
|
+
textStorage.beginEditing()
|
|
2290
|
+
textStorage.edited(.editedAttributes, range: attachmentState.range, changeInLength: 0)
|
|
2291
|
+
textStorage.endEditing()
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
func setImageResizePreviewActive(_ active: Bool) {
|
|
2295
|
+
isPreviewingImageResize = active
|
|
2296
|
+
}
|
|
2297
|
+
|
|
1133
2298
|
/// Handle return key press as a block split operation.
|
|
1134
2299
|
private func handleReturnKey() {
|
|
1135
2300
|
// If there's a range selection, atomically delete and split.
|
|
@@ -1224,7 +2389,7 @@ final class EditorTextView: UITextView, UITextViewDelegate {
|
|
|
1224
2389
|
lastAuthorizedText = textStorage.string
|
|
1225
2390
|
isApplyingRustState = false
|
|
1226
2391
|
|
|
1227
|
-
|
|
2392
|
+
refreshPlaceholderVisibility()
|
|
1228
2393
|
Self.updateLog.debug(
|
|
1229
2394
|
"[applyUpdateJSON.rendered] after=\(self.textSnapshotSummary(), privacy: .public)"
|
|
1230
2395
|
)
|
|
@@ -1237,6 +2402,7 @@ final class EditorTextView: UITextView, UITextViewDelegate {
|
|
|
1237
2402
|
if heightBehavior == .autoGrow {
|
|
1238
2403
|
notifyHeightChangeIfNeeded(force: true)
|
|
1239
2404
|
}
|
|
2405
|
+
onSelectionOrContentMayChange?()
|
|
1240
2406
|
|
|
1241
2407
|
Self.updateLog.debug(
|
|
1242
2408
|
"[applyUpdateJSON.end] finalSelection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
|
|
@@ -1270,11 +2436,12 @@ final class EditorTextView: UITextView, UITextViewDelegate {
|
|
|
1270
2436
|
lastAuthorizedText = textStorage.string
|
|
1271
2437
|
isApplyingRustState = false
|
|
1272
2438
|
|
|
1273
|
-
|
|
2439
|
+
refreshPlaceholderVisibility()
|
|
1274
2440
|
refreshTypingAttributesForSelection()
|
|
1275
2441
|
if heightBehavior == .autoGrow {
|
|
1276
2442
|
notifyHeightChangeIfNeeded(force: true)
|
|
1277
2443
|
}
|
|
2444
|
+
onSelectionOrContentMayChange?()
|
|
1278
2445
|
Self.updateLog.debug(
|
|
1279
2446
|
"[applyRenderJSON.end] after=\(self.textSnapshotSummary(), privacy: .public)"
|
|
1280
2447
|
)
|
|
@@ -1306,6 +2473,7 @@ final class EditorTextView: UITextView, UITextViewDelegate {
|
|
|
1306
2473
|
let startPos = PositionBridge.scalarToTextView(min(anchorScalar, headScalar), in: self)
|
|
1307
2474
|
let endPos = PositionBridge.scalarToTextView(max(anchorScalar, headScalar), in: self)
|
|
1308
2475
|
selectedTextRange = textRange(from: startPos, to: endPos)
|
|
2476
|
+
refreshNativeSelectionChromeVisibility()
|
|
1309
2477
|
Self.selectionLog.debug(
|
|
1310
2478
|
"[applySelectionFromJSON.text] doc=\(anchorNum.uint32Value)-\(headNum.uint32Value) scalar=\(anchorScalar)-\(headScalar) final=\(self.selectionSummary(), privacy: .public)"
|
|
1311
2479
|
)
|
|
@@ -1320,12 +2488,14 @@ final class EditorTextView: UITextView, UITextViewDelegate {
|
|
|
1320
2488
|
if let endPos = position(from: startPos, offset: 1) {
|
|
1321
2489
|
selectedTextRange = textRange(from: startPos, to: endPos)
|
|
1322
2490
|
}
|
|
2491
|
+
refreshNativeSelectionChromeVisibility()
|
|
1323
2492
|
Self.selectionLog.debug(
|
|
1324
2493
|
"[applySelectionFromJSON.node] doc=\(posNum.uint32Value) scalar=\(posScalar) final=\(self.selectionSummary(), privacy: .public)"
|
|
1325
2494
|
)
|
|
1326
2495
|
|
|
1327
2496
|
case "all":
|
|
1328
2497
|
selectedTextRange = textRange(from: beginningOfDocument, to: endOfDocument)
|
|
2498
|
+
refreshNativeSelectionChromeVisibility()
|
|
1329
2499
|
Self.selectionLog.debug(
|
|
1330
2500
|
"[applySelectionFromJSON.all] final=\(self.selectionSummary(), privacy: .public)"
|
|
1331
2501
|
)
|
|
@@ -1334,6 +2504,7 @@ final class EditorTextView: UITextView, UITextViewDelegate {
|
|
|
1334
2504
|
break
|
|
1335
2505
|
}
|
|
1336
2506
|
}
|
|
2507
|
+
|
|
1337
2508
|
}
|
|
1338
2509
|
|
|
1339
2510
|
// MARK: - EditorTextView + NSTextStorageDelegate (Reconciliation Fallback)
|
|
@@ -1423,8 +2594,21 @@ final class RichTextEditorView: UIView {
|
|
|
1423
2594
|
|
|
1424
2595
|
/// The editor text view that handles input interception.
|
|
1425
2596
|
let textView: EditorTextView
|
|
2597
|
+
private let remoteSelectionOverlayView = RemoteSelectionOverlayView()
|
|
2598
|
+
private let imageTapOverlayView = ImageTapOverlayView()
|
|
2599
|
+
private let imageResizeOverlayView = ImageResizeOverlayView()
|
|
1426
2600
|
var onHeightMayChange: (() -> Void)?
|
|
1427
2601
|
private var lastAutoGrowWidth: CGFloat = 0
|
|
2602
|
+
private var remoteSelections: [RemoteSelectionDecoration] = []
|
|
2603
|
+
var allowImageResizing = true {
|
|
2604
|
+
didSet {
|
|
2605
|
+
guard oldValue != allowImageResizing else { return }
|
|
2606
|
+
textView.allowImageResizing = allowImageResizing
|
|
2607
|
+
textView.refreshSelectionVisualState()
|
|
2608
|
+
imageTapOverlayView.isHidden = editorId == 0 || !allowImageResizing
|
|
2609
|
+
imageResizeOverlayView.refresh()
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
1428
2612
|
|
|
1429
2613
|
var heightBehavior: EditorHeightBehavior = .fixed {
|
|
1430
2614
|
didSet {
|
|
@@ -1433,6 +2617,8 @@ final class RichTextEditorView: UIView {
|
|
|
1433
2617
|
invalidateIntrinsicContentSize()
|
|
1434
2618
|
setNeedsLayout()
|
|
1435
2619
|
onHeightMayChange?()
|
|
2620
|
+
remoteSelectionOverlayView.refresh()
|
|
2621
|
+
imageResizeOverlayView.refresh()
|
|
1436
2622
|
}
|
|
1437
2623
|
}
|
|
1438
2624
|
|
|
@@ -1444,6 +2630,12 @@ final class RichTextEditorView: UIView {
|
|
|
1444
2630
|
} else {
|
|
1445
2631
|
textView.unbindEditor()
|
|
1446
2632
|
}
|
|
2633
|
+
remoteSelectionOverlayView.update(
|
|
2634
|
+
selections: remoteSelections,
|
|
2635
|
+
editorId: editorId
|
|
2636
|
+
)
|
|
2637
|
+
imageTapOverlayView.isHidden = editorId == 0 || !allowImageResizing
|
|
2638
|
+
imageResizeOverlayView.refresh()
|
|
1447
2639
|
}
|
|
1448
2640
|
}
|
|
1449
2641
|
|
|
@@ -1464,19 +2656,48 @@ final class RichTextEditorView: UIView {
|
|
|
1464
2656
|
private func setupView() {
|
|
1465
2657
|
// Add the text view as a subview.
|
|
1466
2658
|
textView.translatesAutoresizingMaskIntoConstraints = false
|
|
2659
|
+
remoteSelectionOverlayView.translatesAutoresizingMaskIntoConstraints = false
|
|
2660
|
+
imageTapOverlayView.translatesAutoresizingMaskIntoConstraints = false
|
|
2661
|
+
imageResizeOverlayView.translatesAutoresizingMaskIntoConstraints = false
|
|
2662
|
+
remoteSelectionOverlayView.bind(textView: textView)
|
|
2663
|
+
imageTapOverlayView.bind(editorView: self)
|
|
2664
|
+
imageResizeOverlayView.bind(editorView: self)
|
|
2665
|
+
textView.allowImageResizing = allowImageResizing
|
|
2666
|
+
imageTapOverlayView.isHidden = editorId == 0 || !allowImageResizing
|
|
1467
2667
|
textView.onHeightMayChange = { [weak self] in
|
|
1468
2668
|
guard let self, self.heightBehavior == .autoGrow else { return }
|
|
1469
2669
|
self.invalidateIntrinsicContentSize()
|
|
1470
2670
|
self.superview?.setNeedsLayout()
|
|
1471
2671
|
self.onHeightMayChange?()
|
|
1472
2672
|
}
|
|
2673
|
+
textView.onViewportMayChange = { [weak self] in
|
|
2674
|
+
self?.refreshOverlays()
|
|
2675
|
+
}
|
|
2676
|
+
textView.onSelectionOrContentMayChange = { [weak self] in
|
|
2677
|
+
self?.refreshOverlays()
|
|
2678
|
+
}
|
|
1473
2679
|
addSubview(textView)
|
|
2680
|
+
addSubview(remoteSelectionOverlayView)
|
|
2681
|
+
addSubview(imageTapOverlayView)
|
|
2682
|
+
addSubview(imageResizeOverlayView)
|
|
1474
2683
|
|
|
1475
2684
|
NSLayoutConstraint.activate([
|
|
1476
2685
|
textView.topAnchor.constraint(equalTo: topAnchor),
|
|
1477
2686
|
textView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
1478
2687
|
textView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
1479
2688
|
textView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
2689
|
+
remoteSelectionOverlayView.topAnchor.constraint(equalTo: topAnchor),
|
|
2690
|
+
remoteSelectionOverlayView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
2691
|
+
remoteSelectionOverlayView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
2692
|
+
remoteSelectionOverlayView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
2693
|
+
imageTapOverlayView.topAnchor.constraint(equalTo: topAnchor),
|
|
2694
|
+
imageTapOverlayView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
2695
|
+
imageTapOverlayView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
2696
|
+
imageTapOverlayView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
2697
|
+
imageResizeOverlayView.topAnchor.constraint(equalTo: topAnchor),
|
|
2698
|
+
imageResizeOverlayView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
2699
|
+
imageResizeOverlayView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
2700
|
+
imageResizeOverlayView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
1480
2701
|
])
|
|
1481
2702
|
}
|
|
1482
2703
|
|
|
@@ -1494,6 +2715,7 @@ final class RichTextEditorView: UIView {
|
|
|
1494
2715
|
|
|
1495
2716
|
override func layoutSubviews() {
|
|
1496
2717
|
super.layoutSubviews()
|
|
2718
|
+
refreshOverlays()
|
|
1497
2719
|
guard heightBehavior == .autoGrow else { return }
|
|
1498
2720
|
let currentWidth = bounds.width.rounded(.towardZero)
|
|
1499
2721
|
guard currentWidth != lastAutoGrowWidth else { return }
|
|
@@ -1527,6 +2749,64 @@ final class RichTextEditorView: UIView {
|
|
|
1527
2749
|
let cornerRadius = theme?.borderRadius ?? 0
|
|
1528
2750
|
layer.cornerRadius = cornerRadius
|
|
1529
2751
|
clipsToBounds = cornerRadius > 0
|
|
2752
|
+
refreshOverlays()
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
func setRemoteSelections(_ selections: [RemoteSelectionDecoration]) {
|
|
2756
|
+
remoteSelections = selections
|
|
2757
|
+
remoteSelectionOverlayView.update(
|
|
2758
|
+
selections: selections,
|
|
2759
|
+
editorId: editorId
|
|
2760
|
+
)
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
func refreshRemoteSelections() {
|
|
2764
|
+
remoteSelectionOverlayView.refresh()
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
func remoteSelectionOverlaySubviewsForTesting() -> [UIView] {
|
|
2768
|
+
remoteSelectionOverlayView.subviews
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
func imageResizeOverlayRectForTesting() -> CGRect? {
|
|
2772
|
+
imageResizeOverlayView.visibleRectForTesting
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
func imageTapOverlayInterceptsPointForTesting(_ point: CGPoint) -> Bool {
|
|
2776
|
+
imageTapOverlayView.interceptsPointForTesting(convert(point, to: imageTapOverlayView))
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
@discardableResult
|
|
2780
|
+
func tapImageOverlayForTesting(at point: CGPoint) -> Bool {
|
|
2781
|
+
imageTapOverlayView.handleTapForTesting(convert(point, to: imageTapOverlayView))
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
func imageResizePreviewHasImageForTesting() -> Bool {
|
|
2785
|
+
imageResizeOverlayView.previewHasImageForTesting
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
func refreshSelectionVisualStateForTesting() {
|
|
2789
|
+
textView.refreshSelectionVisualState()
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
func imageResizeOverlayInterceptsPointForTesting(_ point: CGPoint) -> Bool {
|
|
2793
|
+
imageResizeOverlayView.interceptsPointForTesting(convert(point, to: imageResizeOverlayView))
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
func maximumImageWidthForTesting() -> CGFloat {
|
|
2797
|
+
textView.maximumRenderableImageWidth()
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
func resizeSelectedImageForTesting(width: CGFloat, height: CGFloat) {
|
|
2801
|
+
imageResizeOverlayView.simulateResizeForTesting(width: width, height: height)
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
func previewResizeSelectedImageForTesting(width: CGFloat, height: CGFloat) {
|
|
2805
|
+
imageResizeOverlayView.simulatePreviewResizeForTesting(width: width, height: height)
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
func commitPreviewResizeForTesting() {
|
|
2809
|
+
imageResizeOverlayView.commitPreviewResizeForTesting()
|
|
1530
2810
|
}
|
|
1531
2811
|
|
|
1532
2812
|
/// Set initial content from HTML.
|
|
@@ -1567,6 +2847,50 @@ final class RichTextEditorView: UIView {
|
|
|
1567
2847
|
return UIScreen.main.bounds.width
|
|
1568
2848
|
}
|
|
1569
2849
|
|
|
2850
|
+
fileprivate func selectedImageGeometry() -> (docPos: UInt32, rect: CGRect)? {
|
|
2851
|
+
guard let geometry = textView.selectedImageGeometry() else { return nil }
|
|
2852
|
+
return (
|
|
2853
|
+
docPos: geometry.docPos,
|
|
2854
|
+
rect: textView.convert(geometry.rect, to: imageResizeOverlayView)
|
|
2855
|
+
)
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
fileprivate func setImageResizePreviewActive(_ active: Bool) {
|
|
2859
|
+
textView.setImageResizePreviewActive(active)
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
fileprivate func imagePreviewForResize(docPos: UInt32) -> UIImage? {
|
|
2863
|
+
textView.imagePreviewForDocPos(docPos)
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
fileprivate func imageResizePreviewBackgroundColor() -> UIColor {
|
|
2867
|
+
textView.backgroundColor ?? .systemBackground
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
fileprivate func maximumImageWidthForResizeGesture() -> CGFloat {
|
|
2871
|
+
textView.maximumRenderableImageWidth()
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
fileprivate func clampedImageSize(_ size: CGSize, maximumWidth: CGFloat? = nil) -> CGSize {
|
|
2875
|
+
let aspectRatio = max(size.width / max(size.height, 1), 0.1)
|
|
2876
|
+
let maxWidth = max(48, maximumWidth ?? textView.maximumRenderableImageWidth())
|
|
2877
|
+
let clampedWidth = min(maxWidth, max(48, size.width))
|
|
2878
|
+
let clampedHeight = max(48, clampedWidth / aspectRatio)
|
|
2879
|
+
return CGSize(width: clampedWidth, height: clampedHeight)
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
fileprivate func resizeImage(docPos: UInt32, size: CGSize) {
|
|
2883
|
+
let clampedSize = clampedImageSize(size)
|
|
2884
|
+
let width = max(48, Int(clampedSize.width.rounded()))
|
|
2885
|
+
let height = max(48, Int(clampedSize.height.rounded()))
|
|
2886
|
+
textView.resizeImageAtDocPos(docPos, width: UInt32(width), height: UInt32(height))
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
private func refreshOverlays() {
|
|
2890
|
+
remoteSelectionOverlayView.refresh()
|
|
2891
|
+
imageResizeOverlayView.refresh()
|
|
2892
|
+
}
|
|
2893
|
+
|
|
1570
2894
|
// MARK: - Cleanup
|
|
1571
2895
|
|
|
1572
2896
|
deinit {
|