@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.
Files changed (53) hide show
  1. package/README.md +12 -7
  2. package/android/build.gradle +7 -2
  3. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +289 -2
  4. package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +51 -1
  5. package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +199 -0
  6. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +16 -3
  7. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +82 -1
  8. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +403 -45
  9. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +246 -0
  10. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +841 -155
  11. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +125 -8
  12. package/{src/EditorTheme.ts → dist/EditorTheme.d.ts} +12 -52
  13. package/dist/EditorTheme.js +29 -0
  14. package/dist/EditorToolbar.d.ts +129 -0
  15. package/dist/EditorToolbar.js +394 -0
  16. package/dist/NativeEditorBridge.d.ts +242 -0
  17. package/dist/NativeEditorBridge.js +647 -0
  18. package/dist/NativeRichTextEditor.d.ts +142 -0
  19. package/dist/NativeRichTextEditor.js +649 -0
  20. package/dist/YjsCollaboration.d.ts +83 -0
  21. package/dist/YjsCollaboration.js +585 -0
  22. package/dist/addons.d.ts +70 -0
  23. package/dist/addons.js +77 -0
  24. package/dist/index.d.ts +8 -0
  25. package/dist/index.js +26 -0
  26. package/dist/schemas.d.ts +35 -0
  27. package/{src/schemas.ts → dist/schemas.js} +62 -27
  28. package/dist/useNativeEditor.d.ts +40 -0
  29. package/dist/useNativeEditor.js +117 -0
  30. package/ios/EditorAddons.swift +26 -3
  31. package/ios/EditorCore.xcframework/Info.plist +5 -5
  32. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  33. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  34. package/ios/EditorLayoutManager.swift +236 -0
  35. package/ios/EditorTheme.swift +51 -1
  36. package/ios/Generated_editor_core.swift +270 -2
  37. package/ios/NativeEditorExpoView.swift +612 -45
  38. package/ios/NativeEditorModule.swift +81 -0
  39. package/ios/PositionBridge.swift +22 -0
  40. package/ios/RenderBridge.swift +427 -39
  41. package/ios/RichTextEditorView.swift +1342 -18
  42. package/ios/editor_coreFFI/editor_coreFFI.h +209 -0
  43. package/package.json +80 -64
  44. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  45. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  46. package/rust/android/x86_64/libeditor_core.so +0 -0
  47. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +404 -4
  48. package/src/EditorToolbar.tsx +0 -620
  49. package/src/NativeEditorBridge.ts +0 -607
  50. package/src/NativeRichTextEditor.tsx +0 -951
  51. package/src/addons.ts +0 -158
  52. package/src/index.ts +0 -63
  53. 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 { placeholderLabel.text = placeholder }
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: textContainerInset.left + textContainer.lineFragmentPadding,
226
- y: textContainerInset.top,
227
- width: bounds.width - textContainerInset.left - textContainerInset.right - 2 * textContainer.lineFragmentPadding,
228
- height: bounds.height - textContainerInset.top - textContainerInset.bottom
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
- let rect = super.caretRect(for: position)
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 = trailingHorizontalRuleDeleteRangeForBackwardDelete(
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 trailingHorizontalRuleDeleteRangeForBackwardDelete(
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
- let attrs = textStorage.attributes(at: attachmentIndex, effectiveRange: nil)
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 == "horizontalRule"
1443
+ attrs[RenderBridgeAttributes.voidNodeType] as? String != nil
489
1444
  else {
490
1445
  return nil
491
1446
  }
492
1447
 
493
- let placeholderEndScalar = PositionBridge.utf16OffsetToScalar(
494
- placeholderRange.location + placeholderRange.length,
1448
+ let attachmentEndScalar = PositionBridge.utf16OffsetToScalar(
1449
+ utf16Offset + 1,
495
1450
  in: self
496
1451
  )
497
- guard placeholderEndScalar > 0 else { return nil }
498
- return (from: placeholderEndScalar - 1, to: placeholderEndScalar)
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
- placeholderLabel.isHidden = !textStorage.string.isEmpty
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
- placeholderLabel.isHidden = !textStorage.string.isEmpty
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 {