damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

TextViewWrapper.swift (14091B)


      1 //
      2 //  TextViewWrapper.swift
      3 //  damus
      4 //
      5 //  Created by Swift on 2/24/23.
      6 //
      7 
      8 import SwiftUI
      9 
     10 struct TextViewWrapper: UIViewRepresentable {
     11     @Binding var attributedText: NSMutableAttributedString
     12     @EnvironmentObject var tagModel: TagModel
     13     @Binding var textHeight: CGFloat?
     14     let initialTextSuffix: String?
     15     @Binding var imagePastedFromPasteboard: PreUploadedMedia?
     16     @Binding var imageUploadConfirmPasteboard: Bool
     17     
     18     let cursorIndex: Int?
     19     var getFocusWordForMention: ((String?, NSRange?) -> Void)? = nil
     20     let updateCursorPosition: ((Int) -> Void)
     21     
     22     func makeUIView(context: Context) -> UITextView {
     23         let textView = CustomPostTextView(imagePastedFromPasteboard: $imagePastedFromPasteboard,
     24                                           imageUploadConfirm: $imageUploadConfirmPasteboard)
     25         textView.backgroundColor = UIColor(DamusColors.adaptableWhite)
     26         textView.delegate = context.coordinator
     27         
     28         // Disable scrolling (this view will expand vertically as needed to fit text)
     29         textView.isScrollEnabled = false
     30         // Set low content compression resistance to make this view wrap lines of text, and avoid text overflowing to the right
     31         textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
     32         textView.setContentCompressionResistancePriority(.required, for: .vertical)
     33         
     34         // Inline text suggestions interfere with mentions generation
     35         if #available(iOS 17.0, *) {
     36             textView.inlinePredictionType = .no
     37         }
     38         
     39         TextViewWrapper.setTextProperties(textView)
     40         return textView
     41     }
     42     
     43     static func setTextProperties(_ uiView: UITextView) {
     44         uiView.textColor = UIColor.label
     45         uiView.font = UIFont.preferredFont(forTextStyle: .body)
     46         let linkAttributes: [NSAttributedString.Key : Any] = [
     47             NSAttributedString.Key.foregroundColor: UIColor(Color.accentColor)]
     48         uiView.linkTextAttributes = linkAttributes
     49     }
     50 
     51     func updateUIView(_ uiView: UITextView, context: Context) {
     52         uiView.attributedText = attributedText
     53 
     54         TextViewWrapper.setTextProperties(uiView)
     55         setCursorPosition(textView: uiView)
     56         let range = uiView.selectedRange
     57 
     58         // Set the text height that will fit all the text
     59         // This is needed because the UIKit auto-layout prefers to overflow the text to the right than to expand the text box vertically, even with low horizontal compression resistance
     60         self.setIdealHeight(uiView: uiView)
     61 
     62         uiView.selectedRange = NSRange(location: range.location + tagModel.diff, length: range.length)
     63         tagModel.diff = 0
     64     }
     65     
     66     /// Based on our desired layout, calculate the ideal size of the text box, then set the height to the ideal size
     67     private func setIdealHeight(uiView: UITextView) {
     68         DispatchQueue.main.async {  // Queue on main thread, because modifying view state directly during re-render causes undefined behavior
     69             let idealSize = uiView.sizeThatFits(CGSize(
     70                 width: uiView.frame.width,  // We want to stay within the horizontal bounds given to us
     71                 height: .infinity           // We can expand vertically without any resistance
     72             ))
     73             if self.textHeight != idealSize.height {    // Only update height when it changes, to avoid infinite re-render calls
     74                 self.textHeight = idealSize.height
     75             }
     76         }
     77     }
     78 
     79     private func setCursorPosition(textView: UITextView) {
     80         guard let index = cursorIndex, let newPosition = textView.position(from: textView.beginningOfDocument, offset: index) else {
     81             return
     82         }
     83         textView.selectedTextRange = textView.textRange(from: newPosition, to: newPosition)
     84     }
     85 
     86     func makeCoordinator() -> Coordinator {
     87         Coordinator(attributedText: $attributedText, getFocusWordForMention: getFocusWordForMention, updateCursorPosition: updateCursorPosition, initialTextSuffix: initialTextSuffix)
     88     }
     89 
     90     class Coordinator: NSObject, UITextViewDelegate {
     91         @Binding var attributedText: NSMutableAttributedString
     92         var getFocusWordForMention: ((String?, NSRange?) -> Void)? = nil
     93         let updateCursorPosition: ((Int) -> Void)
     94         let initialTextSuffix: String?
     95         var initialTextSuffixWasAdded: Bool = false
     96         static let ESCAPE_SEQUENCES = ["\n", "@", "  ", ", ", ". ", "! ", "? ", "; ", "#"]
     97 
     98         init(attributedText: Binding<NSMutableAttributedString>,
     99              getFocusWordForMention: ((String?, NSRange?) -> Void)?,
    100              updateCursorPosition: @escaping ((Int) -> Void),
    101              initialTextSuffix: String?
    102         ) {
    103             _attributedText = attributedText
    104             self.getFocusWordForMention = getFocusWordForMention
    105             self.updateCursorPosition = updateCursorPosition
    106             self.initialTextSuffix = initialTextSuffix
    107         }
    108 
    109         func textViewDidChange(_ textView: UITextView) {
    110             if let initialTextSuffix, !self.initialTextSuffixWasAdded {
    111                 self.initialTextSuffixWasAdded = true
    112                 var mutable = NSMutableAttributedString(attributedString: textView.attributedText)
    113                 let originalRange = textView.selectedRange
    114                 addUnattributedText(initialTextSuffix, to: &mutable, inRange: originalRange)
    115                 attributedText = mutable
    116                 DispatchQueue.main.async {
    117                     self.updateCursorPosition(originalRange.location)
    118                 }
    119             }
    120             else {
    121                 attributedText = NSMutableAttributedString(attributedString: textView.attributedText)
    122             }
    123             processFocusedWordForMention(textView: textView)
    124         }
    125 
    126         private func processFocusedWordForMention(textView: UITextView) {
    127             var val: (String?, NSRange?) = (nil, nil)
    128             
    129             guard let selectedRange = textView.selectedTextRange else { return }
    130             
    131             let wordRange = rangeOfMention(in: textView, from: selectedRange.start)
    132             
    133             if let wordRange,
    134                let startPosition = textView.position(from: wordRange.start, offset: -1),
    135                let cursorPosition = textView.position(from: selectedRange.start, offset: 0)
    136             {
    137                 let word = textView.text(in: textView.textRange(from: startPosition, to: cursorPosition)!)
    138                 val = (word, convertToNSRange(startPosition, cursorPosition, textView))
    139             }
    140             
    141             getFocusWordForMention?(val.0, val.1)
    142         }
    143         
    144         func rangeOfMention(in textView: UITextView, from position: UITextPosition) -> UITextRange? {
    145             var startPosition = position
    146 
    147             while startPosition != textView.beginningOfDocument {
    148                 guard let previousPosition = textView.position(from: startPosition, offset: -1),
    149                       let range = textView.textRange(from: previousPosition, to: position),
    150                       let text = textView.text(in: range), !text.isEmpty else {
    151                     break
    152                 }
    153                 
    154                 startPosition = previousPosition
    155                 
    156                 if let styling = textView.textStyling(at: previousPosition, in: .backward),
    157                    styling[NSAttributedString.Key.link] != nil {
    158                     break
    159                 }
    160                 
    161                 var found_escape_sequence = false
    162                 for escape_sequence in Self.ESCAPE_SEQUENCES {
    163                     if text.contains(escape_sequence) {
    164                         startPosition = textView.position(from: startPosition, offset: escape_sequence.count) ?? startPosition
    165                         found_escape_sequence = true
    166                         break
    167                     }
    168                 }
    169                 if found_escape_sequence { break }
    170             }
    171 
    172             return startPosition == position ? nil : textView.textRange(from: startPosition, to: position)
    173         }
    174 
    175         private func convertToNSRange( _ startPosition: UITextPosition, _ endPosition: UITextPosition, _ textView: UITextView) -> NSRange? {
    176             let startOffset = textView.offset(from: textView.beginningOfDocument, to: startPosition)
    177             let endOffset = textView.offset(from: textView.beginningOfDocument, to: endPosition)
    178             let length = endOffset - startOffset
    179             guard length >= 0, startOffset >= 0 else {
    180                 return nil
    181             }
    182             return NSRange(location: startOffset, length: length)
    183         }
    184 
    185         // This `UITextViewDelegate` method is automatically called by the editor when edits occur, to check whether a change should occur
    186         // We will use this method to manually handle edits concerning mention ("@") links, to avoid manual text edits to attributed mention links
    187         func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
    188             guard let attributedString = textView.attributedText else {
    189                 return true     // If we cannot get an attributed string, just fail gracefully and allow changes
    190             }
    191             var mutable = NSMutableAttributedString(attributedString: attributedString)
    192             
    193             let entireRange = NSRange(location: 0, length: attributedString.length)
    194             var shouldAllowChange = true
    195             var performEditActionManually = false
    196 
    197             attributedString.enumerateAttribute(.link, in: entireRange, options: []) { (value, linkRange, stop) in
    198                 guard value != nil else {
    199                     return  // This range is not a link. Skip checking.
    200                 }
    201                 
    202                 if range.contains(linkRange.upperBound) && range.contains(linkRange.lowerBound) {
    203                     // Edit range engulfs all of this link's range.
    204                     // This link will naturally disappear, so no work needs to be done in this range.
    205                     return
    206                 }
    207                 else if linkRange.intersection(range) != nil {
    208                     // If user tries to change an existing link directly, remove the link attribute
    209                     mutable.removeAttribute(.link, range: linkRange)
    210                     // Perform action manually to flush above changes to the view, and to prevent the character being added from having an attributed link property
    211                     performEditActionManually = true
    212                     return
    213                 }
    214                 else if range.location == linkRange.location + linkRange.length && range.length == 0 {
    215                     // If we are inserting a character at the right edge of a link, UITextInput tends to include the new character inside the link.
    216                     // Therefore, we need to manually append that character outside of the link
    217                     performEditActionManually = true
    218                     return
    219                 }
    220             }
    221             
    222             if performEditActionManually {
    223                 shouldAllowChange = false
    224                 addUnattributedText(text, to: &mutable, inRange: range)
    225                 attributedText = mutable
    226                 
    227                 // Move caret to the end of the newly changed text.
    228                 updateCursorPosition(range.location + text.count)
    229             }
    230 
    231             return shouldAllowChange
    232         }
    233 
    234         func addUnattributedText(_ text: String, to attributedString: inout NSMutableAttributedString, inRange range: NSRange) {
    235             if range.length == 0 {
    236                 attributedString.insert(NSAttributedString(string: text, attributes: nil), at: range.location)
    237             }
    238             else {
    239                 attributedString.replaceCharacters(in: range, with: text)
    240             }
    241         }
    242         
    243     }
    244 }
    245 
    246 class CustomPostTextView: UITextView {
    247     @Binding var imagePastedFromPasteboard: PreUploadedMedia?
    248     @Binding var imageUploadConfirm: Bool
    249     
    250     // Custom initializer
    251     init(imagePastedFromPasteboard: Binding<PreUploadedMedia?>, imageUploadConfirm: Binding<Bool>) {
    252         self._imagePastedFromPasteboard = imagePastedFromPasteboard
    253         self._imageUploadConfirm = imageUploadConfirm
    254         super.init(frame: .zero, textContainer: nil)
    255     }
    256     
    257     required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
    258 
    259     // Override canPerformAction to enable image pasting
    260     override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
    261         if action == #selector(UIResponderStandardEditActions.paste(_:)),
    262            UIPasteboard.general.image != nil {
    263             return true // Show `Paste` option while long-pressing if there is an image present in the clipboard
    264         }
    265         return super.canPerformAction(action, withSender: sender) // Default behavior for other actions
    266     }
    267 
    268     // Override paste to handle image pasting
    269     override func paste(_ sender: Any?) {
    270         let pasteboard = UIPasteboard.general
    271 
    272         if let data = pasteboard.data(forPasteboardType: Constants.GIF_IMAGE_TYPE),
    273            let url = saveGIFToTemporaryDirectory(data) {
    274             imagePastedFromPasteboard = PreUploadedMedia.unprocessed_image(url)
    275             imageUploadConfirm = true
    276         } else if let image = pasteboard.image {
    277             // handle .png, .jpeg files here
    278             imagePastedFromPasteboard = PreUploadedMedia.uiimage(image)
    279             // Show alert view in PostView for Confirming upload
    280             imageUploadConfirm = true
    281         } else {
    282             // fall back to default paste behavior if no image or gif file found
    283             super.paste(sender)
    284         }
    285     }
    286 
    287     private func saveGIFToTemporaryDirectory(_ data: Data) -> URL? {
    288         let tempDirectory = FileManager.default.temporaryDirectory
    289         let gifURL = tempDirectory.appendingPathComponent("pasted_image.gif")
    290         do {
    291             try data.write(to: gifURL)
    292             return gifURL
    293         } catch {
    294             return nil
    295         }
    296     }
    297 }