@apollohg/react-native-prose-editor 0.2.0 → 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 (32) hide show
  1. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +228 -2
  2. package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +199 -0
  3. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +4 -0
  4. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +3 -0
  5. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +6 -0
  6. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +347 -10
  7. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +76 -8
  8. package/dist/EditorToolbar.d.ts +9 -2
  9. package/dist/EditorToolbar.js +20 -10
  10. package/dist/NativeEditorBridge.d.ts +2 -0
  11. package/dist/NativeEditorBridge.js +3 -0
  12. package/dist/NativeRichTextEditor.d.ts +17 -1
  13. package/dist/NativeRichTextEditor.js +94 -37
  14. package/dist/index.d.ts +2 -2
  15. package/dist/index.js +5 -1
  16. package/dist/schemas.d.ts +12 -0
  17. package/dist/schemas.js +45 -1
  18. package/ios/EditorCore.xcframework/Info.plist +5 -5
  19. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  20. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  21. package/ios/EditorLayoutManager.swift +0 -16
  22. package/ios/Generated_editor_core.swift +20 -2
  23. package/ios/NativeEditorExpoView.swift +51 -16
  24. package/ios/NativeEditorModule.swift +3 -0
  25. package/ios/RenderBridge.swift +208 -0
  26. package/ios/RichTextEditorView.swift +896 -15
  27. package/ios/editor_coreFFI/editor_coreFFI.h +11 -0
  28. package/package.json +1 -1
  29. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  30. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  31. package/rust/android/x86_64/libeditor_core.so +0 -0
  32. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +25 -2
@@ -253,6 +253,365 @@ private final class RemoteSelectionOverlayView: UIView {
253
253
  }
254
254
  }
255
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
+
256
615
  // MARK: - EditorTextView
257
616
 
258
617
  /// UITextView subclass that intercepts all text input and routes it through
