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() } } }