From 82281662e19894bbad4796573bbf34cee1128a0f Mon Sep 17 00:00:00 2001 From: Ali <> Date: Fri, 10 Nov 2023 15:20:17 +0400 Subject: [PATCH] Code highlight improvements --- .../AttachmentTextInputPanelNode.swift | 62 +++ submodules/Display/Source/TextNode.swift | 18 +- .../ChatItemGalleryFooterContentNode.swift | 37 +- .../GalleryUI/Sources/GalleryController.swift | 31 +- .../Sources/ChatInputTextNode.swift | 164 +++---- .../StoryContentCaptionComponent.swift | 59 ++- .../StoryItemSetContainerComponent.swift | 1 + .../Components/TextFieldComponent/BUILD | 3 + .../Sources/TextFieldComponent.swift | 421 ++++++++++++++---- .../Sources/StringWithAppliedEntities.swift | 38 ++ 10 files changed, 656 insertions(+), 178 deletions(-) diff --git a/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift b/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift index e49ca54d11..a65befe803 100644 --- a/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift +++ b/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift @@ -191,6 +191,55 @@ final class CustomEmojiContainerView: UIView { } } +private func makeTextInputTheme(context: AccountContext, interfaceState: ChatPresentationInterfaceState) -> ChatInputTextView.Theme { + let lineStyle: ChatInputTextView.Theme.Quote.LineStyle + let authorNameColor: UIColor + + if let peer = interfaceState.renderedPeer?.peer as? TelegramChannel, case .broadcast = peer.info, let nameColor = peer.nameColor { + let colors = context.peerNameColors.get(nameColor) + authorNameColor = colors.main + + if let secondary = colors.secondary, let tertiary = colors.tertiary { + lineStyle = .tripleDashed(mainColor: colors.main, secondaryColor: secondary, tertiaryColor: tertiary) + } else if let secondary = colors.secondary { + lineStyle = .doubleDashed(mainColor: colors.main, secondaryColor: secondary) + } else { + lineStyle = .solid(color: colors.main) + } + } else if let accountPeerColor = interfaceState.accountPeerColor { + authorNameColor = interfaceState.theme.list.itemAccentColor + + switch accountPeerColor.style { + case .solid: + lineStyle = .solid(color: authorNameColor) + case .doubleDashed: + lineStyle = .doubleDashed(mainColor: authorNameColor, secondaryColor: .clear) + case .tripleDashed: + lineStyle = .tripleDashed(mainColor: authorNameColor, secondaryColor: .clear, tertiaryColor: .clear) + } + } else { + lineStyle = .solid(color: interfaceState.theme.list.itemAccentColor) + authorNameColor = interfaceState.theme.list.itemAccentColor + } + + let codeBackgroundColor: UIColor + if interfaceState.theme.overallDarkAppearance { + codeBackgroundColor = UIColor(white: 1.0, alpha: 0.05) + } else { + codeBackgroundColor = UIColor(white: 0.0, alpha: 0.05) + } + + return ChatInputTextView.Theme( + quote: ChatInputTextView.Theme.Quote( + background: authorNameColor.withMultipliedAlpha(interfaceState.theme.overallDarkAppearance ? 0.2 : 0.1), + foreground: authorNameColor, + lineStyle: lineStyle, + codeBackground: codeBackgroundColor, + codeForeground: authorNameColor + ) + ) +} + public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, ASEditableTextNodeDelegate, ChatInputTextNodeDelegate { private let context: AccountContext @@ -499,6 +548,7 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS private func loadTextInputNode() { let textInputNode = CaptionEditableTextNode() textInputNode.initialPrimaryLanguage = self.presentationInterfaceState?.interfaceState.inputLanguage + var textColor: UIColor = .black var tintColor: UIColor = .blue var baseFontSize: CGFloat = 17.0 @@ -508,6 +558,8 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS tintColor = presentationInterfaceState.theme.list.itemAccentColor baseFontSize = max(minInputFontSize, presentationInterfaceState.fontSize.baseDisplaySize) keyboardAppearance = presentationInterfaceState.theme.rootController.keyboardColor.keyboardAppearance + + textInputNode.textView.theme = makeTextInputTheme(context: self.context, interfaceState: presentationInterfaceState) } let paragraphStyle = NSMutableParagraphStyle() @@ -1536,11 +1588,21 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS } if hasSpoilers { + children.insert(UIAction(title: self.strings?.TextFormat_Quote ?? "Quote", image: nil) { [weak self] (action) in + if let strongSelf = self { + strongSelf.formatAttributesQuote(strongSelf) + } + }, at: 0) children.append(UIAction(title: self.strings?.TextFormat_Spoiler ?? "Spoiler", image: nil) { [weak self] (action) in if let strongSelf = self { strongSelf.formatAttributesSpoiler(strongSelf) } }) + children.append(UIAction(title: self.strings?.TextFormat_Code ?? "Code", image: nil) { [weak self] (action) in + if let strongSelf = self { + strongSelf.formatAttributesCodeBlock(strongSelf) + } + }) } let formatMenu = UIMenu(title: self.strings?.TextFormat_Format ?? "Format", image: nil, children: children) diff --git a/submodules/Display/Source/TextNode.swift b/submodules/Display/Source/TextNode.swift index 6326867e41..60fac84e77 100644 --- a/submodules/Display/Source/TextNode.swift +++ b/submodules/Display/Source/TextNode.swift @@ -1606,17 +1606,15 @@ open class TextNode: ASDisplayNode { return TextNodeLayout(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, lines: [], blockQuotes: [], backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textShadowBlur: textShadowBlur, textStroke: textStroke, displaySpoilers: displaySpoilers) } - if maximumNumberOfLines == 0 { - var found = false - attributedString.enumerateAttribute(NSAttributedString.Key("Attribute__Blockquote"), in: NSRange(location: 0, length: attributedString.length), using: { value, effectiveRange, _ in - if let _ = value as? TextNodeBlockQuoteData { - found = true - } - }) - - if found { - 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, displaySpoilers: displaySpoilers, displayEmbeddedItemsUnderSpoilers: displayEmbeddedItemsUnderSpoilers, customTruncationToken: customTruncationToken) + var found = false + attributedString.enumerateAttribute(NSAttributedString.Key("Attribute__Blockquote"), in: NSRange(location: 0, length: attributedString.length), using: { value, effectiveRange, _ in + if let _ = value as? TextNodeBlockQuoteData { + found = true } + }) + + if found { + 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, displaySpoilers: displaySpoilers, displayEmbeddedItemsUnderSpoilers: displayEmbeddedItemsUnderSpoilers, customTruncationToken: customTruncationToken) } let stringLength = attributedString.length diff --git a/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift b/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift index 1049e30430..d0bfe0326c 100644 --- a/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift +++ b/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift @@ -173,6 +173,8 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, UIScroll private var validLayout: (CGSize, LayoutMetrics, CGFloat, CGFloat, CGFloat, CGFloat)? + private var codeHighlightState: (id: EngineMessage.Id, specs: [CachedMessageSyntaxHighlight.Spec], disposable: Disposable)? + var playbackControl: (() -> Void)? var seekBackward: ((Double) -> Void)? var seekForward: ((Double) -> Void)? @@ -631,6 +633,7 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, UIScroll deinit { self.messageContextDisposable.dispose() + self.codeHighlightState?.disposable.dispose() } override func didLoad() { @@ -946,7 +949,39 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, UIScroll } } } - messageText = galleryCaptionStringWithAppliedEntities(text, entities: entities, message: message) + + let codeHighlightSpecs = extractMessageSyntaxHighlightSpecs(text: text, entities: entities) + var cachedMessageSyntaxHighlight: CachedMessageSyntaxHighlight? + + if !codeHighlightSpecs.isEmpty { + for attribute in message.attributes { + if let attribute = attribute as? DerivedDataMessageAttribute { + if let value = attribute.data["code"]?.get(CachedMessageSyntaxHighlight.self) { + cachedMessageSyntaxHighlight = value + } + } + } + } + + if !codeHighlightSpecs.isEmpty { + if let current = self.codeHighlightState, current.id == message.id, current.specs == codeHighlightSpecs { + } else { + if let codeHighlightState = self.codeHighlightState { + self.codeHighlightState = nil + codeHighlightState.disposable.dispose() + } + + let disposable = MetaDisposable() + self.codeHighlightState = (message.id, codeHighlightSpecs, disposable) + disposable.set(asyncUpdateMessageSyntaxHighlight(engine: self.context.engine, messageId: message.id, current: cachedMessageSyntaxHighlight, specs: codeHighlightSpecs).startStrict(completed: { + })) + } + } else if let codeHighlightState = self.codeHighlightState { + self.codeHighlightState = nil + codeHighlightState.disposable.dispose() + } + + messageText = galleryCaptionStringWithAppliedEntities(context: self.context, text: text, entities: entities, message: message, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight) } if self.currentMessageText != messageText || canDelete != !self.deleteButton.isHidden || canFullscreen != !self.fullscreenButton.isHidden || canShare != !self.actionButton.isHidden || canEdit != !self.editButton.isHidden || self.currentAuthorNameText != authorNameText || self.currentDateText != dateText { diff --git a/submodules/GalleryUI/Sources/GalleryController.swift b/submodules/GalleryUI/Sources/GalleryController.swift index b077375dae..eddd2abc22 100644 --- a/submodules/GalleryUI/Sources/GalleryController.swift +++ b/submodules/GalleryUI/Sources/GalleryController.swift @@ -135,8 +135,31 @@ private let italicFont = Font.italic(16.0) private let boldItalicFont = Font.semiboldItalic(16.0) private let fixedFont = UIFont(name: "Menlo-Regular", size: 15.0) ?? textFont -public func galleryCaptionStringWithAppliedEntities(_ text: String, entities: [MessageTextEntity], message: Message?) -> NSAttributedString { - return stringWithAppliedEntities(text, entities: entities, baseColor: .white, linkColor: UIColor(rgb: 0x5ac8fa), baseFont: textFont, linkFont: textFont, boldFont: boldFont, italicFont: italicFont, boldItalicFont: boldItalicFont, fixedFont: fixedFont, blockQuoteFont: textFont, underlineLinks: false, message: message, adjustQuoteFontSize: true) +public func galleryCaptionStringWithAppliedEntities(context: AccountContext, text: String, entities: [MessageTextEntity], message: Message?, cachedMessageSyntaxHighlight: CachedMessageSyntaxHighlight? = nil) -> NSAttributedString { + var baseQuoteSecondaryTintColor: UIColor? + var baseQuoteTertiaryTintColor: UIColor? + if let nameColor = message?.author?.nameColor { + let resolvedColor = context.peerNameColors.get(nameColor) + if resolvedColor.secondary != nil { + baseQuoteSecondaryTintColor = .clear + } + if resolvedColor.tertiary != nil { + baseQuoteTertiaryTintColor = .clear + } + } + + return stringWithAppliedEntities( + text, + entities: entities, + baseColor: .white, + linkColor: UIColor(rgb: 0x5ac8fa), + baseQuoteTintColor: .white, + baseQuoteSecondaryTintColor: baseQuoteSecondaryTintColor, + baseQuoteTertiaryTintColor: baseQuoteTertiaryTintColor, + codeBlockTitleColor: .white, + codeBlockAccentColor: .white, + codeBlockBackgroundColor: UIColor(white: 1.0, alpha: 0.2), + baseFont: textFont, linkFont: textFont, boldFont: boldFont, italicFont: italicFont, boldItalicFont: boldItalicFont, fixedFont: fixedFont, blockQuoteFont: textFont, underlineLinks: false, message: message, adjustQuoteFontSize: true, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight) } private func galleryMessageCaptionText(_ message: Message) -> String { @@ -225,7 +248,7 @@ public func galleryItemForEntry( entities = result } - let caption = galleryCaptionStringWithAppliedEntities(text, entities: entities, message: message) + let caption = galleryCaptionStringWithAppliedEntities(context: context, text: text, entities: entities, message: message) return UniversalVideoGalleryItem( context: context, presentationData: presentationData, @@ -322,7 +345,7 @@ public func galleryItemForEntry( if let result = addLocallyGeneratedEntities(descriptionText, enabledTypes: [.timecode], entities: entities, mediaDuration: 86400) { entities = result } - description = galleryCaptionStringWithAppliedEntities(descriptionText, entities: entities, message: message) + description = galleryCaptionStringWithAppliedEntities(context: context, text: descriptionText, entities: entities, message: message) } return UniversalVideoGalleryItem( context: context, diff --git a/submodules/TelegramUI/Components/Chat/ChatInputTextNode/Sources/ChatInputTextNode.swift b/submodules/TelegramUI/Components/Chat/ChatInputTextNode/Sources/ChatInputTextNode.swift index 976d187e5b..2c3823e1f3 100644 --- a/submodules/TelegramUI/Components/Chat/ChatInputTextNode/Sources/ChatInputTextNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatInputTextNode/Sources/ChatInputTextNode.swift @@ -26,7 +26,7 @@ public protocol ChatInputTextNodeDelegate: AnyObject { func chatInputTextNodeTargetForAction(action: Selector) -> ChatInputTextNode.TargetForAction? } -open class ChatInputTextNode: ASDisplayNode, UITextViewDelegate { +open class ChatInputTextNode: ASDisplayNode { public final class TargetForAction { public let target: Any? @@ -41,8 +41,6 @@ open class ChatInputTextNode: ASDisplayNode, UITextViewDelegate { } } - private var selectionChangedForEditedText: Bool = false - public var textView: ChatInputTextView { return self.view as! ChatInputTextView } @@ -122,30 +120,6 @@ open class ChatInputTextNode: ASDisplayNode, UITextViewDelegate { self.setViewBlock({ return ChatInputTextView(disableTiling: disableTiling) }) - - self.textView.delegate = self - self.textView.shouldRespondToAction = { [weak self] action in - guard let self, let action else { - return false - } - if let delegate = self.delegate { - return delegate.chatInputTextNodeShouldRespondToAction(action: action) - } else { - return true - } - } - self.textView.targetForAction = { [weak self] action in - guard let self, let action else { - return nil - } - if let delegate = self.delegate { - return delegate.chatInputTextNodeTargetForAction(action: action).flatMap { value in - return ChatInputTextViewImplTargetForAction(target: value.target) - } - } else { - return nil - } - } } public func resetInitialPrimaryLanguage() { @@ -155,52 +129,6 @@ open class ChatInputTextNode: ASDisplayNode, UITextViewDelegate { return self.textView.textHeightForWidth(width, rightInset: rightInset) } - @objc public func textViewDidBeginEditing(_ textView: UITextView) { - self.delegate?.chatInputTextNodeDidBeginEditing() - } - - @objc public func textViewDidEndEditing(_ textView: UITextView) { - self.delegate?.chatInputTextNodeDidFinishEditing() - } - - @objc public func textViewDidChange(_ textView: UITextView) { - self.selectionChangedForEditedText = true - - self.delegate?.chatInputTextNodeDidUpdateText() - - self.textView.updateTextContainerInset() - } - - @objc public func textViewDidChangeSelection(_ textView: UITextView) { - if self.textView.isPreservingSelection { - return - } - - self.selectionChangedForEditedText = false - - DispatchQueue.main.async { [weak self] in - guard let self else { - return - } - self.delegate?.chatInputTextNodeDidChangeSelection(dueToEditing: self.selectionChangedForEditedText) - } - } - - @available(iOS 16.0, *) - @objc public func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? { - return self.delegate?.chatInputTextNodeMenu(forTextRange: range, suggestedActions: suggestedActions) - } - - @objc public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { - guard let delegate = self.delegate else { - return true - } - if self.textView.isPreservingText { - return false - } - return delegate.chatInputTextNode(shouldChangeTextIn: range, replacementText: text) - } - public func updateLayout(size: CGSize) { self.textView.updateLayout(size: size) } @@ -267,7 +195,7 @@ private final class ChatInputTextContainer: NSTextContainer { } } -public final class ChatInputTextView: ChatInputTextViewImpl, NSLayoutManagerDelegate, NSTextStorageDelegate { +public final class ChatInputTextView: ChatInputTextViewImpl, UITextViewDelegate, NSLayoutManagerDelegate, NSTextStorageDelegate { public final class Theme: Equatable { public final class Quote: Equatable { public enum LineStyle: Equatable { @@ -387,6 +315,8 @@ public final class ChatInputTextView: ChatInputTextViewImpl, NSLayoutManagerDele private var didInitializePrimaryInputLanguage: Bool = false public var initialPrimaryLanguage: String? + private var selectionChangedForEditedText: Bool = false + override public var textInputMode: UITextInputMode? { if !self.didInitializePrimaryInputLanguage { self.didInitializePrimaryInputLanguage = true @@ -422,6 +352,31 @@ public final class ChatInputTextView: ChatInputTextViewImpl, NSLayoutManagerDele super.init(frame: CGRect(), textContainer: self.customTextContainer, disableTiling: disableTiling) + self.delegate = self + + self.shouldRespondToAction = { [weak self] action in + guard let self, let action else { + return false + } + if let delegate = self.customDelegate { + return delegate.chatInputTextNodeShouldRespondToAction(action: action) + } else { + return true + } + } + self.targetForAction = { [weak self] action in + guard let self, let action else { + return nil + } + if let delegate = self.customDelegate { + return delegate.chatInputTextNodeTargetForAction(action: action).flatMap { value in + return ChatInputTextViewImplTargetForAction(target: value.target) + } + } else { + return nil + } + } + self.textContainerInset = UIEdgeInsets() self.backgroundColor = nil self.isOpaque = false @@ -570,6 +525,54 @@ public final class ChatInputTextView: ChatInputTextViewImpl, NSLayoutManagerDele self.updateTextElements() } + @objc public func textViewDidBeginEditing(_ textView: UITextView) { + self.customDelegate?.chatInputTextNodeDidBeginEditing() + } + + @objc public func textViewDidEndEditing(_ textView: UITextView) { + self.customDelegate?.chatInputTextNodeDidFinishEditing() + } + + @objc public func textViewDidChange(_ textView: UITextView) { + self.selectionChangedForEditedText = true + + self.updateTextContainerInset() + + self.customDelegate?.chatInputTextNodeDidUpdateText() + + self.updateTextContainerInset() + } + + @objc public func textViewDidChangeSelection(_ textView: UITextView) { + if self.isPreservingSelection { + return + } + + self.selectionChangedForEditedText = false + + DispatchQueue.main.async { [weak self] in + guard let self else { + return + } + self.customDelegate?.chatInputTextNodeDidChangeSelection(dueToEditing: self.selectionChangedForEditedText) + } + } + + @available(iOS 16.0, *) + @objc public func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? { + return self.customDelegate?.chatInputTextNodeMenu(forTextRange: range, suggestedActions: suggestedActions) + } + + @objc public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + guard let customDelegate = self.customDelegate else { + return true + } + if self.isPreservingText { + return false + } + return customDelegate.chatInputTextNode(shouldChangeTextIn: range, replacementText: text) + } + public func updateTextContainerInset() { var result = self.defaultTextContainerInset @@ -608,9 +611,16 @@ public final class ChatInputTextView: ChatInputTextViewImpl, NSLayoutManagerDele public func textHeightForWidth(_ width: CGFloat, rightInset: CGFloat) -> CGFloat { let measureSize = CGSize(width: width, height: 1000000.0) - if self.measurementTextStorage != self.attributedText || self.measurementTextContainer.size != measureSize || self.measurementTextContainer.rightInset != rightInset { + let measureText: NSAttributedString + if let attributedText = self.attributedText, attributedText.length != 0 { + measureText = attributedText + } else { + measureText = NSAttributedString(string: "A", attributes: self.typingAttributes) + } + + if self.measurementTextStorage != measureText || self.measurementTextContainer.size != measureSize || self.measurementTextContainer.rightInset != rightInset { self.measurementTextContainer.rightInset = rightInset - self.measurementTextStorage.setAttributedString(self.attributedText ?? NSAttributedString()) + self.measurementTextStorage.setAttributedString(measureText) self.measurementTextContainer.size = measureSize self.measurementLayoutManager.invalidateLayout(forCharacterRange: NSRange(location: 0, length: self.measurementTextStorage.length), actualCharacterRange: nil) self.measurementLayoutManager.ensureLayout(for: self.measurementTextContainer) @@ -618,7 +628,7 @@ public final class ChatInputTextView: ChatInputTextViewImpl, NSLayoutManagerDele let textSize = self.measurementLayoutManager.usedRect(for: self.measurementTextContainer).size - return textSize.height + self.textContainerInset.top + self.textContainerInset.bottom + return ceil(textSize.height + self.textContainerInset.top + self.textContainerInset.bottom) } public func updateLayout(size: CGSize) { diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentCaptionComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentCaptionComponent.swift index 6f7cc5ec62..0b9c613f3f 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentCaptionComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentCaptionComponent.swift @@ -11,6 +11,7 @@ import InvisibleInkDustNode import UrlEscaping import TelegramPresentationData import TextSelectionNode +import SwiftSignalKit final class StoryContentCaptionComponent: Component { enum Action { @@ -55,6 +56,7 @@ final class StoryContentCaptionComponent: Component { let strings: PresentationStrings let theme: PresentationTheme let text: String + let author: EnginePeer let entities: [MessageTextEntity] let entityFiles: [EngineMedia.Id: TelegramMediaFile] let action: (Action) -> Void @@ -68,6 +70,7 @@ final class StoryContentCaptionComponent: Component { strings: PresentationStrings, theme: PresentationTheme, text: String, + author: EnginePeer, entities: [MessageTextEntity], entityFiles: [EngineMedia.Id: TelegramMediaFile], action: @escaping (Action) -> Void, @@ -79,6 +82,7 @@ final class StoryContentCaptionComponent: Component { self.context = context self.strings = strings self.theme = theme + self.author = author self.text = text self.entities = entities self.entityFiles = entityFiles @@ -101,6 +105,9 @@ final class StoryContentCaptionComponent: Component { if lhs.theme !== rhs.theme { return false } + if lhs.author != rhs.author { + return false + } if lhs.text != rhs.text { return false } @@ -165,6 +172,7 @@ final class StoryContentCaptionComponent: Component { private var component: StoryContentCaptionComponent? private weak var state: EmptyComponentState? + private var isUpdating: Bool = false private var itemLayout: ItemLayout? @@ -173,6 +181,9 @@ final class StoryContentCaptionComponent: Component { private var isExpanded: Bool = false + private var codeHighlight: CachedMessageSyntaxHighlight? + private var codeHighlightState: (specs: [CachedMessageSyntaxHighlight.Spec], disposable: Disposable)? + private static let shadowImage: UIImage? = { UIImage(named: "Stories/PanelGradient") }() @@ -245,6 +256,10 @@ final class StoryContentCaptionComponent: Component { fatalError("init(coder:) has not been implemented") } + deinit { + self.codeHighlightState?.disposable.dispose() + } + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if !self.bounds.contains(point) { return nil @@ -518,6 +533,11 @@ final class StoryContentCaptionComponent: Component { } func update(component: StoryContentCaptionComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + self.ignoreExternalState = true self.component = component @@ -527,11 +547,47 @@ final class StoryContentCaptionComponent: Component { let verticalInset: CGFloat = 7.0 let textContainerSize = CGSize(width: availableSize.width - sideInset * 2.0, height: availableSize.height - verticalInset * 2.0) + var baseQuoteSecondaryTintColor: UIColor? + var baseQuoteTertiaryTintColor: UIColor? + if let nameColor = component.author.nameColor { + let resolvedColor = component.context.peerNameColors.get(nameColor) + if resolvedColor.secondary != nil { + baseQuoteSecondaryTintColor = .clear + } + if resolvedColor.tertiary != nil { + baseQuoteTertiaryTintColor = .clear + } + } + + let codeSpec = extractMessageSyntaxHighlightSpecs(text: component.text, entities: component.entities) + if self.codeHighlightState?.specs != codeSpec { + let disposable = MetaDisposable() + self.codeHighlightState = (codeSpec, disposable) + disposable.set((asyncStanaloneSyntaxHighlight(current: self.codeHighlight, specs: codeSpec) + |> deliverOnMainQueue).start(next: { [weak self] result in + guard let self else { + return + } + if self.codeHighlight != result { + self.codeHighlight = result + if !self.isUpdating { + self.state?.updated(transition: .immediate) + } + } + })) + } + let attributedText = stringWithAppliedEntities( component.text, entities: component.entities, baseColor: .white, linkColor: .white, + baseQuoteTintColor: .white, + baseQuoteSecondaryTintColor: baseQuoteSecondaryTintColor, + baseQuoteTertiaryTintColor: baseQuoteTertiaryTintColor, + codeBlockTitleColor: .white, + codeBlockAccentColor: .white, + codeBlockBackgroundColor: UIColor(white: 1.0, alpha: 0.2), baseFont: Font.regular(16.0), linkFont: Font.regular(16.0), boldFont: Font.semibold(16.0), @@ -541,7 +597,8 @@ final class StoryContentCaptionComponent: Component { blockQuoteFont: Font.monospace(16.0), message: nil, entityFiles: component.entityFiles, - adjustQuoteFontSize: true + adjustQuoteFontSize: true, + cachedMessageSyntaxHighlight: self.codeHighlight ) let truncationToken = NSMutableAttributedString() diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift index 5598bab0db..4d192dd829 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift @@ -4089,6 +4089,7 @@ public final class StoryItemSetContainerComponent: Component { strings: component.strings, theme: component.theme, text: component.slice.item.storyItem.text, + author: component.slice.peer, entities: enableEntities ? component.slice.item.storyItem.entities : [], entityFiles: component.slice.item.entityFiles, action: { [weak self] action in diff --git a/submodules/TelegramUI/Components/TextFieldComponent/BUILD b/submodules/TelegramUI/Components/TextFieldComponent/BUILD index db908db94c..045b43ad01 100644 --- a/submodules/TelegramUI/Components/TextFieldComponent/BUILD +++ b/submodules/TelegramUI/Components/TextFieldComponent/BUILD @@ -21,6 +21,9 @@ swift_library( "//submodules/ChatTextLinkEditUI", "//submodules/Pasteboard", "//submodules/ImageTransparency", + "//submodules/TelegramUI/Components/Chat/ChatInputTextNode", + "//submodules/TextInputMenu", + "//submodules/ObjCRuntimeUtils", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift b/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift index f7824966c8..c2079d85f3 100644 --- a/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift +++ b/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift @@ -13,6 +13,9 @@ import Pasteboard import ChatTextLinkEditUI import MobileCoreServices import ImageTransparency +import ChatInputTextNode +import TextInputMenu +import ObjCRuntimeUtils public final class EmptyInputView: UIView, UIInputViewAudioFeedback { public var enableInputClicksWhenVisible: Bool { @@ -180,28 +183,9 @@ public final class TextFieldComponent: Component { } } - final class TextView: UITextView { - var onPaste: () -> Bool = { return true } - - override func paste(_ sender: Any?) { - if self.onPaste() { - super.paste(sender) - } - } - - override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { - if action == #selector(self.paste(_:)) { - return true - } - return super.canPerformAction(action, withSender: sender) - } - } - - public final class View: UIView, UITextViewDelegate, UIScrollViewDelegate { - private let textContainer: NSTextContainer - private let textStorage: NSTextStorage - private let layoutManager: NSLayoutManager - private let textView: TextView + public final class View: UIView, UIScrollViewDelegate, ChatInputTextNodeDelegate { + private let textView: ChatInputTextView + private let inputMenu: TextInputMenu private var spoilerView: InvisibleInkDustView? private var customEmojiContainerView: CustomEmojiContainerView? @@ -216,22 +200,10 @@ public final class TextFieldComponent: Component { private var component: TextFieldComponent? private weak var state: EmptyComponentState? + private var isUpdating: Bool = false override init(frame: CGRect) { - self.textContainer = NSTextContainer(size: CGSize()) - self.textContainer.widthTracksTextView = false - self.textContainer.heightTracksTextView = false - self.textContainer.lineBreakMode = .byWordWrapping - self.textContainer.lineFragmentPadding = 8.0 - - self.textStorage = NSTextStorage() - - self.layoutManager = NSLayoutManager() - self.layoutManager.allowsNonContiguousLayout = false - self.layoutManager.addTextContainer(self.textContainer) - self.textStorage.addLayoutManager(self.layoutManager) - - self.textView = TextView(frame: CGRect(), textContainer: self.textContainer) + self.textView = ChatInputTextView(disableTiling: false) self.textView.translatesAutoresizingMaskIntoConstraints = false self.textView.backgroundColor = nil self.textView.layer.isOpaque = false @@ -239,16 +211,15 @@ public final class TextFieldComponent: Component { self.textView.indicatorStyle = .white self.textView.scrollIndicatorInsets = UIEdgeInsets(top: 9.0, left: 0.0, bottom: 9.0, right: 0.0) + self.inputMenu = TextInputMenu(hasSpoilers: true, hasQuotes: true) + super.init(frame: frame) self.clipsToBounds = true - self.textView.delegate = self + self.textView.customDelegate = self self.addSubview(self.textView) - self.textContainer.widthTracksTextView = false - self.textContainer.heightTracksTextView = false - if #available(iOS 13.0, *) { self.textView.overrideUserInterfaceStyle = .dark } @@ -257,10 +228,6 @@ public final class TextFieldComponent: Component { NSAttributedString.Key.font: Font.regular(17.0), NSAttributedString.Key.foregroundColor: UIColor.white ] - - self.textView.onPaste = { [weak self] in - return self?.onPaste() ?? false - } } required init?(coder: NSCoder) { @@ -274,12 +241,20 @@ public final class TextFieldComponent: Component { let inputState = f(self.inputState) - self.textView.attributedText = textAttributedStringForStateText(inputState.inputText, fontSize: component.fontSize, textColor: component.textColor, accentTextColor: component.textColor, writingDirection: nil, spoilersRevealed: self.spoilersRevealed, availableEmojis: Set(component.context.animatedEmojiStickers.keys), emojiViewProvider: self.emojiViewProvider) + let currentAttributedText = self.textView.attributedText + let updatedAttributedText = textAttributedStringForStateText(inputState.inputText, fontSize: component.fontSize, textColor: component.textColor, accentTextColor: component.textColor, writingDirection: nil, spoilersRevealed: self.spoilersRevealed, availableEmojis: Set(component.context.animatedEmojiStickers.keys), emojiViewProvider: self.emojiViewProvider) + if currentAttributedText != updatedAttributedText { + self.textView.attributedText = updatedAttributedText + } self.textView.selectedRange = NSMakeRange(inputState.selectionRange.lowerBound, inputState.selectionRange.count) refreshChatTextInputAttributes(textView: self.textView, primaryTextColor: component.textColor, accentTextColor: component.textColor, baseFontSize: component.fontSize, spoilersRevealed: self.spoilersRevealed, availableEmojis: Set(component.context.animatedEmojiStickers.keys), emojiViewProvider: self.emojiViewProvider) self.updateEntities() + + if currentAttributedText != updatedAttributedText && !self.isUpdating { + self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(AnimationHint(kind: .textChanged))) + } } public func hasFirstResponder() -> Bool { @@ -290,7 +265,9 @@ public final class TextFieldComponent: Component { self.updateInputState { state in return state.insertText(text) } - self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(AnimationHint(kind: .textChanged))) + if !self.isUpdating { + self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(AnimationHint(kind: .textChanged))) + } } public func deleteBackward() { @@ -301,7 +278,9 @@ public final class TextFieldComponent: Component { self.updateInputState { _ in return TextFieldComponent.InputState(inputText: text, selectionRange: selectionRange) } - self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(AnimationHint(kind: .textChanged))) + if !self.isUpdating { + self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(AnimationHint(kind: .textChanged))) + } } private func onPaste() -> Bool { @@ -327,7 +306,9 @@ public final class TextFieldComponent: Component { return InputState(inputText: attributedString) } } - self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(AnimationHint(kind: .textChanged))) + if !self.isUpdating { + self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(AnimationHint(kind: .textChanged))) + } component.paste(.text) return false } @@ -382,12 +363,13 @@ public final class TextFieldComponent: Component { return true } - public func textViewDidChange(_ textView: UITextView) { + public func chatInputTextNodeDidUpdateText() { guard let component = self.component else { return } refreshChatTextInputAttributes(textView: self.textView, primaryTextColor: component.textColor, accentTextColor: component.textColor, baseFontSize: component.fontSize, spoilersRevealed: self.spoilersRevealed, availableEmojis: Set(component.context.animatedEmojiStickers.keys), emojiViewProvider: self.emojiViewProvider) refreshChatTextInputTypingAttributes(self.textView, textColor: component.textColor, baseFontSize: component.fontSize) + self.textView.updateTextContainerInset() if self.spoilerIsDisappearing { self.spoilerIsDisappearing = false @@ -395,11 +377,16 @@ public final class TextFieldComponent: Component { } self.updateEntities() - - self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(AnimationHint(kind: .textChanged))) + if !self.isUpdating { + self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(AnimationHint(kind: .textChanged))) + } } - public func textViewDidChangeSelection(_ textView: UITextView) { + public func chatInputTextNodeShouldReturn() -> Bool { + return true + } + + public func chatInputTextNodeDidChangeSelection(dueToEditing: Bool) { guard let _ = self.component else { return } @@ -408,11 +395,13 @@ public final class TextFieldComponent: Component { self.updateEmojiSuggestion(transition: .immediate) } - public func textViewDidBeginEditing(_ textView: UITextView) { + public func chatInputTextNodeDidBeginEditing() { guard let component = self.component else { return } - self.state?.updated(transition: Transition(animation: .curve(duration: 0.5, curve: .spring)).withUserData(AnimationHint(kind: .textFocusChanged))) + if !self.isUpdating { + self.state?.updated(transition: Transition(animation: .curve(duration: 0.5, curve: .spring)).withUserData(AnimationHint(kind: .textFocusChanged))) + } if component.isOneLineWhenUnfocused { Queue.mainQueue().justDispatch { self.textView.selectedTextRange = self.textView.textRange(from: self.textView.endOfDocument, to: self.textView.endOfDocument) @@ -420,12 +409,17 @@ public final class TextFieldComponent: Component { } } - public func textViewDidEndEditing(_ textView: UITextView) { - self.state?.updated(transition: Transition(animation: .curve(duration: 0.5, curve: .spring)).withUserData(AnimationHint(kind: .textFocusChanged))) + public func chatInputTextNodeDidFinishEditing() { + if !self.isUpdating { + self.state?.updated(transition: Transition(animation: .curve(duration: 0.5, curve: .spring)).withUserData(AnimationHint(kind: .textFocusChanged))) + } } - - @available(iOS 16.0, *) - public func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? { + + public func chatInputTextNodeBackspaceWhileEmpty() { + } + + @available(iOS 13.0, *) + public func chatInputTextNodeMenu(forTextRange textRange: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu { let filteredActions: Set = Set([ "com.apple.menu.format", "com.apple.menu.replace" @@ -437,7 +431,7 @@ public final class TextFieldComponent: Component { return true } } - guard let component = self.component, !textView.attributedText.string.isEmpty && textView.selectedRange.length > 0 else { + guard let component = self.component, let attributedText = self.textView.attributedText, !attributedText.string.isEmpty, self.textView.selectedRange.length > 0 else { return UIMenu(children: suggestedActions) } let strings = component.strings @@ -504,6 +498,36 @@ public final class TextFieldComponent: Component { self.updateSpoilersRevealed(animated: animated) } }) + actions.insert(UIAction(title: strings.TextFormat_Quote, image: nil) { [weak self] action in + if let self { + var animated = false + let attributedText = self.inputState.inputText + attributedText.enumerateAttributes(in: NSMakeRange(0, attributedText.length), options: [], using: { attributes, _, _ in + if let _ = attributes[ChatTextInputAttributes.block] { + animated = true + } + }) + + self.toggleAttribute(key: ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .quote)) + + self.updateSpoilersRevealed(animated: animated) + } + }, at: 0) + actions.append(UIAction(title: strings.TextFormat_Code, image: nil) { [weak self] action in + if let self { + var animated = false + let attributedText = self.inputState.inputText + attributedText.enumerateAttributes(in: NSMakeRange(0, attributedText.length), options: [], using: { attributes, _, _ in + if let _ = attributes[ChatTextInputAttributes.block] { + animated = true + } + }) + + self.toggleAttribute(key: ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .code(language: nil))) + + self.updateSpoilersRevealed(animated: animated) + } + }) var updatedActions = suggestedActions let formatMenu = UIMenu(title: strings.TextFormat_Format, image: nil, children: actions) @@ -512,9 +536,148 @@ public final class TextFieldComponent: Component { return UIMenu(children: updatedActions) } - private func toggleAttribute(key: NSAttributedString.Key) { + public func chatInputTextNode(shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + return true + } + + public func chatInputTextNodeShouldCopy() -> Bool { + return true + } + + public func chatInputTextNodeShouldPaste() -> Bool { + return self.onPaste() + } + + public func chatInputTextNodeShouldRespondToAction(action: Selector) -> Bool { + if action == #selector(self.paste(_:)) { + return true + } + return true + } + + public func chatInputTextNodeTargetForAction(action: Selector) -> ChatInputTextNode.TargetForAction? { + if action == makeSelectorFromString("_accessibilitySpeak:") { + if case .format = self.inputMenu.state { + return ChatInputTextNode.TargetForAction(target: nil) + } else if self.textView.selectedRange.length > 0 { + return ChatInputTextNode.TargetForAction(target: self) + } else { + return ChatInputTextNode.TargetForAction(target: nil) + } + } else if action == makeSelectorFromString("_accessibilitySpeakSpellOut:") { + if case .format = self.inputMenu.state { + return ChatInputTextNode.TargetForAction(target: nil) + } else if self.textView.selectedRange.length > 0 { + return nil + } else { + return ChatInputTextNode.TargetForAction(target: nil) + } + } + else if action == makeSelectorFromString("_accessibilitySpeakLanguageSelection:") || action == makeSelectorFromString("_accessibilityPauseSpeaking:") || action == makeSelectorFromString("_accessibilitySpeakSentence:") { + return ChatInputTextNode.TargetForAction(target: nil) + } else if action == makeSelectorFromString("_showTextStyleOptions:") { + if #available(iOS 16.0, *) { + return ChatInputTextNode.TargetForAction(target: nil) + } else { + if case .general = self.inputMenu.state { + if self.textView.attributedText == nil || self.textView.attributedText!.length == 0 { + return ChatInputTextNode.TargetForAction(target: nil) + } + return ChatInputTextNode.TargetForAction(target: self) + } else { + return ChatInputTextNode.TargetForAction(target: nil) + } + } + } else if action == #selector(self.formatAttributesBold(_:)) || action == #selector(self.formatAttributesItalic(_:)) || action == #selector(self.formatAttributesMonospace(_:)) || action == #selector(self.formatAttributesStrikethrough(_:)) || action == #selector(self.formatAttributesUnderline(_:)) || action == #selector(self.formatAttributesSpoiler(_:)) || action == #selector(self.formatAttributesQuote(_:)) || action == #selector(self.formatAttributesCodeBlock(_:)) { + if case .format = self.inputMenu.state { + if action == #selector(self.formatAttributesSpoiler(_:)) { + let selectedRange = self.textView.selectedRange + var intersectsMonospace = false + self.inputState.inputText.enumerateAttributes(in: selectedRange, options: [], using: { attributes, _, _ in + if let _ = attributes[ChatTextInputAttributes.monospace] { + intersectsMonospace = true + } + }) + if !intersectsMonospace { + return ChatInputTextNode.TargetForAction(target: self) + } else { + return ChatInputTextNode.TargetForAction(target: nil) + } + } else if action == #selector(self.formatAttributesQuote(_:)) { + return ChatInputTextNode.TargetForAction(target: self) + } else if action == #selector(self.formatAttributesCodeBlock(_:)) { + return ChatInputTextNode.TargetForAction(target: self) + } else if action == #selector(self.formatAttributesMonospace(_:)) { + var intersectsSpoiler = false + self.inputState.inputText.enumerateAttributes(in: self.textView.selectedRange, options: [], using: { attributes, _, _ in + if let _ = attributes[ChatTextInputAttributes.spoiler] { + intersectsSpoiler = true + } + }) + if !intersectsSpoiler { + return ChatInputTextNode.TargetForAction(target: self) + } else { + return ChatInputTextNode.TargetForAction(target: nil) + } + } else { + return ChatInputTextNode.TargetForAction(target: self) + } + } else { + return ChatInputTextNode.TargetForAction(target: nil) + } + } + if case .format = self.inputMenu.state { + return ChatInputTextNode.TargetForAction(target: nil) + } + return nil + } + + @objc func _showTextStyleOptions(_ sender: Any) { + let selectionRect: CGRect + if let selectedTextRange = self.textView.selectedTextRange { + selectionRect = self.textView.firstRect(for: selectedTextRange) + } else { + selectionRect = self.textView.bounds + } + + self.inputMenu.format(view: self.textView, rect: selectionRect.offsetBy(dx: 0.0, dy: -self.textView.contentOffset.y).insetBy(dx: 0.0, dy: -1.0)) + } + + @objc func formatAttributesBold(_ sender: Any) { + self.inputMenu.back() + } + + @objc func formatAttributesItalic(_ sender: Any) { + self.inputMenu.back() + } + + @objc func formatAttributesMonospace(_ sender: Any) { + self.inputMenu.back() + } + + @objc func formatAttributesStrikethrough(_ sender: Any) { + self.inputMenu.back() + } + + @objc func formatAttributesUnderline(_ sender: Any) { + self.inputMenu.back() + } + + @objc func formatAttributesQuote(_ sender: Any) { + self.inputMenu.back() + } + + @objc func formatAttributesCodeBlock(_ sender: Any) { + self.inputMenu.back() + } + + @objc func formatAttributesSpoiler(_ sender: Any) { + self.inputMenu.back() + } + + private func toggleAttribute(key: NSAttributedString.Key, value: Any? = nil) { self.updateInputState { state in - return state.addFormattingAttribute(attribute: key) + return state.addFormattingAttribute(attribute: key, value: value) } } @@ -563,7 +726,7 @@ public final class TextFieldComponent: Component { self.updateInputState { _ in return TextFieldComponent.InputState(inputText: string, selectionRange: string.length ..< string.length) } - if updateState { + if updateState && !self.isUpdating { self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(AnimationHint(kind: .textChanged))) } } @@ -769,8 +932,8 @@ public final class TextFieldComponent: Component { var hasTracking = false var hasTrackingView = false - if self.textView.selectedRange.length == 0 && self.textView.selectedRange.location > 0 { - let selectedSubstring = self.textView.attributedText.attributedSubstring(from: NSRange(location: 0, length: self.textView.selectedRange.location)) + if let attributedText = self.textView.attributedText, self.textView.selectedRange.length == 0, self.textView.selectedRange.location > 0 { + let selectedSubstring = attributedText.attributedSubstring(from: NSRange(location: 0, length: self.textView.selectedRange.location)) if let lastCharacter = selectedSubstring.string.last, String(lastCharacter).isSingleEmoji { let queryLength = (String(lastCharacter) as NSString).length if selectedSubstring.attribute(ChatTextInputAttributes.customEmoji, at: selectedSubstring.length - queryLength, effectiveRange: nil) == nil { @@ -817,7 +980,7 @@ public final class TextFieldComponent: Component { } func rightmostPositionOfFirstLine() -> CGPoint? { - let glyphRange = self.layoutManager.glyphRange(for: self.textContainer) + let glyphRange = self.textView.layoutManager.glyphRange(for: self.textView.textContainer) if glyphRange.length == 0 { return nil } @@ -826,16 +989,16 @@ public final class TextFieldComponent: Component { var lineRange: NSRange = NSRange() repeat { - lineRect = self.layoutManager.lineFragmentUsedRect(forGlyphAt: glyphIndexForStringStart, effectiveRange: &lineRange) + lineRect = self.textView.layoutManager.lineFragmentUsedRect(forGlyphAt: glyphIndexForStringStart, effectiveRange: &lineRange) if NSMaxRange(lineRange) > glyphRange.length { lineRange.length = glyphRange.length - lineRange.location } glyphIndexForStringStart = NSMaxRange(lineRange) } while glyphIndexForStringStart < NSMaxRange(glyphRange) && !NSLocationInRange(glyphRange.location, lineRange) - let padding = self.textView.textContainerInset.left + let padding = self.textView.defaultTextContainerInset.left var rightmostX = lineRect.maxX + padding - let rightmostY = lineRect.minY + self.textView.textContainerInset.top + let rightmostY = lineRect.minY + self.textView.defaultTextContainerInset.top let nsString = (self.textView.text as NSString) let firstLineEndRange = NSMakeRange(lineRange.location + lineRange.length - 1, 1) @@ -850,6 +1013,11 @@ public final class TextFieldComponent: Component { } func update(component: TextFieldComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + self.component = component self.state = state @@ -877,8 +1045,13 @@ public final class TextFieldComponent: Component { let wasEditing = component.externalState.isEditing let isEditing = self.textView.isFirstResponder - if self.textView.textContainerInset != component.insets { - self.textView.textContainerInset = component.insets + var innerTextInsets = component.insets + innerTextInsets.left = 0.0 + + let textLeftInset = component.insets.left + 8.0 + + if self.textView.defaultTextContainerInset != innerTextInsets { + self.textView.defaultTextContainerInset = innerTextInsets } var availableSize = availableSize @@ -886,17 +1059,28 @@ public final class TextFieldComponent: Component { availableSize.width += 32.0 } - self.textContainer.size = CGSize(width: availableSize.width - self.textView.textContainerInset.left - self.textView.textContainerInset.right, height: 10000000.0) - self.layoutManager.ensureLayout(for: self.textContainer) + let textHeight = self.textView.textHeightForWidth(availableSize.width - textLeftInset, rightInset: innerTextInsets.right) + let size = CGSize(width: availableSize.width, height: min(textHeight, availableSize.height)) - let boundingRect = self.layoutManager.boundingRect(forGlyphRange: NSRange(location: 0, length: self.textStorage.length), in: self.textContainer) - let size = CGSize(width: availableSize.width, height: min(availableSize.height, ceil(boundingRect.height) + self.textView.textContainerInset.top + self.textView.textContainerInset.bottom)) + let textFrame = CGRect(origin: CGPoint(x: textLeftInset, y: 0.0), size: CGSize(width: size.width - component.insets.left, height: size.height)) - var refreshScrolling = self.textView.bounds.size != size + var refreshScrolling = self.textView.bounds.size != textFrame.size if component.isOneLineWhenUnfocused && !isEditing && isEditing != wasEditing { refreshScrolling = true } - self.textView.frame = CGRect(origin: CGPoint(), size: size) + + self.textView.theme = ChatInputTextView.Theme( + quote: ChatInputTextView.Theme.Quote( + background: component.textColor.withMultipliedAlpha(0.1), + foreground: component.textColor, + lineStyle: .solid(color: component.textColor), + codeBackground: component.textColor.withMultipliedAlpha(0.1), + codeForeground: component.textColor + ) + ) + + self.textView.frame = textFrame + self.textView.updateLayout(size: textFrame.size) self.textView.panGestureRecognizer.isEnabled = isEditing self.updateEmojiSuggestion(transition: .immediate) @@ -911,9 +1095,9 @@ public final class TextFieldComponent: Component { } } - component.externalState.hasText = self.textStorage.length != 0 + component.externalState.hasText = self.textView.textStorage.length != 0 component.externalState.isEditing = isEditing - component.externalState.textLength = self.textStorage.string.count + component.externalState.textLength = self.textView.textStorage.string.count if let inputView = component.customInputView { if self.textView.inputView == nil { @@ -1011,28 +1195,95 @@ extension TextFieldComponent.InputState { return TextFieldComponent.InputState(inputText: inputText, selectionRange: selectionPosition ..< selectionPosition) } - public func addFormattingAttribute(attribute: NSAttributedString.Key) -> TextFieldComponent.InputState { + public func addFormattingAttribute(attribute: NSAttributedString.Key, value: Any? = nil) -> TextFieldComponent.InputState { if !self.selectionRange.isEmpty { let nsRange = NSRange(location: self.selectionRange.lowerBound, length: self.selectionRange.count) var addAttribute = true var attributesToRemove: [NSAttributedString.Key] = [] - self.inputText.enumerateAttributes(in: nsRange, options: .longestEffectiveRangeNotRequired) { attributes, range, stop in + self.inputText.enumerateAttributes(in: nsRange, options: .longestEffectiveRangeNotRequired) { attributes, range, _ in for (key, _) in attributes { - if key == attribute && range == nsRange { + if key == attribute { addAttribute = false attributesToRemove.append(key) } } } + var selectionRange = self.selectionRange + let result = NSMutableAttributedString(attributedString: self.inputText) for attribute in attributesToRemove { - result.removeAttribute(attribute, range: nsRange) + if attribute == ChatTextInputAttributes.block { + var removeRange = nsRange + + var selectionIndex = nsRange.upperBound + if nsRange.upperBound != result.length && (result.string as NSString).character(at: nsRange.upperBound) != 0x0a { + result.insert(NSAttributedString(string: "\n"), at: nsRange.upperBound) + selectionIndex += 1 + removeRange.length += 1 + } + if nsRange.lowerBound != 0 && (result.string as NSString).character(at: nsRange.lowerBound - 1) != 0x0a { + result.insert(NSAttributedString(string: "\n"), at: nsRange.lowerBound) + selectionIndex += 1 + removeRange.location += 1 + } else if nsRange.lowerBound != 0 { + removeRange.location -= 1 + removeRange.length += 1 + } + + if removeRange.lowerBound > result.length { + removeRange = NSRange(location: result.length, length: 0) + } else if removeRange.upperBound > result.length { + removeRange = NSRange(location: removeRange.lowerBound, length: result.length - removeRange.lowerBound) + } + result.removeAttribute(attribute, range: removeRange) + + if selectionRange.lowerBound > result.length { + selectionRange = result.length ..< result.length + } else if selectionRange.upperBound > result.length { + selectionRange = selectionRange.lowerBound ..< result.length + } + + // Prevent merge back + result.enumerateAttributes(in: NSRange(location: selectionIndex, length: result.length - selectionIndex), options: .longestEffectiveRangeNotRequired) { attributes, range, _ in + for (key, value) in attributes { + if let value = value as? ChatTextInputTextQuoteAttribute { + result.removeAttribute(key, range: range) + result.addAttribute(key, value: ChatTextInputTextQuoteAttribute(kind: value.kind), range: range) + } + } + } + + selectionRange = selectionIndex ..< selectionIndex + } else { + result.removeAttribute(attribute, range: nsRange) + } } + if addAttribute { - result.addAttribute(attribute, value: true as Bool, range: nsRange) + if attribute == ChatTextInputAttributes.block { + result.addAttribute(attribute, value: value ?? ChatTextInputTextQuoteAttribute(kind: .quote), range: nsRange) + var selectionIndex = nsRange.upperBound + if nsRange.upperBound != result.length && (result.string as NSString).character(at: nsRange.upperBound) != 0x0a { + result.insert(NSAttributedString(string: "\n"), at: nsRange.upperBound) + selectionIndex += 1 + } + if nsRange.lowerBound != 0 && (result.string as NSString).character(at: nsRange.lowerBound - 1) != 0x0a { + result.insert(NSAttributedString(string: "\n"), at: nsRange.lowerBound) + selectionIndex += 1 + } + selectionRange = selectionIndex ..< selectionIndex + } else { + result.addAttribute(attribute, value: true as Bool, range: nsRange) + } } - return TextFieldComponent.InputState(inputText: result, selectionRange: self.selectionRange) + if selectionRange.lowerBound > result.length { + selectionRange = result.length ..< result.length + } else if selectionRange.upperBound > result.length { + selectionRange = selectionRange.lowerBound ..< result.length + } + + return TextFieldComponent.InputState(inputText: result, selectionRange: selectionRange) } else { return self } diff --git a/submodules/TextFormat/Sources/StringWithAppliedEntities.swift b/submodules/TextFormat/Sources/StringWithAppliedEntities.swift index 7eed41bb7d..a3949917e9 100644 --- a/submodules/TextFormat/Sources/StringWithAppliedEntities.swift +++ b/submodules/TextFormat/Sources/StringWithAppliedEntities.swift @@ -538,3 +538,41 @@ public func asyncUpdateMessageSyntaxHighlight(engine: TelegramEngine, messageId: } |> runOn(messageSyntaxHighlightQueue) } + +public func asyncStanaloneSyntaxHighlight(current: CachedMessageSyntaxHighlight?, specs: [CachedMessageSyntaxHighlight.Spec]) -> Signal { + if let current, !specs.contains(where: { current.values[$0] == nil }) { + return .single(current) + } + + return Signal { subscriber in + var updated: [CachedMessageSyntaxHighlight.Spec: MessageSyntaxHighlight] = [:] + + let theme = SyntaxterTheme(dark: false, textColor: .black, textFont: internalFixedCodeFont, italicFont: internalFixedCodeFont, mediumFont: internalFixedCodeFont) + + for spec in specs { + if let value = current?.values[spec] { + updated[spec] = value + } else { + var entities: [MessageSyntaxHighlight.Entity] = [] + + if let syntaxHighlighter { + if let highlightedString = syntaxHighlighter.syntax(spec.text, language: spec.language, theme: theme) { + highlightedString.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: highlightedString.length), using: { value, subRange, _ in + if let value = value as? UIColor, value != .black { + entities.append(MessageSyntaxHighlight.Entity(color: Int32(bitPattern: value.rgb), range: subRange.lowerBound ..< subRange.upperBound)) + } + }) + } + } + + updated[spec] = MessageSyntaxHighlight(entities: entities) + } + } + + subscriber.putNext(CachedMessageSyntaxHighlight(values: updated)) + subscriber.putCompletion() + + return EmptyDisposable + } + |> runOn(messageSyntaxHighlightQueue) +}