Files
damus/damus/Components/SelectableText.swift

258 lines
9.4 KiB
Swift

//
// SelectableText.swift
// damus
//
// Created by Oleg Abalonski on 2/16/23.
//
import UIKit
import SwiftUI
struct SelectableText: View {
let damus_state: DamusState
let event: NostrEvent?
let attributedString: AttributedString
let textAlignment: NSTextAlignment
@State private var selectedTextActionState: SelectedTextActionState = .hide
@State private var selectedTextHeight: CGFloat = .zero
@State private var selectedTextWidth: CGFloat = .zero
let size: EventViewKind
init(damus_state: DamusState, event: NostrEvent?, attributedString: AttributedString, textAlignment: NSTextAlignment? = nil, size: EventViewKind) {
self.damus_state = damus_state
self.event = event
self.attributedString = attributedString
self.textAlignment = textAlignment ?? NSTextAlignment.natural
self.size = size
}
var body: some View {
GeometryReader { geo in
TextViewRepresentable(
attributedString: attributedString,
textColor: UIColor.label,
font: eventviewsize_to_uifont(size),
fixedWidth: selectedTextWidth,
textAlignment: self.textAlignment,
enableHighlighting: self.enableHighlighting(),
postHighlight: { selectedText in
self.selectedTextActionState = .show_highlight_post_view(highlighted_text: selectedText)
},
muteWord: { selectedText in
self.selectedTextActionState = .show_mute_word_view(highlighted_text: selectedText)
},
height: $selectedTextHeight
)
.padding([.leading, .trailing], -1.0)
.onAppear {
if geo.size.width == .zero {
self.selectedTextHeight = 1000.0
} else {
self.selectedTextWidth = geo.size.width
}
}
.onChange(of: geo.size) { newSize in
self.selectedTextWidth = newSize.width
}
}
.sheet(isPresented: Binding(get: {
return self.selectedTextActionState.should_show_highlight_post_view()
}, set: { newValue in
self.selectedTextActionState = newValue ? .show_highlight_post_view(highlighted_text: self.selectedTextActionState.highlighted_text() ?? "") : .hide
})) {
if let event, case .show_highlight_post_view(let highlighted_text) = self.selectedTextActionState {
PostView(
action: .highlighting(.init(selected_text: highlighted_text, source: .event(event.id))),
damus_state: damus_state
)
.presentationDragIndicator(.visible)
.presentationDetents([.height(selectedTextHeight + 450), .medium, .large])
}
}
.sheet(isPresented: Binding(get: {
return self.selectedTextActionState.should_show_mute_word_view()
}, set: { newValue in
self.selectedTextActionState = newValue ? .show_mute_word_view(highlighted_text: self.selectedTextActionState.highlighted_text() ?? "") : .hide
})) {
if case .show_mute_word_view(let highlighted_text) = selectedTextActionState {
AddMuteItemView(state: damus_state, new_text: .constant(highlighted_text))
.presentationDragIndicator(.visible)
.presentationDetents([.height(300), .medium, .large])
}
}
.frame(height: selectedTextHeight)
}
func enableHighlighting() -> Bool {
self.event != nil
}
enum SelectedTextActionState {
case hide
case show_highlight_post_view(highlighted_text: String)
case show_mute_word_view(highlighted_text: String)
func should_show_highlight_post_view() -> Bool {
guard case .show_highlight_post_view = self else { return false }
return true
}
func should_show_mute_word_view() -> Bool {
guard case .show_mute_word_view = self else { return false }
return true
}
func highlighted_text() -> String? {
switch self {
case .hide:
return nil
case .show_mute_word_view(highlighted_text: let highlighted_text):
return highlighted_text
case .show_highlight_post_view(highlighted_text: let highlighted_text):
return highlighted_text
}
}
}
}
fileprivate class TextView: UITextView {
var postHighlight: (String) -> Void
var muteWord: (String) -> Void
private let enableHighlighting: Bool
init(frame: CGRect, textContainer: NSTextContainer?, postHighlight: @escaping (String) -> Void, muteWord: @escaping (String) -> Void, enableHighlighting: Bool) {
self.postHighlight = postHighlight
self.muteWord = muteWord
self.enableHighlighting = enableHighlighting
super.init(frame: frame, textContainer: textContainer)
if enableHighlighting {
self.delegate = self
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if action == #selector(highlightText(_:)) {
return true
}
if action == #selector(muteText(_:)) {
return true
}
return super.canPerformAction(action, withSender: sender)
}
private func getSelectedText() -> String? {
guard let selectedRange = self.selectedTextRange else { return nil }
return self.text(in: selectedRange)
}
@objc private func highlightText(_ sender: Any?) {
guard let selectedText = self.getSelectedText() else { return }
self.postHighlight(selectedText)
}
@objc private func muteText(_ sender: Any?) {
guard let selectedText = self.getSelectedText() else { return }
self.muteWord(selectedText)
}
}
extension TextView: UITextViewDelegate {
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
guard enableHighlighting,
let selectedTextRange = self.selectedTextRange,
let selectedText = self.text(in: selectedTextRange),
!selectedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return nil
}
let highlightAction = UIAction(title: NSLocalizedString("Highlight", comment: "Context menu action to highlight the selected text as context to draft a new note."), image: UIImage(systemName: "highlighter")) { [weak self] _ in
self?.postHighlight(selectedText)
}
let muteAction = UIAction(title: NSLocalizedString("Mute", comment: "Context menu action to mute the selected word."), image: UIImage(systemName: "speaker.slash")) { [weak self] _ in
self?.muteWord(selectedText)
}
return UIMenu(children: suggestedActions + [highlightAction, muteAction])
}
}
fileprivate struct TextViewRepresentable: UIViewRepresentable {
let attributedString: AttributedString
let textColor: UIColor
let font: UIFont
let fixedWidth: CGFloat
let textAlignment: NSTextAlignment
let enableHighlighting: Bool
let postHighlight: (String) -> Void
let muteWord: (String) -> Void
@Binding var height: CGFloat
func makeUIView(context: UIViewRepresentableContext<Self>) -> TextView {
let view = TextView(frame: .zero, textContainer: nil, postHighlight: postHighlight, muteWord: muteWord, enableHighlighting: enableHighlighting)
view.isEditable = false
view.dataDetectorTypes = .all
view.isSelectable = true
view.backgroundColor = .clear
view.textContainer.lineFragmentPadding = 0
view.textContainerInset = .zero
view.textContainerInset.left = 1.0
view.textContainerInset.right = 1.0
view.textAlignment = textAlignment
return view
}
func updateUIView(_ uiView: TextView, context: UIViewRepresentableContext<Self>) {
let mutableAttributedString = createNSAttributedString()
uiView.attributedText = mutableAttributedString
uiView.textAlignment = self.textAlignment
let newHeight = mutableAttributedString.height(containerWidth: fixedWidth)
DispatchQueue.main.async {
height = newHeight
}
}
func createNSAttributedString() -> NSMutableAttributedString {
let mutableAttributedString = NSMutableAttributedString(attributedString)
let myAttribute = [
NSAttributedString.Key.font: font,
NSAttributedString.Key.foregroundColor: textColor
]
mutableAttributedString.addAttributes(
myAttribute,
range: NSRange.init(location: 0, length: mutableAttributedString.length)
)
return mutableAttributedString
}
}
fileprivate extension NSAttributedString {
func height(containerWidth: CGFloat) -> CGFloat {
let rect = self.boundingRect(
with: CGSize.init(width: containerWidth, height: CGFloat.greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin, .usesFontLeading],
context: nil
)
return ceil(rect.size.height)
}
}