import Foundation import UIKit import Display import AsyncDisplayKit import EmojiTextAttachmentView import TextFormat import AccountContext import AnimationCache import MultiAnimationRenderer private final class InlineStickerItem: Hashable { let emoji: ChatTextInputTextCustomEmojiAttribute let fontSize: CGFloat init(emoji: ChatTextInputTextCustomEmojiAttribute, fontSize: CGFloat) { self.emoji = emoji 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.fontSize != rhs.fontSize { return false } return true } } public final class TextNodeWithEntities { public final class Arguments { public let context: AccountContext public let cache: AnimationCache public let renderer: MultiAnimationRenderer public let placeholderColor: UIColor public init( context: AccountContext, cache: AnimationCache, renderer: MultiAnimationRenderer, placeholderColor: UIColor ) { self.context = context self.cache = cache self.renderer = renderer self.placeholderColor = placeholderColor } } public let textNode: TextNode private var inlineStickerItemLayers: [InlineStickerItemLayer.Key: InlineStickerItemLayer] = [:] public var visibilityRect: CGRect? { didSet { if !self.inlineStickerItemLayers.isEmpty && oldValue != self.visibilityRect { for (_, itemLayer) in self.inlineStickerItemLayers { let isItemVisible: Bool if let visibilityRect = self.visibilityRect { if itemLayer.frame.intersects(visibilityRect) { isItemVisible = true } else { isItemVisible = false } } else { isItemVisible = false } itemLayer.isVisibleForAnimations = isItemVisible } } } } public init() { self.textNode = TextNode() } private init(textNode: TextNode) { self.textNode = textNode } public static func asyncLayout(_ maybeNode: TextNodeWithEntities?) -> (TextNodeLayoutArguments) -> (TextNodeLayout, (TextNodeWithEntities.Arguments?) -> TextNodeWithEntities) { let makeLayout = TextNode.asyncLayout(maybeNode?.textNode) return { [weak maybeNode] arguments in var updatedString: NSAttributedString? if let sourceString = arguments.attributedString { let string = NSMutableAttributedString(attributedString: sourceString) let fullRange = NSRange(location: 0, length: string.length) string.enumerateAttribute(ChatTextInputAttributes.customEmoji, in: fullRange, options: [], using: { value, range, _ in if let value = value as? ChatTextInputTextCustomEmojiAttribute { if let font = string.attribute(.font, at: range.location, effectiveRange: nil) as? UIFont { string.addAttribute(NSAttributedString.Key("Attribute__EmbeddedItem"), value: InlineStickerItem(emoji: ChatTextInputTextCustomEmojiAttribute(stickerPack: value.stickerPack, fileId: value.fileId), fontSize: font.pointSize), range: range) } } }) updatedString = string } let (layout, apply) = makeLayout(arguments.withAttributedString(updatedString)) return (layout, { applyArguments in let result = apply() if let maybeNode = maybeNode { if let applyArguments = applyArguments { maybeNode.updateInlineStickers(context: applyArguments.context, cache: applyArguments.cache, renderer: applyArguments.renderer, textLayout: layout, placeholderColor: applyArguments.placeholderColor) } return maybeNode } else { let resultNode = TextNodeWithEntities(textNode: result) if let applyArguments = applyArguments { resultNode.updateInlineStickers(context: applyArguments.context, cache: applyArguments.cache, renderer: applyArguments.renderer, textLayout: layout, placeholderColor: applyArguments.placeholderColor) } return resultNode } }) } } private func isItemVisible(itemRect: CGRect) -> Bool { if let visibilityRect = self.visibilityRect { return itemRect.intersects(visibilityRect) } else { return false } } private func updateInlineStickers(context: AccountContext, cache: AnimationCache, renderer: MultiAnimationRenderer, textLayout: TextNodeLayout?, placeholderColor: UIColor) { var nextIndexById: [Int64: Int] = [:] var validIds: [InlineStickerItemLayer.Key] = [] if let textLayout = textLayout { for item in textLayout.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 = floor(stickerItem.fontSize * 24.0 / 17.0) let itemFrame = CGRect(origin: item.rect.offsetBy(dx: textLayout.insets.left, dy: textLayout.insets.top + 0.0).center, size: CGSize()).insetBy(dx: -itemSize / 2.0, dy: -itemSize / 2.0) let itemLayer: InlineStickerItemLayer if let current = self.inlineStickerItemLayers[id] { itemLayer = current } else { itemLayer = InlineStickerItemLayer(context: context, groupId: "inlineEmoji", attemptSynchronousLoad: false, emoji: stickerItem.emoji, cache: cache, renderer: renderer, placeholderColor: placeholderColor, pointSize: CGSize(width: itemSize, height: itemSize)) self.inlineStickerItemLayers[id] = itemLayer self.textNode.layer.addSublayer(itemLayer) itemLayer.isVisibleForAnimations = self.isItemVisible(itemRect: itemFrame) } itemLayer.frame = itemFrame } } } var removeKeys: [InlineStickerItemLayer.Key] = [] for (key, itemLayer) in self.inlineStickerItemLayers { if !validIds.contains(key) { removeKeys.append(key) itemLayer.removeFromSuperlayer() } } for key in removeKeys { self.inlineStickerItemLayers.removeValue(forKey: key) } } } public class ImmediateTextNodeWithEntities: TextNode { public var attributedText: NSAttributedString? public var textAlignment: NSTextAlignment = .natural public var verticalAlignment: TextVerticalAlignment = .top public var truncationType: CTLineTruncationType = .end public var maximumNumberOfLines: Int = 1 public var lineSpacing: CGFloat = 0.0 public var insets: UIEdgeInsets = UIEdgeInsets() public var textShadowColor: UIColor? public var textStroke: (UIColor, CGFloat)? public var cutout: TextNodeCutout? public var displaySpoilers = false public var arguments: TextNodeWithEntities.Arguments? private var inlineStickerItemLayers: [InlineStickerItemLayer.Key: InlineStickerItemLayer] = [:] public var visibility: Bool = false { didSet { if !self.inlineStickerItemLayers.isEmpty && oldValue != self.visibility { for (_, itemLayer) in self.inlineStickerItemLayers { let isItemVisible: Bool = self.visibility itemLayer.isVisibleForAnimations = isItemVisible } } } } public var truncationMode: NSLineBreakMode { get { switch self.truncationType { case .start: return .byTruncatingHead case .middle: return .byTruncatingMiddle case .end: return .byTruncatingTail @unknown default: return .byTruncatingTail } } set(value) { switch value { case .byTruncatingHead: self.truncationType = .start case .byTruncatingMiddle: self.truncationType = .middle case .byTruncatingTail: self.truncationType = .end default: self.truncationType = .end } } } private var tapRecognizer: TapLongTapOrDoubleTapGestureRecognizer? private var linkHighlightingNode: LinkHighlightingNode? public var linkHighlightColor: UIColor? public var trailingLineWidth: CGFloat? var constrainedSize: CGSize? public var highlightAttributeAction: (([NSAttributedString.Key: Any]) -> NSAttributedString.Key?)? { didSet { if self.isNodeLoaded { self.updateInteractiveActions() } } } public var tapAttributeAction: (([NSAttributedString.Key: Any], Int) -> Void)? public var longTapAttributeAction: (([NSAttributedString.Key: Any], Int) -> Void)? private func processedAttributedText() -> NSAttributedString? { var updatedString: NSAttributedString? if let sourceString = self.attributedText { let string = NSMutableAttributedString(attributedString: sourceString) let fullRange = NSRange(location: 0, length: string.length) string.enumerateAttribute(ChatTextInputAttributes.customEmoji, in: fullRange, options: [], using: { value, range, _ in if let value = value as? ChatTextInputTextCustomEmojiAttribute { if let font = string.attribute(.font, at: range.location, effectiveRange: nil) as? UIFont { string.addAttribute(NSAttributedString.Key("Attribute__EmbeddedItem"), value: InlineStickerItem(emoji: ChatTextInputTextCustomEmojiAttribute(stickerPack: value.stickerPack, fileId: value.fileId), fontSize: font.pointSize), range: range) } } }) updatedString = string } return updatedString } public func updateLayout(_ constrainedSize: CGSize) -> CGSize { self.constrainedSize = constrainedSize let makeLayout = TextNode.asyncLayout(self) let (layout, apply) = makeLayout(TextNodeLayoutArguments(attributedString: self.processedAttributedText(), backgroundColor: nil, maximumNumberOfLines: self.maximumNumberOfLines, truncationType: self.truncationType, constrainedSize: constrainedSize, alignment: self.textAlignment, verticalAlignment: self.verticalAlignment, lineSpacing: self.lineSpacing, cutout: self.cutout, insets: self.insets, textShadowColor: self.textShadowColor, textStroke: self.textStroke, displaySpoilers: self.displaySpoilers)) let _ = apply() if let arguments = self.arguments { self.updateInlineStickers(context: arguments.context, cache: arguments.cache, renderer: arguments.renderer, textLayout: layout, placeholderColor: arguments.placeholderColor) } if layout.numberOfLines > 1 { self.trailingLineWidth = layout.trailingLineWidth } else { self.trailingLineWidth = nil } return layout.size } private func updateInlineStickers(context: AccountContext, cache: AnimationCache, renderer: MultiAnimationRenderer, textLayout: TextNodeLayout?, placeholderColor: UIColor) { var nextIndexById: [Int64: Int] = [:] var validIds: [InlineStickerItemLayer.Key] = [] if let textLayout = textLayout { for item in textLayout.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 = floor(stickerItem.fontSize * 24.0 / 17.0) let itemFrame = CGRect(origin: item.rect.offsetBy(dx: textLayout.insets.left, dy: textLayout.insets.top + 0.0).center, size: CGSize()).insetBy(dx: -itemSize / 2.0, dy: -itemSize / 2.0) let itemLayer: InlineStickerItemLayer if let current = self.inlineStickerItemLayers[id] { itemLayer = current } else { itemLayer = InlineStickerItemLayer(context: context, groupId: "inlineEmoji", attemptSynchronousLoad: false, emoji: stickerItem.emoji, cache: cache, renderer: renderer, placeholderColor: placeholderColor, pointSize: CGSize(width: itemSize, height: itemSize)) self.inlineStickerItemLayers[id] = itemLayer self.layer.addSublayer(itemLayer) itemLayer.isVisibleForAnimations = self.visibility } itemLayer.frame = itemFrame } } } var removeKeys: [InlineStickerItemLayer.Key] = [] for (key, itemLayer) in self.inlineStickerItemLayers { if !validIds.contains(key) { removeKeys.append(key) itemLayer.removeFromSuperlayer() } } for key in removeKeys { self.inlineStickerItemLayers.removeValue(forKey: key) } } public func updateLayoutInfo(_ constrainedSize: CGSize) -> ImmediateTextNodeLayoutInfo { self.constrainedSize = constrainedSize let makeLayout = TextNode.asyncLayout(self) let (layout, apply) = makeLayout(TextNodeLayoutArguments(attributedString: self.processedAttributedText(), backgroundColor: nil, maximumNumberOfLines: self.maximumNumberOfLines, truncationType: self.truncationType, constrainedSize: constrainedSize, alignment: self.textAlignment, verticalAlignment: self.verticalAlignment, lineSpacing: self.lineSpacing, cutout: self.cutout, insets: self.insets, displaySpoilers: self.displaySpoilers)) let _ = apply() if let arguments = self.arguments { self.updateInlineStickers(context: arguments.context, cache: arguments.cache, renderer: arguments.renderer, textLayout: layout, placeholderColor: arguments.placeholderColor) } return ImmediateTextNodeLayoutInfo(size: layout.size, truncated: layout.truncated, numberOfLines: layout.numberOfLines) } public func updateLayoutFullInfo(_ constrainedSize: CGSize) -> TextNodeLayout { self.constrainedSize = constrainedSize let makeLayout = TextNode.asyncLayout(self) let (layout, apply) = makeLayout(TextNodeLayoutArguments(attributedString: self.processedAttributedText(), backgroundColor: nil, maximumNumberOfLines: self.maximumNumberOfLines, truncationType: self.truncationType, constrainedSize: constrainedSize, alignment: self.textAlignment, verticalAlignment: self.verticalAlignment, lineSpacing: self.lineSpacing, cutout: self.cutout, insets: self.insets, displaySpoilers: self.displaySpoilers)) let _ = apply() if let arguments = self.arguments { self.updateInlineStickers(context: arguments.context, cache: arguments.cache, renderer: arguments.renderer, textLayout: layout, placeholderColor: arguments.placeholderColor) } return layout } public func redrawIfPossible() { if let constrainedSize = self.constrainedSize { let _ = self.updateLayout(constrainedSize) } } override public func didLoad() { super.didLoad() self.updateInteractiveActions() } private func updateInteractiveActions() { if self.highlightAttributeAction != nil { if self.tapRecognizer == nil { let tapRecognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapAction(_:))) tapRecognizer.highlight = { [weak self] point in if let strongSelf = self { var rects: [CGRect]? if let point = point { if let (index, attributes) = strongSelf.attributesAtPoint(CGPoint(x: point.x, y: point.y)) { if let selectedAttribute = strongSelf.highlightAttributeAction?(attributes) { let initialRects = strongSelf.lineAndAttributeRects(name: selectedAttribute.rawValue, at: index) if let initialRects = initialRects, case .center = strongSelf.textAlignment { var mappedRects: [CGRect] = [] for i in 0 ..< initialRects.count { let lineRect = initialRects[i].0 var itemRect = initialRects[i].1 itemRect.origin.x = floor((strongSelf.bounds.size.width - lineRect.width) / 2.0) + itemRect.origin.x mappedRects.append(itemRect) } rects = mappedRects } else { rects = strongSelf.attributeRects(name: selectedAttribute.rawValue, at: index) } } } } if let rects = rects { let linkHighlightingNode: LinkHighlightingNode if let current = strongSelf.linkHighlightingNode { linkHighlightingNode = current } else { linkHighlightingNode = LinkHighlightingNode(color: strongSelf.linkHighlightColor ?? .clear) strongSelf.linkHighlightingNode = linkHighlightingNode strongSelf.addSubnode(linkHighlightingNode) } linkHighlightingNode.frame = strongSelf.bounds linkHighlightingNode.updateRects(rects.map { $0.offsetBy(dx: 0.0, dy: 0.0) }) } else if let linkHighlightingNode = strongSelf.linkHighlightingNode { strongSelf.linkHighlightingNode = nil linkHighlightingNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak linkHighlightingNode] _ in linkHighlightingNode?.removeFromSupernode() }) } } } self.view.addGestureRecognizer(tapRecognizer) } } else if let tapRecognizer = self.tapRecognizer { self.tapRecognizer = nil self.view.removeGestureRecognizer(tapRecognizer) } } @objc private func tapAction(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { switch recognizer.state { case .ended: if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation { switch gesture { case .tap: if let (index, attributes) = self.attributesAtPoint(CGPoint(x: location.x, y: location.y)) { self.tapAttributeAction?(attributes, index) } case .longTap: if let (index, attributes) = self.attributesAtPoint(CGPoint(x: location.x, y: location.y)) { self.longTapAttributeAction?(attributes, index) } default: break } } default: break } } }