@@ -280,7 +639,7 @@ private final class RemoteSelectionOverlayView: UIView {
280
639
  /// (`editor_insert_text`, `editor_delete_range`, etc.) are synchronous and
281
640
  /// fast enough for main-thread use. If profiling shows otherwise, we can
282
641
  /// dispatch to a serial queue and batch updates.
283
- final class EditorTextView: UITextView, UITextViewDelegate {
642
+ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerDelegate {
284
643
  private static let emptyBlockPlaceholderScalar = UnicodeScalar(0x200B)
285
644
 
286
645
  // MARK: - Properties
@@ -292,6 +651,10 @@ final class EditorTextView: UITextView, UITextViewDelegate {
292
651
  /// Guard flag to prevent re-entrant input interception while we're
293
652
  /// applying state from Rust (calling replaceCharacters on the text storage).
294
653
  var isApplyingRustState = false
654
+ private var visibleSelectionTintColor: UIColor = .systemBlue
655
+ private var hidesNativeSelectionChrome = false
656
+ private var isPreviewingImageResize = false
657
+ var allowImageResizing = true
295
658
 
296
659
  /// The base font used for unstyled text. Configurable from React props.
297
660
  var baseFont: UIFont = .systemFont(ofSize: 16)
@@ -333,6 +696,7 @@ final class EditorTextView: UITextView, UITextViewDelegate {
333
696
 
334
697
  var onHeightMayChange: (() -> Void)?
335
698
  var onViewportMayChange: (() -> Void)?
699
+ var onSelectionOrContentMayChange: (() -> Void)?
336
700
  private var lastAutoGrowMeasuredHeight: CGFloat = 0
337
701
 
338
702
  /// Delegate for editor events.
@@ -366,6 +730,14 @@ final class EditorTextView: UITextView, UITextViewDelegate {
366
730
 
367
731
  /// Tracks whether we're in a composition session (CJK / IME input).
368
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
+ }()
369
741
 
370
742
  /// Guards against reconciliation firing while we're already intercepting
371
743
  /// and replaying a user input operation through Rust, including the
@@ -376,6 +748,8 @@ final class EditorTextView: UITextView, UITextViewDelegate {
376
748
  /// Coalesces selection sync until UIKit has finished resolving the
377
749
  /// current tap/drag gesture's final caret position.
378
750
  private var pendingSelectionSyncGeneration: UInt64 = 0
751
+ private var pendingDeferredImageSelectionRange: NSRange?
752
+ private var pendingDeferredImageSelectionGeneration: UInt64 = 0
379
753
 
380
754
  /// Stores the text that was composed during a marked text session,
381
755
  /// captured when `unmarkText` is called.
@@ -422,6 +796,12 @@ final class EditorTextView: UITextView, UITextViewDelegate {
422
796
  private func commonInit() {
423
797
  textContainer.widthTracksTextView = true
424
798
  editorLayoutManager.allowsNonContiguousLayout = false
799
+ NotificationCenter.default.addObserver(
800
+ self,
801
+ selector: #selector(handleImageAttachmentDidLoad(_:)),
802
+ name: .editorImageAttachmentDidLoad,
803
+ object: nil
804
+ )
425
805
 
426
806
  // Configure the text view as a Rust-controlled editor surface.
427
807
  // UIKit smart-edit features mutate text storage outside our transaction
@@ -443,20 +823,64 @@ final class EditorTextView: UITextView, UITextViewDelegate {
443
823
  textColor = baseTextColor
444
824
  backgroundColor = baseBackgroundColor
445
825
  baseTextContainerInset = textContainerInset
826
+ visibleSelectionTintColor = tintColor
446
827
 
447
828
  // Register as the text storage delegate so we can detect unauthorized
448
829
  // mutations (reconciliation fallback).
449
830
  textStorage.delegate = self
450
831
  delegate = self
832
+ addGestureRecognizer(imageSelectionTapRecognizer)
833
+ installImageSelectionTapDependencies()
451
834
 
452
835
  addSubview(placeholderLabel)
453
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)
454
877
  }
455
878
 
456
879
  // MARK: - Layout
457
880
 
458
881
  override func layoutSubviews() {
459
882
  super.layoutSubviews()
883
+ installImageSelectionTapDependencies()
460
884
  let placeholderX = textContainerInset.left + textContainer.lineFragmentPadding
461
885
  let placeholderY = textContainerInset.top
462
886
  let placeholderWidth = max(
@@ -476,15 +900,23 @@ final class EditorTextView: UITextView, UITextViewDelegate {
476
900
  width: placeholderWidth,
477
901
  height: min(maxPlaceholderHeight, ceil(fittedHeight))
478
902
  )
479
- if heightBehavior == .autoGrow {
903
+ if heightBehavior == .autoGrow, !isPreviewingImageResize {
480
904
  notifyHeightChangeIfNeeded()
481
905
  }
482
- onViewportMayChange?()
906
+ if !isPreviewingImageResize {
907
+ onViewportMayChange?()
908
+ }
909
+ }
910
+
911
+ deinit {
912
+ NotificationCenter.default.removeObserver(self)
483
913
  }
484
914
 
485
915
  override var contentOffset: CGPoint {
486
916
  didSet {
487
- onViewportMayChange?()
917
+ if !isPreviewingImageResize {
918
+ onViewportMayChange?()
919
+ }
488
920
  }
489
921
  }
490
922
 
@@ -508,6 +940,122 @@ final class EditorTextView: UITextView, UITextViewDelegate {
508
940
  placeholderLabel.isHidden = placeholder.isEmpty || !isRenderedContentEmpty()
509
941
  }
510
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
+
511
1059
  func isPlaceholderVisibleForTesting() -> Bool {
512
1060
  !placeholderLabel.isHidden
513
1061
  }
@@ -528,7 +1076,64 @@ final class EditorTextView: UITextView, UITextViewDelegate {
528
1076
  editorLayoutManager.blockquoteStripeDrawPassesForTesting
529
1077
  }
530
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
1131
+ }
1132
+
531
1133
  override func caretRect(for position: UITextPosition) -> CGRect {
1134
+ if hidesNativeSelectionChrome {
1135
+ return .zero
1136
+ }
532
1137
  let rect = resolvedCaretReferenceRect(for: position)
533
1138
  guard rect.height > 0 else { return rect }
534
1139
 
@@ -746,7 +1351,7 @@ final class EditorTextView: UITextView, UITextViewDelegate {
746
1351
  return
747
1352
  }
748
1353
 
749
- if let deleteRange = trailingHorizontalRuleDeleteRangeForBackwardDelete(
1354
+ if let deleteRange = trailingVoidBlockDeleteRangeForBackwardDelete(
750
1355
  cursorUtf16Offset: cursorUtf16Offset
751
1356
  ) {
752
1357
  performInterceptedInput {
@@ -797,7 +1402,7 @@ final class EditorTextView: UITextView, UITextViewDelegate {
797
1402
  return (from: cursorScalar, to: cursorScalar + 1)
798
1403
  }
799
1404
 
800
- private func trailingHorizontalRuleDeleteRangeForBackwardDelete(
1405
+ private func trailingVoidBlockDeleteRangeForBackwardDelete(
801
1406
  cursorUtf16Offset: Int
802
1407
  ) -> (from: UInt32, to: UInt32)? {
803
1408
  let text = textStorage.string as NSString
@@ -818,19 +1423,34 @@ final class EditorTextView: UITextView, UITextViewDelegate {
818
1423
  guard text.character(at: paragraphRange.location - 1) == 0x000A else { return nil }
819
1424
 
820
1425
  let attachmentIndex = paragraphRange.location - 2
821
- 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)
822
1442
  guard attrs[.attachment] is NSTextAttachment,
823
- attrs[RenderBridgeAttributes.voidNodeType] as? String == "horizontalRule"
1443
+ attrs[RenderBridgeAttributes.voidNodeType] as? String != nil
824
1444
  else {
825
1445
  return nil
826
1446
  }
827
1447
 
828
- let placeholderEndScalar = PositionBridge.utf16OffsetToScalar(
829
- placeholderRange.location + placeholderRange.length,
1448
+ let attachmentEndScalar = PositionBridge.utf16OffsetToScalar(
1449
+ utf16Offset + 1,
830
1450
  in: self
831
1451
  )
832
- guard placeholderEndScalar > 0 else { return nil }
833
- return (from: placeholderEndScalar - 1, to: placeholderEndScalar)
1452
+ guard attachmentEndScalar > 0 else { return nil }
1453
+ return (from: attachmentEndScalar - 1, to: attachmentEndScalar)
834
1454
  }
835
1455
 
836
1456
  private func handleListDepthKeyCommand(outdent: Bool) {
@@ -1019,6 +1639,8 @@ final class EditorTextView: UITextView, UITextViewDelegate {
1019
1639
  /// internally during tap handling and word-boundary resolution.
1020
1640
  func textViewDidChangeSelection(_ textView: UITextView) {
1021
1641
  guard textView === self else { return }
1642
+ refreshNativeSelectionChromeVisibility()
1643
+ onSelectionOrContentMayChange?()
1022
1644
  scheduleSelectionSync()
1023
1645
  }
1024
1646
 
@@ -1037,6 +1659,25 @@ final class EditorTextView: UITextView, UITextViewDelegate {
1037
1659
  in characterRange: NSRange,
1038
1660
  interaction: UITextItemInteraction
1039
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()
1040
1681
  return false
1041
1682
  }
1042
1683
 
@@ -1128,6 +1769,25 @@ final class EditorTextView: UITextView, UITextViewDelegate {
1128
1769
  typingAttributes = attrs
1129
1770
  }
1130
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
+
1131
1791
  private func scheduleSelectionSync() {
1132
1792
  pendingSelectionSyncGeneration &+= 1
1133
1793
  let generation = pendingSelectionSyncGeneration
@@ -1537,6 +2197,104 @@ final class EditorTextView: UITextView, UITextViewDelegate {
1537
2197
  return (anchor: scalarRange.from, head: scalarRange.to)
1538
2198
  }
1539
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
+
1540
2298
  /// Handle return key press as a block split operation.
1541
2299
  private func handleReturnKey() {
1542
2300
  // If there's a range selection, atomically delete and split.
@@ -1644,6 +2402,7 @@ final class EditorTextView: UITextView, UITextViewDelegate {
1644
2402
  if heightBehavior == .autoGrow {
1645
2403
  notifyHeightChangeIfNeeded(force: true)
1646
2404
  }
2405
+ onSelectionOrContentMayChange?()
1647
2406
 
1648
2407
  Self.updateLog.debug(
1649
2408
  "[applyUpdateJSON.end] finalSelection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
@@ -1682,6 +2441,7 @@ final class EditorTextView: UITextView, UITextViewDelegate {
1682
2441
  if heightBehavior == .autoGrow {
1683
2442
  notifyHeightChangeIfNeeded(force: true)
1684
2443
  }
2444
+ onSelectionOrContentMayChange?()
1685
2445
  Self.updateLog.debug(
1686
2446
  "[applyRenderJSON.end] after=\(self.textSnapshotSummary(), privacy: .public)"
1687
2447
  )
@@ -1713,6 +2473,7 @@ final class EditorTextView: UITextView, UITextViewDelegate {
1713
2473
  let startPos = PositionBridge.scalarToTextView(min(anchorScalar, headScalar), in: self)
1714
2474
  let endPos = PositionBridge.scalarToTextView(max(anchorScalar, headScalar), in: self)
1715
2475
  selectedTextRange = textRange(from: startPos, to: endPos)
2476
+ refreshNativeSelectionChromeVisibility()
1716
2477
  Self.selectionLog.debug(
1717
2478
  "[applySelectionFromJSON.text] doc=\(anchorNum.uint32Value)-\(headNum.uint32Value) scalar=\(anchorScalar)-\(headScalar) final=\(self.selectionSummary(), privacy: .public)"
1718
2479
  )
@@ -1727,12 +2488,14 @@ final class EditorTextView: UITextView, UITextViewDelegate {
1727
2488
  if let endPos = position(from: startPos, offset: 1) {
1728
2489
  selectedTextRange = textRange(from: startPos, to: endPos)
1729
2490
  }
2491
+ refreshNativeSelectionChromeVisibility()
1730
2492
  Self.selectionLog.debug(
1731
2493
  "[applySelectionFromJSON.node] doc=\(posNum.uint32Value) scalar=\(posScalar) final=\(self.selectionSummary(), privacy: .public)"
1732
2494
  )
1733
2495
 
1734
2496
  case "all":
1735
2497
  selectedTextRange = textRange(from: beginningOfDocument, to: endOfDocument)
2498
+ refreshNativeSelectionChromeVisibility()
1736
2499
  Self.selectionLog.debug(
1737
2500
  "[applySelectionFromJSON.all] final=\(self.selectionSummary(), privacy: .public)"
1738
2501
  )
@@ -1832,9 +2595,20 @@ final class RichTextEditorView: UIView {
1832
2595
  /// The editor text view that handles input interception.
1833
2596
  let textView: EditorTextView
1834
2597
  private let remoteSelectionOverlayView = RemoteSelectionOverlayView()
2598
+ private let imageTapOverlayView = ImageTapOverlayView()
2599
+ private let imageResizeOverlayView = ImageResizeOverlayView()
1835
2600
  var onHeightMayChange: (() -> Void)?
1836
2601
  private var lastAutoGrowWidth: CGFloat = 0
1837
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
+ }
1838
2612
 
1839
2613
  var heightBehavior: EditorHeightBehavior = .fixed {
1840
2614
  didSet {
@@ -1844,6 +2618,7 @@ final class RichTextEditorView: UIView {
1844
2618
  setNeedsLayout()
1845
2619
  onHeightMayChange?()
1846
2620
  remoteSelectionOverlayView.refresh()
2621
+ imageResizeOverlayView.refresh()
1847
2622
  }
1848
2623
  }
1849
2624
 
@@ -1859,6 +2634,8 @@ final class RichTextEditorView: UIView {
1859
2634
  selections: remoteSelections,
1860
2635
  editorId: editorId
1861
2636
  )
2637
+ imageTapOverlayView.isHidden = editorId == 0 || !allowImageResizing
2638
+ imageResizeOverlayView.refresh()
1862
2639
  }
1863
2640
  }
1864
2641
 
@@ -1880,7 +2657,13 @@ final class RichTextEditorView: UIView {
1880
2657
  // Add the text view as a subview.
1881
2658
  textView.translatesAutoresizingMaskIntoConstraints = false
1882
2659
  remoteSelectionOverlayView.translatesAutoresizingMaskIntoConstraints = false
2660
+ imageTapOverlayView.translatesAutoresizingMaskIntoConstraints = false
2661
+ imageResizeOverlayView.translatesAutoresizingMaskIntoConstraints = false
1883
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
1884
2667
  textView.onHeightMayChange = { [weak self] in
1885
2668
  guard let self, self.heightBehavior == .autoGrow else { return }
1886
2669
  self.invalidateIntrinsicContentSize()
@@ -1888,10 +2671,15 @@ final class RichTextEditorView: UIView {
1888
2671
  self.onHeightMayChange?()
1889
2672
  }
1890
2673
  textView.onViewportMayChange = { [weak self] in
1891
- self?.remoteSelectionOverlayView.refresh()
2674
+ self?.refreshOverlays()
2675
+ }
2676
+ textView.onSelectionOrContentMayChange = { [weak self] in
2677
+ self?.refreshOverlays()
1892
2678
  }
1893
2679
  addSubview(textView)
1894
2680
  addSubview(remoteSelectionOverlayView)
2681
+ addSubview(imageTapOverlayView)
2682
+ addSubview(imageResizeOverlayView)
1895
2683
 
1896
2684
  NSLayoutConstraint.activate([
1897
2685
  textView.topAnchor.constraint(equalTo: topAnchor),
@@ -1902,6 +2690,14 @@ final class RichTextEditorView: UIView {
1902
2690
  remoteSelectionOverlayView.leadingAnchor.constraint(equalTo: leadingAnchor),
1903
2691
  remoteSelectionOverlayView.trailingAnchor.constraint(equalTo: trailingAnchor),
1904
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),
1905
2701
  ])
1906
2702
  }
1907
2703
 
@@ -1919,7 +2715,7 @@ final class RichTextEditorView: UIView {
1919
2715
 
1920
2716
  override func layoutSubviews() {
1921
2717
  super.layoutSubviews()
1922
- remoteSelectionOverlayView.refresh()
2718
+ refreshOverlays()
1923
2719
  guard heightBehavior == .autoGrow else { return }
1924
2720
  let currentWidth = bounds.width.rounded(.towardZero)
1925
2721
  guard currentWidth != lastAutoGrowWidth else { return }
@@ -1953,7 +2749,7 @@ final class RichTextEditorView: UIView {
1953
2749
  let cornerRadius = theme?.borderRadius ?? 0
1954
2750
  layer.cornerRadius = cornerRadius
1955
2751
  clipsToBounds = cornerRadius > 0
1956
- remoteSelectionOverlayView.refresh()
2752
+ refreshOverlays()
1957
2753
  }
1958
2754
 
1959
2755
  func setRemoteSelections(_ selections: [RemoteSelectionDecoration]) {
@@ -1972,6 +2768,47 @@ final class RichTextEditorView: UIView {
1972
2768
  remoteSelectionOverlayView.subviews
1973
2769
  }
1974
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()
2810
+ }
2811
+
1975
2812
  /// Set initial content from HTML.
1976
2813
  ///
1977
2814
  /// - Parameter html: The HTML string to load.
@@ -2010,6 +2847,50 @@ final class RichTextEditorView: UIView {
2010
2847
  return UIScreen.main.bounds.width
2011
2848
  }
2012
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
+
2013
2894
  // MARK: - Cleanup
2014
2895
 
2015
2896
  deinit {