SelectableText.swift (8678B)
1 // 2 // SelectableText.swift 3 // damus 4 // 5 // Created by Oleg Abalonski on 2/16/23. 6 // 7 8 import UIKit 9 import SwiftUI 10 11 struct SelectableText: View { 12 let damus_state: DamusState 13 let event: NostrEvent? 14 let attributedString: AttributedString 15 let textAlignment: NSTextAlignment 16 @State private var selectedTextActionState: SelectedTextActionState = .hide 17 @State private var selectedTextHeight: CGFloat = .zero 18 @State private var selectedTextWidth: CGFloat = .zero 19 20 let size: EventViewKind 21 22 init(damus_state: DamusState, event: NostrEvent?, attributedString: AttributedString, textAlignment: NSTextAlignment? = nil, size: EventViewKind) { 23 self.damus_state = damus_state 24 self.event = event 25 self.attributedString = attributedString 26 self.textAlignment = textAlignment ?? NSTextAlignment.natural 27 self.size = size 28 } 29 30 var body: some View { 31 GeometryReader { geo in 32 TextViewRepresentable( 33 attributedString: attributedString, 34 textColor: UIColor.label, 35 font: eventviewsize_to_uifont(size), 36 fixedWidth: selectedTextWidth, 37 textAlignment: self.textAlignment, 38 enableHighlighting: self.enableHighlighting(), 39 postHighlight: { selectedText in 40 self.selectedTextActionState = .show_highlight_post_view(highlighted_text: selectedText) 41 }, 42 muteWord: { selectedText in 43 self.selectedTextActionState = .show_mute_word_view(highlighted_text: selectedText) 44 }, 45 height: $selectedTextHeight 46 ) 47 .padding([.leading, .trailing], -1.0) 48 .onAppear { 49 if geo.size.width == .zero { 50 self.selectedTextHeight = 1000.0 51 } else { 52 self.selectedTextWidth = geo.size.width 53 } 54 } 55 .onChange(of: geo.size) { newSize in 56 self.selectedTextWidth = newSize.width 57 } 58 } 59 .sheet(isPresented: Binding(get: { 60 return self.selectedTextActionState.should_show_highlight_post_view() 61 }, set: { newValue in 62 self.selectedTextActionState = newValue ? .show_highlight_post_view(highlighted_text: self.selectedTextActionState.highlighted_text() ?? "") : .hide 63 })) { 64 if let event, case .show_highlight_post_view(let highlighted_text) = self.selectedTextActionState { 65 PostView( 66 action: .highlighting(.init(selected_text: highlighted_text, source: .event(event.id))), 67 damus_state: damus_state 68 ) 69 .presentationDragIndicator(.visible) 70 .presentationDetents([.height(selectedTextHeight + 450), .medium, .large]) 71 } 72 } 73 .sheet(isPresented: Binding(get: { 74 return self.selectedTextActionState.should_show_mute_word_view() 75 }, set: { newValue in 76 self.selectedTextActionState = newValue ? .show_mute_word_view(highlighted_text: self.selectedTextActionState.highlighted_text() ?? "") : .hide 77 })) { 78 if case .show_mute_word_view(let highlighted_text) = selectedTextActionState { 79 AddMuteItemView(state: damus_state, new_text: .constant(highlighted_text)) 80 .presentationDragIndicator(.visible) 81 .presentationDetents([.height(300), .medium, .large]) 82 } 83 } 84 .frame(height: selectedTextHeight) 85 } 86 87 func enableHighlighting() -> Bool { 88 self.event != nil 89 } 90 91 enum SelectedTextActionState { 92 case hide 93 case show_highlight_post_view(highlighted_text: String) 94 case show_mute_word_view(highlighted_text: String) 95 96 func should_show_highlight_post_view() -> Bool { 97 guard case .show_highlight_post_view(let highlighted_text) = self else { return false } 98 return true 99 } 100 101 func should_show_mute_word_view() -> Bool { 102 guard case .show_mute_word_view(let highlighted_text) = self else { return false } 103 return true 104 } 105 106 func highlighted_text() -> String? { 107 switch self { 108 case .hide: 109 return nil 110 case .show_mute_word_view(highlighted_text: let highlighted_text): 111 return highlighted_text 112 case .show_highlight_post_view(highlighted_text: let highlighted_text): 113 return highlighted_text 114 } 115 } 116 } 117 } 118 119 fileprivate class TextView: UITextView { 120 var postHighlight: (String) -> Void 121 var muteWord: (String) -> Void 122 123 init(frame: CGRect, textContainer: NSTextContainer?, postHighlight: @escaping (String) -> Void, muteWord: @escaping (String) -> Void) { 124 self.postHighlight = postHighlight 125 self.muteWord = muteWord 126 super.init(frame: frame, textContainer: textContainer) 127 } 128 129 required init?(coder: NSCoder) { 130 fatalError("init(coder:) has not been implemented") 131 } 132 133 override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { 134 if action == #selector(highlightText(_:)) { 135 return true 136 } 137 138 if action == #selector(muteText(_:)) { 139 return true 140 } 141 142 return super.canPerformAction(action, withSender: sender) 143 } 144 145 func getSelectedText() -> String? { 146 guard let selectedRange = self.selectedTextRange else { return nil } 147 return self.text(in: selectedRange) 148 } 149 150 @objc public func highlightText(_ sender: Any?) { 151 guard let selectedText = self.getSelectedText() else { return } 152 self.postHighlight(selectedText) 153 } 154 155 @objc public func muteText(_ sender: Any?) { 156 guard let selectedText = self.getSelectedText() else { return } 157 self.muteWord(selectedText) 158 } 159 160 } 161 162 fileprivate struct TextViewRepresentable: UIViewRepresentable { 163 164 let attributedString: AttributedString 165 let textColor: UIColor 166 let font: UIFont 167 let fixedWidth: CGFloat 168 let textAlignment: NSTextAlignment 169 let enableHighlighting: Bool 170 let postHighlight: (String) -> Void 171 let muteWord: (String) -> Void 172 @Binding var height: CGFloat 173 174 func makeUIView(context: UIViewRepresentableContext<Self>) -> TextView { 175 let view = TextView(frame: .zero, textContainer: nil, postHighlight: postHighlight, muteWord: muteWord) 176 view.isEditable = false 177 view.dataDetectorTypes = .all 178 view.isSelectable = true 179 view.backgroundColor = .clear 180 view.textContainer.lineFragmentPadding = 0 181 view.textContainerInset = .zero 182 view.textContainerInset.left = 1.0 183 view.textContainerInset.right = 1.0 184 view.textAlignment = textAlignment 185 186 let menuController = UIMenuController.shared 187 let highlightItem = UIMenuItem(title: "Highlight", action: #selector(view.highlightText(_:))) 188 let muteItem = UIMenuItem(title: "Mute", action: #selector(view.muteText(_:))) 189 menuController.menuItems = self.enableHighlighting ? [highlightItem, muteItem] : [] 190 191 return view 192 } 193 194 func updateUIView(_ uiView: TextView, context: UIViewRepresentableContext<Self>) { 195 let mutableAttributedString = createNSAttributedString() 196 uiView.attributedText = mutableAttributedString 197 uiView.textAlignment = self.textAlignment 198 199 let newHeight = mutableAttributedString.height(containerWidth: fixedWidth) 200 201 DispatchQueue.main.async { 202 height = newHeight 203 } 204 } 205 206 func createNSAttributedString() -> NSMutableAttributedString { 207 let mutableAttributedString = NSMutableAttributedString(attributedString) 208 let myAttribute = [ 209 NSAttributedString.Key.font: font, 210 NSAttributedString.Key.foregroundColor: textColor 211 ] 212 213 mutableAttributedString.addAttributes( 214 myAttribute, 215 range: NSRange.init(location: 0, length: mutableAttributedString.length) 216 ) 217 218 return mutableAttributedString 219 } 220 } 221 222 fileprivate extension NSAttributedString { 223 224 func height(containerWidth: CGFloat) -> CGFloat { 225 226 let rect = self.boundingRect( 227 with: CGSize.init(width: containerWidth, height: CGFloat.greatestFiniteMagnitude), 228 options: [.usesLineFragmentOrigin, .usesFontLeading], 229 context: nil 230 ) 231 232 return ceil(rect.size.height) 233 } 234 }