mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2026-05-09 13:58:59 +00:00
799 lines
40 KiB
Swift
799 lines
40 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import SwiftSignalKit
|
|
import Display
|
|
import TelegramPresentationData
|
|
import ComponentFlow
|
|
import AccountContext
|
|
import MultilineTextComponent
|
|
import BundleIconComponent
|
|
import TelegramCore
|
|
import TextFormat
|
|
import PlainButtonComponent
|
|
import CheckComponent
|
|
import ShimmerEffect
|
|
import TextSelectionNode
|
|
import Pasteboard
|
|
import Speak
|
|
import InteractiveTextComponent
|
|
|
|
final class TextProcessingTextAreaComponent: Component {
|
|
let context: AccountContext
|
|
let theme: PresentationTheme
|
|
let strings: PresentationStrings
|
|
let titlePrefix: String
|
|
let title: String
|
|
let titleAction: ((UIView) -> Void)?
|
|
let isExpanded: (value: Bool, toggle: () -> Void)?
|
|
let copyAction: (() -> Void)?
|
|
let emojify: (value: Bool, toggle: () -> Void)?
|
|
let text: TextWithEntities?
|
|
let loadingStateMeasuringText: String?
|
|
let textCorrectionRanges: [Range<Int>]
|
|
let present: (ViewController, Any?) -> Void
|
|
let rootViewForTextSelection: () -> UIView?
|
|
|
|
init(
|
|
context: AccountContext,
|
|
theme: PresentationTheme,
|
|
strings: PresentationStrings,
|
|
titlePrefix: String,
|
|
title: String,
|
|
titleAction: ((UIView) -> Void)?,
|
|
isExpanded: (value: Bool, toggle: () -> Void)?,
|
|
copyAction: (() -> Void)?,
|
|
emojify: (value: Bool, toggle: () -> Void)?,
|
|
text: TextWithEntities?,
|
|
loadingStateMeasuringText: String?,
|
|
textCorrectionRanges: [Range<Int>],
|
|
present: @escaping (ViewController, Any?) -> Void,
|
|
rootViewForTextSelection: @escaping () -> UIView?
|
|
) {
|
|
self.context = context
|
|
self.theme = theme
|
|
self.strings = strings
|
|
self.titlePrefix = titlePrefix
|
|
self.isExpanded = isExpanded
|
|
self.copyAction = copyAction
|
|
self.title = title
|
|
self.titleAction = titleAction
|
|
self.emojify = emojify
|
|
self.text = text
|
|
self.loadingStateMeasuringText = loadingStateMeasuringText
|
|
self.textCorrectionRanges = textCorrectionRanges
|
|
self.present = present
|
|
self.rootViewForTextSelection = rootViewForTextSelection
|
|
}
|
|
|
|
static func ==(lhs: TextProcessingTextAreaComponent, rhs: TextProcessingTextAreaComponent) -> Bool {
|
|
if lhs.context !== rhs.context {
|
|
return false
|
|
}
|
|
if lhs.theme !== rhs.theme {
|
|
return false
|
|
}
|
|
if lhs.strings !== rhs.strings {
|
|
return false
|
|
}
|
|
if lhs.titlePrefix != rhs.titlePrefix {
|
|
return false
|
|
}
|
|
if lhs.title != rhs.title {
|
|
return false
|
|
}
|
|
if (lhs.titleAction == nil) != (rhs.titleAction == nil) {
|
|
return false
|
|
}
|
|
if lhs.isExpanded?.value != rhs.isExpanded?.value {
|
|
return false
|
|
}
|
|
if (lhs.copyAction == nil) != (rhs.copyAction == nil) {
|
|
return false
|
|
}
|
|
if lhs.emojify?.value != rhs.emojify?.value {
|
|
return false
|
|
}
|
|
if lhs.text != rhs.text {
|
|
return false
|
|
}
|
|
if lhs.loadingStateMeasuringText != rhs.loadingStateMeasuringText {
|
|
return false
|
|
}
|
|
if lhs.textCorrectionRanges != rhs.textCorrectionRanges {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
final class View: UIView {
|
|
private var component: TextProcessingTextAreaComponent?
|
|
private weak var state: EmptyComponentState?
|
|
private var isUpdating: Bool = false
|
|
|
|
private let titlePrefix = ComponentView<Empty>()
|
|
private let title = ComponentView<Empty>()
|
|
private var titleArrow: ComponentView<Empty>?
|
|
private var emojify: ComponentView<Empty>?
|
|
private let titleButton: HighlightTrackingButton
|
|
|
|
private let textState = InteractiveTextComponent.External()
|
|
private let textContainer: UIView
|
|
private let text = ComponentView<Empty>()
|
|
private var expandShadow: UIImageView?
|
|
private var expandButton: ComponentView<Empty>?
|
|
|
|
private let copyButton = ComponentView<Empty>()
|
|
|
|
private var previousText: TextWithEntities?
|
|
private var previousTextLineCount: Int?
|
|
private let measureLoadingTextState = InteractiveTextComponent.External()
|
|
private let measureLoadingText = ComponentView<Empty>()
|
|
private var shimmerEffectNode: ShimmerEffectNode?
|
|
|
|
private var textSelectionNode: TextSelectionNode?
|
|
private let textSelectionContainer: UIView
|
|
private let textSelectionKnobContainer: UIView
|
|
private let textSelectionKnobSurface: UIView
|
|
|
|
private var expandedBlockIds: Set<Int> = Set()
|
|
private var appliedExpandedBlockIds: Set<Int>?
|
|
private var displayContentsUnderSpoilers: (value: Bool, location: CGPoint?) = (false, nil)
|
|
|
|
private var currentSpeechHolder: SpeechSynthesizerHolder?
|
|
|
|
override init(frame: CGRect) {
|
|
self.textContainer = UIView()
|
|
self.textContainer.clipsToBounds = true
|
|
|
|
self.textSelectionContainer = UIView()
|
|
self.textSelectionContainer.isUserInteractionEnabled = false
|
|
self.textSelectionKnobContainer = UIView()
|
|
self.textSelectionKnobSurface = UIView()
|
|
self.textSelectionKnobContainer.addSubview(self.textSelectionKnobSurface)
|
|
|
|
self.titleButton = HighlightTrackingButton()
|
|
|
|
super.init(frame: frame)
|
|
|
|
self.addSubview(self.textContainer)
|
|
self.addSubview(self.textSelectionContainer)
|
|
self.addSubview(self.titleButton)
|
|
|
|
self.titleButton.highligthedChanged = { [weak self] highighed in
|
|
guard let self, let titleView = self.title.view, let titleArrowView = self.titleArrow?.view else {
|
|
return
|
|
}
|
|
if highighed {
|
|
titleView.alpha = 0.6
|
|
titleArrowView.alpha = 0.6
|
|
} else {
|
|
let transition: ComponentTransition = .easeInOut(duration: 0.25)
|
|
transition.setAlpha(view: titleView, alpha: 1.0)
|
|
transition.setAlpha(view: titleArrowView, alpha: 1.0)
|
|
}
|
|
}
|
|
self.titleButton.addTarget(self, action: #selector(self.titleButtonPressed), for: .touchUpInside)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
preconditionFailure()
|
|
}
|
|
|
|
@objc private func titleButtonPressed() {
|
|
guard let component = self.component, let titleView = self.title.view else {
|
|
return
|
|
}
|
|
component.titleAction?(titleView)
|
|
}
|
|
|
|
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
guard let result = super.hitTest(point, with: event) else {
|
|
return nil
|
|
}
|
|
if result == self.textSelectionNode?.view && !self.displayContentsUnderSpoilers.value {
|
|
if let textView = self.text.view as? InteractiveTextComponent.View {
|
|
let textPoint = self.convert(point, to: textView.textNode.view)
|
|
if let attributes = textView.textNode.attributesAtPoint(textPoint, orNearest: false)?.1 {
|
|
if attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] != nil || attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] != nil {
|
|
if let value = textView.textNode.view.hitTest(textPoint, with: event) {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func update(component: TextProcessingTextAreaComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
|
self.isUpdating = true
|
|
defer {
|
|
self.isUpdating = false
|
|
}
|
|
|
|
let alphaTransition: ComponentTransition = transition.animation.isImmediate ? .immediate : .easeInOut(duration: 0.2)
|
|
|
|
self.component = component
|
|
self.state = state
|
|
|
|
self.titleButton.isUserInteractionEnabled = component.titleAction != nil
|
|
|
|
let topInset: CGFloat = 0.0
|
|
let bottomInset: CGFloat = 0.0
|
|
let sideInset: CGFloat = 0.0
|
|
|
|
var contentHeight: CGFloat = 0.0
|
|
contentHeight += topInset
|
|
|
|
let titlePrefixSize = self.titlePrefix.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(MultilineTextComponent(
|
|
text: .plain(NSAttributedString(string: component.titlePrefix, font: Font.semibold(13.0), textColor: component.theme.list.itemSecondaryTextColor))
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - 10.0, height: 100.0)
|
|
)
|
|
let titleSize = self.title.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(MultilineTextComponent(
|
|
text: .plain(NSAttributedString(string: component.title, font: Font.semibold(13.0), textColor: component.theme.list.itemAccentColor))
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - 10.0, height: 100.0)
|
|
)
|
|
|
|
let titlePrefixFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: titlePrefixSize)
|
|
var titleFrame = CGRect(origin: CGPoint(x: titlePrefixFrame.maxX, y: titlePrefixFrame.minY), size: titleSize)
|
|
if !component.titlePrefix.isEmpty {
|
|
titleFrame.origin.x += 3.0
|
|
}
|
|
|
|
transition.setFrame(view: self.titleButton, frame: titleFrame.insetBy(dx: -10.0, dy: -10.0))
|
|
|
|
if let titlePrefixView = self.titlePrefix.view {
|
|
if titlePrefixView.superview == nil {
|
|
titlePrefixView.layer.anchorPoint = CGPoint()
|
|
titlePrefixView.isUserInteractionEnabled = false
|
|
self.addSubview(titlePrefixView)
|
|
}
|
|
titlePrefixView.bounds = CGRect(origin: CGPoint(), size: titlePrefixFrame.size)
|
|
transition.setPosition(view: titlePrefixView, position: titlePrefixFrame.origin)
|
|
}
|
|
if let titleView = self.title.view {
|
|
if titleView.superview == nil {
|
|
titleView.layer.anchorPoint = CGPoint()
|
|
titleView.isUserInteractionEnabled = false
|
|
self.addSubview(titleView)
|
|
}
|
|
titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size)
|
|
transition.setPosition(view: titleView, position: titleFrame.origin)
|
|
}
|
|
|
|
if component.titleAction != nil {
|
|
let titleArrow: ComponentView<Empty>
|
|
var titleArrowTransition = transition
|
|
if let current = self.titleArrow {
|
|
titleArrow = current
|
|
} else {
|
|
titleArrowTransition = titleArrowTransition.withAnimation(.none)
|
|
titleArrow = ComponentView()
|
|
self.titleArrow = titleArrow
|
|
}
|
|
let titleArrowSize = titleArrow.update(
|
|
transition: titleArrowTransition,
|
|
component: AnyComponent(BundleIconComponent(
|
|
name: "Item List/ContextDisclosureArrow",
|
|
tintColor: component.theme.list.itemAccentColor.withMultipliedAlpha(0.8),
|
|
maxSize: CGSize(width: 8.0, height: 11.0)
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: 100.0, height: 100.0)
|
|
)
|
|
let titleArrowFrame = CGRect(origin: CGPoint(x: titleFrame.maxX + 2.0, y: titleFrame.minY + 1.0 + floorToScreenPixels((titleFrame.height - titleArrowSize.height) * 0.5)), size: titleArrowSize)
|
|
if let titleArrowView = titleArrow.view {
|
|
if titleArrowView.superview == nil {
|
|
titleArrowView.isUserInteractionEnabled = false
|
|
self.addSubview(titleArrowView)
|
|
}
|
|
transition.setFrame(view: titleArrowView, frame: titleArrowFrame)
|
|
}
|
|
} else {
|
|
if let titleArrow = self.titleArrow {
|
|
self.titleArrow = nil
|
|
titleArrow.view?.removeFromSuperview()
|
|
}
|
|
}
|
|
|
|
if let emojifyValue = component.emojify {
|
|
let emojify: ComponentView<Empty>
|
|
var emojifyTransition = transition
|
|
if let current = self.emojify {
|
|
emojify = current
|
|
} else {
|
|
emojify = ComponentView()
|
|
self.emojify = emojify
|
|
emojifyTransition = emojifyTransition.withAnimation(.none)
|
|
}
|
|
let checkTheme = CheckComponent.Theme(
|
|
backgroundColor: component.theme.list.itemCheckColors.fillColor,
|
|
strokeColor: component.theme.list.itemCheckColors.foregroundColor,
|
|
borderColor: component.theme.list.itemCheckColors.strokeColor,
|
|
overlayBorder: false,
|
|
hasInset: false,
|
|
hasShadow: false
|
|
)
|
|
let emojifySize = emojify.update(
|
|
transition: emojifyTransition,
|
|
component: AnyComponent(PlainButtonComponent(
|
|
content: AnyComponent(HStack([
|
|
AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(CheckComponent(
|
|
theme: checkTheme,
|
|
size: CGSize(width: 16.0, height: 16.0),
|
|
selected: emojifyValue.value
|
|
))),
|
|
AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(MultilineTextComponent(
|
|
text: .plain(NSAttributedString(string: "Emojify", font: Font.semibold(13.0), textColor: component.theme.list.itemSecondaryTextColor))
|
|
)))
|
|
], spacing: 7.0)),
|
|
effectAlignment: .center,
|
|
action: {
|
|
emojifyValue.toggle()
|
|
},
|
|
animateAlpha: false,
|
|
animateScale: false
|
|
)),
|
|
environment: {
|
|
},
|
|
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0)
|
|
)
|
|
let emojifyFrame = CGRect(origin: CGPoint(x: availableSize.width - sideInset - emojifySize.width, y: contentHeight), size: emojifySize)
|
|
if let emojifyView = emojify.view {
|
|
if emojifyView.superview == nil {
|
|
self.addSubview(emojifyView)
|
|
}
|
|
emojifyTransition.setFrame(view: emojifyView, frame: emojifyFrame)
|
|
}
|
|
} else {
|
|
if let emojify = self.emojify {
|
|
self.emojify = nil
|
|
emojify.view?.removeFromSuperview()
|
|
}
|
|
}
|
|
|
|
contentHeight += 25.0
|
|
|
|
let fontSize: CGFloat = 17.0
|
|
let textValue = NSMutableAttributedString(attributedString: stringWithAppliedEntities(
|
|
component.text?.text ?? self.previousText?.text ?? "",
|
|
entities: component.text?.entities ?? self.previousText?.entities ?? [],
|
|
baseColor: component.theme.list.itemPrimaryTextColor,
|
|
linkColor: component.theme.list.itemAccentColor,
|
|
baseFont: Font.regular(fontSize),
|
|
linkFont: Font.regular(fontSize),
|
|
boldFont: Font.semibold(fontSize),
|
|
italicFont: Font.italic(fontSize),
|
|
boldItalicFont: Font.semiboldItalic(fontSize),
|
|
fixedFont: Font.monospace(fontSize),
|
|
blockQuoteFont: Font.monospace(fontSize),
|
|
message: nil
|
|
))
|
|
for range in component.textCorrectionRanges {
|
|
if range.lowerBound >= 0 && range.upperBound < textValue.length {
|
|
textValue.addAttributes([
|
|
.underlineColor: component.theme.list.itemAccentColor,
|
|
.underlineStyle: NSUnderlineStyle.patternDot.rawValue
|
|
], range: NSRange(location: range.lowerBound, length: range.upperBound - range.lowerBound))
|
|
}
|
|
}
|
|
|
|
let textOrigin = CGPoint(x: sideInset, y: contentHeight)
|
|
|
|
var spoilerExpandPoint: CGPoint?
|
|
if let location = self.displayContentsUnderSpoilers.location {
|
|
self.displayContentsUnderSpoilers.location = nil
|
|
|
|
spoilerExpandPoint = CGPoint(x: location.x - textOrigin.x, y: location.y - textOrigin.y)
|
|
}
|
|
|
|
let textSize = self.text.update(
|
|
transition: transition,
|
|
component: AnyComponent(InteractiveTextComponent(
|
|
external: self.textState,
|
|
attributedString: textValue,
|
|
backgroundColor: nil,
|
|
minimumNumberOfLines: 1,
|
|
maximumNumberOfLines: 0,
|
|
truncationType: .end,
|
|
alignment: .left,
|
|
verticalAlignment: .top,
|
|
lineSpacing: 0.12,
|
|
cutout: nil,
|
|
insets: UIEdgeInsets(),
|
|
lineColor: nil,
|
|
textShadowColor: nil,
|
|
textShadowBlur: nil,
|
|
textStroke: nil,
|
|
displayContentsUnderSpoilers: self.displayContentsUnderSpoilers.value,
|
|
customTruncationToken: nil,
|
|
expandedBlocks: Set(),
|
|
context: component.context,
|
|
cache: component.context.animationCache,
|
|
renderer: component.context.animationRenderer,
|
|
placeholderColor: component.theme.list.mediaPlaceholderColor,
|
|
attemptSynchronous: true,
|
|
textColor: component.theme.list.itemPrimaryTextColor,
|
|
spoilerEffectColor: component.theme.list.itemPrimaryTextColor,
|
|
spoilerTextColor: component.theme.list.itemPrimaryTextColor,
|
|
areContentAnimationsEnabled: true,
|
|
spoilerExpandPoint: spoilerExpandPoint,
|
|
canHandleTapAtPoint: { _ in
|
|
return true
|
|
},
|
|
requestToggleBlockCollapsed: { [weak self] blockId in
|
|
guard let self else {
|
|
return
|
|
}
|
|
if self.expandedBlockIds.contains(blockId) {
|
|
self.expandedBlockIds.remove(blockId)
|
|
} else {
|
|
self.expandedBlockIds.insert(blockId)
|
|
}
|
|
self.state?.updated(transition: .spring(duration: 0.4))
|
|
},
|
|
requestDisplayContentsUnderSpoilers: { [weak self] location in
|
|
guard let self, let textView = self.text.view as? InteractiveTextComponent.View else {
|
|
return
|
|
}
|
|
|
|
cancelParentGestures(view: self)
|
|
|
|
var mappedLocation: CGPoint?
|
|
if let location {
|
|
mappedLocation = textView.textNode.layer.convert(location, to: self.layer)
|
|
}
|
|
self.displayContentsUnderSpoilers = (true, mappedLocation)
|
|
self.state?.updated(transition: .spring(duration: 0.4))
|
|
}
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: availableSize.height)
|
|
)
|
|
let textFrame = CGRect(origin: textOrigin, size: textSize)
|
|
if let textView = self.text.view {
|
|
if textView.superview == nil {
|
|
textView.layer.anchorPoint = CGPoint()
|
|
self.textContainer.addSubview(textView)
|
|
textView.alpha = 0.0
|
|
}
|
|
textView.bounds = CGRect(origin: CGPoint(), size: textFrame.size)
|
|
alphaTransition.setAlpha(view: textView, alpha: component.text != nil ? 1.0 : 0.0)
|
|
}
|
|
if component.text != nil, let layout = self.textState.layout {
|
|
self.previousText = component.text
|
|
self.previousTextLineCount = layout.numberOfLines
|
|
}
|
|
|
|
var textContainerFrame = textFrame
|
|
if let isExpanded = component.isExpanded, let textLayout = self.textState.layout, textLayout.numberOfLines > 1 {
|
|
if !isExpanded.value, let firstLineRect = textLayout.linesRects().first {
|
|
textContainerFrame.size.height = ceil(firstLineRect.maxY)
|
|
}
|
|
|
|
let expandButton: ComponentView<Empty>
|
|
var expandButtonTransition = transition
|
|
if let current = self.expandButton {
|
|
expandButton = current
|
|
} else {
|
|
expandButtonTransition = expandButtonTransition.withAnimation(.none)
|
|
expandButton = ComponentView()
|
|
self.expandButton = expandButton
|
|
}
|
|
let expandShadow: UIImageView
|
|
if let current = self.expandShadow {
|
|
expandShadow = current
|
|
} else {
|
|
expandShadow = UIImageView()
|
|
self.expandShadow = expandShadow
|
|
self.addSubview(expandShadow)
|
|
}
|
|
let expandShadowExtent: CGFloat = 20.0
|
|
if expandShadow.image == nil {
|
|
let baseSize: CGFloat = 20.0
|
|
expandShadow.image = generateImage(CGSize(width: baseSize + expandShadowExtent * 2.0, height: baseSize + expandShadowExtent * 2.0), rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
|
|
let colors: [CGColor] = [UIColor.white.cgColor, UIColor.white.withAlphaComponent(0.0).cgColor]
|
|
let locations: [CGFloat] = [0.0, 1.0]
|
|
if let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: colors as CFArray, locations: locations) {
|
|
let center = CGPoint(x: size.width / 2.0, y: size.height / 2.0)
|
|
context.drawRadialGradient(gradient, startCenter: center, startRadius: baseSize / 2.0, endCenter: center, endRadius: size.width / 2.0, options: [.drawsBeforeStartLocation, .drawsAfterEndLocation])
|
|
}
|
|
})?.withRenderingMode(.alwaysTemplate).stretchableImage(withLeftCapWidth: Int(baseSize / 2.0 + expandShadowExtent), topCapHeight: Int(baseSize / 2.0 + expandShadowExtent))
|
|
}
|
|
expandShadow.tintColor = component.theme.list.itemBlocksBackgroundColor
|
|
|
|
let expandButtonSize = expandButton.update(
|
|
transition: expandButtonTransition,
|
|
component: AnyComponent(PlainButtonComponent(
|
|
content: AnyComponent(MultilineTextComponent(
|
|
text: .plain(NSAttributedString(string: isExpanded.value ? "less" : "more", font: Font.regular(17.0), textColor: component.theme.list.itemAccentColor))
|
|
)),
|
|
effectAlignment: .right,
|
|
action: {
|
|
isExpanded.toggle()
|
|
},
|
|
animateAlpha: true,
|
|
animateScale: false,
|
|
animateContents: false
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: 200.0, height: 100.0)
|
|
)
|
|
|
|
if isExpanded.value && textLayout.trailingLineWidth >= availableSize.width - sideInset - expandButtonSize.width - 8.0 {
|
|
textContainerFrame.size.height += 22.0
|
|
}
|
|
|
|
let expandButtonFrame = CGRect(origin: CGPoint(x: availableSize.width - sideInset - expandButtonSize.width, y: textContainerFrame.maxY - expandButtonSize.height - 2.0), size: expandButtonSize)
|
|
if let expandButtonView = expandButton.view {
|
|
if expandButtonView.superview == nil {
|
|
expandButtonView.layer.anchorPoint = CGPoint(x: 1.0, y: 0.0)
|
|
self.addSubview(expandButtonView)
|
|
}
|
|
expandButtonTransition.setPosition(view: expandButtonView, position: CGPoint(x: expandButtonFrame.maxX, y: expandButtonFrame.minY))
|
|
expandButtonTransition.setBounds(view: expandButtonView, bounds: CGRect(origin: CGPoint(), size: expandButtonFrame.size))
|
|
|
|
expandButtonTransition.setFrame(view: expandShadow, frame: expandButtonFrame.insetBy(dx: -expandShadowExtent, dy: -expandShadowExtent))
|
|
expandButtonTransition.setAlpha(view: expandShadow, alpha: isExpanded.value ? 0.0 : 1.0)
|
|
}
|
|
|
|
} else {
|
|
if let expandButton = self.expandButton {
|
|
self.expandButton = nil
|
|
expandButton.view?.removeFromSuperview()
|
|
}
|
|
if let expandShadow = self.expandShadow {
|
|
self.expandShadow = nil
|
|
expandShadow.removeFromSuperview()
|
|
}
|
|
|
|
if component.copyAction != nil, let textLayout = self.textState.layout {
|
|
if textLayout.trailingLineWidth >= availableSize.width - sideInset - 32.0 || textLayout.trailingLineIsRTL {
|
|
textContainerFrame.size.height += 28.0
|
|
}
|
|
}
|
|
}
|
|
|
|
if component.text != nil, component.isExpanded?.value ?? true, let textView = self.text.view as? InteractiveTextComponent.View {
|
|
let textSelectionNode: TextSelectionNode
|
|
if let current = self.textSelectionNode {
|
|
textSelectionNode = current
|
|
} else {
|
|
textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: component.theme.list.itemAccentColor.withMultipliedAlpha(0.5), knob: component.theme.list.itemAccentColor, isDark: component.theme.overallDarkAppearance), strings: component.strings, textNodeOrView: .node(textView.textNode), updateIsActive: { [weak self] value in
|
|
guard let self else {
|
|
return
|
|
}
|
|
let _ = self
|
|
}, present: { [weak self] c, a in
|
|
guard let self, let component = self.component else {
|
|
return
|
|
}
|
|
component.present(c, a)
|
|
}, rootView: { [weak self] in
|
|
guard let self, let component = self.component else {
|
|
return nil
|
|
}
|
|
return component.rootViewForTextSelection()
|
|
}, externalKnobSurface: self.textSelectionKnobSurface, performAction: { [weak self] text, action in
|
|
guard let self, let component = self.component else {
|
|
return
|
|
}
|
|
|
|
switch action {
|
|
case .copy:
|
|
storeAttributedTextInPasteboard(text)
|
|
case .share:
|
|
let shareController = component.context.sharedContext.makeShareController(context: component.context, subject: .text(text.string), forceExternal: true, shareStory: nil, enqueued: nil, actionCompleted: nil)
|
|
component.present(shareController, nil)
|
|
case .lookup:
|
|
let controller = UIReferenceLibraryViewController(term: text.string)
|
|
if let window = self.window {
|
|
controller.popoverPresentationController?.sourceView = window
|
|
controller.popoverPresentationController?.sourceRect = CGRect(origin: CGPoint(x: window.bounds.width / 2.0, y: window.bounds.size.height - 1.0), size: CGSize(width: 1.0, height: 1.0))
|
|
window.rootViewController?.present(controller, animated: true)
|
|
}
|
|
case .speak:
|
|
if let speechHolder = speakText(context: component.context, text: text.string) {
|
|
speechHolder.completion = { [weak self, weak speechHolder] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
if self.currentSpeechHolder === speechHolder {
|
|
self.currentSpeechHolder = nil
|
|
}
|
|
}
|
|
self.currentSpeechHolder = speechHolder
|
|
}
|
|
case .translate:
|
|
break
|
|
case .quote:
|
|
break
|
|
}
|
|
})
|
|
|
|
textSelectionNode.enableLookup = true
|
|
textSelectionNode.enableTranslate = false
|
|
textSelectionNode.menuSkipCoordnateConversion = false
|
|
textSelectionNode.canBeginSelection = { _ in
|
|
return true
|
|
}
|
|
self.textSelectionNode = textSelectionNode
|
|
|
|
self.textSelectionContainer.insertSubview(self.textSelectionKnobContainer, at: 0)
|
|
self.textContainer.insertSubview(textSelectionNode.highlightAreaNode.view, belowSubview: textView)
|
|
self.textContainer.insertSubview(textSelectionNode.view, aboveSubview: textView)
|
|
}
|
|
|
|
textSelectionNode.frame = textView.frame
|
|
textSelectionNode.highlightAreaNode.frame = textView.frame
|
|
self.textSelectionKnobSurface.frame = textView.frame
|
|
} else {
|
|
for subview in Array(self.textSelectionContainer.subviews) {
|
|
subview.removeFromSuperview()
|
|
}
|
|
if self.textSelectionKnobContainer.superview != nil {
|
|
self.textSelectionKnobContainer.removeFromSuperview()
|
|
}
|
|
if let textSelectionNode = self.textSelectionNode {
|
|
self.textSelectionNode = nil
|
|
textSelectionNode.view.removeFromSuperview()
|
|
|
|
if textSelectionNode.highlightAreaNode.view.superview != nil {
|
|
textSelectionNode.highlightAreaNode.view.removeFromSuperview()
|
|
}
|
|
}
|
|
}
|
|
|
|
if component.text == nil {
|
|
let shimmerEffectNode: ShimmerEffectNode
|
|
if let current = self.shimmerEffectNode {
|
|
shimmerEffectNode = current
|
|
} else {
|
|
shimmerEffectNode = ShimmerEffectNode()
|
|
shimmerEffectNode.layer.allowsGroupOpacity = true
|
|
shimmerEffectNode.alpha = 0.0
|
|
self.shimmerEffectNode = shimmerEffectNode
|
|
self.addSubview(shimmerEffectNode.view)
|
|
}
|
|
|
|
var fakeLines = ""
|
|
if let previousTextLineCount = self.previousTextLineCount {
|
|
for _ in 0 ..< min(20, previousTextLineCount) {
|
|
if !fakeLines.isEmpty {
|
|
fakeLines.append("\n")
|
|
}
|
|
fakeLines.append("a")
|
|
}
|
|
} else if let loadingStateMeasuringText = component.loadingStateMeasuringText {
|
|
fakeLines = loadingStateMeasuringText
|
|
} else {
|
|
for _ in 0 ..< 4 {
|
|
if !fakeLines.isEmpty {
|
|
fakeLines.append("\n")
|
|
}
|
|
fakeLines.append("a")
|
|
}
|
|
}
|
|
|
|
let measureLoadingTextSize = self.measureLoadingText.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(InteractiveTextComponent(
|
|
external: self.measureLoadingTextState,
|
|
attributedString: NSAttributedString(string: fakeLines, font: Font.regular(fontSize), textColor: .black),
|
|
backgroundColor: nil,
|
|
minimumNumberOfLines: 1,
|
|
maximumNumberOfLines: 0,
|
|
truncationType: .end,
|
|
alignment: .left,
|
|
verticalAlignment: .top,
|
|
lineSpacing: 0.12,
|
|
cutout: nil,
|
|
insets: UIEdgeInsets(),
|
|
lineColor: nil,
|
|
textShadowColor: nil,
|
|
textShadowBlur: nil,
|
|
textStroke: nil,
|
|
displayContentsUnderSpoilers: true,
|
|
customTruncationToken: nil,
|
|
expandedBlocks: Set(),
|
|
context: component.context,
|
|
cache: component.context.animationCache,
|
|
renderer: component.context.animationRenderer,
|
|
placeholderColor: component.theme.list.mediaPlaceholderColor,
|
|
attemptSynchronous: true,
|
|
textColor: component.theme.list.itemPrimaryTextColor,
|
|
spoilerEffectColor: component.theme.list.itemPrimaryTextColor,
|
|
spoilerTextColor: component.theme.list.itemPrimaryTextColor,
|
|
areContentAnimationsEnabled: true,
|
|
spoilerExpandPoint: nil
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: availableSize.height)
|
|
)
|
|
textContainerFrame.size = CGSize(width: availableSize.width, height: measureLoadingTextSize.height)
|
|
|
|
shimmerEffectNode.frame = textContainerFrame
|
|
|
|
var shapes: [ShimmerEffectNode.Shape] = []
|
|
if let textLayout = self.measureLoadingTextState.layout {
|
|
var seed: UInt32 = 0x9E3779B9
|
|
for (index, lineRect) in textLayout.linesRects().enumerated() {
|
|
seed = seed &* 1664525 &+ UInt32(index) &+ 1013904223
|
|
let normalized = CGFloat(seed >> 16) / CGFloat(0xFFFF)
|
|
let width = 0.7 + normalized * 0.3
|
|
shapes.append(.roundedRectLine(startPoint: CGPoint(x: 0.0, y: lineRect.midY - 18.0), width: floor(textContainerFrame.width * width), diameter: 6.0))
|
|
}
|
|
}
|
|
shimmerEffectNode.updateAbsoluteRect(shimmerEffectNode.bounds, within: shimmerEffectNode.bounds.size)
|
|
shimmerEffectNode.update(backgroundColor: component.theme.list.itemBlocksBackgroundColor, foregroundColor: component.theme.list.mediaPlaceholderColor, shimmeringColor: component.theme.list.itemBlocksBackgroundColor.withAlphaComponent(0.4), shapes: shapes, size: shimmerEffectNode.bounds.size)
|
|
alphaTransition.setAlpha(view: shimmerEffectNode.view, alpha: 1.0)
|
|
} else {
|
|
if let shimmerEffectNode = self.shimmerEffectNode {
|
|
self.shimmerEffectNode = nil
|
|
alphaTransition.setAlpha(view: shimmerEffectNode.view, alpha: 0.0, completion: { [weak shimmerEffectNode] _ in
|
|
shimmerEffectNode?.view.removeFromSuperview()
|
|
})
|
|
}
|
|
}
|
|
|
|
if let copyAction = component.copyAction {
|
|
let copyButtonSize = self.copyButton.update(
|
|
transition: transition,
|
|
component: AnyComponent(PlainButtonComponent(
|
|
content: AnyComponent(BundleIconComponent(
|
|
name: "Chat/Context Menu/Copy",
|
|
tintColor: component.theme.list.itemAccentColor
|
|
)),
|
|
effectAlignment: .right,
|
|
action: {
|
|
copyAction()
|
|
},
|
|
animateAlpha: true,
|
|
animateScale: false,
|
|
animateContents: false
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: 200.0, height: 100.0)
|
|
)
|
|
let copyButtonFrame = CGRect(origin: CGPoint(x: availableSize.width - sideInset - copyButtonSize.width, y: textContainerFrame.maxY - copyButtonSize.height - 2.0), size: copyButtonSize)
|
|
if let copyButtonView = self.copyButton.view {
|
|
if copyButtonView.superview == nil {
|
|
copyButtonView.layer.anchorPoint = CGPoint(x: 1.0, y: 0.0)
|
|
self.addSubview(copyButtonView)
|
|
}
|
|
transition.setPosition(view: copyButtonView, position: CGPoint(x: copyButtonFrame.maxX, y: copyButtonFrame.minY))
|
|
transition.setBounds(view: copyButtonView, bounds: CGRect(origin: CGPoint(), size: copyButtonFrame.size))
|
|
alphaTransition.setAlpha(view: copyButtonView, alpha: component.text != nil ? 1.0 : 0.0)
|
|
}
|
|
}
|
|
|
|
transition.setFrame(view: self.textContainer, frame: textContainerFrame)
|
|
transition.setFrame(view: self.textSelectionContainer, frame: textContainerFrame)
|
|
contentHeight += textContainerFrame.height
|
|
|
|
contentHeight += bottomInset
|
|
|
|
return CGSize(width: availableSize.width, height: contentHeight)
|
|
}
|
|
}
|
|
|
|
func makeView() -> View {
|
|
return View(frame: CGRect())
|
|
}
|
|
|
|
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
|
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
|
}
|
|
}
|
|
|