2023-06-20 18:05:20 +04:00

601 lines
28 KiB
Swift

import Foundation
import UIKit
import Display
import ComponentFlow
import TextFormat
import TelegramPresentationData
import InvisibleInkDustNode
import EmojiTextAttachmentView
import AccountContext
import TextFormat
import ChatTextLinkEditUI
public final class EmptyInputView: UIView, UIInputViewAudioFeedback {
public var enableInputClicksWhenVisible: Bool {
return true
}
}
public final class TextFieldComponent: Component {
public final class ExternalState {
public fileprivate(set) var isEditing: Bool = false
public fileprivate(set) var hasText: Bool = false
public init() {
}
}
public final class AnimationHint {
public enum Kind {
case textChanged
case textFocusChanged
}
public let kind: Kind
fileprivate init(kind: Kind) {
self.kind = kind
}
}
public let context: AccountContext
public let strings: PresentationStrings
public let externalState: ExternalState
public let fontSize: CGFloat
public let textColor: UIColor
public let insets: UIEdgeInsets
public let hideKeyboard: Bool
public let present: (ViewController) -> Void
public init(
context: AccountContext,
strings: PresentationStrings,
externalState: ExternalState,
fontSize: CGFloat,
textColor: UIColor,
insets: UIEdgeInsets,
hideKeyboard: Bool,
present: @escaping (ViewController) -> Void
) {
self.context = context
self.strings = strings
self.externalState = externalState
self.fontSize = fontSize
self.textColor = textColor
self.insets = insets
self.hideKeyboard = hideKeyboard
self.present = present
}
public static func ==(lhs: TextFieldComponent, rhs: TextFieldComponent) -> Bool {
if lhs.strings !== rhs.strings {
return false
}
if lhs.externalState !== rhs.externalState {
return false
}
if lhs.fontSize != rhs.fontSize {
return false
}
if lhs.textColor != rhs.textColor {
return false
}
if lhs.insets != rhs.insets {
return false
}
if lhs.hideKeyboard != rhs.hideKeyboard {
return false
}
return true
}
public struct InputState {
public var inputText: NSAttributedString
public var selectionRange: Range<Int>
}
public final class View: UIView, UITextViewDelegate, UIScrollViewDelegate {
private let textContainer: NSTextContainer
private let textStorage: NSTextStorage
private let layoutManager: NSLayoutManager
private let textView: UITextView
private var spoilerView: InvisibleInkDustView?
private var customEmojiContainerView: CustomEmojiContainerView?
private var emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView)?
//private var inputState = InputState(inputText: NSAttributedString(), selectionRange: 0 ..< 0)
private var inputState: InputState {
let selectionRange: Range<Int> = self.textView.selectedRange.location ..< (self.textView.selectedRange.location + self.textView.selectedRange.length)
return InputState(inputText: stateAttributedStringForText(self.textView.attributedText ?? NSAttributedString()), selectionRange: selectionRange)
}
private var component: TextFieldComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
self.textContainer = NSTextContainer(size: CGSize())
self.textContainer.widthTracksTextView = false
self.textContainer.heightTracksTextView = false
self.textContainer.lineBreakMode = .byWordWrapping
self.textContainer.lineFragmentPadding = 8.0
self.textStorage = NSTextStorage()
self.layoutManager = NSLayoutManager()
self.layoutManager.allowsNonContiguousLayout = false
self.layoutManager.addTextContainer(self.textContainer)
self.textStorage.addLayoutManager(self.layoutManager)
self.textView = UITextView(frame: CGRect(), textContainer: self.textContainer)
self.textView.translatesAutoresizingMaskIntoConstraints = false
self.textView.backgroundColor = nil
self.textView.layer.isOpaque = false
self.textView.keyboardAppearance = .dark
self.textView.indicatorStyle = .white
self.textView.scrollIndicatorInsets = UIEdgeInsets(top: 9.0, left: 0.0, bottom: 9.0, right: 0.0)
super.init(frame: frame)
self.clipsToBounds = true
self.textView.delegate = self
self.addSubview(self.textView)
self.textContainer.widthTracksTextView = false
self.textContainer.heightTracksTextView = false
self.textView.typingAttributes = [
NSAttributedString.Key.font: Font.regular(17.0),
NSAttributedString.Key.foregroundColor: UIColor.white
]
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateInputState(_ f: (InputState) -> InputState) {
guard let component = self.component else {
return
}
let inputState = f(self.inputState)
self.textView.attributedText = textAttributedStringForStateText(inputState.inputText, fontSize: component.fontSize, textColor: component.textColor, accentTextColor: component.textColor, writingDirection: nil, spoilersRevealed: self.spoilersRevealed, availableEmojis: Set(component.context.animatedEmojiStickers.keys), emojiViewProvider: self.emojiViewProvider)
self.textView.selectedRange = NSMakeRange(inputState.selectionRange.lowerBound, inputState.selectionRange.count)
refreshChatTextInputAttributes(textView: self.textView, primaryTextColor: component.textColor, accentTextColor: component.textColor, baseFontSize: component.fontSize, spoilersRevealed: self.spoilersRevealed, availableEmojis: Set(component.context.animatedEmojiStickers.keys), emojiViewProvider: self.emojiViewProvider)
self.updateEntities()
}
public func insertText(_ text: NSAttributedString) {
self.updateInputState { state in
return state.insertText(text)
}
self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(AnimationHint(kind: .textChanged)))
}
public func deleteBackward() {
self.textView.deleteBackward()
}
public func updateText(_ text: NSAttributedString, selectionRange: Range<Int>) {
self.updateInputState { _ in
return TextFieldComponent.InputState(inputText: text, selectionRange: selectionRange)
}
self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(AnimationHint(kind: .textChanged)))
}
public func textViewDidChange(_ textView: UITextView) {
guard let component = self.component else {
return
}
refreshChatTextInputAttributes(textView: self.textView, primaryTextColor: component.textColor, accentTextColor: component.textColor, baseFontSize: component.fontSize, spoilersRevealed: self.spoilersRevealed, availableEmojis: Set(component.context.animatedEmojiStickers.keys), emojiViewProvider: self.emojiViewProvider)
refreshChatTextInputTypingAttributes(self.textView, textColor: component.textColor, baseFontSize: component.fontSize)
self.updateEntities()
self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(AnimationHint(kind: .textChanged)))
}
public func textViewDidChangeSelection(_ textView: UITextView) {
guard let _ = self.component else {
return
}
}
public func textViewDidBeginEditing(_ textView: UITextView) {
self.state?.updated(transition: Transition(animation: .curve(duration: 0.5, curve: .spring)).withUserData(AnimationHint(kind: .textFocusChanged)))
}
public func textViewDidEndEditing(_ textView: UITextView) {
self.state?.updated(transition: Transition(animation: .curve(duration: 0.5, curve: .spring)).withUserData(AnimationHint(kind: .textFocusChanged)))
}
@available(iOS 16.0, *)
public func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
let filteredActions: Set<String> = Set([
"com.apple.menu.format",
"com.apple.menu.replace"
])
let suggestedActions = suggestedActions.filter {
if let action = $0 as? UIMenu, filteredActions.contains(action.identifier.rawValue) {
return false
} else {
return true
}
}
guard let component = self.component, !textView.attributedText.string.isEmpty && textView.selectedRange.length > 0 else {
return UIMenu(children: suggestedActions)
}
let strings = component.strings
var actions: [UIAction] = [
UIAction(title: strings.TextFormat_Bold, image: nil) { [weak self] action in
if let self {
self.toggleAttribute(key: ChatTextInputAttributes.bold)
}
},
UIAction(title: strings.TextFormat_Italic, image: nil) { [weak self] action in
if let self {
self.toggleAttribute(key: ChatTextInputAttributes.italic)
}
},
UIAction(title: strings.TextFormat_Monospace, image: nil) { [weak self] action in
if let self {
self.toggleAttribute(key: ChatTextInputAttributes.monospace)
}
},
UIAction(title: strings.TextFormat_Link, image: nil) { [weak self] action in
if let self {
self.openLinkEditing()
}
},
UIAction(title: strings.TextFormat_Strikethrough, image: nil) { [weak self] action in
if let self {
self.toggleAttribute(key: ChatTextInputAttributes.strikethrough)
}
},
UIAction(title: strings.TextFormat_Underline, image: nil) { [weak self] action in
if let self {
self.toggleAttribute(key: ChatTextInputAttributes.underline)
}
}
]
actions.append(UIAction(title: strings.TextFormat_Spoiler, image: nil) { [weak self] action in
if let self {
self.toggleAttribute(key: ChatTextInputAttributes.spoiler)
}
})
var updatedActions = suggestedActions
let formatMenu = UIMenu(title: strings.TextFormat_Format, image: nil, children: actions)
updatedActions.insert(formatMenu, at: 1)
return UIMenu(children: updatedActions)
}
private func toggleAttribute(key: NSAttributedString.Key) {
self.updateInputState { state in
return state.addFormattingAttribute(attribute: key)
}
}
private func openLinkEditing() {
guard let component = self.component else {
return
}
let selectionRange = self.inputState.selectionRange
let text = self.inputState.inputText.attributedSubstring(from: NSRange(location: selectionRange.startIndex, length: selectionRange.count))
var link: String?
text.enumerateAttributes(in: NSMakeRange(0, text.length)) { attributes, _, _ in
if let linkAttribute = attributes[ChatTextInputAttributes.textUrl] as? ChatTextInputTextUrlAttribute {
link = linkAttribute.url
}
}
let controller = chatTextLinkEditController(sharedContext: component.context.sharedContext, account: component.context.account, text: text.string, link: link, apply: { [weak self] link in
if let self {
if let link = link {
self.updateInputState { state in
return state.addLinkAttribute(selectionRange: selectionRange, url: link)
}
}
// strongSelf.updateChatPresentationInterfaceState(animated: false, interactive: true, {
// return $0.updatedInputMode({ _ in return inputMode }).updatedInterfaceState({
// $0.withUpdatedEffectiveInputState(ChatTextInputState(inputText: $0.effectiveInputState.inputText, selectionRange: selectionRange.endIndex ..< selectionRange.endIndex))
// })
// })
}
})
component.present(controller)
}
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
//print("didScroll \(scrollView.bounds)")
}
public func getInputState() -> TextFieldComponent.InputState {
return self.inputState
}
public func getAttributedText() -> NSAttributedString {
Keyboard.applyAutocorrection(textView: self.textView)
return self.inputState.inputText
}
public func setAttributedText(_ string: NSAttributedString) {
self.updateInputState { _ in
return TextFieldComponent.InputState(inputText: string, selectionRange: string.length ..< string.length)
}
self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(AnimationHint(kind: .textChanged)))
}
public func activateInput() {
self.textView.becomeFirstResponder()
}
var spoilersRevealed = false
func updateEntities() {
guard let component = self.component else {
return
}
var spoilerRects: [CGRect] = []
var customEmojiRects: [(CGRect, ChatTextInputTextCustomEmojiAttribute)] = []
let textView = self.textView
if let attributedText = textView.attributedText {
let beginning = textView.beginningOfDocument
attributedText.enumerateAttributes(in: NSMakeRange(0, attributedText.length), options: [], using: { attributes, range, _ in
if let _ = attributes[ChatTextInputAttributes.spoiler] {
func addSpoiler(startIndex: Int, endIndex: Int) {
if let start = textView.position(from: beginning, offset: startIndex), let end = textView.position(from: start, offset: endIndex - startIndex), let textRange = textView.textRange(from: start, to: end) {
let textRects = textView.selectionRects(for: textRange)
for textRect in textRects {
if textRect.rect.width > 1.0 && textRect.rect.size.height > 1.0 {
spoilerRects.append(textRect.rect.insetBy(dx: 1.0, dy: 1.0).offsetBy(dx: 0.0, dy: 1.0))
}
}
}
}
var startIndex: Int?
var currentIndex: Int?
let nsString = (attributedText.string as NSString)
nsString.enumerateSubstrings(in: range, options: .byComposedCharacterSequences) { substring, range, _, _ in
if let substring = substring, substring.rangeOfCharacter(from: .whitespacesAndNewlines) != nil {
if let currentStartIndex = startIndex {
startIndex = nil
let endIndex = range.location
addSpoiler(startIndex: currentStartIndex, endIndex: endIndex)
}
} else if startIndex == nil {
startIndex = range.location
}
currentIndex = range.location + range.length
}
if let currentStartIndex = startIndex, let currentIndex = currentIndex {
startIndex = nil
let endIndex = currentIndex
addSpoiler(startIndex: currentStartIndex, endIndex: endIndex)
}
}
if let value = attributes[ChatTextInputAttributes.customEmoji] as? ChatTextInputTextCustomEmojiAttribute {
if let start = textView.position(from: beginning, offset: range.location), let end = textView.position(from: start, offset: range.length), let textRange = textView.textRange(from: start, to: end) {
let textRects = textView.selectionRects(for: textRange)
for textRect in textRects {
customEmojiRects.append((textRect.rect, value))
break
}
}
}
})
}
if !spoilerRects.isEmpty {
let spoilerView: InvisibleInkDustView
if let current = self.spoilerView {
spoilerView = current
} else {
spoilerView = InvisibleInkDustView(textNode: nil, enableAnimations: component.context.sharedContext.energyUsageSettings.fullTranslucency)
spoilerView.alpha = self.spoilersRevealed ? 0.0 : 1.0
spoilerView.isUserInteractionEnabled = false
self.textView.addSubview(spoilerView)
self.spoilerView = spoilerView
}
spoilerView.frame = CGRect(origin: CGPoint(), size: self.textView.contentSize)
spoilerView.update(size: self.textView.contentSize, color: component.textColor, textColor: component.textColor, rects: spoilerRects, wordRects: spoilerRects)
} else if let spoilerView = self.spoilerView {
spoilerView.removeFromSuperview()
self.spoilerView = nil
}
if !customEmojiRects.isEmpty {
let customEmojiContainerView: CustomEmojiContainerView
if let current = self.customEmojiContainerView {
customEmojiContainerView = current
} else {
customEmojiContainerView = CustomEmojiContainerView(emojiViewProvider: { [weak self] emoji in
guard let strongSelf = self, let emojiViewProvider = strongSelf.emojiViewProvider else {
return nil
}
return emojiViewProvider(emoji)
})
customEmojiContainerView.isUserInteractionEnabled = false
self.textView.addSubview(customEmojiContainerView)
self.customEmojiContainerView = customEmojiContainerView
}
customEmojiContainerView.update(fontSize: component.fontSize, textColor: component.textColor, emojiRects: customEmojiRects)
} else if let customEmojiContainerView = self.customEmojiContainerView {
customEmojiContainerView.removeFromSuperview()
self.customEmojiContainerView = nil
}
}
func update(component: TextFieldComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
self.component = component
self.state = state
if self.emojiViewProvider == nil {
self.emojiViewProvider = { [weak self] emoji in
guard let component = self?.component else {
return UIView()
}
let pointSize = floor(24.0 * 1.3)
return EmojiTextAttachmentView(context: component.context, userLocation: .other, emoji: emoji, file: emoji.file, cache: component.context.animationCache, renderer: component.context.animationRenderer, placeholderColor: UIColor.white.withAlphaComponent(0.12), pointSize: CGSize(width: pointSize, height: pointSize))
}
}
if self.textView.textContainerInset != component.insets {
self.textView.textContainerInset = component.insets
}
self.textContainer.size = CGSize(width: availableSize.width - self.textView.textContainerInset.left - self.textView.textContainerInset.right, height: 10000000.0)
self.layoutManager.ensureLayout(for: self.textContainer)
let boundingRect = self.layoutManager.boundingRect(forGlyphRange: NSRange(location: 0, length: self.textStorage.length), in: self.textContainer)
let size = CGSize(width: availableSize.width, height: min(availableSize.height, ceil(boundingRect.height) + self.textView.textContainerInset.top + self.textView.textContainerInset.bottom))
let wasEditing = component.externalState.isEditing
let isEditing = self.textView.isFirstResponder
let refreshScrolling = self.textView.bounds.size != size
self.textView.frame = CGRect(origin: CGPoint(), size: size)
self.textView.panGestureRecognizer.isEnabled = isEditing
if refreshScrolling {
if isEditing {
if wasEditing {
self.textView.setContentOffset(CGPoint(x: 0.0, y: max(0.0, self.textView.contentSize.height - self.textView.bounds.height)), animated: false)
}
} else {
self.textView.setContentOffset(CGPoint(x: 0.0, y: 0.0), animated: true)
}
}
component.externalState.hasText = self.textStorage.length != 0
component.externalState.isEditing = isEditing
if component.hideKeyboard {
if self.textView.inputView == nil {
self.textView.inputView = EmptyInputView()
if self.textView.isFirstResponder {
self.textView.reloadInputViews()
}
}
} else {
if self.textView.inputView != nil {
self.textView.inputView = nil
if self.textView.isFirstResponder {
self.textView.reloadInputViews()
}
}
}
return size
}
}
public func makeView() -> View {
return View(frame: CGRect())
}
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
extension TextFieldComponent.InputState {
public func insertText(_ text: NSAttributedString) -> TextFieldComponent.InputState {
let inputText = NSMutableAttributedString(attributedString: self.inputText)
let range = self.selectionRange
inputText.replaceCharacters(in: NSMakeRange(range.lowerBound, range.count), with: text)
let selectionPosition = range.lowerBound + (text.string as NSString).length
return TextFieldComponent.InputState(inputText: inputText, selectionRange: selectionPosition ..< selectionPosition)
}
public func addFormattingAttribute(attribute: NSAttributedString.Key) -> TextFieldComponent.InputState {
if !self.selectionRange.isEmpty {
let nsRange = NSRange(location: self.selectionRange.lowerBound, length: self.selectionRange.count)
var addAttribute = true
var attributesToRemove: [NSAttributedString.Key] = []
self.inputText.enumerateAttributes(in: nsRange, options: .longestEffectiveRangeNotRequired) { attributes, range, stop in
for (key, _) in attributes {
if key == attribute && range == nsRange {
addAttribute = false
attributesToRemove.append(key)
}
}
}
let result = NSMutableAttributedString(attributedString: self.inputText)
for attribute in attributesToRemove {
result.removeAttribute(attribute, range: nsRange)
}
if addAttribute {
result.addAttribute(attribute, value: true as Bool, range: nsRange)
}
return TextFieldComponent.InputState(inputText: result, selectionRange: self.selectionRange)
} else {
return self
}
}
public func clearFormattingAttributes() -> TextFieldComponent.InputState {
if !self.selectionRange.isEmpty {
let nsRange = NSRange(location: self.selectionRange.lowerBound, length: self.selectionRange.count)
var attributesToRemove: [NSAttributedString.Key] = []
self.inputText.enumerateAttributes(in: nsRange, options: .longestEffectiveRangeNotRequired) { attributes, range, stop in
for (key, _) in attributes {
attributesToRemove.append(key)
}
}
let result = NSMutableAttributedString(attributedString: self.inputText)
for attribute in attributesToRemove {
result.removeAttribute(attribute, range: nsRange)
}
return TextFieldComponent.InputState(inputText: result, selectionRange: self.selectionRange)
} else {
return self
}
}
public func addLinkAttribute(selectionRange: Range<Int>, url: String) -> TextFieldComponent.InputState {
if !selectionRange.isEmpty {
let nsRange = NSRange(location: selectionRange.lowerBound, length: selectionRange.count)
var linkRange = nsRange
var attributesToRemove: [(NSAttributedString.Key, NSRange)] = []
self.inputText.enumerateAttributes(in: nsRange, options: .longestEffectiveRangeNotRequired) { attributes, range, stop in
for (key, _) in attributes {
if key == ChatTextInputAttributes.textUrl {
attributesToRemove.append((key, range))
linkRange = linkRange.union(range)
} else {
attributesToRemove.append((key, nsRange))
}
}
}
let result = NSMutableAttributedString(attributedString: self.inputText)
for (attribute, range) in attributesToRemove {
result.removeAttribute(attribute, range: range)
}
result.addAttribute(ChatTextInputAttributes.textUrl, value: ChatTextInputTextUrlAttribute(url: url), range: nsRange)
return TextFieldComponent.InputState(inputText: result, selectionRange: selectionRange)
} else {
return self
}
}
}