@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.
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +228 -2
- package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +199 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +4 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +3 -0
- package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +6 -0
- package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +347 -10
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +76 -8
- package/dist/EditorToolbar.d.ts +9 -2
- package/dist/EditorToolbar.js +20 -10
- package/dist/NativeEditorBridge.d.ts +2 -0
- package/dist/NativeEditorBridge.js +3 -0
- package/dist/NativeRichTextEditor.d.ts +17 -1
- package/dist/NativeRichTextEditor.js +94 -37
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -1
- package/dist/schemas.d.ts +12 -0
- package/dist/schemas.js +45 -1
- package/ios/EditorCore.xcframework/Info.plist +5 -5
- package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
- package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
- package/ios/EditorLayoutManager.swift +0 -16
- package/ios/Generated_editor_core.swift +20 -2
- package/ios/NativeEditorExpoView.swift +51 -16
- package/ios/NativeEditorModule.swift +3 -0
- package/ios/RenderBridge.swift +208 -0
- package/ios/RichTextEditorView.swift +896 -15
- package/ios/editor_coreFFI/editor_coreFFI.h +11 -0
- package/package.json +1 -1
- package/rust/android/arm64-v8a/libeditor_core.so +0 -0
- package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
- package/rust/android/x86_64/libeditor_core.so +0 -0
- package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
1443
|
+
attrs[RenderBridgeAttributes.voidNodeType] as? String != nil
|
|
824
1444
|
else {
|
|
825
1445
|
return nil
|
|
826
1446
|
}
|
|
827
1447
|
|
|
828
|
-
let
|
|
829
|
-
|
|
1448
|
+
let attachmentEndScalar = PositionBridge.utf16OffsetToScalar(
|
|
1449
|
+
utf16Offset + 1,
|
|
830
1450
|
in: self
|
|
831
1451
|
)
|
|
832
|
-
guard
|
|
833
|
-
return (from:
|
|
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?.
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|