Files
Swiftgram/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextComponent.swift

2923 lines
143 KiB
Swift

import Foundation
import UIKit
import AsyncDisplayKit
import Display
import CoreText
import AppBundle
import ComponentFlow
import TextFormat
import MessageInlineBlockBackgroundView
import InvisibleInkDustNode
import EmojiTextAttachmentView
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.4, 1.0]
colors = [UIColor.black.withAlphaComponent(0.0).cgColor, UIColor.black.withAlphaComponent(0.0).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 - 8.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
let constrainedWidth: CGFloat
var frame: CGRect
let intrinsicWidth: CGFloat
let ascent: CGFloat
let descent: CGFloat
let range: NSRange?
let isTruncated: Bool
let isRTL: Bool
var strikethroughs: [InteractiveTextNodeStrikethrough]
var underlines: [InteractiveTextNodeStrikethrough]
var spoilers: [InteractiveTextNodeSpoiler]
var spoilerWords: [InteractiveTextNodeSpoiler]
var embeddedItems: [InteractiveTextNodeEmbeddedItem]
var attachments: [InteractiveTextNodeAttachment]
let additionalTrailingLine: (CTLine, Double)?
init(line: CTLine, constrainedWidth: CGFloat, frame: CGRect, intrinsicWidth: CGFloat, ascent: CGFloat, descent: CGFloat, range: NSRange?, isTruncated: Bool, isRTL: Bool, strikethroughs: [InteractiveTextNodeStrikethrough], underlines: [InteractiveTextNodeStrikethrough], spoilers: [InteractiveTextNodeSpoiler], spoilerWords: [InteractiveTextNodeSpoiler], embeddedItems: [InteractiveTextNodeEmbeddedItem], attachments: [InteractiveTextNodeAttachment], additionalTrailingLine: (CTLine, Double)?) {
self.line = line
self.constrainedWidth = constrainedWidth
self.frame = frame
self.intrinsicWidth = intrinsicWidth
self.ascent = ascent
self.descent = descent
self.range = range
self.isTruncated = isTruncated
self.isRTL = isRTL
self.strikethroughs = strikethroughs
self.underlines = underlines
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]
public var hasBlockQuote: Bool {
return self.blockQuote != nil
}
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: ((UIFont, Bool) -> NSAttributedString?)?
public let expandedBlocks: Set<Int>
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: ((UIFont, Bool) -> NSAttributedString?)? = nil,
expandedBlocks: Set<Int> = 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.expandedBlocks = expandedBlocks
}
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,
expandedBlocks: self.expandedBlocks
)
}
}
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)?
public let displayContentsUnderSpoilers: Bool
fileprivate let expandedBlocks: Set<Int>
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,
expandedBlocks: Set<Int>
) {
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.expandedBlocks = expandedBlocks
}
func withUpdatedDisplayContentsUnderSpoilers(_ displayContentsUnderSpoilers: Bool) -> InteractiveTextNodeLayout {
return InteractiveTextNodeLayout(
attributedString: self.attributedString,
maximumNumberOfLines: self.maximumNumberOfLines,
truncationType: self.truncationType,
constrainedSize: self.constrainedSize,
explicitAlignment: self.explicitAlignment,
resolvedAlignment: self.resolvedAlignment,
verticalAlignment: self.verticalAlignment,
lineSpacing: self.lineSpacing,
cutout: self.cutout,
insets: self.insets,
size: self.size,
rawTextSize: self.rawTextSize,
truncated: self.truncated,
firstLineOffset: self.firstLineOffset,
segments: self.segments,
backgroundColor: self.backgroundColor,
lineColor: self.lineColor,
textShadowColor: self.textShadowColor,
textShadowBlur: self.textShadowBlur,
textStroke: self.textStroke,
displayContentsUnderSpoilers: displayContentsUnderSpoilers,
expandedBlocks: self.expandedBlocks
)
}
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 additionalTrailingLine = lastLine.additionalTrailingLine {
width += additionalTrailingLine.1
}
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, UIGestureRecognizerDelegate {
public final class ApplyArguments {
public let animation: ListViewItemUpdateAnimation
public let spoilerTextColor: UIColor
public let spoilerEffectColor: UIColor
public let areContentAnimationsEnabled: Bool
public let spoilerExpandRect: CGRect?
public init(
animation: ListViewItemUpdateAnimation,
spoilerTextColor: UIColor,
spoilerEffectColor: UIColor,
areContentAnimationsEnabled: Bool,
spoilerExpandRect: CGRect?
) {
self.animation = animation
self.spoilerTextColor = spoilerTextColor
self.spoilerEffectColor = spoilerEffectColor
self.areContentAnimationsEnabled = areContentAnimationsEnabled
self.spoilerExpandRect = spoilerExpandRect
}
}
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] = [:]
private var isDisplayingContentsUnderSpoilers: Bool?
public var canHandleTapAtPoint: ((CGPoint) -> Bool)?
public var requestToggleBlockCollapsed: ((Int) -> Void)?
public var requestDisplayContentsUnderSpoilers: ((CGPoint?) -> 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 params = contentItemLayer.params else {
continue
}
guard let blockQuote = params.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 canHandleTapAtPoint = self.canHandleTapAtPoint else {
return nil
}
if !canHandleTapAtPoint(point) {
return nil
}
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: ((UIFont, Bool) -> NSAttributedString?)?,
expandedBlocks: Set<Int>
) -> 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
}
}
class CalculatedSegment {
let id: Int?
var titleLine: InteractiveTextNodeLine?
var lines: [InteractiveTextNodeLine] = []
var tintColor: UIColor?
var secondaryTintColor: UIColor?
var tertiaryTintColor: UIColor?
var blockQuote: TextNodeBlockQuoteData?
var additionalWidth: CGFloat = 0.0
init(id: Int?) {
self.id = id
}
}
var calculatedSegments: [CalculatedSegment] = []
var remainingLines = maximumNumberOfLines <= 0 ? Int.max : maximumNumberOfLines
var nextBlockIndex = 0
for segment in stringSegments {
if remainingLines <= 0 {
break
}
var blockIndex: Int?
var isCollapsed = false
if let blockQuote = segment.blockQuote {
let blockIndexValue = nextBlockIndex
blockIndex = blockIndexValue
nextBlockIndex += 1
if blockQuote.isCollapsible {
isCollapsed = !expandedBlocks.contains(blockIndexValue)
}
}
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
let calculatedSegment = CalculatedSegment(id: blockIndex)
calculatedSegment.blockQuote = segment.blockQuote
calculatedSegment.tintColor = segment.tintColor
calculatedSegment.secondaryTintColor = segment.secondaryTintColor
calculatedSegment.tertiaryTintColor = segment.tertiaryTintColor
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)
let constrainedLineWidth = constrainedSegmentWidth - additionalSegmentRightInset
if let titleLine = CTLineCreateTruncatedLine(rawTitleLine, constrainedLineWidth, .end, nil) {
var lineAscent: CGFloat = 0.0
var lineDescent: CGFloat = 0.0
let lineWidth = CTLineGetTypographicBounds(titleLine, &lineAscent, &lineDescent, nil)
calculatedSegment.titleLine = InteractiveTextNodeLine(
line: titleLine,
constrainedWidth: constrainedLineWidth,
frame: CGRect(origin: CGPoint(x: additionalOffsetX, y: 0.0), size: CGSize(width: lineWidth + additionalSegmentRightInset, height: lineAscent + lineDescent)),
intrinsicWidth: lineWidth,
ascent: lineAscent,
descent: lineDescent,
range: nil,
isTruncated: false,
isRTL: false,
strikethroughs: [],
underlines: [],
spoilers: [],
spoilerWords: [],
embeddedItems: [],
attachments: [],
additionalTrailingLine: nil
)
additionalSegmentRightInset = 0.0
}
}
while true {
let constrainedLineWidth = constrainedSegmentWidth - additionalSegmentRightInset
let lineCharacterCount = CTTypesetterSuggestLineBreak(typesetter, currentLineStartIndex, constrainedLineWidth)
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,
constrainedWidth: constrainedLineWidth,
frame: CGRect(origin: CGPoint(x: additionalOffsetX, y: 0.0), size: CGSize(width: lineWidth + additionalSegmentRightInset, height: lineAscent + lineDescent)),
intrinsicWidth: lineWidth,
ascent: lineAscent,
descent: lineDescent,
range: NSRange(location: currentLineStartIndex, length: lineCharacterCount),
isTruncated: false,
isRTL: isRTL && segment.blockQuote == nil,
strikethroughs: [],
underlines: [],
spoilers: [],
spoilerWords: [],
embeddedItems: [],
attachments: [],
additionalTrailingLine: nil
))
remainingLines -= 1
if remainingLines <= 0 {
break
}
}
additionalSegmentRightInset = 0.0
currentLineStartIndex += lineCharacterCount
if currentLineStartIndex >= segmentEndIndex {
break
}
if remainingLines <= 0 {
break
}
}
if isCollapsed, calculatedSegment.lines.count > 3 {
let lastLine = calculatedSegment.lines[2]
if !lastLine.isTruncated, let lineRange = lastLine.range, let lineFont = attributedString.attribute(.font, at: lineRange.lowerBound, effectiveRange: nil) as? UIFont {
var truncationTokenAttributes: [NSAttributedString.Key : AnyObject] = [:]
truncationTokenAttributes[NSAttributedString.Key.font] = lineFont
truncationTokenAttributes[NSAttributedString.Key(rawValue: kCTForegroundColorFromContextAttributeName as String)] = true as NSNumber
let tokenString = "\u{2026}"
let truncatedTokenString = NSAttributedString(string: tokenString, attributes: truncationTokenAttributes)
let truncationToken = CTLineCreateWithAttributedString(truncatedTokenString)
var truncationTokenAscent: CGFloat = 0.0
var truncationTokenDescent: CGFloat = 0.0
let truncationTokenWidth = CTLineGetTypographicBounds(truncationToken, &truncationTokenAscent, &truncationTokenDescent, nil)
if let updatedLine = CTLineCreateTruncatedLine(lastLine.line, max(0.0, lastLine.constrainedWidth - truncationTokenWidth), .end, nil) {
var lineAscent: CGFloat = 0.0
var lineDescent: CGFloat = 0.0
var lineWidth = CTLineGetTypographicBounds(updatedLine, &lineAscent, &lineDescent, nil)
lineWidth = min(lineWidth, lastLine.constrainedWidth)
calculatedSegment.lines[2] = InteractiveTextNodeLine(
line: updatedLine,
constrainedWidth: lastLine.constrainedWidth,
frame: CGRect(origin: lastLine.frame.origin, size: CGSize(width: lineWidth, height: lineAscent + lineDescent)),
intrinsicWidth: lineWidth,
ascent: lineAscent,
descent: lineDescent,
range: lastLine.range,
isTruncated: true,
isRTL: lastLine.isRTL,
strikethroughs: [],
underlines: [],
spoilers: [],
spoilerWords: [],
embeddedItems: [],
attachments: [],
additionalTrailingLine: (truncationToken, 0.0)
)
}
}
}
calculatedSegments.append(calculatedSegment)
}
if remainingLines <= 0, let lastSegment = calculatedSegments.last, let lastLine = lastSegment.lines.last, !lastLine.isTruncated, let lineRange = lastLine.range, let lineFont = attributedString.attribute(.font, at: lineRange.lowerBound, effectiveRange: nil) as? UIFont {
if let range = lastLine.range, range.upperBound != attributedString.length {
let truncatedTokenString: NSAttributedString
if let customTruncationTokenValue = customTruncationToken?(lineFont, lastSegment.blockQuote != nil) {
if lineRange.length == 0 && customTruncationTokenValue.string.hasPrefix("\u{2026} ") {
truncatedTokenString = customTruncationTokenValue.attributedSubstring(from: NSRange(location: 2, length: customTruncationTokenValue.length - 2))
} else {
truncatedTokenString = customTruncationTokenValue
}
} else {
var truncationTokenAttributes: [NSAttributedString.Key : AnyObject] = [:]
truncationTokenAttributes[NSAttributedString.Key.font] = lineFont
truncationTokenAttributes[NSAttributedString.Key(rawValue: kCTForegroundColorFromContextAttributeName as String)] = true as NSNumber
let tokenString = "\u{2026}"
truncatedTokenString = NSAttributedString(string: tokenString, attributes: truncationTokenAttributes)
}
let truncationToken = CTLineCreateWithAttributedString(truncatedTokenString)
var truncationTokenAscent: CGFloat = 0.0
var truncationTokenDescent: CGFloat = 0.0
let truncationTokenWidth = CTLineGetTypographicBounds(truncationToken, &truncationTokenAscent, &truncationTokenDescent, nil)
if let updatedLine = CTLineCreateTruncatedLine(lastLine.line, max(0.0, lastLine.constrainedWidth - truncationTokenWidth), .end, nil) {
var lineAscent: CGFloat = 0.0
var lineDescent: CGFloat = 0.0
var lineWidth = CTLineGetTypographicBounds(updatedLine, &lineAscent, &lineDescent, nil)
lineWidth = min(lineWidth, lastLine.constrainedWidth)
lastSegment.lines[lastSegment.lines.count - 1] = InteractiveTextNodeLine(
line: updatedLine,
constrainedWidth: lastLine.constrainedWidth,
frame: CGRect(origin: lastLine.frame.origin, size: CGSize(width: lineWidth, height: lineAscent + lineDescent)),
intrinsicWidth: lineWidth,
ascent: lineAscent,
descent: lineDescent,
range: lastLine.range,
isTruncated: true,
isRTL: lastLine.isRTL,
strikethroughs: [],
underlines: [],
spoilers: [],
spoilerWords: [],
embeddedItems: [],
attachments: [],
additionalTrailingLine: (truncationToken, truncationTokenWidth)
)
}
}
}
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 {
var additionalTrailingWidth: CGFloat = 0.0
if let additionalTrailingLine = line.additionalTrailingLine {
additionalTrailingWidth += CTLineGetTypographicBounds(additionalTrailingLine.0, nil, nil, nil)
}
size.width = max(size.width, line.frame.origin.x + line.frame.width + segment.additionalWidth + additionalTrailingWidth)
}
}
var segments: [InteractiveTextNodeSegment] = []
var firstLineOffset: CGFloat?
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
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)
}
let blockIndex = segment.id
var isCollapsed = false
if let blockIndex, let blockQuote = segment.blockQuote {
if blockQuote.isCollapsible {
isCollapsed = !expandedBlocks.contains(blockIndex)
}
}
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
if i != 0 {
segmentHeight += line.frame.height * lineSpacingFactor
}
if isCollapsed && lineCount > 3 {
} else {
effectiveSegmentHeight += line.frame.height * lineSpacingFactor
}
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)
segmentHeight += line.frame.height
if isCollapsed && lineCount > 3 {
} else {
effectiveSegmentHeight += line.frame.height
visibleLineCount = i + 1
}
var additionalTrailingWidth: CGFloat = 0.0
if let additionalTrailingLine = line.additionalTrailingLine {
additionalTrailingWidth += CTLineGetTypographicBounds(additionalTrailingLine.0, nil, nil, nil)
}
blockWidth = max(blockWidth, line.frame.origin.x + line.frame.width + additionalTrailingWidth)
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)))
} else if let _ = attributes[NSAttributedString.Key.underlineStyle] {
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.underlines.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)
var isHiddenBySpoiler = attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] != nil || attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] != nil
if displayContentsUnderSpoilers {
isHiddenBySpoiler = false
}
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
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, let firstLine = segment.lines.first, let lastLine = segment.lines.last {
segmentBlockQuote = InteractiveTextNodeBlockQuote(
id: blockIndex,
frame: CGRect(
origin: CGPoint(x: 0.0, y: blockMinY - floor(firstLine.frame.height * 0.2)),
size: CGSize(width: blockWidth, height: blockMaxY - blockMinY + floor(firstLine.frame.height * 0.2) + floor(lastLine.frame.height * 0.15))
),
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,
expandedBlocks: expandedBlocks
)
}
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: ((UIFont, Bool) -> NSAttributedString?)?, expandedBlocks: Set<Int>) -> 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, expandedBlocks: expandedBlocks)
}
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, expandedBlocks: expandedBlocks)
}
private func updateContentItems(arguments: ApplyArguments) {
guard let cachedLayout = self.cachedLayout else {
return
}
let animateContents = self.isDisplayingContentsUnderSpoilers != nil && self.isDisplayingContentsUnderSpoilers != cachedLayout.displayContentsUnderSpoilers && arguments.animation.isAnimated
let synchronous = animateContents
self.isDisplayingContentsUnderSpoilers = cachedLayout.displayContentsUnderSpoilers
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 let additionalTrailingLine = line.additionalTrailingLine {
lineRect.size.width += CTLineGetTypographicBounds(additionalTrailingLine.0, nil, nil, nil)
}
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 = arguments.animation
let contentItemLayer: TextContentItemLayer
var itemSpoilerExpandRect: CGRect?
var itemAnimateContents = animateContents && contentItemAnimation.isAnimated
if let current = self.contentItemLayers[itemId] {
contentItemLayer = current
if arguments.animation.isAnimated, let spoilerExpandRect = arguments.spoilerExpandRect {
itemSpoilerExpandRect = spoilerExpandRect.offsetBy(dx: -contentItemFrame.minX, dy: -contentItemFrame.minY)
itemAnimateContents = true
}
} else {
contentItemAnimation = .None
contentItemLayer = TextContentItemLayer()
self.contentItemLayers[contentItem.id] = contentItemLayer
self.layer.addSublayer(contentItemLayer)
}
contentItemLayer.update(
params: TextContentItemLayer.Params(
item: contentItem,
spoilerTextColor: arguments.spoilerTextColor,
spoilerEffectColor: arguments.spoilerEffectColor,
areContentAnimationsEnabled: arguments.areContentAnimationsEnabled
),
animation: contentItemAnimation,
synchronously: synchronous,
animateContents: itemAnimateContents,
spoilerExpandRect: itemSpoilerExpandRect
)
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)
tapRecognizer.delegate = self
}
} else if let tapRecognizer = self.tapRecognizer {
self.tapRecognizer = nil
self.view.removeGestureRecognizer(tapRecognizer)
}
}
public override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
@objc private func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
let point = recognizer.location(in: self.view)
if let cachedLayout = self.cachedLayout, !cachedLayout.displayContentsUnderSpoilers, let (_, attributes) = self.attributesAtPoint(point) {
if attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] != nil || attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] != nil {
self.requestDisplayContentsUnderSpoilers?(point)
return
}
}
if let blockId = self.collapsibleBlockAtPoint(point) {
self.requestToggleBlockCollapsed?(blockId)
}
}
}
public static func asyncLayout(_ maybeNode: InteractiveTextNode?) -> (InteractiveTextNodeLayoutArguments) -> (InteractiveTextNodeLayout, (ApplyArguments) -> InteractiveTextNode) {
let existingLayout: InteractiveTextNodeLayout? = maybeNode?.cachedLayout
return { arguments in
var 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.expandedBlocks == arguments.expandedBlocks {
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
if layout.displayContentsUnderSpoilers != arguments.displayContentsUnderSpoilers {
layout = layout.withUpdatedDisplayContentsUnderSpoilers(arguments.displayContentsUnderSpoilers)
}
} 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, expandedBlocks: arguments.expandedBlocks)
}
} 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, expandedBlocks: arguments.expandedBlocks)
}
let node = maybeNode ?? InteractiveTextNode()
return (layout, { arguments in
if node.cachedLayout !== layout {
node.cachedLayout = layout
node.updateContentItems(arguments: arguments)
}
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
}
}
private let drawUnderlinesManually: Bool = {
if #available(iOS 18.0, *) {
return true
} else {
return false
}
}()
final class TextContentItemLayer: SimpleLayer {
final class Params {
let item: TextContentItem
let spoilerTextColor: UIColor
let spoilerEffectColor: UIColor
let areContentAnimationsEnabled: Bool
init(
item: TextContentItem,
spoilerTextColor: UIColor,
spoilerEffectColor: UIColor,
areContentAnimationsEnabled: Bool
) {
self.item = item
self.spoilerTextColor = spoilerTextColor
self.spoilerEffectColor = spoilerEffectColor
self.areContentAnimationsEnabled = areContentAnimationsEnabled
}
}
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 drawUnderlinesManually {
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.midY, width: frame.width, height: 1.0))
}
}
if !line.underlines.isEmpty {
for strikethrough in line.underlines {
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.maxY - 2.0, width: frame.width, height: 1.0))
}
}
}
if let (additionalTrailingLine, _) = line.additionalTrailingLine {
context.textPosition = CGPoint(x: lineFrame.minX + line.intrinsicWidth, y: lineFrame.maxY - line.descent)
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 params: Params?
let renderNode: RenderNode
private var contentMaskNode: ASImageNode?
private var overlayContentLayer: SimpleLayer?
private var overlayContentMaskNode: ASImageNode?
private var spoilerEffectNode: InvisibleInkDustNode?
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(
params: Params,
animation: ListViewItemUpdateAnimation,
synchronously: Bool,
animateContents: Bool,
spoilerExpandRect: CGRect?
) {
self.params = params
let contentFrame = CGRect(origin: CGPoint(), size: params.item.size)
var effectiveContentFrame = contentFrame
var contentMask: RenderMask?
if let blockQuote = params.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: params.item.contentOffset.x, dy: params.item.contentOffset.y)
if animation.isAnimated {
if blockBackgroundFrame != blockBackgroundView.layer.frame {
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 params = self.params else {
return
}
self.isAnimating = false
self.update(
params: params,
animation: .None,
synchronously: true,
animateContents: false,
spoilerExpandRect: nil
)
})
}
} 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: contentFrame.minY - blockBackgroundFrame.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
contentMaskNode.layer.removeFromSuperlayer()
}
self.renderNode.layer.mask = nil
}
if !params.item.segment.spoilers.isEmpty {
let spoilerEffectNode: InvisibleInkDustNode
if let current = self.spoilerEffectNode {
spoilerEffectNode = current
} else {
spoilerEffectNode = InvisibleInkDustNode(textNode: nil, enableAnimations: params.areContentAnimationsEnabled)
self.spoilerEffectNode = spoilerEffectNode
}
spoilerEffectNode.frame = contentFrame
spoilerEffectNode.update(
size: contentFrame.size,
color: params.spoilerEffectColor,
textColor: params.spoilerTextColor,
rects: params.item.segment.spoilers.map { $0.1.offsetBy(dx: 0.0 + params.item.contentOffset.x, dy: params.item.contentOffset.y + 0.0).insetBy(dx: 1.0, dy: 1.0) },
wordRects: params.item.segment.spoilerWords.map { $0.1.offsetBy(dx: params.item.contentOffset.x + 0.0, dy: params.item.contentOffset.y + 0.0).insetBy(dx: 1.0, dy: 1.0) }
)
} else {
if let spoilerEffectNode = self.spoilerEffectNode {
self.spoilerEffectNode = nil
spoilerEffectNode.layer.removeFromSuperlayer()
}
}
if self.spoilerEffectNode != nil {
let overlayContentLayer: SimpleLayer
if let current = self.overlayContentLayer {
overlayContentLayer = current
animation.animator.updateFrame(layer: overlayContentLayer, frame: effectiveContentFrame, completion: nil)
} else {
overlayContentLayer = SimpleLayer()
self.overlayContentLayer = overlayContentLayer
overlayContentLayer.masksToBounds = true
self.addSublayer(overlayContentLayer)
overlayContentLayer.frame = effectiveContentFrame
}
if let contentMask {
var overlayContentMaskAnimation = animation
let overlayContentMaskNode: ASImageNode
if let current = self.overlayContentMaskNode {
overlayContentMaskNode = current
} else {
overlayContentMaskNode = ASImageNode()
overlayContentMaskNode.isLayerBacked = true
overlayContentMaskNode.backgroundColor = .clear
self.overlayContentMaskNode = overlayContentMaskNode
overlayContentLayer.mask = overlayContentMaskNode.layer
if let currentContentMask = self.currentContentMask {
overlayContentMaskNode.frame = currentContentMask.frame
} else {
overlayContentMaskAnimation = .None
}
overlayContentMaskNode.image = contentMask.image
}
overlayContentMaskAnimation.animator.updateBackgroundColor(layer: overlayContentMaskNode.layer, color: contentMask.isOpaque ? UIColor.white : UIColor.clear, completion: nil)
overlayContentMaskAnimation.animator.updateFrame(layer: overlayContentMaskNode.layer, frame: contentMask.frame, completion: nil)
} else {
if let _ = self.overlayContentMaskNode {
self.overlayContentMaskNode = nil
overlayContentLayer.mask = nil
}
}
if let spoilerEffectNode = self.spoilerEffectNode {
if spoilerEffectNode.layer.superlayer !== overlayContentLayer {
overlayContentLayer.addSublayer(spoilerEffectNode.layer)
}
}
} else {
if let overlayContentLayer = self.overlayContentLayer {
self.overlayContentLayer = nil
overlayContentLayer.removeFromSuperlayer()
}
}
self.currentContentMask = contentMask
self.renderNode.params = RenderParams(size: contentFrame.size, item: params.item, mask: staticContentMask)
if synchronously {
if let spoilerExpandRect, animation.isAnimated {
let localSpoilerExpandRect = spoilerExpandRect.offsetBy(dx: -self.renderNode.frame.minX, dy: -self.renderNode.frame.minY)
let revealAnimationDuration: CGFloat = 0.55
let revealTransition: ContainedViewLayoutTransition = .animated(duration: revealAnimationDuration, curve: .easeInOut)
let previousContents = self.renderNode.layer.contents
let copyContentsLayer = SimpleLayer()
copyContentsLayer.frame = self.renderNode.frame
copyContentsLayer.contents = previousContents
copyContentsLayer.masksToBounds = self.renderNode.layer.masksToBounds
copyContentsLayer.contentsGravity = self.renderNode.layer.contentsGravity
copyContentsLayer.contentsScale = self.renderNode.layer.contentsScale
for sublayer in self.renderNode.layer.sublayers ?? [] {
let copySublayer = SimpleLayer()
copySublayer.contentsScale = sublayer.contentsScale
copySublayer.position = sublayer.position
copySublayer.bounds = sublayer.bounds
copySublayer.transform = sublayer.transform
copySublayer.opacity = sublayer.opacity
copySublayer.isHidden = sublayer.isHidden
if let sublayer = sublayer as? InlineStickerItemLayer {
sublayer.mirrorLayer = copySublayer
} else {
copySublayer.contents = sublayer.contents
}
copyContentsLayer.addSublayer(copySublayer)
}
self.renderNode.layer.superlayer?.insertSublayer(copyContentsLayer, below: self.renderNode.layer)
self.renderNode.displayImmediately()
let rectangularExpandedSide = max(localSpoilerExpandRect.width, localSpoilerExpandRect.height)
// The gradient starts at 0.7
let adjustedExpandedSide = ceil(rectangularExpandedSide * 1.3)
let rectangularExpandedRect = CGSize(width: adjustedExpandedSide, height: adjustedExpandedSide).centered(around: spoilerExpandRect.center)
let maskFrame = self.renderNode.bounds
let maskLayer = SimpleLayer()
maskLayer.masksToBounds = true
self.renderNode.layer.mask = maskLayer
maskLayer.frame = maskFrame
animateRadialExpansionMask(maskLayer: maskLayer, expandedRect: rectangularExpandedRect, transition: revealTransition, inverse: false, completion: { [weak self] in
guard let self, let params = self.params else {
return
}
self.renderNode.layer.mask = nil
self.update(
params: params,
animation: .None,
synchronously: true,
animateContents: false,
spoilerExpandRect: nil
)
})
let copyMaskLayer = SimpleLayer()
copyMaskLayer.masksToBounds = true
copyContentsLayer.mask = copyMaskLayer
copyMaskLayer.frame = maskFrame
animateRadialExpansionMask(maskLayer: copyMaskLayer, expandedRect: rectangularExpandedRect, transition: revealTransition, inverse: true, completion: { [weak copyContentsLayer] in
copyContentsLayer?.removeFromSuperlayer()
})
if let spoilerEffectNode = self.spoilerEffectNode {
let spoilerMaskLayer = SimpleLayer()
spoilerMaskLayer.masksToBounds = true
spoilerEffectNode.layer.mask = spoilerMaskLayer
spoilerMaskLayer.frame = maskFrame
let spoilerLocalPosition = self.convert(rectangularExpandedRect.center, to: spoilerEffectNode.layer)
spoilerEffectNode.revealWithoutMaskAtLocation(spoilerLocalPosition)
animateRadialExpansionMask(maskLayer: spoilerMaskLayer, expandedRect: rectangularExpandedRect, transition: revealTransition, inverse: true, completion: { [weak self] in
guard let self, let spoilerEffectNode = self.spoilerEffectNode else {
return
}
spoilerEffectNode.layer.mask = nil
spoilerEffectNode.layer.opacity = 0.0
})
}
} else {
let previousContents = self.renderNode.layer.contents
self.renderNode.displayImmediately()
if animateContents, let previousContents {
animation.transition.animateContents(layer: self.renderNode.layer, from: previousContents)
}
if let spoilerEffectNode = self.spoilerEffectNode {
animation.transition.updateAlpha(layer: spoilerEffectNode.layer, alpha: params.item.displayContentsUnderSpoilers ? 0.0 : 1.0)
}
}
} else {
self.renderNode.setNeedsDisplay()
if let spoilerEffectNode = self.spoilerEffectNode {
animation.transition.updateAlpha(layer: spoilerEffectNode.layer, alpha: params.item.displayContentsUnderSpoilers ? 0.0 : 1.0)
spoilerEffectNode.update(revealed: params.item.displayContentsUnderSpoilers, animated: animation.isAnimated)
}
}
}
}
private func animateRadialExpansionMask(maskLayer: CALayer, expandedRect: CGRect, transition: ContainedViewLayoutTransition, inverse: Bool, completion: @escaping () -> Void) {
let maskGradientLayer = SimpleGradientLayer()
maskLayer.addSublayer(maskGradientLayer)
maskGradientLayer.frame = expandedRect
setupSpoilerExpansionMaskGradient(
gradientLayer: maskGradientLayer,
centerLocation: CGPoint(
x: 0.5,
y: 0.5
),
radius: CGSize(
width: 0.5,
height: 0.5
),
inverse: inverse
)
let minGradientFrame = CGSize(width: 1.0, height: 1.0).centered(around: expandedRect.center)
transition.animateFrame(layer: maskGradientLayer, from: minGradientFrame, delay: 0.1, completion: { _ in
completion()
})
if inverse {
let outerBoundsSourceRect = minGradientFrame.insetBy(dx: 0.5, dy: 0.5)
let outerBoundsDestinationRect = expandedRect.insetBy(dx: 0.5, dy: 0.5)
for sideIndex in 0 ..< 4 {
let copyMaskOuterBoundsTopLayer = SimpleLayer()
copyMaskOuterBoundsTopLayer.backgroundColor = UIColor.white.cgColor
maskLayer.addSublayer(copyMaskOuterBoundsTopLayer)
let sourceFrame: CGRect
let destinationFrame: CGRect
// Top, left, bottom, right
if sideIndex == 0 {
sourceFrame = CGRect(origin: CGPoint(x: 0.0, y: outerBoundsSourceRect.minY - expandedRect.height), size: expandedRect.size)
destinationFrame = CGRect(origin: CGPoint(x: 0.0, y: outerBoundsDestinationRect.minY - expandedRect.height), size: expandedRect.size)
} else if sideIndex == 1 {
sourceFrame = CGRect(origin: CGPoint(x: outerBoundsSourceRect.minX - expandedRect.width, y: 0.0), size: expandedRect.size)
destinationFrame = CGRect(origin: CGPoint(x: outerBoundsDestinationRect.minX - expandedRect.width, y: 0.0), size: expandedRect.size)
} else if sideIndex == 2 {
sourceFrame = CGRect(origin: CGPoint(x: 0.0, y: outerBoundsSourceRect.maxY), size: expandedRect.size)
destinationFrame = CGRect(origin: CGPoint(x: 0.0, y: outerBoundsDestinationRect.maxY), size: expandedRect.size)
} else {
sourceFrame = CGRect(origin: CGPoint(x: outerBoundsSourceRect.maxX, y: 0.0), size: expandedRect.size)
destinationFrame = CGRect(origin: CGPoint(x: outerBoundsDestinationRect.maxX, y: 0.0), size: expandedRect.size)
}
copyMaskOuterBoundsTopLayer.frame = destinationFrame
transition.animateFrame(layer: copyMaskOuterBoundsTopLayer, from: sourceFrame, delay: 0.1)
}
}
}
private func setupSpoilerExpansionMaskGradient(gradientLayer: SimpleGradientLayer, centerLocation: CGPoint, radius: CGSize, inverse: Bool) {
let startAlpha: CGFloat = inverse ? 0.0 : 1.0
let endAlpha: CGFloat = inverse ? 1.0 : 0.0
let locations: [CGFloat] = [0.0, 0.7, 0.95, 1.0]
let colors: [CGColor] = [
UIColor(rgb: 0xff0000, alpha: startAlpha).cgColor,
UIColor(rgb: 0xff0000, alpha: startAlpha).cgColor,
UIColor(rgb: 0xff0000, alpha: endAlpha).cgColor,
UIColor(rgb: 0xff0000, alpha: endAlpha).cgColor
]
gradientLayer.type = .radial
gradientLayer.colors = colors
gradientLayer.locations = locations.map { $0 as NSNumber }
gradientLayer.startPoint = centerLocation
let endEndPoint = CGPoint(x: (gradientLayer.startPoint.x + radius.width) * 1.0, y: (gradientLayer.startPoint.y + radius.height) * 1.0)
gradientLayer.endPoint = endEndPoint
}