diff --git a/submodules/AccountContext/Sources/ChatController.swift b/submodules/AccountContext/Sources/ChatController.swift index ae42cad445..ecdabb35b3 100644 --- a/submodules/AccountContext/Sources/ChatController.swift +++ b/submodules/AccountContext/Sources/ChatController.swift @@ -400,7 +400,7 @@ public enum ChatTextInputStateTextAttributeType: Codable, Equatable { case strikethrough case underline case spoiler - case quote + case quote(isCollapsed: Bool) case codeBlock(language: String?) public init(from decoder: Decoder) throws { @@ -430,7 +430,7 @@ public enum ChatTextInputStateTextAttributeType: Codable, Equatable { case 8: self = .spoiler case 9: - self = .quote + self = .quote(isCollapsed: try container.decodeIfPresent(Bool.self, forKey: "isCollapsed") ?? false) case 10: self = .codeBlock(language: try container.decodeIfPresent(String.self, forKey: "l")) default: @@ -464,8 +464,9 @@ public enum ChatTextInputStateTextAttributeType: Codable, Equatable { try container.encode(7 as Int32, forKey: "t") case .spoiler: try container.encode(8 as Int32, forKey: "t") - case .quote: + case let .quote(isCollapsed): try container.encode(9 as Int32, forKey: "t") + try container.encode(isCollapsed, forKey: "isCollapsed") case let .codeBlock(language): try container.encode(10 as Int32, forKey: "t") try container.encodeIfPresent(language, forKey: "l") @@ -545,7 +546,7 @@ public struct ChatTextInputStateText: Codable, Equatable { } else if key == ChatTextInputAttributes.block, let value = value as? ChatTextInputTextQuoteAttribute { switch value.kind { case .quote: - parsedAttributes.append(ChatTextInputStateTextAttribute(type: .quote, range: range.location ..< (range.location + range.length))) + parsedAttributes.append(ChatTextInputStateTextAttribute(type: .quote(isCollapsed: value.isCollapsed), range: range.location ..< (range.location + range.length))) case let .code(language): parsedAttributes.append(ChatTextInputStateTextAttribute(type: .codeBlock(language: language), range: range.location ..< (range.location + range.length))) } @@ -593,10 +594,10 @@ public struct ChatTextInputStateText: Codable, Equatable { result.addAttribute(ChatTextInputAttributes.underline, value: true as NSNumber, range: NSRange(location: attribute.range.lowerBound, length: attribute.range.count)) case .spoiler: result.addAttribute(ChatTextInputAttributes.spoiler, value: true as NSNumber, range: NSRange(location: attribute.range.lowerBound, length: attribute.range.count)) - case .quote: - result.addAttribute(ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .quote), range: NSRange(location: attribute.range.lowerBound, length: attribute.range.count)) + case let .quote(isCollapsed): + result.addAttribute(ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .quote, isCollapsed: isCollapsed), range: NSRange(location: attribute.range.lowerBound, length: attribute.range.count)) case let .codeBlock(language): - result.addAttribute(ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .code(language: language)), range: NSRange(location: attribute.range.lowerBound, length: attribute.range.count)) + result.addAttribute(ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .code(language: language), isCollapsed: false), range: NSRange(location: attribute.range.lowerBound, length: attribute.range.count)) } } return result diff --git a/submodules/AsyncDisplayKit/Source/PublicHeaders/AsyncDisplayKit/ASDisplayNode.h b/submodules/AsyncDisplayKit/Source/PublicHeaders/AsyncDisplayKit/ASDisplayNode.h index 3119c01976..5683097d5a 100644 --- a/submodules/AsyncDisplayKit/Source/PublicHeaders/AsyncDisplayKit/ASDisplayNode.h +++ b/submodules/AsyncDisplayKit/Source/PublicHeaders/AsyncDisplayKit/ASDisplayNode.h @@ -569,6 +569,8 @@ AS_EXTERN NSInteger const ASDefaultDrawingPriority; @property (nonatomic) bool disableClearContentsOnHide; +- (void)displayImmediately; + @end /** diff --git a/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift b/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift index 4dd48fa2dd..726dc5aa23 100644 --- a/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift +++ b/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift @@ -1715,7 +1715,7 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS self.inputMenu.back() self.interfaceInteraction?.updateTextInputStateAndMode { current, inputMode in - return (chatTextInputAddFormattingAttribute(current, attribute: ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .quote)), inputMode) + return (chatTextInputAddFormattingAttribute(current, attribute: ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .quote, isCollapsed: false)), inputMode) } } @@ -1723,7 +1723,7 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS self.inputMenu.back() self.interfaceInteraction?.updateTextInputStateAndMode { current, inputMode in - return (chatTextInputAddFormattingAttribute(current, attribute: ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .code(language: nil))), inputMode) + return (chatTextInputAddFormattingAttribute(current, attribute: ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .code(language: nil), isCollapsed: false)), inputMode) } } diff --git a/submodules/ChatPresentationInterfaceState/Sources/ChatTextFormat.swift b/submodules/ChatPresentationInterfaceState/Sources/ChatTextFormat.swift index f123a516d2..6c76dab4ed 100644 --- a/submodules/ChatPresentationInterfaceState/Sources/ChatTextFormat.swift +++ b/submodules/ChatPresentationInterfaceState/Sources/ChatTextFormat.swift @@ -59,7 +59,7 @@ public func chatTextInputAddFormattingAttribute(_ state: ChatTextInputState, att for (key, value) in attributes { if let value = value as? ChatTextInputTextQuoteAttribute { result.removeAttribute(key, range: range) - result.addAttribute(key, value: ChatTextInputTextQuoteAttribute(kind: value.kind), range: range) + result.addAttribute(key, value: ChatTextInputTextQuoteAttribute(kind: value.kind, isCollapsed: value.isCollapsed), range: range) } } } @@ -72,7 +72,7 @@ public func chatTextInputAddFormattingAttribute(_ state: ChatTextInputState, att if addAttribute { if attribute == ChatTextInputAttributes.block { - result.addAttribute(attribute, value: value ?? ChatTextInputTextQuoteAttribute(kind: .quote), range: nsRange) + result.addAttribute(attribute, value: value ?? ChatTextInputTextQuoteAttribute(kind: .quote, isCollapsed: false), range: nsRange) var selectionIndex = nsRange.upperBound if nsRange.upperBound != result.length && (result.string as NSString).character(at: nsRange.upperBound) != 0x0a { result.insert(NSAttributedString(string: "\n"), at: nsRange.upperBound) @@ -197,6 +197,6 @@ public func chatTextInputAddQuoteAttribute(_ state: ChatTextInputState, selectio for (attribute, range) in attributesToRemove { result.removeAttribute(attribute, range: range) } - result.addAttribute(ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: kind), range: nsRange) + result.addAttribute(ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: kind, isCollapsed: false), range: nsRange) return ChatTextInputState(inputText: result, selectionRange: selectionRange) } diff --git a/submodules/ComposePollUI/Sources/CreatePollTextInputItem.swift b/submodules/ComposePollUI/Sources/CreatePollTextInputItem.swift index 37106a74a6..8179f8b295 100644 --- a/submodules/ComposePollUI/Sources/CreatePollTextInputItem.swift +++ b/submodules/ComposePollUI/Sources/CreatePollTextInputItem.swift @@ -557,14 +557,14 @@ public class CreatePollTextInputItemNode: ListViewItemNode, ASEditableTextNodeDe @objc func formatAttributesQuote(_ sender: Any) { self.inputMenu.back() if let item = self.item { - chatTextInputAddFormattingAttribute(item: item, textNode: self.textNode, theme: item.presentationData.theme, attribute: ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .quote)) + chatTextInputAddFormattingAttribute(item: item, textNode: self.textNode, theme: item.presentationData.theme, attribute: ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .quote, isCollapsed: false)) } } @objc func formatAttributesCodeBlock(_ sender: Any) { self.inputMenu.back() if let item = self.item { - chatTextInputAddFormattingAttribute(item: item, textNode: self.textNode, theme: item.presentationData.theme, attribute: ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .code(language: nil))) + chatTextInputAddFormattingAttribute(item: item, textNode: self.textNode, theme: item.presentationData.theme, attribute: ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .code(language: nil), isCollapsed: false)) } } diff --git a/submodules/Display/Source/ContainedViewLayoutTransition.swift b/submodules/Display/Source/ContainedViewLayoutTransition.swift index 86fd7ffcc1..c46b10a17a 100644 --- a/submodules/Display/Source/ContainedViewLayoutTransition.swift +++ b/submodules/Display/Source/ContainedViewLayoutTransition.swift @@ -1822,6 +1822,8 @@ public protocol ControlledTransitionAnimator: AnyObject { func updateFrame(layer: CALayer, frame: CGRect, completion: ((Bool) -> Void)?) func updateCornerRadius(layer: CALayer, cornerRadius: CGFloat, completion: ((Bool) -> Void)?) func updateContentsRect(layer: CALayer, contentsRect: CGRect, completion: ((Bool) -> Void)?) + func updateTransform(layer: CALayer, transform: CATransform3D, completion: ((Bool) -> Void)?) + func updateBackgroundColor(layer: CALayer, color: UIColor, completion: ((Bool) -> Void)?) } protocol AnyValueProviding { @@ -1967,6 +1969,83 @@ extension CGRect: AnyValueProviding { } } +extension CATransform3D: Equatable { + public static func ==(lhs: CATransform3D, rhs: CATransform3D) -> Bool { + return CATransform3DEqualToTransform(lhs, rhs) + } +} + +extension CATransform3D: AnyValueProviding { + func interpolate(with other: CATransform3D, fraction: CGFloat) -> CATransform3D { + return CATransform3D( + m11: self.m11.interpolate(with: other.m11, fraction: fraction), + m12: self.m12.interpolate(with: other.m12, fraction: fraction), + m13: self.m13.interpolate(with: other.m13, fraction: fraction), + m14: self.m14.interpolate(with: other.m14, fraction: fraction), + m21: self.m21.interpolate(with: other.m21, fraction: fraction), + m22: self.m22.interpolate(with: other.m22, fraction: fraction), + m23: self.m23.interpolate(with: other.m23, fraction: fraction), + m24: self.m24.interpolate(with: other.m24, fraction: fraction), + m31: self.m31.interpolate(with: other.m31, fraction: fraction), + m32: self.m32.interpolate(with: other.m32, fraction: fraction), + m33: self.m33.interpolate(with: other.m33, fraction: fraction), + m34: self.m34.interpolate(with: other.m34, fraction: fraction), + m41: self.m41.interpolate(with: other.m41, fraction: fraction), + m42: self.m42.interpolate(with: other.m42, fraction: fraction), + m43: self.m43.interpolate(with: other.m43, fraction: fraction), + m44: self.m44.interpolate(with: other.m44, fraction: fraction) + ) + } + + var anyValue: ControlledTransitionProperty.AnyValue { + return ControlledTransitionProperty.AnyValue( + value: self, + nsValue: NSValue(caTransform3D: self), + stringValue: { "\(self)" }, + isEqual: { other in + if let otherValue = other.value as? CATransform3D { + return self == otherValue + } else { + return false + } + }, + interpolate: { other, fraction in + guard let otherValue = other.value as? CATransform3D else { + preconditionFailure() + } + return self.interpolate(with: otherValue, fraction: fraction).anyValue + } + ) + } +} + +extension CGColor: AnyValueProviding { + func interpolate(with other: CGColor, fraction: CGFloat) -> CGColor { + return UIColor(cgColor: self).mixedWith(UIColor(cgColor: other), alpha: fraction).cgColor + } + + var anyValue: ControlledTransitionProperty.AnyValue { + return ControlledTransitionProperty.AnyValue( + value: self, + nsValue: self, + stringValue: { "\(self)" }, + isEqual: { other in + if CFGetTypeID(other.value as CFTypeRef) == CGColor.typeID { + return self == (other.value as! CGColor) + } else { + return false + } + }, + interpolate: { other, fraction in + guard CFGetTypeID(other.value as CFTypeRef) == CGColor.typeID else { + preconditionFailure() + } + return self.interpolate(with: other.value as! CGColor, fraction: fraction).anyValue + } + ) + } +} + final class ControlledTransitionProperty { final class AnyValue: Equatable, CustomStringConvertible { let value: Any @@ -2232,6 +2311,76 @@ public final class ControlledTransition { self.updateBounds(layer: layer, bounds: CGRect(origin: CGPoint(), size: frame.size), completion: nil) } + public func updateTransform(layer: CALayer, transform: CATransform3D, completion: ((Bool) -> Void)?) { + if layer.transform == transform { + return + } + let fromValue: CATransform3D + if let animationKeys = layer.animationKeys(), animationKeys.contains(where: { key in + guard let animation = layer.animation(forKey: key) as? CAPropertyAnimation else { + return false + } + if animation.keyPath == "transform" { + return true + } else { + return false + } + }) { + fromValue = layer.presentation()?.transform ?? layer.transform + } else { + fromValue = layer.transform + } + layer.transform = transform + self.add(animation: ControlledTransitionProperty( + layer: layer, + path: "transform", + fromValue: fromValue, + toValue: transform, + completion: completion + )) + } + + public func updateBackgroundColor(layer: CALayer, color: UIColor, completion: ((Bool) -> Void)?) { + if let currentColor = layer.backgroundColor, currentColor == color.cgColor { + if let completion = completion { + completion(true) + } + return + } + + let fromValue: CGColor? + if let animationKeys = layer.animationKeys(), animationKeys.contains(where: { key in + guard let animation = layer.animation(forKey: key) as? CAPropertyAnimation else { + return false + } + if animation.keyPath == "backgroundColor" { + return true + } else { + return false + } + }) { + fromValue = layer.presentation()?.backgroundColor ?? layer.backgroundColor + } else { + fromValue = layer.backgroundColor + } + + var mappedFromValue: UIColor + if let fromValue { + mappedFromValue = UIColor(cgColor: fromValue) + } else { + mappedFromValue = .clear + } + + layer.backgroundColor = color.cgColor + self.add(animation: ControlledTransitionProperty( + layer: layer, + path: "backgroundColor", + fromValue: mappedFromValue.cgColor, + toValue: color.cgColor, + completion: completion + )) + } + public func updateCornerRadius(layer: CALayer, cornerRadius: CGFloat, completion: ((Bool) -> Void)?) { if layer.cornerRadius == cornerRadius { return @@ -2305,6 +2454,14 @@ public final class ControlledTransition { self.transition.updatePosition(layer: layer, position: position, completion: completion) } + public func updateTransform(layer: CALayer, transform: CATransform3D, completion: ((Bool) -> Void)?) { + self.transition.updateTransform(layer: layer, transform: CATransform3DGetAffineTransform(transform), completion: completion) + } + + public func updateBackgroundColor(layer: CALayer, color: UIColor, completion: ((Bool) -> Void)?) { + self.transition.updateBackgroundColor(layer: layer, color: color, completion: completion) + } + public func animatePosition(layer: CALayer, from fromValue: CGPoint, to toValue: CGPoint, completion: ((Bool) -> Void)?) { self.transition.animatePosition(layer: layer, from: fromValue, to: toValue, completion: completion) } diff --git a/submodules/Display/Source/Nodes/ASImageNode.swift b/submodules/Display/Source/Nodes/ASImageNode.swift index 2ebf0e9987..183f0632e7 100644 --- a/submodules/Display/Source/Nodes/ASImageNode.swift +++ b/submodules/Display/Source/Nodes/ASImageNode.swift @@ -8,7 +8,7 @@ open class ASImageNode: ASDisplayNode { if self.isNodeLoaded { if let image = self.image { let capInsets = image.capInsets - if capInsets.left.isZero && capInsets.top.isZero { + if capInsets.left.isZero && capInsets.top.isZero && capInsets.right.isZero && capInsets.bottom.isZero { self.contentsScale = image.scale self.contents = image.cgImage } else { diff --git a/submodules/Display/Source/TextNode.swift b/submodules/Display/Source/TextNode.swift index b469147b60..ce88413420 100644 --- a/submodules/Display/Source/TextNode.swift +++ b/submodules/Display/Source/TextNode.swift @@ -83,14 +83,16 @@ public final class TextNodeBlockQuoteData: NSObject { public let secondaryColor: UIColor? public let tertiaryColor: UIColor? public let backgroundColor: UIColor + public let isCollapsible: Bool - public init(kind: Kind, title: NSAttributedString?, color: UIColor, secondaryColor: UIColor?, tertiaryColor: UIColor?, backgroundColor: UIColor) { + public init(kind: Kind, title: NSAttributedString?, color: UIColor, secondaryColor: UIColor?, tertiaryColor: UIColor?, backgroundColor: UIColor, isCollapsible: Bool) { self.kind = kind self.title = title self.color = color self.secondaryColor = secondaryColor self.tertiaryColor = tertiaryColor self.backgroundColor = backgroundColor + self.isCollapsible = isCollapsible super.init() } @@ -1167,7 +1169,13 @@ private func addAttachment(attachment: UIImage, line: TextNodeLine, ascent: CGFl line.attachments.append(TextNodeAttachment(range: NSMakeRange(startIndex, endIndex - startIndex), frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset) + rightInset, height: ascent + descent), attachment: attachment)) } -open class TextNode: ASDisplayNode { +public protocol TextNodeProtocol: ASDisplayNode { + var currentText: NSAttributedString? { get } + func textRangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? + func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? +} + +open class TextNode: ASDisplayNode, TextNodeProtocol { public struct RenderContentTypes: OptionSet { public var rawValue: Int @@ -1196,6 +1204,14 @@ open class TextNode: ASDisplayNode { public internal(set) var cachedLayout: TextNodeLayout? public var renderContentTypes: RenderContentTypes = .all + public var currentText: NSAttributedString? { + return self.cachedLayout?.attributedString + } + + public func textRangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? { + return self.cachedLayout?.rangeRects(in: range) + } + override public init() { super.init() diff --git a/submodules/MtProtoKit/Sources/MTEncryption.m b/submodules/MtProtoKit/Sources/MTEncryption.m index 97713424f4..a595dc695f 100644 --- a/submodules/MtProtoKit/Sources/MTEncryption.m +++ b/submodules/MtProtoKit/Sources/MTEncryption.m @@ -588,7 +588,7 @@ bool MTCheckIsSafePrime(id provider, NSData *numberBytes, id id bnNumber = [context create]; [context assignBinTo:bnNumber value:numberBytes]; - int result = [context isPrime:bnNumber numberOfChecks:30]; + int result = [context isPrime:bnNumber numberOfChecks:64]; if (result == 1) { id bnNumberOne = [context create]; @@ -600,7 +600,7 @@ bool MTCheckIsSafePrime(id provider, NSData *numberBytes, id id bnNumberMinusOneDivByTwo = [context create]; [context rightShift1Bit:bnNumberMinusOneDivByTwo a:bnNumberMinusOne]; - result = [context isPrime:bnNumberMinusOneDivByTwo numberOfChecks:30]; + result = [context isPrime:bnNumberMinusOneDivByTwo numberOfChecks:64]; } [keychain setObject:@(result == 1) forKey:primeKey group:@"primes"]; diff --git a/submodules/TelegramCore/Sources/PendingMessages/EnqueueMessage.swift b/submodules/TelegramCore/Sources/PendingMessages/EnqueueMessage.swift index 3c178747f7..13c3f5f895 100644 --- a/submodules/TelegramCore/Sources/PendingMessages/EnqueueMessage.swift +++ b/submodules/TelegramCore/Sources/PendingMessages/EnqueueMessage.swift @@ -277,6 +277,8 @@ private func filterMessageAttributesForForwardedMessage(_ attributes: [MessageAt return true case _ as MediaSpoilerMessageAttribute: return true + case _ as InvertMediaMessageAttribute: + return true case let attribute as ReplyMessageAttribute: if attribute.quote != nil { return true diff --git a/submodules/TelegramUI/Components/Chat/ChatInputTextNode/ChatInputTextViewImpl/Sources/ChatInputTextViewImpl.m b/submodules/TelegramUI/Components/Chat/ChatInputTextNode/ChatInputTextViewImpl/Sources/ChatInputTextViewImpl.m index eb3e9c739b..a3356a56de 100755 --- a/submodules/TelegramUI/Components/Chat/ChatInputTextNode/ChatInputTextViewImpl/Sources/ChatInputTextViewImpl.m +++ b/submodules/TelegramUI/Components/Chat/ChatInputTextNode/ChatInputTextViewImpl/Sources/ChatInputTextViewImpl.m @@ -46,6 +46,10 @@ return self; } +- (BOOL)touchesShouldCancelInContentView:(UIView *)view { + return false; +} + - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer { return true; } diff --git a/submodules/TelegramUI/Components/Chat/ChatInputTextNode/Sources/ChatInputTextNode.swift b/submodules/TelegramUI/Components/Chat/ChatInputTextNode/Sources/ChatInputTextNode.swift index ca6cad9775..0a0b3ae3d3 100644 --- a/submodules/TelegramUI/Components/Chat/ChatInputTextNode/Sources/ChatInputTextNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatInputTextNode/Sources/ChatInputTextNode.swift @@ -26,6 +26,171 @@ public protocol ChatInputTextNodeDelegate: AnyObject { 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? @@ -156,7 +321,14 @@ private final class ChatInputTextContainer: NSTextContainer { result.size.width -= 5.0 result.size.width -= self.rightInset - if let textStorage = self.layoutManager?.textStorage { + 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 { @@ -195,6 +367,388 @@ private final class ChatInputTextContainer: NSTextContainer { } } +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 attribute: ChatTextInputTextQuoteAttribute + var range: NSRange + + init(id: Int, boundingRect: CGRect, attribute: ChatTextInputTextQuoteAttribute, range: NSRange) { + self.id = id + self.boundingRect = boundingRect + self.attribute = attribute + 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, attribute: value, 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, attribute: attribute, range: fragmentRange) + } + } + } + + return true + }) + + return Array(result.values).sorted(by: { lhs, rhs in + return lhs.boundingRect.minY < rhs.boundingRect.minY + }) + } +} + public final class ChatInputTextView: ChatInputTextViewImpl, UITextViewDelegate, NSLayoutManagerDelegate, NSTextStorageDelegate { public final class Theme: Equatable { public final class Quote: Equatable { @@ -291,13 +845,10 @@ public final class ChatInputTextView: ChatInputTextViewImpl, UITextViewDelegate, } } - private let customTextContainer: ChatInputTextContainer - private let customTextStorage: NSTextStorage - private let customLayoutManager: NSLayoutManager + public var toggleQuoteCollapse: ((NSRange) -> Void)? - private let measurementTextContainer: ChatInputTextContainer - private let measurementTextStorage: NSTextStorage - private let measurementLayoutManager: NSLayoutManager + private let displayInternal: ChatInputTextInternal + private let measureInternal: ChatInputTextInternal private var validLayoutSize: CGSize? private var isUpdatingLayout: Bool = false @@ -313,7 +864,7 @@ public final class ChatInputTextView: ChatInputTextViewImpl, UITextViewDelegate, } public var currentRightInset: CGFloat { - return self.customTextContainer.rightInset + return self.displayInternal.textContainer.rightInset } private var didInitializePrimaryInputLanguage: Bool = false @@ -342,22 +893,24 @@ public final class ChatInputTextView: ChatInputTextViewImpl, UITextViewDelegate, } public init(disableTiling: Bool) { - self.customTextContainer = ChatInputTextContainer(size: CGSize(width: 100.0, height: 100000.0)) - self.customLayoutManager = NSLayoutManager() - self.customTextStorage = NSTextStorage() - self.customTextStorage.addLayoutManager(self.customLayoutManager) - self.customLayoutManager.addTextContainer(self.customTextContainer) + let useModernImpl = !"".isEmpty - self.measurementTextContainer = ChatInputTextContainer(size: CGSize(width: 100.0, height: 100000.0)) - self.measurementLayoutManager = NSLayoutManager() - self.measurementTextStorage = NSTextStorage() - self.measurementTextStorage.addLayoutManager(self.measurementLayoutManager) - self.measurementLayoutManager.addTextContainer(self.measurementTextContainer) + 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.customTextContainer, disableTiling: disableTiling) + 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 @@ -385,18 +938,6 @@ public final class ChatInputTextView: ChatInputTextViewImpl, UITextViewDelegate, self.backgroundColor = nil self.isOpaque = false - self.customTextContainer.widthTracksTextView = false - self.customTextContainer.heightTracksTextView = false - - self.measurementTextContainer.widthTracksTextView = false - self.measurementTextContainer.heightTracksTextView = false - - self.customLayoutManager.delegate = self - self.measurementLayoutManager.delegate = self - - self.customTextStorage.delegate = self - self.measurementTextStorage.delegate = self - self.dropAutocorrectioniOS16 = { [weak self] in guard let self else { return @@ -462,73 +1003,6 @@ public final class ChatInputTextView: ChatInputTextViewImpl, UITextViewDelegate, super.scrollRectToVisible(rect, animated: animated) } - @objc public 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 public 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 - } - - public func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorage.EditActions, range editedRange: NSRange, changeInLength delta: Int) { - if textStorage !== self.customTextStorage { - return - } - } - - public func layoutManager(_ layoutManager: NSLayoutManager, didCompleteLayoutFor textContainer: NSTextContainer?, atEnd layoutFinishedFlag: Bool) { - if textContainer !== self.customTextContainer { - return - } - self.updateTextElements() - } - @objc public func textViewDidBeginEditing(_ textView: UITextView) { self.customDelegate?.chatInputTextNodeDidBeginEditing() } @@ -578,20 +1052,23 @@ public final class ChatInputTextView: ChatInputTextViewImpl, UITextViewDelegate, } public func updateTextContainerInset() { + self.displayInternal.defaultTextContainerInset = self.defaultTextContainerInset + self.measureInternal.defaultTextContainerInset = self.defaultTextContainerInset + var result = self.defaultTextContainerInset var horizontalInsetsUpdated = false - if self.customTextContainer.rightInset != result.right { + if self.displayInternal.textContainer.rightInset != result.right { horizontalInsetsUpdated = true - self.customTextContainer.rightInset = result.right + self.displayInternal.textContainer.rightInset = result.right } result.left = 0.0 result.right = 0.0 - if self.customTextStorage.length != 0 { - let topAttributes = self.customTextStorage.attributes(at: 0, effectiveRange: nil) - let bottomAttributes = self.customTextStorage.attributes(at: self.customTextStorage.length - 1, effectiveRange: nil) + 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 @@ -605,8 +1082,7 @@ public final class ChatInputTextView: ChatInputTextViewImpl, UITextViewDelegate, self.textContainerInset = result } if horizontalInsetsUpdated { - self.customLayoutManager.invalidateLayout(forCharacterRange: NSRange(location: 0, length: self.customTextStorage.length), actualCharacterRange: nil) - self.customLayoutManager.ensureLayout(for: self.customTextContainer) + self.displayInternal.invalidateLayout() } self.updateTextElements() @@ -622,15 +1098,14 @@ public final class ChatInputTextView: ChatInputTextViewImpl, UITextViewDelegate, measureText = NSAttributedString(string: "A", attributes: self.typingAttributes) } - if self.measurementTextStorage != measureText || self.measurementTextContainer.size != measureSize || self.measurementTextContainer.rightInset != rightInset { - self.measurementTextContainer.rightInset = rightInset - self.measurementTextStorage.setAttributedString(measureText) - self.measurementTextContainer.size = measureSize - self.measurementLayoutManager.invalidateLayout(forCharacterRange: NSRange(location: 0, length: self.measurementTextStorage.length), actualCharacterRange: nil) - self.measurementLayoutManager.ensureLayout(for: self.measurementTextContainer) + 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.measurementLayoutManager.usedRect(for: self.measurementTextContainer).size + let textSize = self.measureInternal.textSize() return ceil(textSize.height + self.textContainerInset.top + self.textContainerInset.bottom) } @@ -640,8 +1115,7 @@ public final class ChatInputTextView: ChatInputTextViewImpl, UITextViewDelegate, if self.textContainer.size != measureSize { self.textContainer.size = measureSize - self.customLayoutManager.invalidateLayout(forCharacterRange: NSRange(location: 0, length: self.customTextStorage.length), actualCharacterRange: nil) - self.customLayoutManager.ensureLayout(for: self.customTextContainer) + self.displayInternal.invalidateLayout() } } @@ -661,108 +1135,37 @@ public final class ChatInputTextView: ChatInputTextViewImpl, UITextViewDelegate, } public func currentTextBoundingRect() -> CGRect { - let glyphRange = self.customLayoutManager.glyphRange(forCharacterRange: NSRange(location: 0, length: self.textStorage.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 + return self.displayInternal.currentTextBoundingRect() } public func lastLineBoundingRect() -> CGRect { - let glyphRange = self.customLayoutManager.glyphRange(forCharacterRange: NSRange(location: 0, length: self.textStorage.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 + return self.displayInternal.currentTextLastLineBoundingRect() } public func updateTextElements() { - var blockQuoteIndex = 0 var validBlockQuotes: [Int] = [] - - self.textStorage.enumerateAttribute(NSAttributedString.Key(rawValue: "Attribute__Blockquote"), in: NSRange(location: 0, length: self.textStorage.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 - - let blockQuote: QuoteBackgroundView - if let current = self.blockQuotes[id] { - blockQuote = current - } else { - blockQuote = QuoteBackgroundView() - self.blockQuotes[id] = blockQuote - self.insertSubview(blockQuote, at: 0) - } - - var boundingRect = self.customLayoutManager.boundingRect(forGlyphRange: glyphRange, in: self.customTextContainer) - - 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) + 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 } - 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.bounds.width - 18.0) - } - boundingRect.size.width = min(boundingRect.size.width, self.bounds.width) - - boundingRect.origin.y -= 4.0 - boundingRect.size.height += 8.0 - - blockQuote.frame = boundingRect - if let theme = self.theme { - blockQuote.update(value: value, size: boundingRect.size, theme: theme.quote) - } - - validBlockQuotes.append(blockQuoteIndex) - blockQuoteIndex += 1 + self.toggleQuoteCollapse?(range) + }) + self.blockQuotes[displayBlockQuote.id] = blockQuote + self.insertSubview(blockQuote, at: 0) } - }) + + blockQuote.frame = displayBlockQuote.boundingRect + if let theme = self.theme { + blockQuote.update(value: displayBlockQuote.attribute, range: displayBlockQuote.range, size: displayBlockQuote.boundingRect.size, theme: theme.quote) + } + + validBlockQuotes.append(displayBlockQuote.id) + } var removedBlockQuotes: [Int] = [] for (id, blockQuote) in self.blockQuotes { @@ -777,121 +1180,106 @@ public final class ChatInputTextView: ChatInputTextViewImpl, UITextViewDelegate, } override public func caretRect(for position: UITextPosition) -> CGRect { - var result = super.caretRect(for: position) - - if "".isEmpty { - return result - } - - guard let textStorage = self.customLayoutManager.textStorage else { - return result - } - let _ = textStorage - - let index = self.offset(from: self.beginningOfDocument, to: position) - - let glyphRange = self.customLayoutManager.glyphRange(forCharacterRange: NSMakeRange(index, 1), actualCharacterRange: nil) - var boundingRect = self.customLayoutManager.boundingRect(forGlyphRange: glyphRange, in: self.customTextContainer) - - boundingRect.origin.y += 5.0 - - result.origin.y = boundingRect.minY - result.size.height = boundingRect.height - - return result + return super.caretRect(for: position) } override public func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { - let sourceRects = super.selectionRects(for: range) - - var result: [UITextSelectionRect] = [] - for rect in sourceRects { - var mappedRect = rect.rect - //mappedRect.size.height = 10.0 - mappedRect.size.height += 0.0 - result.append(CustomTextSelectionRect( - rect: mappedRect, - writingDirection: rect.writingDirection, - containsStart: rect.containsStart, - containsEnd: rect.containsEnd, - isVertical: rect.isVertical - )) - } - - return result + 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 final class CustomTextSelectionRect: UITextSelectionRect { - let rectValue: CGRect - let writingDirectionValue: NSWritingDirection - let containsStartValue: Bool - let containsEndValue: Bool - let isVerticalValue: Bool - - override var rect: CGRect { - return self.rectValue - } - override var writingDirection: NSWritingDirection { - return self.writingDirectionValue - } - override var containsStart: Bool { - return self.containsStartValue - } - override var containsEnd: Bool { - return self.containsEndValue - } - override var isVertical: Bool { - return self.isVerticalValue - } - - init(rect: CGRect, writingDirection: NSWritingDirection, containsStart: Bool, containsEnd: Bool, isVertical: Bool) { - self.rectValue = rect - self.writingDirectionValue = writingDirection - self.containsStartValue = containsStart - self.containsEndValue = containsEnd - self.isVerticalValue = isVertical - } -} - 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? - override init(frame: CGRect) { + init(toggleCollapse: @escaping (NSRange) -> Void) { + self.toggleCollapse = toggleCollapse + self.backgroundView = MessageInlineBlockBackgroundView() self.iconView = UIImageView(image: quoteIcon) - super.init(frame: frame) + 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") } - func update(value: ChatTextInputTextQuoteAttribute, size: CGSize, theme: ChatInputTextView.Theme.Quote) { + @objc private func toggleCollapsedTapped(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + if let range = self.range { + self.toggleCollapse(range) + } + } + } + + func update(value: ChatTextInputTextQuoteAttribute, 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 value.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? @@ -899,7 +1287,13 @@ private final class QuoteBackgroundView: UIView { switch value.kind { case .quote: - self.iconView.isHidden = false + if size.height >= 100.0 { + self.iconView.isHidden = true + self.collapseButton.isHidden = false + } else { + self.iconView.isHidden = false + self.collapseButton.isHidden = true + } switch theme.lineStyle { case let .solid(color): @@ -916,6 +1310,7 @@ private final class QuoteBackgroundView: UIView { backgroundColor = nil case .code: self.iconView.isHidden = true + self.collapseButton.isHidden = true primaryColor = theme.codeForeground backgroundColor = theme.codeBackground diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/BUILD index 748b191a91..5654796921 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/BUILD @@ -37,6 +37,7 @@ swift_library( "//submodules/TelegramUI/Components/Chat/MessageQuoteComponent", "//submodules/TelegramUI/Components/TextLoadingEffect", "//submodules/TelegramUI/Components/ChatControllerInteraction", + "//submodules/TelegramUI/Components/InteractiveTextComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift index c6ce0ca329..cd412646d9 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift @@ -26,6 +26,7 @@ import ShimmeringLinkNode import ChatMessageItemCommon import TextLoadingEffect import ChatControllerInteraction +import InteractiveTextComponent private final class CachedChatMessageText { let text: String @@ -83,8 +84,7 @@ private func findQuoteRange(string: String, quoteText: String, offset: Int?) -> public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { private let containerNode: ASDisplayNode - private let textNode: TextNodeWithEntities - private var spoilerTextNode: TextNodeWithEntities? + private let textNode: InteractiveTextNodeWithEntities private var dustNode: InvisibleInkDustNode? private let textAccessibilityOverlayNode: TextAccessibilityOverlayNode @@ -111,19 +111,19 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { private var codeHighlightState: (id: EngineMessage.Id, specs: [CachedMessageSyntaxHighlight.Spec], disposable: Disposable)? + private var collapsedBlockIds: Set = Set() + override public var visibility: ListViewItemNodeVisibility { didSet { if oldValue != self.visibility { switch self.visibility { case .none: self.textNode.visibilityRect = nil - self.spoilerTextNode?.visibilityRect = nil case let .visible(_, subRect): var subRect = subRect subRect.origin.x = 0.0 subRect.size.width = 10000.0 self.textNode.visibilityRect = subRect - self.spoilerTextNode?.visibilityRect = subRect } } } @@ -132,7 +132,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { required public init() { self.containerNode = ASDisplayNode() - self.textNode = TextNodeWithEntities() + self.textNode = InteractiveTextNodeWithEntities() self.textAccessibilityOverlayNode = TextAccessibilityOverlayNode() @@ -140,16 +140,28 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { self.addSubnode(self.containerNode) - self.textNode.textNode.isUserInteractionEnabled = false + self.textNode.textNode.isUserInteractionEnabled = true self.textNode.textNode.contentMode = .topLeft self.textNode.textNode.contentsScale = UIScreenScale self.textNode.textNode.displaysAsynchronously = true + //self.containerNode.addSubnode(self.textAccessibilityOverlayNode) self.containerNode.addSubnode(self.textNode.textNode) - self.containerNode.addSubnode(self.textAccessibilityOverlayNode) self.textAccessibilityOverlayNode.openUrl = { [weak self] url in self?.item?.controllerInteraction.openUrl(ChatControllerInteraction.OpenUrl(url: url, concealed: false, external: false)) } + + self.textNode.textNode.requestToggleBlockCollapsed = { [weak self] blockId in + guard let self, let item = self.item else { + return + } + if self.collapsedBlockIds.contains(blockId) { + self.collapsedBlockIds.remove(blockId) + } else { + self.collapsedBlockIds.insert(blockId) + } + item.controllerInteraction.requestMessageUpdate(item.message.id, false) + } } required public init?(coder aDecoder: NSCoder) { @@ -163,11 +175,11 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } override public func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize, _ avatarInset: CGFloat) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))) { - let textLayout = TextNodeWithEntities.asyncLayout(self.textNode) - let spoilerTextLayout = TextNodeWithEntities.asyncLayout(self.spoilerTextNode) + let textLayout = InteractiveTextNodeWithEntities.asyncLayout(self.textNode) let statusLayout = ChatMessageDateAndStatusNode.asyncLayout(self.statusNode) let currentCachedChatMessageText = self.cachedChatMessageText + let collapsedBlockIds = self.collapsedBlockIds return { item, layoutConstants, _, _, _, _ in let contentProperties = ChatMessageBubbleContentProperties(hidesSimpleAuthorHeader: false, headerSpacing: 0.0, hidesBackground: .never, forceFullCorners: false, forceAlignment: .none) @@ -503,7 +515,6 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { let currentDict = updatedString.attributes(at: range.lowerBound, effectiveRange: nil) var updatedAttributes: [NSAttributedString.Key: Any] = currentDict - //updatedAttributes[NSAttributedString.Key.foregroundColor] = UIColor.clear.cgColor updatedAttributes[ChatTextInputAttributes.customEmoji] = ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: fileId, file: item.message.associatedMedia[MediaId(namespace: Namespaces.Media.CloudFile, id: fileId)] as? TelegramMediaFile) let insertString = NSAttributedString(string: updatedString.attributedSubstring(from: range).string, attributes: updatedAttributes) @@ -542,14 +553,20 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } let textInsets = UIEdgeInsets(top: 2.0, left: 2.0, bottom: 5.0, right: 2.0) - let (textLayout, textApply) = textLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, maximumNumberOfLines: maximumNumberOfLines, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: textInsets, lineColor: messageTheme.accentControlColor, customTruncationToken: customTruncationToken)) - - let spoilerTextLayoutAndApply: (TextNodeLayout, (TextNodeWithEntities.Arguments?) -> TextNodeWithEntities)? - if !textLayout.spoilers.isEmpty { - spoilerTextLayoutAndApply = spoilerTextLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, maximumNumberOfLines: maximumNumberOfLines, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: textInsets, lineColor: messageTheme.accentControlColor, displaySpoilers: true, displayEmbeddedItemsUnderSpoilers: true)) - } else { - spoilerTextLayoutAndApply = nil - } + let (textLayout, textApply) = textLayout(InteractiveTextNodeLayoutArguments( + attributedString: attributedText, + backgroundColor: nil, + maximumNumberOfLines: maximumNumberOfLines, + truncationType: .end, + constrainedSize: textConstrainedSize, + alignment: .natural, + cutout: nil, + insets: textInsets, + lineColor: messageTheme.accentControlColor, + displayContentsUnderSpoilers: false, + customTruncationToken: customTruncationToken, + collapsedBlocks: collapsedBlockIds + )) var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageDateAndStatusNode))? if let statusType = statusType { @@ -559,7 +576,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } let trailingWidthToMeasure: CGFloat - if textLayout.hasRTL { + if let lastSegment = textLayout.segments.last, lastSegment.hasRTL { trailingWidthToMeasure = 10000.0 } else { trailingWidthToMeasure = textLayout.trailingLineWidth @@ -630,42 +647,20 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { strongSelf.textNode.textNode.displaysAsynchronously = !item.presentationData.isPreview strongSelf.containerNode.frame = CGRect(origin: CGPoint(), size: boundingSize) - let cachedLayout = strongSelf.textNode.textNode.cachedLayout - - if case .System = animation { - if let cachedLayout = cachedLayout { - if !cachedLayout.areLinesEqual(to: textLayout) { - if let textContents = strongSelf.textNode.textNode.contents { - let fadeNode = ASDisplayNode() - fadeNode.displaysAsynchronously = false - fadeNode.contents = textContents - fadeNode.frame = strongSelf.textNode.textNode.frame - fadeNode.isLayerBacked = true - strongSelf.containerNode.addSubnode(fadeNode) - fadeNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak fadeNode] _ in - fadeNode?.removeFromSupernode() - }) - strongSelf.textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) - } - } - } - } - - let _ = textApply(TextNodeWithEntities.Arguments(context: item.context, cache: item.controllerInteraction.presentationContext.animationCache, renderer: item.controllerInteraction.presentationContext.animationRenderer, placeholderColor: messageTheme.mediaPlaceholderColor, attemptSynchronous: synchronousLoads)) + let _ = textApply(InteractiveTextNodeWithEntities.Arguments( + context: item.context, + cache: item.controllerInteraction.presentationContext.animationCache, + renderer: item.controllerInteraction.presentationContext.animationRenderer, + placeholderColor: messageTheme.mediaPlaceholderColor, + attemptSynchronous: synchronousLoads, + textColor: messageTheme.primaryTextColor, + spoilerEffectColor: messageTheme.secondaryTextColor, + animation: animation + )) animation.animator.updateFrame(layer: strongSelf.textNode.textNode.layer, frame: textFrame, completion: nil) - if let (_, spoilerTextApply) = spoilerTextLayoutAndApply { - let spoilerTextNode = spoilerTextApply(TextNodeWithEntities.Arguments(context: item.context, cache: item.controllerInteraction.presentationContext.animationCache, renderer: item.controllerInteraction.presentationContext.animationRenderer, placeholderColor: messageTheme.mediaPlaceholderColor, attemptSynchronous: synchronousLoads)) - if strongSelf.spoilerTextNode == nil { - spoilerTextNode.textNode.alpha = 0.0 - spoilerTextNode.textNode.isUserInteractionEnabled = false - spoilerTextNode.textNode.contentMode = .topLeft - spoilerTextNode.textNode.contentsScale = UIScreenScale - spoilerTextNode.textNode.displaysAsynchronously = false - strongSelf.containerNode.insertSubnode(spoilerTextNode.textNode, aboveSubnode: strongSelf.textAccessibilityOverlayNode) - - strongSelf.spoilerTextNode = spoilerTextNode - } + /*if let (_, spoilerTextApply) = spoilerTextLayoutAndApply { + let spoilerTextNode = spoilerTextApply(InteractiveTextNodeWithEntities.Arguments(context: item.context, cache: item.controllerInteraction.presentationContext.animationCache, renderer: item.controllerInteraction.presentationContext.animationRenderer, placeholderColor: messageTheme.mediaPlaceholderColor, attemptSynchronous: synchronousLoads, animation: animation)) strongSelf.spoilerTextNode?.textNode.frame = textFrame @@ -687,18 +682,16 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { strongSelf.dustNode = nil dustNode.removeFromSupernode() } - } + }*/ switch strongSelf.visibility { case .none: strongSelf.textNode.visibilityRect = nil - strongSelf.spoilerTextNode?.visibilityRect = nil case let .visible(_, subRect): var subRect = subRect subRect.origin.x = 0.0 subRect.size.width = 10000.0 strongSelf.textNode.visibilityRect = subRect - strongSelf.spoilerTextNode?.visibilityRect = subRect } if let textSelectionNode = strongSelf.textSelectionNode { @@ -710,7 +703,8 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } } strongSelf.textAccessibilityOverlayNode.frame = textFrame - strongSelf.textAccessibilityOverlayNode.cachedLayout = textLayout + //TODO:localize + //strongSelf.textAccessibilityOverlayNode.cachedLayout = textLayout strongSelf.updateIsTranslating(isTranslating) @@ -852,7 +846,8 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } let textNodeFrame = self.textNode.textNode.frame - if let (index, attributes) = self.textNode.textNode.attributesAtPoint(CGPoint(x: point.x - textNodeFrame.minX, y: point.y - textNodeFrame.minY)) { + let textLocalPoint = CGPoint(x: point.x - textNodeFrame.minX, y: point.y - textNodeFrame.minY) + if let (index, attributes) = self.textNode.textNode.attributesAtPoint(textLocalPoint) { if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Spoiler)], !(self.dustNode?.isRevealed ?? true) { return ChatMessageBubbleContentTapAction(content: .none) } else if let url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String { @@ -945,10 +940,14 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } else if let code = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Code)] as? String { return ChatMessageBubbleContentTapAction(content: .copy(code)) } else if let _ = attributes[NSAttributedString.Key(rawValue: "Attribute__Blockquote")] { - if let text = self.textNode.textNode.attributeSubstring(name: "Attribute__Blockquote", index: index) { - return ChatMessageBubbleContentTapAction(content: .copy(text.1)) - } else { + if let _ = self.textNode.textNode.collapsibleBlockAtPoint(textLocalPoint) { return ChatMessageBubbleContentTapAction(content: .none) + } else { + if let text = self.textNode.textNode.attributeSubstring(name: "Attribute__Blockquote", index: index) { + return ChatMessageBubbleContentTapAction(content: .copy(text.1)) + } else { + return ChatMessageBubbleContentTapAction(content: .none) + } } } else if let emoji = attributes[NSAttributedString.Key(rawValue: ChatTextInputAttributes.customEmoji.rawValue)] as? ChatTextInputTextCustomEmojiAttribute, let file = emoji.file { return ChatMessageBubbleContentTapAction(content: .customEmoji(file)) @@ -1074,7 +1073,17 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { rectsSet = [] } for i in 0 ..< rectsSet.count { - let rects = rectsSet[i] + var rects = rectsSet[i] + if rects.count > 1 { + for i in 0 ..< rects.count - 1 { + let deltaY = rects[i + 1].minY - rects[i].maxY + if deltaY > 0.0 && deltaY <= 2.0 { + rects[i].size.height += deltaY * 0.5 + rects[i + 1].size.height += deltaY * 0.5 + rects[i + 1].origin.y -= deltaY * 0.5 + } + } + } let textHighlightNode: LinkHighlightingNode if i < self.textHighlightingNodes.count { textHighlightNode = self.textHighlightingNodes[i] @@ -1302,11 +1311,13 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { guard let strongSelf = self else { return } - if let dustNode = strongSelf.dustNode, !dustNode.isRevealed, let textLayout = strongSelf.textNode.textNode.cachedLayout, !textLayout.spoilers.isEmpty, let selectionRange = selectionRange { - for (spoilerRange, _) in textLayout.spoilers { - if let intersection = selectionRange.intersection(spoilerRange), intersection.length > 0 { - dustNode.update(revealed: true) - return + if let dustNode = strongSelf.dustNode, !dustNode.isRevealed, let textLayout = strongSelf.textNode.textNode.cachedLayout, textLayout.segments.contains(where: { !$0.spoilers.isEmpty }), let selectionRange { + for segment in textLayout.segments { + for (spoilerRange, _) in segment.spoilers { + if let intersection = selectionRange.intersection(spoilerRange), intersection.length > 0 { + dustNode.update(revealed: true) + return + } } } } diff --git a/submodules/TelegramUI/Components/InteractiveTextComponent/BUILD b/submodules/TelegramUI/Components/InteractiveTextComponent/BUILD new file mode 100644 index 0000000000..86bdcf4b76 --- /dev/null +++ b/submodules/TelegramUI/Components/InteractiveTextComponent/BUILD @@ -0,0 +1,29 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "InteractiveTextComponent", + module_name = "InteractiveTextComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/AsyncDisplayKit", + "//submodules/Display", + "//submodules/ComponentFlow", + "//submodules/AppBundle", + "//submodules/TextFormat", + "//submodules/AccountContext", + "//submodules/TelegramUI/Components/AnimationCache", + "//submodules/TelegramUI/Components/MultiAnimationRenderer", + "//submodules/TelegramCore", + "//submodules/TelegramUI/Components/EmojiTextAttachmentView", + "//submodules/TelegramUI/Components/Chat/MessageInlineBlockBackgroundView", + "//submodules/InvisibleInkDustNode", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextComponent.swift b/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextComponent.swift new file mode 100644 index 0000000000..4035454d1c --- /dev/null +++ b/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextComponent.swift @@ -0,0 +1,2311 @@ +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import CoreText +import AppBundle +import ComponentFlow +import TextFormat +import MessageInlineBlockBackgroundView + +private let defaultFont = UIFont.systemFont(ofSize: 15.0) + +private let quoteIcon: UIImage = { + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/ReplyQuoteIcon"), color: .white)! +}() + +private let codeIcon: UIImage = { + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/TextCodeIcon"), color: .white)! +}() + +private let expandArrowIcon: UIImage = { + return generateTintedImage(image: UIImage(bundleImageName: "Item List/ExpandingItemVerticalRegularArrow"), color: .white)! +}() + +private func generateBlockMaskImage() -> UIImage { + let size = CGSize(width: 36.0 + 20.0, height: 36.0) + return generateImage(size, rotatedContext: { size, context in + context.clear(CGRect(origin: .zero, size: size)) + + context.setFillColor(UIColor.black.cgColor) + context.fill(CGRect(origin: .zero, size: size)) + + let colorSpace = CGColorSpaceCreateDeviceRGB() + + var locations: [CGFloat] = [0.0, 0.5, 1.0] + var colors: [CGColor] = [UIColor.black.withAlphaComponent(0.0).cgColor, UIColor.black.withAlphaComponent(0.0).cgColor, UIColor.black.cgColor] + + var gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)! + + context.setBlendMode(.copy) + context.drawRadialGradient(gradient, startCenter: CGPoint(x: size.width - 20.0, y: size.height), startRadius: 0.0, endCenter: CGPoint(x: size.width - 20.0, y: size.height), endRadius: 34.0, options: CGGradientDrawingOptions()) + + locations = [0.0, 0.5, 1.0] + colors = [UIColor.black.withAlphaComponent(0.0).cgColor, UIColor.black.withAlphaComponent(0.4).cgColor, UIColor.black.cgColor] + gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)! + + context.setBlendMode(.destinationIn) + context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: size.height), end: CGPoint(x: 0.0, y: size.height - 7.0), options: CGGradientDrawingOptions()) + })!.resizableImage(withCapInsets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: size.height - 1.0, right: size.width - 1.0), resizingMode: .stretch) +} + +private let expandableBlockMaskImage: UIImage = { + return generateBlockMaskImage() +}() + +private final class InteractiveTextNodeStrikethrough { + let range: NSRange + let frame: CGRect + + init(range: NSRange, frame: CGRect) { + self.range = range + self.frame = frame + } +} + +private final class InteractiveTextNodeSpoiler { + let range: NSRange + let frame: CGRect + + init(range: NSRange, frame: CGRect) { + self.range = range + self.frame = frame + } +} + +private final class InteractiveTextNodeEmbeddedItem { + let range: NSRange + let frame: CGRect + let item: AnyHashable + let isHiddenBySpoiler: Bool + + init(range: NSRange, frame: CGRect, item: AnyHashable, isHiddenBySpoiler: Bool) { + self.range = range + self.frame = frame + self.item = item + self.isHiddenBySpoiler = isHiddenBySpoiler + } +} + +private final class InteractiveTextNodeAttachment { + let range: NSRange + let frame: CGRect + let attachment: UIImage + + init(range: NSRange, frame: CGRect, attachment: UIImage) { + self.range = range + self.frame = frame + self.attachment = attachment + } +} + +private final class InteractiveTextNodeLine { + let line: CTLine + var frame: CGRect + let ascent: CGFloat + let descent: CGFloat + let range: NSRange? + let isRTL: Bool + var strikethroughs: [InteractiveTextNodeStrikethrough] + var spoilers: [InteractiveTextNodeSpoiler] + var spoilerWords: [InteractiveTextNodeSpoiler] + var embeddedItems: [InteractiveTextNodeEmbeddedItem] + var attachments: [InteractiveTextNodeAttachment] + let additionalTrailingLine: (CTLine, Double)? + + init(line: CTLine, frame: CGRect, ascent: CGFloat, descent: CGFloat, range: NSRange?, isRTL: Bool, strikethroughs: [InteractiveTextNodeStrikethrough], spoilers: [InteractiveTextNodeSpoiler], spoilerWords: [InteractiveTextNodeSpoiler], embeddedItems: [InteractiveTextNodeEmbeddedItem], attachments: [InteractiveTextNodeAttachment], additionalTrailingLine: (CTLine, Double)?) { + self.line = line + self.frame = frame + self.ascent = ascent + self.descent = descent + self.range = range + self.isRTL = isRTL + self.strikethroughs = strikethroughs + self.spoilers = spoilers + self.spoilerWords = spoilerWords + self.embeddedItems = embeddedItems + self.attachments = attachments + self.additionalTrailingLine = additionalTrailingLine + } +} + +private final class InteractiveTextNodeBlockQuote { + let id: Int + let frame: CGRect + let data: TextNodeBlockQuoteData + let tintColor: UIColor + let secondaryTintColor: UIColor? + let tertiaryTintColor: UIColor? + let backgroundColor: UIColor + let isCollapsed: Bool? + + init(id: Int, frame: CGRect, data: TextNodeBlockQuoteData, tintColor: UIColor, secondaryTintColor: UIColor?, tertiaryTintColor: UIColor?, backgroundColor: UIColor, isCollapsed: Bool?) { + self.id = id + self.frame = frame + self.data = data + self.tintColor = tintColor + self.secondaryTintColor = secondaryTintColor + self.tertiaryTintColor = tertiaryTintColor + self.backgroundColor = backgroundColor + self.isCollapsed = isCollapsed + } +} + +private func displayLineFrame(frame: CGRect, isRTL: Bool, boundingRect: CGRect, cutout: TextNodeCutout?) -> CGRect { + if frame.width.isEqual(to: boundingRect.width) { + return frame + } + var lineFrame = frame + let intersectionFrame = lineFrame.offsetBy(dx: 0.0, dy: 0.0) + + if isRTL { + lineFrame.origin.x = max(0.0, floor(boundingRect.width - lineFrame.size.width)) + if let topRight = cutout?.topRight { + let topRightRect = CGRect(origin: CGPoint(x: boundingRect.width - topRight.width, y: 0.0), size: topRight) + if intersectionFrame.intersects(topRightRect) { + lineFrame.origin.x -= topRight.width + return lineFrame + } + } + if let bottomRight = cutout?.bottomRight { + let bottomRightRect = CGRect(origin: CGPoint(x: boundingRect.width - bottomRight.width, y: boundingRect.height - bottomRight.height), size: bottomRight) + if intersectionFrame.intersects(bottomRightRect) { + lineFrame.origin.x -= bottomRight.width + return lineFrame + } + } + } + return lineFrame +} + +public final class InteractiveTextNodeSegment { + fileprivate let lines: [InteractiveTextNodeLine] + public let visibleLineCount: Int + fileprivate let tintColor: UIColor? + fileprivate let secondaryTintColor: UIColor? + fileprivate let tertiaryTintColor: UIColor? + fileprivate let blockQuote: InteractiveTextNodeBlockQuote? + public let hasRTL: Bool + public let spoilers: [(NSRange, CGRect)] + public let spoilerWords: [(NSRange, CGRect)] + public let embeddedItems: [InteractiveTextNodeLayout.EmbeddedItem] + + fileprivate init( + lines: [InteractiveTextNodeLine], + visibleLineCount: Int, + tintColor: UIColor?, + secondaryTintColor: UIColor?, + tertiaryTintColor: UIColor?, + blockQuote: InteractiveTextNodeBlockQuote?, + attributedString: NSAttributedString?, + resolvedAlignment: NSTextAlignment, + layoutSize: CGSize + ) { + self.lines = lines + self.visibleLineCount = visibleLineCount + self.tintColor = tintColor + self.secondaryTintColor = secondaryTintColor + self.tertiaryTintColor = tertiaryTintColor + self.blockQuote = blockQuote + + var hasRTL = false + var spoilers: [(NSRange, CGRect)] = [] + var spoilerWords: [(NSRange, CGRect)] = [] + var embeddedItems: [InteractiveTextNodeLayout.EmbeddedItem] = [] + + for line in self.lines { + if line.isRTL { + hasRTL = true + } + + let lineFrame: CGRect + switch resolvedAlignment { + case .center: + lineFrame = CGRect(origin: CGPoint(x: floor((layoutSize.width - line.frame.size.width) / 2.0), y: line.frame.minY), size: line.frame.size) + case .right: + lineFrame = CGRect(origin: CGPoint(x: layoutSize.width - line.frame.size.width, y: line.frame.minY), size: line.frame.size) + default: + lineFrame = displayLineFrame(frame: line.frame, isRTL: line.isRTL, boundingRect: CGRect(origin: CGPoint(), size: layoutSize), cutout: nil) + } + + spoilers.append(contentsOf: line.spoilers.map { ( $0.range, $0.frame.offsetBy(dx: lineFrame.minX, dy: lineFrame.minY)) }) + spoilerWords.append(contentsOf: line.spoilerWords.map { ( $0.range, $0.frame.offsetBy(dx: lineFrame.minX, dy: lineFrame.minY)) }) + for embeddedItem in line.embeddedItems { + var textColor: UIColor? + if let attributedString, embeddedItem.range.location < attributedString.length { + if let color = attributedString.attribute(.foregroundColor, at: embeddedItem.range.location, effectiveRange: nil) as? UIColor { + textColor = color + } + if textColor == nil { + if let color = attributedString.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? UIColor { + textColor = color + } + } + } + embeddedItems.append(InteractiveTextNodeLayout.EmbeddedItem(range: embeddedItem.range, rect: embeddedItem.frame.offsetBy(dx: lineFrame.minX, dy: lineFrame.minY), value: embeddedItem.item, textColor: textColor ?? .black, isHiddenBySpoiler: embeddedItem.isHiddenBySpoiler)) + } + } + + self.hasRTL = hasRTL + self.spoilers = spoilers + self.spoilerWords = spoilerWords + self.embeddedItems = embeddedItems + } +} + +public final class InteractiveTextNodeLayoutArguments { + public let attributedString: NSAttributedString? + public let backgroundColor: UIColor? + public let minimumNumberOfLines: Int + public let maximumNumberOfLines: Int + public let truncationType: CTLineTruncationType + public let constrainedSize: CGSize + public let alignment: NSTextAlignment + public let verticalAlignment: TextVerticalAlignment + public let lineSpacing: CGFloat + public let cutout: TextNodeCutout? + public let insets: UIEdgeInsets + public let lineColor: UIColor? + public let textShadowColor: UIColor? + public let textShadowBlur: CGFloat? + public let textStroke: (UIColor, CGFloat)? + public let displayContentsUnderSpoilers: Bool + public let customTruncationToken: NSAttributedString? + public let collapsedBlocks: Set + + public init( + attributedString: NSAttributedString?, + backgroundColor: UIColor? = nil, + minimumNumberOfLines: Int = 0, + maximumNumberOfLines: Int, + truncationType: CTLineTruncationType, + constrainedSize: CGSize, + alignment: NSTextAlignment = .natural, + verticalAlignment: TextVerticalAlignment = .top, + lineSpacing: CGFloat = 0.12, + cutout: TextNodeCutout? = nil, + insets: UIEdgeInsets = UIEdgeInsets(), + lineColor: UIColor? = nil, + textShadowColor: UIColor? = nil, + textShadowBlur: CGFloat? = nil, + textStroke: (UIColor, CGFloat)? = nil, + displayContentsUnderSpoilers: Bool = false, + customTruncationToken: NSAttributedString? = nil, + collapsedBlocks: Set = Set() + ) { + self.attributedString = attributedString + self.backgroundColor = backgroundColor + self.minimumNumberOfLines = minimumNumberOfLines + self.maximumNumberOfLines = maximumNumberOfLines + self.truncationType = truncationType + self.constrainedSize = constrainedSize + self.alignment = alignment + self.verticalAlignment = verticalAlignment + self.lineSpacing = lineSpacing + self.cutout = cutout + self.insets = insets + self.lineColor = lineColor + self.textShadowColor = textShadowColor + self.textShadowBlur = textShadowBlur + self.textStroke = textStroke + self.displayContentsUnderSpoilers = displayContentsUnderSpoilers + self.customTruncationToken = customTruncationToken + self.collapsedBlocks = collapsedBlocks + } + + public func withAttributedString(_ attributedString: NSAttributedString?) -> InteractiveTextNodeLayoutArguments { + return InteractiveTextNodeLayoutArguments( + attributedString: attributedString, + backgroundColor: self.backgroundColor, + minimumNumberOfLines: self.minimumNumberOfLines, + maximumNumberOfLines: self.maximumNumberOfLines, + truncationType: self.truncationType, + constrainedSize: self.constrainedSize, + alignment: self.alignment, + verticalAlignment: self.verticalAlignment, + lineSpacing: self.lineSpacing, + cutout: self.cutout, + insets: self.insets, + lineColor: self.lineColor, + textShadowColor: self.textShadowColor, + textShadowBlur: self.textShadowBlur, + textStroke: self.textStroke, + displayContentsUnderSpoilers: self.displayContentsUnderSpoilers, + customTruncationToken: self.customTruncationToken, + collapsedBlocks: self.collapsedBlocks + ) + } +} + +public final class InteractiveTextNodeLayout: NSObject { + public final class EmbeddedItem: Equatable { + public let range: NSRange + public let rect: CGRect + public let value: AnyHashable + public let textColor: UIColor + public let isHiddenBySpoiler: Bool + + public init(range: NSRange, rect: CGRect, value: AnyHashable, textColor: UIColor, isHiddenBySpoiler: Bool) { + self.range = range + self.rect = rect + self.value = value + self.textColor = textColor + self.isHiddenBySpoiler = isHiddenBySpoiler + } + + public static func ==(lhs: EmbeddedItem, rhs: EmbeddedItem) -> Bool { + if lhs.range != rhs.range { + return false + } + if lhs.rect != rhs.rect { + return false + } + if lhs.value != rhs.value { + return false + } + if lhs.textColor != rhs.textColor { + return false + } + if lhs.isHiddenBySpoiler != rhs.isHiddenBySpoiler { + return false + } + return true + } + } + + public let attributedString: NSAttributedString? + fileprivate let maximumNumberOfLines: Int + fileprivate let truncationType: CTLineTruncationType + fileprivate let backgroundColor: UIColor? + fileprivate let constrainedSize: CGSize + fileprivate let explicitAlignment: NSTextAlignment + fileprivate let resolvedAlignment: NSTextAlignment + fileprivate let verticalAlignment: TextVerticalAlignment + fileprivate let lineSpacing: CGFloat + fileprivate let cutout: TextNodeCutout? + public let insets: UIEdgeInsets + public let size: CGSize + public let rawTextSize: CGSize + public let truncated: Bool + fileprivate let firstLineOffset: CGFloat + public let segments: [InteractiveTextNodeSegment] + fileprivate let lineColor: UIColor? + fileprivate let textShadowColor: UIColor? + fileprivate let textShadowBlur: CGFloat? + fileprivate let textStroke: (UIColor, CGFloat)? + fileprivate let displayContentsUnderSpoilers: Bool + fileprivate let collapsedBlocks: Set + + fileprivate init( + attributedString: NSAttributedString?, + maximumNumberOfLines: Int, + truncationType: CTLineTruncationType, + constrainedSize: CGSize, + explicitAlignment: NSTextAlignment, + resolvedAlignment: NSTextAlignment, + verticalAlignment: TextVerticalAlignment, + lineSpacing: CGFloat, + cutout: TextNodeCutout?, + insets: UIEdgeInsets, + size: CGSize, + rawTextSize: CGSize, + truncated: Bool, + firstLineOffset: CGFloat, + segments: [InteractiveTextNodeSegment], + backgroundColor: UIColor?, + lineColor: UIColor?, + textShadowColor: UIColor?, + textShadowBlur: CGFloat?, + textStroke: (UIColor, CGFloat)?, + displayContentsUnderSpoilers: Bool, + collapsedBlocks: Set + ) { + self.attributedString = attributedString + self.maximumNumberOfLines = maximumNumberOfLines + self.truncationType = truncationType + self.constrainedSize = constrainedSize + self.explicitAlignment = explicitAlignment + self.resolvedAlignment = resolvedAlignment + self.verticalAlignment = verticalAlignment + self.lineSpacing = lineSpacing + self.cutout = cutout + self.insets = insets + self.size = size + self.rawTextSize = rawTextSize + self.truncated = truncated + self.firstLineOffset = firstLineOffset + self.segments = segments + self.backgroundColor = backgroundColor + self.lineColor = lineColor + self.textShadowColor = textShadowColor + self.textShadowBlur = textShadowBlur + self.textStroke = textStroke + self.displayContentsUnderSpoilers = displayContentsUnderSpoilers + self.collapsedBlocks = collapsedBlocks + } + + public var numberOfLines: Int { + var result = 0 + for segment in self.segments { + result += segment.lines.count + } + return result + } + + public var trailingLineWidth: CGFloat { + if let lastSegment = self.segments.last, let lastLine = lastSegment.lines.last { + var width = lastLine.frame.maxX + + if let blockQuote = lastSegment.blockQuote { + if lastLine.frame.intersects(blockQuote.frame) { + width = max(width, ceil(blockQuote.frame.maxX) + 2.0) + } + } + return width + } else { + return 0.0 + } + } + + public var trailingLineIsRTL: Bool { + if let lastSegment = self.segments.last, let lastLine = lastSegment.lines.last { + return lastLine.isRTL + } else { + return false + } + } + + public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? { + if let attributedString = self.attributedString { + let transformedPoint = CGPoint(x: point.x - self.insets.left, y: point.y - self.insets.top) + if orNearest { + var segmentIndex = -1 + var closestLine: ((segment: Int, line: Int), CGRect, CGFloat)? + for segment in self.segments { + segmentIndex += 1 + var lineIndex = -1 + for line in segment.lines.prefix(segment.visibleLineCount) { + lineIndex += 1 + var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y), size: line.frame.size) + switch self.resolvedAlignment { + case .center: + lineFrame.origin.x = floor((self.size.width - lineFrame.size.width) / 2.0) + case .natural, .left: + lineFrame = displayLineFrame(frame: lineFrame, isRTL: line.isRTL, boundingRect: CGRect(origin: CGPoint(), size: self.size), cutout: self.cutout) + case .right: + lineFrame.origin.x = self.size.width - lineFrame.size.width + default: + break + } + + let currentDistance = (lineFrame.center.y - point.y) * (lineFrame.center.y - point.y) + if let current = closestLine { + if current.2 > currentDistance { + closestLine = ((segmentIndex, lineIndex), lineFrame, currentDistance) + } + } else { + closestLine = ((segmentIndex, lineIndex), lineFrame, currentDistance) + } + } + } + + if let (index, lineFrame, _) = closestLine { + let line = self.segments[index.segment].lines[index.line] + + let lineRange = CTLineGetStringRange(line.line) + var index: Int + if transformedPoint.x <= lineFrame.minX { + index = lineRange.location + } else if transformedPoint.x >= lineFrame.maxX { + index = lineRange.location + lineRange.length + } else { + index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: transformedPoint.x - lineFrame.minX, y: floor(lineFrame.height / 2.0))) + if index != 0 { + var glyphStart: CGFloat = 0.0 + CTLineGetOffsetForStringIndex(line.line, index, &glyphStart) + if transformedPoint.x < glyphStart { + var closestLowerIndex: Int? + let glyphRuns = CTLineGetGlyphRuns(line.line) as NSArray + if glyphRuns.count != 0 { + for run in glyphRuns { + let run = run as! CTRun + let glyphCount = CTRunGetGlyphCount(run) + for i in 0 ..< glyphCount { + var glyphIndex: CFIndex = 0 + CTRunGetStringIndices(run, CFRangeMake(i, 1), &glyphIndex) + if glyphIndex < index { + if let closestLowerIndexValue = closestLowerIndex { + if closestLowerIndexValue < glyphIndex { + closestLowerIndex = glyphIndex + } + } else { + closestLowerIndex = glyphIndex + } + } + } + } + } + if let closestLowerIndex = closestLowerIndex { + index = closestLowerIndex + } + } + } + } + return (index, [:]) + } + } + var segmentIndex = -1 + for segment in self.segments { + segmentIndex += 1 + var lineIndex = -1 + for line in segment.lines.prefix(segment.visibleLineCount) { + lineIndex += 1 + var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y), size: line.frame.size) + switch self.resolvedAlignment { + case .center: + lineFrame.origin.x = floor((self.size.width - lineFrame.size.width) / 2.0) + case .natural, .left: + lineFrame = displayLineFrame(frame: lineFrame, isRTL: line.isRTL, boundingRect: CGRect(origin: CGPoint(), size: self.size), cutout: self.cutout) + case .right: + lineFrame.origin.x = self.size.width - lineFrame.size.width + default: + break + } + if lineFrame.contains(transformedPoint) { + var index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: transformedPoint.x - lineFrame.minX, y: transformedPoint.y - lineFrame.minY)) + if index == attributedString.length { + var closestLowerIndex: Int? + let glyphRuns = CTLineGetGlyphRuns(line.line) as NSArray + if glyphRuns.count != 0 { + for run in glyphRuns { + let run = run as! CTRun + let glyphCount = CTRunGetGlyphCount(run) + for i in 0 ..< glyphCount { + var glyphIndex: CFIndex = 0 + CTRunGetStringIndices(run, CFRangeMake(i, 1), &glyphIndex) + if glyphIndex < index { + if let closestLowerIndexValue = closestLowerIndex { + if closestLowerIndexValue < glyphIndex { + closestLowerIndex = glyphIndex + } + } else { + closestLowerIndex = glyphIndex + } + } + } + } + } + if let closestLowerIndex = closestLowerIndex { + index = closestLowerIndex + } + } else if index != 0 { + var glyphStart: CGFloat = 0.0 + CTLineGetOffsetForStringIndex(line.line, index, &glyphStart) + if transformedPoint.x < glyphStart { + var closestLowerIndex: Int? + let glyphRuns = CTLineGetGlyphRuns(line.line) as NSArray + if glyphRuns.count != 0 { + for run in glyphRuns { + let run = run as! CTRun + let glyphCount = CTRunGetGlyphCount(run) + for i in 0 ..< glyphCount { + var glyphIndex: CFIndex = 0 + CTRunGetStringIndices(run, CFRangeMake(i, 1), &glyphIndex) + if glyphIndex < index { + if let closestLowerIndexValue = closestLowerIndex { + if closestLowerIndexValue < glyphIndex { + closestLowerIndex = glyphIndex + } + } else { + closestLowerIndex = glyphIndex + } + } + } + } + } + if let closestLowerIndex = closestLowerIndex { + index = closestLowerIndex + } + } + } + if index >= 0 && index < attributedString.length { + if let range = line.range, index < range.location + range.length { + return (index, attributedString.attributes(at: index, effectiveRange: nil)) + } + } + break + } + } + } + + segmentIndex = -1 + for segment in self.segments { + segmentIndex += 1 + var lineIndex = -1 + for line in segment.lines.prefix(segment.visibleLineCount) { + lineIndex += 1 + var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y), size: line.frame.size) + switch self.resolvedAlignment { + case .center: + lineFrame.origin.x = floor((self.size.width - lineFrame.size.width) / 2.0) + case .natural: + lineFrame = displayLineFrame(frame: lineFrame, isRTL: line.isRTL, boundingRect: CGRect(origin: CGPoint(), size: self.size), cutout: self.cutout) + case .right: + lineFrame.origin.x = self.size.width - lineFrame.size.width + default: + break + } + if lineFrame.offsetBy(dx: 0.0, dy: -lineFrame.size.height).insetBy(dx: -3.0, dy: -3.0).contains(transformedPoint) { + var index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: transformedPoint.x - lineFrame.minX, y: transformedPoint.y - lineFrame.minY)) + if index == attributedString.length { + var closestLowerIndex: Int? + let glyphRuns = CTLineGetGlyphRuns(line.line) as NSArray + if glyphRuns.count != 0 { + for run in glyphRuns { + let run = run as! CTRun + let glyphCount = CTRunGetGlyphCount(run) + for i in 0 ..< glyphCount { + var glyphIndex: CFIndex = 0 + CTRunGetStringIndices(run, CFRangeMake(i, 1), &glyphIndex) + if glyphIndex < index { + if let closestLowerIndexValue = closestLowerIndex { + if closestLowerIndexValue < glyphIndex { + closestLowerIndex = glyphIndex + } + } else { + closestLowerIndex = glyphIndex + } + } + } + } + } + if let closestLowerIndex = closestLowerIndex { + index = closestLowerIndex + } + } else if index != 0 { + var glyphStart: CGFloat = 0.0 + CTLineGetOffsetForStringIndex(line.line, index, &glyphStart) + if transformedPoint.x < glyphStart { + var closestLowerIndex: Int? + let glyphRuns = CTLineGetGlyphRuns(line.line) as NSArray + if glyphRuns.count != 0 { + for run in glyphRuns { + let run = run as! CTRun + let glyphCount = CTRunGetGlyphCount(run) + for i in 0 ..< glyphCount { + var glyphIndex: CFIndex = 0 + CTRunGetStringIndices(run, CFRangeMake(i, 1), &glyphIndex) + if glyphIndex < index { + if let closestLowerIndexValue = closestLowerIndex { + if closestLowerIndexValue < glyphIndex { + closestLowerIndex = glyphIndex + } + } else { + closestLowerIndex = glyphIndex + } + } + } + } + } + if let closestLowerIndex = closestLowerIndex { + index = closestLowerIndex + } + } + } + if index >= 0 && index < attributedString.length { + if let range = line.range, index < range.location + range.length { + return (index, attributedString.attributes(at: index, effectiveRange: nil)) + } + } + break + } + } + } + } + return nil + } + + public func linesRects() -> [CGRect] { + var rects: [CGRect] = [] + for segment in self.segments { + for line in segment.lines.prefix(segment.visibleLineCount) { + rects.append(line.frame) + } + } + return rects + } + + public func textRangesRects(text: String) -> [[CGRect]] { + guard let attributedString = self.attributedString else { + return [] + } + + let (ranges, searchText) = findSubstringRanges(in: attributedString.string, query: text) + + var result: [[CGRect]] = [] + for stringRange in ranges { + var rects: [CGRect] = [] + let range = NSRange(stringRange, in: searchText) + for segment in self.segments { + for line in segment.lines.prefix(segment.visibleLineCount) { + guard let rangeValue = line.range else { + continue + } + let lineRange = NSIntersectionRange(range, rangeValue) + if lineRange.length != 0 { + var leftOffset: CGFloat = 0.0 + if lineRange.location != rangeValue.location { + leftOffset = floor(CTLineGetOffsetForStringIndex(line.line, lineRange.location, nil)) + } + var rightOffset: CGFloat = line.frame.width + if lineRange.location + lineRange.length != rangeValue.length { + var secondaryOffset: CGFloat = 0.0 + let rawOffset = CTLineGetOffsetForStringIndex(line.line, lineRange.location + lineRange.length, &secondaryOffset) + rightOffset = ceil(rawOffset) + if !rawOffset.isEqual(to: secondaryOffset) { + rightOffset = ceil(secondaryOffset) + } + } + var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y), size: line.frame.size) + lineFrame = displayLineFrame(frame: lineFrame, isRTL: line.isRTL, boundingRect: CGRect(origin: CGPoint(), size: self.size), cutout: self.cutout) + + let width = abs(rightOffset - leftOffset) + rects.append(CGRect(origin: CGPoint(x: lineFrame.minX + min(leftOffset, rightOffset) + self.insets.left, y: lineFrame.minY + self.insets.top), size: CGSize(width: width, height: lineFrame.size.height))) + } + } + } + if !rects.isEmpty { + result.append(rects) + } + } + return result + } + + public func attributeSubstring(name: String, index: Int) -> (String, String)? { + if let attributedString = self.attributedString { + var range = NSRange() + let _ = attributedString.attribute(NSAttributedString.Key(rawValue: name), at: index, effectiveRange: &range) + if range.length != 0 { + return ((attributedString.string as NSString).substring(with: range), attributedString.string) + } + } + return nil + } + + public func attributeSubstringWithRange(name: String, index: Int) -> (String, String, NSRange)? { + if let attributedString = self.attributedString { + var range = NSRange() + let _ = attributedString.attribute(NSAttributedString.Key(rawValue: name), at: index, effectiveRange: &range) + if range.length != 0 { + return ((attributedString.string as NSString).substring(with: range), attributedString.string, range) + } + } + return nil + } + + public func allAttributeRects(name: String) -> [(Any, CGRect)] { + guard let attributedString = self.attributedString else { + return [] + } + var result: [(Any, CGRect)] = [] + attributedString.enumerateAttribute(NSAttributedString.Key(rawValue: name), in: NSRange(location: 0, length: attributedString.length), options: []) { (value, range, _) in + if let value = value, range.length != 0 { + var coveringRect = CGRect() + for segment in self.segments { + for line in segment.lines.prefix(segment.visibleLineCount) { + guard let rangeValue = line.range else { + continue + } + let lineRange = NSIntersectionRange(range, rangeValue) + if lineRange.length != 0 { + var leftOffset: CGFloat = 0.0 + if lineRange.location != rangeValue.location { + leftOffset = floor(CTLineGetOffsetForStringIndex(line.line, lineRange.location, nil)) + } + var rightOffset: CGFloat = line.frame.width + if lineRange.location + lineRange.length != rangeValue.length { + var secondaryOffset: CGFloat = 0.0 + let rawOffset = CTLineGetOffsetForStringIndex(line.line, lineRange.location + lineRange.length, &secondaryOffset) + rightOffset = ceil(rawOffset) + if !rawOffset.isEqual(to: secondaryOffset) { + rightOffset = ceil(secondaryOffset) + } + } + + var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y), size: line.frame.size) + switch self.resolvedAlignment { + case .center: + lineFrame.origin.x = floor((self.size.width - lineFrame.size.width) / 2.0) + case .natural: + lineFrame = displayLineFrame(frame: lineFrame, isRTL: line.isRTL, boundingRect: CGRect(origin: CGPoint(), size: self.size), cutout: self.cutout) + case .right: + lineFrame.origin.x = self.size.width - lineFrame.size.width + default: + break + } + + let rect = CGRect(origin: CGPoint(x: lineFrame.minX + min(leftOffset, rightOffset) + self.insets.left, y: lineFrame.minY + self.insets.top), size: CGSize(width: abs(rightOffset - leftOffset), height: lineFrame.size.height)) + if coveringRect.isEmpty { + coveringRect = rect + } else { + coveringRect = coveringRect.union(rect) + } + } + } + if !coveringRect.isEmpty { + result.append((value, coveringRect)) + } + } + } + } + return result + } + + public func lineAndAttributeRects(name: String, at index: Int) -> [(CGRect, CGRect)]? { + if let attributedString = self.attributedString { + var range = NSRange() + let _ = attributedString.attribute(NSAttributedString.Key(rawValue: name), at: index, effectiveRange: &range) + if range.length != 0 { + var rects: [(CGRect, CGRect)] = [] + for segment in self.segments { + for line in segment.lines.prefix(segment.visibleLineCount) { + guard let rangeValue = line.range else { + continue + } + let lineRange = NSIntersectionRange(range, rangeValue) + if lineRange.length != 0 { + var leftOffset: CGFloat = 0.0 + if lineRange.location != rangeValue.location || line.isRTL { + leftOffset = floor(CTLineGetOffsetForStringIndex(line.line, lineRange.location, nil)) + } + var rightOffset: CGFloat = line.frame.width + if lineRange.location + lineRange.length != rangeValue.length || line.isRTL { + var secondaryOffset: CGFloat = 0.0 + let rawOffset = CTLineGetOffsetForStringIndex(line.line, lineRange.location + lineRange.length, &secondaryOffset) + rightOffset = ceil(rawOffset) + if !rawOffset.isEqual(to: secondaryOffset) { + rightOffset = ceil(secondaryOffset) + } + } + var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y), size: line.frame.size) + + lineFrame = displayLineFrame(frame: lineFrame, isRTL: line.isRTL, boundingRect: CGRect(origin: CGPoint(), size: self.size), cutout: self.cutout) + + let width = abs(rightOffset - leftOffset) + if width > 1.0 { + rects.append((lineFrame, CGRect(origin: CGPoint(x: lineFrame.minX + min(leftOffset, rightOffset) + self.insets.left, y: lineFrame.minY + self.insets.top), size: CGSize(width: width, height: lineFrame.size.height)))) + } + } + } + } + if !rects.isEmpty { + return rects + } + } + } + return nil + } + + public func rangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? { + guard let _ = self.attributedString, range.length != 0 else { + return nil + } + var rects: [(CGRect, CGRect)] = [] + var startEdge: TextRangeRectEdge? + var endEdge: TextRangeRectEdge? + for segment in self.segments { + for line in segment.lines.prefix(segment.visibleLineCount) { + guard let rangeValue = line.range else { + continue + } + let lineRange = NSIntersectionRange(range, rangeValue) + if lineRange.length != 0 { + var leftOffset: CGFloat = 0.0 + if lineRange.location != rangeValue.location || line.isRTL { + leftOffset = floor(CTLineGetOffsetForStringIndex(line.line, lineRange.location, nil)) + } + var rightOffset: CGFloat = line.frame.width + if lineRange.location + lineRange.length != rangeValue.upperBound || line.isRTL { + var secondaryOffset: CGFloat = 0.0 + let rawOffset = CTLineGetOffsetForStringIndex(line.line, lineRange.location + lineRange.length, &secondaryOffset) + rightOffset = ceil(rawOffset) + if !rawOffset.isEqual(to: secondaryOffset) { + rightOffset = ceil(secondaryOffset) + } + } + var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y), size: line.frame.size) + lineFrame = displayLineFrame(frame: lineFrame, isRTL: line.isRTL, boundingRect: CGRect(origin: CGPoint(), size: self.size), cutout: self.cutout) + + let width = max(0.0, abs(rightOffset - leftOffset)) + + if rangeValue.contains(range.lowerBound) { + let offsetX = floor(CTLineGetOffsetForStringIndex(line.line, range.lowerBound, nil)) + startEdge = TextRangeRectEdge(x: lineFrame.minX + offsetX, y: lineFrame.minY, height: lineFrame.height) + } + if rangeValue.contains(range.upperBound - 1) { + let offsetX: CGFloat + if rangeValue.upperBound == range.upperBound { + offsetX = lineFrame.maxX + } else { + var secondaryOffset: CGFloat = 0.0 + let primaryOffset = floor(CTLineGetOffsetForStringIndex(line.line, range.upperBound - 1, &secondaryOffset)) + secondaryOffset = floor(secondaryOffset) + let nextOffet = floor(CTLineGetOffsetForStringIndex(line.line, range.upperBound, &secondaryOffset)) + + if primaryOffset != secondaryOffset { + offsetX = secondaryOffset + } else { + offsetX = nextOffet + } + } + endEdge = TextRangeRectEdge(x: lineFrame.minX + offsetX, y: lineFrame.minY, height: lineFrame.height) + } + + rects.append((lineFrame, CGRect(origin: CGPoint(x: lineFrame.minX + min(leftOffset, rightOffset) + self.insets.left, y: lineFrame.minY + self.insets.top), size: CGSize(width: width, height: lineFrame.size.height)))) + } + } + } + if !rects.isEmpty, var startEdge = startEdge, var endEdge = endEdge { + startEdge.x += self.insets.left + startEdge.y += self.insets.top + endEdge.x += self.insets.left + endEdge.y += self.insets.top + return (rects.map { $1 }, startEdge, endEdge) + } + return nil + } +} + +private func addSpoiler(line: InteractiveTextNodeLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int) { + var secondaryLeftOffset: CGFloat = 0.0 + let rawLeftOffset = CTLineGetOffsetForStringIndex(line.line, startIndex, &secondaryLeftOffset) + var leftOffset = floor(rawLeftOffset) + if !rawLeftOffset.isEqual(to: secondaryLeftOffset) { + leftOffset = floor(secondaryLeftOffset) + } + + var secondaryRightOffset: CGFloat = 0.0 + let rawRightOffset = CTLineGetOffsetForStringIndex(line.line, endIndex, &secondaryRightOffset) + var rightOffset = ceil(rawRightOffset) + if !rawRightOffset.isEqual(to: secondaryRightOffset) { + rightOffset = ceil(secondaryRightOffset) + } + + line.spoilers.append(InteractiveTextNodeSpoiler(range: NSMakeRange(startIndex, endIndex - startIndex + 1), frame: CGRect(x: min(leftOffset, rightOffset), y: 0.0, width: abs(rightOffset - leftOffset), height: ascent + descent))) +} + +private func addSpoilerWord(line: InteractiveTextNodeLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int, rightInset: CGFloat = 0.0) { + var secondaryLeftOffset: CGFloat = 0.0 + let rawLeftOffset = CTLineGetOffsetForStringIndex(line.line, startIndex, &secondaryLeftOffset) + var leftOffset = floor(rawLeftOffset) + if !rawLeftOffset.isEqual(to: secondaryLeftOffset) { + leftOffset = floor(secondaryLeftOffset) + } + + var secondaryRightOffset: CGFloat = 0.0 + let rawRightOffset = CTLineGetOffsetForStringIndex(line.line, endIndex, &secondaryRightOffset) + var rightOffset = ceil(rawRightOffset) + if !rawRightOffset.isEqual(to: secondaryRightOffset) { + rightOffset = ceil(secondaryRightOffset) + } + + line.spoilerWords.append(InteractiveTextNodeSpoiler(range: NSMakeRange(startIndex, endIndex - startIndex + 1), frame: CGRect(x: min(leftOffset, rightOffset), y: 0.0, width: abs(rightOffset - leftOffset) + rightInset, height: ascent + descent))) +} + +private func addEmbeddedItem(item: AnyHashable, isHiddenBySpoiler: Bool, line: InteractiveTextNodeLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int, rightInset: CGFloat = 0.0) { + var secondaryLeftOffset: CGFloat = 0.0 + let rawLeftOffset = CTLineGetOffsetForStringIndex(line.line, startIndex, &secondaryLeftOffset) + var leftOffset = floor(rawLeftOffset) + if !rawLeftOffset.isEqual(to: secondaryLeftOffset) { + leftOffset = floor(secondaryLeftOffset) + } + + var secondaryRightOffset: CGFloat = 0.0 + let rawRightOffset = CTLineGetOffsetForStringIndex(line.line, endIndex, &secondaryRightOffset) + var rightOffset = ceil(rawRightOffset) + if !rawRightOffset.isEqual(to: secondaryRightOffset) { + rightOffset = ceil(secondaryRightOffset) + } + + line.embeddedItems.append(InteractiveTextNodeEmbeddedItem(range: NSMakeRange(startIndex, endIndex - startIndex + 1), frame: CGRect(x: min(leftOffset, rightOffset), y: 0.0, width: abs(rightOffset - leftOffset) + rightInset, height: ascent + descent), item: item, isHiddenBySpoiler: isHiddenBySpoiler)) +} + +private func addAttachment(attachment: UIImage, line: InteractiveTextNodeLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int, rightInset: CGFloat = 0.0) { + var secondaryLeftOffset: CGFloat = 0.0 + let rawLeftOffset = CTLineGetOffsetForStringIndex(line.line, startIndex, &secondaryLeftOffset) + var leftOffset = floor(rawLeftOffset) + if !rawLeftOffset.isEqual(to: secondaryLeftOffset) { + leftOffset = floor(secondaryLeftOffset) + } + + var secondaryRightOffset: CGFloat = 0.0 + let rawRightOffset = CTLineGetOffsetForStringIndex(line.line, endIndex, &secondaryRightOffset) + var rightOffset = ceil(rawRightOffset) + if !rawRightOffset.isEqual(to: secondaryRightOffset) { + rightOffset = ceil(secondaryRightOffset) + } + + line.attachments.append(InteractiveTextNodeAttachment(range: NSMakeRange(startIndex, endIndex - startIndex), frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset) + rightInset, height: ascent + descent), attachment: attachment)) +} + +open class InteractiveTextNode: ASDisplayNode, TextNodeProtocol { + public struct RenderContentTypes: OptionSet { + public var rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public static let text = RenderContentTypes(rawValue: 1 << 0) + public static let emoji = RenderContentTypes(rawValue: 1 << 1) + + public static let all: RenderContentTypes = [.text, .emoji] + } + + final class DrawingParameters: NSObject { + let cachedLayout: InteractiveTextNodeLayout? + let renderContentTypes: RenderContentTypes + + init(cachedLayout: InteractiveTextNodeLayout?, renderContentTypes: RenderContentTypes) { + self.cachedLayout = cachedLayout + self.renderContentTypes = renderContentTypes + + super.init() + } + } + + public internal(set) var cachedLayout: InteractiveTextNodeLayout? + public var renderContentTypes: RenderContentTypes = .all + private var contentItemLayers: [Int: TextContentItemLayer] = [:] + + public var requestToggleBlockCollapsed: ((Int) -> Void)? + private var tapRecognizer: UITapGestureRecognizer? + + public var currentText: NSAttributedString? { + return self.cachedLayout?.attributedString + } + + public func textRangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? { + return self.cachedLayout?.rangeRects(in: range) + } + + override public init() { + super.init() + + self.backgroundColor = UIColor.clear + self.isOpaque = false + self.clipsToBounds = false + } + + override open func didLoad() { + super.didLoad() + } + + public func attributesAtPoint(_ point: CGPoint, orNearest: Bool = false) -> (Int, [NSAttributedString.Key: Any])? { + if let cachedLayout = self.cachedLayout { + return cachedLayout.attributesAtPoint(point, orNearest: orNearest) + } else { + return nil + } + } + + public func collapsibleBlockAtPoint(_ point: CGPoint) -> Int? { + for (_, contentItemLayer) in self.contentItemLayers { + if !contentItemLayer.frame.contains(point) { + continue + } + if !contentItemLayer.renderNode.frame.offsetBy(dx: contentItemLayer.frame.minX, dy: contentItemLayer.frame.minY).contains(point) { + continue + } + + guard let item = contentItemLayer.item else { + continue + } + guard let blockQuote = item.segment.blockQuote else { + continue + } + if blockQuote.isCollapsed == nil { + continue + } + + return blockQuote.id + } + return nil + } + + func segmentLayer(index: Int) -> TextContentItemLayer? { + return self.contentItemLayers[index] + } + + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard let result = super.hitTest(point, with: event) else { + return nil + } + return result + } + + public func textRangesRects(text: String) -> [[CGRect]] { + return self.cachedLayout?.textRangesRects(text: text) ?? [] + } + + public func attributeSubstring(name: String, index: Int) -> (String, String)? { + return self.cachedLayout?.attributeSubstring(name: name, index: index) + } + + public func attributeSubstringWithRange(name: String, index: Int) -> (String, String, NSRange)? { + return self.cachedLayout?.attributeSubstringWithRange(name: name, index: index) + } + + public func attributeRects(name: String, at index: Int) -> [CGRect]? { + if let cachedLayout = self.cachedLayout { + return cachedLayout.lineAndAttributeRects(name: name, at: index)?.map { $0.1 } + } else { + return nil + } + } + + public func rangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? { + if let cachedLayout = self.cachedLayout { + return cachedLayout.rangeRects(in: range) + } else { + return nil + } + } + + public func lineAndAttributeRects(name: String, at index: Int) -> [(CGRect, CGRect)]? { + if let cachedLayout = self.cachedLayout { + return cachedLayout.lineAndAttributeRects(name: name, at: index) + } else { + return nil + } + } + + private static func calculateLayoutV2( + attributedString: NSAttributedString, + minimumNumberOfLines: Int, + maximumNumberOfLines: Int, + truncationType: CTLineTruncationType, + backgroundColor: UIColor?, + constrainedSize: CGSize, + alignment: NSTextAlignment, + verticalAlignment: TextVerticalAlignment, + lineSpacingFactor: CGFloat, + cutout: TextNodeCutout?, + insets: UIEdgeInsets, + lineColor: UIColor?, + textShadowColor: UIColor?, + textShadowBlur: CGFloat?, + textStroke: (UIColor, CGFloat)?, + displayContentsUnderSpoilers: Bool, + customTruncationToken: NSAttributedString?, + collapsedBlocks: Set + ) -> InteractiveTextNodeLayout { + let blockQuoteLeftInset: CGFloat = 9.0 + let blockQuoteRightInset: CGFloat = 0.0 + let blockQuoteIconInset: CGFloat = 7.0 + + struct StringSegment { + let title: NSAttributedString? + let substring: NSAttributedString + let firstCharacterOffset: Int + let blockQuote: TextNodeBlockQuoteData? + let tintColor: UIColor? + let secondaryTintColor: UIColor? + let tertiaryTintColor: UIColor? + } + var stringSegments: [StringSegment] = [] + + let rawWholeString = attributedString.string as NSString + let wholeStringLength = rawWholeString.length + + var segmentCharacterOffset = 0 + while true { + var found = false + attributedString.enumerateAttribute(NSAttributedString.Key("Attribute__Blockquote"), in: NSRange(location: segmentCharacterOffset, length: wholeStringLength - segmentCharacterOffset), using: { value, effectiveRange, stop in + found = true + stop.pointee = ObjCBool(true) + + if segmentCharacterOffset != effectiveRange.location { + stringSegments.append(StringSegment( + title: nil, + substring: attributedString.attributedSubstring(from: NSRange( + location: segmentCharacterOffset, + length: effectiveRange.location - segmentCharacterOffset + )), + firstCharacterOffset: segmentCharacterOffset, + blockQuote: nil, + tintColor: nil, + secondaryTintColor: nil, + tertiaryTintColor: nil + )) + } + + if let value = value as? TextNodeBlockQuoteData { + if effectiveRange.length != 0 { + stringSegments.append(StringSegment( + title: value.title, + substring: attributedString.attributedSubstring(from: effectiveRange), + firstCharacterOffset: effectiveRange.location, + blockQuote: value, + tintColor: value.color, + secondaryTintColor: value.secondaryColor, + tertiaryTintColor: value.tertiaryColor + )) + } + segmentCharacterOffset = effectiveRange.location + effectiveRange.length + if segmentCharacterOffset < wholeStringLength && rawWholeString.character(at: segmentCharacterOffset) == 0x0a { + segmentCharacterOffset += 1 + } + } else { + stringSegments.append(StringSegment( + title: nil, + substring: attributedString.attributedSubstring(from: effectiveRange), + firstCharacterOffset: effectiveRange.location, + blockQuote: nil, + tintColor: nil, + secondaryTintColor: nil, + tertiaryTintColor: nil + )) + segmentCharacterOffset = effectiveRange.location + effectiveRange.length + } + }) + if !found { + if segmentCharacterOffset != wholeStringLength { + stringSegments.append(StringSegment( + title: nil, + substring: attributedString.attributedSubstring(from: NSRange( + location: segmentCharacterOffset, + length: wholeStringLength - segmentCharacterOffset + )), + firstCharacterOffset: segmentCharacterOffset, + blockQuote: nil, + tintColor: nil, + secondaryTintColor: nil, + tertiaryTintColor: nil + )) + } + + break + } + } + + struct CalculatedSegment { + var titleLine: InteractiveTextNodeLine? + var lines: [InteractiveTextNodeLine] = [] + var tintColor: UIColor? + var secondaryTintColor: UIColor? + var tertiaryTintColor: UIColor? + var blockQuote: TextNodeBlockQuoteData? + var additionalWidth: CGFloat = 0.0 + } + + var calculatedSegments: [CalculatedSegment] = [] + + for segment in stringSegments { + var calculatedSegment = CalculatedSegment() + calculatedSegment.blockQuote = segment.blockQuote + calculatedSegment.tintColor = segment.tintColor + calculatedSegment.secondaryTintColor = segment.secondaryTintColor + calculatedSegment.tertiaryTintColor = segment.tertiaryTintColor + + let rawSubstring = segment.substring.string as NSString + let substringLength = rawSubstring.length + + let segmentTypesetterString = attributedString.attributedSubstring(from: NSRange(location: 0, length: segment.firstCharacterOffset + substringLength)) + let typesetter = CTTypesetterCreateWithAttributedString(segmentTypesetterString as CFAttributedString) + + var currentLineStartIndex = segment.firstCharacterOffset + let segmentEndIndex = segment.firstCharacterOffset + substringLength + + var constrainedSegmentWidth = constrainedSize.width + var additionalOffsetX: CGFloat = 0.0 + if segment.blockQuote != nil { + additionalOffsetX += blockQuoteLeftInset + constrainedSegmentWidth -= additionalOffsetX + blockQuoteLeftInset + blockQuoteRightInset + calculatedSegment.additionalWidth += blockQuoteLeftInset + blockQuoteRightInset + } + + var additionalSegmentRightInset: CGFloat = 0.0 + if let blockQuote = segment.blockQuote { + switch blockQuote.kind { + case .quote: + additionalSegmentRightInset = blockQuoteIconInset + case .code: + if segment.title != nil { + additionalSegmentRightInset = blockQuoteIconInset + } + } + } + + if let title = segment.title { + let rawTitleLine = CTLineCreateWithAttributedString(title) + if let titleLine = CTLineCreateTruncatedLine(rawTitleLine, constrainedSegmentWidth - additionalSegmentRightInset, .end, nil) { + var lineAscent: CGFloat = 0.0 + var lineDescent: CGFloat = 0.0 + let lineWidth = CTLineGetTypographicBounds(titleLine, &lineAscent, &lineDescent, nil) + calculatedSegment.titleLine = InteractiveTextNodeLine( + line: titleLine, + frame: CGRect(origin: CGPoint(x: additionalOffsetX, y: 0.0), size: CGSize(width: lineWidth + additionalSegmentRightInset, height: lineAscent + lineDescent)), + ascent: lineAscent, + descent: lineDescent, + range: nil, + isRTL: false, + strikethroughs: [], + spoilers: [], + spoilerWords: [], + embeddedItems: [], + attachments: [], + additionalTrailingLine: nil + ) + additionalSegmentRightInset = 0.0 + } + } + + while true { + let lineCharacterCount = CTTypesetterSuggestLineBreak(typesetter, currentLineStartIndex, constrainedSegmentWidth - additionalSegmentRightInset) + + if lineCharacterCount != 0 { + let line = CTTypesetterCreateLine(typesetter, CFRange(location: currentLineStartIndex, length: lineCharacterCount)) + var lineAscent: CGFloat = 0.0 + var lineDescent: CGFloat = 0.0 + var lineWidth = CTLineGetTypographicBounds(line, &lineAscent, &lineDescent, nil) + lineWidth = min(lineWidth, constrainedSegmentWidth - additionalSegmentRightInset) + + var isRTL = false + let glyphRuns = CTLineGetGlyphRuns(line) as NSArray + if glyphRuns.count != 0 { + let run = glyphRuns[0] as! CTRun + if CTRunGetStatus(run).contains(CTRunStatus.rightToLeft) { + isRTL = true + } + } + + calculatedSegment.lines.append(InteractiveTextNodeLine( + line: line, + frame: CGRect(origin: CGPoint(x: additionalOffsetX, y: 0.0), size: CGSize(width: lineWidth + additionalSegmentRightInset, height: lineAscent + lineDescent)), + ascent: lineAscent, + descent: lineDescent, + range: NSRange(location: currentLineStartIndex, length: lineCharacterCount), + isRTL: isRTL && segment.blockQuote == nil, + strikethroughs: [], + spoilers: [], + spoilerWords: [], + embeddedItems: [], + attachments: [], + additionalTrailingLine: nil + )) + } + + additionalSegmentRightInset = 0.0 + + currentLineStartIndex += lineCharacterCount + + if currentLineStartIndex >= segmentEndIndex { + break + } + } + + calculatedSegments.append(calculatedSegment) + } + + var size = CGSize() + let isTruncated = false + + for segment in calculatedSegments { + if let titleLine = segment.titleLine { + size.width = max(size.width, titleLine.frame.origin.x + titleLine.frame.width + segment.additionalWidth) + } + for line in segment.lines { + size.width = max(size.width, line.frame.origin.x + line.frame.width + segment.additionalWidth) + } + } + + var segments: [InteractiveTextNodeSegment] = [] + + var firstLineOffset: CGFloat? + + var nextBlockIndex = 0 + + for i in 0 ..< calculatedSegments.count { + var segmentLines: [InteractiveTextNodeLine] = [] + + let segment = calculatedSegments[i] + if i != 0 { + if segment.blockQuote != nil { + size.height += 6.0 + } + } else { + if segment.blockQuote != nil { + size.height += 7.0 + } + } + + let blockMinY = size.height - insets.bottom + var blockWidth: CGFloat = 0.0 + + if let titleLine = segment.titleLine { + titleLine.frame = CGRect(origin: CGPoint(x: titleLine.frame.origin.x, y: size.height), size: titleLine.frame.size) + titleLine.frame.size.width += max(0.0, segment.additionalWidth - 2.0) + size.height += titleLine.frame.height + titleLine.frame.height * lineSpacingFactor + blockWidth = max(blockWidth, titleLine.frame.origin.x + titleLine.frame.width) + + segmentLines.append(titleLine) + } + + var blockIndex: Int? + var isCollapsed = false + if let blockQuote = segment.blockQuote { + let blockIndexValue = nextBlockIndex + blockIndex = blockIndexValue + nextBlockIndex += 1 + if blockQuote.isCollapsible { + isCollapsed = collapsedBlocks.contains(blockIndexValue) + } + } + + var lineCount = 0 + var visibleLineCount = 0 + var segmentHeight: CGFloat = 0.0 + var effectiveSegmentHeight: CGFloat = 0.0 + for i in 0 ..< segment.lines.count { + let line = segment.lines[i] + lineCount += 1 + + line.frame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: size.height + segmentHeight), size: line.frame.size) + line.frame.size.width += max(0.0, segment.additionalWidth) + + var lineHeightIncrease = line.frame.height + if i != segment.lines.count - 1 { + lineHeightIncrease += line.frame.height * lineSpacingFactor + } + + segmentHeight += lineHeightIncrease + if isCollapsed && lineCount > 3 { + } else { + effectiveSegmentHeight += lineHeightIncrease + visibleLineCount = i + 1 + } + blockWidth = max(blockWidth, line.frame.origin.x + line.frame.width) + + if let range = line.range { + attributedString.enumerateAttributes(in: range, options: []) { attributes, range, _ in + if attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] != nil || attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] != nil { + var ascent: CGFloat = 0.0 + var descent: CGFloat = 0.0 + CTLineGetTypographicBounds(line.line, &ascent, &descent, nil) + + var startIndex: Int? + var currentIndex: Int? + + let nsString = (attributedString.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 + addSpoilerWord(line: line, ascent: ascent, descent: descent, 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 + addSpoilerWord(line: line, ascent: ascent, descent: descent, startIndex: currentStartIndex, endIndex: endIndex, rightInset: 0.0) + } + + addSpoiler(line: line, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length) + } else if let _ = attributes[NSAttributedString.Key.strikethroughStyle] { + let lowerX = floor(CTLineGetOffsetForStringIndex(line.line, range.location, nil)) + let upperX = ceil(CTLineGetOffsetForStringIndex(line.line, range.location + range.length, nil)) + let x = lowerX < upperX ? lowerX : upperX + line.strikethroughs.append(InteractiveTextNodeStrikethrough(range: range, frame: CGRect(x: x, y: 0.0, width: abs(upperX - lowerX), height: line.frame.height))) + } + + if let embeddedItem = (attributes[NSAttributedString.Key(rawValue: "TelegramEmbeddedItem")] as? AnyHashable ?? attributes[NSAttributedString.Key(rawValue: "Attribute__EmbeddedItem")] as? AnyHashable) { + var ascent: CGFloat = 0.0 + var descent: CGFloat = 0.0 + CTLineGetTypographicBounds(line.line, &ascent, &descent, nil) + + let isHiddenBySpoiler = attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] != nil || attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] != nil + + addEmbeddedItem(item: embeddedItem, isHiddenBySpoiler: isHiddenBySpoiler, line: line, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length) + } + + if let attachment = attributes[NSAttributedString.Key.attachment] as? UIImage { + var ascent: CGFloat = 0.0 + var descent: CGFloat = 0.0 + CTLineGetTypographicBounds(line.line, &ascent, &descent, nil) + + addAttachment(attachment: attachment, line: line, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length) + } + } + } + + segmentLines.append(line) + + if firstLineOffset == nil, let firstLine = segmentLines.first { + firstLineOffset = firstLine.descent + } + + if !isCollapsed, let blockQuote = segment.blockQuote, blockQuote.isCollapsible, !segment.lines.isEmpty { + let lastLine = segment.lines[segment.lines.count - 1] + if lastLine.frame.maxX + 16.0 <= constrainedSize.width { + lastLine.frame.size.width += 16.0 + blockWidth = max(blockWidth, lastLine.frame.maxX) + } else { + segmentHeight += 10.0 + effectiveSegmentHeight += 10.0 + } + } + } + + segmentHeight = ceil(segmentHeight) + effectiveSegmentHeight = ceil(effectiveSegmentHeight) + + size.height += effectiveSegmentHeight + let blockMaxY = size.height - insets.bottom + + if i != calculatedSegments.count - 1 { + if segment.blockQuote != nil { + size.height += 8.0 + } + } else { + if segment.blockQuote != nil { + size.height += 6.0 + } + } + + var segmentBlockQuote: InteractiveTextNodeBlockQuote? + if let blockQuote = segment.blockQuote, let tintColor = segment.tintColor, let blockIndex { + segmentBlockQuote = InteractiveTextNodeBlockQuote(id: blockIndex, frame: CGRect(origin: CGPoint(x: 0.0, y: blockMinY - 2.0), size: CGSize(width: blockWidth, height: blockMaxY - (blockMinY - 2.0) + 4.0)), data: blockQuote, tintColor: tintColor, secondaryTintColor: segment.secondaryTintColor, tertiaryTintColor: segment.tertiaryTintColor, backgroundColor: blockQuote.backgroundColor, isCollapsed: (blockQuote.isCollapsible && segmentLines.count > 3) ? isCollapsed : nil) + } + + segments.append(InteractiveTextNodeSegment( + lines: segmentLines, + visibleLineCount: visibleLineCount, + tintColor: segment.tintColor, + secondaryTintColor: segment.secondaryTintColor, + tertiaryTintColor: segment.tertiaryTintColor, + blockQuote: segmentBlockQuote, + attributedString: attributedString, + resolvedAlignment: alignment, + layoutSize: size + )) + } + + size.width = ceil(size.width) + size.height = ceil(size.height) + + let rawTextSize = size + size.width += insets.left + insets.right + size.height += insets.top + insets.bottom + + return InteractiveTextNodeLayout( + attributedString: attributedString, + maximumNumberOfLines: maximumNumberOfLines, + truncationType: truncationType, + constrainedSize: constrainedSize, + explicitAlignment: alignment, + resolvedAlignment: alignment, + verticalAlignment: verticalAlignment, + lineSpacing: lineSpacingFactor, + cutout: cutout, + insets: insets, + size: size, + rawTextSize: rawTextSize, + truncated: isTruncated, + firstLineOffset: firstLineOffset ?? 0.0, + segments: segments, + backgroundColor: backgroundColor, + lineColor: lineColor, + textShadowColor: textShadowColor, + textShadowBlur: textShadowBlur, + textStroke: textStroke, + displayContentsUnderSpoilers: displayContentsUnderSpoilers, + collapsedBlocks: collapsedBlocks + ) + } + + static func calculateLayout(attributedString: NSAttributedString?, minimumNumberOfLines: Int, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, backgroundColor: UIColor?, constrainedSize: CGSize, alignment: NSTextAlignment, verticalAlignment: TextVerticalAlignment, lineSpacingFactor: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets, lineColor: UIColor?, textShadowColor: UIColor?, textShadowBlur: CGFloat?, textStroke: (UIColor, CGFloat)?, displayContentsUnderSpoilers: Bool, customTruncationToken: NSAttributedString?, collapsedBlocks: Set) -> InteractiveTextNodeLayout { + guard let attributedString else { + return InteractiveTextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: alignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(), rawTextSize: CGSize(), truncated: false, firstLineOffset: 0.0, segments: [], backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textShadowBlur: textShadowBlur, textStroke: textStroke, displayContentsUnderSpoilers: displayContentsUnderSpoilers, collapsedBlocks: collapsedBlocks) + } + + return calculateLayoutV2(attributedString: attributedString, minimumNumberOfLines: minimumNumberOfLines, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, backgroundColor: backgroundColor, constrainedSize: constrainedSize, alignment: alignment, verticalAlignment: verticalAlignment, lineSpacingFactor: lineSpacingFactor, cutout: cutout, insets: insets, lineColor: lineColor, textShadowColor: textShadowColor, textShadowBlur: textShadowBlur, textStroke: textStroke, displayContentsUnderSpoilers: displayContentsUnderSpoilers, customTruncationToken: customTruncationToken, collapsedBlocks: collapsedBlocks) + } + + private func updateContentItems(animation: ListViewItemUpdateAnimation) { + guard let cachedLayout = self.cachedLayout else { + return + } + + let topLeftOffset = CGPoint(x: cachedLayout.insets.left, y: cachedLayout.insets.top) + + var validIds: [Int] = [] + var nextItemId = 0 + for segment in cachedLayout.segments { + let itemId = nextItemId + nextItemId += 1 + + var segmentRect = CGRect() + for line in segment.lines { + var lineRect = line.frame + lineRect.origin.y = topLeftOffset.y + line.frame.minY + lineRect.origin.x = topLeftOffset.x + line.frame.minX + if segmentRect.isEmpty { + segmentRect = lineRect + } else { + segmentRect = segmentRect.union(lineRect) + } + } + + segmentRect.size.width += cachedLayout.insets.left + cachedLayout.insets.right + segmentRect.origin.x -= cachedLayout.insets.left + segmentRect.size.height += cachedLayout.insets.top + cachedLayout.insets.bottom + segmentRect.origin.y -= cachedLayout.insets.top + + segmentRect = segmentRect.integral + + let contentItem = TextContentItem( + id: itemId, + size: segmentRect.size, + attributedString: cachedLayout.attributedString, + textShadowColor: cachedLayout.textShadowColor, + textShadowBlur: cachedLayout.textShadowBlur, + textStroke: cachedLayout.textStroke, + contentOffset: CGPoint(x: -segmentRect.minX + topLeftOffset.x, y: -segmentRect.minY + topLeftOffset.y), + segment: segment, + displayContentsUnderSpoilers: cachedLayout.displayContentsUnderSpoilers + ) + validIds.append(contentItem.id) + + let contentItemFrame = CGRect(origin: CGPoint(x: segmentRect.minX, y: segmentRect.minY), size: CGSize(width: contentItem.size.width, height: contentItem.size.height)) + + var contentItemAnimation = animation + let contentItemLayer: TextContentItemLayer + if let current = self.contentItemLayers[itemId] { + contentItemLayer = current + } else { + contentItemAnimation = .None + contentItemLayer = TextContentItemLayer() + self.contentItemLayers[contentItem.id] = contentItemLayer + self.layer.addSublayer(contentItemLayer) + } + + contentItemLayer.update(item: contentItem, animation: contentItemAnimation) + + contentItemAnimation.animator.updateFrame(layer: contentItemLayer, frame: contentItemFrame, completion: nil) + } + var removedIds: [Int] = [] + for (id, contentItemLayer) in self.contentItemLayers { + if !validIds.contains(id) { + removedIds.append(id) + contentItemLayer.removeFromSuperlayer() + } + } + for id in removedIds { + self.contentItemLayers.removeValue(forKey: id) + } + + if !self.contentItemLayers.isEmpty { + if self.tapRecognizer == nil { + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(tapGesture(_:))) + self.tapRecognizer = tapRecognizer + self.view.addGestureRecognizer(tapRecognizer) + } + } else if let tapRecognizer = self.tapRecognizer { + self.tapRecognizer = nil + self.view.removeGestureRecognizer(tapRecognizer) + } + } + + @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + let point = recognizer.location(in: self.view) + if let blockId = self.collapsibleBlockAtPoint(point) { + self.requestToggleBlockCollapsed?(blockId) + } + } + } + + public static func asyncLayout(_ maybeNode: InteractiveTextNode?) -> (InteractiveTextNodeLayoutArguments) -> (InteractiveTextNodeLayout, (ListViewItemUpdateAnimation) -> InteractiveTextNode) { + let existingLayout: InteractiveTextNodeLayout? = maybeNode?.cachedLayout + + return { arguments in + let layout: InteractiveTextNodeLayout + + if let existingLayout = existingLayout, existingLayout.constrainedSize == arguments.constrainedSize && existingLayout.maximumNumberOfLines == arguments.maximumNumberOfLines && existingLayout.truncationType == arguments.truncationType && existingLayout.cutout == arguments.cutout && existingLayout.explicitAlignment == arguments.alignment && existingLayout.lineSpacing.isEqual(to: arguments.lineSpacing) && existingLayout.collapsedBlocks == arguments.collapsedBlocks { + let stringMatch: Bool + + var colorMatch: Bool = true + if let backgroundColor = arguments.backgroundColor, let previousBackgroundColor = existingLayout.backgroundColor { + if !backgroundColor.isEqual(previousBackgroundColor) { + colorMatch = false + } + } else if (arguments.backgroundColor != nil) != (existingLayout.backgroundColor != nil) { + colorMatch = false + } + + if !colorMatch { + stringMatch = false + } else if let existingString = existingLayout.attributedString, let string = arguments.attributedString { + stringMatch = existingString.isEqual(to: string) + } else if existingLayout.attributedString == nil && arguments.attributedString == nil { + stringMatch = true + } else { + stringMatch = false + } + + if stringMatch { + layout = existingLayout + } else { + layout = InteractiveTextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textShadowBlur: arguments.textShadowBlur, textStroke: arguments.textStroke, displayContentsUnderSpoilers: arguments.displayContentsUnderSpoilers, customTruncationToken: arguments.customTruncationToken, collapsedBlocks: arguments.collapsedBlocks) + } + } else { + layout = InteractiveTextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textShadowBlur: arguments.textShadowBlur, textStroke: arguments.textStroke, displayContentsUnderSpoilers: arguments.displayContentsUnderSpoilers, customTruncationToken: arguments.customTruncationToken, collapsedBlocks: arguments.collapsedBlocks) + } + + let node = maybeNode ?? InteractiveTextNode() + + return (layout, { animation in + if node.cachedLayout !== layout { + node.cachedLayout = layout + node.updateContentItems(animation: animation) + } + + return node + }) + } + } +} + +final class TextContentItem { + let id: Int + let size: CGSize + let attributedString: NSAttributedString? + let textShadowColor: UIColor? + let textShadowBlur: CGFloat? + let textStroke: (UIColor, CGFloat)? + let contentOffset: CGPoint + let segment: InteractiveTextNodeSegment + let displayContentsUnderSpoilers: Bool + + init( + id: Int, + size: CGSize, + attributedString: NSAttributedString?, + textShadowColor: UIColor?, + textShadowBlur: CGFloat?, + textStroke: (UIColor, CGFloat)?, + contentOffset: CGPoint, + segment: InteractiveTextNodeSegment, + displayContentsUnderSpoilers: Bool + ) { + self.id = id + self.size = size + self.attributedString = attributedString + self.textShadowColor = textShadowColor + self.textShadowBlur = textShadowBlur + self.textStroke = textStroke + self.contentOffset = contentOffset + self.segment = segment + self.displayContentsUnderSpoilers = displayContentsUnderSpoilers + } +} + +final class TextContentItemLayer: SimpleLayer { + final class RenderMask { + let image: UIImage + let isOpaque: Bool + let frame: CGRect + + init(image: UIImage, isOpaque: Bool, frame: CGRect) { + self.image = image + self.isOpaque = isOpaque + self.frame = frame + } + } + + fileprivate final class RenderParams: NSObject { + let size: CGSize + let item: TextContentItem + let mask: RenderMask? + + init(size: CGSize, item: TextContentItem, mask: RenderMask?) { + self.size = size + self.item = item + self.mask = mask + + super.init() + } + } + + final class RenderNode: ASDisplayNode { + fileprivate var params: RenderParams? + + override init() { + super.init() + + self.isOpaque = false + self.backgroundColor = nil + self.layer.masksToBounds = true + self.layer.contentsGravity = .bottomLeft + self.layer.contentsScale = UIScreenScale + } + + override func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? { + return self.params + } + + @objc override static func display(withParameters parameters: Any?, isCancelled isCancelledBlock: () -> Bool) -> UIImage? { + guard let params = parameters as? RenderParams else { + return nil + } + if isCancelledBlock() { + return nil + } + guard let renderingContext = DrawingContext(size: params.size, opaque: false, clear: true) else { + return nil + } + + renderingContext.withContext { context in + UIGraphicsPushContext(context) + defer { + UIGraphicsPopContext() + } + + if let mask = params.mask { + context.clip(to: [mask.frame]) + } + + context.saveGState() + + context.setAllowsAntialiasing(true) + + context.setAllowsFontSmoothing(false) + context.setShouldSmoothFonts(false) + + context.setAllowsFontSubpixelPositioning(false) + context.setShouldSubpixelPositionFonts(false) + + context.setAllowsFontSubpixelQuantization(true) + context.setShouldSubpixelQuantizeFonts(true) + + if let textShadowColor = params.item.textShadowColor { + context.setTextDrawingMode(.fill) + context.setShadow(offset: params.item.textShadowBlur != nil ? .zero : CGSize(width: 0.0, height: 1.0), blur: params.item.textShadowBlur ?? 0.0, color: textShadowColor.cgColor) + } + + if let (textStrokeColor, textStrokeWidth) = params.item.textStroke { + context.setBlendMode(.normal) + + context.setLineCap(.round) + context.setLineJoin(.round) + context.setStrokeColor(textStrokeColor.cgColor) + context.setFillColor(textStrokeColor.cgColor) + context.setLineWidth(textStrokeWidth) + context.setTextDrawingMode(.fillStroke) + } + + let textMatrix = context.textMatrix + let textPosition = context.textPosition + context.textMatrix = CGAffineTransform(scaleX: 1.0, y: -1.0) + + let offset = params.item.contentOffset + let alignment: NSTextAlignment = .left + + for i in 0 ..< params.item.segment.lines.count { + let line = params.item.segment.lines[i] + + var lineFrame = line.frame + lineFrame.origin.y += offset.y + + if alignment == .center { + lineFrame.origin.x = offset.x + floor((params.size.width - lineFrame.width) / 2.0) + } else if alignment == .natural || alignment == .left { + if line.isRTL { + lineFrame.origin.x = offset.x + floor(params.size.width - lineFrame.width) + lineFrame = displayLineFrame(frame: lineFrame, isRTL: line.isRTL, boundingRect: CGRect(origin: CGPoint(), size: params.size), cutout: nil) + } else { + lineFrame.origin.x += offset.x + } + } else if alignment == .right { + lineFrame.origin.x = offset.x + (params.size.width - lineFrame.width) + } + + context.textPosition = CGPoint(x: lineFrame.minX, y: lineFrame.maxY - line.descent) + + let glyphRuns = CTLineGetGlyphRuns(line.line) as NSArray + + if glyphRuns.count != 0 { + let hasAttachments = !line.attachments.isEmpty + let hasHiddenSpoilers = !params.item.displayContentsUnderSpoilers && !line.spoilers.isEmpty + for run in glyphRuns { + let run = run as! CTRun + let glyphCount = CTRunGetGlyphCount(run) + let attributes = CTRunGetAttributes(run) as NSDictionary + if attributes["Attribute__EmbeddedItem"] != nil { + continue + } + if hasHiddenSpoilers && attributes["Attribute__Spoiler"] != nil || attributes["TelegramSpoiler"] != nil { + continue + } + + /*if renderContentTypes != .all { + if let font = attributes["NSFont"] as? UIFont, font.fontName.contains("ColorEmoji") { + if !renderContentTypes.contains(.emoji) { + continue + } + } else { + if !renderContentTypes.contains(.text) { + continue + } + } + }*/ + + var fixDoubleEmoji = false + if glyphCount == 2, let font = attributes["NSFont"] as? UIFont, font.fontName.contains("ColorEmoji"), let string = params.item.attributedString { + let range = CTRunGetStringRange(run) + + if range.location < string.length && (range.location + range.length) <= string.length { + let substring = string.attributedSubstring(from: NSMakeRange(range.location, range.length)).string + + let heart = Unicode.Scalar(0x2764)! + let man = Unicode.Scalar(0x1F468)! + let woman = Unicode.Scalar(0x1F469)! + let leftHand = Unicode.Scalar(0x1FAF1)! + let rightHand = Unicode.Scalar(0x1FAF2)! + + if substring.unicodeScalars.contains(heart) && (substring.unicodeScalars.contains(man) || substring.unicodeScalars.contains(woman)) { + fixDoubleEmoji = true + } else if substring.unicodeScalars.contains(leftHand) && substring.unicodeScalars.contains(rightHand) { + fixDoubleEmoji = true + } + } + } + + if fixDoubleEmoji { + context.setBlendMode(.normal) + } + + if hasAttachments { + let stringRange = CTRunGetStringRange(run) + if line.attachments.contains(where: { $0.range.contains(stringRange.location) }) { + } else { + CTRunDraw(run, context, CFRangeMake(0, glyphCount)) + } + } else { + CTRunDraw(run, context, CFRangeMake(0, glyphCount)) + } + + if fixDoubleEmoji { + context.setBlendMode(.normal) + } + } + } + + for attachment in line.attachments { + let image = attachment.attachment + var textColor: UIColor? + params.item.attributedString?.enumerateAttributes(in: attachment.range, options: []) { attributes, range, _ in + if let color = attributes[NSAttributedString.Key.foregroundColor] as? UIColor { + textColor = color + } + } + if let textColor { + if let tintedImage = generateTintedImage(image: image, color: textColor) { + let imageRect = CGRect(origin: CGPoint(x: attachment.frame.midX - tintedImage.size.width * 0.5, y: attachment.frame.midY - tintedImage.size.height * 0.5 + 1.0), size: tintedImage.size).offsetBy(dx: lineFrame.minX, dy: lineFrame.minY) + context.translateBy(x: imageRect.midX, y: imageRect.midY) + context.scaleBy(x: 1.0, y: -1.0) + context.translateBy(x: -imageRect.midX, y: -imageRect.midY) + context.draw(tintedImage.cgImage!, in: imageRect) + context.translateBy(x: imageRect.midX, y: imageRect.midY) + context.scaleBy(x: 1.0, y: -1.0) + context.translateBy(x: -imageRect.midX, y: -imageRect.midY) + } + } + } + + if !line.strikethroughs.isEmpty { + for strikethrough in line.strikethroughs { + guard let lineRange = line.range else { + continue + } + var textColor: UIColor? + params.item.attributedString?.enumerateAttributes(in: NSMakeRange(lineRange.location, lineRange.length), options: []) { attributes, range, _ in + if range == strikethrough.range, let color = attributes[NSAttributedString.Key.foregroundColor] as? UIColor { + textColor = color + } + } + if let textColor = textColor { + context.setFillColor(textColor.cgColor) + } + let frame = strikethrough.frame.offsetBy(dx: lineFrame.minX, dy: lineFrame.minY) + context.fill(CGRect(x: frame.minX, y: frame.minY - 5.0, width: frame.width, height: 1.0)) + } + } + + if let (additionalTrailingLine, _) = line.additionalTrailingLine { + context.textPosition = CGPoint(x: lineFrame.maxX, y: lineFrame.minY) + + let glyphRuns = CTLineGetGlyphRuns(additionalTrailingLine) as NSArray + if glyphRuns.count != 0 { + for run in glyphRuns { + let run = run as! CTRun + let glyphCount = CTRunGetGlyphCount(run) + let attributes = CTRunGetAttributes(run) as NSDictionary + if attributes["Attribute__EmbeddedItem"] != nil { + continue + } + + var fixDoubleEmoji = false + if glyphCount == 2, let font = attributes["NSFont"] as? UIFont, font.fontName.contains("ColorEmoji"), let string = params.item.attributedString { + let range = CTRunGetStringRange(run) + + if range.location < string.length && (range.location + range.length) <= string.length { + let substring = string.attributedSubstring(from: NSMakeRange(range.location, range.length)).string + + let heart = Unicode.Scalar(0x2764)! + let man = Unicode.Scalar(0x1F468)! + let woman = Unicode.Scalar(0x1F469)! + let leftHand = Unicode.Scalar(0x1FAF1)! + let rightHand = Unicode.Scalar(0x1FAF2)! + + if substring.unicodeScalars.contains(heart) && (substring.unicodeScalars.contains(man) || substring.unicodeScalars.contains(woman)) { + fixDoubleEmoji = true + } else if substring.unicodeScalars.contains(leftHand) && substring.unicodeScalars.contains(rightHand) { + fixDoubleEmoji = true + } + } + } + + if fixDoubleEmoji { + context.setBlendMode(.normal) + } + CTRunDraw(run, context, CFRangeMake(0, glyphCount)) + if fixDoubleEmoji { + context.setBlendMode(.normal) + } + } + } + } + } + + context.textMatrix = textMatrix + context.textPosition = CGPoint(x: textPosition.x, y: textPosition.y) + + context.setShadow(offset: CGSize(), blur: 0.0) + context.setAlpha(1.0) + + context.restoreGState() + + if let mask = params.mask, !mask.isOpaque { + mask.image.draw(in: mask.frame, blendMode: .destinationIn, alpha: 1.0) + } + } + + return renderingContext.generateImage() + } + } + + private(set) var item: TextContentItem? + + let renderNode: RenderNode + private var contentMaskNode: ASImageNode? + private var blockBackgroundView: MessageInlineBlockBackgroundView? + private var quoteTypeIconNode: ASImageNode? + private var blockExpandArrow: SimpleLayer? + + private var currentAnimationId: Int = 0 + private var isAnimating: Bool = false + private var currentContentMask: RenderMask? + + override init() { + self.renderNode = RenderNode() + + super.init() + + self.addSublayer(self.renderNode.layer) + } + + override init(layer: Any) { + self.renderNode = RenderNode() + + super.init(layer: layer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(item: TextContentItem, animation: ListViewItemUpdateAnimation, synchronously: Bool = false) { + self.item = item + self.setNeedsDisplay() + + let contentFrame = CGRect(origin: CGPoint(), size: item.size) + var effectiveContentFrame = contentFrame + var contentMask: RenderMask? + + if let blockQuote = item.segment.blockQuote { + let blockBackgroundView: MessageInlineBlockBackgroundView + if let current = self.blockBackgroundView { + blockBackgroundView = current + } else { + blockBackgroundView = MessageInlineBlockBackgroundView() + self.blockBackgroundView = blockBackgroundView + self.insertSublayer(blockBackgroundView.layer, at: 0) + } + + let blockExpandArrow: SimpleLayer + if let current = self.blockExpandArrow { + blockExpandArrow = current + } else { + blockExpandArrow = SimpleLayer() + self.blockExpandArrow = blockExpandArrow + self.addSublayer(blockExpandArrow) + blockExpandArrow.contents = expandArrowIcon.cgImage + } + blockExpandArrow.layerTintColor = blockQuote.tintColor.cgColor + + let blockBackgroundFrame = blockQuote.frame.offsetBy(dx: item.contentOffset.x, dy: item.contentOffset.y + 4.0) + + if animation.isAnimated { + self.isAnimating = true + self.currentAnimationId += 1 + let animationId = self.currentAnimationId + animation.animator.updateFrame(layer: blockBackgroundView.layer, frame: blockBackgroundFrame, completion: { [weak self] completed in + guard completed, let self, self.currentAnimationId == animationId, let item = self.item else { + return + } + self.isAnimating = false + self.update(item: item, animation: .None, synchronously: true) + }) + } else { + blockBackgroundView.layer.frame = blockBackgroundFrame + } + blockBackgroundView.update( + size: blockBackgroundFrame.size, + isTransparent: false, + primaryColor: blockQuote.tintColor, + secondaryColor: blockQuote.secondaryTintColor, + thirdColor: blockQuote.tertiaryTintColor, + backgroundColor: nil, + pattern: nil, + patternTopRightPosition: nil, + patternAlpha: 1.0, + animation: animation + ) + + var quoteTypeIcon: UIImage? + switch blockQuote.data.kind { + case .code: + quoteTypeIcon = codeIcon + case .quote: + quoteTypeIcon = quoteIcon + } + + if let quoteTypeIcon { + let quoteTypeIconNode: ASImageNode + if let current = self.quoteTypeIconNode { + quoteTypeIconNode = current + } else { + quoteTypeIconNode = ASImageNode() + self.quoteTypeIconNode = quoteTypeIconNode + self.addSublayer(quoteTypeIconNode.layer) + } + if quoteTypeIconNode.image !== quoteTypeIcon { + quoteTypeIconNode.image = quoteTypeIcon + } + let quoteTypeIconFrame = CGRect(origin: CGPoint(x: blockBackgroundFrame.maxX - 4.0 - quoteTypeIcon.size.width, y: blockBackgroundFrame.minY + 4.0), size: quoteTypeIcon.size) + quoteTypeIconNode.layer.layerTintColor = blockQuote.tintColor.cgColor + animation.animator.updateFrame(layer: quoteTypeIconNode.layer, frame: quoteTypeIconFrame, completion: nil) + } else if let quoteTypeIconNode = self.quoteTypeIconNode { + self.quoteTypeIconNode = nil + quoteTypeIconNode.removeFromSupernode() + } + + if let isCollapsed = blockQuote.isCollapsed { + let expandArrowFrame = CGRect(origin: CGPoint(x: blockBackgroundFrame.maxX - 6.0 - expandArrowIcon.size.width, y: blockBackgroundFrame.maxY - 3.0 - expandArrowIcon.size.height), size: expandArrowIcon.size) + animation.animator.updatePosition(layer: blockExpandArrow, position: expandArrowFrame.center, completion: nil) + animation.animator.updateBounds(layer: blockExpandArrow, bounds: CGRect(origin: CGPoint(), size: expandArrowFrame.size), completion: nil) + animation.animator.updateTransform(layer: blockExpandArrow, transform: CATransform3DMakeRotation(isCollapsed ? 0.0 : CGFloat.pi, 0.0, 0.0, 1.0), completion: nil) + + let contentMaskFrame = CGRect(origin: CGPoint(x: 0.0, y: blockBackgroundFrame.minY - contentFrame.minY), size: CGSize(width: contentFrame.width, height: blockBackgroundFrame.height)) + contentMask = RenderMask(image: expandableBlockMaskImage, isOpaque: !isCollapsed, frame: contentMaskFrame) + effectiveContentFrame.size.height = ceil(contentMaskFrame.height - contentMaskFrame.minY) + } else { + if let blockExpandArrow = self.blockExpandArrow { + self.blockExpandArrow = nil + blockExpandArrow.removeFromSuperlayer() + } + } + } else { + if let blockBackgroundView = self.blockBackgroundView { + self.blockBackgroundView = nil + blockBackgroundView.removeFromSuperview() + } + if let blockExpandArrow = self.blockExpandArrow { + self.blockExpandArrow = nil + blockExpandArrow.removeFromSuperlayer() + } + if let quoteTypeIconNode = self.quoteTypeIconNode { + self.quoteTypeIconNode = nil + quoteTypeIconNode.removeFromSupernode() + } + + if self.isAnimating { + self.isAnimating = false + self.currentAnimationId += 1 + } + } + + animation.animator.updateFrame(layer: self.renderNode.layer, frame: effectiveContentFrame, completion: nil) + + var staticContentMask = contentMask + if let contentMask, self.isAnimating { + staticContentMask = nil + + var contentMaskAnimation = animation + let contentMaskNode: ASImageNode + if let current = self.contentMaskNode { + contentMaskNode = current + } else { + contentMaskNode = ASImageNode() + contentMaskNode.isLayerBacked = true + contentMaskNode.backgroundColor = .clear + self.contentMaskNode = contentMaskNode + self.renderNode.layer.mask = contentMaskNode.layer + + if let currentContentMask = self.currentContentMask { + contentMaskNode.frame = currentContentMask.frame + } else { + contentMaskAnimation = .None + } + + contentMaskNode.image = contentMask.image + } + + contentMaskAnimation.animator.updateBackgroundColor(layer: contentMaskNode.layer, color: contentMask.isOpaque ? UIColor.white : UIColor.clear, completion: nil) + contentMaskAnimation.animator.updateFrame(layer: contentMaskNode.layer, frame: contentMask.frame, completion: nil) + } else { + if let contentMaskNode = self.contentMaskNode { + self.contentMaskNode = nil + self.renderNode.layer.mask = nil + contentMaskNode.layer.removeFromSuperlayer() + } + } + + self.currentContentMask = contentMask + + self.renderNode.params = RenderParams(size: contentFrame.size, item: item, mask: staticContentMask) + if synchronously { + self.renderNode.displayImmediately() + } else { + self.renderNode.setNeedsDisplay() + } + } +} diff --git a/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextNodeWithEntities.swift b/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextNodeWithEntities.swift new file mode 100644 index 0000000000..e4d8de4142 --- /dev/null +++ b/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextNodeWithEntities.swift @@ -0,0 +1,366 @@ +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import CoreText +import AppBundle +import ComponentFlow +import TextFormat +import AccountContext +import AnimationCache +import MultiAnimationRenderer +import TelegramCore +import EmojiTextAttachmentView +import InvisibleInkDustNode + +private final class InlineStickerItem: Hashable { + let emoji: ChatTextInputTextCustomEmojiAttribute + let file: TelegramMediaFile? + let fontSize: CGFloat + + init(emoji: ChatTextInputTextCustomEmojiAttribute, file: TelegramMediaFile?, fontSize: CGFloat) { + self.emoji = emoji + self.file = file + self.fontSize = fontSize + } + + func hash(into hasher: inout Hasher) { + hasher.combine(emoji.fileId) + hasher.combine(self.fontSize) + } + + static func ==(lhs: InlineStickerItem, rhs: InlineStickerItem) -> Bool { + if lhs.emoji.fileId != rhs.emoji.fileId { + return false + } + if lhs.file?.fileId != rhs.file?.fileId { + return false + } + if lhs.fontSize != rhs.fontSize { + return false + } + return true + } +} + +private final class RunDelegateData { + let ascent: CGFloat + let descent: CGFloat + let width: CGFloat + + init(ascent: CGFloat, descent: CGFloat, width: CGFloat) { + self.ascent = ascent + self.descent = descent + self.width = width + } +} + +public final class InteractiveTextNodeWithEntities { + public final class Arguments { + public let context: AccountContext + public let cache: AnimationCache + public let renderer: MultiAnimationRenderer + public let placeholderColor: UIColor + public let attemptSynchronous: Bool + public let textColor: UIColor + public let spoilerEffectColor: UIColor + public let animation: ListViewItemUpdateAnimation + + public init( + context: AccountContext, + cache: AnimationCache, + renderer: MultiAnimationRenderer, + placeholderColor: UIColor, + attemptSynchronous: Bool, + textColor: UIColor, + spoilerEffectColor: UIColor, + animation: ListViewItemUpdateAnimation + ) { + self.context = context + self.cache = cache + self.renderer = renderer + self.placeholderColor = placeholderColor + self.attemptSynchronous = attemptSynchronous + self.textColor = textColor + self.spoilerEffectColor = spoilerEffectColor + self.animation = animation + } + + public func withUpdatedPlaceholderColor(_ color: UIColor) -> Arguments { + return Arguments( + context: self.context, + cache: self.cache, + renderer: self.renderer, + placeholderColor: color, + attemptSynchronous: self.attemptSynchronous, + textColor: self.textColor, + spoilerEffectColor: self.spoilerEffectColor, + animation: self.animation + ) + } + } + + private final class InlineStickerItemLayerData { + let itemLayer: InlineStickerItemLayer + var rect: CGRect = CGRect() + + init(itemLayer: InlineStickerItemLayer) { + self.itemLayer = itemLayer + } + } + + public let textNode: InteractiveTextNode + + private var inlineStickerItemLayers: [InlineStickerItemLayer.Key: InlineStickerItemLayerData] = [:] + private var dustEffectNodes: [Int: InvisibleInkDustNode] = [:] + + private var enableLooping: Bool = true + + public var visibilityRect: CGRect? { + didSet { + if !self.inlineStickerItemLayers.isEmpty && oldValue != self.visibilityRect { + for (_, itemLayerData) in self.inlineStickerItemLayers { + let isItemVisible: Bool + if let visibilityRect = self.visibilityRect { + if itemLayerData.rect.intersects(visibilityRect) { + isItemVisible = true + } else { + isItemVisible = false + } + } else { + isItemVisible = false + } + itemLayerData.itemLayer.isVisibleForAnimations = self.enableLooping && isItemVisible + } + } + } + } + + public init() { + self.textNode = InteractiveTextNode() + } + + private init(textNode: InteractiveTextNode) { + self.textNode = textNode + } + + public static func asyncLayout(_ maybeNode: InteractiveTextNodeWithEntities?) -> (InteractiveTextNodeLayoutArguments) -> (InteractiveTextNodeLayout, (InteractiveTextNodeWithEntities.Arguments?) -> InteractiveTextNodeWithEntities) { + let makeLayout = InteractiveTextNode.asyncLayout(maybeNode?.textNode) + return { [weak maybeNode] arguments in + var updatedString: NSAttributedString? + if let sourceString = arguments.attributedString { + let string = NSMutableAttributedString(attributedString: sourceString) + + var fullRange = NSRange(location: 0, length: string.length) + var originalTextId = 0 + while true { + var found = false + string.enumerateAttribute(ChatTextInputAttributes.customEmoji, in: fullRange, options: [], using: { value, range, stop in + if let value = value as? ChatTextInputTextCustomEmojiAttribute, let font = string.attribute(.font, at: range.location, effectiveRange: nil) as? UIFont { + let updatedSubstring = NSMutableAttributedString(string: "&") + + let replacementRange = NSRange(location: 0, length: updatedSubstring.length) + updatedSubstring.addAttributes(string.attributes(at: range.location, effectiveRange: nil), range: replacementRange) + updatedSubstring.addAttribute(NSAttributedString.Key("Attribute__EmbeddedItem"), value: InlineStickerItem(emoji: value, file: value.file, fontSize: font.pointSize), range: replacementRange) + updatedSubstring.addAttribute(originalTextAttributeKey, value: OriginalTextAttribute(id: originalTextId, string: string.attributedSubstring(from: range).string), range: replacementRange) + originalTextId += 1 + + let itemSize = (font.pointSize * 24.0 / 17.0) + + let runDelegateData = RunDelegateData( + ascent: font.ascender, + descent: font.descender, + width: itemSize + ) + var callbacks = CTRunDelegateCallbacks( + version: kCTRunDelegateCurrentVersion, + dealloc: { dataRef in + Unmanaged.fromOpaque(dataRef).release() + }, + getAscent: { dataRef in + let data = Unmanaged.fromOpaque(dataRef) + return data.takeUnretainedValue().ascent + }, + getDescent: { dataRef in + let data = Unmanaged.fromOpaque(dataRef) + return data.takeUnretainedValue().descent + }, + getWidth: { dataRef in + let data = Unmanaged.fromOpaque(dataRef) + return data.takeUnretainedValue().width + } + ) + + if let runDelegate = CTRunDelegateCreate(&callbacks, Unmanaged.passRetained(runDelegateData).toOpaque()) { + updatedSubstring.addAttribute(NSAttributedString.Key(kCTRunDelegateAttributeName as String), value: runDelegate, range: replacementRange) + } + + string.replaceCharacters(in: range, with: updatedSubstring) + let updatedRange = NSRange(location: range.location, length: updatedSubstring.length) + + found = true + stop.pointee = ObjCBool(true) + fullRange = NSRange(location: updatedRange.upperBound, length: fullRange.upperBound - range.upperBound) + } + }) + if !found { + break + } + } + + updatedString = string + } + + let (layout, apply) = makeLayout(arguments.withAttributedString(updatedString)) + return (layout, { applyArguments in + let animation: ListViewItemUpdateAnimation = applyArguments?.animation ?? .None + + let result = apply(animation) + + if let maybeNode = maybeNode { + if let applyArguments = applyArguments { + maybeNode.updateInteractiveContents(context: applyArguments.context, cache: applyArguments.cache, renderer: applyArguments.renderer, textLayout: layout, placeholderColor: applyArguments.placeholderColor, attemptSynchronousLoad: false, textColor: applyArguments.textColor, spoilerEffectColor: applyArguments.spoilerEffectColor, animation: animation) + } + + return maybeNode + } else { + let resultNode = InteractiveTextNodeWithEntities(textNode: result) + + if let applyArguments = applyArguments { + resultNode.updateInteractiveContents(context: applyArguments.context, cache: applyArguments.cache, renderer: applyArguments.renderer, textLayout: layout, placeholderColor: applyArguments.placeholderColor, attemptSynchronousLoad: false, textColor: applyArguments.textColor, spoilerEffectColor: applyArguments.spoilerEffectColor, animation: .None) + } + + return resultNode + } + }) + } + } + + private func isItemVisible(itemRect: CGRect) -> Bool { + if let visibilityRect = self.visibilityRect { + return itemRect.intersects(visibilityRect) + } else { + return false + } + } + + private func updateInteractiveContents( + context: AccountContext, + cache: AnimationCache, + renderer: MultiAnimationRenderer, + textLayout: InteractiveTextNodeLayout?, + placeholderColor: UIColor, + attemptSynchronousLoad: Bool, + textColor: UIColor, + spoilerEffectColor: UIColor, + animation: ListViewItemUpdateAnimation + ) { + self.enableLooping = context.sharedContext.energyUsageSettings.loopEmoji + + var nextIndexById: [Int64: Int] = [:] + var validIds: [InlineStickerItemLayer.Key] = [] + + var validDustEffectIds: [Int] = [] + + if let textLayout { + for i in 0 ..< textLayout.segments.count { + let segment = textLayout.segments[i] + guard let segmentLayer = self.textNode.segmentLayer(index: i), let segmentItem = segmentLayer.item else { + continue + } + + for item in segment.embeddedItems { + if let stickerItem = item.value as? InlineStickerItem { + let index: Int + if let currentNext = nextIndexById[stickerItem.emoji.fileId] { + index = currentNext + } else { + index = 0 + } + nextIndexById[stickerItem.emoji.fileId] = index + 1 + let id = InlineStickerItemLayer.Key(id: stickerItem.emoji.fileId, index: index) + validIds.append(id) + + let itemSize = floorToScreenPixels(stickerItem.fontSize * 24.0 / 17.0) + + var itemFrame = CGRect(origin: item.rect.center, size: CGSize()).insetBy(dx: -itemSize / 2.0, dy: -itemSize / 2.0) + itemFrame.origin.x = floorToScreenPixels(itemFrame.origin.x) + itemFrame.origin.y = floorToScreenPixels(itemFrame.origin.y) + + itemFrame.origin.x += segmentItem.contentOffset.x + itemFrame.origin.y += segmentItem.contentOffset.y + + let itemLayerData: InlineStickerItemLayerData + if let current = self.inlineStickerItemLayers[id] { + itemLayerData = current + itemLayerData.itemLayer.dynamicColor = item.textColor + + if itemLayerData.itemLayer.superlayer !== segmentLayer.renderNode.layer { + segmentLayer.addSublayer(itemLayerData.itemLayer) + } + } else { + let pointSize = floor(itemSize * 1.3) + itemLayerData = InlineStickerItemLayerData(itemLayer: InlineStickerItemLayer(context: context, userLocation: .other, attemptSynchronousLoad: attemptSynchronousLoad, emoji: stickerItem.emoji, file: stickerItem.file, cache: cache, renderer: renderer, placeholderColor: placeholderColor, pointSize: CGSize(width: pointSize, height: pointSize), dynamicColor: item.textColor)) + self.inlineStickerItemLayers[id] = itemLayerData + segmentLayer.renderNode.layer.addSublayer(itemLayerData.itemLayer) + + itemLayerData.itemLayer.isVisibleForAnimations = self.enableLooping && self.isItemVisible(itemRect: itemFrame.offsetBy(dx: -segmentItem.contentOffset.x, dy: -segmentItem.contentOffset.x)) + } + + itemLayerData.itemLayer.opacity = item.isHiddenBySpoiler ? 0.0 : 1.0 + + itemLayerData.itemLayer.frame = itemFrame + itemLayerData.rect = itemFrame.offsetBy(dx: -segmentItem.contentOffset.x, dy: -segmentItem.contentOffset.y) + } + } + + if !segment.spoilers.isEmpty { + validDustEffectIds.append(i) + + let dustEffectNode: InvisibleInkDustNode + if let current = self.dustEffectNodes[i] { + dustEffectNode = current + if dustEffectNode.layer.superlayer !== segmentLayer.renderNode.layer { + segmentLayer.renderNode.layer.addSublayer(dustEffectNode.layer) + } + } else { + dustEffectNode = InvisibleInkDustNode(textNode: nil, enableAnimations: context.sharedContext.energyUsageSettings.fullTranslucency) + self.dustEffectNodes[i] = dustEffectNode + segmentLayer.renderNode.layer.addSublayer(dustEffectNode.layer) + } + let dustNodeFrame = CGRect(origin: CGPoint(), size: segmentItem.size).insetBy(dx: -3.0, dy: -3.0) + dustEffectNode.frame = dustNodeFrame + dustEffectNode.update( + size: dustNodeFrame.size, + color: spoilerEffectColor, + textColor: textColor, + rects: segment.spoilers.map { $0.1.offsetBy(dx: 3.0 + segmentItem.contentOffset.x, dy: segmentItem.contentOffset.y + 3.0).insetBy(dx: 1.0, dy: 1.0) }, + wordRects: segment.spoilerWords.map { $0.1.offsetBy(dx: segmentItem.contentOffset.x + 3.0, dy: segmentItem.contentOffset.y + 3.0).insetBy(dx: 1.0, dy: 1.0) } + ) + } + } + } + + var removeKeys: [InlineStickerItemLayer.Key] = [] + for (key, itemLayerData) in self.inlineStickerItemLayers { + if !validIds.contains(key) { + removeKeys.append(key) + itemLayerData.itemLayer.removeFromSuperlayer() + } + } + for key in removeKeys { + self.inlineStickerItemLayers.removeValue(forKey: key) + } + + var removeDustEffectIds: [Int] = [] + for (id, dustEffectNode) in self.dustEffectNodes { + if !validDustEffectIds.contains(id) { + removeDustEffectIds.append(id) + dustEffectNode.removeFromSupernode() + } + } + for id in removeDustEffectIds { + self.dustEffectNodes.removeValue(forKey: id) + } + } +} diff --git a/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift b/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift index d445bd96fc..4ac5a12927 100644 --- a/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift +++ b/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift @@ -622,7 +622,7 @@ public final class TextFieldComponent: Component { } }) - self.toggleAttribute(key: ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .quote)) + self.toggleAttribute(key: ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .quote, isCollapsed: false)) self.updateSpoilersRevealed(animated: animated) } @@ -639,7 +639,7 @@ public final class TextFieldComponent: Component { } }) - self.toggleAttribute(key: ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .code(language: nil))) + self.toggleAttribute(key: ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .code(language: nil), isCollapsed: false)) self.updateSpoilersRevealed(animated: animated) } @@ -1438,7 +1438,7 @@ extension TextFieldComponent.InputState { for (key, value) in attributes { if let value = value as? ChatTextInputTextQuoteAttribute { result.removeAttribute(key, range: range) - result.addAttribute(key, value: ChatTextInputTextQuoteAttribute(kind: value.kind), range: range) + result.addAttribute(key, value: ChatTextInputTextQuoteAttribute(kind: value.kind, isCollapsed: value.isCollapsed), range: range) } } } @@ -1451,7 +1451,7 @@ extension TextFieldComponent.InputState { if addAttribute { if attribute == ChatTextInputAttributes.block { - result.addAttribute(attribute, value: value ?? ChatTextInputTextQuoteAttribute(kind: .quote), range: nsRange) + result.addAttribute(attribute, value: value ?? ChatTextInputTextQuoteAttribute(kind: .quote, isCollapsed: false), range: nsRange) var selectionIndex = nsRange.upperBound if nsRange.upperBound != result.length && (result.string as NSString).character(at: nsRange.upperBound) != 0x0a { result.insert(NSAttributedString(string: "\n"), at: nsRange.upperBound) diff --git a/submodules/TelegramUI/Components/TextLoadingEffect/Sources/TextLoadingEffect.swift b/submodules/TelegramUI/Components/TextLoadingEffect/Sources/TextLoadingEffect.swift index e89d933895..d9d433495c 100644 --- a/submodules/TelegramUI/Components/TextLoadingEffect/Sources/TextLoadingEffect.swift +++ b/submodules/TelegramUI/Components/TextLoadingEffect/Sources/TextLoadingEffect.swift @@ -112,12 +112,10 @@ public final class TextLoadingEffectView: UIView { self.borderBackgroundView.layer.add(animation, forKey: "shimmer") } - public func update(color: UIColor, textNode: TextNode, range: NSRange) { + public func update(color: UIColor, textNode: TextNodeProtocol, range: NSRange) { var rectsSet: [CGRect] = [] - if let cachedLayout = textNode.cachedLayout { - if let rects = cachedLayout.rangeRects(in: range)?.rects, !rects.isEmpty { - rectsSet = rects - } + if let rects = textNode.textRangeRects(in: range)?.rects, !rects.isEmpty { + rectsSet = rects } let maskFrame = CGRect(origin: CGPoint(), size: textNode.bounds.size).insetBy(dx: -4.0, dy: -4.0) diff --git a/submodules/TelegramUI/Sources/ChatControllerNode.swift b/submodules/TelegramUI/Sources/ChatControllerNode.swift index ed4d9de045..fa649566be 100644 --- a/submodules/TelegramUI/Sources/ChatControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatControllerNode.swift @@ -602,10 +602,13 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { } var historyNodeRotated = true + var isChatPreview = false switch chatPresentationInterfaceState.mode { case let .standard(standardMode): if case .embedded(true) = standardMode { historyNodeRotated = false + } else if case .previewing = standardMode { + isChatPreview = true } default: break @@ -614,7 +617,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { self.controllerInteraction.chatIsRotated = historyNodeRotated var getMessageTransitionNode: (() -> ChatMessageTransitionNodeImpl?)? - self.historyNode = ChatHistoryListNodeImpl(context: context, updatedPresentationData: controller?.updatedPresentationData ?? (context.sharedContext.currentPresentationData.with({ $0 }), context.sharedContext.presentationData), chatLocation: chatLocation, chatLocationContextHolder: chatLocationContextHolder, tag: nil, source: source, subject: subject, controllerInteraction: controllerInteraction, selectedMessages: self.selectedMessagesPromise.get(), rotated: historyNodeRotated, messageTransitionNode: { + self.historyNode = ChatHistoryListNodeImpl(context: context, updatedPresentationData: controller?.updatedPresentationData ?? (context.sharedContext.currentPresentationData.with({ $0 }), context.sharedContext.presentationData), chatLocation: chatLocation, chatLocationContextHolder: chatLocationContextHolder, tag: nil, source: source, subject: subject, controllerInteraction: controllerInteraction, selectedMessages: self.selectedMessagesPromise.get(), rotated: historyNodeRotated, isChatPreview: isChatPreview, messageTransitionNode: { return getMessageTransitionNode?() }) diff --git a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift index eb5953cd03..64a2f5080d 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift @@ -718,7 +718,7 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto return self._isReady.get() } - public init(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal), chatLocation: ChatLocation, chatLocationContextHolder: Atomic, tag: HistoryViewInputTag?, source: ChatHistoryListSource, subject: ChatControllerSubject?, controllerInteraction: ChatControllerInteraction, selectedMessages: Signal?, NoError>, mode: ChatHistoryListMode = .bubbles, rotated: Bool = false, messageTransitionNode: @escaping () -> ChatMessageTransitionNodeImpl?) { + public init(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal), chatLocation: ChatLocation, chatLocationContextHolder: Atomic, tag: HistoryViewInputTag?, source: ChatHistoryListSource, subject: ChatControllerSubject?, controllerInteraction: ChatControllerInteraction, selectedMessages: Signal?, NoError>, mode: ChatHistoryListMode = .bubbles, rotated: Bool = false, isChatPreview: Bool, messageTransitionNode: @escaping () -> ChatMessageTransitionNodeImpl?) { var tag = tag if case .pinnedMessages = subject { tag = .tag(.pinned) @@ -752,13 +752,15 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto self.prefetchManager = InChatPrefetchManager(context: context) var displayAdPeer: PeerId? - switch subject { - case .none, .message: - if case let .peer(peerId) = chatLocation { - displayAdPeer = peerId + if !isChatPreview { + switch subject { + case .none, .message: + if case let .peer(peerId) = chatLocation { + displayAdPeer = peerId + } + default: + break } - default: - break } var adMessages: Signal<(interPostInterval: Int32?, messages: [Message]), NoError> if case .bubbles = mode, let peerId = displayAdPeer { diff --git a/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift index 61084aecd2..69a7069afb 100644 --- a/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift @@ -1200,6 +1200,24 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate, Ch self.textInputBackgroundNode.isUserInteractionEnabled = !textInputNode.isUserInteractionEnabled //self.textInputBackgroundNode.view.removeGestureRecognizer(self.textInputBackgroundNode.view.gestureRecognizers![0]) + textInputNode.textView.toggleQuoteCollapse = { [weak self] range in + guard let self else { + return + } + self.interfaceInteraction?.updateTextInputStateAndMode { current, inputMode in + let result = NSMutableAttributedString(attributedString: current.inputText) + + if let current = result.attribute(ChatTextInputAttributes.block, at: range.lowerBound, effectiveRange: nil) as? ChatTextInputTextQuoteAttribute { + result.addAttribute(ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: current.kind, isCollapsed: !current.isCollapsed), range: range) + } + + return (ChatTextInputState( + inputText: result, + selectionRange: current.selectionRange + ), inputMode) + } + } + let recognizer = TouchDownGestureRecognizer(target: self, action: #selector(self.textInputBackgroundViewTap(_:))) recognizer.touchDown = { [weak self] in if let strongSelf = self { @@ -4250,7 +4268,7 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate, Ch self.inputMenu.back() self.interfaceInteraction?.updateTextInputStateAndMode { current, inputMode in - return (chatTextInputAddFormattingAttribute(current, attribute: ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .quote)), inputMode) + return (chatTextInputAddFormattingAttribute(current, attribute: ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .quote, isCollapsed: false)), inputMode) } } @@ -4258,7 +4276,7 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate, Ch self.inputMenu.back() self.interfaceInteraction?.updateTextInputStateAndMode { current, inputMode in - return (chatTextInputAddFormattingAttribute(current, attribute: ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .code(language: nil))), inputMode) + return (chatTextInputAddFormattingAttribute(current, attribute: ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .code(language: nil), isCollapsed: false)), inputMode) } } diff --git a/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift b/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift index ad1c3ab6ba..d7f111b194 100644 --- a/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift +++ b/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift @@ -224,7 +224,7 @@ final class OverlayAudioPlayerControllerNode: ViewControllerTracingNode, ASGestu self.isGlobalSearch = false } - self.historyNode = ChatHistoryListNodeImpl(context: context, updatedPresentationData: (context.sharedContext.currentPresentationData.with({ $0 }), context.sharedContext.presentationData), chatLocation: chatLocation, chatLocationContextHolder: chatLocationContextHolder, tag: .tag(tagMask), source: source, subject: .message(id: .id(initialMessageId), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil), controllerInteraction: self.controllerInteraction, selectedMessages: .single(nil), mode: .list(search: false, reversed: self.currentIsReversed, reverseGroups: !self.currentIsReversed, displayHeaders: .none, hintLinks: false, isGlobalSearch: self.isGlobalSearch), messageTransitionNode: { return nil }) + self.historyNode = ChatHistoryListNodeImpl(context: context, updatedPresentationData: (context.sharedContext.currentPresentationData.with({ $0 }), context.sharedContext.presentationData), chatLocation: chatLocation, chatLocationContextHolder: chatLocationContextHolder, tag: .tag(tagMask), source: source, subject: .message(id: .id(initialMessageId), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil), controllerInteraction: self.controllerInteraction, selectedMessages: .single(nil), mode: .list(search: false, reversed: self.currentIsReversed, reverseGroups: !self.currentIsReversed, displayHeaders: .none, hintLinks: false, isGlobalSearch: self.isGlobalSearch), isChatPreview: false, messageTransitionNode: { return nil }) self.historyNode.clipsToBounds = true super.init() @@ -566,7 +566,7 @@ final class OverlayAudioPlayerControllerNode: ViewControllerTracingNode, ASGestu } let chatLocationContextHolder = Atomic(value: nil) - let historyNode = ChatHistoryListNodeImpl(context: self.context, updatedPresentationData: (self.context.sharedContext.currentPresentationData.with({ $0 }), self.context.sharedContext.presentationData), chatLocation: self.chatLocation, chatLocationContextHolder: chatLocationContextHolder, tag: .tag(tagMask), source: .default, subject: .message(id: .id(messageId), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil), controllerInteraction: self.controllerInteraction, selectedMessages: .single(nil), mode: .list(search: false, reversed: self.currentIsReversed, reverseGroups: !self.currentIsReversed, displayHeaders: .none, hintLinks: false, isGlobalSearch: self.isGlobalSearch), messageTransitionNode: { return nil }) + let historyNode = ChatHistoryListNodeImpl(context: self.context, updatedPresentationData: (self.context.sharedContext.currentPresentationData.with({ $0 }), self.context.sharedContext.presentationData), chatLocation: self.chatLocation, chatLocationContextHolder: chatLocationContextHolder, tag: .tag(tagMask), source: .default, subject: .message(id: .id(messageId), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil), controllerInteraction: self.controllerInteraction, selectedMessages: .single(nil), mode: .list(search: false, reversed: self.currentIsReversed, reverseGroups: !self.currentIsReversed, displayHeaders: .none, hintLinks: false, isGlobalSearch: self.isGlobalSearch), isChatPreview: false, messageTransitionNode: { return nil }) historyNode.clipsToBounds = true historyNode.preloadPages = true historyNode.stackFromBottom = true diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 9e39f17eed..7917547204 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -1638,6 +1638,7 @@ public final class SharedAccountContextImpl: SharedAccountContext { controllerInteraction: controllerInteraction as! ChatControllerInteraction, selectedMessages: selectedMessages, mode: mode, + isChatPreview: false, messageTransitionNode: { return nil } ) } diff --git a/submodules/TextFormat/Sources/ChatTextInputAttributes.swift b/submodules/TextFormat/Sources/ChatTextInputAttributes.swift index b4036b8007..cdabbbf67e 100644 --- a/submodules/TextFormat/Sources/ChatTextInputAttributes.swift +++ b/submodules/TextFormat/Sources/ChatTextInputAttributes.swift @@ -249,9 +249,11 @@ public final class ChatTextInputTextQuoteAttribute: NSObject { } public let kind: Kind + public let isCollapsed: Bool - public init(kind: Kind) { + public init(kind: Kind, isCollapsed: Bool) { self.kind = kind + self.isCollapsed = isCollapsed super.init() } @@ -264,6 +266,9 @@ public final class ChatTextInputTextQuoteAttribute: NSObject { if self.kind != other.kind { return false } + if self.isCollapsed != other.isCollapsed { + return false + } return true } @@ -646,7 +651,7 @@ private func refreshBlockQuotes(text: NSString, initialAttributedText: NSAttribu if !quoteRangesEqual(quoteRanges, initialQuoteRanges) { attributedText.removeAttribute(ChatTextInputAttributes.block, range: fullRange) for (range, attribute) in quoteRanges { - attributedText.addAttribute(ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: attribute.kind), range: range) + attributedText.addAttribute(ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: attribute.kind, isCollapsed: attribute.isCollapsed), range: range) } } } @@ -1067,7 +1072,7 @@ public func convertMarkdownToAttributes(_ text: NSAttributedString) -> NSAttribu substring = substring.substring(with: NSRange(location: 0, length: substring.length - 1)) as NSString } - result.append(NSAttributedString(string: substring as String, attributes: [ChatTextInputAttributes.block: ChatTextInputTextQuoteAttribute(kind: .code(language: language))])) + result.append(NSAttributedString(string: substring as String, attributes: [ChatTextInputAttributes.block: ChatTextInputTextQuoteAttribute(kind: .code(language: language), isCollapsed: false)])) offsetRanges.append((NSMakeRange(matchIndex + match.range(at: 1).length, text.count), 6)) } } diff --git a/submodules/TextFormat/Sources/GenerateTextEntities.swift b/submodules/TextFormat/Sources/GenerateTextEntities.swift index 09b57ce6af..4079103b32 100644 --- a/submodules/TextFormat/Sources/GenerateTextEntities.swift +++ b/submodules/TextFormat/Sources/GenerateTextEntities.swift @@ -171,7 +171,7 @@ public func generateChatInputTextEntities(_ text: NSAttributedString, maxAnimate } else if key == ChatTextInputAttributes.block, let value = value as? ChatTextInputTextQuoteAttribute { switch value.kind { case .quote: - entities.append(MessageTextEntity(range: range.lowerBound ..< range.upperBound, type: .BlockQuote(isCollapsed: false))) + entities.append(MessageTextEntity(range: range.lowerBound ..< range.upperBound, type: .BlockQuote(isCollapsed: value.isCollapsed))) case let .code(language): entities.append(MessageTextEntity(range: range.lowerBound ..< range.upperBound, type: .Pre(language: language))) } diff --git a/submodules/TextFormat/Sources/StringWithAppliedEntities.swift b/submodules/TextFormat/Sources/StringWithAppliedEntities.swift index 6cf40ed818..51fafc5c26 100644 --- a/submodules/TextFormat/Sources/StringWithAppliedEntities.swift +++ b/submodules/TextFormat/Sources/StringWithAppliedEntities.swift @@ -49,9 +49,9 @@ public func chatInputStateStringWithAppliedEntities(_ text: String, entities: [M case let .CustomEmoji(_, fileId): string.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: fileId, file: nil), range: range) case let .Pre(language): - string.addAttribute(ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .code(language: language)), range: range) - case .BlockQuote: - string.addAttribute(ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .quote), range: range) + string.addAttribute(ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .code(language: language), isCollapsed: false), range: range) + case let .BlockQuote(isCollapsed): + string.addAttribute(ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .quote, isCollapsed: isCollapsed), range: range) default: break } @@ -219,12 +219,12 @@ public func stringWithAppliedEntities(_ text: String, entities: [MessageTextEnti if let language, !language.isEmpty { title = NSAttributedString(string: language.capitalized, font: boldFont.withSize(round(boldFont.pointSize * 0.8235294117647058)), textColor: codeBlockTitleColor) } - string.addAttribute(NSAttributedString.Key(rawValue: "Attribute__Blockquote"), value: TextNodeBlockQuoteData(kind: .code(language: language), title: title, color: codeBlockAccentColor, secondaryColor: nil, tertiaryColor: nil, backgroundColor: codeBlockBackgroundColor), range: range) + string.addAttribute(NSAttributedString.Key(rawValue: "Attribute__Blockquote"), value: TextNodeBlockQuoteData(kind: .code(language: language), title: title, color: codeBlockAccentColor, secondaryColor: nil, tertiaryColor: nil, backgroundColor: codeBlockBackgroundColor, isCollapsible: false), range: range) } - case .BlockQuote: + case let .BlockQuote(isCollapsed): addFontAttributes(range, .blockQuote) - string.addAttribute(NSAttributedString.Key(rawValue: "Attribute__Blockquote"), value: TextNodeBlockQuoteData(kind: .quote, title: nil, color: baseQuoteTintColor, secondaryColor: baseQuoteSecondaryTintColor, tertiaryColor: baseQuoteTertiaryTintColor, backgroundColor: baseQuoteTintColor.withMultipliedAlpha(0.1)), range: range) + string.addAttribute(NSAttributedString.Key(rawValue: "Attribute__Blockquote"), value: TextNodeBlockQuoteData(kind: .quote, title: nil, color: baseQuoteTintColor, secondaryColor: baseQuoteSecondaryTintColor, tertiaryColor: baseQuoteTertiaryTintColor, backgroundColor: baseQuoteTintColor.withMultipliedAlpha(0.1), isCollapsible: isCollapsed), range: range) case .BankCard: string.addAttribute(NSAttributedString.Key.foregroundColor, value: linkColor, range: range) if underlineLinks && underlineAllLinks { diff --git a/submodules/TextSelectionNode/Sources/TextSelectionNode.swift b/submodules/TextSelectionNode/Sources/TextSelectionNode.swift index f212669410..f29ff1ef8a 100644 --- a/submodules/TextSelectionNode/Sources/TextSelectionNode.swift +++ b/submodules/TextSelectionNode/Sources/TextSelectionNode.swift @@ -218,7 +218,7 @@ public enum TextSelectionAction: Equatable { public final class TextSelectionNode: ASDisplayNode { private let theme: TextSelectionTheme private let strings: PresentationStrings - private let textNode: TextNode + private let textNode: TextNodeProtocol private let updateIsActive: (Bool) -> Void public var canBeginSelection: (CGPoint) -> Bool = { _ in true } public var updateRange: ((NSRange?) -> Void)? @@ -252,7 +252,7 @@ public final class TextSelectionNode: ASDisplayNode { private weak var contextMenu: ContextMenuController? - public init(theme: TextSelectionTheme, strings: PresentationStrings, textNode: TextNode, updateIsActive: @escaping (Bool) -> Void, present: @escaping (ViewController, Any?) -> Void, rootNode: @escaping () -> ASDisplayNode?, externalKnobSurface: UIView? = nil, performAction: @escaping (NSAttributedString, TextSelectionAction) -> Void) { + public init(theme: TextSelectionTheme, strings: PresentationStrings, textNode: TextNodeProtocol, updateIsActive: @escaping (Bool) -> Void, present: @escaping (ViewController, Any?) -> Void, rootNode: @escaping () -> ASDisplayNode?, externalKnobSurface: UIView? = nil, performAction: @escaping (NSAttributedString, TextSelectionAction) -> Void) { self.theme = theme self.strings = strings self.textNode = textNode @@ -302,7 +302,7 @@ public final class TextSelectionNode: ASDisplayNode { return self?.knobAtPoint(point) } recognizer.moveKnob = { [weak self] knob, point in - guard let strongSelf = self, let cachedLayout = strongSelf.textNode.cachedLayout, let _ = cachedLayout.attributedString, let currentRange = strongSelf.currentRange else { + guard let strongSelf = self, let currentRange = strongSelf.currentRange else { return } @@ -335,7 +335,7 @@ public final class TextSelectionNode: ASDisplayNode { strongSelf.displayMenu() } recognizer.beginSelection = { [weak self] point in - guard let strongSelf = self, let cachedLayout = strongSelf.textNode.cachedLayout, let attributedString = cachedLayout.attributedString else { + guard let strongSelf = self, let attributedString = strongSelf.textNode.currentText else { return } @@ -398,7 +398,7 @@ public final class TextSelectionNode: ASDisplayNode { } public func pretendInitiateSelection() { - guard let cachedLayout = self.textNode.cachedLayout, let attributedString = cachedLayout.attributedString else { + guard let attributedString = self.textNode.currentText else { return } @@ -432,7 +432,7 @@ public final class TextSelectionNode: ASDisplayNode { } public func pretendExtendSelection(to index: Int) { - guard let cachedLayout = self.textNode.cachedLayout, let _ = cachedLayout.attributedString, let endRangeRect = cachedLayout.rangeRects(in: NSRange(location: index, length: 1))?.rects.first else { + guard let endRangeRect = self.textNode.textRangeRects(in: NSRange(location: index, length: 1))?.rects.first else { return } let startPoint = self.rightKnob.frame.center @@ -449,7 +449,7 @@ public final class TextSelectionNode: ASDisplayNode { } public func setSelection(range: NSRange, displayMenu: Bool) { - guard let cachedLayout = self.textNode.cachedLayout, let attributedString = cachedLayout.attributedString else { + guard let attributedString = self.textNode.currentText else { return } let range = self.convertSelectionFromOriginalText(attributedString: attributedString, range: range) @@ -561,7 +561,7 @@ public final class TextSelectionNode: ASDisplayNode { } public func getSelection() -> NSRange? { - guard let currentRange = self.currentRange, let cachedLayout = self.textNode.cachedLayout, let attributedString = cachedLayout.attributedString else { + guard let currentRange = self.currentRange, let attributedString = self.textNode.currentText else { return nil } let range = NSRange(location: min(currentRange.0, currentRange.1), length: max(currentRange.0, currentRange.1) - min(currentRange.0, currentRange.1)) @@ -573,8 +573,24 @@ public final class TextSelectionNode: ASDisplayNode { var rects: (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? - if let range = range { - rects = self.textNode.rangeRects(in: range) + if let range { + if var rectsValue = self.textNode.textRangeRects(in: range) { + var rectList = rectsValue.rects + if rectList.count > 1 { + for i in 0 ..< rectList.count - 1 { + let deltaY = rectList[i + 1].minY - rectList[i].maxY + if deltaY > 0.0 && deltaY <= 4.0 { + rectList[i].size.height += deltaY * 0.5 + rectList[i + 1].size.height += deltaY * 0.5 + rectList[i + 1].origin.y -= deltaY * 0.5 + } + } + } + rectsValue.rects = rectList + rects = rectsValue + } else { + rects = nil + } } self.currentRects = rects?.rects @@ -667,7 +683,7 @@ public final class TextSelectionNode: ASDisplayNode { } private func displayMenu() { - guard let currentRects = self.currentRects, !currentRects.isEmpty, let currentRange = self.currentRange, let cachedLayout = self.textNode.cachedLayout, let attributedString = cachedLayout.attributedString else { + guard let currentRects = self.currentRects, !currentRects.isEmpty, let currentRange = self.currentRange, let attributedString = self.textNode.currentText else { return } let range = NSRange(location: min(currentRange.0, currentRange.1), length: max(currentRange.0, currentRange.1) - min(currentRange.0, currentRange.1))