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 }