2024-05-24 15:52:01 +04:00

1514 lines
59 KiB
Swift

import Foundation
import UIKit
import AsyncDisplayKit
import Display
import AppBundle
import ChatInputTextViewImpl
import MessageInlineBlockBackgroundView
import TextFormat
import AccountContext
public protocol ChatInputTextNodeDelegate: AnyObject {
func chatInputTextNodeDidUpdateText()
func chatInputTextNodeShouldReturn() -> Bool
func chatInputTextNodeDidChangeSelection(dueToEditing: Bool)
func chatInputTextNodeDidBeginEditing()
func chatInputTextNodeDidFinishEditing()
func chatInputTextNodeBackspaceWhileEmpty()
@available(iOS 13.0, *)
func chatInputTextNodeMenu(forTextRange textRange: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu
func chatInputTextNode(shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool
func chatInputTextNodeShouldCopy() -> Bool
func chatInputTextNodeShouldPaste() -> Bool
func chatInputTextNodeShouldRespondToAction(action: Selector) -> Bool
func chatInputTextNodeTargetForAction(action: Selector) -> ChatInputTextNode.TargetForAction?
}
@available(iOS 15.0, *)
private final class ChatInputTextLayoutManager: NSTextLayoutManager {
weak var contentStorage: ChatInputTextContentStorage?
init(contentStorage: ChatInputTextContentStorage) {
self.contentStorage = contentStorage
super.init()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@discardableResult
override func enumerateTextLayoutFragments(from location: NSTextLocation?, options: NSTextLayoutFragment.EnumerationOptions = [], using block: (NSTextLayoutFragment) -> Bool) -> NSTextLocation? {
/*guard let contentStorage = self.contentStorage else {
return nil
}
var layoutFragments: [NSTextLayoutFragment] = []
contentStorage.enumerateTextElements(from: contentStorage.documentRange.location, options: [], using: { textElement in
if let textElement = textElement as? NSTextParagraph {
let layoutFragment = BubbleLayoutFragment(textElement: textElement, range: textElement.elementRange)
layoutFragments.append(layoutFragment)
} else {
assertionFailure()
}
return true
})
/*super.enumerateTextLayoutFragments(from: self.documentRange.location, options: [.ensuresLayout, .ensuresExtraLineFragment], using: { fragment in
layoutFragments.append(fragment)
return true
})*/
let quoteId: (NSTextLayoutFragment) -> ObjectIdentifier? = { fragment in
guard let contentStorage = self.contentStorage else {
return nil
}
let lowerBound = contentStorage.offset(from: contentStorage.documentRange.location, to: fragment.rangeInElement.location)
let upperBound = contentStorage.offset(from: contentStorage.documentRange.location, to: fragment.rangeInElement.endLocation)
if let textStorage = contentStorage.textStorage, lowerBound != NSNotFound, upperBound != NSNotFound, lowerBound >= 0, upperBound <= textStorage.length {
let fragmentString = textStorage.attributedSubstring(from: NSRange(location: lowerBound, length: upperBound - lowerBound))
if fragmentString.length != 0, let attribute = fragmentString.attribute(NSAttributedString.Key(rawValue: "Attribute__Blockquote"), at: 0, effectiveRange: nil) as? ChatTextInputTextQuoteAttribute {
return ObjectIdentifier(attribute)
}
}
return nil
}
return super.enumerateTextLayoutFragments(from: location, options: options, using: { fragment in
var fragment = fragment
if let index = layoutFragments.firstIndex(where: { $0.rangeInElement.isEqual(to: fragment.rangeInElement) }) {
fragment = layoutFragments[index]
if let fragment = fragment as? BubbleLayoutFragment {
if let fragmentQuoteId = quoteId(fragment) {
if index == 0 {
fragment.quoteIsFirst = false
} else if quoteId(layoutFragments[index - 1]) == fragmentQuoteId {
fragment.quoteIsFirst = false
} else {
fragment.quoteIsFirst = true
}
if index == layoutFragments.count - 1 {
fragment.quoteIsLast = false
} else if quoteId(layoutFragments[index + 1]) == fragmentQuoteId {
fragment.quoteIsLast = false
} else {
fragment.quoteIsLast = true
}
} else {
fragment.quoteIsFirst = false
fragment.quoteIsLast = false
}
}
} else if layoutFragments.isEmpty {
} else {
assertionFailure()
}
return block(fragment)
})*/
return super.enumerateTextLayoutFragments(from: location, options: options, using: block)
}
}
@available(iOS 15.0, *)
private class BubbleLayoutFragment: NSTextLayoutFragment {
var quoteIsFirst: Bool = false
var quoteIsLast: Bool = false
override var leadingPadding: CGFloat {
return 0.0
}
override var trailingPadding: CGFloat {
return 0.0
}
override var topMargin: CGFloat {
return self.quoteIsFirst ? 10.0 : 0.0
}
override var bottomMargin: CGFloat {
return self.quoteIsLast ? 10.0 : 0.0
}
override var layoutFragmentFrame: CGRect {
let result = super.layoutFragmentFrame
return result
}
override var renderingSurfaceBounds: CGRect {
return super.renderingSurfaceBounds
}
private var tightTextBounds: CGRect {
var fragmentTextBounds = CGRect.null
for lineFragment in textLineFragments {
let lineFragmentBounds = lineFragment.typographicBounds
if fragmentTextBounds.isNull {
fragmentTextBounds = lineFragmentBounds
} else {
fragmentTextBounds = fragmentTextBounds.union(lineFragmentBounds)
}
}
return fragmentTextBounds
}
// Return the bounding rect of the chat bubble, in the space of the first line fragment.
private var bubbleRect: CGRect { return tightTextBounds.insetBy(dx: -3, dy: -3) }
private var bubbleCornerRadius: CGFloat { return 20 }
private var bubbleColor: UIColor { return .systemIndigo.withAlphaComponent(0.5) }
private func createBubblePath(with ctx: CGContext) -> CGPath {
let bubbleRect = self.bubbleRect
let rect = min(bubbleCornerRadius, bubbleRect.size.height / 2, bubbleRect.size.width / 2)
return CGPath(roundedRect: bubbleRect, cornerWidth: rect, cornerHeight: rect, transform: nil)
}
override func draw(at renderingOrigin: CGPoint, in ctx: CGContext) {
// Draw the bubble and debug outline.
ctx.saveGState()
let bubblePath = createBubblePath(with: ctx)
ctx.addPath(bubblePath)
ctx.setFillColor(bubbleColor.cgColor)
ctx.fillPath()
ctx.restoreGState()
var offset: CGFloat = 0.0
for textLineFragment in self.textLineFragments {
textLineFragment.draw(at: CGPoint(x: renderingOrigin.x, y: renderingOrigin.y + offset), in: ctx)
offset += textLineFragment.typographicBounds.height
}
}
}
open class ChatInputTextNode: ASDisplayNode {
public final class TargetForAction {
public let target: Any?
public init(target: Any?) {
self.target = target
}
}
public weak var delegate: ChatInputTextNodeDelegate? {
didSet {
self.textView.customDelegate = self.delegate
}
}
public var textView: ChatInputTextView {
return self.view as! ChatInputTextView
}
public var keyboardAppearance: UIKeyboardAppearance {
get {
return self.textView.keyboardAppearance
}
set {
guard newValue != self.keyboardAppearance else {
return
}
self.textView.keyboardAppearance = newValue
self.textView.reloadInputViews()
}
}
public var initialPrimaryLanguage: String? {
get {
return self.textView.initialPrimaryLanguage
} set(value) {
self.textView.initialPrimaryLanguage = value
}
}
public func isCurrentlyEmoji() -> Bool {
return false
}
public var textInputMode: UITextInputMode? {
return self.textView.textInputMode
}
public var selectedRange: NSRange {
get {
return self.textView.selectedRange
} set(value) {
if self.textView.selectedRange != value {
self.textView.selectedRange = value
}
}
}
public var attributedText: NSAttributedString? {
get {
return self.textView.attributedText
} set(value) {
self.textView.attributedText = value
}
}
public var isRTL: Bool {
return self.textView.isRTL
}
public var selectionRect: CGRect {
guard let range = self.textView.selectedTextRange else {
return self.textView.bounds
}
return self.textView.firstRect(for: range)
}
public var textContainerInset: UIEdgeInsets {
get {
return self.textView.defaultTextContainerInset
} set(value) {
let targetValue = UIEdgeInsets(top: value.top, left: value.left, bottom: value.bottom, right: value.right)
if self.textView.defaultTextContainerInset != value {
self.textView.defaultTextContainerInset = targetValue
}
}
}
public init(disableTiling: Bool = false) {
super.init()
self.setViewBlock({
return ChatInputTextView(disableTiling: disableTiling)
})
}
public func resetInitialPrimaryLanguage() {
}
public func textHeightForWidth(_ width: CGFloat, rightInset: CGFloat) -> CGFloat {
return self.textView.textHeightForWidth(width, rightInset: rightInset)
}
public func updateLayout(size: CGSize) {
self.textView.updateLayout(size: size)
}
}
private final class ChatInputTextContainer: NSTextContainer {
var rightInset: CGFloat = 0.0
override var isSimpleRectangularTextContainer: Bool {
return false
}
override init(size: CGSize) {
super.init(size: size)
}
required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func lineFragmentRect(forProposedRect proposedRect: CGRect, at characterIndex: Int, writingDirection baseWritingDirection: NSWritingDirection, remaining remainingRect: UnsafeMutablePointer<CGRect>?) -> CGRect {
var result = super.lineFragmentRect(forProposedRect: proposedRect, at: characterIndex, writingDirection: baseWritingDirection, remaining: remainingRect)
result.origin.x -= 5.0
result.size.width -= 5.0
result.size.width -= self.rightInset
var attributedString: NSAttributedString?
if #available(iOS 15.0, *), let textLayoutManager = self.textLayoutManager as? ChatInputTextLayoutManager {
attributedString = textLayoutManager.contentStorage?.attributedString
} else if let textStorage = self.layoutManager?.textStorage {
attributedString = textStorage
}
if let textStorage = attributedString {
let string: NSString = textStorage.string as NSString
let index = Int(characterIndex)
if index >= 0 && index < string.length {
let attributes = textStorage.attributes(at: index, effectiveRange: nil)
let blockQuote = attributes[NSAttributedString.Key(rawValue: "Attribute__Blockquote")] as? ChatTextInputTextQuoteAttribute
if let blockQuote {
result.origin.x += 9.0
result.size.width -= 9.0
result.size.width -= 7.0
var isFirstLine = false
if index == 0 {
isFirstLine = true
} else {
let previousAttributes = textStorage.attributes(at: index - 1, effectiveRange: nil)
let previousBlockQuote = previousAttributes[NSAttributedString.Key(rawValue: "Attribute__Blockquote")] as? NSObject
if let previousBlockQuote {
if !blockQuote.isEqual(previousBlockQuote) {
isFirstLine = true
}
} else {
isFirstLine = true
}
}
if isFirstLine, case .quote = blockQuote.kind {
result.size.width -= 18.0
}
}
}
}
result.size.width = max(1.0, result.size.width)
return result
}
}
private final class ChatInputLegacyLayoutManager: NSLayoutManager {
override init() {
super.init()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func notShownAttribute(forGlyphAt glyphIndex: Int) -> Bool {
return true
}
override func drawGlyphs(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) {
guard let context = UIGraphicsGetCurrentContext() else {
super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin)
return
}
let _ = context
/*for i in glyphsToShow.lowerBound ..< glyphsToShow.upperBound {
let rect = self.lineFragmentRect(forGlyphAt: i, effectiveRange: nil, withoutAdditionalLayout: true)
context.setAlpha(max(0.0, min(1.0, rect.minY / 200.0)))
let location = self.location(forGlyphAt: i)
super.drawGlyphs(forGlyphRange: NSRange(location: i, length: 1), at: location)
}
context.setAlpha(1.0)*/
super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin)
}
}
private struct DisplayBlockQuote {
var id: Int
var boundingRect: CGRect
var kind: ChatTextInputTextQuoteAttribute.Kind
var isCollapsed: Bool
var range: NSRange
init(id: Int, boundingRect: CGRect, kind: ChatTextInputTextQuoteAttribute.Kind, isCollapsed: Bool, range: NSRange) {
self.id = id
self.boundingRect = boundingRect
self.kind = kind
self.isCollapsed = isCollapsed
self.range = range
}
}
private protocol ChatInputTextInternal: AnyObject {
var textContainer: ChatInputTextContainer { get }
var defaultTextContainerInset: UIEdgeInsets { get set }
var updateDisplayElements: (() -> Void)? { get set }
var attributedString: NSAttributedString? { get }
func invalidateLayout()
func setAttributedString(attributedString: NSAttributedString)
func textSize() -> CGSize
func currentTextBoundingRect() -> CGRect
func currentTextLastLineBoundingRect() -> CGRect
func displayBlockQuotes() -> [DisplayBlockQuote]
}
private final class ChatInputTextLegacyInternal: NSObject, ChatInputTextInternal, NSLayoutManagerDelegate, NSTextStorageDelegate {
let textContainer: ChatInputTextContainer
let customTextStorage: NSTextStorage
let customLayoutManager: ChatInputLegacyLayoutManager
var defaultTextContainerInset: UIEdgeInsets = UIEdgeInsets()
var updateDisplayElements: (() -> Void)?
var attributedString: NSAttributedString? {
return self.customTextStorage
}
override init() {
self.textContainer = ChatInputTextContainer(size: CGSize(width: 100.0, height: 100000.0))
self.customTextStorage = NSTextStorage()
self.customLayoutManager = ChatInputLegacyLayoutManager()
self.customTextStorage.addLayoutManager(self.customLayoutManager)
self.customLayoutManager.addTextContainer(self.textContainer)
super.init()
self.textContainer.widthTracksTextView = false
self.textContainer.heightTracksTextView = false
self.customLayoutManager.delegate = self
self.customTextStorage.delegate = self
}
@objc func layoutManager(_ layoutManager: NSLayoutManager, paragraphSpacingBeforeGlyphAt glyphIndex: Int, withProposedLineFragmentRect rect: CGRect) -> CGFloat {
guard let textStorage = layoutManager.textStorage else {
return 0.0
}
let characterIndex = Int(layoutManager.characterIndexForGlyph(at: glyphIndex))
if characterIndex < 0 || characterIndex >= textStorage.length {
return 0.0
}
let attributes = textStorage.attributes(at: characterIndex, effectiveRange: nil)
guard let blockQuote = attributes[NSAttributedString.Key("Attribute__Blockquote")] as? NSObject else {
return 0.0
}
if characterIndex != 0 {
let previousAttributes = textStorage.attributes(at: characterIndex - 1, effectiveRange: nil)
let previousBlockQuote = previousAttributes[NSAttributedString.Key("Attribute__Blockquote")] as? NSObject
if let previousBlockQuote, blockQuote.isEqual(previousBlockQuote) {
return 0.0
}
}
return 8.0
}
@objc func layoutManager(_ layoutManager: NSLayoutManager, paragraphSpacingAfterGlyphAt glyphIndex: Int, withProposedLineFragmentRect rect: CGRect) -> CGFloat {
guard let textStorage = layoutManager.textStorage else {
return 0.0
}
var characterIndex = Int(layoutManager.characterIndexForGlyph(at: glyphIndex))
characterIndex -= 1
if characterIndex < 0 {
characterIndex = 0
}
if characterIndex < 0 || characterIndex >= textStorage.length {
return 0.0
}
let attributes = textStorage.attributes(at: characterIndex, effectiveRange: nil)
guard let blockQuote = attributes[NSAttributedString.Key("Attribute__Blockquote")] as? NSObject else {
return 0.0
}
if characterIndex + 1 < textStorage.length {
let nextAttributes = textStorage.attributes(at: characterIndex + 1, effectiveRange: nil)
let nextBlockQuote = nextAttributes[NSAttributedString.Key("Attribute__Blockquote")] as? NSObject
if let nextBlockQuote, blockQuote.isEqual(nextBlockQuote) {
return 0.0
}
}
return 8.0
}
@objc func layoutManager(_ layoutManager: NSLayoutManager, didCompleteLayoutFor textContainer: NSTextContainer?, atEnd layoutFinishedFlag: Bool) {
if textContainer !== self.textContainer {
return
}
self.updateDisplayElements?()
}
func invalidateLayout() {
self.customLayoutManager.invalidateLayout(forCharacterRange: NSRange(location: 0, length: self.customTextStorage.length), actualCharacterRange: nil)
self.customLayoutManager.ensureLayout(for: self.textContainer)
}
func setAttributedString(attributedString: NSAttributedString) {
self.customTextStorage.setAttributedString(attributedString)
}
func textSize() -> CGSize {
return self.customLayoutManager.usedRect(for: self.textContainer).size
}
func currentTextBoundingRect() -> CGRect {
let glyphRange = self.customLayoutManager.glyphRange(forCharacterRange: NSRange(location: 0, length: self.customTextStorage.length), actualCharacterRange: nil)
var boundingRect = CGRect()
var startIndex = glyphRange.lowerBound
while startIndex < glyphRange.upperBound {
var effectiveRange = NSRange(location: NSNotFound, length: 0)
let rect = self.customLayoutManager.lineFragmentUsedRect(forGlyphAt: startIndex, effectiveRange: &effectiveRange)
if boundingRect.isEmpty {
boundingRect = rect
} else {
boundingRect = boundingRect.union(rect)
}
if effectiveRange.location != NSNotFound {
startIndex = max(startIndex + 1, effectiveRange.upperBound)
} else {
break
}
}
return boundingRect
}
func currentTextLastLineBoundingRect() -> CGRect {
let glyphRange = self.customLayoutManager.glyphRange(forCharacterRange: NSRange(location: 0, length: self.customTextStorage.length), actualCharacterRange: nil)
var boundingRect = CGRect()
var startIndex = glyphRange.lowerBound
while startIndex < glyphRange.upperBound {
var effectiveRange = NSRange(location: NSNotFound, length: 0)
let rect = self.customLayoutManager.lineFragmentUsedRect(forGlyphAt: startIndex, effectiveRange: &effectiveRange)
boundingRect = rect
if effectiveRange.location != NSNotFound {
startIndex = max(startIndex + 1, effectiveRange.upperBound)
} else {
break
}
}
return boundingRect
}
func displayBlockQuotes() -> [DisplayBlockQuote] {
var result: [DisplayBlockQuote] = []
var blockQuoteIndex = 0
self.customTextStorage.enumerateAttribute(NSAttributedString.Key(rawValue: "Attribute__Blockquote"), in: NSRange(location: 0, length: self.customTextStorage.length), using: { value, range, _ in
if let value = value as? ChatTextInputTextQuoteAttribute {
let glyphRange = self.customLayoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil)
if self.customLayoutManager.isValidGlyphIndex(glyphRange.location) && self.customLayoutManager.isValidGlyphIndex(glyphRange.location + glyphRange.length - 1) {
} else {
return
}
let id = blockQuoteIndex
var boundingRect = CGRect()
var startIndex = glyphRange.lowerBound
while startIndex < glyphRange.upperBound {
var effectiveRange = NSRange(location: NSNotFound, length: 0)
let rect = self.customLayoutManager.lineFragmentUsedRect(forGlyphAt: startIndex, effectiveRange: &effectiveRange)
if boundingRect.isEmpty {
boundingRect = rect
} else {
boundingRect = boundingRect.union(rect)
}
if effectiveRange.location != NSNotFound {
startIndex = max(startIndex + 1, effectiveRange.upperBound)
} else {
break
}
}
boundingRect.origin.y += self.defaultTextContainerInset.top
boundingRect.origin.x -= 4.0
boundingRect.size.width += 4.0
if case .quote = value.kind {
boundingRect.size.width += 18.0
boundingRect.size.width = min(boundingRect.size.width, self.textContainer.size.width - 18.0)
}
boundingRect.size.width = min(boundingRect.size.width, self.textContainer.size.width)
boundingRect.origin.y -= 4.0
boundingRect.size.height += 8.0
result.append(DisplayBlockQuote(id: id, boundingRect: boundingRect, kind: value.kind, isCollapsed: value.isCollapsed, range: range))
blockQuoteIndex += 1
}
})
self.customTextStorage.enumerateAttribute(NSAttributedString.Key.attachment, in: NSRange(location: 0, length: self.customTextStorage.length), using: { value, range, _ in
if let _ = value as? ChatInputTextCollapsedQuoteAttachment {
let glyphRange = self.customLayoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil)
if self.customLayoutManager.isValidGlyphIndex(glyphRange.location) && self.customLayoutManager.isValidGlyphIndex(glyphRange.location + glyphRange.length - 1) {
} else {
return
}
let id = blockQuoteIndex
var boundingRect = CGRect()
var startIndex = glyphRange.lowerBound
while startIndex < glyphRange.upperBound {
var effectiveRange = NSRange(location: NSNotFound, length: 0)
let rect = self.customLayoutManager.lineFragmentUsedRect(forGlyphAt: startIndex, effectiveRange: &effectiveRange)
if boundingRect.isEmpty {
boundingRect = rect
} else {
boundingRect = boundingRect.union(rect)
}
if effectiveRange.location != NSNotFound {
startIndex = max(startIndex + 1, effectiveRange.upperBound)
} else {
break
}
}
boundingRect.origin.y += self.defaultTextContainerInset.top
boundingRect.origin.x += 5.0
boundingRect.size.width += 4.0
boundingRect.size.width += 18.0
boundingRect.size.width = min(boundingRect.size.width, self.textContainer.size.width - 18.0)
boundingRect.size.width = min(boundingRect.size.width, self.textContainer.size.width)
boundingRect.origin.y += 4.0
boundingRect.size.height -= 8.0
result.append(DisplayBlockQuote(id: id, boundingRect: boundingRect, kind: .quote, isCollapsed: true, range: range))
blockQuoteIndex += 1
}
})
return result
}
}
@available(iOS 15.0, *)
private final class ChatInputTextContentStorage: NSTextContentStorage {
}
@available(iOS 15.0, *)
private final class ChatInputTextNewInternal: NSObject, ChatInputTextInternal, NSTextContentStorageDelegate, NSTextLayoutManagerDelegate {
let textContainer: ChatInputTextContainer
let contentStorage: ChatInputTextContentStorage
let customLayoutManager: ChatInputTextLayoutManager
var defaultTextContainerInset: UIEdgeInsets = UIEdgeInsets()
var updateDisplayElements: (() -> Void)?
var attributedString: NSAttributedString? {
return self.contentStorage.attributedString
}
override init() {
self.textContainer = ChatInputTextContainer(size: CGSize(width: 100.0, height: 100000.0))
self.contentStorage = ChatInputTextContentStorage()
self.customLayoutManager = ChatInputTextLayoutManager(contentStorage: self.contentStorage)
self.contentStorage.addTextLayoutManager(self.customLayoutManager)
self.customLayoutManager.textContainer = self.textContainer
super.init()
self.contentStorage.delegate = self
self.customLayoutManager.delegate = self
}
func invalidateLayout() {
self.customLayoutManager.invalidateLayout(for: self.contentStorage.documentRange)
self.customLayoutManager.ensureLayout(for: self.contentStorage.documentRange)
}
func setAttributedString(attributedString: NSAttributedString) {
self.contentStorage.attributedString = attributedString
}
func textSize() -> CGSize {
return self.currentTextBoundingRect().size
}
func currentTextBoundingRect() -> CGRect {
var boundingRect = CGRect()
self.customLayoutManager.enumerateTextLayoutFragments(from: self.contentStorage.documentRange.location, options: [.ensuresLayout, .ensuresExtraLineFragment], using: { fragment in
let fragmentFrame = fragment.layoutFragmentFrame
if boundingRect.isEmpty {
boundingRect = fragmentFrame
} else {
boundingRect = boundingRect.union(fragmentFrame)
}
return true
})
return boundingRect
}
func currentTextLastLineBoundingRect() -> CGRect {
var boundingRect = CGRect()
self.customLayoutManager.enumerateTextLayoutFragments(from: self.contentStorage.documentRange.location, options: [.ensuresLayout, .ensuresExtraLineFragment], using: { fragment in
let fragmentFrame = fragment.layoutFragmentFrame
for textLineFragment in fragment.textLineFragments {
boundingRect = textLineFragment.typographicBounds.offsetBy(dx: fragmentFrame.minX, dy: fragmentFrame.minY)
}
return true
})
return boundingRect
}
@objc func textLayoutManager(_ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: NSTextLocation, in textElement: NSTextElement) -> NSTextLayoutFragment {
let layoutFragment = BubbleLayoutFragment(textElement: textElement, range: textElement.elementRange)
return layoutFragment
}
func displayBlockQuotes() -> [DisplayBlockQuote] {
var nextId = 0
var result: [ObjectIdentifier: DisplayBlockQuote] = [:]
self.customLayoutManager.enumerateTextLayoutFragments(from: self.contentStorage.documentRange.location, options: [.ensuresLayout, .ensuresExtraLineFragment], using: { fragment in
let lowerBound = self.contentStorage.offset(from: self.contentStorage.documentRange.location, to: fragment.rangeInElement.location)
let upperBound = self.contentStorage.offset(from: self.contentStorage.documentRange.location, to: fragment.rangeInElement.endLocation)
if let textStorage = self.contentStorage.textStorage, lowerBound != NSNotFound, upperBound != NSNotFound, lowerBound >= 0, upperBound <= textStorage.length {
let fragmentRange = NSRange(location: lowerBound, length: upperBound - lowerBound)
let fragmentString = textStorage.attributedSubstring(from: fragmentRange)
var fragmentFrame = fragment.layoutFragmentFrame
if fragmentString.length != 0, let attribute = fragmentString.attribute(NSAttributedString.Key(rawValue: "Attribute__Blockquote"), at: 0, effectiveRange: nil) as? ChatTextInputTextQuoteAttribute {
fragmentFrame.origin.y += self.defaultTextContainerInset.top
fragmentFrame.origin.x -= 4.0
fragmentFrame.size.width += 4.0
if case .quote = attribute.kind {
fragmentFrame.size.width += 18.0
fragmentFrame.size.width = min(fragmentFrame.size.width, self.textContainer.size.width - 18.0)
}
fragmentFrame.size.width = min(fragmentFrame.size.width, self.textContainer.size.width)
let quoteId = ObjectIdentifier(attribute)
if var current = result[quoteId] {
current.boundingRect = current.boundingRect.union(fragmentFrame)
let newLowerBound = min(current.range.lowerBound, fragmentRange.lowerBound)
let newUpperBound = max(current.range.upperBound, fragmentRange.upperBound)
current.range = NSRange(location: newLowerBound, length: newUpperBound - newLowerBound)
result[quoteId] = current
} else {
let id = nextId
nextId += 1
result[quoteId] = DisplayBlockQuote(id: id, boundingRect: fragmentFrame, kind: attribute.kind, isCollapsed: attribute.isCollapsed, range: fragmentRange)
}
}
}
return true
})
return Array(result.values).sorted(by: { lhs, rhs in
return lhs.boundingRect.minY < rhs.boundingRect.minY
})
}
}
private let registeredViewProvider: Void = {
if #available(iOS 15.0, *) {
NSTextAttachment.registerViewProviderClass(ChatInputTextCollapsedQuoteAttachmentImpl.ViewProvider.self, forFileType: "public.data")
}
}()
public final class ChatInputTextCollapsedQuoteAttachmentImpl: NSTextAttachment, ChatInputTextCollapsedQuoteAttachment {
final class View: UIView {
let attachment: ChatInputTextCollapsedQuoteAttachmentImpl
let textNode: ImmediateTextNode
init(attachment: ChatInputTextCollapsedQuoteAttachmentImpl) {
self.attachment = attachment
self.textNode = ImmediateTextNode()
self.textNode.maximumNumberOfLines = 3
super.init(frame: CGRect())
self.addSubview(self.textNode.view)
}
required init(coder: NSCoder) {
preconditionFailure()
}
static func calculateSize(attachment: ChatInputTextCollapsedQuoteAttachmentImpl, constrainedSize: CGSize) -> CGSize {
guard let context = attachment.attributes.context as? AccountContext else {
return CGSize(width: 10.0, height: 10.0)
}
/*let renderingText = NSMutableAttributedString(attributedString: attachment.text)
renderingText.addAttribute(.font, value: attachment.attributes.font, range: NSRange(location: 0, length: renderingText.length))
renderingText.addAttribute(.foregroundColor, value: attachment.attributes.textColor, range: NSRange(location: 0, length: renderingText.length))*/
let renderingText = textAttributedStringForStateText(
context: context,
stateText: attachment.text,
fontSize: attachment.attributes.fontSize,
textColor: attachment.attributes.textColor,
accentTextColor: attachment.attributes.accentTextColor,
writingDirection: nil,
spoilersRevealed: false,
availableEmojis: Set(context.animatedEmojiStickersValue.keys),
emojiViewProvider: nil,
makeCollapsedQuoteAttachment: nil
)
let textNode = ImmediateTextNode()
textNode.maximumNumberOfLines = 3
textNode.attributedText = renderingText
textNode.cutout = TextNodeCutout(topRight: CGSize(width: 40.0, height: 10.0))
let layoutInfo = textNode.updateLayoutFullInfo(CGSize(width: constrainedSize.width - 9.0, height: constrainedSize.height))
return CGSize(width: constrainedSize.width, height: 8.0 + layoutInfo.size.height + 8.0)
}
override func layoutSubviews() {
super.layoutSubviews()
guard let context = self.attachment.attributes.context as? AccountContext else {
return
}
let renderingText = textAttributedStringForStateText(
context: context, stateText: self.attachment.text,
fontSize: self.attachment.attributes.fontSize,
textColor: self.attachment.attributes.textColor,
accentTextColor: self.attachment.attributes.accentTextColor,
writingDirection: nil,
spoilersRevealed: false,
availableEmojis: Set(context.animatedEmojiStickersValue.keys),
emojiViewProvider: nil,
makeCollapsedQuoteAttachment: nil
)
/*let renderingText = NSMutableAttributedString(attributedString: attachment.text)
renderingText.addAttribute(.font, value: attachment.attributes.font, range: NSRange(location: 0, length: renderingText.length))
renderingText.addAttribute(.foregroundColor, value: attachment.attributes.textColor, range: NSRange(location: 0, length: renderingText.length))*/
self.textNode.attributedText = renderingText
self.textNode.cutout = TextNodeCutout(topRight: CGSize(width: 10.0, height: 8.0))
let maxTextSize = CGSize(width: self.bounds.size.width - 9.0, height: self.bounds.size.height)
let layoutInfo = self.textNode.updateLayoutFullInfo(maxTextSize)
self.textNode.frame = CGRect(origin: CGPoint(x: 9.0, y: 8.0), size: layoutInfo.size)
}
}
@available(iOS 15.0, *)
final class ViewProvider: NSTextAttachmentViewProvider {
override init(
textAttachment: NSTextAttachment,
parentView: UIView?,
textLayoutManager: NSTextLayoutManager?,
location: NSTextLocation
) {
super.init(textAttachment: textAttachment, parentView: parentView, textLayoutManager: textLayoutManager, location: location)
}
override public func loadView() {
if let textAttachment = self.textAttachment as? ChatInputTextCollapsedQuoteAttachmentImpl {
self.view = View(attachment: textAttachment)
} else {
self.view = UIView()
}
}
}
public let text: NSAttributedString
public let attributes: ChatInputTextCollapsedQuoteAttributes
public init(text: NSAttributedString, attributes: ChatInputTextCollapsedQuoteAttributes) {
let _ = registeredViewProvider
self.text = text
self.attributes = attributes
super.init(data: nil, ofType: "public.data")
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override public func attachmentBounds(for textContainer: NSTextContainer?, proposedLineFragment lineFrag: CGRect, glyphPosition position: CGPoint, characterIndex charIndex: Int) -> CGRect {
return CGRect(origin: CGPoint(), size: View.calculateSize(attachment: self, constrainedSize: CGSize(width: lineFrag.width, height: 10000.0)))
}
override public func image(forBounds imageBounds: CGRect, textContainer: NSTextContainer?, characterIndex charIndex: Int) -> UIImage? {
return nil
}
}
public final class ChatInputTextView: ChatInputTextViewImpl, UITextViewDelegate, NSLayoutManagerDelegate, NSTextStorageDelegate {
public final class Theme: Equatable {
public final class Quote: Equatable {
public enum LineStyle: Equatable {
case solid(color: UIColor)
case doubleDashed(mainColor: UIColor, secondaryColor: UIColor)
case tripleDashed(mainColor: UIColor, secondaryColor: UIColor, tertiaryColor: UIColor)
}
public let background: UIColor
public let foreground: UIColor
public let lineStyle: LineStyle
public let codeBackground: UIColor
public let codeForeground: UIColor
public init(
background: UIColor,
foreground: UIColor,
lineStyle: LineStyle,
codeBackground: UIColor,
codeForeground: UIColor
) {
self.background = background
self.foreground = foreground
self.lineStyle = lineStyle
self.codeBackground = codeBackground
self.codeForeground = codeForeground
}
public static func ==(lhs: Quote, rhs: Quote) -> Bool {
if !lhs.background.isEqual(rhs.background) {
return false
}
if !lhs.foreground.isEqual(rhs.foreground) {
return false
}
if lhs.lineStyle != rhs.lineStyle {
return false
}
if !lhs.codeBackground.isEqual(rhs.codeBackground) {
return false
}
if !lhs.codeForeground.isEqual(rhs.codeForeground) {
return false
}
return true
}
}
public let quote: Quote
public init(quote: Quote) {
self.quote = quote
}
public static func ==(lhs: Theme, rhs: Theme) -> Bool {
if lhs.quote != rhs.quote {
return false
}
return true
}
}
override public var attributedText: NSAttributedString? {
get {
return super.attributedText
} set(value) {
if self.attributedText != value {
let selectedRange = self.selectedRange
let preserveSelectedRange = selectedRange.location != self.textStorage.length
super.attributedText = value ?? NSAttributedString()
if preserveSelectedRange {
self.isPreservingSelection = true
self.selectedRange = selectedRange
self.isPreservingSelection = false
}
self.updateTextContainerInset()
}
}
}
fileprivate var isPreservingSelection: Bool = false
fileprivate var isPreservingText: Bool = false
public weak var customDelegate: ChatInputTextNodeDelegate?
public var theme: Theme? {
didSet {
if self.theme != oldValue {
self.updateTextElements()
}
}
}
public var toggleQuoteCollapse: ((NSRange) -> Void)?
private let displayInternal: ChatInputTextInternal
private let measureInternal: ChatInputTextInternal
private var validLayoutSize: CGSize?
private var isUpdatingLayout: Bool = false
private var blockQuotes: [Int: QuoteBackgroundView] = [:]
public var defaultTextContainerInset: UIEdgeInsets = UIEdgeInsets() {
didSet {
if self.defaultTextContainerInset != oldValue {
self.updateTextContainerInset()
}
}
}
public var currentRightInset: CGFloat {
return self.displayInternal.textContainer.rightInset
}
private var didInitializePrimaryInputLanguage: Bool = false
public var initialPrimaryLanguage: String?
private var selectionChangedForEditedText: Bool = false
override public var textInputMode: UITextInputMode? {
if !self.didInitializePrimaryInputLanguage {
self.didInitializePrimaryInputLanguage = true
if let initialPrimaryLanguage = self.initialPrimaryLanguage {
for inputMode in UITextInputMode.activeInputModes {
if let primaryLanguage = inputMode.primaryLanguage, primaryLanguage == initialPrimaryLanguage {
return inputMode
}
}
}
}
return super.textInputMode
}
override public var bounds: CGRect {
didSet {
assert(true)
}
}
public init(disableTiling: Bool) {
let useModernImpl = !"".isEmpty
if #available(iOS 15.0, *), useModernImpl {
self.displayInternal = ChatInputTextNewInternal()
self.measureInternal = ChatInputTextNewInternal()
} else {
self.displayInternal = ChatInputTextLegacyInternal()
self.measureInternal = ChatInputTextLegacyInternal()
}
super.init(frame: CGRect(), textContainer: self.displayInternal.textContainer, disableTiling: disableTiling)
self.delegate = self
self.displayInternal.updateDisplayElements = { [weak self] in
self?.updateTextElements()
}
self.shouldRespondToAction = { [weak self] action in
guard let self, let action else {
return false
}
if let delegate = self.customDelegate {
return delegate.chatInputTextNodeShouldRespondToAction(action: action)
} else {
return true
}
}
self.targetForAction = { [weak self] action in
guard let self, let action else {
return nil
}
if let delegate = self.customDelegate {
return delegate.chatInputTextNodeTargetForAction(action: action).flatMap { value in
return ChatInputTextViewImplTargetForAction(target: value.target)
}
} else {
return nil
}
}
self.textContainerInset = UIEdgeInsets()
self.backgroundColor = nil
self.isOpaque = false
self.dropAutocorrectioniOS16 = { [weak self] in
guard let self else {
return
}
self.isPreservingSelection = true
self.isPreservingText = true
let rangeCopy = self.selectedRange
var fakeRange = rangeCopy
if fakeRange.location != 0 {
fakeRange.location -= 1
}
self.unmarkText()
self.selectedRange = fakeRange
self.selectedRange = rangeCopy
self.isPreservingSelection = false
self.isPreservingText = false
}
self.shouldCopy = { [weak self] in
guard let self else {
return true
}
return self.customDelegate?.chatInputTextNodeShouldCopy() ?? true
}
self.shouldPaste = { [weak self] in
guard let self else {
return true
}
return self.customDelegate?.chatInputTextNodeShouldPaste() ?? true
}
self.shouldReturn = { [weak self] in
guard let self else {
return true
}
return self.customDelegate?.chatInputTextNodeShouldReturn() ?? true
}
self.backspaceWhileEmpty = { [weak self] in
guard let self else {
return
}
self.customDelegate?.chatInputTextNodeBackspaceWhileEmpty()
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override public func scrollRectToVisible(_ rect: CGRect, animated: Bool) {
var rect = rect
if rect.maxY > self.contentSize.height - 8.0 {
rect = CGRect(origin: CGPoint(x: rect.minX, y: self.contentSize.height - 1.0), size: CGSize(width: rect.width, height: 1.0))
}
var animated = animated
if self.isUpdatingLayout {
animated = false
}
super.scrollRectToVisible(rect, animated: animated)
}
@objc public func textViewDidBeginEditing(_ textView: UITextView) {
self.customDelegate?.chatInputTextNodeDidBeginEditing()
}
@objc public func textViewDidEndEditing(_ textView: UITextView) {
self.customDelegate?.chatInputTextNodeDidFinishEditing()
}
@objc public func textViewDidChange(_ textView: UITextView) {
self.selectionChangedForEditedText = true
self.updateTextContainerInset()
self.customDelegate?.chatInputTextNodeDidUpdateText()
self.updateTextContainerInset()
}
@objc public func textViewDidChangeSelection(_ textView: UITextView) {
if self.isPreservingSelection {
return
}
self.selectionChangedForEditedText = false
DispatchQueue.main.async { [weak self] in
guard let self else {
return
}
self.customDelegate?.chatInputTextNodeDidChangeSelection(dueToEditing: self.selectionChangedForEditedText)
}
}
@available(iOS 16.0, *)
@objc public func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
return self.customDelegate?.chatInputTextNodeMenu(forTextRange: range, suggestedActions: suggestedActions)
}
@objc public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
guard let customDelegate = self.customDelegate else {
return true
}
if self.isPreservingText {
return false
}
return customDelegate.chatInputTextNode(shouldChangeTextIn: range, replacementText: text)
}
public func updateTextContainerInset() {
self.displayInternal.defaultTextContainerInset = self.defaultTextContainerInset
self.measureInternal.defaultTextContainerInset = self.defaultTextContainerInset
var result = self.defaultTextContainerInset
var horizontalInsetsUpdated = false
if self.displayInternal.textContainer.rightInset != result.right {
horizontalInsetsUpdated = true
self.displayInternal.textContainer.rightInset = result.right
}
result.left = 0.0
result.right = 0.0
if let string = self.displayInternal.attributedString, string.length != 0 {
let topAttributes = string.attributes(at: 0, effectiveRange: nil)
let bottomAttributes = string.attributes(at: string.length - 1, effectiveRange: nil)
if topAttributes[NSAttributedString.Key("Attribute__Blockquote")] != nil {
result.top += 7.0
}
if bottomAttributes[NSAttributedString.Key("Attribute__Blockquote")] != nil {
result.bottom += 8.0
}
}
if self.textContainerInset != result {
self.textContainerInset = result
}
if horizontalInsetsUpdated {
self.displayInternal.invalidateLayout()
}
self.updateTextElements()
}
public func textHeightForWidth(_ width: CGFloat, rightInset: CGFloat) -> CGFloat {
let measureSize = CGSize(width: width, height: 1000000.0)
let measureText: NSAttributedString
if let attributedText = self.attributedText, attributedText.length != 0 {
measureText = attributedText
} else {
measureText = NSAttributedString(string: "A", attributes: self.typingAttributes)
}
if self.measureInternal.attributedString != measureText || self.measureInternal.textContainer.size != measureSize || self.measureInternal.textContainer.rightInset != rightInset {
self.measureInternal.textContainer.rightInset = rightInset
self.measureInternal.setAttributedString(attributedString: measureText)
self.measureInternal.textContainer.size = measureSize
self.measureInternal.invalidateLayout()
}
let textSize = self.measureInternal.textSize()
return ceil(textSize.height + self.textContainerInset.top + self.textContainerInset.bottom)
}
public func updateLayout(size: CGSize) {
let measureSize = CGSize(width: size.width, height: 1000000.0)
if self.textContainer.size != measureSize {
self.textContainer.size = measureSize
self.displayInternal.invalidateLayout()
}
}
override public func setNeedsLayout() {
super.setNeedsLayout()
}
override public func layoutSubviews() {
let isLayoutUpdated = self.validLayoutSize != self.bounds.size
self.validLayoutSize = self.bounds.size
self.isUpdatingLayout = isLayoutUpdated
super.layoutSubviews()
self.isUpdatingLayout = false
}
public func currentTextBoundingRect() -> CGRect {
return self.displayInternal.currentTextBoundingRect()
}
public func lastLineBoundingRect() -> CGRect {
return self.displayInternal.currentTextLastLineBoundingRect()
}
public func updateTextElements() {
var validBlockQuotes: [Int] = []
for displayBlockQuote in self.displayInternal.displayBlockQuotes() {
let blockQuote: QuoteBackgroundView
if let current = self.blockQuotes[displayBlockQuote.id] {
blockQuote = current
} else {
blockQuote = QuoteBackgroundView(toggleCollapse: { [weak self] range in
guard let self else {
return
}
self.toggleQuoteCollapse?(range)
})
self.blockQuotes[displayBlockQuote.id] = blockQuote
self.insertSubview(blockQuote, at: 0)
}
blockQuote.frame = displayBlockQuote.boundingRect
if let theme = self.theme {
blockQuote.update(kind: displayBlockQuote.kind, isCollapsed: displayBlockQuote.isCollapsed, range: displayBlockQuote.range, size: displayBlockQuote.boundingRect.size, theme: theme.quote)
}
validBlockQuotes.append(displayBlockQuote.id)
}
var removedBlockQuotes: [Int] = []
for (id, blockQuote) in self.blockQuotes {
if !validBlockQuotes.contains(id) {
removedBlockQuotes.append(id)
blockQuote.removeFromSuperview()
}
}
for id in removedBlockQuotes {
self.blockQuotes.removeValue(forKey: id)
}
}
override public func caretRect(for position: UITextPosition) -> CGRect {
return super.caretRect(for: position)
}
override public func selectionRects(for range: UITextRange) -> [UITextSelectionRect] {
return super.selectionRects(for: range)
}
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if self.bounds.contains(point) {
for (_, blockQuote) in self.blockQuotes {
if let result = blockQuote.collapseButton.hitTest(self.convert(point, to: blockQuote.collapseButton), with: event) {
return result
}
}
}
let result = super.hitTest(point, with: event)
return result
}
}
private let quoteIcon: UIImage = {
return UIImage(bundleImageName: "Chat/Message/ReplyQuoteIcon")!.precomposed().withRenderingMode(.alwaysTemplate)
}()
private let quoteCollapseImage: UIImage = {
return UIImage(bundleImageName: "Media Gallery/Minimize")!.precomposed().withRenderingMode(.alwaysTemplate)
}()
private let quoteExpandImage: UIImage = {
return UIImage(bundleImageName: "Media Gallery/Fullscreen")!.precomposed().withRenderingMode(.alwaysTemplate)
}()
private final class QuoteBackgroundView: UIView {
private let toggleCollapse: (NSRange) -> Void
private let backgroundView: MessageInlineBlockBackgroundView
private let iconView: UIImageView
let collapseButton: UIView
let collapseButtonIconView: UIImageView
private var range: NSRange?
private var theme: ChatInputTextView.Theme.Quote?
init(toggleCollapse: @escaping (NSRange) -> Void) {
self.toggleCollapse = toggleCollapse
self.backgroundView = MessageInlineBlockBackgroundView()
self.iconView = UIImageView(image: quoteIcon)
self.collapseButton = UIView()
self.collapseButtonIconView = UIImageView()
self.collapseButton.addSubview(self.collapseButtonIconView)
super.init(frame: CGRect())
self.addSubview(self.backgroundView)
self.addSubview(self.iconView)
self.addSubview(self.collapseButton)
self.collapseButton.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.toggleCollapsedTapped(_:))))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func toggleCollapsedTapped(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
if let range = self.range {
self.toggleCollapse(range)
}
}
}
func update(kind: ChatTextInputTextQuoteAttribute.Kind, isCollapsed: Bool, range: NSRange, size: CGSize, theme: ChatInputTextView.Theme.Quote) {
self.range = range
if self.theme != theme {
self.theme = theme
self.iconView.tintColor = theme.foreground
self.collapseButtonIconView.tintColor = theme.foreground
}
self.iconView.frame = CGRect(origin: CGPoint(x: size.width - 4.0 - quoteIcon.size.width, y: 4.0), size: quoteIcon.size)
let collapseButtonSize = CGSize(width: 18.0, height: 18.0)
self.collapseButton.frame = CGRect(origin: CGPoint(x: size.width - 2.0 - collapseButtonSize.width, y: 2.0), size: collapseButtonSize)
if isCollapsed {
self.collapseButtonIconView.image = quoteExpandImage
} else {
self.collapseButtonIconView.image = quoteCollapseImage
}
if let image = self.collapseButtonIconView.image {
let iconSize = image.size.aspectFitted(collapseButtonSize)
self.collapseButtonIconView.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((collapseButtonSize.width - iconSize.width) * 0.5), y: floorToScreenPixels((collapseButtonSize.height - iconSize.height) * 0.5)), size: iconSize)
}
var primaryColor: UIColor
var secondaryColor: UIColor?
var tertiaryColor: UIColor?
let backgroundColor: UIColor?
switch kind {
case .quote:
if size.height >= 100.0 || isCollapsed {
self.iconView.isHidden = true
self.collapseButton.isHidden = false
} else {
self.iconView.isHidden = false
self.collapseButton.isHidden = true
}
switch theme.lineStyle {
case let .solid(color):
primaryColor = color
case let .doubleDashed(mainColor, secondaryColorValue):
primaryColor = mainColor
secondaryColor = secondaryColorValue
case let .tripleDashed(mainColor, secondaryColorValue, tertiaryColorValue):
primaryColor = mainColor
secondaryColor = secondaryColorValue
tertiaryColor = tertiaryColorValue
}
backgroundColor = nil
case .code:
self.iconView.isHidden = true
self.collapseButton.isHidden = true
primaryColor = theme.codeForeground
backgroundColor = theme.codeBackground
}
self.backgroundView.update(
size: size,
isTransparent: false,
primaryColor: primaryColor,
secondaryColor: secondaryColor,
thirdColor: tertiaryColor,
backgroundColor: backgroundColor,
pattern: nil,
animation: .None
)
self.backgroundView.frame = CGRect(origin: CGPoint(), size: size)
}
}